Merge remote-tracking branch 'origin/main' into HEAD

Цей коміт міститься в:
l0ng-ai committed 2026-10-31 20:17:43 +08:00
коміт 9fc719b4db
280 files changed
+13595 -1171

No files matched your search

Створено
+3
Переглянути файл
@@ -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",
+3 -2
Переглянути файл
@@ -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
+2 -1
Переглянути файл
@@ -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 {
+16 -1
Переглянути файл
@@ -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)>();
+104 -8
Переглянути файл
@@ -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'"));
}
}
}
+1 -1
Переглянути файл
@@ -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"] }
+3
Переглянути файл
@@ -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(),
}
}
+100
Переглянути файл
@@ -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())]);
+30 -1
Переглянути файл
@@ -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
);
}
}
}
+60
Переглянути файл
@@ -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");
}
}
+69
Переглянути файл
@@ -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);
}
}
+33 -3
Переглянути файл
@@ -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()));
+39 -2
Переглянути файл
@@ -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]
+8
Переглянути файл
@@ -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.
+8 -1
Переглянути файл
@@ -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,
})
})
}
}
+4 -3
Переглянути файл
@@ -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 ----
+15
Переглянути файл
@@ -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();
+207
Переглянути файл
@@ -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
});
}
+15
Переглянути файл
@@ -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;
+10 -3
Переглянути файл
@@ -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")?)
+200
Переглянути файл
@@ -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);
}
+60 -46
Переглянути файл
@@ -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).
+102
Переглянути файл
@@ -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();
}
}
+3
Переглянути файл
@@ -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 {
+8
Переглянути файл
@@ -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();
+30 -1
Переглянути файл
@@ -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).
+15 -9
Переглянути файл
@@ -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()))?
+1 -1
Переглянути файл
@@ -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 {
+1 -1
Переглянути файл
@@ -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))
};
+54 -21
Переглянути файл
@@ -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, &current, &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, &current, &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(
+40 -3
Переглянути файл
@@ -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");
}
}
+4
Переглянути файл
@@ -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")?;
+184
Переглянути файл
@@ -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");
}
+158
Переглянути файл
@@ -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();
}
+19
Переглянути файл
@@ -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));
}
}
+4 -4
Переглянути файл
@@ -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);
+1
Переглянути файл
@@ -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
+32 -1
Переглянути файл
@@ -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());
}
}
+3
Переглянути файл
@@ -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
+199 -11
Переглянути файл
@@ -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!({
+9
Переглянути файл
@@ -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,
+9 -5
Переглянути файл
@@ -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);
+2 -1
Переглянути файл
@@ -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(),
+48 -11
Переглянути файл
@@ -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;
};
+3 -1
Переглянути файл
@@ -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};
+58 -28
Переглянути файл
@@ -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}"),
}
}
+264
Переглянути файл
@@ -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());
}
+2
Переглянути файл
@@ -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
+6 -2
Переглянути файл
@@ -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)?;
+16
Переглянути файл
@@ -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;
+173
Переглянути файл
@@ -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(())
}
+292
Переглянути файл
@@ -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
}
+6
Переглянути файл
@@ -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)
+101 -9
Переглянути файл
@@ -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();
+28 -3
Переглянути файл
@@ -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");
+87
Переглянути файл
@@ -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}");
}
+115
Переглянути файл
@@ -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"));
}
+150
Переглянути файл
@@ -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"),
);
}
}
+109
Переглянути файл
@@ -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::*;
+329
Переглянути файл
@@ -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");
}
}
+1
Переглянути файл
@@ -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);
+1
Переглянути файл
@@ -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;
+73 -4
Переглянути файл
@@ -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);
}
}
+67
Переглянути файл
@@ -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!({}));
}
+274
Переглянути файл
@@ -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 -1
Переглянути файл
@@ -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;
+3 -3
Переглянути файл
@@ -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 }
}
+189
Переглянути файл
@@ -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 })
})
}
}
+2
Переглянути файл
@@ -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};
+78
Переглянути файл
@@ -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
Переглянути файл
@@ -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;
+44 -29
Переглянути файл
@@ -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, &current);
let extended = path_with_tools(app_data, &ide_core::process::with_user_path(&current));
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);
+63
Переглянути файл
@@ -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: &[],
+68 -1
Переглянути файл
@@ -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 =
+73 -2
Переглянути файл
@@ -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());
}
+31 -15
Переглянути файл
@@ -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>> {
+110 -10
Переглянути файл
@@ -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::*;
+168 -5
Переглянути файл
@@ -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;
+62 -8
Переглянути файл
@@ -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;
+60 -14
Переглянути файл
@@ -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, &current);
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));
}
+95
Переглянути файл
@@ -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");
}
+117
Переглянути файл
@@ -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();
+2 -1
Переглянути файл
@@ -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};
+117 -5
Переглянути файл
@@ -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 {
+6
Переглянути файл
@@ -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 {
+55 -5
Переглянути файл
@@ -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();
+101
Переглянути файл
@@ -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());
}
+3
Переглянути файл
@@ -0,0 +1,3 @@
# Per-user IDE state
/shelf/
/changelists.json
+61 -107
Переглянути файл
@@ -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
}
+8 -2
Переглянути файл
@@ -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]
+7
Переглянути файл
@@ -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,
+10
Переглянути файл
@@ -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
}
+41 -9
Переглянути файл
@@ -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
}
+3 -3
Переглянути файл
@@ -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.
+11
Переглянути файл
@@ -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}")
+1 -1
Переглянути файл
@@ -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));
+18
Переглянути файл
@@ -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". */
+33 -6
Переглянути файл
@@ -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",
+3
Переглянути файл
@@ -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