дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
Merge remote-tracking branch 'origin/main' into HEAD
Цей коміт міститься в:
коміт
9fc719b4db
280 files changed
+13595
-1171
No files matched your search
Створено
@@ -4366,6 +4366,7 @@ dependencies = [
|
||||
name = "ide-lsp"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4460,7 +4461,9 @@ dependencies = [
|
||||
name = "ide-run"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-build",
|
||||
"ide-core",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
|
||||
@@ -12,12 +12,13 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
`python/python-requirements` (`fileTypes/requirements`); `python/pipenv`
|
||||
(`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` →
|
||||
`fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`,
|
||||
`kotlinScript`, `kotlinGradleScript`); `java/openapi` (`fileTypes/maven`);
|
||||
`kotlinScript`, `kotlinGradleScript`, `icons/expui/objectKotlin` → `kotlin/objectKotlin`); `java/openapi` (`fileTypes/maven`);
|
||||
`plugins/markdown` (`fileTypes/markdown`); `plugins/toml` (`fileTypes/toml`);
|
||||
`plugins/terminal` (`toolwindows/terminal`); `plugins/maven`
|
||||
(`images/expui/toolwindow/maven` → `toolwindows/maven`, `images/expui/build/maven*`
|
||||
→ `maven/`); `plugins/gradle` (`icons/expui/gradle` → `toolwindows/gradle`,
|
||||
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`);
|
||||
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`,
|
||||
`icons/expui/gradle`, `icons/gradleSubproject` → `gradle/`);
|
||||
`platform/external-system-impl` (`icons/newui/task` → `externalSystem/task`).
|
||||
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
|
||||
|
||||
@@ -27,7 +27,8 @@ pub(crate) fn newest_install(command: &str) -> String {
|
||||
let path = ide_core::process::host_var_os("PATH")
|
||||
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()))
|
||||
.unwrap_or_default();
|
||||
newest_install_in(command, &path)
|
||||
// Windows: read afresh, so a tool the user installed since the IDE started is found too.
|
||||
newest_install_in(command, &ide_core::process::with_user_path(&path))
|
||||
}
|
||||
|
||||
fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
|
||||
|
||||
@@ -58,6 +58,18 @@ pub(crate) fn run(
|
||||
cwd: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
cancel: &CancellationToken,
|
||||
on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
run_with_env(program, args, cwd, env, false, cancel, on_line)
|
||||
}
|
||||
|
||||
pub(crate) fn run_with_env(
|
||||
program: &str,
|
||||
args: &[String],
|
||||
cwd: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
clear_env: bool,
|
||||
cancel: &CancellationToken,
|
||||
mut on_line: impl FnMut(Stream, &str),
|
||||
) -> IdeResult<Output> {
|
||||
tracing::info!("build: {program} {} (in {})", args.join(" "), cwd.display());
|
||||
@@ -66,7 +78,7 @@ pub(crate) fn run(
|
||||
ide_core::output_encoding::fix(program, args, env, |k| std::env::var(k).ok(), "UTF-8", cfg!(windows));
|
||||
let program = program_path(program, env);
|
||||
let mut command = Command::new(&program);
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env).stdin(Stdio::null());
|
||||
command.args(args).current_dir(cwd).envs(env).envs(encoding.env.iter().map(|(k, v)| (k, v))).stdin(Stdio::null());
|
||||
command.stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
#[cfg(unix)]
|
||||
{
|
||||
@@ -74,6 +86,9 @@ pub(crate) fn run(
|
||||
command.process_group(0);
|
||||
}
|
||||
ide_core::process::prepare(&mut command);
|
||||
if clear_env {
|
||||
command.env_clear().envs(env).envs(encoding.env);
|
||||
}
|
||||
let mut child =
|
||||
command.spawn().map_err(|e| IdeError::unavailable(format!("cannot start {}: {e}", program.display())))?;
|
||||
let (tx, rx) = mpsc::channel::<(Stream, String)>();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! WAR modules and build-tool-owned archive resolution (including custom output names).
|
||||
use crate::BuildModel;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{collections::BTreeMap, fs, path::Path};
|
||||
@@ -47,6 +47,50 @@ pub struct WebBuild {
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
pub env: BTreeMap<String, String>,
|
||||
maven_project: Option<(Vec<String>, std::path::PathBuf)>,
|
||||
}
|
||||
|
||||
impl WebBuild {
|
||||
/// Resolve the selected module's effective identity using the build's options and environment.
|
||||
/// Runs off the UI thread, before launching the reactor build.
|
||||
pub fn resolve_project(
|
||||
&mut self,
|
||||
root: &Path,
|
||||
env: &BTreeMap<String, String>,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<()> {
|
||||
let Some((args, output)) = &self.maven_project else {
|
||||
return Ok(());
|
||||
};
|
||||
if output.exists() {
|
||||
fs::remove_file(output)?;
|
||||
}
|
||||
let cwd = root.join(&self.cwd).canonicalize()?;
|
||||
let mut env = env.clone();
|
||||
if !env.iter().any(|(k, v)| k.eq_ignore_ascii_case("MAVEN_BASEDIR") && !v.is_empty()) {
|
||||
// -f changes Maven's .mvn search origin; keep the reactor's launcher settings.
|
||||
let base = cwd.ancestors().find(|p| p.join(".mvn").is_dir()).unwrap_or(&cwd);
|
||||
env.retain(|k, _| !k.eq_ignore_ascii_case("MAVEN_BASEDIR"));
|
||||
env.insert("MAVEN_BASEDIR".into(), base.to_string_lossy().into_owned());
|
||||
}
|
||||
let result = crate::process::run_with_env(&self.command, args, &cwd, &env, true, cancel, |_, _| {})?;
|
||||
if !result.success {
|
||||
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", result.text.trim())));
|
||||
}
|
||||
let id = fs::read_to_string(output)?;
|
||||
let parts: Vec<_> = id.trim().split(':').collect();
|
||||
if parts.len() != 4 || parts[2] != "war" || parts.iter().any(|s| s.is_empty()) {
|
||||
return Err(IdeError::invalid(format!("Cannot resolve Maven WAR artifact: {}", id.trim())));
|
||||
}
|
||||
let project = format!("{}:{}:{}", parts[0], parts[1], parts[3]);
|
||||
self.args.push(format!("-Dexpression=session.projectMap({project}).artifact.file"));
|
||||
self.maven_project = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn groovy_literal(s: &str) -> String {
|
||||
format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"))
|
||||
}
|
||||
|
||||
pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResult<WebBuild> {
|
||||
@@ -81,21 +125,36 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
.replace('\\', "/");
|
||||
args.extend(["-pl".into(), module, "-am".into()]);
|
||||
}
|
||||
// help:evaluate runs once, in the reactor's first project. Query the selected
|
||||
// POM's effective identity first; profiles and -D options can change its GAV.
|
||||
let output = cache.join("artifact-project.txt");
|
||||
let mut probe = top.options.clone();
|
||||
probe.extend([
|
||||
"-f".into(),
|
||||
root.join(&p.pom).to_string_lossy().into_owned(),
|
||||
"help:evaluate".into(),
|
||||
"-Dexpression=project.id".into(),
|
||||
format!("-Doutput={}", output.display()),
|
||||
]);
|
||||
args.extend([
|
||||
"-DskipTests".into(),
|
||||
"package".into(),
|
||||
"help:evaluate".into(),
|
||||
"-Dexpression=project.artifact.file".into(),
|
||||
format!("-Doutput={}", manifest.display()),
|
||||
]);
|
||||
return Ok(WebBuild { command: top.command.clone(), args, cwd: top.dir.clone(), env: p.env.clone() });
|
||||
return Ok(WebBuild {
|
||||
command: top.command.clone(),
|
||||
args,
|
||||
cwd: top.dir.clone(),
|
||||
env: p.env.clone(),
|
||||
maven_project: Some((probe, output)),
|
||||
});
|
||||
}
|
||||
if let Some(path) = id.strip_prefix("gradle:") {
|
||||
let g = model.gradle.as_ref().ok_or_else(|| IdeError::invalid("Choose a Gradle WAR module"))?;
|
||||
if !artifacts(root, model).iter().any(|a| a.id == id) {
|
||||
return Err(IdeError::invalid("Choose a Gradle WAR module"));
|
||||
}
|
||||
let literal = |s: &str| format!("'{}'", s.replace('\\', "\\\\").replace('\'', "\\'"));
|
||||
let script = cache.join("war.init.gradle");
|
||||
fs::write(
|
||||
&script,
|
||||
@@ -105,12 +164,12 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
def warTask = p.tasks.named('war')
|
||||
p.tasks.register('openideaTomcatArtifact') {{
|
||||
dependsOn warTask
|
||||
doLast {{ new File({}).text = warTask.get().archiveFile.get().asFile.absolutePath }}
|
||||
doLast {{ new File({}).setText(warTask.get().archiveFile.get().asFile.absolutePath, 'UTF-8') }}
|
||||
}}
|
||||
}}
|
||||
"#,
|
||||
literal(path),
|
||||
literal(&manifest.to_string_lossy())
|
||||
groovy_literal(path),
|
||||
groovy_literal(&manifest.to_string_lossy())
|
||||
),
|
||||
)?;
|
||||
let task = format!("{}:openideaTomcatArtifact", path.trim_end_matches(':'));
|
||||
@@ -119,6 +178,7 @@ pub fn build(root: &Path, model: &BuildModel, id: &str, cache: &Path) -> IdeResu
|
||||
args: vec!["--init-script".into(), script.to_string_lossy().into_owned(), task],
|
||||
cwd: g.dir.clone(),
|
||||
env: g.env.clone(),
|
||||
maven_project: None,
|
||||
});
|
||||
}
|
||||
Err(IdeError::invalid("Choose a WAR artifact"))
|
||||
@@ -136,7 +196,9 @@ mod tests {
|
||||
assert_eq!(a.len(), 1);
|
||||
assert_eq!(a[0].name, "web");
|
||||
let plan = build(tmp.path(), &model, &a[0].id, &tmp.path().join("base with spaces")).unwrap();
|
||||
assert!(plan.args.contains(&"-Dexpression=project.artifact.file".into()));
|
||||
let (probe, _) = plan.maven_project.as_ref().unwrap();
|
||||
assert!(probe.contains(&"-Dexpression=project.id".into()));
|
||||
assert!(probe.contains(&tmp.path().join("pom.xml").to_string_lossy().into_owned()));
|
||||
assert!(
|
||||
plan.args.iter().any(|a| a.ends_with("base with spaces/artifact-path.txt")
|
||||
|| a.ends_with("base with spaces\\artifact-path.txt"))
|
||||
@@ -152,6 +214,13 @@ mod tests {
|
||||
let plan = build(tmp.path(), &model, "maven:web/pom.xml", &tmp.path().join("base")).unwrap();
|
||||
assert_eq!(plan.cwd, "");
|
||||
assert_eq!(&plan.args[..3], &["-pl", "web", "-am"]);
|
||||
assert!(
|
||||
plan.maven_project
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.0
|
||||
.contains(&tmp.path().join("web/pom.xml").to_string_lossy().into_owned())
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn gradle_war_uses_archive_provider_and_ignores_comments() {
|
||||
@@ -169,4 +238,31 @@ mod tests {
|
||||
assert!(fs::read_to_string(tmp.path().join("base/war.init.gradle")).unwrap().contains("archiveFile.get()"));
|
||||
assert!(!crate::gradle::has_war_plugin("// plugins { war }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_manifest_paths_preserve_windows_characters() {
|
||||
for path in [r"C:\工作 区\Tomcat's cache\artifact-path.txt", r"\\server\share\artifact-path.txt"] {
|
||||
let literal = groovy_literal(path);
|
||||
assert_eq!(literal[1..literal.len() - 1].replace("\\'", "'").replace("\\\\", "\\"), path);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gradle_root_and_nested_modules_write_utf8_archive_paths() {
|
||||
for module in [":", ":apps:timesheet"] {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = if module == ":" { "" } else { "apps/timesheet" };
|
||||
fs::create_dir_all(tmp.path().join(dir)).unwrap();
|
||||
fs::write(tmp.path().join("settings.gradle"), "rootProject.name = 'timesheet'\ninclude 'apps:timesheet'")
|
||||
.unwrap();
|
||||
fs::write(tmp.path().join(dir).join("build.gradle"), "plugins { id 'war' }").unwrap();
|
||||
let model = crate::load(tmp.path(), &tmp.path().join("cache"), None);
|
||||
let cache = tmp.path().join("工作 cache");
|
||||
let plan = build(tmp.path(), &model, &format!("gradle:{module}"), &cache).unwrap();
|
||||
assert!(plan.args.contains(&format!("{}:openideaTomcatArtifact", module.trim_end_matches(':'))));
|
||||
let script = fs::read_to_string(cache.join("war.init.gradle")).unwrap();
|
||||
assert!(script.contains(&format!("rootProject.project('{}')", module)));
|
||||
assert!(script.contains("archiveFile.get().asFile.absolutePath, 'UTF-8'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,4 +19,4 @@ tokio-util.workspace = true
|
||||
tempfile.workspace = true
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization"] }
|
||||
windows-sys = { version = "0.61", features = ["Win32_Globalization", "Win32_System_Registry", "Win32_Foundation"] }
|
||||
@@ -438,6 +438,8 @@ pub struct EncodingSettings {
|
||||
pub project: String,
|
||||
/// Fallback for `.properties` files.
|
||||
pub properties: String,
|
||||
/// Display properties Unicode escapes as characters and save non-ASCII as escapes.
|
||||
pub native_to_ascii: bool,
|
||||
/// Create UTF-8 files: `never` (no BOM) | `always` (with BOM) | `windows` (with BOM on Windows).
|
||||
pub utf8_bom: String,
|
||||
}
|
||||
@@ -448,6 +450,7 @@ impl Default for EncodingSettings {
|
||||
global: "utf-8".into(),
|
||||
project: "utf-8".into(),
|
||||
properties: "iso-8859-1".into(),
|
||||
native_to_ascii: false,
|
||||
utf8_bom: "never".into(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,10 +237,110 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf>
|
||||
find_executable_in(name, search, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()))
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries on Windows (`HKCU\Environment`), the part of the
|
||||
/// merged `PATH` a process may not inherit — an IDE started before the variable was
|
||||
/// edited, or as a different user, sees only the machine entries. `%VAR%` references
|
||||
/// (`REG_EXPAND_SZ`) are expanded against the current environment. `None` elsewhere:
|
||||
/// there a login shell already supplies the user's whole `PATH`.
|
||||
#[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,
|
||||
};
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let mut key: HKEY = std::ptr::null_mut();
|
||||
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();
|
||||
RegCloseKey(key);
|
||||
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
|
||||
#[cfg(not(windows))]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `path` followed by the [`user_path`] entries it lacks.
|
||||
pub fn with_user_path(path: &OsStr) -> OsString {
|
||||
user_path().map_or_else(|| path.to_owned(), |user| append_path_entries(path, &user))
|
||||
}
|
||||
|
||||
fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
|
||||
let mut dirs: Vec<std::path::PathBuf> = std::env::split_paths(path).filter(|d| !d.as_os_str().is_empty()).collect();
|
||||
for dir in std::env::split_paths(extra).filter(|d| !d.as_os_str().is_empty()) {
|
||||
if !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn user_path_reads_the_current_users_path() {
|
||||
// This host has a user `Path` only when the test account set one; either way the call
|
||||
// must not fail, and a present value is a non-empty `;`-joined list of directories.
|
||||
if let Some(path) = user_path() {
|
||||
let dirs: Vec<_> = std::env::split_paths(&path).collect();
|
||||
assert!(!dirs.is_empty(), "user PATH should hold at least one directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
|
||||
assert_eq!(append_path_entries(&join(&["a", "b"]), &join(&["b", "c"])), join(&["a", "b", "c"]));
|
||||
assert_eq!(append_path_entries(OsStr::new(""), &join(&["c"])), join(&["c"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() {
|
||||
set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]);
|
||||
|
||||
@@ -103,10 +103,19 @@ impl Default for DeploymentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// An SFTP server and how project folders map onto it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeploymentType {
|
||||
#[default]
|
||||
Sftp,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// A deployment filesystem and how project folders map onto it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentServer {
|
||||
pub kind: DeploymentType,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
@@ -148,3 +157,23 @@ pub struct RunTarget {
|
||||
pub node: String,
|
||||
pub java: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deployment_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deployment_type_defaults_and_round_trips() {
|
||||
let legacy: DeploymentServer =
|
||||
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
|
||||
assert_eq!(legacy.kind, DeploymentType::Sftp);
|
||||
assert_eq!(legacy.ssh_config, "ssh");
|
||||
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
|
||||
let server = DeploymentServer { kind, ..legacy.clone() };
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
|
||||
server
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -342,6 +342,15 @@ impl Default for AppearanceSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Imported editor colour scheme on top of a built-in base.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CustomColorScheme {
|
||||
pub name: String,
|
||||
pub parent: String,
|
||||
pub attributes: BTreeMap<String, TextAttributesOverride>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct EditorSettings {
|
||||
@@ -357,6 +366,8 @@ pub struct EditorSettings {
|
||||
pub format_on_save: bool,
|
||||
/// Editor colour scheme id; `theme-default` follows the IDE theme.
|
||||
pub color_scheme: String,
|
||||
/// Imported editor schemes, stored in user settings.
|
||||
pub custom_schemes: BTreeMap<String, CustomColorScheme>,
|
||||
/// Color Scheme edits: scheme id → attribute id → override.
|
||||
pub color_overrides: BTreeMap<String, BTreeMap<String, TextAttributesOverride>>,
|
||||
/// Color Scheme › Language Defaults › Semantic highlighting: local variables and
|
||||
@@ -404,6 +415,7 @@ impl Default for EditorSettings {
|
||||
format_on_save: false,
|
||||
color_scheme: "theme-default".into(),
|
||||
color_overrides: BTreeMap::new(),
|
||||
custom_schemes: BTreeMap::new(),
|
||||
semantic_highlighting: false,
|
||||
rainbow_brackets: false,
|
||||
ligatures: false,
|
||||
@@ -434,6 +446,8 @@ impl Default for EditorSettings {
|
||||
pub struct FileSettings {
|
||||
/// Save on focus loss / tab switch, like IDEA.
|
||||
pub auto_save: bool,
|
||||
/// Re-check open files against disk when the IDE window gains focus.
|
||||
pub sync_on_focus: bool,
|
||||
pub exclude: Vec<String>,
|
||||
/// Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`].
|
||||
pub ignored_names: Vec<String>,
|
||||
@@ -465,6 +479,7 @@ impl Default for FileSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
auto_save: true,
|
||||
sync_on_focus: true,
|
||||
exclude: vec!["**/.git".into(), "**/node_modules".into(), "**/target".into()],
|
||||
ignored_names: crate::ignored::DEFAULT_IGNORED_NAMES.iter().map(|s| (*s).into()).collect(),
|
||||
trim_trailing_whitespace: true,
|
||||
@@ -546,6 +561,35 @@ pub struct GoSettings {
|
||||
pub goroot: String,
|
||||
/// Empty = `go env GOPATH`.
|
||||
pub gopath: String,
|
||||
pub build_tags: GoBuildTags,
|
||||
pub modules: GoModules,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GoModules {
|
||||
pub env: BTreeMap<String, String>,
|
||||
pub auto_vendor: bool,
|
||||
}
|
||||
|
||||
impl Default for GoModules {
|
||||
fn default() -> Self {
|
||||
Self { env: BTreeMap::new(), auto_vendor: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct GoBuildTags {
|
||||
/// Empty values inherit the selected toolchain's defaults.
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub version: String,
|
||||
pub compiler: String,
|
||||
/// Empty = automatic, "1" = enabled, "0" = disabled.
|
||||
pub cgo: String,
|
||||
pub custom: String,
|
||||
pub experiments: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -754,3 +798,19 @@ mod gradle_settings_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod go_module_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn old_go_settings_inherit_module_defaults() {
|
||||
let old: GoSettings = serde_json::from_str(r#"{"goroot":"/go"}"#).unwrap();
|
||||
assert!(old.modules.auto_vendor);
|
||||
assert!(old.modules.env.is_empty());
|
||||
let configured: GoSettings =
|
||||
serde_json::from_str(r#"{"modules":{"autoVendor":false,"env":{"GOPRIVATE":"example.com/private"}}}"#)
|
||||
.unwrap();
|
||||
assert!(!configured.modules.auto_vendor);
|
||||
assert_eq!(configured.modules.env["GOPRIVATE"], "example.com/private");
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,44 @@ pub struct WorkspaceEdit {
|
||||
pub files: Vec<FileEdit>,
|
||||
}
|
||||
|
||||
impl WorkspaceEdit {
|
||||
/// Coalesces aliases of the same file before applying edits against its original text.
|
||||
/// Resource operations separate batches because later edits may use the new contents.
|
||||
pub fn deduplicate(&mut self) {
|
||||
let mut files = Vec::new();
|
||||
let mut batch = std::collections::HashMap::<std::path::PathBuf, usize>::new();
|
||||
let mut resolve_aliases = true;
|
||||
for file in std::mem::take(&mut self.files) {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
let path = if resolve_aliases {
|
||||
dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
let index = *batch.entry(path.clone()).or_insert_with(|| {
|
||||
files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() });
|
||||
files.len() - 1
|
||||
});
|
||||
let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() };
|
||||
for edit in edits {
|
||||
if !own.contains(&edit) {
|
||||
own.push(edit);
|
||||
}
|
||||
}
|
||||
}
|
||||
op => {
|
||||
batch.clear();
|
||||
// Later paths refer to a filesystem state the resource operation has yet to create.
|
||||
resolve_aliases = false;
|
||||
files.push(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FileEdit {
|
||||
@@ -203,3 +241,34 @@ pub struct OutlineItem {
|
||||
|
||||
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
|
||||
pub type MatchIndices = Vec<u32>;
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod workspace_edit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("main.cpp");
|
||||
let alias = dir.path().join("alias.cpp");
|
||||
std::fs::write(&real, "add()").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &alias).unwrap();
|
||||
let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() };
|
||||
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] };
|
||||
let mut workspace = WorkspaceEdit {
|
||||
files: vec![
|
||||
edit(real.clone()),
|
||||
edit(alias),
|
||||
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
|
||||
edit(real),
|
||||
],
|
||||
};
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace.files.len(), 3);
|
||||
let FileEdit::Edit { edits, .. } = &workspace.files[0] else { panic!() };
|
||||
assert_eq!(edits, &[change]);
|
||||
let once = workspace.clone();
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace, once);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,16 @@ pub fn search_path() -> OsString {
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
|
||||
let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone()));
|
||||
let shims: Vec<PathBuf> = version_managers::managers().iter().map(|m| m.shims()).collect();
|
||||
let dirs = search_dirs(host_var_os("PATH").as_deref(), shell_path.as_deref(), home.as_deref(), &shims);
|
||||
// The Windows user `PATH` may not reach a process started before it was set; append its
|
||||
// entries after the inherited ones so missing user tools still resolve.
|
||||
let user_path = crate::process::user_path();
|
||||
let dirs = search_dirs(
|
||||
host_var_os("PATH").as_deref(),
|
||||
shell_path.as_deref(),
|
||||
user_path.as_deref(),
|
||||
home.as_deref(),
|
||||
&shims,
|
||||
);
|
||||
std::env::join_paths(dirs).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -56,10 +65,12 @@ fn is_executable(path: &Path) -> bool {
|
||||
fn search_dirs(
|
||||
process_path: Option<&OsStr>,
|
||||
shell_path: Option<&OsStr>,
|
||||
user_path: Option<&OsStr>,
|
||||
home: Option<&Path>,
|
||||
shims: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = process_path.into_iter().chain(shell_path).flat_map(std::env::split_paths).collect();
|
||||
let mut dirs: Vec<PathBuf> =
|
||||
process_path.into_iter().chain(shell_path).chain(user_path).flat_map(std::env::split_paths).collect();
|
||||
if let Some(home) = home {
|
||||
dirs.extend(USER_DIRS.iter().map(|d| home.join(d)));
|
||||
dirs.extend(nvm_bins(&home.join(".nvm/versions/node")));
|
||||
@@ -172,6 +183,7 @@ mod tests {
|
||||
Some(OsStr::new("/usr/bin:.:node_modules/.bin:/opt/tools")),
|
||||
// nushell prints `$PATH` as is.
|
||||
Some(OsStr::new("$PATH:/opt/tools:/home/me/.asdf/shims")),
|
||||
None,
|
||||
Some(Path::new("/home/me")),
|
||||
&shims,
|
||||
);
|
||||
@@ -185,10 +197,28 @@ mod tests {
|
||||
let unique: HashSet<&PathBuf> = dirs.iter().collect();
|
||||
assert_eq!(unique.len(), dirs.len());
|
||||
// A `:` can't be in a `PATH` entry: those below such a home are left out.
|
||||
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, Some(Path::new("/home/a:b")), &[]);
|
||||
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, None, Some(Path::new("/home/a:b")), &[]);
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_dirs_append_user_path_entries_once_after_the_inherited_path() {
|
||||
let (system, dup, usertool) =
|
||||
if cfg!(windows) { ("C:/system", "C:/dup", "C:/usertool") } else { ("/system", "/dup", "/usertool") };
|
||||
let home = Path::new(if cfg!(windows) { "C:/home/me" } else { "/home/me" });
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap();
|
||||
let inherited = join(&[system, dup]);
|
||||
let user = join(&[dup, usertool]);
|
||||
let dirs = search_dirs(Some(inherited.as_os_str()), None, Some(user.as_os_str()), Some(home), &[]);
|
||||
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
|
||||
// User entries follow the inherited PATH, de-duplicated, ahead of the fallbacks.
|
||||
assert_eq!(dirs.iter().filter(|d| *d == Path::new(dup)).count(), 1);
|
||||
assert!(at(dup) < at(usertool));
|
||||
// And before this platform's home/system fallbacks.
|
||||
let fallback = dirs.iter().position(|d| d == &home.join(USER_DIRS[0])).unwrap();
|
||||
assert!(at(usertool) < fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvm_versions_newest_first() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id()));
|
||||
|
||||
@@ -348,8 +348,17 @@ pub struct AdapterIo {
|
||||
pub port: Option<u16>,
|
||||
/// Recent stderr lines of the adapter process, for error messages.
|
||||
pub stderr: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
/// (category, line) printed by the debuggee when it shares the adapter's stdio.
|
||||
pub program_output: Option<ProgramOutput>,
|
||||
}
|
||||
|
||||
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
|
||||
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
|
||||
|
||||
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
|
||||
const SHARES_STDIO: &str = "delve";
|
||||
const DELVE_BANNER: &str = "DAP server listening at:";
|
||||
|
||||
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
|
||||
|
||||
/// Starts adapters. Tests substitute in-memory adapters.
|
||||
@@ -385,6 +394,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: None,
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output: None,
|
||||
});
|
||||
}
|
||||
let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) {
|
||||
@@ -407,6 +417,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: None,
|
||||
port: Some(*port),
|
||||
stderr,
|
||||
program_output: None,
|
||||
});
|
||||
}
|
||||
if spec.adapter == DEBUGPY_TOOL {
|
||||
@@ -445,8 +456,14 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone());
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
@@ -458,11 +475,12 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone());
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
|
||||
@@ -484,6 +502,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -495,9 +514,15 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
|
||||
stream: R,
|
||||
name: String,
|
||||
sink: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
program: Option<(ProgramSink, &'static str)>,
|
||||
) {
|
||||
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
|
||||
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
|
||||
if let Some((tx, category)) = &program
|
||||
&& !line.starts_with(DELVE_BANNER)
|
||||
{
|
||||
let _ = tx.send((category, format!("{line}\n")));
|
||||
}
|
||||
push_line(&sink, line);
|
||||
}));
|
||||
}
|
||||
@@ -549,6 +574,18 @@ async fn connect_with_retries(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn program_output_is_forwarded_without_the_adapter_banner() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let sink = Arc::default();
|
||||
let text: &[u8] = b"DAP server listening at: 127.0.0.1:1\n[hello two words]\nno newline";
|
||||
pump_lines(text, "delve".into(), Arc::clone(&sink), Some((tx, "stdout")));
|
||||
assert_eq!(rx.recv().await, Some(("stdout", "[hello two words]\n".into())));
|
||||
assert_eq!(rx.recv().await, Some(("stdout", "no newline\n".into())));
|
||||
assert_eq!(rx.recv().await, None);
|
||||
assert_eq!(sink.lock().len(), 3);
|
||||
}
|
||||
|
||||
/// Managed tools win over `PATH`, and debugpy falls back to the private copy.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
|
||||
@@ -697,6 +697,14 @@ impl DebugManager {
|
||||
};
|
||||
let session =
|
||||
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
|
||||
if let Some(mut output) = io.program_output {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some((category, output)) = output.recv().await {
|
||||
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
|
||||
}
|
||||
});
|
||||
}
|
||||
self.sessions.lock().insert(id, session.clone());
|
||||
self.order.lock().push(id);
|
||||
// Announced before the event loop runs, so it precedes every adapter event.
|
||||
|
||||
@@ -32,7 +32,14 @@ impl AdapterLauncher for FakeLauncher {
|
||||
tokio::spawn(fake_adapter(server, log, script));
|
||||
let (r, w) = tokio::io::split(client);
|
||||
Box::pin(async move {
|
||||
Ok(AdapterIo { reader: Box::new(r), writer: Box::new(w), child: None, port: None, stderr: Arc::default() })
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
port: None,
|
||||
stderr: Arc::default(),
|
||||
program_output: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,12 +39,13 @@ fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<
|
||||
let snap = project.documents.text_snapshot(doc).ok()?;
|
||||
return (!snap.large).then_some((snap.language, snap.text));
|
||||
}
|
||||
let loaded = ide_vfs::load_text(path).ok()?;
|
||||
let loaded = project.documents.load_file(path).ok()?;
|
||||
if loaded.binary || loaded.large || loaded.too_large {
|
||||
return None;
|
||||
}
|
||||
let language = languages.detect(path, loaded.text.lines().next());
|
||||
Some((language, loaded.text))
|
||||
let text = ide_text::normalize_line_endings(&loaded.text).into_owned();
|
||||
let language = languages.detect(path, text.lines().next());
|
||||
Some((language, text))
|
||||
}
|
||||
|
||||
// ---- Inspect Code ----
|
||||
|
||||
@@ -22,6 +22,21 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
Ok(project) => project.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
if config.adapter == "delve" && config.request == "launch" {
|
||||
let root = host.project(window).map(|p| p.root.clone()).unwrap_or_default();
|
||||
let settings = settings.clone();
|
||||
config = host
|
||||
.blocking(move || {
|
||||
ide_sdk::go::debug_environment(&root, &settings, &mut config.env);
|
||||
ide_sdk::go::apply_to_debug(
|
||||
&mut config.env,
|
||||
&mut config.extra_json,
|
||||
&settings.languages.go.build_tags,
|
||||
)?;
|
||||
Ok(config)
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
if config.adapter == ide_dap::kotlin::ADAPTER {
|
||||
let data_dir = host.data_dir.clone();
|
||||
let runtime_root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport};
|
||||
use ide_ssh::{FsConnection, RemoteEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`.
|
||||
fn progress(
|
||||
sink: SharedSink,
|
||||
title: &str,
|
||||
token: CancellationToken,
|
||||
) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) {
|
||||
let id = TaskId::next();
|
||||
let cancel = token;
|
||||
let handle = ide_core::task::on_cancel(id, move || cancel.cancel());
|
||||
let title = title.to_owned();
|
||||
let (s, t) = (sink.clone(), title.clone());
|
||||
let report = move |path: &str, i: usize, n: usize| {
|
||||
let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) };
|
||||
let message = Some(path.to_owned());
|
||||
s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true });
|
||||
};
|
||||
let done = move || {
|
||||
drop(handle);
|
||||
sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false })
|
||||
};
|
||||
(report, done)
|
||||
}
|
||||
|
||||
fn local(paths: Vec<IpcPath>) -> Vec<PathBuf> {
|
||||
paths.into_iter().map(|p| p.to_path_buf()).collect()
|
||||
}
|
||||
|
||||
/// Eligible paths per server, without contacting any deployment host.
|
||||
pub async fn deploy_coverage(window: &str, state: &Host, paths: Vec<IpcPath>) -> IdeResult<Vec<DeploymentCoverage>> {
|
||||
let project = state.project(window)?;
|
||||
Ok(ide_remote::coverage(&project.settings.effective(), &project.root, &local(paths)))
|
||||
}
|
||||
|
||||
/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`).
|
||||
pub async fn deploy_upload(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Download from…: the remote counterparts of files and folders.
|
||||
pub async fn deploy_download(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Deletes the remote counterparts of files and folders.
|
||||
pub async fn deploy_delete_remote(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await
|
||||
}
|
||||
|
||||
/// Sync with Deployed: local and remote files under `path`, side by side.
|
||||
pub async fn deploy_compare(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
path: IpcPath,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<SyncEntry>> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await;
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Applies the Sync with Deployed choices.
|
||||
pub async fn deploy_sync(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
steps: Vec<SyncStep>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Where `path` is deployed (remote path, web URL); `None` when not mapped.
|
||||
pub async fn deploy_info(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
path: IpcPath,
|
||||
server: Option<String>,
|
||||
) -> IdeResult<Option<DeployedPath>> {
|
||||
let project = state.project(window)?;
|
||||
state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await
|
||||
}
|
||||
|
||||
/// Remote Host tool window: the server root of `server`, resolved on the host.
|
||||
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<String> {
|
||||
let settings = match state.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => state.global_settings.effective(),
|
||||
};
|
||||
state.remote.server_root(&settings, &server).await
|
||||
}
|
||||
|
||||
/// Entries of a folder on the selected filesystem (`~`: its home directory).
|
||||
pub async fn remote_list(state: &Host, connection: FsConnection, path: String) -> IdeResult<Vec<RemoteEntry>> {
|
||||
state.remote.list(&connection, &path).await
|
||||
}
|
||||
|
||||
/// Opens a remote file for editing: a local copy, uploaded back on save.
|
||||
pub async fn remote_open(state: &Host, connection: FsConnection, path: String) -> IdeResult<IpcPath> {
|
||||
Ok(state.remote.open_remote(&connection, &path).await?.into())
|
||||
}
|
||||
|
||||
/// The filesystem connection and path a cached editing copy stands for.
|
||||
pub fn remote_of(state: &Host, path: IpcPath) -> Option<(FsConnection, String)> {
|
||||
state.remote.remote_of(&path)
|
||||
}
|
||||
|
||||
pub async fn remote_delete(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.remote_delete(&connection, &path).await
|
||||
}
|
||||
|
||||
pub async fn remote_mkdir(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.remote_mkdir(&connection, &path).await
|
||||
}
|
||||
|
||||
pub async fn remote_rename(state: &Host, connection: FsConnection, from: String, to: String) -> IdeResult<()> {
|
||||
state.remote.remote_rename(&connection, &from, &to).await
|
||||
}
|
||||
|
||||
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.test_connection(&connection, &path).await
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
|
||||
d.add("deploy_coverage", |c| async move { deploy_coverage(&c.window, &c.host, c.arg("paths")?).await });
|
||||
d.add("deploy_upload", |c| async move {
|
||||
deploy_upload(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_download", |c| async move {
|
||||
deploy_download(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_delete_remote", |c| async move {
|
||||
deploy_delete_remote(&c.window, &c.host, c.arg("paths")?, c.arg("server")?).await
|
||||
});
|
||||
d.add("deploy_compare", |c| async move {
|
||||
deploy_compare(&c.window, &c.host, c.arg("path")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_sync", |c| async move {
|
||||
deploy_sync(&c.window, &c.host, c.arg("steps")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_info", |c| async move { deploy_info(&c.window, &c.host, c.arg("path")?, c.arg("server")?).await });
|
||||
d.add("deploy_server_root", |c| async move { deploy_server_root(&c.window, &c.host, c.arg("server")?).await });
|
||||
d.add("remote_list", |c| async move { remote_list(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_open", |c| async move { remote_open(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
|
||||
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_rename", |c| async move {
|
||||
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("deploy_test_connection", |c| async move {
|
||||
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
|
||||
});
|
||||
}
|
||||
@@ -38,6 +38,18 @@ pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
|
||||
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
|
||||
}
|
||||
|
||||
/// Recovers external changes missed by the project's file watchers.
|
||||
pub async fn sync_from_disk(host: &Host, window: &str) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
if project.settings.effective().files.sync_on_focus {
|
||||
project.documents.sync_from_disk();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.snapshot(doc)).await
|
||||
@@ -45,3 +57,6 @@ pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<Docume
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -30,8 +30,15 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<
|
||||
}
|
||||
|
||||
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
|
||||
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
|
||||
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
|
||||
pub async fn doc_set_encoding(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
encoding: String,
|
||||
reload: bool,
|
||||
) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
|
||||
}
|
||||
|
||||
pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
|
||||
@@ -117,7 +124,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
|
||||
d.add("doc_ui_mode", |c| async move { doc_ui_mode(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("doc_set_encoding", |c| async move {
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await
|
||||
});
|
||||
d.add("doc_set_line_ending", |c| async move {
|
||||
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::*;
|
||||
use crate::{HostConfig, Project};
|
||||
use ide_core::{IdeEvent, TextChange, event::NotifyLevel};
|
||||
use parking_lot::Mutex;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
struct Fixture {
|
||||
host: Host,
|
||||
project: Arc<Project>,
|
||||
events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
path: PathBuf,
|
||||
outside: PathBuf,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
// Keep watcher delivery out of these tests: the op must recover it itself.
|
||||
let excluded = root.join("node_modules");
|
||||
std::fs::create_dir_all(&excluded).unwrap();
|
||||
let path = excluded.join("file.txt");
|
||||
let outside = base.join("outside.txt");
|
||||
std::fs::write(&path, "original\n").unwrap();
|
||||
std::fs::write(&outside, "outside\n").unwrap();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = events.clone();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| {
|
||||
let events = captured.clone();
|
||||
Arc::new(move |event| events.lock().push(event))
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
Self { _temp: temp, host, project, events, path, outside }
|
||||
}
|
||||
|
||||
async fn open(&self, path: &Path) -> DocumentInfo {
|
||||
open(&self.host, "main", path.to_path_buf()).await.unwrap()
|
||||
}
|
||||
|
||||
async fn sync(&self) {
|
||||
sync_from_disk(&self.host, "main").await.unwrap();
|
||||
}
|
||||
|
||||
fn document_events(&self) -> Vec<IdeEvent> {
|
||||
self.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, IdeEvent::DocPatched { .. } | IdeEvent::DocDirty { .. } | IdeEvent::Notify { .. }))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn changed_clean_document_reloads_once() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
std::fs::write(&f.path, "externally changed\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, "externally changed\n");
|
||||
assert!(!loaded.dirty);
|
||||
assert!(loaded.version > original.version);
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, loaded.version);
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unchanged_document_has_no_patch_or_event() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, original.text);
|
||||
assert_eq!(loaded.version, original.version);
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn dirty_document_keeps_edits_and_warns_once() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
edit(
|
||||
&f.host,
|
||||
"main",
|
||||
original.id,
|
||||
original.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "local ".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
f.events.lock().clear();
|
||||
std::fs::write(&f.path, "external replacement\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, "local original\n");
|
||||
assert!(loaded.dirty);
|
||||
assert!(matches!(f.document_events().as_slice(), [IdeEvent::Notify { level: NotifyLevel::Warning, .. }]));
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn deleted_document_keeps_its_buffer_and_other_documents_still_reload() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
let outside = f.open(&f.outside).await;
|
||||
std::fs::remove_file(&f.path).unwrap();
|
||||
std::fs::write(&f.outside, "outside changed after deletion\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, original.text);
|
||||
assert_eq!(loaded.version, original.version);
|
||||
assert_eq!(f.project.documents.snapshot(outside.id).unwrap().text, "outside changed after deletion\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn outside_project_document_reloads() {
|
||||
let f = Fixture::new();
|
||||
assert!(!f.outside.starts_with(&f.project.root));
|
||||
let original = f.open(&f.outside).await;
|
||||
std::fs::write(&f.outside, "outside changed externally\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "outside changed externally\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn setting_defaults_on_and_disabled_sync_keeps_buffer() {
|
||||
let f = Fixture::new();
|
||||
assert!(f.project.settings.effective().files.sync_on_focus);
|
||||
assert!(serde_json::from_value::<ide_core::settings::FileSettings>(serde_json::json!({})).unwrap().sync_on_focus);
|
||||
let original = f.open(&f.outside).await;
|
||||
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", false.into()).unwrap();
|
||||
std::fs::write(&f.outside, "external change while disabled\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, original.version);
|
||||
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", true.into()).unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change while disabled\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn properties_reload_preserves_native_to_ascii_decoding() {
|
||||
let f = Fixture::new();
|
||||
let path = f.outside.with_extension("properties");
|
||||
std::fs::write(&path, "key=\\u4F60\n").unwrap();
|
||||
let mut defaults = f.project.documents.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
f.project.documents.set_file_defaults(defaults);
|
||||
let original = f.open(&path).await;
|
||||
assert_eq!(original.text, "key=你\n");
|
||||
std::fs::write(&path, "key=\\u4E16\\u754C\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "key=世界\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn binary_and_virtual_documents_are_skipped() {
|
||||
let f = Fixture::new();
|
||||
let path = f.outside.with_extension("bin");
|
||||
std::fs::write(&path, b"\0\0binary payload").unwrap();
|
||||
let binary = f.open(&path).await;
|
||||
assert!(binary.read_only);
|
||||
let virtual_doc =
|
||||
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
|
||||
std::fs::write(&path, "now ordinary text").unwrap();
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(binary.id).unwrap().version, binary.version);
|
||||
assert_eq!(f.project.documents.snapshot(virtual_doc.id).unwrap().text, "virtual");
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn synchronization_only_changes_the_calling_windows_documents() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.outside).await;
|
||||
let other_root = f.outside.parent().unwrap().join("other-project");
|
||||
std::fs::create_dir(&other_root).unwrap();
|
||||
let other = f.host.open_project("other", &other_root).unwrap();
|
||||
let other_doc = open(&f.host, "other", f.outside.clone()).await.unwrap();
|
||||
std::fs::write(&f.outside, "external change in shared file\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change in shared file\n");
|
||||
assert_eq!(other.documents.snapshot(other_doc.id).unwrap().text, other_doc.text);
|
||||
}
|
||||
@@ -34,58 +34,72 @@ async fn resolve_launch(
|
||||
build: bool,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let started = std::time::Instant::now();
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
// Let jdtls finish its build before the external compiler writes Kotlin/Scala classes.
|
||||
let mut info = project.lsp.java_launch(&module, test, build, cancel).await?;
|
||||
let built = if build && compile::has_non_java_sources(&module) {
|
||||
let (root, cache, module, settings) =
|
||||
(project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone());
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &cancel)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.contains(&p) {
|
||||
class_paths.push(p);
|
||||
let lsp_ms = started.elapsed().as_millis();
|
||||
let data_dir = host.data_dir.clone();
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let built = if build && compile::has_non_java_sources(&module) {
|
||||
compile::build_for_launch(
|
||||
&project.root,
|
||||
&load(&project.root, &project.build_cache_dir(), &settings),
|
||||
&module,
|
||||
test,
|
||||
&cancel,
|
||||
)?
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.contains(&p) {
|
||||
class_paths.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
info.class_paths = class_paths;
|
||||
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
|
||||
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
|
||||
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
|
||||
let release = ide_sdk::java::project_java_version(&module);
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
|
||||
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
|
||||
});
|
||||
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
|
||||
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
|
||||
let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
|
||||
info.shorten_command_line = Some(shorten.as_str().to_owned());
|
||||
if info.error_count > 0 {
|
||||
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
|
||||
let managed = ide_tools::managed_bundle(&host.data_dir, "lombok");
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
|
||||
let root = project.root.clone();
|
||||
// Detection may read the project's sources.
|
||||
let lombok = tokio::task::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
info.hints.extend(lombok.problem());
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
Ok(info)
|
||||
info.class_paths = class_paths;
|
||||
let compile_ms = started.elapsed().as_millis() - lsp_ms;
|
||||
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
|
||||
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
|
||||
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
|
||||
let release = ide_sdk::java::project_java_version(&module);
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
|
||||
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
|
||||
});
|
||||
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
|
||||
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
|
||||
let shorten =
|
||||
ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
|
||||
info.shorten_command_line = Some(shorten.as_str().to_owned());
|
||||
if info.error_count > 0 {
|
||||
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
|
||||
let managed = ide_tools::managed_bundle(&data_dir, "lombok");
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
|
||||
let root = project.root.clone();
|
||||
// Detection may read the project's sources.
|
||||
let lombok = ide_sdk::lombok::lombok_agent(&root, own, managed, repo);
|
||||
info.hints.extend(lombok.problem());
|
||||
}
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
tracing::info!(
|
||||
lsp_ms,
|
||||
compile_ms,
|
||||
jdk_ms = started.elapsed().as_millis() - lsp_ms - compile_ms,
|
||||
total_ms = started.elapsed().as_millis(),
|
||||
"Java launch resolved"
|
||||
);
|
||||
Ok(info)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Bounded, read-only JVM presentation for visible files, also served remotely.
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
|
||||
use ide_syntax::{JvmPresentation, Snapshot};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::Read,
|
||||
path::PathBuf,
|
||||
sync::{Mutex, OnceLock},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
pub const PREFIX_BYTES: u64 = 64 * 1024;
|
||||
const MAX_BATCH: usize = 64;
|
||||
const MAX_CACHE: usize = 4096;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JvmFilePresentation {
|
||||
pub path: PathBuf,
|
||||
pub presentation: JvmPresentation,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Cached {
|
||||
modified: SystemTime,
|
||||
size: u64,
|
||||
presentation: JvmPresentation,
|
||||
}
|
||||
|
||||
fn prefix(path: &PathBuf) -> std::io::Result<String> {
|
||||
let mut bytes = Vec::new();
|
||||
File::open(path)?.take(PREFIX_BYTES).read_to_end(&mut bytes)?;
|
||||
// A truncated final UTF-8 character must not invalidate the entire prefix.
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
pub async fn jvm_file_presentations(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
) -> IdeResult<Vec<JvmFilePresentation>> {
|
||||
if paths.len() > MAX_BATCH {
|
||||
return Err(IdeError::invalid("at most 64 JVM files per batch"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Cached>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(Default::default);
|
||||
let mut results = Vec::new();
|
||||
for path in paths {
|
||||
let language = match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("java") => LanguageId::new("java"),
|
||||
Some("kt") => LanguageId::new("kotlin"),
|
||||
_ => continue,
|
||||
};
|
||||
let Ok(meta) = path.metadata() else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(modified) = meta.modified() else { continue };
|
||||
let hit =
|
||||
cache.lock().unwrap().get(&path).filter(|c| c.modified == modified && c.size == meta.len()).cloned();
|
||||
let presentation = if let Some(hit) = hit {
|
||||
hit.presentation
|
||||
} else {
|
||||
let Ok(source) = prefix(&path) else { continue };
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
|
||||
let presentation = project.structure.jvm_presentation(
|
||||
&Snapshot::text(&language, &source),
|
||||
stem,
|
||||
&CancellationToken::new(),
|
||||
)?;
|
||||
let mut cache = cache.lock().unwrap();
|
||||
if cache.len() >= MAX_CACHE {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(path.clone(), Cached { modified, size: meta.len(), presentation: presentation.clone() });
|
||||
presentation
|
||||
};
|
||||
results.push(JvmFilePresentation { path, presentation });
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn huge_files_read_only_a_prefix() {
|
||||
let path = std::env::temp_dir().join(format!("openidea-icon-prefix-{}.java", std::process::id()));
|
||||
let file = File::create(&path).unwrap();
|
||||
file.set_len(100 * 1024 * 1024).unwrap();
|
||||
assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@ pub mod build;
|
||||
pub mod bytes;
|
||||
pub mod coverage;
|
||||
pub mod debug;
|
||||
pub mod deploy;
|
||||
pub mod doc;
|
||||
pub mod env;
|
||||
pub mod fs;
|
||||
pub mod history;
|
||||
pub mod java;
|
||||
pub mod jvm_icons;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod notebook;
|
||||
@@ -80,6 +82,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
search::register(d);
|
||||
run::register(d);
|
||||
env::register(d);
|
||||
deploy::register(d);
|
||||
}
|
||||
|
||||
impl Host {
|
||||
|
||||
@@ -146,6 +146,14 @@ pub fn set_settings(
|
||||
}
|
||||
}
|
||||
let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() };
|
||||
if key == "editor" || key == "editor.encodings" || key == "editor.encodings.nativeToAscii" {
|
||||
let affected = affected();
|
||||
host.runtime.spawn_blocking(move || {
|
||||
for project in affected {
|
||||
project.documents.reload_properties();
|
||||
}
|
||||
});
|
||||
}
|
||||
// File type associations re-detect open documents (off the command thread).
|
||||
if key == "files" || key.starts_with("files.fileType") {
|
||||
let affected = affected();
|
||||
|
||||
@@ -21,6 +21,11 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
let key = crate::project::project_key(&member);
|
||||
let name = member.file_name().unwrap_or_default().to_string_lossy();
|
||||
for mut config in configs {
|
||||
for task in &mut config.extras.before_launch {
|
||||
if task.kind == "run" {
|
||||
task.target = format!("workspace:{}:{}", key, task.target);
|
||||
}
|
||||
}
|
||||
config.id = format!("workspace:{}:{}", key, config.id);
|
||||
config.name = format!("{name} / {}", config.name);
|
||||
config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into());
|
||||
@@ -97,6 +102,10 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
} else {
|
||||
ide_sdk::apply::resolve_run_command(&mut config, root, &settings);
|
||||
}
|
||||
if config.kind == "go" {
|
||||
ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings);
|
||||
ide_sdk::go::apply_to_run(&mut config, &settings.languages.go.build_tags);
|
||||
}
|
||||
if config.command == ide_sdk::godot::COMMAND {
|
||||
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
|
||||
}
|
||||
@@ -112,12 +121,32 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
}
|
||||
if config.kind == "go" && config.extras.params.get("scope").is_some_and(|s| s == "file") {
|
||||
let root = project.root.clone();
|
||||
config = host
|
||||
.blocking(move || {
|
||||
ide_run::go::prepare_test_file(&root, &mut config)?;
|
||||
Ok(config)
|
||||
})
|
||||
.await?;
|
||||
}
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions {
|
||||
encoding: Some(encoding).filter(|e| !e.trim().is_empty()),
|
||||
python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) },
|
||||
};
|
||||
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
|
||||
let mut sink = project.sink.clone();
|
||||
if config.kind == "go" && matches!(config.args.first().map(String::as_str), Some("mod" | "work")) {
|
||||
let previous = sink;
|
||||
let root = project.root.clone();
|
||||
sink = std::sync::Arc::new(move |event: ide_core::IdeEvent| {
|
||||
if matches!(&event, ide_core::IdeEvent::RunExited { .. }) {
|
||||
ide_sdk::libraries::invalidate_go(&root);
|
||||
}
|
||||
previous.emit(event);
|
||||
});
|
||||
}
|
||||
host.processes.start(&project.root, &config, &options, sink, Box::new(move |c| on_output(c)))
|
||||
}
|
||||
|
||||
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
|
||||
|
||||
@@ -81,22 +81,21 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| artifacts.first().map(|a| &a.id))
|
||||
.ok_or_else(|| IdeError::invalid("Choose a WAR artifact"))?;
|
||||
let plan = ide_build::web::build(
|
||||
let mut plan = ide_build::web::build(
|
||||
&project.root,
|
||||
&model,
|
||||
id,
|
||||
&ide_run::tomcat::base_dir(&project.cache_dir, &config.id),
|
||||
)?;
|
||||
let mut env = plan.env;
|
||||
env.extend(config.env.clone());
|
||||
Ok(Some(RunConfig {
|
||||
plan.env.extend(config.env.clone());
|
||||
let mut build = RunConfig {
|
||||
id: format!("tomcat-build:{}", config.id),
|
||||
name: format!("Build {}", config.name),
|
||||
kind: if id.starts_with("maven:") { "maven" } else { "gradle" }.into(),
|
||||
command: plan.command,
|
||||
args: plan.args,
|
||||
cwd: Some(std::path::PathBuf::from(plan.cwd).into()),
|
||||
env,
|
||||
command: plan.command.clone(),
|
||||
args: vec![],
|
||||
cwd: Some(std::path::PathBuf::from(&plan.cwd).into()),
|
||||
env: plan.env.clone(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
@@ -106,7 +105,14 @@ pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> I
|
||||
env_files: config.extras.env_files,
|
||||
..Default::default()
|
||||
},
|
||||
}))
|
||||
};
|
||||
plan.resolve_project(
|
||||
&project.root,
|
||||
&ide_run::resolved_env(&project.root, &build)?,
|
||||
&ide_core::CancellationToken::new(),
|
||||
)?;
|
||||
build.args = plan.args;
|
||||
Ok(Some(build))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
|
||||
@@ -75,7 +75,7 @@ pub async fn start(
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.text_of(p),
|
||||
&|p| documents.search_text_of(p),
|
||||
&cancel,
|
||||
&|mut file| {
|
||||
if dir != &root {
|
||||
|
||||
@@ -124,7 +124,7 @@ pub async fn syntax_identifiers_in_files(
|
||||
let snap = project.documents.text_snapshot(doc).ok()?;
|
||||
return (!snap.large).then_some((snap.language, Some((doc, snap.version)), snap.text));
|
||||
}
|
||||
let text = ide_vfs::load_text(path).ok()?.text;
|
||||
let text = project.text_of(path).ok()?;
|
||||
let first_line = text.lines().next();
|
||||
Some((languages.detect(path, first_line), None, text))
|
||||
};
|
||||
|
||||
@@ -187,10 +187,13 @@ pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -
|
||||
}
|
||||
|
||||
/// Gutter change markers for a file, computed against its current (unsaved) text.
|
||||
pub fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
|
||||
pub async fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
|
||||
let project = host.project(window)?;
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
blocking(move || {
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false, project.vcs_encoding(&path, None)?))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
|
||||
@@ -210,8 +213,8 @@ pub async fn vcs_blame(
|
||||
) -> IdeResult<Vec<BlameLine>> {
|
||||
let project = host.project(window)?;
|
||||
// An explicit text wins (merge view annotates its side panes, not the worktree).
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
blocking(move || {
|
||||
let text = text.or_else(|| project.documents.text_of(&path));
|
||||
project.with_repo_of(&path, |r| {
|
||||
r.blame(&path, text.as_deref(), revision.as_deref(), project.vcs_encoding(&path, revision.as_deref())?)
|
||||
})
|
||||
@@ -510,14 +513,17 @@ pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: S
|
||||
|
||||
/// Stages only the selected hunks of a file's working-tree changes.
|
||||
/// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`.
|
||||
pub fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
|
||||
pub async fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let encoding = project.vcs_encoding(&path, Some(":0"))?;
|
||||
let current = ide_vfs::load_text_as(&path, encoding.name())?.text;
|
||||
project.with_repo_of(&path, |r| {
|
||||
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
r.stage_hunks(&path, ¤t, &selected, encoding)
|
||||
blocking(move || {
|
||||
let encoding = project.vcs_encoding(&path, Some(":0"))?;
|
||||
let current = project.documents.load_file_as(&path, encoding.charset.name())?.text;
|
||||
project.with_repo_of(&path, |r| {
|
||||
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
r.stage_hunks(&path, ¤t, &selected, encoding)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Clones a repository (Get from VCS). Works without an open project.
|
||||
@@ -806,17 +812,25 @@ pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList
|
||||
|
||||
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
|
||||
/// merge UI); with `remove`, deletes it from the shelf.
|
||||
pub async fn vcs_unshelve(host: &Host, window: &str, id: String, remove: bool) -> IdeResult<ApplyOutcome> {
|
||||
pub async fn vcs_unshelve(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
id: String,
|
||||
remove: bool,
|
||||
path: Option<String>,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove))
|
||||
project.with_repo_recorded(None, Some("Unshelve"), |r| {
|
||||
ide_vcs::shelf::unshelve_path(r, &shelf_dir(&project.root), &id, remove, path.as_deref())
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_delete(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
pub fn vcs_shelf_delete(host: &Host, window: &str, id: String, path: Option<String>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::delete(&shelf_dir(&project.root), &id)
|
||||
ide_vcs::shelf::delete_path(&shelf_dir(&project.root), &id, path.as_deref())
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> IdeResult<()> {
|
||||
@@ -824,9 +838,19 @@ pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) ->
|
||||
ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name)
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String) -> IdeResult<String> {
|
||||
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String, path: Option<String>) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id)
|
||||
ide_vcs::shelf::selected_patch(&shelf_dir(&project.root), &id, path.as_deref())
|
||||
}
|
||||
|
||||
pub async fn vcs_shelf_diff(host: &Host, window: &str, id: String) -> IdeResult<Vec<ide_vcs::shelf::ShelfDiffFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| ide_vcs::shelf::diff_files(r, &shelf_dir(&project.root), &id))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_shelf_import(host: &Host, window: &str, name: String, patch: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || ide_vcs::shelf::import_patch(&shelf_dir(&project.root), &name, &patch)).await
|
||||
}
|
||||
|
||||
pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult<ChangelistState> {
|
||||
@@ -856,7 +880,7 @@ pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch:
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_roots", |c| async move { vcs_roots(&c.host, &c.window) });
|
||||
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
|
||||
d.add("vcs_blame", |c| async move {
|
||||
vcs_blame(&c.host, &c.window, c.arg("path")?, c.arg("text")?, c.arg("revision")?).await
|
||||
@@ -940,7 +964,10 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
"vcs_diff_hunks",
|
||||
|c| async move { Ok(vcs_diff_hunks(&c.host, &c.window, c.arg("oldText")?, c.arg("newText")?)) },
|
||||
);
|
||||
d.add("vcs_stage_hunks", |c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?) });
|
||||
d.add(
|
||||
"vcs_stage_hunks",
|
||||
|c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?).await },
|
||||
);
|
||||
d.add("vcs_clone", |c| async move {
|
||||
vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await
|
||||
});
|
||||
@@ -1004,10 +1031,16 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
);
|
||||
d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await });
|
||||
d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) });
|
||||
d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await });
|
||||
d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("vcs_unshelve", |c| async move {
|
||||
vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?, c.arg("path")?).await
|
||||
});
|
||||
d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?, c.arg("path")?) });
|
||||
d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) });
|
||||
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("vcs_shelf_diff", |c| async move { vcs_shelf_diff(&c.host, &c.window, c.arg("id")?).await });
|
||||
d.add("vcs_shelf_import", |c| async move {
|
||||
vcs_shelf_import(&c.host, &c.window, c.arg("name")?, c.arg("patch")?).await
|
||||
});
|
||||
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?, c.arg("path")?) });
|
||||
d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) });
|
||||
d.add("vcs_changelists_save", |c| async move { vcs_changelists_save(&c.host, &c.window, c.arg("changelists")?) });
|
||||
d.add(
|
||||
|
||||
@@ -689,7 +689,7 @@ impl Project {
|
||||
limit: q.limit,
|
||||
};
|
||||
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None));
|
||||
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) {
|
||||
match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.search_text_of(p), cancel) {
|
||||
Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit },
|
||||
Err(e) => {
|
||||
tracing::debug!("reference candidates: {}", e.message);
|
||||
@@ -747,7 +747,17 @@ impl Project {
|
||||
}
|
||||
|
||||
/// Use the open document's selected encoding, or the same detection/settings as opening it.
|
||||
pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<&'static encoding_rs::Encoding> {
|
||||
pub fn vcs_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<ide_vcs::TextEncoding> {
|
||||
let charset = self.file_encoding(path, rev)?;
|
||||
let encoding = ide_vcs::TextEncoding::from(charset);
|
||||
Ok(if self.documents.native_to_ascii_for(path) {
|
||||
encoding.with_ascii_conversion(ide_vfs::decode_properties_escapes, ide_vfs::encode_properties_ascii)
|
||||
} else {
|
||||
encoding
|
||||
})
|
||||
}
|
||||
|
||||
fn file_encoding(&self, path: &Path, rev: Option<&str>) -> IdeResult<&'static encoding_rs::Encoding> {
|
||||
if let Some(id) = self.documents.by_path(path) {
|
||||
let info = self.documents.snapshot(id)?;
|
||||
return encoding_rs::Encoding::for_label(info.encoding.as_bytes())
|
||||
@@ -768,7 +778,7 @@ impl Project {
|
||||
pub fn text_of(&self, path: &Path) -> IdeResult<String> {
|
||||
match self.documents.text_of(path) {
|
||||
Some(text) => Ok(text),
|
||||
None => Ok(ide_vfs::load_text_with_fallback(path, self.documents.file_defaults().fallback_for(path))?.text),
|
||||
None => Ok(ide_text::normalize_line_endings(&self.documents.load_file(path)?.text).into_owned()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -776,6 +786,8 @@ impl Project {
|
||||
/// Applies a multi-file edit through the document layer so open editors update live.
|
||||
pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> {
|
||||
use ide_core::FileEdit;
|
||||
let mut edit = edit.clone();
|
||||
edit.deduplicate();
|
||||
for file in &edit.files {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
@@ -1119,3 +1131,28 @@ mod index_event_tests {
|
||||
assert!(vcs_path_changed(&index));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod workspace_edit_tests {
|
||||
use super::*;
|
||||
use ide_core::{FileEdit, LanguageId, Pos, Range, TextEdit, WorkspaceEdit};
|
||||
|
||||
#[test]
|
||||
fn rename_via_symlink_updates_the_open_document_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("main.cpp");
|
||||
let alias = dir.path().join("alias.cpp");
|
||||
std::fs::write(&real, "add();\n").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &alias).unwrap();
|
||||
let documents = Documents::new(Arc::new(|_: IdeEvent| {}), Box::new(|_, _| LanguageId::new("cpp")));
|
||||
let doc = documents.open(&alias).unwrap();
|
||||
let changes = |path: PathBuf| FileEdit::Edit {
|
||||
path: path.into(),
|
||||
edits: vec![TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() }],
|
||||
};
|
||||
apply_workspace_edit(&documents, &WorkspaceEdit { files: vec![changes(real.clone()), changes(alias)] })
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(real).unwrap(), "sum_values();\n");
|
||||
assert_eq!(documents.snapshot(doc.id).unwrap().text, "sum_values();\n");
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,9 @@ impl Dispatcher {
|
||||
c.host.close_project(&c.window);
|
||||
Ok(())
|
||||
})
|
||||
.add("jvm_file_presentations", |c| async move {
|
||||
ops::jvm_icons::jvm_file_presentations(&c.host, &c.window, c.arg("paths")?).await
|
||||
})
|
||||
.add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) })
|
||||
.add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) })
|
||||
.add("settings_set", |c| async move {
|
||||
@@ -186,6 +189,7 @@ impl Dispatcher {
|
||||
})
|
||||
.add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await })
|
||||
.add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await })
|
||||
.add("doc_sync_from_disk", |c| async move { ops::doc::sync_from_disk(&c.host, &c.window).await })
|
||||
.add("doc_snapshot", |c| async move { ops::doc::snapshot(&c.host, &c.window, c.arg("doc")?).await })
|
||||
.add("search_start", |c| async move {
|
||||
let on_event = c.channel("onEvent")?;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
use ide_core::{ChangeSet, NullSink, RequestId, TextChange};
|
||||
use ide_host::{
|
||||
Host, HostConfig,
|
||||
ops::{doc, search, vcs},
|
||||
};
|
||||
use ide_search::{SearchQuery, SearchScope};
|
||||
use std::{
|
||||
path::{Path, PathBuf},
|
||||
process::Command,
|
||||
sync::{Arc, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
fn git(root: &Path, args: &[&str]) {
|
||||
let out = Command::new("git").current_dir(root).args(args).output().unwrap();
|
||||
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
|
||||
}
|
||||
|
||||
fn host(base: &Path) -> Host {
|
||||
Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(|_| Arc::new(NullSink)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
})
|
||||
}
|
||||
|
||||
async fn find(host: &Host, paths: Vec<PathBuf>, pattern: &str) -> Vec<ide_search::FileMatches> {
|
||||
let files = Arc::new(Mutex::new(Vec::new()));
|
||||
let found = files.clone();
|
||||
search::start(
|
||||
host,
|
||||
"main",
|
||||
RequestId(519),
|
||||
SearchQuery {
|
||||
pattern: pattern.into(),
|
||||
is_regex: false,
|
||||
case_sensitive: true,
|
||||
whole_word: false,
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
scope: SearchScope::Files { paths: paths.into_iter().map(Into::into).collect() },
|
||||
max_matches: 0,
|
||||
context: Default::default(),
|
||||
},
|
||||
Arc::new(move |event| {
|
||||
if let search::SearchEvent::File { file } = event {
|
||||
found.lock().unwrap().push(file);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
Arc::try_unwrap(files).unwrap().into_inner().unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn properties_search_git_and_document_coordinates_agree() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
git(&root, &["init", "-b", "main"]);
|
||||
git(&root, &["config", "user.name", "Test"]);
|
||||
git(&root, &["config", "user.email", "[email protected]"]);
|
||||
git(&root, &["config", "commit.gpgsign", "false"]);
|
||||
git(&root, &["config", "core.autocrlf", "false"]);
|
||||
let path = root.join("messages.properties");
|
||||
let original = "hello=\\u4f60\\u597d\\uD83D\\uDE00target\nsecond=unchanged\nescaped=line\\u000Alogical\n";
|
||||
std::fs::write(&path, original).unwrap();
|
||||
let host = host(&base);
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
git(&root, &["add", "."]);
|
||||
git(&root, &["commit", "-m", "Properties"]);
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
project.documents.set_file_defaults(defaults);
|
||||
let displayed = "hello=你好😀target\nsecond=unchanged\nescaped=line\\u000Alogical\n";
|
||||
assert_eq!(project.text_of(&path).unwrap(), displayed);
|
||||
let closed = find(&host, vec![path.clone()], "target").await;
|
||||
let closed_json = serde_json::to_value(&closed).unwrap();
|
||||
let opened = doc::open(&host, "main", path.clone()).await.unwrap();
|
||||
assert_eq!(opened.text, displayed);
|
||||
assert_eq!(serde_json::to_value(find(&host, vec![path.clone()], "target").await).unwrap(), closed_json);
|
||||
assert_eq!(closed[0].matches[0].ranges, vec![(10, 16)]);
|
||||
assert_eq!(find(&host, vec![path.clone()], "你好").await[0].matches.len(), 1);
|
||||
assert!(find(&host, vec![path.clone()], r"\u4f60").await.is_empty());
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
|
||||
assert_eq!(vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().as_deref(), Some(displayed));
|
||||
assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test"));
|
||||
doc::edit(
|
||||
&host,
|
||||
"main",
|
||||
opened.id,
|
||||
opened.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 10, to: 16, insert: "世界".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap();
|
||||
assert_eq!(hunks.len(), 1);
|
||||
assert_eq!(hunks[0].old_text, "hello=你好😀target\n");
|
||||
doc::save(&host, "main", opened.id).await.unwrap();
|
||||
let saved = std::fs::read(&path).unwrap();
|
||||
assert!(saved.is_ascii());
|
||||
assert!(String::from_utf8(saved.clone()).unwrap().contains(r"\u4F60\u597D\uD83D\uDE00\u4E16\u754C"));
|
||||
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).await.unwrap();
|
||||
let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap();
|
||||
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), saved);
|
||||
repo.unstage(std::slice::from_ref(&path)).unwrap();
|
||||
// Line staging must match the decoded base even when Git stores lowercase escapes.
|
||||
let patch = ide_vcs::build_partial_patch("messages.properties", displayed, &project.text_of(&path).unwrap(), &[0]);
|
||||
repo.stage_patch(&patch, &|p| project.vcs_encoding(p, Some(":0"))).unwrap();
|
||||
assert_eq!(repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(), saved);
|
||||
repo.unstage(std::slice::from_ref(&path)).unwrap();
|
||||
let snap = project.documents.snapshot(opened.id).unwrap();
|
||||
project.documents.replace_all(opened.id, displayed).unwrap();
|
||||
doc::save(&host, "main", opened.id).await.unwrap();
|
||||
assert!(project.documents.snapshot(opened.id).unwrap().version > snap.version);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
|
||||
// Partial commits identify hunks in displayed line coordinates as well.
|
||||
let partial_text = displayed.replace("target", "世界").replace("logical", "changed");
|
||||
project.documents.replace_all(opened.id, &partial_text).unwrap();
|
||||
doc::save(&host, "main", opened.id).await.unwrap();
|
||||
let hunks = ide_vcs::diff_hunks(displayed, &partial_text);
|
||||
assert_eq!(hunks.len(), 2);
|
||||
let selected = &hunks[0];
|
||||
let options = ide_vcs::CommitOptions {
|
||||
partial: vec![ide_vcs::PartialCommitFile {
|
||||
path: path.clone().into(),
|
||||
hunks: vec![ide_vcs::HunkRange {
|
||||
start: selected.start,
|
||||
lines: selected.lines,
|
||||
old_start: selected.old_start,
|
||||
old_lines: selected.old_lines,
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
repo.commit_with("Selected properties change", &options, &|p| project.vcs_encoding(p, None)).unwrap();
|
||||
let head = vcs::vcs_file_at(&host, "main", path.clone(), None).await.unwrap().unwrap();
|
||||
assert_eq!(head, displayed.replace("target", "世界"));
|
||||
assert!(repo.file_bytes_at(&path, None).unwrap().unwrap().is_ascii());
|
||||
let closed_path = root.join("closed.properties");
|
||||
std::fs::write(&closed_path, r"value=\u4F60\u597D\uD83D\uDE00target").unwrap();
|
||||
let query = SearchQuery {
|
||||
pattern: "target".into(),
|
||||
is_regex: false,
|
||||
case_sensitive: true,
|
||||
whole_word: false,
|
||||
include: Vec::new(),
|
||||
exclude: Vec::new(),
|
||||
scope: SearchScope::Files { paths: vec![closed_path.clone().into()] },
|
||||
max_matches: 0,
|
||||
context: Default::default(),
|
||||
};
|
||||
search::replace_in_files(
|
||||
&host,
|
||||
"main",
|
||||
RequestId(520),
|
||||
query,
|
||||
"世界".into(),
|
||||
false,
|
||||
vec![ide_search::ReplaceTarget {
|
||||
path: closed_path.clone().into(),
|
||||
only: Some(vec![ide_core::Pos { line: 0, col: 10 }]),
|
||||
}],
|
||||
ide_host::ops::Channel::new(|_| {}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&closed_path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u4E16\u754C");
|
||||
assert!(project.documents.by_path(&closed_path).is_none());
|
||||
host.close_project("main");
|
||||
}
|
||||
@@ -81,6 +81,27 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap();
|
||||
assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}");
|
||||
|
||||
// JVM icons use the same read-only protocol as local windows.
|
||||
let java = project.join("src/Foo.java");
|
||||
let kotlin = project.join("src/Singleton.kt");
|
||||
std::fs::write(&java, "abstract class Foo {}").unwrap();
|
||||
std::fs::write(&kotlin, "object Singleton {}").unwrap();
|
||||
let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java, kotlin] })).await.unwrap();
|
||||
assert_eq!(icons[0]["presentation"]["kind"], "abstractClass");
|
||||
assert_eq!(icons[1]["presentation"]["kind"], "object");
|
||||
// Replacing a file invalidates its cached metadata, including same-size writes.
|
||||
std::fs::write(&java, "interface Foo {} ").unwrap();
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open(&java)
|
||||
.unwrap()
|
||||
.set_modified(std::time::SystemTime::UNIX_EPOCH)
|
||||
.unwrap();
|
||||
let icons = invoke(&peer, "jvm_file_presentations", json!({ "paths": [java] })).await.unwrap();
|
||||
assert_eq!(icons[0]["presentation"]["kind"], "interface");
|
||||
let oversized = invoke(&peer, "jvm_file_presentations", json!({ "paths": vec![java.clone(); 65] })).await;
|
||||
assert!(oversized.is_err());
|
||||
|
||||
// Edit and save a document.
|
||||
let file = project.join("src/main.py");
|
||||
let doc = invoke(&peer, "doc_open", json!({ "path": file })).await.unwrap();
|
||||
@@ -91,6 +112,14 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "print('needle')\nprint('more')\n");
|
||||
|
||||
let outside = tmp.path().join("outside.txt");
|
||||
std::fs::write(&outside, "original\n").unwrap();
|
||||
let external = invoke(&peer, "doc_open", json!({ "path": outside })).await.unwrap();
|
||||
std::fs::write(&outside, "external change\n").unwrap();
|
||||
invoke(&peer, "doc_sync_from_disk", json!({})).await.unwrap();
|
||||
let snapshot = invoke(&peer, "doc_snapshot", json!({ "doc": external["id"] })).await.unwrap();
|
||||
assert_eq!(snapshot["text"], "external change\n");
|
||||
|
||||
// Commands shared with the desktop shell (camelCase argument names, as Tauri takes them).
|
||||
invoke(&peer, "doc_set_line_ending", json!({ "doc": id, "lineEnding": "crLf" })).await.unwrap();
|
||||
invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap();
|
||||
@@ -170,3 +199,132 @@ impl EventType for EventMessage {
|
||||
serde_json::to_value(&self.event).unwrap()["type"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn local_deployment_runs_on_the_project_host() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
let target = tmp.path().join("mounted");
|
||||
std::fs::create_dir(&project).unwrap();
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
let project = dunce::canonicalize(project).unwrap();
|
||||
let file = project.join("local.txt");
|
||||
std::fs::write(&file, "host file").unwrap();
|
||||
let slot = Arc::new(OnceLock::new());
|
||||
let sink_slot = slot.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
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()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let dispatcher = Dispatcher::standard();
|
||||
for cmd in [
|
||||
"deploy_coverage",
|
||||
"deploy_upload",
|
||||
"deploy_download",
|
||||
"deploy_delete_remote",
|
||||
"deploy_compare",
|
||||
"deploy_sync",
|
||||
"deploy_info",
|
||||
"deploy_server_root",
|
||||
"deploy_test_connection",
|
||||
"remote_list",
|
||||
"remote_open",
|
||||
"remote_of",
|
||||
"remote_delete",
|
||||
"remote_mkdir",
|
||||
"remote_rename",
|
||||
] {
|
||||
assert!(dispatcher.commands().contains(&cmd), "missing host command {cmd}");
|
||||
}
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host, dispatcher, slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
|
||||
invoke(&peer, "open_project", json!({"path": project})).await.unwrap();
|
||||
let deployment = json!({"servers": [{"id":"local", "kind":"local", "rootPath":target}], "defaultServer":"local"});
|
||||
invoke(&peer, "settings_set", json!({"scope":"project", "key":"deployment", "value":deployment.to_string()}))
|
||||
.await
|
||||
.unwrap();
|
||||
let coverage = invoke(&peer, "deploy_coverage", json!({"paths":[file]})).await.unwrap();
|
||||
assert_eq!(coverage[0]["server"], "local");
|
||||
assert_eq!(coverage[0]["paths"], json!([ide_core::IpcPath::from(&file)]));
|
||||
invoke(&peer, "deploy_test_connection", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap();
|
||||
let report = invoke(&peer, "deploy_upload", json!({"paths":[file], "request":1001})).await.unwrap();
|
||||
assert_eq!(report["files"], 1, "{report}");
|
||||
assert_eq!(std::fs::read_to_string(target.join("local.txt")).unwrap(), "host file");
|
||||
let entries = invoke(&peer, "remote_list", json!({"connection":{"kind":"local"}, "path":target})).await.unwrap();
|
||||
assert_eq!(entries[0]["name"], "local.txt");
|
||||
std::fs::write(target.join("local.txt"), "host download").unwrap();
|
||||
let report = invoke(&peer, "deploy_download", json!({"paths":[file], "request":1002})).await.unwrap();
|
||||
assert_eq!(report["files"], 1);
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "host download");
|
||||
invoke(&peer, "deploy_delete_remote", json!({"paths":[file]})).await.unwrap();
|
||||
assert!(!target.join("local.txt").exists());
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn properties_conversion_through_remote_dispatch_without_a_remote_host() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("properties-project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let path = root.join("messages.properties");
|
||||
std::fs::write(&path, r"value=\u4F60\u597D\uD83D\uDE00!").unwrap();
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: tmp.path().join("config"),
|
||||
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)),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
}));
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let peer = Peer::connect(cr, cw, Arc::new(Client::default()));
|
||||
invoke(&peer, "open_project", json!({"path":root})).await.unwrap();
|
||||
let settings = invoke(&peer, "settings_get", json!({})).await.unwrap();
|
||||
assert_eq!(settings["editor"]["encodings"]["nativeToAscii"], false);
|
||||
let opened = invoke(&peer, "doc_open", json!({"path":path})).await.unwrap();
|
||||
let doc = opened["id"].clone();
|
||||
invoke(&peer, "settings_set", json!({"scope":"project", "key":"editor.encodings.nativeToAscii", "value":"true"}))
|
||||
.await
|
||||
.unwrap();
|
||||
let mut snap = Value::Null;
|
||||
for _ in 0..500 {
|
||||
snap = invoke(&peer, "doc_snapshot", json!({"doc":doc})).await.unwrap();
|
||||
if snap["text"] == "value=你好😀!" {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
assert_eq!(snap["text"], "value=你好😀!");
|
||||
assert_eq!(snap["dirty"], false);
|
||||
invoke(
|
||||
&peer,
|
||||
"doc_edit",
|
||||
json!({"doc":doc, "baseVersion":snap["version"],
|
||||
"changes":{"changes":[{"from":10,"to":11,"insert":"界"}]}}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
invoke(&peer, "doc_save", json!({"doc":doc})).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&path).unwrap(), r"value=\u4F60\u597D\uD83D\uDE00\u754C");
|
||||
invoke(&peer, "doc_set_encoding", json!({"doc":doc,"encoding":"utf-8","reload":true})).await.unwrap();
|
||||
let reloaded = invoke(&peer, "doc_snapshot", json!({"doc":doc})).await.unwrap();
|
||||
assert_eq!(reloaded["text"], "value=你好😀界");
|
||||
assert_eq!(reloaded["dirty"], false);
|
||||
invoke(&peer, "close_project", json!({})).await.unwrap();
|
||||
peer.kill();
|
||||
}
|
||||
@@ -77,3 +77,22 @@ async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
|
||||
assert_eq!(saved.env["JAVA_HOME"], "/explicit-jdk");
|
||||
assert_eq!(saved.command, gradle.command);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn workspace_cmake_before_launch_tasks_resolve_in_the_member() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let member = root.join("native");
|
||||
std::fs::create_dir(&member).unwrap();
|
||||
std::fs::write(member.join("CMakeLists.txt"), "project(demo)").unwrap();
|
||||
let workspace = root.join("workspace");
|
||||
ide_workspace::workspace::Workspace::save(&workspace, "Native", vec![member.clone()], true).unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.open_project("main", &workspace).unwrap();
|
||||
let configs = ops::run::configs(&host, "main").unwrap();
|
||||
let build = configs.iter().find(|c| c.kind == "cmake" && c.args.first().is_some_and(|a| a == "--build")).unwrap();
|
||||
assert_eq!(build.cwd.as_deref(), Some(member.as_path()));
|
||||
for task in &build.extras.before_launch {
|
||||
assert!(configs.iter().any(|c| c.id == task.target && c.cwd == build.cwd));
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
let mut defaults = project.documents.file_defaults();
|
||||
defaults.project_fallback = "windows-1252".into();
|
||||
project.documents.set_file_defaults(defaults);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
|
||||
let from = old.find("value = 1").unwrap() + "value = ".len();
|
||||
let offset = old[..from].encode_utf16().count() as u32;
|
||||
project
|
||||
@@ -64,12 +64,12 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
ChangeSet { changes: vec![TextChange { from: offset, to: offset + 1, insert: "2".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap();
|
||||
let hunks = vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap();
|
||||
assert_eq!(hunks.len(), 1);
|
||||
assert_eq!((hunks[0].start, hunks[0].lines), (3, 1));
|
||||
assert_eq!(hunks[0].old_text, " int value = 1;\n");
|
||||
project.documents.save(doc.id).unwrap();
|
||||
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).unwrap();
|
||||
vcs::vcs_stage_hunks(&host, "main", path.clone(), vec![0]).await.unwrap();
|
||||
let repo = ide_vcs::Repo::discover(&root).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
repo.file_bytes_at(&path, Some(":0")).unwrap().unwrap(),
|
||||
@@ -95,7 +95,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
.unwrap();
|
||||
project.documents.save(doc.id).unwrap();
|
||||
assert_eq!(std::fs::read(&path).unwrap(), original);
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).unwrap().is_empty());
|
||||
assert!(vcs::vcs_line_hunks(&host, "main", path.clone()).await.unwrap().is_empty());
|
||||
assert!(vcs::vcs_blame(&host, "main", path.clone(), None, None).await.unwrap().iter().all(|l| l.author == "Test"));
|
||||
// Missing working files still use configured encoding for historical content.
|
||||
project.documents.close(doc.id);
|
||||
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
dunce.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
|
||||
@@ -661,7 +661,9 @@ pub fn workspace_edit(conv: &Conv, e: lsp::WorkspaceEdit) -> WorkspaceEdit {
|
||||
}
|
||||
}
|
||||
}
|
||||
WorkspaceEdit { files }
|
||||
let mut edit = WorkspaceEdit { files };
|
||||
edit.deduplicate();
|
||||
edit
|
||||
}
|
||||
|
||||
/// Adds the changes of `extra`, another server's edit for the same request, to
|
||||
@@ -710,6 +712,14 @@ pub fn goto_response(conv: &Conv, r: lsp::GotoDefinitionResponse) -> Vec<Locatio
|
||||
}
|
||||
|
||||
pub fn dedup_locations(v: &mut Vec<Location>) {
|
||||
let mut canonical = std::collections::HashMap::new();
|
||||
for location in v.iter_mut().filter(|l| l.uri.is_none()) {
|
||||
let path =
|
||||
canonical.entry(location.path.clone()).or_insert_with(|| dunce::canonicalize(&location.path).ok()).clone();
|
||||
if let Some(path) = path {
|
||||
location.path = path.into();
|
||||
}
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
v.retain(|l| seen.insert(l.clone()));
|
||||
}
|
||||
@@ -1405,3 +1415,24 @@ mod tests {
|
||||
assert!(document_link(&conv, here, link(None)).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod alias_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn references_to_a_file_and_its_symlink_are_one_location() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("main.cpp");
|
||||
let alias = dir.path().join("alias.cpp");
|
||||
std::fs::write(&real, "add()").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &alias).unwrap();
|
||||
let mut locations: Vec<Location> = [real.clone(), alias]
|
||||
.into_iter()
|
||||
.map(|p| Location { path: p.into(), range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), uri: None })
|
||||
.collect();
|
||||
dedup_locations(&mut locations);
|
||||
assert_eq!(locations.len(), 1);
|
||||
assert_eq!(locations[0].path.as_ref(), dunce::canonicalize(real).unwrap());
|
||||
}
|
||||
}
|
||||
@@ -231,6 +231,9 @@ impl Handler for ClientHandler {
|
||||
}
|
||||
}
|
||||
"tsserver/request" => mgr.forward_tsserver_requests(self.server.clone(), self.generation, params),
|
||||
"language/status" if self.server.name == "jdtls" && params["type"] == "ServiceReady" => {
|
||||
mgr.java_import_ready(self.server.clone(), self.generation);
|
||||
}
|
||||
// The Angular server's project loading, shown like `$/progress`; one
|
||||
// task while several projects load.
|
||||
"angular/projectLoadingStart" if !self.server.progress.lock().contains_key(ANGULAR_LOADING) => self
|
||||
|
||||
@@ -30,6 +30,55 @@ const MAX_LAUNCH_ERRORS: usize = 10;
|
||||
/// Build markers can be published just after `java/buildWorkspace` answers.
|
||||
const ERRORS_WAIT: Duration = Duration::from_secs(2);
|
||||
|
||||
const ACCESS_OPTIONS: [&str; 2] = [
|
||||
"org.eclipse.jdt.core.compiler.problem.forbiddenReference",
|
||||
"org.eclipse.jdt.core.compiler.problem.discouragedReference",
|
||||
];
|
||||
|
||||
fn compiler_defaults(cfg: &ide_language::LanguageServerConfig) -> Value {
|
||||
// updateSettings persists project options. Only use it when jdtls redirects
|
||||
// generated metadata into its workspace, and never touch an existing .settings.
|
||||
let metadata = cfg.args.iter().rev().find_map(|a| {
|
||||
a.strip_prefix("--jvm-arg=").unwrap_or(a).strip_prefix("-Djava.import.generatesMetadataFilesAtProjectRoot=")
|
||||
});
|
||||
if metadata != Some("false") {
|
||||
return json!({});
|
||||
}
|
||||
let settings = crate::handler::all_settings(cfg);
|
||||
let Some(url) = settings
|
||||
.pointer("/java/settings/url")
|
||||
.or_else(|| settings.pointer("/settings/java/settings/url"))
|
||||
.and_then(Value::as_str)
|
||||
else {
|
||||
return json!({});
|
||||
};
|
||||
let path = crate::uri::uri_to_path(url).unwrap_or_else(|| PathBuf::from(url));
|
||||
let Ok(prefs) = std::fs::read_to_string(path) else { return json!({}) };
|
||||
let mut options = serde_json::Map::new();
|
||||
for line in prefs.lines() {
|
||||
if let Some((key, value)) = line.split_once('=')
|
||||
&& ACCESS_OPTIONS.contains(&key.trim())
|
||||
&& ["error", "warning", "info", "ignore"].contains(&value.trim())
|
||||
{
|
||||
options.insert(key.trim().into(), json!(value.trim()));
|
||||
}
|
||||
}
|
||||
Value::Object(options)
|
||||
}
|
||||
|
||||
fn cache_owned_maven_settings(root: &Path) -> bool {
|
||||
root.join("pom.xml").is_file() && matches!(root.join(".settings").try_exists(), Ok(false))
|
||||
}
|
||||
|
||||
fn project_path(uri: &str) -> Option<PathBuf> {
|
||||
// jdtls commands use Java's `file:/p` as well as LSP's `file:///p`.
|
||||
let normalized = match uri.strip_prefix("file:") {
|
||||
Some(rest) if !rest.starts_with("//") => format!("file://{rest}"),
|
||||
_ => uri.to_owned(),
|
||||
};
|
||||
crate::uri::uri_to_path(&normalized)
|
||||
}
|
||||
|
||||
/// Result of `java/buildWorkspace` (jdtls `BuildWorkspaceStatus`).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -128,14 +177,10 @@ pub fn parse_classpaths(v: &Value) -> JavaClasspath {
|
||||
.unwrap_or_default()
|
||||
};
|
||||
JavaClasspath {
|
||||
project_root: v.get("projectRoot").and_then(Value::as_str).map(|r| {
|
||||
// jdtls answers `file:/p/demo` (Java URI style) as well as `file:///p/demo`.
|
||||
let normalized = match r.strip_prefix("file:") {
|
||||
Some(rest) if !rest.starts_with("//") => format!("file://{rest}"),
|
||||
_ => r.to_owned(),
|
||||
};
|
||||
crate::uri::uri_to_path(&normalized).unwrap_or_else(|| PathBuf::from(r)).into()
|
||||
}),
|
||||
project_root: v
|
||||
.get("projectRoot")
|
||||
.and_then(Value::as_str)
|
||||
.map(|r| project_path(r).unwrap_or_else(|| PathBuf::from(r)).into()),
|
||||
class_paths: strings("classpaths"),
|
||||
module_paths: strings("modulepaths"),
|
||||
}
|
||||
@@ -176,6 +221,56 @@ async fn cancellable_wait<T>(cancel: &CancellationToken, wait: impl std::future:
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
/// m2e generates a project-specific forbidden-reference error, overriding
|
||||
/// workspace defaults. Apply those defaults only to cache-owned metadata.
|
||||
async fn java_configure_compiler(&self, server: &Server, run: &Running) -> IdeResult<bool> {
|
||||
let options = compiler_defaults(&server.effective_config());
|
||||
if options.as_object().is_none_or(|o| o.is_empty()) {
|
||||
return Ok(false);
|
||||
}
|
||||
let command = |name: &str, arguments: Value| {
|
||||
let params = json!({"command": name, "arguments": arguments});
|
||||
let peer = run.peer.clone();
|
||||
async move {
|
||||
tokio::time::timeout(
|
||||
COMMAND_TIMEOUT,
|
||||
peer.request::<_, Value>("workspace/executeCommand", params, None),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(Err(ide_jsonrpc::RpcError::Timeout))
|
||||
.map_err(|e| crate::rpc_err(SERVER, "workspace/executeCommand", e))
|
||||
}
|
||||
};
|
||||
let projects = command("java.project.getAll", json!([])).await?;
|
||||
let mut changed = false;
|
||||
for uri in projects.as_array().into_iter().flatten().filter_map(Value::as_str) {
|
||||
let Some(root) = project_path(uri) else { continue };
|
||||
// An existing .settings is owned by the project, even if it currently
|
||||
// contains preferences for another Eclipse plugin.
|
||||
if !cache_owned_maven_settings(&root) {
|
||||
continue;
|
||||
}
|
||||
let keys: Vec<_> = options.as_object().unwrap().keys().collect();
|
||||
let current = command("java.project.getSettings", json!([uri, keys])).await?;
|
||||
if options.as_object().unwrap().iter().all(|(k, v)| current.get(k) == Some(v)) {
|
||||
continue;
|
||||
}
|
||||
command("java.project.updateSettings", json!([uri, options])).await?;
|
||||
changed = true;
|
||||
}
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub(crate) fn java_import_ready(self: &Arc<Self>, server: Arc<Server>, generation: u64) {
|
||||
let mgr = self.clone();
|
||||
self.runtime.spawn(async move {
|
||||
let Some(run) = server.running_gen(generation) else { return };
|
||||
if let Err(e) = mgr.java_configure_compiler(&server, &run).await {
|
||||
tracing::warn!("jdtls: cannot apply compiler defaults: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The jdtls instance serving `path` (a module directory or source file),
|
||||
/// started if needed and waited for.
|
||||
async fn jdtls(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<(Arc<Server>, Running)> {
|
||||
@@ -211,9 +306,12 @@ impl LspManager {
|
||||
timeout: Duration,
|
||||
cancel: &CancellationToken,
|
||||
) -> IdeResult<Value> {
|
||||
let started = Instant::now();
|
||||
let (server, run) = self.jdtls(path, cancel).await?;
|
||||
let ready_ms = started.elapsed().as_millis();
|
||||
let phase = params.get("command").and_then(Value::as_str).unwrap_or(method).to_owned();
|
||||
let fut = run.peer.request::<Value, Value>(method, params, Some(cancel.clone()));
|
||||
match tokio::time::timeout(timeout, fut).await {
|
||||
let result = match tokio::time::timeout(timeout, fut).await {
|
||||
Ok(r) => r.map_err(|e| crate::rpc_err(&server.name, method, e)),
|
||||
Err(_) => Err(IdeError::new(
|
||||
ErrorKind::Protocol,
|
||||
@@ -222,7 +320,15 @@ impl LspManager {
|
||||
timeout.as_secs()
|
||||
),
|
||||
)),
|
||||
}
|
||||
};
|
||||
tracing::info!(
|
||||
phase,
|
||||
ready_ms,
|
||||
request_ms = started.elapsed().as_millis() - ready_ms,
|
||||
success = result.is_ok(),
|
||||
"Java launch request"
|
||||
);
|
||||
result
|
||||
}
|
||||
|
||||
/// `workspace/executeCommand` on the jdtls serving `path`.
|
||||
@@ -288,7 +394,16 @@ impl LspManager {
|
||||
/// Incremental build of the jdtls workspace (`java/buildWorkspace`), so
|
||||
/// class files are up to date before a launch.
|
||||
pub async fn java_build(&self, path: &Path, cancel: &CancellationToken) -> IdeResult<JavaBuildStatus> {
|
||||
let v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
|
||||
let mut v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
|
||||
// The first build can finish the Maven import before ServiceReady is handled.
|
||||
let (server, run) = self.jdtls(path, cancel).await?;
|
||||
let changed = cancellable_wait(cancel, self.java_configure_compiler(&server, &run)).await??;
|
||||
// ServiceReady can update the options concurrently with that first build.
|
||||
let recheck = v.as_i64() == Some(2)
|
||||
&& compiler_defaults(&server.effective_config()).as_object().is_some_and(|o| !o.is_empty());
|
||||
if changed || recheck {
|
||||
v = self.jdtls_request(path, "java/buildWorkspace", json!(false), BUILD_TIMEOUT, cancel).await?;
|
||||
}
|
||||
Ok(JavaBuildStatus::from_code(v.as_i64().unwrap_or(0)))
|
||||
}
|
||||
|
||||
@@ -439,6 +554,79 @@ mod tests {
|
||||
assert_eq!(shown, [(4, 5, "a1"), (10, 5, "a2")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiler_defaults_require_redirected_metadata_and_respect_server_settings() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = tmp.path().join("compiler defaults 测试.prefs");
|
||||
std::fs::write(&file, format!("{}=warning\n{}=ignore\nother=error\n", ACCESS_OPTIONS[0], ACCESS_OPTIONS[1]))
|
||||
.unwrap();
|
||||
let mut cfg: ide_language::LanguageServerConfig = toml::from_str("name = 'jdtls'\ncommand = 'jdtls'").unwrap();
|
||||
cfg.initialization_options = Some(
|
||||
toml::Value::try_from(json!({"settings": {"java": {"settings": {"url": file.to_string_lossy()}}}}))
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(compiler_defaults(&cfg), json!({}));
|
||||
for prefix in ["", "--jvm-arg="] {
|
||||
cfg.args = vec![format!("{prefix}-Djava.import.generatesMetadataFilesAtProjectRoot=false")];
|
||||
assert_eq!(compiler_defaults(&cfg), json!({ACCESS_OPTIONS[0]: "warning", ACCESS_OPTIONS[1]: "ignore"}));
|
||||
}
|
||||
cfg.args.push("--jvm-arg=-Djava.import.generatesMetadataFilesAtProjectRoot=true".into());
|
||||
assert_eq!(compiler_defaults(&cfg), json!({}), "the final JVM property wins");
|
||||
cfg.args.pop();
|
||||
cfg.settings = Some(json!({"java": {"settings": {"url": "https://example.invalid/custom.prefs"}}}));
|
||||
assert_eq!(compiler_defaults(&cfg), json!({}), "explicit server settings win");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_settings_are_never_updated_on_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
assert!(!cache_owned_maven_settings(root));
|
||||
std::fs::write(root.join("pom.xml"), "<project/>").unwrap();
|
||||
assert!(cache_owned_maven_settings(root));
|
||||
let lsp_uri = crate::uri::path_to_uri_string(root);
|
||||
let java_uri = lsp_uri.replacen("file://", "file:", 1);
|
||||
for uri in [lsp_uri, java_uri] {
|
||||
assert_eq!(project_path(&uri).as_deref(), Some(root));
|
||||
assert!(cache_owned_maven_settings(&project_path(&uri).unwrap()));
|
||||
}
|
||||
std::fs::create_dir(root.join(".settings")).unwrap();
|
||||
assert!(!cache_owned_maven_settings(root), "even unrelated Eclipse settings belong to the project");
|
||||
let prefs = root.join(".settings/org.eclipse.jdt.core.prefs");
|
||||
std::fs::write(&prefs, format!("{}=error\n", ACCESS_OPTIONS[0])).unwrap();
|
||||
assert!(!cache_owned_maven_settings(root));
|
||||
assert_eq!(std::fs::read_to_string(prefs).unwrap(), format!("{}=error\n", ACCESS_OPTIONS[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn access_warnings_do_not_hide_real_compile_errors() {
|
||||
let path = PathBuf::from("src/Main.java");
|
||||
let restriction =
|
||||
"Access restriction: The type 'BASE64Decoder' is not accessible (restriction on required library rt.jar)";
|
||||
let diagnostic = |severity, message: &str| lsp_types::Diagnostic {
|
||||
severity: Some(severity),
|
||||
message: message.into(),
|
||||
..Default::default()
|
||||
};
|
||||
let warnings: Vec<_> = [restriction, "The type 'BASE64Encoder' is discouraged"]
|
||||
.into_iter()
|
||||
.map(|message| (path.clone(), diagnostic(lsp_types::DiagnosticSeverity::WARNING, message)))
|
||||
.collect();
|
||||
assert_eq!(compile_errors(warnings.clone(), 10), (Vec::new(), 0));
|
||||
let mut diagnostics = warnings;
|
||||
diagnostics.push((
|
||||
path.clone(),
|
||||
diagnostic(lsp_types::DiagnosticSeverity::ERROR, "Missing cannot be resolved to a type"),
|
||||
));
|
||||
let (errors, count) = compile_errors(diagnostics, 10);
|
||||
assert_eq!(count, 1);
|
||||
assert_eq!(errors[0].message, "Missing cannot be resolved to a type");
|
||||
// A project's explicit error severity must still be respected.
|
||||
let (_, count) =
|
||||
compile_errors(vec![(path, diagnostic(lsp_types::DiagnosticSeverity::ERROR, restriction))], 10);
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classpaths() {
|
||||
let v = json!({
|
||||
|
||||
@@ -90,6 +90,8 @@ struct Attached {
|
||||
|
||||
struct DocState {
|
||||
path: PathBuf,
|
||||
/// `path` with links resolved, to recognise a document under another name.
|
||||
canonical: PathBuf,
|
||||
uri: lsp::Uri,
|
||||
language: LanguageId,
|
||||
version: i32,
|
||||
@@ -685,6 +687,11 @@ impl LspManager {
|
||||
if let Some(t) = self.docs.lock().values().find(|d| d.path == path).map(|d| d.text.clone()) {
|
||||
return Some(t);
|
||||
}
|
||||
if let Ok(key) = dunce::canonicalize(path)
|
||||
&& let Some(text) = self.docs.lock().values().find(|d| d.canonical == key).map(|d| d.text.clone())
|
||||
{
|
||||
return Some(text);
|
||||
}
|
||||
let key = path.to_str().filter(|s| is_virtual_uri(s))?;
|
||||
self.virtual_docs.lock().get(key).map(|d| Arc::new(DocText::new(&d.text)))
|
||||
}
|
||||
@@ -730,8 +737,10 @@ impl LspManager {
|
||||
text: &str,
|
||||
servers: Vec<Arc<Server>>,
|
||||
) {
|
||||
let canonical = dunce::canonicalize(&path).unwrap_or_else(|_| path.clone());
|
||||
let mut state = DocState {
|
||||
path,
|
||||
canonical,
|
||||
uri,
|
||||
language: language.clone(),
|
||||
version: version as i32,
|
||||
|
||||
@@ -167,23 +167,27 @@ impl LspManager {
|
||||
/// readable as text are left out and flagged on the guard.
|
||||
async fn open_transient(&self, t: &Target, files: Vec<PathBuf>, cancel: &CancellationToken) -> TransientFiles<'_> {
|
||||
// The server already sees the user's files, however large.
|
||||
let open: HashSet<PathBuf> = self.docs.lock().values().map(|d| d.path.clone()).collect();
|
||||
let open: HashSet<PathBuf> = self.docs.lock().values().map(|d| d.canonical.clone()).collect();
|
||||
let (read, skipped) = {
|
||||
let cancel = cancel.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut read = Vec::new();
|
||||
let mut skipped = false;
|
||||
for f in files.into_iter().filter(|f| !open.contains(f)) {
|
||||
for f in files {
|
||||
if cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
let canonical = dunce::canonicalize(&f).unwrap_or_else(|_| f.clone());
|
||||
if open.contains(&canonical) {
|
||||
continue;
|
||||
}
|
||||
// A file gone since the search no longer mentions anything.
|
||||
let Ok(meta) = std::fs::metadata(&f) else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
match (meta.len() <= MAX_CANDIDATE_BYTES).then(|| std::fs::read_to_string(&f)) {
|
||||
Some(Ok(text)) => read.push((f, text)),
|
||||
Some(Ok(text)) => read.push((f, canonical, text)),
|
||||
_ => skipped = true,
|
||||
}
|
||||
}
|
||||
@@ -198,8 +202,8 @@ impl LspManager {
|
||||
return guard;
|
||||
}
|
||||
let docs = self.docs.lock();
|
||||
for (path, text) in read {
|
||||
if docs.values().any(|d| d.path == path) {
|
||||
for (path, canonical, text) in read {
|
||||
if docs.values().any(|d| d.canonical == canonical) {
|
||||
continue;
|
||||
}
|
||||
let uri = crate::uri::path_to_uri(&path);
|
||||
|
||||
@@ -235,7 +235,8 @@ fn harness_configured(
|
||||
adjust: impl Fn(&mut LanguageServerConfig),
|
||||
) -> Harness {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().to_path_buf();
|
||||
// Edits and references report canonical paths.
|
||||
let root = dunce::canonicalize(dir.path()).unwrap();
|
||||
let lang = LanguageConfig {
|
||||
id: LanguageId::new(lang_id),
|
||||
name: "Mock".into(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Upload, download, delete and synchronise project files with a deployment
|
||||
//! server over SFTP.
|
||||
//! server through a shared filesystem interface.
|
||||
|
||||
use crate::mapping::{Excludes, Mappings, posix};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
@@ -10,6 +10,45 @@ use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Mapping eligibility uses a common synthetic server root: mapping and exclusion
|
||||
/// paths are both relative to that root, so checking never opens a connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeploymentCoverage {
|
||||
pub server: String,
|
||||
pub paths: Vec<String>,
|
||||
pub skipped: Vec<TransferError>,
|
||||
}
|
||||
|
||||
pub fn coverage(settings: &ide_core::settings::Settings, root: &Path, paths: &[PathBuf]) -> Vec<DeploymentCoverage> {
|
||||
settings
|
||||
.deployment
|
||||
.servers
|
||||
.iter()
|
||||
.map(|server| {
|
||||
let map = Mappings::new(server, root, "/");
|
||||
let exclude = Excludes::new(&settings.deployment, server, "/");
|
||||
let mut result = DeploymentCoverage { server: server.id.clone(), paths: Vec::new(), skipped: Vec::new() };
|
||||
for path in paths {
|
||||
let name = ide_core::ipc_path::to_ipc(path);
|
||||
match mapped_path(&map, &exclude, path) {
|
||||
Ok(_) => result.paths.push(name),
|
||||
Err(message) => result.skipped.push(TransferError { path: name, message: message.into() }),
|
||||
}
|
||||
}
|
||||
result
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mapped_path(map: &Mappings, exclude: &Excludes, local: &Path) -> Result<String, &'static str> {
|
||||
let remote = map.remote(local).ok_or("Not covered by this server's mappings")?;
|
||||
if exclude.local(local.strip_prefix(map.project_root()).unwrap_or(local)) || exclude.remote(&remote) {
|
||||
return Err("Excluded from deployment");
|
||||
}
|
||||
Ok(remote)
|
||||
}
|
||||
|
||||
/// Modification times closer than this count as equal (FAT, rounding by servers).
|
||||
const MTIME_SLACK: u32 = 2;
|
||||
|
||||
@@ -96,7 +135,7 @@ pub fn status(local: Option<FileStamp>, remote: Option<FileStamp>) -> SyncStatus
|
||||
}
|
||||
}
|
||||
|
||||
/// Operations on one server through one SFTP session.
|
||||
/// Operations on one server through its filesystem.
|
||||
pub struct Deployment<'a> {
|
||||
pub fs: &'a RemoteFs,
|
||||
pub map: &'a Mappings,
|
||||
@@ -171,7 +210,7 @@ impl Deployment<'_> {
|
||||
break;
|
||||
}
|
||||
let rel = self.rel(local);
|
||||
let Some(remote) = self.map.remote(local).filter(|r| !self.exclude.remote(r)) else {
|
||||
let Some(remote) = mapped_path(self.map, self.exclude, local).ok() else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -193,7 +232,7 @@ impl Deployment<'_> {
|
||||
let mut report = TransferReport::default();
|
||||
let mut jobs: Vec<(String, PathBuf)> = Vec::new();
|
||||
for p in paths {
|
||||
let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r)) else {
|
||||
let Some(remote) = mapped_path(self.map, self.exclude, p).ok() else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -239,7 +278,7 @@ impl Deployment<'_> {
|
||||
let mut report = TransferReport::default();
|
||||
for p in paths {
|
||||
// Never a whole mapping (the project folder went away, or a mapped folder did).
|
||||
let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r) && !self.map.is_root(r)) else {
|
||||
let Some(remote) = mapped_path(self.map, self.exclude, p).ok().filter(|r| !self.map.is_root(r)) else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -257,12 +296,12 @@ impl Deployment<'_> {
|
||||
|
||||
/// Sync with Deployed: local and remote files under `local` side by side.
|
||||
pub async fn compare(&self, local: &Path) -> IdeResult<Vec<SyncEntry>> {
|
||||
let remote_root =
|
||||
self.map.remote(local).ok_or_else(|| IdeError::invalid(format!("{} is not mapped", local.display())))?;
|
||||
let remote_root = mapped_path(self.map, self.exclude, local)
|
||||
.map_err(|reason| IdeError::invalid(format!("{}: {reason}", local.display())))?;
|
||||
let mut by_rel: BTreeMap<String, SyncEntry> = BTreeMap::new();
|
||||
let mut ignored = TransferReport::default();
|
||||
for f in self.local_files(&[local.to_path_buf()], &mut ignored) {
|
||||
let Some(remote_path) = self.map.remote(&f) else { continue };
|
||||
let Ok(remote_path) = mapped_path(self.map, self.exclude, &f) else { continue };
|
||||
let size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0);
|
||||
let stamp = FileStamp { size, mtime: mtime_of(&f) };
|
||||
by_rel.insert(
|
||||
@@ -319,9 +358,7 @@ impl Deployment<'_> {
|
||||
break;
|
||||
}
|
||||
let local = step.path.split('/').filter(|s| !s.is_empty()).fold(root.clone(), |p, s| p.join(s));
|
||||
let Some(remote) =
|
||||
self.map.remote(&local).filter(|r| !self.exclude.remote(r) && !self.excluded_local(&local))
|
||||
else {
|
||||
let Some(remote) = mapped_path(self.map, self.exclude, &local).ok() else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,9 @@ pub mod mapping;
|
||||
pub mod service;
|
||||
pub mod target;
|
||||
|
||||
pub use deploy::{Deployment, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport};
|
||||
pub use deploy::{
|
||||
Deployment, DeploymentCoverage, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport, coverage,
|
||||
};
|
||||
pub use mapping::{Excludes, Mappings};
|
||||
pub use service::{DeployedPath, RemoteService};
|
||||
pub use target::{RemoteRuns, RemoteStart};
|
||||
@@ -1,14 +1,14 @@
|
||||
//! The deployment and run-target operations of one app, over the shared SSH
|
||||
//! connections: what the IPC commands and the save hook call.
|
||||
//! Deployment over SFTP or local folders, and SSH run targets.
|
||||
//! Shared by IPC commands and project save/delete hooks.
|
||||
|
||||
use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport};
|
||||
use crate::mapping::{Excludes, Mappings};
|
||||
use crate::target::{RemoteRuns, RemoteStart, SyncFn};
|
||||
use ide_core::remote_settings::{AutoUpload, DeploymentServer, RunTarget};
|
||||
use ide_core::remote_settings::{AutoUpload, DeploymentServer, DeploymentType, RunTarget};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink};
|
||||
use ide_run::{OutputChunk, RunConfig, RunInfo};
|
||||
use ide_ssh::{RemoteEntry, RemoteFs, SshManager};
|
||||
use ide_ssh::{FsConnection, RemoteEntry, RemoteFs, SshManager};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -22,7 +22,7 @@ pub type Progress<'a> = &'a mut (dyn FnMut(&str, usize, usize) + Send);
|
||||
/// A remote file opened for editing (a local copy uploaded back on save).
|
||||
#[derive(Debug, Clone)]
|
||||
struct RemoteEdit {
|
||||
ssh_config: String,
|
||||
connection: FsConnection,
|
||||
remote: String,
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ pub struct DeployedPath {
|
||||
|
||||
pub struct RemoteService {
|
||||
ssh: Arc<SshManager>,
|
||||
/// Local copies of remote files: `<cache>/<ssh config>/<remote path>`.
|
||||
/// Local copies of remote files, separated by filesystem connection.
|
||||
cache: PathBuf,
|
||||
edits: Mutex<HashMap<PathBuf, RemoteEdit>>,
|
||||
pub runs: RemoteRuns,
|
||||
@@ -71,12 +71,24 @@ impl RemoteService {
|
||||
Self { ssh, cache, edits: Mutex::default(), runs: RemoteRuns::new() }
|
||||
}
|
||||
|
||||
async fn fs(&self, ssh_config: &str) -> IdeResult<RemoteFs> {
|
||||
if ssh_config.is_empty() {
|
||||
return Err(IdeError::invalid("the deployment server has no SSH configuration"));
|
||||
async fn fs(&self, connection: &FsConnection) -> IdeResult<RemoteFs> {
|
||||
match connection {
|
||||
FsConnection::Local => Ok(RemoteFs::Local),
|
||||
FsConnection::Sftp { ssh } => {
|
||||
if ssh.is_empty() {
|
||||
return Err(IdeError::invalid("the deployment server has no SSH configuration"));
|
||||
}
|
||||
let session = self.ssh.session(ssh).await?;
|
||||
Ok(RemoteFs::new(session.sftp().await?))
|
||||
}
|
||||
}
|
||||
let session = self.ssh.session(ssh_config).await?;
|
||||
Ok(RemoteFs::new(session.sftp().await?))
|
||||
}
|
||||
|
||||
pub async fn test_connection(&self, connection: &FsConnection, path: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let result = fs.test(path).await;
|
||||
fs.close().await;
|
||||
result
|
||||
}
|
||||
|
||||
/// Runs `f` with a [`Deployment`] on `server`.
|
||||
@@ -88,7 +100,7 @@ impl RemoteService {
|
||||
cancel: CancellationToken,
|
||||
f: impl AsyncFnOnce(&Deployment<'_>) -> T,
|
||||
) -> IdeResult<T> {
|
||||
let fs = self.fs(&server.ssh_config).await?;
|
||||
let fs = self.fs(&connection(server)).await?;
|
||||
let remote_root = match fs.resolve(&server.root_path).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -198,54 +210,57 @@ impl RemoteService {
|
||||
/// The server root of `server_id`, resolved on the host.
|
||||
pub async fn server_root(&self, settings: &Settings, server_id: &str) -> IdeResult<String> {
|
||||
let server = server(settings, Some(server_id))?;
|
||||
let fs = self.fs(&server.ssh_config).await?;
|
||||
let fs = self.fs(&connection(&server)).await?;
|
||||
let r = fs.resolve(&server.root_path).await;
|
||||
fs.close().await;
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn list(&self, ssh_config: &str, path: &str) -> IdeResult<Vec<RemoteEntry>> {
|
||||
let fs = self.fs(ssh_config).await?;
|
||||
pub async fn list(&self, connection: &FsConnection, path: &str) -> IdeResult<Vec<RemoteEntry>> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let r = async { fs.list(&fs.resolve(path).await?).await }.await;
|
||||
fs.close().await;
|
||||
r
|
||||
}
|
||||
|
||||
/// Downloads a remote file for editing; saving the copy uploads it back.
|
||||
pub async fn open_remote(&self, ssh_config: &str, remote: &str) -> IdeResult<PathBuf> {
|
||||
let rel = remote.split('/').filter(|s| !s.is_empty() && *s != "..").fold(PathBuf::new(), |p, s| p.join(s));
|
||||
let local = self.cache.join(sanitize(ssh_config)).join(rel);
|
||||
let fs = self.fs(ssh_config).await?;
|
||||
pub async fn open_remote(&self, connection: &FsConnection, remote: &str) -> IdeResult<PathBuf> {
|
||||
let rel = remote
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty() && *s != ".." && *s != ".")
|
||||
.fold(PathBuf::new(), |p, s| p.join(s.replace('%', "%25").replace(':', "%3A").replace('\\', "%5C")));
|
||||
let local = self.cache.join(sanitize(&cache_key(connection))).join(rel);
|
||||
let fs = self.fs(connection).await?;
|
||||
let r = fs.download(remote, &local).await;
|
||||
fs.close().await;
|
||||
r?;
|
||||
self.edits
|
||||
.lock()
|
||||
.insert(local.clone(), RemoteEdit { ssh_config: ssh_config.to_owned(), remote: remote.to_owned() });
|
||||
.insert(local.clone(), RemoteEdit { connection: connection.clone(), remote: remote.to_owned() });
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
/// The remote file a local copy stands for.
|
||||
pub fn remote_of(&self, local: &Path) -> Option<(String, String)> {
|
||||
self.edits.lock().get(local).map(|e| (e.ssh_config.clone(), e.remote.clone()))
|
||||
pub fn remote_of(&self, local: &Path) -> Option<(FsConnection, String)> {
|
||||
self.edits.lock().get(local).map(|e| (e.connection.clone(), e.remote.clone()))
|
||||
}
|
||||
|
||||
pub async fn remote_delete(&self, ssh_config: &str, remote: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(ssh_config).await?;
|
||||
pub async fn remote_delete(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let r = fs.remove(remote).await;
|
||||
fs.close().await;
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn remote_mkdir(&self, ssh_config: &str, remote: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(ssh_config).await?;
|
||||
pub async fn remote_mkdir(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let r = fs.mkdir_all(remote).await;
|
||||
fs.close().await;
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn remote_rename(&self, ssh_config: &str, from: &str, to: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(ssh_config).await?;
|
||||
pub async fn remote_rename(&self, connection: &FsConnection, from: &str, to: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let r = fs.rename(from, to).await;
|
||||
fs.close().await;
|
||||
r
|
||||
@@ -366,3 +381,18 @@ impl RemoteService {
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect()
|
||||
}
|
||||
|
||||
/// A server's transport, also used by the browser and remote editor save hook.
|
||||
pub fn connection(server: &DeploymentServer) -> FsConnection {
|
||||
match server.kind {
|
||||
DeploymentType::Local => FsConnection::Local,
|
||||
DeploymentType::Sftp => FsConnection::Sftp { ssh: server.ssh_config.clone() },
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_key(connection: &FsConnection) -> String {
|
||||
match connection {
|
||||
FsConnection::Local => "local".to_owned(),
|
||||
FsConnection::Sftp { ssh } => format!("sftp-{ssh}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Local deployment uses the service entry points used by SFTP and save hooks.
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::remote_settings::{AutoUpload, DeploymentMapping, DeploymentServer, DeploymentType};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_remote::{RemoteService, SyncAction, SyncStatus, SyncStep};
|
||||
use ide_ssh::{FsConnection, SshFiles, SshManager};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn service(root: &Path) -> RemoteService {
|
||||
RemoteService::new(
|
||||
Arc::new(SshManager::with_files(
|
||||
root.join("secrets"),
|
||||
SshFiles { known_hosts: root.join("known_hosts"), config: None },
|
||||
)),
|
||||
root.join("cache"),
|
||||
)
|
||||
}
|
||||
|
||||
fn settings(target: &Path) -> Settings {
|
||||
let mut s = Settings::default();
|
||||
s.deployment.servers.push(DeploymentServer {
|
||||
id: "local".into(),
|
||||
kind: DeploymentType::Local,
|
||||
root_path: target.to_string_lossy().into_owned(),
|
||||
mappings: vec![DeploymentMapping { deployment: "app".into(), ..Default::default() }],
|
||||
excluded_paths: vec!["app/private".into()],
|
||||
..Default::default()
|
||||
});
|
||||
s.deployment.default_server = "local".into();
|
||||
s.deployment.auto_upload = AutoUpload::Always;
|
||||
s.deployment.delete_remote = true;
|
||||
s
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_deployment_and_browser_share_all_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let target = tmp.path().join("mounted");
|
||||
std::fs::create_dir_all(root.join("src/nested")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git")).unwrap();
|
||||
std::fs::create_dir_all(root.join("private")).unwrap();
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
std::fs::write(root.join("src/nested/a.txt"), "initial").unwrap();
|
||||
std::fs::write(root.join(".git/config"), "excluded").unwrap();
|
||||
std::fs::write(root.join("private/key"), "excluded remote path").unwrap();
|
||||
let service = service(tmp.path());
|
||||
let settings = settings(&target);
|
||||
let cancel = CancellationToken::new();
|
||||
service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await.unwrap();
|
||||
let report = service
|
||||
.upload(&settings, &root, None, std::slice::from_ref(&root), cancel.clone(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.files, 1, "{report:?}");
|
||||
assert!(report.errors.is_empty(), "{report:?}");
|
||||
assert!(!target.join("app/.git").exists());
|
||||
assert!(!target.join("app/private").exists());
|
||||
assert_eq!(
|
||||
ide_ssh::sftp::mtime_of(&root.join("src/nested/a.txt")),
|
||||
ide_ssh::sftp::mtime_of(&target.join("app/src/nested/a.txt"))
|
||||
);
|
||||
let compared = service.compare(&settings, &root, None, &root, cancel.clone()).await.unwrap();
|
||||
assert_eq!(compared.iter().find(|e| e.path == "src/nested/a.txt").unwrap().status, SyncStatus::Same);
|
||||
std::fs::write(root.join("src/nested/a.txt"), "saved").unwrap();
|
||||
let saved = service.on_saved(&settings, &root, &root.join("src/nested/a.txt")).await.unwrap().unwrap();
|
||||
assert!(saved.errors.is_empty());
|
||||
let remote_file = target.join("app/src/nested/a.txt");
|
||||
assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "saved");
|
||||
std::fs::write(&remote_file, "downloaded").unwrap();
|
||||
let report =
|
||||
service.download(&settings, &root, None, &[root.join("src")], cancel.clone(), &mut |_, _, _| {}).await.unwrap();
|
||||
assert!(report.errors.is_empty());
|
||||
assert_eq!(std::fs::read_to_string(root.join("src/nested/a.txt")).unwrap(), "downloaded");
|
||||
let connection = FsConnection::Local;
|
||||
let entries = service.list(&connection, target.join("app/src/nested").to_str().unwrap()).await.unwrap();
|
||||
assert_eq!(entries[0].name, "a.txt");
|
||||
let cached = service.open_remote(&connection, remote_file.to_str().unwrap()).await.unwrap();
|
||||
std::fs::write(&cached, "remote edit").unwrap();
|
||||
assert!(service.on_saved(&settings, &root, &cached).await.unwrap().unwrap().errors.is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "remote edit");
|
||||
service.remote_mkdir(&connection, target.join("browser/new/nested").to_str().unwrap()).await.unwrap();
|
||||
service
|
||||
.remote_rename(&connection, target.join("browser").to_str().unwrap(), target.join("renamed").to_str().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
service.remote_delete(&connection, target.join("renamed").to_str().unwrap()).await.unwrap();
|
||||
assert!(!target.join("renamed").exists());
|
||||
let report = service
|
||||
.sync(
|
||||
&settings,
|
||||
&root,
|
||||
None,
|
||||
&[SyncStep { path: "src/nested/a.txt".into(), action: SyncAction::Download }],
|
||||
cancel,
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.files, 1);
|
||||
std::fs::remove_file(root.join("src/nested/a.txt")).unwrap();
|
||||
assert!(
|
||||
service
|
||||
.on_deleted(&settings, &root, &[root.join("src/nested/a.txt")])
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.errors
|
||||
.is_empty()
|
||||
);
|
||||
assert!(!remote_file.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_upload_download_and_aliases_never_destroy_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let service = service(root);
|
||||
let mut settings = settings(root);
|
||||
settings.deployment.servers[0].mappings[0].deployment.clear();
|
||||
let file = root.join("file");
|
||||
std::fs::write(&file, "keep me").unwrap();
|
||||
for download in [false, true] {
|
||||
let report = if download {
|
||||
service
|
||||
.download(
|
||||
&settings,
|
||||
root,
|
||||
None,
|
||||
std::slice::from_ref(&file),
|
||||
CancellationToken::new(),
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
service
|
||||
.upload(&settings, root, None, std::slice::from_ref(&file), CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
}
|
||||
.unwrap();
|
||||
assert!(report.errors[0].message.contains("same file"));
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let alias = root.join("alias");
|
||||
std::fs::hard_link(&file, &alias).unwrap();
|
||||
assert!(
|
||||
ide_ssh::RemoteFs::Local
|
||||
.upload(&file, alias.to_str().unwrap(), true)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("same file")
|
||||
);
|
||||
std::fs::remove_file(&alias).unwrap();
|
||||
std::os::unix::fs::symlink(&file, &alias).unwrap();
|
||||
assert!(
|
||||
ide_ssh::RemoteFs::Local
|
||||
.upload(&file, alias.to_str().unwrap(), true)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("same file")
|
||||
);
|
||||
ide_ssh::RemoteFs::Local.remove(alias.to_str().unwrap()).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn missing_non_directory_and_unwritable_targets_are_clear() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let service = service(tmp.path());
|
||||
let target = tmp.path().join("target");
|
||||
assert!(
|
||||
service
|
||||
.test_connection(&FsConnection::Local, target.to_str().unwrap())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("does not exist")
|
||||
);
|
||||
std::fs::write(&target, "file").unwrap();
|
||||
assert!(
|
||||
service
|
||||
.test_connection(&FsConnection::Local, target.to_str().unwrap())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("Not a folder")
|
||||
);
|
||||
std::fs::remove_file(&target).unwrap();
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
let result = service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await;
|
||||
let file = tmp.path().join("source");
|
||||
std::fs::write(&file, "data").unwrap();
|
||||
let mut settings = settings(&target);
|
||||
settings.deployment.servers[0].mappings[0].deployment.clear();
|
||||
let report = service
|
||||
.upload(&settings, tmp.path(), None, &[file], CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(result.unwrap_err().message.contains("not writable"));
|
||||
assert_eq!(report.errors.len(), 1, "{report:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coverage_matches_transfers_without_connecting() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let target = tmp.path().join("target");
|
||||
for dir in ["src", "src-other", "assets", "src/private", "src/node_modules"] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
}
|
||||
let names = ["src/a.txt", "assets/b.txt", "src-other/c.txt", "src/private/key", "src/node_modules/pkg"];
|
||||
let paths: Vec<_> = names.iter().map(|name| root.join(name)).collect();
|
||||
for path in &paths {
|
||||
std::fs::write(path, "content").unwrap();
|
||||
}
|
||||
let mut settings = settings(&target);
|
||||
settings.deployment.exclude = vec!["node_modules".into()];
|
||||
settings.deployment.servers[0].mappings[0].local = "src".into();
|
||||
settings.deployment.servers[0].excluded_paths = vec!["app/private".into()];
|
||||
let mut assets = settings.deployment.servers[0].clone();
|
||||
assets.id = "assets".into();
|
||||
assets.mappings[0].local = "assets".into();
|
||||
let mut offline = assets.clone();
|
||||
offline.id = "offline".into();
|
||||
offline.kind = DeploymentType::Sftp;
|
||||
offline.ssh_config = "does-not-exist".into();
|
||||
settings.deployment.servers.extend([assets, offline]);
|
||||
let coverage = ide_remote::coverage(&settings, &root, &paths);
|
||||
assert_eq!(coverage[0].paths, vec![ide_core::ipc_path::to_ipc(&paths[0])]);
|
||||
assert_eq!(coverage[1].paths, vec![ide_core::ipc_path::to_ipc(&paths[1])]);
|
||||
assert_eq!(coverage[2].paths, coverage[1].paths);
|
||||
assert_eq!(coverage[0].skipped.len(), 4);
|
||||
assert!(coverage[0].skipped[0].message.contains("mappings"));
|
||||
assert!(coverage[0].skipped[2].message.contains("Excluded"));
|
||||
let service = service(tmp.path());
|
||||
let report = service
|
||||
.upload(&settings, &root, Some("local"), &paths, CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((report.files, report.skipped), (1, 4));
|
||||
let entries =
|
||||
service.compare(&settings, &root, Some("local"), &root.join("src"), CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
let report = service
|
||||
.download(&settings, &root, Some("local"), &paths, CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((report.files, report.skipped), (1, 4));
|
||||
let report = service.delete_remote(&settings, &root, Some("local"), &paths).await.unwrap();
|
||||
assert_eq!((report.files, report.skipped), (1, 4));
|
||||
settings.deployment.servers[0].mappings.clear();
|
||||
assert_eq!(ide_remote::coverage(&settings, &root, &[root.join("assets/b.txt")])[0].paths.len(), 1);
|
||||
assert!(ide_remote::coverage(&settings, &root, &[root.join("../outside")])[0].paths.is_empty());
|
||||
}
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
dunce.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -24,4 +25,5 @@ quick-xml = "0.42"
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
ide-build = { path = "../ide-build" }
|
||||
tempfile.workspace = true
|
||||
@@ -340,7 +340,11 @@ fn write_array(path: &Path, key: &str, configs: &[&RunConfig], suppressed: Optio
|
||||
|
||||
/// Keeps the workspace-local file out of version control (`.openidea/.gitignore`).
|
||||
fn ignore_local(root: &Path) -> IdeResult<()> {
|
||||
let entry = format!("/{LOCAL_FILE}");
|
||||
ignore_private(root, &format!("/{LOCAL_FILE}"))
|
||||
}
|
||||
|
||||
/// Adds `entry` to `.openidea/.gitignore` unless it is already listed.
|
||||
pub(crate) fn ignore_private(root: &Path, entry: &str) -> IdeResult<()> {
|
||||
let file = project_dir::path(root, ".gitignore");
|
||||
let current = fs::read_to_string(&file).unwrap_or_default();
|
||||
if current.lines().any(|l| l.trim() == entry) {
|
||||
@@ -352,7 +356,7 @@ fn ignore_local(root: &Path) -> IdeResult<()> {
|
||||
} else if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str(&entry);
|
||||
text.push_str(entry);
|
||||
text.push('\n');
|
||||
if let Some(dir) = file.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//!
|
||||
//! Detection never fails: unreadable or malformed files simply contribute nothing.
|
||||
|
||||
pub(crate) mod cmake;
|
||||
|
||||
use crate::RunConfig;
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
@@ -22,6 +24,7 @@ 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);
|
||||
detect_make(root, &mut out);
|
||||
detect_just(root, &mut out);
|
||||
detect_godot(root, &mut out);
|
||||
@@ -680,6 +683,19 @@ fn is_go_main_package(dir: &Path) -> bool {
|
||||
}
|
||||
|
||||
fn detect_go(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
if let Some(work) = read(&root.join("go.work")) {
|
||||
let mut seen = HashSet::new();
|
||||
for member in crate::go::work_modules(&work) {
|
||||
let dir = root.join(member);
|
||||
let dir = dir.canonicalize().unwrap_or(dir);
|
||||
if dir.join("go.mod").is_file() && seen.insert(dir.clone()) {
|
||||
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
|
||||
let rel = dir.strip_prefix(&canonical_root).unwrap_or(&dir).to_path_buf();
|
||||
detect_go_module(&dir, (!rel.as_os_str().is_empty()).then_some(rel), out);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if root.join("go.mod").is_file() {
|
||||
detect_go_module(root, None, out);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! CMake targets come from the File API codemodel, including evaluated subdirectories.
|
||||
|
||||
use super::{ConfigExt, config};
|
||||
use crate::{BeforeLaunchTask, RunConfig};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn json(path: &Path) -> Option<Value> {
|
||||
serde_json::from_slice(&fs::read(path).ok()?).ok()
|
||||
}
|
||||
|
||||
fn reply_file(reply: &Path, name: &str) -> Option<Value> {
|
||||
// File API references are file names, never paths outside the reply directory.
|
||||
(Path::new(name).components().count() == 1).then(|| json(&reply.join(name))).flatten()
|
||||
}
|
||||
|
||||
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>) {
|
||||
if !root.join("CMakeLists.txt").is_file() {
|
||||
return;
|
||||
}
|
||||
let mut builds = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(root) {
|
||||
builds.extend(entries.flatten().filter_map(|e| {
|
||||
let p = e.path();
|
||||
(p.is_dir() && p.join("CMakeCache.txt").is_file()).then_some(p)
|
||||
}));
|
||||
}
|
||||
if builds.is_empty() {
|
||||
builds.push(root.join("build"));
|
||||
}
|
||||
builds.sort();
|
||||
builds.dedup();
|
||||
for build in builds {
|
||||
let dir = build.strip_prefix(root).unwrap_or(&build).to_string_lossy().replace('\\', "/");
|
||||
let configuration = fs::read_to_string(build.join("CMakeCache.txt"))
|
||||
.ok()
|
||||
.and_then(|cache| {
|
||||
let values = cache.lines().find_map(|line| line.strip_prefix("CMAKE_CONFIGURATION_TYPES:STRING="))?;
|
||||
Some(if values.split(';').any(|c| c == "Debug") {
|
||||
"Debug".to_owned()
|
||||
} else {
|
||||
values.split(';').find(|s| !s.is_empty())?.to_owned()
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "Debug".into());
|
||||
let prefix = format!("cmake:{dir}");
|
||||
let configure_id = format!("{prefix}:configure");
|
||||
let build_id = format!("{prefix}:build");
|
||||
let mut args = vec!["-S", ".", "-B", &dir, "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"];
|
||||
if !build.join("CMakeCache.txt").is_file() {
|
||||
args.push("-DCMAKE_BUILD_TYPE=Debug");
|
||||
}
|
||||
let mut configure = config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "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", &configuration],
|
||||
);
|
||||
// `cmake --build` regenerates by itself once a build directory is configured.
|
||||
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", &configuration, "--output-on-failure"],
|
||||
);
|
||||
before(&mut test, &build_id);
|
||||
out.push(test);
|
||||
targets(root, &build, &dir, &prefix, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, 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
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy();
|
||||
name.starts_with("index-") && name.ends_with(".json")
|
||||
})
|
||||
.max()
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let Some(index) = json(&index_path) else { return };
|
||||
let Some(reference) = index["objects"]
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().find(|o| o["kind"] == "codemodel" && o["version"]["major"] == 2))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(model) = reference["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { return };
|
||||
let Some(configurations) = model["configurations"].as_array() else { return };
|
||||
let source_root = model["paths"]["source"].as_str().map(PathBuf::from).unwrap_or_else(|| root.into());
|
||||
if source_root.canonicalize().ok() != root.canonicalize().ok() {
|
||||
return;
|
||||
}
|
||||
let source_root = dunce::canonicalize(&source_root).unwrap_or(source_root);
|
||||
for configuration in configurations {
|
||||
let name = configuration["name"].as_str().unwrap_or("");
|
||||
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 };
|
||||
if target["type"] != "EXECUTABLE" {
|
||||
continue;
|
||||
}
|
||||
let (Some(target_name), Some(artifact)) =
|
||||
(target["name"].as_str(), target["artifacts"][0]["path"].as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let id = format!("{prefix}:{name}:{target_name}");
|
||||
let target_build_id = format!("{id}:build");
|
||||
let mut args = vec!["--build", dir, "--target", target_name];
|
||||
if !name.is_empty() {
|
||||
args.extend(["--config", name]);
|
||||
}
|
||||
let build_config =
|
||||
config(&target_build_id, format!("Build {target_name} ({dir} {name})"), "cmake", "cmake", &args);
|
||||
out.push(build_config);
|
||||
let binary = build.join(artifact);
|
||||
let mut run =
|
||||
config(&id, format!("{target_name} ({dir} {name})"), "native", &binary.to_string_lossy(), &[])
|
||||
.debug("lldb-dap");
|
||||
before(&mut run, &target_build_id);
|
||||
let sources: Vec<String> = target["sources"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| {
|
||||
let p = source_root.join(s["path"].as_str()?);
|
||||
Some(p.to_string_lossy().replace('\\', "/"))
|
||||
})
|
||||
.collect();
|
||||
run.extras.params.insert("cmakeSources".into(), sources.join("\n"));
|
||||
if let Some(recorded) = model["paths"]["source"].as_str() {
|
||||
run.extras.params.insert("cmakeSourceRoot".into(), recorded.into());
|
||||
run.extras
|
||||
.params
|
||||
.insert("cmakeCanonicalSourceRoot".into(), source_root.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
out.push(run);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request the codemodel only when the user runs a configure configuration.
|
||||
pub(crate) fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> {
|
||||
if config.kind == "cmake"
|
||||
&& 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");
|
||||
fs::create_dir_all(&query)?;
|
||||
fs::write(query.join("codemodel-v2"), "")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Typed Go configurations use the process service for both build and execution.
|
||||
use crate::RunConfig;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct Plan {
|
||||
pub build: RunConfig,
|
||||
pub run: Option<RunConfig>,
|
||||
}
|
||||
|
||||
pub fn plan(root: &Path, config: &RunConfig) -> IdeResult<Option<Plan>> {
|
||||
if config.kind != "go" || !config.extras.params.contains_key("goKind") {
|
||||
return Ok(None);
|
||||
}
|
||||
let p = &config.extras.params;
|
||||
let get = |key: &str| p.get(key).map(String::as_str).unwrap_or("");
|
||||
let module = root.join(if get("module").is_empty() { "." } else { get("module") });
|
||||
let mut build = config.clone();
|
||||
build.cwd = Some(module.clone().into());
|
||||
let test = get("goKind") == "test";
|
||||
if get("scope") == "directory" {
|
||||
let target = module.join(get("target"));
|
||||
build.cwd = Some(target.into());
|
||||
if let Some(arg) = build.args.iter_mut().skip(1).find(|a| a.as_str() == get("target")) {
|
||||
*arg = ".".into();
|
||||
}
|
||||
}
|
||||
if test {
|
||||
return Ok(Some(Plan { build, run: None }));
|
||||
}
|
||||
let output = if get("outputDir").is_empty() {
|
||||
crate::config::ignore_private(root, "/bin/")?;
|
||||
ide_core::project_dir::path(root, "bin")
|
||||
} else {
|
||||
root.join(get("outputDir"))
|
||||
};
|
||||
std::fs::create_dir_all(&output)?;
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
config.id.hash(&mut hash);
|
||||
let mut binary = output.join(format!("go-app-{:x}{}", hash.finish(), std::env::consts::EXE_SUFFIX));
|
||||
// An explicit Go -o selects the executable just as it does on the command line.
|
||||
let mut i = 1;
|
||||
while i < build.args.len() {
|
||||
if build.args[i] == "-o" {
|
||||
if let Some(path) = build.args.get(i + 1) {
|
||||
binary = module.join(path);
|
||||
}
|
||||
build.args.drain(i..(i + 2).min(build.args.len()));
|
||||
} else if let Some(path) = build.args[i].strip_prefix("-o=") {
|
||||
binary = module.join(path);
|
||||
build.args.remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if binary.is_dir() || binary.to_string_lossy().ends_with(['/', '\\']) {
|
||||
let target = Path::new(get("target").split('|').next().unwrap_or("."));
|
||||
let name = target.file_stem().or_else(|| module.file_name()).unwrap_or_default().to_string_lossy();
|
||||
binary = binary.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
|
||||
}
|
||||
if let Some(dir) = binary.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
build.args.splice(1..1, ["-o".into(), binary.to_string_lossy().into_owned()]);
|
||||
let run = if get("runAfterBuild") == "true" {
|
||||
let mut run = config.clone();
|
||||
run.command = binary.to_string_lossy().into_owned();
|
||||
// Program args are stored separately as an argv vector by the form.
|
||||
run.args = p
|
||||
.get("programArgv")
|
||||
.map(|s| serde_json::from_str(s))
|
||||
.transpose()
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
.unwrap_or_default();
|
||||
run.extras.params.clear();
|
||||
Some(run)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Some(Plan { build, run }))
|
||||
}
|
||||
|
||||
/// Uses Go's own build-file selection, including platform suffixes, cgo and build tags.
|
||||
/// `-find` avoids dependency traversal and package downloads for this local directory.
|
||||
pub fn package_sources(mut command: Command, dir: &Path, flags: &[String]) -> IdeResult<Vec<PathBuf>> {
|
||||
command.args(["list", "-find", "-e", "-json"]);
|
||||
let mut it = flags.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
let name = arg.split('=').next().unwrap_or(arg);
|
||||
if ["-tags", "-compiler", "-mod", "-modfile", "-overlay"].contains(&name) {
|
||||
command.arg(arg);
|
||||
if name == arg
|
||||
&& let Some(value) = it.next()
|
||||
{
|
||||
command.arg(value);
|
||||
}
|
||||
} else if ["-race", "-msan", "-asan"].contains(&name) {
|
||||
command.arg(arg);
|
||||
}
|
||||
}
|
||||
command.arg(".").current_dir(dir).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = command.spawn()?;
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
let reader = std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut text = String::new();
|
||||
if let Some(mut stdout) = stdout {
|
||||
let _ = stdout.read_to_string(&mut text);
|
||||
}
|
||||
text
|
||||
});
|
||||
let errors = std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut text = String::new();
|
||||
if let Some(mut stderr) = stderr {
|
||||
let _ = stderr.read_to_string(&mut text);
|
||||
}
|
||||
text
|
||||
});
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let status = loop {
|
||||
match child.try_wait()? {
|
||||
Some(status) => break status,
|
||||
None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)),
|
||||
None => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(IdeError::unavailable("Go package discovery timed out"));
|
||||
}
|
||||
}
|
||||
};
|
||||
let text = reader.join().unwrap_or_default();
|
||||
let errors = errors.join().unwrap_or_default();
|
||||
if !status.success() {
|
||||
return Err(IdeError::invalid(format!("Go package discovery failed: {errors}")));
|
||||
}
|
||||
let package: serde_json::Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
Ok(["GoFiles", "CgoFiles"]
|
||||
.iter()
|
||||
.flat_map(|key| package[key].as_array().into_iter().flatten())
|
||||
.filter_map(|p| p.as_str())
|
||||
.map(|p| dir.join(p))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn prepare_test_file(root: &Path, config: &mut RunConfig) -> IdeResult<()> {
|
||||
let p = &config.extras.params;
|
||||
if config.kind != "go" || p.get("goKind").is_none_or(|s| s != "test") || p.get("scope").is_none_or(|s| s != "file")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let target = p.get("target").ok_or_else(|| IdeError::invalid("Go test file target is missing"))?;
|
||||
let file = root.join(p.get("module").map(String::as_str).unwrap_or(".")).join(target);
|
||||
let dir = file.parent().ok_or_else(|| IdeError::invalid("Go test file has no directory"))?;
|
||||
let mut command = Command::new(&config.command);
|
||||
crate::process::configure_environment(&mut command, root, config)?;
|
||||
let mut files = package_sources(command, dir, &config.args)?;
|
||||
files.push(file);
|
||||
let at = config
|
||||
.args
|
||||
.iter()
|
||||
.position(|a| a == target)
|
||||
.ok_or_else(|| IdeError::invalid("Go test file target is missing"))?;
|
||||
config.args.splice(at..=at, files.iter().map(|p| p.to_string_lossy().into_owned()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax.
|
||||
pub fn work_modules(text: &str) -> Vec<PathBuf> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\n' | '(' | ')' => tokens.push(ch.to_string()),
|
||||
ch if ch.is_whitespace() => {}
|
||||
'/' if chars.peek() == Some(&'/') => {
|
||||
for c in chars.by_ref() {
|
||||
if c == '\n' {
|
||||
tokens.push("\n".into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' | '`' => {
|
||||
let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() };
|
||||
tokens.push(token);
|
||||
}
|
||||
_ => {
|
||||
let mut token = ch.to_string();
|
||||
while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') {
|
||||
token.push(chars.next().unwrap());
|
||||
}
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut start = true;
|
||||
let mut tokens = tokens.into_iter();
|
||||
while let Some(token) = tokens.next() {
|
||||
if token == "\n" {
|
||||
start = true;
|
||||
continue;
|
||||
}
|
||||
if start
|
||||
&& token == "use"
|
||||
&& let Some(path) = tokens.next()
|
||||
{
|
||||
if path == "(" {
|
||||
let mut paths = Vec::new();
|
||||
let mut closed = false;
|
||||
for path in tokens.by_ref() {
|
||||
if path == ")" {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if path != "\n" && !path.is_empty() {
|
||||
paths.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
if closed {
|
||||
out.extend(paths);
|
||||
}
|
||||
} else if path != "\n" && !path.is_empty() {
|
||||
out.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
start = false;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
out.retain(|path| seen.insert(path.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
fn quoted_work_path(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> Option<String> {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == quote {
|
||||
return String::from_utf8(bytes).ok();
|
||||
}
|
||||
if ch == '\\' && quote == '"' {
|
||||
let escaped = chars.next()?;
|
||||
let byte = match escaped {
|
||||
'a' => 7,
|
||||
'b' => 8,
|
||||
'f' => 12,
|
||||
'n' => 10,
|
||||
'r' => 13,
|
||||
't' => 9,
|
||||
'v' => 11,
|
||||
'\\' => b'\\',
|
||||
'"' => b'"',
|
||||
'x' => {
|
||||
let digits: String = chars.by_ref().take(2).collect();
|
||||
if digits.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(&digits, 16).ok()?
|
||||
}
|
||||
'u' | 'U' => {
|
||||
let width = if escaped == 'u' { 4 } else { 8 };
|
||||
let digits: String = chars.by_ref().take(width).collect();
|
||||
if digits.len() != width {
|
||||
return None;
|
||||
}
|
||||
let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?;
|
||||
bytes.extend_from_slice(ch.to_string().as_bytes());
|
||||
continue;
|
||||
}
|
||||
'0'..='7' => {
|
||||
let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::<String>());
|
||||
if digits.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(&digits, 8).ok()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
bytes.push(byte);
|
||||
} else {
|
||||
if ch == '\n' && quote == '"' {
|
||||
return None;
|
||||
}
|
||||
bytes.extend_from_slice(ch.to_string().as_bytes());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod config;
|
||||
mod detect;
|
||||
pub mod envfile;
|
||||
pub mod go;
|
||||
pub mod gradle_jvm;
|
||||
pub mod java_cmdline;
|
||||
pub mod junit;
|
||||
@@ -240,6 +241,11 @@ pub fn system_env() -> BTreeMap<String, String> {
|
||||
ide_core::process::host_vars().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))).collect()
|
||||
}
|
||||
|
||||
/// Complete environment for a preparatory tool belonging to this run configuration.
|
||||
pub fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
|
||||
process::resolved_env(root, config)
|
||||
}
|
||||
|
||||
/// Path of the user run configuration file for `root`.
|
||||
pub fn user_configs_path(root: &Path) -> PathBuf {
|
||||
config::config_path(root)
|
||||
|
||||
@@ -49,7 +49,7 @@ struct Entry {
|
||||
id: RunId,
|
||||
config_id: String,
|
||||
name: String,
|
||||
pid: Option<u32>,
|
||||
pid: Mutex<Option<u32>>,
|
||||
stdin: Mutex<Option<Box<dyn Write + Send>>>,
|
||||
/// The PTY master of a terminal-emulated run (resize; dropped on exit).
|
||||
pty: Option<Mutex<Option<Box<dyn MasterPty + Send>>>>,
|
||||
@@ -65,7 +65,7 @@ impl Entry {
|
||||
id: self.id,
|
||||
config_id: self.config_id.clone(),
|
||||
name: self.name.clone(),
|
||||
pid: self.pid,
|
||||
pid: *self.pid.lock(),
|
||||
running: !self.exited.load(Ordering::Acquire),
|
||||
pty: self.pty.is_some(),
|
||||
}
|
||||
@@ -299,10 +299,17 @@ impl ProcessManager {
|
||||
if config.command.trim().is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
let plan = crate::go::plan(root, config)?;
|
||||
let followup = plan.as_ref().and_then(|p| p.run.as_ref()).map(|run| {
|
||||
let cwd = resolve_cwd(root, run.cwd.as_deref());
|
||||
(run.clone(), cwd)
|
||||
});
|
||||
let config = plan.as_ref().map_or(config, |p| &p.build);
|
||||
let cwd = resolve_cwd(root, config.cwd.as_deref());
|
||||
if !cwd.is_dir() {
|
||||
return Err(IdeError::not_found(format!("working directory does not exist: {}", cwd.display())));
|
||||
}
|
||||
crate::detect::cmake::prepare(config, &cwd)?;
|
||||
let mut inv = invocation(config, &cwd);
|
||||
let extras = &config.extras;
|
||||
if extras.emulate_terminal && extras.stdin_file.is_some() {
|
||||
@@ -326,6 +333,9 @@ impl ProcessManager {
|
||||
let notice =
|
||||
if extras.emulate_terminal { None } else { match_console_encoding(&mut inv, &mut env, config, options) };
|
||||
let temp = shorten_java(&mut inv, config, &cwd)?;
|
||||
if extras.emulate_terminal && followup.is_some() {
|
||||
return Err(IdeError::invalid("Go build and run requires the output console"));
|
||||
}
|
||||
if extras.emulate_terminal {
|
||||
return self.start_pty(config, &inv, &cwd, &env, stop, options, sink, on_output, temp);
|
||||
}
|
||||
@@ -377,7 +387,7 @@ impl ProcessManager {
|
||||
id,
|
||||
config_id: config.id.clone(),
|
||||
name: config.name.clone(),
|
||||
pid: Some(child.id()),
|
||||
pid: Mutex::new(Some(child.id())),
|
||||
stdin: Mutex::new(stdin),
|
||||
pty: None,
|
||||
stop,
|
||||
@@ -399,7 +409,7 @@ impl ProcessManager {
|
||||
if let Some(err) = stderr {
|
||||
spawn_reader(id, Stream::Stderr, err, tx.clone())?;
|
||||
}
|
||||
spawn_pump(rx, on_output, entry.clone(), sink, options.encoding.clone(), notice)?;
|
||||
spawn_pump(rx, on_output.clone(), entry.clone(), sink, options.encoding.clone(), notice)?;
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(e) = result {
|
||||
@@ -410,10 +420,17 @@ impl ProcessManager {
|
||||
}
|
||||
// The waiter owns the child; it must exist for the pump to ever finish.
|
||||
let waiter_tx = tx;
|
||||
let waiter_entry = entry.clone();
|
||||
let run_root = root.to_path_buf();
|
||||
std::thread::Builder::new()
|
||||
.name(format!("openidea-run-wait-{}", id.0))
|
||||
.spawn(move || {
|
||||
let status = child.wait();
|
||||
let mut status = child.wait();
|
||||
if status.as_ref().is_ok_and(ExitStatus::success)
|
||||
&& let Some((run, cwd)) = followup
|
||||
{
|
||||
status = run_followup(&run_root, &run, &cwd, &waiter_entry, &waiter_tx);
|
||||
}
|
||||
drop(temp);
|
||||
let _ = waiter_tx.send(Msg::Exited(Exit::Pipes(status)));
|
||||
})
|
||||
@@ -479,7 +496,7 @@ impl ProcessManager {
|
||||
id,
|
||||
config_id: config.id.clone(),
|
||||
name: config.name.clone(),
|
||||
pid: child.process_id(),
|
||||
pid: Mutex::new(child.process_id()),
|
||||
stdin: Mutex::new(Some(writer)),
|
||||
pty: Some(Mutex::new(Some(pair.master))),
|
||||
stop,
|
||||
@@ -578,8 +595,9 @@ impl ProcessManager {
|
||||
if entry.exited.load(Ordering::Acquire) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(pid) = entry.pid else { return Ok(()) };
|
||||
if entry.stopping.swap(true, Ordering::AcqRel) {
|
||||
let already_stopping = entry.stopping.swap(true, Ordering::AcqRel);
|
||||
let Some(pid) = *entry.pid.lock() else { return Ok(()) };
|
||||
if already_stopping {
|
||||
// Second stop request: don't wait out the grace period.
|
||||
kill_tree(pid, true);
|
||||
return Ok(());
|
||||
@@ -624,8 +642,9 @@ impl ProcessManager {
|
||||
/// Kills every running process tree at once (shutting down).
|
||||
pub fn kill_all(&self) {
|
||||
for entry in self.runs.lock().values() {
|
||||
entry.stopping.store(true, Ordering::Release);
|
||||
if !entry.exited.load(Ordering::Acquire)
|
||||
&& let Some(pid) = entry.pid
|
||||
&& let Some(pid) = *entry.pid.lock()
|
||||
{
|
||||
kill_tree(pid, true);
|
||||
}
|
||||
@@ -641,6 +660,67 @@ impl Drop for ProcessManager {
|
||||
|
||||
/// The environment a configuration's process gets on top of the inherited one: `.env`
|
||||
/// files, then the configured variables (later wins).
|
||||
fn run_followup(
|
||||
root: &Path,
|
||||
config: &RunConfig,
|
||||
cwd: &Path,
|
||||
entry: &Entry,
|
||||
tx: &Sender<Msg>,
|
||||
) -> std::io::Result<ExitStatus> {
|
||||
let mut pid = entry.pid.lock();
|
||||
if entry.stopping.load(Ordering::Acquire) {
|
||||
return Err(std::io::Error::other("run stopped after build"));
|
||||
}
|
||||
let inv = invocation(config, cwd);
|
||||
let mut cmd = Command::new(&inv.program);
|
||||
cmd.args(&inv.args).current_dir(cwd).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
match config.extras.stdin_file.as_deref() {
|
||||
Some(file) => {
|
||||
cmd.stdin(std::fs::File::open(resolve_cwd(root, Some(Path::new(file))))?);
|
||||
}
|
||||
None => {
|
||||
cmd.stdin(Stdio::piped());
|
||||
}
|
||||
}
|
||||
let env = launch_env(root, config).map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||
apply_env(&mut cmd, &env);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
cmd.creation_flags(0x0000_0200 | ide_core::process::CREATE_NO_WINDOW);
|
||||
}
|
||||
(entry.output)(OutputChunk::System { text: format!("{}\n", inv.display) });
|
||||
let mut child = cmd.spawn()?;
|
||||
*pid = Some(child.id());
|
||||
*entry.stdin.lock() = child.stdin.take().map(|p| Box::new(p) as Box<dyn Write + Send>);
|
||||
drop(pid);
|
||||
let readers = (|| {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
spawn_reader(entry.id, Stream::Stdout, out, tx.clone())?;
|
||||
}
|
||||
if let Some(err) = child.stderr.take() {
|
||||
spawn_reader(entry.id, Stream::Stderr, err, tx.clone())?;
|
||||
}
|
||||
Ok::<_, std::io::Error>(())
|
||||
})();
|
||||
if let Err(e) = readers {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(e);
|
||||
}
|
||||
child.wait()
|
||||
}
|
||||
|
||||
pub(crate) fn configure_environment(command: &mut Command, root: &Path, config: &RunConfig) -> IdeResult<()> {
|
||||
apply_env(command, &launch_env(root, config)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
|
||||
let extras = &config.extras;
|
||||
let mut vars = if extras.env_files.is_empty() {
|
||||
@@ -661,6 +741,18 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
|
||||
Ok(LaunchEnv { clear: !extras.include_system_env, vars })
|
||||
}
|
||||
|
||||
pub(crate) fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
|
||||
let env = launch_env(root, config)?;
|
||||
let mut vars = if env.clear { BTreeMap::new() } else { crate::system_env() };
|
||||
if !env.clear {
|
||||
vars.extend(
|
||||
ide_core::process::shell_env().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))),
|
||||
);
|
||||
}
|
||||
vars.extend(env.vars);
|
||||
Ok(vars)
|
||||
}
|
||||
|
||||
fn apply_env(cmd: &mut Command, env: &LaunchEnv) {
|
||||
if env.clear {
|
||||
cmd.env_clear();
|
||||
|
||||
@@ -216,7 +216,7 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
|
||||
.map_err(|_| invalid("Build the WAR artifact before launching Tomcat"))?;
|
||||
let war = PathBuf::from(war.trim());
|
||||
if !war.is_file() || !war.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")) {
|
||||
return Err(invalid("The built WAR artifact does not exist"));
|
||||
return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display())));
|
||||
}
|
||||
let dest = base.join("deployment");
|
||||
explode(&war, &dest, resources_only)?;
|
||||
@@ -227,11 +227,14 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
|
||||
.get("deploymentPath")
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.ok_or_else(|| invalid("Choose a WAR file or exploded directory"))?;
|
||||
let path = root.join(value).canonicalize()?;
|
||||
let path = root.join(value);
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.map_err(|e| invalid(format!("Invalid Tomcat deployment: {} ({e})", path.display())))?;
|
||||
if (kind == "war" && (!path.is_file() || !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("war"))))
|
||||
|| (kind == "directory" && !path.is_dir())
|
||||
{
|
||||
return Err(invalid("Invalid Tomcat deployment"));
|
||||
return Err(invalid(format!("Invalid Tomcat deployment: {}", path.display())));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
@@ -436,6 +439,28 @@ mod tests {
|
||||
assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent"));
|
||||
}
|
||||
#[test]
|
||||
fn deployment_errors_identify_the_checked_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join("cache");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
for path in ["missing.war", r"C:\工作\timesheet\target\timesheet.war", r"\\server\share\timesheet.war"] {
|
||||
fs::write(base.join("artifact-path.txt"), format!("{path}\r\n")).unwrap();
|
||||
let error = deployment(tmp.path(), &base, &config(), false).unwrap_err().to_string();
|
||||
assert!(error.contains(path), "{error}");
|
||||
}
|
||||
for kind in ["war", "directory"] {
|
||||
let mut c = config();
|
||||
c.extras.params.insert("deployment".into(), kind.into());
|
||||
c.extras.params.insert("deploymentPath".into(), "missing".into());
|
||||
let error = deployment(tmp.path(), &base, &c, false).unwrap_err().to_string();
|
||||
assert!(error.contains(&tmp.path().join("missing").display().to_string()), "{error}");
|
||||
c.extras.params.insert("deploymentPath".into(), "cache".into());
|
||||
if kind == "war" {
|
||||
assert!(deployment(tmp.path(), &base, &c, false).unwrap_err().to_string().contains("cache"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn prepares_isolated_base_and_jdwp_only_for_run() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("Tomcat Home");
|
||||
|
||||
@@ -550,3 +550,90 @@ fn root_package_disables_nested_scan() {
|
||||
assert!(ids(&c).contains(&"go:test"));
|
||||
assert!(!ids(&c).contains(&"go:test:svc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_detection_is_read_only_and_preserves_existing_build_directories() {
|
||||
let dir = project(&[
|
||||
("CMakeLists.txt", "project(demo)"),
|
||||
("cmake-build-debug/CMakeCache.txt", "CMAKE_CONFIGURATION_TYPES:STRING=Release;RelWithDebInfo\n"),
|
||||
]);
|
||||
let configs = detect(dir.path());
|
||||
let configure = find(&configs, "cmake:cmake-build-debug:configure");
|
||||
assert_eq!(configure.args, ["-S", ".", "-B", "cmake-build-debug", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"]);
|
||||
assert!(!dir.path().join("build").exists());
|
||||
assert!(!dir.path().join("cmake-build-debug/.cmake").exists());
|
||||
let build = find(&configs, "cmake:cmake-build-debug:build");
|
||||
assert!(build.extras.before_launch.is_empty(), "a configured build regenerates by itself");
|
||||
assert!(build.args.windows(2).any(|a| a == ["--config", "Release"]));
|
||||
let test = find(&configs, "cmake:cmake-build-debug:test");
|
||||
assert_eq!(test.extras.before_launch[0].target, build.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_configures_before_the_first_build() {
|
||||
let dir = project(&[("CMakeLists.txt", "project(demo)")]);
|
||||
let configs = detect(dir.path());
|
||||
let build = find(&configs, "cmake:build:build");
|
||||
assert_eq!(build.extras.before_launch[0].target, "cmake:build:configure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_ignores_malformed_file_api_replies() {
|
||||
let dir = project(&[
|
||||
("CMakeLists.txt", "project(demo)"),
|
||||
("build/CMakeCache.txt", ""),
|
||||
("build/.cmake/api/v1/reply/index-1.json", "not JSON"),
|
||||
]);
|
||||
let configs = detect(dir.path());
|
||||
assert_eq!(configs.iter().filter(|c| c.kind == "cmake").count(), 3);
|
||||
assert!(!configs.iter().any(|c| c.kind == "native"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_work_members_define_configs_including_deep_and_quoted_directories() {
|
||||
let dir = project(&[
|
||||
(
|
||||
"go.work",
|
||||
"go 1.22\n// use ./ignored\nuse (\n ./apps/deep/server // member\n \"./module with spaces\"\n)\nuse ./apps/deep/server\n",
|
||||
),
|
||||
("apps/deep/server/go.mod", "module example.com/server\ngo 1.22\n"),
|
||||
("apps/deep/server/main.go", "package main\nfunc main() {}"),
|
||||
("module with spaces/go.mod", "module example.com/lib\ngo 1.22\n"),
|
||||
("ignored/go.mod", "module example.com/ignored\ngo 1.22\n"),
|
||||
]);
|
||||
let configs = detect(dir.path());
|
||||
assert_eq!(configs.iter().filter(|c| c.kind == "go" && c.is_test).count(), 2);
|
||||
assert_eq!(find(&configs, "go:run:apps/deep/server").cwd.as_deref(), Some(Path::new("apps/deep/server")));
|
||||
assert!(configs.iter().all(|c| !c.id.contains("ignored")));
|
||||
assert!(configs.iter().any(|c| c.id == "go:test:module with spaces"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_work_parser_ignores_other_directives_comments_and_malformed_groups() {
|
||||
use ide_run::go::work_modules;
|
||||
assert_eq!(
|
||||
work_modules("replace use => ./not-a-member\n// use ./also-not\nuse ./real\n"),
|
||||
vec![std::path::PathBuf::from("./real")]
|
||||
);
|
||||
assert!(work_modules("use (\n./missing-close\n").is_empty());
|
||||
assert!(work_modules("use \"unterminated").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_work_paths_decode_go_string_escapes() {
|
||||
use ide_run::go::work_modules;
|
||||
for path in [r#""./\u0061pp""#, r#""./\x61pp""#, r#""./\141pp""#, "`./app`"] {
|
||||
assert_eq!(work_modules(&format!("use {path}\n")), vec![std::path::PathBuf::from("./app")]);
|
||||
}
|
||||
assert!(work_modules(r#"use "./\qinvalid""#).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_default_build_output_is_ignored_by_version_control() {
|
||||
let dir = project(&[("go.mod", "module x\ngo 1.22\n"), ("main.go", "package main\nfunc main() {}")]);
|
||||
let mut config = detect(dir.path()).into_iter().find(|c| c.kind == "go" && !c.is_test).unwrap();
|
||||
config.extras.params.insert("goKind".into(), "package".into());
|
||||
assert!(ide_run::go::plan(dir.path(), &config).unwrap().is_some());
|
||||
let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap();
|
||||
assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}");
|
||||
}
|
||||
@@ -291,6 +291,10 @@ fn env_files_system_env_and_redirects() {
|
||||
config.extras.include_system_env = false;
|
||||
config.extras.stdin_file = Some("input.txt".into());
|
||||
config.extras.output_file = Some("logs/out.log".into());
|
||||
let prepared = ide_run::resolved_env(root, &config).unwrap();
|
||||
assert_eq!(prepared.get("FROM_FILE").map(String::as_str), Some("file"));
|
||||
assert_eq!(prepared.get("OVERRIDDEN").map(String::as_str), Some("config"));
|
||||
assert!(!prepared.contains_key("HOME"));
|
||||
let (id, chunks) = start(&h, &config);
|
||||
// stdin comes from the file: writing to it is refused.
|
||||
assert!(h.pm.write_stdin(id, "x").is_err());
|
||||
@@ -302,6 +306,7 @@ fn env_files_system_env_and_redirects() {
|
||||
|
||||
// With the system environment, HOME is inherited.
|
||||
config.extras.include_system_env = true;
|
||||
assert!(ide_run::resolved_env(root, &config).unwrap().contains_key("HOME"));
|
||||
config.extras.stdin_file = None;
|
||||
config.extras.output_file = None;
|
||||
config.args = vec!["-c".into(), "printf '%s' \"${HOME:+set}\"".into()];
|
||||
@@ -468,3 +473,113 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() {
|
||||
assert_eq!(text(&chunks, stdout), "import OK vendor OK\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires CMake, CTest and a C++ compiler"]
|
||||
fn cmake_configure_discovers_builds_and_runs_a_multifile_target() {
|
||||
let h = harness();
|
||||
std::fs::write(h.root.path().join("CMakeLists.txt"), "cmake_minimum_required(VERSION 3.20)\nproject(smoke LANGUAGES CXX)\nadd_executable(smoke main.cpp math.cpp)\nenable_testing()\nadd_test(NAME smoke COMMAND smoke)\n").unwrap();
|
||||
std::fs::write(h.root.path().join("main.cpp"), "int add(int,int); int main() { return add(2,3)==5 ? 0 : 1; }\n")
|
||||
.unwrap();
|
||||
std::fs::write(h.root.path().join("math.cpp"), "int add(int a,int b) { return a+b; }\n").unwrap();
|
||||
let initial = ide_run::detect(h.root.path());
|
||||
assert!(!h.root.path().join("build").exists(), "detection must not modify the project");
|
||||
let configure = initial.iter().find(|c| c.id.ends_with(":configure")).unwrap();
|
||||
let (id, output) = start(&h, configure);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&output, stderr));
|
||||
assert!(h.root.path().join("build/.cmake/api/v1/query/client-openidea/codemodel-v2").exists());
|
||||
let root = h.root.path().canonicalize().unwrap();
|
||||
let configs = ide_run::detect(&root);
|
||||
let target = configs.iter().find(|c| c.kind == "native").expect("File API executable");
|
||||
assert_eq!(target.debug_adapter.as_deref(), Some("lldb-dap"));
|
||||
let sources = &target.extras.params["cmakeSources"];
|
||||
assert!(sources.lines().any(|s| s.ends_with("/main.cpp")));
|
||||
assert!(sources.lines().any(|s| s.ends_with("/math.cpp")));
|
||||
assert!(sources.lines().any(|s| s == root.join("main.cpp").to_string_lossy()));
|
||||
assert_eq!(target.extras.params["cmakeCanonicalSourceRoot"], root.to_string_lossy());
|
||||
let build = configs.iter().find(|c| c.id == target.extras.before_launch[0].target).unwrap();
|
||||
let (id, output) = start(&h, build);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&output, stderr));
|
||||
let (id, _) = start(&h, target);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(0));
|
||||
let ctest = configs.iter().find(|c| c.command == "ctest").unwrap();
|
||||
let (id, output) = start(&h, ctest);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(0), "{}", text(&output, stdout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_build_runs_with_separate_arguments_environment_and_working_directory() {
|
||||
let h = harness();
|
||||
let Some(go) = ide_run::which::find_executable("go", &std::env::var_os("PATH").unwrap_or_default()) else {
|
||||
eprintln!("skipped: no Go toolchain");
|
||||
return;
|
||||
};
|
||||
let module = h.root.path().join("module");
|
||||
std::fs::create_dir_all(&module).unwrap();
|
||||
std::fs::write(module.join("go.mod"), "module example.com/demo\ngo 1.22\n").unwrap();
|
||||
std::fs::write(module.join("main.go"), "//go:build demo\n\npackage main\nimport (\"fmt\"; \"os\")\nfunc main(){cwd,_:=os.Getwd();fmt.Printf(\"%q %s %s\\n\",os.Args[1:],os.Getenv(\"GO_RUN_VALUE\"),cwd)}\n").unwrap();
|
||||
let mut c = cfg("go-config", &go.to_string_lossy(), &["build", "-tags", "demo", "."]);
|
||||
c.kind = "go".into();
|
||||
c.env.insert("GO_RUN_VALUE".into(), "configured".into());
|
||||
c.extras.params = BTreeMap::from([
|
||||
("goKind".into(), "build".into()),
|
||||
("scope".into(), "package".into()),
|
||||
("target".into(), ".".into()),
|
||||
("module".into(), "module".into()),
|
||||
("outputDir".into(), "bin with spaces".into()),
|
||||
("runAfterBuild".into(), "true".into()),
|
||||
("programArgv".into(), r#"["-tags","two words"]"#.into()),
|
||||
]);
|
||||
let (id, out) = start(&h, &c);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&out, stderr));
|
||||
let output = text(&out, stdout);
|
||||
assert!(output.contains(r#"["-tags" "two words"] configured"#), "{output}");
|
||||
assert!(output.contains(&h.root.path().display().to_string()), "{output}");
|
||||
assert!(std::fs::read_dir(h.root.path().join("bin with spaces")).unwrap().next().is_some());
|
||||
|
||||
c.extras.params.insert("runAfterBuild".into(), "false".into());
|
||||
let (id, out) = start(&h, &c);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0));
|
||||
assert!(text(&out, stdout).is_empty());
|
||||
|
||||
c.extras.params.insert("runAfterBuild".into(), "true".into());
|
||||
c.args = vec!["build".into(), "missing.go".into()];
|
||||
let (id, out) = start(&h, &c);
|
||||
assert_ne!(wait_exit(&h, id, Duration::from_secs(60)), Some(0));
|
||||
assert!(text(&out, stdout).is_empty(), "a failed build must never run an older binary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_file_tests_use_build_selected_sources_and_only_the_selected_test_file() {
|
||||
let h = harness();
|
||||
let Some(go) = ide_run::which::find_executable("go", &std::env::var_os("PATH").unwrap_or_default()) else {
|
||||
eprintln!("skipped: no Go toolchain");
|
||||
return;
|
||||
};
|
||||
std::fs::write(h.root.path().join("go.mod"), "module example.com/filetests\ngo 1.22\n").unwrap();
|
||||
std::fs::write(h.root.path().join("main.go"), "package filetests\nfunc value() int { return 2 }\n").unwrap();
|
||||
std::fs::write(h.root.path().join("generate.go"), "//go:build ignore\n\npackage main\nfunc main() {}\n").unwrap();
|
||||
std::fs::write(
|
||||
h.root.path().join("demo_test.go"),
|
||||
"package filetests\nimport \"testing\"\nfunc TestChosen(t *testing.T) { if value()!=2 { t.Fatal(\"bad\") } }\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
h.root.path().join("other_test.go"),
|
||||
"package filetests\nimport \"testing\"\nfunc TestOther(t *testing.T) { t.Fatal(\"must not run\") }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut c = cfg("go-file", &go.to_string_lossy(), &["test", "-json", "demo_test.go"]);
|
||||
c.kind = "go".into();
|
||||
c.extras.params = BTreeMap::from([
|
||||
("goKind".into(), "test".into()),
|
||||
("scope".into(), "file".into()),
|
||||
("target".into(), "demo_test.go".into()),
|
||||
]);
|
||||
ide_run::go::prepare_test_file(h.root.path(), &mut c).unwrap();
|
||||
assert!(!c.args.iter().any(|a| a.ends_with("generate.go") || a.ends_with("other_test.go")));
|
||||
let (id, out) = start(&h, &c);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&out, stderr));
|
||||
assert!(text(&out, stdout).contains("TestChosen"));
|
||||
assert!(!text(&out, stdout).contains("TestOther"));
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Opt-in build-tool integration checks: cargo test -p ide-run --test tomcat_artifact -- --ignored
|
||||
use std::{fs, path::Path, process::Command};
|
||||
|
||||
fn write(root: &Path, path: &str, text: &str) {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
fn build_and_deploy(root: &Path, id: &str, expected: &Path) {
|
||||
let cache = root.join("工作 cache");
|
||||
let model = ide_build::load(root, &cache, None);
|
||||
let mut plan = ide_build::web::build(root, &model, id, &cache).unwrap();
|
||||
plan.resolve_project(root, &std::env::vars().collect(), &ide_core::CancellationToken::new()).unwrap();
|
||||
let output = Command::new(&plan.command)
|
||||
.args(&plan.args)
|
||||
.envs(&plan.env)
|
||||
.current_dir(root.join(&plan.cwd))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let manifest = fs::read_to_string(cache.join("artifact-path.txt")).unwrap();
|
||||
assert_eq!(Path::new(manifest.trim()).canonicalize().unwrap(), expected.canonicalize().unwrap());
|
||||
let mut config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({
|
||||
"id": "tomcat:test", "name": "Tomcat", "kind": "tomcat", "command": "catalina",
|
||||
"args": [], "env": {}, "detected": false, "isTest": false
|
||||
}))
|
||||
.unwrap();
|
||||
config.cwd = Some("timesheet".into());
|
||||
config.extras.params.insert("artifact".into(), id.into());
|
||||
let deployed = ide_run::tomcat::deployment(root, &cache, &config, false).unwrap();
|
||||
assert_eq!(fs::read_to_string(deployed.join("index.html")).unwrap(), "issue 515 timesheet");
|
||||
let siblings: Vec<_> = fs::read_dir(expected.parent().unwrap())
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.filter(|name| name.contains(".war."))
|
||||
.collect();
|
||||
assert!(siblings.is_empty(), "Unexpected WAR siblings: {siblings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Maven and downloads Maven plugins"]
|
||||
fn maven_packaged_artifacts_deploy_from_root_and_nested_reactors() {
|
||||
for (reactor, module, custom) in
|
||||
[("", "", false), ("", "timesheet", false), ("parent", "apps/timesheet", true), ("timesheet", "", true)]
|
||||
{
|
||||
let tmp = tempfile::Builder::new().prefix("tomcat maven ").tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let reactor_dir = root.join(reactor);
|
||||
let module_dir = reactor_dir.join(module);
|
||||
let plugins = "<pluginManagement><plugins><plugin><artifactId>maven-war-plugin</artifactId><version>3.4.0</version><configuration><failOnMissingWebXml>false</failOnMissingWebXml></configuration></plugin></plugins></pluginManagement>";
|
||||
let parent = if module.is_empty() && custom {
|
||||
write(&reactor_dir, ".mvn/maven.config", "-Drevision=2\n");
|
||||
"<groupId>test</groupId><version>${revision}</version><properties><revision>1</revision></properties>"
|
||||
.to_owned()
|
||||
} else if module.is_empty() {
|
||||
"<groupId>test</groupId><version>1</version>".to_owned()
|
||||
} else {
|
||||
write(
|
||||
&reactor_dir,
|
||||
"pom.xml",
|
||||
&format!(
|
||||
"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>core</module><module>{module}</module></modules><build>{plugins}</build></project>"
|
||||
),
|
||||
);
|
||||
write(
|
||||
&reactor_dir,
|
||||
"core/pom.xml",
|
||||
"<project><modelVersion>4.0.0</modelVersion><parent><groupId>test</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>core</artifactId></project>",
|
||||
);
|
||||
let relative = "../".repeat(module.split('/').count());
|
||||
format!(
|
||||
"<parent><groupId>test</groupId><artifactId>parent</artifactId><version>1</version><relativePath>{relative}pom.xml</relativePath></parent>"
|
||||
)
|
||||
};
|
||||
let dependency = if module.is_empty() {
|
||||
""
|
||||
} else {
|
||||
"<dependencies><dependency><groupId>test</groupId><artifactId>core</artifactId><version>1</version></dependency></dependencies>"
|
||||
};
|
||||
let output = if custom {
|
||||
"<directory>${project.basedir}/custom output</directory><finalName>custom-timesheet</finalName>"
|
||||
} else {
|
||||
"<finalName>timesheet</finalName>"
|
||||
};
|
||||
write(
|
||||
&module_dir,
|
||||
"pom.xml",
|
||||
&format!(
|
||||
"<project><modelVersion>4.0.0</modelVersion>{parent}<artifactId>timesheet</artifactId><packaging>war</packaging>{dependency}<build>{plugins}{output}</build></project>"
|
||||
),
|
||||
);
|
||||
write(&module_dir, "src/main/webapp/index.html", "issue 515 timesheet");
|
||||
if custom && !module.is_empty() {
|
||||
for dir in [&reactor_dir, &reactor_dir.join("core"), &module_dir] {
|
||||
let pom = dir.join("pom.xml");
|
||||
let text =
|
||||
fs::read_to_string(&pom).unwrap().replace("<version>1</version>", "<version>${revision}</version>");
|
||||
fs::write(pom, text).unwrap();
|
||||
}
|
||||
let pom = reactor_dir.join("pom.xml");
|
||||
fs::write(
|
||||
&pom,
|
||||
fs::read_to_string(&pom).unwrap().replace(
|
||||
"<packaging>pom</packaging>",
|
||||
"<packaging>pom</packaging><properties><revision>1</revision></properties>",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
write(&reactor_dir, ".mvn/maven.config", "-Drevision=2\n");
|
||||
write(&module_dir, ".mvn/maven.config", "-Drevision=3\n");
|
||||
}
|
||||
let pom = module_dir.join("pom.xml").strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/");
|
||||
let expected =
|
||||
module_dir.join(if custom { "custom output/custom-timesheet.war" } else { "target/timesheet.war" });
|
||||
build_and_deploy(root, &format!("maven:{pom}"), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Gradle"]
|
||||
fn gradle_packaged_artifacts_deploy_with_custom_archive_names() {
|
||||
for module in ["", "apps/timesheet"] {
|
||||
let tmp = tempfile::Builder::new().prefix("tomcat gradle ").tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let settings = if module.is_empty() {
|
||||
"rootProject.name = 'timesheet'\n"
|
||||
} else {
|
||||
"rootProject.name = 'timesheet'\ninclude 'apps:timesheet'\n"
|
||||
};
|
||||
write(root, "settings.gradle", settings);
|
||||
write(
|
||||
&root.join(module),
|
||||
"build.gradle",
|
||||
"plugins { id 'war' }\ntasks.named('war') { archiveFileName = 'custom-timesheet.war'; destinationDirectory = layout.projectDirectory.dir('custom output') }\n",
|
||||
);
|
||||
write(&root.join(module), "src/main/webapp/index.html", "issue 515 timesheet");
|
||||
let project = if module.is_empty() { ":" } else { ":apps:timesheet" };
|
||||
build_and_deploy(
|
||||
root,
|
||||
&format!("gradle:{project}"),
|
||||
&root.join(module).join("custom output/custom-timesheet.war"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,32 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
|
||||
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
|
||||
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
|
||||
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
|
||||
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
|
||||
let write = || -> ide_core::IdeResult<()> {
|
||||
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
|
||||
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Err(e) = write() {
|
||||
tracing::warn!("jdtls: cannot write compiler defaults: {e}");
|
||||
return;
|
||||
}
|
||||
// jdtls accepts absolute filesystem paths as well as URLs (including paths
|
||||
// containing spaces and Windows backslashes). Explicit server settings win later.
|
||||
let defaults = json!({"settings": {"java": {"settings": {"url": file.to_string_lossy()}}}});
|
||||
let existing = cfg.initialization_options.clone();
|
||||
merge_init_options(cfg, defaults);
|
||||
if let Some(existing) = existing {
|
||||
merge_init_options(cfg, serde_json::to_value(existing).expect("TOML initialization options"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjusts a language server's launch configuration from settings: interpreter
|
||||
/// paths, toolchain `PATH`, env (`GOROOT`, `RUST_SRC_PATH`, `VIRTUAL_ENV`) and
|
||||
/// server settings (`python.pythonPath`, rust-analyzer `check.command`, `typescript.tsdk`).
|
||||
@@ -358,6 +384,22 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
}
|
||||
|
||||
if name == "gopls" {
|
||||
let mut build = crate::go::build_settings(&s.languages.go.build_tags);
|
||||
let module_env = crate::go::module_environment(server_root, &s.languages.go.modules);
|
||||
for (key, value) in module_env {
|
||||
build["env"][key] = json!(value);
|
||||
}
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
// gopls retains omitted keys on reconfiguration. Explicit empty values
|
||||
// restore defaults when the last project constraint is cleared.
|
||||
let init = cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok());
|
||||
for (key, default) in [("buildFlags", json!([])), ("env", json!({}))] {
|
||||
if settings.get(key).is_none() {
|
||||
settings[key] = init.as_ref().and_then(|v| v.get(key)).cloned().unwrap_or(default);
|
||||
}
|
||||
}
|
||||
merge(&mut settings, build);
|
||||
cfg.settings = Some(settings);
|
||||
if let Some(goroot) = goroot(root, s) {
|
||||
path_dirs.push(goroot.join("bin"));
|
||||
cfg.env.insert("GOROOT".into(), goroot.to_string_lossy().into_owned());
|
||||
@@ -625,6 +667,9 @@ pub fn apply_to_run_configs(configs: &mut [RunConfig], root: &Path, s: &Settings
|
||||
}
|
||||
}
|
||||
"go" => {
|
||||
for (key, value) in crate::go::module_environment(root, &s.languages.go.modules) {
|
||||
c.env.entry(key).or_insert(value);
|
||||
}
|
||||
if let Some(goroot) = &go {
|
||||
let bin = goroot.join("bin");
|
||||
if c.command == "go" && bin.join(exe("go")).is_file() {
|
||||
@@ -729,6 +774,70 @@ pub fn terminal_activation(root: &Path, s: &Settings, shell: Option<&str>) -> Op
|
||||
script(target).is_file().then_some(line)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod java_compiler_tests {
|
||||
use super::*;
|
||||
|
||||
fn options(cfg: &LanguageServerConfig) -> Value {
|
||||
serde_json::to_value(cfg.initialization_options.as_ref().unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compiler_defaults_live_in_the_cache_and_preserve_other_options() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cache = tmp.path().join("cache with spaces 测试");
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"format": {"tabSize": 4}}}}));
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
let options = options(&cfg);
|
||||
let path = PathBuf::from(options.pointer("/settings/java/settings/url").unwrap().as_str().unwrap());
|
||||
assert!(path.starts_with(&cache));
|
||||
let prefs = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(prefs.lines().count(), 2);
|
||||
for problem in ["forbiddenReference", "discouragedReference"] {
|
||||
assert!(prefs.contains(&format!("org.eclipse.jdt.core.compiler.problem.{problem}=warning\n")));
|
||||
}
|
||||
assert_eq!(options.pointer("/settings/java/format/tabSize"), Some(&json!(4)));
|
||||
let modified = std::fs::metadata(&path).unwrap().modified().unwrap();
|
||||
configure_java_compiler(&mut cfg, &cache);
|
||||
assert_eq!(std::fs::metadata(path).unwrap().modified().unwrap(), modified);
|
||||
assert_eq!(self::options(&cfg), options);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_compiler_settings_keep_priority() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = custom_server("jdtls", &LanguageServerSettings::default());
|
||||
merge_init_options(&mut cfg, json!({"settings": {"java": {"settings": {"url": "own.prefs"}}}}));
|
||||
configure_java_compiler(&mut cfg, tmp.path());
|
||||
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("own.prefs")));
|
||||
|
||||
for settings in [
|
||||
json!({"java": {"settings": {"url": "custom.prefs"}}}),
|
||||
json!({"settings": {"java": {"settings": {"url": "custom.prefs"}}}}),
|
||||
] {
|
||||
cfg.settings = None;
|
||||
let mut s = Settings::default();
|
||||
s.language_servers.insert(
|
||||
"jdtls".into(),
|
||||
LanguageServerSettings { settings: Some(settings.clone()), ..Default::default() },
|
||||
);
|
||||
server_protocol_overrides(&mut cfg, &s);
|
||||
assert_eq!(cfg.settings, Some(settings));
|
||||
}
|
||||
let mut s = Settings::default();
|
||||
s.language_servers.insert(
|
||||
"jdtls".into(),
|
||||
LanguageServerSettings {
|
||||
initialization_options: Some(json!({"settings": {"java": {"settings": {"url": "user.prefs"}}}})),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
server_protocol_overrides(&mut cfg, &s);
|
||||
assert_eq!(options(&cfg).pointer("/settings/java/settings/url"), Some(&json!("user.prefs")));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod maven_linkage_tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Go build constraints shared by language services and command execution.
|
||||
use ide_core::settings::GoBuildTags;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn custom_tags(tags: &str) -> Vec<&str> {
|
||||
tags.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// The build category of gopls settings uses flat protocol keys (`buildFlags`, `env`).
|
||||
/// Keep these in protocol settings so changes do not restart the server process.
|
||||
pub fn build_settings(tags: &GoBuildTags) -> Value {
|
||||
let mut out = json!({});
|
||||
let mut flags = Vec::new();
|
||||
let mut selected = custom_tags(&tags.custom);
|
||||
if !tags.version.trim().is_empty() {
|
||||
selected.push(tags.version.trim());
|
||||
}
|
||||
if !selected.is_empty() {
|
||||
flags.push(format!("-tags={}", selected.join(",")));
|
||||
}
|
||||
if !tags.compiler.trim().is_empty() {
|
||||
flags.push(format!("-compiler={}", tags.compiler.trim()));
|
||||
}
|
||||
if !flags.is_empty() {
|
||||
out["buildFlags"] = json!(flags);
|
||||
}
|
||||
for (key, value) in [("GOOS", &tags.os), ("GOARCH", &tags.arch), ("CGO_ENABLED", &tags.cgo)] {
|
||||
if !value.trim().is_empty() {
|
||||
out["env"][key] = json!(value.trim());
|
||||
}
|
||||
}
|
||||
let experiments = tags.experiments.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::<Vec<_>>();
|
||||
if !experiments.is_empty() {
|
||||
out["env"]["GOEXPERIMENT"] = json!(experiments.join(","));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_do_not_override_toolchain_or_existing_server_settings() {
|
||||
assert_eq!(build_settings(&GoBuildTags::default()), json!({}));
|
||||
assert!(custom_tags(" , \n ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraints_map_to_protocol_build_settings() {
|
||||
let tags = GoBuildTags {
|
||||
os: "linux".into(),
|
||||
arch: "arm64".into(),
|
||||
version: "go1.22".into(),
|
||||
compiler: "gc".into(),
|
||||
cgo: "0".into(),
|
||||
custom: "integration debug,feature".into(),
|
||||
experiments: "loopvar, rangefunc ".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
build_settings(&tags),
|
||||
json!({
|
||||
"buildFlags": ["-tags=integration,debug,feature,go1.22", "-compiler=gc"],
|
||||
"env": {"GOOS":"linux", "GOARCH":"arm64", "CGO_ENABLED":"0", "GOEXPERIMENT":"loopvar,rangefunc"}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Project build constraints for Go command launches. Explicit run environment wins.
|
||||
pub fn apply_to_run(config: &mut ide_run::RunConfig, tags: &GoBuildTags) {
|
||||
let settings = build_settings(tags);
|
||||
if let Some(env) = settings["env"].as_object() {
|
||||
for (key, value) in env {
|
||||
if let Some(value) = value.as_str() {
|
||||
config.env.entry(key.clone()).or_insert_with(|| value.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.extras.params.get("useTags").is_some_and(|v| v == "true") {
|
||||
let extra = settings["buildFlags"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
let mut args = config.args.split_off(usize::from(!config.args.is_empty()));
|
||||
merge_flags(&mut args, &extra);
|
||||
config.args.extend(args);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_flags(args: &mut Vec<String>, extra: &[String]) {
|
||||
let boundary = args.iter().position(|a| a == "-args" || a == "--").unwrap_or(args.len());
|
||||
let tail = args.split_off(boundary);
|
||||
let mut tags = Vec::new();
|
||||
let mut merged = Vec::new();
|
||||
let mut all = extra.iter().chain(args.iter());
|
||||
while let Some(arg) = all.next() {
|
||||
if arg == "-tags" {
|
||||
if let Some(value) = all.next() {
|
||||
tags.push(value.as_str());
|
||||
}
|
||||
} else if let Some(value) = arg.strip_prefix("-tags=") {
|
||||
tags.push(value);
|
||||
} else {
|
||||
merged.push(arg.clone());
|
||||
}
|
||||
}
|
||||
if !tags.is_empty() {
|
||||
merged.insert(0, format!("-tags={}", tags.join(",")));
|
||||
}
|
||||
merged.extend(tail);
|
||||
*args = merged;
|
||||
}
|
||||
|
||||
fn delve_build_flags(args: &[String], files: bool) -> Value {
|
||||
if !files {
|
||||
return json!(args);
|
||||
}
|
||||
json!(
|
||||
args.iter()
|
||||
.map(|arg| format!("'{}'", arg.replace('\\', "\\\\").replace('\'', "\\'")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply_to_debug(
|
||||
env: &mut std::collections::BTreeMap<String, String>,
|
||||
extra_json: &mut Option<String>,
|
||||
tags: &GoBuildTags,
|
||||
) -> ide_core::IdeResult<()> {
|
||||
let mut extra: Value = extra_json
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()
|
||||
.map_err(|e| ide_core::IdeError::invalid(e.to_string()))?
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let settings = build_settings(tags);
|
||||
if let Some(values) = settings["env"].as_object() {
|
||||
for (key, value) in values {
|
||||
if let Some(value) = value.as_str() {
|
||||
env.entry(key.clone()).or_insert_with(|| value.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if extra.get("__goToolArgs").is_some() {
|
||||
let mut args: Vec<String> = extra
|
||||
.get("__goToolArgs")
|
||||
.cloned()
|
||||
.map(serde_json::from_value)
|
||||
.transpose()
|
||||
.map_err(|e| ide_core::IdeError::invalid(e.to_string()))?
|
||||
.unwrap_or_default();
|
||||
if extra.get("__goUseTags").and_then(Value::as_bool) == Some(true) {
|
||||
let flags = settings["buildFlags"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
merge_flags(&mut args, &flags);
|
||||
}
|
||||
if let Some(files) = extra.get("__goFiles").and_then(Value::as_array) {
|
||||
if extra["mode"] == "test" {
|
||||
if let Some(dir) = files.first().and_then(Value::as_str).and_then(|f| std::path::Path::new(f).parent())
|
||||
{
|
||||
let mut command = std::process::Command::new("go");
|
||||
ide_core::process::host_env(&mut command);
|
||||
command.envs(env.iter());
|
||||
let sources = ide_run::go::package_sources(command, dir, &args)?;
|
||||
args.extend(sources.iter().map(|p| p.to_string_lossy().into_owned()));
|
||||
}
|
||||
} else {
|
||||
args.extend(files.iter().skip(1).filter_map(Value::as_str).map(str::to_owned));
|
||||
}
|
||||
}
|
||||
// Delve appends its -o after array build flags; positional source files
|
||||
// require the string form so all compiler flags precede the file list.
|
||||
extra["buildFlags"] = delve_build_flags(&args, extra.get("__goFiles").is_some());
|
||||
}
|
||||
if let Some(output) = extra.get("output").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).parent()) {
|
||||
std::fs::create_dir_all(output)?;
|
||||
}
|
||||
if let Some(map) = extra.as_object_mut() {
|
||||
map.remove("__goUseTags");
|
||||
map.remove("__goToolArgs");
|
||||
map.remove("__goFiles");
|
||||
}
|
||||
*extra_json = Some(extra.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod command_tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn merges_custom_tags_without_touching_program_arguments() {
|
||||
let mut flags = vec!["-tags".into(), "explicit".into(), ".".into(), "-args".into(), "-tags=program".into()];
|
||||
merge_flags(&mut flags, &["-tags=project,debug".into()]);
|
||||
assert_eq!(flags, ["-tags=project,debug,explicit", ".", "-args", "-tags=program"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_debug_flags_keep_delves_output_flags_before_source_files() {
|
||||
let flags = vec!["-tags=demo".into(), "C:\\a b\\main.go".into(), "quote'file.go".into()];
|
||||
assert_eq!(delve_build_flags(&flags, false), json!(flags));
|
||||
assert_eq!(delve_build_flags(&flags, true), json!("'-tags=demo' 'C:\\\\a b\\\\main.go' 'quote\\'file.go'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_env_and_flags_use_the_same_constraints_and_preserve_overrides() {
|
||||
let tags = GoBuildTags { custom: "project".into(), os: "linux".into(), ..Default::default() };
|
||||
let mut env = BTreeMap::from([("GOOS".into(), "darwin".into())]);
|
||||
let mut extra =
|
||||
Some(json!({"mode":"test", "__goUseTags":true, "__goToolArgs":["-tags", "explicit"]}).to_string());
|
||||
apply_to_debug(&mut env, &mut extra, &tags).unwrap();
|
||||
let extra: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(extra["buildFlags"], json!(["-tags=project,explicit"]));
|
||||
assert!(extra.get("__goUseTags").is_none());
|
||||
assert_eq!(env["GOOS"], "darwin");
|
||||
assert!(apply_to_debug(&mut env, &mut Some("{".into()), &tags).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug_environment(
|
||||
root: &std::path::Path,
|
||||
settings: &ide_core::settings::Settings,
|
||||
env: &mut std::collections::BTreeMap<String, String>,
|
||||
) {
|
||||
for (key, value) in module_environment(root, &settings.languages.go.modules) {
|
||||
env.entry(key).or_insert(value);
|
||||
}
|
||||
if let Some(goroot) = crate::apply::goroot(root, settings) {
|
||||
env.entry("GOROOT".into()).or_insert_with(|| goroot.to_string_lossy().into_owned());
|
||||
env.entry("PATH".into()).or_insert_with(|| crate::exec::path_with(&[&goroot.join("bin")]));
|
||||
}
|
||||
if let Some(gopath) = crate::apply::setting_path(root, &settings.languages.go.gopath) {
|
||||
env.entry("GOPATH".into()).or_insert_with(|| gopath.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn module_environment(
|
||||
root: &std::path::Path,
|
||||
modules: &ide_core::settings::GoModules,
|
||||
) -> std::collections::BTreeMap<String, String> {
|
||||
let mut env: std::collections::BTreeMap<String, String> = modules
|
||||
.env
|
||||
.iter()
|
||||
.filter(|(key, value)| {
|
||||
!key.is_empty()
|
||||
&& !key.contains(['=', '\0'])
|
||||
&& !key.contains(char::is_whitespace)
|
||||
&& !value.trim().is_empty()
|
||||
&& !value.contains('\0')
|
||||
})
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
for key in ["GOWORK", "GOMODCACHE"] {
|
||||
if let Some(value) = env.get_mut(key)
|
||||
&& value != "off"
|
||||
&& value != "auto"
|
||||
{
|
||||
*value = root.join(value.as_str()).to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
if !modules.auto_vendor {
|
||||
let flags = env
|
||||
.get("GOFLAGS")
|
||||
.cloned()
|
||||
.or_else(|| ide_core::process::host_var_os("GOFLAGS").map(|v| v.to_string_lossy().into_owned()))
|
||||
.unwrap_or_default();
|
||||
if !flags.split_whitespace().any(|flag| flag.starts_with("-mod=")) {
|
||||
let workspace = match env.get("GOWORK").map(String::as_str) {
|
||||
Some("off") => false,
|
||||
Some(path) if path != "auto" => !path.is_empty(),
|
||||
_ => root.ancestors().any(|dir| dir.join("go.work").is_file()),
|
||||
};
|
||||
env.insert(
|
||||
"GOFLAGS".into(),
|
||||
format!("{} -mod={}", flags.trim(), if workspace { "readonly" } else { "mod" }).trim().into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod module_tests {
|
||||
use super::*;
|
||||
use ide_core::settings::GoModules;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn defaults_and_invalid_entries_leave_the_environment_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(module_environment(dir.path(), &GoModules::default()).is_empty());
|
||||
let modules = GoModules {
|
||||
env: BTreeMap::from([
|
||||
("BAD KEY".into(), "x".into()),
|
||||
("GOPROXY".into(), "".into()),
|
||||
("GOPRIVATE".into(), "example.com/private".into()),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
module_environment(dir.path(), &modules),
|
||||
BTreeMap::from([("GOPRIVATE".into(), "example.com/private".into())])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_automatic_vendor_uses_workspace_legal_flags_and_keeps_explicit_flags() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut modules = GoModules { auto_vendor: false, env: BTreeMap::from([("GOFLAGS".into(), "-race".into())]) };
|
||||
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=mod");
|
||||
std::fs::write(dir.path().join("go.work"), "go 1.22\n").unwrap();
|
||||
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=readonly");
|
||||
modules.env.insert("GOWORK".into(), "off".into());
|
||||
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=mod");
|
||||
modules.env.insert("GOFLAGS".into(), "-mod=vendor".into());
|
||||
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-mod=vendor");
|
||||
}
|
||||
}
|
||||
@@ -333,6 +333,7 @@ pub fn configure_jdtls(
|
||||
if cfg.name != "jdtls" {
|
||||
return;
|
||||
}
|
||||
crate::apply::configure_java_compiler(cfg, data_dir);
|
||||
// A Lombok agent from the user's JVM arguments wins.
|
||||
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
|
||||
let agent = own_agent.as_deref().or(lombok);
|
||||
|
||||
@@ -9,6 +9,7 @@ mod angular;
|
||||
pub mod apply;
|
||||
mod discover;
|
||||
mod exec;
|
||||
pub mod go;
|
||||
pub mod godot;
|
||||
pub mod java;
|
||||
pub mod jdtls;
|
||||
|
||||
@@ -92,6 +92,10 @@ struct Cached {
|
||||
|
||||
static CACHE: LazyLock<Mutex<HashMap<(PathBuf, &'static str), Cached>>> = LazyLock::new(Default::default);
|
||||
|
||||
pub fn invalidate_go(root: &Path) {
|
||||
CACHE.lock().unwrap_or_else(|e| e.into_inner()).remove(&(root.to_path_buf(), "go"));
|
||||
}
|
||||
|
||||
fn stamp(files: &[PathBuf]) -> Stamp {
|
||||
files
|
||||
.iter()
|
||||
@@ -410,18 +414,27 @@ pub(crate) fn parse_cargo_metadata(json: &str) -> Vec<(String, PathBuf)> {
|
||||
// ------------------------------------------------------------------ go
|
||||
|
||||
fn go_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
let Some(files) = manifests(root, GO_MANIFESTS) else { return Vec::new() };
|
||||
let Some(mut files) = manifests(root, GO_MANIFESTS) else { return Vec::new() };
|
||||
if !root.join("go.mod").is_file() && !root.join("go.work").is_file() {
|
||||
return Vec::new();
|
||||
}
|
||||
if let Ok(work) = std::fs::read_to_string(root.join("go.work")) {
|
||||
for module in ide_run::go::work_modules(&work) {
|
||||
files.extend([root.join(&module).join("go.mod"), root.join(&module).join("go.sum")]);
|
||||
}
|
||||
}
|
||||
files.extend([root.join("go.work.sum"), root.join("vendor/modules.txt")]);
|
||||
let g = &s.languages.go;
|
||||
cached(root, "go", format!("{}\n{}", g.goroot, g.gopath), &files, || {
|
||||
cached(root, "go", serde_json::to_string(g).unwrap_or_default(), &files, || {
|
||||
let goroot = crate::apply::goroot(root, s);
|
||||
let Some(go) = tool(goroot.as_ref().map(|r| r.join("bin")), "go") else { return Vec::new() };
|
||||
let goroot_s = goroot.as_ref().map(|p| p.to_string_lossy().into_owned());
|
||||
let gopath = crate::apply::setting_path(root, &g.gopath).map(|p| p.to_string_lossy().into_owned());
|
||||
// Never download modules or toolchains just to list libraries.
|
||||
let mut env = vec![("GOPROXY", "off"), ("GOTOOLCHAIN", "local")];
|
||||
let module_env = crate::go::module_environment(root, &g.modules);
|
||||
let mut env: Vec<(&str, &str)> = module_env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
// Listing libraries must stay offline even when module commands use a proxy.
|
||||
env.extend([("GOPROXY", "off"), ("GOTOOLCHAIN", "local")]);
|
||||
env.extend(goroot_s.as_deref().map(|v| ("GOROOT", v)));
|
||||
env.extend(gopath.as_deref().map(|v| ("GOPATH", v)));
|
||||
let mut out = Vec::new();
|
||||
@@ -436,13 +449,38 @@ fn go_groups(root: &Path, s: &Settings) -> Vec<LibraryGroup> {
|
||||
out.extend(group(name, "go", library_roots(vec![Path::new(dir).join("src")])));
|
||||
}
|
||||
}
|
||||
if let Some(text) = query_in(&go, &["list", "-m", "-e", "-json", "all"], Some(root), &env, GRAPH_TIMEOUT) {
|
||||
let flags = module_env.get("GOFLAGS").map(String::as_str).unwrap_or("");
|
||||
let vendor = root.join("vendor");
|
||||
let use_vendor = vendor.is_dir()
|
||||
&& (flags.split_whitespace().any(|f| f == "-mod=vendor")
|
||||
|| (g.modules.auto_vendor && !flags.split_whitespace().any(|f| f.starts_with("-mod="))));
|
||||
if use_vendor {
|
||||
if let Ok(text) = std::fs::read_to_string(vendor.join("modules.txt")) {
|
||||
let modules = vendor_roots(&vendor, &text);
|
||||
out.extend(group("Go Modules", "goModules", named_roots(modules)));
|
||||
}
|
||||
} else if let Some(text) = query_in(&go, &["list", "-m", "-e", "-json", "all"], Some(root), &env, GRAPH_TIMEOUT)
|
||||
{
|
||||
out.extend(group("Go Modules", "goModules", named_roots(parse_go_modules(&text))));
|
||||
}
|
||||
out
|
||||
})
|
||||
}
|
||||
|
||||
fn vendor_roots(vendor: &Path, text: &str) -> Vec<(String, PathBuf)> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
text.lines()
|
||||
.filter_map(|line| {
|
||||
let module = line.strip_prefix("# ")?.split_whitespace().next()?;
|
||||
if module == "=>" || !seen.insert(module) {
|
||||
return None;
|
||||
}
|
||||
let path = vendor.join(module);
|
||||
path.is_dir().then(|| (module.to_string(), path))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `(path@version, dir)` of the downloaded non-main modules in `go list -m -json` output.
|
||||
pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> {
|
||||
#[derive(Deserialize)]
|
||||
@@ -467,3 +505,34 @@ pub(crate) fn parse_go_modules(text: &str) -> Vec<(String, PathBuf)> {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod go_cache_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn vendor_roots_include_local_replacements_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("example.com/local")).unwrap();
|
||||
let text = "# example.com/local v0.0.0 => ../local\n## explicit; go 1.22\nexample.com/local\n# example.com/local => ../local\n";
|
||||
assert_eq!(
|
||||
vendor_roots(dir.path(), text),
|
||||
vec![("example.com/local".into(), dir.path().join("example.com/local"))]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_commands_invalidate_the_graph_even_when_manifests_are_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let count = std::cell::Cell::new(0);
|
||||
let detect = || {
|
||||
count.set(count.get() + 1);
|
||||
Vec::new()
|
||||
};
|
||||
cached(dir.path(), "go", "same".into(), &[], detect);
|
||||
cached(dir.path(), "go", "same".into(), &[], detect);
|
||||
assert_eq!(count.get(), 1);
|
||||
invalidate_go(dir.path());
|
||||
cached(dir.path(), "go", "same".into(), &[], detect);
|
||||
assert_eq!(count.get(), 2);
|
||||
}
|
||||
}
|
||||
@@ -967,3 +967,70 @@ fn node_on_the_search_path_keeps_its_version_manager_as_source() {
|
||||
assert_eq!(source("/home/me/.local/bin/node"), "path");
|
||||
assert_eq!(source("/usr/bin/node"), "system");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_constraints_change_only_protocol_settings() {
|
||||
use serde_json::json;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut settings = Settings::default();
|
||||
let mut original = server("gopls");
|
||||
original.settings = Some(json!({"analyses":{"unusedparams":true}}));
|
||||
let mut defaults = original.clone();
|
||||
configure_server(&mut defaults, tmp.path(), tmp.path(), &settings);
|
||||
assert_eq!(defaults.settings, Some(json!({"analyses":{"unusedparams":true}, "buildFlags":[], "env":{}})));
|
||||
defaults.settings = original.settings.clone();
|
||||
assert_eq!(defaults, original);
|
||||
settings.languages.go.build_tags.custom = "integration".into();
|
||||
settings.languages.go.build_tags.os = "linux".into();
|
||||
let mut configured = original.clone();
|
||||
configure_server(&mut configured, tmp.path(), tmp.path(), &settings);
|
||||
assert_eq!(
|
||||
configured.settings.take(),
|
||||
Some(json!({
|
||||
"analyses":{"unusedparams":true}, "buildFlags":["-tags=integration"], "env":{"GOOS":"linux"}
|
||||
}))
|
||||
);
|
||||
configured.settings = original.settings.clone();
|
||||
assert_eq!(configured, original, "launch configuration stays unchanged for in-place reconfiguration");
|
||||
let mut other = server("rust-analyzer");
|
||||
configure_server(&mut other, tmp.path(), tmp.path(), &settings);
|
||||
assert_eq!(other.settings, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_empty_constraints_preserve_explicit_server_defaults() {
|
||||
use serde_json::json;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut cfg = server("gopls");
|
||||
cfg.initialization_options =
|
||||
Some(toml::Value::try_from(json!({"buildFlags":["-tags=legacy"], "env":{"GOPROXY":"direct"}})).unwrap());
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &Settings::default());
|
||||
assert_eq!(cfg.settings, Some(json!({"buildFlags":["-tags=legacy"], "env":{"GOPROXY":"direct"}})));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_modules_configure_gopls_in_place_and_commands_with_explicit_run_overrides() {
|
||||
use serde_json::json;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut settings = Settings::default();
|
||||
settings.languages.go.modules.env = BTreeMap::from([
|
||||
("GOPROXY".into(), "https://proxy.example".into()),
|
||||
("GOPRIVATE".into(), "example.com/private".into()),
|
||||
("GOFLAGS".into(), "-race".into()),
|
||||
]);
|
||||
settings.languages.go.modules.auto_vendor = false;
|
||||
let mut cfg = server("gopls");
|
||||
configure_server(&mut cfg, tmp.path(), tmp.path(), &settings);
|
||||
let protocol = cfg.settings.take().unwrap();
|
||||
assert_eq!(protocol["env"]["GOFLAGS"], "-race -mod=mod");
|
||||
assert_eq!(protocol["env"]["GOPRIVATE"], "example.com/private");
|
||||
assert!(cfg.env.is_empty(), "module environment changes must not restart gopls");
|
||||
let mut run = run("go", "go", &["mod", "tidy"]);
|
||||
run.env.insert("GOPROXY".into(), "off".into());
|
||||
apply_to_run_configs(std::slice::from_mut(&mut run), tmp.path(), &settings);
|
||||
assert_eq!(run.env["GOPROXY"], "off");
|
||||
assert_eq!(run.env["GOPRIVATE"], "example.com/private");
|
||||
let mut cleared = server("gopls");
|
||||
configure_server(&mut cleared, tmp.path(), tmp.path(), &Settings::default());
|
||||
assert_eq!(cleared.settings.unwrap()["env"], json!({}));
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//! Filesystem operations shared by deployment and remote development bootstrap.
|
||||
use crate::sftp::{RemoteEntry, SftpFs};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use russh_sftp::client::SftpSession;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FsConnection {
|
||||
Sftp { ssh: String },
|
||||
Local,
|
||||
}
|
||||
|
||||
pub enum RemoteFs {
|
||||
Sftp(SftpFs),
|
||||
Local,
|
||||
}
|
||||
|
||||
impl RemoteFs {
|
||||
/// Keeps SSH bootstrap callers explicitly on SFTP.
|
||||
pub fn new(sftp: SftpSession) -> Self {
|
||||
Self::Sftp(SftpFs::new(sftp))
|
||||
}
|
||||
|
||||
pub async fn home(&self) -> IdeResult<String> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.home().await,
|
||||
Self::Local => {
|
||||
dirs::home_dir().map(|p| path_string(&p)).ok_or_else(|| IdeError::invalid("Home folder is unavailable"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resolve(&self, path: &str) -> IdeResult<String> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.resolve(path).await,
|
||||
Self::Local => {
|
||||
let expanded = crate::expand(path.trim());
|
||||
let path = if path.trim().is_empty() || path.trim() == "~" {
|
||||
std::path::PathBuf::from(self.home().await?)
|
||||
} else {
|
||||
expanded
|
||||
};
|
||||
let path = if path.is_absolute() { path } else { std::env::current_dir()?.join(path) };
|
||||
Ok(path_string(&path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stat(&self, path: &str) -> IdeResult<Option<RemoteEntry>> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.stat(path).await,
|
||||
Self::Local => match tokio::fs::metadata(path).await {
|
||||
Ok(meta) => Ok(Some(local_entry(path, meta).await?)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(&self, dir: &str) -> IdeResult<Vec<RemoteEntry>> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.list(dir).await,
|
||||
Self::Local => {
|
||||
let mut read = tokio::fs::read_dir(dir).await?;
|
||||
let mut entries = Vec::new();
|
||||
while let Some(e) = read.next_entry().await? {
|
||||
let path = path_string(&e.path());
|
||||
entries.push(local_entry(&path, tokio::fs::symlink_metadata(e.path()).await?).await?);
|
||||
}
|
||||
entries.sort_by(|a, b| {
|
||||
b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||
});
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn read(&self, path: &str) -> IdeResult<Vec<u8>> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.read(path).await,
|
||||
Self::Local => Ok(tokio::fs::read(path).await?),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write(&self, path: &str, data: &[u8], mtime: Option<u32>) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.write(path, data, mtime).await,
|
||||
Self::Local => {
|
||||
if let Some(parent) = Path::new(path).parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::write(path, data).await?;
|
||||
if let Some(t) = mtime {
|
||||
set_mtime(path, t).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn upload(&self, local: &Path, remote: &str, preserve_time: bool) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.upload(local, remote, preserve_time).await,
|
||||
Self::Local => {
|
||||
reject_same_file(local, Path::new(remote)).await?;
|
||||
copy_file(local, Path::new(remote)).await?;
|
||||
if let Some(t) = preserve_time.then(|| crate::sftp::mtime_of(local)).flatten() {
|
||||
set_mtime(remote, t).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.download(remote, local).await,
|
||||
Self::Local => {
|
||||
reject_same_file(Path::new(remote), local).await?;
|
||||
copy_file(Path::new(remote), local).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mkdir_all(&self, path: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.mkdir_all(path).await,
|
||||
Self::Local => Ok(tokio::fs::create_dir_all(path).await?),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove(&self, path: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.remove(path).await,
|
||||
Self::Local => {
|
||||
let meta = tokio::fs::symlink_metadata(path).await?;
|
||||
if meta.is_dir() {
|
||||
tokio::fs::remove_dir_all(path).await?;
|
||||
} else {
|
||||
tokio::fs::remove_file(path).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rename(&self, from: &str, to: &str) -> IdeResult<()> {
|
||||
match self {
|
||||
Self::Sftp(fs) => fs.rename(from, to).await,
|
||||
Self::Local => Ok(tokio::fs::rename(from, to).await?),
|
||||
}
|
||||
}
|
||||
|
||||
/// Probe actual write access, including ACLs and read-only mounts.
|
||||
pub async fn test(&self, path: &str) -> IdeResult<()> {
|
||||
let path = self.resolve(path).await?;
|
||||
let entry =
|
||||
self.stat(&path).await?.ok_or_else(|| IdeError::not_found(format!("Folder does not exist: {path}")))?;
|
||||
if !entry.is_dir {
|
||||
return Err(IdeError::invalid(format!("Not a folder: {path}")));
|
||||
}
|
||||
if matches!(self, Self::Local) {
|
||||
let name = format!(
|
||||
".openidea-write-test-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos()
|
||||
);
|
||||
let probe = Path::new(&path).join(name);
|
||||
let mut file =
|
||||
tokio::fs::OpenOptions::new().write(true).create_new(true).open(&probe).await.map_err(|e| {
|
||||
IdeError::new(
|
||||
ide_core::error::ErrorKind::PermissionDenied,
|
||||
format!("Folder is not writable: {path}: {e}"),
|
||||
)
|
||||
})?;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let result = file.write_all(b"test").await;
|
||||
drop(file);
|
||||
let cleanup = tokio::fs::remove_file(probe).await;
|
||||
result.and(cleanup).map_err(|e| {
|
||||
IdeError::new(
|
||||
ide_core::error::ErrorKind::PermissionDenied,
|
||||
format!("Folder is not writable: {path}: {e}"),
|
||||
)
|
||||
})?;
|
||||
} else {
|
||||
self.list(&path).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
if let Self::Sftp(fs) = self {
|
||||
fs.close().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copies without holding the file in memory: deployments can include large archives.
|
||||
async fn copy_file(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
if let Some(parent) = to.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
tokio::fs::copy(from, to).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn set_mtime(path: &str, seconds: u32) {
|
||||
let result = async {
|
||||
let file = tokio::fs::OpenOptions::new().write(true).open(path).await?.into_std().await;
|
||||
file.set_modified(std::time::UNIX_EPOCH + std::time::Duration::from_secs(u64::from(seconds)))
|
||||
};
|
||||
if let Err(e) = result.await {
|
||||
tracing::debug!("Cannot set the time of {path}: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn path_string(path: &Path) -> String {
|
||||
let path = path.to_string_lossy().into_owned();
|
||||
#[cfg(windows)]
|
||||
let path = path.replace('\\', "/");
|
||||
path
|
||||
}
|
||||
|
||||
async fn local_entry(path: &str, meta: std::fs::Metadata) -> IdeResult<RemoteEntry> {
|
||||
#[cfg(unix)]
|
||||
let permissions = {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
crate::sftp::mode_string(meta.permissions().mode())
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let permissions = if meta.permissions().readonly() { "r--r--r--" } else { "rw-rw-rw-" }.to_owned();
|
||||
let is_dir = meta.is_dir() || (meta.is_symlink() && tokio::fs::metadata(path).await.is_ok_and(|m| m.is_dir()));
|
||||
Ok(RemoteEntry {
|
||||
name: Path::new(path).file_name().unwrap_or_default().to_string_lossy().into_owned(),
|
||||
path: path.to_owned(),
|
||||
is_dir,
|
||||
is_symlink: meta.is_symlink(),
|
||||
size: meta.len(),
|
||||
mtime: meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.and_then(|t| t.as_secs().try_into().ok()),
|
||||
permissions,
|
||||
})
|
||||
}
|
||||
|
||||
async fn reject_same_file(from: &Path, to: &Path) -> IdeResult<()> {
|
||||
let source = tokio::fs::metadata(from).await?;
|
||||
let target = match tokio::fs::metadata(to).await {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
#[cfg(unix)]
|
||||
let same = {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
source.dev() == target.dev() && source.ino() == target.ino()
|
||||
};
|
||||
#[cfg(not(unix))]
|
||||
let same = {
|
||||
let _ = (source, target);
|
||||
tokio::fs::canonicalize(from).await? == tokio::fs::canonicalize(to).await?
|
||||
};
|
||||
if same {
|
||||
Err(IdeError::invalid(format!("Source and destination are the same file: {}", from.display())))
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! processes, SFTP, local port forwarding, and askpass for `ssh` / `git`.
|
||||
|
||||
pub mod askpass;
|
||||
pub mod filesystem;
|
||||
pub mod git_creds;
|
||||
pub mod manager;
|
||||
pub mod secrets;
|
||||
@@ -14,9 +15,10 @@ pub mod target;
|
||||
pub mod testing;
|
||||
pub mod tunnel;
|
||||
|
||||
pub use filesystem::{FsConnection, RemoteFs};
|
||||
pub use manager::SshManager;
|
||||
pub use russh::Sig;
|
||||
pub use session::{ConnectError, ExecOutput, Output, RemoteProcess, Session, UnknownHostKey};
|
||||
pub use sftp::{RemoteEntry, RemoteFs};
|
||||
pub use sftp::RemoteEntry;
|
||||
pub use target::{SshFiles, SshTarget, expand, openssh_args};
|
||||
pub use tunnel::Tunnel;
|
||||
@@ -41,7 +41,7 @@ pub fn parent(path: &str) -> Option<&str> {
|
||||
Some(if i == 0 { "/" } else { &trimmed[..i] })
|
||||
}
|
||||
|
||||
fn mode_string(mode: u32) -> String {
|
||||
pub(crate) fn mode_string(mode: u32) -> String {
|
||||
let bits = [
|
||||
(0o400, 'r'),
|
||||
(0o200, 'w'),
|
||||
@@ -78,11 +78,11 @@ fn err(path: &str, e: SftpError) -> IdeError {
|
||||
}
|
||||
|
||||
/// Higher-level operations on an [`SftpSession`].
|
||||
pub struct RemoteFs {
|
||||
pub struct SftpFs {
|
||||
sftp: SftpSession,
|
||||
}
|
||||
|
||||
impl RemoteFs {
|
||||
impl SftpFs {
|
||||
pub fn new(sftp: SftpSession) -> Self {
|
||||
Self { sftp }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
//! Syntactic presentation of the primary top-level JVM declaration.
|
||||
use crate::{Snapshot, StructureResult, StructureService};
|
||||
use ide_core::CancellationToken;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use tree_sitter::Node;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum JvmTypeKind {
|
||||
Class,
|
||||
AbstractClass,
|
||||
Interface,
|
||||
Enum,
|
||||
Record,
|
||||
Annotation,
|
||||
Exception,
|
||||
Object,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JvmPresentation {
|
||||
pub kind: Option<JvmTypeKind>,
|
||||
pub spring_boot: bool,
|
||||
pub runnable: bool,
|
||||
}
|
||||
|
||||
fn text<'a>(node: Node, source: &'a str) -> &'a str {
|
||||
source.get(node.byte_range()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn children(node: Node) -> Vec<Node> {
|
||||
node.children(&mut node.walk()).collect()
|
||||
}
|
||||
|
||||
fn descendant(node: Node<'_>, predicate: &impl Fn(Node<'_>) -> bool) -> bool {
|
||||
predicate(node) || node.children(&mut node.walk()).any(|n| descendant(n, predicate))
|
||||
}
|
||||
|
||||
fn keyword(node: Node, source: &str, word: &str) -> bool {
|
||||
descendant(node, &|n| {
|
||||
n.child_count() == 0 && (n.kind() == word || n.kind().ends_with("_modifier") && text(n, source) == word)
|
||||
})
|
||||
}
|
||||
|
||||
fn annotation(node: Node, source: &str, name: &str) -> bool {
|
||||
node.named_children(&mut node.walk()).any(|n| {
|
||||
if !matches!(n.kind(), "annotation" | "marker_annotation") {
|
||||
return false;
|
||||
}
|
||||
if let Some(id) = n.child_by_field_name("name") {
|
||||
return text(id, source).rsplit('.').next() == Some(name);
|
||||
}
|
||||
// Kotlin: @ -> constructor_invocation -> user_type (or type -> user_type).
|
||||
let mut current = n;
|
||||
while let Some(first) = current.named_children(&mut current.walk()).next() {
|
||||
if matches!(first.kind(), "use_site_target") {
|
||||
return false;
|
||||
}
|
||||
if first.kind() == "user_type" {
|
||||
let ids: Vec<_> =
|
||||
first.named_children(&mut first.walk()).filter(|c| c.kind() == "identifier").collect();
|
||||
return ids.last().is_some_and(|id| text(*id, source) == name);
|
||||
}
|
||||
current = first;
|
||||
}
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
fn exception_base(node: Node, source: &str) -> bool {
|
||||
if matches!(node.kind(), "type_arguments" | "value_arguments" | "explicit_delegation") {
|
||||
return false;
|
||||
}
|
||||
if matches!(node.kind(), "type_identifier" | "user_type" | "scoped_type_identifier") {
|
||||
let name = if node.kind() == "user_type" || node.kind() == "scoped_type_identifier" {
|
||||
node.named_children(&mut node.walk())
|
||||
.filter(|n| matches!(n.kind(), "identifier" | "type_identifier"))
|
||||
.last()
|
||||
.map(|n| text(n, source))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
text(node, source)
|
||||
};
|
||||
return name.ends_with("Exception") || name.ends_with("Error") || name == "Throwable";
|
||||
}
|
||||
node.named_children(&mut node.walk()).any(|n| exception_base(n, source))
|
||||
}
|
||||
|
||||
fn members(node: Node<'_>) -> Vec<Node<'_>> {
|
||||
let mut out = Vec::new();
|
||||
for child in node.named_children(&mut node.walk()) {
|
||||
if matches!(child.kind(), "method_declaration" | "function_declaration") {
|
||||
out.push(child);
|
||||
}
|
||||
if matches!(child.kind(), "companion_object" | "class_body" | "enum_body_declarations") {
|
||||
out.extend(members(child));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn is_type(node: Node) -> bool {
|
||||
matches!(
|
||||
node.kind(),
|
||||
"class_declaration"
|
||||
| "interface_declaration"
|
||||
| "enum_declaration"
|
||||
| "record_declaration"
|
||||
| "annotation_type_declaration"
|
||||
| "object_declaration"
|
||||
)
|
||||
}
|
||||
|
||||
impl StructureService {
|
||||
/// Only top-level declarations participate; the filename-matching declaration
|
||||
/// wins, otherwise the first type does. Strings/comments never supply evidence.
|
||||
pub fn jvm_presentation(
|
||||
&self,
|
||||
snap: &Snapshot,
|
||||
stem: &str,
|
||||
cancel: &CancellationToken,
|
||||
) -> StructureResult<JvmPresentation> {
|
||||
self.run(snap, cancel, |cx| {
|
||||
if !matches!(cx.grammar, "java" | "kotlin") {
|
||||
return Ok(JvmPresentation::default());
|
||||
}
|
||||
let top = cx.tree.root_node();
|
||||
let types: Vec<_> = top.named_children(&mut top.walk()).filter(|n| is_type(*n)).collect();
|
||||
let Some(node) = types
|
||||
.iter()
|
||||
.find(|n| n.child_by_field_name("name").is_some_and(|id| text(id, snap.text) == stem))
|
||||
.or(types.first())
|
||||
.copied()
|
||||
else {
|
||||
return Ok(JvmPresentation::default());
|
||||
};
|
||||
let parts = children(node);
|
||||
let modifiers = parts.iter().find(|n| n.kind() == "modifiers").copied();
|
||||
let has = |word| modifiers.is_some_and(|m| keyword(m, snap.text, word));
|
||||
let supertype = parts.iter().find(|n| matches!(n.kind(), "superclass" | "delegation_specifiers"));
|
||||
let exception = supertype.is_some_and(|s| exception_base(*s, snap.text));
|
||||
let kind = match node.kind() {
|
||||
"annotation_type_declaration" => JvmTypeKind::Annotation,
|
||||
"interface_declaration" => JvmTypeKind::Interface,
|
||||
"enum_declaration" => JvmTypeKind::Enum,
|
||||
"record_declaration" => JvmTypeKind::Record,
|
||||
"object_declaration" => JvmTypeKind::Object,
|
||||
_ if has("annotation") => JvmTypeKind::Annotation,
|
||||
_ if has("enum") => JvmTypeKind::Enum,
|
||||
_ if parts.iter().any(|n| n.kind() == "interface") => JvmTypeKind::Interface,
|
||||
_ if exception => JvmTypeKind::Exception,
|
||||
_ if has("abstract") => JvmTypeKind::AbstractClass,
|
||||
_ => JvmTypeKind::Class,
|
||||
};
|
||||
let spring_boot = modifiers.is_some_and(|m| annotation(m, snap.text, "SpringBootApplication"));
|
||||
let body = parts.iter().find(|n| n.kind() == "class_body");
|
||||
let runnable = body.is_some_and(|b| {
|
||||
members(*b).into_iter().any(|n| {
|
||||
if !matches!(n.kind(), "method_declaration" | "function_declaration")
|
||||
|| !n.child_by_field_name("name").is_some_and(|id| text(id, snap.text) == "main")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if cx.grammar == "kotlin" {
|
||||
return children(n)
|
||||
.iter()
|
||||
.any(|m| m.kind() == "modifiers" && annotation(*m, snap.text, "JvmStatic"));
|
||||
}
|
||||
let modifiers = children(n).into_iter().find(|m| m.kind() == "modifiers");
|
||||
let params = n.child_by_field_name("parameters");
|
||||
modifiers.is_some_and(|m| keyword(m, snap.text, "static") && keyword(m, snap.text, "public"))
|
||||
&& n.child_by_field_name("type").is_some_and(|t| text(t, snap.text) == "void")
|
||||
&& params.is_some_and(|p| {
|
||||
let args: Vec<_> = p.named_children(&mut p.walk()).collect();
|
||||
args.len() == 1
|
||||
&& descendant(args[0], &|t| {
|
||||
t.kind() == "type_identifier"
|
||||
&& matches!(text(t, snap.text), "String" | "java.lang.String")
|
||||
})
|
||||
&& (keyword(args[0], snap.text, "[") || keyword(args[0], snap.text, "..."))
|
||||
})
|
||||
})
|
||||
});
|
||||
Ok(JvmPresentation { kind: Some(kind), spring_boot, runnable })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
//! the query loops honour the [`CancellationToken`].
|
||||
|
||||
mod identifiers;
|
||||
mod jvm;
|
||||
mod kinds;
|
||||
mod lines;
|
||||
mod locals;
|
||||
@@ -28,6 +29,7 @@ mod types;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use jvm::{JvmPresentation, JvmTypeKind};
|
||||
pub use types::*;
|
||||
|
||||
use ide_core::{CancellationToken, DocId, IdeError, LanguageId, Pos, Range};
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
use super::*;
|
||||
use crate::{JvmPresentation, JvmTypeKind::*};
|
||||
|
||||
fn presentation(language: &str, text: &str) -> JvmPresentation {
|
||||
service().jvm_presentation(&Snapshot::text(&lang(language), text), "Foo", &none()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jvm_declaration_kinds() {
|
||||
for (language, source, kind) in [
|
||||
("java", "public class Foo {}", Class),
|
||||
("java", "abstract class Foo {}", AbstractClass),
|
||||
("java", "interface Foo {}", Interface),
|
||||
("java", "enum Foo { A }", Enum),
|
||||
("java", "record Foo(int x) {}", Record),
|
||||
("java", "@interface Foo {}", Annotation),
|
||||
("java", "class Foo extends java.lang.RuntimeException {}", Exception),
|
||||
("kotlin", "class Foo", Class),
|
||||
("kotlin", "abstract class Foo", AbstractClass),
|
||||
("kotlin", "interface Foo", Interface),
|
||||
("kotlin", "enum class Foo { A }", Enum),
|
||||
("kotlin", "annotation class Foo", Annotation),
|
||||
("kotlin", "class Foo : RuntimeException()", Exception),
|
||||
("kotlin", "object Foo {}", Object),
|
||||
("kotlin", "data class Foo(val x: Int)", Class),
|
||||
] {
|
||||
assert_eq!(presentation(language, source).kind, Some(kind), "{language}: {source}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boot_annotations_are_on_the_primary_type_only() {
|
||||
for language in ["java", "kotlin"] {
|
||||
for annotation in ["@SpringBootApplication", "@org.springframework.boot.autoconfigure.SpringBootApplication"] {
|
||||
assert!(presentation(language, &format!("{annotation} class Foo {{}}")).spring_boot);
|
||||
}
|
||||
for source in [
|
||||
"// @SpringBootApplication\nclass Foo {}",
|
||||
"/* @SpringBootApplication */ class Foo {}",
|
||||
"@Service class Foo {}",
|
||||
"@Controller class Foo {}",
|
||||
"@Wrapper(\"@SpringBootApplication\") class Foo {}",
|
||||
"class Outer { @SpringBootApplication class Nested {} } class Foo {}",
|
||||
] {
|
||||
assert!(!presentation(language, source).spring_boot, "{source}");
|
||||
}
|
||||
}
|
||||
assert!(!presentation("java", "class Foo { String s = \"@SpringBootApplication\"; }").spring_boot);
|
||||
assert!(!presentation("kotlin", "class Foo { val s = \"@SpringBootApplication\" }").spring_boot);
|
||||
assert!(!presentation("java", "class Foo { @SpringBootApplication void method() {} }").spring_boot);
|
||||
assert!(!presentation("java", "@Wrapper(@SpringBootApplication) class Foo {}").spring_boot);
|
||||
assert_eq!(presentation("java", "class Foo extends Box<RuntimeException> {}").kind, Some(Class));
|
||||
assert_eq!(presentation("kotlin", "class Foo : Box<RuntimeException>()").kind, Some(Class));
|
||||
assert_eq!(presentation("java", "// no type\n"), JvmPresentation::default());
|
||||
assert_eq!(presentation("kotlin", "val text = \"class Foo\""), JvmPresentation::default());
|
||||
assert_eq!(presentation("java", "interface Other {} class Foo {} ").kind, Some(Class));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runnable_methods() {
|
||||
assert!(presentation("java", "class Foo { public static void main(String[] args) {} }").runnable);
|
||||
assert!(presentation("java", "class Foo { public static void main(String... args) {} }").runnable);
|
||||
assert!(!presentation("java", "class Foo { void main() {} }").runnable);
|
||||
assert!(!presentation("java", "class Foo { class Nested { public static void main(String[] a) {} } }").runnable);
|
||||
assert!(!presentation("java", "class Foo { String s = \"public static void main(String[] args) {}\"; }").runnable);
|
||||
assert!(
|
||||
presentation(
|
||||
"kotlin",
|
||||
"class Foo {
|
||||
companion object {
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {}
|
||||
}
|
||||
}"
|
||||
)
|
||||
.runnable
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
//! Test helpers: sources are plain strings; positions are named by the n-th
|
||||
//! whole-word occurrence of an identifier (1-based), so fixtures stay readable.
|
||||
|
||||
mod jvm;
|
||||
mod locals_langs;
|
||||
mod structure;
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ fn bin_dirs_in(root: &Path, platform: Platform) -> Vec<PathBuf> {
|
||||
}
|
||||
|
||||
fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<PathBuf> {
|
||||
match spec.method {
|
||||
match spec.fallback.unwrap_or(spec.method) {
|
||||
Method::Npm { .. } => Some(root.join("node_modules").join(".bin")),
|
||||
Method::Go { .. } | Method::Gem { .. } | Method::DotnetTool { .. } | Method::Release { .. } => {
|
||||
Some(root.join(spec.name).join("bin"))
|
||||
@@ -151,11 +151,13 @@ fn managed_bin_dir(root: &Path, spec: &ToolSpec, platform: Platform) -> Option<P
|
||||
}
|
||||
|
||||
/// Prepends [`tools_bin_dirs`] to this process's `PATH` so language servers,
|
||||
/// debug adapters and terminals find managed tools. Call once at startup,
|
||||
/// before other threads start (it mutates the process environment).
|
||||
/// debug adapters and terminals find managed tools, and appends the Windows user
|
||||
/// `PATH` entries a process may not inherit, for agent detection and for what the
|
||||
/// tools found there run in turn. Call once at startup, before other threads start
|
||||
/// (it mutates the process environment).
|
||||
pub fn extend_process_path(app_data: &Path) {
|
||||
let current = std::env::var_os("PATH").unwrap_or_default();
|
||||
let extended = path_with_tools(app_data, ¤t);
|
||||
let extended = path_with_tools(app_data, &ide_core::process::with_user_path(¤t));
|
||||
if extended != current {
|
||||
// SAFETY: documented to run at startup before any other thread reads the environment.
|
||||
unsafe { std::env::set_var("PATH", extended) };
|
||||
@@ -520,16 +522,26 @@ impl Tools {
|
||||
parse_version(&out)
|
||||
}
|
||||
|
||||
/// The installer that owns this tool: a managed copy is refreshed and removed by the
|
||||
/// fallback that made it, even if the primary installer has appeared since.
|
||||
fn install_method(&self, spec: &ToolSpec) -> Method {
|
||||
let Some(fallback) = spec.fallback else { return spec.method };
|
||||
let managed = managed_bin_dir(&self.root, spec, self.platform)
|
||||
.and_then(|dir| self.exe_in(&dir, spec.commands[0]))
|
||||
.is_some();
|
||||
if managed || self.method_missing(spec.method).is_some() { fallback } else { spec.method }
|
||||
}
|
||||
|
||||
/// Requirement check for installing `spec` right now.
|
||||
fn unavailable_reason(&self, spec: &ToolSpec) -> Option<String> {
|
||||
self.installer_missing(spec)
|
||||
self.method_missing(self.install_method(spec))
|
||||
.or_else(|| spec.needs.iter().find(|(cmd, _)| self.which(cmd).is_none()).map(|(_, why)| why.to_string()))
|
||||
}
|
||||
|
||||
/// What the install method itself lacks (npm, a build for this platform, …).
|
||||
fn installer_missing(&self, spec: &ToolSpec) -> Option<String> {
|
||||
fn method_missing(&self, method: Method) -> Option<String> {
|
||||
let need = |ok: bool, msg: &str| (!ok).then(|| msg.to_string());
|
||||
match spec.method {
|
||||
match method {
|
||||
Method::Rustup { .. } => need(self.which("rustup").is_some(), "rustup is required (https://rustup.rs)"),
|
||||
Method::Npm { .. } => need(
|
||||
self.which_any(&["npm", "pnpm"]).is_some(),
|
||||
@@ -602,7 +614,7 @@ impl Tools {
|
||||
status,
|
||||
path: path.map(Into::into),
|
||||
version,
|
||||
install_method: Some(spec.method.label().to_string()),
|
||||
install_method: Some(self.install_method(spec).label().to_string()),
|
||||
can_install: unavailable_reason.is_none(),
|
||||
unavailable_reason,
|
||||
can_uninstall,
|
||||
@@ -643,7 +655,16 @@ impl Tools {
|
||||
|
||||
fn detect(&self, spec: &ToolSpec) -> (ToolStatus, Option<PathBuf>, Option<String>) {
|
||||
let main = spec.commands[0];
|
||||
match spec.method {
|
||||
let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main));
|
||||
if let Some(path) = managed {
|
||||
let version = match spec.method {
|
||||
Method::Npm { packages } => self.npm_package_version(npm_name(packages[0])),
|
||||
_ => self.manifest(spec.name).and_then(|m| m.version),
|
||||
}
|
||||
.or_else(|| self.query_version(spec, &path));
|
||||
return (ToolStatus::Installed, Some(path), version);
|
||||
}
|
||||
match self.install_method(spec) {
|
||||
Method::Rustup { .. } => {
|
||||
let Some(path) = self.which(main) else { return (ToolStatus::Missing, None, None) };
|
||||
// The rustup proxy exists even without the component; it only answers when installed.
|
||||
@@ -678,24 +699,13 @@ impl Tools {
|
||||
_ => (ToolStatus::Missing, None, None),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let managed = managed_bin_dir(&self.root, spec, self.platform).and_then(|d| self.exe_in(&d, main));
|
||||
if let Some(path) = managed {
|
||||
let version = match spec.method {
|
||||
Method::Npm { packages } => self.npm_package_version(npm_name(packages[0])),
|
||||
_ => self.manifest(spec.name).and_then(|m| m.version),
|
||||
}
|
||||
.or_else(|| self.query_version(spec, &path));
|
||||
return (ToolStatus::Installed, Some(path), version);
|
||||
_ => match self.which(main) {
|
||||
Some(path) => {
|
||||
let version = self.query_version(spec, &path);
|
||||
(ToolStatus::OnPath, Some(path), version)
|
||||
}
|
||||
match self.which(main) {
|
||||
Some(path) => {
|
||||
let version = self.query_version(spec, &path);
|
||||
(ToolStatus::OnPath, Some(path), version)
|
||||
}
|
||||
None => (ToolStatus::Missing, None, None),
|
||||
}
|
||||
}
|
||||
None => (ToolStatus::Missing, None, None),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,8 +779,9 @@ impl Tools {
|
||||
let emit = &fan as Emit;
|
||||
let result = (|| {
|
||||
std::fs::create_dir_all(&self.root)?;
|
||||
emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, spec.method.label()) });
|
||||
match spec.method {
|
||||
let method = self.install_method(spec);
|
||||
emit(InstallEvent::Line { text: format!("Installing {} via {}…", spec.display, method.label()) });
|
||||
match method {
|
||||
Method::Rustup { component } => {
|
||||
let mut c = self.command(self.which("rustup").expect("checked"));
|
||||
c.args(["component", "add", component]);
|
||||
@@ -1072,6 +1083,10 @@ impl Tools {
|
||||
}
|
||||
ReleaseSource::OpenVsx { namespace, extension } => {
|
||||
let json = http.get_text(&sources::openvsx_latest_url(namespace, extension), cancel)?;
|
||||
let json = match sources::openvsx_platform_url(&json, assets, self.platform)? {
|
||||
Some(url) => http.get_text(&url, cancel)?,
|
||||
None => json,
|
||||
};
|
||||
sources::openvsx_pick(&json, assets, self.platform)
|
||||
}
|
||||
ReleaseSource::MavenCentral { group, artifact } => {
|
||||
@@ -1172,7 +1187,7 @@ impl Tools {
|
||||
let _guard = InstallGuard::acquire(&format!("uninstall-{name}"))?;
|
||||
let cancel = CancellationToken::new();
|
||||
let quiet = |_: InstallEvent| {};
|
||||
match spec.method {
|
||||
match self.install_method(spec) {
|
||||
Method::Rustup { component } => {
|
||||
let rustup = self.which("rustup").ok_or_else(|| IdeError::unavailable("rustup not found"))?;
|
||||
let mut c = self.command(rustup);
|
||||
|
||||
@@ -82,6 +82,8 @@ pub struct ToolSpec {
|
||||
/// is a plugin bundle loaded by another tool (copied to `bin/` under this name).
|
||||
pub commands: &'static [&'static str],
|
||||
pub method: Method,
|
||||
/// Used when the primary installer is unavailable.
|
||||
pub fallback: Option<Method>,
|
||||
/// Version query: a command (looked up next to the main binary first) and its args.
|
||||
pub version: Option<(&'static str, &'static [&'static str])>,
|
||||
pub homepage: &'static str,
|
||||
@@ -123,6 +125,18 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["rust-analyzer"],
|
||||
method: Method::Rustup { component: "rust-analyzer" },
|
||||
fallback: Some(Method::Release {
|
||||
source: ReleaseSource::OpenVsx { namespace: "rust-lang", extension: "rust-analyzer" },
|
||||
assets: &[
|
||||
rule(Mac, Arm64, "rust-lang.rust-analyzer-*@darwin-arm64.vsix"),
|
||||
rule(Mac, X64, "rust-lang.rust-analyzer-*@darwin-x64.vsix"),
|
||||
rule(Linux, Arm64, "rust-lang.rust-analyzer-*@linux-arm64.vsix"),
|
||||
rule(Linux, X64, "rust-lang.rust-analyzer-*@linux-x64.vsix"),
|
||||
rule(Windows, Arm64, "rust-lang.rust-analyzer-*@win32-arm64.vsix"),
|
||||
rule(Windows, X64, "rust-lang.rust-analyzer-*@win32-x64.vsix"),
|
||||
],
|
||||
locate: &["rust-analyzer"],
|
||||
}),
|
||||
version: ver("rust-analyzer"),
|
||||
homepage: "https://rust-analyzer.github.io",
|
||||
needs: &[],
|
||||
@@ -133,6 +147,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["vtsls"],
|
||||
method: Method::Npm { packages: &["@vtsls/language-server"] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/yioneko/vtsls",
|
||||
needs: &[],
|
||||
@@ -143,6 +158,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["typescript-language-server"],
|
||||
method: Method::Npm { packages: &["typescript-language-server", TYPESCRIPT] },
|
||||
fallback: None,
|
||||
version: ver("typescript-language-server"),
|
||||
homepage: "https://github.com/typescript-language-server/typescript-language-server",
|
||||
needs: &[],
|
||||
@@ -153,6 +169,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["pyright-langserver", "pyright"],
|
||||
method: Method::Npm { packages: &["pyright"] },
|
||||
fallback: None,
|
||||
version: ver("pyright"),
|
||||
homepage: "https://github.com/microsoft/pyright",
|
||||
needs: &[],
|
||||
@@ -163,6 +180,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["basedpyright-langserver", "basedpyright"],
|
||||
method: Method::PipVenv { package: "basedpyright" },
|
||||
fallback: None,
|
||||
version: ver("basedpyright"),
|
||||
homepage: "https://docs.basedpyright.com",
|
||||
needs: &[],
|
||||
@@ -173,6 +191,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["ty"],
|
||||
method: Method::PipVenv { package: "ty" },
|
||||
fallback: None,
|
||||
version: ver("ty"),
|
||||
homepage: "https://docs.astral.sh/ty/",
|
||||
needs: &[],
|
||||
@@ -183,6 +202,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["bash-language-server"],
|
||||
method: Method::Npm { packages: &["bash-language-server"] },
|
||||
fallback: None,
|
||||
version: ver("bash-language-server"),
|
||||
homepage: "https://github.com/bash-lsp/bash-language-server",
|
||||
needs: &[],
|
||||
@@ -193,6 +213,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["yaml-language-server"],
|
||||
method: Method::Npm { packages: &["yaml-language-server"] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/redhat-developer/yaml-language-server",
|
||||
needs: &[],
|
||||
@@ -203,6 +224,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["vscode-json-language-server"],
|
||||
method: Method::Npm { packages: &["vscode-langservers-extracted"] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/hrsh7th/vscode-langservers-extracted",
|
||||
needs: &[],
|
||||
@@ -213,6 +235,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["vscode-css-language-server"],
|
||||
method: Method::Npm { packages: &["vscode-langservers-extracted"] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/hrsh7th/vscode-langservers-extracted",
|
||||
needs: &[],
|
||||
@@ -223,6 +246,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["vscode-html-language-server"],
|
||||
method: Method::Npm { packages: &["vscode-langservers-extracted"] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/hrsh7th/vscode-langservers-extracted",
|
||||
needs: &[],
|
||||
@@ -233,6 +257,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["docker-langserver"],
|
||||
method: Method::Npm { packages: &["dockerfile-language-server-nodejs"] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/rcjsuen/dockerfile-language-server",
|
||||
needs: &[],
|
||||
@@ -243,6 +268,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["svelteserver"],
|
||||
method: Method::Npm { packages: &["svelte-language-server", TYPESCRIPT] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/sveltejs/language-tools",
|
||||
needs: &[],
|
||||
@@ -253,6 +279,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["vue-language-server"],
|
||||
method: Method::Npm { packages: &["@vue/language-server", TYPESCRIPT] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/vuejs/language-tools",
|
||||
needs: &[],
|
||||
@@ -264,6 +291,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
commands: &["ngserver"],
|
||||
// Brings the TypeScript and `@angular/language-service` it loads.
|
||||
method: Method::Npm { packages: &["@angular/language-server"] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://angular.dev/tools/language-service",
|
||||
needs: &[],
|
||||
@@ -274,6 +302,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["graphql-lsp"],
|
||||
method: Method::Npm { packages: &["graphql-language-service-cli"] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/graphql/graphiql",
|
||||
needs: &[],
|
||||
@@ -284,6 +313,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["intelephense"],
|
||||
method: Method::Npm { packages: &["intelephense"] },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://intelephense.com",
|
||||
needs: &[],
|
||||
@@ -294,6 +324,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["gopls"],
|
||||
method: Method::Go { module: "golang.org/x/tools/gopls@latest" },
|
||||
fallback: None,
|
||||
version: Some(("gopls", &["version"])),
|
||||
homepage: "https://pkg.go.dev/golang.org/x/tools/gopls",
|
||||
needs: &[],
|
||||
@@ -304,6 +335,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["sqls"],
|
||||
method: Method::Go { module: "github.com/sqls-server/sqls@latest" },
|
||||
fallback: None,
|
||||
version: Some(("sqls", &["--version"])),
|
||||
homepage: "https://github.com/sqls-server/sqls",
|
||||
needs: &[],
|
||||
@@ -314,6 +346,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: Debugger,
|
||||
commands: &["dlv"],
|
||||
method: Method::Go { module: "github.com/go-delve/delve/cmd/dlv@latest" },
|
||||
fallback: None,
|
||||
version: Some(("dlv", &["version"])),
|
||||
homepage: "https://github.com/go-delve/delve",
|
||||
needs: &[],
|
||||
@@ -324,6 +357,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: Debugger,
|
||||
commands: &["python3"],
|
||||
method: Method::PipTarget { package: "debugpy", module: "debugpy" },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/microsoft/debugpy",
|
||||
needs: &[],
|
||||
@@ -342,6 +376,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["clangd"],
|
||||
},
|
||||
fallback: None,
|
||||
version: ver("clangd"),
|
||||
homepage: "https://clangd.llvm.org",
|
||||
needs: &[],
|
||||
@@ -362,6 +397,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["lua-language-server"],
|
||||
},
|
||||
fallback: None,
|
||||
version: ver("lua-language-server"),
|
||||
homepage: "https://luals.github.io",
|
||||
needs: &[],
|
||||
@@ -381,6 +417,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["marksman"],
|
||||
},
|
||||
fallback: None,
|
||||
version: ver("marksman"),
|
||||
homepage: "https://github.com/artempyanykh/marksman",
|
||||
needs: &[],
|
||||
@@ -402,6 +439,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["taplo"],
|
||||
},
|
||||
fallback: None,
|
||||
version: ver("taplo"),
|
||||
homepage: "https://taplo.tamasfe.dev",
|
||||
needs: &[],
|
||||
@@ -423,6 +461,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["zls"],
|
||||
},
|
||||
fallback: None,
|
||||
version: ver("zls"),
|
||||
homepage: "https://github.com/zigtools/zls",
|
||||
needs: &[],
|
||||
@@ -444,6 +483,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["terraform-ls"],
|
||||
},
|
||||
fallback: None,
|
||||
version: Some(("terraform-ls", &["version"])),
|
||||
homepage: "https://github.com/hashicorp/terraform-ls",
|
||||
needs: &[],
|
||||
@@ -469,6 +509,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["codelldb"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/vadimcn/codelldb",
|
||||
needs: &[],
|
||||
@@ -488,6 +529,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["dapDebugServer.js"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/microsoft/vscode-js-debug",
|
||||
needs: &[],
|
||||
@@ -506,6 +548,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["jdtls", "jdtls.bat"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/eclipse-jdtls/eclipse.jdt.ls",
|
||||
needs: &[],
|
||||
@@ -525,6 +568,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["com.microsoft.java.debug.plugin-*.jar"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/microsoft/java-debug",
|
||||
needs: &[],
|
||||
@@ -544,6 +588,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["lombok.jar"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://projectlombok.org",
|
||||
needs: &[],
|
||||
@@ -568,6 +613,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["kotlin-lsp", "kotlin-lsp.sh", "kotlin-lsp.cmd"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/Kotlin/kotlin-lsp",
|
||||
needs: &[],
|
||||
@@ -584,6 +630,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
assets: ELIXIR_LS_ASSETS,
|
||||
locate: &["language_server.sh", "language_server.bat"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/elixir-lsp/elixir-ls",
|
||||
needs: ELIXIR,
|
||||
@@ -598,6 +645,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
assets: ELIXIR_LS_ASSETS,
|
||||
locate: &["debug_adapter.sh", "debug_adapter.bat"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/elixir-lsp/elixir-ls",
|
||||
needs: ELIXIR,
|
||||
@@ -619,6 +667,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["buf"],
|
||||
},
|
||||
fallback: None,
|
||||
version: ver("buf"),
|
||||
homepage: "https://buf.build",
|
||||
needs: &[],
|
||||
@@ -639,6 +688,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["clojure-lsp"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://clojure-lsp.io",
|
||||
needs: &[],
|
||||
@@ -660,6 +710,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["texlab"],
|
||||
},
|
||||
fallback: None,
|
||||
version: ver("texlab"),
|
||||
homepage: "https://github.com/latex-lsp/texlab",
|
||||
needs: &[],
|
||||
@@ -680,6 +731,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["neocmakelsp"],
|
||||
},
|
||||
fallback: None,
|
||||
version: ver("neocmakelsp"),
|
||||
homepage: "https://github.com/Decodetalkers/neocmakelsp",
|
||||
needs: &[],
|
||||
@@ -700,6 +752,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["lemminx-*"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/eclipse/lemminx",
|
||||
needs: &[],
|
||||
@@ -718,6 +771,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["perlnavigator"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/bscan/PerlNavigator",
|
||||
needs: &[("perl", "Perl is required (https://www.perl.org/get.html)")],
|
||||
@@ -732,6 +786,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
assets: &[any_arch(Mac, "server.zip"), any_arch(Linux, "server.zip"), any_arch(Windows, "server.zip")],
|
||||
locate: &["kotlin-language-server"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/fwcd/kotlin-language-server",
|
||||
needs: JAVA,
|
||||
@@ -746,6 +801,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
assets: &[any_arch(Mac, "adapter.zip"), any_arch(Linux, "adapter.zip"), any_arch(Windows, "adapter.zip")],
|
||||
locate: &["kotlin-debug-adapter"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/fwcd/kotlin-debug-adapter",
|
||||
needs: JAVA,
|
||||
@@ -765,6 +821,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["netcoredbg"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/Samsung/netcoredbg",
|
||||
needs: &[],
|
||||
@@ -783,6 +840,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["bashDebug.js"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/rogalmic/vscode-bash-debug",
|
||||
needs: NODE,
|
||||
@@ -801,6 +859,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
],
|
||||
locate: &["phpDebug.js"],
|
||||
},
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/xdebug/vscode-php-debug",
|
||||
needs: NODE,
|
||||
@@ -811,6 +870,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["ruby-lsp"],
|
||||
method: Method::Gem { gem: "ruby-lsp" },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://shopify.github.io/ruby-lsp",
|
||||
needs: &[],
|
||||
@@ -821,6 +881,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["solargraph"],
|
||||
method: Method::Gem { gem: "solargraph" },
|
||||
fallback: None,
|
||||
version: ver("solargraph"),
|
||||
homepage: "https://solargraph.org",
|
||||
needs: &[],
|
||||
@@ -831,6 +892,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["csharp-ls"],
|
||||
method: Method::DotnetTool { package: "csharp-ls" },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/razzmatazz/csharp-language-server",
|
||||
needs: &[],
|
||||
@@ -841,6 +903,7 @@ pub static TOOLS: &[ToolSpec] = &[
|
||||
kind: LanguageServer,
|
||||
commands: &["fsautocomplete"],
|
||||
method: Method::DotnetTool { package: "fsautocomplete" },
|
||||
fallback: None,
|
||||
version: V,
|
||||
homepage: "https://github.com/ionide/FsAutoComplete",
|
||||
needs: &[],
|
||||
|
||||
@@ -177,6 +177,8 @@ pub fn jdtls_download(version: &str, latest_txt: &str) -> IdeResult<Download> {
|
||||
#[derive(Deserialize)]
|
||||
struct VsxExtension {
|
||||
version: String,
|
||||
#[serde(default)]
|
||||
downloads: std::collections::BTreeMap<String, String>,
|
||||
files: VsxFiles,
|
||||
}
|
||||
|
||||
@@ -191,7 +193,22 @@ pub fn openvsx_latest_url(namespace: &str, extension: &str) -> String {
|
||||
format!("https://open-vsx.org/api/{namespace}/{extension}/latest")
|
||||
}
|
||||
|
||||
/// The `.vsix` of an Open VSX `…/latest` response (platform-independent extensions only).
|
||||
/// Select platform metadata when the universal package does not match the asset rules.
|
||||
pub fn openvsx_platform_url(json: &str, rules: &[AssetRule], platform: Platform) -> IdeResult<Option<String>> {
|
||||
let ext: VsxExtension =
|
||||
serde_json::from_str(json).map_err(|e| proto(format!("unexpected Open VSX response: {e}")))?;
|
||||
if select_asset(&[file_name(&ext.files.download)], rules, platform).is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
let names: Vec<&str> = ext.downloads.values().map(|url| file_name(url)).collect();
|
||||
let name = select_asset(&names, rules, platform)
|
||||
.ok_or_else(|| IdeError::unavailable("No Open VSX package for this platform"))?;
|
||||
let url = ext.downloads.values().find(|url| file_name(url) == name).expect("selected download");
|
||||
let (metadata, _) = url.rsplit_once("/file/").ok_or_else(|| proto("unexpected Open VSX download URL"))?;
|
||||
Ok(Some(metadata.to_string()))
|
||||
}
|
||||
|
||||
/// The `.vsix` of an Open VSX `…/latest` response.
|
||||
pub fn openvsx_pick(json: &str, rules: &[AssetRule], platform: Platform) -> IdeResult<Download> {
|
||||
let ext: VsxExtension =
|
||||
serde_json::from_str(json).map_err(|e| proto(format!("unexpected Open VSX response: {e}")))?;
|
||||
@@ -355,6 +372,56 @@ mod tests {
|
||||
assert!(openvsx_pick(&other, rules, LINUX).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openvsx_rust_analyzer_platform_packages() {
|
||||
use crate::platform::{Arch, Os};
|
||||
let crate::registry::Method::Release { assets, .. } = find("rust-analyzer").unwrap().fallback.unwrap() else {
|
||||
panic!("expected release fallback")
|
||||
};
|
||||
let targets = [
|
||||
(Os::Mac, Arch::Arm64, "darwin-arm64"),
|
||||
(Os::Mac, Arch::X64, "darwin-x64"),
|
||||
(Os::Linux, Arch::Arm64, "linux-arm64"),
|
||||
(Os::Linux, Arch::X64, "linux-x64"),
|
||||
(Os::Windows, Arch::Arm64, "win32-arm64"),
|
||||
(Os::Windows, Arch::X64, "win32-x64"),
|
||||
];
|
||||
let base = "https://open-vsx.org/api/rust-lang/rust-analyzer";
|
||||
let downloads: serde_json::Map<String, serde_json::Value> = targets
|
||||
.iter()
|
||||
.map(|(_, _, target)| {
|
||||
(
|
||||
target.to_string(),
|
||||
format!("{base}/{target}/1.0.0/file/rust-lang.rust-analyzer-1.0.0@{target}.vsix").into(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let universal = serde_json::json!({
|
||||
"version": "1.0.0", "downloads": downloads,
|
||||
"files": {"download": format!("{base}/1.0.0/file/rust-lang.rust-analyzer-1.0.0.vsix")}
|
||||
})
|
||||
.to_string();
|
||||
for (os, arch, target) in targets {
|
||||
let platform = Platform { os, arch };
|
||||
assert_eq!(
|
||||
openvsx_platform_url(&universal, assets, platform).unwrap(),
|
||||
Some(format!("{base}/{target}/1.0.0"))
|
||||
);
|
||||
let json = serde_json::json!({
|
||||
"version": "1.0.0",
|
||||
"files": {
|
||||
"download": downloads[target],
|
||||
"sha256": format!("{base}/{target}/1.0.0/file/rust-lang.rust-analyzer-1.0.0@{target}.sha256")
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let download = openvsx_pick(&json, assets, platform).unwrap();
|
||||
assert!(download.name.ends_with(&format!("@{target}.vsix")));
|
||||
assert!(download.checksum_url.unwrap().ends_with(&format!("@{target}.sha256")));
|
||||
}
|
||||
assert!(openvsx_platform_url(&universal.replace("downloads", "unused"), assets, MAC_ARM).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maven_central() {
|
||||
let xml =
|
||||
|
||||
@@ -65,8 +65,8 @@ fn everything_missing_on_an_empty_system() {
|
||||
let ra = find(&list, "rust-analyzer");
|
||||
assert_eq!(ra.status, ToolStatus::Missing);
|
||||
assert_eq!(ra.languages, vec!["Rust".to_string()]);
|
||||
assert!(!ra.can_install, "rustup is not on the fake PATH");
|
||||
assert!(ra.unavailable_reason.as_deref().unwrap().contains("rustup"));
|
||||
assert!(!ra.can_install, "curl is not on the fake PATH");
|
||||
assert!(ra.unavailable_reason.as_deref().unwrap().contains("curl"));
|
||||
let vtsls = find(&list, "vtsls");
|
||||
assert!(vtsls.languages.iter().any(|l| l == "TypeScript"));
|
||||
assert!(vtsls.unavailable_reason.as_deref().unwrap().contains("npm"));
|
||||
@@ -552,3 +552,74 @@ fn the_installing_request_can_still_cancel_its_shared_install() {
|
||||
assert!(slot.cancel.is_cancelled());
|
||||
release_install_slot("taplo", &slot);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rust_analyzer_selects_openvsx_without_rustup() {
|
||||
let env = Env::new();
|
||||
env.script(&env.sys, "curl", "exit 0");
|
||||
let tools = env.tools();
|
||||
let spec = registry::find("rust-analyzer").unwrap();
|
||||
assert!(matches!(
|
||||
tools.install_method(spec),
|
||||
Method::Release { source: ReleaseSource::OpenVsx { namespace: "rust-lang", extension: "rust-analyzer" }, .. }
|
||||
));
|
||||
let info = tools.info("rust-analyzer", &langs()).unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Missing);
|
||||
assert_eq!(info.install_method.as_deref(), Some("Open VSX"));
|
||||
assert!(info.can_install);
|
||||
assert!(info.unavailable_reason.is_none());
|
||||
assert!(bin_dirs_in(&env.root, Platform::current()).contains(&env.root.join("rust-analyzer/bin")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
static RUST_ANALYZER_INSTALL: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rust_analyzer_prefers_rustup_and_installs_component() {
|
||||
let _guard = RUST_ANALYZER_INSTALL.lock();
|
||||
let env = Env::new();
|
||||
env.script(&env.sys, "rustup", "test \"$*\" = 'component add rust-analyzer'");
|
||||
env.script(&env.sys, "rust-analyzer", "echo 'rust-analyzer 1.0.0'");
|
||||
let tools = env.tools();
|
||||
assert!(matches!(
|
||||
tools.install_method(registry::find("rust-analyzer").unwrap()),
|
||||
Method::Rustup { component: "rust-analyzer" }
|
||||
));
|
||||
let info = tools.info("rust-analyzer", &langs()).unwrap();
|
||||
assert_eq!(info.install_method.as_deref(), Some("rustup"));
|
||||
assert!(info.can_install, "rustup does not require curl");
|
||||
let mut lines = Vec::new();
|
||||
let emit = parking_lot::Mutex::new(&mut lines);
|
||||
tools
|
||||
.install("rust-analyzer", &langs(), &CancellationToken::new(), &|event| {
|
||||
if let InstallEvent::Line { text } = event {
|
||||
emit.lock().push(text);
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
assert!(lines.iter().any(|line| line.contains("via rustup")));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn rust_analyzer_managed_copy_survives_rustup_becoming_available() {
|
||||
let _guard = RUST_ANALYZER_INSTALL.lock();
|
||||
let env = Env::new();
|
||||
let bin = env.root.join("rust-analyzer/bin");
|
||||
env.script(&bin, "rust-analyzer", "echo 'rust-analyzer 1.0.0'");
|
||||
for with_rustup in [false, true] {
|
||||
if with_rustup {
|
||||
env.script(&env.sys, "rustup", "exit 1");
|
||||
env.script(&env.sys, "rust-analyzer", "exit 1");
|
||||
}
|
||||
let info = env.tools().info("rust-analyzer", &langs()).unwrap();
|
||||
assert_eq!(info.status, ToolStatus::Installed);
|
||||
assert_eq!(info.path.as_deref(), Some(bin.join("rust-analyzer").as_path()));
|
||||
assert_eq!(info.install_method.as_deref(), Some("Open VSX"), "Reinstall refreshes the copy in use");
|
||||
assert!(info.can_uninstall);
|
||||
}
|
||||
env.tools().uninstall("rust-analyzer", &langs()).unwrap();
|
||||
assert!(!env.root.join("rust-analyzer").exists());
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
//! writes (commit, checkout, push/pull, stash, merge) shell out to `git` so
|
||||
//! hooks, credentials and signing behave exactly as on the command line.
|
||||
|
||||
use encoding_rs::Encoding;
|
||||
use ide_core::ignored::IgnoredNames;
|
||||
use ide_core::{CancellationToken, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
pub use text_encoding::TextEncoding;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -540,7 +540,13 @@ impl Repo {
|
||||
}
|
||||
/// Content of `path` at `rev` (default HEAD); `None` if absent there.
|
||||
/// `rev` may be any revision git understands, or `":"` / `":0"` for the index.
|
||||
pub fn file_at(&self, path: &Path, rev: Option<&str>, encoding: &'static Encoding) -> IdeResult<Option<String>> {
|
||||
pub fn file_at(
|
||||
&self,
|
||||
path: &Path,
|
||||
rev: Option<&str>,
|
||||
encoding: impl Into<TextEncoding>,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let encoding = encoding.into();
|
||||
let rel = self.rel_path(path)?;
|
||||
let repo = self.gix()?;
|
||||
Ok(repo::blob_at(&repo, &rel, rev.unwrap_or("HEAD"))?.map(|b| text_encoding::decode(&b, encoding).into_owned()))
|
||||
@@ -555,8 +561,9 @@ impl Repo {
|
||||
path: &Path,
|
||||
current: &str,
|
||||
selected: &[usize],
|
||||
encoding: &'static Encoding,
|
||||
encoding: impl Into<TextEncoding>,
|
||||
) -> IdeResult<()> {
|
||||
let encoding = encoding.into();
|
||||
write::stage_hunks(self, path, current, selected, encoding)
|
||||
}
|
||||
/// Hunks of `current_text` against HEAD (or the index when `against_index`).
|
||||
@@ -570,8 +577,9 @@ impl Repo {
|
||||
path: &Path,
|
||||
current_text: &str,
|
||||
against_index: bool,
|
||||
encoding: &'static Encoding,
|
||||
encoding: impl Into<TextEncoding>,
|
||||
) -> IdeResult<Vec<LineHunk>> {
|
||||
let encoding = encoding.into();
|
||||
let Ok(rel) = self.rel_path(path) else { return Ok(Vec::new()) };
|
||||
if rel.is_empty() || rel == ".git" || rel.starts_with(".git/") {
|
||||
return Ok(Vec::new());
|
||||
@@ -598,8 +606,9 @@ impl Repo {
|
||||
path: &Path,
|
||||
current_text: Option<&str>,
|
||||
revision: Option<&str>,
|
||||
encoding: &'static Encoding,
|
||||
encoding: impl Into<TextEncoding>,
|
||||
) -> IdeResult<Vec<BlameLine>> {
|
||||
let encoding = encoding.into();
|
||||
write::blame(self, path, current_text, revision, encoding)
|
||||
}
|
||||
/// Commits newest-first in topological order (children before parents).
|
||||
@@ -631,7 +640,8 @@ impl Repo {
|
||||
}
|
||||
/// Unified diff of a file: index↔worktree (`staged == false`, untracked files diff
|
||||
/// against /dev/null) or HEAD↔index (`staged == true`). Empty when unchanged.
|
||||
pub fn diff_text(&self, path: &Path, staged: bool, encoding: &'static Encoding) -> IdeResult<String> {
|
||||
pub fn diff_text(&self, path: &Path, staged: bool, encoding: impl Into<TextEncoding>) -> IdeResult<String> {
|
||||
let encoding = encoding.into();
|
||||
write::diff_text(self, path, staged, encoding)
|
||||
}
|
||||
|
||||
@@ -650,8 +660,12 @@ impl Repo {
|
||||
}
|
||||
/// Applies a unified-diff patch to the index (partial staging of hunks/lines).
|
||||
/// Zero-context patches (see [`build_partial_patch`]) are accepted.
|
||||
pub fn stage_patch(&self, patch: &str, encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>) -> IdeResult<()> {
|
||||
write::stage_patch(self, patch, encoding)
|
||||
pub fn stage_patch<E: Into<TextEncoding>>(
|
||||
&self,
|
||||
patch: &str,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<E>,
|
||||
) -> IdeResult<()> {
|
||||
write::stage_patch(self, patch, &|path| encoding(path).map(Into::into))
|
||||
}
|
||||
/// Discards unstaged changes: tracked paths are restored from the index (staged
|
||||
/// changes are kept), untracked files and directories are deleted.
|
||||
@@ -662,16 +676,16 @@ impl Repo {
|
||||
/// `amend` keeps the previous message.
|
||||
pub fn commit(&self, message: &str, amend: bool, sign_off: bool) -> IdeResult<String> {
|
||||
let options = CommitOptions { amend, sign_off, author: None, run_hooks: true, partial: Vec::new() };
|
||||
write::commit(self, message, &options, &|_| Ok(encoding_rs::UTF_8))
|
||||
write::commit(self, message, &options, &|_| Ok(encoding_rs::UTF_8.into()))
|
||||
}
|
||||
/// Commits with the Commit Options popup's choices.
|
||||
pub fn commit_with(
|
||||
pub fn commit_with<E: Into<TextEncoding>>(
|
||||
&self,
|
||||
message: &str,
|
||||
options: &CommitOptions,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<E>,
|
||||
) -> IdeResult<String> {
|
||||
write::commit(self, message, options, encoding)
|
||||
write::commit(self, message, options, &|path| encoding(path).map(Into::into))
|
||||
}
|
||||
/// Switches to `branch`. With `create`, creates it at HEAD. Without, a remote branch
|
||||
/// (`origin/x`) gets a local tracking branch and any other revision is checked out
|
||||
@@ -897,7 +911,7 @@ impl Repo {
|
||||
/// Patch of the local changes of `paths` against HEAD (untracked files included).
|
||||
pub fn create_patch(&self, paths: &[PathBuf], reverse: bool) -> IdeResult<String> {
|
||||
let rel = self.rel_paths(paths)?;
|
||||
Ok(patch::working_patch(self, &rel, reverse)?.0)
|
||||
Ok(patch::working_patch(self, &rel, reverse, true)?.0)
|
||||
}
|
||||
/// Patch of the changes made by `commits` (oldest first).
|
||||
pub fn create_commits_patch(&self, commits: &[String], reverse: bool) -> IdeResult<String> {
|
||||
@@ -925,7 +939,8 @@ impl Repo {
|
||||
}
|
||||
/// The versions of a conflicted file with the user's work as `ours`, as IDEA's merge
|
||||
/// dialog shows them (sides swapped during a rebase).
|
||||
pub fn merge_versions(&self, path: &Path, encoding: &'static Encoding) -> IdeResult<MergeVersions> {
|
||||
pub fn merge_versions(&self, path: &Path, encoding: impl Into<TextEncoding>) -> IdeResult<MergeVersions> {
|
||||
let encoding = encoding.into();
|
||||
let (base, ours, theirs) = self.conflict_versions(path, encoding)?;
|
||||
let sides = self.merge_sides()?;
|
||||
let (ours, theirs) = if sides.reversed { (theirs, ours) } else { (ours, theirs) };
|
||||
@@ -939,8 +954,9 @@ impl Repo {
|
||||
pub fn conflict_versions(
|
||||
&self,
|
||||
path: &Path,
|
||||
encoding: &'static Encoding,
|
||||
encoding: impl Into<TextEncoding>,
|
||||
) -> IdeResult<(Option<String>, Option<String>, Option<String>)> {
|
||||
let encoding = encoding.into();
|
||||
let rel = self.rel_path(path)?;
|
||||
let repo = self.gix()?;
|
||||
let stage = |n: u8| -> IdeResult<Option<String>> {
|
||||
|
||||
@@ -36,7 +36,12 @@ impl Drop for TempIndex {
|
||||
/// Binary-safe patch of the working-tree changes (staged or not, untracked files
|
||||
/// included) of `rel_paths` against HEAD, plus the files it touches (renames split
|
||||
/// into delete + add). The real index is left untouched.
|
||||
pub(crate) fn working_patch(r: &Repo, rel_paths: &[String], reverse: bool) -> IdeResult<(String, Vec<ChangedFile>)> {
|
||||
pub(crate) fn working_patch(
|
||||
r: &Repo,
|
||||
rel_paths: &[String],
|
||||
reverse: bool,
|
||||
detect_renames: bool,
|
||||
) -> IdeResult<(String, Vec<ChangedFile>)> {
|
||||
if rel_paths.is_empty() {
|
||||
return Err(IdeError::invalid("no files selected"));
|
||||
}
|
||||
@@ -48,8 +53,16 @@ pub(crate) fn working_patch(r: &Repo, rel_paths: &[String], reverse: bool) -> Id
|
||||
run_env(r.root(), &["read-tree", "--empty"], None, &env)?;
|
||||
}
|
||||
cli::run_paths_env(r.root(), &["--literal-pathspecs", "add", "-A", "--"], rel_paths, None, &env)?;
|
||||
let mut diff =
|
||||
vec!["diff", "--cached", "--binary", "--full-index", "-M", "--no-color", "--no-ext-diff", "--no-textconv"];
|
||||
let mut diff = vec![
|
||||
"diff",
|
||||
"--cached",
|
||||
"--binary",
|
||||
"--full-index",
|
||||
if detect_renames { "-M" } else { "--no-renames" },
|
||||
"--no-color",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
];
|
||||
if reverse {
|
||||
diff.push("-R");
|
||||
}
|
||||
@@ -163,13 +176,49 @@ fn unmerged(r: &Repo) -> IdeResult<Vec<String>> {
|
||||
Ok(out.stdout_str().split('\0').filter(|s| !s.is_empty()).map(str::to_string).collect())
|
||||
}
|
||||
|
||||
fn git_path(p: &str) -> String {
|
||||
let Some(p) = p.strip_prefix('"').and_then(|p| p.strip_suffix('"')) else { return p.to_string() };
|
||||
let mut bytes = Vec::new();
|
||||
let mut chars = p.bytes().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
if c != b'\\' {
|
||||
bytes.push(c);
|
||||
continue;
|
||||
}
|
||||
let Some(c) = chars.next() else { break };
|
||||
bytes.push(match c {
|
||||
b'n' => b'\n',
|
||||
b't' => b'\t',
|
||||
b'r' => b'\r',
|
||||
b'a' => 7,
|
||||
b'b' => 8,
|
||||
b'v' => 11,
|
||||
b'f' => 12,
|
||||
b'0'..=b'7' => {
|
||||
let mut n = c - b'0';
|
||||
for _ in 0..2 {
|
||||
if let Some(d @ b'0'..=b'7') = chars.peek().copied() {
|
||||
chars.next();
|
||||
n = n.wrapping_mul(8).wrapping_add(d - b'0');
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
c => c,
|
||||
});
|
||||
}
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
fn strip_prefix(p: &str) -> Option<String> {
|
||||
let p = p.split('\t').next().unwrap_or(p).trim_end();
|
||||
let p = if p.starts_with('"') { p } else { p.split('\t').next().unwrap_or(p) };
|
||||
if p == "/dev/null" {
|
||||
return None;
|
||||
}
|
||||
let p = p.strip_prefix("\"").and_then(|q| q.strip_suffix('"')).unwrap_or(p);
|
||||
Some(p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")).unwrap_or(p).to_string())
|
||||
let p = git_path(p);
|
||||
Some(p.strip_prefix("a/").or_else(|| p.strip_prefix("b/")).unwrap_or(&p).to_string())
|
||||
}
|
||||
|
||||
/// Files changed by a unified diff (git extended headers or plain `---`/`+++`).
|
||||
@@ -210,7 +259,12 @@ pub(crate) fn parse_patch(patch: &str) -> Vec<PatchFile> {
|
||||
}
|
||||
if let Some(rest) = line.strip_prefix("diff --git ") {
|
||||
out.extend(cur.take());
|
||||
let path = rest.rsplit_once(" b/").map(|(_, b)| b.to_string()).unwrap_or_else(|| rest.to_string());
|
||||
let path = rest
|
||||
.rsplit_once(" \"b/")
|
||||
.map(|(_, b)| git_path(&format!("\"{b}")))
|
||||
.or_else(|| rest.rsplit_once(" b/").map(|(_, b)| b.to_string()))
|
||||
.unwrap_or_else(|| rest.to_string());
|
||||
let path = path.strip_prefix("b/").unwrap_or(&path).to_string();
|
||||
cur = Some(new_file(path));
|
||||
fresh_header = true;
|
||||
} else if let Some(rest) = line.strip_prefix("--- ") {
|
||||
@@ -249,10 +303,10 @@ pub(crate) fn parse_patch(patch: &str) -> Vec<PatchFile> {
|
||||
} else if line.starts_with("deleted file mode") {
|
||||
c.kind = ChangeKind::Deleted;
|
||||
} else if let Some(p) = line.strip_prefix("rename from ") {
|
||||
c.old_path = Some(p.to_string());
|
||||
c.old_path = Some(git_path(p));
|
||||
c.kind = ChangeKind::Renamed;
|
||||
} else if let Some(p) = line.strip_prefix("rename to ") {
|
||||
c.path = p.to_string();
|
||||
c.path = git_path(p);
|
||||
} else if line.starts_with("GIT binary patch") || line.starts_with("Binary files ") {
|
||||
c.binary = true;
|
||||
}
|
||||
@@ -295,7 +349,7 @@ fn lossless_diff(r: &Repo, args: &[&str], env: &[(&str, &OsStr)]) -> IdeResult<S
|
||||
pub(crate) fn encode_text_patch(
|
||||
r: &Repo,
|
||||
patch: &str,
|
||||
encoding: &dyn Fn(&std::path::Path) -> IdeResult<&'static encoding_rs::Encoding>,
|
||||
encoding: &dyn Fn(&std::path::Path) -> IdeResult<crate::TextEncoding>,
|
||||
) -> IdeResult<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut header = String::new();
|
||||
@@ -325,6 +379,52 @@ pub(crate) fn encode_text_patch(
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Apply displayed-text line selections in a temporary UTF-8 index, then
|
||||
/// serialize the result. Literal escape spelling in the real index must not
|
||||
/// prevent a selected patch from matching its decoded base.
|
||||
pub(crate) fn stage_converted_patch(
|
||||
r: &Repo,
|
||||
patch: &str,
|
||||
encoding: &dyn Fn(&std::path::Path) -> IdeResult<crate::TextEncoding>,
|
||||
) -> IdeResult<()> {
|
||||
use crate::text_encoding::{decode, encode, has_bom};
|
||||
let tmp = TempIndex::new(r);
|
||||
let env = tmp.env();
|
||||
let index = r.git_dir.join("index");
|
||||
if index.exists() {
|
||||
std::fs::copy(index, &tmp.0)?;
|
||||
} else {
|
||||
run_env(r.root(), &["read-tree", "--empty"], None, &env)?;
|
||||
}
|
||||
let mut files = Vec::new();
|
||||
for file in parse_patch(patch) {
|
||||
let path = r.abs_path(&file.path);
|
||||
let enc = encoding(&path)?;
|
||||
let original = r.file_bytes_at(&path, Some(":0"))?;
|
||||
let bom = original.as_ref().is_some_and(|bytes| has_bom(bytes, enc));
|
||||
if let Some(original) = original {
|
||||
crate::write::write_index_blob(r, &file.path, decode(&original, enc).as_bytes(), &env)?;
|
||||
}
|
||||
files.push((file.path, enc, bom));
|
||||
}
|
||||
run_env(
|
||||
r.root(),
|
||||
&["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "--recount", "-"],
|
||||
Some(patch.as_bytes()),
|
||||
&env,
|
||||
)?;
|
||||
for (rel, enc, bom) in files {
|
||||
let staged = cli::run_raw_env(r.root(), &["show", &format!(":0:{rel}")], None, &env)?;
|
||||
if staged.code == Some(0) {
|
||||
let text = std::str::from_utf8(&staged.stdout).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
crate::write::write_index_blob(r, &rel, &encode(text, enc, bom)?, &[])?;
|
||||
} else {
|
||||
run(r.root(), &["--literal-pathspecs", "update-index", "--force-remove", "--", &rel], None)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//! `.idea/shelf`. Each shelved change list is `<shelf>/<id>/shelved.patch` plus
|
||||
//! `<shelf>/<id>/shelf.json` with its name, date and files.
|
||||
|
||||
use crate::cli::run_paths;
|
||||
use crate::cli::{run_env, run_paths};
|
||||
use crate::{ApplyOutcome, ChangeKind, Repo, ShelvedChangeList};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -81,7 +81,10 @@ pub fn list(shelf: &Path) -> IdeResult<Vec<ShelvedChangeList>> {
|
||||
name: info.name,
|
||||
time_ms: info.time_ms,
|
||||
branch: info.branch,
|
||||
files: info.files,
|
||||
files: crate::patch::parse_patch(&std::fs::read_to_string(dir.join(PATCH))?)
|
||||
.into_iter()
|
||||
.map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path })
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| b.time_ms.total_cmp(&a.time_ms).then_with(|| a.name.cmp(&b.name)));
|
||||
@@ -117,7 +120,7 @@ pub(crate) fn shelve(r: &Repo, shelf: &Path, name: &str, rel_paths: &[String]) -
|
||||
if name.is_empty() {
|
||||
return Err(IdeError::invalid("name must not be empty"));
|
||||
}
|
||||
let (patch, files) = crate::patch::working_patch(r, rel_paths, false)?;
|
||||
let (patch, files) = crate::patch::working_patch(r, rel_paths, false, false)?;
|
||||
if files.is_empty() || patch.trim().is_empty() {
|
||||
return Err(IdeError::invalid("no changes to shelve"));
|
||||
}
|
||||
@@ -173,14 +176,174 @@ fn revert_files(r: &Repo, files: &[crate::ChangedFile]) -> IdeResult<()> {
|
||||
/// Applies a shelved change list; with `remove`, deletes it from the shelf once it
|
||||
/// applied without conflicts (conflicts are left for the merge UI and the entry is kept).
|
||||
pub(crate) fn unshelve(r: &Repo, shelf: &Path, id: &str, remove: bool) -> IdeResult<ApplyOutcome> {
|
||||
let patch = patch_of(shelf, id)?;
|
||||
unshelve_path(r, shelf, id, remove, None)
|
||||
}
|
||||
|
||||
pub fn unshelve_path(r: &Repo, shelf: &Path, id: &str, remove: bool, path: Option<&str>) -> IdeResult<ApplyOutcome> {
|
||||
let patch = selected_patch(shelf, id, path)?;
|
||||
let outcome = crate::patch::apply(r, &patch)?;
|
||||
if remove && outcome.conflicts.is_empty() {
|
||||
delete(shelf, id)?;
|
||||
delete_path(shelf, id, path)?;
|
||||
}
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ShelfDiffFile {
|
||||
pub path: String,
|
||||
pub patch: String,
|
||||
pub before: Option<String>,
|
||||
pub after: Option<String>,
|
||||
}
|
||||
|
||||
fn sections(patch: &str) -> Vec<(&str, crate::PatchFile)> {
|
||||
let mut starts: Vec<_> = patch
|
||||
.match_indices("diff --git ")
|
||||
.filter(|(i, _)| *i == 0 || patch.as_bytes()[i - 1] == b'\n')
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
if starts.is_empty() {
|
||||
let (mut old, mut new) = (0u32, 0u32);
|
||||
let mut offset = 0;
|
||||
let mut lines = patch.split_inclusive('\n').peekable();
|
||||
while let Some(line) = lines.next() {
|
||||
if old > 0 || new > 0 {
|
||||
match line.as_bytes().first() {
|
||||
Some(b'-') => old = old.saturating_sub(1),
|
||||
Some(b'+') => new = new.saturating_sub(1),
|
||||
Some(b' ') => {
|
||||
old = old.saturating_sub(1);
|
||||
new = new.saturating_sub(1);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
} else if line.starts_with("--- ") && lines.peek().is_some_and(|l| l.starts_with("+++ ")) {
|
||||
starts.push(offset);
|
||||
}
|
||||
if let Some(range) = line.strip_prefix("@@ ") {
|
||||
let mut parts = range.split_whitespace();
|
||||
let count = |s: &str| s.split_once(',').map_or(1, |(_, n)| n.parse().unwrap_or(0));
|
||||
old = parts.next().map(count).unwrap_or(0);
|
||||
new = parts.next().map(count).unwrap_or(0);
|
||||
}
|
||||
offset += line.len();
|
||||
}
|
||||
}
|
||||
starts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(n, &start)| {
|
||||
let section = &patch[start..starts.get(n + 1).copied().unwrap_or(patch.len())];
|
||||
crate::patch::parse_patch(section).into_iter().next().map(|file| (section, file))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn selected_patch(shelf: &Path, id: &str, path: Option<&str>) -> IdeResult<String> {
|
||||
let patch = patch_of(shelf, id)?;
|
||||
let Some(path) = path else { return Ok(patch) };
|
||||
let selected: String = sections(&patch).into_iter().filter(|(_, f)| f.path == path).map(|(p, _)| p).collect();
|
||||
if selected.is_empty() {
|
||||
return Err(IdeError::not_found("shelved file not found"));
|
||||
}
|
||||
Ok(selected)
|
||||
}
|
||||
|
||||
pub fn delete_path(shelf: &Path, id: &str, path: Option<&str>) -> IdeResult<()> {
|
||||
let Some(path) = path else { return delete(shelf, id) };
|
||||
selected_patch(shelf, id, Some(path))?;
|
||||
let patch = patch_of(shelf, id)?;
|
||||
let remaining: String = sections(&patch).into_iter().filter(|(_, f)| f.path != path).map(|(p, _)| p).collect();
|
||||
if remaining.is_empty() {
|
||||
return delete(shelf, id);
|
||||
}
|
||||
let dir = entry_dir(shelf, id)?;
|
||||
let mut info = read_info(&dir).ok_or_else(|| IdeError::invalid("shelf metadata is missing"))?;
|
||||
info.files = crate::patch::parse_patch(&remaining)
|
||||
.into_iter()
|
||||
.map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path })
|
||||
.collect();
|
||||
std::fs::write(dir.join(PATCH), remaining)?;
|
||||
std::fs::write(dir.join(INFO), serde_json::to_vec_pretty(&info).map_err(|e| IdeError::internal(e.to_string()))?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reconstruct each side in a throwaway index; neither HEAD nor the working files are used.
|
||||
pub fn diff_files(r: &Repo, shelf: &Path, id: &str) -> IdeResult<Vec<ShelfDiffFile>> {
|
||||
let patch = patch_of(shelf, id)?;
|
||||
let mut out = Vec::new();
|
||||
for (section, file) in sections(&patch) {
|
||||
let mut item = ShelfDiffFile { path: file.path.clone(), patch: section.to_string(), before: None, after: None };
|
||||
if !file.binary {
|
||||
let reconstruct = || -> IdeResult<(String, String)> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let index = temp.path().join("index");
|
||||
let env = [("GIT_INDEX_FILE", index.as_os_str())];
|
||||
run_env(
|
||||
r.root(),
|
||||
&["apply", &format!("--build-fake-ancestor={}", index.display()), "-"],
|
||||
Some(section.as_bytes()),
|
||||
&env,
|
||||
)?;
|
||||
let read = |path: &str| -> IdeResult<String> {
|
||||
let bytes = run_env(r.root(), &["show", &format!(":{path}")], None, &env)?.stdout;
|
||||
if bytes.contains(&0) {
|
||||
return Err(IdeError::invalid("binary file"));
|
||||
}
|
||||
String::from_utf8(bytes).map_err(|_| IdeError::invalid("non-UTF-8 file"))
|
||||
};
|
||||
let before = if file.kind == ChangeKind::Added {
|
||||
String::new()
|
||||
} else {
|
||||
read(file.old_path.as_deref().unwrap_or(&file.path))?
|
||||
};
|
||||
run_env(
|
||||
r.root(),
|
||||
&["apply", "--cached", "--binary", "--whitespace=nowarn", "-"],
|
||||
Some(section.as_bytes()),
|
||||
&env,
|
||||
)?;
|
||||
let after = if file.kind == ChangeKind::Deleted { String::new() } else { read(&file.path)? };
|
||||
Ok((before, after))
|
||||
};
|
||||
if let Ok((before, after)) = reconstruct() {
|
||||
item.before = Some(before);
|
||||
item.after = Some(after);
|
||||
}
|
||||
}
|
||||
out.push(item);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Import into the shelf without applying to the working tree.
|
||||
pub fn import_patch(shelf: &Path, name: &str, patch: &str) -> IdeResult<()> {
|
||||
let files = crate::patch::parse_patch(patch);
|
||||
if files.is_empty() || sections(patch).len() != files.len() {
|
||||
return Err(IdeError::invalid("no file changes found in the patch"));
|
||||
}
|
||||
std::fs::create_dir_all(shelf)?;
|
||||
let mut id = slug(name);
|
||||
while shelf.join(&id).exists() {
|
||||
id.push('_');
|
||||
}
|
||||
let dir = shelf.join(id);
|
||||
std::fs::create_dir(&dir)?;
|
||||
let info = Info {
|
||||
name: name.to_string(),
|
||||
time_ms: now_ms(),
|
||||
branch: None,
|
||||
files: files
|
||||
.into_iter()
|
||||
.map(|f| crate::ChangedFile { path: f.path, kind: f.kind, old_path: f.old_path })
|
||||
.collect(),
|
||||
};
|
||||
std::fs::write(dir.join(PATCH), patch)?;
|
||||
std::fs::write(dir.join(INFO), serde_json::to_vec_pretty(&info).map_err(|e| IdeError::internal(e.to_string()))?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::slug;
|
||||
|
||||
@@ -3,23 +3,77 @@ use encoding_rs::{Encoding, UTF_8, UTF_16BE, UTF_16LE};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::borrow::Cow;
|
||||
|
||||
pub(crate) fn has_bom(bytes: &[u8], encoding: &'static Encoding) -> bool {
|
||||
Encoding::for_bom(bytes).is_some_and(|(enc, _)| enc == encoding)
|
||||
/// File charset plus an optional reversible ASCII representation (for properties).
|
||||
/// The host supplies conversion functions so Git and documents share one codec.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct TextEncoding {
|
||||
pub charset: &'static Encoding,
|
||||
ascii_conversion: Option<AsciiConversion>,
|
||||
}
|
||||
|
||||
pub(crate) fn decode<'a>(bytes: &'a [u8], encoding: &'static Encoding) -> Cow<'a, str> {
|
||||
#[derive(Clone, Copy)]
|
||||
struct AsciiConversion {
|
||||
decode: fn(&str) -> String,
|
||||
encode: fn(&str) -> String,
|
||||
}
|
||||
|
||||
impl From<&'static Encoding> for TextEncoding {
|
||||
fn from(charset: &'static Encoding) -> Self {
|
||||
Self { charset, ascii_conversion: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl TextEncoding {
|
||||
pub(crate) fn display_text(self, text: &str) -> String {
|
||||
match self.ascii_conversion {
|
||||
Some(conversion) => (conversion.decode)(text),
|
||||
None => text.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_ascii_conversion(self) -> bool {
|
||||
self.ascii_conversion.is_some()
|
||||
}
|
||||
|
||||
pub fn with_ascii_conversion(mut self, decode: fn(&str) -> String, encode: fn(&str) -> String) -> Self {
|
||||
self.ascii_conversion = Some(AsciiConversion { decode, encode });
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_bom(bytes: &[u8], encoding: TextEncoding) -> bool {
|
||||
Encoding::for_bom(bytes).is_some_and(|(enc, _)| enc == encoding.charset)
|
||||
}
|
||||
|
||||
pub(crate) fn decode_raw<'a>(bytes: &'a [u8], encoding: TextEncoding) -> Cow<'a, str> {
|
||||
let body = match Encoding::for_bom(bytes) {
|
||||
Some((enc, len)) if enc == encoding => &bytes[len..],
|
||||
Some((enc, len)) if enc == encoding.charset => &bytes[len..],
|
||||
_ => bytes,
|
||||
};
|
||||
encoding.decode_without_bom_handling(body).0
|
||||
if encoding.ascii_conversion.is_some() && body.is_ascii() {
|
||||
Cow::Borrowed(std::str::from_utf8(body).expect("ASCII"))
|
||||
} else {
|
||||
encoding.charset.decode_without_bom_handling(body).0
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_binary(bytes: &[u8], encoding: &'static Encoding) -> bool {
|
||||
encoding != UTF_16LE && encoding != UTF_16BE && bytes.contains(&0)
|
||||
pub(crate) fn decode<'a>(bytes: &'a [u8], encoding: TextEncoding) -> Cow<'a, str> {
|
||||
let text = decode_raw(bytes, encoding);
|
||||
match encoding.ascii_conversion {
|
||||
Some(conversion) => Cow::Owned((conversion.decode)(&text)),
|
||||
None => text,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn encode(text: &str, encoding: &'static Encoding, bom: bool) -> IdeResult<Vec<u8>> {
|
||||
pub(crate) fn is_binary(bytes: &[u8], encoding: TextEncoding) -> bool {
|
||||
encoding.charset != UTF_16LE && encoding.charset != UTF_16BE && bytes.contains(&0)
|
||||
}
|
||||
|
||||
pub(crate) fn encode(text: &str, encoding: TextEncoding, bom: bool) -> IdeResult<Vec<u8>> {
|
||||
if let Some(conversion) = encoding.ascii_conversion {
|
||||
return Ok((conversion.encode)(text).into_bytes());
|
||||
}
|
||||
let encoding = encoding.charset;
|
||||
let mut out = Vec::new();
|
||||
if encoding == UTF_16LE || encoding == UTF_16BE {
|
||||
let le = encoding == UTF_16LE;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Operations delegated to the git CLI (all writes, plus blame and textual diffs).
|
||||
|
||||
use crate::TextEncoding;
|
||||
use crate::cli::{self, run};
|
||||
use crate::repo::{gx, resolve};
|
||||
use crate::text_encoding::{decode, encode, has_bom, is_binary};
|
||||
@@ -7,7 +8,6 @@ use crate::{
|
||||
BlameLine, CheckoutMode, CheckoutOptions, CheckoutOutcome, CommitOptions, DeletedBranch, DivergedLocal,
|
||||
PushBranchOptions, Repo,
|
||||
};
|
||||
use encoding_rs::Encoding;
|
||||
use gix::bstr::{BStr, ByteSlice};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -46,7 +46,7 @@ pub(crate) fn blame(
|
||||
path: &Path,
|
||||
current_text: Option<&str>,
|
||||
revision: Option<&str>,
|
||||
encoding: &'static Encoding,
|
||||
encoding: TextEncoding,
|
||||
) -> IdeResult<Vec<BlameLine>> {
|
||||
let rel = r.rel_path(path)?;
|
||||
let revision = match revision {
|
||||
@@ -59,7 +59,11 @@ pub(crate) fn blame(
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let text = current_text.map(str::to_owned).unwrap_or_else(|| decode(&disk, encoding).into_owned());
|
||||
if encoding.has_ascii_conversion() {
|
||||
return converted_blame(r, path, &rel, &text, revision, encoding);
|
||||
}
|
||||
let contents = match current_text {
|
||||
Some(_) if crate::diff::normalize_eol(&text) == crate::diff::normalize_eol(&decode(&disk, encoding)) => disk,
|
||||
Some(_) => {
|
||||
let disk_text = decode(&disk, encoding);
|
||||
let eol = if disk_text.contains("\r\n") {
|
||||
@@ -102,6 +106,46 @@ pub(crate) fn blame(
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
fn converted_blame(
|
||||
r: &Repo,
|
||||
path: &Path,
|
||||
rel: &str,
|
||||
text: &str,
|
||||
revision: &str,
|
||||
encoding: TextEncoding,
|
||||
) -> IdeResult<Vec<BlameLine>> {
|
||||
let current = crate::diff::normalize_eol(text);
|
||||
let mut result = uncommitted_lines(current.split_inclusive('\n').count());
|
||||
let Some(base) = r.file_bytes_at(path, Some(revision))? else { return Ok(result) };
|
||||
let out = run(r.root(), &["--literal-pathspecs", "blame", "--porcelain", revision, "--", rel], None)?;
|
||||
let physical = crate::blame::parse_porcelain(&out.stdout_str());
|
||||
let raw = crate::text_encoding::decode_raw(&base, encoding);
|
||||
let raw = crate::diff::normalize_eol(&raw);
|
||||
let mut logical = Vec::new();
|
||||
for (line, metadata) in raw.split_inclusive('\n').zip(physical) {
|
||||
let displayed = encoding.display_text(line);
|
||||
let displayed = crate::diff::normalize_eol(&displayed);
|
||||
logical.extend(std::iter::repeat_n(metadata, displayed.split_inclusive('\n').count()));
|
||||
}
|
||||
let base = decode(&base, encoding);
|
||||
let hunks = crate::diff::line_hunks(&base, ¤t);
|
||||
let (mut old, mut new) = (0usize, 0usize);
|
||||
for h in hunks {
|
||||
let unchanged = h.start as usize - new;
|
||||
for i in 0..unchanged {
|
||||
if let Some(metadata) = logical.get(old + i) {
|
||||
result[new + i] = metadata.clone();
|
||||
}
|
||||
}
|
||||
old = (h.old_start + h.old_lines) as usize;
|
||||
new = (h.start + h.lines) as usize;
|
||||
}
|
||||
for (line, metadata) in result[new..].iter_mut().zip(&logical[old.min(logical.len())..]) {
|
||||
*line = metadata.clone();
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn uncommitted_lines(n: usize) -> Vec<BlameLine> {
|
||||
let now =
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as f64).unwrap_or(0.0);
|
||||
@@ -115,7 +159,7 @@ fn uncommitted_lines(n: usize) -> Vec<BlameLine> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn diff_text(r: &Repo, path: &Path, staged: bool, encoding: &'static Encoding) -> IdeResult<String> {
|
||||
pub(crate) fn diff_text(r: &Repo, path: &Path, staged: bool, encoding: TextEncoding) -> IdeResult<String> {
|
||||
let rel = r.rel_path(path)?;
|
||||
let base =
|
||||
["-c", "diff.noprefix=false", "-c", "diff.mnemonicPrefix=false", "-c", "diff.external=", "--literal-pathspecs"];
|
||||
@@ -156,11 +200,7 @@ pub(crate) fn unstage(r: &Repo, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn stage_patch(
|
||||
r: &Repo,
|
||||
patch: &str,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>,
|
||||
) -> IdeResult<()> {
|
||||
pub(crate) fn stage_patch(r: &Repo, patch: &str, encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>) -> IdeResult<()> {
|
||||
if patch.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -168,6 +208,12 @@ pub(crate) fn stage_patch(
|
||||
if !patch.ends_with('\n') {
|
||||
patch.push('\n');
|
||||
}
|
||||
if crate::patch::parse_patch(&patch)
|
||||
.iter()
|
||||
.any(|file| encoding(&r.abs_path(&file.path)).is_ok_and(TextEncoding::has_ascii_conversion))
|
||||
{
|
||||
return crate::patch::stage_converted_patch(r, &patch, encoding);
|
||||
}
|
||||
let bytes = crate::patch::encode_text_patch(r, &patch, encoding)?;
|
||||
run(r.root(), &["apply", "--cached", "--unidiff-zero", "--whitespace=nowarn", "--recount", "-"], Some(&bytes))
|
||||
.map(|_| ())
|
||||
@@ -257,7 +303,7 @@ pub(crate) fn commit(
|
||||
r: &Repo,
|
||||
message: &str,
|
||||
options: &CommitOptions,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<String> {
|
||||
let mut args = vec!["commit", "-q", "--cleanup=whitespace"];
|
||||
if options.amend {
|
||||
@@ -311,7 +357,7 @@ fn commit_partial(
|
||||
commit_args: &[&str],
|
||||
stdin: Option<&[u8]>,
|
||||
partial: &[crate::PartialCommitFile],
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<&'static Encoding>,
|
||||
encoding: &dyn Fn(&Path) -> IdeResult<TextEncoding>,
|
||||
) -> IdeResult<()> {
|
||||
let tmp = TempIndex(r.git_dir.join("openidea-partial-index"));
|
||||
let index = r.git_dir.join("index");
|
||||
@@ -852,7 +898,7 @@ fn push_remote(r: &Repo) -> IdeResult<String> {
|
||||
.ok_or_else(|| IdeError::invalid("no remote configured"))
|
||||
}
|
||||
|
||||
fn write_index_blob(r: &Repo, rel: &str, bytes: &[u8], env: &[(&str, &std::ffi::OsStr)]) -> IdeResult<()> {
|
||||
pub(crate) fn write_index_blob(r: &Repo, rel: &str, bytes: &[u8], env: &[(&str, &std::ffi::OsStr)]) -> IdeResult<()> {
|
||||
let entry =
|
||||
cli::run_env(r.root(), &["--literal-pathspecs", "ls-files", "--stage", "--", rel], None, env)?.stdout_str();
|
||||
let mode = entry.split_whitespace().next().unwrap_or("100644");
|
||||
@@ -866,7 +912,7 @@ pub(crate) fn stage_hunks(
|
||||
path: &Path,
|
||||
current: &str,
|
||||
selected: &[usize],
|
||||
encoding: &'static Encoding,
|
||||
encoding: TextEncoding,
|
||||
) -> IdeResult<()> {
|
||||
if selected.is_empty() {
|
||||
return Ok(());
|
||||
@@ -887,7 +933,7 @@ pub(crate) fn stage_hunks(
|
||||
}
|
||||
|
||||
// Git headers contain UTF-8 paths; only source lines use the file's encoding.
|
||||
fn decode_diff(bytes: &[u8], encoding: &'static Encoding) -> String {
|
||||
fn decode_diff(bytes: &[u8], encoding: TextEncoding) -> String {
|
||||
let mut hunks = false;
|
||||
let mut text = String::new();
|
||||
for line in bytes.split_inclusive(|&b| b == b'\n') {
|
||||
@@ -898,7 +944,7 @@ fn decode_diff(bytes: &[u8], encoding: &'static Encoding) -> String {
|
||||
hunks = true;
|
||||
}
|
||||
if hunks {
|
||||
text.push_str(&decode(line, encoding));
|
||||
text.push_str(&crate::text_encoding::decode_raw(line, encoding));
|
||||
} else {
|
||||
text.push_str(&String::from_utf8_lossy(line));
|
||||
}
|
||||
|
||||
@@ -567,3 +567,98 @@ fn update_branch_fast_forwards_a_branch_not_checked_out() {
|
||||
// The checked-out branch and branches without upstream are refused.
|
||||
assert!(repo.update_branch("work", &cancel, &out).is_err());
|
||||
}
|
||||
|
||||
/// `"` and tabs are illegal in Windows file names, and only they make Git quote a path.
|
||||
const QUOTED: &str = if cfg!(windows) { "quote '.txt" } else { "quote\"\t.txt" };
|
||||
|
||||
#[test]
|
||||
fn shelf_diff_and_scoped_operations_preserve_siblings() {
|
||||
let fx = Fixture::new();
|
||||
for path in ["a.txt", "b.txt", QUOTED, "gone.txt"] {
|
||||
fx.write(path, "base\n");
|
||||
}
|
||||
fx.commit_all("base");
|
||||
for path in ["a.txt", "b.txt", QUOTED] {
|
||||
fx.write(path, "shelved\n");
|
||||
}
|
||||
std::fs::remove_file(fx.path("gone.txt")).unwrap();
|
||||
fx.write("new.txt", "new\n");
|
||||
std::fs::write(fx.path("binary.dat"), [0, 1, 255]).unwrap();
|
||||
let repo = fx.repo();
|
||||
let shelf = fx._tmp.path().join("shelf");
|
||||
let paths = ["a.txt", "b.txt", QUOTED, "gone.txt", "new.txt", "binary.dat"].map(|p| fx.path(p));
|
||||
let entry = repo.shelve(&shelf, "work", &paths).unwrap();
|
||||
fx.write("a.txt", "new head\n");
|
||||
fx.commit_all("advance head");
|
||||
fx.write("a.txt", "local edits\n");
|
||||
let status = fx.git(&["status", "--porcelain"]);
|
||||
let index = fx.git(&["ls-files", "--stage"]);
|
||||
let diffs = shelf::diff_files(&repo, &shelf, &entry.id).unwrap();
|
||||
for path in ["a.txt", "b.txt", QUOTED] {
|
||||
let f = diffs.iter().find(|f| f.path == path).unwrap();
|
||||
assert_eq!(f.before.as_deref(), Some("base\n"), "{path}");
|
||||
assert_eq!(f.after.as_deref(), Some("shelved\n"), "{path}");
|
||||
}
|
||||
assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().before.as_deref(), Some(""));
|
||||
assert_eq!(diffs.iter().find(|f| f.path == "gone.txt").unwrap().after.as_deref(), Some(""));
|
||||
assert!(diffs.iter().find(|f| f.path == "binary.dat").unwrap().before.is_none());
|
||||
assert_eq!(fx.git(&["status", "--porcelain"]), status);
|
||||
assert_eq!(fx.git(&["ls-files", "--stage"]), index);
|
||||
assert_eq!(fx.read("a.txt"), "local edits\n");
|
||||
let patch = shelf::selected_patch(&shelf, &entry.id, Some("b.txt")).unwrap();
|
||||
assert!(patch.contains("b/b.txt"));
|
||||
assert!(!patch.contains("b/a.txt"));
|
||||
assert!(shelf::selected_patch(&shelf, &entry.id, Some("missing")).is_err());
|
||||
shelf::unshelve_path(&repo, &shelf, &entry.id, true, Some("b.txt")).unwrap();
|
||||
assert_eq!(fx.read("b.txt"), "shelved\n");
|
||||
assert!(!shelf::list(&shelf).unwrap()[0].files.iter().any(|f| f.path == "b.txt"));
|
||||
shelf::delete_path(&shelf, &entry.id, Some(QUOTED)).unwrap();
|
||||
assert!(shelf::selected_patch(&shelf, &entry.id, Some("a.txt")).is_ok());
|
||||
shelf::import_patch(&shelf, "imported", &patch).unwrap();
|
||||
assert_eq!(shelf::list(&shelf).unwrap().len(), 2);
|
||||
assert_eq!(fx.read("a.txt"), "local edits\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shelf_imported_rename_diff_and_last_file_delete() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("old.txt", "same\n");
|
||||
fx.commit_all("base");
|
||||
fx.git(&["mv", "old.txt", "new.txt"]);
|
||||
let patch = fx.git(&["diff", "--cached", "--binary", "--full-index", "-M"]) + "\n";
|
||||
fx.git(&["reset", "--hard", "HEAD"]);
|
||||
let repo = fx.repo();
|
||||
let shelf = fx._tmp.path().join("shelf");
|
||||
shelf::import_patch(&shelf, "rename", &patch).unwrap();
|
||||
let id = &shelf::list(&shelf).unwrap()[0].id;
|
||||
let diffs = shelf::diff_files(&repo, &shelf, id).unwrap();
|
||||
assert_eq!(diffs.len(), 1);
|
||||
assert_eq!(diffs[0].path, "new.txt");
|
||||
// A rename-only imported patch has no embedded blob ids: do not guess from HEAD.
|
||||
assert!(diffs[0].before.is_none());
|
||||
assert!(diffs[0].patch.contains("rename from old.txt"));
|
||||
shelf::delete_path(&shelf, id, Some("new.txt")).unwrap();
|
||||
assert!(shelf::list(&shelf).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shelf_new_renames_keep_self_contained_sides_and_plain_imports_keep_scope() {
|
||||
let fx = Fixture::new();
|
||||
fx.write("old.txt", "renamed content\n");
|
||||
fx.commit_all("base");
|
||||
fx.git(&["mv", "old.txt", "new.txt"]);
|
||||
let repo = fx.repo();
|
||||
let shelf = fx._tmp.path().join("shelf");
|
||||
let entry = repo.shelve(&shelf, "rename", &[fx.path("old.txt"), fx.path("new.txt")]).unwrap();
|
||||
let diffs = shelf::diff_files(&repo, &shelf, &entry.id).unwrap();
|
||||
assert_eq!(diffs.len(), 2);
|
||||
assert_eq!(diffs.iter().find(|f| f.path == "old.txt").unwrap().before.as_deref(), Some("renamed content\n"));
|
||||
assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().after.as_deref(), Some("renamed content\n"));
|
||||
let plain = "--- a/one.txt\n+++ b/one.txt\n@@ -1 +1 @@\n--- removed\n+++ added\n--- a/two.txt\n+++ b/two.txt\n@@ -1 +1 @@\n-old\n+new\n";
|
||||
shelf::import_patch(&shelf, "plain", plain).unwrap();
|
||||
let selected = shelf::selected_patch(&shelf, "plain", Some("one.txt")).unwrap();
|
||||
assert!(selected.contains("--- removed\n+++ added"));
|
||||
assert!(!selected.contains("two.txt"));
|
||||
shelf::delete_path(&shelf, "plain", Some("one.txt")).unwrap();
|
||||
assert_eq!(shelf::list(&shelf).unwrap().iter().find(|s| s.id == "plain").unwrap().files[0].path, "two.txt");
|
||||
}
|
||||
@@ -22,6 +22,87 @@ pub(crate) fn encoding_label(enc: &'static Encoding) -> String {
|
||||
enc.name().to_ascii_lowercase()
|
||||
}
|
||||
|
||||
fn unicode_escape(text: &str) -> Option<u16> {
|
||||
let digits = text.strip_prefix("\\u")?.as_bytes().get(..4)?;
|
||||
digits.iter().try_fold(0u16, |n, b| Some(n * 16 + (*b as char).to_digit(16)? as u16))
|
||||
}
|
||||
|
||||
fn unicode_character(text: &str) -> Option<(char, usize)> {
|
||||
let unit = unicode_escape(text)?;
|
||||
if (0xD800..=0xDBFF).contains(&unit) {
|
||||
let low = unicode_escape(&text[6..]).filter(|low| (0xDC00..=0xDFFF).contains(low))?;
|
||||
char::from_u32(0x10000 + ((unit as u32 - 0xD800) << 10) + low as u32 - 0xDC00).map(|c| (c, 12))
|
||||
} else {
|
||||
// A decoded line terminator would save as a real line break and split the property.
|
||||
char::from_u32(unit as u32).filter(|c| !matches!(c, '\n' | '\r')).map(|c| (c, 6))
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes eligible Java properties Unicode escapes once. Backslash pairs,
|
||||
/// malformed escapes and unpaired surrogates retain their literal spelling.
|
||||
pub fn decode_properties_escapes(text: &str) -> String {
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while !rest.is_empty() {
|
||||
if rest.starts_with("\\\\") {
|
||||
out.push_str("\\\\");
|
||||
rest = &rest[2..];
|
||||
continue;
|
||||
}
|
||||
if let Some((c, len)) = unicode_character(rest) {
|
||||
out.push(c);
|
||||
rest = &rest[len..];
|
||||
continue;
|
||||
}
|
||||
let c = rest.chars().next().expect("nonempty");
|
||||
out.push(c);
|
||||
rest = &rest[c.len_utf8()..];
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Serializes displayed properties text as ASCII, using uppercase UTF-16 units.
|
||||
pub fn encode_properties_ascii(text: &str) -> String {
|
||||
use std::fmt::Write;
|
||||
let mut out = String::with_capacity(text.len());
|
||||
let mut rest = text;
|
||||
while let Some(c) = rest.chars().next() {
|
||||
let current = rest;
|
||||
rest = &rest[c.len_utf8()..];
|
||||
if c == '\\' {
|
||||
if rest.starts_with('\\') {
|
||||
out.push_str("\\\\");
|
||||
rest = &rest[1..];
|
||||
} else if unicode_character(current).is_some() || rest.chars().next().is_some_and(|next| !next.is_ascii()) {
|
||||
// Keep a literal backslash from swallowing a generated escape,
|
||||
// or becoming a Unicode escape on the next load.
|
||||
out.push_str("\\u005C");
|
||||
} else {
|
||||
out.push(c);
|
||||
}
|
||||
} else if c.is_ascii() && c != '\0' {
|
||||
out.push(c);
|
||||
} else {
|
||||
for unit in c.encode_utf16(&mut [0; 2]) {
|
||||
write!(out, "\\u{unit:04X}").expect("write to string");
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Preserve an ASCII file's exact escape spelling when edits were undone or
|
||||
/// replaced with identical displayed text. Otherwise serialize the new buffer.
|
||||
pub fn save_properties_text(path: &Path, text: &str) -> IdeResult<f64> {
|
||||
if let Ok(bytes) = fs::read(path)
|
||||
&& bytes.is_ascii()
|
||||
&& decode_properties_escapes(std::str::from_utf8(&bytes).expect("ASCII")) == text
|
||||
{
|
||||
return Ok(mtime_ms(&fs::metadata(path)?));
|
||||
}
|
||||
write_atomic(path, encode_properties_ascii(text).as_bytes())
|
||||
}
|
||||
|
||||
/// Detects BOM-less UTF-16 from the distribution of NUL bytes in mostly-ASCII text.
|
||||
fn sniff_utf16(sample: &[u8]) -> Option<&'static Encoding> {
|
||||
if sample.len() < 4 {
|
||||
@@ -285,6 +366,42 @@ fn write_atomic_impl(path: &Path, bytes: &[u8], allow_in_place: bool) -> IdeResu
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn properties_unicode_escapes() {
|
||||
for (source, displayed) in [
|
||||
(r"test.key1=Hello", "test.key1=Hello"),
|
||||
(r"test.key2=\u4F60\u597D", "test.key2=你好"),
|
||||
(r"test.key3=\u4f60\u597d", "test.key3=你好"),
|
||||
(r"\u0041\uD83D\uDE00!", "A😀!"),
|
||||
(r"\\u0041", r"\\u0041"),
|
||||
(r"\\\u0041", r"\\A"),
|
||||
(r"\\\\u0041", r"\\\\u0041"),
|
||||
(r"\u005Cu0041", r"\u0041"),
|
||||
(r"\u123 \u12xz \uu0041 \uD800 \uDC00", r"\u123 \u12xz \uu0041 \uD800 \uDC00"),
|
||||
(r"\uD800\u0041", r"\uD800A"),
|
||||
(r"\uD800\uD83D\uDE00", "\\uD800😀"),
|
||||
] {
|
||||
assert_eq!(decode_properties_escapes(source), displayed, "{source}");
|
||||
}
|
||||
assert_eq!(encode_properties_ascii("你好😀"), r"\u4F60\u597D\uD83D\uDE00");
|
||||
for text in ["plain=ASCII\r\n", r"escaped=\\u0041", r"bad=\u12xz", r"bad=\uD800"] {
|
||||
assert_eq!(encode_properties_ascii(text), text);
|
||||
}
|
||||
for text in ["你好😀", "\0", "\\中", "\\\\中", "\\\\\\😀", r"\u0041", r"\\u0041", r"\uD83D\uDE00"] {
|
||||
let ascii = encode_properties_ascii(text);
|
||||
assert!(ascii.is_ascii());
|
||||
assert_eq!(decode_properties_escapes(&ascii), text, "{ascii}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_line_terminator_escapes_survive_an_edit() {
|
||||
let source = r"key=one\u000Atwo\u000Dthree";
|
||||
let displayed = decode_properties_escapes(source);
|
||||
assert_eq!(displayed, source);
|
||||
assert_eq!(encode_properties_ascii(&displayed.replace("three", "3")), r"key=one\u000Atwo\u000D3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strict_write_replaces_complete_contents() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -22,7 +22,8 @@ pub use archive::{
|
||||
pub use bytes::{ByteRange, FileProbe, MAX_RANGE_LEN, base64, image_mime, probe, read_data_url, read_range};
|
||||
use encoding::SNIFF_LEN;
|
||||
pub use encoding::{
|
||||
LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, encode, write_atomic, write_atomic_strict,
|
||||
LARGE_FILE_THRESHOLD, MAX_OPEN_SIZE, READ_ONLY_THRESHOLD, decode_properties_escapes, encode,
|
||||
encode_properties_ascii, save_properties_text, write_atomic, write_atomic_strict,
|
||||
};
|
||||
pub use rules::ExcludeMatcher;
|
||||
pub use watch::{WatchMode, Watcher};
|
||||
|
||||
@@ -118,6 +118,7 @@ struct DocState {
|
||||
ui_mode: Option<String>,
|
||||
encoding: String,
|
||||
has_bom: bool,
|
||||
native_to_ascii: bool,
|
||||
read_only: bool,
|
||||
large: bool,
|
||||
editorconfig: Option<EditorConfigProps>,
|
||||
@@ -189,6 +190,72 @@ impl Documents {
|
||||
*self.defaults.write() = defaults;
|
||||
}
|
||||
|
||||
/// Re-decode only clean properties buffers after the conversion setting changes.
|
||||
/// Runs on the host's blocking pool; dirty documents retain their current codec.
|
||||
pub fn reload_properties(&self) {
|
||||
for id in self.open_documents() {
|
||||
let result = self.with_doc(id, |st| {
|
||||
let enabled = self.file_defaults().native_to_ascii_for(&st.path);
|
||||
if st.dirty || st.uri.is_some() || st.binary || enabled == st.native_to_ascii {
|
||||
return Ok(());
|
||||
}
|
||||
let loaded = Self::load_as(&st.path, &st.encoding, enabled)?;
|
||||
self.replace_locked(id, st, &loaded.text)?;
|
||||
st.native_to_ascii = enabled;
|
||||
st.disk_mtime = loaded.mtime_ms;
|
||||
st.disk_len = disk_len(&st.path);
|
||||
Ok(())
|
||||
});
|
||||
if let Err(e) = result {
|
||||
tracing::warn!("reload properties {id}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn native_to_ascii_for(&self, path: &Path) -> bool {
|
||||
if let Some(id) = self.by_path(path)
|
||||
&& let Ok(enabled) = self.with_doc(id, |st| Ok(st.native_to_ascii))
|
||||
{
|
||||
return enabled;
|
||||
}
|
||||
self.file_defaults().native_to_ascii_for(path)
|
||||
}
|
||||
|
||||
fn load_as(path: &Path, encoding: &str, native_to_ascii: bool) -> IdeResult<ide_vfs::LoadedText> {
|
||||
let mut loaded = if native_to_ascii {
|
||||
let detected = ide_vfs::load_text(path)?;
|
||||
// Converted files are ASCII even when the selected source charset is UTF-16.
|
||||
if detected.text.is_ascii() && !detected.binary && detected.encoding == "utf-8" {
|
||||
detected
|
||||
} else {
|
||||
ide_vfs::load_text_as(path, encoding)?
|
||||
}
|
||||
} else {
|
||||
ide_vfs::load_text_as(path, encoding)?
|
||||
};
|
||||
if native_to_ascii {
|
||||
loaded.text = ide_vfs::decode_properties_escapes(&loaded.text);
|
||||
}
|
||||
Ok(loaded)
|
||||
}
|
||||
|
||||
/// Closed-file readers use the same decoding as document opening.
|
||||
pub fn load_file(&self, path: &Path) -> IdeResult<ide_vfs::LoadedText> {
|
||||
Self::load_with_defaults(path, &self.file_defaults())
|
||||
}
|
||||
|
||||
pub fn load_file_as(&self, path: &Path, encoding: &str) -> IdeResult<ide_vfs::LoadedText> {
|
||||
Self::load_as(path, encoding, self.native_to_ascii_for(path))
|
||||
}
|
||||
|
||||
fn load_with_defaults(path: &Path, defaults: &FileDefaults) -> IdeResult<ide_vfs::LoadedText> {
|
||||
let mut loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(&absolute(path)))?;
|
||||
if !loaded.binary && defaults.native_to_ascii_for(path) {
|
||||
loaded.text = ide_vfs::decode_properties_escapes(&loaded.text);
|
||||
}
|
||||
Ok(loaded)
|
||||
}
|
||||
|
||||
pub fn file_defaults(&self) -> FileDefaults {
|
||||
self.defaults.read().clone()
|
||||
}
|
||||
@@ -263,7 +330,7 @@ impl Documents {
|
||||
return Ok(info);
|
||||
}
|
||||
let defaults = self.file_defaults();
|
||||
let loaded = ide_vfs::load_text_with_fallback(path, defaults.fallback_for(&absolute(path)))?;
|
||||
let loaded = Self::load_with_defaults(path, &defaults)?;
|
||||
let language = (self.detect_language)(path, loaded.text.lines().next());
|
||||
let ui_mode = (self.detect_ui_mode)(path, &language);
|
||||
// Archive entries (`<jar>!/<entry>`) are read-only and kept from language
|
||||
@@ -305,6 +372,7 @@ impl Documents {
|
||||
ui_mode,
|
||||
encoding,
|
||||
has_bom,
|
||||
native_to_ascii: !loaded.binary && defaults.native_to_ascii_for(path),
|
||||
read_only: !observed || !writable,
|
||||
large: loaded.large,
|
||||
editorconfig,
|
||||
@@ -362,6 +430,7 @@ impl Documents {
|
||||
ui_mode: None,
|
||||
encoding: "utf-8".into(),
|
||||
has_bom: false,
|
||||
native_to_ascii: false,
|
||||
read_only: true,
|
||||
large,
|
||||
editorconfig: None,
|
||||
@@ -524,7 +593,12 @@ impl Documents {
|
||||
if !st.dirty || st.uri.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let mtime = ide_vfs::save_text(&st.path, &st.doc.text_for_save(), &st.encoding, st.has_bom)?;
|
||||
let text = st.doc.text_for_save();
|
||||
let mtime = if st.native_to_ascii {
|
||||
ide_vfs::save_properties_text(&st.path, &text)?
|
||||
} else {
|
||||
ide_vfs::save_text(&st.path, &text, &st.encoding, st.has_bom)?
|
||||
};
|
||||
st.disk_mtime = mtime;
|
||||
st.disk_len = disk_len(&st.path);
|
||||
self.set_dirty(doc, st, false);
|
||||
@@ -582,6 +656,18 @@ impl Documents {
|
||||
self.with_doc(id, |st| Ok(st.doc.text())).ok()
|
||||
}
|
||||
|
||||
/// Search overlays include closed properties files when their disk spelling
|
||||
/// differs from the text that opening the document would display.
|
||||
pub fn search_text_of(&self, path: &Path) -> Option<String> {
|
||||
self.text_of(path).or_else(|| {
|
||||
if !self.native_to_ascii_for(path) {
|
||||
return None;
|
||||
}
|
||||
let loaded = self.load_file(path).ok()?;
|
||||
(!loaded.binary && !loaded.too_large).then(|| ide_text::normalize_line_endings(&loaded.text).into_owned())
|
||||
})
|
||||
}
|
||||
|
||||
/// Toggle Read-only Attribute: sets the file's writable bit and, when the
|
||||
/// file is open, whether its document accepts edits. Returns the resulting
|
||||
/// read-only state.
|
||||
@@ -672,14 +758,18 @@ impl Documents {
|
||||
if st.uri.is_some() {
|
||||
return Err(read_only_error(&st.path));
|
||||
}
|
||||
ide_vfs::encode("", encoding, false)?;
|
||||
if reload {
|
||||
if st.dirty {
|
||||
return Err(IdeError::invalid("save or revert the unsaved changes before reloading"));
|
||||
}
|
||||
let loaded = ide_vfs::load_text_as(&st.path, encoding)?;
|
||||
let enabled = self.file_defaults().native_to_ascii_for(&st.path);
|
||||
let loaded = Self::load_as(&st.path, encoding, enabled)?;
|
||||
st.native_to_ascii = enabled;
|
||||
st.encoding = loaded.encoding.clone();
|
||||
st.has_bom = loaded.has_bom;
|
||||
st.disk_mtime = loaded.mtime_ms;
|
||||
st.disk_len = disk_len(&st.path);
|
||||
self.replace_locked(doc, st, &loaded.text)?;
|
||||
// Reloading is not an edit: the buffer matches the file again.
|
||||
self.set_dirty(doc, st, false);
|
||||
@@ -691,7 +781,9 @@ impl Documents {
|
||||
let utf16 = encoding.starts_with("utf-16");
|
||||
let has_bom = utf16 || (st.has_bom && encoding == "utf-8");
|
||||
// Fails (with the offending character) when the text is not representable.
|
||||
ide_vfs::encode(&st.doc.text_for_save(), encoding, has_bom)?;
|
||||
if !st.native_to_ascii {
|
||||
ide_vfs::encode(&st.doc.text_for_save(), encoding, has_bom)?;
|
||||
}
|
||||
st.encoding = encoding.to_owned();
|
||||
st.has_bom = has_bom;
|
||||
self.set_dirty(doc, st, true);
|
||||
@@ -699,6 +791,21 @@ impl Documents {
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-checks open file buffers, including files outside the watched roots.
|
||||
/// Call from a blocking worker; unchanged files only need their metadata.
|
||||
pub fn sync_from_disk(&self) {
|
||||
let slots: Vec<Slot> = self.registry.lock().docs.values().map(|e| e.slot.clone()).collect();
|
||||
let paths: Vec<PathBuf> = slots
|
||||
.iter()
|
||||
.filter_map(|slot| {
|
||||
let st = slot.lock();
|
||||
(!st.closed && st.uri.is_none() && !st.binary && !ide_vfs::is_archive_path(&st.path))
|
||||
.then(|| st.path.clone())
|
||||
})
|
||||
.collect();
|
||||
self.on_disk_changed(&paths);
|
||||
}
|
||||
|
||||
/// Re-reads from disk after an external change. Clean documents reload
|
||||
/// silently; dirty ones are left alone and reported via `IdeEvent::Notify`.
|
||||
/// Changes matching our own last save are ignored.
|
||||
@@ -706,6 +813,9 @@ impl Documents {
|
||||
for path in paths {
|
||||
let Some(id) = self.by_path(path) else { continue };
|
||||
let result = self.with_doc(id, |st| {
|
||||
if st.uri.is_some() || st.binary || ide_vfs::is_archive_path(&st.path) {
|
||||
return Ok(());
|
||||
}
|
||||
let Ok(meta) = std::fs::metadata(&st.path) else { return Ok(()) };
|
||||
let mtime = ide_vfs::mtime_ms(&meta);
|
||||
if mtime == st.disk_mtime && meta.len() == st.disk_len {
|
||||
@@ -726,7 +836,9 @@ impl Documents {
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
let loaded = ide_vfs::load_text(&st.path)?;
|
||||
let defaults = self.file_defaults();
|
||||
let loaded = Self::load_with_defaults(&st.path, &defaults)?;
|
||||
st.native_to_ascii = defaults.native_to_ascii_for(&st.path);
|
||||
st.disk_mtime = loaded.mtime_ms;
|
||||
st.disk_len = disk_len(&st.path);
|
||||
if loaded.binary || st.binary {
|
||||
|
||||
@@ -18,6 +18,7 @@ pub struct FileDefaults {
|
||||
/// Fallback for files outside `root`.
|
||||
pub global_fallback: String,
|
||||
pub properties_fallback: String,
|
||||
pub native_to_ascii: bool,
|
||||
pub editorconfig: bool,
|
||||
pub root: Option<PathBuf>,
|
||||
}
|
||||
@@ -47,6 +48,7 @@ impl FileDefaults {
|
||||
project_fallback: normalize(&enc.project),
|
||||
global_fallback: normalize(&enc.global),
|
||||
properties_fallback: normalize(&enc.properties),
|
||||
native_to_ascii: enc.native_to_ascii,
|
||||
editorconfig: style.editorconfig,
|
||||
root: root.map(Path::to_path_buf),
|
||||
}
|
||||
@@ -64,6 +66,10 @@ impl FileDefaults {
|
||||
(label != "utf-8").then_some(label.as_str())
|
||||
}
|
||||
|
||||
pub fn native_to_ascii_for(&self, path: &Path) -> bool {
|
||||
self.native_to_ascii && path.extension().is_some_and(|e| e == "properties")
|
||||
}
|
||||
|
||||
/// Bytes of a new empty file: a BOM when UTF-8 files are created with one.
|
||||
pub fn new_file_bytes(&self) -> Vec<u8> {
|
||||
if self.encoding == "utf-8" && self.utf8_bom {
|
||||
|
||||
@@ -27,6 +27,12 @@ pub enum SettingsScope {
|
||||
Project,
|
||||
}
|
||||
|
||||
fn remove_custom_schemes(layer: &mut Json) {
|
||||
if let Some(editor) = layer.get_mut("editor").and_then(Json::as_object_mut) {
|
||||
editor.remove("customSchemes");
|
||||
}
|
||||
}
|
||||
|
||||
fn defaults_json() -> Json {
|
||||
serde_json::to_value(Settings::default()).expect("settings serialize")
|
||||
}
|
||||
@@ -178,10 +184,12 @@ impl SettingsStore {
|
||||
|
||||
fn compute(&self) -> Settings {
|
||||
let mut merged = defaults_json();
|
||||
for (_, file) in self.layers() {
|
||||
if let Some(layer) = read_layer(&file)
|
||||
&& let Err(e) = try_merge(&mut merged, layer)
|
||||
{
|
||||
for (scope, file) in self.layers() {
|
||||
let Some(mut layer) = read_layer(&file) else { continue };
|
||||
if scope == SettingsScope::Project {
|
||||
remove_custom_schemes(&mut layer);
|
||||
}
|
||||
if let Err(e) = try_merge(&mut merged, layer) {
|
||||
tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display());
|
||||
}
|
||||
}
|
||||
@@ -205,6 +213,13 @@ impl SettingsStore {
|
||||
/// `null` removes the key from that layer. Unknown keys and values of the
|
||||
/// wrong type are rejected with `InvalidInput`; the file is left untouched.
|
||||
pub fn set(&self, scope: SettingsScope, key: &str, value: serde_json::Value) -> IdeResult<Settings> {
|
||||
if scope == SettingsScope::Project
|
||||
&& (key == "editor.customSchemes"
|
||||
|| key.starts_with("editor.customSchemes.")
|
||||
|| (key == "editor" && value.get("customSchemes").is_some()))
|
||||
{
|
||||
return Err(IdeError::invalid("custom color schemes belong to user settings"));
|
||||
}
|
||||
let file = self.file(scope).ok_or_else(|| IdeError::invalid("no project is open"))?.to_path_buf();
|
||||
let segments: Vec<&str> = key.split('.').collect();
|
||||
if segments.iter().any(|s| s.is_empty()) {
|
||||
@@ -242,7 +257,10 @@ impl SettingsStore {
|
||||
for (layer_scope, layer_file) in self.layers() {
|
||||
let is_target = layer_scope == scope;
|
||||
let layer = if is_target { parse_layer(&new_text).ok() } else { read_layer(&layer_file) };
|
||||
let Some(layer) = layer else { continue };
|
||||
let Some(mut layer) = layer else { continue };
|
||||
if layer_scope == SettingsScope::Project {
|
||||
remove_custom_schemes(&mut layer);
|
||||
}
|
||||
if let Err(e) = try_merge(&mut merged, layer)
|
||||
&& is_target
|
||||
{
|
||||
@@ -316,6 +334,38 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn custom_color_schemes_are_user_settings_and_survive_reload() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg = tmp.path().join("cfg");
|
||||
let proj = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(proj.join(".openidea")).unwrap();
|
||||
let store = SettingsStore::load(&cfg, Some(&proj));
|
||||
let schemes = json!({"custom-forest": {"name": "Forest", "parent": "dark", "attributes": {
|
||||
"keyword": {"foreground": "#99dd77", "bold": true}
|
||||
}}});
|
||||
store.set(SettingsScope::Global, "editor.customSchemes", schemes.clone()).unwrap();
|
||||
store.set(SettingsScope::Global, "editor.colorScheme", json!("custom-forest")).unwrap();
|
||||
let loaded = SettingsStore::load(&cfg, Some(&proj)).effective();
|
||||
assert_eq!(loaded.editor.color_scheme, "custom-forest");
|
||||
assert_eq!(serde_json::to_value(&loaded.editor.custom_schemes).unwrap(), schemes);
|
||||
assert!(store.set(SettingsScope::Project, "editor.customSchemes", schemes.clone()).is_err());
|
||||
assert!(
|
||||
store
|
||||
.set(SettingsScope::Project, "editor.customSchemes.custom-other", schemes["custom-forest"].clone())
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.set(SettingsScope::Project, "editor", json!({"customSchemes": schemes})).is_err());
|
||||
std::fs::write(
|
||||
proj.join(".openidea/settings.toml"),
|
||||
"[editor.customSchemes.custom-forest]\nname = \"Project\"\nparent = \"light\"\nattributes = {}\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(store.reload().editor.custom_schemes["custom-forest"].name, "Forest");
|
||||
store.set(SettingsScope::Global, "editor.customSchemes", json!({})).unwrap();
|
||||
assert!(SettingsStore::load(&cfg, Some(&proj)).effective().editor.custom_schemes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layering_and_fallback() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -597,5 +597,106 @@ fn archive_entries_open_read_only_without_observers() {
|
||||
assert_eq!((info.text.as_str(), info.language.as_str(), info.read_only), ("fn main() {}\n", "rust", true));
|
||||
assert_eq!(*info.path, *path);
|
||||
assert!(f.rec.log.lock().is_empty(), "language servers / history never see archive entries");
|
||||
fs::remove_file(&jar).unwrap();
|
||||
f.docs.sync_from_disk();
|
||||
assert_eq!(f.docs.snapshot(info.id).unwrap().version, info.version);
|
||||
assert_eq!(f.docs.snapshot(info.id).unwrap().text, info.text);
|
||||
f.docs.close(info.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_conversion_uses_displayed_utf16_and_survives_reload() {
|
||||
let f = fixture();
|
||||
let path = f.root.join("messages.properties");
|
||||
let original = "greeting=\\u4f60\\u597d\\uD83D\\uDE00!\r\nliteral=\\\\u0041\r\nbad=\\u12xz\r\n";
|
||||
fs::write(&path, original).unwrap();
|
||||
let mut defaults = f.docs.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
f.docs.set_file_defaults(defaults);
|
||||
let doc = f.docs.open(&path).unwrap();
|
||||
assert_eq!(doc.text, "greeting=你好😀!\nliteral=\\\\u0041\nbad=\\u12xz\n");
|
||||
assert!(!doc.dirty);
|
||||
assert_eq!(f.docs.text_snapshot(doc.id).unwrap().text, doc.text);
|
||||
f.docs.save(doc.id).unwrap();
|
||||
assert_eq!(fs::read(&path).unwrap(), original.as_bytes());
|
||||
// Two BMP characters + one surrogate pair put ! at UTF-16 offset 13.
|
||||
f.docs.edit(doc.id, doc.version, change(13, 14, "界")).unwrap();
|
||||
f.docs.save(doc.id).unwrap();
|
||||
let expected = "greeting=\\u4F60\\u597D\\uD83D\\uDE00\\u754C\r\nliteral=\\\\u0041\r\nbad=\\u12xz\r\n";
|
||||
assert_eq!(fs::read(&path).unwrap(), expected.as_bytes());
|
||||
assert!(f.rec.log.lock().iter().any(|line| line.contains("greeting=你好😀界")));
|
||||
let saved = f.docs.snapshot(doc.id).unwrap();
|
||||
f.docs.set_encoding(doc.id, "iso-8859-1", true).unwrap();
|
||||
assert_eq!(f.docs.snapshot(doc.id).unwrap().version, saved.version);
|
||||
// Force a watcher event even though the serialized contents are identical.
|
||||
fs::File::options().write(true).open(&path).unwrap().set_modified(std::time::SystemTime::UNIX_EPOCH).unwrap();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
let reloaded = f.docs.snapshot(doc.id).unwrap();
|
||||
assert_eq!(reloaded.text, saved.text);
|
||||
assert_eq!(reloaded.version, saved.version);
|
||||
assert!(!reloaded.dirty);
|
||||
fs::write(&path, "greeting=\\u4E16\\u754C\r\n").unwrap();
|
||||
f.docs.on_disk_changed(std::slice::from_ref(&path));
|
||||
assert_eq!(f.docs.snapshot(doc.id).unwrap().text, "greeting=世界\n");
|
||||
assert!(!f.docs.is_dirty(doc.id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_toggle_reloads_only_clean_properties_and_pins_dirty_codec() {
|
||||
let f = fixture();
|
||||
let paths = ["clean.properties", "dirty.properties", "other.txt"].map(|name| f.root.join(name));
|
||||
for path in &paths {
|
||||
fs::write(path, r"value=\u4F60").unwrap();
|
||||
}
|
||||
let [clean, dirty, other] = paths.each_ref().map(|p| f.docs.open(p).unwrap());
|
||||
f.docs.edit(dirty.id, dirty.version, change(12, 12, "!")).unwrap();
|
||||
let mut defaults = f.docs.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
f.docs.set_file_defaults(defaults.clone());
|
||||
f.docs.reload_properties();
|
||||
assert_eq!(f.docs.snapshot(clean.id).unwrap().text, "value=你");
|
||||
assert!(!f.docs.is_dirty(clean.id).unwrap());
|
||||
assert_eq!(f.docs.snapshot(dirty.id).unwrap().text, r"value=\u4F60!");
|
||||
assert!(!f.docs.native_to_ascii_for(&paths[1]));
|
||||
assert_eq!(f.docs.snapshot(other.id).unwrap().text, r"value=\u4F60");
|
||||
f.docs.save(dirty.id).unwrap();
|
||||
assert_eq!(fs::read_to_string(&paths[1]).unwrap(), r"value=\u4F60!");
|
||||
f.docs.reload_properties();
|
||||
assert_eq!(f.docs.snapshot(dirty.id).unwrap().text, "value=你!");
|
||||
f.docs.edit(dirty.id, f.docs.snapshot(dirty.id).unwrap().version, change(7, 8, "好")).unwrap();
|
||||
defaults.native_to_ascii = false;
|
||||
f.docs.set_file_defaults(defaults);
|
||||
f.docs.reload_properties();
|
||||
assert_eq!(f.docs.snapshot(clean.id).unwrap().text, r"value=\u4F60");
|
||||
assert!(f.docs.native_to_ascii_for(&paths[1]));
|
||||
f.docs.save(dirty.id).unwrap();
|
||||
assert_eq!(fs::read_to_string(&paths[1]).unwrap(), r"value=\u4F60\u597D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn properties_ascii_noop_and_charset_conversion() {
|
||||
let f = fixture();
|
||||
let mut defaults = f.docs.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
f.docs.set_file_defaults(defaults);
|
||||
for (i, source) in ["a=b\r\n# comment\r\n", r"a=\u0041", r"a=\\u0041", r"a=\uD800"].iter().enumerate() {
|
||||
let path = f.root.join(format!("{i}.properties"));
|
||||
fs::write(&path, source).unwrap();
|
||||
let doc = f.docs.open(&path).unwrap();
|
||||
f.docs.edit(doc.id, doc.version, change(0, 0, "x")).unwrap();
|
||||
f.docs.edit(doc.id, doc.version + 1, change(0, 1, "")).unwrap();
|
||||
f.docs.save(doc.id).unwrap();
|
||||
f.docs.set_encoding(doc.id, "utf-8", true).unwrap();
|
||||
f.docs.save(doc.id).unwrap();
|
||||
assert_eq!(fs::read_to_string(path).unwrap(), *source);
|
||||
}
|
||||
let path = f.root.join("utf16.properties");
|
||||
fs::write(&path, ide_vfs::encode("a=你好😀", "utf-16le", true).unwrap()).unwrap();
|
||||
let doc = f.docs.open(&path).unwrap();
|
||||
f.docs.replace_all(doc.id, "a=世界😀").unwrap();
|
||||
f.docs.save(doc.id).unwrap();
|
||||
assert!(fs::read(&path).unwrap().is_ascii());
|
||||
f.docs.set_encoding(doc.id, "utf-16le", true).unwrap();
|
||||
assert_eq!(f.docs.snapshot(doc.id).unwrap().text, "a=世界😀");
|
||||
assert!(!f.docs.is_dirty(doc.id).unwrap());
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Per-user IDE state
|
||||
/shelf/
|
||||
/changelists.json
|
||||
@@ -1,38 +1,19 @@
|
||||
use crate::state::AppState;
|
||||
use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
use ide_remote::{DeployedPath, SyncEntry, SyncStep, TransferReport};
|
||||
use ide_ssh::RemoteEntry;
|
||||
use std::path::PathBuf;
|
||||
use ide_core::{IdeResult, IpcPath, RequestId};
|
||||
use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport};
|
||||
use ide_ssh::{FsConnection, RemoteEntry};
|
||||
use tauri::{State, Window};
|
||||
|
||||
/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`.
|
||||
fn progress(
|
||||
sink: SharedSink,
|
||||
title: &str,
|
||||
token: CancellationToken,
|
||||
) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) {
|
||||
let id = TaskId::next();
|
||||
let cancel = token;
|
||||
let handle = ide_core::task::on_cancel(id, move || cancel.cancel());
|
||||
let title = title.to_owned();
|
||||
let (s, t) = (sink.clone(), title.clone());
|
||||
let report = move |path: &str, i: usize, n: usize| {
|
||||
let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) };
|
||||
let message = Some(path.to_owned());
|
||||
s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true });
|
||||
};
|
||||
let done = move || {
|
||||
drop(handle);
|
||||
sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false })
|
||||
};
|
||||
(report, done)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn deploy_coverage(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
paths: Vec<IpcPath>,
|
||||
) -> IdeResult<Vec<DeploymentCoverage>> {
|
||||
ide_host::ops::deploy::deploy_coverage(window.label(), &state.host, paths).await
|
||||
}
|
||||
|
||||
fn local(paths: Vec<IpcPath>) -> Vec<PathBuf> {
|
||||
paths.into_iter().map(|p| p.to_path_buf()).collect()
|
||||
}
|
||||
|
||||
/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn deploy_upload(
|
||||
@@ -42,19 +23,9 @@ pub async fn deploy_upload(
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window.label())?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
ide_host::ops::deploy::deploy_upload(window.label(), &state.host, paths, server, request).await
|
||||
}
|
||||
|
||||
/// Download from…: the remote counterparts of files and folders.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn deploy_download(
|
||||
@@ -64,19 +35,9 @@ pub async fn deploy_download(
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window.label())?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
ide_host::ops::deploy::deploy_download(window.label(), &state.host, paths, server, request).await
|
||||
}
|
||||
|
||||
/// Deletes the remote counterparts of files and folders.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn deploy_delete_remote(
|
||||
@@ -85,11 +46,9 @@ pub async fn deploy_delete_remote(
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window.label())?;
|
||||
state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await
|
||||
ide_host::ops::deploy::deploy_delete_remote(window.label(), &state.host, paths, server).await
|
||||
}
|
||||
|
||||
/// Sync with Deployed: local and remote files under `path`, side by side.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn deploy_compare(
|
||||
@@ -99,14 +58,9 @@ pub async fn deploy_compare(
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<SyncEntry>> {
|
||||
let project = state.project(window.label())?;
|
||||
let token = state.begin_request(request);
|
||||
let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await;
|
||||
state.end_request(request);
|
||||
r
|
||||
ide_host::ops::deploy::deploy_compare(window.label(), &state.host, path, server, request).await
|
||||
}
|
||||
|
||||
/// Applies the Sync with Deployed choices.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn deploy_sync(
|
||||
@@ -116,19 +70,9 @@ pub async fn deploy_sync(
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window.label())?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
ide_host::ops::deploy::deploy_sync(window.label(), &state.host, steps, server, request).await
|
||||
}
|
||||
|
||||
/// Where `path` is deployed (remote path, web URL); `None` when not mapped.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn deploy_info(
|
||||
@@ -137,56 +81,66 @@ pub async fn deploy_info(
|
||||
path: IpcPath,
|
||||
server: Option<String>,
|
||||
) -> IdeResult<Option<DeployedPath>> {
|
||||
let project = state.project(window.label())?;
|
||||
state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await
|
||||
ide_host::ops::deploy::deploy_info(window.label(), &state.host, path, server).await
|
||||
}
|
||||
|
||||
/// Remote Host tool window: the server root of `server`, resolved on the host.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn deploy_server_root(window: Window, state: State<'_, AppState>, server: String) -> IdeResult<String> {
|
||||
let settings = match state.project(window.label()) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => state.global_settings.effective(),
|
||||
};
|
||||
state.remote.server_root(&settings, &server).await
|
||||
}
|
||||
|
||||
/// Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_list(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<Vec<RemoteEntry>> {
|
||||
state.remote.list(&ssh, &path).await
|
||||
}
|
||||
|
||||
/// Opens a remote file for editing: a local copy, uploaded back on save.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_open(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<IpcPath> {
|
||||
Ok(state.remote.open_remote(&ssh, &path).await?.into())
|
||||
}
|
||||
|
||||
/// The remote file a local copy stands for: (SSH configuration id, remote path).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(String, String)> {
|
||||
state.remote.remote_of(&path)
|
||||
ide_host::ops::deploy::deploy_server_root(window.label(), &state.host, server).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_delete(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> {
|
||||
state.remote.remote_delete(&ssh, &path).await
|
||||
pub async fn remote_list(
|
||||
state: State<'_, AppState>,
|
||||
connection: FsConnection,
|
||||
path: String,
|
||||
) -> IdeResult<Vec<RemoteEntry>> {
|
||||
ide_host::ops::deploy::remote_list(&state.host, connection, path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_mkdir(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> {
|
||||
state.remote.remote_mkdir(&ssh, &path).await
|
||||
pub async fn remote_open(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult<IpcPath> {
|
||||
ide_host::ops::deploy::remote_open(&state.host, connection, path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_rename(state: State<'_, AppState>, ssh: String, from: String, to: String) -> IdeResult<()> {
|
||||
state.remote.remote_rename(&ssh, &from, &to).await
|
||||
pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(FsConnection, String)> {
|
||||
ide_host::ops::deploy::remote_of(&state.host, path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_delete(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
ide_host::ops::deploy::remote_delete(&state.host, connection, path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_mkdir(state: State<'_, AppState>, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
ide_host::ops::deploy::remote_mkdir(&state.host, connection, path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_rename(
|
||||
state: State<'_, AppState>,
|
||||
connection: FsConnection,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::deploy::remote_rename(&state.host, connection, from, to).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn deploy_test_connection(
|
||||
state: State<'_, AppState>,
|
||||
connection: FsConnection,
|
||||
path: String,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::deploy::deploy_test_connection(&state.host, connection, path).await
|
||||
}
|
||||
@@ -50,6 +50,12 @@ pub async fn doc_save_all(window: Window, state: State<'_, AppState>) -> IdeResu
|
||||
ide_host::ops::doc::save_all(&state.host, window.label()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_sync_from_disk(window: Window, state: State<'_, AppState>) -> IdeResult<()> {
|
||||
ide_host::ops::doc::sync_from_disk(&state.host, window.label()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_snapshot(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<DocumentInfo> {
|
||||
@@ -74,14 +80,14 @@ pub async fn doc_ui_mode(window: Window, state: State<'_, AppState>, path: PathB
|
||||
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn doc_set_encoding(
|
||||
pub async fn doc_set_encoding(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
doc: DocId,
|
||||
encoding: String,
|
||||
reload: bool,
|
||||
) -> IdeResult<String> {
|
||||
ide_host::ops::doc::doc_set_encoding(&state.host, window.label(), doc, encoding, reload)
|
||||
ide_host::ops::doc::doc_set_encoding(&state.host, window.label(), doc, encoding, reload).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -143,6 +143,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
doc::doc_edit,
|
||||
doc::doc_save,
|
||||
doc::doc_save_all,
|
||||
doc::doc_sync_from_disk,
|
||||
doc::doc_snapshot,
|
||||
doc::doc_ui_mode,
|
||||
doc::doc_set_encoding,
|
||||
@@ -151,6 +152,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
doc::doc_folding,
|
||||
doc::doc_highlights,
|
||||
doc::doc_selection_ranges,
|
||||
syntax::jvm_file_presentations,
|
||||
syntax::syntax_support,
|
||||
syntax::syntax_node_at,
|
||||
syntax::syntax_expression_at,
|
||||
@@ -276,6 +278,8 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
vcs::vcs_shelf_delete,
|
||||
vcs::vcs_shelf_rename,
|
||||
vcs::vcs_shelf_patch,
|
||||
vcs::vcs_shelf_diff,
|
||||
vcs::vcs_shelf_import,
|
||||
vcs::vcs_changelists_load,
|
||||
vcs::vcs_changelists_save,
|
||||
vcs::vcs_write_patch,
|
||||
@@ -448,6 +452,7 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
ssh::git_credentials,
|
||||
ssh::git_credential_forget,
|
||||
ssh::ssh_terminal_command,
|
||||
deploy::deploy_coverage,
|
||||
deploy::deploy_upload,
|
||||
deploy::deploy_download,
|
||||
deploy::deploy_delete_remote,
|
||||
@@ -456,11 +461,13 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
deploy::deploy_info,
|
||||
deploy::deploy_server_root,
|
||||
deploy::remote_list,
|
||||
deploy::deploy_test_connection,
|
||||
deploy::remote_open,
|
||||
deploy::remote_of,
|
||||
deploy::remote_delete,
|
||||
deploy::remote_mkdir,
|
||||
deploy::remote_rename,
|
||||
crate::remote_dev::remote_bootstrap_list,
|
||||
crate::remote_dev::remote_connect,
|
||||
crate::remote_dev::remote_current,
|
||||
crate::remote_dev::remote_disconnect,
|
||||
|
||||
@@ -112,3 +112,13 @@ pub async fn syntax_structural_ranges(
|
||||
) -> IdeResult<StructuralRanges> {
|
||||
ide_host::ops::syntax::syntax_structural_ranges(&state.host, window.label(), doc, pos, request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn jvm_file_presentations(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
paths: Vec<PathBuf>,
|
||||
) -> IdeResult<Vec<ide_host::ops::jvm_icons::JvmFilePresentation>> {
|
||||
ide_host::ops::jvm_icons::jvm_file_presentations(&state.host, window.label(), paths).await
|
||||
}
|
||||
@@ -35,8 +35,8 @@ pub async fn vcs_branch_info(
|
||||
/// Gutter change markers for a file, computed against its current (unsaved) text.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn vcs_line_hunks(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
|
||||
ide_host::ops::vcs::vcs_line_hunks(&state.host, window.label(), path)
|
||||
pub async fn vcs_line_hunks(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
|
||||
ide_host::ops::vcs::vcs_line_hunks(&state.host, window.label(), path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -338,8 +338,13 @@ pub fn vcs_diff_hunks(state: State<'_, AppState>, old_text: String, new_text: St
|
||||
/// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn vcs_stage_hunks(window: Window, state: State<'_, AppState>, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
|
||||
ide_host::ops::vcs::vcs_stage_hunks(&state.host, window.label(), path, hunks)
|
||||
pub async fn vcs_stage_hunks(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
hunks: Vec<u32>,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::vcs::vcs_stage_hunks(&state.host, window.label(), path, hunks).await
|
||||
}
|
||||
|
||||
/// Clones a repository (Get from VCS). Works without an open project.
|
||||
@@ -608,14 +613,15 @@ pub async fn vcs_unshelve(
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
remove: bool,
|
||||
path: Option<String>,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
ide_host::ops::vcs::vcs_unshelve(&state.host, window.label(), id, remove).await
|
||||
ide_host::ops::vcs::vcs_unshelve(&state.host, window.label(), id, remove, path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<()> {
|
||||
ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id)
|
||||
pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String, path: Option<String>) -> IdeResult<()> {
|
||||
ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id, path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -626,8 +632,13 @@ pub fn vcs_shelf_rename(window: Window, state: State<'_, AppState>, id: String,
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn vcs_shelf_patch(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<String> {
|
||||
ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id)
|
||||
pub fn vcs_shelf_patch(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
path: Option<String>,
|
||||
) -> IdeResult<String> {
|
||||
ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id, path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -648,3 +659,24 @@ pub fn vcs_changelists_save(window: Window, state: State<'_, AppState>, changeli
|
||||
pub async fn vcs_write_patch(state: State<'_, AppState>, path: PathBuf, patch: String) -> IdeResult<()> {
|
||||
ide_host::ops::vcs::vcs_write_patch(&state.host, "", path, patch).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_shelf_diff(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
id: String,
|
||||
) -> IdeResult<Vec<ide_vcs::shelf::ShelfDiffFile>> {
|
||||
ide_host::ops::vcs::vcs_shelf_diff(&state.host, window.label(), id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn vcs_shelf_import(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
patch: String,
|
||||
) -> IdeResult<()> {
|
||||
ide_host::ops::vcs::vcs_shelf_import(&state.host, window.label(), name, patch).await
|
||||
}
|
||||
@@ -1173,10 +1173,10 @@ pub fn model(spec: &[Entry], recent: &[RecentItem], config: &MenuConfig) -> Vec<
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the OS menu bar is used. Windows project windows draw IDEA's
|
||||
/// hamburger menu in their own title bar instead.
|
||||
/// Whether the OS menu bar is used. Windows and Linux project windows are
|
||||
/// frameless and draw IDEA's hamburger menu in their own title bar instead.
|
||||
pub fn native_menu_bar() -> bool {
|
||||
!cfg!(target_os = "windows")
|
||||
cfg!(target_os = "macos")
|
||||
}
|
||||
|
||||
/// Rebuilds and installs the app menu from the current config and recent list.
|
||||
|
||||
@@ -17,6 +17,17 @@ use std::sync::Arc;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{AppHandle, Manager, State, Window};
|
||||
|
||||
/// Browse before connecting, using the desktop's SSH configuration even in a remote window.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_bootstrap_list(
|
||||
state: State<'_, AppState>,
|
||||
ssh: String,
|
||||
path: String,
|
||||
) -> IdeResult<Vec<ide_ssh::RemoteEntry>> {
|
||||
state.remote.list(&ide_ssh::FsConnection::Sftp { ssh }, &path).await
|
||||
}
|
||||
|
||||
/// Where the server is installed on the remote host (per version).
|
||||
fn install_dir(version: &str) -> String {
|
||||
format!("~/.openidea/server/{version}")
|
||||
|
||||
@@ -724,7 +724,7 @@ pub fn open_settings_window(
|
||||
let mut config = window_config(app)?;
|
||||
config.label = label.clone();
|
||||
config.title = "Settings".into();
|
||||
// Frameless on Windows like project windows (tauri.windows.conf.json): the page draws
|
||||
// Frameless on Windows and Linux like project windows (tauri.<os>.conf.json): the page draws
|
||||
// the title bar in the app theme instead of a system caption that follows the OS theme.
|
||||
(config.width, config.height) = SETTINGS_SIZE;
|
||||
(config.min_width, config.min_height) = (Some(SETTINGS_MIN_SIZE.0), Some(SETTINGS_MIN_SIZE.1));
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"create": false,
|
||||
"title": "OpenIDEA",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 800,
|
||||
"minHeight": 500,
|
||||
"decorations": false,
|
||||
"backgroundColor": "#E9EAEE",
|
||||
"dragDropEnabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+97
-20
@@ -199,6 +199,7 @@ export const commands = {
|
||||
docEdit: (doc: DocId, baseVersion: number, changes: ChangeSet) => typedError<number, IdeError>(__TAURI_INVOKE("doc_edit", { doc, baseVersion, changes })),
|
||||
docSave: (doc: DocId) => typedError<null, IdeError>(__TAURI_INVOKE("doc_save", { doc })),
|
||||
docSaveAll: () => typedError<null, IdeError>(__TAURI_INVOKE("doc_save_all")),
|
||||
docSyncFromDisk: () => typedError<null, IdeError>(__TAURI_INVOKE("doc_sync_from_disk")),
|
||||
docSnapshot: (doc: DocId) => typedError<DocumentInfo, IdeError>(__TAURI_INVOKE("doc_snapshot", { doc })),
|
||||
/**
|
||||
* The CodeMirror mode `path` gets when opened (`DocumentInfo::ui_mode`),
|
||||
@@ -226,6 +227,7 @@ export const commands = {
|
||||
* Large documents fall back to word / line / document ranges.
|
||||
*/
|
||||
docSelectionRanges: (doc: DocId, offset: number) => typedError<Range[], IdeError>(__TAURI_INVOKE("doc_selection_ranges", { doc, offset })),
|
||||
jvmFilePresentations: (paths: string[]) => typedError<JvmFilePresentation[], IdeError>(__TAURI_INVOKE("jvm_file_presentations", { paths })),
|
||||
/**
|
||||
* What the structure service supports for a language (`tree`: every query,
|
||||
* `locals`: scope analysis).
|
||||
@@ -516,10 +518,12 @@ export const commands = {
|
||||
* Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
|
||||
* merge UI); with `remove`, deletes it from the shelf.
|
||||
*/
|
||||
vcsUnshelve: (id: string, remove: boolean) => typedError<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_unshelve", { id, remove })),
|
||||
vcsShelfDelete: (id: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_shelf_delete", { id })),
|
||||
vcsUnshelve: (id: string, remove: boolean, path: string | null) => typedError<ApplyOutcome, IdeError>(__TAURI_INVOKE("vcs_unshelve", { id, remove, path })),
|
||||
vcsShelfDelete: (id: string, path: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_shelf_delete", { id, path })),
|
||||
vcsShelfRename: (id: string, name: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_shelf_rename", { id, name })),
|
||||
vcsShelfPatch: (id: string) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_shelf_patch", { id })),
|
||||
vcsShelfPatch: (id: string, path: string | null) => typedError<string, IdeError>(__TAURI_INVOKE("vcs_shelf_patch", { id, path })),
|
||||
vcsShelfDiff: (id: string) => typedError<ShelfDiffFile[], IdeError>(__TAURI_INVOKE("vcs_shelf_diff", { id })),
|
||||
vcsShelfImport: (name: string, patch: string) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_shelf_import", { name, patch })),
|
||||
vcsChangelistsLoad: () => typedError<ChangelistState, IdeError>(__TAURI_INVOKE("vcs_changelists_load")),
|
||||
vcsChangelistsSave: (changelists: ChangelistState) => typedError<null, IdeError>(__TAURI_INVOKE("vcs_changelists_save", { changelists })),
|
||||
/** Create Patch › Save to file. */
|
||||
@@ -965,33 +969,27 @@ export const commands = {
|
||||
* (run in a terminal tab, so prompts and host-key questions are answered there).
|
||||
*/
|
||||
sshTerminalCommand: (id: string) => typedError<SshTerminalCommand, IdeError>(__TAURI_INVOKE("ssh_terminal_command", { id })),
|
||||
/** Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`). */
|
||||
deployCoverage: (paths: IpcPath[]) => typedError<DeploymentCoverage[], IdeError>(__TAURI_INVOKE("deploy_coverage", { paths })),
|
||||
deployUpload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError<TransferReport, IdeError>(__TAURI_INVOKE("deploy_upload", { paths, server, request })),
|
||||
/** Download from…: the remote counterparts of files and folders. */
|
||||
deployDownload: (paths: IpcPath[], server: string | null, request: RequestId) => typedError<TransferReport, IdeError>(__TAURI_INVOKE("deploy_download", { paths, server, request })),
|
||||
/** Deletes the remote counterparts of files and folders. */
|
||||
deployDeleteRemote: (paths: IpcPath[], server: string | null) => typedError<TransferReport, IdeError>(__TAURI_INVOKE("deploy_delete_remote", { paths, server })),
|
||||
/** Sync with Deployed: local and remote files under `path`, side by side. */
|
||||
deployCompare: (path: IpcPath, server: string | null, request: RequestId) => typedError<SyncEntry[], IdeError>(__TAURI_INVOKE("deploy_compare", { path, server, request })),
|
||||
/** Applies the Sync with Deployed choices. */
|
||||
deploySync: (steps: SyncStep[], server: string | null, request: RequestId) => typedError<TransferReport, IdeError>(__TAURI_INVOKE("deploy_sync", { steps, server, request })),
|
||||
/** Where `path` is deployed (remote path, web URL); `None` when not mapped. */
|
||||
deployInfo: (path: IpcPath, server: string | null) => typedError<{
|
||||
server: string,
|
||||
remotePath: string,
|
||||
webUrl: string | null,
|
||||
} | null, IdeError>(__TAURI_INVOKE("deploy_info", { path, server })),
|
||||
/** Remote Host tool window: the server root of `server`, resolved on the host. */
|
||||
deployServerRoot: (server: string) => typedError<string, IdeError>(__TAURI_INVOKE("deploy_server_root", { server })),
|
||||
/** Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory). */
|
||||
remoteList: (ssh: string, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_list", { ssh, path })),
|
||||
/** Opens a remote file for editing: a local copy, uploaded back on save. */
|
||||
remoteOpen: (ssh: string, path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("remote_open", { ssh, path })),
|
||||
/** The remote file a local copy stands for: (SSH configuration id, remote path). */
|
||||
remoteOf: (path: IpcPath) => __TAURI_INVOKE<[string, string] | null>("remote_of", { path }),
|
||||
remoteDelete: (ssh: string, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_delete", { ssh, path })),
|
||||
remoteMkdir: (ssh: string, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_mkdir", { ssh, path })),
|
||||
remoteRename: (ssh: string, from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_rename", { ssh, from, to })),
|
||||
remoteList: (connection: FsConnection, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_list", { connection, path })),
|
||||
deployTestConnection: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("deploy_test_connection", { connection, path })),
|
||||
remoteOpen: (connection: FsConnection, path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("remote_open", { connection, path })),
|
||||
remoteOf: (path: IpcPath) => __TAURI_INVOKE<[FsConnection, string] | null>("remote_of", { path }),
|
||||
remoteDelete: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_delete", { connection, path })),
|
||||
remoteMkdir: (connection: FsConnection, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_mkdir", { connection, path })),
|
||||
remoteRename: (connection: FsConnection, from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_rename", { connection, from, to })),
|
||||
/** Browse before connecting, using the desktop's SSH configuration even in a remote window. */
|
||||
remoteBootstrapList: (ssh: string, path: string) => typedError<RemoteEntry[], IdeError>(__TAURI_INVOKE("remote_bootstrap_list", { ssh, path })),
|
||||
/**
|
||||
* File › Remote Development: opens `path` on the host of SSH configuration
|
||||
* `ssh_config` in this window (installs / starts the server as needed).
|
||||
@@ -1991,6 +1989,23 @@ export type CreatePullRequest = {
|
||||
push: boolean,
|
||||
};
|
||||
|
||||
/** Imported editor colour scheme on top of a built-in base. */
|
||||
export type CustomColorScheme = CustomColorScheme_Serialize | CustomColorScheme_Deserialize;
|
||||
|
||||
/** Imported editor colour scheme on top of a built-in base. */
|
||||
export type CustomColorScheme_Deserialize = {
|
||||
name: string,
|
||||
parent: string,
|
||||
attributes: { [key in string]: TextAttributesOverride_Deserialize },
|
||||
};
|
||||
|
||||
/** Imported editor colour scheme on top of a built-in base. */
|
||||
export type CustomColorScheme_Serialize = {
|
||||
name: string,
|
||||
parent: string,
|
||||
attributes: { [key in string]: TextAttributesOverride_Serialize },
|
||||
};
|
||||
|
||||
/** A named profile made with Copy. */
|
||||
export type CustomInspectionProfile = {
|
||||
name?: string,
|
||||
@@ -2383,6 +2398,16 @@ export type DeployedPath = {
|
||||
webUrl: string | null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Mapping eligibility uses a common synthetic server root: mapping and exclusion
|
||||
* paths are both relative to that root, so checking never opens a connection.
|
||||
*/
|
||||
export type DeploymentCoverage = {
|
||||
server: string,
|
||||
paths: string[],
|
||||
skipped: TransferError[],
|
||||
};
|
||||
|
||||
export type DeploymentMapping = {
|
||||
/** Project-relative folder; empty = the project root. */
|
||||
local?: string,
|
||||
@@ -2392,8 +2417,9 @@ export type DeploymentMapping = {
|
||||
web?: string,
|
||||
};
|
||||
|
||||
/** An SFTP server and how project folders map onto it. */
|
||||
/** A deployment filesystem and how project folders map onto it. */
|
||||
export type DeploymentServer = {
|
||||
kind?: DeploymentType,
|
||||
id?: string,
|
||||
name?: string,
|
||||
/** [`SshConfiguration::id`]. */
|
||||
@@ -2421,6 +2447,8 @@ export type DeploymentSettings = {
|
||||
exclude?: string[],
|
||||
};
|
||||
|
||||
export type DeploymentType = "sftp" | "local";
|
||||
|
||||
export type Diagnostic = {
|
||||
range: Range,
|
||||
severity: Severity,
|
||||
@@ -2737,6 +2765,8 @@ export type EditorSettings_Deserialize = {
|
||||
formatOnSave?: boolean,
|
||||
/** Editor colour scheme id; `theme-default` follows the IDE theme. */
|
||||
colorScheme?: string,
|
||||
/** Imported editor schemes, stored in user settings. */
|
||||
customSchemes?: { [key in string]: CustomColorScheme_Deserialize },
|
||||
/** Color Scheme edits: scheme id → attribute id → override. */
|
||||
colorOverrides?: { [key in string]: { [key in string]: TextAttributesOverride_Deserialize } },
|
||||
/**
|
||||
@@ -2784,6 +2814,8 @@ export type EditorSettings_Serialize = {
|
||||
formatOnSave: boolean,
|
||||
/** Editor colour scheme id; `theme-default` follows the IDE theme. */
|
||||
colorScheme: string,
|
||||
/** Imported editor schemes, stored in user settings. */
|
||||
customSchemes: { [key in string]: CustomColorScheme_Serialize },
|
||||
/** Color Scheme edits: scheme id → attribute id → override. */
|
||||
colorOverrides: { [key in string]: { [key in string]: TextAttributesOverride_Serialize } },
|
||||
/**
|
||||
@@ -2853,6 +2885,8 @@ export type EncodingSettings = {
|
||||
project?: string,
|
||||
/** Fallback for `.properties` files. */
|
||||
properties?: string,
|
||||
/** Display properties Unicode escapes as characters and save non-ASCII as escapes. */
|
||||
nativeToAscii?: boolean,
|
||||
/** Create UTF-8 files: `never` (no BOM) | `always` (with BOM) | `windows` (with BOM on Windows). */
|
||||
utf8Bom?: string,
|
||||
};
|
||||
@@ -3017,6 +3051,8 @@ export type FileRenameEdits = {
|
||||
export type FileSettings = {
|
||||
/** Save on focus loss / tab switch, like IDEA. */
|
||||
autoSave?: boolean,
|
||||
/** Re-check open files against disk when the IDE window gains focus. */
|
||||
syncOnFocus?: boolean,
|
||||
exclude?: string[],
|
||||
/** Ignored Files and Folders: name patterns hidden everywhere; see [`crate::ignored`]. */
|
||||
ignoredNames?: string[],
|
||||
@@ -3096,6 +3132,8 @@ export type Fragment = {
|
||||
text: string,
|
||||
};
|
||||
|
||||
export type FsConnection = { kind: "sftp"; ssh: string } | { kind: "local" };
|
||||
|
||||
/**
|
||||
* A method breakpoint (DAP function breakpoint). `name` uses the adapter's syntax:
|
||||
* `pkg.Class#method` for java-debug, `pkg.Func` / `pkg.(*T).Method` for Delve, a plain
|
||||
@@ -3212,11 +3250,30 @@ export type GitSettings = {
|
||||
combineStashAndShelf?: boolean,
|
||||
};
|
||||
|
||||
export type GoBuildTags = {
|
||||
/** Empty values inherit the selected toolchain's defaults. */
|
||||
os?: string,
|
||||
arch?: string,
|
||||
version?: string,
|
||||
compiler?: string,
|
||||
/** Empty = automatic, "1" = enabled, "0" = disabled. */
|
||||
cgo?: string,
|
||||
custom?: string,
|
||||
experiments?: string,
|
||||
};
|
||||
|
||||
export type GoModules = {
|
||||
env?: { [key in string]: string },
|
||||
autoVendor?: boolean,
|
||||
};
|
||||
|
||||
export type GoSettings = {
|
||||
/** Empty = the `go` on `PATH`. */
|
||||
goroot?: string,
|
||||
/** Empty = `go env GOPATH`. */
|
||||
gopath?: string,
|
||||
buildTags?: GoBuildTags,
|
||||
modules?: GoModules,
|
||||
};
|
||||
|
||||
export type GotoKind = "definition" | "declaration" | "typeDefinition" | "implementation";
|
||||
@@ -3737,6 +3794,19 @@ export type JdkVendor = {
|
||||
name: string,
|
||||
};
|
||||
|
||||
export type JvmFilePresentation = {
|
||||
path: string,
|
||||
presentation: JvmPresentation,
|
||||
};
|
||||
|
||||
export type JvmPresentation = {
|
||||
kind: JvmTypeKind | null,
|
||||
springBoot: boolean,
|
||||
runnable: boolean,
|
||||
};
|
||||
|
||||
export type JvmTypeKind = "class" | "abstractClass" | "interface" | "enum" | "record" | "annotation" | "exception" | "object";
|
||||
|
||||
/** Editor › Code Style › <Language> › Tabs and Indents (unset fields inherit). */
|
||||
export type LanguageCodeStyle = LanguageCodeStyle_Serialize | LanguageCodeStyle_Deserialize;
|
||||
|
||||
@@ -5497,6 +5567,13 @@ export type Settings_Serialize = {
|
||||
|
||||
export type Severity = "error" | "warning" | "info" | "hint";
|
||||
|
||||
export type ShelfDiffFile = {
|
||||
path: string,
|
||||
patch: string,
|
||||
before: string | null,
|
||||
after: string | null,
|
||||
};
|
||||
|
||||
/** Version Control › Shelf. */
|
||||
export type ShelfSettings = {
|
||||
/** Initial state of Unshelve's "Remove successfully applied files from the shelf". */
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
type SimpleRange,
|
||||
} from "./model/selectionStack";
|
||||
import type { EditorSlot } from "./model/slot";
|
||||
import { findAgain, openFindBar } from "./view/FindBar";
|
||||
import { findAgain, findPanelTarget, openFindBar, openFindBarResults, useFindPreferences } from "./view/FindBar";
|
||||
import {
|
||||
collapseAll,
|
||||
collapseRecursively,
|
||||
@@ -479,6 +479,19 @@ export function registerEditorActions(): () => void {
|
||||
const pane = s.panes[s.activePane];
|
||||
return pane?.active ? { paneId: pane.id, tabId: pane.active } : null;
|
||||
};
|
||||
const occurrence = (id: string, title: string, run: (view: EditorView) => unknown): Action => ({
|
||||
id,
|
||||
title,
|
||||
category: "Edit",
|
||||
when: `${TEXT_WHEN} || findBarFocus || findBarOpen && popupOpen`,
|
||||
run: ({ arg, ctx }) => {
|
||||
const view = typeof arg === "string" || ctx.findBarFocus ? findPanelTarget(arg)?.view() : null;
|
||||
if (view) {
|
||||
run(view);
|
||||
view.focus();
|
||||
} else withView(run)();
|
||||
},
|
||||
});
|
||||
return registerActions([
|
||||
schemeSwitch("editor.toggleSemanticHighlighting", "Semantic Highlighting", "semanticHighlighting", [
|
||||
"rainbow variables",
|
||||
@@ -552,10 +565,8 @@ export function registerEditorActions(): () => void {
|
||||
category: "Folding",
|
||||
keywords: ["unfold"],
|
||||
}),
|
||||
text("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", (v) => addNextOccurrence(v), {
|
||||
category: "Edit",
|
||||
}),
|
||||
text("code.selectAllOccurrences", "Select All Occurrences", (v) => selectAllOccurrences(v), { category: "Edit" }),
|
||||
occurrence("code.addCaretNextOccurrence", "Add Selection for Next Occurrence", addNextOccurrence),
|
||||
occurrence("code.selectAllOccurrences", "Select All Occurrences", selectAllOccurrences),
|
||||
text("code.completeStatement", "Complete Current Statement", (v, slot) => completeCurrentStatement(v, slot)),
|
||||
text("code.startNewLine", "Start New Line", (v) => startNewLine(v)),
|
||||
text("code.startNewLineBefore", "Start New Line Before Current", (v) => startNewLineBefore(v)),
|
||||
@@ -623,7 +634,7 @@ export function registerEditorActions(): () => void {
|
||||
text("code.addCaretsToLineEnds", "Add Carets to Ends of Selected Lines", (v) => addCaretsToLineEnds(v), {
|
||||
category: "Edit",
|
||||
}),
|
||||
text("code.unselectOccurrence", "Unselect Occurrence", (v) => unselectOccurrence(v), { category: "Edit" }),
|
||||
occurrence("code.unselectOccurrence", "Unselect Occurrence", unselectOccurrence),
|
||||
text("code.pasteFromHistory", "Paste from History…", (v) => pasteFromHistory(v), {
|
||||
category: "Edit",
|
||||
keywords: ["clipboard", "paste multiple"],
|
||||
@@ -717,6 +728,22 @@ export function registerEditorActions(): () => void {
|
||||
if (slot) toggleReadOnly(slot.model);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "find.openInWindow",
|
||||
title: "Open Results in Find Window",
|
||||
category: "Find",
|
||||
when: "findBarHasResults",
|
||||
keyWhen: "findBarFocus",
|
||||
run: ({ arg }) => openFindBarResults(arg),
|
||||
},
|
||||
{
|
||||
id: "find.scrollToResults",
|
||||
title: "Scroll to Results During Typing",
|
||||
category: "Find",
|
||||
when: "findBarOpen",
|
||||
toggled: () => useFindPreferences.getState().scrollToResults,
|
||||
run: () => useFindPreferences.setState((s) => ({ scrollToResults: !s.scrollToResults })),
|
||||
},
|
||||
{
|
||||
id: "find.inFile",
|
||||
title: "Find",
|
||||
|
||||
@@ -38,6 +38,8 @@ export interface DocBackend {
|
||||
close(doc: DocId): Promise<void>;
|
||||
edit(doc: DocId, baseVersion: number, changes: TextChange[]): Promise<number>;
|
||||
save(doc: DocId): Promise<void>;
|
||||
/** Re-check every open file for external changes missed by file watchers. */
|
||||
syncFromDisk?(): Promise<void>;
|
||||
snapshot(doc: DocId): Promise<DocumentInfo>;
|
||||
setLineEnding(doc: DocId, lineEnding: LineEnding): Promise<void>;
|
||||
/** "Reload with Encoding" (`reload`) or "Convert to Encoding"; resolves to the new encoding. */
|
||||
@@ -67,6 +69,7 @@ export const ipcBackend: DocBackend = {
|
||||
close: async (doc) => void (await call(commands.docClose(doc))),
|
||||
edit: (doc, baseVersion, changes) => call(commands.docEdit(doc, baseVersion, { changes })),
|
||||
save: async (doc) => void (await call(commands.docSave(doc))),
|
||||
syncFromDisk: async () => void (await call(commands.docSyncFromDisk())),
|
||||
snapshot: (doc) => call(commands.docSnapshot(doc)),
|
||||
setLineEnding: async (doc, le) => void (await call(commands.docSetLineEnding(doc, le))),
|
||||
setEncoding: (doc, encoding, reload) => call(commands.docSetEncoding(doc, encoding, reload)),
|
||||
|
||||
Loaded 100 of 280 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача