mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 23:56:12 -10:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6087b8b8e8 | ||
|
|
2c9a6470ff | ||
|
|
cf51b57924 | ||
|
|
e7740e4032 | ||
|
|
bdadecdf91 | ||
|
|
178fc9eaba | ||
|
|
17e3cd2b39 | ||
|
|
4796265b5c | ||
|
|
1e9cf1c447 | ||
|
|
7610fc3f80 | ||
|
|
a2405c083a | ||
|
|
ee2585e7e9 | ||
|
|
999eb86408 | ||
|
|
8f85f35ebe | ||
|
|
1e8551ad0b | ||
|
|
27ed0515e8 | ||
|
|
ac360b6143 | ||
|
|
4418f49c35 | ||
|
|
c5cb217c7c | ||
|
|
a553dafa7a | ||
|
|
cbdc177bbc | ||
|
|
c0f8557f05 | ||
|
|
d4b354732c | ||
|
|
53bebe4836 | ||
|
|
290abd303e | ||
|
|
bde19959b9 | ||
|
|
92cbdd0cb8 | ||
|
|
8fe09eba7f | ||
|
|
9d670e6262 | ||
|
|
504d8592d2 | ||
|
|
4f1feebbd0 | ||
|
|
4a620122e8 | ||
|
|
f9a7c68897 | ||
|
|
28baa4a57a | ||
|
|
a5fb30e04f | ||
|
|
f2b8db548f | ||
|
|
89475f4fed | ||
|
|
2bc5e8d0c8 | ||
|
|
79e4233c56 | ||
|
|
df6ed5ccba | ||
|
|
f592ff5b05 | ||
|
|
c0c9553550 | ||
|
|
059fd8a69b | ||
|
|
696377acc2 | ||
|
|
52834088fe | ||
|
|
c3d29dc842 | ||
|
|
8dc1ee062f | ||
|
|
fcc36d67e7 | ||
|
|
00108a669e | ||
|
|
c08b0f543e | ||
|
|
7f9dda50b6 | ||
|
|
98c8c55b9e | ||
|
|
8fabf054ef | ||
|
|
a3cd99db1f | ||
|
|
0660f3c020 | ||
|
|
2754cf327f | ||
|
|
986b0ade58 |
No files matched your search
@@ -70,6 +70,12 @@ jobs:
|
||||
- name: Linux x64
|
||||
os: ubuntu-22.04
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- name: Linux arm64
|
||||
os: ubuntu-22.04-arm
|
||||
target: aarch64-unknown-linux-gnu
|
||||
- name: Windows arm64
|
||||
os: windows-11-arm
|
||||
target: aarch64-pc-windows-msvc
|
||||
- name: Windows x64
|
||||
os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
@@ -196,11 +202,16 @@ jobs:
|
||||
}
|
||||
// Bundle suffix → updater platform keys, as tauri-action names them.
|
||||
const kinds = [
|
||||
[/_aarch64\.AppImage$/, ["linux-aarch64", "linux-aarch64-appimage"]],
|
||||
[/_arm64\.deb$/, ["linux-aarch64-deb"]],
|
||||
[/\.aarch64\.rpm$/, ["linux-aarch64-rpm"]],
|
||||
[/_amd64\.AppImage$/, ["linux-x86_64", "linux-x86_64-appimage"]],
|
||||
[/_amd64\.deb$/, ["linux-x86_64-deb"]],
|
||||
[/\.x86_64\.rpm$/, ["linux-x86_64-rpm"]],
|
||||
[/_aarch64\.app\.tar\.gz$/, ["darwin-aarch64", "darwin-aarch64-app"]],
|
||||
[/_x64\.app\.tar\.gz$/, ["darwin-x86_64", "darwin-x86_64-app"]],
|
||||
[/_arm64_en-US\.msi$/, ["windows-aarch64", "windows-aarch64-msi"]],
|
||||
[/_arm64-setup\.exe$/, ["windows-aarch64-nsis"]],
|
||||
[/_x64_en-US\.msi$/, ["windows-x86_64", "windows-x86_64-msi"]],
|
||||
[/_x64-setup\.exe$/, ["windows-x86_64-nsis"]],
|
||||
];
|
||||
|
||||
Generated
+39
-35
@@ -4038,7 +4038,7 @@ checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
|
||||
|
||||
[[package]]
|
||||
name = "ide-agent"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4061,7 +4061,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-analysis"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ast-grep-core",
|
||||
"ide-core",
|
||||
@@ -4075,7 +4075,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-build"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4090,7 +4090,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-clipboard"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"objc2 0.6.5",
|
||||
"objc2-app-kit 0.3.2",
|
||||
@@ -4099,7 +4099,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-core"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4115,7 +4115,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-coverage"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4129,7 +4129,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-dap"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
@@ -4147,7 +4147,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-db"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"bb8",
|
||||
"dirs 6.0.0",
|
||||
@@ -4169,7 +4169,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-docker"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4183,7 +4183,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-fonts"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-text",
|
||||
@@ -4194,7 +4194,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-github"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-vcs",
|
||||
@@ -4208,7 +4208,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
@@ -4253,7 +4253,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4271,7 +4271,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-index"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4293,7 +4293,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-jsonrpc"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"futures",
|
||||
@@ -4309,7 +4309,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-language"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"criterion",
|
||||
@@ -4364,14 +4364,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-lsp"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"futures",
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-text",
|
||||
"ignore",
|
||||
"lsp-types",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
@@ -4386,7 +4388,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-notebook"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"futures",
|
||||
"ide-core",
|
||||
@@ -4409,7 +4411,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-plugin"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4427,7 +4429,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-profiler"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
@@ -4440,7 +4442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
@@ -4457,9 +4459,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
"ide-build",
|
||||
"ide-core",
|
||||
"libc",
|
||||
"parking_lot",
|
||||
@@ -4478,7 +4482,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-sdk"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"ide-core",
|
||||
@@ -4497,7 +4501,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-search"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"globset",
|
||||
@@ -4517,7 +4521,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-spell"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
@@ -4530,7 +4534,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
@@ -4549,7 +4553,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"ide-language",
|
||||
@@ -4564,7 +4568,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-terminal"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"libc",
|
||||
@@ -4577,7 +4581,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-text"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"criterion",
|
||||
"ide-core",
|
||||
@@ -4589,7 +4593,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-tools"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"hex",
|
||||
@@ -4610,7 +4614,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vcs"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"dunce",
|
||||
"encoding_rs",
|
||||
@@ -4629,7 +4633,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-vfs"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"chardetng",
|
||||
"dunce",
|
||||
@@ -4651,7 +4655,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-web"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"ide-core",
|
||||
"open",
|
||||
@@ -4667,7 +4671,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ide-workspace"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dunce",
|
||||
@@ -6137,7 +6141,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "openidea"
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "3"
|
||||
members = ["crates/*", "src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.28"
|
||||
version = "0.1.29"
|
||||
edition = "2024"
|
||||
rust-version = "1.96"
|
||||
license = "Apache-2.0"
|
||||
|
||||
@@ -12,12 +12,13 @@ icon set, copied unmodified (some renamed) from JetBrains' open-source repositor
|
||||
`python/python-requirements` (`fileTypes/requirements`); `python/pipenv`
|
||||
(`fileTypes/pipenv`); `python/python-pyproject` (`expui/toml.svg` →
|
||||
`fileTypes/pyprojectToml`); `plugins/kotlin/base` (`fileTypes/kotlin`,
|
||||
`kotlinScript`, `kotlinGradleScript`); `java/openapi` (`fileTypes/maven`);
|
||||
`kotlinScript`, `kotlinGradleScript`, `icons/expui/objectKotlin` → `kotlin/objectKotlin`); `java/openapi` (`fileTypes/maven`);
|
||||
`plugins/markdown` (`fileTypes/markdown`); `plugins/toml` (`fileTypes/toml`);
|
||||
`plugins/terminal` (`toolwindows/terminal`); `plugins/maven`
|
||||
(`images/expui/toolwindow/maven` → `toolwindows/maven`, `images/expui/build/maven*`
|
||||
→ `maven/`); `plugins/gradle` (`icons/expui/gradle` → `toolwindows/gradle`,
|
||||
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`);
|
||||
`icons/expui/gradleLoadChanges` → `build/gradleLoadChanges`,
|
||||
`icons/expui/gradle`, `icons/gradleSubproject` → `gradle/`);
|
||||
`platform/external-system-impl` (`icons/newui/task` → `externalSystem/task`).
|
||||
Copyright 2000-2026 JetBrains s.r.o. and contributors. Apache License 2.0 (below).
|
||||
- [intellij-plugins](https://github.com/JetBrains/intellij-plugins) (each plugin
|
||||
|
||||
@@ -27,7 +27,8 @@ pub(crate) fn newest_install(command: &str) -> String {
|
||||
let path = ide_core::process::host_var_os("PATH")
|
||||
.or_else(|| ide_core::process::shell_env().find(|(k, _)| *k == "PATH").map(|(_, v)| v.to_owned()))
|
||||
.unwrap_or_default();
|
||||
newest_install_in(command, &path)
|
||||
// Windows: read afresh, so a tool the user installed since the IDE started is found too.
|
||||
newest_install_in(command, &ide_core::process::with_user_path(&path))
|
||||
}
|
||||
|
||||
fn newest_install_in(command: &str, search: &std::ffi::OsStr) -> String {
|
||||
|
||||
@@ -222,6 +222,13 @@ fn choose<'a>(entry: &'a Entry, platform: Platform, on_path: &dyn Fn(&str) -> bo
|
||||
{
|
||||
return Some(Choice::Uvx(p));
|
||||
}
|
||||
// Last resort: npx and uvx run natively, this one under OS emulation.
|
||||
if platform.os == Os::Windows
|
||||
&& platform.arch == Arch::Arm64
|
||||
&& let Some(b) = d.binary.get("windows-x86_64")
|
||||
{
|
||||
return Some(Choice::Binary(b));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -689,6 +696,20 @@ mod tests {
|
||||
const LINUX: Platform = Platform { os: Os::Linux, arch: Arch::X64 };
|
||||
const WINDOWS: Platform = Platform { os: Os::Windows, arch: Arch::X64 };
|
||||
|
||||
#[test]
|
||||
fn windows_arm_binary_prefers_native_then_x64() {
|
||||
let mut entry = parse(INDEX).unwrap().remove(0);
|
||||
let arm = Platform { os: Os::Windows, arch: Arch::Arm64 };
|
||||
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(_))));
|
||||
assert!(choose(&entry, Platform { os: Os::Linux, arch: Arch::Arm64 }, &|_| false).is_none());
|
||||
entry.distribution.npx = Some(serde_json::from_str(r#"{"package":"amp-acp"}"#).unwrap());
|
||||
assert!(matches!(choose(&entry, arm, &|_| true), Some(Choice::Npx(_))));
|
||||
let mut native = entry.distribution.binary["windows-x86_64"].clone();
|
||||
native.cmd = "native.exe".into();
|
||||
entry.distribution.binary.insert("windows-aarch64".into(), native);
|
||||
assert!(matches!(choose(&entry, arm, &|_| false), Some(Choice::Binary(b)) if b.cmd == "native.exe"));
|
||||
}
|
||||
|
||||
const INDEX: &str = r#"{"version":"1.0.0","agents":[
|
||||
{"id":"amp-acp","name":"Amp","version":"0.10.0","description":"Amp over ACP",
|
||||
"repository":"https://github.com/x/amp-acp","authors":["x"],"license":"Apache-2.0",
|
||||
|
||||
@@ -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'"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
@@ -12,6 +12,8 @@ use std::collections::BTreeMap;
|
||||
pub struct EditorGeneralSettings {
|
||||
/// Change font size with ⌘ + mouse wheel.
|
||||
pub wheel_zoom: bool,
|
||||
pub markdown_image_folder: String,
|
||||
pub markdown_image_name_pattern: String,
|
||||
/// Drag-and-drop of selected text inside the editor.
|
||||
pub drag_and_drop: bool,
|
||||
/// When the caret moves out of view: `minimize` scrolling | `center` the caret line.
|
||||
@@ -35,6 +37,8 @@ impl Default for EditorGeneralSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
wheel_zoom: false,
|
||||
markdown_image_folder: "assets".into(),
|
||||
markdown_image_name_pattern: "image-{timestamp}".into(),
|
||||
drag_and_drop: true,
|
||||
caret_scroll: "minimize".into(),
|
||||
soft_wrap_files: "*.md; *.txt; *.rst; *.adoc".into(),
|
||||
@@ -434,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,
|
||||
}
|
||||
@@ -444,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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,12 +15,20 @@ use std::sync::Arc;
|
||||
pub enum IdeEvent {
|
||||
/// Project window membership or selection changed.
|
||||
WindowGroupsChanged,
|
||||
ProjectTrustChanged {
|
||||
root: crate::IpcPath,
|
||||
trusted: bool,
|
||||
},
|
||||
/// Files or directories changed on disk (after debouncing, ignore rules applied).
|
||||
FsChanged {
|
||||
created: Vec<crate::IpcPath>,
|
||||
modified: Vec<crate::IpcPath>,
|
||||
removed: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// Excluded or ignored folders whose contents changed; they stay out of `FsChanged`.
|
||||
ExcludedDirsChanged {
|
||||
dirs: Vec<crate::IpcPath>,
|
||||
},
|
||||
/// The backend changed a document (reload from disk, formatting, refactoring).
|
||||
/// The UI applies `changes` on top of `base_version`, yielding `version`.
|
||||
DocPatched {
|
||||
|
||||
@@ -237,10 +237,110 @@ pub fn find_executable(name: &str, search: &OsStr) -> Option<std::path::PathBuf>
|
||||
find_executable_in(name, search, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()))
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries on Windows (`HKCU\Environment`), the part of the
|
||||
/// merged `PATH` a process may not inherit — an IDE started before the variable was
|
||||
/// edited, or as a different user, sees only the machine entries. `%VAR%` references
|
||||
/// (`REG_EXPAND_SZ`) are expanded against the current environment. `None` elsewhere:
|
||||
/// there a login shell already supplies the user's whole `PATH`.
|
||||
#[cfg(windows)]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
use windows_sys::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows_sys::Win32::System::Registry::{
|
||||
HKEY, HKEY_CURRENT_USER, KEY_READ, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey, RegGetValueW,
|
||||
RegOpenKeyExW,
|
||||
};
|
||||
|
||||
fn wide(s: &str) -> Vec<u16> {
|
||||
s.encode_utf16().chain(std::iter::once(0)).collect()
|
||||
}
|
||||
|
||||
unsafe {
|
||||
let mut key: HKEY = std::ptr::null_mut();
|
||||
if RegOpenKeyExW(HKEY_CURRENT_USER, wide("Environment").as_ptr(), 0, KEY_READ, &mut key) != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let read = || {
|
||||
// Only `Path`; `REG_EXPAND_SZ` is accepted so `%USERPROFILE%\…` entries expand.
|
||||
let mut kind = 0u32;
|
||||
let mut len = 0u32;
|
||||
let name = wide("Path");
|
||||
let status = RegGetValueW(
|
||||
key,
|
||||
std::ptr::null(),
|
||||
name.as_ptr(),
|
||||
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
|
||||
&mut kind,
|
||||
std::ptr::null_mut(),
|
||||
&mut len,
|
||||
);
|
||||
if status != ERROR_SUCCESS || len == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf = vec![0u16; (len as usize).div_ceil(2)];
|
||||
let status = RegGetValueW(
|
||||
key,
|
||||
std::ptr::null(),
|
||||
name.as_ptr(),
|
||||
RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ,
|
||||
&mut kind,
|
||||
buf.as_mut_ptr() as *mut _,
|
||||
&mut len,
|
||||
);
|
||||
if status != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
let text = String::from_utf16_lossy(&buf);
|
||||
Some(text.trim_end_matches('\0').to_owned())
|
||||
};
|
||||
let value = read();
|
||||
RegCloseKey(key);
|
||||
value.filter(|v| !v.trim().is_empty()).map(OsString::from)
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's own `PATH` entries; `None` outside Windows (see the Windows impl).
|
||||
#[cfg(not(windows))]
|
||||
pub fn user_path() -> Option<OsString> {
|
||||
None
|
||||
}
|
||||
|
||||
/// `path` followed by the [`user_path`] entries it lacks.
|
||||
pub fn with_user_path(path: &OsStr) -> OsString {
|
||||
user_path().map_or_else(|| path.to_owned(), |user| append_path_entries(path, &user))
|
||||
}
|
||||
|
||||
fn append_path_entries(path: &OsStr, extra: &OsStr) -> OsString {
|
||||
let mut dirs: Vec<std::path::PathBuf> = std::env::split_paths(path).filter(|d| !d.as_os_str().is_empty()).collect();
|
||||
for dir in std::env::split_paths(extra).filter(|d| !d.as_os_str().is_empty()) {
|
||||
if !dirs.contains(&dir) {
|
||||
dirs.push(dir);
|
||||
}
|
||||
}
|
||||
std::env::join_paths(dirs).unwrap_or_else(|_| path.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn user_path_reads_the_current_users_path() {
|
||||
// This host has a user `Path` only when the test account set one; either way the call
|
||||
// must not fail, and a present value is a non-empty `;`-joined list of directories.
|
||||
if let Some(path) = user_path() {
|
||||
let dirs: Vec<_> = std::env::split_paths(&path).collect();
|
||||
assert!(!dirs.is_empty(), "user PATH should hold at least one directory");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_path_entries_keeps_the_order_and_adds_what_is_missing() {
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs).unwrap();
|
||||
assert_eq!(append_path_entries(&join(&["a", "b"]), &join(&["b", "c"])), join(&["a", "b", "c"]));
|
||||
assert_eq!(append_path_entries(OsStr::new(""), &join(&["c"])), join(&["c"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_env_adds_only_what_the_process_lacks_and_the_overlay_wins() {
|
||||
set_shell_env(vec![("OPENIDEA_SHELL_ONLY_VAR".into(), "1".into()), ("PATH".into(), "/from/shell".into())]);
|
||||
|
||||
@@ -103,10 +103,19 @@ impl Default for DeploymentSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// An SFTP server and how project folders map onto it.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DeploymentType {
|
||||
#[default]
|
||||
Sftp,
|
||||
Local,
|
||||
}
|
||||
|
||||
/// A deployment filesystem and how project folders map onto it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentServer {
|
||||
pub kind: DeploymentType,
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
@@ -148,3 +157,23 @@ pub struct RunTarget {
|
||||
pub node: String,
|
||||
pub java: String,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod deployment_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deployment_type_defaults_and_round_trips() {
|
||||
let legacy: DeploymentServer =
|
||||
serde_json::from_str(r#"{"id":"existing","sshConfig":"ssh","rootPath":"/srv"}"#).unwrap();
|
||||
assert_eq!(legacy.kind, DeploymentType::Sftp);
|
||||
assert_eq!(legacy.ssh_config, "ssh");
|
||||
for kind in [DeploymentType::Sftp, DeploymentType::Local] {
|
||||
let server = DeploymentServer { kind, ..legacy.clone() };
|
||||
assert_eq!(
|
||||
serde_json::from_str::<DeploymentServer>(&serde_json::to_string(&server).unwrap()).unwrap(),
|
||||
server
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -285,6 +285,8 @@ pub struct AppearanceSettings {
|
||||
pub project_colors_in_toolbar: bool,
|
||||
pub dnd_with_alt_only: bool,
|
||||
pub menu_icons: bool,
|
||||
/// `hamburger` | `merged`: main menu presentation in the frameless title bar.
|
||||
pub main_menu_style: String,
|
||||
/// `subpixel` | `greyscale` | `none`
|
||||
pub ide_antialiasing: String,
|
||||
pub editor_antialiasing: String,
|
||||
@@ -331,6 +333,7 @@ impl Default for AppearanceSettings {
|
||||
project_colors_in_toolbar: true,
|
||||
dnd_with_alt_only: false,
|
||||
menu_icons: true,
|
||||
main_menu_style: "hamburger".into(),
|
||||
ide_antialiasing: "greyscale".into(),
|
||||
editor_antialiasing: "greyscale".into(),
|
||||
show_tool_window_bars: true,
|
||||
@@ -342,6 +345,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 +369,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 +418,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 +449,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 +482,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 +564,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)]
|
||||
@@ -672,6 +719,15 @@ impl Settings {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn main_menu_style_defaults_and_round_trips() {
|
||||
let old: Settings = serde_json::from_str(r#"{"appearance":{"compactMode":true}}"#).unwrap();
|
||||
assert_eq!(old.appearance.main_menu_style, "hamburger");
|
||||
let merged: Settings = serde_json::from_str(r#"{"appearance":{"mainMenuStyle":"merged"}}"#).unwrap();
|
||||
assert_eq!(merged.appearance.main_menu_style, "merged");
|
||||
assert_eq!(serde_json::to_value(merged).unwrap()["appearance"]["mainMenuStyle"], "merged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_font_weights_default_to_regular_and_bold() {
|
||||
let json = serde_json::to_value(Settings::default()).unwrap();
|
||||
@@ -754,3 +810,19 @@ mod gradle_settings_tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod go_module_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn old_go_settings_inherit_module_defaults() {
|
||||
let old: GoSettings = serde_json::from_str(r#"{"goroot":"/go"}"#).unwrap();
|
||||
assert!(old.modules.auto_vendor);
|
||||
assert!(old.modules.env.is_empty());
|
||||
let configured: GoSettings =
|
||||
serde_json::from_str(r#"{"modules":{"autoVendor":false,"env":{"GOPRIVATE":"example.com/private"}}}"#)
|
||||
.unwrap();
|
||||
assert!(!configured.modules.auto_vendor);
|
||||
assert_eq!(configured.modules.env["GOPRIVATE"], "example.com/private");
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,44 @@ pub struct WorkspaceEdit {
|
||||
pub files: Vec<FileEdit>,
|
||||
}
|
||||
|
||||
impl WorkspaceEdit {
|
||||
/// Coalesces aliases of the same file before applying edits against its original text.
|
||||
/// Resource operations separate batches because later edits may use the new contents.
|
||||
pub fn deduplicate(&mut self) {
|
||||
let mut files = Vec::new();
|
||||
let mut batch = std::collections::HashMap::<std::path::PathBuf, usize>::new();
|
||||
let mut resolve_aliases = true;
|
||||
for file in std::mem::take(&mut self.files) {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
let path = if resolve_aliases {
|
||||
dunce::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
|
||||
} else {
|
||||
path.to_path_buf()
|
||||
};
|
||||
let index = *batch.entry(path.clone()).or_insert_with(|| {
|
||||
files.push(FileEdit::Edit { path: path.into(), edits: Vec::new() });
|
||||
files.len() - 1
|
||||
});
|
||||
let FileEdit::Edit { edits: own, .. } = &mut files[index] else { unreachable!() };
|
||||
for edit in edits {
|
||||
if !own.contains(&edit) {
|
||||
own.push(edit);
|
||||
}
|
||||
}
|
||||
}
|
||||
op => {
|
||||
batch.clear();
|
||||
// Later paths refer to a filesystem state the resource operation has yet to create.
|
||||
resolve_aliases = false;
|
||||
files.push(op);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum FileEdit {
|
||||
@@ -203,3 +241,34 @@ pub struct OutlineItem {
|
||||
|
||||
/// Fuzzy-match highlight: indices (in chars of the displayed string) that matched.
|
||||
pub type MatchIndices = Vec<u32>;
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod workspace_edit_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn aliases_are_one_edit_but_resource_operations_preserve_order() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let real = dir.path().join("main.cpp");
|
||||
let alias = dir.path().join("alias.cpp");
|
||||
std::fs::write(&real, "add()").unwrap();
|
||||
std::os::unix::fs::symlink(&real, &alias).unwrap();
|
||||
let change = TextEdit { range: Range::new(Pos::new(0, 0), Pos::new(0, 3)), new_text: "sum_values".into() };
|
||||
let edit = |path: PathBuf| FileEdit::Edit { path: path.into(), edits: vec![change.clone()] };
|
||||
let mut workspace = WorkspaceEdit {
|
||||
files: vec![
|
||||
edit(real.clone()),
|
||||
edit(alias),
|
||||
FileEdit::Create { path: dir.path().join("other").into(), overwrite: false },
|
||||
edit(real),
|
||||
],
|
||||
};
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace.files.len(), 3);
|
||||
let FileEdit::Edit { edits, .. } = &workspace.files[0] else { panic!() };
|
||||
assert_eq!(edits, &[change]);
|
||||
let once = workspace.clone();
|
||||
workspace.deduplicate();
|
||||
assert_eq!(workspace, once);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,16 @@ pub fn search_path() -> OsString {
|
||||
let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(PathBuf::from);
|
||||
let shell_path = login_env().and_then(|env| env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone()));
|
||||
let shims: Vec<PathBuf> = version_managers::managers().iter().map(|m| m.shims()).collect();
|
||||
let dirs = search_dirs(host_var_os("PATH").as_deref(), shell_path.as_deref(), home.as_deref(), &shims);
|
||||
// The Windows user `PATH` may not reach a process started before it was set; append its
|
||||
// entries after the inherited ones so missing user tools still resolve.
|
||||
let user_path = crate::process::user_path();
|
||||
let dirs = search_dirs(
|
||||
host_var_os("PATH").as_deref(),
|
||||
shell_path.as_deref(),
|
||||
user_path.as_deref(),
|
||||
home.as_deref(),
|
||||
&shims,
|
||||
);
|
||||
std::env::join_paths(dirs).unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -56,10 +65,12 @@ fn is_executable(path: &Path) -> bool {
|
||||
fn search_dirs(
|
||||
process_path: Option<&OsStr>,
|
||||
shell_path: Option<&OsStr>,
|
||||
user_path: Option<&OsStr>,
|
||||
home: Option<&Path>,
|
||||
shims: &[PathBuf],
|
||||
) -> Vec<PathBuf> {
|
||||
let mut dirs: Vec<PathBuf> = process_path.into_iter().chain(shell_path).flat_map(std::env::split_paths).collect();
|
||||
let mut dirs: Vec<PathBuf> =
|
||||
process_path.into_iter().chain(shell_path).chain(user_path).flat_map(std::env::split_paths).collect();
|
||||
if let Some(home) = home {
|
||||
dirs.extend(USER_DIRS.iter().map(|d| home.join(d)));
|
||||
dirs.extend(nvm_bins(&home.join(".nvm/versions/node")));
|
||||
@@ -80,6 +91,8 @@ const USER_DIRS: &[&str] = &[
|
||||
".cargo/bin",
|
||||
".local/bin",
|
||||
"go/bin",
|
||||
// `dotnet tool install --global` puts fsautocomplete, csharp-ls and friends here.
|
||||
".dotnet/tools",
|
||||
".bun/bin",
|
||||
".deno/bin",
|
||||
".npm-global/bin",
|
||||
@@ -96,6 +109,12 @@ const USER_DIRS: &[&str] = &[
|
||||
|
||||
const SYSTEM_DIRS: &[&str] = &["/opt/homebrew/bin", "/usr/local/go/bin", "/usr/local/bin", "/usr/bin", "/bin"];
|
||||
|
||||
/// Whether `dir` is one of the system's own bin directories, which [`search_path`] adds
|
||||
/// whatever `PATH` holds.
|
||||
pub fn is_system_dir(dir: &Path) -> bool {
|
||||
SYSTEM_DIRS.iter().any(|d| Path::new(d) == dir)
|
||||
}
|
||||
|
||||
/// The `bin` directories of nvm's Node versions in `dir`, newest first.
|
||||
fn nvm_bins(dir: &Path) -> Vec<PathBuf> {
|
||||
let mut versions: Vec<(Vec<u64>, PathBuf)> = std::fs::read_dir(dir)
|
||||
@@ -164,21 +183,42 @@ 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,
|
||||
);
|
||||
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
|
||||
assert_eq!(dirs[..3], [PathBuf::from("/usr/bin"), "/opt/tools".into(), "/home/me/.asdf/shims".into()]);
|
||||
assert!(at("/home/me/.npm-global/bin") < at("/home/me/.pyenv/shims"));
|
||||
// Global .NET tools (fsautocomplete, csharp-ls) install here, off PATH.
|
||||
assert!(at("/home/me/.dotnet/tools") < at("/usr/local/bin"));
|
||||
assert!(at("/home/me/.local/share/mise/shims") < at("/usr/local/bin"));
|
||||
assert!(dirs.iter().all(|d| d.is_absolute()));
|
||||
let unique: HashSet<&PathBuf> = dirs.iter().collect();
|
||||
assert_eq!(unique.len(), dirs.len());
|
||||
// A `:` can't be in a `PATH` entry: those below such a home are left out.
|
||||
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, Some(Path::new("/home/a:b")), &[]);
|
||||
let dirs = search_dirs(Some(OsStr::new("/usr/bin")), None, None, Some(Path::new("/home/a:b")), &[]);
|
||||
assert!(dirs.contains(&PathBuf::from("/usr/bin")) && !dirs.iter().any(|d| d.starts_with("/home")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_dirs_append_user_path_entries_once_after_the_inherited_path() {
|
||||
let (system, dup, usertool) =
|
||||
if cfg!(windows) { ("C:/system", "C:/dup", "C:/usertool") } else { ("/system", "/dup", "/usertool") };
|
||||
let home = Path::new(if cfg!(windows) { "C:/home/me" } else { "/home/me" });
|
||||
let join = |dirs: &[&str]| std::env::join_paths(dirs.iter().map(PathBuf::from)).unwrap();
|
||||
let inherited = join(&[system, dup]);
|
||||
let user = join(&[dup, usertool]);
|
||||
let dirs = search_dirs(Some(inherited.as_os_str()), None, Some(user.as_os_str()), Some(home), &[]);
|
||||
let at = |d: &str| dirs.iter().position(|p| p == Path::new(d)).unwrap();
|
||||
// User entries follow the inherited PATH, de-duplicated, ahead of the fallbacks.
|
||||
assert_eq!(dirs.iter().filter(|d| *d == Path::new(dup)).count(), 1);
|
||||
assert!(at(dup) < at(usertool));
|
||||
// And before this platform's home/system fallbacks.
|
||||
let fallback = dirs.iter().position(|d| d == &home.join(USER_DIRS[0])).unwrap();
|
||||
assert!(at(usertool) < fallback);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nvm_versions_newest_first() {
|
||||
let dir = std::env::temp_dir().join(format!("openidea-nvm-{}", std::process::id()));
|
||||
|
||||
@@ -348,8 +348,17 @@ pub struct AdapterIo {
|
||||
pub port: Option<u16>,
|
||||
/// Recent stderr lines of the adapter process, for error messages.
|
||||
pub stderr: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
/// (category, line) printed by the debuggee when it shares the adapter's stdio.
|
||||
pub program_output: Option<ProgramOutput>,
|
||||
}
|
||||
|
||||
pub type ProgramOutput = tokio::sync::mpsc::UnboundedReceiver<(&'static str, String)>;
|
||||
type ProgramSink = tokio::sync::mpsc::UnboundedSender<(&'static str, String)>;
|
||||
|
||||
/// Delve's debuggee inherits its stdio; no DAP output event carries what the program prints.
|
||||
const SHARES_STDIO: &str = "delve";
|
||||
const DELVE_BANNER: &str = "DAP server listening at:";
|
||||
|
||||
pub type LaunchFuture = Pin<Box<dyn Future<Output = IdeResult<AdapterIo>> + Send>>;
|
||||
|
||||
/// Starts adapters. Tests substitute in-memory adapters.
|
||||
@@ -385,6 +394,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: None,
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output: None,
|
||||
});
|
||||
}
|
||||
let embedded = match spec.connect.as_deref().filter(|_| spec.transport == Transport::Tcp) {
|
||||
@@ -407,6 +417,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: None,
|
||||
port: Some(*port),
|
||||
stderr,
|
||||
program_output: None,
|
||||
});
|
||||
}
|
||||
if spec.adapter == DEBUGPY_TOOL {
|
||||
@@ -445,8 +456,14 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
.kill_on_drop(true);
|
||||
let mut child =
|
||||
cmd.spawn().map_err(|e| IdeError::unavailable(format!("failed to start {}: {e}", binary.display())))?;
|
||||
let (program_sink, program_output) = if spec.adapter == SHARES_STDIO {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
(Some(tx), Some(rx))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
if let Some(err) = child.stderr.take() {
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone());
|
||||
pump_lines(err, spec.adapter.clone(), stderr.clone(), program_sink.clone().map(|tx| (tx, "stderr")));
|
||||
}
|
||||
match port {
|
||||
None => {
|
||||
@@ -458,11 +475,12 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: Some(child),
|
||||
port: None,
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
Some(port) => {
|
||||
if let Some(out) = child.stdout.take() {
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone());
|
||||
pump_lines(out, spec.adapter.clone(), stderr.clone(), program_sink.map(|tx| (tx, "stdout")));
|
||||
}
|
||||
let timeout = if embedded.is_some() { EMBEDDED_START_TIMEOUT } else { CONNECT_TIMEOUT };
|
||||
let stream = match connect_with_retries(LOOPBACK, port, Some(&mut child), timeout).await {
|
||||
@@ -484,6 +502,7 @@ impl AdapterLauncher for ProcessLauncher {
|
||||
child: Some(child),
|
||||
port: Some(port),
|
||||
stderr,
|
||||
program_output,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -495,9 +514,15 @@ fn pump_lines<R: AsyncRead + Unpin + Send + 'static>(
|
||||
stream: R,
|
||||
name: String,
|
||||
sink: Arc<parking_lot::Mutex<Vec<String>>>,
|
||||
program: Option<(ProgramSink, &'static str)>,
|
||||
) {
|
||||
tokio::spawn(ide_jsonrpc::output::read_lines(stream, move |line| {
|
||||
tracing::debug!(target: "ide_dap::adapter", "[{name}] {line}");
|
||||
if let Some((tx, category)) = &program
|
||||
&& !line.starts_with(DELVE_BANNER)
|
||||
{
|
||||
let _ = tx.send((category, format!("{line}\n")));
|
||||
}
|
||||
push_line(&sink, line);
|
||||
}));
|
||||
}
|
||||
@@ -549,6 +574,18 @@ async fn connect_with_retries(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn program_output_is_forwarded_without_the_adapter_banner() {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let sink = Arc::default();
|
||||
let text: &[u8] = b"DAP server listening at: 127.0.0.1:1\n[hello two words]\nno newline";
|
||||
pump_lines(text, "delve".into(), Arc::clone(&sink), Some((tx, "stdout")));
|
||||
assert_eq!(rx.recv().await, Some(("stdout", "[hello two words]\n".into())));
|
||||
assert_eq!(rx.recv().await, Some(("stdout", "no newline\n".into())));
|
||||
assert_eq!(rx.recv().await, None);
|
||||
assert_eq!(sink.lock().len(), 3);
|
||||
}
|
||||
|
||||
/// Managed tools win over `PATH`, and debugpy falls back to the private copy.
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
|
||||
@@ -697,6 +697,14 @@ impl DebugManager {
|
||||
};
|
||||
let session =
|
||||
Arc::new(Session::new(id, owner, name, adapter_name, parent, request, conn, spec, io.port, io.child));
|
||||
if let Some(mut output) = io.program_output {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some((category, output)) = output.recv().await {
|
||||
session.emit(ide_core::event::DebugEvent::Output { category: category.into(), output });
|
||||
}
|
||||
});
|
||||
}
|
||||
self.sessions.lock().insert(id, session.clone());
|
||||
self.order.lock().push(id);
|
||||
// Announced before the event loop runs, so it precedes every adapter event.
|
||||
|
||||
@@ -32,7 +32,14 @@ impl AdapterLauncher for FakeLauncher {
|
||||
tokio::spawn(fake_adapter(server, log, script));
|
||||
let (r, w) = tokio::io::split(client);
|
||||
Box::pin(async move {
|
||||
Ok(AdapterIo { reader: Box::new(r), writer: Box::new(w), child: None, port: None, stderr: Arc::default() })
|
||||
Ok(AdapterIo {
|
||||
reader: Box::new(r),
|
||||
writer: Box::new(w),
|
||||
child: None,
|
||||
port: None,
|
||||
stderr: Arc::default(),
|
||||
program_output: None,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ pub fn availability(root: &Path) -> GhAvailability {
|
||||
}
|
||||
let (repo, login) = std::thread::scope(|s| {
|
||||
let repo = s.spawn(|| {
|
||||
let cmd = command(root, &["repo", "view", "--json", "nameWithOwner,url,defaultBranchRef"])?;
|
||||
let cmd = command(root, &["repo", "view", "--json", REPO_FIELDS])?;
|
||||
process::run(cmd, QUICK, None)
|
||||
});
|
||||
let login = s.spawn(|| run(root, &["api", "user", "--jq", ".login"]));
|
||||
@@ -278,6 +278,53 @@ pub fn add_comment(root: &Path, number: u32, body: &str) -> IdeResult<()> {
|
||||
run(root, &["pr", "comment", &n, "--body", body]).map(drop)
|
||||
}
|
||||
|
||||
/// A GitHub API call through `gh api`: GraphQL (`endpoint` `graphql`, `body`
|
||||
/// `{"query", "variables"}`) or REST (`repos/{owner}/{repo}/…`, gh fills in the
|
||||
/// placeholders from the project's remote).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GhApiRequest {
|
||||
/// `GET`, `POST`, `PATCH`, `PUT`, `DELETE`.
|
||||
pub method: String,
|
||||
pub endpoint: String,
|
||||
/// JSON request body, sent on stdin.
|
||||
pub body: Option<String>,
|
||||
/// Follow REST `Link` pages; the result is the array of all pages (`--slurp`).
|
||||
pub paginate: bool,
|
||||
}
|
||||
|
||||
const METHODS: &[&str] = &["GET", "POST", "PATCH", "PUT", "DELETE"];
|
||||
|
||||
/// `gh api` arguments for `req`.
|
||||
pub fn api_args(req: &GhApiRequest) -> IdeResult<Vec<String>> {
|
||||
let method = req.method.to_ascii_uppercase();
|
||||
if !METHODS.contains(&method.as_str()) {
|
||||
return Err(IdeError::invalid(format!("unsupported HTTP method {}", req.method)));
|
||||
}
|
||||
let endpoint = req.endpoint.trim();
|
||||
if endpoint.is_empty() || endpoint.starts_with('-') || endpoint.contains("://") {
|
||||
return Err(IdeError::invalid(format!("not a GitHub API endpoint: {endpoint}")));
|
||||
}
|
||||
let mut args: Vec<String> = vec!["api".into(), "--method".into(), method, endpoint.into()];
|
||||
if req.body.is_some() {
|
||||
args.push("--input".into());
|
||||
args.push("-".into());
|
||||
}
|
||||
if req.paginate {
|
||||
args.push("--paginate".into());
|
||||
args.push("--slurp".into());
|
||||
}
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
/// Runs `req`; returns the response body (JSON text, empty for `204 No Content`).
|
||||
pub fn api(root: &Path, req: &GhApiRequest) -> IdeResult<String> {
|
||||
let args = api_args(req)?;
|
||||
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
let out = process::run_with_input(command(root, &refs)?, req.body.as_deref(), QUICK, None)?;
|
||||
if out.success { Ok(out.stdout) } else { Err(failure(&out.stderr, &out.stdout)) }
|
||||
}
|
||||
|
||||
/// Revisions to diff a pull request's files with, present in the local repository.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
|
||||
@@ -34,6 +34,12 @@ pub struct GhRepo {
|
||||
pub name_with_owner: String,
|
||||
pub url: String,
|
||||
pub default_branch: Option<String>,
|
||||
/// Merge methods the repository allows (all of them when gh does not say).
|
||||
pub merge_commit_allowed: bool,
|
||||
pub squash_merge_allowed: bool,
|
||||
pub rebase_merge_allowed: bool,
|
||||
/// The signed-in user's permission: `ADMIN`, `MAINTAIN`, `WRITE`, `TRIAGE`, `READ`; empty when unknown.
|
||||
pub viewer_permission: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -152,6 +158,8 @@ pub struct PullRequestDetails {
|
||||
pub base_oid: String,
|
||||
/// `MERGEABLE`, `CONFLICTING`, `UNKNOWN`.
|
||||
pub mergeable: String,
|
||||
/// GitHub's merge box state: `CLEAN`, `UNSTABLE`, `HAS_HOOKS`, `BLOCKED`, `BEHIND`, `DIRTY`, `DRAFT`, `UNKNOWN`.
|
||||
pub merge_state_status: String,
|
||||
pub is_cross_repository: bool,
|
||||
pub merged_at: Option<String>,
|
||||
pub closed_at: Option<String>,
|
||||
@@ -242,7 +250,7 @@ impl RawSummary {
|
||||
pub const LIST_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions";
|
||||
|
||||
/// Fields requested from `gh pr view`.
|
||||
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
|
||||
pub const VIEW_FIELDS: &str = "number,title,author,state,isDraft,headRefName,baseRefName,createdAt,updatedAt,labels,reviewDecision,url,additions,deletions,body,headRefOid,baseRefOid,mergeable,mergeStateStatus,isCrossRepository,mergedAt,closedAt,statusCheckRollup,reviewRequests,reviews,assignees,commits,files,comments";
|
||||
|
||||
pub fn parse_list(json: &str) -> serde_json::Result<Vec<PullRequestSummary>> {
|
||||
let raw: Vec<RawSummary> = serde_json::from_str(json)?;
|
||||
@@ -348,6 +356,7 @@ struct RawDetails {
|
||||
head_ref_oid: String,
|
||||
base_ref_oid: String,
|
||||
mergeable: String,
|
||||
merge_state_status: String,
|
||||
is_cross_repository: bool,
|
||||
merged_at: Option<String>,
|
||||
closed_at: Option<String>,
|
||||
@@ -442,6 +451,7 @@ pub fn parse_view(json: &str) -> serde_json::Result<PullRequestDetails> {
|
||||
head_oid: raw.head_ref_oid,
|
||||
base_oid: raw.base_ref_oid,
|
||||
mergeable: raw.mergeable,
|
||||
merge_state_status: raw.merge_state_status,
|
||||
is_cross_repository: raw.is_cross_repository,
|
||||
merged_at: raw.merged_at.filter(|s| !s.is_empty()),
|
||||
closed_at: raw.closed_at.filter(|s| !s.is_empty()),
|
||||
@@ -534,6 +544,10 @@ struct RawRepo {
|
||||
name_with_owner: String,
|
||||
url: String,
|
||||
default_branch_ref: Option<RawBranchRef>,
|
||||
merge_commit_allowed: Option<bool>,
|
||||
squash_merge_allowed: Option<bool>,
|
||||
rebase_merge_allowed: Option<bool>,
|
||||
viewer_permission: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
@@ -542,13 +556,21 @@ struct RawBranchRef {
|
||||
name: String,
|
||||
}
|
||||
|
||||
/// `gh repo view --json nameWithOwner,url,defaultBranchRef`.
|
||||
/// Fields requested from `gh repo view`.
|
||||
pub const REPO_FIELDS: &str =
|
||||
"nameWithOwner,url,defaultBranchRef,mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed,viewerPermission";
|
||||
|
||||
/// `gh repo view --json` with [`REPO_FIELDS`].
|
||||
pub fn parse_repo(json: &str) -> serde_json::Result<GhRepo> {
|
||||
let raw: RawRepo = serde_json::from_str(json)?;
|
||||
Ok(GhRepo {
|
||||
name_with_owner: raw.name_with_owner,
|
||||
url: raw.url,
|
||||
default_branch: raw.default_branch_ref.map(|b| b.name).filter(|n| !n.is_empty()),
|
||||
merge_commit_allowed: raw.merge_commit_allowed.unwrap_or(true),
|
||||
squash_merge_allowed: raw.squash_merge_allowed.unwrap_or(true),
|
||||
rebase_merge_allowed: raw.rebase_merge_allowed.unwrap_or(true),
|
||||
viewer_permission: raw.viewer_permission.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -60,9 +60,30 @@ fn read_all(mut pipe: impl Read + Send + 'static) -> std::thread::JoinHandle<Str
|
||||
|
||||
/// Runs `cmd` to completion. Fails when it outlives `timeout` or `cancel` fires;
|
||||
/// a non-zero exit is reported in [`Output::success`], not as an error.
|
||||
pub(crate) fn run(mut cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
pub(crate) fn run(cmd: Command, timeout: Duration, cancel: Option<&CancellationToken>) -> IdeResult<Output> {
|
||||
run_with_input(cmd, None, timeout, cancel)
|
||||
}
|
||||
|
||||
/// [`run`] with `input` written to the child's stdin (then closed).
|
||||
pub(crate) fn run_with_input(
|
||||
mut cmd: Command,
|
||||
input: Option<&str>,
|
||||
timeout: Duration,
|
||||
cancel: Option<&CancellationToken>,
|
||||
) -> IdeResult<Output> {
|
||||
prepare(&mut cmd);
|
||||
if input.is_some() {
|
||||
cmd.stdin(Stdio::piped());
|
||||
}
|
||||
let mut child = spawn(&mut cmd)?;
|
||||
if let (Some(input), Some(mut stdin)) = (input, child.stdin.take()) {
|
||||
let input = input.to_owned();
|
||||
// A writer thread: a large body must not block on a full pipe while gh is not reading yet.
|
||||
std::thread::spawn(move || {
|
||||
use std::io::Write;
|
||||
let _ = stdin.write_all(input.as_bytes());
|
||||
});
|
||||
}
|
||||
let out = child.stdout.take().map(read_all);
|
||||
let err = child.stderr.take().map(read_all);
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
|
||||
{"additions":42,"assignees":[],"author":{"id":"MDQ6VXNlcjM2NzI4OTMx","is_bot":false,"login":"babakks","name":"Babak K. Shandiz"},"baseRefName":"trunk","baseRefOid":"ae7b9715976c5508398b11dc8546e1279e10f6fc","body":"### Description\n\nThis PR moves gh to the tagged version of `cli/oauth`.","closedAt":"2026-10-21T12:29:58Z","comments":[{"id":"IC_1","author":{"login":"babakks"},"authorAssociation":"MEMBER","body":"Rebased on trunk.","createdAt":"2026-10-21T08:00:00Z","includesCreatedEdit":false,"isMinimized":false,"minimizedReason":"","reactionGroups":[],"url":"https://github.com/cli/cli/pull/14768#issuecomment-1","viewerDidAuthor":false}],"commits":[{"authoredDate":"2026-10-20T16:37:24Z","authors":[{"email":"[email protected]","id":"MDQ6VXNlcjM2NzI4OTMx","login":"babakks","name":"Babak K. Shandiz"}],"committedDate":"2026-10-21T12:06:51Z","messageBody":"cli/oauth v1.3.0 exposes the token expiry fields as time.Duration values.","messageHeadline":"chore(deps): bump github.com/cli/oauth to v1.3.0","oid":"fd1b1feb222dde885d00262b2c21b107c6c6e416"}],"createdAt":"2026-10-20T16:40:41Z","deletions":48,"files":[{"path":"go.mod","additions":1,"deletions":1,"changeType":"MODIFIED"},{"path":"pkg/cmd/status/status.go","additions":3,"deletions":2,"changeType":"MODIFIED"}],"headRefName":"babakks/refresh-token-bump-deps","headRefOid":"6ec1ba54542baca6cc5932582634104c83f36a28","isCrossRepository":false,"isDraft":false,"labels":[],"mergeable":"UNKNOWN","mergeStateStatus":"UNKNOWN","mergedAt":"2026-10-21T12:29:58Z","number":14768,"reviewDecision":"APPROVED","reviewRequests":[{"__typename":"User","login":"niik"}],"reviews":[{"id":"PRR_1","author":{"login":"copilot-pull-request-reviewer"},"authorAssociation":"CONTRIBUTOR","body":"","submittedAt":"2026-10-21T09:46:01Z","includesCreatedEdit":false,"reactionGroups":[],"state":"COMMENTED","commit":{"oid":"831b9343fa77c2f11f521a49fd01da2d2d007b8e"}},{"id":"PRR_2","author":{"login":"williammartin"},"authorAssociation":"MEMBER","body":"LGTM","submittedAt":"2026-10-21T12:20:00Z","includesCreatedEdit":false,"reactionGroups":[],"state":"APPROVED","commit":{"oid":"6ec1ba54542baca6cc5932582634104c83f36a28"}}],"state":"MERGED","statusCheckRollup":[{"__typename":"CheckRun","completedAt":"2026-10-21T12:09:03Z","conclusion":"SUCCESS","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/1","name":"build (ubuntu)","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"0001-01-01T00:00:00Z","conclusion":"","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/2","name":"build (windows)","startedAt":"2026-10-21T12:07:01Z","status":"IN_PROGRESS","workflowName":"Tests"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"FAILURE","detailsUrl":"https://github.com/cli/cli/actions/runs/1/job/3","name":"lint","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":"Lint"},{"__typename":"CheckRun","completedAt":"2026-10-21T12:08:00Z","conclusion":"SKIPPED","detailsUrl":"","name":"docs","startedAt":"2026-10-21T12:07:01Z","status":"COMPLETED","workflowName":""},{"__typename":"StatusContext","context":"ci/legacy","state":"PENDING","targetUrl":"https://ci.example.com/1","startedAt":"2026-10-21T12:07:01Z"}],"title":"Refreshable tokens: bump cli/oauth to v1.3.0 and fix `gh status` column width","updatedAt":"2026-10-21T12:30:00Z","url":"https://github.com/cli/cli/pull/14768"}
|
||||
@@ -22,6 +22,7 @@ fn pr_view() {
|
||||
assert_eq!(pr.summary.number, 14768);
|
||||
assert_eq!(pr.summary.head_ref, "babakks/refresh-token-bump-deps");
|
||||
assert_eq!(pr.head_oid, "6ec1ba54542baca6cc5932582634104c83f36a28");
|
||||
assert_eq!(pr.merge_state_status, "UNKNOWN");
|
||||
assert_eq!(pr.files.len(), 2);
|
||||
assert_eq!(pr.files[0].change_type, "MODIFIED");
|
||||
assert_eq!(pr.commits[0].headline, "chore(deps): bump github.com/cli/oauth to v1.3.0");
|
||||
@@ -69,6 +70,16 @@ fn repo_view() {
|
||||
.unwrap();
|
||||
assert_eq!(r.name_with_owner, "l0ng-ai/openidea");
|
||||
assert_eq!(r.default_branch.as_deref(), Some("main"));
|
||||
assert!(r.merge_commit_allowed && r.squash_merge_allowed && r.rebase_merge_allowed, "unknown: allowed");
|
||||
assert_eq!(r.viewer_permission, "");
|
||||
|
||||
let r = parse_repo(
|
||||
r#"{"mergeCommitAllowed":true,"nameWithOwner":"o/r","url":"","rebaseMergeAllowed":false,"squashMergeAllowed":false,"viewerPermission":"WRITE"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(r.merge_commit_allowed);
|
||||
assert!(!r.squash_merge_allowed && !r.rebase_merge_allowed);
|
||||
assert_eq!(r.viewer_permission, "WRITE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -87,3 +98,24 @@ fn list_arguments() {
|
||||
assert!(joined.contains("--limit 500 --state merged --author @me"));
|
||||
assert_eq!(args.last().map(String::as_str), Some("review-requested:@me fix login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_arguments() {
|
||||
let req =
|
||||
GhApiRequest { method: "post".into(), endpoint: "graphql".into(), body: Some("{}".into()), paginate: false };
|
||||
assert_eq!(api_args(&req).unwrap(), ["api", "--method", "POST", "graphql", "--input", "-"]);
|
||||
let req = GhApiRequest {
|
||||
method: "GET".into(),
|
||||
endpoint: "repos/{owner}/{repo}/labels".into(),
|
||||
body: None,
|
||||
paginate: true,
|
||||
};
|
||||
assert_eq!(
|
||||
api_args(&req).unwrap(),
|
||||
["api", "--method", "GET", "repos/{owner}/{repo}/labels", "--paginate", "--slurp"]
|
||||
);
|
||||
for bad in ["--hostname", "https://evil.example/x", ""] {
|
||||
assert!(api_args(&GhApiRequest { endpoint: bad.into(), ..req.clone() }).is_err(), "{bad}");
|
||||
}
|
||||
assert!(api_args(&GhApiRequest { method: "TRACE".into(), ..req }).is_err());
|
||||
}
|
||||
@@ -44,6 +44,7 @@ struct State {
|
||||
project: Changes,
|
||||
/// Project roots whose `postStartCommand` ran in this process.
|
||||
post_started: HashSet<PathBuf>,
|
||||
post_commands: std::collections::HashMap<PathBuf, CancellationToken>,
|
||||
}
|
||||
|
||||
pub struct Environment {
|
||||
@@ -78,7 +79,12 @@ impl Environment {
|
||||
|
||||
pub(crate) fn project_closed(&self) {
|
||||
if self.project_env {
|
||||
self.state.lock().project.clear();
|
||||
let mut state = self.state.lock();
|
||||
for (_, token) in state.post_commands.drain() {
|
||||
token.cancel();
|
||||
}
|
||||
state.project.clear();
|
||||
drop(state);
|
||||
self.publish();
|
||||
}
|
||||
}
|
||||
@@ -144,6 +150,7 @@ impl Environment {
|
||||
/// a failure is a notification.
|
||||
pub(crate) fn spawn_post_start(&self, cmd: LifecycleCommand, root: PathBuf, sink: SharedSink) {
|
||||
let cancel = self.cancel.child_token();
|
||||
self.state.lock().post_commands.insert(root.clone(), cancel.clone());
|
||||
let spawned = std::thread::Builder::new().name("openidea-post-start".into()).spawn(move || {
|
||||
run_post_start(&cmd, &root, &sink, &cancel);
|
||||
});
|
||||
@@ -263,6 +270,9 @@ fn run_post_start(cmd: &LifecycleCommand, root: &Path, sink: &SharedSink, cancel
|
||||
/// Runs `argv` in `root` with the children's environment, its stdout and stderr line by line
|
||||
/// to `on_line`; `Err` says how it failed.
|
||||
fn run_one(argv: &[String], root: &Path, cancel: &CancellationToken, on_line: &dyn Fn(String)) -> Result<(), String> {
|
||||
if cancel.is_cancelled() {
|
||||
return Err("cancelled".into());
|
||||
}
|
||||
let (program, args) = argv.split_first().ok_or("empty command")?;
|
||||
let mut child = ide_core::process::prepare(&mut Command::new(program))
|
||||
.args(args)
|
||||
|
||||
@@ -12,6 +12,7 @@ mod proxy;
|
||||
mod requests;
|
||||
pub mod rpc;
|
||||
pub mod shell_env;
|
||||
pub mod trust;
|
||||
|
||||
pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention};
|
||||
|
||||
@@ -44,6 +45,7 @@ pub struct HostConfig {
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub trust: trust::TrustStore,
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
@@ -67,6 +69,7 @@ pub struct Host {
|
||||
/// One project per window, keyed by window label.
|
||||
projects: RwLock<HashMap<String, Arc<Project>>>,
|
||||
requests: requests::Requests,
|
||||
project_requests: RwLock<HashMap<String, Vec<RequestId>>>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
@@ -77,6 +80,7 @@ impl Host {
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
let host = Self {
|
||||
trust: trust::TrustStore::load(&config_dir, &data_dir),
|
||||
proxy_password: ide_ssh::secrets::SecretFile::new(data_dir.join("proxy-password.json")),
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
@@ -95,6 +99,7 @@ impl Host {
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
requests: Default::default(),
|
||||
project_requests: RwLock::default(),
|
||||
};
|
||||
host.apply_proxy();
|
||||
host
|
||||
@@ -119,6 +124,10 @@ impl Host {
|
||||
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
|
||||
}
|
||||
|
||||
pub fn require_trust(&self, window: &str) -> IdeResult<()> {
|
||||
self.project(window)?.require_trust()
|
||||
}
|
||||
|
||||
/// The window that owns what `window` starts.
|
||||
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
|
||||
(self.owner)(window)
|
||||
@@ -212,7 +221,19 @@ impl Host {
|
||||
self.requests.begin(id)
|
||||
}
|
||||
|
||||
pub fn begin_project_request(&self, window: &str, id: RequestId) -> CancellationToken {
|
||||
let token = self.begin_request(id);
|
||||
self.project_requests.write().entry(self.owner_window(window).to_owned()).or_default().push(id);
|
||||
if self.project(window).is_ok_and(|p| !p.is_trusted()) {
|
||||
token.cancel();
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
pub fn end_request(&self, id: RequestId) {
|
||||
for ids in self.project_requests.write().values_mut() {
|
||||
ids.retain(|entry| *entry != id);
|
||||
}
|
||||
self.requests.end(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -407,6 +407,24 @@ impl NotebookService {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn restrict(&self) {
|
||||
let documents: Vec<_> = self.registry.lock().documents.values().cloned().collect();
|
||||
for document in &documents {
|
||||
let mut state = document.state.lock();
|
||||
state.generation += 1;
|
||||
if let Some(batch) = &state.active {
|
||||
batch.cancel.cancel();
|
||||
}
|
||||
}
|
||||
for document in documents {
|
||||
let _ = self.kernels.shutdown_kernel(&document.path).await;
|
||||
let batch = document.state.lock().active.clone();
|
||||
if let Err(error) = self.drain(&document, batch).await {
|
||||
tracing::warn!("notebook restriction: {error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
self.stop_admission();
|
||||
self.kernels.shutdown().await;
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeRe
|
||||
}
|
||||
|
||||
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
|
||||
host.require_trust(window)?;
|
||||
let mut providers = host.project(window)?.agents.providers();
|
||||
ide_agent::registry::mark_updates(®istry_paths(host), &mut providers);
|
||||
Ok(providers)
|
||||
@@ -88,6 +89,7 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let requested = request.project_root.take();
|
||||
@@ -99,6 +101,7 @@ pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> Ide
|
||||
|
||||
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
|
||||
pub async fn prompt(host: &Host, window: &str, task: String, prompt: AgentPrompt, now: bool) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.prompt(&task, prompt, now).await
|
||||
}
|
||||
|
||||
@@ -109,26 +112,31 @@ pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeRe
|
||||
|
||||
/// Sends a queued message now, interrupting the current turn.
|
||||
pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.send_queued_now(&task, &queued)
|
||||
}
|
||||
|
||||
/// Changes a session setting (model, mode, thinking effort…).
|
||||
pub async fn set_config(host: &Host, window: &str, task: String, option: String, value: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.set_config(&task, &option, value).await
|
||||
}
|
||||
|
||||
/// Copies the task's conversation into a new task, up to its first `turns` turns (all when `None`).
|
||||
pub async fn fork(host: &Host, window: &str, task: String, turns: Option<u32>) -> IdeResult<AgentTask> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.fork(&task, turns).await
|
||||
}
|
||||
|
||||
/// Drops the task's turns after its first `turns`; returns the message of the first dropped one.
|
||||
pub async fn rollback(host: &Host, window: &str, task: String, turns: u32) -> IdeResult<Option<AgentPrompt>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.rollback(&task, turns).await
|
||||
}
|
||||
|
||||
/// What the agent reports about itself when it starts, without opening a session.
|
||||
pub async fn probe(host: &Host, window: &str, provider: String, refresh: bool) -> IdeResult<AgentProbe> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.probe(&provider, refresh).await
|
||||
}
|
||||
|
||||
@@ -139,6 +147,7 @@ pub fn cancel(host: &Host, window: &str, task: String, subagent: Option<String>)
|
||||
|
||||
/// Answers a permission request; `option: None` cancels it.
|
||||
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.respond(&task, permission, option)
|
||||
}
|
||||
|
||||
@@ -151,15 +160,18 @@ pub fn unarchive(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
}
|
||||
|
||||
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.delete(&task, remove_worktree).await
|
||||
}
|
||||
|
||||
pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentChange>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.changes(&task).await
|
||||
}
|
||||
|
||||
/// Content of `path` when the task started (left side of its diff).
|
||||
pub async fn base_text(host: &Host, window: &str, task: String, path: IpcPath) -> IdeResult<Option<String>> {
|
||||
host.require_trust(window)?;
|
||||
host.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
@@ -76,6 +76,7 @@ pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
|
||||
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
|
||||
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
@@ -93,9 +94,10 @@ pub async fn build_sync(
|
||||
profiles: Vec<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = host.begin_request(request);
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
@@ -223,11 +225,12 @@ pub async fn build_run(
|
||||
clear: Vec<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
|
||||
let settings = project.settings.effective();
|
||||
let config_dir = host.config_dir.clone();
|
||||
let token = host.begin_request(request);
|
||||
let token = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
|
||||
@@ -16,6 +16,7 @@ pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send
|
||||
/// How to run `config` with coverage: the command to launch and where its report will be,
|
||||
/// or which tool is missing.
|
||||
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
|
||||
blocking(move || {
|
||||
@@ -26,7 +27,8 @@ pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> I
|
||||
}
|
||||
|
||||
/// Reads the report a coverage run left behind.
|
||||
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
pub async fn coverage_collect(host: &Host, window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
host.require_trust(window)?;
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::path::PathBuf;
|
||||
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
host.require_trust(window)?;
|
||||
let mut config = match_java_debug_encoding(config, cfg!(windows));
|
||||
if config.adapter == "debugpy"
|
||||
&& config.request == "launch"
|
||||
@@ -22,6 +23,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();
|
||||
@@ -61,6 +77,7 @@ pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> Ide
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.require_trust(window)?;
|
||||
let result = host.debugger.start_with(config, owner, custom).await;
|
||||
flush_output();
|
||||
result
|
||||
@@ -292,11 +309,12 @@ pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResul
|
||||
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
|
||||
pub async fn debug_listen_for_jvm(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
request: ide_core::RequestId,
|
||||
jvm_host: String,
|
||||
port: u16,
|
||||
) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
let token = host.begin_request(request);
|
||||
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
|
||||
host.end_request(request);
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
|
||||
use ide_remote::{DeployedPath, DeploymentCoverage, SyncEntry, SyncStep, TransferReport};
|
||||
use ide_ssh::{FsConnection, RemoteEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Reports a transfer as a background task (status bar progress) whose Cancel fires `token`.
|
||||
fn progress(
|
||||
sink: SharedSink,
|
||||
title: &str,
|
||||
token: CancellationToken,
|
||||
) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) {
|
||||
let id = TaskId::next();
|
||||
let cancel = token;
|
||||
let handle = ide_core::task::on_cancel(id, move || cancel.cancel());
|
||||
let title = title.to_owned();
|
||||
let (s, t) = (sink.clone(), title.clone());
|
||||
let report = move |path: &str, i: usize, n: usize| {
|
||||
let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) };
|
||||
let message = Some(path.to_owned());
|
||||
s.emit(IdeEvent::Task { id, title: t.clone(), message, fraction, done: false, cancellable: true });
|
||||
};
|
||||
let done = move || {
|
||||
drop(handle);
|
||||
sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true, cancellable: false })
|
||||
};
|
||||
(report, done)
|
||||
}
|
||||
|
||||
fn local(paths: Vec<IpcPath>) -> Vec<PathBuf> {
|
||||
paths.into_iter().map(|p| p.to_path_buf()).collect()
|
||||
}
|
||||
|
||||
/// Eligible paths per server, without contacting any deployment host.
|
||||
pub async fn deploy_coverage(window: &str, state: &Host, paths: Vec<IpcPath>) -> IdeResult<Vec<DeploymentCoverage>> {
|
||||
let project = state.project(window)?;
|
||||
Ok(ide_remote::coverage(&project.settings.effective(), &project.root, &local(paths)))
|
||||
}
|
||||
|
||||
/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`).
|
||||
pub async fn deploy_upload(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Uploading", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Download from…: the remote counterparts of files and folders.
|
||||
pub async fn deploy_download(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Downloading", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Deletes the remote counterparts of files and folders.
|
||||
pub async fn deploy_delete_remote(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
paths: Vec<IpcPath>,
|
||||
server: Option<String>,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await
|
||||
}
|
||||
|
||||
/// Sync with Deployed: local and remote files under `path`, side by side.
|
||||
pub async fn deploy_compare(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
path: IpcPath,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<SyncEntry>> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await;
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Applies the Sync with Deployed choices.
|
||||
pub async fn deploy_sync(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
steps: Vec<SyncStep>,
|
||||
server: Option<String>,
|
||||
request: RequestId,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let project = state.project(window)?;
|
||||
let token = state.begin_request(request);
|
||||
let (mut report, done) = progress(project.sink.clone(), "Synchronizing", token.clone());
|
||||
let r = state
|
||||
.remote
|
||||
.sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report)
|
||||
.await;
|
||||
done();
|
||||
state.end_request(request);
|
||||
r
|
||||
}
|
||||
|
||||
/// Where `path` is deployed (remote path, web URL); `None` when not mapped.
|
||||
pub async fn deploy_info(
|
||||
window: &str,
|
||||
state: &Host,
|
||||
path: IpcPath,
|
||||
server: Option<String>,
|
||||
) -> IdeResult<Option<DeployedPath>> {
|
||||
let project = state.project(window)?;
|
||||
state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await
|
||||
}
|
||||
|
||||
/// Remote Host tool window: the server root of `server`, resolved on the host.
|
||||
pub async fn deploy_server_root(window: &str, state: &Host, server: String) -> IdeResult<String> {
|
||||
let settings = match state.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => state.global_settings.effective(),
|
||||
};
|
||||
state.remote.server_root(&settings, &server).await
|
||||
}
|
||||
|
||||
/// Entries of a folder on the selected filesystem (`~`: its home directory).
|
||||
pub async fn remote_list(state: &Host, connection: FsConnection, path: String) -> IdeResult<Vec<RemoteEntry>> {
|
||||
state.remote.list(&connection, &path).await
|
||||
}
|
||||
|
||||
/// Opens a remote file for editing: a local copy, uploaded back on save.
|
||||
pub async fn remote_open(state: &Host, connection: FsConnection, path: String) -> IdeResult<IpcPath> {
|
||||
Ok(state.remote.open_remote(&connection, &path).await?.into())
|
||||
}
|
||||
|
||||
/// The filesystem connection and path a cached editing copy stands for.
|
||||
pub fn remote_of(state: &Host, path: IpcPath) -> Option<(FsConnection, String)> {
|
||||
state.remote.remote_of(&path)
|
||||
}
|
||||
|
||||
pub async fn remote_delete(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.remote_delete(&connection, &path).await
|
||||
}
|
||||
|
||||
pub async fn remote_mkdir(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.remote_mkdir(&connection, &path).await
|
||||
}
|
||||
|
||||
pub async fn remote_rename(state: &Host, connection: FsConnection, from: String, to: String) -> IdeResult<()> {
|
||||
state.remote.remote_rename(&connection, &from, &to).await
|
||||
}
|
||||
|
||||
pub async fn deploy_test_connection(state: &Host, connection: FsConnection, path: String) -> IdeResult<()> {
|
||||
state.remote.test_connection(&connection, &path).await
|
||||
}
|
||||
|
||||
pub(crate) fn register(d: &mut crate::rpc::Dispatcher) {
|
||||
d.add("deploy_coverage", |c| async move { deploy_coverage(&c.window, &c.host, c.arg("paths")?).await });
|
||||
d.add("deploy_upload", |c| async move {
|
||||
deploy_upload(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_download", |c| async move {
|
||||
deploy_download(&c.window, &c.host, c.arg("paths")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_delete_remote", |c| async move {
|
||||
deploy_delete_remote(&c.window, &c.host, c.arg("paths")?, c.arg("server")?).await
|
||||
});
|
||||
d.add("deploy_compare", |c| async move {
|
||||
deploy_compare(&c.window, &c.host, c.arg("path")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_sync", |c| async move {
|
||||
deploy_sync(&c.window, &c.host, c.arg("steps")?, c.arg("server")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("deploy_info", |c| async move { deploy_info(&c.window, &c.host, c.arg("path")?, c.arg("server")?).await });
|
||||
d.add("deploy_server_root", |c| async move { deploy_server_root(&c.window, &c.host, c.arg("server")?).await });
|
||||
d.add("remote_list", |c| async move { remote_list(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_open", |c| async move { remote_open(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_of", |c| async move { Ok(remote_of(&c.host, c.arg("path")?)) });
|
||||
d.add("remote_delete", |c| async move { remote_delete(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_mkdir", |c| async move { remote_mkdir(&c.host, c.arg("connection")?, c.arg("path")?).await });
|
||||
d.add("remote_rename", |c| async move {
|
||||
remote_rename(&c.host, c.arg("connection")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("deploy_test_connection", |c| async move {
|
||||
deploy_test_connection(&c.host, c.arg("connection")?, c.arg("path")?).await
|
||||
});
|
||||
}
|
||||
@@ -38,6 +38,18 @@ pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
|
||||
host.debugger.refresh_source_mirrors(host.owner_window(window)).await
|
||||
}
|
||||
|
||||
/// Recovers external changes missed by the project's file watchers.
|
||||
pub async fn sync_from_disk(host: &Host, window: &str) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
if project.settings.effective().files.sync_on_focus {
|
||||
project.documents.sync_from_disk();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.snapshot(doc)).await
|
||||
@@ -45,3 +57,6 @@ pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<Docume
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -30,8 +30,15 @@ pub async fn doc_ui_mode(host: &Host, window: &str, path: PathBuf) -> IdeResult<
|
||||
}
|
||||
|
||||
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
|
||||
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
|
||||
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
|
||||
pub async fn doc_set_encoding(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
encoding: String,
|
||||
reload: bool,
|
||||
) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.set_encoding(doc, &encoding, reload)).await
|
||||
}
|
||||
|
||||
pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
|
||||
@@ -117,7 +124,7 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
|
||||
d.add("doc_ui_mode", |c| async move { doc_ui_mode(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("doc_set_encoding", |c| async move {
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?).await
|
||||
});
|
||||
d.add("doc_set_line_ending", |c| async move {
|
||||
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
use super::*;
|
||||
use crate::{HostConfig, Project};
|
||||
use ide_core::{IdeEvent, TextChange, event::NotifyLevel};
|
||||
use parking_lot::Mutex;
|
||||
use std::{path::Path, sync::Arc};
|
||||
|
||||
struct Fixture {
|
||||
host: Host,
|
||||
project: Arc<Project>,
|
||||
events: Arc<Mutex<Vec<IdeEvent>>>,
|
||||
path: PathBuf,
|
||||
outside: PathBuf,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
fn new() -> Self {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(temp.path()).unwrap();
|
||||
let root = base.join("project");
|
||||
// Keep watcher delivery out of these tests: the op must recover it itself.
|
||||
let excluded = root.join("node_modules");
|
||||
std::fs::create_dir_all(&excluded).unwrap();
|
||||
let path = excluded.join("file.txt");
|
||||
let outside = base.join("outside.txt");
|
||||
std::fs::write(&path, "original\n").unwrap();
|
||||
std::fs::write(&outside, "outside\n").unwrap();
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let captured = events.clone();
|
||||
let host = Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |_| {
|
||||
let events = captured.clone();
|
||||
Arc::new(move |event| events.lock().push(event))
|
||||
}),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
Self { _temp: temp, host, project, events, path, outside }
|
||||
}
|
||||
|
||||
async fn open(&self, path: &Path) -> DocumentInfo {
|
||||
open(&self.host, "main", path.to_path_buf()).await.unwrap()
|
||||
}
|
||||
|
||||
async fn sync(&self) {
|
||||
sync_from_disk(&self.host, "main").await.unwrap();
|
||||
}
|
||||
|
||||
fn document_events(&self) -> Vec<IdeEvent> {
|
||||
self.events
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|e| matches!(e, IdeEvent::DocPatched { .. } | IdeEvent::DocDirty { .. } | IdeEvent::Notify { .. }))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn changed_clean_document_reloads_once() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
std::fs::write(&f.path, "externally changed\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, "externally changed\n");
|
||||
assert!(!loaded.dirty);
|
||||
assert!(loaded.version > original.version);
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, loaded.version);
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn unchanged_document_has_no_patch_or_event() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, original.text);
|
||||
assert_eq!(loaded.version, original.version);
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn dirty_document_keeps_edits_and_warns_once() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
edit(
|
||||
&f.host,
|
||||
"main",
|
||||
original.id,
|
||||
original.version,
|
||||
ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "local ".into() }] },
|
||||
)
|
||||
.unwrap();
|
||||
f.events.lock().clear();
|
||||
std::fs::write(&f.path, "external replacement\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, "local original\n");
|
||||
assert!(loaded.dirty);
|
||||
assert!(matches!(f.document_events().as_slice(), [IdeEvent::Notify { level: NotifyLevel::Warning, .. }]));
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn deleted_document_keeps_its_buffer_and_other_documents_still_reload() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.path).await;
|
||||
let outside = f.open(&f.outside).await;
|
||||
std::fs::remove_file(&f.path).unwrap();
|
||||
std::fs::write(&f.outside, "outside changed after deletion\n").unwrap();
|
||||
f.sync().await;
|
||||
let loaded = f.project.documents.snapshot(original.id).unwrap();
|
||||
assert_eq!(loaded.text, original.text);
|
||||
assert_eq!(loaded.version, original.version);
|
||||
assert_eq!(f.project.documents.snapshot(outside.id).unwrap().text, "outside changed after deletion\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn outside_project_document_reloads() {
|
||||
let f = Fixture::new();
|
||||
assert!(!f.outside.starts_with(&f.project.root));
|
||||
let original = f.open(&f.outside).await;
|
||||
std::fs::write(&f.outside, "outside changed externally\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "outside changed externally\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn setting_defaults_on_and_disabled_sync_keeps_buffer() {
|
||||
let f = Fixture::new();
|
||||
assert!(f.project.settings.effective().files.sync_on_focus);
|
||||
assert!(serde_json::from_value::<ide_core::settings::FileSettings>(serde_json::json!({})).unwrap().sync_on_focus);
|
||||
let original = f.open(&f.outside).await;
|
||||
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", false.into()).unwrap();
|
||||
std::fs::write(&f.outside, "external change while disabled\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().version, original.version);
|
||||
f.project.settings.set(ide_workspace::settings::SettingsScope::Project, "files.syncOnFocus", true.into()).unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change while disabled\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn properties_reload_preserves_native_to_ascii_decoding() {
|
||||
let f = Fixture::new();
|
||||
let path = f.outside.with_extension("properties");
|
||||
std::fs::write(&path, "key=\\u4F60\n").unwrap();
|
||||
let mut defaults = f.project.documents.file_defaults();
|
||||
defaults.native_to_ascii = true;
|
||||
f.project.documents.set_file_defaults(defaults);
|
||||
let original = f.open(&path).await;
|
||||
assert_eq!(original.text, "key=你\n");
|
||||
std::fs::write(&path, "key=\\u4E16\\u754C\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "key=世界\n");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn binary_and_virtual_documents_are_skipped() {
|
||||
let f = Fixture::new();
|
||||
let path = f.outside.with_extension("bin");
|
||||
std::fs::write(&path, b"\0\0binary payload").unwrap();
|
||||
let binary = f.open(&path).await;
|
||||
assert!(binary.read_only);
|
||||
let virtual_doc =
|
||||
f.project.documents.open_virtual("test://virtual", ide_core::LanguageId::plain_text(), "virtual").unwrap();
|
||||
std::fs::write(&path, "now ordinary text").unwrap();
|
||||
f.events.lock().clear();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(binary.id).unwrap().version, binary.version);
|
||||
assert_eq!(f.project.documents.snapshot(virtual_doc.id).unwrap().text, "virtual");
|
||||
assert!(f.document_events().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn synchronization_only_changes_the_calling_windows_documents() {
|
||||
let f = Fixture::new();
|
||||
let original = f.open(&f.outside).await;
|
||||
let other_root = f.outside.parent().unwrap().join("other-project");
|
||||
std::fs::create_dir(&other_root).unwrap();
|
||||
let other = f.host.open_project("other", &other_root).unwrap();
|
||||
let other_doc = open(&f.host, "other", f.outside.clone()).await.unwrap();
|
||||
std::fs::write(&f.outside, "external change in shared file\n").unwrap();
|
||||
f.sync().await;
|
||||
assert_eq!(f.project.documents.snapshot(original.id).unwrap().text, "external change in shared file\n");
|
||||
assert_eq!(other.documents.snapshot(other_doc.id).unwrap().text, other_doc.text);
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use std::sync::Arc;
|
||||
/// Re-reads the login shell (and the window's project `remoteEnv` where it applies);
|
||||
/// running terminals and processes keep their environment.
|
||||
pub async fn reload(host: &Arc<Host>, window: &str) -> IdeResult<EnvReloaded> {
|
||||
let root = host.project(window).ok().filter(|p| !p.light).map(|p| p.root.clone());
|
||||
let root = host.project(window).ok().filter(|p| !p.light && p.is_trusted()).map(|p| p.root.clone());
|
||||
let sink = host.window_sink(window);
|
||||
let h = host.clone();
|
||||
host.blocking(move || h.environment.reload(root.as_deref(), &sink)).await
|
||||
|
||||
@@ -49,6 +49,10 @@ pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_file_bytes(host: &Host, path: PathBuf, data: Vec<u8>) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::create_file_bytes(&path, &data)).await
|
||||
}
|
||||
|
||||
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
|
||||
/// state). Returns whether the file is read-only now.
|
||||
pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult<bool> {
|
||||
|
||||
@@ -20,7 +20,8 @@ pub async fn java_resolve_launch(
|
||||
build: bool,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = resolve_launch(host, window, module, test, build, &cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
@@ -34,62 +35,77 @@ 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).
|
||||
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_debug_server(&module).await
|
||||
@@ -101,6 +117,7 @@ pub async fn java_build_workspace(
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module, &CancellationToken::new()).await
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
//! Bounded, read-only JVM presentation for visible files, also served remotely.
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, LanguageId};
|
||||
use ide_syntax::{JvmPresentation, Snapshot};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fs::File,
|
||||
io::Read,
|
||||
path::PathBuf,
|
||||
sync::{Mutex, OnceLock},
|
||||
time::SystemTime,
|
||||
};
|
||||
|
||||
pub const PREFIX_BYTES: u64 = 64 * 1024;
|
||||
const MAX_BATCH: usize = 64;
|
||||
const MAX_CACHE: usize = 4096;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct JvmFilePresentation {
|
||||
pub path: PathBuf,
|
||||
pub presentation: JvmPresentation,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Cached {
|
||||
modified: SystemTime,
|
||||
size: u64,
|
||||
presentation: JvmPresentation,
|
||||
}
|
||||
|
||||
fn prefix(path: &PathBuf) -> std::io::Result<String> {
|
||||
let mut bytes = Vec::new();
|
||||
File::open(path)?.take(PREFIX_BYTES).read_to_end(&mut bytes)?;
|
||||
// A truncated final UTF-8 character must not invalidate the entire prefix.
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
pub async fn jvm_file_presentations(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
) -> IdeResult<Vec<JvmFilePresentation>> {
|
||||
if paths.len() > MAX_BATCH {
|
||||
return Err(IdeError::invalid("at most 64 JVM files per batch"));
|
||||
}
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
static CACHE: OnceLock<Mutex<HashMap<PathBuf, Cached>>> = OnceLock::new();
|
||||
let cache = CACHE.get_or_init(Default::default);
|
||||
let mut results = Vec::new();
|
||||
for path in paths {
|
||||
let language = match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("java") => LanguageId::new("java"),
|
||||
Some("kt") => LanguageId::new("kotlin"),
|
||||
_ => continue,
|
||||
};
|
||||
let Ok(meta) = path.metadata() else { continue };
|
||||
if !meta.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Ok(modified) = meta.modified() else { continue };
|
||||
let hit =
|
||||
cache.lock().unwrap().get(&path).filter(|c| c.modified == modified && c.size == meta.len()).cloned();
|
||||
let presentation = if let Some(hit) = hit {
|
||||
hit.presentation
|
||||
} else {
|
||||
let Ok(source) = prefix(&path) else { continue };
|
||||
let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or_default();
|
||||
let presentation = project.structure.jvm_presentation(
|
||||
&Snapshot::text(&language, &source),
|
||||
stem,
|
||||
&CancellationToken::new(),
|
||||
)?;
|
||||
let mut cache = cache.lock().unwrap();
|
||||
if cache.len() >= MAX_CACHE {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(path.clone(), Cached { modified, size: meta.len(), presentation: presentation.clone() });
|
||||
presentation
|
||||
};
|
||||
results.push(JvmFilePresentation { path, presentation });
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn huge_files_read_only_a_prefix() {
|
||||
let path = std::env::temp_dir().join(format!("openidea-icon-prefix-{}.java", std::process::id()));
|
||||
let file = File::create(&path).unwrap();
|
||||
file.set_len(100 * 1024 * 1024).unwrap();
|
||||
assert_eq!(prefix(&path).unwrap().len(), PREFIX_BYTES as usize);
|
||||
std::fs::remove_file(path).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,13 @@ pub mod build;
|
||||
pub mod bytes;
|
||||
pub mod coverage;
|
||||
pub mod debug;
|
||||
pub mod deploy;
|
||||
pub mod doc;
|
||||
pub mod env;
|
||||
pub mod fs;
|
||||
pub mod history;
|
||||
pub mod java;
|
||||
pub mod jvm_icons;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod notebook;
|
||||
@@ -80,6 +82,7 @@ pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
search::register(d);
|
||||
run::register(d);
|
||||
env::register(d);
|
||||
deploy::register(d);
|
||||
}
|
||||
|
||||
impl Host {
|
||||
|
||||
@@ -54,6 +54,7 @@ pub async fn reload(host: &Host, window: &str, id: String) -> IdeResult<Notebook
|
||||
}
|
||||
|
||||
pub async fn restart(host: &Host, window: &str, id: String) -> IdeResult<NotebookSnapshot> {
|
||||
host.require_trust(window)?;
|
||||
let notebooks = host.project(window)?.notebooks.clone();
|
||||
tokio::spawn(async move { notebooks.reload(&id, true).await })
|
||||
.await
|
||||
@@ -84,8 +85,9 @@ pub async fn execute(
|
||||
request: RequestId,
|
||||
on_output: Channel<NotebookOutputEvent>,
|
||||
) -> IdeResult<NotebookSnapshot> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let _request = RequestGuard { host, request, cancel: cancel.clone() };
|
||||
// Reserve the WHOLE batch and capture source before asynchronous discovery.
|
||||
let admission = project.notebooks.admit(&id, cells, cancel.clone())?;
|
||||
|
||||
@@ -10,6 +10,7 @@ use std::sync::Arc;
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectInfo {
|
||||
pub trusted: bool,
|
||||
pub root: ide_core::IpcPath,
|
||||
pub name: String,
|
||||
pub is_git: bool,
|
||||
@@ -22,6 +23,7 @@ pub fn info(project: &Project) -> ProjectInfo {
|
||||
let settings = project.settings.effective();
|
||||
let custom = settings.general.project_name.trim();
|
||||
ProjectInfo {
|
||||
trusted: project.is_trusted(),
|
||||
name: if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
@@ -146,6 +148,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();
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::time::Duration;
|
||||
|
||||
/// Detected configs followed by user-defined ones (user ids win on conflict).
|
||||
pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let user = ide_run::load_user_configs(&root)?;
|
||||
@@ -21,6 +22,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());
|
||||
@@ -65,6 +71,7 @@ pub fn save_configs(
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
@@ -97,6 +104,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 +123,33 @@ 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)))
|
||||
project.require_trust()?;
|
||||
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).
|
||||
|
||||
@@ -50,6 +50,7 @@ pub async fn run_test_reports(
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResult<ide_run::tomcat::TomcatHome> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window)?.root.clone();
|
||||
tokio::task::spawn_blocking(move || ide_run::tomcat::validate_home(&root.join(home)))
|
||||
.await
|
||||
@@ -57,6 +58,7 @@ pub async fn run_tomcat_home(host: &Host, window: &str, home: String) -> IdeResu
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<ide_build::web::WebArtifact>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&project.root, &project.build_cache_dir(), &project.settings.effective());
|
||||
@@ -67,6 +69,7 @@ pub async fn run_tomcat_artifacts(host: &Host, window: &str) -> IdeResult<Vec<id
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_build(host: &Host, window: &str, config: RunConfig) -> IdeResult<Option<RunConfig>> {
|
||||
host.require_trust(window)?;
|
||||
if config.extras.params.get("deployment").is_some_and(|v| v != "artifact") {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -81,22 +84,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,13 +108,21 @@ 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()))?
|
||||
}
|
||||
|
||||
pub async fn run_tomcat_update(host: &Host, window: &str, config: RunConfig, mode: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
if !host.processes.list().iter().any(|r| r.running && r.config_id == config.id) {
|
||||
return Err(IdeError::invalid("Start this Tomcat configuration before updating it"));
|
||||
|
||||
@@ -22,12 +22,14 @@ pub struct LocatedTools {
|
||||
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
host.require_trust(window)?;
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
@@ -35,6 +37,7 @@ pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_c
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
@@ -56,11 +59,12 @@ pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<i
|
||||
|
||||
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
|
||||
pub async fn sdk_locate_tools(
|
||||
_host: &Host,
|
||||
_window: &str,
|
||||
host: &Host,
|
||||
window: &str,
|
||||
names: Vec<String>,
|
||||
near: Option<PathBuf>,
|
||||
) -> IdeResult<LocatedTools> {
|
||||
host.require_trust(window)?;
|
||||
tokio::task::spawn_blocking(move || LocatedTools {
|
||||
tools: ide_sdk::locate::locate_tools(&names, near.as_deref()),
|
||||
compile_dir: ide_sdk::locate::compile_dir().into(),
|
||||
@@ -72,13 +76,14 @@ pub async fn sdk_locate_tools(
|
||||
/// `base -m venv location`; output lines stream through `on_output`.
|
||||
pub async fn sdk_python_create_venv(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
base: PathBuf,
|
||||
location: PathBuf,
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
@@ -92,11 +97,12 @@ pub async fn sdk_python_create_venv(
|
||||
|
||||
pub async fn sdk_python_packages(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
python: PathBuf,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<PythonPackage>> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
@@ -107,7 +113,7 @@ pub async fn sdk_python_packages(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn sdk_python_pip(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
window: &str,
|
||||
python: PathBuf,
|
||||
requirement: String,
|
||||
uninstall: bool,
|
||||
@@ -115,7 +121,8 @@ pub async fn sdk_python_pip(
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let cancel = host.begin_request(request);
|
||||
host.require_trust(window)?;
|
||||
let cancel = host.begin_project_request(window, request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let send = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
|
||||
@@ -75,7 +75,7 @@ pub async fn start(
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.text_of(p),
|
||||
&|p| documents.search_text_of(p),
|
||||
&cancel,
|
||||
&|mut file| {
|
||||
if dir != &root {
|
||||
|
||||
@@ -124,7 +124,7 @@ pub async fn syntax_identifiers_in_files(
|
||||
let snap = project.documents.text_snapshot(doc).ok()?;
|
||||
return (!snap.large).then_some((snap.language, Some((doc, snap.version)), snap.text));
|
||||
}
|
||||
let text = ide_vfs::load_text(path).ok()?.text;
|
||||
let text = project.text_of(path).ok()?;
|
||||
let first_line = text.lines().next();
|
||||
Some((languages.detect(path, first_line), None, text))
|
||||
};
|
||||
|
||||
@@ -3,12 +3,24 @@ use crate::Host;
|
||||
use ide_core::{IdeResult, TerminalId};
|
||||
use ide_terminal::{TerminalInfo, TerminalOptions};
|
||||
|
||||
pub fn spawn(host: &Host, window: &str, options: TerminalOptions, on_output: Emit<String>) -> IdeResult<TerminalInfo> {
|
||||
pub fn spawn(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
mut options: TerminalOptions,
|
||||
on_output: Emit<String>,
|
||||
) -> IdeResult<TerminalInfo> {
|
||||
if !host.project(window).is_ok_and(|p| p.is_trusted()) {
|
||||
// Project terminal settings can otherwise start a command as soon as a tab restores.
|
||||
let global = host.global_settings.effective().terminal;
|
||||
options.shell = None;
|
||||
options.args.clear();
|
||||
options.env = global.env;
|
||||
}
|
||||
// Python › "Activate virtualenv": typed into the new shell, like PyCharm.
|
||||
let activation = host
|
||||
.project(window)
|
||||
.ok()
|
||||
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref()));
|
||||
let activation =
|
||||
host.project(window).ok().filter(|p| p.is_trusted()).and_then(|p| {
|
||||
ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref())
|
||||
});
|
||||
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
|
||||
if let Some(line) = activation {
|
||||
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
|
||||
|
||||
@@ -11,6 +11,7 @@ use std::path::PathBuf;
|
||||
|
||||
/// The installer, reporting the executables chosen in the window's settings.
|
||||
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
|
||||
host.require_trust(window)?;
|
||||
let data = host.data_dir.clone();
|
||||
let (settings, root) = match host.project(window) {
|
||||
Ok(project) => (project.settings.effective(), project.root.clone()),
|
||||
|
||||
@@ -7,7 +7,9 @@ pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let settings = project.settings.effective();
|
||||
apply_git_executable(&settings);
|
||||
if project.is_trusted() {
|
||||
apply_git_executable(&settings);
|
||||
}
|
||||
let repos = project.repos();
|
||||
if repos.is_empty() {
|
||||
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
|
||||
|
||||
@@ -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())?)
|
||||
})
|
||||
@@ -256,6 +259,7 @@ pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
|
||||
}
|
||||
|
||||
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
@@ -268,6 +272,7 @@ pub async fn vcs_add_to_ignore(
|
||||
paths: Vec<PathBuf>,
|
||||
destination: IgnoreDestination,
|
||||
) -> IdeResult<Vec<String>> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
for path in &paths {
|
||||
@@ -289,16 +294,19 @@ pub async fn vcs_add_to_ignore(
|
||||
}
|
||||
|
||||
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.stage_patch(&patch, &|path| project.vcs_encoding(path, Some(":0"))))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
|
||||
}
|
||||
@@ -310,6 +318,7 @@ pub async fn vcs_commit(
|
||||
message: String,
|
||||
options: CommitOptions,
|
||||
) -> IdeResult<String> {
|
||||
host.require_trust(window)?;
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
@@ -328,6 +337,7 @@ pub async fn vcs_checkout(
|
||||
create: bool,
|
||||
options: Option<ide_vcs::CheckoutOptions>,
|
||||
) -> IdeResult<ide_vcs::CheckoutOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let options = options.unwrap_or_default();
|
||||
blocking(move || {
|
||||
@@ -346,6 +356,7 @@ pub async fn vcs_create_branch(
|
||||
start: Option<String>,
|
||||
checkout: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let recorded = checkout.then_some("Git checkout");
|
||||
blocking(move || {
|
||||
@@ -357,6 +368,7 @@ pub async fn vcs_create_branch(
|
||||
/// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
|
||||
/// Returns the undone commit's message.
|
||||
pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option<PathBuf>, commit: String) -> IdeResult<String> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
|
||||
}
|
||||
@@ -367,6 +379,7 @@ pub async fn vcs_delete_branch(
|
||||
repo: Option<PathBuf>,
|
||||
branch: String,
|
||||
) -> IdeResult<ide_vcs::DeletedBranch> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await
|
||||
}
|
||||
@@ -378,6 +391,7 @@ pub async fn vcs_restore_branch(
|
||||
repo: Option<PathBuf>,
|
||||
deleted: ide_vcs::DeletedBranch,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await
|
||||
}
|
||||
@@ -389,16 +403,19 @@ pub async fn vcs_rename_branch(
|
||||
from: String,
|
||||
to: String,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_merge(host: &Host, window: &str, repo: Option<PathBuf>, branch: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_rebase(host: &Host, window: &str, repo: Option<PathBuf>, onto: String) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await
|
||||
}
|
||||
@@ -411,6 +428,7 @@ pub async fn vcs_stash_push(
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
@@ -429,6 +447,7 @@ pub async fn vcs_stash_apply(
|
||||
reinstate_index: bool,
|
||||
branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() {
|
||||
@@ -440,6 +459,7 @@ pub async fn vcs_stash_apply(
|
||||
}
|
||||
|
||||
pub async fn vcs_stash_drop(host: &Host, window: &str, repo: Option<PathBuf>, index: u32) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await
|
||||
}
|
||||
@@ -453,6 +473,7 @@ pub async fn vcs_remote(
|
||||
op: RemoteOp,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
@@ -510,14 +531,18 @@ 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<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let encoding = project.vcs_encoding(&path, Some(":0"))?;
|
||||
let current = ide_vfs::load_text_as(&path, encoding.name())?.text;
|
||||
project.with_repo_of(&path, |r| {
|
||||
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
r.stage_hunks(&path, ¤t, &selected, encoding)
|
||||
blocking(move || {
|
||||
let encoding = project.vcs_encoding(&path, Some(":0"))?;
|
||||
let current = project.documents.load_file_as(&path, encoding.charset.name())?.text;
|
||||
project.with_repo_of(&path, |r| {
|
||||
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
r.stage_hunks(&path, ¤t, &selected, encoding)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Clones a repository (Get from VCS). Works without an open project.
|
||||
@@ -550,7 +575,8 @@ pub async fn vcs_init(host: &Host, _window: &str, dir: PathBuf) -> IdeResult<()>
|
||||
|
||||
/// Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected
|
||||
/// executable when `path` is empty.
|
||||
pub async fn vcs_git_version(_host: &Host, _window: &str, path: String) -> IdeResult<GitExecutableInfo> {
|
||||
pub async fn vcs_git_version(host: &Host, window: &str, path: String) -> IdeResult<GitExecutableInfo> {
|
||||
host.require_trust(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let explicit = path.trim();
|
||||
let exe = if explicit.is_empty() {
|
||||
@@ -592,6 +618,7 @@ pub async fn vcs_operation(
|
||||
op: GitOperation,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
@@ -632,6 +659,7 @@ pub async fn vcs_create_tag(
|
||||
message: Option<String>,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force)))
|
||||
.await
|
||||
@@ -650,6 +678,7 @@ pub async fn vcs_merge_with(
|
||||
branches: Vec<String>,
|
||||
options: MergeOptions,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options))
|
||||
@@ -665,6 +694,7 @@ pub async fn vcs_rebase_with(
|
||||
upstream: String,
|
||||
options: RebaseOptions,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options))
|
||||
@@ -678,6 +708,7 @@ pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> Id
|
||||
}
|
||||
|
||||
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await
|
||||
}
|
||||
@@ -695,6 +726,7 @@ pub async fn vcs_add_worktree(
|
||||
commitish: String,
|
||||
new_branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
|
||||
@@ -709,6 +741,7 @@ pub async fn vcs_remove_worktree(
|
||||
path: PathBuf,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
|
||||
}
|
||||
@@ -736,6 +769,19 @@ pub async fn vcs_diff_revisions(
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
/// The best common ancestor of `a` and `b` (`git merge-base`), if any: the New Pull
|
||||
/// Request tab's three-dot diff.
|
||||
pub async fn vcs_merge_base(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
a: String,
|
||||
b: String,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.merge_base(&a, &b))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_create_patch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
@@ -771,12 +817,14 @@ pub async fn vcs_apply_patch(
|
||||
repo: Option<PathBuf>,
|
||||
patch: String,
|
||||
) -> IdeResult<ApplyOutcome> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.apply_patch(&patch))).await
|
||||
}
|
||||
|
||||
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
|
||||
pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec<PathBuf>) -> IdeResult<ShelvedChangeList> {
|
||||
host.require_trust(window)?;
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
in_primary_repo(&project, &paths)?;
|
||||
@@ -793,17 +841,26 @@ 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> {
|
||||
host.require_trust(window)?;
|
||||
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<()> {
|
||||
@@ -811,9 +868,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> {
|
||||
@@ -843,7 +910,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
|
||||
@@ -927,7 +994,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
|
||||
});
|
||||
@@ -976,6 +1046,9 @@ pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_diff_revisions", |c| async move {
|
||||
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("vcs_merge_base", |c| async move {
|
||||
vcs_merge_base(&c.host, &c.window, c.arg("repo")?, c.arg("a")?, c.arg("b")?).await
|
||||
});
|
||||
d.add("vcs_create_patch", |c| async move {
|
||||
vcs_create_patch(&c.host, &c.window, c.arg("repo")?, c.arg("source")?).await
|
||||
});
|
||||
@@ -988,10 +1061,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(
|
||||
|
||||
@@ -15,9 +15,11 @@ use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoo
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Everything bound to one open project folder.
|
||||
pub struct Project {
|
||||
pub(crate) trusted: Arc<AtomicBool>,
|
||||
pub root: PathBuf,
|
||||
/// Window-scoped event sink, for commands that emit project events.
|
||||
pub sink: SharedSink,
|
||||
@@ -80,7 +82,9 @@ impl Host {
|
||||
self.close_project(window);
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
// Before anything the project starts: its children get the project's environment.
|
||||
let post_start = self.environment.project_opened(&root, &sink);
|
||||
let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?));
|
||||
let post_start =
|
||||
trusted.load(Ordering::Acquire).then(|| self.environment.project_opened(&root, &sink)).flatten();
|
||||
let settings = SettingsStore::load(&self.config_dir, Some(&root));
|
||||
let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root)));
|
||||
let excludes = exclude_globs(&root, &settings.effective(), &detected_roots.read());
|
||||
@@ -114,6 +118,7 @@ impl Host {
|
||||
)?;
|
||||
mark("index");
|
||||
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
|
||||
lsp.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
lsp.set_content_roots(extra_roots.clone());
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
@@ -208,9 +213,13 @@ impl Host {
|
||||
root: root.clone(),
|
||||
sink: sink.clone(),
|
||||
runtime: self.runtime.clone(),
|
||||
trusted: trusted.clone(),
|
||||
}));
|
||||
|
||||
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
|
||||
for repo in repos.read().all() {
|
||||
repo.set_execution_allowed(trusted.load(Ordering::Acquire));
|
||||
}
|
||||
let watcher = {
|
||||
let repos = repos.clone();
|
||||
let settings = settings.clone();
|
||||
@@ -223,6 +232,8 @@ impl Host {
|
||||
let history = history.clone();
|
||||
let remote = self.remote.clone();
|
||||
let runtime = self.runtime.clone();
|
||||
let lsp = lsp.clone();
|
||||
let trusted = trusted.clone();
|
||||
Watcher::start(
|
||||
&root.clone(),
|
||||
excludes.clone(),
|
||||
@@ -230,6 +241,7 @@ impl Host {
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
lsp.project_files_changed(batch.created.iter().chain(&batch.removed));
|
||||
// Atomic saves by external tools surface as `created`.
|
||||
documents.on_disk_changed(&changed);
|
||||
// A framework's root marker (`angular.json`) created or deleted: its
|
||||
@@ -239,7 +251,7 @@ impl Host {
|
||||
documents.redetect_languages();
|
||||
}
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
if !batch.removed.is_empty() {
|
||||
if trusted.load(Ordering::Acquire) && !batch.removed.is_empty() {
|
||||
delete_deployed(
|
||||
&runtime,
|
||||
remote.clone(),
|
||||
@@ -329,6 +341,7 @@ impl Host {
|
||||
);
|
||||
ide_agent::registry::maintain(crate::ops::agent::registry_paths(self));
|
||||
let project = Arc::new(Project {
|
||||
trusted,
|
||||
root,
|
||||
sink,
|
||||
languages,
|
||||
@@ -358,6 +371,12 @@ impl Host {
|
||||
weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
{
|
||||
let weak = Arc::downgrade(&project);
|
||||
project.lsp.set_project_excludes_hook(Box::new(move || {
|
||||
weak.upgrade().map(|p| p.exclude_globs()).unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
if let Some(cmd) = post_start {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
@@ -438,6 +457,7 @@ impl Host {
|
||||
sink.clone(),
|
||||
);
|
||||
let project = Arc::new(Project {
|
||||
trusted: Arc::new(AtomicBool::new(true)),
|
||||
sink,
|
||||
languages,
|
||||
settings,
|
||||
@@ -468,6 +488,18 @@ impl Host {
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn is_trusted(&self) -> bool {
|
||||
self.trusted.load(Ordering::Acquire)
|
||||
}
|
||||
|
||||
pub fn require_trust(&self) -> IdeResult<()> {
|
||||
if self.is_trusted() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(IdeError::unavailable("Project is in Restricted Mode. Trust the project to enable execution."))
|
||||
}
|
||||
}
|
||||
|
||||
/// LightEdit: follow external changes to the files of `dir` (its own entries only).
|
||||
pub fn watch_light_dir(&self, dir: &Path) {
|
||||
if !self.light {
|
||||
@@ -542,7 +574,11 @@ impl Project {
|
||||
|
||||
/// The project's git repositories.
|
||||
pub fn repos(&self) -> Repos {
|
||||
self.repos.read().clone()
|
||||
let repos = self.repos.read().clone();
|
||||
for repo in repos.all() {
|
||||
repo.set_execution_allowed(self.is_trusted());
|
||||
}
|
||||
repos
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository whose root is `at`, the project's primary repository
|
||||
@@ -574,7 +610,9 @@ impl Project {
|
||||
|
||||
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let settings = self.settings.effective();
|
||||
apply_git_executable(&settings);
|
||||
if self.is_trusted() {
|
||||
apply_git_executable(&settings);
|
||||
}
|
||||
repo.set_ignored_names(settings.ignored_names());
|
||||
f(repo)
|
||||
}
|
||||
@@ -681,7 +719,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);
|
||||
@@ -739,7 +777,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())
|
||||
@@ -760,7 +808,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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -768,6 +816,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 } => {
|
||||
@@ -961,6 +1011,7 @@ impl DocumentObserver for HistoryObserver {
|
||||
|
||||
/// Uploads saved files: remote files' local copies, and automatic upload to the default server.
|
||||
struct DeployObserver {
|
||||
trusted: Arc<AtomicBool>,
|
||||
remote: Arc<ide_remote::RemoteService>,
|
||||
settings: Arc<SettingsStore>,
|
||||
root: PathBuf,
|
||||
@@ -970,6 +1021,9 @@ struct DeployObserver {
|
||||
|
||||
impl DocumentObserver for DeployObserver {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
|
||||
if !self.trusted.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let (remote, settings, root, sink, path) =
|
||||
(self.remote.clone(), self.settings.clone(), self.root.clone(), self.sink.clone(), path.to_path_buf());
|
||||
self.runtime.spawn(async move {
|
||||
@@ -1111,3 +1165,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");
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
pub const PROTOCOL_VERSION: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -138,6 +138,15 @@ impl Dispatcher {
|
||||
/// terminals, runs and VCS status.
|
||||
pub fn standard() -> Self {
|
||||
let mut d = Self::default();
|
||||
d.add("project_trust_status", |c| async move { c.host.trust.status(&c.arg::<PathBuf>("path")?) })
|
||||
.add("project_trust_list", |c| async move { c.host.trust.decisions() })
|
||||
.add("project_trust_set", |c| async move {
|
||||
c.host.set_project_trust(&c.arg::<PathBuf>("path")?, c.arg("trusted")?, c.arg("folder")?).await
|
||||
})
|
||||
.add("project_trust_remove_location", |c| async move {
|
||||
c.host.trust.remove_location(&c.arg::<PathBuf>("path")?)?;
|
||||
c.host.refresh_project_trust().await
|
||||
});
|
||||
d.add("open_project", |c| async move {
|
||||
let path: PathBuf = c.arg("path")?;
|
||||
let project = ops::project::open(&c.host, &c.window, &path).await?;
|
||||
@@ -148,6 +157,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 {
|
||||
@@ -168,6 +180,9 @@ impl Dispatcher {
|
||||
.add("cancel_task", |c| async move { Ok(ide_core::task::cancel(c.arg::<TaskId>("id")?)) })
|
||||
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file_bytes", |c| async move {
|
||||
ops::fs::create_file_bytes(&c.host, c.arg("path")?, c.arg("data")?).await
|
||||
})
|
||||
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
|
||||
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
|
||||
.add("fs_set_writable", |c| async move {
|
||||
@@ -183,6 +198,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")?;
|
||||
@@ -284,6 +300,7 @@ impl Handler for ServerHandler {
|
||||
let Some(handler) = self.dispatcher.handlers.get(call.cmd.as_str()).cloned() else {
|
||||
return Err((codes::METHOD_NOT_FOUND, format!("command {} is not available remotely", call.cmd)));
|
||||
};
|
||||
self.host.authorize_command(&call.window, &call.cmd).map_err(error_reply)?;
|
||||
let c = Call { host: self.host.clone(), window: call.window, args: call.args, peer: self.peer.clone() };
|
||||
// Requests run on blocking-capable threads: wait for the command here.
|
||||
self.host.runtime.block_on(handler(c)).map_err(error_reply)
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
//! User-owned project execution decisions. Project settings never grant trust.
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustDecisions {
|
||||
pub projects: Vec<PathBuf>,
|
||||
pub locations: Vec<PathBuf>,
|
||||
pub restricted: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
pub struct TrustStore {
|
||||
file: PathBuf,
|
||||
state: Mutex<IdeResult<TrustDecisions>>,
|
||||
}
|
||||
|
||||
impl TrustStore {
|
||||
pub fn load(config: &Path, data: &Path) -> Self {
|
||||
let file = data.join("project-trust.json");
|
||||
let state = match std::fs::read(&file) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
let projects = ide_workspace::RecentProjects::load(config)
|
||||
.paths()
|
||||
.into_iter()
|
||||
.filter_map(|p| dunce::canonicalize(p).ok())
|
||||
.collect();
|
||||
let migrated = TrustDecisions { projects, ..Default::default() };
|
||||
persist(&file, &migrated).map(|()| migrated)
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
};
|
||||
Self { file, state: Mutex::new(state) }
|
||||
}
|
||||
|
||||
pub fn decisions(&self) -> IdeResult<TrustDecisions> {
|
||||
self.state.lock().clone()
|
||||
}
|
||||
|
||||
pub fn trusted(&self, path: &Path) -> IdeResult<bool> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let decisions = self.decisions()?;
|
||||
Ok(!decisions.restricted.contains(&root)
|
||||
&& (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p))))
|
||||
}
|
||||
|
||||
pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
let root = match dunce::canonicalize(path) {
|
||||
Ok(root) => root,
|
||||
Err(_) if !trusted && path.is_absolute() => path.to_path_buf(),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
let mut state = self.state.lock();
|
||||
let mut next = state.clone()?;
|
||||
next.projects.retain(|p| p != &root);
|
||||
next.restricted.retain(|p| p != &root);
|
||||
if trusted {
|
||||
next.projects.push(root.clone());
|
||||
if folder && let Some(parent) = root.parent() {
|
||||
let parent = parent.to_path_buf();
|
||||
if !next.locations.contains(&parent) {
|
||||
next.locations.push(parent);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
next.restricted.push(root);
|
||||
}
|
||||
persist(&self.file, &next)?;
|
||||
*state = Ok(next);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_location(&self, path: &Path) -> IdeResult<()> {
|
||||
let mut state = self.state.lock();
|
||||
let mut next = state.clone()?;
|
||||
next.locations.retain(|p| p != path);
|
||||
persist(&self.file, &next)?;
|
||||
*state = Ok(next);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(file: &Path, state: &TrustDecisions) -> IdeResult<()> {
|
||||
if let Some(parent) = file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let bytes = serde_json::to_vec_pretty(state).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
ide_vfs::write_atomic(file, &bytes)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl crate::Host {
|
||||
pub async fn set_project_trust(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> {
|
||||
self.trust.decide(path, trusted, folder)?;
|
||||
self.refresh_project_trust().await
|
||||
}
|
||||
|
||||
pub async fn refresh_project_trust(&self) -> IdeResult<()> {
|
||||
for project in self.projects().into_iter().filter(|p| !p.light) {
|
||||
let trusted = self.trust.trusted(&project.root).unwrap_or(false);
|
||||
let previous = project.trusted.swap(trusted, std::sync::atomic::Ordering::AcqRel);
|
||||
if previous == trusted {
|
||||
continue;
|
||||
}
|
||||
if !trusted {
|
||||
project.lsp.set_execution_allowed(false);
|
||||
}
|
||||
project.repos();
|
||||
if trusted {
|
||||
if let Some(cmd) = self.environment.project_opened(&project.root, &project.sink) {
|
||||
self.environment.spawn_post_start(cmd, project.root.clone(), project.sink.clone());
|
||||
}
|
||||
project.lsp.set_execution_allowed(true);
|
||||
} else {
|
||||
self.environment.project_closed();
|
||||
project.agents.shutdown();
|
||||
self.processes.kill_project(&project.root);
|
||||
project.notebooks.restrict().await;
|
||||
let windows: Vec<_> = self
|
||||
.projects
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, current)| std::sync::Arc::ptr_eq(current, &project))
|
||||
.map(|(window, _)| window.clone())
|
||||
.collect();
|
||||
for window in windows {
|
||||
let requests = self.project_requests.write().remove(&window).unwrap_or_default();
|
||||
for request in requests {
|
||||
self.cancel_request(request);
|
||||
}
|
||||
for session in self.debugger.sessions(Some(&window)) {
|
||||
let _ = self.debugger.stop(session.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
project.lsp.reconfigure().await;
|
||||
project.sink.emit(ide_core::IdeEvent::ProjectTrustChanged { root: project.root.clone().into(), trusted });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Commands which only inspect/edit source or operate user-owned application state.
|
||||
/// New execution commands are denied until deliberately classified here.
|
||||
pub fn restricted_command_allowed(cmd: &str) -> bool {
|
||||
if [
|
||||
"fs_",
|
||||
"doc_",
|
||||
"syntax_",
|
||||
"search_",
|
||||
"nav_",
|
||||
"history_",
|
||||
"spell_",
|
||||
"analysis_",
|
||||
"structural_",
|
||||
"refactor_",
|
||||
"bytes_",
|
||||
"window_",
|
||||
"window_group_",
|
||||
"term_",
|
||||
"settings_",
|
||||
"project_trust_",
|
||||
"ssh_",
|
||||
"git_credential",
|
||||
]
|
||||
.iter()
|
||||
.any(|p| cmd.starts_with(p))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
matches!(
|
||||
cmd,
|
||||
"app_info"
|
||||
| "app_metrics"
|
||||
| "app_quit"
|
||||
| "app_cancel_quit"
|
||||
| "app_restart"
|
||||
| "languages"
|
||||
| "menu_refresh"
|
||||
| "menu_model"
|
||||
| "menu_configure"
|
||||
| "menu_set_disabled"
|
||||
| "lsp_servers"
|
||||
| "lsp_resend_diagnostics"
|
||||
| "recent_projects"
|
||||
| "remove_recent_project"
|
||||
| "open_project"
|
||||
| "close_project"
|
||||
| "project_current"
|
||||
| "project_roots"
|
||||
| "index_status"
|
||||
| "jvm_file_presentations"
|
||||
| "deploy_coverage"
|
||||
| "find_files"
|
||||
| "find_symbols"
|
||||
| "mark_recent"
|
||||
| "remove_recent"
|
||||
| "recent_files"
|
||||
| "web_goto"
|
||||
| "path_completions"
|
||||
| "web_unresolved_references"
|
||||
| "project_rename"
|
||||
| "light_edit_open"
|
||||
| "workspace_create"
|
||||
| "workspace_update"
|
||||
| "workspace_attach"
|
||||
| "cancel_request"
|
||||
| "cancel_task"
|
||||
| "log_frontend"
|
||||
| "env_reload"
|
||||
| "remote_invoke"
|
||||
| "remote_current"
|
||||
| "remote_disconnect"
|
||||
| "remote_connect"
|
||||
| "remote_list"
|
||||
| "remote_open"
|
||||
| "remote_of"
|
||||
| "web_browsers"
|
||||
| "web_url"
|
||||
| "web_open"
|
||||
| "debug_attachable_processes"
|
||||
| "agent_subscribe"
|
||||
| "agent_entries"
|
||||
| "agent_commands"
|
||||
| "agent_unqueue"
|
||||
| "agent_cancel"
|
||||
| "agent_archive"
|
||||
| "agent_unarchive"
|
||||
| "agent_registry_list"
|
||||
| "agent_registry_install"
|
||||
| "agent_registry_remove"
|
||||
| "coverage_import"
|
||||
| "system_font_families"
|
||||
| "plugin_subscribe"
|
||||
| "plugin_list"
|
||||
| "plugin_set_active_file"
|
||||
| "plugin_set_theme"
|
||||
| "plugin_set_theme_frame"
|
||||
| "plugin_catalog"
|
||||
| "notebook_open"
|
||||
| "notebook_get"
|
||||
| "notebook_edit"
|
||||
| "notebook_save"
|
||||
| "notebook_undo"
|
||||
| "notebook_redo"
|
||||
| "notebook_reload"
|
||||
| "notebook_close"
|
||||
| "notebook_interrupt"
|
||||
| "run_stop"
|
||||
| "run_list"
|
||||
| "debug_stop"
|
||||
| "debug_sessions"
|
||||
| "vcs_clone"
|
||||
| "vcs_init"
|
||||
| "vcs_status"
|
||||
| "vcs_roots"
|
||||
| "vcs_branch_info"
|
||||
| "vcs_line_hunks"
|
||||
| "vcs_file_at"
|
||||
| "vcs_blame"
|
||||
| "vcs_log"
|
||||
| "vcs_commit_files"
|
||||
| "vcs_commits_changes"
|
||||
| "vcs_branches"
|
||||
| "vcs_stashes"
|
||||
| "vcs_conflict_versions"
|
||||
| "vcs_diff_text"
|
||||
| "vcs_diff_hunks"
|
||||
| "vcs_detected_git"
|
||||
| "vcs_config_get"
|
||||
| "vcs_tags"
|
||||
| "vcs_remotes"
|
||||
| "vcs_worktrees"
|
||||
| "vcs_diff_with_worktree"
|
||||
| "vcs_diff_revisions"
|
||||
| "vcs_merge_base"
|
||||
| "vcs_shelves"
|
||||
| "vcs_shelf_patch"
|
||||
| "vcs_changelists_load"
|
||||
) || cmd.starts_with("plugin:")
|
||||
}
|
||||
|
||||
impl crate::Host {
|
||||
pub fn authorize_command(&self, window: &str, command: &str) -> IdeResult<()> {
|
||||
if restricted_command_allowed(command) {
|
||||
return Ok(());
|
||||
}
|
||||
self.project(window)?.require_trust()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TrustStatus {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub trusted: bool,
|
||||
pub decided: bool,
|
||||
}
|
||||
impl TrustStore {
|
||||
pub fn status(&self, path: &Path) -> IdeResult<TrustStatus> {
|
||||
let root = dunce::canonicalize(path)?;
|
||||
let trusted = self.trusted(&root)?;
|
||||
let state = self.decisions()?;
|
||||
Ok(TrustStatus { decided: trusted || state.restricted.contains(&root), root: root.into(), trusted })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
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);
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
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");
|
||||
}
|
||||
@@ -100,6 +100,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
"postStartCommand": ["/bin/sh", "-c", "echo \"$FROM_PROJECT\" >> started.txt; echo post-start-output"],
|
||||
}"#,
|
||||
);
|
||||
host.trust.decide(&project, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
|
||||
assert_eq!(child_var("FROM_PROJECT"), "two-demo");
|
||||
assert!(child_var("PATH").starts_with("/project/bin:"), "{}", child_var("PATH"));
|
||||
@@ -158,6 +159,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// Reopening the project doesn't run postStartCommand again (once per server start).
|
||||
host.close_project("main");
|
||||
assert_eq!(child_var("FROM_PROJECT"), "", "remoteEnv leaves with the project");
|
||||
host.trust.decide(&project, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &project)).unwrap();
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
assert_eq!(std::fs::read_to_string(&started).unwrap(), "two-demo\n");
|
||||
@@ -165,6 +167,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// A failing postStartCommand is a notification; the project opens regardless.
|
||||
let failing = tmp.join("failing");
|
||||
write(&failing.join(".devcontainer.json"), r#"{ "postStartCommand": "echo oops >&2; exit 7" }"#);
|
||||
host.trust.decide(&failing, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &failing)).unwrap();
|
||||
wait_for("the failure notification", || {
|
||||
events.0.lock().iter().any(|e| {
|
||||
@@ -176,6 +179,7 @@ fn server_environment_from_login_shell_and_devcontainer() {
|
||||
// A broken file is a warning, not an error.
|
||||
let broken = tmp.join("broken");
|
||||
write(&broken.join(".devcontainer.json"), "{ \"remoteEnv\": ");
|
||||
host.trust.decide(&broken, true, false).unwrap();
|
||||
runtime.block_on(ide_host::ops::project::open(&host, "main", &broken)).unwrap();
|
||||
assert!(events.0.lock().iter().any(|e| matches!(e,
|
||||
IdeEvent::Notify { level: NotifyLevel::Warning, title, .. } if title == "devcontainer.json was not applied")));
|
||||
|
||||
@@ -70,17 +70,51 @@ async fn serves_projects_files_documents_search_and_runs() {
|
||||
let client = Arc::new(Client::default());
|
||||
let peer = Peer::connect(cr, cw, client.clone());
|
||||
|
||||
let info: ServerInfo = peer.request("initialize", json!({ "version": 1 }), None).await.unwrap();
|
||||
let info: ServerInfo = peer.request("initialize", json!({ "version": rpc::PROTOCOL_VERSION }), None).await.unwrap();
|
||||
assert_eq!(info.protocol, rpc::PROTOCOL_VERSION);
|
||||
|
||||
let opened = invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
||||
assert_eq!(opened["name"], "demo");
|
||||
assert_eq!(opened["trusted"], false);
|
||||
invoke(&peer, "index_status", json!({})).await.unwrap();
|
||||
assert_eq!(invoke(&peer, "lsp_servers", json!({})).await.unwrap(), json!([]));
|
||||
invoke(&peer, "lsp_resend_diagnostics", json!({})).await.unwrap();
|
||||
let files = invoke(&peer, "find_files", json!({"query": "main", "limit": 10})).await.unwrap();
|
||||
assert!(files.is_array());
|
||||
for command in ["build_sync", "run_start", "sdk_locate_tools", "notebook_execute", "debug_start"] {
|
||||
let error = invoke(&peer, command, json!({})).await.unwrap_err();
|
||||
assert!(error.message.contains("Restricted Mode"), "{command}: {error:?}");
|
||||
}
|
||||
let call = Invoke { window: "unknown".into(), cmd: "coverage_collect".into(), args: json!({}) };
|
||||
assert!(peer.request::<_, Value>("invoke", call, None).await.is_err());
|
||||
invoke(&peer, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
|
||||
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
|
||||
assert_eq!(current["name"], "demo");
|
||||
|
||||
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 +125,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 +212,133 @@ 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, "project_trust_set", json!({"path": project, "trusted": true, "folder": false})).await.unwrap();
|
||||
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();
|
||||
}
|
||||
@@ -21,6 +21,7 @@ async fn removed_detections_stay_removed_and_overrides_restore() {
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("package.json"), r#"{"scripts":{"dev":"node app.js","test":"node test.js"}}"#).unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let detected = ops::run::configs(&host, "main").unwrap();
|
||||
assert!(detected.len() >= 2);
|
||||
@@ -58,6 +59,7 @@ async fn gradle_defaults_are_not_saved_as_explicit_run_choices() {
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap();
|
||||
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
let host = host(&root.join("app"));
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
ops::project::set_settings(
|
||||
&host,
|
||||
@@ -77,3 +79,24 @@ 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.trust.decide(&workspace, true, false).unwrap();
|
||||
host.trust.decide(&member, true, false).unwrap();
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops, trust::TrustStore};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
fn host(base: &Path, environment: bool) -> Arc<Host> {
|
||||
Arc::new(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: environment,
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn persists_user_decisions_locations_revocation_and_one_time_migration() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("config");
|
||||
let data = tmp.path().join("data");
|
||||
let a = tmp.path().join("projects/a");
|
||||
let b = tmp.path().join("projects/b");
|
||||
std::fs::create_dir_all(a.join(".openidea")).unwrap();
|
||||
std::fs::create_dir_all(b.join(".openidea")).unwrap();
|
||||
std::fs::write(a.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap();
|
||||
std::fs::write(b.join(".openidea/project-trust.json"), r#"{"trusted":true}"#).unwrap();
|
||||
let recent = ide_workspace::RecentProjects::load(&config);
|
||||
recent.touch(&a, None).unwrap();
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(trust.trusted(&a).unwrap());
|
||||
assert!(!trust.status(&b).unwrap().decided);
|
||||
recent.touch(&b, None).unwrap();
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(!trust.trusted(&b).unwrap(), "migration is not repeated");
|
||||
trust.decide(&a, true, true).unwrap();
|
||||
assert!(trust.trusted(&b).unwrap());
|
||||
trust.decide(&b, false, false).unwrap();
|
||||
assert!(!trust.trusted(&b).unwrap(), "revocation overrides a trusted location");
|
||||
let trust = TrustStore::load(&config, &data);
|
||||
assert!(!trust.trusted(&b).unwrap());
|
||||
trust.remove_location(&dunce::canonicalize(a.parent().unwrap()).unwrap()).unwrap();
|
||||
assert!(trust.trusted(&a).unwrap());
|
||||
trust.decide(&a, false, false).unwrap();
|
||||
assert!(!TrustStore::load(&config, &data).trusted(&a).unwrap());
|
||||
std::fs::write(data.join("project-trust.json"), "broken").unwrap();
|
||||
assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn script(root: &Path, name: &str, marker: &str) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let path = root.join(name);
|
||||
std::fs::write(&path, format!("#!/bin/sh\nprintf executed > '{marker}'\n")).unwrap();
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn malicious_project_stays_editable_then_starts_in_place_after_trust() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("malicious");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
script(&root, "hook", "hook.marker");
|
||||
script(&root, "server", "lsp.marker");
|
||||
script(&root, "gradlew", "build.marker");
|
||||
std::fs::write(root.join("build.gradle"), "plugins { id 'java' }").unwrap();
|
||||
std::fs::write(root.join("main.py"), "print('safe')\n").unwrap();
|
||||
std::fs::write(root.join(".devcontainer.json"), r#"{"postStartCommand":["./hook"]}"#).unwrap();
|
||||
std::fs::write(root.join(".openidea/settings.toml"), format!(
|
||||
"[languageServers.trust-witness]\nlanguages = ['python']\ncommand = {:?}\n[languageServers.pyright]\nenabled = false\n", root.join("server").to_str().unwrap()
|
||||
)).unwrap();
|
||||
let host = host(&tmp.path().join("app"), true);
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert!(!project.is_trusted());
|
||||
let doc = ops::doc::open(&host, "main", root.join("main.py")).await.unwrap();
|
||||
ops::doc::save(&host, "main", doc.id).await.unwrap();
|
||||
assert!(project.index.wait_idle(Duration::from_secs(10)));
|
||||
assert!(project.index.status().files > 0);
|
||||
let sync = || {
|
||||
ops::build::build_sync(
|
||||
&host,
|
||||
"main",
|
||||
ide_build::BuildTool::Gradle,
|
||||
ide_core::RequestId(472),
|
||||
false,
|
||||
vec![],
|
||||
ops::Channel::new(|_| {}),
|
||||
)
|
||||
};
|
||||
assert!(sync().await.is_err(), "restricted build marker: {}", root.join("build.marker").exists());
|
||||
assert!(
|
||||
ops::sdk::sdk_locate_tools(&host, "missing-window", vec!["server".into()], Some(root.clone())).await.is_err()
|
||||
);
|
||||
for command in [
|
||||
"run_start",
|
||||
"debug_start",
|
||||
"notebook_execute",
|
||||
"coverage_collect",
|
||||
"profiler_collect",
|
||||
"docker_compose_op",
|
||||
"agent_prompt",
|
||||
"sdk_detect",
|
||||
"build_sync",
|
||||
] {
|
||||
assert!(host.authorize_command("main", command).is_err(), "{command}");
|
||||
assert!(host.authorize_command("missing-window", command).is_err(), "{command}");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
for marker in ["hook.marker", "lsp.marker", "build.marker"] {
|
||||
assert!(!root.join(marker).exists(), "{marker}");
|
||||
}
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
assert!(Arc::ptr_eq(&project, &host.project("main").unwrap()));
|
||||
assert_eq!(project.documents.snapshot(doc.id).unwrap().text, doc.text);
|
||||
sync().await.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !root.join("hook.marker").exists() || !root.join("lsp.marker").exists() {
|
||||
assert!(Instant::now() < deadline, "trusted services must start");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(root.join("build.marker").exists());
|
||||
host.set_project_trust(&root, false, false).await.unwrap();
|
||||
assert!(!project.is_trusted());
|
||||
assert!(sync().await.is_err());
|
||||
ops::doc::save(&host, "main", doc.id).await.unwrap();
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_terminal_keeps_user_input_but_ignores_project_startup_commands() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
let host = host(&tmp.path().join("app"), false);
|
||||
host.global_settings
|
||||
.set(
|
||||
ide_workspace::SettingsScope::Global,
|
||||
"terminal.env",
|
||||
serde_json::json!({"ZDOTDIR": root, "ENV": "/dev/null", "BASH_ENV": "/dev/null"}),
|
||||
)
|
||||
.unwrap();
|
||||
host.open_project("main", &root).unwrap();
|
||||
let terminal = ops::term::spawn(
|
||||
&host,
|
||||
"main",
|
||||
ide_terminal::TerminalOptions {
|
||||
cwd: root.clone().into(),
|
||||
shell: Some("/bin/sh".into()),
|
||||
args: vec!["-c".into(), "touch automatic.marker".into()],
|
||||
env: Default::default(),
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
shell_integration: false,
|
||||
},
|
||||
Arc::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
host.terminals.write(terminal.id, "printf user > terminal.marker; exit\n").unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while !root.join("terminal.marker").exists() {
|
||||
assert!(Instant::now() < deadline, "user terminal input must run");
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
assert!(!root.join("automatic.marker").exists());
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn trusted_locations_use_canonical_directory_boundaries() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("trusted/project");
|
||||
let other = tmp.path().join("trusted-other/project");
|
||||
std::fs::create_dir_all(&root).unwrap();
|
||||
std::fs::create_dir_all(&other).unwrap();
|
||||
std::os::unix::fs::symlink(other.parent().unwrap(), root.parent().unwrap().join("escape")).unwrap();
|
||||
let trust = TrustStore::load(&tmp.path().join("config"), &tmp.path().join("data"));
|
||||
trust.decide(&root, true, true).unwrap();
|
||||
assert!(!trust.trusted(&other).unwrap());
|
||||
let alias = root.parent().unwrap().join("escape/project");
|
||||
assert!(!trust.trusted(&alias).unwrap());
|
||||
assert_eq!(trust.status(&alias).unwrap().root.as_ref(), dunce::canonicalize(&other).unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_git_views_ignore_project_executables_and_mutations_cannot_run_hooks() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = dunce::canonicalize(tmp.path()).unwrap().join("project");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let git = |args: &[&str]| {
|
||||
let output = std::process::Command::new("git").current_dir(&root).args(args).output().unwrap();
|
||||
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
|
||||
};
|
||||
git(&["init", "-q"]);
|
||||
git(&["config", "user.name", "Test"]);
|
||||
git(&["config", "user.email", "[email protected]"]);
|
||||
git(&["config", "commit.gpgsign", "false"]);
|
||||
git(&["config", "core.hooksPath", ".git/hooks"]);
|
||||
let file = root.join("safe.txt");
|
||||
std::fs::write(&file, "safe\n").unwrap();
|
||||
script(&root, ".git/hooks/pre-commit", "git-hook.marker");
|
||||
let executable = root.join(".openidea/git-witness");
|
||||
std::fs::write(&executable, "#!/bin/sh\nprintf executed > git-executable.marker\nexec git \"$@\"\n").unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::write(
|
||||
root.join(".openidea/settings.toml"),
|
||||
format!("[vcs.git]\nexecutable = {:?}\n", executable.to_str().unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
let host = host(&tmp.path().join("app"), false);
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert!(!project.with_repo(|repo| repo.status()).unwrap().is_empty());
|
||||
assert!(ops::vcs::vcs_stage(&host, "main", vec![file.clone()]).await.is_err());
|
||||
assert!(!root.join("git-hook.marker").exists());
|
||||
assert!(!root.join("git-executable.marker").exists());
|
||||
host.set_project_trust(&root, true, false).await.unwrap();
|
||||
ops::vcs::vcs_stage(&host, "main", vec![file]).await.unwrap();
|
||||
project.with_repo(|repo| repo.commit("trusted", false, false)).unwrap();
|
||||
assert!(root.join("git-executable.marker").exists());
|
||||
assert!(root.join("git-hook.marker").exists());
|
||||
ide_vcs::set_git_executable(None);
|
||||
host.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn revocation_still_applies_when_a_project_folder_was_removed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
std::fs::create_dir(&root).unwrap();
|
||||
let host = host(&tmp.path().join("app"), false);
|
||||
host.trust.decide(&root, true, true).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
host.set_project_trust(&project.root, false, false).await.unwrap();
|
||||
assert!(!project.is_trusted());
|
||||
host.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//! The configured Git binary is process-wide, so it has its own test binary.
|
||||
#![cfg(unix)]
|
||||
use ide_core::NullSink;
|
||||
use ide_host::{Host, HostConfig, ops};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn restricted_status_does_not_select_the_project_git_for_other_windows() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = dunce::canonicalize(tmp.path()).unwrap();
|
||||
let host = Arc::new(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,
|
||||
}));
|
||||
let root = base.join("malicious");
|
||||
std::fs::create_dir_all(root.join(".openidea")).unwrap();
|
||||
let marker = base.join("git.marker");
|
||||
let git = root.join("git");
|
||||
std::fs::write(&git, format!("#!/bin/sh\nprintf executed > '{}'\n", marker.display())).unwrap();
|
||||
std::fs::set_permissions(&git, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::write(root.join(".openidea/settings.toml"), format!("[vcs.git]\nexecutable = {:?}\n", git)).unwrap();
|
||||
ide_vcs::init(&root).unwrap();
|
||||
|
||||
host.open_project("main", &root).unwrap();
|
||||
ops::vcs::status(&host, "main").await.unwrap();
|
||||
|
||||
let _ = ide_vcs::git_version(None);
|
||||
assert!(!marker.exists(), "another window's next Git command would run the project's binary");
|
||||
}
|
||||
@@ -35,6 +35,7 @@ async fn gbk_document_gutter_diff_stage_and_rollback_share_encoding() {
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if !project.repos().is_empty() {
|
||||
@@ -53,7 +54,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 +65,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 +96,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);
|
||||
|
||||
@@ -38,6 +38,7 @@ async fn patches_read_and_write_the_requested_repository() {
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
project_environment: false,
|
||||
});
|
||||
host.trust.decide(&project_root, true, false).unwrap();
|
||||
let project = host.open_project("main", &project_root).unwrap();
|
||||
for _ in 0..500 {
|
||||
if project.repos().all().len() == 2 {
|
||||
|
||||
@@ -30,6 +30,7 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di
|
||||
project_environment: false,
|
||||
});
|
||||
std::fs::write(root.join("internal-needle.txt"), "needle").unwrap();
|
||||
host.trust.decide(&root, true, false).unwrap();
|
||||
let project = host.open_project("main", &root).unwrap();
|
||||
assert_eq!(ops::project::info(&project).name, "Full stack");
|
||||
assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), root.as_path());
|
||||
|
||||
@@ -120,6 +120,9 @@ pub struct LanguageServerConfig {
|
||||
/// a JDK 21+); reported as the failure instead of launching it.
|
||||
#[serde(skip)]
|
||||
pub launch_error: Option<String>,
|
||||
/// Register source files with tsserver when no project configuration exists.
|
||||
#[serde(default)]
|
||||
pub tsserver_project: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -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
|
||||
@@ -19,6 +20,7 @@ parking_lot.workspace = true
|
||||
tracing.workspace = true
|
||||
futures.workspace = true
|
||||
globset.workspace = true
|
||||
ignore.workspace = true
|
||||
toml.workspace = true
|
||||
percent-encoding = "2"
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Give tsserver a project for unopened sources, without creating a config file.
|
||||
use crate::LspManager;
|
||||
use crate::server::{Running, Server};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
pub type ExcludesFn = dyn Fn() -> Vec<String> + Send + Sync;
|
||||
const COMMAND: &str = "typescript.tsserverRequest";
|
||||
const LIMIT: usize = 10_000;
|
||||
const SCAN_LIMIT: usize = 100_000;
|
||||
/// tsserver turns its language service off past 20 MiB of JavaScript in a project.
|
||||
const SCRIPT_BYTES_LIMIT: u64 = 16 << 20;
|
||||
const REFRESH_DELAY: Duration = Duration::from_millis(300);
|
||||
const TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct State {
|
||||
generation: u64,
|
||||
files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn supported(server: &Server, run: &Running) -> bool {
|
||||
server.config.tsserver_project
|
||||
&& run
|
||||
.caps
|
||||
.read()
|
||||
.raw
|
||||
.pointer("/executeCommandProvider/commands")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|commands| commands.iter().any(|c| c.as_str() == Some(COMMAND)))
|
||||
}
|
||||
|
||||
fn configured(dir: &Path) -> bool {
|
||||
["tsconfig.json", "jsconfig.json"].iter().any(|name| dir.join(name).is_file())
|
||||
}
|
||||
|
||||
fn is_script(ext: &str) -> bool {
|
||||
matches!(ext, "js" | "jsx" | "mjs" | "cjs")
|
||||
}
|
||||
|
||||
fn is_source(ext: &str) -> bool {
|
||||
is_script(ext) || matches!(ext, "ts" | "tsx" | "mts" | "cts")
|
||||
}
|
||||
|
||||
/// All-or-nothing at the caps: a partial project could displace an inferred
|
||||
/// project while omitting some of its files. Configured trees stay with tsserver.
|
||||
fn source_files(root: &Path, exclude_root: &Path, excludes: Vec<String>, limit: usize) -> Vec<PathBuf> {
|
||||
if root.ancestors().any(configured) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut ignores = ignore::gitignore::GitignoreBuilder::new(exclude_root);
|
||||
for pattern in excludes {
|
||||
if ignores.add_line(None, &pattern).is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
let Ok(ignores) = ignores.build() else { return Vec::new() };
|
||||
let mut walk = ignore::WalkBuilder::new(root);
|
||||
walk.require_git(false).follow_links(false).filter_entry(move |entry| {
|
||||
if entry.depth() == 0 {
|
||||
return true;
|
||||
}
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy();
|
||||
!matches!(name.as_ref(), "node_modules" | "dist" | "build" | "out" | "coverage" | "target" | ".git")
|
||||
&& !ignores.matched_path_or_any_parents(path, entry.file_type().is_some_and(|t| t.is_dir())).is_ignore()
|
||||
});
|
||||
let mut files = Vec::new();
|
||||
let mut script_bytes = 0;
|
||||
for (visited, entry) in walk.build().enumerate() {
|
||||
// Do not claim a tree is config-less if we could not inspect it fully.
|
||||
let Ok(entry) = entry else { return Vec::new() };
|
||||
if visited >= SCAN_LIMIT {
|
||||
return Vec::new();
|
||||
}
|
||||
if entry.file_type().is_some_and(|t| t.is_dir()) && configured(entry.path()) {
|
||||
return Vec::new();
|
||||
}
|
||||
let ext = entry.path().extension().and_then(|s| s.to_str()).unwrap_or_default();
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) && is_source(ext) {
|
||||
if is_script(ext) {
|
||||
script_bytes += entry.metadata().map_or(0, |m| m.len());
|
||||
if script_bytes > SCRIPT_BYTES_LIMIT {
|
||||
tracing::debug!("external TypeScript project exceeds its script size; retaining inferred projects");
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
files.push(entry.into_path());
|
||||
if files.len() > limit {
|
||||
tracing::debug!("external TypeScript project exceeds {limit} files; retaining inferred projects");
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
}
|
||||
files.sort();
|
||||
files
|
||||
}
|
||||
|
||||
fn project_name(server: &Server) -> PathBuf {
|
||||
server.root.join(".openidea-external-project")
|
||||
}
|
||||
|
||||
async fn request(run: &Running, command: &str, args: Value) -> bool {
|
||||
let request = run.peer.request::<_, Value>(
|
||||
"workspace/executeCommand",
|
||||
json!({
|
||||
"command": COMMAND,
|
||||
"arguments": [command, args, { "isAsync": false, "expectsResult": true }]
|
||||
}),
|
||||
None,
|
||||
);
|
||||
match tokio::time::timeout(TIMEOUT, request).await {
|
||||
Ok(Ok(result)) if result.get("success").and_then(Value::as_bool) == Some(true) => true,
|
||||
result => {
|
||||
tracing::debug!("tsserver {command}: {result:?}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
pub fn set_project_excludes_hook(&self, hook: Box<ExcludesFn>) {
|
||||
*self.excludes_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_external_project(&self, server: &Server, run: &Running) {
|
||||
if !supported(server, run) {
|
||||
return;
|
||||
}
|
||||
let mut state = server.external_project.lock().await;
|
||||
if state.generation != run.generation {
|
||||
*state = State { generation: run.generation, files: Vec::new() };
|
||||
server.external_diagnostics.store(false, Ordering::Release);
|
||||
}
|
||||
let root = server.root.clone();
|
||||
let exclude_root = self.project_root_for(&root);
|
||||
let hook = self.excludes_hook.read().clone();
|
||||
let files = tokio::task::spawn_blocking(move || {
|
||||
source_files(&root, &exclude_root, hook.map(|h| h()).unwrap_or_default(), LIMIT)
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
if state.files == files {
|
||||
return;
|
||||
}
|
||||
if files.is_empty() {
|
||||
if request(run, "closeExternalProject", json!({ "projectFileName": project_name(server) })).await {
|
||||
state.files.clear();
|
||||
// Keep suppressing delayed diagnostics for unopened files from this instance.
|
||||
}
|
||||
return;
|
||||
}
|
||||
server.external_diagnostics.store(true, Ordering::Release);
|
||||
let roots: Vec<_> = files.iter().map(|p| json!({ "fileName": p })).collect();
|
||||
if request(
|
||||
run,
|
||||
"openExternalProject",
|
||||
json!({
|
||||
"projectFileName": project_name(server), "rootFiles": roots,
|
||||
// What an inferred project assumes, so sharing a project changes no diagnostics.
|
||||
"options": { "allowJs": true, "target": "ES2022", "module": "ESNext",
|
||||
"moduleResolution": "Node", "allowNonTsExtensions": true, "jsx": "preserve",
|
||||
"allowSyntheticDefaultImports": true, "strictNullChecks": true,
|
||||
"strictFunctionTypes": true, "moduleDetection": "force" }
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::debug!(
|
||||
"{}: registered {} external project files at {}",
|
||||
server.name,
|
||||
files.len(),
|
||||
server.root.display()
|
||||
);
|
||||
state.files = files;
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-registers config-less projects after sources or configurations changed on disk.
|
||||
pub fn project_files_changed<'a>(&self, paths: impl IntoIterator<Item = &'a PathBuf>) {
|
||||
let relevant = paths.into_iter().any(|p| {
|
||||
p.extension().and_then(|s| s.to_str()).is_some_and(is_source)
|
||||
|| p.file_name().is_some_and(|n| n == "tsconfig.json" || n == "jsconfig.json")
|
||||
});
|
||||
if !relevant {
|
||||
return;
|
||||
}
|
||||
for server in self.servers.lock().iter().filter(|s| s.config.tsserver_project) {
|
||||
if server.external_refresh_queued.swap(true, Ordering::AcqRel) {
|
||||
continue;
|
||||
}
|
||||
let (me, server) = (self.me.clone(), server.clone());
|
||||
self.runtime.spawn(async move {
|
||||
tokio::time::sleep(REFRESH_DELAY).await;
|
||||
server.external_refresh_queued.store(false, Ordering::Release);
|
||||
if let (Some(me), Some(run)) = (me.upgrade(), server.running()) {
|
||||
me.refresh_external_project(&server, &run).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn close_external_project(&self, server: &Server, run: &Running) {
|
||||
let mut state = server.external_project.lock().await;
|
||||
if state.generation == run.generation && !state.files.is_empty() {
|
||||
request(run, "closeExternalProject", json!({ "projectFileName": project_name(server) })).await;
|
||||
state.files.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn write(root: &Path, path: &str) {
|
||||
let path = root.join(path);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, "").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selects_sources_honouring_ignores_exclusions_and_roots() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
for path in [
|
||||
"a.ts",
|
||||
"src/b.js",
|
||||
"src/c.tsx",
|
||||
"readme.md",
|
||||
"node_modules/n.ts",
|
||||
"dist/d.js",
|
||||
"excluded/e.ts",
|
||||
"ignored/i.ts",
|
||||
"other/f.py",
|
||||
] {
|
||||
write(root, path);
|
||||
}
|
||||
std::fs::write(root.join(".gitignore"), "ignored/\n").unwrap();
|
||||
assert_eq!(
|
||||
source_files(root, root, vec!["excluded".into()], LIMIT),
|
||||
vec![root.join("a.ts"), root.join("src/b.js"), root.join("src/c.tsx")]
|
||||
);
|
||||
assert!(source_files(root, root, vec![], 1).is_empty());
|
||||
std::fs::File::create(root.join("large.js")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
std::fs::remove_file(root.join("large.js")).unwrap();
|
||||
std::fs::File::create(root.join("large.ts")).unwrap().set_len(SCRIPT_BYTES_LIMIT + 1).unwrap();
|
||||
assert_eq!(source_files(root, root, vec!["excluded".into()], LIMIT).len(), 4);
|
||||
std::fs::remove_file(root.join("large.ts")).unwrap();
|
||||
assert_eq!(source_files(&root.join("src"), root, vec![], LIMIT).len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_ancestor_and_nested_configs_keep_existing_projects() {
|
||||
for name in ["tsconfig.json", "jsconfig.json"] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
write(root, "a.ts");
|
||||
write(root, "pkg/b.js");
|
||||
assert_eq!(source_files(root, root, vec![], LIMIT).len(), 2);
|
||||
write(root, &format!("pkg/{name}"));
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
std::fs::remove_file(root.join("pkg").join(name)).unwrap();
|
||||
write(root, name);
|
||||
assert!(source_files(root, root, vec![], LIMIT).is_empty());
|
||||
assert!(source_files(&root.join("pkg"), root, vec![], LIMIT).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn additions_deletions_and_renames_change_membership() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
write(dir.path(), "a.js");
|
||||
let first = source_files(dir.path(), dir.path(), vec![], LIMIT);
|
||||
write(dir.path(), "b.ts");
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT).len(), 2);
|
||||
std::fs::rename(dir.path().join("b.ts"), dir.path().join("b.txt")).unwrap();
|
||||
assert_eq!(source_files(dir.path(), dir.path(), vec![], LIMIT), first);
|
||||
std::fs::remove_file(dir.path().join("a.js")).unwrap();
|
||||
assert!(source_files(dir.path(), dir.path(), vec![], LIMIT).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,10 @@ impl ClientHandler {
|
||||
};
|
||||
let Some(path) = uri_to_path(params.uri.as_str()) else { return };
|
||||
// Files opened only for a request (references search) are not the user's problems.
|
||||
if self.server.suppresses_diagnostics(&path) {
|
||||
if (self.server.external_diagnostics.load(std::sync::atomic::Ordering::Acquire)
|
||||
&& !mgr.docs.lock().values().any(|d| d.path == path))
|
||||
|| self.server.suppresses_diagnostics(&path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let conv = mgr.conv(self.encoding());
|
||||
@@ -228,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
|
||||
|
||||
+203
-12
@@ -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")]
|
||||
@@ -71,6 +120,8 @@ pub struct JavaLaunchInfo {
|
||||
pub project_root: Option<ide_core::IpcPath>,
|
||||
pub class_paths: Vec<String>,
|
||||
pub module_paths: Vec<String>,
|
||||
/// Path list separator of the host that runs the JVM.
|
||||
pub path_list_separator: String,
|
||||
/// JDK home jdtls compiles the project against.
|
||||
pub java_home: Option<ide_core::IpcPath>,
|
||||
/// The `java` launcher to use (filled in by the caller from the installed JDKs).
|
||||
@@ -126,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"),
|
||||
}
|
||||
@@ -174,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)> {
|
||||
@@ -192,7 +289,7 @@ impl LspManager {
|
||||
ide_core::event::ServerStatus::Missing => {
|
||||
"jdtls is not installed (Settings › Languages & Frameworks › Language Servers)".to_owned()
|
||||
}
|
||||
ide_core::event::ServerStatus::Misconfigured => {
|
||||
ide_core::event::ServerStatus::Misconfigured | ide_core::event::ServerStatus::Crashed => {
|
||||
server.status_message().unwrap_or_else(|| "jdtls cannot start".to_owned())
|
||||
}
|
||||
_ => format!("jdtls is not ready ({status:?})"),
|
||||
@@ -209,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,
|
||||
@@ -220,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`.
|
||||
@@ -286,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)))
|
||||
}
|
||||
|
||||
@@ -351,6 +468,7 @@ impl LspManager {
|
||||
project_root: cp.project_root,
|
||||
class_paths: cp.class_paths,
|
||||
module_paths: cp.module_paths,
|
||||
path_list_separator: if cfg!(windows) { ";" } else { ":" }.into(),
|
||||
java_home: java_home.map(Into::into),
|
||||
java_exec: None,
|
||||
shorten_command_line: None,
|
||||
@@ -436,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,6 +9,7 @@
|
||||
mod angular;
|
||||
mod caps;
|
||||
mod convert;
|
||||
mod external_project;
|
||||
mod file_ops;
|
||||
mod handler;
|
||||
mod inspect;
|
||||
@@ -89,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,
|
||||
@@ -169,7 +172,9 @@ pub struct LspManager {
|
||||
prepare_hook: RwLock<Option<Arc<PrepareFn>>>,
|
||||
servers_hook: RwLock<Option<Arc<ServersFn>>>,
|
||||
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
|
||||
excludes_hook: RwLock<Option<Arc<external_project::ExcludesFn>>>,
|
||||
shutting_down: AtomicBool,
|
||||
execution_allowed: AtomicBool,
|
||||
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
|
||||
shown_messages: Mutex<HashSet<(String, String)>>,
|
||||
/// Virtual (non-`file:`) URIs a server returned, by the server that owns them.
|
||||
@@ -299,7 +304,9 @@ impl LspManager {
|
||||
prepare_hook: RwLock::new(None),
|
||||
servers_hook: RwLock::new(None),
|
||||
candidates_hook: RwLock::new(None),
|
||||
excludes_hook: RwLock::new(None),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
execution_allowed: AtomicBool::new(true),
|
||||
shown_messages: Mutex::new(HashSet::new()),
|
||||
virtual_origins: Mutex::new(HashMap::new()),
|
||||
virtual_docs: Mutex::new(HashMap::new()),
|
||||
@@ -367,6 +374,9 @@ impl LspManager {
|
||||
|
||||
/// The definitions of the servers `language` uses.
|
||||
fn language_servers(&self, language: &LanguageId) -> Vec<LanguageServerConfig> {
|
||||
if !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(cfg) = self.languages.get(language) else { return Vec::new() };
|
||||
match self.servers_hook.read().clone() {
|
||||
Some(hook) => hook(language, &cfg.language_servers),
|
||||
@@ -413,7 +423,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
fn ensure_started(&self, server: &Arc<Server>) {
|
||||
if self.shutting_down.load(Ordering::Acquire) {
|
||||
if self.shutting_down.load(Ordering::Acquire) || !self.execution_allowed.load(Ordering::Acquire) {
|
||||
return;
|
||||
}
|
||||
let Some(generation) = server.begin_start() else { return };
|
||||
@@ -431,7 +441,13 @@ impl LspManager {
|
||||
// The settings hook may touch the filesystem (e.g. scanning for JDKs); TCP
|
||||
// servers may take a while to start listening.
|
||||
let launched = tokio::task::spawn_blocking(move || {
|
||||
if !me.execution_allowed.load(Ordering::Acquire) {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
let mut cfg = me.configured(&tail);
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
tail.set_effective_config(cfg.clone());
|
||||
let prepare = me.prepare_hook.read().clone();
|
||||
if !cancel.is_cancelled()
|
||||
@@ -448,6 +464,9 @@ impl LspManager {
|
||||
let missing = !me.launcher.available(&cfg, &root);
|
||||
return Err(if missing { LaunchError::Missing } else { LaunchError::Config(message.clone()) });
|
||||
}
|
||||
if !me.execution_allowed.load(Ordering::Acquire) || cancel.is_cancelled() {
|
||||
return Err(LaunchError::Config("Project is in Restricted Mode".into()));
|
||||
}
|
||||
me.launcher.launch_cancellable(&cfg, &root, handler, Box::new(move |line| tail.push_stderr(line)), &cancel)
|
||||
})
|
||||
.await
|
||||
@@ -459,24 +478,37 @@ impl LspManager {
|
||||
let cfg = server.effective_config();
|
||||
let message =
|
||||
cfg.install_hint.clone().unwrap_or_else(|| format!("`{}` was not found on PATH", cfg.command));
|
||||
if server.finish_start(generation, Conn::Missing) {
|
||||
server.set_status(&self.sink, ide_core::event::ServerStatus::Missing, Some(message));
|
||||
if server.fail_start(
|
||||
generation,
|
||||
Conn::Missing,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Missing,
|
||||
Some(message),
|
||||
) {
|
||||
self.attach_fallbacks();
|
||||
}
|
||||
return;
|
||||
}
|
||||
Err(LaunchError::Config(message)) => {
|
||||
tracing::warn!("{}: {message}", server.name);
|
||||
if server.finish_start(generation, Conn::Failed) {
|
||||
server.set_status(&self.sink, ide_core::event::ServerStatus::Misconfigured, Some(message));
|
||||
}
|
||||
server.fail_start(
|
||||
generation,
|
||||
Conn::Failed,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Misconfigured,
|
||||
Some(message),
|
||||
);
|
||||
return;
|
||||
}
|
||||
Err(LaunchError::Failed(message)) => {
|
||||
tracing::warn!("{}: {message}", server.name);
|
||||
if server.finish_start(generation, Conn::Failed) {
|
||||
server.set_status(&self.sink, ide_core::event::ServerStatus::Crashed, Some(message));
|
||||
}
|
||||
server.fail_start(
|
||||
generation,
|
||||
Conn::Failed,
|
||||
&self.sink,
|
||||
ide_core::event::ServerStatus::Crashed,
|
||||
Some(message),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -517,6 +549,8 @@ impl LspManager {
|
||||
}
|
||||
let run = Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)) };
|
||||
|
||||
self.refresh_external_project(&server, &run).await;
|
||||
|
||||
// Publish readiness and (re)open attached documents atomically w.r.t. did_change.
|
||||
{
|
||||
let mut docs = self.docs.lock();
|
||||
@@ -559,6 +593,7 @@ impl LspManager {
|
||||
};
|
||||
self.clear_server_state(&server);
|
||||
if intentional {
|
||||
server.announce_down();
|
||||
return;
|
||||
}
|
||||
let tail = server.stderr_tail(5);
|
||||
@@ -575,6 +610,9 @@ impl LspManager {
|
||||
match server.record_crash() {
|
||||
Some(delay) => {
|
||||
server.set_status(&self.sink, ServerStatus::Crashed, Some(format!("{message}\nRestarting…")));
|
||||
// Woken after the crash is reported: a request that finds no instance
|
||||
// reads this status to say why. The restart sets `Starting` again.
|
||||
server.announce_down();
|
||||
tokio::time::sleep(delay).await;
|
||||
if server.is_idle_gen(generation) {
|
||||
self.ensure_started(&server);
|
||||
@@ -587,6 +625,8 @@ impl LspManager {
|
||||
ServerStatus::Crashed,
|
||||
Some(format!("{message}\nCrashed repeatedly; restart it manually.")),
|
||||
);
|
||||
// Requests still waiting for a start read this status for their error.
|
||||
server.announce_down();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -661,6 +701,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)))
|
||||
}
|
||||
@@ -706,8 +751,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,
|
||||
@@ -1975,6 +2022,10 @@ impl LspManager {
|
||||
/// The server's base configuration with the settings hook applied.
|
||||
fn configured(&self, server: &Server) -> LanguageServerConfig {
|
||||
let mut cfg = server.config.clone();
|
||||
if !self.execution_allowed.load(Ordering::Acquire) {
|
||||
cfg.launch_error = Some("Project is in Restricted Mode".into());
|
||||
return cfg;
|
||||
}
|
||||
if let Some(hook) = self.config_hook.read().clone() {
|
||||
hook(&mut cfg, &server.root);
|
||||
}
|
||||
@@ -1985,7 +2036,27 @@ impl LspManager {
|
||||
/// (e.g. a new project interpreter); stopped ones pick it up on their next start.
|
||||
/// A change of `settings` alone reaches a running server without a restart.
|
||||
/// Servers no longer enabled stop; newly enabled ones attach to open documents.
|
||||
pub fn set_execution_allowed(&self, allowed: bool) {
|
||||
let previous = self.execution_allowed.swap(allowed, Ordering::AcqRel);
|
||||
if !allowed {
|
||||
for server in self.servers.lock().iter() {
|
||||
server.start_token().cancel();
|
||||
}
|
||||
}
|
||||
if allowed && !previous {
|
||||
for server in self.servers.lock().iter() {
|
||||
server.reset_for_restart();
|
||||
self.ensure_started(server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn reconfigure(&self) {
|
||||
if !self.execution_allowed.load(Ordering::Acquire) {
|
||||
let servers = self.servers.lock().clone();
|
||||
join_all(servers.iter().map(|s| self.stop_instance(s, true))).await;
|
||||
return;
|
||||
}
|
||||
self.stop_unlisted().await;
|
||||
let Some(me) = self.me.upgrade() else { return };
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
@@ -2212,6 +2283,7 @@ impl LspManager {
|
||||
return;
|
||||
}
|
||||
let graceful = async {
|
||||
self.close_external_project(server, &run).await;
|
||||
peer.request::<_, Value>("shutdown", (), None).await.ok();
|
||||
peer.notify("exit", ()).ok();
|
||||
peer.closed().await;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -99,6 +99,9 @@ pub struct Server {
|
||||
/// Woken when the last `$/progress` task ends.
|
||||
idle_notify: Notify,
|
||||
transient: Mutex<HashMap<PathBuf, Transient>>,
|
||||
pub external_project: tokio::sync::Mutex<crate::external_project::State>,
|
||||
pub external_diagnostics: std::sync::atomic::AtomicBool,
|
||||
pub external_refresh_queued: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
@@ -123,6 +126,9 @@ impl Server {
|
||||
published_notify: Notify::new(),
|
||||
idle_notify: Notify::new(),
|
||||
transient: Mutex::new(HashMap::new()),
|
||||
external_project: tokio::sync::Mutex::new(Default::default()),
|
||||
external_diagnostics: std::sync::atomic::AtomicBool::new(false),
|
||||
external_refresh_queued: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,15 +168,27 @@ impl Server {
|
||||
Some(st.generation)
|
||||
}
|
||||
|
||||
/// Starting → Missing/Failed.
|
||||
pub fn finish_start(&self, generation: u64, conn: Conn) -> bool {
|
||||
let mut st = self.state.lock();
|
||||
if st.generation == generation && matches!(st.conn, Conn::Starting) {
|
||||
/// Starting → Missing/Failed, publishing the status the failed start reports
|
||||
/// before waiters wake: a request that wakes on `Phase::Down` reads the status
|
||||
/// to say why it has no server, and must not still see `Starting`.
|
||||
pub fn fail_start(
|
||||
&self,
|
||||
generation: u64,
|
||||
conn: Conn,
|
||||
sink: &SharedSink,
|
||||
status: ServerStatus,
|
||||
message: Option<String>,
|
||||
) -> bool {
|
||||
{
|
||||
let mut st = self.state.lock();
|
||||
if st.generation != generation || !matches!(st.conn, Conn::Starting) {
|
||||
return false;
|
||||
}
|
||||
st.conn = conn;
|
||||
self.phase.send_replace(Phase::Down);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
self.set_status(sink, status, message);
|
||||
self.announce_down();
|
||||
true
|
||||
}
|
||||
|
||||
/// Starting → Ready. False when the start was superseded or cancelled.
|
||||
@@ -191,6 +209,12 @@ impl Server {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wakes requests waiting for a start that ended without an instance, after
|
||||
/// its status is published.
|
||||
pub fn announce_down(&self) {
|
||||
self.phase.send_replace(Phase::Down);
|
||||
}
|
||||
|
||||
pub fn running(&self) -> Option<Running> {
|
||||
match &self.state.lock().conn {
|
||||
Conn::Ready(r) => Some(r.clone()),
|
||||
@@ -246,7 +270,8 @@ impl Server {
|
||||
}
|
||||
|
||||
/// Instance `generation`'s connection closed. `None` if superseded, else whether
|
||||
/// the close was intentional.
|
||||
/// the close was intentional. Waiters are woken by [`Self::announce_down`] once
|
||||
/// the crash is reported, not here: they read the status to say why.
|
||||
pub fn on_closed(&self, generation: u64) -> Option<bool> {
|
||||
let mut st = self.state.lock();
|
||||
if st.generation != generation {
|
||||
@@ -255,7 +280,6 @@ impl Server {
|
||||
match &st.conn {
|
||||
Conn::Ready(_) | Conn::Starting => {
|
||||
st.conn = Conn::Idle;
|
||||
self.phase.send_replace(Phase::Down);
|
||||
Some(false)
|
||||
}
|
||||
_ => Some(true),
|
||||
@@ -529,6 +553,36 @@ mod tests {
|
||||
assert_eq!(s.record_crash(), Some(Duration::from_millis(500)));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_failed_start_reports_its_status_before_waiters_wake() {
|
||||
// The race behind "jdtls is not ready (Starting)": a request woken by the
|
||||
// phase change reads the status to say why it has no server, so the status
|
||||
// must already be the failure's.
|
||||
let s = Arc::new(server());
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
let generation = s.begin_start().unwrap();
|
||||
let waiter = {
|
||||
let s = s.clone();
|
||||
tokio::spawn(async move {
|
||||
let run = s.wait_ready(Duration::from_secs(30)).await;
|
||||
(run.is_none(), s.status())
|
||||
})
|
||||
};
|
||||
tokio::task::yield_now().await;
|
||||
assert!(s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, Some("boom".into())));
|
||||
assert_eq!(waiter.await.unwrap(), (true, ServerStatus::Crashed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_superseded_start_is_not_announced() {
|
||||
let s = server();
|
||||
let generation = s.begin_start().unwrap();
|
||||
s.begin_stop(); // abandons the start and invalidates its generation
|
||||
let sink: SharedSink = Arc::new(|_: IdeEvent| {});
|
||||
assert!(!s.fail_start(generation, Conn::Failed, &sink, ServerStatus::Crashed, Some("boom".into())));
|
||||
assert_eq!(s.status(), ServerStatus::Stopped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crash_excerpt_keeps_the_exception_line() {
|
||||
// Node.js 24 crash of vue-language-server (issue #25), verbatim shape.
|
||||
|
||||
@@ -210,6 +210,7 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,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(),
|
||||
@@ -2258,3 +2260,67 @@ async fn rename_edits_files_outside_the_analysis_scope() {
|
||||
assert_eq!(paths, vec![a, b.clone()]);
|
||||
eventually(|| uris(&s.notified("textDocument/didClose")) == vec![uri_of(&b)]).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_projects_are_capability_gated_and_follow_lifecycle() {
|
||||
for capable in [false, true] {
|
||||
let mut caps = incremental_caps();
|
||||
caps["executeCommandProvider"] = json!({ "commands": if capable {
|
||||
vec!["typescript.tsserverRequest"]
|
||||
} else { vec!["_typescript.organizeImports"] } });
|
||||
let h = harness_configured("typescript", "ts", &[("arbitrary-name", caps)], |cfg| {
|
||||
cfg.tsserver_project = true;
|
||||
});
|
||||
let s = h.launcher.state("arbitrary-name");
|
||||
s.respond("workspace/executeCommand", json!({ "success": true, "body": true }));
|
||||
std::fs::write(h.root.join("unopened.ts"), "export const helper = 1;").unwrap();
|
||||
h.open("main.ts", "helper");
|
||||
h.wait_status("arbitrary-name", ServerStatus::Ready).await;
|
||||
if !capable {
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
h.mgr.shutdown_all().await;
|
||||
assert!(s.requested("workspace/executeCommand").is_empty());
|
||||
continue;
|
||||
}
|
||||
let requests = s.requested("workspace/executeCommand");
|
||||
assert_eq!(requests[0]["arguments"][0], "openExternalProject");
|
||||
assert_eq!(requests[0]["arguments"][1]["rootFiles"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(s.docs.lock().len(), 1, "registration must not open source documents");
|
||||
std::fs::write(h.root.join("new.ts"), "export const newHelper = 1;").unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("new.ts")]);
|
||||
eventually(|| s.requested("workspace/executeCommand").len() >= 2).await;
|
||||
assert_eq!(
|
||||
s.requested("workspace/executeCommand")[1]["arguments"][1]["rootFiles"].as_array().unwrap().len(),
|
||||
3
|
||||
);
|
||||
std::fs::rename(h.root.join("new.ts"), h.root.join("renamed.ts")).unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
|
||||
eventually(|| s.requested("workspace/executeCommand").len() >= 3).await;
|
||||
assert!(s.requested("workspace/executeCommand")[2].to_string().contains("renamed.ts"));
|
||||
std::fs::remove_file(h.root.join("renamed.ts")).unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("renamed.ts")]);
|
||||
eventually(|| s.requested("workspace/executeCommand").len() >= 4).await;
|
||||
let id = h.mgr.servers()[0].id;
|
||||
h.mgr.restart(id).await.unwrap();
|
||||
h.wait_status("arbitrary-name", ServerStatus::Ready).await;
|
||||
eventually(|| {
|
||||
s.requested("workspace/executeCommand")
|
||||
.iter()
|
||||
.filter(|r| r["arguments"][0] == "openExternalProject")
|
||||
.count()
|
||||
>= 5
|
||||
})
|
||||
.await;
|
||||
std::fs::write(h.root.join("tsconfig.json"), "{}").unwrap();
|
||||
h.mgr.project_files_changed([&h.root.join("tsconfig.json")]);
|
||||
eventually(|| {
|
||||
s.requested("workspace/executeCommand")
|
||||
.iter()
|
||||
.filter(|r| r["arguments"][0] == "closeExternalProject")
|
||||
.count()
|
||||
>= 2
|
||||
})
|
||||
.await;
|
||||
h.mgr.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,7 @@ async fn missing_binary_is_reported_with_install_hint() {
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
};
|
||||
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
|
||||
let path = dir.path().join("a.nope");
|
||||
@@ -247,6 +248,7 @@ async fn typescript_language_server_end_to_end() {
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
};
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
|
||||
let doc = DocId::next();
|
||||
@@ -292,6 +294,7 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
#![cfg(unix)]
|
||||
|
||||
use ide_core::event::ServerStatus;
|
||||
use ide_core::{DocId, IdeEvent, LanguageId};
|
||||
use ide_core::{CancellationToken, DocId, IdeEvent, LanguageId};
|
||||
use ide_language::{LanguageConfig, LanguageRegistry, LanguageServerConfig};
|
||||
use ide_lsp::{LspManager, ProcessLauncher};
|
||||
use parking_lot::Mutex;
|
||||
@@ -65,3 +65,40 @@ async fn crash_before_the_handshake_reports_what_the_server_printed() {
|
||||
assert!(message.contains(". . ."), "{message}");
|
||||
mgr.shutdown_all().await;
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_request_that_needs_a_crashed_jdtls_reports_why_it_crashed() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().to_path_buf();
|
||||
let script = root.join("fake-jdtls");
|
||||
std::fs::write(&script, LAUNCHER).unwrap();
|
||||
std::fs::set_permissions(&script, std::os::unix::fs::PermissionsExt::from_mode(0o755)).unwrap();
|
||||
|
||||
let server: LanguageServerConfig =
|
||||
toml::from_str(&format!("name = \"jdtls\"\ncommand = {:?}", script.to_string_lossy())).unwrap();
|
||||
let lang: LanguageConfig = toml::from_str("id = \"java\"\nname = \"Java\"\nextensions = [\"java\"]").unwrap();
|
||||
let lang = LanguageConfig { language_servers: vec![server], ..lang };
|
||||
let registry = Arc::new(LanguageRegistry::from_configs(vec![lang]));
|
||||
let events = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = {
|
||||
let events = events.clone();
|
||||
Arc::new(move |e: IdeEvent| events.lock().push(e))
|
||||
};
|
||||
let mgr = LspManager::with_launcher(root.clone(), registry, sink, Arc::new(ProcessLauncher));
|
||||
let path = root.join("A.java");
|
||||
std::fs::write(&path, "class A {}\n").unwrap();
|
||||
mgr.did_open(DocId::next(), &path, &LanguageId::new("java"), 1, "class A {}\n");
|
||||
for _ in 0..1000 {
|
||||
let crashed =
|
||||
events.lock().iter().any(|e| matches!(e, IdeEvent::LanguageServer { status: ServerStatus::Crashed, .. }));
|
||||
if crashed {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
|
||||
let error = mgr.java_classpath(&path, false, &CancellationToken::new()).await.unwrap_err();
|
||||
assert!(error.message.contains("jdtls exited with code 1"), "{}", error.message);
|
||||
assert!(error.message.contains("'python' is not recognized"), "{}", error.message);
|
||||
mgr.shutdown_all().await;
|
||||
}
|
||||
@@ -92,7 +92,10 @@ impl LoadedPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
type ProjectPolicy = Arc<dyn Fn(&Path) -> bool + Send + Sync>;
|
||||
|
||||
pub struct PluginHost {
|
||||
project_policy: RwLock<ProjectPolicy>,
|
||||
engine: PluginEngine,
|
||||
plugins_dir: PathBuf,
|
||||
sink: Arc<dyn PluginEventSink>,
|
||||
@@ -116,6 +119,7 @@ impl PluginHost {
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default();
|
||||
Ok(Self {
|
||||
project_policy: RwLock::new(Arc::new(|_| false)),
|
||||
engine: PluginEngine::new().map_err(IdeError::internal)?,
|
||||
plugins_dir,
|
||||
sink,
|
||||
@@ -159,7 +163,30 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
/// Loads new dev plugins from `<root>/.openidea/plugins/` and unloads vanished ones.
|
||||
pub fn set_project_policy(&self, policy: ProjectPolicy) {
|
||||
*self.project_policy.write() = policy;
|
||||
}
|
||||
|
||||
pub fn restrict_project(&self, root: &Path) {
|
||||
let _guard = self.lifecycle.lock();
|
||||
let ids: Vec<_> = self
|
||||
.plugins
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, p)| p.dev_root.as_deref() == Some(root))
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in ids {
|
||||
self.unload(&id);
|
||||
}
|
||||
self.emit(None, PluginEvent::CommandsChanged);
|
||||
}
|
||||
|
||||
pub fn sync_project(&self, root: &Path) {
|
||||
if !(self.project_policy.read())(root) {
|
||||
self.restrict_project(root);
|
||||
return;
|
||||
}
|
||||
let _guard = self.lifecycle.lock();
|
||||
let dirs = package_dirs(&ide_core::project_dir::path(root, "plugins"));
|
||||
let stale: Vec<String> = self
|
||||
@@ -448,7 +475,12 @@ impl PluginHost {
|
||||
// ---- internals ----
|
||||
|
||||
fn get(&self, id: &str) -> IdeResult<Arc<LoadedPlugin>> {
|
||||
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))
|
||||
let plugin =
|
||||
self.plugins.read().get(id).cloned().ok_or_else(|| IdeError::not_found(format!("no plugin {id:?}")))?;
|
||||
if plugin.dev_root.as_deref().is_some_and(|root| !(self.project_policy.read())(root)) {
|
||||
return Err(IdeError::unavailable("Project is in Restricted Mode"));
|
||||
}
|
||||
Ok(plugin)
|
||||
}
|
||||
|
||||
fn is_loaded_from(&self, dir: &Path) -> bool {
|
||||
|
||||
@@ -380,6 +380,7 @@ fn state_persists_across_restarts() {
|
||||
#[test]
|
||||
fn dev_plugins_follow_the_project() {
|
||||
let f = fixture(Limits::default());
|
||||
f.host.set_project_policy(Arc::new(|_| true));
|
||||
let dev = f.project.join(DEV_PLUGINS_DIR).join("toy");
|
||||
copy_dir(&sample("language-toy"), &dev);
|
||||
f.host.sync_project(&f.project);
|
||||
@@ -474,3 +475,38 @@ fn catalog_lists_sample_packages() {
|
||||
let x = catalog.iter().find(|c| c.id == "x").unwrap();
|
||||
assert!(x.problem.as_deref().unwrap().contains("plugin.wasm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_dev_plugin_has_no_activation_registration() {
|
||||
let f = fixture(Limits::default());
|
||||
let dev = f.project.join(DEV_PLUGINS_DIR).join("hello");
|
||||
copy_dir(&sample("hello"), &dev);
|
||||
let manifest = dev.join("plugin.toml");
|
||||
let text = std::fs::read_to_string(&manifest).unwrap();
|
||||
std::fs::write(&manifest, text.split("[[contributes.commands]]").next().unwrap()).unwrap();
|
||||
assert!(PluginManifest::load(&dev).unwrap().contributes.commands.is_empty());
|
||||
let trusted = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let policy = trusted.clone();
|
||||
f.host.set_project_policy(Arc::new(move |_| policy.load(std::sync::atomic::Ordering::Acquire)));
|
||||
f.host.sync_project(&f.project);
|
||||
let untrusted = f.host.list(Some(&f.project));
|
||||
assert!(
|
||||
!untrusted.plugins.iter().flat_map(|plugin| &plugin.commands).any(|command| command.id == "word-count"),
|
||||
"untrusted development plugin registered its activation witness"
|
||||
);
|
||||
assert!(untrusted.plugins.is_empty());
|
||||
assert!(f.host.info_of("hello").is_err());
|
||||
|
||||
// Positive control: activation itself registers these commands, not the manifest.
|
||||
trusted.store(true, std::sync::atomic::Ordering::Release);
|
||||
f.host.sync_project(&f.project);
|
||||
let info = f.host.info_of("hello").unwrap();
|
||||
assert_eq!(info.status, PluginStatus::Active);
|
||||
assert!(info.commands.iter().any(|c| c.id == "word-count"));
|
||||
trusted.store(false, std::sync::atomic::Ordering::Release);
|
||||
assert!(f.host.reload("hello").is_err());
|
||||
assert!(f.host.set_enabled("hello", true).is_err());
|
||||
assert!(f.run("hello", "word-count", None).is_err());
|
||||
f.host.sync_project(&f.project);
|
||||
assert!(f.host.list(Some(&f.project)).plugins.is_empty());
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Upload, download, delete and synchronise project files with a deployment
|
||||
//! server over SFTP.
|
||||
//! server through a shared filesystem interface.
|
||||
|
||||
use crate::mapping::{Excludes, Mappings, posix};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
@@ -10,6 +10,45 @@ use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Mapping eligibility uses a common synthetic server root: mapping and exclusion
|
||||
/// paths are both relative to that root, so checking never opens a connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeploymentCoverage {
|
||||
pub server: String,
|
||||
pub paths: Vec<String>,
|
||||
pub skipped: Vec<TransferError>,
|
||||
}
|
||||
|
||||
pub fn coverage(settings: &ide_core::settings::Settings, root: &Path, paths: &[PathBuf]) -> Vec<DeploymentCoverage> {
|
||||
settings
|
||||
.deployment
|
||||
.servers
|
||||
.iter()
|
||||
.map(|server| {
|
||||
let map = Mappings::new(server, root, "/");
|
||||
let exclude = Excludes::new(&settings.deployment, server, "/");
|
||||
let mut result = DeploymentCoverage { server: server.id.clone(), paths: Vec::new(), skipped: Vec::new() };
|
||||
for path in paths {
|
||||
let name = ide_core::ipc_path::to_ipc(path);
|
||||
match mapped_path(&map, &exclude, path) {
|
||||
Ok(_) => result.paths.push(name),
|
||||
Err(message) => result.skipped.push(TransferError { path: name, message: message.into() }),
|
||||
}
|
||||
}
|
||||
result
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn mapped_path(map: &Mappings, exclude: &Excludes, local: &Path) -> Result<String, &'static str> {
|
||||
let remote = map.remote(local).ok_or("Not covered by this server's mappings")?;
|
||||
if exclude.local(local.strip_prefix(map.project_root()).unwrap_or(local)) || exclude.remote(&remote) {
|
||||
return Err("Excluded from deployment");
|
||||
}
|
||||
Ok(remote)
|
||||
}
|
||||
|
||||
/// Modification times closer than this count as equal (FAT, rounding by servers).
|
||||
const MTIME_SLACK: u32 = 2;
|
||||
|
||||
@@ -96,7 +135,7 @@ pub fn status(local: Option<FileStamp>, remote: Option<FileStamp>) -> SyncStatus
|
||||
}
|
||||
}
|
||||
|
||||
/// Operations on one server through one SFTP session.
|
||||
/// Operations on one server through its filesystem.
|
||||
pub struct Deployment<'a> {
|
||||
pub fs: &'a RemoteFs,
|
||||
pub map: &'a Mappings,
|
||||
@@ -171,7 +210,7 @@ impl Deployment<'_> {
|
||||
break;
|
||||
}
|
||||
let rel = self.rel(local);
|
||||
let Some(remote) = self.map.remote(local).filter(|r| !self.exclude.remote(r)) else {
|
||||
let Some(remote) = mapped_path(self.map, self.exclude, local).ok() else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -193,7 +232,7 @@ impl Deployment<'_> {
|
||||
let mut report = TransferReport::default();
|
||||
let mut jobs: Vec<(String, PathBuf)> = Vec::new();
|
||||
for p in paths {
|
||||
let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r)) else {
|
||||
let Some(remote) = mapped_path(self.map, self.exclude, p).ok() else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -239,7 +278,7 @@ impl Deployment<'_> {
|
||||
let mut report = TransferReport::default();
|
||||
for p in paths {
|
||||
// Never a whole mapping (the project folder went away, or a mapped folder did).
|
||||
let Some(remote) = self.map.remote(p).filter(|r| !self.exclude.remote(r) && !self.map.is_root(r)) else {
|
||||
let Some(remote) = mapped_path(self.map, self.exclude, p).ok().filter(|r| !self.map.is_root(r)) else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
@@ -257,12 +296,12 @@ impl Deployment<'_> {
|
||||
|
||||
/// Sync with Deployed: local and remote files under `local` side by side.
|
||||
pub async fn compare(&self, local: &Path) -> IdeResult<Vec<SyncEntry>> {
|
||||
let remote_root =
|
||||
self.map.remote(local).ok_or_else(|| IdeError::invalid(format!("{} is not mapped", local.display())))?;
|
||||
let remote_root = mapped_path(self.map, self.exclude, local)
|
||||
.map_err(|reason| IdeError::invalid(format!("{}: {reason}", local.display())))?;
|
||||
let mut by_rel: BTreeMap<String, SyncEntry> = BTreeMap::new();
|
||||
let mut ignored = TransferReport::default();
|
||||
for f in self.local_files(&[local.to_path_buf()], &mut ignored) {
|
||||
let Some(remote_path) = self.map.remote(&f) else { continue };
|
||||
let Ok(remote_path) = mapped_path(self.map, self.exclude, &f) else { continue };
|
||||
let size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0);
|
||||
let stamp = FileStamp { size, mtime: mtime_of(&f) };
|
||||
by_rel.insert(
|
||||
@@ -319,9 +358,7 @@ impl Deployment<'_> {
|
||||
break;
|
||||
}
|
||||
let local = step.path.split('/').filter(|s| !s.is_empty()).fold(root.clone(), |p, s| p.join(s));
|
||||
let Some(remote) =
|
||||
self.map.remote(&local).filter(|r| !self.exclude.remote(r) && !self.excluded_local(&local))
|
||||
else {
|
||||
let Some(remote) = mapped_path(self.map, self.exclude, &local).ok() else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
|
||||
@@ -6,7 +6,9 @@ pub mod mapping;
|
||||
pub mod service;
|
||||
pub mod target;
|
||||
|
||||
pub use deploy::{Deployment, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport};
|
||||
pub use deploy::{
|
||||
Deployment, DeploymentCoverage, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport, coverage,
|
||||
};
|
||||
pub use mapping::{Excludes, Mappings};
|
||||
pub use service::{DeployedPath, RemoteService};
|
||||
pub use target::{RemoteRuns, RemoteStart};
|
||||
@@ -1,14 +1,14 @@
|
||||
//! The deployment and run-target operations of one app, over the shared SSH
|
||||
//! connections: what the IPC commands and the save hook call.
|
||||
//! Deployment over SFTP or local folders, and SSH run targets.
|
||||
//! Shared by IPC commands and project save/delete hooks.
|
||||
|
||||
use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport};
|
||||
use crate::mapping::{Excludes, Mappings};
|
||||
use crate::target::{RemoteRuns, RemoteStart, SyncFn};
|
||||
use ide_core::remote_settings::{AutoUpload, DeploymentServer, RunTarget};
|
||||
use ide_core::remote_settings::{AutoUpload, DeploymentServer, DeploymentType, RunTarget};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink};
|
||||
use ide_run::{OutputChunk, RunConfig, RunInfo};
|
||||
use ide_ssh::{RemoteEntry, RemoteFs, SshManager};
|
||||
use ide_ssh::{FsConnection, RemoteEntry, RemoteFs, SshManager};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
@@ -22,7 +22,7 @@ pub type Progress<'a> = &'a mut (dyn FnMut(&str, usize, usize) + Send);
|
||||
/// A remote file opened for editing (a local copy uploaded back on save).
|
||||
#[derive(Debug, Clone)]
|
||||
struct RemoteEdit {
|
||||
ssh_config: String,
|
||||
connection: FsConnection,
|
||||
remote: String,
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ pub struct DeployedPath {
|
||||
|
||||
pub struct RemoteService {
|
||||
ssh: Arc<SshManager>,
|
||||
/// Local copies of remote files: `<cache>/<ssh config>/<remote path>`.
|
||||
/// Local copies of remote files, separated by filesystem connection.
|
||||
cache: PathBuf,
|
||||
edits: Mutex<HashMap<PathBuf, RemoteEdit>>,
|
||||
pub runs: RemoteRuns,
|
||||
@@ -71,12 +71,24 @@ impl RemoteService {
|
||||
Self { ssh, cache, edits: Mutex::default(), runs: RemoteRuns::new() }
|
||||
}
|
||||
|
||||
async fn fs(&self, ssh_config: &str) -> IdeResult<RemoteFs> {
|
||||
if ssh_config.is_empty() {
|
||||
return Err(IdeError::invalid("the deployment server has no SSH configuration"));
|
||||
async fn fs(&self, connection: &FsConnection) -> IdeResult<RemoteFs> {
|
||||
match connection {
|
||||
FsConnection::Local => Ok(RemoteFs::Local),
|
||||
FsConnection::Sftp { ssh } => {
|
||||
if ssh.is_empty() {
|
||||
return Err(IdeError::invalid("the deployment server has no SSH configuration"));
|
||||
}
|
||||
let session = self.ssh.session(ssh).await?;
|
||||
Ok(RemoteFs::new(session.sftp().await?))
|
||||
}
|
||||
}
|
||||
let session = self.ssh.session(ssh_config).await?;
|
||||
Ok(RemoteFs::new(session.sftp().await?))
|
||||
}
|
||||
|
||||
pub async fn test_connection(&self, connection: &FsConnection, path: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let result = fs.test(path).await;
|
||||
fs.close().await;
|
||||
result
|
||||
}
|
||||
|
||||
/// Runs `f` with a [`Deployment`] on `server`.
|
||||
@@ -88,7 +100,7 @@ impl RemoteService {
|
||||
cancel: CancellationToken,
|
||||
f: impl AsyncFnOnce(&Deployment<'_>) -> T,
|
||||
) -> IdeResult<T> {
|
||||
let fs = self.fs(&server.ssh_config).await?;
|
||||
let fs = self.fs(&connection(server)).await?;
|
||||
let remote_root = match fs.resolve(&server.root_path).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
@@ -198,54 +210,57 @@ impl RemoteService {
|
||||
/// The server root of `server_id`, resolved on the host.
|
||||
pub async fn server_root(&self, settings: &Settings, server_id: &str) -> IdeResult<String> {
|
||||
let server = server(settings, Some(server_id))?;
|
||||
let fs = self.fs(&server.ssh_config).await?;
|
||||
let fs = self.fs(&connection(&server)).await?;
|
||||
let r = fs.resolve(&server.root_path).await;
|
||||
fs.close().await;
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn list(&self, ssh_config: &str, path: &str) -> IdeResult<Vec<RemoteEntry>> {
|
||||
let fs = self.fs(ssh_config).await?;
|
||||
pub async fn list(&self, connection: &FsConnection, path: &str) -> IdeResult<Vec<RemoteEntry>> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let r = async { fs.list(&fs.resolve(path).await?).await }.await;
|
||||
fs.close().await;
|
||||
r
|
||||
}
|
||||
|
||||
/// Downloads a remote file for editing; saving the copy uploads it back.
|
||||
pub async fn open_remote(&self, ssh_config: &str, remote: &str) -> IdeResult<PathBuf> {
|
||||
let rel = remote.split('/').filter(|s| !s.is_empty() && *s != "..").fold(PathBuf::new(), |p, s| p.join(s));
|
||||
let local = self.cache.join(sanitize(ssh_config)).join(rel);
|
||||
let fs = self.fs(ssh_config).await?;
|
||||
pub async fn open_remote(&self, connection: &FsConnection, remote: &str) -> IdeResult<PathBuf> {
|
||||
let rel = remote
|
||||
.split('/')
|
||||
.filter(|s| !s.is_empty() && *s != ".." && *s != ".")
|
||||
.fold(PathBuf::new(), |p, s| p.join(s.replace('%', "%25").replace(':', "%3A").replace('\\', "%5C")));
|
||||
let local = self.cache.join(sanitize(&cache_key(connection))).join(rel);
|
||||
let fs = self.fs(connection).await?;
|
||||
let r = fs.download(remote, &local).await;
|
||||
fs.close().await;
|
||||
r?;
|
||||
self.edits
|
||||
.lock()
|
||||
.insert(local.clone(), RemoteEdit { ssh_config: ssh_config.to_owned(), remote: remote.to_owned() });
|
||||
.insert(local.clone(), RemoteEdit { connection: connection.clone(), remote: remote.to_owned() });
|
||||
Ok(local)
|
||||
}
|
||||
|
||||
/// The remote file a local copy stands for.
|
||||
pub fn remote_of(&self, local: &Path) -> Option<(String, String)> {
|
||||
self.edits.lock().get(local).map(|e| (e.ssh_config.clone(), e.remote.clone()))
|
||||
pub fn remote_of(&self, local: &Path) -> Option<(FsConnection, String)> {
|
||||
self.edits.lock().get(local).map(|e| (e.connection.clone(), e.remote.clone()))
|
||||
}
|
||||
|
||||
pub async fn remote_delete(&self, ssh_config: &str, remote: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(ssh_config).await?;
|
||||
pub async fn remote_delete(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let r = fs.remove(remote).await;
|
||||
fs.close().await;
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn remote_mkdir(&self, ssh_config: &str, remote: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(ssh_config).await?;
|
||||
pub async fn remote_mkdir(&self, connection: &FsConnection, remote: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let r = fs.mkdir_all(remote).await;
|
||||
fs.close().await;
|
||||
r
|
||||
}
|
||||
|
||||
pub async fn remote_rename(&self, ssh_config: &str, from: &str, to: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(ssh_config).await?;
|
||||
pub async fn remote_rename(&self, connection: &FsConnection, from: &str, to: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(connection).await?;
|
||||
let r = fs.rename(from, to).await;
|
||||
fs.close().await;
|
||||
r
|
||||
@@ -366,3 +381,18 @@ impl RemoteService {
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect()
|
||||
}
|
||||
|
||||
/// A server's transport, also used by the browser and remote editor save hook.
|
||||
pub fn connection(server: &DeploymentServer) -> FsConnection {
|
||||
match server.kind {
|
||||
DeploymentType::Local => FsConnection::Local,
|
||||
DeploymentType::Sftp => FsConnection::Sftp { ssh: server.ssh_config.clone() },
|
||||
}
|
||||
}
|
||||
|
||||
fn cache_key(connection: &FsConnection) -> String {
|
||||
match connection {
|
||||
FsConnection::Local => "local".to_owned(),
|
||||
FsConnection::Sftp { ssh } => format!("sftp-{ssh}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
//! Local deployment uses the service entry points used by SFTP and save hooks.
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::remote_settings::{AutoUpload, DeploymentMapping, DeploymentServer, DeploymentType};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_remote::{RemoteService, SyncAction, SyncStatus, SyncStep};
|
||||
use ide_ssh::{FsConnection, SshFiles, SshManager};
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn service(root: &Path) -> RemoteService {
|
||||
RemoteService::new(
|
||||
Arc::new(SshManager::with_files(
|
||||
root.join("secrets"),
|
||||
SshFiles { known_hosts: root.join("known_hosts"), config: None },
|
||||
)),
|
||||
root.join("cache"),
|
||||
)
|
||||
}
|
||||
|
||||
fn settings(target: &Path) -> Settings {
|
||||
let mut s = Settings::default();
|
||||
s.deployment.servers.push(DeploymentServer {
|
||||
id: "local".into(),
|
||||
kind: DeploymentType::Local,
|
||||
root_path: target.to_string_lossy().into_owned(),
|
||||
mappings: vec![DeploymentMapping { deployment: "app".into(), ..Default::default() }],
|
||||
excluded_paths: vec!["app/private".into()],
|
||||
..Default::default()
|
||||
});
|
||||
s.deployment.default_server = "local".into();
|
||||
s.deployment.auto_upload = AutoUpload::Always;
|
||||
s.deployment.delete_remote = true;
|
||||
s
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_deployment_and_browser_share_all_paths() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let target = tmp.path().join("mounted");
|
||||
std::fs::create_dir_all(root.join("src/nested")).unwrap();
|
||||
std::fs::create_dir_all(root.join(".git")).unwrap();
|
||||
std::fs::create_dir_all(root.join("private")).unwrap();
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
std::fs::write(root.join("src/nested/a.txt"), "initial").unwrap();
|
||||
std::fs::write(root.join(".git/config"), "excluded").unwrap();
|
||||
std::fs::write(root.join("private/key"), "excluded remote path").unwrap();
|
||||
let service = service(tmp.path());
|
||||
let settings = settings(&target);
|
||||
let cancel = CancellationToken::new();
|
||||
service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await.unwrap();
|
||||
let report = service
|
||||
.upload(&settings, &root, None, std::slice::from_ref(&root), cancel.clone(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.files, 1, "{report:?}");
|
||||
assert!(report.errors.is_empty(), "{report:?}");
|
||||
assert!(!target.join("app/.git").exists());
|
||||
assert!(!target.join("app/private").exists());
|
||||
assert_eq!(
|
||||
ide_ssh::sftp::mtime_of(&root.join("src/nested/a.txt")),
|
||||
ide_ssh::sftp::mtime_of(&target.join("app/src/nested/a.txt"))
|
||||
);
|
||||
let compared = service.compare(&settings, &root, None, &root, cancel.clone()).await.unwrap();
|
||||
assert_eq!(compared.iter().find(|e| e.path == "src/nested/a.txt").unwrap().status, SyncStatus::Same);
|
||||
std::fs::write(root.join("src/nested/a.txt"), "saved").unwrap();
|
||||
let saved = service.on_saved(&settings, &root, &root.join("src/nested/a.txt")).await.unwrap().unwrap();
|
||||
assert!(saved.errors.is_empty());
|
||||
let remote_file = target.join("app/src/nested/a.txt");
|
||||
assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "saved");
|
||||
std::fs::write(&remote_file, "downloaded").unwrap();
|
||||
let report =
|
||||
service.download(&settings, &root, None, &[root.join("src")], cancel.clone(), &mut |_, _, _| {}).await.unwrap();
|
||||
assert!(report.errors.is_empty());
|
||||
assert_eq!(std::fs::read_to_string(root.join("src/nested/a.txt")).unwrap(), "downloaded");
|
||||
let connection = FsConnection::Local;
|
||||
let entries = service.list(&connection, target.join("app/src/nested").to_str().unwrap()).await.unwrap();
|
||||
assert_eq!(entries[0].name, "a.txt");
|
||||
let cached = service.open_remote(&connection, remote_file.to_str().unwrap()).await.unwrap();
|
||||
std::fs::write(&cached, "remote edit").unwrap();
|
||||
assert!(service.on_saved(&settings, &root, &cached).await.unwrap().unwrap().errors.is_empty());
|
||||
assert_eq!(std::fs::read_to_string(&remote_file).unwrap(), "remote edit");
|
||||
service.remote_mkdir(&connection, target.join("browser/new/nested").to_str().unwrap()).await.unwrap();
|
||||
service
|
||||
.remote_rename(&connection, target.join("browser").to_str().unwrap(), target.join("renamed").to_str().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
service.remote_delete(&connection, target.join("renamed").to_str().unwrap()).await.unwrap();
|
||||
assert!(!target.join("renamed").exists());
|
||||
let report = service
|
||||
.sync(
|
||||
&settings,
|
||||
&root,
|
||||
None,
|
||||
&[SyncStep { path: "src/nested/a.txt".into(), action: SyncAction::Download }],
|
||||
cancel,
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(report.files, 1);
|
||||
std::fs::remove_file(root.join("src/nested/a.txt")).unwrap();
|
||||
assert!(
|
||||
service
|
||||
.on_deleted(&settings, &root, &[root.join("src/nested/a.txt")])
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.errors
|
||||
.is_empty()
|
||||
);
|
||||
assert!(!remote_file.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn self_upload_download_and_aliases_never_destroy_content() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let service = service(root);
|
||||
let mut settings = settings(root);
|
||||
settings.deployment.servers[0].mappings[0].deployment.clear();
|
||||
let file = root.join("file");
|
||||
std::fs::write(&file, "keep me").unwrap();
|
||||
for download in [false, true] {
|
||||
let report = if download {
|
||||
service
|
||||
.download(
|
||||
&settings,
|
||||
root,
|
||||
None,
|
||||
std::slice::from_ref(&file),
|
||||
CancellationToken::new(),
|
||||
&mut |_, _, _| {},
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
service
|
||||
.upload(&settings, root, None, std::slice::from_ref(&file), CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
}
|
||||
.unwrap();
|
||||
assert!(report.errors[0].message.contains("same file"));
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me");
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let alias = root.join("alias");
|
||||
std::fs::hard_link(&file, &alias).unwrap();
|
||||
assert!(
|
||||
ide_ssh::RemoteFs::Local
|
||||
.upload(&file, alias.to_str().unwrap(), true)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("same file")
|
||||
);
|
||||
std::fs::remove_file(&alias).unwrap();
|
||||
std::os::unix::fs::symlink(&file, &alias).unwrap();
|
||||
assert!(
|
||||
ide_ssh::RemoteFs::Local
|
||||
.upload(&file, alias.to_str().unwrap(), true)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("same file")
|
||||
);
|
||||
ide_ssh::RemoteFs::Local.remove(alias.to_str().unwrap()).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "keep me");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn missing_non_directory_and_unwritable_targets_are_clear() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let service = service(tmp.path());
|
||||
let target = tmp.path().join("target");
|
||||
assert!(
|
||||
service
|
||||
.test_connection(&FsConnection::Local, target.to_str().unwrap())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("does not exist")
|
||||
);
|
||||
std::fs::write(&target, "file").unwrap();
|
||||
assert!(
|
||||
service
|
||||
.test_connection(&FsConnection::Local, target.to_str().unwrap())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.message
|
||||
.contains("Not a folder")
|
||||
);
|
||||
std::fs::remove_file(&target).unwrap();
|
||||
std::fs::create_dir(&target).unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o555)).unwrap();
|
||||
let result = service.test_connection(&FsConnection::Local, target.to_str().unwrap()).await;
|
||||
let file = tmp.path().join("source");
|
||||
std::fs::write(&file, "data").unwrap();
|
||||
let mut settings = settings(&target);
|
||||
settings.deployment.servers[0].mappings[0].deployment.clear();
|
||||
let report = service
|
||||
.upload(&settings, tmp.path(), None, &[file], CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
std::fs::set_permissions(&target, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
assert!(result.unwrap_err().message.contains("not writable"));
|
||||
assert_eq!(report.errors.len(), 1, "{report:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn coverage_matches_transfers_without_connecting() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().join("project");
|
||||
let target = tmp.path().join("target");
|
||||
for dir in ["src", "src-other", "assets", "src/private", "src/node_modules"] {
|
||||
std::fs::create_dir_all(root.join(dir)).unwrap();
|
||||
}
|
||||
let names = ["src/a.txt", "assets/b.txt", "src-other/c.txt", "src/private/key", "src/node_modules/pkg"];
|
||||
let paths: Vec<_> = names.iter().map(|name| root.join(name)).collect();
|
||||
for path in &paths {
|
||||
std::fs::write(path, "content").unwrap();
|
||||
}
|
||||
let mut settings = settings(&target);
|
||||
settings.deployment.exclude = vec!["node_modules".into()];
|
||||
settings.deployment.servers[0].mappings[0].local = "src".into();
|
||||
settings.deployment.servers[0].excluded_paths = vec!["app/private".into()];
|
||||
let mut assets = settings.deployment.servers[0].clone();
|
||||
assets.id = "assets".into();
|
||||
assets.mappings[0].local = "assets".into();
|
||||
let mut offline = assets.clone();
|
||||
offline.id = "offline".into();
|
||||
offline.kind = DeploymentType::Sftp;
|
||||
offline.ssh_config = "does-not-exist".into();
|
||||
settings.deployment.servers.extend([assets, offline]);
|
||||
let coverage = ide_remote::coverage(&settings, &root, &paths);
|
||||
assert_eq!(coverage[0].paths, vec![ide_core::ipc_path::to_ipc(&paths[0])]);
|
||||
assert_eq!(coverage[1].paths, vec![ide_core::ipc_path::to_ipc(&paths[1])]);
|
||||
assert_eq!(coverage[2].paths, coverage[1].paths);
|
||||
assert_eq!(coverage[0].skipped.len(), 4);
|
||||
assert!(coverage[0].skipped[0].message.contains("mappings"));
|
||||
assert!(coverage[0].skipped[2].message.contains("Excluded"));
|
||||
let service = service(tmp.path());
|
||||
let report = service
|
||||
.upload(&settings, &root, Some("local"), &paths, CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((report.files, report.skipped), (1, 4));
|
||||
let entries =
|
||||
service.compare(&settings, &root, Some("local"), &root.join("src"), CancellationToken::new()).await.unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
let report = service
|
||||
.download(&settings, &root, Some("local"), &paths, CancellationToken::new(), &mut |_, _, _| {})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!((report.files, report.skipped), (1, 4));
|
||||
let report = service.delete_remote(&settings, &root, Some("local"), &paths).await.unwrap();
|
||||
assert_eq!((report.files, report.skipped), (1, 4));
|
||||
settings.deployment.servers[0].mappings.clear();
|
||||
assert_eq!(ide_remote::coverage(&settings, &root, &[root.join("assets/b.txt")])[0].paths.len(), 1);
|
||||
assert!(ide_remote::coverage(&settings, &root, &[root.join("../outside")])[0].paths.is_empty());
|
||||
}
|
||||
@@ -7,6 +7,7 @@ license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
dunce.workspace = true
|
||||
encoding_rs.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
@@ -24,4 +25,5 @@ quick-xml = "0.42"
|
||||
libc = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
ide-build = { path = "../ide-build" }
|
||||
tempfile.workspace = true
|
||||
@@ -78,6 +78,7 @@ struct FileConfig {
|
||||
#[serde(default)]
|
||||
args: Vec<String>,
|
||||
cwd: Option<PathBuf>,
|
||||
module: Option<String>,
|
||||
#[serde(default)]
|
||||
env: BTreeMap<String, String>,
|
||||
#[serde(default, alias = "isTest", alias = "is_test")]
|
||||
@@ -161,6 +162,7 @@ fn convert(configs: Vec<FileConfig>, local: bool) -> Vec<RunConfig> {
|
||||
debug_adapter: c.debug_adapter.filter(|d| !d.is_empty()),
|
||||
allow_parallel: c.allow_parallel,
|
||||
extras: RunExtras {
|
||||
module: c.module,
|
||||
params: c.params,
|
||||
folder: non_empty(c.folder),
|
||||
local,
|
||||
@@ -210,6 +212,9 @@ fn to_table(c: &RunConfig) -> Table {
|
||||
if !c.args.is_empty() {
|
||||
t.insert("args", value(c.args.iter().collect::<Array>()));
|
||||
}
|
||||
if let Some(module) = &c.extras.module {
|
||||
t.insert("module", value(module));
|
||||
}
|
||||
if let Some(cwd) = &c.cwd {
|
||||
t.insert("cwd", value(cwd.to_string_lossy().replace('\\', "/")));
|
||||
}
|
||||
@@ -335,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) {
|
||||
@@ -347,7 +356,7 @@ fn ignore_local(root: &Path) -> IdeResult<()> {
|
||||
} else if !text.ends_with('\n') {
|
||||
text.push('\n');
|
||||
}
|
||||
text.push_str(&entry);
|
||||
text.push_str(entry);
|
||||
text.push('\n');
|
||||
if let Some(dir) = file.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
//!
|
||||
//! Detection never fails: unreadable or malformed files simply contribute nothing.
|
||||
|
||||
pub(crate) mod cmake;
|
||||
|
||||
use crate::RunConfig;
|
||||
use serde_json::Value as Json;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
@@ -22,6 +24,7 @@ pub(crate) fn detect_excluding(root: &Path, excluded_poms: &[PathBuf]) -> Vec<Ru
|
||||
detect_maven(root, excluded_poms, &mut out);
|
||||
detect_go(root, &mut out);
|
||||
detect_python(root, &mut out);
|
||||
cmake::detect(root, &mut out);
|
||||
detect_make(root, &mut out);
|
||||
detect_just(root, &mut out);
|
||||
detect_godot(root, &mut out);
|
||||
@@ -548,11 +551,11 @@ fn spring_boot_apps(root: &Path, build: &Path, out: &mut Vec<RunConfig>) {
|
||||
Some(m) => format!("application:{m}:{class}"),
|
||||
None => format!("application:{class}"),
|
||||
};
|
||||
out.push(
|
||||
config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class])
|
||||
.in_dir(module_rel.as_ref().map(PathBuf::from))
|
||||
.debug("java-debug"),
|
||||
);
|
||||
let mut app = config(id, simple, "spring-boot", "java", &["-cp", "${classpath}", &class])
|
||||
.in_dir(module_rel.as_ref().map(PathBuf::from))
|
||||
.debug("java-debug");
|
||||
app.extras.module = Some(module_rel.clone().unwrap_or_default());
|
||||
out.push(app);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,6 +683,19 @@ fn is_go_main_package(dir: &Path) -> bool {
|
||||
}
|
||||
|
||||
fn detect_go(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
if let Some(work) = read(&root.join("go.work")) {
|
||||
let mut seen = HashSet::new();
|
||||
for member in crate::go::work_modules(&work) {
|
||||
let dir = root.join(member);
|
||||
let dir = dir.canonicalize().unwrap_or(dir);
|
||||
if dir.join("go.mod").is_file() && seen.insert(dir.clone()) {
|
||||
let canonical_root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
|
||||
let rel = dir.strip_prefix(&canonical_root).unwrap_or(&dir).to_path_buf();
|
||||
detect_go_module(&dir, (!rel.as_os_str().is_empty()).then_some(rel), out);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if root.join("go.mod").is_file() {
|
||||
detect_go_module(root, None, out);
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
//! CMake targets come from the File API codemodel, including evaluated subdirectories.
|
||||
|
||||
use super::{ConfigExt, config};
|
||||
use crate::{BeforeLaunchTask, RunConfig};
|
||||
use serde_json::Value;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn json(path: &Path) -> Option<Value> {
|
||||
serde_json::from_slice(&fs::read(path).ok()?).ok()
|
||||
}
|
||||
|
||||
fn reply_file(reply: &Path, name: &str) -> Option<Value> {
|
||||
// File API references are file names, never paths outside the reply directory.
|
||||
(Path::new(name).components().count() == 1).then(|| json(&reply.join(name))).flatten()
|
||||
}
|
||||
|
||||
fn before(config: &mut RunConfig, id: &str) {
|
||||
config.extras.before_launch.push(BeforeLaunchTask { kind: "run".into(), target: id.into() });
|
||||
}
|
||||
|
||||
pub(super) fn detect(root: &Path, out: &mut Vec<RunConfig>) {
|
||||
if !root.join("CMakeLists.txt").is_file() {
|
||||
return;
|
||||
}
|
||||
let mut builds = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(root) {
|
||||
builds.extend(entries.flatten().filter_map(|e| {
|
||||
let p = e.path();
|
||||
(p.is_dir() && p.join("CMakeCache.txt").is_file()).then_some(p)
|
||||
}));
|
||||
}
|
||||
if builds.is_empty() {
|
||||
builds.push(root.join("build"));
|
||||
}
|
||||
builds.sort();
|
||||
builds.dedup();
|
||||
for build in builds {
|
||||
let dir = build.strip_prefix(root).unwrap_or(&build).to_string_lossy().replace('\\', "/");
|
||||
let configuration = fs::read_to_string(build.join("CMakeCache.txt"))
|
||||
.ok()
|
||||
.and_then(|cache| {
|
||||
let values = cache.lines().find_map(|line| line.strip_prefix("CMAKE_CONFIGURATION_TYPES:STRING="))?;
|
||||
Some(if values.split(';').any(|c| c == "Debug") {
|
||||
"Debug".to_owned()
|
||||
} else {
|
||||
values.split(';').find(|s| !s.is_empty())?.to_owned()
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| "Debug".into());
|
||||
let prefix = format!("cmake:{dir}");
|
||||
let configure_id = format!("{prefix}:configure");
|
||||
let build_id = format!("{prefix}:build");
|
||||
let mut args = vec!["-S", ".", "-B", &dir, "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"];
|
||||
if !build.join("CMakeCache.txt").is_file() {
|
||||
args.push("-DCMAKE_BUILD_TYPE=Debug");
|
||||
}
|
||||
let mut configure = config(&configure_id, format!("CMake Configure ({dir})"), "cmake", "cmake", &args);
|
||||
configure.extras.params.insert("buildDir".into(), dir.clone());
|
||||
out.push(configure);
|
||||
let mut all = config(
|
||||
&build_id,
|
||||
format!("CMake Build ({dir})"),
|
||||
"cmake",
|
||||
"cmake",
|
||||
&["--build", &dir, "--config", &configuration],
|
||||
);
|
||||
// `cmake --build` regenerates by itself once a build directory is configured.
|
||||
if !build.join("CMakeCache.txt").is_file() {
|
||||
before(&mut all, &configure_id);
|
||||
}
|
||||
out.push(all);
|
||||
let mut test = config(
|
||||
format!("{prefix}:test"),
|
||||
format!("CTest ({dir})"),
|
||||
"cmake",
|
||||
"ctest",
|
||||
&["--test-dir", &dir, "-C", &configuration, "--output-on-failure"],
|
||||
);
|
||||
before(&mut test, &build_id);
|
||||
out.push(test);
|
||||
targets(root, &build, &dir, &prefix, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn targets(root: &Path, build: &Path, dir: &str, prefix: &str, out: &mut Vec<RunConfig>) {
|
||||
let reply = build.join(".cmake/api/v1/reply");
|
||||
let Some(index_path) = fs::read_dir(&reply).ok().and_then(|entries| {
|
||||
entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| {
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy();
|
||||
name.starts_with("index-") && name.ends_with(".json")
|
||||
})
|
||||
.max()
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
let Some(index) = json(&index_path) else { return };
|
||||
let Some(reference) = index["objects"]
|
||||
.as_array()
|
||||
.and_then(|a| a.iter().find(|o| o["kind"] == "codemodel" && o["version"]["major"] == 2))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(model) = reference["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { return };
|
||||
let Some(configurations) = model["configurations"].as_array() else { return };
|
||||
let source_root = model["paths"]["source"].as_str().map(PathBuf::from).unwrap_or_else(|| root.into());
|
||||
if source_root.canonicalize().ok() != root.canonicalize().ok() {
|
||||
return;
|
||||
}
|
||||
let source_root = dunce::canonicalize(&source_root).unwrap_or(source_root);
|
||||
for configuration in configurations {
|
||||
let name = configuration["name"].as_str().unwrap_or("");
|
||||
let Some(targets) = configuration["targets"].as_array() else { continue };
|
||||
for target in targets {
|
||||
let Some(target) = target["jsonFile"].as_str().and_then(|n| reply_file(&reply, n)) else { continue };
|
||||
if target["type"] != "EXECUTABLE" {
|
||||
continue;
|
||||
}
|
||||
let (Some(target_name), Some(artifact)) =
|
||||
(target["name"].as_str(), target["artifacts"][0]["path"].as_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let id = format!("{prefix}:{name}:{target_name}");
|
||||
let target_build_id = format!("{id}:build");
|
||||
let mut args = vec!["--build", dir, "--target", target_name];
|
||||
if !name.is_empty() {
|
||||
args.extend(["--config", name]);
|
||||
}
|
||||
let build_config =
|
||||
config(&target_build_id, format!("Build {target_name} ({dir} {name})"), "cmake", "cmake", &args);
|
||||
out.push(build_config);
|
||||
let binary = build.join(artifact);
|
||||
let mut run =
|
||||
config(&id, format!("{target_name} ({dir} {name})"), "native", &binary.to_string_lossy(), &[])
|
||||
.debug("lldb-dap");
|
||||
before(&mut run, &target_build_id);
|
||||
let sources: Vec<String> = target["sources"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|s| {
|
||||
let p = source_root.join(s["path"].as_str()?);
|
||||
Some(p.to_string_lossy().replace('\\', "/"))
|
||||
})
|
||||
.collect();
|
||||
run.extras.params.insert("cmakeSources".into(), sources.join("\n"));
|
||||
if let Some(recorded) = model["paths"]["source"].as_str() {
|
||||
run.extras.params.insert("cmakeSourceRoot".into(), recorded.into());
|
||||
run.extras
|
||||
.params
|
||||
.insert("cmakeCanonicalSourceRoot".into(), source_root.to_string_lossy().replace('\\', "/"));
|
||||
}
|
||||
out.push(run);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Request the codemodel only when the user runs a configure configuration.
|
||||
pub(crate) fn prepare(config: &RunConfig, cwd: &Path) -> crate::IdeResult<()> {
|
||||
if config.kind == "cmake"
|
||||
&& config.args.first().is_some_and(|a| a == "-S")
|
||||
&& let Some(dir) = config.extras.params.get("buildDir")
|
||||
{
|
||||
let query = cwd.join(dir).join(".cmake/api/v1/query/client-openidea");
|
||||
fs::create_dir_all(&query)?;
|
||||
fs::write(query.join("codemodel-v2"), "")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
//! Typed Go configurations use the process service for both build and execution.
|
||||
use crate::RunConfig;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
pub struct Plan {
|
||||
pub build: RunConfig,
|
||||
pub run: Option<RunConfig>,
|
||||
}
|
||||
|
||||
pub fn plan(root: &Path, config: &RunConfig) -> IdeResult<Option<Plan>> {
|
||||
if config.kind != "go" || !config.extras.params.contains_key("goKind") {
|
||||
return Ok(None);
|
||||
}
|
||||
let p = &config.extras.params;
|
||||
let get = |key: &str| p.get(key).map(String::as_str).unwrap_or("");
|
||||
let module = root.join(if get("module").is_empty() { "." } else { get("module") });
|
||||
let mut build = config.clone();
|
||||
build.cwd = Some(module.clone().into());
|
||||
let test = get("goKind") == "test";
|
||||
if get("scope") == "directory" {
|
||||
let target = module.join(get("target"));
|
||||
build.cwd = Some(target.into());
|
||||
if let Some(arg) = build.args.iter_mut().skip(1).find(|a| a.as_str() == get("target")) {
|
||||
*arg = ".".into();
|
||||
}
|
||||
}
|
||||
if test {
|
||||
return Ok(Some(Plan { build, run: None }));
|
||||
}
|
||||
let output = if get("outputDir").is_empty() {
|
||||
crate::config::ignore_private(root, "/bin/")?;
|
||||
ide_core::project_dir::path(root, "bin")
|
||||
} else {
|
||||
root.join(get("outputDir"))
|
||||
};
|
||||
std::fs::create_dir_all(&output)?;
|
||||
let mut hash = std::collections::hash_map::DefaultHasher::new();
|
||||
config.id.hash(&mut hash);
|
||||
let mut binary = output.join(format!("go-app-{:x}{}", hash.finish(), std::env::consts::EXE_SUFFIX));
|
||||
// An explicit Go -o selects the executable just as it does on the command line.
|
||||
let mut i = 1;
|
||||
while i < build.args.len() {
|
||||
if build.args[i] == "-o" {
|
||||
if let Some(path) = build.args.get(i + 1) {
|
||||
binary = module.join(path);
|
||||
}
|
||||
build.args.drain(i..(i + 2).min(build.args.len()));
|
||||
} else if let Some(path) = build.args[i].strip_prefix("-o=") {
|
||||
binary = module.join(path);
|
||||
build.args.remove(i);
|
||||
} else {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
if binary.is_dir() || binary.to_string_lossy().ends_with(['/', '\\']) {
|
||||
let target = Path::new(get("target").split('|').next().unwrap_or("."));
|
||||
let name = target.file_stem().or_else(|| module.file_name()).unwrap_or_default().to_string_lossy();
|
||||
binary = binary.join(format!("{name}{}", std::env::consts::EXE_SUFFIX));
|
||||
}
|
||||
if let Some(dir) = binary.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
build.args.splice(1..1, ["-o".into(), binary.to_string_lossy().into_owned()]);
|
||||
let run = if get("runAfterBuild") == "true" {
|
||||
let mut run = config.clone();
|
||||
run.command = binary.to_string_lossy().into_owned();
|
||||
// Program args are stored separately as an argv vector by the form.
|
||||
run.args = p
|
||||
.get("programArgv")
|
||||
.map(|s| serde_json::from_str(s))
|
||||
.transpose()
|
||||
.map_err(|e| IdeError::invalid(e.to_string()))?
|
||||
.unwrap_or_default();
|
||||
run.extras.params.clear();
|
||||
Some(run)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Some(Plan { build, run }))
|
||||
}
|
||||
|
||||
/// Uses Go's own build-file selection, including platform suffixes, cgo and build tags.
|
||||
/// `-find` avoids dependency traversal and package downloads for this local directory.
|
||||
pub fn package_sources(mut command: Command, dir: &Path, flags: &[String]) -> IdeResult<Vec<PathBuf>> {
|
||||
command.args(["list", "-find", "-e", "-json"]);
|
||||
let mut it = flags.iter();
|
||||
while let Some(arg) = it.next() {
|
||||
let name = arg.split('=').next().unwrap_or(arg);
|
||||
if ["-tags", "-compiler", "-mod", "-modfile", "-overlay"].contains(&name) {
|
||||
command.arg(arg);
|
||||
if name == arg
|
||||
&& let Some(value) = it.next()
|
||||
{
|
||||
command.arg(value);
|
||||
}
|
||||
} else if ["-race", "-msan", "-asan"].contains(&name) {
|
||||
command.arg(arg);
|
||||
}
|
||||
}
|
||||
command.arg(".").current_dir(dir).stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
let mut child = command.spawn()?;
|
||||
let stdout = child.stdout.take();
|
||||
let stderr = child.stderr.take();
|
||||
let reader = std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut text = String::new();
|
||||
if let Some(mut stdout) = stdout {
|
||||
let _ = stdout.read_to_string(&mut text);
|
||||
}
|
||||
text
|
||||
});
|
||||
let errors = std::thread::spawn(move || {
|
||||
use std::io::Read;
|
||||
let mut text = String::new();
|
||||
if let Some(mut stderr) = stderr {
|
||||
let _ = stderr.read_to_string(&mut text);
|
||||
}
|
||||
text
|
||||
});
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
let status = loop {
|
||||
match child.try_wait()? {
|
||||
Some(status) => break status,
|
||||
None if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)),
|
||||
None => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(IdeError::unavailable("Go package discovery timed out"));
|
||||
}
|
||||
}
|
||||
};
|
||||
let text = reader.join().unwrap_or_default();
|
||||
let errors = errors.join().unwrap_or_default();
|
||||
if !status.success() {
|
||||
return Err(IdeError::invalid(format!("Go package discovery failed: {errors}")));
|
||||
}
|
||||
let package: serde_json::Value = serde_json::from_str(&text).map_err(|e| IdeError::invalid(e.to_string()))?;
|
||||
Ok(["GoFiles", "CgoFiles"]
|
||||
.iter()
|
||||
.flat_map(|key| package[key].as_array().into_iter().flatten())
|
||||
.filter_map(|p| p.as_str())
|
||||
.map(|p| dir.join(p))
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub fn prepare_test_file(root: &Path, config: &mut RunConfig) -> IdeResult<()> {
|
||||
let p = &config.extras.params;
|
||||
if config.kind != "go" || p.get("goKind").is_none_or(|s| s != "test") || p.get("scope").is_none_or(|s| s != "file")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let target = p.get("target").ok_or_else(|| IdeError::invalid("Go test file target is missing"))?;
|
||||
let file = root.join(p.get("module").map(String::as_str).unwrap_or(".")).join(target);
|
||||
let dir = file.parent().ok_or_else(|| IdeError::invalid("Go test file has no directory"))?;
|
||||
let mut command = Command::new(&config.command);
|
||||
crate::process::configure_environment(&mut command, root, config)?;
|
||||
let mut files = package_sources(command, dir, &config.args)?;
|
||||
files.push(file);
|
||||
let at = config
|
||||
.args
|
||||
.iter()
|
||||
.position(|a| a == target)
|
||||
.ok_or_else(|| IdeError::invalid("Go test file target is missing"))?;
|
||||
config.args.splice(at..=at, files.iter().map(|p| p.to_string_lossy().into_owned()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Module directories from a go.work file. Quoted paths and comments follow Go's file syntax.
|
||||
pub fn work_modules(text: &str) -> Vec<PathBuf> {
|
||||
let mut tokens = Vec::new();
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
match ch {
|
||||
'\n' | '(' | ')' => tokens.push(ch.to_string()),
|
||||
ch if ch.is_whitespace() => {}
|
||||
'/' if chars.peek() == Some(&'/') => {
|
||||
for c in chars.by_ref() {
|
||||
if c == '\n' {
|
||||
tokens.push("\n".into());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
'"' | '`' => {
|
||||
let Some(token) = quoted_work_path(&mut chars, ch) else { return Vec::new() };
|
||||
tokens.push(token);
|
||||
}
|
||||
_ => {
|
||||
let mut token = ch.to_string();
|
||||
while chars.peek().is_some_and(|c| !c.is_whitespace() && *c != '(' && *c != ')') {
|
||||
token.push(chars.next().unwrap());
|
||||
}
|
||||
tokens.push(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
let mut start = true;
|
||||
let mut tokens = tokens.into_iter();
|
||||
while let Some(token) = tokens.next() {
|
||||
if token == "\n" {
|
||||
start = true;
|
||||
continue;
|
||||
}
|
||||
if start
|
||||
&& token == "use"
|
||||
&& let Some(path) = tokens.next()
|
||||
{
|
||||
if path == "(" {
|
||||
let mut paths = Vec::new();
|
||||
let mut closed = false;
|
||||
for path in tokens.by_ref() {
|
||||
if path == ")" {
|
||||
closed = true;
|
||||
break;
|
||||
}
|
||||
if path != "\n" && !path.is_empty() {
|
||||
paths.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
if closed {
|
||||
out.extend(paths);
|
||||
}
|
||||
} else if path != "\n" && !path.is_empty() {
|
||||
out.push(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
start = false;
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
out.retain(|path| seen.insert(path.clone()));
|
||||
out
|
||||
}
|
||||
|
||||
fn quoted_work_path(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, quote: char) -> Option<String> {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == quote {
|
||||
return String::from_utf8(bytes).ok();
|
||||
}
|
||||
if ch == '\\' && quote == '"' {
|
||||
let escaped = chars.next()?;
|
||||
let byte = match escaped {
|
||||
'a' => 7,
|
||||
'b' => 8,
|
||||
'f' => 12,
|
||||
'n' => 10,
|
||||
'r' => 13,
|
||||
't' => 9,
|
||||
'v' => 11,
|
||||
'\\' => b'\\',
|
||||
'"' => b'"',
|
||||
'x' => {
|
||||
let digits: String = chars.by_ref().take(2).collect();
|
||||
if digits.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(&digits, 16).ok()?
|
||||
}
|
||||
'u' | 'U' => {
|
||||
let width = if escaped == 'u' { 4 } else { 8 };
|
||||
let digits: String = chars.by_ref().take(width).collect();
|
||||
if digits.len() != width {
|
||||
return None;
|
||||
}
|
||||
let ch = char::from_u32(u32::from_str_radix(&digits, 16).ok()?)?;
|
||||
bytes.extend_from_slice(ch.to_string().as_bytes());
|
||||
continue;
|
||||
}
|
||||
'0'..='7' => {
|
||||
let digits = format!("{escaped}{}", chars.by_ref().take(2).collect::<String>());
|
||||
if digits.len() != 3 {
|
||||
return None;
|
||||
}
|
||||
u8::from_str_radix(&digits, 8).ok()?
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
bytes.push(byte);
|
||||
} else {
|
||||
if ch == '\n' && quote == '"' {
|
||||
return None;
|
||||
}
|
||||
bytes.extend_from_slice(ch.to_string().as_bytes());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
mod config;
|
||||
mod detect;
|
||||
pub mod envfile;
|
||||
pub mod go;
|
||||
pub mod gradle_jvm;
|
||||
pub mod java_cmdline;
|
||||
pub mod junit;
|
||||
@@ -70,6 +71,10 @@ fn is_true(b: &bool) -> bool {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RunExtras {
|
||||
/// Module supplying a JVM application's classpath; empty means the project root.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub module: Option<String>,
|
||||
/// Values of the configuration type's own form fields (the command line derives from them).
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
#[specta(optional)]
|
||||
@@ -137,6 +142,7 @@ pub struct RunExtras {
|
||||
impl Default for RunExtras {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
module: None,
|
||||
params: BTreeMap::new(),
|
||||
folder: None,
|
||||
local: false,
|
||||
@@ -235,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)
|
||||
|
||||
+116
-10
@@ -46,10 +46,11 @@ struct StopCommand {
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
project_root: PathBuf,
|
||||
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 +66,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 +300,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,8 +334,11 @@ 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);
|
||||
return self.start_pty(root, config, &inv, &cwd, &env, stop, options, sink, on_output, temp);
|
||||
}
|
||||
|
||||
let mut cmd = Command::new(&inv.program);
|
||||
@@ -374,10 +385,11 @@ impl ProcessManager {
|
||||
_ => None,
|
||||
};
|
||||
let entry = Arc::new(Entry {
|
||||
project_root: root.to_path_buf(),
|
||||
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 +411,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 +422,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)));
|
||||
})
|
||||
@@ -427,6 +446,7 @@ impl ProcessManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn start_pty(
|
||||
&self,
|
||||
root: &Path,
|
||||
config: &RunConfig,
|
||||
inv: &Invocation,
|
||||
cwd: &Path,
|
||||
@@ -476,10 +496,11 @@ impl ProcessManager {
|
||||
|
||||
let id = RunId::next();
|
||||
let entry = Arc::new(Entry {
|
||||
project_root: root.to_path_buf(),
|
||||
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 +599,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(());
|
||||
@@ -622,10 +644,21 @@ impl ProcessManager {
|
||||
}
|
||||
|
||||
/// Kills every running process tree at once (shutting down).
|
||||
pub fn kill_project(&self, root: &Path) {
|
||||
for entry in self.runs.lock().values().filter(|e| e.project_root == root) {
|
||||
if !entry.exited.load(Ordering::Acquire)
|
||||
&& let Some(pid) = *entry.pid.lock()
|
||||
{
|
||||
kill_tree(pid, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 +674,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 +755,18 @@ fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
|
||||
Ok(LaunchEnv { clear: !extras.include_system_env, vars })
|
||||
}
|
||||
|
||||
pub(crate) fn resolved_env(root: &Path, config: &RunConfig) -> IdeResult<BTreeMap<String, String>> {
|
||||
let env = launch_env(root, config)?;
|
||||
let mut vars = if env.clear { BTreeMap::new() } else { crate::system_env() };
|
||||
if !env.clear {
|
||||
vars.extend(
|
||||
ide_core::process::shell_env().filter_map(|(k, v)| Some((k.into_string().ok()?, v.into_string().ok()?))),
|
||||
);
|
||||
}
|
||||
vars.extend(env.vars);
|
||||
Ok(vars)
|
||||
}
|
||||
|
||||
fn apply_env(cmd: &mut Command, env: &LaunchEnv) {
|
||||
if env.clear {
|
||||
cmd.env_clear();
|
||||
|
||||
@@ -216,7 +216,7 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
|
||||
.map_err(|_| invalid("Build the WAR artifact before launching Tomcat"))?;
|
||||
let war = PathBuf::from(war.trim());
|
||||
if !war.is_file() || !war.extension().is_some_and(|e| e.eq_ignore_ascii_case("war")) {
|
||||
return Err(invalid("The built WAR artifact does not exist"));
|
||||
return Err(invalid(format!("The built WAR artifact does not exist: {}", war.display())));
|
||||
}
|
||||
let dest = base.join("deployment");
|
||||
explode(&war, &dest, resources_only)?;
|
||||
@@ -227,11 +227,14 @@ pub fn deployment(root: &Path, base: &Path, config: &RunConfig, resources_only:
|
||||
.get("deploymentPath")
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.ok_or_else(|| invalid("Choose a WAR file or exploded directory"))?;
|
||||
let path = root.join(value).canonicalize()?;
|
||||
let path = root.join(value);
|
||||
let path = path
|
||||
.canonicalize()
|
||||
.map_err(|e| invalid(format!("Invalid Tomcat deployment: {} ({e})", path.display())))?;
|
||||
if (kind == "war" && (!path.is_file() || !path.extension().is_some_and(|e| e.eq_ignore_ascii_case("war"))))
|
||||
|| (kind == "directory" && !path.is_dir())
|
||||
{
|
||||
return Err(invalid("Invalid Tomcat deployment"));
|
||||
return Err(invalid(format!("Invalid Tomcat deployment: {}", path.display())));
|
||||
}
|
||||
Ok(path)
|
||||
}
|
||||
@@ -436,6 +439,28 @@ mod tests {
|
||||
assert!(deployment(tmp.path(), &base, &c, false).unwrap().ends_with("WebContent"));
|
||||
}
|
||||
#[test]
|
||||
fn deployment_errors_identify_the_checked_path() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let base = tmp.path().join("cache");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
for path in ["missing.war", r"C:\工作\timesheet\target\timesheet.war", r"\\server\share\timesheet.war"] {
|
||||
fs::write(base.join("artifact-path.txt"), format!("{path}\r\n")).unwrap();
|
||||
let error = deployment(tmp.path(), &base, &config(), false).unwrap_err().to_string();
|
||||
assert!(error.contains(path), "{error}");
|
||||
}
|
||||
for kind in ["war", "directory"] {
|
||||
let mut c = config();
|
||||
c.extras.params.insert("deployment".into(), kind.into());
|
||||
c.extras.params.insert("deploymentPath".into(), "missing".into());
|
||||
let error = deployment(tmp.path(), &base, &c, false).unwrap_err().to_string();
|
||||
assert!(error.contains(&tmp.path().join("missing").display().to_string()), "{error}");
|
||||
c.extras.params.insert("deploymentPath".into(), "cache".into());
|
||||
if kind == "war" {
|
||||
assert!(deployment(tmp.path(), &base, &c, false).unwrap_err().to_string().contains("cache"));
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn prepares_isolated_base_and_jdwp_only_for_run() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let home = tmp.path().join("Tomcat Home");
|
||||
|
||||
@@ -234,3 +234,31 @@ fn suppression_round_trip_and_resaving() {
|
||||
assert_eq!(load_suppressed_config_ids(dir.path()).unwrap(), vec![ids[1].clone()]);
|
||||
assert!(fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap().contains("/run.local.toml"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn java_module_is_optional_and_independent_of_working_directory() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = user_configs_path(dir.path());
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(
|
||||
&path,
|
||||
r#"[[configurations]]
|
||||
id = "user:main"
|
||||
name = "Main"
|
||||
kind = "application"
|
||||
command = "java"
|
||||
args = ["-cp", "${classpath}", "a.Main"]
|
||||
cwd = "sibling"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let mut configs = load_user_configs(dir.path()).unwrap();
|
||||
assert_eq!(configs[0].extras.module, None);
|
||||
for module in ["app", ""] {
|
||||
configs[0].extras.module = Some(module.into());
|
||||
save_user_configs(dir.path(), &configs).unwrap();
|
||||
let loaded = load_user_configs(dir.path()).unwrap();
|
||||
assert_eq!(loaded, configs);
|
||||
assert_eq!(loaded[0].cwd.as_deref(), Some(std::path::Path::new("sibling")));
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,7 @@ fn builds_nested_in_a_folder_of_repositories() {
|
||||
assert_eq!(app.kind, "spring-boot");
|
||||
assert_eq!(cmdline(app), "java -cp ${classpath} org.dromara.DromaraApplication");
|
||||
assert_eq!(app.cwd.as_deref(), Some(Path::new("RuoYi-Vue-Plus/ruoyi-admin")));
|
||||
assert_eq!(app.extras.module.as_deref(), Some("RuoYi-Vue-Plus/ruoyi-admin"));
|
||||
assert_eq!(app.debug_adapter.as_deref(), Some("java-debug"));
|
||||
let package = find(&c, "maven:package:RuoYi-Vue-Plus");
|
||||
assert_eq!(package.name, "RuoYi-Vue-Plus: mvn package");
|
||||
@@ -549,3 +550,90 @@ fn root_package_disables_nested_scan() {
|
||||
assert!(ids(&c).contains(&"go:test"));
|
||||
assert!(!ids(&c).contains(&"go:test:svc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_detection_is_read_only_and_preserves_existing_build_directories() {
|
||||
let dir = project(&[
|
||||
("CMakeLists.txt", "project(demo)"),
|
||||
("cmake-build-debug/CMakeCache.txt", "CMAKE_CONFIGURATION_TYPES:STRING=Release;RelWithDebInfo\n"),
|
||||
]);
|
||||
let configs = detect(dir.path());
|
||||
let configure = find(&configs, "cmake:cmake-build-debug:configure");
|
||||
assert_eq!(configure.args, ["-S", ".", "-B", "cmake-build-debug", "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON"]);
|
||||
assert!(!dir.path().join("build").exists());
|
||||
assert!(!dir.path().join("cmake-build-debug/.cmake").exists());
|
||||
let build = find(&configs, "cmake:cmake-build-debug:build");
|
||||
assert!(build.extras.before_launch.is_empty(), "a configured build regenerates by itself");
|
||||
assert!(build.args.windows(2).any(|a| a == ["--config", "Release"]));
|
||||
let test = find(&configs, "cmake:cmake-build-debug:test");
|
||||
assert_eq!(test.extras.before_launch[0].target, build.id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_configures_before_the_first_build() {
|
||||
let dir = project(&[("CMakeLists.txt", "project(demo)")]);
|
||||
let configs = detect(dir.path());
|
||||
let build = find(&configs, "cmake:build:build");
|
||||
assert_eq!(build.extras.before_launch[0].target, "cmake:build:configure");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cmake_ignores_malformed_file_api_replies() {
|
||||
let dir = project(&[
|
||||
("CMakeLists.txt", "project(demo)"),
|
||||
("build/CMakeCache.txt", ""),
|
||||
("build/.cmake/api/v1/reply/index-1.json", "not JSON"),
|
||||
]);
|
||||
let configs = detect(dir.path());
|
||||
assert_eq!(configs.iter().filter(|c| c.kind == "cmake").count(), 3);
|
||||
assert!(!configs.iter().any(|c| c.kind == "native"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_work_members_define_configs_including_deep_and_quoted_directories() {
|
||||
let dir = project(&[
|
||||
(
|
||||
"go.work",
|
||||
"go 1.22\n// use ./ignored\nuse (\n ./apps/deep/server // member\n \"./module with spaces\"\n)\nuse ./apps/deep/server\n",
|
||||
),
|
||||
("apps/deep/server/go.mod", "module example.com/server\ngo 1.22\n"),
|
||||
("apps/deep/server/main.go", "package main\nfunc main() {}"),
|
||||
("module with spaces/go.mod", "module example.com/lib\ngo 1.22\n"),
|
||||
("ignored/go.mod", "module example.com/ignored\ngo 1.22\n"),
|
||||
]);
|
||||
let configs = detect(dir.path());
|
||||
assert_eq!(configs.iter().filter(|c| c.kind == "go" && c.is_test).count(), 2);
|
||||
assert_eq!(find(&configs, "go:run:apps/deep/server").cwd.as_deref(), Some(Path::new("apps/deep/server")));
|
||||
assert!(configs.iter().all(|c| !c.id.contains("ignored")));
|
||||
assert!(configs.iter().any(|c| c.id == "go:test:module with spaces"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_work_parser_ignores_other_directives_comments_and_malformed_groups() {
|
||||
use ide_run::go::work_modules;
|
||||
assert_eq!(
|
||||
work_modules("replace use => ./not-a-member\n// use ./also-not\nuse ./real\n"),
|
||||
vec![std::path::PathBuf::from("./real")]
|
||||
);
|
||||
assert!(work_modules("use (\n./missing-close\n").is_empty());
|
||||
assert!(work_modules("use \"unterminated").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_work_paths_decode_go_string_escapes() {
|
||||
use ide_run::go::work_modules;
|
||||
for path in [r#""./\u0061pp""#, r#""./\x61pp""#, r#""./\141pp""#, "`./app`"] {
|
||||
assert_eq!(work_modules(&format!("use {path}\n")), vec![std::path::PathBuf::from("./app")]);
|
||||
}
|
||||
assert!(work_modules(r#"use "./\qinvalid""#).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_default_build_output_is_ignored_by_version_control() {
|
||||
let dir = project(&[("go.mod", "module x\ngo 1.22\n"), ("main.go", "package main\nfunc main() {}")]);
|
||||
let mut config = detect(dir.path()).into_iter().find(|c| c.kind == "go" && !c.is_test).unwrap();
|
||||
config.extras.params.insert("goKind".into(), "package".into());
|
||||
assert!(ide_run::go::plan(dir.path(), &config).unwrap().is_some());
|
||||
let ignore = fs::read_to_string(dir.path().join(".openidea/.gitignore")).unwrap();
|
||||
assert!(ignore.lines().any(|l| l == "/bin/"), "{ignore}");
|
||||
}
|
||||
@@ -291,6 +291,10 @@ fn env_files_system_env_and_redirects() {
|
||||
config.extras.include_system_env = false;
|
||||
config.extras.stdin_file = Some("input.txt".into());
|
||||
config.extras.output_file = Some("logs/out.log".into());
|
||||
let prepared = ide_run::resolved_env(root, &config).unwrap();
|
||||
assert_eq!(prepared.get("FROM_FILE").map(String::as_str), Some("file"));
|
||||
assert_eq!(prepared.get("OVERRIDDEN").map(String::as_str), Some("config"));
|
||||
assert!(!prepared.contains_key("HOME"));
|
||||
let (id, chunks) = start(&h, &config);
|
||||
// stdin comes from the file: writing to it is refused.
|
||||
assert!(h.pm.write_stdin(id, "x").is_err());
|
||||
@@ -302,6 +306,7 @@ fn env_files_system_env_and_redirects() {
|
||||
|
||||
// With the system environment, HOME is inherited.
|
||||
config.extras.include_system_env = true;
|
||||
assert!(ide_run::resolved_env(root, &config).unwrap().contains_key("HOME"));
|
||||
config.extras.stdin_file = None;
|
||||
config.extras.output_file = None;
|
||||
config.args = vec!["-c".into(), "printf '%s' \"${HOME:+set}\"".into()];
|
||||
@@ -468,3 +473,133 @@ fn python_scripts_import_sibling_packages_and_preserve_env_file_paths() {
|
||||
assert_eq!(text(&chunks, stdout), "import OK vendor OK\n");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revoking_one_project_stops_only_its_runs() {
|
||||
let h = harness();
|
||||
let other = tempfile::tempdir().unwrap();
|
||||
let (mine, _) = start(&h, &cfg("mine", "/bin/sh", &["-c", "sleep 30"]));
|
||||
let theirs =
|
||||
h.pm.start(
|
||||
other.path(),
|
||||
&cfg("theirs", "/bin/sh", &["-c", "sleep 30"]),
|
||||
&StartOptions::default(),
|
||||
h.sink.clone(),
|
||||
Box::new(|_| {}),
|
||||
)
|
||||
.unwrap();
|
||||
h.pm.kill_project(h.root.path());
|
||||
wait_exit(&h, mine, Duration::from_secs(5));
|
||||
assert!(h.pm.list().iter().any(|run| run.id == theirs.id && run.running));
|
||||
h.pm.stop(theirs.id).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires CMake, CTest and a C++ compiler"]
|
||||
fn cmake_configure_discovers_builds_and_runs_a_multifile_target() {
|
||||
let h = harness();
|
||||
std::fs::write(h.root.path().join("CMakeLists.txt"), "cmake_minimum_required(VERSION 3.20)\nproject(smoke LANGUAGES CXX)\nadd_executable(smoke main.cpp math.cpp)\nenable_testing()\nadd_test(NAME smoke COMMAND smoke)\n").unwrap();
|
||||
std::fs::write(h.root.path().join("main.cpp"), "int add(int,int); int main() { return add(2,3)==5 ? 0 : 1; }\n")
|
||||
.unwrap();
|
||||
std::fs::write(h.root.path().join("math.cpp"), "int add(int a,int b) { return a+b; }\n").unwrap();
|
||||
let initial = ide_run::detect(h.root.path());
|
||||
assert!(!h.root.path().join("build").exists(), "detection must not modify the project");
|
||||
let configure = initial.iter().find(|c| c.id.ends_with(":configure")).unwrap();
|
||||
let (id, output) = start(&h, configure);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&output, stderr));
|
||||
assert!(h.root.path().join("build/.cmake/api/v1/query/client-openidea/codemodel-v2").exists());
|
||||
let root = h.root.path().canonicalize().unwrap();
|
||||
let configs = ide_run::detect(&root);
|
||||
let target = configs.iter().find(|c| c.kind == "native").expect("File API executable");
|
||||
assert_eq!(target.debug_adapter.as_deref(), Some("lldb-dap"));
|
||||
let sources = &target.extras.params["cmakeSources"];
|
||||
assert!(sources.lines().any(|s| s.ends_with("/main.cpp")));
|
||||
assert!(sources.lines().any(|s| s.ends_with("/math.cpp")));
|
||||
assert!(sources.lines().any(|s| s == root.join("main.cpp").to_string_lossy()));
|
||||
assert_eq!(target.extras.params["cmakeCanonicalSourceRoot"], root.to_string_lossy());
|
||||
let build = configs.iter().find(|c| c.id == target.extras.before_launch[0].target).unwrap();
|
||||
let (id, output) = start(&h, build);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&output, stderr));
|
||||
let (id, _) = start(&h, target);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(0));
|
||||
let ctest = configs.iter().find(|c| c.command == "ctest").unwrap();
|
||||
let (id, output) = start(&h, ctest);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(10)), Some(0), "{}", text(&output, stdout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_build_runs_with_separate_arguments_environment_and_working_directory() {
|
||||
let h = harness();
|
||||
let Some(go) = ide_run::which::find_executable("go", &std::env::var_os("PATH").unwrap_or_default()) else {
|
||||
eprintln!("skipped: no Go toolchain");
|
||||
return;
|
||||
};
|
||||
let module = h.root.path().join("module");
|
||||
std::fs::create_dir_all(&module).unwrap();
|
||||
std::fs::write(module.join("go.mod"), "module example.com/demo\ngo 1.22\n").unwrap();
|
||||
std::fs::write(module.join("main.go"), "//go:build demo\n\npackage main\nimport (\"fmt\"; \"os\")\nfunc main(){cwd,_:=os.Getwd();fmt.Printf(\"%q %s %s\\n\",os.Args[1:],os.Getenv(\"GO_RUN_VALUE\"),cwd)}\n").unwrap();
|
||||
let mut c = cfg("go-config", &go.to_string_lossy(), &["build", "-tags", "demo", "."]);
|
||||
c.kind = "go".into();
|
||||
c.env.insert("GO_RUN_VALUE".into(), "configured".into());
|
||||
c.extras.params = BTreeMap::from([
|
||||
("goKind".into(), "build".into()),
|
||||
("scope".into(), "package".into()),
|
||||
("target".into(), ".".into()),
|
||||
("module".into(), "module".into()),
|
||||
("outputDir".into(), "bin with spaces".into()),
|
||||
("runAfterBuild".into(), "true".into()),
|
||||
("programArgv".into(), r#"["-tags","two words"]"#.into()),
|
||||
]);
|
||||
let (id, out) = start(&h, &c);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&out, stderr));
|
||||
let output = text(&out, stdout);
|
||||
assert!(output.contains(r#"["-tags" "two words"] configured"#), "{output}");
|
||||
assert!(output.contains(&h.root.path().display().to_string()), "{output}");
|
||||
assert!(std::fs::read_dir(h.root.path().join("bin with spaces")).unwrap().next().is_some());
|
||||
|
||||
c.extras.params.insert("runAfterBuild".into(), "false".into());
|
||||
let (id, out) = start(&h, &c);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0));
|
||||
assert!(text(&out, stdout).is_empty());
|
||||
|
||||
c.extras.params.insert("runAfterBuild".into(), "true".into());
|
||||
c.args = vec!["build".into(), "missing.go".into()];
|
||||
let (id, out) = start(&h, &c);
|
||||
assert_ne!(wait_exit(&h, id, Duration::from_secs(60)), Some(0));
|
||||
assert!(text(&out, stdout).is_empty(), "a failed build must never run an older binary");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn go_file_tests_use_build_selected_sources_and_only_the_selected_test_file() {
|
||||
let h = harness();
|
||||
let Some(go) = ide_run::which::find_executable("go", &std::env::var_os("PATH").unwrap_or_default()) else {
|
||||
eprintln!("skipped: no Go toolchain");
|
||||
return;
|
||||
};
|
||||
std::fs::write(h.root.path().join("go.mod"), "module example.com/filetests\ngo 1.22\n").unwrap();
|
||||
std::fs::write(h.root.path().join("main.go"), "package filetests\nfunc value() int { return 2 }\n").unwrap();
|
||||
std::fs::write(h.root.path().join("generate.go"), "//go:build ignore\n\npackage main\nfunc main() {}\n").unwrap();
|
||||
std::fs::write(
|
||||
h.root.path().join("demo_test.go"),
|
||||
"package filetests\nimport \"testing\"\nfunc TestChosen(t *testing.T) { if value()!=2 { t.Fatal(\"bad\") } }\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
h.root.path().join("other_test.go"),
|
||||
"package filetests\nimport \"testing\"\nfunc TestOther(t *testing.T) { t.Fatal(\"must not run\") }\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut c = cfg("go-file", &go.to_string_lossy(), &["test", "-json", "demo_test.go"]);
|
||||
c.kind = "go".into();
|
||||
c.extras.params = BTreeMap::from([
|
||||
("goKind".into(), "test".into()),
|
||||
("scope".into(), "file".into()),
|
||||
("target".into(), "demo_test.go".into()),
|
||||
]);
|
||||
ide_run::go::prepare_test_file(h.root.path(), &mut c).unwrap();
|
||||
assert!(!c.args.iter().any(|a| a.ends_with("generate.go") || a.ends_with("other_test.go")));
|
||||
let (id, out) = start(&h, &c);
|
||||
assert_eq!(wait_exit(&h, id, Duration::from_secs(60)), Some(0), "{}", text(&out, stderr));
|
||||
assert!(text(&out, stdout).contains("TestChosen"));
|
||||
assert!(!text(&out, stdout).contains("TestOther"));
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Opt-in build-tool integration checks: cargo test -p ide-run --test tomcat_artifact -- --ignored
|
||||
use std::{fs, path::Path, process::Command};
|
||||
|
||||
fn write(root: &Path, path: &str, text: &str) {
|
||||
let path = root.join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
|
||||
fn build_and_deploy(root: &Path, id: &str, expected: &Path) {
|
||||
let cache = root.join("工作 cache");
|
||||
let model = ide_build::load(root, &cache, None);
|
||||
let mut plan = ide_build::web::build(root, &model, id, &cache).unwrap();
|
||||
plan.resolve_project(root, &std::env::vars().collect(), &ide_core::CancellationToken::new()).unwrap();
|
||||
let output = Command::new(&plan.command)
|
||||
.args(&plan.args)
|
||||
.envs(&plan.env)
|
||||
.current_dir(root.join(&plan.cwd))
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"{}\n{}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
let manifest = fs::read_to_string(cache.join("artifact-path.txt")).unwrap();
|
||||
assert_eq!(Path::new(manifest.trim()).canonicalize().unwrap(), expected.canonicalize().unwrap());
|
||||
let mut config: ide_run::RunConfig = serde_json::from_value(serde_json::json!({
|
||||
"id": "tomcat:test", "name": "Tomcat", "kind": "tomcat", "command": "catalina",
|
||||
"args": [], "env": {}, "detected": false, "isTest": false
|
||||
}))
|
||||
.unwrap();
|
||||
config.cwd = Some("timesheet".into());
|
||||
config.extras.params.insert("artifact".into(), id.into());
|
||||
let deployed = ide_run::tomcat::deployment(root, &cache, &config, false).unwrap();
|
||||
assert_eq!(fs::read_to_string(deployed.join("index.html")).unwrap(), "issue 515 timesheet");
|
||||
let siblings: Vec<_> = fs::read_dir(expected.parent().unwrap())
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
|
||||
.filter(|name| name.contains(".war."))
|
||||
.collect();
|
||||
assert!(siblings.is_empty(), "Unexpected WAR siblings: {siblings:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Maven and downloads Maven plugins"]
|
||||
fn maven_packaged_artifacts_deploy_from_root_and_nested_reactors() {
|
||||
for (reactor, module, custom) in
|
||||
[("", "", false), ("", "timesheet", false), ("parent", "apps/timesheet", true), ("timesheet", "", true)]
|
||||
{
|
||||
let tmp = tempfile::Builder::new().prefix("tomcat maven ").tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let reactor_dir = root.join(reactor);
|
||||
let module_dir = reactor_dir.join(module);
|
||||
let plugins = "<pluginManagement><plugins><plugin><artifactId>maven-war-plugin</artifactId><version>3.4.0</version><configuration><failOnMissingWebXml>false</failOnMissingWebXml></configuration></plugin></plugins></pluginManagement>";
|
||||
let parent = if module.is_empty() && custom {
|
||||
write(&reactor_dir, ".mvn/maven.config", "-Drevision=2\n");
|
||||
"<groupId>test</groupId><version>${revision}</version><properties><revision>1</revision></properties>"
|
||||
.to_owned()
|
||||
} else if module.is_empty() {
|
||||
"<groupId>test</groupId><version>1</version>".to_owned()
|
||||
} else {
|
||||
write(
|
||||
&reactor_dir,
|
||||
"pom.xml",
|
||||
&format!(
|
||||
"<project><modelVersion>4.0.0</modelVersion><groupId>test</groupId><artifactId>parent</artifactId><version>1</version><packaging>pom</packaging><modules><module>core</module><module>{module}</module></modules><build>{plugins}</build></project>"
|
||||
),
|
||||
);
|
||||
write(
|
||||
&reactor_dir,
|
||||
"core/pom.xml",
|
||||
"<project><modelVersion>4.0.0</modelVersion><parent><groupId>test</groupId><artifactId>parent</artifactId><version>1</version></parent><artifactId>core</artifactId></project>",
|
||||
);
|
||||
let relative = "../".repeat(module.split('/').count());
|
||||
format!(
|
||||
"<parent><groupId>test</groupId><artifactId>parent</artifactId><version>1</version><relativePath>{relative}pom.xml</relativePath></parent>"
|
||||
)
|
||||
};
|
||||
let dependency = if module.is_empty() {
|
||||
""
|
||||
} else {
|
||||
"<dependencies><dependency><groupId>test</groupId><artifactId>core</artifactId><version>1</version></dependency></dependencies>"
|
||||
};
|
||||
let output = if custom {
|
||||
"<directory>${project.basedir}/custom output</directory><finalName>custom-timesheet</finalName>"
|
||||
} else {
|
||||
"<finalName>timesheet</finalName>"
|
||||
};
|
||||
write(
|
||||
&module_dir,
|
||||
"pom.xml",
|
||||
&format!(
|
||||
"<project><modelVersion>4.0.0</modelVersion>{parent}<artifactId>timesheet</artifactId><packaging>war</packaging>{dependency}<build>{plugins}{output}</build></project>"
|
||||
),
|
||||
);
|
||||
write(&module_dir, "src/main/webapp/index.html", "issue 515 timesheet");
|
||||
if custom && !module.is_empty() {
|
||||
for dir in [&reactor_dir, &reactor_dir.join("core"), &module_dir] {
|
||||
let pom = dir.join("pom.xml");
|
||||
let text =
|
||||
fs::read_to_string(&pom).unwrap().replace("<version>1</version>", "<version>${revision}</version>");
|
||||
fs::write(pom, text).unwrap();
|
||||
}
|
||||
let pom = reactor_dir.join("pom.xml");
|
||||
fs::write(
|
||||
&pom,
|
||||
fs::read_to_string(&pom).unwrap().replace(
|
||||
"<packaging>pom</packaging>",
|
||||
"<packaging>pom</packaging><properties><revision>1</revision></properties>",
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
write(&reactor_dir, ".mvn/maven.config", "-Drevision=2\n");
|
||||
write(&module_dir, ".mvn/maven.config", "-Drevision=3\n");
|
||||
}
|
||||
let pom = module_dir.join("pom.xml").strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/");
|
||||
let expected =
|
||||
module_dir.join(if custom { "custom output/custom-timesheet.war" } else { "target/timesheet.war" });
|
||||
build_and_deploy(root, &format!("maven:{pom}"), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires Gradle"]
|
||||
fn gradle_packaged_artifacts_deploy_with_custom_archive_names() {
|
||||
for module in ["", "apps/timesheet"] {
|
||||
let tmp = tempfile::Builder::new().prefix("tomcat gradle ").tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let settings = if module.is_empty() {
|
||||
"rootProject.name = 'timesheet'\n"
|
||||
} else {
|
||||
"rootProject.name = 'timesheet'\ninclude 'apps:timesheet'\n"
|
||||
};
|
||||
write(root, "settings.gradle", settings);
|
||||
write(
|
||||
&root.join(module),
|
||||
"build.gradle",
|
||||
"plugins { id 'war' }\ntasks.named('war') { archiveFileName = 'custom-timesheet.war'; destinationDirectory = layout.projectDirectory.dir('custom output') }\n",
|
||||
);
|
||||
write(&root.join(module), "src/main/webapp/index.html", "issue 515 timesheet");
|
||||
let project = if module.is_empty() { ":" } else { ":apps:timesheet" };
|
||||
build_and_deploy(
|
||||
root,
|
||||
&format!("gradle:{project}"),
|
||||
&root.join(module).join("custom output/custom-timesheet.war"),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,32 @@ pub(crate) fn merge_init_options(cfg: &mut LanguageServerConfig, layer: Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Workspace compiler defaults; Eclipse project preferences still take precedence.
|
||||
pub(crate) fn configure_java_compiler(cfg: &mut LanguageServerConfig, data_dir: &Path) {
|
||||
let file = data_dir.join("lsp/jdtls/compiler-defaults.prefs");
|
||||
const DEFAULTS: &str = "org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning\n\
|
||||
org.eclipse.jdt.core.compiler.problem.discouragedReference=warning\n";
|
||||
let write = || -> ide_core::IdeResult<()> {
|
||||
std::fs::create_dir_all(file.parent().expect("compiler preferences directory"))?;
|
||||
if std::fs::read_to_string(&file).ok().as_deref() != Some(DEFAULTS) {
|
||||
ide_vfs::write_atomic(&file, DEFAULTS.as_bytes())?;
|
||||
}
|
||||
Ok(())
|
||||
};
|
||||
if let Err(e) = write() {
|
||||
tracing::warn!("jdtls: cannot write compiler defaults: {e}");
|
||||
return;
|
||||
}
|
||||
// jdtls accepts absolute filesystem paths as well as URLs (including paths
|
||||
// containing spaces and Windows backslashes). Explicit server settings win later.
|
||||
let defaults = json!({"settings": {"java": {"settings": {"url": file.to_string_lossy()}}}});
|
||||
let existing = cfg.initialization_options.clone();
|
||||
merge_init_options(cfg, defaults);
|
||||
if let Some(existing) = existing {
|
||||
merge_init_options(cfg, serde_json::to_value(existing).expect("TOML initialization options"));
|
||||
}
|
||||
}
|
||||
|
||||
/// Adjusts a language server's launch configuration from settings: interpreter
|
||||
/// paths, toolchain `PATH`, env (`GOROOT`, `RUST_SRC_PATH`, `VIRTUAL_ENV`) and
|
||||
/// server settings (`python.pythonPath`, rust-analyzer `check.command`, `typescript.tsdk`).
|
||||
@@ -358,6 +384,22 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
}
|
||||
|
||||
if name == "gopls" {
|
||||
let mut build = crate::go::build_settings(&s.languages.go.build_tags);
|
||||
let module_env = crate::go::module_environment(server_root, &s.languages.go.modules);
|
||||
for (key, value) in module_env {
|
||||
build["env"][key] = json!(value);
|
||||
}
|
||||
let mut settings = cfg.settings.take().unwrap_or_else(|| json!({}));
|
||||
// gopls retains omitted keys on reconfiguration. Explicit empty values
|
||||
// restore defaults when the last project constraint is cleared.
|
||||
let init = cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok());
|
||||
for (key, default) in [("buildFlags", json!([])), ("env", json!({}))] {
|
||||
if settings.get(key).is_none() {
|
||||
settings[key] = init.as_ref().and_then(|v| v.get(key)).cloned().unwrap_or(default);
|
||||
}
|
||||
}
|
||||
merge(&mut settings, build);
|
||||
cfg.settings = Some(settings);
|
||||
if let Some(goroot) = goroot(root, s) {
|
||||
path_dirs.push(goroot.join("bin"));
|
||||
cfg.env.insert("GOROOT".into(), goroot.to_string_lossy().into_owned());
|
||||
@@ -462,6 +504,7 @@ fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfi
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
tsserver_project: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -624,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() {
|
||||
@@ -728,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::*;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Finding installed interpreters and toolchains.
|
||||
|
||||
use crate::exec::{query, search_path, which};
|
||||
use crate::exec::{executables_in, query, search_path, which};
|
||||
use crate::{Sdk, SdkKind, bin_dir, exe, home_dir, pinned, venv_of};
|
||||
use ide_core::version_managers::{self, Tool};
|
||||
use std::collections::HashSet;
|
||||
@@ -236,11 +236,32 @@ fn subdirs(dir: &Path) -> Vec<PathBuf> {
|
||||
|
||||
// ------------------------------------------------------------------ node
|
||||
|
||||
/// Node's own version managers, by where they keep installs below the home directory.
|
||||
const NODE_MANAGERS: &[(&str, &str)] =
|
||||
&[(".nvm/versions/node", "nvm"), (".volta/tools/image/node", "volta"), (".local/share/fnm/node-versions", "fnm")];
|
||||
|
||||
/// Where a Node on the search path comes from: its version manager, "system" for one
|
||||
/// of the system's own bin directories, "path" for anything else the user put on `PATH`.
|
||||
pub(crate) fn node_source(path: &Path, home: Option<&Path>) -> &'static str {
|
||||
let managed = home.and_then(|home| {
|
||||
NODE_MANAGERS.iter().find(|(dir, _)| path.starts_with(home.join(dir))).map(|(_, manager)| *manager)
|
||||
});
|
||||
managed.or_else(|| version_managers::manager_of(path)).unwrap_or_else(|| {
|
||||
if path.parent().is_some_and(ide_core::tool_path::is_system_dir) { "system" } else { "path" }
|
||||
})
|
||||
}
|
||||
|
||||
fn node() -> Vec<Sdk> {
|
||||
let mut found: Vec<(PathBuf, &'static str)> = Vec::new();
|
||||
// A mise / asdf shim picks the version by directory: its installs are listed instead.
|
||||
if let Some(p) = which("node").filter(|p| !version_managers::is_shim(p)) {
|
||||
let source = version_managers::manager_of(&p).unwrap_or("path");
|
||||
// Every Node on the search path, not only the first one `which` finds: an SSH
|
||||
// session's minimal PATH puts /usr/bin ahead of the user's own ~/.local/bin, so
|
||||
// /usr/bin/node would be the only install listed (#440).
|
||||
for p in executables_in("node", std::env::split_paths(&search_path())) {
|
||||
// A mise / asdf shim picks the version by directory: its installs are listed instead.
|
||||
if version_managers::is_shim(&p) {
|
||||
continue;
|
||||
}
|
||||
let source = node_source(&p, home_dir().as_deref());
|
||||
found.push((p, source));
|
||||
}
|
||||
if let Some(home) = home_dir() {
|
||||
@@ -257,9 +278,6 @@ fn node() -> Vec<Sdk> {
|
||||
for (v, manager) in version_managers::installs(Tool::Node).into_iter().rev() {
|
||||
found.push((v.join("bin").join(exe("node")), manager));
|
||||
}
|
||||
for p in ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"] {
|
||||
found.push((PathBuf::from(p), "system"));
|
||||
}
|
||||
let found = dedupe_by_real_path(found);
|
||||
par_map(&found, |(path, source)| {
|
||||
let version = query(path, &["--version"], VERSION_TIMEOUT)?;
|
||||
|
||||
@@ -26,6 +26,17 @@ pub(crate) fn which(name: &str) -> Option<PathBuf> {
|
||||
ide_core::process::find_executable(name, &path)
|
||||
}
|
||||
|
||||
/// Every install of `name` on `dirs`, one per directory holding it, in `dirs` order (the
|
||||
/// first is what `which` returns). Tool detection lists them all: a login shell's `PATH`
|
||||
/// and an SSH exec channel's minimal one disagree on which comes first (#440).
|
||||
pub(crate) fn executables_in(name: &str, dirs: impl Iterator<Item = PathBuf>) -> Vec<PathBuf> {
|
||||
let pathext = ide_core::process::host_var_os("PATHEXT");
|
||||
let names = ide_core::process::executable_names(name, cfg!(windows), pathext.as_deref().and_then(|p| p.to_str()));
|
||||
dirs.filter(|d| !d.as_os_str().is_empty())
|
||||
.filter_map(|dir| names.iter().map(|n| dir.join(n)).find(|p| p.is_file()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Runs `program args` and returns trimmed stdout (stderr when stdout is empty)
|
||||
/// on success; `None` on failure or after `timeout`.
|
||||
pub(crate) fn query(program: &Path, args: &[&str], timeout: Duration) -> Option<String> {
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
//! Go build constraints shared by language services and command execution.
|
||||
use ide_core::settings::GoBuildTags;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
pub fn custom_tags(tags: &str) -> Vec<&str> {
|
||||
tags.split(|c: char| c.is_whitespace() || c == ',').filter(|s| !s.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// The build category of gopls settings uses flat protocol keys (`buildFlags`, `env`).
|
||||
/// Keep these in protocol settings so changes do not restart the server process.
|
||||
pub fn build_settings(tags: &GoBuildTags) -> Value {
|
||||
let mut out = json!({});
|
||||
let mut flags = Vec::new();
|
||||
let mut selected = custom_tags(&tags.custom);
|
||||
if !tags.version.trim().is_empty() {
|
||||
selected.push(tags.version.trim());
|
||||
}
|
||||
if !selected.is_empty() {
|
||||
flags.push(format!("-tags={}", selected.join(",")));
|
||||
}
|
||||
if !tags.compiler.trim().is_empty() {
|
||||
flags.push(format!("-compiler={}", tags.compiler.trim()));
|
||||
}
|
||||
if !flags.is_empty() {
|
||||
out["buildFlags"] = json!(flags);
|
||||
}
|
||||
for (key, value) in [("GOOS", &tags.os), ("GOARCH", &tags.arch), ("CGO_ENABLED", &tags.cgo)] {
|
||||
if !value.trim().is_empty() {
|
||||
out["env"][key] = json!(value.trim());
|
||||
}
|
||||
}
|
||||
let experiments = tags.experiments.split(',').map(str::trim).filter(|s| !s.is_empty()).collect::<Vec<_>>();
|
||||
if !experiments.is_empty() {
|
||||
out["env"]["GOEXPERIMENT"] = json!(experiments.join(","));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn defaults_do_not_override_toolchain_or_existing_server_settings() {
|
||||
assert_eq!(build_settings(&GoBuildTags::default()), json!({}));
|
||||
assert!(custom_tags(" , \n ").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constraints_map_to_protocol_build_settings() {
|
||||
let tags = GoBuildTags {
|
||||
os: "linux".into(),
|
||||
arch: "arm64".into(),
|
||||
version: "go1.22".into(),
|
||||
compiler: "gc".into(),
|
||||
cgo: "0".into(),
|
||||
custom: "integration debug,feature".into(),
|
||||
experiments: "loopvar, rangefunc ".into(),
|
||||
};
|
||||
assert_eq!(
|
||||
build_settings(&tags),
|
||||
json!({
|
||||
"buildFlags": ["-tags=integration,debug,feature,go1.22", "-compiler=gc"],
|
||||
"env": {"GOOS":"linux", "GOARCH":"arm64", "CGO_ENABLED":"0", "GOEXPERIMENT":"loopvar,rangefunc"}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Project build constraints for Go command launches. Explicit run environment wins.
|
||||
pub fn apply_to_run(config: &mut ide_run::RunConfig, tags: &GoBuildTags) {
|
||||
let settings = build_settings(tags);
|
||||
if let Some(env) = settings["env"].as_object() {
|
||||
for (key, value) in env {
|
||||
if let Some(value) = value.as_str() {
|
||||
config.env.entry(key.clone()).or_insert_with(|| value.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if config.extras.params.get("useTags").is_some_and(|v| v == "true") {
|
||||
let extra = settings["buildFlags"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str())
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
let mut args = config.args.split_off(usize::from(!config.args.is_empty()));
|
||||
merge_flags(&mut args, &extra);
|
||||
config.args.extend(args);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_flags(args: &mut Vec<String>, extra: &[String]) {
|
||||
let boundary = args.iter().position(|a| a == "-args" || a == "--").unwrap_or(args.len());
|
||||
let tail = args.split_off(boundary);
|
||||
let mut tags = Vec::new();
|
||||
let mut merged = Vec::new();
|
||||
let mut all = extra.iter().chain(args.iter());
|
||||
while let Some(arg) = all.next() {
|
||||
if arg == "-tags" {
|
||||
if let Some(value) = all.next() {
|
||||
tags.push(value.as_str());
|
||||
}
|
||||
} else if let Some(value) = arg.strip_prefix("-tags=") {
|
||||
tags.push(value);
|
||||
} else {
|
||||
merged.push(arg.clone());
|
||||
}
|
||||
}
|
||||
if !tags.is_empty() {
|
||||
merged.insert(0, format!("-tags={}", tags.join(",")));
|
||||
}
|
||||
merged.extend(tail);
|
||||
*args = merged;
|
||||
}
|
||||
|
||||
fn delve_build_flags(args: &[String], files: bool) -> Value {
|
||||
if !files {
|
||||
return json!(args);
|
||||
}
|
||||
json!(
|
||||
args.iter()
|
||||
.map(|arg| format!("'{}'", arg.replace('\\', "\\\\").replace('\'', "\\'")))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
)
|
||||
}
|
||||
|
||||
pub fn apply_to_debug(
|
||||
env: &mut std::collections::BTreeMap<String, String>,
|
||||
extra_json: &mut Option<String>,
|
||||
tags: &GoBuildTags,
|
||||
) -> ide_core::IdeResult<()> {
|
||||
let mut extra: Value = extra_json
|
||||
.as_deref()
|
||||
.map(serde_json::from_str)
|
||||
.transpose()
|
||||
.map_err(|e| ide_core::IdeError::invalid(e.to_string()))?
|
||||
.unwrap_or_else(|| json!({}));
|
||||
let settings = build_settings(tags);
|
||||
if let Some(values) = settings["env"].as_object() {
|
||||
for (key, value) in values {
|
||||
if let Some(value) = value.as_str() {
|
||||
env.entry(key.clone()).or_insert_with(|| value.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if extra.get("__goToolArgs").is_some() {
|
||||
let mut args: Vec<String> = extra
|
||||
.get("__goToolArgs")
|
||||
.cloned()
|
||||
.map(serde_json::from_value)
|
||||
.transpose()
|
||||
.map_err(|e| ide_core::IdeError::invalid(e.to_string()))?
|
||||
.unwrap_or_default();
|
||||
if extra.get("__goUseTags").and_then(Value::as_bool) == Some(true) {
|
||||
let flags = settings["buildFlags"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
merge_flags(&mut args, &flags);
|
||||
}
|
||||
if let Some(files) = extra.get("__goFiles").and_then(Value::as_array) {
|
||||
if extra["mode"] == "test" {
|
||||
if let Some(dir) = files.first().and_then(Value::as_str).and_then(|f| std::path::Path::new(f).parent())
|
||||
{
|
||||
let mut command = std::process::Command::new("go");
|
||||
ide_core::process::host_env(&mut command);
|
||||
command.envs(env.iter());
|
||||
let sources = ide_run::go::package_sources(command, dir, &args)?;
|
||||
args.extend(sources.iter().map(|p| p.to_string_lossy().into_owned()));
|
||||
}
|
||||
} else {
|
||||
args.extend(files.iter().skip(1).filter_map(Value::as_str).map(str::to_owned));
|
||||
}
|
||||
}
|
||||
// Delve appends its -o after array build flags; positional source files
|
||||
// require the string form so all compiler flags precede the file list.
|
||||
extra["buildFlags"] = delve_build_flags(&args, extra.get("__goFiles").is_some());
|
||||
}
|
||||
if let Some(output) = extra.get("output").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).parent()) {
|
||||
std::fs::create_dir_all(output)?;
|
||||
}
|
||||
if let Some(map) = extra.as_object_mut() {
|
||||
map.remove("__goUseTags");
|
||||
map.remove("__goToolArgs");
|
||||
map.remove("__goFiles");
|
||||
}
|
||||
*extra_json = Some(extra.to_string());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod command_tests {
|
||||
use super::*;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn merges_custom_tags_without_touching_program_arguments() {
|
||||
let mut flags = vec!["-tags".into(), "explicit".into(), ".".into(), "-args".into(), "-tags=program".into()];
|
||||
merge_flags(&mut flags, &["-tags=project,debug".into()]);
|
||||
assert_eq!(flags, ["-tags=project,debug,explicit", ".", "-args", "-tags=program"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_debug_flags_keep_delves_output_flags_before_source_files() {
|
||||
let flags = vec!["-tags=demo".into(), "C:\\a b\\main.go".into(), "quote'file.go".into()];
|
||||
assert_eq!(delve_build_flags(&flags, false), json!(flags));
|
||||
assert_eq!(delve_build_flags(&flags, true), json!("'-tags=demo' 'C:\\\\a b\\\\main.go' 'quote\\'file.go'"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_env_and_flags_use_the_same_constraints_and_preserve_overrides() {
|
||||
let tags = GoBuildTags { custom: "project".into(), os: "linux".into(), ..Default::default() };
|
||||
let mut env = BTreeMap::from([("GOOS".into(), "darwin".into())]);
|
||||
let mut extra =
|
||||
Some(json!({"mode":"test", "__goUseTags":true, "__goToolArgs":["-tags", "explicit"]}).to_string());
|
||||
apply_to_debug(&mut env, &mut extra, &tags).unwrap();
|
||||
let extra: Value = serde_json::from_str(extra.as_deref().unwrap()).unwrap();
|
||||
assert_eq!(extra["buildFlags"], json!(["-tags=project,explicit"]));
|
||||
assert!(extra.get("__goUseTags").is_none());
|
||||
assert_eq!(env["GOOS"], "darwin");
|
||||
assert!(apply_to_debug(&mut env, &mut Some("{".into()), &tags).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn debug_environment(
|
||||
root: &std::path::Path,
|
||||
settings: &ide_core::settings::Settings,
|
||||
env: &mut std::collections::BTreeMap<String, String>,
|
||||
) {
|
||||
for (key, value) in module_environment(root, &settings.languages.go.modules) {
|
||||
env.entry(key).or_insert(value);
|
||||
}
|
||||
if let Some(goroot) = crate::apply::goroot(root, settings) {
|
||||
env.entry("GOROOT".into()).or_insert_with(|| goroot.to_string_lossy().into_owned());
|
||||
env.entry("PATH".into()).or_insert_with(|| crate::exec::path_with(&[&goroot.join("bin")]));
|
||||
}
|
||||
if let Some(gopath) = crate::apply::setting_path(root, &settings.languages.go.gopath) {
|
||||
env.entry("GOPATH".into()).or_insert_with(|| gopath.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn module_environment(
|
||||
root: &std::path::Path,
|
||||
modules: &ide_core::settings::GoModules,
|
||||
) -> std::collections::BTreeMap<String, String> {
|
||||
let mut env: std::collections::BTreeMap<String, String> = modules
|
||||
.env
|
||||
.iter()
|
||||
.filter(|(key, value)| {
|
||||
!key.is_empty()
|
||||
&& !key.contains(['=', '\0'])
|
||||
&& !key.contains(char::is_whitespace)
|
||||
&& !value.trim().is_empty()
|
||||
&& !value.contains('\0')
|
||||
})
|
||||
.map(|(key, value)| (key.clone(), value.clone()))
|
||||
.collect();
|
||||
for key in ["GOWORK", "GOMODCACHE"] {
|
||||
if let Some(value) = env.get_mut(key)
|
||||
&& value != "off"
|
||||
&& value != "auto"
|
||||
{
|
||||
*value = root.join(value.as_str()).to_string_lossy().into_owned();
|
||||
}
|
||||
}
|
||||
if !modules.auto_vendor {
|
||||
let flags = env
|
||||
.get("GOFLAGS")
|
||||
.cloned()
|
||||
.or_else(|| ide_core::process::host_var_os("GOFLAGS").map(|v| v.to_string_lossy().into_owned()))
|
||||
.unwrap_or_default();
|
||||
if !flags.split_whitespace().any(|flag| flag.starts_with("-mod=")) {
|
||||
let workspace = match env.get("GOWORK").map(String::as_str) {
|
||||
Some("off") => false,
|
||||
Some(path) if path != "auto" => !path.is_empty(),
|
||||
_ => root.ancestors().any(|dir| dir.join("go.work").is_file()),
|
||||
};
|
||||
env.insert(
|
||||
"GOFLAGS".into(),
|
||||
format!("{} -mod={}", flags.trim(), if workspace { "readonly" } else { "mod" }).trim().into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
env
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod module_tests {
|
||||
use super::*;
|
||||
use ide_core::settings::GoModules;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn defaults_and_invalid_entries_leave_the_environment_unchanged() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(module_environment(dir.path(), &GoModules::default()).is_empty());
|
||||
let modules = GoModules {
|
||||
env: BTreeMap::from([
|
||||
("BAD KEY".into(), "x".into()),
|
||||
("GOPROXY".into(), "".into()),
|
||||
("GOPRIVATE".into(), "example.com/private".into()),
|
||||
]),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
module_environment(dir.path(), &modules),
|
||||
BTreeMap::from([("GOPRIVATE".into(), "example.com/private".into())])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_automatic_vendor_uses_workspace_legal_flags_and_keeps_explicit_flags() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mut modules = GoModules { auto_vendor: false, env: BTreeMap::from([("GOFLAGS".into(), "-race".into())]) };
|
||||
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=mod");
|
||||
std::fs::write(dir.path().join("go.work"), "go 1.22\n").unwrap();
|
||||
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=readonly");
|
||||
modules.env.insert("GOWORK".into(), "off".into());
|
||||
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-race -mod=mod");
|
||||
modules.env.insert("GOFLAGS".into(), "-mod=vendor".into());
|
||||
assert_eq!(module_environment(dir.path(), &modules)["GOFLAGS"], "-mod=vendor");
|
||||
}
|
||||
}
|
||||
@@ -333,6 +333,7 @@ pub fn configure_jdtls(
|
||||
if cfg.name != "jdtls" {
|
||||
return;
|
||||
}
|
||||
crate::apply::configure_java_compiler(cfg, data_dir);
|
||||
// A Lombok agent from the user's JVM arguments wins.
|
||||
let own_agent = crate::lombok::agent_in_args(cfg.args.iter().map(String::as_str));
|
||||
let agent = own_agent.as_deref().or(lombok);
|
||||
|
||||
@@ -9,6 +9,7 @@ mod angular;
|
||||
pub mod apply;
|
||||
mod discover;
|
||||
mod exec;
|
||||
pub mod go;
|
||||
pub mod godot;
|
||||
pub mod java;
|
||||
pub mod jdtls;
|
||||
|
||||
Loaded 100 of 643 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user