mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 17:22:55 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bf57a8297 | ||
|
|
7c09a13afc | ||
|
|
b9f8a0b4bd | ||
|
|
aaf4d12ba6 | ||
|
|
a645632b23 | ||
|
|
69fa7f7387 | ||
|
|
1122d67f7d | ||
|
|
60722e0cc5 | ||
|
|
48ac2094fc | ||
|
|
025e0019d2 |
No files matched your search
@@ -54,6 +54,21 @@ jobs:
|
||||
if: runner.os == 'Linux'
|
||||
run: git diff --exit-code src/bindings.ts
|
||||
|
||||
# The remote development server ships as a static Linux binary (release.yml):
|
||||
# keep that build working.
|
||||
server:
|
||||
name: Remote server (linux musl)
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: x86_64-unknown-linux-musl }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: server-musl }
|
||||
- run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- run: cargo build --locked -p ide-host --bin openidea-server --target x86_64-unknown-linux-musl
|
||||
- run: target/x86_64-unknown-linux-musl/debug/openidea-server --version
|
||||
|
||||
frontend:
|
||||
name: Frontend
|
||||
runs-on: ubuntu-22.04
|
||||
|
||||
@@ -131,8 +131,49 @@ jobs:
|
||||
gh release upload "$TAG" "$dmg" --clobber
|
||||
done
|
||||
|
||||
# The remote development server, per host platform it can run on. The desktop
|
||||
# app downloads the one for the host (ide-tools `server`) and installs it over SSH.
|
||||
# Linux builds are static (musl), so they run whatever the host's glibc.
|
||||
server:
|
||||
needs: create-release
|
||||
name: Server (${{ matrix.os }}-${{ matrix.arch }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { runner: ubuntu-22.04, target: x86_64-unknown-linux-musl, os: linux, arch: x86_64 }
|
||||
- { runner: ubuntu-24.04-arm, target: aarch64-unknown-linux-musl, os: linux, arch: aarch64 }
|
||||
- { runner: macos-latest, target: aarch64-apple-darwin, os: macos, arch: aarch64 }
|
||||
- { runner: macos-latest, target: x86_64-apple-darwin, os: macos, arch: x86_64 }
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with: { ref: "${{ env.TAG }}" }
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with: { targets: "${{ matrix.target }}" }
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with: { key: "server-${{ matrix.target }}" }
|
||||
- name: musl toolchain
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt-get update && sudo apt-get install -y musl-tools
|
||||
- name: Build
|
||||
run: |
|
||||
# musl-tools only ships `musl-gcc`; cc-rs would look for `<arch>-linux-musl-gcc`.
|
||||
if [ "$RUNNER_OS" = Linux ]; then export CC_${TARGET//-/_}=musl-gcc; fi
|
||||
cargo build --release --locked -p ide-host --bin openidea-server --target "$TARGET"
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
- name: Upload
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ASSET: openidea-server-${{ matrix.os }}-${{ matrix.arch }}.gz
|
||||
run: |
|
||||
gzip -9 -c target/${{ matrix.target }}/release/openidea-server > "$ASSET"
|
||||
if command -v sha256sum >/dev/null; then sha256sum "$ASSET"; else shasum -a 256 "$ASSET"; fi > "$ASSET.sha256"
|
||||
gh release upload "$TAG" "$ASSET" "$ASSET.sha256" --clobber
|
||||
|
||||
publish:
|
||||
needs: [create-release, build]
|
||||
needs: [create-release, build, server]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v7
|
||||
|
||||
Generated
+127
-2
@@ -3461,6 +3461,18 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gloo-timers"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "482ce8a491a501da4cd806bd190275363d674f2845005c6ddbd5d3e1dd54495d"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.18.0"
|
||||
@@ -4019,6 +4031,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"specta-typescript",
|
||||
"thiserror 2.0.21",
|
||||
"tokio-util",
|
||||
]
|
||||
@@ -4061,9 +4074,8 @@ dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"futures",
|
||||
"ide-core",
|
||||
"ide-ssh",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
@@ -4102,6 +4114,47 @@ dependencies = [
|
||||
"which",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-host"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-agent",
|
||||
"ide-analysis",
|
||||
"ide-build",
|
||||
"ide-core",
|
||||
"ide-coverage",
|
||||
"ide-dap",
|
||||
"ide-db",
|
||||
"ide-http",
|
||||
"ide-index",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"ide-sdk",
|
||||
"ide-search",
|
||||
"ide-spell",
|
||||
"ide-ssh",
|
||||
"ide-syntax",
|
||||
"ide-terminal",
|
||||
"ide-text",
|
||||
"ide-tools",
|
||||
"ide-vcs",
|
||||
"ide-vfs",
|
||||
"ide-workspace",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-http"
|
||||
version = "0.1.18"
|
||||
@@ -4169,6 +4222,7 @@ dependencies = [
|
||||
"parking_lot",
|
||||
"regex-lite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"tracing",
|
||||
@@ -4265,6 +4319,23 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-remote"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"globset",
|
||||
"ide-core",
|
||||
"ide-run",
|
||||
"ide-ssh",
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-run"
|
||||
version = "0.1.18"
|
||||
@@ -4337,6 +4408,25 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-ssh"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"dirs 6.0.0",
|
||||
"flate2",
|
||||
"ide-core",
|
||||
"parking_lot",
|
||||
"russh",
|
||||
"russh-config",
|
||||
"russh-sftp",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ide-syntax"
|
||||
version = "0.1.18"
|
||||
@@ -5880,6 +5970,7 @@ dependencies = [
|
||||
name = "openidea"
|
||||
version = "0.1.18"
|
||||
dependencies = [
|
||||
"blake3",
|
||||
"dirs 6.0.0",
|
||||
"dunce",
|
||||
"ide-agent",
|
||||
@@ -5892,16 +5983,20 @@ dependencies = [
|
||||
"ide-db",
|
||||
"ide-docker",
|
||||
"ide-github",
|
||||
"ide-host",
|
||||
"ide-http",
|
||||
"ide-index",
|
||||
"ide-jsonrpc",
|
||||
"ide-language",
|
||||
"ide-lsp",
|
||||
"ide-plugin",
|
||||
"ide-profiler",
|
||||
"ide-remote",
|
||||
"ide-run",
|
||||
"ide-sdk",
|
||||
"ide-search",
|
||||
"ide-spell",
|
||||
"ide-ssh",
|
||||
"ide-syntax",
|
||||
"ide-terminal",
|
||||
"ide-text",
|
||||
@@ -6993,6 +7088,26 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "russh-sftp"
|
||||
version = "2.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9de67aace74530a29086db0671fa200c470a58eb380081f28ad512ffb0c5356b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"gloo-timers",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_bytes",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"wasm-bindgen-futures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "russh-util"
|
||||
version = "0.52.0"
|
||||
@@ -7326,6 +7441,16 @@ dependencies = [
|
||||
"typeid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_bytes"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
|
||||
@@ -36,6 +36,9 @@ ide-web = { path = "crates/ide-web" }
|
||||
ide-spell = { path = "crates/ide-spell" }
|
||||
ide-agent = { path = "crates/ide-agent" }
|
||||
ide-clipboard = { path = "crates/ide-clipboard" }
|
||||
ide-ssh = { path = "crates/ide-ssh" }
|
||||
ide-remote = { path = "crates/ide-remote" }
|
||||
ide-host = { path = "crates/ide-host" }
|
||||
|
||||
# serialization & IPC typing
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
@@ -43,6 +46,7 @@ serde_json = "1"
|
||||
toml = "0.9"
|
||||
toml_edit = "0.25"
|
||||
specta = { version = "=2.0.0-rc.25", features = ["derive"] }
|
||||
specta-typescript = "0.0.12"
|
||||
|
||||
# async & concurrency
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "process", "io-util", "fs"] }
|
||||
|
||||
@@ -703,8 +703,11 @@ impl Task {
|
||||
.chain(prompt.images.iter().map(|i| i.name.clone().unwrap_or_else(|| "image".into())))
|
||||
.collect();
|
||||
self.push_entry(AgentEntry::User { text: prompt.text.clone(), attachments });
|
||||
self.set_status(AgentStatus::Running, None);
|
||||
// Launching the agent can take a while (`npx` fetches its adapter on first use).
|
||||
let starting = self.live.lock().as_ref().is_none_or(|l| l.peer.is_closed());
|
||||
self.set_status(if starting { AgentStatus::Starting } else { AgentStatus::Running }, None);
|
||||
let live = self.ensure_session().await?;
|
||||
self.set_status(AgentStatus::Running, None);
|
||||
if !prompt.images.is_empty() && !live.images {
|
||||
return Err("This agent can't read images.".into());
|
||||
}
|
||||
@@ -720,6 +723,14 @@ impl Task {
|
||||
.await;
|
||||
match result {
|
||||
Ok(v) => Ok(v.get("stopReason").and_then(Value::as_str).unwrap_or("end_turn").to_owned()),
|
||||
// Signed out, or the account was refused (Claude Code: "API Error: 403").
|
||||
Err(RpcError::Server { message, .. }) if message.to_lowercase().contains("auth") => {
|
||||
let provider = self.state.lock().task.provider.clone();
|
||||
Err(match self.shared.spec(&provider) {
|
||||
Some(spec) => format!("{} needs you to sign in. {}", spec.name, spec.login_hint),
|
||||
None => message,
|
||||
})
|
||||
}
|
||||
Err(e) => Err(self.describe(e)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,5 +10,6 @@ dunce.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
specta-typescript.workspace = true
|
||||
thiserror.workspace = true
|
||||
tokio-util.workspace = true
|
||||
@@ -100,6 +100,28 @@ pub enum IdeEvent {
|
||||
ProjectRootsChanged {
|
||||
root: crate::IpcPath,
|
||||
},
|
||||
/// A child process (`ssh`, `git`) asks for a credential; answered with `ssh_askpass_answer`.
|
||||
Askpass {
|
||||
id: u64,
|
||||
prompt: String,
|
||||
kind: AskpassKind,
|
||||
},
|
||||
/// Prompt `id` was answered (in any window) or gave up.
|
||||
AskpassDone {
|
||||
id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// What an askpass prompt asks for.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AskpassKind {
|
||||
/// Password or passphrase: hidden input.
|
||||
Secret,
|
||||
/// User name: visible input.
|
||||
Text,
|
||||
/// Yes / no (an unknown host key); answered with `yes` or `no`.
|
||||
Confirm,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod inspection_settings;
|
||||
pub mod ipc_path;
|
||||
pub mod process;
|
||||
pub mod project_dir;
|
||||
pub mod remote_settings;
|
||||
pub mod settings;
|
||||
pub mod text;
|
||||
pub mod workflow_settings;
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//! Settings of remote work: SSH configurations, SFTP deployment and SSH run
|
||||
//! targets. The fields hang off `Settings` in `settings.rs`.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
|
||||
/// How an SSH connection authenticates.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SshAuth {
|
||||
Password,
|
||||
/// A private key file, optionally protected by a passphrase.
|
||||
KeyPair,
|
||||
/// `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent.
|
||||
#[default]
|
||||
OpenSsh,
|
||||
}
|
||||
|
||||
/// Tools › SSH Configurations.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SshSettings {
|
||||
pub configs: Vec<SshConfiguration>,
|
||||
}
|
||||
|
||||
/// A named SSH host, shared by terminals, deployment, run targets and data sources.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct SshConfiguration {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// Host name, or a `Host` alias of `~/.ssh/config` with [`SshAuth::OpenSsh`].
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
pub user: String,
|
||||
pub auth: SshAuth,
|
||||
/// Private key for [`SshAuth::KeyPair`]; `~/` is the home directory.
|
||||
pub key_file: String,
|
||||
/// Keep the password / key passphrase in the saved passwords.
|
||||
pub save_secret: bool,
|
||||
}
|
||||
|
||||
impl SshConfiguration {
|
||||
/// `user@host:port` with what is known (the name when the host is empty).
|
||||
pub fn label(&self) -> String {
|
||||
let host = self.host.trim();
|
||||
if host.is_empty() {
|
||||
return self.name.clone();
|
||||
}
|
||||
let mut s = String::new();
|
||||
if !self.user.trim().is_empty() {
|
||||
s.push_str(self.user.trim());
|
||||
s.push('@');
|
||||
}
|
||||
s.push_str(host);
|
||||
if let Some(p) = self.port {
|
||||
s.push_str(&format!(":{p}"));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// When local changes are uploaded to the default deployment server.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum AutoUpload {
|
||||
#[default]
|
||||
Never,
|
||||
/// Files saved with Save / Save All (⌘S).
|
||||
OnExplicitSave,
|
||||
/// Every save, including autosave and external changes.
|
||||
Always,
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Deployment (and its Options page).
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentSettings {
|
||||
pub servers: Vec<DeploymentServer>,
|
||||
/// Server used by Upload / Download / Sync and automatic upload; empty = none.
|
||||
pub default_server: String,
|
||||
pub auto_upload: AutoUpload,
|
||||
/// Delete remote files when their local file is deleted (automatic upload).
|
||||
pub delete_remote: bool,
|
||||
/// Keep local modification times on uploaded files.
|
||||
pub preserve_timestamps: bool,
|
||||
/// File names never uploaded or downloaded (`*` and `?` wildcards; `;`-separated in the UI).
|
||||
pub exclude: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for DeploymentSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
servers: Vec::new(),
|
||||
default_server: String::new(),
|
||||
auto_upload: AutoUpload::Never,
|
||||
delete_remote: false,
|
||||
preserve_timestamps: true,
|
||||
exclude: [".git", ".svn", ".hg", ".idea", ".openidea", ".DS_Store", "*.pyc", "__pycache__"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An SFTP server 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 id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
pub ssh_config: String,
|
||||
/// Absolute remote folder the mappings are relative to; empty = the login directory.
|
||||
pub root_path: String,
|
||||
/// Web server URL of the root (Open in Browser on the deployed file).
|
||||
pub web_url: String,
|
||||
pub mappings: Vec<DeploymentMapping>,
|
||||
/// Remote paths (relative to the root) never touched.
|
||||
pub excluded_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct DeploymentMapping {
|
||||
/// Project-relative folder; empty = the project root.
|
||||
pub local: String,
|
||||
/// Remote folder, relative to the server root (a leading `/` is the root too).
|
||||
pub deployment: String,
|
||||
/// Web path, relative to the server's URL.
|
||||
pub web: String,
|
||||
}
|
||||
|
||||
/// Build, Execution, Deployment › Run Targets: run configurations on another machine.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct RunTarget {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
/// [`SshConfiguration::id`].
|
||||
pub ssh_config: String,
|
||||
/// Remote folder the project is synchronised to and run from.
|
||||
pub project_root: String,
|
||||
/// Upload changed project files before each run.
|
||||
pub sync_before_run: bool,
|
||||
/// Interpreters on the target; empty = the one on the remote `PATH`.
|
||||
pub python: String,
|
||||
pub node: String,
|
||||
pub java: String,
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use std::collections::BTreeMap;
|
||||
|
||||
pub use crate::editor_settings::*;
|
||||
pub use crate::inspection_settings::*;
|
||||
pub use crate::remote_settings::*;
|
||||
pub use crate::workflow_settings::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
@@ -41,10 +42,19 @@ pub struct Settings {
|
||||
/// Language servers / debug adapters at a user-chosen location, by the
|
||||
/// command the language definition launches (`elixir-ls`, `codelldb`).
|
||||
pub tools: BTreeMap<String, ToolSettings>,
|
||||
/// Per language server, by name (`rust-analyzer`): launch and protocol
|
||||
/// options on top of the language definition, or a server defined here.
|
||||
pub language_servers: BTreeMap<String, LanguageServerSettings>,
|
||||
/// Editor › Inspections profiles.
|
||||
pub inspections: InspectionSettings,
|
||||
/// Editor › Natural Languages › Spelling.
|
||||
pub spelling: SpellingSettings,
|
||||
/// Tools › SSH Configurations.
|
||||
pub ssh: SshSettings,
|
||||
/// Build, Execution, Deployment › Deployment.
|
||||
pub deployment: DeploymentSettings,
|
||||
/// Build, Execution, Deployment › Run Targets.
|
||||
pub run_targets: Vec<RunTarget>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
@@ -54,6 +64,58 @@ pub struct ToolSettings {
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Type)]
|
||||
#[serde(default, rename_all = "camelCase")]
|
||||
pub struct LanguageServerSettings {
|
||||
/// `false` keeps the server from starting for any language.
|
||||
pub enabled: bool,
|
||||
/// Executable to launch instead of the definition's; required for a server
|
||||
/// defined here (one with `languages`).
|
||||
pub command: String,
|
||||
/// Replaces the definition's arguments.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub args: Option<Vec<String>>,
|
||||
/// Added to the server's environment.
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// Merged over the definition's `initializationOptions` (sent once at startup).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional, type = Option<specta_typescript::Unknown>)]
|
||||
pub initialization_options: Option<serde_json::Value>,
|
||||
/// Answers the server's `workspace/configuration` requests; changes are pushed
|
||||
/// with `workspace/didChangeConfiguration` without a restart.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional, type = Option<specta_typescript::Unknown>)]
|
||||
pub settings: Option<serde_json::Value>,
|
||||
/// Language ids a server defined here serves (`python`); empty for the servers
|
||||
/// of the language definitions.
|
||||
pub languages: Vec<String>,
|
||||
/// Files marking a workspace root for a server defined here (`pyproject.toml`).
|
||||
pub root_markers: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for LanguageServerSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
command: String::new(),
|
||||
args: None,
|
||||
env: BTreeMap::new(),
|
||||
initialization_options: None,
|
||||
settings: None,
|
||||
languages: Vec::new(),
|
||||
root_markers: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LanguageServerSettings {
|
||||
/// A server defined in settings rather than by a language definition.
|
||||
pub fn is_custom(&self) -> bool {
|
||||
!self.languages.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The configured executable for `command`, if any (`~/` not yet expanded).
|
||||
pub fn tool_path(&self, command: &str) -> Option<&str> {
|
||||
@@ -82,8 +144,12 @@ impl Default for Settings {
|
||||
actions_on_save: ActionsOnSaveSettings::default(),
|
||||
external_tools: Vec::new(),
|
||||
tools: BTreeMap::new(),
|
||||
language_servers: BTreeMap::new(),
|
||||
inspections: InspectionSettings::default(),
|
||||
spelling: SpellingSettings::default(),
|
||||
ssh: SshSettings::default(),
|
||||
deployment: DeploymentSettings::default(),
|
||||
run_targets: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,7 @@ parking_lot.workspace = true
|
||||
futures.workspace = true
|
||||
sqlx.workspace = true
|
||||
dirs.workspace = true
|
||||
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
|
||||
russh-config = "0.58.0"
|
||||
ide-ssh.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
|
||||
@@ -192,7 +192,7 @@ fn looks_like_query(sql: &str) -> bool {
|
||||
|
||||
fn file_setting(path: &str) -> Option<PathBuf> {
|
||||
let path = path.trim();
|
||||
(!path.is_empty()).then(|| crate::tunnel::expand(path))
|
||||
(!path.is_empty()).then(|| ide_ssh::expand(path))
|
||||
}
|
||||
|
||||
fn is_identifier(name: &str) -> bool {
|
||||
|
||||
+92
-65
@@ -9,7 +9,6 @@ pub mod secrets;
|
||||
pub mod source;
|
||||
pub mod split;
|
||||
pub mod sql;
|
||||
pub mod tunnel;
|
||||
|
||||
pub use conn::{Held, Pool, QueryResult, ResultColumn};
|
||||
pub use history::HistoryEntry;
|
||||
@@ -21,6 +20,7 @@ pub use sql::{CellValue, DbRowEdit, SortKey};
|
||||
|
||||
use history::HistoryStore;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, error::ErrorKind};
|
||||
use ide_ssh::{SshManager, SshTarget, Tunnel};
|
||||
use parking_lot::Mutex;
|
||||
use secrets::SecretStore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -29,7 +29,6 @@ use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tunnel::{OpenError, Tunnel, TunnelSpec, UnknownHostKey};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
@@ -210,12 +209,12 @@ pub struct DbManager {
|
||||
tunnels: Mutex<HashMap<String, Arc<Tunnel>>>,
|
||||
/// Serialises opening tunnels so concurrent connects share one.
|
||||
tunnel_open: tokio::sync::Mutex<()>,
|
||||
/// Host keys awaiting the user's trust, by data source id.
|
||||
unknown_host_keys: Mutex<HashMap<String, UnknownHostKey>>,
|
||||
known_hosts: PathBuf,
|
||||
ssh_config: Option<PathBuf>,
|
||||
/// App-wide SSH connections and host-key trust.
|
||||
ssh: Arc<SshManager>,
|
||||
saved_passwords: SecretStore,
|
||||
history: Arc<HistoryStore>,
|
||||
/// Records executed statements in the history off the async threads, in order.
|
||||
recorder: std::sync::mpsc::Sender<(String, String, f64)>,
|
||||
/// Passwords entered this session (not saved, or not yet), by credential key.
|
||||
session_passwords: Mutex<HashMap<String, String>>,
|
||||
/// Manual-commit data sources, each with the connection of its open
|
||||
@@ -281,12 +280,12 @@ pub fn split_target(target: &str) -> (&str, Option<&str>) {
|
||||
}
|
||||
|
||||
/// Says which leg failed when the database is unreachable from the SSH host.
|
||||
fn through_tunnel(e: IdeError, source: &DataSource, tunneled: bool) -> IdeError {
|
||||
let Some(ssh) = source.ssh.as_ref().filter(|_| tunneled && e.kind == ErrorKind::Io) else { return e };
|
||||
fn through_tunnel(e: IdeError, source: &DataSource, via: Option<&str>) -> IdeError {
|
||||
let Some(via) = via.filter(|_| e.kind == ErrorKind::Io) else { return e };
|
||||
let port = source.port.or(source.driver.default_port()).unwrap_or_default();
|
||||
IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("{} through the SSH tunnel ({}:{port} as seen from {})", e.message, source.host, ssh.host),
|
||||
format!("{} through the SSH tunnel ({}:{port} as seen from {via})", e.message, source.host),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -321,29 +320,31 @@ async fn end_session(session: &Session) {
|
||||
impl DbManager {
|
||||
/// `passwords` / `history` are the app-wide files of saved passwords
|
||||
/// ([`SecretStore`]) and query history ([`HistoryStore`]).
|
||||
pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf) -> Self {
|
||||
let ssh_dir = dirs::home_dir().unwrap_or_default().join(".ssh");
|
||||
Self::with_ssh_files(root, passwords, history, ssh_dir.join("known_hosts"), Some(ssh_dir.join("config")))
|
||||
}
|
||||
|
||||
/// [`DbManager::new`] with other `known_hosts` / `~/.ssh/config` files (tests).
|
||||
pub fn with_ssh_files(
|
||||
root: PathBuf,
|
||||
passwords: PathBuf,
|
||||
history: PathBuf,
|
||||
known_hosts: PathBuf,
|
||||
ssh_config: Option<PathBuf>,
|
||||
) -> Self {
|
||||
pub fn new(root: PathBuf, passwords: PathBuf, history: PathBuf, ssh: Arc<SshManager>) -> Self {
|
||||
let history = Arc::new(HistoryStore::new(history, &root));
|
||||
let (recorder, statements) = std::sync::mpsc::channel::<(String, String, f64)>();
|
||||
{
|
||||
let history = history.clone();
|
||||
let spawned = std::thread::Builder::new().name("openidea-db-history".into()).spawn(move || {
|
||||
for (source, text, time) in statements {
|
||||
if let Err(e) = history.add(&source, &text, time) {
|
||||
tracing::warn!("{}", e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Err(e) = spawned {
|
||||
tracing::warn!("query history recorder: {e}");
|
||||
}
|
||||
}
|
||||
Self {
|
||||
saved_passwords: SecretStore::new(passwords, &root),
|
||||
history: Arc::new(HistoryStore::new(history, &root)),
|
||||
history,
|
||||
recorder,
|
||||
root,
|
||||
pools: Mutex::default(),
|
||||
tunnels: Mutex::default(),
|
||||
tunnel_open: tokio::sync::Mutex::new(()),
|
||||
unknown_host_keys: Mutex::default(),
|
||||
known_hosts,
|
||||
ssh_config,
|
||||
ssh,
|
||||
session_passwords: Mutex::default(),
|
||||
sessions: Mutex::default(),
|
||||
}
|
||||
@@ -397,7 +398,14 @@ impl DbManager {
|
||||
}
|
||||
|
||||
/// [`DbManager::set_password`] for the SSH password / key passphrase.
|
||||
/// A data source using an SSH configuration sets that configuration's secret.
|
||||
pub async fn set_ssh_secret(&self, id: &str, secret: Option<String>, save: bool) -> IdeResult<()> {
|
||||
let config = self.sources()?.into_iter().find(|s| s.id == id).and_then(|s| s.ssh).map(|s| s.config);
|
||||
if let Some(c) = config.filter(|c| !c.trim().is_empty()) {
|
||||
let save = self.ssh.config(&c).map(|c| c.save_secret).unwrap_or(save);
|
||||
self.ssh.set_secret(&c, secret, save);
|
||||
return Ok(());
|
||||
}
|
||||
self.remember(secrets::ssh_key(id), secret, save).await
|
||||
}
|
||||
|
||||
@@ -443,10 +451,27 @@ impl DbManager {
|
||||
|
||||
/// Trusts the host key that failed the last connection of `id` (adds it to `known_hosts`).
|
||||
pub async fn trust_ssh_host(&self, id: &str) -> IdeResult<()> {
|
||||
let key =
|
||||
self.unknown_host_keys.lock().remove(id).ok_or_else(|| IdeError::not_found("no host key to trust"))?;
|
||||
let path = self.known_hosts.clone();
|
||||
tokio::task::spawn_blocking(move || key.trust(&path)).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
if self.ssh.trust_host(&self.host_key(id)).await.is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
let config = self.sources()?.into_iter().find(|s| s.id == id).and_then(|s| s.ssh).map(|s| s.config);
|
||||
match config.filter(|c| !c.trim().is_empty()) {
|
||||
Some(c) => self.ssh.trust_host(&c).await,
|
||||
None => Err(IdeError::not_found("no host key to trust")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Key of the unknown host keys of data source `id`'s own SSH settings.
|
||||
fn host_key(&self, id: &str) -> String {
|
||||
format!("db:{}:{id}", self.root.display())
|
||||
}
|
||||
|
||||
/// How the SSH host of `ssh` is shown in errors.
|
||||
fn ssh_label(&self, ssh: &DbSsh) -> String {
|
||||
match self.ssh.config(ssh.config.trim()) {
|
||||
Ok(c) if !ssh.config.trim().is_empty() => c.label(),
|
||||
_ => ssh.host.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the SSH tunnel of `source` when it has one, returning the settings to
|
||||
@@ -470,35 +495,34 @@ impl DbManager {
|
||||
return Ok((local(&t), Some(t)));
|
||||
}
|
||||
}
|
||||
let secret = match ssh_secret {
|
||||
Some(s) => Some(s),
|
||||
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
let config = ssh.config.trim();
|
||||
let session = if config.is_empty() {
|
||||
let secret = match ssh_secret {
|
||||
Some(s) => Some(s),
|
||||
None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await,
|
||||
};
|
||||
let target = SshTarget {
|
||||
host: ssh.host.clone(),
|
||||
port: ssh.port,
|
||||
user: ssh.user.clone(),
|
||||
auth: ssh.auth,
|
||||
key_file: ssh.key_file.clone(),
|
||||
};
|
||||
Arc::new(self.ssh.connect(&self.host_key(&source.id), &target, secret.as_deref()).await?)
|
||||
} else {
|
||||
if let Some(s) = ssh_secret {
|
||||
let save = self.ssh.config(config)?.save_secret;
|
||||
self.ssh.set_secret(config, Some(s), save);
|
||||
}
|
||||
self.ssh.session(config).await?
|
||||
};
|
||||
let target_port = source.port.or(source.driver.default_port()).unwrap_or_default();
|
||||
let target_host = if source.host.trim().is_empty() { "localhost" } else { source.host.trim() };
|
||||
let spec = TunnelSpec {
|
||||
ssh,
|
||||
secret: secret.as_deref(),
|
||||
target_host,
|
||||
target_port,
|
||||
known_hosts: &self.known_hosts,
|
||||
ssh_config: self.ssh_config.as_deref(),
|
||||
};
|
||||
match Tunnel::open(spec).await {
|
||||
Ok(t) => {
|
||||
let t = Arc::new(t);
|
||||
if shared {
|
||||
self.tunnels.lock().insert(source.id.clone(), t.clone());
|
||||
}
|
||||
Ok((local(&t), Some(t)))
|
||||
}
|
||||
Err(OpenError::Other(e)) => Err(e),
|
||||
Err(OpenError::UnknownHostKey(k)) => {
|
||||
let message = format!("Unknown SSH host key for {}:{} ({})", k.host, k.port, k.fingerprint());
|
||||
self.unknown_host_keys.lock().insert(source.id.clone(), *k);
|
||||
Err(IdeError::new(ErrorKind::Protocol, message))
|
||||
}
|
||||
let t = Arc::new(Tunnel::open(session, target_host, target_port).await?);
|
||||
if shared {
|
||||
self.tunnels.lock().insert(source.id.clone(), t.clone());
|
||||
}
|
||||
Ok((local(&t), Some(t)))
|
||||
}
|
||||
|
||||
/// Connects with `source` as given (not necessarily saved): Test Connection.
|
||||
@@ -514,9 +538,13 @@ impl DbManager {
|
||||
None => self.password(source).await,
|
||||
};
|
||||
let (target, tunnel) = self.endpoint(source, ssh_secret, false).await?;
|
||||
let pool = Pool::connect(&target, password.as_deref(), Some(&self.root))
|
||||
.await
|
||||
.map_err(|e| through_tunnel(e, source, tunnel.is_some()))?;
|
||||
let pool = Pool::connect(&target, password.as_deref(), Some(&self.root)).await.map_err(|e| {
|
||||
through_tunnel(
|
||||
e,
|
||||
source,
|
||||
tunnel.is_some().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref(),
|
||||
)
|
||||
})?;
|
||||
let version = match source.driver {
|
||||
DbDriver::Sqlite => "SELECT 'SQLite ' || sqlite_version()",
|
||||
DbDriver::Postgres => "SELECT version()",
|
||||
@@ -553,7 +581,11 @@ impl DbManager {
|
||||
Ok(p) => Arc::new(p),
|
||||
Err(e) => {
|
||||
self.drop_unused_tunnel(source_id);
|
||||
return Err(through_tunnel(e, &source, tunnel.is_some()));
|
||||
return Err(through_tunnel(
|
||||
e,
|
||||
&source,
|
||||
tunnel.is_some().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref(),
|
||||
));
|
||||
}
|
||||
};
|
||||
// Another caller may have connected meanwhile; keep theirs.
|
||||
@@ -901,15 +933,10 @@ impl DbManager {
|
||||
}
|
||||
|
||||
fn remember_statement(&self, id: &str, statement: &str) {
|
||||
let (history, source, text) = (self.history.clone(), split_target(id).0.to_owned(), statement.to_owned());
|
||||
let time = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map_or(0.0, |d| d.as_secs_f64() * 1000.0);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
if let Err(e) = history.add(&source, &text, time) {
|
||||
tracing::warn!("{}", e.message);
|
||||
}
|
||||
});
|
||||
let _ = self.recorder.send((split_target(id).0.to_owned(), statement.to_owned(), time));
|
||||
}
|
||||
|
||||
/// The query history of data source `id`, newest first.
|
||||
|
||||
@@ -122,23 +122,16 @@ pub struct DbProperty {
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// How the SSH tunnel authenticates (IDEA's SSH configuration "Authentication type").
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum DbSshAuth {
|
||||
Password,
|
||||
/// A private key file, optionally protected by a passphrase.
|
||||
KeyPair,
|
||||
/// `~/.ssh/config` (HostName, User, Port, IdentityFile, ProxyJump) and the SSH agent.
|
||||
#[default]
|
||||
OpenSsh,
|
||||
}
|
||||
/// How the SSH tunnel authenticates (shared with Settings › SSH Configurations).
|
||||
pub use ide_core::remote_settings::SshAuth as DbSshAuth;
|
||||
|
||||
/// Connect through an SSH tunnel: the database host and port are reached from `host`.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase", default)]
|
||||
pub struct DbSsh {
|
||||
pub enabled: bool,
|
||||
/// A Settings › SSH Configurations id to connect with; empty = the fields below.
|
||||
pub config: String,
|
||||
/// Host name, or a `Host` alias of `~/.ssh/config` with [`DbSshAuth::OpenSsh`].
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
|
||||
@@ -1,474 +0,0 @@
|
||||
//! SSH tunnels for data sources: a listener on 127.0.0.1 whose connections are
|
||||
//! forwarded to the database through `direct-tcpip` channels of an SSH session
|
||||
//! (russh), optionally reached through `ProxyJump` hosts.
|
||||
//!
|
||||
//! Host keys are checked against `known_hosts`; an unknown key fails the
|
||||
//! connection with [`OpenError::UnknownHostKey`] so the UI can ask the user,
|
||||
//! and a changed key is always refused.
|
||||
|
||||
use crate::source::{DbSsh, DbSshAuth};
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::Mutex;
|
||||
use russh::client::{self, Handle};
|
||||
use russh::keys::{self, HashAlg, PrivateKeyWithHashAlg, PublicKey, PublicKeyOrCertificate, known_hosts};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
/// Generous: a relayed VPN path (e.g. Tailscale via DERP) can take ~1 s per round trip.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
const DEFAULT_IDENTITIES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/// A host key the user has not trusted yet.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnknownHostKey {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub key: PublicKey,
|
||||
}
|
||||
|
||||
impl UnknownHostKey {
|
||||
/// `ED25519 SHA256:…`, as OpenSSH prints it.
|
||||
pub fn fingerprint(&self) -> String {
|
||||
format!("{} {}", self.key.algorithm().as_str(), self.key.fingerprint(HashAlg::Sha256))
|
||||
}
|
||||
|
||||
/// Records the key in `known_hosts` (blocking).
|
||||
pub fn trust(&self, known_hosts: &Path) -> IdeResult<()> {
|
||||
known_hosts::learn_known_hosts_path(&self.host, self.port, &self.key, known_hosts)
|
||||
.map_err(|e| IdeError::new(ErrorKind::Io, format!("cannot update {}: {e}", known_hosts.display())))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum OpenError {
|
||||
UnknownHostKey(Box<UnknownHostKey>),
|
||||
Other(IdeError),
|
||||
}
|
||||
|
||||
impl From<IdeError> for OpenError {
|
||||
fn from(e: IdeError) -> Self {
|
||||
OpenError::Other(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Where and how to connect.
|
||||
pub struct TunnelSpec<'a> {
|
||||
pub ssh: &'a DbSsh,
|
||||
/// SSH password or key passphrase.
|
||||
pub secret: Option<&'a str>,
|
||||
/// The database, as seen from the SSH host.
|
||||
pub target_host: &'a str,
|
||||
pub target_port: u16,
|
||||
pub known_hosts: &'a Path,
|
||||
/// `~/.ssh/config` (used with [`DbSshAuth::OpenSsh`]).
|
||||
pub ssh_config: Option<&'a Path>,
|
||||
}
|
||||
|
||||
/// An open tunnel; dropping it stops forwarding and closes the SSH sessions.
|
||||
pub struct Tunnel {
|
||||
port: u16,
|
||||
session: Arc<Handle<Client>>,
|
||||
/// Sessions of the `ProxyJump` hosts, kept alive under `session`.
|
||||
_jumps: Vec<Handle<Client>>,
|
||||
accept: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Tunnel {
|
||||
/// Connects and starts listening on a free local port.
|
||||
pub async fn open(spec: TunnelSpec<'_>) -> Result<Tunnel, OpenError> {
|
||||
let host = format!("{}:{}", spec.ssh.host, spec.ssh.port.unwrap_or(22));
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, Self::open_inner(spec)).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => Err(IdeError::new(
|
||||
ErrorKind::Io,
|
||||
format!("timed out connecting to the SSH host {host} ({} s)", CONNECT_TIMEOUT.as_secs()),
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn open_inner(spec: TunnelSpec<'_>) -> Result<Tunnel, OpenError> {
|
||||
let hops = resolve_hops(spec.ssh, spec.ssh_config)?;
|
||||
let mut sessions: Vec<Handle<Client>> = Vec::new();
|
||||
for (i, hop) in hops.iter().enumerate() {
|
||||
let target = i == hops.len() - 1;
|
||||
let mut handle = match sessions.last() {
|
||||
None => connect::<tokio::net::TcpStream>(hop, spec.known_hosts, None).await?,
|
||||
Some(prev) => {
|
||||
let channel = prev
|
||||
.channel_open_direct_tcpip(hop.host.as_str(), u32::from(hop.port), "127.0.0.1", 0)
|
||||
.await
|
||||
.map_err(|e| io(format!("cannot reach {} through the jump host: {e}", hop.label())))?;
|
||||
connect(hop, spec.known_hosts, Some(channel.into_stream())).await?
|
||||
}
|
||||
};
|
||||
// Jump hosts always use the agent / identity files; the secret belongs to the target.
|
||||
let mode = if target { spec.ssh.auth } else { DbSshAuth::OpenSsh };
|
||||
authenticate(&mut handle, hop, mode, &spec.ssh.key_file, spec.secret).await?;
|
||||
sessions.push(handle);
|
||||
}
|
||||
let session = Arc::new(sessions.pop().expect("at least one hop"));
|
||||
// Fail fast with the real reason when the SSH host cannot reach the database
|
||||
// (a refused forward otherwise surfaces as the driver's connect timeout).
|
||||
let via = hops.last().expect("at least one hop").label();
|
||||
session
|
||||
.channel_open_direct_tcpip(spec.target_host, u32::from(spec.target_port), "127.0.0.1", 0)
|
||||
.await
|
||||
.map_err(|e| unreachable_target(&via, spec.target_host, spec.target_port, &e))?;
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await.map_err(|e| io(e.to_string()))?;
|
||||
let port = listener.local_addr().map_err(|e| io(e.to_string()))?.port();
|
||||
let accept = tokio::spawn(forward(listener, session.clone(), spec.target_host.to_owned(), spec.target_port));
|
||||
Ok(Tunnel { port, session, _jumps: sessions, accept })
|
||||
}
|
||||
|
||||
/// The local port to connect to instead of the database.
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
/// The SSH session ended (network loss, server restart).
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.session.is_closed()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tunnel {
|
||||
fn drop(&mut self) {
|
||||
self.accept.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward(listener: TcpListener, session: Arc<Handle<Client>>, host: String, port: u16) {
|
||||
loop {
|
||||
let Ok((mut socket, peer)) = listener.accept().await else { return };
|
||||
let session = session.clone();
|
||||
let host = host.clone();
|
||||
tokio::spawn(async move {
|
||||
match session
|
||||
.channel_open_direct_tcpip(host.as_str(), u32::from(port), "127.0.0.1", u32::from(peer.port()))
|
||||
.await
|
||||
{
|
||||
Ok(channel) => {
|
||||
let mut stream = channel.into_stream();
|
||||
let _ = tokio::io::copy_bidirectional(&mut socket, &mut stream).await;
|
||||
}
|
||||
Err(e) => tracing::warn!("SSH tunnel: cannot open a channel to {host}:{port}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn unreachable_target(via: &str, host: &str, port: u16, e: &russh::Error) -> IdeError {
|
||||
let why = match e {
|
||||
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::ConnectFailed) => {
|
||||
"connection refused or host unreachable"
|
||||
}
|
||||
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::AdministrativelyProhibited) => {
|
||||
"port forwarding is not allowed by the SSH server (AllowTcpForwarding)"
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
let why = if why.is_empty() { e.to_string() } else { why.to_owned() };
|
||||
io(format!("SSH host {via} cannot reach the database at {host}:{port}: {why}"))
|
||||
}
|
||||
|
||||
fn io(message: impl Into<String>) -> IdeError {
|
||||
IdeError::new(ErrorKind::Io, message)
|
||||
}
|
||||
|
||||
// --- hosts ---------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct Hop {
|
||||
host: String,
|
||||
port: u16,
|
||||
user: String,
|
||||
identity_files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Hop {
|
||||
fn label(&self) -> String {
|
||||
format!("{}@{}:{}", self.user, self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
fn home() -> Option<PathBuf> {
|
||||
dirs::home_dir()
|
||||
}
|
||||
|
||||
/// `~/x` → `$HOME/x`.
|
||||
pub(crate) fn expand(path: &str) -> PathBuf {
|
||||
match (path.strip_prefix("~/"), home()) {
|
||||
(Some(rest), Some(h)) => h.join(rest),
|
||||
_ => PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_user() -> String {
|
||||
std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One `~/.ssh/config` lookup (a missing or unreadable file means defaults).
|
||||
fn config_hop(config: Option<&Path>, alias: &str) -> (Hop, Option<String>) {
|
||||
let c = config
|
||||
.and_then(|p| russh_config::parse_path(p, alias).ok())
|
||||
.unwrap_or_else(|| russh_config::Config::default(alias));
|
||||
let identity_files = c.host_config.identity_file.clone().unwrap_or_default();
|
||||
let hop = Hop { host: c.host().to_owned(), port: c.port(), user: c.user(), identity_files };
|
||||
(hop, c.host_config.proxy_jump.clone())
|
||||
}
|
||||
|
||||
/// `[user@]host[:port]` of a `ProxyJump` entry.
|
||||
fn jump_hop(config: Option<&Path>, spec: &str) -> Hop {
|
||||
let (user, rest) = match spec.rsplit_once('@') {
|
||||
Some((u, r)) => (Some(u), r),
|
||||
None => (None, spec),
|
||||
};
|
||||
let (host, port) = match rest.rsplit_once(':') {
|
||||
Some((h, p)) if p.parse::<u16>().is_ok() => (h, p.parse().ok()),
|
||||
_ => (rest, None),
|
||||
};
|
||||
let (mut hop, _) = config_hop(config, host);
|
||||
if let Some(u) = user {
|
||||
hop.user = u.to_owned();
|
||||
}
|
||||
if let Some(p) = port {
|
||||
hop.port = p;
|
||||
}
|
||||
hop
|
||||
}
|
||||
|
||||
/// The SSH hosts to go through, the tunnel's host last.
|
||||
fn resolve_hops(ssh: &DbSsh, config: Option<&Path>) -> IdeResult<Vec<Hop>> {
|
||||
let host = ssh.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(IdeError::invalid("SSH host is empty"));
|
||||
}
|
||||
let (mut hop, proxy_jump) = if ssh.auth == DbSshAuth::OpenSsh {
|
||||
config_hop(config, host)
|
||||
} else {
|
||||
(Hop { host: host.to_owned(), port: 22, user: local_user(), identity_files: Vec::new() }, None)
|
||||
};
|
||||
if let Some(p) = ssh.port {
|
||||
hop.port = p;
|
||||
}
|
||||
if !ssh.user.trim().is_empty() {
|
||||
hop.user = ssh.user.trim().to_owned();
|
||||
}
|
||||
let mut hops: Vec<Hop> = proxy_jump
|
||||
.filter(|j| !j.eq_ignore_ascii_case("none"))
|
||||
.map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect())
|
||||
.unwrap_or_default();
|
||||
hops.push(hop);
|
||||
Ok(hops)
|
||||
}
|
||||
|
||||
// --- connection ----------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
enum HostKeyIssue {
|
||||
Unknown(PublicKey),
|
||||
Changed(usize),
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
host: String,
|
||||
port: u16,
|
||||
known_hosts: PathBuf,
|
||||
issue: Arc<Mutex<Option<HostKeyIssue>>>,
|
||||
}
|
||||
|
||||
impl client::Handler for Client {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
|
||||
let key = match key {
|
||||
PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(),
|
||||
PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()),
|
||||
};
|
||||
let issue = match known_hosts::check_known_hosts_path(&self.host, self.port, &key, &self.known_hosts) {
|
||||
Ok(true) => return Ok(true),
|
||||
Err(keys::Error::KeyChanged { line }) => HostKeyIssue::Changed(line),
|
||||
Ok(false) | Err(_) => HostKeyIssue::Unknown(key),
|
||||
};
|
||||
*self.issue.lock() = Some(issue);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect<S>(hop: &Hop, known_hosts: &Path, stream: Option<S>) -> Result<Handle<Client>, OpenError>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: None,
|
||||
keepalive_interval: Some(Duration::from_secs(30)),
|
||||
keepalive_max: 3,
|
||||
nodelay: true,
|
||||
..Default::default()
|
||||
});
|
||||
let issue = Arc::new(Mutex::new(None));
|
||||
let handler =
|
||||
Client { host: hop.host.clone(), port: hop.port, known_hosts: known_hosts.to_owned(), issue: issue.clone() };
|
||||
let result = match stream {
|
||||
None => client::connect(config, (hop.host.as_str(), hop.port), handler).await,
|
||||
Some(s) => client::connect_stream(config, s, handler).await,
|
||||
};
|
||||
result.map_err(|e| match issue.lock().take() {
|
||||
Some(HostKeyIssue::Unknown(key)) => {
|
||||
OpenError::UnknownHostKey(Box::new(UnknownHostKey { host: hop.host.clone(), port: hop.port, key }))
|
||||
}
|
||||
Some(HostKeyIssue::Changed(line)) => OpenError::Other(IdeError::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"SSH host key of {}:{} has changed (known_hosts line {line}); possible man-in-the-middle attack",
|
||||
hop.host, hop.port
|
||||
),
|
||||
)),
|
||||
None => OpenError::Other(io(format!("cannot connect to SSH host {}:{}: {e}", hop.host, hop.port))),
|
||||
})
|
||||
}
|
||||
|
||||
// --- authentication ------------------------------------------------------------
|
||||
|
||||
fn denied(message: String) -> IdeError {
|
||||
IdeError::new(ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
|
||||
async fn rsa_hash(handle: &Handle<Client>) -> Option<HashAlg> {
|
||||
handle.best_supported_rsa_hash().await.ok().flatten().flatten()
|
||||
}
|
||||
|
||||
async fn with_key(handle: &mut Handle<Client>, user: &str, key: keys::PrivateKey) -> IdeResult<bool> {
|
||||
let key = PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash(handle).await);
|
||||
let r = handle.authenticate_publickey(user, key).await.map_err(|e| io(e.to_string()))?;
|
||||
Ok(r.success())
|
||||
}
|
||||
|
||||
/// Every identity of the running SSH agent, until one is accepted.
|
||||
async fn with_agent(handle: &mut Handle<Client>, user: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
let agent = keys::agent::client::AgentClient::connect_env().await;
|
||||
#[cfg(windows)]
|
||||
let agent = keys::agent::client::AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await;
|
||||
let Ok(mut agent) = agent else { return false };
|
||||
let Ok(identities) = agent.request_identities().await else { return false };
|
||||
let hash = rsa_hash(handle).await;
|
||||
for id in identities {
|
||||
let ok = match id {
|
||||
keys::agent::AgentIdentity::PublicKey { key, .. } => {
|
||||
handle.authenticate_publickey_with(user, key, hash, &mut agent).await.map(|r| r.success())
|
||||
}
|
||||
keys::agent::AgentIdentity::Certificate { certificate, .. } => {
|
||||
handle.authenticate_certificate_with(user, certificate, hash, &mut agent).await.map(|r| r.success())
|
||||
}
|
||||
};
|
||||
if ok.unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn authenticate(
|
||||
handle: &mut Handle<Client>,
|
||||
hop: &Hop,
|
||||
mode: DbSshAuth,
|
||||
key_file: &str,
|
||||
secret: Option<&str>,
|
||||
) -> IdeResult<()> {
|
||||
let user = hop.user.as_str();
|
||||
let failed = || denied(format!("SSH authentication failed for {}", hop.label()));
|
||||
match mode {
|
||||
DbSshAuth::Password => {
|
||||
let Some(password) = secret else {
|
||||
return Err(denied(format!("SSH password required for {}", hop.label())));
|
||||
};
|
||||
let r = handle.authenticate_password(user, password).await.map_err(|e| io(e.to_string()))?;
|
||||
if r.success() { Ok(()) } else { Err(failed()) }
|
||||
}
|
||||
DbSshAuth::KeyPair => {
|
||||
if key_file.trim().is_empty() {
|
||||
return Err(IdeError::invalid("SSH private key file is not set"));
|
||||
}
|
||||
let path = expand(key_file.trim());
|
||||
let key = load_key(&path, secret)?;
|
||||
if with_key(handle, user, key).await? { Ok(()) } else { Err(failed()) }
|
||||
}
|
||||
DbSshAuth::OpenSsh => {
|
||||
if with_agent(handle, user).await {
|
||||
return Ok(());
|
||||
}
|
||||
let files: Vec<PathBuf> = if hop.identity_files.is_empty() {
|
||||
home().map(|h| DEFAULT_IDENTITIES.iter().map(|n| h.join(".ssh").join(n)).collect()).unwrap_or_default()
|
||||
} else {
|
||||
hop.identity_files.iter().map(|p| expand(&p.to_string_lossy())).collect()
|
||||
};
|
||||
let mut locked: Option<PathBuf> = None;
|
||||
for path in files.iter().filter(|p| p.is_file()) {
|
||||
match load_key(path, secret) {
|
||||
Ok(key) => {
|
||||
if with_key(handle, user, key).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::PermissionDenied => locked = locked.or(Some(path.clone())),
|
||||
Err(e) => tracing::warn!("SSH key {}: {}", path.display(), e.message),
|
||||
}
|
||||
}
|
||||
match locked {
|
||||
// An encrypted key could not be tried: ask for its passphrase.
|
||||
Some(path) if secret.is_none() => {
|
||||
Err(denied(format!("SSH key passphrase required for {}", path.display())))
|
||||
}
|
||||
_ => Err(failed()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads a private key; an encrypted key without (or with a wrong) passphrase is `PermissionDenied`.
|
||||
fn load_key(path: &Path, passphrase: Option<&str>) -> IdeResult<keys::PrivateKey> {
|
||||
match keys::load_secret_key(path, passphrase) {
|
||||
Ok(k) => Ok(k),
|
||||
Err(keys::Error::KeyIsEncrypted) => Err(denied(format!("SSH key passphrase required for {}", path.display()))),
|
||||
Err(e) if passphrase.is_some() => {
|
||||
Err(denied(format!("cannot decrypt SSH key {} (wrong passphrase?): {e}", path.display())))
|
||||
}
|
||||
Err(e) => Err(IdeError::invalid(format!("cannot read SSH key {}: {e}", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_aliases_and_proxy_jumps_from_ssh_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("config");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"Host db-bastion\n HostName 10.0.0.5\n User ops\n Port 2222\n ProxyJump [email protected]:2200,edge\n IdentityFile /keys/id_bastion\n\nHost edge\n HostName edge.example.com\n User edgeuser\n",
|
||||
)
|
||||
.unwrap();
|
||||
let ssh = DbSsh { enabled: true, host: "db-bastion".into(), auth: DbSshAuth::OpenSsh, ..Default::default() };
|
||||
let hops = resolve_hops(&ssh, Some(&config)).unwrap();
|
||||
let labels: Vec<String> = hops.iter().map(Hop::label).collect();
|
||||
assert_eq!(labels, ["[email protected]:2200", "[email protected]:22", "[email protected]:2222"]);
|
||||
assert_eq!(hops[2].identity_files, [PathBuf::from("/keys/id_bastion")]);
|
||||
|
||||
// Explicit fields win over the config.
|
||||
let ssh = DbSsh { user: "me".into(), port: Some(22), ..ssh };
|
||||
assert_eq!(resolve_hops(&ssh, Some(&config)).unwrap()[2].label(), "[email protected]:22");
|
||||
|
||||
// Without OpenSSH mode the host is used as is.
|
||||
let ssh = DbSsh { auth: DbSshAuth::Password, user: "u".into(), port: None, ..ssh };
|
||||
let hops = resolve_hops(&ssh, Some(&config)).unwrap();
|
||||
assert_eq!(hops.iter().map(Hop::label).collect::<Vec<_>>(), ["u@db-bastion:22"]);
|
||||
assert!(resolve_hops(&DbSsh::default(), None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ async fn setup() -> (tempfile::TempDir, DbManager) {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path().to_path_buf();
|
||||
std::fs::File::create(root.join("app.db")).unwrap();
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"));
|
||||
let ssh = std::sync::Arc::new(ide_ssh::SshManager::new(root.join("ssh-passwords.json")));
|
||||
let db = DbManager::new(root.clone(), root.join("passwords.json"), root.join("history.json"), ssh);
|
||||
db.save_sources(vec![DataSource {
|
||||
id: "s1".into(),
|
||||
name: "app".into(),
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
//! SSH tunnels against an in-process russh server that forwards `direct-tcpip`
|
||||
//! channels to a local echo server.
|
||||
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_db::tunnel::{OpenError, Tunnel, TunnelSpec};
|
||||
use ide_db::{DbSsh, DbSshAuth};
|
||||
use russh::Channel;
|
||||
use russh::keys::{PrivateKey, PublicKey, decode_secret_key};
|
||||
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ssh").join(name)
|
||||
}
|
||||
|
||||
fn key(name: &str, passphrase: Option<&str>) -> PrivateKey {
|
||||
decode_secret_key(&std::fs::read_to_string(fixture(name)).unwrap(), passphrase).unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Sshd {
|
||||
client_key: PublicKey,
|
||||
}
|
||||
|
||||
impl server::Server for Sshd {
|
||||
type Handler = Sshd;
|
||||
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Sshd {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl server::Handler for Sshd {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "dev" && key.key_data() == self.client_key.key_data() { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn channel_open_direct_tcpip(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
host: &str,
|
||||
port: u32,
|
||||
_originator_address: &str,
|
||||
_originator_port: u32,
|
||||
reply: ChannelOpenHandle,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
let Ok(mut upstream) = TcpStream::connect((host, port as u16)).await else {
|
||||
return Ok(());
|
||||
};
|
||||
reply.accept().await;
|
||||
tokio::spawn(async move {
|
||||
let mut stream = channel.into_stream();
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo server standing in for the database.
|
||||
async fn echo() -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut s, _)) = listener.accept().await {
|
||||
tokio::spawn(async move {
|
||||
let (mut r, mut w) = s.split();
|
||||
let _ = tokio::io::copy(&mut r, &mut w).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
async fn sshd() -> u16 {
|
||||
let config = Arc::new(server::Config {
|
||||
keys: vec![key("host", None)],
|
||||
auth_rejection_time: std::time::Duration::from_millis(10),
|
||||
auth_rejection_time_initial: Some(std::time::Duration::ZERO),
|
||||
..Default::default()
|
||||
});
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let mut server = Sshd { client_key: key("client", None).public_key().clone() };
|
||||
tokio::spawn(async move {
|
||||
let _ = server.run_on_socket(config, &listener).await;
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
struct Env {
|
||||
_tmp: tempfile::TempDir,
|
||||
known_hosts: PathBuf,
|
||||
ssh_port: u16,
|
||||
db_port: u16,
|
||||
}
|
||||
|
||||
async fn env() -> Env {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let known_hosts = tmp.path().join("known_hosts");
|
||||
Env { known_hosts, _tmp: tmp, ssh_port: sshd().await, db_port: echo().await }
|
||||
}
|
||||
|
||||
impl Env {
|
||||
fn ssh(&self, auth: DbSshAuth) -> DbSsh {
|
||||
DbSsh {
|
||||
enabled: true,
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(self.ssh_port),
|
||||
user: "dev".into(),
|
||||
auth,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn open(&self, ssh: &DbSsh, secret: Option<&str>) -> Result<Tunnel, OpenError> {
|
||||
Tunnel::open(TunnelSpec {
|
||||
ssh,
|
||||
secret,
|
||||
target_host: "127.0.0.1",
|
||||
target_port: self.db_port,
|
||||
known_hosts: &self.known_hosts,
|
||||
ssh_config: None,
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Trusts the server's key on the first connection, as the UI does.
|
||||
async fn open_trusted(&self, ssh: &DbSsh, secret: Option<&str>) -> Result<Tunnel, OpenError> {
|
||||
match self.open(ssh, secret).await {
|
||||
Err(OpenError::UnknownHostKey(k)) => {
|
||||
k.trust(&self.known_hosts).unwrap();
|
||||
self.open(ssh, secret).await
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn round_trip(t: &Tunnel) {
|
||||
let mut s = TcpStream::connect(("127.0.0.1", t.port())).await.unwrap();
|
||||
s.write_all(b"SELECT 1").await.unwrap();
|
||||
let mut buf = [0u8; 8];
|
||||
s.read_exact(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, b"SELECT 1");
|
||||
}
|
||||
|
||||
fn denied(r: Result<Tunnel, OpenError>) -> String {
|
||||
match r {
|
||||
Err(OpenError::Other(e)) if e.kind == ErrorKind::PermissionDenied => e.message,
|
||||
Err(OpenError::Other(e)) => panic!("expected permission denied, got {e:?}"),
|
||||
Err(OpenError::UnknownHostKey(k)) => panic!("unexpected unknown host key {}", k.fingerprint()),
|
||||
Ok(_) => panic!("expected permission denied, connected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_host_key_must_be_trusted_and_changed_key_is_refused() {
|
||||
let env = env().await;
|
||||
let ssh = env.ssh(DbSshAuth::Password);
|
||||
let Err(OpenError::UnknownHostKey(k)) = env.open(&ssh, Some("secret")).await else {
|
||||
panic!("expected an unknown host key");
|
||||
};
|
||||
assert!(k.fingerprint().starts_with("ssh-ed25519 SHA256:"), "{}", k.fingerprint());
|
||||
k.trust(&env.known_hosts).unwrap();
|
||||
round_trip(&env.open(&ssh, Some("secret")).await.ok().unwrap()).await;
|
||||
|
||||
// Another key recorded for the same host: refused, never offered for trust.
|
||||
let other = key("client", None).public_key().to_openssh().unwrap();
|
||||
std::fs::write(&env.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap();
|
||||
let msg = denied(env.open(&ssh, Some("secret")).await);
|
||||
assert!(msg.contains("has changed"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_authentication() {
|
||||
let env = env().await;
|
||||
let ssh = env.ssh(DbSshAuth::Password);
|
||||
let tunnel = env.open_trusted(&ssh, Some("secret")).await.ok().unwrap();
|
||||
round_trip(&tunnel).await;
|
||||
round_trip(&tunnel).await;
|
||||
assert!(!tunnel.is_closed());
|
||||
|
||||
assert!(denied(env.open(&ssh, None).await).starts_with("SSH password required"));
|
||||
assert!(denied(env.open(&ssh, Some("wrong")).await).starts_with("SSH authentication failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_pair_authentication_with_and_without_passphrase() {
|
||||
let env = env().await;
|
||||
let ssh = DbSsh { key_file: fixture("client").to_string_lossy().into(), ..env.ssh(DbSshAuth::KeyPair) };
|
||||
round_trip(&env.open_trusted(&ssh, None).await.ok().unwrap()).await;
|
||||
|
||||
// An encrypted key the server does not accept: passphrase first, then the key is refused.
|
||||
let locked = DbSsh { key_file: fixture("locked").to_string_lossy().into(), ..ssh.clone() };
|
||||
assert!(denied(env.open(&locked, None).await).starts_with("SSH key passphrase required"));
|
||||
assert!(denied(env.open(&locked, Some("nope")).await).contains("wrong passphrase"));
|
||||
assert!(denied(env.open(&locked, Some("pw")).await).starts_with("SSH authentication failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unreachable_database_fails_fast_with_the_reason() {
|
||||
let env = env().await;
|
||||
let ssh = env.ssh(DbSshAuth::Password);
|
||||
drop(env.open_trusted(&ssh, Some("secret")).await.ok().unwrap());
|
||||
let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port();
|
||||
let r = Tunnel::open(TunnelSpec {
|
||||
ssh: &ssh,
|
||||
secret: Some("secret"),
|
||||
target_host: "127.0.0.1",
|
||||
target_port: closed,
|
||||
known_hosts: &env.known_hosts,
|
||||
ssh_config: None,
|
||||
})
|
||||
.await;
|
||||
let Err(OpenError::Other(e)) = r else { panic!("expected an error") };
|
||||
assert!(e.message.contains(&format!("cannot reach the database at 127.0.0.1:{closed}")), "{}", e.message);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "ide-host"
|
||||
description = "The IDE backend without a UI toolkit: projects per window, terminals, processes, and the JSON-RPC server for remote development"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "openidea-server"
|
||||
path = "src/bin/server.rs"
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-text.workspace = true
|
||||
ide-vfs.workspace = true
|
||||
ide-workspace.workspace = true
|
||||
ide-language.workspace = true
|
||||
ide-index.workspace = true
|
||||
ide-syntax.workspace = true
|
||||
ide-search.workspace = true
|
||||
ide-lsp.workspace = true
|
||||
ide-vcs.workspace = true
|
||||
ide-dap.workspace = true
|
||||
ide-build.workspace = true
|
||||
ide-analysis.workspace = true
|
||||
ide-coverage.workspace = true
|
||||
ide-spell.workspace = true
|
||||
ide-terminal.workspace = true
|
||||
ide-run.workspace = true
|
||||
ide-sdk.workspace = true
|
||||
ide-http.workspace = true
|
||||
ide-db.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-remote.workspace = true
|
||||
ide-jsonrpc.workspace = true
|
||||
ide-agent.workspace = true
|
||||
ide-tools = { path = "../ide-tools" }
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tokio = { workspace = true, features = ["io-std"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
parking_lot.workspace = true
|
||||
dunce.workspace = true
|
||||
dirs.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-util"] }
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,75 @@
|
||||
//! `openidea-server --stdio`: the IDE backend for remote development, serving
|
||||
//! one desktop client over stdin / stdout (started by the client over SSH).
|
||||
|
||||
use ide_host::rpc::{self, Dispatcher};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_language::LanguageRegistry;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if args.iter().any(|a| a == "--version") {
|
||||
println!("openidea-server {} (protocol {})", env!("CARGO_PKG_VERSION"), rpc::PROTOCOL_VERSION);
|
||||
return;
|
||||
}
|
||||
if !args.iter().any(|a| a == "--stdio") {
|
||||
eprintln!("usage: openidea-server --stdio | --version");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// stdout carries the protocol: logs go to stderr.
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_env("OPENIDEA_LOG").unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let base = std::env::var_os("OPENIDEA_SERVER_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".openidea"));
|
||||
// An SSH exec channel gets a minimal PATH: take the login shell's, as the
|
||||
// desktop app does, plus the IDE-managed tools. Before any other thread starts.
|
||||
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path("openidea-server");
|
||||
let data_dir = base.join("data");
|
||||
ide_tools::extend_process_path(&data_dir);
|
||||
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data_dir));
|
||||
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data_dir));
|
||||
if let Some(refresh) = refresh_shell_path {
|
||||
refresh();
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("tokio runtime");
|
||||
let code = runtime.block_on(run(base));
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
async fn run(base: PathBuf) -> i32 {
|
||||
let (config_dir, data_dir) = (base.join("config"), base.join("data"));
|
||||
for dir in [&config_dir, &data_dir] {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
eprintln!("cannot create {}: {e}", dir.display());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
let languages = match LanguageRegistry::load(Some(&config_dir.join("languages"))) {
|
||||
Ok(l) => Arc::new(l),
|
||||
Err(e) => {
|
||||
eprintln!("language definitions: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let peer = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir,
|
||||
data_dir,
|
||||
languages,
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |window| rpc::event_sink(sink_peer.clone(), Some(window.to_owned()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
}));
|
||||
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
|
||||
rpc::serve(host.clone(), Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
|
||||
// `process::exit` skips destructors: nothing started for the client may outlive it.
|
||||
if tokio::time::timeout(std::time::Duration::from_secs(10), host.shutdown()).await.is_err() {
|
||||
tracing::warn!("language servers did not shut down in time");
|
||||
}
|
||||
0
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! The IDE backend without a UI toolkit: open projects per window, terminals,
|
||||
//! processes, settings and the remote services. The desktop shell (`src-tauri`)
|
||||
//! wraps one [`Host`] and adds windows, menus and plugins; `openidea-server`
|
||||
//! runs one on a remote machine and serves it over JSON-RPC ([`rpc`]).
|
||||
|
||||
pub mod ops;
|
||||
mod project;
|
||||
pub mod rpc;
|
||||
pub mod shell_env;
|
||||
|
||||
pub use project::{Project, apply_git_executable, apply_workspace_edit, history_retention};
|
||||
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, RequestId, SharedSink};
|
||||
use ide_language::{LanguageRegistry, Syntax};
|
||||
use ide_run::ProcessManager;
|
||||
use ide_terminal::TerminalManager;
|
||||
use ide_workspace::SettingsStore;
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
/// Event sink for one window (by label).
|
||||
pub type SinkFactory = Box<dyn Fn(&str) -> SharedSink + Send + Sync>;
|
||||
|
||||
pub struct HostConfig {
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
/// Grammars, possibly still being built on another thread.
|
||||
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
pub sink_for: SinkFactory,
|
||||
/// The window that owns what `window` starts (e.g. a settings window's parent).
|
||||
pub owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
pub global_settings: SettingsStore,
|
||||
pub terminals: TerminalManager,
|
||||
pub processes: ProcessManager,
|
||||
/// Debug sessions (adapters run on this machine, next to the project).
|
||||
pub debugger: Arc<ide_dap::DebugManager>,
|
||||
/// SSH connections of Settings › SSH Configurations.
|
||||
pub ssh: Arc<ide_ssh::SshManager>,
|
||||
/// SFTP deployment, remote file editing and runs on run targets.
|
||||
pub remote: Arc<ide_remote::RemoteService>,
|
||||
pub(crate) sink_for: SinkFactory,
|
||||
owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
/// One project per window, keyed by window label.
|
||||
projects: RwLock<HashMap<String, Arc<Project>>>,
|
||||
requests: Mutex<HashMap<RequestId, CancellationToken>>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime } = config;
|
||||
let global_settings = SettingsStore::load(&config_dir, None);
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
Self {
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
global_settings,
|
||||
// Sessions, runs and terminals report to the window that started them (`window_sink`).
|
||||
terminals: TerminalManager::new(),
|
||||
processes: ProcessManager::new(),
|
||||
debugger: ide_dap::DebugManager::new(languages.clone()),
|
||||
languages,
|
||||
syntax,
|
||||
config_dir,
|
||||
data_dir,
|
||||
sink_for,
|
||||
owner,
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
requests: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tree-sitter grammars (blocks only if start-up warm-up is still running).
|
||||
pub fn syntax(&self) -> Arc<Syntax> {
|
||||
self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone()
|
||||
}
|
||||
|
||||
/// Every open project (all windows).
|
||||
pub fn projects(&self) -> Vec<Arc<Project>> {
|
||||
self.projects.read().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// The project of `window` (or of the window owning it).
|
||||
pub fn project(&self, window: &str) -> IdeResult<Arc<Project>> {
|
||||
self.projects
|
||||
.read()
|
||||
.get((self.owner)(window))
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
|
||||
}
|
||||
|
||||
/// The window that owns what `window` starts.
|
||||
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
|
||||
(self.owner)(window)
|
||||
}
|
||||
|
||||
/// Event sink for things `window` starts (debug sessions, runs, terminals): its
|
||||
/// project's window-scoped sink, or a sink for that window when no project is open.
|
||||
pub fn window_sink(&self, window: &str) -> SharedSink {
|
||||
match self.project(window) {
|
||||
Ok(p) => p.sink.clone(),
|
||||
Err(_) => (self.sink_for)((self.owner)(window)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_project(&self, window: &str) {
|
||||
if let Some(project) = self.projects.write().remove(window) {
|
||||
project.agents.shutdown();
|
||||
let lsp = project.lsp.clone();
|
||||
self.runtime.spawn(async move { lsp.shutdown_all().await });
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops what this host started: language servers, agents, runs and terminals.
|
||||
/// For a process that exits without dropping the host (the remote server).
|
||||
pub async fn shutdown(&self) {
|
||||
self.processes.kill_all();
|
||||
self.terminals.kill_all();
|
||||
let projects: Vec<_> = self.projects.write().drain().map(|(_, p)| p).collect();
|
||||
for project in projects {
|
||||
project.agents.shutdown();
|
||||
project.lsp.shutdown_all().await;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- cancellable requests: the UI allocates ids, `cancel_request` cancels ----
|
||||
|
||||
pub fn begin_request(&self, id: RequestId) -> CancellationToken {
|
||||
let token = CancellationToken::new();
|
||||
if let Some(old) = self.requests.lock().insert(id, token.clone()) {
|
||||
old.cancel();
|
||||
}
|
||||
token
|
||||
}
|
||||
|
||||
pub fn end_request(&self, id: RequestId) {
|
||||
self.requests.lock().remove(&id);
|
||||
}
|
||||
|
||||
pub fn cancel_request(&self, id: RequestId) {
|
||||
if let Some(token) = self.requests.lock().remove(&id) {
|
||||
token.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn insert_project(&self, window: &str, project: Arc<Project>) {
|
||||
self.projects.write().insert(window.to_owned(), project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Agents tool window: coding agent tasks of the project (over ACP). In a remote
|
||||
//! window they run on the host, next to the project they work on.
|
||||
use super::Emit;
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_agent::{
|
||||
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask,
|
||||
};
|
||||
use ide_core::{IdeResult, IpcPath};
|
||||
|
||||
/// Streams the project's agent events to this window; returns every task.
|
||||
pub fn subscribe(host: &Host, window: &str, on_event: Emit<AgentEvent>) -> IdeResult<Vec<AgentTask>> {
|
||||
Ok(host.project(window)?.agents.subscribe(on_event))
|
||||
}
|
||||
|
||||
pub fn providers(host: &Host, window: &str) -> IdeResult<Vec<AgentProvider>> {
|
||||
Ok(host.project(window)?.agents.providers())
|
||||
}
|
||||
|
||||
/// Slash commands to complete for `provider` in `task`'s directory, or in the project
|
||||
/// before a task exists (skills and custom commands on disk included).
|
||||
pub async fn commands(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
provider: String,
|
||||
task: Option<String>,
|
||||
) -> IdeResult<Vec<AgentCommand>> {
|
||||
host.project(window)?.agents.commands(&provider, task.as_deref()).await
|
||||
}
|
||||
|
||||
pub fn entries(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentEntry>> {
|
||||
host.project(window)?.agents.entries(&task)
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.project(window)?.agents.create(request).await
|
||||
}
|
||||
|
||||
/// 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.project(window)?.agents.prompt(&task, prompt, now).await
|
||||
}
|
||||
|
||||
/// Drops a queued message and returns it (to edit it).
|
||||
pub fn unqueue(host: &Host, window: &str, task: String, queued: String) -> IdeResult<AgentQueued> {
|
||||
host.project(window)?.agents.unqueue(&task, &queued)
|
||||
}
|
||||
|
||||
/// Sends a queued message now, interrupting the current turn.
|
||||
pub fn send_queued_now(host: &Host, window: &str, task: String, queued: String) -> IdeResult<()> {
|
||||
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.project(window)?.agents.set_config(&task, &option, value).await
|
||||
}
|
||||
|
||||
pub fn cancel(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.cancel(&task)
|
||||
}
|
||||
|
||||
/// Answers a permission request; `option: None` cancels it.
|
||||
pub fn respond(host: &Host, window: &str, task: String, permission: u32, option: Option<String>) -> IdeResult<()> {
|
||||
host.project(window)?.agents.respond(&task, permission, option)
|
||||
}
|
||||
|
||||
pub fn archive(host: &Host, window: &str, task: String) -> IdeResult<()> {
|
||||
host.project(window)?.agents.archive(&task)
|
||||
}
|
||||
|
||||
pub async fn delete(host: &Host, window: &str, task: String, remove_worktree: bool) -> IdeResult<()> {
|
||||
host.project(window)?.agents.delete(&task, remove_worktree).await
|
||||
}
|
||||
|
||||
pub async fn changes(host: &Host, window: &str, task: String) -> IdeResult<Vec<AgentChange>> {
|
||||
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.project(window)?.agents.base_text(&task, path.into_path_buf()).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("agent_subscribe", |c| async move { subscribe(&c.host, &c.window, c.channel("onEvent")?) });
|
||||
d.add("agent_providers", |c| async move { providers(&c.host, &c.window) });
|
||||
d.add("agent_commands", |c| async move { commands(&c.host, &c.window, c.arg("provider")?, c.arg("task")?).await });
|
||||
d.add("agent_entries", |c| async move { entries(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add("agent_create", |c| async move { create(&c.host, &c.window, c.arg("request")?).await });
|
||||
d.add("agent_prompt", |c| async move {
|
||||
prompt(&c.host, &c.window, c.arg("task")?, c.arg("prompt")?, c.arg("now")?).await
|
||||
});
|
||||
d.add("agent_unqueue", |c| async move { unqueue(&c.host, &c.window, c.arg("task")?, c.arg("queued")?) });
|
||||
d.add(
|
||||
"agent_send_queued_now",
|
||||
|c| async move { send_queued_now(&c.host, &c.window, c.arg("task")?, c.arg("queued")?) },
|
||||
);
|
||||
d.add("agent_set_config", |c| async move {
|
||||
set_config(&c.host, &c.window, c.arg("task")?, c.arg("option")?, c.arg("value")?).await
|
||||
});
|
||||
d.add("agent_cancel", |c| async move { cancel(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add("agent_respond", |c| async move {
|
||||
respond(&c.host, &c.window, c.arg("task")?, c.arg("permission")?, c.arg("option")?)
|
||||
});
|
||||
d.add("agent_archive", |c| async move { archive(&c.host, &c.window, c.arg("task")?) });
|
||||
d.add(
|
||||
"agent_delete",
|
||||
|c| async move { delete(&c.host, &c.window, c.arg("task")?, c.arg("removeWorktree")?).await },
|
||||
);
|
||||
d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await });
|
||||
d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await });
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Code analysis over a scope of files: Inspect Code (batch LSP diagnostics),
|
||||
//! Locate Duplicates and Search / Replace Structurally (`ide-analysis`).
|
||||
//!
|
||||
//! Scopes are Find in Files scopes; excluded roots are never analysed. Every
|
||||
//! command takes a UI-allocated `RequestId` and is cancelled via `cancel_request`.
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::doc::blocking;
|
||||
use ide_analysis::{
|
||||
DuplicateOptions, DuplicatesReport, SourceFile, StructuralFile, StructuralQuery, StructuralSummary,
|
||||
};
|
||||
use ide_core::{CancellationToken, Diagnostic, DocId, IdeResult, IpcPath, LanguageId, RequestId};
|
||||
use ide_language::LanguageRegistry;
|
||||
use ide_lsp::{FileInspection, InspectHost, InspectOptions};
|
||||
use ide_search::{FileMask, SearchScope};
|
||||
use ide_workspace::Documents;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Files of `scope`, minus excluded roots and ignored names.
|
||||
fn files_in_scope(project: &Project, scope: &SearchScope, cancel: &CancellationToken) -> IdeResult<Vec<SourceFile>> {
|
||||
let mask = FileMask::new(&[], &project.search_excludes())?;
|
||||
Ok(ide_search::scope_files(&project.root, scope, &mask, None, cancel)?
|
||||
.into_iter()
|
||||
.map(|f| SourceFile { path: f.path, rel_path: f.rel_path })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Current text of a file (the unsaved buffer when open) and its language.
|
||||
fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<(LanguageId, String)> {
|
||||
if let Some(doc) = project.documents.by_path(path) {
|
||||
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()?;
|
||||
if loaded.binary || loaded.large || loaded.too_large {
|
||||
return None;
|
||||
}
|
||||
let language = languages.detect(path, loaded.text.lines().next());
|
||||
Some((language, loaded.text))
|
||||
}
|
||||
|
||||
// ---- Inspect Code ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InspectedFile {
|
||||
pub path: IpcPath,
|
||||
pub rel_path: String,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InspectSummary {
|
||||
/// Files inspected by a language server.
|
||||
pub files: u32,
|
||||
pub problems: u32,
|
||||
/// Files in scope no language server serves.
|
||||
pub skipped: u32,
|
||||
/// Files whose server failed or never became ready.
|
||||
pub failed: u32,
|
||||
pub cancelled: bool,
|
||||
pub elapsed_ms: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum InspectEvent {
|
||||
/// Files about to be inspected.
|
||||
Started {
|
||||
total: u32,
|
||||
},
|
||||
/// A file finished (with or without problems).
|
||||
File {
|
||||
file: InspectedFile,
|
||||
done: u32,
|
||||
},
|
||||
Done {
|
||||
summary: InspectSummary,
|
||||
},
|
||||
}
|
||||
|
||||
struct DocumentsHost(Arc<Documents>);
|
||||
|
||||
impl InspectHost for DocumentsHost {
|
||||
fn open(&self, path: &Path) -> IdeResult<(DocId, bool)> {
|
||||
let was_open = self.0.by_path(path).is_some();
|
||||
Ok((self.0.open(path)?.id, was_open))
|
||||
}
|
||||
|
||||
fn close(&self, doc: DocId) {
|
||||
self.0.close(doc);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Locate Duplicates ----
|
||||
|
||||
// ---- Structural Search ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum StructuralEvent {
|
||||
File { file: StructuralFile },
|
||||
Done { summary: StructuralSummary },
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StructuralLanguage {
|
||||
pub id: LanguageId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Inspect Code: diagnostics of every file in `scope` that a language server
|
||||
/// serves. Files stream through `on_event` as they finish; ends with `Done`.
|
||||
pub async fn inspect_code(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
scope: SearchScope,
|
||||
on_event: Channel<InspectEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let started = Instant::now();
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let listed = {
|
||||
let (project, cancel) = (project.clone(), cancel.clone());
|
||||
blocking(move || files_in_scope(&project, &scope, &cancel)).await
|
||||
};
|
||||
let files = match listed {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
host.end_request(request);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let total_in_scope = files.len() as u32;
|
||||
let rel: std::collections::HashMap<_, _> = files
|
||||
.iter()
|
||||
.filter(|f| project.lsp.serves_path(&f.path))
|
||||
.map(|f| (f.path.clone(), f.rel_path.clone()))
|
||||
.collect();
|
||||
let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() };
|
||||
let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 });
|
||||
|
||||
let mut paths: Vec<_> = rel.keys().cloned().collect();
|
||||
paths.sort();
|
||||
let tally = Mutex::new(summary.clone());
|
||||
let done = std::sync::atomic::AtomicU32::new(0);
|
||||
let docs = Arc::new(DocumentsHost(project.documents.clone()));
|
||||
project
|
||||
.lsp
|
||||
.inspect_files(docs, paths, InspectOptions::default(), cancel.clone(), &|path, outcome| {
|
||||
let done = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let diagnostics = {
|
||||
let mut t = tally.lock();
|
||||
match outcome {
|
||||
FileInspection::Inspected(items) => {
|
||||
t.files += 1;
|
||||
t.problems += items.len() as u32;
|
||||
items
|
||||
}
|
||||
FileInspection::NoServer => {
|
||||
t.skipped += 1;
|
||||
Vec::new()
|
||||
}
|
||||
FileInspection::Failed(message) => {
|
||||
tracing::debug!("inspect {}: {message}", path.display());
|
||||
t.failed += 1;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
let rel_path = rel.get(&path).cloned().unwrap_or_default();
|
||||
let file = InspectedFile { path: IpcPath(path), rel_path, diagnostics };
|
||||
let _ = on_event.send(InspectEvent::File { file, done });
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
summary = tally.into_inner();
|
||||
summary.cancelled = cancel.is_cancelled();
|
||||
summary.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
|
||||
let _ = on_event.send(InspectEvent::Done { summary });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locate Duplicates over the files of `scope`.
|
||||
pub async fn analysis_locate_duplicates(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
scope: SearchScope,
|
||||
options: DuplicateOptions,
|
||||
) -> IdeResult<DuplicatesReport> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let result = blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
let loader = |p: &Path| load(&project, &languages, p);
|
||||
Ok(ide_analysis::find_duplicates(&syntax, &files, &loader, &options, &cancel))
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Languages Search Structurally supports (those with a bundled grammar).
|
||||
pub async fn structural_languages(host: &Host, _window: &str) -> IdeResult<Vec<StructuralLanguage>> {
|
||||
let syntax = host.syntax();
|
||||
let mut out: Vec<StructuralLanguage> = host
|
||||
.languages
|
||||
.all()
|
||||
.into_iter()
|
||||
.filter(|c| syntax.supports(&c.id))
|
||||
.map(|c| StructuralLanguage { id: c.id, name: c.name })
|
||||
.collect();
|
||||
out.sort_by_key(|l| l.name.to_lowercase());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Checks a structural search template (and language) while the user types.
|
||||
pub async fn structural_validate(host: &Host, _window: &str, query: StructuralQuery) -> IdeResult<()> {
|
||||
let syntax = host.syntax();
|
||||
blocking(move || ide_analysis::validate_structural(&syntax, &query)).await
|
||||
}
|
||||
|
||||
/// Search Structurally over `scope`; files with matches stream through
|
||||
/// `on_event`, ending with `Done`. With a replacement, matches carry their
|
||||
/// replacement text (applied by the UI as a workspace edit).
|
||||
pub async fn structural_search(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
scope: SearchScope,
|
||||
query: StructuralQuery,
|
||||
on_event: Channel<StructuralEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
let result = blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
let loader = |p: &Path| load(&project, &languages, p);
|
||||
ide_analysis::search_structurally(&syntax, &files, &loader, &query, &cancel, &|file| {
|
||||
let _ = events.send(StructuralEvent::File { file });
|
||||
})
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
let _ = on_event.send(StructuralEvent::Done { summary: result? });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("inspect_code", |c| async move {
|
||||
inspect_code(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.sender("onEvent")?).await
|
||||
});
|
||||
d.add("analysis_locate_duplicates", |c| async move {
|
||||
analysis_locate_duplicates(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("structural_languages", |c| async move { structural_languages(&c.host, &c.window).await });
|
||||
d.add("structural_validate", |c| async move { structural_validate(&c.host, &c.window, c.arg("query")?).await });
|
||||
d.add("structural_search", |c| async move {
|
||||
structural_search(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.arg("query")?, c.sender("onEvent")?)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
//! Maven / Gradle tool windows (the build model and its background sync) and
|
||||
//! Build › Build Project (compiling with the project's build system).
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use crate::{Host, Project, apply_workspace_edit};
|
||||
|
||||
use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult};
|
||||
use ide_build::pom_edit::{ArtifactKey, Exclusion};
|
||||
use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_core::{FileEdit, IdeError, IdeEvent, IdeResult, Location, Range, RequestId, WorkspaceEdit};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
fn join_err(e: tokio::task::JoinError) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
}
|
||||
|
||||
/// `JAVA_HOME`: the project JDK, else the installed JDK matching `release` (none
|
||||
/// when the project names no version).
|
||||
fn java_env(project_jdk: Option<&ide_sdk::java::Jdk>, release: Option<u32>) -> BTreeMap<String, String> {
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r))));
|
||||
jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
|
||||
/// the Maven home and options from Settings › Maven.
|
||||
pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings));
|
||||
let jdk = ide_sdk::apply::project_jdk(root, settings);
|
||||
if let Some(m) = &mut model.maven {
|
||||
let mvn = ide_sdk::apply::maven_command(root, settings);
|
||||
let options = ide_sdk::apply::maven_options(root, settings);
|
||||
for p in &mut m.projects {
|
||||
p.env = java_env(jdk.as_ref(), p.java_version);
|
||||
if let Some(mvn) = &mvn {
|
||||
p.command = mvn.to_string_lossy().into_owned();
|
||||
}
|
||||
p.options = options.clone();
|
||||
}
|
||||
}
|
||||
if let Some(g) = &mut model.gradle {
|
||||
g.env = java_env(jdk.as_ref(), g.java_version);
|
||||
}
|
||||
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
|
||||
let missing = |release: Option<u32>| match jdk {
|
||||
Some(_) => None,
|
||||
None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release),
|
||||
};
|
||||
let explain = |error: &mut Option<String>, hint: Option<String>| {
|
||||
if let (Some(e), Some(hint)) = (error.as_mut(), hint) {
|
||||
*e = format!("{hint}\n{e}");
|
||||
}
|
||||
};
|
||||
if let Some(m) = &mut model.maven
|
||||
&& m.sync_error.is_some()
|
||||
{
|
||||
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
|
||||
explain(&mut m.sync_error, hint);
|
||||
}
|
||||
if let Some(g) = &mut model.gradle
|
||||
&& g.sync_error.is_some()
|
||||
{
|
||||
let hint = missing(g.java_version);
|
||||
explain(&mut g.sync_error, hint);
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
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)
|
||||
}
|
||||
|
||||
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
|
||||
/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
|
||||
/// Returns the refreshed model; a failed build is reported in its `syncError`.
|
||||
pub async fn build_sync(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
tool: BuildTool,
|
||||
request: RequestId,
|
||||
offline: bool,
|
||||
profiles: Vec<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
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 result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
BuildTool::Maven => current
|
||||
.maven
|
||||
.as_ref()
|
||||
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
|
||||
.map(|p| p.env.clone())
|
||||
.unwrap_or_default(),
|
||||
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
};
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
profiles,
|
||||
env,
|
||||
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(&root, &settings),
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
|
||||
}
|
||||
Ok(load(&root, &cache, &settings))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// The POM declaring `dependency` for the Maven module `pom` (project-relative): the
|
||||
/// module's own, else its nearest parent in the project; its path, text and declaration.
|
||||
fn declaring_pom(
|
||||
project: &Project,
|
||||
pom: &str,
|
||||
dependency: &ArtifactKey,
|
||||
) -> IdeResult<(std::path::PathBuf, String, Range)> {
|
||||
let model = ide_build::maven::load(&project.root);
|
||||
for rel in ide_build::pom_edit::declaring_poms(&model, pom) {
|
||||
let path = project.root.join(&rel);
|
||||
let text = project.text_of(&path)?;
|
||||
if let Some(range) = ide_build::pom_edit::declaration(&text, dependency) {
|
||||
return Ok((path, text, range));
|
||||
}
|
||||
}
|
||||
Err(IdeError::not_found(format!(
|
||||
"{dependency} is not declared in a pom.xml of this project (it may come from an imported BOM or an external parent POM)"
|
||||
)))
|
||||
}
|
||||
|
||||
/// Where the direct dependency `dependency` of the Maven module `pom` is declared.
|
||||
pub async fn maven_dependency_declaration(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
pom: String,
|
||||
dependency: ArtifactKey,
|
||||
) -> IdeResult<Location> {
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let (path, _, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
Ok(Location::file(path, range))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
}
|
||||
|
||||
/// Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
|
||||
/// `dependency` of the Maven module `pom`, through the open document when there is one
|
||||
/// (saved). Returns the declaration; nothing changes when it already excludes it.
|
||||
pub async fn maven_exclude_dependency(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
pom: String,
|
||||
dependency: ArtifactKey,
|
||||
exclusion: ArtifactKey,
|
||||
) -> IdeResult<Location> {
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let (path, text, range) = declaring_pom(&project, &pom, &dependency)?;
|
||||
match ide_build::pom_edit::add_exclusion(&text, &dependency, &exclusion) {
|
||||
Some(Exclusion::Edit(edit)) => {
|
||||
let edit =
|
||||
WorkspaceEdit { files: vec![FileEdit::Edit { path: path.clone().into(), edits: vec![edit] }] };
|
||||
apply_workspace_edit(&project.documents, &edit)?;
|
||||
}
|
||||
Some(Exclusion::Present) => {}
|
||||
None => return Err(IdeError::not_found(format!("{dependency} is not declared in {}", path.display()))),
|
||||
}
|
||||
Ok(Location::file(path, range))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)?
|
||||
}
|
||||
|
||||
/// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
|
||||
/// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable
|
||||
/// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build)
|
||||
/// are cleared first; the new messages are published as `build` diagnostics.
|
||||
pub async fn build_run(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
kind: BuildKind,
|
||||
file: Option<std::path::PathBuf>,
|
||||
request: RequestId,
|
||||
clear: Vec<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
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 token = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
source: compile::DIAGNOSTICS_SOURCE.into(),
|
||||
items: Vec::new(),
|
||||
});
|
||||
}
|
||||
let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?;
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
for (path, items) in compile::diagnostics(&result.messages) {
|
||||
sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items });
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("build_model", |c| async move { build_model(&c.host, &c.window).await });
|
||||
d.add("build_sync", |c| async move {
|
||||
build_sync(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("tool")?,
|
||||
c.arg("request")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("profiles")?,
|
||||
c.sender("onProgress")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("maven_dependency_declaration", |c| async move {
|
||||
maven_dependency_declaration(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?).await
|
||||
});
|
||||
d.add("maven_exclude_dependency", |c| async move {
|
||||
maven_exclude_dependency(&c.host, &c.window, c.arg("pom")?, c.arg("dependency")?, c.arg("exclusion")?).await
|
||||
});
|
||||
d.add("build_run", |c| async move {
|
||||
build_run(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("kind")?,
|
||||
c.arg("file")?,
|
||||
c.arg("request")?,
|
||||
c.arg("clear")?,
|
||||
c.sender("onEvent")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Raw file bytes for the image viewer and hex view (see `ide_vfs::bytes`).
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use crate::ops::doc::blocking;
|
||||
use ide_core::IdeResult;
|
||||
use ide_vfs::{ByteRange, FileProbe};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Size and text/binary classification, from the file's head only.
|
||||
pub async fn fs_probe(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<FileProbe> {
|
||||
blocking(move || ide_vfs::probe(&path)).await
|
||||
}
|
||||
|
||||
/// Up to `len` bytes from `offset` (capped per call), base64-encoded.
|
||||
pub async fn fs_read_range(_host: &Host, _window: &str, path: PathBuf, offset: u64, len: u32) -> IdeResult<ByteRange> {
|
||||
blocking(move || ide_vfs::read_range(&path, offset, len)).await
|
||||
}
|
||||
|
||||
/// An image file as a `data:` URL.
|
||||
pub async fn fs_read_image(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<String> {
|
||||
blocking(move || ide_vfs::read_data_url(&path)).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("fs_probe", |c| async move { fs_probe(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("fs_read_range", |c| async move {
|
||||
fs_read_range(&c.host, &c.window, c.arg("path")?, c.arg("offset")?, c.arg("len")?).await
|
||||
});
|
||||
d.add("fs_read_image", |c| async move { fs_read_image(&c.host, &c.window, c.arg("path")?).await });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Run with Coverage: launch plans and coverage reports.
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite};
|
||||
use ide_run::RunConfig;
|
||||
|
||||
/// Output directories kept per kind (older runs' data is deleted).
|
||||
pub const KEEP_RUNS: usize = 10;
|
||||
|
||||
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
|
||||
blocking(move || {
|
||||
let out = ide_run::wrap::new_output_dir(&cache, KEEP_RUNS)?;
|
||||
Ok(ide_coverage::prepare(&root, &config, &out))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reads the report a coverage run left behind.
|
||||
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
}
|
||||
|
||||
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
|
||||
/// with paths mapped into this project.
|
||||
pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult<CoverageSuite> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
blocking(move || ide_coverage::import(&path, &root)).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("coverage_prepare", |c| async move { coverage_prepare(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("coverage_collect", |c| async move { coverage_collect(&c.host, &c.window, c.arg("report")?).await });
|
||||
d.add("coverage_import", |c| async move { coverage_import(&c.host, &c.window, c.arg("path")?).await });
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DebugSessionId, IdeResult};
|
||||
use ide_dap::*;
|
||||
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> {
|
||||
let label = window;
|
||||
let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label));
|
||||
let settings = match host.project(label) {
|
||||
Ok(project) => project.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let custom = |command: &str| {
|
||||
let path = ide_sdk::apply::tool_command(&root, &settings, command)?;
|
||||
Some(path.to_string_lossy().into_owned())
|
||||
};
|
||||
host.debugger.start_with(config, owner, custom).await
|
||||
}
|
||||
|
||||
pub async fn debug_set_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
path: PathBuf,
|
||||
breakpoints: Vec<Breakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
host.debugger.set_breakpoints(path, breakpoints).await
|
||||
}
|
||||
|
||||
pub async fn debug_threads(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
|
||||
host.debugger.threads(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_stack_trace(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
) -> IdeResult<Vec<StackFrame>> {
|
||||
host.debugger.stack_trace(session, thread_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_scopes(host: &Host, _window: &str, session: DebugSessionId, frame_id: i64) -> IdeResult<Vec<Scope>> {
|
||||
host.debugger.scopes(session, frame_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_variables(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
reference: i64,
|
||||
) -> IdeResult<Vec<Variable>> {
|
||||
host.debugger.variables(session, reference).await
|
||||
}
|
||||
|
||||
pub async fn debug_evaluate(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
frame_id: Option<i64>,
|
||||
expression: String,
|
||||
) -> IdeResult<Variable> {
|
||||
host.debugger.evaluate(session, frame_id, expression).await
|
||||
}
|
||||
|
||||
pub async fn debug_resume(host: &Host, _window: &str, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
host.debugger.resume(session, thread_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
host.debugger.pause(session, thread_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_step(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
kind: StepKind,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.step(session, thread_id, kind).await
|
||||
}
|
||||
|
||||
pub async fn debug_run_to(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
path: PathBuf,
|
||||
line: u32,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.run_to(session, thread_id, path, line, force).await
|
||||
}
|
||||
|
||||
pub async fn debug_stop(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.stop(session).await
|
||||
}
|
||||
|
||||
/// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`.
|
||||
pub async fn debug_evaluate_in(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
frame_id: Option<i64>,
|
||||
expression: String,
|
||||
context: Option<String>,
|
||||
) -> IdeResult<Variable> {
|
||||
host.debugger.evaluate_in(session, frame_id, expression, context).await
|
||||
}
|
||||
|
||||
/// Last known verification states of a file's breakpoints (refetch on `breakpointsChanged`).
|
||||
pub fn debug_breakpoint_states(host: &Host, _window: &str, path: PathBuf) -> IdeResult<Vec<BreakpointState>> {
|
||||
Ok(host.debugger.breakpoint_states(&path))
|
||||
}
|
||||
|
||||
/// This window's live sessions, including adapter-started child sessions.
|
||||
pub fn debug_sessions(host: &Host, window: &str) -> IdeResult<Vec<SessionInfo>> {
|
||||
Ok(host.debugger.sessions(Some(host.owner_window(window))))
|
||||
}
|
||||
|
||||
pub fn debug_exception_filters(host: &Host, window: &str) -> IdeResult<Vec<SessionExceptionFilters>> {
|
||||
Ok(host.debugger.exception_filters(Some(host.owner_window(window))))
|
||||
}
|
||||
|
||||
pub async fn debug_set_exception_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
choices: std::collections::BTreeMap<String, bool>,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.set_exception_breakpoints(choices).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn debug_set_variable(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
container: i64,
|
||||
name: String,
|
||||
evaluate_name: Option<String>,
|
||||
frame_id: Option<i64>,
|
||||
value: String,
|
||||
) -> IdeResult<Variable> {
|
||||
host.debugger.set_variable(session, container, name, evaluate_name, frame_id, value).await
|
||||
}
|
||||
|
||||
/// Force Step Over: step over with every breakpoint suspended until the step ends.
|
||||
pub async fn debug_force_step_over(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.force_step_over(session, thread_id).await
|
||||
}
|
||||
|
||||
/// Smart Step Into: the calls on the current line of `frame_id`.
|
||||
pub async fn debug_step_in_targets(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
frame_id: i64,
|
||||
) -> IdeResult<Vec<StepInTarget>> {
|
||||
host.debugger.step_in_targets(session, frame_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_step_in_target(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
target_id: i64,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.step_in_target(session, thread_id, target_id).await
|
||||
}
|
||||
|
||||
/// Reset Frame (Drop Frame).
|
||||
pub async fn debug_restart_frame(host: &Host, _window: &str, session: DebugSessionId, frame_id: i64) -> IdeResult<()> {
|
||||
host.debugger.restart_frame(session, frame_id).await
|
||||
}
|
||||
|
||||
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
host.debugger.capabilities(session)
|
||||
}
|
||||
|
||||
/// Method breakpoints (all files); states aligned with `breakpoints`.
|
||||
pub async fn debug_set_function_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
breakpoints: Vec<FunctionBreakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
host.debugger.set_function_breakpoints(breakpoints).await
|
||||
}
|
||||
|
||||
/// Field watchpoints; states aligned with `breakpoints`.
|
||||
pub async fn debug_set_data_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
breakpoints: Vec<DataBreakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
host.debugger.set_data_breakpoints(breakpoints).await
|
||||
}
|
||||
|
||||
pub async fn debug_data_breakpoint_info(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
container: i64,
|
||||
name: String,
|
||||
frame_id: Option<i64>,
|
||||
) -> IdeResult<DataBreakpointInfo> {
|
||||
host.debugger.data_breakpoint_info(session, container, name, frame_id).await
|
||||
}
|
||||
|
||||
/// Attach to Process: the user's processes a debugger can attach to.
|
||||
pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResult<Vec<attach::AttachableProcess>> {
|
||||
tokio::task::spawn_blocking(attach::attachable_processes)
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
|
||||
}
|
||||
|
||||
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
|
||||
/// 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,
|
||||
request: ide_core::RequestId,
|
||||
jvm_host: String,
|
||||
port: u16,
|
||||
) -> IdeResult<u16> {
|
||||
let token = host.begin_request(request);
|
||||
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// HotSwap (Reload Changed Classes) in a java-debug session.
|
||||
pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<HotSwapResult> {
|
||||
host.debugger.redefine_classes(session).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("debug_set_breakpoints", |c| async move {
|
||||
debug_set_breakpoints(&c.host, &c.window, c.arg("path")?, c.arg("breakpoints")?).await
|
||||
});
|
||||
d.add("debug_threads", |c| async move { debug_threads(&c.host, &c.window, c.arg("session")?).await });
|
||||
d.add("debug_stack_trace", |c| async move {
|
||||
debug_stack_trace(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await
|
||||
});
|
||||
d.add(
|
||||
"debug_scopes",
|
||||
|c| async move { debug_scopes(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await },
|
||||
);
|
||||
d.add("debug_variables", |c| async move {
|
||||
debug_variables(&c.host, &c.window, c.arg("session")?, c.arg("reference")?).await
|
||||
});
|
||||
d.add("debug_evaluate", |c| async move {
|
||||
debug_evaluate(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?, c.arg("expression")?).await
|
||||
});
|
||||
d.add(
|
||||
"debug_resume",
|
||||
|c| async move { debug_resume(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
|
||||
);
|
||||
d.add(
|
||||
"debug_pause",
|
||||
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
|
||||
);
|
||||
d.add("debug_step", |c| async move {
|
||||
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
|
||||
});
|
||||
d.add("debug_run_to", |c| async move {
|
||||
debug_run_to(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("threadId")?,
|
||||
c.arg("path")?,
|
||||
c.arg("line")?,
|
||||
c.arg("force")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_stop", |c| async move { debug_stop(&c.host, &c.window, c.arg("session")?).await });
|
||||
d.add("debug_evaluate_in", |c| async move {
|
||||
debug_evaluate_in(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("frameId")?,
|
||||
c.arg("expression")?,
|
||||
c.arg("context")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_breakpoint_states", |c| async move { debug_breakpoint_states(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("debug_sessions", |c| async move { debug_sessions(&c.host, &c.window) });
|
||||
d.add("debug_exception_filters", |c| async move { debug_exception_filters(&c.host, &c.window) });
|
||||
d.add("debug_set_exception_breakpoints", |c| async move {
|
||||
debug_set_exception_breakpoints(&c.host, &c.window, c.arg("choices")?).await
|
||||
});
|
||||
d.add("debug_set_variable", |c| async move {
|
||||
debug_set_variable(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("container")?,
|
||||
c.arg("name")?,
|
||||
c.arg("evaluateName")?,
|
||||
c.arg("frameId")?,
|
||||
c.arg("value")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_force_step_over", |c| async move {
|
||||
debug_force_step_over(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await
|
||||
});
|
||||
d.add("debug_step_in_targets", |c| async move {
|
||||
debug_step_in_targets(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
|
||||
});
|
||||
d.add("debug_step_in_target", |c| async move {
|
||||
debug_step_in_target(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("targetId")?).await
|
||||
});
|
||||
d.add("debug_restart_frame", |c| async move {
|
||||
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
|
||||
});
|
||||
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
|
||||
d.add("debug_set_function_breakpoints", |c| async move {
|
||||
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
|
||||
});
|
||||
d.add("debug_set_data_breakpoints", |c| async move {
|
||||
debug_set_data_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
|
||||
});
|
||||
d.add("debug_data_breakpoint_info", |c| async move {
|
||||
debug_data_breakpoint_info(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("container")?,
|
||||
c.arg("name")?,
|
||||
c.arg("frameId")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
|
||||
d.add("debug_listen_for_jvm", |c| async move {
|
||||
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
|
||||
});
|
||||
d.add(
|
||||
"debug_redefine_classes",
|
||||
|c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use crate::Host;
|
||||
use ide_core::{ChangeSet, DocId, IdeResult};
|
||||
use ide_workspace::DocumentInfo;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn open(host: &Host, window: &str, path: PathBuf) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let info = project.documents.open(&path)?;
|
||||
project.index.mark_recent(&path);
|
||||
Ok(info)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
host.project(window)?.documents.close(doc);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
|
||||
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
|
||||
host.project(window)?.documents.edit(doc, base_version, changes)
|
||||
}
|
||||
|
||||
pub async fn save(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.save(doc)).await
|
||||
}
|
||||
|
||||
pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.save_all()).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
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,125 @@
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeError, IdeResult, OutlineItem, Range};
|
||||
use ide_language::{FoldRange, SyntaxHighlights};
|
||||
use ide_text::LineEnding;
|
||||
use ide_workspace::{DocumentInfo, HistoryEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool,
|
||||
/// keeping it off the main thread and the async runtime's workers.
|
||||
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Opens a read-only virtual document (see `lsp_virtual_document`) as an
|
||||
/// editor buffer, so syntax features and language-server requests work in it.
|
||||
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
let vd = project.lsp.virtual_document(&uri).await?;
|
||||
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
|
||||
}
|
||||
|
||||
/// "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 fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
|
||||
host.project(window)?.documents.set_line_ending(doc, line_ending)
|
||||
}
|
||||
|
||||
/// Structure view: LSP document symbols when available (with tree-sitter
|
||||
/// visibility), tree-sitter otherwise.
|
||||
/// Tree-sitter answers come from the per-document tree cache (incremental
|
||||
/// re-parse); large documents get no syntax structure.
|
||||
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
|
||||
blocking(move || {
|
||||
let snap = match project.documents.text_snapshot(doc) {
|
||||
Ok(snap) => snap,
|
||||
Err(e) => return lsp.ok_or(e),
|
||||
};
|
||||
let syntax = || project.syntax.outline(doc, &snap.language, snap.version, &snap.text);
|
||||
Ok(match lsp {
|
||||
// Server symbols carry no visibility; borrow it from the syntax tree.
|
||||
Some(mut items) => {
|
||||
if !snap.large {
|
||||
ide_language::merge_visibility(&mut items, &syntax());
|
||||
}
|
||||
items
|
||||
}
|
||||
None if snap.large => Vec::new(),
|
||||
None => syntax(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<FoldRange>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tree-sitter highlighting for languages the UI has no CodeMirror mode for;
|
||||
/// empty when the grammar has no highlights query or the document is large.
|
||||
pub async fn doc_highlights(host: &Host, window: &str, doc: DocId) -> IdeResult<SyntaxHighlights> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(SyntaxHighlights::default());
|
||||
}
|
||||
Ok(project.syntax.highlights(doc, &snap.language, snap.version, &snap.text))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
|
||||
/// Large documents fall back to word / line / document ranges.
|
||||
pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: u32) -> IdeResult<Vec<Range>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
|
||||
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn history_list(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
|
||||
host.project(window)?.history.list(&path)
|
||||
}
|
||||
|
||||
pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult<String> {
|
||||
host.project(window)?.history.content(&id)
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
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_set_encoding", |c| async move {
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
|
||||
});
|
||||
d.add("doc_set_line_ending", |c| async move {
|
||||
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
|
||||
});
|
||||
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_highlights", |c| async move { doc_highlights(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_selection_ranges", |c| async move {
|
||||
doc_selection_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?).await
|
||||
});
|
||||
d.add("history_list", |c| async move { history_list(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("history_content", |c| async move { history_content(&c.host, &c.window, c.arg("id")?) });
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use ide_vfs::FileEntry;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Files of a deleted folder snapshotted in Local History.
|
||||
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
|
||||
|
||||
pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
|
||||
let excludes = host
|
||||
.project(window)
|
||||
.map(|p| {
|
||||
let globs = p.exclude_globs();
|
||||
// `read_dir` matches relative to the enclosing repository: re-anchor
|
||||
// project-root patterns (`/build`) when the project is a repo subfolder.
|
||||
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
|
||||
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
|
||||
Some(rel) => {
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
|
||||
}
|
||||
None => globs,
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ide_vfs::read_dir(&path, &excludes)
|
||||
}
|
||||
|
||||
/// Creates an empty file; inside a project, honours the EditorConfig
|
||||
/// `charset` (writes the BOM for `utf-8-bom` / UTF-16).
|
||||
pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
match host.project(window) {
|
||||
Ok(p) => {
|
||||
let defaults = p.documents.file_defaults();
|
||||
if defaults.editorconfig {
|
||||
ide_workspace::editorconfig::create_file(p.documents.editorconfig(), &path)?;
|
||||
} else {
|
||||
ide_vfs::create_file(&path)?;
|
||||
}
|
||||
// No EditorConfig charset: File Encodings › "Create UTF-8 files with BOM".
|
||||
let empty = std::fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(false);
|
||||
let bom = defaults.new_file_bytes();
|
||||
if empty && !bom.is_empty() {
|
||||
std::fs::write(&path, bom)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => ide_vfs::create_file(&path),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
match host.project(window) {
|
||||
Ok(p) => p.documents.set_writable(&path, writable),
|
||||
Err(_) => ide_vfs::set_writable(&path, writable).map(|()| !writable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves to the trash; Local History keeps the contents so deleted files can be restored.
|
||||
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
let history = host.project(window).ok().map(|p| p.history.clone());
|
||||
host.blocking(move || {
|
||||
if let Some(h) = history {
|
||||
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
ide_vfs::delete(&path)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::copy(&from, &to)).await
|
||||
}
|
||||
|
||||
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
|
||||
pub async fn read_text(host: &Host, window: &str, path: PathBuf) -> IdeResult<String> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || match project {
|
||||
Some(p) => p.text_of(&path),
|
||||
None => Ok(ide_vfs::load_text(&path)?.text),
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Local History beyond single files: folders, labels, restoring deleted files.
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::IdeResult;
|
||||
use ide_workspace::DirectoryHistory;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::doc::blocking;
|
||||
|
||||
/// Show History on a folder: snapshots of the files under it (deleted ones included) and labels.
|
||||
pub async fn history_list_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<DirectoryHistory> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.history.list_dir(&path)).await
|
||||
}
|
||||
|
||||
/// Local History › Put Label…: a named point in the history of every file.
|
||||
pub fn history_put_label(host: &Host, window: &str, label: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
project.history.put_label(&project.root, &label)
|
||||
}
|
||||
|
||||
/// Revert to a snapshot: writes its content to `path` (re-creating a deleted
|
||||
/// file and its folders); an open editor updates in place.
|
||||
pub async fn history_restore(host: &Host, window: &str, path: PathBuf, id: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let text = project.history.content(&id)?;
|
||||
if let Some(doc) = project.documents.by_path(&path) {
|
||||
project.documents.replace_all(doc, &text)?;
|
||||
return project.documents.save(doc);
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if !path.exists() {
|
||||
ide_vfs::create_file(&path)?;
|
||||
}
|
||||
let doc = project.documents.open(&path)?.id;
|
||||
let result = project.documents.replace_all(doc, &text).and_then(|_| project.documents.save(doc));
|
||||
project.documents.close(doc);
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("history_list_dir", |c| async move { history_list_dir(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("history_put_label", |c| async move { history_put_label(&c.host, &c.window, c.arg("label")?) });
|
||||
d.add(
|
||||
"history_restore",
|
||||
|c| async move { history_restore(&c.host, &c.window, c.arg("path")?, c.arg("id")?).await },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::build::load;
|
||||
use ide_build::compile;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_lsp::jdtls::JavaLaunchInfo;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Builds the workspace (when `build`), then resolves the runtime (or test)
|
||||
/// classpath and the JDK of the module at `module` (absolute, or relative to the project root).
|
||||
/// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first.
|
||||
pub async fn java_resolve_launch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
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());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut info = project.lsp.java_launch(&module, test, build).await?;
|
||||
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());
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_debug_server(&module).await
|
||||
}
|
||||
|
||||
/// Incremental build of the jdtls workspace serving `module` (e.g. before HotSwap).
|
||||
pub async fn java_build_workspace(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("java_resolve_launch", |c| async move {
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?).await
|
||||
});
|
||||
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
use crate::Host;
|
||||
use crate::apply_workspace_edit as apply_edit;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
use ide_lsp::types::*;
|
||||
|
||||
pub async fn lsp_completion(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
trigger: Option<String>,
|
||||
) -> IdeResult<CompletionList> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.completion(doc, pos, trigger, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing.
|
||||
pub async fn lsp_inline_completion(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
automatic: bool,
|
||||
) -> IdeResult<Vec<InlineCompletionItem>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_resolve_completion(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
item: CompletionItem,
|
||||
) -> IdeResult<CompletionItem> {
|
||||
host.project(window)?.lsp.resolve_completion(doc, item).await
|
||||
}
|
||||
|
||||
pub async fn lsp_hover(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<Hover>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.hover(doc, pos, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
|
||||
host.project(window)?.lsp.signature_help(doc, pos).await
|
||||
}
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
host.project(window)?.lsp.goto(doc, pos, kind).await
|
||||
}
|
||||
|
||||
/// Implementations of the symbols at `positions`, one list per position (the
|
||||
/// editor's "is implemented / overridden" gutter markers). Cancel with
|
||||
/// `cancel_request(request)`.
|
||||
pub async fn lsp_implementations(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
positions: Vec<Pos>,
|
||||
) -> IdeResult<Vec<Vec<Location>>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.implementations(doc, &positions, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Contents of a virtual document (library class, decompiled code) that a
|
||||
/// language server returned a non-`file:` URI for. Cached per project.
|
||||
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
/// References of the symbol at `pos`. `project_wide` also searches project files
|
||||
/// outside the language server's analysis scope; bulk scans over many symbols
|
||||
/// leave it off. Cancel with `cancel_request(request)`.
|
||||
pub async fn lsp_references(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
include_declaration: bool,
|
||||
project_wide: bool,
|
||||
) -> IdeResult<References> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Vec<Highlight>> {
|
||||
host.project(window)?.lsp.document_highlights(doc, pos).await
|
||||
}
|
||||
|
||||
pub async fn lsp_workspace_symbols(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
query: String,
|
||||
) -> IdeResult<Vec<WorkspaceSymbol>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.workspace_symbols(&query, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_code_actions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<Vec<CodeAction>> {
|
||||
host.project(window)?.lsp.code_actions(doc, range, only).await
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
pub async fn lsp_code_action_query(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<CodeActionQuery> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.code_action_query(doc, range, only, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolves a code action's edit without applying it (intention preview).
|
||||
pub async fn lsp_resolve_code_action(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
action: CodeAction,
|
||||
) -> IdeResult<CodeAction> {
|
||||
if action.edit.is_some() {
|
||||
return Ok(action);
|
||||
}
|
||||
host.project(window)?.lsp.resolve_code_action(doc, action).await
|
||||
}
|
||||
|
||||
/// Resolves (if needed), applies the edit, then runs the command of a code action.
|
||||
pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let action = if action.edit.is_none() && action.command.is_none() {
|
||||
project.lsp.resolve_code_action(doc, action).await?
|
||||
} else {
|
||||
action
|
||||
};
|
||||
if let Some(edit) = &action.edit {
|
||||
apply_edit(&project.documents, edit)?;
|
||||
}
|
||||
if let Some(command) = action.command {
|
||||
project.lsp.execute_command(doc, command).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
|
||||
host.project(window)?.lsp.prepare_rename(doc, pos).await
|
||||
}
|
||||
|
||||
/// Computes the rename edit without applying it, so the UI can preview conflicts.
|
||||
pub async fn lsp_rename(host: &Host, window: &str, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
|
||||
host.project(window)?.lsp.rename(doc, pos, new_name).await
|
||||
}
|
||||
|
||||
pub async fn lsp_format(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
host.project(window)?.lsp.format(doc, range, options).await
|
||||
}
|
||||
|
||||
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
|
||||
host.project(window)?.lsp.inlay_hints(doc, range).await
|
||||
}
|
||||
|
||||
pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult<Option<SemanticTokens>> {
|
||||
host.project(window)?.lsp.semantic_tokens(doc).await
|
||||
}
|
||||
|
||||
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
|
||||
host.project(window)?.lsp.code_lens(doc).await
|
||||
}
|
||||
|
||||
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
|
||||
pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
|
||||
host.project(window)?.lsp.document_links(doc).await
|
||||
}
|
||||
|
||||
pub async fn lsp_call_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
host.project(window)?.lsp.call_hierarchy(doc, pos, direction).await
|
||||
}
|
||||
|
||||
pub async fn lsp_type_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
host.project(window)?.lsp.type_hierarchy(doc, pos, direction).await
|
||||
}
|
||||
|
||||
pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
|
||||
Ok(host.project(window)?.lsp.servers())
|
||||
}
|
||||
|
||||
pub fn lsp_resend_diagnostics(host: &Host, window: &str) -> IdeResult<()> {
|
||||
host.project(window)?.lsp.resend_diagnostics();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
|
||||
host.project(window)?.lsp.restart(server).await
|
||||
}
|
||||
|
||||
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
|
||||
pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
// Multi-file refactorings leave a Local History point to go back to.
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
apply_edit(&project.documents, &edit)
|
||||
}
|
||||
|
||||
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
|
||||
/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
|
||||
pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> {
|
||||
host.project(window)?.lsp.execute_command(doc, command).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_completion", |c| async move {
|
||||
lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await
|
||||
});
|
||||
d.add("lsp_inline_completion", |c| async move {
|
||||
lsp_inline_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("automatic")?)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_resolve_completion", |c| async move {
|
||||
lsp_resolve_completion(&c.host, &c.window, c.arg("doc")?, c.arg("item")?).await
|
||||
});
|
||||
d.add("lsp_hover", |c| async move {
|
||||
lsp_hover(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_signature_help", |c| async move {
|
||||
lsp_signature_help(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add(
|
||||
"lsp_goto",
|
||||
|c| async move { lsp_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("kind")?).await },
|
||||
);
|
||||
d.add("lsp_implementations", |c| async move {
|
||||
lsp_implementations(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("positions")?).await
|
||||
});
|
||||
d.add("lsp_virtual_document", |c| async move { lsp_virtual_document(&c.host, &c.window, c.arg("uri")?).await });
|
||||
d.add("lsp_references", |c| async move {
|
||||
lsp_references(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("request")?,
|
||||
c.arg("doc")?,
|
||||
c.arg("pos")?,
|
||||
c.arg("includeDeclaration")?,
|
||||
c.arg("projectWide")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_document_highlights", |c| async move {
|
||||
lsp_document_highlights(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_workspace_symbols", |c| async move {
|
||||
lsp_workspace_symbols(&c.host, &c.window, c.arg("request")?, c.arg("query")?).await
|
||||
});
|
||||
d.add("lsp_code_actions", |c| async move {
|
||||
lsp_code_actions(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("only")?).await
|
||||
});
|
||||
d.add("lsp_code_action_query", |c| async move {
|
||||
lsp_code_action_query(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("range")?, c.arg("only")?)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_resolve_code_action", |c| async move {
|
||||
lsp_resolve_code_action(&c.host, &c.window, c.arg("doc")?, c.arg("action")?).await
|
||||
});
|
||||
d.add("lsp_apply_code_action", |c| async move {
|
||||
lsp_apply_code_action(&c.host, &c.window, c.arg("doc")?, c.arg("action")?).await
|
||||
});
|
||||
d.add("lsp_prepare_rename", |c| async move {
|
||||
lsp_prepare_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_rename", |c| async move {
|
||||
lsp_rename(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("newName")?).await
|
||||
});
|
||||
d.add("lsp_format", |c| async move {
|
||||
lsp_format(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("options")?).await
|
||||
});
|
||||
d.add(
|
||||
"lsp_inlay_hints",
|
||||
|c| async move { lsp_inlay_hints(&c.host, &c.window, c.arg("doc")?, c.arg("range")?).await },
|
||||
);
|
||||
d.add("lsp_semantic_tokens", |c| async move { lsp_semantic_tokens(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_code_lens", |c| async move { lsp_code_lens(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_document_links", |c| async move { lsp_document_links(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("lsp_call_hierarchy", |c| async move {
|
||||
lsp_call_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
});
|
||||
d.add("lsp_type_hierarchy", |c| async move {
|
||||
lsp_type_hierarchy(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("direction")?).await
|
||||
});
|
||||
d.add("lsp_servers", |c| async move { lsp_servers(&c.host, &c.window) });
|
||||
d.add("lsp_resend_diagnostics", |c| async move { lsp_resend_diagnostics(&c.host, &c.window) });
|
||||
d.add("lsp_restart", |c| async move { lsp_restart(&c.host, &c.window, c.arg("server")?).await });
|
||||
d.add("apply_workspace_edit", |c| async move { apply_workspace_edit(&c.host, &c.window, c.arg("edit")?) });
|
||||
d.add("lsp_execute_command", |c| async move {
|
||||
lsp_execute_command(&c.host, &c.window, c.arg("doc")?, c.arg("command")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//! Command implementations shared by the desktop shell's IPC commands and the
|
||||
//! remote server ([`crate::rpc`]): each takes the [`Host`], the calling window
|
||||
//! and the command's arguments, and streams through [`Emit`] callbacks.
|
||||
|
||||
pub mod agent;
|
||||
pub mod analysis;
|
||||
pub mod build;
|
||||
pub mod bytes;
|
||||
pub mod coverage;
|
||||
pub mod debug;
|
||||
pub mod doc;
|
||||
pub mod fs;
|
||||
pub mod history;
|
||||
pub mod java;
|
||||
pub mod lsp;
|
||||
pub mod nav;
|
||||
pub mod project;
|
||||
pub mod refactor;
|
||||
pub mod run;
|
||||
pub mod sdk;
|
||||
pub mod search;
|
||||
pub mod spell;
|
||||
pub mod syntax;
|
||||
pub mod term;
|
||||
pub mod tools;
|
||||
pub mod vcs;
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// A stream of values to the caller (a Tauri channel, or channel notifications).
|
||||
pub type Emit<T> = Arc<dyn Fn(T) + Send + Sync>;
|
||||
|
||||
/// A streaming argument: what the desktop shell passes as a Tauri channel and
|
||||
/// the server as `channel` notifications.
|
||||
pub struct Channel<T>(Emit<T>);
|
||||
|
||||
impl<T> Channel<T> {
|
||||
pub fn new(send: impl Fn(T) + Send + Sync + 'static) -> Self {
|
||||
Self(Arc::new(send))
|
||||
}
|
||||
|
||||
/// Sends one value; never fails (a closed receiver drops it).
|
||||
pub fn send(&self, value: T) -> IdeResult<()> {
|
||||
(self.0)(value);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for Channel<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the commands of every module with the remote server.
|
||||
pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
agent::register(d);
|
||||
lsp::register(d);
|
||||
nav::register(d);
|
||||
refactor::register(d);
|
||||
analysis::register(d);
|
||||
syntax::register(d);
|
||||
spell::register(d);
|
||||
history::register(d);
|
||||
coverage::register(d);
|
||||
build::register(d);
|
||||
tools::register(d);
|
||||
sdk::register(d);
|
||||
java::register(d);
|
||||
debug::register(d);
|
||||
bytes::register(d);
|
||||
vcs::register(d);
|
||||
doc::register(d);
|
||||
search::register(d);
|
||||
run::register(d);
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool.
|
||||
pub async fn blocking<T: Send + 'static>(&self, f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use super::doc::blocking;
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
|
||||
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A web reference no language server resolves and where it points: `class="card"` →
|
||||
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
|
||||
/// `--x:` declarations.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebGoto {
|
||||
pub reference: WebReference,
|
||||
pub targets: Vec<Location>,
|
||||
}
|
||||
|
||||
const MAX_WEB_TARGETS: usize = 200;
|
||||
|
||||
/// The project root and its resource roots (served at `/`), for file references.
|
||||
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
|
||||
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
|
||||
(project.root.clone(), resources)
|
||||
}
|
||||
|
||||
pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
|
||||
Ok(host.project(window)?.index.status())
|
||||
}
|
||||
|
||||
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
|
||||
Ok(host.project(window)?.index.find_files(&query, limit as usize))
|
||||
}
|
||||
|
||||
pub fn find_symbols(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
query: String,
|
||||
filter: SymbolFilter,
|
||||
limit: u32,
|
||||
) -> IdeResult<Vec<SymbolMatch>> {
|
||||
Ok(host.project(window)?.index.find_symbols(&query, filter, limit as usize))
|
||||
}
|
||||
|
||||
pub fn mark_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
host.project(window)?.index.mark_recent(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
host.project(window)?.index.remove_recent(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recently opened files, most recent first (⌘E).
|
||||
pub fn recent_files(host: &Host, window: &str) -> IdeResult<Vec<ide_core::IpcPath>> {
|
||||
Ok(host.project(window)?.index.recent_files().into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// The web reference at `pos` of `doc` and its targets; `None` when there is no reference.
|
||||
pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<WebGoto>> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(reference) = syntax.web_reference_at(&snap.language, &snap.text, pos) else { return Ok(None) };
|
||||
let here = |range| Location { path: snap.path.clone(), range, uri: None };
|
||||
let targets = match reference.symbol() {
|
||||
None => syntax.web_anchors(&snap.language, &snap.text, &reference.name).into_iter().map(here).collect(),
|
||||
Some((name, kind)) => {
|
||||
// The open document from its (possibly unsaved) text, other files from the index.
|
||||
let mut targets: Vec<Location> = syntax
|
||||
.web_definitions(&snap.language, &snap.text)
|
||||
.into_iter()
|
||||
.filter(|t| t.name == name && t.kind == kind)
|
||||
.map(|t| here(t.range))
|
||||
.collect();
|
||||
targets.extend(
|
||||
project
|
||||
.index
|
||||
.symbols_named(&name, Some(kind), MAX_WEB_TARGETS)
|
||||
.into_iter()
|
||||
.filter(|m| *m.path != *snap.path)
|
||||
.map(|m| Location { path: m.path, range: m.range, uri: None }),
|
||||
);
|
||||
targets
|
||||
}
|
||||
};
|
||||
Ok(Some(WebGoto { reference, targets }))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
|
||||
pub async fn fs_resolve_reference(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
from: IpcPath,
|
||||
value: String,
|
||||
) -> IdeResult<Option<IpcPath>> {
|
||||
let project = host.project(window).ok();
|
||||
blocking(move || {
|
||||
let (root, resources) = project.as_deref().map(reference_roots).unzip();
|
||||
let resources = resources.unwrap_or_default();
|
||||
Ok(file_refs::resolve(&value, &from, root.as_deref(), &resources).map(Into::into))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Files and folders that continue `prefix`, a path being typed in `from`.
|
||||
pub async fn path_completions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
from: IpcPath,
|
||||
prefix: String,
|
||||
) -> IdeResult<Vec<PathCompletion>> {
|
||||
let project = host.project(window).ok();
|
||||
blocking(move || {
|
||||
let (root, resources) = project.as_deref().map(reference_roots).unzip();
|
||||
let resources = resources.unwrap_or_default();
|
||||
Ok(file_refs::complete(&prefix, &from, root.as_deref(), &resources))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Paths in an HTML document or stylesheet that name no existing file.
|
||||
pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<FileReference>> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (root, resources) = reference_roots(&project);
|
||||
Ok(syntax
|
||||
.web_file_references(&snap.language, &snap.text)
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
file_refs::is_file_reference(&r.value)
|
||||
&& !file_refs::path_part(&r.value).is_empty()
|
||||
&& file_refs::resolve(&r.value, &snap.path, Some(&root), &resources).is_none()
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
|
||||
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_symbols", |c| async move {
|
||||
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
|
||||
});
|
||||
d.add("mark_recent", |c| async move { mark_recent(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) });
|
||||
d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await });
|
||||
d.add("fs_resolve_reference", |c| async move {
|
||||
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
|
||||
});
|
||||
d.add("path_completions", |c| async move {
|
||||
path_completions(&c.host, &c.window, c.arg("from")?, c.arg("prefix")?).await
|
||||
});
|
||||
d.add("web_unresolved_references", |c| async move {
|
||||
web_unresolved_references(&c.host, &c.window, c.arg("doc")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use crate::{Host, Project};
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::settings::Settings;
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectInfo {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub name: String,
|
||||
pub is_git: bool,
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
pub fn info(project: &Project) -> ProjectInfo {
|
||||
let settings = project.settings.effective();
|
||||
let custom = settings.general.project_name.trim();
|
||||
ProjectInfo {
|
||||
name: if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
custom.to_owned()
|
||||
},
|
||||
is_git: !project.repos.read().is_empty(),
|
||||
settings,
|
||||
root: project.root.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `path` in `window` (off the async workers: it touches the disk).
|
||||
pub async fn open(host: &Arc<Host>, window: &str, path: &Path) -> IdeResult<Arc<Project>> {
|
||||
let (h, window, path) = (host.clone(), window.to_owned(), path.to_path_buf());
|
||||
host.blocking(move || h.open_project(&window, &path)).await
|
||||
}
|
||||
|
||||
pub fn current(host: &Host, window: &str) -> Option<ProjectInfo> {
|
||||
host.project(window).ok().map(|p| info(&p))
|
||||
}
|
||||
|
||||
/// Effective settings for the window's project, or global settings without one.
|
||||
pub fn settings(host: &Host, window: &str) -> Settings {
|
||||
match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots> {
|
||||
Ok(host.project(window)?.roots())
|
||||
}
|
||||
|
||||
/// What [`set_settings`] changed; the caller tells the windows.
|
||||
pub struct SettingsApplied {
|
||||
pub settings: Settings,
|
||||
/// A project value (else a global one).
|
||||
pub project_scope: bool,
|
||||
/// The project's root, when its content roots changed.
|
||||
pub roots_changed: Option<ide_core::IpcPath>,
|
||||
}
|
||||
|
||||
/// Sets `key` to `value` (JSON text, e.g. `14` or `"dark"`) for the window's
|
||||
/// project or globally, and applies it to the open projects: file defaults,
|
||||
/// file types, language servers, index scope and SSH configurations.
|
||||
pub fn set_settings(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
scope: SettingsScope,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> IdeResult<SettingsApplied> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(value).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
|
||||
let current = host.project(window).ok();
|
||||
let project_scope = matches!(scope, SettingsScope::Project) && current.is_some();
|
||||
let settings = match ¤t {
|
||||
Some(p) if project_scope => p.settings.set(scope, key, value)?,
|
||||
Some(p) => {
|
||||
host.global_settings.set(SettingsScope::Global, key, value)?;
|
||||
p.settings.reload()
|
||||
}
|
||||
None => host.global_settings.set(SettingsScope::Global, key, value)?,
|
||||
};
|
||||
if let Some(p) = ¤t {
|
||||
p.documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings, Some(&p.root)));
|
||||
}
|
||||
// A global value reaches every other open project too.
|
||||
if !project_scope {
|
||||
for project in host.projects() {
|
||||
if current.as_ref().is_some_and(|c| c.root == project.root) {
|
||||
continue;
|
||||
}
|
||||
let effective = project.settings.reload();
|
||||
project
|
||||
.documents
|
||||
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
|
||||
}
|
||||
}
|
||||
let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() };
|
||||
// File type associations re-detect open documents (off the command thread).
|
||||
if key == "files" || key.starts_with("files.fileType") {
|
||||
let affected = affected();
|
||||
host.runtime.spawn_blocking(move || {
|
||||
for project in affected {
|
||||
project.apply_file_types(&project.settings.effective());
|
||||
}
|
||||
});
|
||||
}
|
||||
if key == "ssh" || key.starts_with("ssh.") {
|
||||
host.ssh.set_configs(host.global_settings.effective().ssh.configs);
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
if ["languages", "tools", "languageServers"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
|
||||
for project in affected() {
|
||||
let lsp = project.lsp.clone();
|
||||
host.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
}
|
||||
let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.");
|
||||
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
|
||||
// index and the watchers without reopening the project (the rescan runs in the background).
|
||||
if roots_changed || key == "files" || key.starts_with("files.") {
|
||||
let affected = affected();
|
||||
host.runtime.spawn_blocking(move || {
|
||||
for project in affected {
|
||||
project.refresh_index_scope();
|
||||
}
|
||||
});
|
||||
}
|
||||
let roots_changed = current.as_ref().filter(|_| roots_changed && project_scope).map(|p| p.root.clone().into());
|
||||
Ok(SettingsApplied { settings, project_scope, roots_changed })
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! File move / rename refactoring: reference updates from the language
|
||||
//! servers, the move itself (one undoable step) and its undo.
|
||||
use crate::Host;
|
||||
use crate::apply_workspace_edit;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeResult, RequestId, WorkspaceEdit};
|
||||
use ide_lsp::types::FileRenameEdits;
|
||||
use ide_workspace::{FileMove, UndoOutcome};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::doc::blocking;
|
||||
|
||||
fn pairs(moves: &[FileMove]) -> Vec<(PathBuf, PathBuf)> {
|
||||
moves.iter().map(|m| (m.from.to_path_buf(), m.to.to_path_buf())).collect()
|
||||
}
|
||||
|
||||
/// `workspace/willRenameFiles` on every interested server (before the move). Cancellable.
|
||||
pub async fn refactor_file_move_edits(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
moves: Vec<FileMove>,
|
||||
) -> IdeResult<FileRenameEdits> {
|
||||
let project = host.project(window)?;
|
||||
let token = host.begin_request(request);
|
||||
let result = project.lsp.will_rename_files(&pairs(&moves), token).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the
|
||||
/// files and notifies the servers. Returns the id for [`refactor_undo_file_move`].
|
||||
pub async fn refactor_move_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
moves: Vec<FileMove>,
|
||||
edit: WorkspaceEdit,
|
||||
label: String,
|
||||
) -> IdeResult<u32> {
|
||||
let project = host.project(window)?;
|
||||
let p = project.clone();
|
||||
let moved = moves.clone();
|
||||
let id = blocking(move || {
|
||||
let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e);
|
||||
p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply)
|
||||
})
|
||||
.await?;
|
||||
project.lsp.did_rename_files(&pairs(&moves));
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Undoes a file move: moves back and reverts the reference updates.
|
||||
pub async fn refactor_undo_file_move(host: &Host, window: &str, id: u32) -> IdeResult<UndoOutcome> {
|
||||
let project = host.project(window)?;
|
||||
let p = project.clone();
|
||||
let outcome = blocking(move || p.moves.undo(&p.documents, id)).await?;
|
||||
project.lsp.did_rename_files(&pairs(&outcome.moves));
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Whether the file move `id` can still be undone.
|
||||
pub fn refactor_can_undo_file_move(host: &Host, window: &str, id: u32) -> IdeResult<bool> {
|
||||
Ok(host.project(window)?.moves.can_undo(id))
|
||||
}
|
||||
|
||||
/// "Search in comments and strings": textual occurrences of the moved files'
|
||||
/// names, qualified names and paths (before the move). Cancellable.
|
||||
pub async fn refactor_file_move_text_edits(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
moves: Vec<FileMove>,
|
||||
) -> IdeResult<WorkspaceEdit> {
|
||||
let project = host.project(window)?;
|
||||
let token = host.begin_request(request);
|
||||
let cancel = token.clone();
|
||||
let result = blocking(move || {
|
||||
let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves);
|
||||
let edit = ide_workspace::text_refs::find_text_occurrences(
|
||||
&project.root,
|
||||
&project.exclude_globs(),
|
||||
&replacements,
|
||||
&|| cancel.is_cancelled(),
|
||||
);
|
||||
if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) }
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("refactor_file_move_edits", |c| async move {
|
||||
refactor_file_move_edits(&c.host, &c.window, c.arg("request")?, c.arg("moves")?).await
|
||||
});
|
||||
d.add("refactor_move_files", |c| async move {
|
||||
refactor_move_files(&c.host, &c.window, c.arg("moves")?, c.arg("edit")?, c.arg("label")?).await
|
||||
});
|
||||
d.add(
|
||||
"refactor_undo_file_move",
|
||||
|c| async move { refactor_undo_file_move(&c.host, &c.window, c.arg("id")?).await },
|
||||
);
|
||||
d.add(
|
||||
"refactor_can_undo_file_move",
|
||||
|c| async move { refactor_can_undo_file_move(&c.host, &c.window, c.arg("id")?) },
|
||||
);
|
||||
d.add("refactor_file_move_text_edits", |c| async move {
|
||||
refactor_file_move_text_edits(&c.host, &c.window, c.arg("request")?, c.arg("moves")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use super::Emit;
|
||||
use crate::Host;
|
||||
use ide_core::{IdeError, IdeResult, RunId};
|
||||
use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions};
|
||||
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>> {
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let user = ide_run::load_user_configs(&root)?;
|
||||
let mut all: Vec<RunConfig> =
|
||||
ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect();
|
||||
all.extend(user);
|
||||
// Project interpreter / package manager / toolchains from Languages & Frameworks.
|
||||
ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective());
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn save_configs(host: &Host, window: &str, configs: &[RunConfig]) -> IdeResult<()> {
|
||||
ide_run::save_user_configs(&host.project(window)?.root, configs)
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
let project = host.project(window)?;
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
// Settings › Run Targets: over SSH.
|
||||
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
let out = on_output.clone();
|
||||
return host
|
||||
.remote
|
||||
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
|
||||
.await;
|
||||
}
|
||||
let settings = project.settings.effective();
|
||||
// Configurations made in the UI name bare tools (`godot`) that may live outside PATH.
|
||||
let mut config = config;
|
||||
ide_sdk::apply::resolve_run_command(&mut config, &project.root, &settings);
|
||||
if config.command == ide_sdk::godot::COMMAND {
|
||||
return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND));
|
||||
}
|
||||
let encoding = settings.console.encoding;
|
||||
let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) };
|
||||
host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c)))
|
||||
}
|
||||
|
||||
/// Stops a run (interrupt, then kill after Settings › Run's termination timeout).
|
||||
pub fn stop(host: &Host, window: &str, id: RunId) -> IdeResult<()> {
|
||||
let settings = match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
let grace = Duration::from_millis(u64::from(settings.run.termination_timeout_ms));
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.stop(id, grace);
|
||||
}
|
||||
host.processes.stop_with_grace(id, grace)
|
||||
}
|
||||
|
||||
pub fn stdin(host: &Host, id: RunId, data: &str) -> IdeResult<()> {
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.write_stdin(id, data);
|
||||
}
|
||||
host.processes.write_stdin(id, data)
|
||||
}
|
||||
|
||||
pub fn resize(host: &Host, id: RunId, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.resize(id, cols, rows);
|
||||
}
|
||||
host.processes.resize(id, cols, rows)
|
||||
}
|
||||
|
||||
pub fn close_stdin(host: &Host, id: RunId) -> IdeResult<()> {
|
||||
if host.remote.runs.contains(id) {
|
||||
return host.remote.runs.close_stdin(id);
|
||||
}
|
||||
host.processes.close_stdin(id)
|
||||
}
|
||||
|
||||
pub fn list(host: &Host) -> Vec<RunInfo> {
|
||||
let mut runs = host.processes.list();
|
||||
runs.extend(host.remote.runs.list());
|
||||
runs
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,48 @@
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_run::RunConfig;
|
||||
|
||||
/// Configuration templates: per-type defaults applied to new configurations.
|
||||
pub fn run_templates(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
ide_run::load_templates(&root)
|
||||
}
|
||||
|
||||
pub fn run_save_templates(host: &Host, window: &str, templates: Vec<RunConfig>) -> IdeResult<()> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
ide_run::save_templates(&root, &templates)
|
||||
}
|
||||
|
||||
/// The environment runs inherit when "Include system environment variables" is on.
|
||||
pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap<String, String> {
|
||||
ide_run::system_env()
|
||||
}
|
||||
|
||||
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
|
||||
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
|
||||
pub async fn run_test_reports(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: Option<std::path::PathBuf>,
|
||||
since_ms: f64,
|
||||
) -> IdeResult<Vec<ide_run::junit::TestCaseReport>> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
let dir = module.map(|m| root.join(m)).unwrap_or(root);
|
||||
let since = std::time::UNIX_EPOCH + std::time::Duration::from_millis(since_ms.max(0.0) as u64);
|
||||
tokio::task::spawn_blocking(move || ide_run::junit::read_reports(&ide_run::junit::report_dirs(&dir), since))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
|
||||
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
|
||||
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
|
||||
d.add("run_test_reports", |c| async move {
|
||||
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Interpreters / toolchains (`ide-sdk`): discovery, Python virtualenvs and packages.
|
||||
use crate::Host;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_sdk::python::PythonPackage;
|
||||
use ide_sdk::{Sdk, SdkKind};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn join_err(e: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
}
|
||||
|
||||
/// Executables found for "run this file", and where single files are compiled to.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocatedTools {
|
||||
pub tools: Vec<ide_sdk::locate::LocatedTool>,
|
||||
pub compile_dir: ide_core::IpcPath,
|
||||
}
|
||||
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
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>> {
|
||||
let project = host.project(window)?;
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&root, &cache, &settings);
|
||||
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
|
||||
.into_iter()
|
||||
.map(|c| ide_sdk::libraries::BuildDependency {
|
||||
gradle: c.gradle,
|
||||
group: c.group_id,
|
||||
artifact: c.artifact_id,
|
||||
version: c.version,
|
||||
})
|
||||
.collect();
|
||||
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// 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,
|
||||
names: Vec<String>,
|
||||
near: Option<PathBuf>,
|
||||
) -> IdeResult<LocatedTools> {
|
||||
tokio::task::spawn_blocking(move || LocatedTools {
|
||||
tools: ide_sdk::locate::locate_tools(&names, near.as_deref()),
|
||||
compile_dir: ide_sdk::locate::compile_dir().into(),
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// `base -m venv location`; output lines stream through `on_output`.
|
||||
pub async fn sdk_python_create_venv(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
base: PathBuf,
|
||||
location: PathBuf,
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
pub async fn sdk_python_packages(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
python: PathBuf,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<PythonPackage>> {
|
||||
let cancel = host.begin_request(request);
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Installs (`uninstall = false`) or removes a package; output streams through `on_output`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn sdk_python_pip(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
python: PathBuf,
|
||||
requirement: String,
|
||||
uninstall: bool,
|
||||
upgrade: bool,
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let send = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
};
|
||||
if uninstall {
|
||||
ide_sdk::python::uninstall_package(&python, &requirement, &cancel, &send)
|
||||
} else {
|
||||
ide_sdk::python::install_package(&python, &requirement, upgrade, &cancel, &send)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
|
||||
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
|
||||
d.add("sdk_locate_tools", |c| async move {
|
||||
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
|
||||
});
|
||||
d.add("sdk_python_create_venv", |c| async move {
|
||||
sdk_python_create_venv(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("base")?,
|
||||
c.arg("location")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onOutput")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("sdk_python_packages", |c| async move {
|
||||
sdk_python_packages(&c.host, &c.window, c.arg("python")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("sdk_python_pip", |c| async move {
|
||||
sdk_python_pip(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("python")?,
|
||||
c.arg("requirement")?,
|
||||
c.arg("uninstall")?,
|
||||
c.arg("upgrade")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onOutput")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use super::Emit;
|
||||
use crate::Host;
|
||||
use ide_core::{IdeResult, RequestId};
|
||||
use ide_search::{FileMatches, ProjectFileSet, SearchQuery, SearchSummary};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum SearchEvent {
|
||||
File { file: FileMatches },
|
||||
Done { summary: SearchSummary },
|
||||
}
|
||||
|
||||
pub fn literal_range(l: ide_language::Literal) -> ide_search::LiteralRange {
|
||||
let kind = match l.kind {
|
||||
ide_language::LiteralKind::Comment => ide_search::LiteralKind::Comment,
|
||||
ide_language::LiteralKind::String => ide_search::LiteralKind::String,
|
||||
};
|
||||
ide_search::LiteralRange { start: l.start, end: l.end, kind }
|
||||
}
|
||||
|
||||
/// Find in Files. Results stream through `on_event`; ends with `Done`.
|
||||
/// Cancel with `cancel_request(request)`.
|
||||
pub async fn start(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
mut query: SearchQuery,
|
||||
on_event: Emit<SearchEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
|
||||
// ignored names are never searched.
|
||||
query.exclude.extend(project.search_excludes());
|
||||
let roots = project.roots();
|
||||
let set = match query.scope {
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let cancel = host.begin_request(request);
|
||||
let documents = project.documents.clone();
|
||||
let root = project.root.clone();
|
||||
// The whole project also covers the Project Structure content roots.
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => {
|
||||
project.content_roots()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
let summary = host
|
||||
.blocking(move || {
|
||||
let filter = |rel: &str| match set {
|
||||
Some(ProjectFileSet::Production) => !roots.is_test(rel),
|
||||
Some(ProjectFileSet::Test) => roots.is_test(rel),
|
||||
Some(ProjectFileSet::All) | None => true,
|
||||
};
|
||||
// Comments and string literals for the search context filter.
|
||||
let literals = |path: &Path, text: &str| {
|
||||
let language = languages.detect(path, text.lines().next());
|
||||
let found = syntax.literals(&language, text)?;
|
||||
Some(found.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(&root).chain(&content_roots) {
|
||||
let s = ide_search::search_in_context(
|
||||
dir,
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.text_of(p),
|
||||
&cancel,
|
||||
&|file| events(SearchEvent::File { file }),
|
||||
)?;
|
||||
total.files += s.files;
|
||||
total.matches += s.matches;
|
||||
total.elapsed_ms += s.elapsed_ms;
|
||||
total.limit_hit |= s.limit_hit;
|
||||
total.cancelled |= s.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
on_event(SearchEvent::Done { summary: summary? });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,133 @@
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::Host;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeResult, Pos, RequestId, TextEdit};
|
||||
use ide_search::{ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceTarget, SearchQuery};
|
||||
use ide_workspace::Documents;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct DocumentsHost<'a>(&'a Documents);
|
||||
|
||||
impl ReplaceHost for DocumentsHost<'_> {
|
||||
fn edit_file(&self, path: &Path, compute: &mut dyn FnMut(&str) -> IdeResult<Vec<TextEdit>>) -> IdeResult<u32> {
|
||||
let docs = self.0;
|
||||
let (doc, transient) = match docs.by_path(path) {
|
||||
Some(doc) => (doc, false),
|
||||
None => (docs.open(path)?.id, true),
|
||||
};
|
||||
let result = (|| {
|
||||
// Recompute if the user typed between reading the text and applying.
|
||||
for _ in 0..3 {
|
||||
let snap = docs.text_snapshot(doc)?;
|
||||
let edits = compute(&snap.text)?;
|
||||
if edits.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
if docs.text_snapshot(doc)?.version != snap.version {
|
||||
continue;
|
||||
}
|
||||
docs.apply_edits(doc, &edits)?;
|
||||
if transient {
|
||||
docs.save(doc)?;
|
||||
}
|
||||
return Ok(edits.len() as u32);
|
||||
}
|
||||
Err(ide_core::IdeError::unavailable("the file kept changing during Replace"))
|
||||
})();
|
||||
if transient {
|
||||
docs.close(doc);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace in Files. Runs off the command thread; progress streams through
|
||||
/// `on_event` and ends with `Done`. Cancel with `cancel_request(request)`.
|
||||
///
|
||||
/// Edits go through the document layer as one patch per file, so open editors
|
||||
/// update live and undo the whole file's replacements in one step. Unsaved
|
||||
/// editor buffers are replaced in place and left unsaved; files that were not
|
||||
/// open are opened transiently, saved and closed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn replace_in_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
query: SearchQuery,
|
||||
replacement: String,
|
||||
preserve_case: bool,
|
||||
targets: Vec<ReplaceTarget>,
|
||||
on_event: Channel<ReplaceEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let events = on_event.clone();
|
||||
let summary = tokio::task::spawn_blocking(move || {
|
||||
let host = DocumentsHost(&project.documents);
|
||||
ide_search::replace_files(&host, &query, &replacement, preserve_case, &targets, &cancel, &|e| {
|
||||
let _ = events.send(e);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()));
|
||||
host.end_request(request);
|
||||
let summary = summary??;
|
||||
let _ = on_event.send(ReplaceEvent::Done { summary });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Before / after text of `path` for Replace › Preview (unsaved buffer included).
|
||||
pub async fn replace_preview(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
query: SearchQuery,
|
||||
replacement: String,
|
||||
preserve_case: bool,
|
||||
path: PathBuf,
|
||||
only: Option<Vec<Pos>>,
|
||||
) -> IdeResult<ReplacePreview> {
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let text = project.text_of(&path)?;
|
||||
ide_search::preview(&text, &query, &replacement, preserve_case, only.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Validates a query (e.g. regex syntax) while the user types.
|
||||
pub fn search_validate(_host: &Host, _window: &str, query: SearchQuery) -> IdeResult<()> {
|
||||
ide_search::validate_query(&query)
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("replace_in_files", |c| async move {
|
||||
replace_in_files(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("request")?,
|
||||
c.arg("query")?,
|
||||
c.arg("replacement")?,
|
||||
c.arg("preserveCase")?,
|
||||
c.arg("targets")?,
|
||||
c.sender("onEvent")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("replace_preview", |c| async move {
|
||||
replace_preview(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("query")?,
|
||||
c.arg("replacement")?,
|
||||
c.arg("preserveCase")?,
|
||||
c.arg("path")?,
|
||||
c.arg("only")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("search_validate", |c| async move { search_validate(&c.host, &c.window, c.arg("query")?) });
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Proofreading › Typo: spell checking of open documents.
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::doc::blocking;
|
||||
use ide_core::settings::SpellingSettings;
|
||||
use ide_core::{DocId, IdeError, IdeResult, RequestId};
|
||||
use ide_language::SpellingKind;
|
||||
use ide_spell::{Region, RegionKind, SpellScope, Speller, Typo};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Languages checked as prose (all text but code spans).
|
||||
const PROSE: &[&str] = &["plaintext", "markdown", "restructuredtext", "asciidoc", "latex"];
|
||||
|
||||
/// Custom dictionary paths: `~/…` from home, relative ones from the project root.
|
||||
fn dictionary_paths(settings: &SpellingSettings, root: &Path) -> Vec<PathBuf> {
|
||||
settings
|
||||
.custom_dictionaries
|
||||
.iter()
|
||||
.map(|p| p.trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| match p.strip_prefix("~/") {
|
||||
Some(rest) => dirs::home_dir().unwrap_or_default().join(rest),
|
||||
None => root.join(p),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn speller(project: &Project) -> Speller {
|
||||
let s = project.settings.effective().spelling;
|
||||
let words = s.ide_words.iter().chain(&s.project_words);
|
||||
Speller::new(words, &dictionary_paths(&s, &project.root))
|
||||
}
|
||||
|
||||
/// Misspelt words in the comments, string literals and declared names of
|
||||
/// `doc` that `scope` includes (all of its text for prose). Cancel with
|
||||
/// `cancel_request(request)`.
|
||||
pub async fn spell_check(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
scope: SpellScope,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<Typo>> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let regions = if PROSE.contains(&snap.language.as_str()) {
|
||||
ide_spell::prose_regions(&snap.text)
|
||||
} else {
|
||||
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) };
|
||||
found
|
||||
.into_iter()
|
||||
.map(|r| Region {
|
||||
start: r.start,
|
||||
end: r.end,
|
||||
kind: match r.kind {
|
||||
SpellingKind::Comment => RegionKind::Comment,
|
||||
SpellingKind::String => RegionKind::String,
|
||||
SpellingKind::Name => RegionKind::Name,
|
||||
},
|
||||
})
|
||||
.filter(|r| scope.includes(r.kind))
|
||||
.collect()
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
speller(&project).check(&snap.text, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Replacements for a misspelt `word`, best first (Typo › Change to…).
|
||||
pub async fn spell_suggest(host: &Host, window: &str, word: String) -> IdeResult<Vec<String>> {
|
||||
let project = host.project(window).ok();
|
||||
blocking(move || {
|
||||
let speller = project.as_deref().map(speller).unwrap_or_default();
|
||||
Ok(speller.suggest(&word, 8))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("spell_check", |c| async move {
|
||||
spell_check(&c.host, &c.window, c.arg("doc")?, c.arg("scope")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("spell_suggest", |c| async move { spell_suggest(&c.host, &c.window, c.arg("word")?).await });
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Tree-sitter structure queries (`ide-syntax`): the fallback editor features
|
||||
//! use when the language server is unavailable, not ready or failing.
|
||||
//!
|
||||
//! All positions are 0-based lines / UTF-16 columns. Document queries run on
|
||||
//! the blocking pool against the document's cached, incrementally re-parsed
|
||||
//! tree; an optional `request` id makes them cancellable via `cancel_request`.
|
||||
//! Languages without a grammar (and documents too large to parse) reject with
|
||||
//! `unavailable`.
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::doc::blocking;
|
||||
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, LanguageId, Pos, Range, RequestId};
|
||||
use ide_syntax::{
|
||||
ExpressionAt, FileIdentifiers, IdentifierOccurrence, Locals, NodeAt, Snapshot, StructuralRanges, StructureResult,
|
||||
StructureService, StructureSupport, SyntaxErrors,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Runs `f` on the current snapshot of `doc` off the command thread.
|
||||
async fn on_doc<T: Send + 'static>(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
request: Option<RequestId>,
|
||||
f: impl FnOnce(&StructureService, &Snapshot, &CancellationToken) -> StructureResult<T> + Send + 'static,
|
||||
) -> IdeResult<T> {
|
||||
let project: Arc<Project> = host.project(window)?;
|
||||
let cancel = request.map_or_else(CancellationToken::new, |id| host.begin_request(id));
|
||||
let result = blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Err(IdeError::unavailable("file is too large for syntax analysis"));
|
||||
}
|
||||
let s = Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
|
||||
Ok(f(&project.structure, &s, &cancel)?)
|
||||
})
|
||||
.await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// What the structure service supports for a language (`tree`: every query,
|
||||
/// `locals`: scope analysis).
|
||||
pub fn syntax_support(host: &Host, window: &str, language: LanguageId) -> IdeResult<StructureSupport> {
|
||||
Ok(host.project(window)?.structure.support(&language))
|
||||
}
|
||||
|
||||
/// The smallest named node covering `range`, with its ancestors (parent first).
|
||||
pub async fn syntax_node_at(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<Option<NodeAt>> {
|
||||
on_doc(host, window, doc, request, move |s, snap, c| s.node_at(snap, range, c)).await
|
||||
}
|
||||
|
||||
/// The expression a selection denotes (exact or minimally enclosing), with its
|
||||
/// statement, block and caret candidates.
|
||||
pub async fn syntax_expression_at(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<ExpressionAt> {
|
||||
on_doc(host, window, doc, request, move |s, snap, c| s.expression_at(snap, range, c)).await
|
||||
}
|
||||
|
||||
/// `ERROR` / `MISSING` nodes (e.g. a missing `;`) in document order.
|
||||
pub async fn syntax_errors(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<SyntaxErrors> {
|
||||
on_doc(host, window, doc, request, |s, snap, c| s.syntax_errors(snap, c)).await
|
||||
}
|
||||
|
||||
/// Scope analysis of the identifier at `pos`: its local definition and
|
||||
/// references, or why it is not a local.
|
||||
pub async fn syntax_locals(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<Locals> {
|
||||
on_doc(host, window, doc, request, move |s, snap, c| s.locals(snap, pos, c)).await
|
||||
}
|
||||
|
||||
/// Identifier tokens spelled `name` in a document, excluding comments and strings.
|
||||
pub async fn syntax_identifiers(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
name: String,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<Vec<IdentifierOccurrence>> {
|
||||
on_doc(host, window, doc, request, move |s, snap, c| s.identifiers_named(snap, &name, c)).await
|
||||
}
|
||||
|
||||
/// [`syntax_identifiers`] over files (open buffers win over disk); files
|
||||
/// without occurrences or without a grammar are left out. Cancellable.
|
||||
pub async fn syntax_identifiers_in_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
name: String,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<FileIdentifiers>> {
|
||||
let project = host.project(window)?;
|
||||
let languages = project.languages.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = blocking(move || {
|
||||
let load = |path: &std::path::Path| {
|
||||
if let Some(doc) = project.documents.by_path(path) {
|
||||
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 first_line = text.lines().next();
|
||||
Some((languages.detect(path, first_line), None, text))
|
||||
};
|
||||
Ok(project.structure.identifiers_in_files(&paths, &name, load, &cancel)?)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Extend Selection chain plus statement / declaration ranges at `pos`
|
||||
/// (Move Statement, Complete Statement for languages without a Lezer grammar).
|
||||
pub async fn syntax_structural_ranges(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<StructuralRanges> {
|
||||
on_doc(host, window, doc, request, move |s, snap, c| s.structural_ranges(snap, pos, c)).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("syntax_support", |c| async move { syntax_support(&c.host, &c.window, c.arg("language")?) });
|
||||
d.add("syntax_node_at", |c| async move {
|
||||
syntax_node_at(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("syntax_expression_at", |c| async move {
|
||||
syntax_expression_at(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("request")?).await
|
||||
});
|
||||
d.add(
|
||||
"syntax_errors",
|
||||
|c| async move { syntax_errors(&c.host, &c.window, c.arg("doc")?, c.arg("request")?).await },
|
||||
);
|
||||
d.add("syntax_locals", |c| async move {
|
||||
syntax_locals(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("syntax_identifiers", |c| async move {
|
||||
syntax_identifiers(&c.host, &c.window, c.arg("doc")?, c.arg("name")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("syntax_identifiers_in_files", |c| async move {
|
||||
syntax_identifiers_in_files(&c.host, &c.window, c.arg("paths")?, c.arg("name")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("syntax_structural_ranges", |c| async move {
|
||||
syntax_structural_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("request")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use super::Emit;
|
||||
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> {
|
||||
// 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 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"));
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub fn write(host: &Host, id: TerminalId, data: &str) -> IdeResult<()> {
|
||||
host.terminals.write(id, data)
|
||||
}
|
||||
|
||||
pub fn resize(host: &Host, id: TerminalId, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
host.terminals.resize(id, cols, rows)
|
||||
}
|
||||
|
||||
pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> {
|
||||
host.terminals.kill(id)
|
||||
}
|
||||
|
||||
pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult<bool> {
|
||||
host.terminals.is_busy(id)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Language server / debug adapter installer and JDK downloads (`ide-tools`).
|
||||
use crate::Host;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_sdk::Sdk;
|
||||
use ide_tools::jdk::{JdkReleases, JdkVendor};
|
||||
use ide_tools::{InstallEvent, ToolInfo, Tools};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// The installer, reporting the executables chosen in the window's settings.
|
||||
fn tools(host: &Host, window: &str) -> IdeResult<Tools> {
|
||||
let data = host.data_dir.clone();
|
||||
let (settings, root) = match host.project(window) {
|
||||
Ok(project) => (project.settings.effective(), project.root.clone()),
|
||||
Err(_) => (host.global_settings.effective(), Default::default()),
|
||||
};
|
||||
let custom = settings
|
||||
.tools
|
||||
.iter()
|
||||
.filter_map(|(command, t)| Some((command.clone(), ide_sdk::apply::setting_path(&root, &t.path)?)))
|
||||
.collect();
|
||||
// Godot is usually an app bundle / Flatpak outside the search path.
|
||||
let found = ide_sdk::godot::find(None).map(|p| (ide_sdk::godot::COMMAND.to_owned(), p));
|
||||
Ok(Tools::new(&data).with_custom_paths(custom).with_found_paths(found.into_iter().collect()))
|
||||
}
|
||||
|
||||
/// The language definitions plus the servers defined in the window's settings.
|
||||
fn languages(host: &Host, window: &str) -> Vec<ide_language::LanguageConfig> {
|
||||
let settings = match host.project(window) {
|
||||
Ok(project) => project.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
let mut langs = host.languages.all();
|
||||
ide_sdk::apply::with_custom_servers(&mut langs, &settings);
|
||||
langs
|
||||
}
|
||||
|
||||
fn join_err(e: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
}
|
||||
|
||||
fn reconfigure_servers(host: &Host) {
|
||||
for project in host.projects() {
|
||||
let lsp = project.lsp.clone();
|
||||
host.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of every known language server / debug adapter and the languages using it.
|
||||
pub async fn tools_list(host: &Host, window: &str) -> IdeResult<Vec<ToolInfo>> {
|
||||
let tools = tools(host, window)?;
|
||||
let langs = languages(host, window);
|
||||
tokio::task::spawn_blocking(move || tools.list(&langs)).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// Installs (or reinstalls) a tool into `<app-data>/tools`, streaming output through
|
||||
/// `on_event`. Cancel with `cancel_request(request)`.
|
||||
pub async fn tools_install(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
name: String,
|
||||
request: RequestId,
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> IdeResult<ToolInfo> {
|
||||
let tools = tools(host, window)?;
|
||||
let langs = languages(host, window);
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
tools.install(&name, &langs, &cancel, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
let info = result??;
|
||||
// Language servers whose launch depends on installed tools (jdtls loads the
|
||||
// java-debug bundle) pick the change up.
|
||||
reconfigure_servers(host);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Removes a tool the IDE installed.
|
||||
pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult<ToolInfo> {
|
||||
let tools = tools(host, window)?;
|
||||
let langs = languages(host, window);
|
||||
let info = tokio::task::spawn_blocking(move || tools.uninstall(&name, &langs)).await.map_err(join_err)??;
|
||||
reconfigure_servers(host);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// JDK distributions Download JDK offers.
|
||||
pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec<JdkVendor> {
|
||||
ide_tools::jdk::vendors()
|
||||
}
|
||||
|
||||
/// Java versions `vendor` publishes; cancellable with `cancel_request(request)`.
|
||||
pub async fn jdk_download_releases(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
vendor: String,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JdkReleases> {
|
||||
let tools = tools(host, window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Where Download JDK puts JDKs unless told otherwise.
|
||||
pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::IpcPath> {
|
||||
Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into())
|
||||
}
|
||||
|
||||
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
|
||||
/// [`jdk_download_location`]), streaming progress through `on_event`; cancellable
|
||||
/// with `cancel_request(request)`. The JDK is then detected like an installed one,
|
||||
/// and language servers waiting for a JDK (jdtls) start on it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn jdk_download(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
vendor: String,
|
||||
version: u32,
|
||||
location: Option<PathBuf>,
|
||||
request: RequestId,
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let tools = tools(host, window)?;
|
||||
let location = location.filter(|p| !p.as_os_str().is_empty());
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let jdk = tools.download_jdk(&vendor, version, location.as_deref(), &cancel, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
let home = jdk.home.into_path_buf();
|
||||
// A chosen location is scanned from now on too (the default one is from startup).
|
||||
if let Some(location) = &location {
|
||||
ide_sdk::java::add_jdk_root(location);
|
||||
}
|
||||
ide_sdk::java::rescan_jdks();
|
||||
let jdk = ide_sdk::java::jdk_at(&home)
|
||||
.ok_or_else(|| IdeError::internal(format!("{} is not a JDK home", home.display())))?;
|
||||
IdeResult::Ok(ide_sdk::jdk_sdk(&jdk))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
let sdk = result??;
|
||||
reconfigure_servers(host);
|
||||
Ok(sdk)
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("tools_list", |c| async move { tools_list(&c.host, &c.window).await });
|
||||
d.add("tools_install", |c| async move {
|
||||
tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await
|
||||
});
|
||||
d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await });
|
||||
d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) });
|
||||
d.add("jdk_download_releases", |c| async move {
|
||||
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("jdk_download_location", |c| async move { jdk_download_location(&c.host, &c.window) });
|
||||
d.add("jdk_download", |c| async move {
|
||||
jdk_download(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("vendor")?,
|
||||
c.arg("version")?,
|
||||
c.arg("location")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onEvent")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use crate::{Host, apply_git_executable};
|
||||
use ide_core::IdeResult;
|
||||
use ide_vcs::FileStatus;
|
||||
|
||||
/// Changes of every repository of the project.
|
||||
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);
|
||||
let repos = project.repos();
|
||||
if repos.is_empty() {
|
||||
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
|
||||
}
|
||||
for r in repos.all() {
|
||||
r.set_ignored_names(settings.ignored_names());
|
||||
}
|
||||
repos.status()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,919 @@
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeResult, RequestId};
|
||||
use ide_vcs::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::apply_git_executable;
|
||||
|
||||
/// A repository of the project (IDEA's VCS root).
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VcsRoot {
|
||||
/// Worktree root.
|
||||
pub root: ide_core::IpcPath,
|
||||
/// Merge / rebase / cherry-pick / revert in progress.
|
||||
pub operation: Option<String>,
|
||||
/// Current branch; `None` when HEAD is detached (or unreadable).
|
||||
pub head: Option<String>,
|
||||
/// HEAD commit; `None` before the first commit.
|
||||
pub head_commit: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "op", rename_all = "camelCase")]
|
||||
pub enum RemoteOp {
|
||||
Fetch,
|
||||
Pull {
|
||||
rebase: bool,
|
||||
},
|
||||
Push {
|
||||
force_with_lease: bool,
|
||||
set_upstream: bool,
|
||||
/// Push dialog › Push tags.
|
||||
#[serde(default)]
|
||||
tags: Option<PushTags>,
|
||||
},
|
||||
/// Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`).
|
||||
DeleteBranch {
|
||||
branch: String,
|
||||
},
|
||||
/// Pushes local `branch` (only up to `revision` when set) to its upstream, or to
|
||||
/// remote branch `target` of the same remote.
|
||||
PushBranch {
|
||||
branch: String,
|
||||
revision: Option<String>,
|
||||
force_with_lease: bool,
|
||||
#[serde(default)]
|
||||
target: Option<String>,
|
||||
},
|
||||
/// Fast-forwards local `branch` (not checked out) to its upstream.
|
||||
UpdateBranch {
|
||||
branch: String,
|
||||
},
|
||||
/// Pull dialog: `branches` of `remote` (none = the configured merge branch).
|
||||
PullFrom {
|
||||
remote: String,
|
||||
branches: Vec<String>,
|
||||
rebase: bool,
|
||||
options: MergeOptions,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitExecutableInfo {
|
||||
/// `git version …` output.
|
||||
pub version: String,
|
||||
/// The executable that was run.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
// ---- in-progress operations, history rewriting (Git Log actions) ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "op", rename_all = "camelCase")]
|
||||
pub enum GitOperation {
|
||||
/// Continue the merge / rebase / cherry-pick / revert in progress.
|
||||
Continue,
|
||||
Abort,
|
||||
Skip,
|
||||
/// Commits oldest first.
|
||||
CherryPick {
|
||||
commits: Vec<String>,
|
||||
},
|
||||
/// Commits newest first.
|
||||
Revert {
|
||||
commits: Vec<String>,
|
||||
},
|
||||
Reset {
|
||||
commit: String,
|
||||
mode: ResetMode,
|
||||
},
|
||||
/// Rebases `base..HEAD` (`base: None` = from the root) following `entries`, oldest first.
|
||||
InteractiveRebase {
|
||||
base: Option<String>,
|
||||
entries: Vec<RebaseEntry>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Runs `f` once per repository with the `paths` that belong to it.
|
||||
fn per_repo(
|
||||
project: &Project,
|
||||
paths: &[PathBuf],
|
||||
recorded: Option<&str>,
|
||||
f: impl Fn(&Repo, &[PathBuf]) -> IdeResult<()>,
|
||||
) -> IdeResult<()> {
|
||||
let groups = project.repos().group(paths);
|
||||
if groups.is_empty() {
|
||||
return project.with_repo_recorded(None, recorded, |r| f(r, paths));
|
||||
}
|
||||
for (repo, paths) in groups {
|
||||
project.with_repo_recorded(Some(repo.root()), recorded, |r| f(r, &paths))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shelf and patches work in the primary repository only: changes of a nested one
|
||||
/// fail instead of being left out silently.
|
||||
fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
let repos = project.repos();
|
||||
let primary = repos.primary().map(|r| r.root());
|
||||
match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) {
|
||||
Some(p) => Err(ide_core::IdeError::invalid(format!(
|
||||
"{} is in another git repository; shelving and patches support the project's main repository only",
|
||||
p.display()
|
||||
))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| ide_core::IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
// ---- patches ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "from", rename_all = "camelCase")]
|
||||
pub enum PatchSource {
|
||||
/// Local changes of these files (absolute paths).
|
||||
Changes { paths: Vec<ide_core::IpcPath>, reverse: bool },
|
||||
/// Changes made by these commits (oldest first).
|
||||
Commits { commits: Vec<String>, reverse: bool },
|
||||
}
|
||||
|
||||
// ---- shelf (`.openidea/shelf`) and change lists (`.openidea/changelists.json`) ----
|
||||
|
||||
const PRIVATE_ENTRIES: [&str; 2] = ["/shelf/", "/changelists.json"];
|
||||
|
||||
fn shelf_dir(root: &Path) -> PathBuf {
|
||||
ide_core::project_dir::path(root, "shelf")
|
||||
}
|
||||
|
||||
fn keep_private(root: &Path) {
|
||||
if let Err(e) = ide_vcs::changelists::ensure_ignored(&ide_core::project_dir::path(root, ""), &PRIVATE_ENTRIES) {
|
||||
tracing::warn!("could not update .openidea/.gitignore: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The project's repositories, the primary one first.
|
||||
pub fn vcs_roots(host: &Host, window: &str) -> IdeResult<Vec<VcsRoot>> {
|
||||
let repos = host.project(window)?.repos();
|
||||
let primary = repos.primary().map(|r| r.root().to_path_buf());
|
||||
let mut all: Vec<&std::sync::Arc<Repo>> = repos.all().iter().collect();
|
||||
all.sort_by_key(|r| Some(r.root()) != primary.as_deref());
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let (head, head_commit) = r.head().unwrap_or_default();
|
||||
VcsRoot { root: r.root().to_path_buf().into(), operation: r.operation().ok().flatten(), head, head_commit }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<BranchInfo> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
|
||||
}
|
||||
|
||||
/// 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>> {
|
||||
let project = host.project(window)?;
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false))
|
||||
}
|
||||
|
||||
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_blame(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<BlameLine>> {
|
||||
let project = host.project(window)?;
|
||||
let text = project.documents.text_of(&path);
|
||||
blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_commit_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
commit: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
}
|
||||
|
||||
/// Combined changes of several commits, `commits` oldest first.
|
||||
pub async fn vcs_commits_changes(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
commits: Vec<String>,
|
||||
) -> IdeResult<Vec<RevisionChange>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stashes())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
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<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.stage_patch(&patch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_commit(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
message: String,
|
||||
options: CommitOptions,
|
||||
) -> IdeResult<String> {
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_checkout(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
branch: String,
|
||||
create: bool,
|
||||
options: Option<ide_vcs::CheckoutOptions>,
|
||||
) -> IdeResult<ide_vcs::CheckoutOutcome> {
|
||||
let project = host.project(window)?;
|
||||
let options = options.unwrap_or_default();
|
||||
blocking(move || {
|
||||
project
|
||||
.with_repo_recorded(repo.as_deref(), Some("Git checkout"), |r| r.checkout_with(&branch, create, &options))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out.
|
||||
pub async fn vcs_create_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
name: String,
|
||||
start: Option<String>,
|
||||
checkout: bool,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let recorded = checkout.then_some("Git checkout");
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_delete_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
branch: String,
|
||||
) -> IdeResult<ide_vcs::DeletedBranch> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await
|
||||
}
|
||||
|
||||
/// "Restore" of the branch-deleted notification.
|
||||
pub async fn vcs_restore_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
deleted: ide_vcs::DeletedBranch,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_rename_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> IdeResult<()> {
|
||||
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<()> {
|
||||
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<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stash_push(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
message: Option<String>,
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
r.stash_push(message.as_deref(), include_untracked, keep_index)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_stash_apply(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
index: u32,
|
||||
pop: bool,
|
||||
reinstate_index: bool,
|
||||
branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() {
|
||||
Some(name) => r.stash_branch(index, name),
|
||||
None => r.stash_apply(index, pop, reinstate_index),
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_stash_drop(host: &Host, window: &str, repo: Option<PathBuf>, index: u32) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await
|
||||
}
|
||||
|
||||
/// Network operation; progress lines stream through `on_output`. Cancellable.
|
||||
pub async fn vcs_remote(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
request: RequestId,
|
||||
op: RemoteOp,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let out = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
};
|
||||
let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. }).then_some("Git pull");
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
|
||||
RemoteOp::Fetch => r.fetch(&cancel, &out),
|
||||
RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out),
|
||||
RemoteOp::Push { force_with_lease, set_upstream, tags } => {
|
||||
r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out)
|
||||
}
|
||||
RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PushBranch { branch, revision, force_with_lease, target } => match target {
|
||||
Some(target) => {
|
||||
r.push_branch_to(&branch, revision.as_deref(), &target, force_with_lease, &cancel, &out)
|
||||
}
|
||||
None => r.push_branch(&branch, revision.as_deref(), force_with_lease, &cancel, &out),
|
||||
},
|
||||
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PullFrom { remote, branches, rebase, options } => {
|
||||
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// The user's side is `ours` (sides swapped during a rebase, as in IDEA).
|
||||
pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_vcs::MergeVersions> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await
|
||||
}
|
||||
|
||||
/// Line hunks between two arbitrary texts (diff viewer, commit dialog).
|
||||
pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: String) -> Vec<LineHunk> {
|
||||
ide_vcs::diff_hunks(&old_text, &new_text)
|
||||
}
|
||||
|
||||
/// 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<()> {
|
||||
let project = host.project(window)?;
|
||||
let current = ide_vfs::load_text(&path)?.text;
|
||||
project.with_repo_of(&path, |r| {
|
||||
let index_text = r.file_at(&path, Some(":0"))?.unwrap_or_default();
|
||||
let rel = path.strip_prefix(r.root()).unwrap_or(&path).to_string_lossy().replace('\\', "/");
|
||||
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
let patch = ide_vcs::build_partial_patch(&rel, &index_text, ¤t, &selected);
|
||||
if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Clones a repository (Get from VCS). Works without an open project.
|
||||
pub async fn vcs_clone(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
request: RequestId,
|
||||
url: String,
|
||||
dest: PathBuf,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
apply_git_executable(&host.global_settings.effective());
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_vcs::clone(&url, &dest, &cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// `git init` in `dir` (File › New Project… "Create Git repository").
|
||||
pub async fn vcs_init(host: &Host, _window: &str, dir: PathBuf) -> IdeResult<()> {
|
||||
apply_git_executable(&host.global_settings.effective());
|
||||
blocking(move || ide_vcs::init(&dir)).await
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let explicit = path.trim();
|
||||
let exe = if explicit.is_empty() {
|
||||
ide_vcs::detected_git_executable()
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("git executable not found on PATH"))?
|
||||
} else {
|
||||
PathBuf::from(explicit)
|
||||
};
|
||||
let version = ide_vcs::git_version(Some(&exe))?;
|
||||
Ok(GitExecutableInfo { version, path: exe.to_string_lossy().into_owned() })
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// The auto-detected git executable (shown as the field's placeholder).
|
||||
pub fn vcs_detected_git(_host: &Host, _window: &str) -> Option<String> {
|
||||
ide_vcs::detected_git_executable().map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// `git config --get <key>` in the project repository; `None` when unset.
|
||||
pub async fn vcs_config_get(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
key: String,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
|
||||
}
|
||||
|
||||
/// Local history operation that may take a while (hooks, large checkouts); git's
|
||||
/// output streams through `on_output`. Cancellable.
|
||||
pub async fn vcs_operation(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
request: RequestId,
|
||||
op: GitOperation,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let out = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
};
|
||||
let recorded = match &op {
|
||||
GitOperation::CherryPick { .. } => Some("Git cherry-pick"),
|
||||
GitOperation::Revert { .. } => Some("Git revert"),
|
||||
GitOperation::Reset { .. } => Some("Git reset"),
|
||||
GitOperation::InteractiveRebase { .. } => Some("Git rebase"),
|
||||
_ => None,
|
||||
};
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
|
||||
GitOperation::Continue => r.continue_operation(&cancel, &out),
|
||||
GitOperation::Abort => r.abort_operation(&cancel, &out),
|
||||
GitOperation::Skip => r.skip_operation(&cancel, &out),
|
||||
GitOperation::CherryPick { commits } => r.cherry_pick(&commits, &cancel, &out),
|
||||
GitOperation::Revert { commits } => r.revert(&commits, &cancel, &out),
|
||||
GitOperation::Reset { commit, mode } => r.reset(&commit, mode, &cancel, &out),
|
||||
GitOperation::InteractiveRebase { base, entries } => {
|
||||
r.interactive_rebase(base.as_deref(), &entries, &cancel, &out)
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn vcs_create_tag(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
name: String,
|
||||
commit: String,
|
||||
message: Option<String>,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
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
|
||||
}
|
||||
|
||||
pub async fn vcs_tags(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
|
||||
}
|
||||
|
||||
/// Merge dialog: merges `branches` into the current branch.
|
||||
pub async fn vcs_merge_with(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
branches: Vec<String>,
|
||||
options: MergeOptions,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rebase dialog: rebases the current branch onto `upstream` (or `--onto`).
|
||||
pub async fn vcs_rebase_with(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
upstream: String,
|
||||
options: RebaseOptions,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_worktrees(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Worktree>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.worktrees())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_add_worktree(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
path: PathBuf,
|
||||
commitish: String,
|
||||
new_branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_remove_worktree(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
path: PathBuf,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
|
||||
}
|
||||
|
||||
/// Compare with Local: tracked files that differ between `rev` and the working tree.
|
||||
pub async fn vcs_diff_with_worktree(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
rev: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
}
|
||||
|
||||
/// Files that differ between two revisions (the "N files updated" of Update Project).
|
||||
pub async fn vcs_diff_revisions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo(|r| match source {
|
||||
PatchSource::Changes { paths, reverse } => {
|
||||
let paths: Vec<PathBuf> = paths.into_iter().map(Into::into).collect();
|
||||
in_primary_repo(&project, &paths)?;
|
||||
r.create_patch(&paths, reverse)
|
||||
}
|
||||
PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse),
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_patch_preview(host: &Host, window: &str, patch: String) -> IdeResult<Vec<PatchFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_apply_patch(host: &Host, window: &str, patch: String) -> IdeResult<ApplyOutcome> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|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> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
in_primary_repo(&project, &paths)?;
|
||||
keep_private(&project.root);
|
||||
project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList>> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::list(&shelf_dir(&project.root))
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_delete(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::delete(&shelf_dir(&project.root), &id)
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name)
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id)
|
||||
}
|
||||
|
||||
pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult<ChangelistState> {
|
||||
let project = host.project(window)?;
|
||||
Ok(ide_vcs::changelists::load(&ide_core::project_dir::path(&project.root, "changelists.json")))
|
||||
}
|
||||
|
||||
pub fn vcs_changelists_save(host: &Host, window: &str, changelists: ChangelistState) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
keep_private(&project.root);
|
||||
ide_vcs::changelists::save(&ide_core::project_dir::path(&project.root, "changelists.json"), &changelists)
|
||||
}
|
||||
|
||||
/// Create Patch › Save to file.
|
||||
pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: String) -> IdeResult<()> {
|
||||
blocking(move || {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(&path, patch)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
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_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")?).await });
|
||||
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
|
||||
d.add("vcs_commit_files", |c| async move {
|
||||
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
|
||||
});
|
||||
d.add("vcs_commits_changes", |c| async move {
|
||||
vcs_commits_changes(&c.host, &c.window, c.arg("repo")?, c.arg("commits")?).await
|
||||
});
|
||||
d.add("vcs_branches", |c| async move { vcs_branches(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_stashes", |c| async move { vcs_stashes(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_stage", |c| async move { vcs_stage(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_commit", |c| async move {
|
||||
vcs_commit(&c.host, &c.window, c.arg("repo")?, c.arg("message")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("vcs_checkout", |c| async move {
|
||||
vcs_checkout(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?, c.arg("create")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("vcs_create_branch", |c| async move {
|
||||
vcs_create_branch(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("start")?, c.arg("checkout")?).await
|
||||
});
|
||||
d.add(
|
||||
"vcs_undo_commit",
|
||||
|c| async move { vcs_undo_commit(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await },
|
||||
);
|
||||
d.add("vcs_delete_branch", |c| async move {
|
||||
vcs_delete_branch(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await
|
||||
});
|
||||
d.add("vcs_restore_branch", |c| async move {
|
||||
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
|
||||
});
|
||||
d.add("vcs_rename_branch", |c| async move {
|
||||
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
|
||||
d.add("vcs_rebase", |c| async move { vcs_rebase(&c.host, &c.window, c.arg("repo")?, c.arg("onto")?).await });
|
||||
d.add("vcs_stash_push", |c| async move {
|
||||
vcs_stash_push(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("repo")?,
|
||||
c.arg("message")?,
|
||||
c.arg("includeUntracked")?,
|
||||
c.arg("keepIndex")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_stash_apply", |c| async move {
|
||||
vcs_stash_apply(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("repo")?,
|
||||
c.arg("index")?,
|
||||
c.arg("pop")?,
|
||||
c.arg("reinstateIndex")?,
|
||||
c.arg("branch")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add(
|
||||
"vcs_stash_drop",
|
||||
|c| async move { vcs_stash_drop(&c.host, &c.window, c.arg("repo")?, c.arg("index")?).await },
|
||||
);
|
||||
d.add("vcs_remote", |c| async move {
|
||||
vcs_remote(&c.host, &c.window, c.arg("repo")?, c.arg("request")?, c.arg("op")?, c.sender("onOutput")?).await
|
||||
});
|
||||
d.add("vcs_conflict_versions", |c| async move { vcs_conflict_versions(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add(
|
||||
"vcs_diff_text",
|
||||
|c| async move { vcs_diff_text(&c.host, &c.window, c.arg("path")?, c.arg("staged")?).await },
|
||||
);
|
||||
d.add(
|
||||
"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_clone", |c| async move {
|
||||
vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await
|
||||
});
|
||||
d.add("vcs_init", |c| async move { vcs_init(&c.host, &c.window, c.arg("dir")?).await });
|
||||
d.add("vcs_git_version", |c| async move { vcs_git_version(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_detected_git", |c| async move { Ok(vcs_detected_git(&c.host, &c.window)) });
|
||||
d.add("vcs_config_get", |c| async move { vcs_config_get(&c.host, &c.window, c.arg("repo")?, c.arg("key")?).await });
|
||||
d.add("vcs_operation", |c| async move {
|
||||
vcs_operation(&c.host, &c.window, c.arg("repo")?, c.arg("request")?, c.arg("op")?, c.sender("onOutput")?).await
|
||||
});
|
||||
d.add("vcs_create_tag", |c| async move {
|
||||
vcs_create_tag(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("repo")?,
|
||||
c.arg("name")?,
|
||||
c.arg("commit")?,
|
||||
c.arg("message")?,
|
||||
c.arg("force")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_tags", |c| async move { vcs_tags(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_merge_with", |c| async move {
|
||||
vcs_merge_with(&c.host, &c.window, c.arg("repo")?, c.arg("branches")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("vcs_rebase_with", |c| async move {
|
||||
vcs_rebase_with(&c.host, &c.window, c.arg("repo")?, c.arg("upstream")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("vcs_remotes", |c| async move { vcs_remotes(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add(
|
||||
"vcs_edit_remote",
|
||||
|c| async move { vcs_edit_remote(&c.host, &c.window, c.arg("repo")?, c.arg("edit")?).await },
|
||||
);
|
||||
d.add("vcs_worktrees", |c| async move { vcs_worktrees(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_add_worktree", |c| async move {
|
||||
vcs_add_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("commitish")?, c.arg("newBranch")?)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_remove_worktree", |c| async move {
|
||||
vcs_remove_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("force")?).await
|
||||
});
|
||||
d.add("vcs_diff_with_worktree", |c| async move {
|
||||
vcs_diff_with_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("rev")?).await
|
||||
});
|
||||
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_create_patch", |c| async move { vcs_create_patch(&c.host, &c.window, c.arg("source")?).await });
|
||||
d.add("vcs_patch_preview", |c| async move { vcs_patch_preview(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_apply_patch", |c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("patch")?).await });
|
||||
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_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_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(
|
||||
"vcs_write_patch",
|
||||
|c| async move { vcs_write_patch(&c.host, &c.window, c.arg("path")?, c.arg("patch")?).await },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,776 @@
|
||||
//! One open project folder: documents, index, language servers, repositories,
|
||||
//! watchers and the per-project services, opened by [`crate::Host::open_project`].
|
||||
|
||||
use crate::Host;
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, SharedSink};
|
||||
use ide_index::ProjectIndex;
|
||||
use ide_language::{FileTypes, LanguageRegistry, SyntaxCache};
|
||||
use ide_lsp::LspManager;
|
||||
use ide_syntax::StructureService;
|
||||
use ide_text::{AppliedChange, ByteEdit};
|
||||
use ide_vcs::{Repo, Repos};
|
||||
use ide_vfs::Watcher;
|
||||
use ide_workspace::documents::DocumentObserver;
|
||||
use ide_workspace::{DocumentInfo, Documents, FileMoves, LocalHistory, ProjectRoots, SettingsStore, SourceFolder};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Everything bound to one open project folder.
|
||||
pub struct Project {
|
||||
pub root: PathBuf,
|
||||
/// Window-scoped event sink, for commands that emit project events.
|
||||
pub sink: SharedSink,
|
||||
/// The app's language definitions with this project's File Types applied.
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
pub settings: Arc<SettingsStore>,
|
||||
pub documents: Arc<Documents>,
|
||||
pub history: Arc<LocalHistory>,
|
||||
/// Undo stack of file move / rename refactorings.
|
||||
pub moves: FileMoves,
|
||||
pub index: Arc<ProjectIndex>,
|
||||
pub lsp: Arc<LspManager>,
|
||||
/// Git repositories: the one containing the project root and those nested below it.
|
||||
pub repos: Arc<RwLock<Repos>>,
|
||||
/// Incrementally maintained syntax trees of the open documents.
|
||||
pub syntax: Arc<SyntaxCache>,
|
||||
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
|
||||
pub structure: Arc<StructureService>,
|
||||
/// Roots auto-detected from build files; see [`Project::roots`].
|
||||
pub detected_roots: Arc<RwLock<Vec<SourceFolder>>>,
|
||||
/// Per-project cache directory (build tool sync results…).
|
||||
pub cache_dir: PathBuf,
|
||||
/// Database tool window: data sources and their connections.
|
||||
pub db: Arc<ide_db::DbManager>,
|
||||
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
|
||||
pub http_history: Arc<ide_http::History>,
|
||||
/// Coding agent tasks (Agents tool window).
|
||||
pub agents: Arc<ide_agent::AgentManager>,
|
||||
pub(crate) watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
pub(crate) _scratch_watcher: Option<Watcher>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn open_project(&self, window: &str, root: &Path) -> IdeResult<Arc<Project>> {
|
||||
let started = std::time::Instant::now();
|
||||
let mut lap = started;
|
||||
let mut laps: Vec<(&str, u128)> = Vec::new();
|
||||
let mut mark = |name: &'static str| {
|
||||
laps.push((name, lap.elapsed().as_millis()));
|
||||
lap = std::time::Instant::now();
|
||||
};
|
||||
let root = dunce::canonicalize(root)?;
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
// it, with its warm index, language servers and unsaved documents.
|
||||
if let Ok(current) = self.project(window)
|
||||
&& current.root == root
|
||||
{
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
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(&settings.effective(), &detected_roots.read());
|
||||
let extra_roots = content_roots(&settings.effective(), &root);
|
||||
mark("roots");
|
||||
|
||||
// The project's own registry: File Types associations may be project-scoped.
|
||||
let languages = Arc::new(self.languages.fork());
|
||||
languages.set_file_types(FileTypes::from_settings(&settings.effective().files, Some(&root)));
|
||||
let documents = {
|
||||
let languages = languages.clone();
|
||||
Arc::new(Documents::new(
|
||||
sink.clone(),
|
||||
Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)),
|
||||
))
|
||||
};
|
||||
documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings.effective(), Some(&root)));
|
||||
let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &root)?);
|
||||
{
|
||||
let history = history.clone();
|
||||
let retention = history_retention(&settings.effective());
|
||||
std::thread::Builder::new().name("openidea-history-prune".into()).spawn(move || {
|
||||
if let Err(e) = history.apply_retention(retention) {
|
||||
tracing::warn!("local history: {e}");
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let project_key = project_key(&root);
|
||||
let index = ProjectIndex::open(
|
||||
&root,
|
||||
&self.data_dir.join("index").join(&project_key),
|
||||
extra_roots.clone(),
|
||||
excludes.clone(),
|
||||
languages.clone(),
|
||||
self.syntax(),
|
||||
sink.clone(),
|
||||
)?;
|
||||
mark("index");
|
||||
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
lsp.set_config_hook(Box::new(move |cfg, server_root| {
|
||||
let effective = settings.effective();
|
||||
ide_sdk::apply::server_launch_overrides(cfg, &effective);
|
||||
ide_sdk::apply::configure_server(cfg, &root, server_root, &effective);
|
||||
ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks());
|
||||
if cfg.name == "jdtls" {
|
||||
let bundles: Vec<PathBuf> =
|
||||
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
|
||||
let min_java = ide_sdk::java::jdtls_min_java(cfg);
|
||||
let jdks = ide_sdk::java::jdtls_jdks(min_java);
|
||||
let lombok = ide_sdk::lombok::lombok_agent(
|
||||
server_root,
|
||||
ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)),
|
||||
ide_tools::managed_bundle(&data_dir, "lombok"),
|
||||
ide_sdk::apply::maven_local_repository(&root, &effective),
|
||||
);
|
||||
if let Some(problem) = lombok.problem() {
|
||||
tracing::warn!("{problem}");
|
||||
}
|
||||
let server_jdk = ide_sdk::apply::jdtls_jdk(&root, &effective).unwrap_or_else(|e| {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
&data_dir,
|
||||
&jdks,
|
||||
ide_sdk::apply::project_jdk(&root, &effective).as_ref(),
|
||||
server_jdk.as_ref(),
|
||||
min_java,
|
||||
&bundles,
|
||||
lombok.jar(),
|
||||
);
|
||||
ide_sdk::jdtls::launch_directly(cfg);
|
||||
}
|
||||
ide_sdk::apply::server_protocol_overrides(cfg, &effective);
|
||||
}));
|
||||
}
|
||||
{
|
||||
let settings = settings.clone();
|
||||
lsp.set_servers_hook(Box::new(move |language, defined| {
|
||||
ide_sdk::apply::language_servers(language.as_str(), defined, &settings.effective())
|
||||
}));
|
||||
}
|
||||
{
|
||||
let documents = documents.clone();
|
||||
lsp.set_apply_edit_hook(Box::new(move |edit| apply_workspace_edit(&documents, &edit).is_ok()));
|
||||
}
|
||||
let syntax = Arc::new(SyntaxCache::new(self.syntax()));
|
||||
documents.add_observer(Arc::new(SyntaxObserver(syntax.clone())));
|
||||
documents.add_observer(Arc::new(LspObserver(lsp.clone())));
|
||||
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
|
||||
documents.add_observer(Arc::new(DeployObserver {
|
||||
remote: self.remote.clone(),
|
||||
settings: settings.clone(),
|
||||
root: root.clone(),
|
||||
sink: sink.clone(),
|
||||
runtime: self.runtime.clone(),
|
||||
}));
|
||||
|
||||
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
|
||||
let watcher = {
|
||||
let repos = repos.clone();
|
||||
let settings = settings.clone();
|
||||
let index = index.clone();
|
||||
let documents = documents.clone();
|
||||
let sink = sink.clone();
|
||||
let root = root.clone();
|
||||
let detected_roots = detected_roots.clone();
|
||||
let history = history.clone();
|
||||
let remote = self.remote.clone();
|
||||
let runtime = self.runtime.clone();
|
||||
Watcher::start(
|
||||
&root.clone(),
|
||||
excludes.clone(),
|
||||
sink.clone(),
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
// Atomic saves by external tools surface as `created`.
|
||||
documents.on_disk_changed(&changed);
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
if !batch.removed.is_empty() {
|
||||
delete_deployed(
|
||||
&runtime,
|
||||
remote.clone(),
|
||||
settings.clone(),
|
||||
root.clone(),
|
||||
batch.removed.clone(),
|
||||
sink.clone(),
|
||||
);
|
||||
}
|
||||
// Build files / conventional folders changed: re-detect roots.
|
||||
let touched = changed.iter().chain(&batch.removed).filter_map(|p| p.strip_prefix(&root).ok());
|
||||
if touched.into_iter().any(ide_workspace::roots::affects_roots) {
|
||||
let fresh = ide_workspace::roots::detect(&root);
|
||||
if *detected_roots.read() != fresh {
|
||||
// Detected excluded folders (`target`, `build`) leave the index too.
|
||||
let effective = settings.effective();
|
||||
index.set_scope(content_roots(&effective, &root), exclude_globs(&effective, &fresh));
|
||||
*detected_roots.write() = fresh;
|
||||
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
|
||||
}
|
||||
}
|
||||
// A repository was cloned / initialised / removed below the project: its
|
||||
// `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went.
|
||||
let known = repos.read().roots();
|
||||
let git_dirs = changed.iter().chain(&batch.removed).any(|p| {
|
||||
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
known.iter().any(|r| r == dir) != p.is_file()
|
||||
});
|
||||
if git_dirs {
|
||||
scan_repositories(&root, &repos, &settings, &sink);
|
||||
}
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}),
|
||||
)?
|
||||
};
|
||||
|
||||
// Scratch files live outside the project; watch them too so the
|
||||
// Project view and open editors follow changes made there.
|
||||
let scratch_watcher = {
|
||||
let dir = self.data_dir.join("scratches");
|
||||
let documents = documents.clone();
|
||||
std::fs::create_dir_all(&dir).ok().and_then(|()| {
|
||||
Watcher::start(
|
||||
&dir,
|
||||
Vec::new(),
|
||||
sink.clone(),
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
documents.on_disk_changed(&changed);
|
||||
}),
|
||||
)
|
||||
.map_err(|e| tracing::warn!("scratches watcher: {e}"))
|
||||
.ok()
|
||||
})
|
||||
};
|
||||
|
||||
let content_watchers = extra_roots
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?;
|
||||
Some((r.clone(), w))
|
||||
})
|
||||
.collect();
|
||||
mark("watcher");
|
||||
{
|
||||
let (root, repos, settings, sink) = (root.clone(), repos.clone(), settings.clone(), sink.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("openidea-vcs-roots".into())
|
||||
.spawn(move || scan_repositories(&root, &repos, &settings, &sink))?;
|
||||
}
|
||||
mark("git");
|
||||
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
|
||||
|
||||
let db = Arc::new(ide_db::DbManager::new(
|
||||
root.clone(),
|
||||
self.data_dir.join("db-passwords.json"),
|
||||
self.data_dir.join("db-history.json"),
|
||||
self.ssh.clone(),
|
||||
));
|
||||
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key)));
|
||||
let agents = Arc::new(ide_agent::AgentManager::new(
|
||||
ide_agent::AgentPaths {
|
||||
project_root: root.clone(),
|
||||
store_dir: self.data_dir.join("agents").join(&project_key),
|
||||
// Outside the app data dir: its path has spaces on macOS, which trips up build scripts.
|
||||
worktrees_dir: dirs::home_dir()
|
||||
.unwrap_or_else(|| self.data_dir.clone())
|
||||
.join(".openidea")
|
||||
.join("worktrees")
|
||||
.join(&project_key),
|
||||
models_file: self.data_dir.join("agents").join("models.json"),
|
||||
custom_agents_dir: Some(self.config_dir.join("agents")),
|
||||
home_dir: dirs::home_dir(),
|
||||
},
|
||||
self.runtime.clone(),
|
||||
));
|
||||
let project = Arc::new(Project {
|
||||
root,
|
||||
sink,
|
||||
languages,
|
||||
settings,
|
||||
documents,
|
||||
history,
|
||||
moves: FileMoves::new(),
|
||||
index,
|
||||
lsp,
|
||||
repos,
|
||||
structure: Arc::new(StructureService::new(syntax.clone())),
|
||||
syntax,
|
||||
detected_roots,
|
||||
cache_dir: self.data_dir.join("cache").join(&project_key),
|
||||
db,
|
||||
http_history,
|
||||
agents,
|
||||
watcher,
|
||||
content_watchers: Mutex::new(content_watchers),
|
||||
_scratch_watcher: scratch_watcher,
|
||||
});
|
||||
{
|
||||
let weak = Arc::downgrade(&project);
|
||||
project.lsp.set_candidates_hook(Box::new(move |q, cancel| {
|
||||
weak.upgrade().map(|p| p.reference_candidates(q, cancel)).unwrap_or_default()
|
||||
}));
|
||||
}
|
||||
self.insert_project(window, project.clone());
|
||||
Ok(project)
|
||||
}
|
||||
}
|
||||
|
||||
/// Looks for repositories nested below the project (and the one containing it);
|
||||
/// emits `VcsChanged` when the set of roots changed.
|
||||
fn scan_repositories(root: &Path, repos: &RwLock<Repos>, settings: &SettingsStore, sink: &SharedSink) {
|
||||
let ignored = settings.effective().ignored_names();
|
||||
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
|
||||
match Repos::discover(root, &skip) {
|
||||
Ok(found) => {
|
||||
if found.roots() != repos.read().roots() {
|
||||
*repos.write() = found;
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("looking for git repositories: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// The project's git repositories.
|
||||
pub fn repos(&self) -> Repos {
|
||||
self.repos.read().clone()
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository whose root is `at`, the project's primary repository
|
||||
/// when `None`; fails when there is none (a repository-scoped command never falls back
|
||||
/// to another repository).
|
||||
pub fn with_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let repos = self.repos();
|
||||
match at {
|
||||
Some(root) => match repos.at_root(root) {
|
||||
Some(repo) => self.run_in(repo, f),
|
||||
None => {
|
||||
Err(IdeError::unavailable(format!("{} is not a git repository of the project", root.display())))
|
||||
}
|
||||
},
|
||||
None => match repos.primary() {
|
||||
Some(repo) => self.run_in(repo, f),
|
||||
None => Err(IdeError::unavailable("project is not a git repository")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository `path` (a file or folder) belongs to.
|
||||
pub fn with_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
match self.repos().for_path(path) {
|
||||
Some(repo) => self.run_in(repo, f),
|
||||
None => Err(IdeError::unavailable(format!("{} is not in a git repository", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let settings = self.settings.effective();
|
||||
apply_git_executable(&settings);
|
||||
repo.set_ignored_names(settings.ignored_names());
|
||||
f(repo)
|
||||
}
|
||||
|
||||
/// Runs `f` with the project's primary git repository, or fails when the project is not under git.
|
||||
pub fn with_repo<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_at(None, f)
|
||||
}
|
||||
|
||||
/// Runs a VCS operation that rewrites the working tree (checkout, merge,
|
||||
/// rebase, pull, reset, stash, rollback): Local History gets a label and a
|
||||
/// snapshot of every locally changed file before, and a label after.
|
||||
/// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree).
|
||||
pub fn with_repo_recorded<T>(
|
||||
&self,
|
||||
at: Option<&Path>,
|
||||
operation: Option<&str>,
|
||||
f: impl FnOnce(&Repo) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
let Some(name) = operation else { return self.with_repo_at(at, f) };
|
||||
self.history_label(&format!("Before {name}"));
|
||||
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
|
||||
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
|
||||
if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) {
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = self.with_repo_at(at, f);
|
||||
self.history_label(&format!("After {name}"));
|
||||
result
|
||||
}
|
||||
|
||||
fn history_label(&self, label: &str) {
|
||||
if let Err(e) = self.history.put_label(&self.root, label) {
|
||||
tracing::warn!("local history: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Local History snapshots of `paths` before a refactoring changes them.
|
||||
pub fn record_before_refactoring(&self, paths: &[&Path], name: &str) {
|
||||
let label = format!("Before refactoring: {name}");
|
||||
for path in paths {
|
||||
let result = match self.documents.text_of(path) {
|
||||
Some(text) => self.history.record_event(path, &text, &label),
|
||||
None => self.history.record_file(path, &label, true).map(|_| ()),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
|
||||
pub fn roots(&self) -> ProjectRoots {
|
||||
ProjectRoots::compute(self.detected_roots.read().clone(), &self.settings.effective().project_structure)
|
||||
}
|
||||
|
||||
/// Where the Maven / Gradle tool windows cache what the build tools reported.
|
||||
pub fn build_cache_dir(&self) -> PathBuf {
|
||||
self.cache_dir.join("build")
|
||||
}
|
||||
|
||||
/// File masks (`ide_search::FileMask`) for Find in Files and inspections: the
|
||||
/// excluded roots, the ignored names and the "Exclude files" patterns (files,
|
||||
/// and everything under such folders).
|
||||
pub fn search_excludes(&self) -> Vec<String> {
|
||||
let mut out: Vec<String> =
|
||||
self.roots().excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/'))).collect();
|
||||
let settings = self.settings.effective();
|
||||
let names =
|
||||
settings.files.ignored_names.iter().cloned().chain(settings.project_structure.exclude_file_patterns());
|
||||
for name in names {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() && !name.contains(['/', ',']) {
|
||||
out.push(name.to_owned());
|
||||
out.push(format!("**/{name}/**"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Project files of `q.languages` under `q.root` containing `q.word` as a whole
|
||||
/// word (unsaved buffers included; excluded roots, ignored names and
|
||||
/// `.gitignore`d files skipped): the files a references search opens on its server.
|
||||
pub fn reference_candidates(
|
||||
&self,
|
||||
q: &ide_lsp::CandidateQuery,
|
||||
cancel: &CancellationToken,
|
||||
) -> ide_lsp::CandidateFiles {
|
||||
if !q.root.starts_with(&self.root) {
|
||||
return ide_lsp::CandidateFiles::default();
|
||||
}
|
||||
let query = ide_search::WordQuery {
|
||||
word: q.word.clone(),
|
||||
dir: q.root.clone(),
|
||||
exclude: self.search_excludes(),
|
||||
limit: q.limit,
|
||||
};
|
||||
let filter = |rel: &str| q.languages.contains(&self.languages.detect(&self.root.join(rel), None));
|
||||
match ide_search::files_with_word(&self.root, &query, Some(&filter), &|p| self.documents.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);
|
||||
ide_lsp::CandidateFiles::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected).
|
||||
pub fn exclude_globs(&self) -> Vec<String> {
|
||||
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
|
||||
}
|
||||
|
||||
/// Project Structure content roots besides the project folder (existing directories).
|
||||
pub fn content_roots(&self) -> Vec<PathBuf> {
|
||||
content_roots(&self.settings.effective(), &self.root)
|
||||
}
|
||||
|
||||
/// Applies Settings › File Types; open documents whose language changed are
|
||||
/// re-detected (language servers and syntax trees follow) and the index
|
||||
/// re-parses the files whose language changed.
|
||||
pub fn apply_file_types(&self, settings: &ide_core::settings::Settings) {
|
||||
let before = Arc::new(self.languages.snapshot());
|
||||
if self.languages.set_file_types(FileTypes::from_settings(&settings.files, Some(&self.root))) {
|
||||
self.documents.redetect_languages();
|
||||
self.index.languages_changed(before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies changed content roots and exclude settings to the index (which
|
||||
/// rescans in the background) and the file watchers, without reopening the project.
|
||||
pub fn refresh_index_scope(&self) {
|
||||
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
|
||||
self.index.set_scope(roots.clone(), excludes.clone());
|
||||
self.watcher.set_excludes(&excludes);
|
||||
let mut watchers = self.content_watchers.lock();
|
||||
watchers.retain(|(p, _)| roots.contains(p));
|
||||
for (_, w) in watchers.iter() {
|
||||
w.set_excludes(&excludes);
|
||||
}
|
||||
for r in roots {
|
||||
if watchers.iter().any(|(p, _)| *p == r) {
|
||||
continue;
|
||||
}
|
||||
if let Some(w) =
|
||||
content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history)
|
||||
{
|
||||
watchers.push((r, w));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current text of `path`: the open buffer when available, else the file on disk.
|
||||
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(path)?.text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
for file in &edit.files {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
// Files not open in an editor are opened transiently and closed again.
|
||||
let (doc, transient) = match documents.by_path(path) {
|
||||
Some(doc) => (doc, false),
|
||||
None => (documents.open(path)?.id, true),
|
||||
};
|
||||
let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc));
|
||||
if transient {
|
||||
documents.close(doc);
|
||||
}
|
||||
result?;
|
||||
}
|
||||
FileEdit::Create { path, overwrite } => {
|
||||
if path.exists() {
|
||||
if *overwrite {
|
||||
ide_vfs::save_text(path, "", "utf-8", false)?;
|
||||
}
|
||||
} else {
|
||||
ide_vfs::create_file(path)?;
|
||||
}
|
||||
}
|
||||
FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?,
|
||||
FileEdit::Delete { path, .. } => ide_vfs::delete(path)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Files the watcher reports per batch that Local History snapshots.
|
||||
const HISTORY_BATCH_LIMIT: usize = 500;
|
||||
|
||||
/// Local History for changes made outside the IDE (other tools, VCS, deletions).
|
||||
/// Contents equal to the last snapshot (e.g. the IDE's own saves) are skipped.
|
||||
fn record_external_changes(history: &LocalHistory, changed: &[PathBuf], removed: &[PathBuf]) {
|
||||
for path in changed.iter().take(HISTORY_BATCH_LIMIT) {
|
||||
if let Err(e) = history.record_file(path, "External change", false) {
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
if !removed.is_empty()
|
||||
&& let Err(e) = history.note_removed(removed)
|
||||
{
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspace::Retention {
|
||||
ide_workspace::Retention {
|
||||
working_days: settings.files.local_history_days,
|
||||
max_bytes: u64::from(settings.files.local_history_max_mb.max(1)) * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Project Structure content roots outside `root` (existing absolute directories).
|
||||
fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for r in &settings.project_structure.content_roots {
|
||||
let p = PathBuf::from(r.trim());
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Watches a content root outside the project: the index, open documents and
|
||||
/// Local History follow its changes like the project folder's.
|
||||
fn content_root_watcher(
|
||||
dir: &Path,
|
||||
excludes: &[String],
|
||||
sink: &SharedSink,
|
||||
index: &Arc<ProjectIndex>,
|
||||
documents: &Arc<Documents>,
|
||||
history: &Arc<LocalHistory>,
|
||||
) -> Option<Watcher> {
|
||||
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
|
||||
Watcher::start(
|
||||
dir,
|
||||
excludes.to_vec(),
|
||||
sink.clone(),
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
documents.on_disk_changed(&changed);
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
}),
|
||||
)
|
||||
.map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display()))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec<String> {
|
||||
let mut globs = settings.file_excludes();
|
||||
globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs());
|
||||
globs
|
||||
}
|
||||
|
||||
fn project_key(root: &Path) -> String {
|
||||
// Stable FNV-1a directory name per project for caches.
|
||||
let s = root.to_string_lossy();
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for b in s.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
struct LspObserver(Arc<LspManager>);
|
||||
|
||||
impl DocumentObserver for LspObserver {
|
||||
fn opened(&self, info: &DocumentInfo) {
|
||||
// Language servers choke on huge files (IDEA disables code insight too);
|
||||
// changes to documents never opened on a server are ignored.
|
||||
if info.large {
|
||||
return;
|
||||
}
|
||||
match &info.uri {
|
||||
Some(uri) => self.0.did_open_virtual(info.id, uri, &info.language, info.version, &info.text),
|
||||
None => self.0.did_open(info.id, &info.path, &info.language, info.version, &info.text),
|
||||
}
|
||||
}
|
||||
fn changed(
|
||||
&self,
|
||||
doc: ide_core::DocId,
|
||||
_path: &Path,
|
||||
version: u32,
|
||||
changes: &[AppliedChange],
|
||||
full_text: &dyn Fn() -> String,
|
||||
) {
|
||||
self.0.did_change(doc, version, changes, full_text);
|
||||
}
|
||||
fn saved(&self, doc: ide_core::DocId, _path: &Path, text: &str) {
|
||||
self.0.did_save(doc, text);
|
||||
}
|
||||
fn closed(&self, doc: ide_core::DocId, _path: &Path) {
|
||||
self.0.did_close(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds document edits into the per-project syntax tree cache.
|
||||
struct SyntaxObserver(Arc<SyntaxCache>);
|
||||
|
||||
impl DocumentObserver for SyntaxObserver {
|
||||
fn opened(&self, info: &DocumentInfo) {
|
||||
if !info.large {
|
||||
self.0.open(info.id, &info.language, info.version);
|
||||
}
|
||||
}
|
||||
fn edited(&self, doc: ide_core::DocId, version: u32, edits: &[ByteEdit]) {
|
||||
self.0.edit(doc, version, edits);
|
||||
}
|
||||
fn closed(&self, doc: ide_core::DocId, _path: &Path) {
|
||||
self.0.close(doc);
|
||||
}
|
||||
}
|
||||
|
||||
struct HistoryObserver(Arc<LocalHistory>);
|
||||
|
||||
impl DocumentObserver for HistoryObserver {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, text: &str) {
|
||||
if let Err(e) = self.0.record(path, text, "Saved") {
|
||||
tracing::warn!("local history: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads saved files: remote files' local copies, and automatic upload to the default server.
|
||||
struct DeployObserver {
|
||||
remote: Arc<ide_remote::RemoteService>,
|
||||
settings: Arc<SettingsStore>,
|
||||
root: PathBuf,
|
||||
sink: SharedSink,
|
||||
runtime: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
impl DocumentObserver for DeployObserver {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
|
||||
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 {
|
||||
if let Some(result) = remote.on_saved(&settings.effective(), &root, &path).await {
|
||||
notify_transfer(&sink, "Upload", &path, result);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_deployed(
|
||||
runtime: &tokio::runtime::Handle,
|
||||
remote: Arc<ide_remote::RemoteService>,
|
||||
settings: Arc<SettingsStore>,
|
||||
root: PathBuf,
|
||||
removed: Vec<PathBuf>,
|
||||
sink: SharedSink,
|
||||
) {
|
||||
runtime.spawn(async move {
|
||||
if let Some(result) = remote.on_deleted(&settings.effective(), &root, &removed).await {
|
||||
let first = removed.first().cloned().unwrap_or_default();
|
||||
notify_transfer(&sink, "Delete on server", &first, result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Reports a failed automatic transfer (successes stay quiet).
|
||||
fn notify_transfer(sink: &SharedSink, what: &str, path: &Path, result: IdeResult<ide_remote::TransferReport>) {
|
||||
let body = match result {
|
||||
Ok(r) if r.errors.is_empty() => return,
|
||||
Ok(r) => r.errors.iter().map(|e| format!("{}: {}", e.path, e.message)).collect::<Vec<_>>().join("\n"),
|
||||
Err(e) => e.message,
|
||||
};
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Error,
|
||||
title: format!("{what} failed: {name}"),
|
||||
body: Some(body),
|
||||
});
|
||||
}
|
||||
|
||||
/// Settings › Version Control › Git › Path to Git executable (empty = auto-detect).
|
||||
pub fn apply_git_executable(settings: &ide_core::settings::Settings) {
|
||||
let exe = settings.vcs.git.executable.trim();
|
||||
ide_vcs::set_git_executable((!exe.is_empty()).then(|| Path::new(exe)));
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Remote development protocol: JSON-RPC 2.0 (`Content-Length` framing) between
|
||||
//! the desktop app and `openidea-server`, over the server's stdio (an SSH exec
|
||||
//! channel).
|
||||
//!
|
||||
//! * client → server request `initialize` `{ version }` → [`ServerInfo`];
|
||||
//! * client → server request `invoke` `{ window, cmd, args }` → the command's
|
||||
//! result, `args` as the desktop command takes them (camelCase names). A
|
||||
//! streaming argument is `{ "$channel": <id> }`;
|
||||
//! * server → client notification `event` `{ window, event }` (an `IdeEvent`
|
||||
//! for that window, or every window when `window` is null);
|
||||
//! * server → client notification `channel` `{ id, data }`: one value of a
|
||||
//! streaming argument.
|
||||
|
||||
use crate::Host;
|
||||
use crate::ops::{self, Emit};
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, RequestId};
|
||||
use ide_jsonrpc::{Handler, Peer, codes};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerInfo {
|
||||
pub version: String,
|
||||
pub protocol: u32,
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub home: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Invoke {
|
||||
pub window: String,
|
||||
pub cmd: String,
|
||||
#[serde(default)]
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventMessage {
|
||||
pub window: Option<String>,
|
||||
pub event: IdeEvent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelMessage {
|
||||
pub id: u64,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
/// One `invoke`: its arguments and a way to stream values back.
|
||||
pub struct Call {
|
||||
pub host: Arc<Host>,
|
||||
pub window: String,
|
||||
pub args: Value,
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
}
|
||||
|
||||
impl Call {
|
||||
/// Argument `name` (missing = `null`, so `Option`s may be omitted).
|
||||
pub fn arg<T: DeserializeOwned>(&self, name: &str) -> IdeResult<T> {
|
||||
let v = self.args.get(name).cloned().unwrap_or(Value::Null);
|
||||
serde_json::from_value(v).map_err(|e| IdeError::invalid(format!("argument {name}: {e}")))
|
||||
}
|
||||
|
||||
/// Streaming argument `name` as an [`ops::Channel`].
|
||||
pub fn sender<T: Serialize + 'static>(&self, name: &str) -> IdeResult<ops::Channel<T>> {
|
||||
let emit = self.channel(name)?;
|
||||
Ok(ops::Channel::new(move |v| emit(v)))
|
||||
}
|
||||
|
||||
/// Streaming argument `name` (`{ "$channel": id }`).
|
||||
pub fn channel<T: Serialize + 'static>(&self, name: &str) -> IdeResult<Emit<T>> {
|
||||
let id = self
|
||||
.args
|
||||
.get(name)
|
||||
.and_then(|v| v.get("$channel"))
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| IdeError::invalid(format!("argument {name}: expected a channel")))?;
|
||||
let peer = self.peer.clone();
|
||||
Ok(Arc::new(move |data: T| {
|
||||
let Some(peer) = peer.get() else { return };
|
||||
match serde_json::to_value(&data) {
|
||||
Ok(data) => {
|
||||
let _ = peer.notify("channel", ChannelMessage { id, data });
|
||||
}
|
||||
Err(e) => tracing::warn!("channel {id}: {e}"),
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
|
||||
type HandlerFn = Arc<dyn Fn(Call) -> BoxFuture<IdeResult<Value>> + Send + Sync>;
|
||||
|
||||
/// Commands the server answers, by name.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Dispatcher {
|
||||
handlers: HashMap<&'static str, HandlerFn>,
|
||||
}
|
||||
|
||||
impl Dispatcher {
|
||||
pub fn add<F, Fut, T>(&mut self, name: &'static str, f: F) -> &mut Self
|
||||
where
|
||||
F: Fn(Call) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = IdeResult<T>> + Send + 'static,
|
||||
T: Serialize,
|
||||
{
|
||||
let f = Arc::new(f);
|
||||
self.handlers.insert(
|
||||
name,
|
||||
Arc::new(move |call| {
|
||||
let fut = f(call);
|
||||
Box::pin(async move {
|
||||
let value = fut.await?;
|
||||
serde_json::to_value(value).map_err(|e| IdeError::internal(e.to_string()))
|
||||
})
|
||||
}),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> Vec<&'static str> {
|
||||
let mut out: Vec<_> = self.handlers.keys().copied().collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// The commands of the core remote slice: project, files, documents, search,
|
||||
/// terminals, runs and VCS status.
|
||||
pub fn standard() -> Self {
|
||||
let mut d = Self::default();
|
||||
d.add("open_project", |c| async move {
|
||||
let path: PathBuf = c.arg("path")?;
|
||||
let project = ops::project::open(&c.host, &c.window, &path).await?;
|
||||
Ok(ops::project::info(&project))
|
||||
})
|
||||
.add("project_current", |c| async move { Ok(ops::project::current(&c.host, &c.window)) })
|
||||
.add("close_project", |c| async move {
|
||||
c.host.close_project(&c.window);
|
||||
Ok(())
|
||||
})
|
||||
.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 {
|
||||
let key: String = c.arg("key")?;
|
||||
let scope = c.arg("scope")?;
|
||||
let applied = ops::project::set_settings(&c.host, &c.window, scope, &key, &c.arg::<String>("value")?)?;
|
||||
let sink = c.host.window_sink(&c.window);
|
||||
sink.emit(IdeEvent::SettingsChanged { key });
|
||||
if let Some(root) = applied.roots_changed {
|
||||
sink.emit(IdeEvent::ProjectRootsChanged { root });
|
||||
}
|
||||
Ok(applied.settings)
|
||||
})
|
||||
.add("cancel_request", |c| async move {
|
||||
c.host.cancel_request(c.arg::<RequestId>("id")?);
|
||||
Ok(())
|
||||
})
|
||||
.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_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 {
|
||||
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
|
||||
})
|
||||
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) })
|
||||
.add("doc_edit", |c| async move {
|
||||
ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?)
|
||||
})
|
||||
.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_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")?;
|
||||
ops::search::start(&c.host, &c.window, c.arg("request")?, c.arg("query")?, on_event).await
|
||||
})
|
||||
.add("term_spawn", |c| async move {
|
||||
let on_output = c.channel("onOutput")?;
|
||||
ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output)
|
||||
})
|
||||
.add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
|
||||
.add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
|
||||
.add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) })
|
||||
.add("term_is_busy", |c| async move { ops::term::is_busy(&c.host, c.arg("id")?) })
|
||||
.add("term_default_shell", |_| async move { Ok(ide_terminal::default_shell_path()) })
|
||||
.add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) })
|
||||
.add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) })
|
||||
.add("run_save_configs", |c| async move {
|
||||
ops::run::save_configs(&c.host, &c.window, &c.arg::<Vec<ide_run::RunConfig>>("configs")?)
|
||||
})
|
||||
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
|
||||
.add("run_start", |c| async move {
|
||||
let on_output = c.channel("onOutput")?;
|
||||
ops::run::start(&c.host, &c.window, c.arg("config")?, on_output).await
|
||||
})
|
||||
.add("run_stop", |c| async move { ops::run::stop(&c.host, &c.window, c.arg("id")?) })
|
||||
.add("run_stdin", |c| async move { ops::run::stdin(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
|
||||
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
|
||||
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
|
||||
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
|
||||
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
|
||||
ops::register_all(&mut d);
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves `host` over `reader` / `writer` until the client disconnects.
|
||||
/// `peer` must be the one whose events `host` sends ([`event_sink`]).
|
||||
pub async fn serve<R, W>(host: Arc<Host>, dispatcher: Dispatcher, peer: Arc<OnceLock<Peer>>, reader: R, writer: W)
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
W: tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let handler = Arc::new(ServerHandler { host: host.clone(), dispatcher, peer: peer.clone() });
|
||||
let p = Peer::connect(reader, writer, handler);
|
||||
let _ = peer.set(p.clone());
|
||||
p.closed().await;
|
||||
}
|
||||
|
||||
/// Sends a window's events to the client as `event` notifications.
|
||||
pub fn event_sink(peer: Arc<OnceLock<Peer>>, window: Option<String>) -> ide_core::SharedSink {
|
||||
Arc::new(PeerSink { peer, window })
|
||||
}
|
||||
|
||||
struct PeerSink {
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
window: Option<String>,
|
||||
}
|
||||
|
||||
impl ide_core::EventSink for PeerSink {
|
||||
fn emit(&self, event: IdeEvent) {
|
||||
if let Some(peer) = self.peer.get() {
|
||||
let _ = peer.notify("event", EventMessage { window: self.window.clone(), event });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerHandler {
|
||||
host: Arc<Host>,
|
||||
dispatcher: Dispatcher,
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
}
|
||||
|
||||
pub fn server_info() -> ServerInfo {
|
||||
ServerInfo {
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
protocol: PROTOCOL_VERSION,
|
||||
os: std::env::consts::OS.into(),
|
||||
arch: std::env::consts::ARCH.into(),
|
||||
home: dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler for ServerHandler {
|
||||
fn on_notification(&self, _method: &str, _params: Value) {}
|
||||
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
let internal = |e: serde_json::Error| (codes::INTERNAL_ERROR, e.to_string());
|
||||
match method {
|
||||
"initialize" => serde_json::to_value(server_info()).map_err(internal),
|
||||
"commands" => serde_json::to_value(self.dispatcher.commands()).map_err(internal),
|
||||
"invoke" => {
|
||||
let call: Invoke =
|
||||
serde_json::from_value(params).map_err(|e| (codes::INVALID_PARAMS, e.to_string()))?;
|
||||
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)));
|
||||
};
|
||||
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)
|
||||
}
|
||||
_ => Err((codes::METHOD_NOT_FOUND, format!("unknown method {method}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An `IdeError` as a JSON-RPC error: the JSON of the error is the message.
|
||||
fn error_reply(e: IdeError) -> (i64, String) {
|
||||
(codes::INTERNAL_ERROR, serde_json::to_string(&e).unwrap_or(e.message))
|
||||
}
|
||||
|
||||
/// The `IdeError` of an `invoke` reply (see [`error_reply`]).
|
||||
pub fn error_from_reply(e: ide_jsonrpc::RpcError) -> IdeError {
|
||||
match e {
|
||||
ide_jsonrpc::RpcError::Server { message, .. } => serde_json::from_str(&message)
|
||||
.unwrap_or_else(|_| IdeError::new(ide_core::error::ErrorKind::Protocol, message)),
|
||||
other => other.into(),
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,151 @@
|
||||
//! The remote protocol end to end: a server on one side of an in-memory pipe,
|
||||
//! a JSON-RPC client on the other.
|
||||
|
||||
use ide_host::rpc::{self, ChannelMessage, Dispatcher, EventMessage, Invoke, ServerInfo};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Client {
|
||||
channels: Mutex<Vec<ChannelMessage>>,
|
||||
events: Mutex<Vec<EventMessage>>,
|
||||
}
|
||||
|
||||
impl Handler for Client {
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
match method {
|
||||
"channel" => self.channels.lock().push(serde_json::from_value(params).unwrap()),
|
||||
"event" => self.events.lock().push(serde_json::from_value(params).unwrap()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> {
|
||||
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn invoke(peer: &Peer, cmd: &str, args: Value) -> Result<Value, ide_core::IdeError> {
|
||||
let call = Invoke { window: "main".into(), cmd: cmd.into(), args };
|
||||
peer.request::<_, Value>("invoke", call, None).await.map_err(rpc::error_from_reply)
|
||||
}
|
||||
|
||||
async fn until(what: &str, mut done: impl FnMut() -> bool) {
|
||||
for _ in 0..500 {
|
||||
if done() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("timed out waiting for {what}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn serves_projects_files_documents_search_and_runs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("demo");
|
||||
std::fs::create_dir_all(project.join("src")).unwrap();
|
||||
std::fs::write(project.join("src/main.py"), "print('needle')\n").unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer_slot.clone();
|
||||
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(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
}));
|
||||
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 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();
|
||||
assert_eq!(info.protocol, rpc::PROTOCOL_VERSION);
|
||||
|
||||
let opened = invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
||||
assert_eq!(opened["name"], "demo");
|
||||
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}");
|
||||
|
||||
// Edit and save a document.
|
||||
let file = project.join("src/main.py");
|
||||
let doc = invoke(&peer, "doc_open", json!({ "path": file })).await.unwrap();
|
||||
let id = doc["id"].clone();
|
||||
let version = doc["version"].as_u64().unwrap();
|
||||
let changes = json!({ "changes": [{ "from": 16, "to": 16, "insert": "print('more')\n" }] });
|
||||
invoke(&peer, "doc_edit", json!({ "doc": id, "baseVersion": version, "changes": changes })).await.unwrap();
|
||||
invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "print('needle')\nprint('more')\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();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "print('needle')\r\nprint('more')\r\n");
|
||||
let settings = invoke(
|
||||
&peer,
|
||||
"settings_set",
|
||||
json!({ "scope": "project", "key": "deployment.autoUpload",
|
||||
"value": "\"always\"" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings["deployment"]["autoUpload"], "always");
|
||||
assert!(std::fs::read_to_string(project.join(".openidea/settings.toml")).unwrap().contains("autoUpload"));
|
||||
invoke(&peer, "vcs_init", json!({ "dir": project })).await.unwrap();
|
||||
until("git repository", || project.join(".git").is_dir()).await;
|
||||
|
||||
// Unknown commands and bad arguments come back as IdeErrors.
|
||||
let e = invoke(&peer, "window_minimize", json!({})).await.unwrap_err();
|
||||
assert!(e.message.contains("not available remotely"), "{e:?}");
|
||||
let e = invoke(&peer, "doc_save", json!({ "doc": "nope" })).await.unwrap_err();
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput, "{e:?}");
|
||||
|
||||
// Find in Files streams through a channel.
|
||||
let query = json!({ "pattern": "needle", "isRegex": false, "caseSensitive": false, "wholeWord": false,
|
||||
"include": [], "exclude": [], "scope": { "kind": "project" }, "maxMatches": 0 });
|
||||
invoke(&peer, "search_start", json!({ "request": 7, "query": query, "onEvent": { "$channel": 1 } })).await.unwrap();
|
||||
let found = client.channels.lock().iter().filter(|c| c.id == 1).map(|c| c.data.clone()).collect::<Vec<_>>();
|
||||
assert!(found.iter().any(|d| d["type"] == "file"), "{found:?}");
|
||||
assert_eq!(found.last().unwrap()["type"], "done");
|
||||
|
||||
// A run's output streams through a channel and its exit is an event.
|
||||
let config = json!({ "id": "echo", "name": "echo", "kind": "shell", "command": "echo", "args": ["remote-hi"],
|
||||
"cwd": null, "env": {}, "detected": false, "isTest": false, "debugAdapter": null });
|
||||
invoke(&peer, "run_start", json!({ "config": config, "onOutput": { "$channel": 2 } })).await.unwrap();
|
||||
until("run exit", || client.events.lock().iter().any(|e| e.event_type() == "runExited")).await;
|
||||
let out: String = client
|
||||
.channels
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|c| c.id == 2)
|
||||
.filter_map(|c| c.data["text"].as_str().map(str::to_owned))
|
||||
.collect();
|
||||
assert!(out.contains("remote-hi"), "{out}");
|
||||
assert_eq!(
|
||||
client.events.lock().iter().find(|e| e.event_type() == "runExited").unwrap().window.as_deref(),
|
||||
Some("main")
|
||||
);
|
||||
}
|
||||
|
||||
trait EventType {
|
||||
fn event_type(&self) -> String;
|
||||
}
|
||||
|
||||
impl EventType for EventMessage {
|
||||
fn event_type(&self) -> String {
|
||||
serde_json::to_value(&self.event).unwrap()["type"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
}
|
||||
@@ -502,7 +502,9 @@ impl Peer {
|
||||
}
|
||||
|
||||
// Notifications are delivered in order on a dedicated thread. Requests start from the same
|
||||
// queue, so a request handler sees every notification that arrived before it.
|
||||
// queue, so a request handler sees every notification that arrived before it, and responses
|
||||
// complete from it, so a caller sees the notifications sent before its response (a streamed
|
||||
// result's last values).
|
||||
let (notif_tx, notif_rx) = std::sync::mpsc::channel::<Incoming>();
|
||||
{
|
||||
let handler = handler.clone();
|
||||
@@ -525,6 +527,9 @@ impl Peer {
|
||||
Incoming::Request(id, method, params) => {
|
||||
runtime.spawn(handle_request(inner.clone(), handler.clone(), id, method, params));
|
||||
}
|
||||
Incoming::Response(tx, result) => {
|
||||
let _ = tx.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -696,6 +701,7 @@ fn parse_id(id: &Value) -> Option<i64> {
|
||||
enum Incoming {
|
||||
Notification(String, Value),
|
||||
Request(Value, String, Value),
|
||||
Response(oneshot::Sender<Result<Value, RpcError>>, Result<Value, RpcError>),
|
||||
}
|
||||
|
||||
async fn handle_request(inner: Arc<Inner>, handler: Arc<dyn Handler>, id: Value, method: String, params: Value) {
|
||||
@@ -767,7 +773,7 @@ fn dispatch(inner: &Arc<Inner>, notif_tx: &std::sync::mpsc::Sender<Incoming>, bo
|
||||
}
|
||||
_ => Ok(obj.remove("result").unwrap_or(Value::Null)),
|
||||
};
|
||||
let _ = tx.send(result);
|
||||
let _ = notif_tx.send(Incoming::Response(tx, result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ license.workspace = true
|
||||
ide-core.workspace = true
|
||||
ide-text.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
toml.workspace = true
|
||||
tree-sitter.workspace = true
|
||||
|
||||
@@ -72,6 +72,10 @@ pub struct LanguageServerConfig {
|
||||
/// JSON passed as `initializationOptions`.
|
||||
#[serde(default)]
|
||||
pub initialization_options: Option<toml::Value>,
|
||||
/// Answers `workspace/configuration`; without it the sections are looked up
|
||||
/// in `initialization_options`.
|
||||
#[serde(default)]
|
||||
pub settings: Option<serde_json::Value>,
|
||||
/// Human hint shown when the binary is missing.
|
||||
#[serde(default)]
|
||||
pub install_hint: Option<String>,
|
||||
@@ -87,6 +91,10 @@ pub struct LanguageServerConfig {
|
||||
/// basedpyright): only used while none of those is installed.
|
||||
#[serde(default)]
|
||||
pub fallback: bool,
|
||||
/// Newest Java release the server runs on (kotlin-language-server: 21). A
|
||||
/// newer `JAVA_HOME` is replaced by the newest installed JDK that fits.
|
||||
#[serde(default)]
|
||||
pub max_java: Option<u32>,
|
||||
/// Set by the settings hook when the server can't run here (jdtls without
|
||||
/// a JDK 21+); reported as the failure instead of launching it.
|
||||
#[serde(skip)]
|
||||
|
||||
@@ -79,15 +79,24 @@ fn is_name_leaf(kind: &str) -> bool {
|
||||
kind.contains("identifier") || kind == "name"
|
||||
}
|
||||
|
||||
/// Node kinds whose `name` field is a use, not a declaration.
|
||||
const USE_PARENTS: &[&str] =
|
||||
&["invocation", "call", "access", "scoped", "reference", "expression", "selector", "qualified", "use_", "import"];
|
||||
|
||||
/// Whether the identifier leaf under `cursor` is the name a declaration
|
||||
/// introduces (`fn name`, `let pattern`, `int *declarator`, parameters,
|
||||
/// `name = …` assignments) rather than a use of one.
|
||||
fn declares(cursor: &TreeCursor<'_>, node: &Node<'_>) -> bool {
|
||||
let parent = node.parent().map(|p| p.kind()).unwrap_or_default();
|
||||
match cursor.field_name() {
|
||||
Some("name" | "pattern" | "declarator") => true,
|
||||
// Uses name their target with a `name` field too: Java `s.substring()`,
|
||||
// C# `a.Name`, Rust `use a::b::name`.
|
||||
Some("name") => !USE_PARENTS.iter().any(|p| parent.contains(p)),
|
||||
Some("pattern" | "declarator") => true,
|
||||
Some("left") => parent.contains("assignment") || parent.contains("declarator"),
|
||||
_ => parent.contains("parameter"),
|
||||
// A parameter's name, not its type (`String text`).
|
||||
Some("type") => false,
|
||||
_ => parent.contains("parameter") && !node.kind().contains("type"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1310,4 +1310,23 @@ fn spelling_regions_cover_declared_names_only() {
|
||||
.map(|r| &py[r.start..r.end])
|
||||
.collect();
|
||||
assert_eq!(names, ["load_config", "path", "retries", "total"]);
|
||||
// Names in paths and calls are uses.
|
||||
let rs = "use crate::ui::keymap;\nfn run() { let n = std::ffi::OsStr::new(\"x\"); a::b::millis(); }\n";
|
||||
let names: Vec<&str> = syntax()
|
||||
.spelling_regions(&lang("rust"), rs)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r.kind == SpellingKind::Name)
|
||||
.map(|r| &rs[r.start..r.end])
|
||||
.collect();
|
||||
assert_eq!(names, ["run", "n"]);
|
||||
let java = "class Bird { String fly(String text) { return text.substring(1); } }\n";
|
||||
let names: Vec<&str> = syntax()
|
||||
.spelling_regions(&lang("java"), java)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r.kind == SpellingKind::Name)
|
||||
.map(|r| &java[r.start..r.end])
|
||||
.collect();
|
||||
assert_eq!(names, ["Bird", "fly", "text"]);
|
||||
}
|
||||
@@ -125,20 +125,18 @@ impl ClientHandler {
|
||||
}
|
||||
|
||||
fn configuration(&self, params: &Value) -> Value {
|
||||
let options = self
|
||||
.server
|
||||
.effective_config()
|
||||
.initialization_options
|
||||
.as_ref()
|
||||
.and_then(|v| serde_json::to_value(v).ok())
|
||||
.unwrap_or(Value::Null);
|
||||
let options = all_settings(&self.server.effective_config());
|
||||
let items = params.get("items").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
Value::Array(
|
||||
items
|
||||
.iter()
|
||||
// vtsls asks for the whole configuration as section `""`.
|
||||
.map(|item| match item.get("section").and_then(Value::as_str).filter(|s| !s.is_empty()) {
|
||||
Some(section) if section == self.server.name => options.clone(),
|
||||
// The server's own section: its block under `settings` (basedpyright asks
|
||||
// `basedpyright` and `basedpyright.analysis`), else the options themselves.
|
||||
Some(section) if section == self.server.name => {
|
||||
options.get("settings").and_then(|s| s.get(section)).unwrap_or(&options).clone()
|
||||
}
|
||||
// Servers that take settings under `initializationOptions.settings`
|
||||
// (jdtls) ask for the same sections later.
|
||||
Some(section) => [&options, options.get("settings").unwrap_or(&Value::Null)]
|
||||
@@ -244,3 +242,29 @@ impl Handler for ClientHandler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What `workspace/configuration` sections are looked up in: the server's
|
||||
/// `initializationOptions` with its `settings` merged over them.
|
||||
pub(crate) fn all_settings(cfg: &ide_language::LanguageServerConfig) -> Value {
|
||||
let mut out = cfg.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).unwrap_or(Value::Null);
|
||||
if let Some(settings) = &cfg.settings {
|
||||
merge(&mut out, settings.clone());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn merge(base: &mut Value, layer: Value) {
|
||||
match (base, layer) {
|
||||
(Value::Object(base), Value::Object(layer)) => {
|
||||
for (k, v) in layer {
|
||||
match base.get_mut(&k) {
|
||||
Some(existing) => merge(existing, v),
|
||||
None => {
|
||||
base.insert(k, v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(base, layer) => *base = layer,
|
||||
}
|
||||
}
|
||||
@@ -65,8 +65,7 @@ impl LspManager {
|
||||
/// run doesn't prompt to install every language it meets.
|
||||
pub fn serves_path(&self, path: &Path) -> bool {
|
||||
let lang = self.languages.detect(path, None);
|
||||
let Some(config) = self.languages.get(&lang) else { return false };
|
||||
config.language_servers.iter().any(|ls| {
|
||||
self.language_servers(&lang).iter().any(|ls| {
|
||||
let mut cfg = ls.clone();
|
||||
if let Some(hook) = self.config_hook.read().clone() {
|
||||
hook(&mut cfg, &self.project_root);
|
||||
|
||||
@@ -74,7 +74,7 @@ impl Launcher for ProcessLauncher {
|
||||
let search = server_search_path(config);
|
||||
let binary = which::which_in(&config.command, Some(&search), root)
|
||||
.ok()
|
||||
.map(|b| resolve_rustup_proxy(&config.command, b));
|
||||
.and_then(|b| resolve_rustup_proxy(&config.command, b));
|
||||
let mut env: Vec<(String, String)> = vec![("PATH".into(), search.to_string_lossy().into_owned())];
|
||||
env.extend(config.env.iter().filter(|(k, _)| *k != "PATH").map(|(k, v)| (k.clone(), v.clone())));
|
||||
if let Some(addr) = &config.connect {
|
||||
@@ -109,11 +109,12 @@ fn server_search_path(config: &LanguageServerConfig) -> OsString {
|
||||
/// minimal environment.
|
||||
/// `~/.cargo/bin/<tool>` is usually a rustup proxy that fails when the active
|
||||
/// toolchain lacks the component (common for rust-analyzer). In that case use
|
||||
/// the newest installed toolchain that ships the binary.
|
||||
fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> PathBuf {
|
||||
/// the newest installed toolchain that ships the binary; with none, the tool is
|
||||
/// missing (an install hint, not a server that crashes on every restart).
|
||||
fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> Option<PathBuf> {
|
||||
let is_proxy = binary.parent().is_some_and(|d| d.ends_with(".cargo/bin"));
|
||||
if !is_proxy || command.contains('/') {
|
||||
return binary;
|
||||
return Some(binary);
|
||||
}
|
||||
let works = ide_core::process::no_window(&mut std::process::Command::new(&binary))
|
||||
.arg("--version")
|
||||
@@ -123,7 +124,7 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> PathBuf {
|
||||
.status()
|
||||
.is_ok_and(|s| s.success());
|
||||
if works {
|
||||
return binary;
|
||||
return Some(binary);
|
||||
}
|
||||
let toolchains = std::env::var_os("RUSTUP_HOME")
|
||||
.map(PathBuf::from)
|
||||
@@ -143,9 +144,12 @@ fn resolve_rustup_proxy(command: &str, binary: PathBuf) -> PathBuf {
|
||||
match candidates.pop() {
|
||||
Some((_, path)) => {
|
||||
tracing::info!("using {} instead of the rustup proxy", path.display());
|
||||
path
|
||||
Some(path)
|
||||
}
|
||||
None => {
|
||||
tracing::info!("{} is a rustup proxy without the component", binary.display());
|
||||
None
|
||||
}
|
||||
None => binary,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,7 +290,7 @@ mod proxy_tests {
|
||||
#[test]
|
||||
fn non_proxy_binaries_are_kept() {
|
||||
let p = PathBuf::from("/usr/bin/true");
|
||||
assert_eq!(resolve_rustup_proxy("true", p.clone()), p);
|
||||
assert_eq!(resolve_rustup_proxy("true", p.clone()), Some(p));
|
||||
}
|
||||
|
||||
/// Exercises the real machine setup when a broken rustup proxy is present.
|
||||
@@ -295,7 +299,7 @@ mod proxy_tests {
|
||||
fn broken_rustup_proxy_falls_back_to_a_toolchain() {
|
||||
let home = PathBuf::from(std::env::var_os("HOME").unwrap());
|
||||
let proxy = home.join(".cargo/bin/rust-analyzer");
|
||||
let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone());
|
||||
let resolved = resolve_rustup_proxy("rust-analyzer", proxy.clone()).unwrap();
|
||||
eprintln!("resolved: {}", resolved.display());
|
||||
assert_ne!(resolved, proxy);
|
||||
}
|
||||
|
||||
+161
-5
@@ -56,6 +56,12 @@ use types::*;
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const COMPLETION_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const LONG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long a server may take to answer `initialize`. Some import the whole
|
||||
/// build there (kotlin-lsp, jdtls on a large Gradle project) and need minutes
|
||||
/// on a cold start; the status shows "starting" meanwhile, and a crash still
|
||||
/// ends the wait at once.
|
||||
const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
/// How long a request waits for a server that is still initializing.
|
||||
const START_WAIT: Duration = Duration::from_secs(10);
|
||||
/// File moves may start a server (jdtls takes a while to import).
|
||||
@@ -68,6 +74,9 @@ pub type ApplyEditHook = Arc<dyn Fn(WorkspaceEdit) -> bool + Send + Sync>;
|
||||
/// settings before each launch; receives the server's workspace root.
|
||||
pub type ConfigFn = dyn Fn(&mut LanguageServerConfig, &Path) + Send + Sync;
|
||||
pub type ConfigHook = Arc<ConfigFn>;
|
||||
/// The servers a language uses, from its definition's list (user settings
|
||||
/// disable some and add others).
|
||||
pub type ServersFn = dyn Fn(&LanguageId, &[LanguageServerConfig]) -> Vec<LanguageServerConfig> + Send + Sync;
|
||||
|
||||
struct Attached {
|
||||
server: Arc<Server>,
|
||||
@@ -146,6 +155,7 @@ pub struct LspManager {
|
||||
docs: Mutex<HashMap<DocId, DocState>>,
|
||||
apply_edit_hook: RwLock<Option<ApplyEditHook>>,
|
||||
config_hook: RwLock<Option<ConfigHook>>,
|
||||
servers_hook: RwLock<Option<Arc<ServersFn>>>,
|
||||
candidates_hook: RwLock<Option<Arc<CandidateFn>>>,
|
||||
shutting_down: AtomicBool,
|
||||
/// `window/showMessage` texts already surfaced; servers repeat them on every restart/reconfigure.
|
||||
@@ -264,6 +274,7 @@ impl LspManager {
|
||||
docs: Mutex::new(HashMap::new()),
|
||||
apply_edit_hook: RwLock::new(None),
|
||||
config_hook: RwLock::new(None),
|
||||
servers_hook: RwLock::new(None),
|
||||
candidates_hook: RwLock::new(None),
|
||||
shutting_down: AtomicBool::new(false),
|
||||
shown_messages: Mutex::new(HashSet::new()),
|
||||
@@ -297,10 +308,9 @@ impl LspManager {
|
||||
/// The servers serving `path`: every regular one, and each `fallback` one
|
||||
/// only while all servers listed before it are missing.
|
||||
fn servers_for(&self, language: &LanguageId, path: &Path) -> Vec<Arc<Server>> {
|
||||
let Some(cfg) = self.languages.get(language) else { return Vec::new() };
|
||||
let mut out = Vec::new();
|
||||
let mut available_before = false;
|
||||
for sc in &cfg.language_servers {
|
||||
for sc in &self.language_servers(language) {
|
||||
if sc.fallback && available_before {
|
||||
continue;
|
||||
}
|
||||
@@ -311,8 +321,18 @@ impl LspManager {
|
||||
out
|
||||
}
|
||||
|
||||
/// The definitions of the servers `language` uses.
|
||||
fn language_servers(&self, language: &LanguageId) -> Vec<LanguageServerConfig> {
|
||||
let Some(cfg) = self.languages.get(language) else { return Vec::new() };
|
||||
match self.servers_hook.read().clone() {
|
||||
Some(hook) => hook(language, &cfg.language_servers),
|
||||
None => cfg.language_servers,
|
||||
}
|
||||
}
|
||||
|
||||
/// A server turned out to be missing: attach the fallbacks it was hiding
|
||||
/// to the open documents (and start them).
|
||||
/// to the open documents (and start them). Also attaches servers newly
|
||||
/// enabled in settings.
|
||||
fn attach_fallbacks(&self) {
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> =
|
||||
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
|
||||
@@ -407,7 +427,7 @@ impl LspManager {
|
||||
};
|
||||
|
||||
let init = self.initialize_params(&server);
|
||||
let result = tokio::time::timeout(Duration::from_secs(60), peer.request::<_, Value>("initialize", init, None))
|
||||
let result = tokio::time::timeout(INITIALIZE_TIMEOUT, peer.request::<_, Value>("initialize", init, None))
|
||||
.await
|
||||
.unwrap_or(Err(RpcError::Timeout));
|
||||
let caps = match result {
|
||||
@@ -432,6 +452,14 @@ impl LspManager {
|
||||
if let Err(e) = peer.notify("initialized", json!({})) {
|
||||
tracing::warn!("{}: {e}", server.name);
|
||||
}
|
||||
// Servers that don't ask with `workspace/configuration` only learn settings this way.
|
||||
let cfg = server.effective_config();
|
||||
if cfg.settings.is_some()
|
||||
&& let Err(e) =
|
||||
peer.notify("workspace/didChangeConfiguration", json!({ "settings": handler::all_settings(&cfg) }))
|
||||
{
|
||||
tracing::debug!("{}: didChangeConfiguration: {e}", server.name);
|
||||
}
|
||||
let run = Running { generation, peer: peer.clone(), caps: Arc::new(RwLock::new(caps)) };
|
||||
|
||||
// Publish readiness and (re)open attached documents atomically w.r.t. did_change.
|
||||
@@ -1778,6 +1806,11 @@ impl LspManager {
|
||||
*self.config_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
/// Installed by `src-tauri` to apply the servers enabled / defined in settings.
|
||||
pub fn set_servers_hook(&self, hook: Box<ServersFn>) {
|
||||
*self.servers_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
/// The server's base configuration with the settings hook applied.
|
||||
fn configured(&self, server: &Server) -> LanguageServerConfig {
|
||||
let mut cfg = server.config.clone();
|
||||
@@ -1789,11 +1822,26 @@ impl LspManager {
|
||||
|
||||
/// Restarts running servers whose settings-derived configuration changed
|
||||
/// (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 async fn reconfigure(&self) {
|
||||
self.stop_unlisted().await;
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
for s in servers {
|
||||
let next = self.configured(&s);
|
||||
if next == s.effective_config() {
|
||||
let current = s.effective_config();
|
||||
if next == current {
|
||||
continue;
|
||||
}
|
||||
let without_settings = |c: &LanguageServerConfig| LanguageServerConfig { settings: None, ..c.clone() };
|
||||
if let Some(run) = s.running()
|
||||
&& without_settings(&next) == without_settings(¤t)
|
||||
{
|
||||
s.set_effective_config(next);
|
||||
let params = json!({ "settings": handler::all_settings(&s.effective_config()) });
|
||||
if let Err(e) = run.peer.notify("workspace/didChangeConfiguration", params) {
|
||||
tracing::debug!("{}: didChangeConfiguration: {e}", s.name);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
match s.status() {
|
||||
@@ -1815,12 +1863,120 @@ impl LspManager {
|
||||
ide_core::event::ServerStatus::Stopped => {}
|
||||
}
|
||||
}
|
||||
self.attach_fallbacks();
|
||||
self.detach_shadowed_fallbacks().await;
|
||||
}
|
||||
|
||||
/// A server enabled again shadows the fallbacks attached in its place: they
|
||||
/// leave the open documents, and stop once no document uses them.
|
||||
async fn detach_shadowed_fallbacks(&self) {
|
||||
let open: Vec<(DocId, LanguageId, PathBuf)> =
|
||||
self.docs.lock().iter().map(|(id, d)| (*id, d.language.clone(), d.path.clone())).collect();
|
||||
let mut shadowed: Vec<(DocId, Vec<ide_core::ServerId>)> = Vec::new();
|
||||
for (id, language, path) in open {
|
||||
let configs = self.language_servers(&language);
|
||||
let wanted = self.servers_for(&language, &path);
|
||||
let docs = self.docs.lock();
|
||||
let Some(d) = docs.get(&id) else { continue };
|
||||
let gone: Vec<ide_core::ServerId> = d
|
||||
.servers
|
||||
.iter()
|
||||
.filter(|a| {
|
||||
!wanted.iter().any(|w| w.id == a.server.id)
|
||||
&& configs.iter().any(|c| c.fallback && c.name == a.server.name)
|
||||
})
|
||||
.map(|a| a.server.id)
|
||||
.collect();
|
||||
if !gone.is_empty() {
|
||||
shadowed.push((id, gone));
|
||||
}
|
||||
}
|
||||
if shadowed.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut detached: Vec<Arc<Server>> = Vec::new();
|
||||
{
|
||||
let mut docs = self.docs.lock();
|
||||
for (id, gone) in shadowed {
|
||||
let Some(d) = docs.get_mut(&id) else { continue };
|
||||
let (leaving, staying): (Vec<Attached>, Vec<Attached>) =
|
||||
d.servers.drain(..).partition(|a| gone.contains(&a.server.id));
|
||||
d.servers = staying;
|
||||
for a in leaving {
|
||||
if let Some(run) = a.server.running()
|
||||
&& a.opened_gen == Some(run.generation)
|
||||
&& run.caps.read().open_close
|
||||
&& let Err(e) =
|
||||
run.peer.notify("textDocument/didClose", json!({ "textDocument": { "uri": d.uri } }))
|
||||
{
|
||||
tracing::debug!("{}: didClose: {e}", a.server.name);
|
||||
}
|
||||
self.sink.emit(ide_core::IdeEvent::Diagnostics {
|
||||
path: d.path.clone().into(),
|
||||
source: a.server.name.clone(),
|
||||
items: Vec::new(),
|
||||
});
|
||||
if !detached.iter().any(|s| s.id == a.server.id) {
|
||||
detached.push(a.server);
|
||||
}
|
||||
}
|
||||
}
|
||||
let in_use = |s: &Arc<Server>| docs.values().any(|d| d.servers.iter().any(|a| a.server.id == s.id));
|
||||
detached.retain(|s| !in_use(s));
|
||||
}
|
||||
if detached.is_empty() {
|
||||
return;
|
||||
}
|
||||
for s in &detached {
|
||||
tracing::info!("{}: shadowed by an enabled server, stopping", s.name);
|
||||
self.stop_instance(s, true).await;
|
||||
}
|
||||
self.servers.lock().retain(|s| !detached.iter().any(|d| d.id == s.id));
|
||||
}
|
||||
|
||||
/// Stops and forgets the servers that none of their languages uses any more.
|
||||
async fn stop_unlisted(&self) {
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
let unlisted: Vec<Arc<Server>> = servers
|
||||
.into_iter()
|
||||
.filter(|s| !s.languages().iter().any(|l| self.language_servers(l).iter().any(|c| c.name == s.name)))
|
||||
.collect();
|
||||
if unlisted.is_empty() {
|
||||
return;
|
||||
}
|
||||
for s in &unlisted {
|
||||
tracing::info!("{}: no longer enabled, stopping", s.name);
|
||||
self.stop_instance(s, true).await;
|
||||
}
|
||||
let gone = |s: &Arc<Server>| unlisted.iter().any(|u| u.id == s.id);
|
||||
self.servers.lock().retain(|s| !gone(s));
|
||||
for d in self.docs.lock().values_mut() {
|
||||
d.servers.retain(|a| !gone(&a.server));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_apply_edit_hook(&self, hook: Box<dyn Fn(WorkspaceEdit) -> bool + Send + Sync>) {
|
||||
*self.apply_edit_hook.write() = Some(Arc::from(hook));
|
||||
}
|
||||
|
||||
/// Emits every server's current diagnostics again: a UI that (re)loaded
|
||||
/// after they were published has missed those events.
|
||||
pub fn resend_diagnostics(&self) {
|
||||
let servers: Vec<Arc<Server>> = self.servers.lock().clone();
|
||||
for server in servers {
|
||||
let encoding = server.running().map(|r| r.caps.read().encoding).unwrap_or_default();
|
||||
let conv = self.conv(encoding);
|
||||
for (path, items) in server.diagnostics_by_path() {
|
||||
let items = items.into_iter().map(|d| convert::diagnostic(&conv, &path, d, &server.name)).collect();
|
||||
self.sink.emit(ide_core::IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
source: server.name.clone(),
|
||||
items,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn servers(&self) -> Vec<ServerInfo> {
|
||||
self.servers
|
||||
.lock()
|
||||
|
||||
@@ -461,6 +461,11 @@ impl Server {
|
||||
d.iter().flat_map(|(p, items)| items.iter().map(move |i| (p.clone(), i.clone()))).collect()
|
||||
}
|
||||
|
||||
/// The current diagnostics of each file that has some.
|
||||
pub fn diagnostics_by_path(&self) -> Vec<(PathBuf, Vec<lsp::Diagnostic>)> {
|
||||
self.diagnostics.lock().iter().map(|(p, d)| (p.clone(), d.clone())).collect()
|
||||
}
|
||||
|
||||
pub fn take_diagnostic_paths(&self) -> Vec<PathBuf> {
|
||||
self.published.lock().clear();
|
||||
self.diagnostics.lock().drain().map(|(p, _)| p).collect()
|
||||
|
||||
@@ -203,9 +203,11 @@ fn server_cfg(name: &str) -> LanguageServerConfig {
|
||||
env: Default::default(),
|
||||
root_markers: vec!["mock.toml".into()],
|
||||
initialization_options: Some(toml::Value::Table(toml::toml! { flag = true nested = { deep = 1 } })),
|
||||
settings: None,
|
||||
install_hint: Some(format!("install {name}")),
|
||||
connect: None,
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
}
|
||||
}
|
||||
@@ -521,6 +523,15 @@ async fn diagnostics_become_events_and_feed_code_actions() {
|
||||
assert_eq!(items[0].range, rng(0, 4, 0, 10));
|
||||
}
|
||||
|
||||
// A reloaded UI asks for them again.
|
||||
h.events.lock().clear();
|
||||
h.mgr.resend_diagnostics();
|
||||
let again = h.events.lock().iter().find_map(|e| match e {
|
||||
IdeEvent::Diagnostics { path, items, .. } => Some((path.clone(), items.len())),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(again, Some((path.clone().into(), 2)));
|
||||
|
||||
s.respond(
|
||||
"textDocument/codeAction",
|
||||
json!([
|
||||
@@ -795,6 +806,54 @@ async fn launch_error_fails_without_starting_the_server() {
|
||||
assert!(!h.statuses("mock").contains(&ServerStatus::Crashed));
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn settings_changes_reach_servers_without_restarts() {
|
||||
let h = harness(&[("mock", incremental_caps()), ("extra", incremental_caps())]);
|
||||
let settings: Arc<Mutex<Option<Value>>> = Default::default();
|
||||
let disabled: Arc<Mutex<Vec<String>>> = Default::default();
|
||||
{
|
||||
let settings = settings.clone();
|
||||
h.mgr.set_config_hook(Box::new(move |cfg, _| cfg.settings = settings.lock().clone()));
|
||||
}
|
||||
{
|
||||
let disabled = disabled.clone();
|
||||
h.mgr.set_servers_hook(Box::new(move |_, defined| {
|
||||
defined.iter().filter(|c| !disabled.lock().contains(&c.name)).cloned().collect()
|
||||
}));
|
||||
}
|
||||
h.open("a.mk", "x");
|
||||
h.wait_status("mock", ServerStatus::Ready).await;
|
||||
h.wait_status("extra", ServerStatus::Ready).await;
|
||||
|
||||
// Only `settings` changed: pushed to the running server, which keeps running.
|
||||
let s = h.launcher.state("mock");
|
||||
*settings.lock() = Some(json!({ "nested": { "other": 2 } }));
|
||||
h.mgr.reconfigure().await;
|
||||
eventually(|| !s.notified("workspace/didChangeConfiguration").is_empty()).await;
|
||||
assert_eq!(
|
||||
s.notified("workspace/didChangeConfiguration")[0],
|
||||
json!({ "settings": { "flag": true, "nested": { "deep": 1, "other": 2 } } })
|
||||
);
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
|
||||
let cfg: Value = s
|
||||
.peer()
|
||||
.request("workspace/configuration", json!({ "items": [{ "section": "nested.other" }] }), None)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(cfg, json!([2]));
|
||||
|
||||
// Disabled: stopped and forgotten; enabled again: attached to the open document.
|
||||
disabled.lock().push("extra".into());
|
||||
h.mgr.reconfigure().await;
|
||||
assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["mock"]);
|
||||
assert_eq!(h.statuses("extra").last(), Some(&ServerStatus::Stopped));
|
||||
disabled.lock().clear();
|
||||
h.mgr.reconfigure().await;
|
||||
h.wait_status("extra", ServerStatus::Ready).await;
|
||||
eventually(|| h.launcher.state("extra").notified("textDocument/didOpen").len() == 2).await;
|
||||
assert_eq!(s.launches.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
|
||||
// Preferred installed: the alternative is never started.
|
||||
@@ -819,6 +878,40 @@ async fn fallback_servers_only_run_when_the_preferred_one_is_missing() {
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn a_fallback_leaves_when_the_preferred_server_is_enabled_again() {
|
||||
let h = harness_with_fallbacks(&[("main", incremental_caps()), ("alt", incremental_caps())], &["alt"]);
|
||||
let disabled: Arc<Mutex<Vec<String>>> = Default::default();
|
||||
{
|
||||
let disabled = disabled.clone();
|
||||
h.mgr.set_servers_hook(Box::new(move |_, defined| {
|
||||
defined.iter().filter(|c| !disabled.lock().contains(&c.name)).cloned().collect()
|
||||
}));
|
||||
}
|
||||
let (doc, path) = h.open("a.mk", "x");
|
||||
let uri = uri_of(&path);
|
||||
h.wait_status("main", ServerStatus::Ready).await;
|
||||
assert!(h.statuses("alt").is_empty());
|
||||
|
||||
// Preferred disabled: the fallback takes over the open document.
|
||||
disabled.lock().push("main".into());
|
||||
h.mgr.reconfigure().await;
|
||||
h.wait_status("alt", ServerStatus::Ready).await;
|
||||
eventually(|| h.launcher.state("alt").docs.lock().contains_key(&uri)).await;
|
||||
assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["alt"]);
|
||||
|
||||
// Enabled again: the fallback closes the document and stops.
|
||||
disabled.lock().clear();
|
||||
h.mgr.reconfigure().await;
|
||||
h.wait_status("main", ServerStatus::Ready).await;
|
||||
eventually(|| h.launcher.state("main").docs.lock().contains_key(&uri)).await;
|
||||
assert!(!h.launcher.state("alt").notified("textDocument/didClose").is_empty());
|
||||
assert_eq!(h.statuses("alt").last(), Some(&ServerStatus::Stopped));
|
||||
assert_eq!(h.mgr.servers().iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["main"]);
|
||||
assert!(h.mgr.hover(doc, Pos::new(0, 0), Default::default()).await.is_ok());
|
||||
assert_eq!(h.launcher.state("alt").requested("textDocument/hover").len(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread")]
|
||||
async fn implementations_per_position() {
|
||||
let caps = json!({ "textDocumentSync": 1, "implementationProvider": true });
|
||||
|
||||
@@ -70,9 +70,11 @@ async fn missing_binary_is_reported_with_install_hint() {
|
||||
env: Default::default(),
|
||||
root_markers: vec![],
|
||||
initialization_options: None,
|
||||
settings: None,
|
||||
install_hint: Some("npm i -g nope-ls".into()),
|
||||
connect: None,
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
};
|
||||
let (mgr, events) = manager(dir.path(), LanguageRegistry::from_configs(vec![lang("nope", "nope", server)]));
|
||||
@@ -198,9 +200,11 @@ async fn typescript_language_server_end_to_end() {
|
||||
env: Default::default(),
|
||||
root_markers: vec!["tsconfig.json".into()],
|
||||
initialization_options: None,
|
||||
settings: None,
|
||||
install_hint: None,
|
||||
connect: None,
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
};
|
||||
let (mgr, events) = manager(&root, LanguageRegistry::from_configs(vec![lang("typescript", "ts", server)]));
|
||||
@@ -240,9 +244,11 @@ fn npm_server(tools: &Path, name: &str, bin: &str, init: Option<serde_json::Valu
|
||||
env: Default::default(),
|
||||
root_markers: vec!["package.json".into()],
|
||||
initialization_options: init.map(|v| toml::Value::try_from(v).unwrap()),
|
||||
settings: None,
|
||||
install_hint: None,
|
||||
connect: None,
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "ide-remote"
|
||||
description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
ide-ssh.workspace = true
|
||||
ide-run.workspace = true
|
||||
serde.workspace = true
|
||||
specta.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
globset.workspace = true
|
||||
ignore.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
ide-ssh = { workspace = true, features = ["testing"] }
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Upload, download, delete and synchronise project files with a deployment
|
||||
//! server over SFTP.
|
||||
|
||||
use crate::mapping::{Excludes, Mappings, posix};
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_ssh::RemoteFs;
|
||||
use ide_ssh::sftp::mtime_of;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Modification times closer than this count as equal (FAT, rounding by servers).
|
||||
const MTIME_SLACK: u32 = 2;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransferReport {
|
||||
pub files: u32,
|
||||
/// Files left alone: excluded, or outside every mapping.
|
||||
pub skipped: u32,
|
||||
pub errors: Vec<TransferError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct TransferError {
|
||||
pub path: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
impl TransferReport {
|
||||
fn fail(&mut self, path: impl Into<String>, e: IdeError) {
|
||||
self.errors.push(TransferError { path: path.into(), message: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FileStamp {
|
||||
pub size: u64,
|
||||
pub mtime: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SyncStatus {
|
||||
LocalOnly,
|
||||
RemoteOnly,
|
||||
LocalNewer,
|
||||
RemoteNewer,
|
||||
/// Same time, different size.
|
||||
Different,
|
||||
Same,
|
||||
}
|
||||
|
||||
/// One file of Sync with Deployed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncEntry {
|
||||
/// Project-relative, `/`-separated.
|
||||
pub path: String,
|
||||
pub remote_path: String,
|
||||
pub local: Option<FileStamp>,
|
||||
pub remote: Option<FileStamp>,
|
||||
pub status: SyncStatus,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SyncAction {
|
||||
Upload,
|
||||
Download,
|
||||
DeleteRemote,
|
||||
DeleteLocal,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncStep {
|
||||
/// Project-relative, `/`-separated.
|
||||
pub path: String,
|
||||
pub action: SyncAction,
|
||||
}
|
||||
|
||||
pub fn status(local: Option<FileStamp>, remote: Option<FileStamp>) -> SyncStatus {
|
||||
match (local, remote) {
|
||||
(Some(_), None) => SyncStatus::LocalOnly,
|
||||
(None, Some(_)) | (None, None) => SyncStatus::RemoteOnly,
|
||||
(Some(l), Some(r)) => match (l.mtime, r.mtime) {
|
||||
(Some(lt), Some(rt)) if lt > rt + MTIME_SLACK => SyncStatus::LocalNewer,
|
||||
(Some(lt), Some(rt)) if rt > lt + MTIME_SLACK => SyncStatus::RemoteNewer,
|
||||
_ if l.size != r.size => SyncStatus::Different,
|
||||
_ => SyncStatus::Same,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Operations on one server through one SFTP session.
|
||||
pub struct Deployment<'a> {
|
||||
pub fs: &'a RemoteFs,
|
||||
pub map: &'a Mappings,
|
||||
pub exclude: &'a Excludes,
|
||||
pub preserve_time: bool,
|
||||
pub cancel: CancellationToken,
|
||||
}
|
||||
|
||||
impl Deployment<'_> {
|
||||
fn rel(&self, local: &Path) -> String {
|
||||
posix(local.strip_prefix(self.map.project_root()).unwrap_or(local))
|
||||
}
|
||||
|
||||
fn excluded_local(&self, local: &Path) -> bool {
|
||||
self.exclude.local(local.strip_prefix(self.map.project_root()).unwrap_or(local))
|
||||
}
|
||||
|
||||
/// Local files under `paths` (folders recursively), excluded names left out.
|
||||
fn local_files(&self, paths: &[PathBuf], report: &mut TransferReport) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for p in paths {
|
||||
if self.excluded_local(p) {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
let walk = ignore::WalkBuilder::new(p).standard_filters(false).follow_links(false).build();
|
||||
for entry in walk.flatten() {
|
||||
let path = entry.path();
|
||||
if entry.file_type().is_some_and(|t| t.is_file()) {
|
||||
if self.excluded_local(path) {
|
||||
report.skipped += 1;
|
||||
} else {
|
||||
out.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if p.is_file() {
|
||||
out.push(p.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Remote files under `dir` (recursively), excluded ones left out.
|
||||
async fn remote_files(&self, dir: &str, out: &mut Vec<ide_ssh::RemoteEntry>) -> IdeResult<()> {
|
||||
for e in self.fs.list(dir).await? {
|
||||
if self.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
if self.exclude.remote(&e.path) {
|
||||
continue;
|
||||
}
|
||||
if e.is_dir && !e.is_symlink {
|
||||
Box::pin(self.remote_files(&e.path, out)).await?;
|
||||
} else if !e.is_dir {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
&self,
|
||||
paths: &[PathBuf],
|
||||
progress: &mut (dyn FnMut(&str, usize, usize) + Send),
|
||||
) -> TransferReport {
|
||||
let mut report = TransferReport::default();
|
||||
let files = self.local_files(paths, &mut report);
|
||||
for (i, local) in files.iter().enumerate() {
|
||||
if self.cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
let rel = self.rel(local);
|
||||
let Some(remote) = self.map.remote(local).filter(|r| !self.exclude.remote(r)) else {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
progress(&rel, i, files.len());
|
||||
match self.fs.upload(local, &remote, self.preserve_time).await {
|
||||
Ok(()) => report.files += 1,
|
||||
Err(e) => report.fail(rel, e),
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Downloads the remote counterparts of `paths` (folders recursively).
|
||||
pub async fn download(
|
||||
&self,
|
||||
paths: &[PathBuf],
|
||||
progress: &mut (dyn FnMut(&str, usize, usize) + Send),
|
||||
) -> TransferReport {
|
||||
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 {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
match self.fs.stat(&remote).await {
|
||||
Ok(Some(e)) if e.is_dir => {
|
||||
let mut files = Vec::new();
|
||||
if let Err(e) = self.remote_files(&remote, &mut files).await {
|
||||
report.fail(self.rel(p), e);
|
||||
}
|
||||
for f in files {
|
||||
if let Some(local) = self.map.local(&f.path) {
|
||||
jobs.push((f.path, local));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => jobs.push((remote, p.clone())),
|
||||
Ok(None) => {
|
||||
report.fail(self.rel(p), IdeError::not_found(format!("{remote} does not exist on the server")))
|
||||
}
|
||||
Err(e) => report.fail(self.rel(p), e),
|
||||
}
|
||||
}
|
||||
for (i, (remote, local)) in jobs.iter().enumerate() {
|
||||
if self.cancel.is_cancelled() {
|
||||
break;
|
||||
}
|
||||
if self.excluded_local(local) {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let rel = self.rel(local);
|
||||
progress(&rel, i, jobs.len());
|
||||
match self.fs.download(remote, local).await {
|
||||
Ok(()) => report.files += 1,
|
||||
Err(e) => report.fail(rel, e),
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Deletes the remote counterparts of `paths`.
|
||||
pub async fn delete_remote(&self, paths: &[PathBuf]) -> TransferReport {
|
||||
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 {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
match self.fs.stat(&remote).await {
|
||||
Ok(None) => {}
|
||||
Ok(Some(_)) => match self.fs.remove(&remote).await {
|
||||
Ok(()) => report.files += 1,
|
||||
Err(e) => report.fail(self.rel(p), e),
|
||||
},
|
||||
Err(e) => report.fail(self.rel(p), e),
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// 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 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 size = std::fs::metadata(&f).map(|m| m.len()).unwrap_or(0);
|
||||
let stamp = FileStamp { size, mtime: mtime_of(&f) };
|
||||
by_rel.insert(
|
||||
self.rel(&f),
|
||||
SyncEntry {
|
||||
path: self.rel(&f),
|
||||
remote_path,
|
||||
local: Some(stamp),
|
||||
remote: None,
|
||||
status: SyncStatus::LocalOnly,
|
||||
},
|
||||
);
|
||||
}
|
||||
let mut remote = Vec::new();
|
||||
match self.fs.stat(&remote_root).await? {
|
||||
Some(e) if e.is_dir => self.remote_files(&remote_root, &mut remote).await?,
|
||||
Some(e) => remote.push(e),
|
||||
None => {}
|
||||
}
|
||||
for e in remote {
|
||||
let Some(l) = self.map.local(&e.path) else { continue };
|
||||
if self.excluded_local(&l) {
|
||||
continue;
|
||||
}
|
||||
let rel = self.rel(&l);
|
||||
let stamp = FileStamp { size: e.size, mtime: e.mtime };
|
||||
by_rel.entry(rel.clone()).and_modify(|x| x.remote = Some(stamp)).or_insert(SyncEntry {
|
||||
path: rel,
|
||||
remote_path: e.path,
|
||||
local: None,
|
||||
remote: Some(stamp),
|
||||
status: SyncStatus::RemoteOnly,
|
||||
});
|
||||
}
|
||||
Ok(by_rel
|
||||
.into_values()
|
||||
.map(|mut e| {
|
||||
e.status = status(e.local, e.remote);
|
||||
e
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Applies the chosen Sync with Deployed actions.
|
||||
pub async fn apply(
|
||||
&self,
|
||||
steps: &[SyncStep],
|
||||
progress: &mut (dyn FnMut(&str, usize, usize) + Send),
|
||||
) -> TransferReport {
|
||||
let mut report = TransferReport::default();
|
||||
let root = self.map.project_root().to_path_buf();
|
||||
for (i, step) in steps.iter().enumerate() {
|
||||
if self.cancel.is_cancelled() {
|
||||
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 {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
};
|
||||
progress(&step.path, i, steps.len());
|
||||
let r = match step.action {
|
||||
SyncAction::Upload => self.fs.upload(&local, &remote, self.preserve_time).await,
|
||||
SyncAction::Download => self.fs.download(&remote, &local).await,
|
||||
SyncAction::DeleteRemote => self.fs.remove(&remote).await,
|
||||
SyncAction::DeleteLocal => std::fs::remove_file(&local).map_err(IdeError::from),
|
||||
};
|
||||
match r {
|
||||
Ok(()) => report.files += 1,
|
||||
Err(e) => report.fail(step.path.clone(), e),
|
||||
}
|
||||
}
|
||||
report
|
||||
}
|
||||
|
||||
/// Uploads what changed locally under `local` (new, newer or resized files): sync before a run.
|
||||
pub async fn push_changes(
|
||||
&self,
|
||||
local: &Path,
|
||||
progress: &mut (dyn FnMut(&str, usize, usize) + Send),
|
||||
) -> IdeResult<TransferReport> {
|
||||
let steps: Vec<SyncStep> = self
|
||||
.compare(local)
|
||||
.await?
|
||||
.into_iter()
|
||||
.filter(|e| matches!(e.status, SyncStatus::LocalOnly | SyncStatus::LocalNewer | SyncStatus::Different))
|
||||
.map(|e| SyncStep { path: e.path, action: SyncAction::Upload })
|
||||
.collect();
|
||||
Ok(self.apply(&steps, progress).await)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn stamp(size: u64, mtime: u32) -> Option<FileStamp> {
|
||||
Some(FileStamp { size, mtime: Some(mtime) })
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compares_by_time_then_size() {
|
||||
assert_eq!(status(stamp(1, 100), None), SyncStatus::LocalOnly);
|
||||
assert_eq!(status(None, stamp(1, 100)), SyncStatus::RemoteOnly);
|
||||
assert_eq!(status(stamp(1, 200), stamp(1, 100)), SyncStatus::LocalNewer);
|
||||
assert_eq!(status(stamp(1, 100), stamp(1, 200)), SyncStatus::RemoteNewer);
|
||||
assert_eq!(status(stamp(1, 101), stamp(1, 100)), SyncStatus::Same);
|
||||
assert_eq!(status(stamp(2, 100), stamp(1, 100)), SyncStatus::Different);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//! Remote work over SSH: SFTP deployment ([`deploy`], [`mapping`]) and run
|
||||
//! configurations on run targets ([`target`]).
|
||||
|
||||
pub mod deploy;
|
||||
pub mod mapping;
|
||||
pub mod service;
|
||||
pub mod target;
|
||||
|
||||
pub use deploy::{Deployment, SyncAction, SyncEntry, SyncStatus, SyncStep, TransferReport};
|
||||
pub use mapping::{Excludes, Mappings};
|
||||
pub use service::{DeployedPath, RemoteService};
|
||||
pub use target::{RemoteRuns, RemoteStart};
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Local ↔ remote paths of a deployment server's mappings, and excluded names.
|
||||
|
||||
use globset::{Glob, GlobSet, GlobSetBuilder};
|
||||
use ide_core::remote_settings::{DeploymentServer, DeploymentSettings};
|
||||
use ide_ssh::sftp::join;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// A server's mappings resolved against the project and the server root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Mappings {
|
||||
root: PathBuf,
|
||||
/// (absolute local folder, absolute remote folder, web path), longest local first.
|
||||
entries: Vec<(PathBuf, String, String)>,
|
||||
web_url: String,
|
||||
}
|
||||
|
||||
/// `a/b` (POSIX) of a relative path.
|
||||
pub fn posix(rel: &Path) -> String {
|
||||
rel.components()
|
||||
.filter_map(|c| match c {
|
||||
Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
impl Mappings {
|
||||
/// `remote_root` is the server root resolved on the host (its login
|
||||
/// directory when the setting is empty). A server without mappings maps the
|
||||
/// project root onto the server root.
|
||||
pub fn new(server: &DeploymentServer, project_root: &Path, remote_root: &str) -> Self {
|
||||
let mut entries: Vec<(PathBuf, String, String)> = server
|
||||
.mappings
|
||||
.iter()
|
||||
.map(|m| {
|
||||
let local = m.local.trim().trim_matches('/');
|
||||
let local = if local.is_empty() { project_root.to_path_buf() } else { project_root.join(local) };
|
||||
// Relative to the server root, with or without a leading `/`.
|
||||
let deployment = m.deployment.trim().trim_start_matches("./").trim_matches('/');
|
||||
let remote = if deployment.is_empty() { remote_root.to_owned() } else { join(remote_root, deployment) };
|
||||
(local, remote.trim_end_matches('/').to_owned(), m.web.trim().trim_matches('/').to_owned())
|
||||
})
|
||||
.collect();
|
||||
if entries.is_empty() {
|
||||
entries.push((project_root.to_path_buf(), remote_root.trim_end_matches('/').to_owned(), String::new()));
|
||||
}
|
||||
entries.sort_by_key(|(l, _, _)| std::cmp::Reverse(l.components().count()));
|
||||
Self {
|
||||
root: project_root.to_path_buf(),
|
||||
entries,
|
||||
web_url: server.web_url.trim().trim_end_matches('/').to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn project_root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
/// The remote path of a local file or folder (`None`: not under any mapping).
|
||||
pub fn remote(&self, local: &Path) -> Option<String> {
|
||||
self.entries.iter().find_map(|(l, r, _)| {
|
||||
let rel = local.strip_prefix(l).ok()?;
|
||||
// `..` would leave the mapping (`posix` drops it).
|
||||
if rel.components().any(|c| !matches!(c, Component::Normal(_))) {
|
||||
return None;
|
||||
}
|
||||
let rel = posix(rel);
|
||||
Some(if rel.is_empty() { r.clone() } else { join(r, &rel) })
|
||||
})
|
||||
}
|
||||
|
||||
/// `remote` is the remote folder of a mapping (deleting it would wipe the deployment).
|
||||
pub fn is_root(&self, remote: &str) -> bool {
|
||||
let remote = remote.trim_end_matches('/');
|
||||
self.entries.iter().any(|(_, r, _)| r.trim_end_matches('/') == remote)
|
||||
}
|
||||
|
||||
/// The local path of a remote file or folder (`None`: not under any mapping).
|
||||
pub fn local(&self, remote: &str) -> Option<PathBuf> {
|
||||
let remote = remote.trim_end_matches('/');
|
||||
let mut best: Option<(&PathBuf, &str, usize)> = None;
|
||||
for (l, r, _) in &self.entries {
|
||||
let rest = if remote == r {
|
||||
""
|
||||
} else if let Some(rest) = remote.strip_prefix(r.as_str()).and_then(|x| x.strip_prefix('/')) {
|
||||
rest
|
||||
} else if r.is_empty() || r == "/" {
|
||||
remote.trim_start_matches('/')
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if best.is_none_or(|(_, _, len)| r.len() > len) {
|
||||
best = Some((l, rest, r.len()));
|
||||
}
|
||||
}
|
||||
let (l, rest, _) = best?;
|
||||
// A `..` from the server must not leave the local folder.
|
||||
if rest.split('/').any(|s| s == ".." || s == ".") {
|
||||
return None;
|
||||
}
|
||||
Some(rest.split('/').filter(|s| !s.is_empty()).fold(l.clone(), |p, s| p.join(s)))
|
||||
}
|
||||
|
||||
/// The web URL of a local file (`None` without a server URL or mapping).
|
||||
pub fn web_url(&self, local: &Path) -> Option<String> {
|
||||
if self.web_url.is_empty() {
|
||||
return None;
|
||||
}
|
||||
self.entries.iter().find_map(|(l, _, web)| {
|
||||
let rel = posix(local.strip_prefix(l).ok()?);
|
||||
let path =
|
||||
[web.as_str(), rel.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join("/");
|
||||
Some(if path.is_empty() { format!("{}/", self.web_url) } else { format!("{}/{path}", self.web_url) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings › Deployment › Options "Exclude items by name".
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Excludes {
|
||||
names: GlobSet,
|
||||
/// Remote paths (absolute) never touched.
|
||||
remote: Vec<String>,
|
||||
}
|
||||
|
||||
impl Excludes {
|
||||
pub fn new(settings: &DeploymentSettings, server: &DeploymentServer, remote_root: &str) -> Self {
|
||||
let mut b = GlobSetBuilder::new();
|
||||
for pattern in settings.exclude.iter().flat_map(|p| p.split(';')).map(str::trim).filter(|p| !p.is_empty()) {
|
||||
match Glob::new(pattern) {
|
||||
Ok(g) => {
|
||||
b.add(g);
|
||||
}
|
||||
Err(e) => tracing::warn!("deployment exclude {pattern:?}: {e}"),
|
||||
}
|
||||
}
|
||||
let remote = server
|
||||
.excluded_paths
|
||||
.iter()
|
||||
.map(|p| p.trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| join(remote_root, p.trim_start_matches('/')).trim_end_matches('/').to_owned())
|
||||
.collect();
|
||||
Self { names: b.build().unwrap_or_else(|_| GlobSet::empty()), remote }
|
||||
}
|
||||
|
||||
pub fn name(&self, name: &str) -> bool {
|
||||
self.names.is_match(name)
|
||||
}
|
||||
|
||||
/// Any component of `rel` (relative to the project) is excluded by name.
|
||||
pub fn local(&self, rel: &Path) -> bool {
|
||||
rel.components().any(|c| matches!(c, Component::Normal(s) if self.name(&s.to_string_lossy())))
|
||||
}
|
||||
|
||||
pub fn remote(&self, path: &str) -> bool {
|
||||
let path = path.trim_end_matches('/');
|
||||
path.rsplit('/').next().is_some_and(|n| self.name(n))
|
||||
|| self
|
||||
.remote
|
||||
.iter()
|
||||
.any(|r| path == r || path.strip_prefix(r.as_str()).is_some_and(|x| x.starts_with('/')))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ide_core::remote_settings::DeploymentMapping;
|
||||
|
||||
fn server(mappings: Vec<(&str, &str, &str)>) -> DeploymentServer {
|
||||
DeploymentServer {
|
||||
web_url: "https://example.com/app/".into(),
|
||||
mappings: mappings
|
||||
.into_iter()
|
||||
.map(|(l, d, w)| DeploymentMapping { local: l.into(), deployment: d.into(), web: w.into() })
|
||||
.collect(),
|
||||
excluded_paths: vec!["logs".into()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_paths_both_ways_by_the_longest_mapping() {
|
||||
let root = Path::new("/p");
|
||||
let m = Mappings::new(&server(vec![("", "www", ""), ("static", "/cdn/assets/", "s")]), root, "/home/me");
|
||||
assert_eq!(m.remote(Path::new("/p/src/a.py")).as_deref(), Some("/home/me/www/src/a.py"));
|
||||
assert_eq!(m.remote(Path::new("/p/static/x.css")).as_deref(), Some("/home/me/cdn/assets/x.css"));
|
||||
assert_eq!(m.remote(Path::new("/p")).as_deref(), Some("/home/me/www"));
|
||||
assert_eq!(m.remote(Path::new("/elsewhere/a")), None);
|
||||
assert_eq!(m.local("/home/me/www/src/a.py"), Some(PathBuf::from("/p/src/a.py")));
|
||||
assert_eq!(m.local("/home/me/cdn/assets/x.css"), Some(PathBuf::from("/p/static/x.css")));
|
||||
assert_eq!(m.local("/var/other"), None);
|
||||
assert_eq!(m.web_url(Path::new("/p/static/x.css")).as_deref(), Some("https://example.com/app/s/x.css"));
|
||||
assert_eq!(m.web_url(Path::new("/p/index.html")).as_deref(), Some("https://example.com/app/index.html"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn without_mappings_the_project_root_maps_onto_the_server_root() {
|
||||
let m = Mappings::new(&server(vec![]), Path::new("/p"), "/srv/app");
|
||||
assert_eq!(m.remote(Path::new("/p/a/b.txt")).as_deref(), Some("/srv/app/a/b.txt"));
|
||||
assert_eq!(m.local("/srv/app"), Some(PathBuf::from("/p")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn excludes_names_and_remote_paths() {
|
||||
let settings =
|
||||
DeploymentSettings { exclude: vec![".git;*.pyc".into(), "node_modules".into()], ..Default::default() };
|
||||
let x = Excludes::new(&settings, &server(vec![]), "/srv");
|
||||
assert!(x.local(Path::new("src/.git/config")));
|
||||
assert!(x.local(Path::new("a/b.pyc")));
|
||||
assert!(x.local(Path::new("node_modules/x")));
|
||||
assert!(!x.local(Path::new("src/main.py")));
|
||||
assert!(x.remote("/srv/logs/today.log"));
|
||||
assert!(x.remote("/srv/logs"));
|
||||
assert!(!x.remote("/srv/logsx"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! The deployment and run-target operations of one app, over the shared SSH
|
||||
//! connections: what the IPC commands and the save hook call.
|
||||
|
||||
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::settings::Settings;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink};
|
||||
use ide_run::{OutputChunk, RunConfig, RunInfo};
|
||||
use ide_ssh::{RemoteEntry, RemoteFs, SshManager};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Progress of a transfer: (project-relative path, index, total).
|
||||
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,
|
||||
remote: String,
|
||||
}
|
||||
|
||||
/// Where a local file is deployed.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DeployedPath {
|
||||
pub server: String,
|
||||
pub remote_path: String,
|
||||
pub web_url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct RemoteService {
|
||||
ssh: Arc<SshManager>,
|
||||
/// Local copies of remote files: `<cache>/<ssh config>/<remote path>`.
|
||||
cache: PathBuf,
|
||||
edits: Mutex<HashMap<PathBuf, RemoteEdit>>,
|
||||
pub runs: RemoteRuns,
|
||||
}
|
||||
|
||||
/// The deployment server `id`, or the default one.
|
||||
pub fn server(settings: &Settings, id: Option<&str>) -> IdeResult<DeploymentServer> {
|
||||
let d = &settings.deployment;
|
||||
let id = id.filter(|s| !s.is_empty()).unwrap_or(&d.default_server);
|
||||
if id.is_empty() {
|
||||
return Err(IdeError::invalid("no default deployment server (Settings › Deployment)"));
|
||||
}
|
||||
d.servers
|
||||
.iter()
|
||||
.find(|s| s.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found(format!("no deployment server {id}")))
|
||||
}
|
||||
|
||||
pub fn run_target(settings: &Settings, id: &str) -> IdeResult<RunTarget> {
|
||||
settings
|
||||
.run_targets
|
||||
.iter()
|
||||
.find(|t| t.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found(format!("no run target {id} (Settings › Run Targets)")))
|
||||
}
|
||||
|
||||
impl RemoteService {
|
||||
pub fn new(ssh: Arc<SshManager>, cache: PathBuf) -> Self {
|
||||
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"));
|
||||
}
|
||||
let session = self.ssh.session(ssh_config).await?;
|
||||
Ok(RemoteFs::new(session.sftp().await?))
|
||||
}
|
||||
|
||||
/// Runs `f` with a [`Deployment`] on `server`.
|
||||
async fn with_deployment<T>(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
server: &DeploymentServer,
|
||||
cancel: CancellationToken,
|
||||
f: impl AsyncFnOnce(&Deployment<'_>) -> T,
|
||||
) -> IdeResult<T> {
|
||||
let fs = self.fs(&server.ssh_config).await?;
|
||||
let remote_root = match fs.resolve(&server.root_path).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
fs.close().await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let map = Mappings::new(server, root, &remote_root);
|
||||
let exclude = Excludes::new(&settings.deployment, server, &remote_root);
|
||||
let d = Deployment {
|
||||
fs: &fs,
|
||||
map: &map,
|
||||
exclude: &exclude,
|
||||
preserve_time: settings.deployment.preserve_timestamps,
|
||||
cancel,
|
||||
};
|
||||
let out = f(&d).await;
|
||||
fs.close().await;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn upload(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
server_id: Option<&str>,
|
||||
paths: &[PathBuf],
|
||||
cancel: CancellationToken,
|
||||
progress: Progress<'_>,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let server = server(settings, server_id)?;
|
||||
self.with_deployment(settings, root, &server, cancel, async |d| d.upload(paths, progress).await).await
|
||||
}
|
||||
|
||||
pub async fn download(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
server_id: Option<&str>,
|
||||
paths: &[PathBuf],
|
||||
cancel: CancellationToken,
|
||||
progress: Progress<'_>,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let server = server(settings, server_id)?;
|
||||
self.with_deployment(settings, root, &server, cancel, async |d| d.download(paths, progress).await).await
|
||||
}
|
||||
|
||||
pub async fn delete_remote(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
server_id: Option<&str>,
|
||||
paths: &[PathBuf],
|
||||
) -> IdeResult<TransferReport> {
|
||||
let server = server(settings, server_id)?;
|
||||
self.with_deployment(settings, root, &server, CancellationToken::new(), async |d| d.delete_remote(paths).await)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn compare(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
server_id: Option<&str>,
|
||||
path: &Path,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Vec<SyncEntry>> {
|
||||
let server = server(settings, server_id)?;
|
||||
self.with_deployment(settings, root, &server, cancel, async |d| d.compare(path).await).await?
|
||||
}
|
||||
|
||||
pub async fn sync(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
server_id: Option<&str>,
|
||||
steps: &[SyncStep],
|
||||
cancel: CancellationToken,
|
||||
progress: Progress<'_>,
|
||||
) -> IdeResult<TransferReport> {
|
||||
let server = server(settings, server_id)?;
|
||||
self.with_deployment(settings, root, &server, cancel, async |d| d.apply(steps, progress).await).await
|
||||
}
|
||||
|
||||
/// Where `path` is deployed on `server_id` (or the default server).
|
||||
pub async fn deployed(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
server_id: Option<&str>,
|
||||
path: &Path,
|
||||
) -> IdeResult<Option<DeployedPath>> {
|
||||
let server = server(settings, server_id)?;
|
||||
let name = server.name.clone();
|
||||
self.with_deployment(settings, root, &server, CancellationToken::new(), async |d| {
|
||||
d.map.remote(path).map(|remote_path| DeployedPath {
|
||||
server: name,
|
||||
remote_path,
|
||||
web_url: d.map.web_url(path),
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
// --- Remote Host browser -------------------------------------------------------
|
||||
|
||||
/// 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 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?;
|
||||
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?;
|
||||
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() });
|
||||
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 async fn remote_delete(&self, ssh_config: &str, remote: &str) -> IdeResult<()> {
|
||||
let fs = self.fs(ssh_config).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?;
|
||||
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?;
|
||||
let r = fs.rename(from, to).await;
|
||||
fs.close().await;
|
||||
r
|
||||
}
|
||||
|
||||
// --- save hook -----------------------------------------------------------------
|
||||
|
||||
/// After a document save: uploads a remote file's copy, or (automatic upload)
|
||||
/// the file to the default server. `None` when nothing applies.
|
||||
pub async fn on_saved(&self, settings: &Settings, root: &Path, path: &Path) -> Option<IdeResult<TransferReport>> {
|
||||
if let Some((config, remote)) = self.remote_of(path) {
|
||||
let r = async {
|
||||
let fs = self.fs(&config).await?;
|
||||
let r = fs.upload(path, &remote, false).await;
|
||||
fs.close().await;
|
||||
r.map(|()| TransferReport { files: 1, ..Default::default() })
|
||||
}
|
||||
.await;
|
||||
return Some(r);
|
||||
}
|
||||
if settings.deployment.auto_upload == AutoUpload::Never
|
||||
|| settings.deployment.default_server.is_empty()
|
||||
|| !path.starts_with(root)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(
|
||||
self.upload(settings, root, None, &[path.to_path_buf()], CancellationToken::new(), &mut |_, _, _| {}).await,
|
||||
)
|
||||
}
|
||||
|
||||
/// After files were deleted locally: their remote copies too (automatic upload + Delete remote files).
|
||||
pub async fn on_deleted(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
paths: &[PathBuf],
|
||||
) -> Option<IdeResult<TransferReport>> {
|
||||
let d = &settings.deployment;
|
||||
if d.auto_upload == AutoUpload::Never || !d.delete_remote || d.default_server.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(self.delete_remote(settings, root, None, paths).await)
|
||||
}
|
||||
|
||||
// --- run targets ---------------------------------------------------------------
|
||||
|
||||
/// Starts `config` on its run target.
|
||||
pub async fn run(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
config: &RunConfig,
|
||||
sink: SharedSink,
|
||||
on_output: Box<dyn Fn(OutputChunk) + Send + Sync>,
|
||||
) -> IdeResult<RunInfo> {
|
||||
let target_id = config.extras.target.as_deref().unwrap_or_default();
|
||||
let target = run_target(settings, target_id)?;
|
||||
let session = self.ssh.session(&target.ssh_config).await?;
|
||||
let fs = RemoteFs::new(session.sftp().await?);
|
||||
let folder = if target.project_root.trim().is_empty() {
|
||||
format!(
|
||||
"~/{}",
|
||||
root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "project".into())
|
||||
)
|
||||
} else {
|
||||
target.project_root.clone()
|
||||
};
|
||||
let remote_root = fs.resolve(&folder).await;
|
||||
let remote_root = match remote_root {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
fs.close().await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let sync: Option<SyncFn> = if target.sync_before_run {
|
||||
let deployment = settings.deployment.clone();
|
||||
let root = root.to_path_buf();
|
||||
let remote_root = remote_root.clone();
|
||||
Some(Box::new(move |out| {
|
||||
Box::pin(async move {
|
||||
let server = DeploymentServer { root_path: remote_root.clone(), ..Default::default() };
|
||||
let map = Mappings::new(&server, &root, &remote_root);
|
||||
let exclude = Excludes::new(&deployment, &server, &remote_root);
|
||||
let d = Deployment {
|
||||
fs: &fs,
|
||||
map: &map,
|
||||
exclude: &exclude,
|
||||
preserve_time: true,
|
||||
cancel: CancellationToken::new(),
|
||||
};
|
||||
let mut progress = |_: &str, _: usize, _: usize| {};
|
||||
let report = d.push_changes(&root, &mut progress).await;
|
||||
fs.close().await;
|
||||
let report = report?;
|
||||
out(OutputChunk::System {
|
||||
text: format!("Uploaded {} changed file(s) to {remote_root}\n", report.files),
|
||||
});
|
||||
match report.errors.first() {
|
||||
Some(e) => Err(IdeError::new(
|
||||
ide_core::error::ErrorKind::Io,
|
||||
format!("upload of {} failed: {}", e.path, e.message),
|
||||
)),
|
||||
None => Ok(()),
|
||||
}
|
||||
})
|
||||
}))
|
||||
} else {
|
||||
fs.close().await;
|
||||
None
|
||||
};
|
||||
let start = RemoteStart { session, target, root: root.to_path_buf(), remote_root, sync };
|
||||
Ok(self.runs.start(start, config, sink, on_output))
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize(s: &str) -> String {
|
||||
s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect()
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
//! Run configurations on a run target: the project is synchronised to the
|
||||
//! target's folder, then the command runs there over SSH with local paths and
|
||||
//! interpreters swapped for their remote counterparts.
|
||||
|
||||
use ide_core::remote_settings::RunTarget;
|
||||
use ide_core::{CancellationToken, IdeError, IdeEvent, IdeResult, RunId, SharedSink};
|
||||
use ide_run::{OutputChunk, RunConfig, RunInfo};
|
||||
use ide_ssh::sftp::join;
|
||||
use ide_ssh::{Output, RemoteProcess, Session, Sig};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::future::Future;
|
||||
use std::path::Path;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
type OutputFn = Arc<dyn Fn(OutputChunk) + Send + Sync>;
|
||||
/// Uploads local changes before the run; reports progress lines.
|
||||
pub type SyncFn = Box<dyn FnOnce(OutputFn) -> Pin<Box<dyn Future<Output = IdeResult<()>> + Send>> + Send>;
|
||||
|
||||
/// The remote program, arguments, folder and environment of a configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteCommand {
|
||||
pub program: String,
|
||||
pub args: Vec<String>,
|
||||
pub cwd: String,
|
||||
pub env: BTreeMap<String, String>,
|
||||
/// A whole shell command line (configurations with a command and no arguments).
|
||||
pub shell_line: Option<String>,
|
||||
}
|
||||
|
||||
impl RemoteCommand {
|
||||
pub fn line(&self) -> String {
|
||||
match &self.shell_line {
|
||||
Some(line) => {
|
||||
let exports: String = self
|
||||
.env
|
||||
.iter()
|
||||
.map(|(k, v)| format!("export {}; ", ide_ssh::shell::quote(&format!("{k}={v}"))))
|
||||
.collect();
|
||||
format!("cd {} && {exports}{line}", ide_ssh::shell::quote(&self.cwd))
|
||||
}
|
||||
None => ide_ssh::shell::command_line(Some(&self.cwd), &self.env, &self.program, &self.args),
|
||||
}
|
||||
}
|
||||
|
||||
/// The echoed command line.
|
||||
pub fn display(&self) -> String {
|
||||
match &self.shell_line {
|
||||
Some(line) => line.clone(),
|
||||
None => std::iter::once(self.program.as_str())
|
||||
.chain(self.args.iter().map(String::as_str))
|
||||
.map(ide_ssh::shell::quote)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" "),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `s` with the path `root` replaced by `remote` where it appears as a whole path
|
||||
/// (followed by a separator or the end of a path), not as the prefix of a longer name.
|
||||
/// The rest of a Windows path after the root takes `/` separators too.
|
||||
fn replace_root(s: &str, root: &str, remote: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find(root) {
|
||||
let mut after = &rest[i + root.len()..];
|
||||
let whole = after.chars().next().is_none_or(|c| !(c.is_alphanumeric() || matches!(c, '_' | '-' | '.')));
|
||||
out.push_str(&rest[..i]);
|
||||
if whole {
|
||||
out.push_str(remote);
|
||||
if root.contains('\\') {
|
||||
let end = after
|
||||
.find(|c: char| c.is_whitespace() || matches!(c, ';' | ',' | '"' | '\''))
|
||||
.unwrap_or(after.len());
|
||||
out.push_str(&after[..end].replace('\\', "/"));
|
||||
after = &after[end..];
|
||||
}
|
||||
} else {
|
||||
out.push_str(root);
|
||||
}
|
||||
rest = after;
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
fn file_name(program: &str) -> &str {
|
||||
program.rsplit(['/', '\\']).next().unwrap_or(program)
|
||||
}
|
||||
|
||||
/// The target's interpreter for a local interpreter executable, if it is one.
|
||||
fn interpreter<'a>(program: &str, target: &'a RunTarget) -> Option<&'a str> {
|
||||
let name = file_name(program).trim_end_matches(".exe").to_ascii_lowercase();
|
||||
let pick = |configured: &'a str, default: &'a str| {
|
||||
Some(if configured.trim().is_empty() { default } else { configured.trim() })
|
||||
};
|
||||
if name == "python" || name.starts_with("python3") || name == "python2" {
|
||||
pick(&target.python, "python3")
|
||||
} else if name == "node" {
|
||||
pick(&target.node, "node")
|
||||
} else if name == "java" {
|
||||
pick(&target.java, "java")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the remote command of `config`; `root` (the project) maps onto `remote_root`.
|
||||
pub fn remote_command(config: &RunConfig, target: &RunTarget, root: &Path, remote_root: &str) -> RemoteCommand {
|
||||
let root_str = root.to_string_lossy().into_owned();
|
||||
let map = |s: &str| -> String {
|
||||
if root_str.is_empty() {
|
||||
return s.to_owned();
|
||||
}
|
||||
replace_root(s, &root_str, remote_root.trim_end_matches('/')).replace('\\', "/")
|
||||
};
|
||||
let map_text = |s: &str| -> String {
|
||||
if root_str.is_empty() { s.to_owned() } else { replace_root(s, &root_str, remote_root.trim_end_matches('/')) }
|
||||
};
|
||||
let cwd = match config.cwd.as_ref().map(|p| p.to_path_buf()) {
|
||||
Some(dir) if dir.is_absolute() => match dir.strip_prefix(root) {
|
||||
Ok(rel) => join(remote_root, &crate::mapping::posix(rel)),
|
||||
Err(_) => remote_root.to_owned(),
|
||||
},
|
||||
Some(dir) if !dir.as_os_str().is_empty() => join(remote_root, &crate::mapping::posix(&dir)),
|
||||
_ => remote_root.to_owned(),
|
||||
};
|
||||
let env = config.env.iter().map(|(k, v)| (k.clone(), map_text(v))).collect();
|
||||
let command = config.command.trim();
|
||||
if config.args.is_empty() && command.contains(char::is_whitespace) {
|
||||
return RemoteCommand {
|
||||
program: String::new(),
|
||||
args: Vec::new(),
|
||||
cwd,
|
||||
env,
|
||||
shell_line: Some(map_text(command)),
|
||||
};
|
||||
}
|
||||
let program = match interpreter(command, target) {
|
||||
Some(remote) => remote.to_owned(),
|
||||
None if Path::new(command).is_absolute() && Path::new(command).starts_with(root) => map(command),
|
||||
None if Path::new(command).is_absolute() => file_name(command).to_owned(),
|
||||
None => command.to_owned(),
|
||||
};
|
||||
let args = config.args.iter().map(|a| map_text(a)).collect();
|
||||
RemoteCommand { program, args, cwd, env, shell_line: None }
|
||||
}
|
||||
|
||||
struct RemoteRun {
|
||||
info: Mutex<RunInfo>,
|
||||
process: Mutex<Option<Arc<RemoteProcess>>>,
|
||||
cancel: CancellationToken,
|
||||
stopping: AtomicBool,
|
||||
/// The runtime it started on: stdin / stop requests may come from threads without one.
|
||||
runtime: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
/// Runs on run targets, alongside the local `ide_run::ProcessManager`.
|
||||
#[derive(Default)]
|
||||
pub struct RemoteRuns {
|
||||
runs: Arc<Mutex<BTreeMap<RunId, Arc<RemoteRun>>>>,
|
||||
}
|
||||
|
||||
pub struct RemoteStart {
|
||||
pub session: Arc<Session>,
|
||||
pub target: RunTarget,
|
||||
pub root: std::path::PathBuf,
|
||||
/// The target's project folder, resolved on the host.
|
||||
pub remote_root: String,
|
||||
/// Uploads local changes first (`sync_before_run`).
|
||||
pub sync: Option<SyncFn>,
|
||||
}
|
||||
|
||||
impl RemoteRuns {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn contains(&self, id: RunId) -> bool {
|
||||
self.runs.lock().contains_key(&id)
|
||||
}
|
||||
|
||||
pub fn list(&self) -> Vec<RunInfo> {
|
||||
self.runs.lock().values().map(|r| r.info.lock().clone()).collect()
|
||||
}
|
||||
|
||||
/// Starts `config` on the target; returns at once (synchronisation and the
|
||||
/// remote process follow in the background, reported as output).
|
||||
pub fn start(
|
||||
&self,
|
||||
start: RemoteStart,
|
||||
config: &RunConfig,
|
||||
sink: SharedSink,
|
||||
on_output: Box<dyn Fn(OutputChunk) + Send + Sync>,
|
||||
) -> RunInfo {
|
||||
let id = RunId::next();
|
||||
let pty = config.extras.emulate_terminal;
|
||||
let info =
|
||||
RunInfo { id, config_id: config.id.clone(), name: config.name.clone(), pid: None, running: true, pty };
|
||||
let run = Arc::new(RemoteRun {
|
||||
info: Mutex::new(info.clone()),
|
||||
process: Mutex::new(None),
|
||||
cancel: CancellationToken::new(),
|
||||
stopping: AtomicBool::new(false),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
});
|
||||
self.runs.lock().insert(id, run.clone());
|
||||
let on_output: OutputFn = Arc::from(on_output);
|
||||
let command = remote_command(config, &start.target, &start.root, &start.remote_root);
|
||||
tokio::spawn(async move {
|
||||
let code = run_remote(&run, start, command, pty, on_output.clone()).await;
|
||||
let message = match &code {
|
||||
Ok(Some(c)) => format!("Process finished with exit code {c}"),
|
||||
Ok(None) if run.stopping.load(Ordering::Acquire) => "Process finished (stopped)".to_owned(),
|
||||
Ok(None) => "Process finished".to_owned(),
|
||||
Err(e) if e.kind == ide_core::error::ErrorKind::Cancelled => "Process finished (stopped)".to_owned(),
|
||||
Err(e) => format!("Cannot run on the target: {}", e.message),
|
||||
};
|
||||
on_output(OutputChunk::System { text: format!("\n{message}\n") });
|
||||
run.info.lock().running = false;
|
||||
run.process.lock().take();
|
||||
let code = code.ok().flatten().and_then(|c| i32::try_from(c).ok());
|
||||
sink.emit(IdeEvent::RunExited { run: id, code });
|
||||
});
|
||||
info
|
||||
}
|
||||
|
||||
pub fn write_stdin(&self, id: RunId, data: &str) -> IdeResult<()> {
|
||||
let (process, rt) = self.process(id)?;
|
||||
let data = data.as_bytes().to_vec();
|
||||
rt.spawn(async move { process.write(&data).await });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn close_stdin(&self, id: RunId) -> IdeResult<()> {
|
||||
let (process, rt) = self.process(id)?;
|
||||
rt.spawn(async move { process.eof().await });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn resize(&self, id: RunId, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
let (process, rt) = self.process(id)?;
|
||||
rt.spawn(async move { process.resize(cols, rows).await });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `INT`, then `KILL` (closing the channel) after `grace`.
|
||||
pub fn stop(&self, id: RunId, grace: std::time::Duration) -> IdeResult<()> {
|
||||
let run =
|
||||
self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?;
|
||||
run.stopping.store(true, Ordering::Release);
|
||||
run.cancel.cancel();
|
||||
let Some(process) = run.process.lock().clone() else { return Ok(()) };
|
||||
let rt = run.runtime.clone();
|
||||
rt.spawn(async move {
|
||||
let _ = process.signal(Sig::INT).await;
|
||||
tokio::time::sleep(grace).await;
|
||||
if run.info.lock().running {
|
||||
let _ = process.signal(Sig::KILL).await;
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process(&self, id: RunId) -> IdeResult<(Arc<RemoteProcess>, tokio::runtime::Handle)> {
|
||||
let run =
|
||||
self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?;
|
||||
let process = run.process.lock().clone();
|
||||
let process = process.ok_or_else(|| IdeError::unavailable("the remote process has not started yet"))?;
|
||||
Ok((process, run.runtime.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_remote(
|
||||
run: &RemoteRun,
|
||||
start: RemoteStart,
|
||||
command: RemoteCommand,
|
||||
pty: bool,
|
||||
on_output: OutputFn,
|
||||
) -> IdeResult<Option<u32>> {
|
||||
on_output(OutputChunk::System { text: format!("[{}] {}\n", start.target.name, start.session.label()) });
|
||||
if let Some(sync) = start.sync {
|
||||
tokio::select! {
|
||||
r = sync(on_output.clone()) => r?,
|
||||
_ = run.cancel.cancelled() => return Err(IdeError::cancelled()),
|
||||
}
|
||||
}
|
||||
if run.cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
on_output(OutputChunk::System { text: format!("{}\n", command.display()) });
|
||||
let out = on_output.clone();
|
||||
let process = start
|
||||
.session
|
||||
.spawn(
|
||||
&command.line(),
|
||||
pty.then_some((120, 30)),
|
||||
Box::new(move |chunk| match chunk {
|
||||
Output::Stdout(b) => out(OutputChunk::Stdout { text: String::from_utf8_lossy(b).into_owned() }),
|
||||
Output::Stderr(b) => out(OutputChunk::Stderr { text: String::from_utf8_lossy(b).into_owned() }),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let process = Arc::new(process);
|
||||
*run.process.lock() = Some(process.clone());
|
||||
Ok(process.wait().await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[cfg(unix)]
|
||||
fn config(command: &str, args: &[&str], cwd: Option<&str>) -> RunConfig {
|
||||
RunConfig {
|
||||
id: "c".into(),
|
||||
name: "c".into(),
|
||||
kind: "python".into(),
|
||||
command: command.into(),
|
||||
args: args.iter().map(|s| s.to_string()).collect(),
|
||||
cwd: cwd.map(|c| ide_core::IpcPath::from(std::path::PathBuf::from(c))),
|
||||
env: BTreeMap::from([("DATA".to_owned(), "/work/proj/data".to_owned())]),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
// Unix paths are not absolute on Windows (`tests/remote.rs` runs there).
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn maps_interpreters_paths_and_folders() {
|
||||
let target = RunTarget { python: "/opt/py/bin/python".into(), ..Default::default() };
|
||||
let root = Path::new("/work/proj");
|
||||
let c = remote_command(
|
||||
&config("/work/proj/.venv/bin/python", &["-u", "/work/proj/src/main.py"], Some("/work/proj/src")),
|
||||
&target,
|
||||
root,
|
||||
"/home/me/proj",
|
||||
);
|
||||
assert_eq!(c.program, "/opt/py/bin/python");
|
||||
assert_eq!(c.args, ["-u", "/home/me/proj/src/main.py"]);
|
||||
assert_eq!(c.cwd, "/home/me/proj/src");
|
||||
assert_eq!(c.env["DATA"], "/home/me/proj/data");
|
||||
assert_eq!(
|
||||
c.line(),
|
||||
"cd /home/me/proj/src && exec env DATA=/home/me/proj/data /opt/py/bin/python -u /home/me/proj/src/main.py"
|
||||
);
|
||||
|
||||
let c = remote_command(&config("npm run dev", &[], None), &target, root, "/home/me/proj");
|
||||
assert_eq!(c.line(), "cd /home/me/proj && export DATA=/home/me/proj/data; npm run dev");
|
||||
let c = remote_command(&config("/usr/local/bin/node", &["app.js"], None), &RunTarget::default(), root, "/srv");
|
||||
assert_eq!((c.program.as_str(), c.cwd.as_str()), ("node", "/srv"));
|
||||
let c = remote_command(&config("/usr/bin/make", &["test"], None), &RunTarget::default(), root, "/srv");
|
||||
assert_eq!(c.program, "make");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_the_root_only_as_a_whole_path() {
|
||||
assert_eq!(replace_root("/work/proj2/x:/work/proj/y", "/work/proj", "/srv"), "/work/proj2/x:/srv/y");
|
||||
assert_eq!(replace_root("/work/proj", "/work/proj", "/srv"), "/srv");
|
||||
assert_eq!(replace_root(r"C:\p\src/a.py C:\p2", r"C:\p", "/srv"), r"/srv/src/a.py C:\p2");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Deployment and run targets against the in-process SSH / SFTP server.
|
||||
|
||||
use ide_core::remote_settings::{DeploymentMapping, DeploymentServer, DeploymentSettings, RunTarget, SshAuth};
|
||||
use ide_core::{CancellationToken, IdeEvent, SharedSink};
|
||||
use ide_remote::{Deployment, Excludes, Mappings, RemoteRuns, RemoteStart, SyncAction, SyncStatus, SyncStep};
|
||||
use ide_run::{OutputChunk, RunConfig};
|
||||
use ide_ssh::{RemoteFs, Session, SshFiles, SshTarget};
|
||||
use parking_lot::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
struct Env {
|
||||
_tmp: tempfile::TempDir,
|
||||
project: std::path::PathBuf,
|
||||
server_root: std::path::PathBuf,
|
||||
session: Arc<Session>,
|
||||
}
|
||||
|
||||
async fn env() -> Env {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("project");
|
||||
let server_root = tmp.path().join("server");
|
||||
std::fs::create_dir_all(project.join("src")).unwrap();
|
||||
std::fs::create_dir_all(project.join(".git")).unwrap();
|
||||
std::fs::create_dir_all(&server_root).unwrap();
|
||||
std::fs::write(project.join("src/main.py"), "print('hi')\n").unwrap();
|
||||
std::fs::write(project.join("README.md"), "# demo\n").unwrap();
|
||||
std::fs::write(project.join(".git/HEAD"), "ref: refs/heads/main\n").unwrap();
|
||||
let port = ide_ssh::testing::sshd(server_root.clone(), Arc::new(|cmd| (format!("ran: {cmd}"), 0))).await;
|
||||
let files = SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None };
|
||||
let target = SshTarget {
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(port),
|
||||
user: "dev".into(),
|
||||
auth: SshAuth::Password,
|
||||
key_file: String::new(),
|
||||
};
|
||||
let session = match Session::connect(&target, Some("secret"), &files).await {
|
||||
Err(ide_ssh::ConnectError::UnknownHostKey(k)) => {
|
||||
k.trust(&files.known_hosts).unwrap();
|
||||
Session::connect(&target, Some("secret"), &files).await.ok().unwrap()
|
||||
}
|
||||
other => other.ok().unwrap(),
|
||||
};
|
||||
Env { _tmp: tmp, project, server_root, session: Arc::new(session) }
|
||||
}
|
||||
|
||||
fn server() -> DeploymentServer {
|
||||
DeploymentServer {
|
||||
id: "s".into(),
|
||||
name: "staging".into(),
|
||||
root_path: "/srv".into(),
|
||||
mappings: vec![DeploymentMapping { local: String::new(), deployment: "app".into(), web: String::new() }],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn no_progress() -> impl FnMut(&str, usize, usize) + Send {
|
||||
|_, _, _| {}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn uploads_compares_syncs_and_downloads() {
|
||||
let env = env().await;
|
||||
let fs = RemoteFs::new(env.session.sftp().await.unwrap());
|
||||
let server = server();
|
||||
let settings = DeploymentSettings::default();
|
||||
let map = Mappings::new(&server, &env.project, "/srv");
|
||||
let exclude = Excludes::new(&settings, &server, "/srv");
|
||||
let d = Deployment { fs: &fs, map: &map, exclude: &exclude, preserve_time: true, cancel: CancellationToken::new() };
|
||||
|
||||
let report = d.upload(std::slice::from_ref(&env.project), &mut no_progress()).await;
|
||||
assert_eq!((report.files, report.errors.len()), (2, 0), "{report:?}");
|
||||
assert_eq!(std::fs::read_to_string(env.server_root.join("srv/app/src/main.py")).unwrap(), "print('hi')\n");
|
||||
assert!(!env.server_root.join("srv/app/.git").exists(), ".git is excluded by name");
|
||||
|
||||
let same = d.compare(&env.project).await.unwrap();
|
||||
assert!(same.iter().all(|e| e.status == SyncStatus::Same), "{same:?}");
|
||||
|
||||
// A new local file, a file only on the server, a remote change.
|
||||
std::fs::write(env.project.join("src/new.py"), "x = 1\n").unwrap();
|
||||
std::fs::write(env.server_root.join("srv/app/remote.txt"), "server side\n").unwrap();
|
||||
std::fs::write(env.server_root.join("srv/app/README.md"), "# changed on the server, longer\n").unwrap();
|
||||
let file = std::fs::File::options().write(true).open(env.server_root.join("srv/app/README.md")).unwrap();
|
||||
file.set_modified(std::time::SystemTime::now() + std::time::Duration::from_secs(60)).unwrap();
|
||||
let entries = d.compare(&env.project).await.unwrap();
|
||||
let status = |p: &str| entries.iter().find(|e| e.path == p).map(|e| e.status);
|
||||
assert_eq!(status("src/new.py"), Some(SyncStatus::LocalOnly));
|
||||
assert_eq!(status("remote.txt"), Some(SyncStatus::RemoteOnly));
|
||||
assert_eq!(status("README.md"), Some(SyncStatus::RemoteNewer));
|
||||
assert_eq!(status("src/main.py"), Some(SyncStatus::Same));
|
||||
|
||||
let steps = vec![
|
||||
SyncStep { path: "src/new.py".into(), action: SyncAction::Upload },
|
||||
SyncStep { path: "README.md".into(), action: SyncAction::Download },
|
||||
SyncStep { path: "remote.txt".into(), action: SyncAction::DeleteRemote },
|
||||
];
|
||||
let report = d.apply(&steps, &mut no_progress()).await;
|
||||
assert_eq!((report.files, report.errors.len()), (3, 0), "{report:?}");
|
||||
assert!(env.server_root.join("srv/app/src/new.py").is_file());
|
||||
assert!(!env.server_root.join("srv/app/remote.txt").exists());
|
||||
assert_eq!(std::fs::read_to_string(env.project.join("README.md")).unwrap(), "# changed on the server, longer\n");
|
||||
|
||||
// Download a folder, then delete it remotely.
|
||||
std::fs::remove_dir_all(env.project.join("src")).unwrap();
|
||||
let report = d.download(&[env.project.join("src")], &mut no_progress()).await;
|
||||
assert_eq!((report.files, report.errors.len()), (2, 0), "{report:?}");
|
||||
assert!(env.project.join("src/main.py").is_file());
|
||||
let report = d.delete_remote(&[env.project.join("src")]).await;
|
||||
assert_eq!(report.files, 1);
|
||||
assert!(!env.server_root.join("srv/app/src").exists());
|
||||
|
||||
// Sync before a run uploads only what changed.
|
||||
let report = d.push_changes(&env.project, &mut no_progress()).await.unwrap();
|
||||
assert_eq!(report.files, 2, "{report:?}");
|
||||
assert_eq!(d.push_changes(&env.project, &mut no_progress()).await.unwrap().files, 0);
|
||||
}
|
||||
|
||||
struct Sink(Mutex<Vec<IdeEvent>>);
|
||||
|
||||
impl ide_core::EventSink for Sink {
|
||||
fn emit(&self, event: IdeEvent) {
|
||||
self.0.lock().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runs_a_configuration_on_a_target() {
|
||||
let env = env().await;
|
||||
let runs = RemoteRuns::new();
|
||||
let target =
|
||||
RunTarget { id: "t".into(), name: "box".into(), python: "/usr/bin/python3".into(), ..Default::default() };
|
||||
let config = RunConfig {
|
||||
id: "main".into(),
|
||||
name: "main".into(),
|
||||
kind: "python".into(),
|
||||
command: "python3".into(),
|
||||
args: vec![env.project.join("src/main.py").to_string_lossy().into_owned()],
|
||||
cwd: None,
|
||||
env: Default::default(),
|
||||
detected: false,
|
||||
is_test: false,
|
||||
debug_adapter: None,
|
||||
allow_parallel: false,
|
||||
extras: Default::default(),
|
||||
};
|
||||
let output: Arc<Mutex<String>> = Arc::default();
|
||||
let out = output.clone();
|
||||
let sink = Arc::new(Sink(Mutex::default()));
|
||||
let start = RemoteStart {
|
||||
session: env.session.clone(),
|
||||
target,
|
||||
root: env.project.clone(),
|
||||
remote_root: "/home/dev/demo".into(),
|
||||
sync: None,
|
||||
};
|
||||
let info = runs.start(
|
||||
start,
|
||||
&config,
|
||||
sink.clone() as SharedSink,
|
||||
Box::new(move |chunk| match chunk {
|
||||
OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => {
|
||||
out.lock().push_str(&text)
|
||||
}
|
||||
}),
|
||||
);
|
||||
assert!(runs.contains(info.id));
|
||||
for _ in 0..200 {
|
||||
if !sink.0.lock().is_empty() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
}
|
||||
let events = sink.0.lock().clone();
|
||||
assert!(matches!(events.as_slice(), [IdeEvent::RunExited { code: Some(0), .. }]), "{events:?}");
|
||||
let text = output.lock().clone();
|
||||
assert!(text.contains("ran: cd /home/dev/demo && exec /usr/bin/python3 /home/dev/demo/src/main.py"), "{text}");
|
||||
assert!(text.contains("Process finished with exit code 0"), "{text}");
|
||||
assert!(!runs.list()[0].running);
|
||||
}
|
||||
@@ -111,6 +111,7 @@ struct FileConfig {
|
||||
emulate_terminal: bool,
|
||||
#[serde(alias = "stopCommand")]
|
||||
stop_command: Option<String>,
|
||||
target: Option<String>,
|
||||
}
|
||||
|
||||
fn slug(s: &str) -> String {
|
||||
@@ -173,6 +174,7 @@ fn convert(configs: Vec<FileConfig>, local: bool) -> Vec<RunConfig> {
|
||||
focus_tool_window: c.focus_tool_window,
|
||||
emulate_terminal: c.emulate_terminal,
|
||||
stop_command: non_empty(c.stop_command),
|
||||
target: non_empty(c.target),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -275,6 +277,9 @@ fn to_table(c: &RunConfig) -> Table {
|
||||
if let Some(stop) = &x.stop_command {
|
||||
t.insert("stop-command", value(stop));
|
||||
}
|
||||
if let Some(target) = &x.target {
|
||||
t.insert("target", value(target));
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ pub struct RunExtras {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub stop_command: Option<String>,
|
||||
/// Settings › Run Targets id the process runs on (over SSH); `None` = this machine.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[specta(optional)]
|
||||
pub target: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for RunExtras {
|
||||
@@ -145,6 +149,7 @@ impl Default for RunExtras {
|
||||
focus_tool_window: false,
|
||||
emulate_terminal: false,
|
||||
stop_command: None,
|
||||
target: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -575,10 +575,9 @@ impl ProcessManager {
|
||||
pub fn list(&self) -> Vec<RunInfo> {
|
||||
self.runs.lock().values().map(|e| e.info()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessManager {
|
||||
fn drop(&mut self) {
|
||||
/// Kills every running process tree at once (shutting down).
|
||||
pub fn kill_all(&self) {
|
||||
for entry in self.runs.lock().values() {
|
||||
if !entry.exited.load(Ordering::Acquire)
|
||||
&& let Some(pid) = entry.pid
|
||||
@@ -589,6 +588,12 @@ impl Drop for ProcessManager {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ProcessManager {
|
||||
fn drop(&mut self) {
|
||||
self.kill_all();
|
||||
}
|
||||
}
|
||||
|
||||
/// The environment a configuration's process gets on top of the inherited one: `.env`
|
||||
/// files, then the configured variables (later wins).
|
||||
fn launch_env(root: &Path, config: &RunConfig) -> IdeResult<LaunchEnv> {
|
||||
|
||||
@@ -5,8 +5,8 @@ use crate::discover::{project_venv_python, workspace_tsdk};
|
||||
use crate::exec::{path_with, search_path};
|
||||
use crate::{bin_dir, exe, venv_of};
|
||||
use crate::{godot, vue};
|
||||
use ide_core::settings::Settings;
|
||||
use ide_language::LanguageServerConfig;
|
||||
use ide_core::settings::{LanguageServerSettings, Settings};
|
||||
use ide_language::{LanguageConfig, LanguageServerConfig};
|
||||
use ide_run::RunConfig;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -302,6 +302,78 @@ pub fn configure_server(cfg: &mut LanguageServerConfig, root: &Path, server_root
|
||||
}
|
||||
}
|
||||
|
||||
/// The servers `language` uses after Settings › Language Servers: the
|
||||
/// definition's ones that aren't disabled, then the ones defined in settings for it.
|
||||
pub fn language_servers(language: &str, defined: &[LanguageServerConfig], s: &Settings) -> Vec<LanguageServerConfig> {
|
||||
let enabled = |name: &str| s.language_servers.get(name).is_none_or(|ls| ls.enabled);
|
||||
let mut out: Vec<LanguageServerConfig> = defined.iter().filter(|c| enabled(&c.name)).cloned().collect();
|
||||
for (name, ls) in &s.language_servers {
|
||||
if ls.enabled && ls.languages.iter().any(|l| l == language) && !out.iter().any(|c| &c.name == name) {
|
||||
out.push(custom_server(name, ls));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Adds the servers defined in settings to the languages they serve (to list
|
||||
/// them with the tools); disabled ones stay so they can be turned back on.
|
||||
pub fn with_custom_servers(langs: &mut [LanguageConfig], s: &Settings) {
|
||||
for (name, ls) in s.language_servers.iter().filter(|(_, ls)| ls.is_custom()) {
|
||||
for lang in langs.iter_mut().filter(|l| ls.languages.iter().any(|id| id == l.id.as_str())) {
|
||||
if !lang.language_servers.iter().any(|c| &c.name == name) {
|
||||
lang.language_servers.push(custom_server(name, ls));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn custom_server(name: &str, ls: &LanguageServerSettings) -> LanguageServerConfig {
|
||||
LanguageServerConfig {
|
||||
name: name.to_owned(),
|
||||
command: ls.command.trim().to_owned(),
|
||||
args: Vec::new(),
|
||||
env: Default::default(),
|
||||
root_markers: ls.root_markers.clone(),
|
||||
initialization_options: None,
|
||||
settings: None,
|
||||
install_hint: Some(match ls.command.trim() {
|
||||
"" => format!("Set the command of `{name}` in Settings › Language Servers"),
|
||||
command => format!("`{command}` was not found: install it, or set its path in Settings › Language Servers"),
|
||||
}),
|
||||
connect: None,
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Command and arguments from Settings › Language Servers; applied before
|
||||
/// [`configure_server`] so toolchain adjustments (a custom path, JVM args) still follow.
|
||||
pub fn server_launch_overrides(cfg: &mut LanguageServerConfig, s: &Settings) {
|
||||
let Some(ls) = s.language_servers.get(&cfg.name) else { return };
|
||||
if let Some(command) = non_empty(ls.command.trim()) {
|
||||
cfg.command = command.to_owned();
|
||||
}
|
||||
if let Some(args) = &ls.args {
|
||||
cfg.args = args.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment, `initializationOptions` and `workspace/configuration` settings
|
||||
/// from Settings › Language Servers; applied last so they win over derived values.
|
||||
pub fn server_protocol_overrides(cfg: &mut LanguageServerConfig, s: &Settings) {
|
||||
let Some(ls) = s.language_servers.get(&cfg.name) else { return };
|
||||
cfg.env.extend(ls.env.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
if let Some(options) = &ls.initialization_options {
|
||||
merge_init_options(cfg, options.clone());
|
||||
}
|
||||
if let Some(settings) = &ls.settings {
|
||||
let mut current = cfg.settings.take().filter(Value::is_object).unwrap_or_else(|| json!({}));
|
||||
merge(&mut current, settings.clone());
|
||||
cfg.settings = Some(current);
|
||||
}
|
||||
}
|
||||
|
||||
/// vue-language-server loads TypeScript as a library (`--tsdk`, else its own
|
||||
/// `require('typescript')`): the selected version, else its own, else the
|
||||
/// project's — whichever still has the language-service API.
|
||||
|
||||
@@ -161,6 +161,19 @@ pub fn jdtls_jdks(min_java: u32) -> Arc<[Jdk]> {
|
||||
}
|
||||
}
|
||||
|
||||
/// For a server with `max-java`: when the `JAVA_HOME` it would get (its env,
|
||||
/// else the IDE's) is a newer JDK, points it at the newest of `jdks` that fits.
|
||||
pub fn cap_java_home(cfg: &mut LanguageServerConfig, jdks: &[Jdk]) {
|
||||
let Some(max) = cfg.max_java else { return };
|
||||
let current = cfg.env.get("JAVA_HOME").cloned().or_else(|| std::env::var("JAVA_HOME").ok());
|
||||
if current.as_deref().and_then(|h| jdk_at(Path::new(h))).is_some_and(|j| j.major <= max) {
|
||||
return;
|
||||
}
|
||||
if let Some(jdk) = jdks.iter().filter(|j| j.major <= max).max_by_key(|j| j.major) {
|
||||
cfg.env.insert("JAVA_HOME".into(), jdk.home.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
|
||||
/// Oldest Java version jdtls runs on when its install can't tell (its launcher's check).
|
||||
pub const JDTLS_MIN_JAVA: u32 = 21;
|
||||
|
||||
|
||||
@@ -28,9 +28,11 @@ fn server(name: &str) -> LanguageServerConfig {
|
||||
env: BTreeMap::new(),
|
||||
root_markers: vec![],
|
||||
initialization_options: None,
|
||||
settings: None,
|
||||
install_hint: None,
|
||||
connect: None,
|
||||
fallback: false,
|
||||
max_java: None,
|
||||
launch_error: None,
|
||||
}
|
||||
}
|
||||
@@ -205,6 +207,59 @@ fn project_jdk_and_maven_settings() {
|
||||
assert_eq!(project_jdk(tmp.path(), &s), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn language_server_settings_disable_add_and_override() {
|
||||
use crate::apply::{language_servers, server_launch_overrides, server_protocol_overrides, with_custom_servers};
|
||||
use ide_core::settings::LanguageServerSettings;
|
||||
use serde_json::json;
|
||||
|
||||
let mut s = Settings::default();
|
||||
s.language_servers.insert("pylsp".into(), LanguageServerSettings { enabled: false, ..Default::default() });
|
||||
s.language_servers.insert(
|
||||
"ruff".into(),
|
||||
LanguageServerSettings {
|
||||
command: "ruff".into(),
|
||||
args: Some(vec!["server".into()]),
|
||||
languages: vec!["python".into()],
|
||||
root_markers: vec!["pyproject.toml".into()],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let defined = [server("basedpyright"), server("pylsp")];
|
||||
let names = |v: Vec<LanguageServerConfig>| v.into_iter().map(|c| c.name).collect::<Vec<_>>();
|
||||
assert_eq!(names(language_servers("python", &defined, &s)), ["basedpyright", "ruff"]);
|
||||
assert_eq!(names(language_servers("rust", &[server("rust-analyzer")], &s)), ["rust-analyzer"]);
|
||||
|
||||
// Listed with the tools even while disabled.
|
||||
let mut langs = ide_language::LanguageRegistry::load(None).unwrap().all();
|
||||
with_custom_servers(&mut langs, &s);
|
||||
let python = langs.iter().find(|l| l.id.as_str() == "python").unwrap();
|
||||
let mut ruff = python.language_servers.iter().find(|c| c.name == "ruff").unwrap().clone();
|
||||
assert_eq!(ruff.root_markers, ["pyproject.toml"]);
|
||||
server_launch_overrides(&mut ruff, &s);
|
||||
assert_eq!((ruff.command.as_str(), ruff.args.as_slice()), ("ruff", &["server".to_string()][..]));
|
||||
|
||||
s.language_servers.insert(
|
||||
"rust-analyzer".into(),
|
||||
LanguageServerSettings {
|
||||
env: BTreeMap::from([("RA_LOG".into(), "info".into())]),
|
||||
initialization_options: Some(json!({ "check": { "command": "clippy" } })),
|
||||
settings: Some(json!({ "rust-analyzer": { "cargo": { "features": "all" } } })),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let mut ra = server("rust-analyzer");
|
||||
ra.args = vec!["--stdio".into()];
|
||||
ra.initialization_options = Some(toml::Value::Table(toml::toml! { checkOnSave = false }));
|
||||
server_launch_overrides(&mut ra, &s);
|
||||
assert_eq!(ra.args, ["--stdio"], "no args setting keeps the definition's");
|
||||
server_protocol_overrides(&mut ra, &s);
|
||||
assert_eq!(ra.env["RA_LOG"], "info");
|
||||
let init = serde_json::to_value(ra.initialization_options.unwrap()).unwrap();
|
||||
assert_eq!(init, json!({ "checkOnSave": false, "check": { "command": "clippy" } }));
|
||||
assert_eq!(ra.settings, Some(json!({ "rust-analyzer": { "cargo": { "features": "all" } } })));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jdtls_jdk_setting() {
|
||||
if cfg!(windows) {
|
||||
@@ -544,3 +599,29 @@ fn vue_language_server_gets_a_typescript_with_the_language_service_api() {
|
||||
let lib = project.join("node_modules/typescript/lib");
|
||||
assert_eq!(cfg.args, [format!("--tsdk={}", lib.to_string_lossy())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_java_picks_an_older_jdk() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let jdk = |major: u32| {
|
||||
let home = dir.path().join(format!("jdk-{major}"));
|
||||
std::fs::create_dir_all(home.join("bin")).unwrap();
|
||||
std::fs::write(home.join("release"), format!("JAVA_VERSION=\"{major}.0.1\"\n")).unwrap();
|
||||
std::fs::write(home.join("bin").join(exe("java")), "").unwrap();
|
||||
crate::java::jdk_at(&home).unwrap()
|
||||
};
|
||||
let (j17, j21, j25) = (jdk(17), jdk(21), jdk(25));
|
||||
let jdks = [j25.clone(), j21.clone(), j17];
|
||||
let mut cfg = LanguageServerConfig { max_java: Some(21), ..server("kotlin-language-server") };
|
||||
cfg.env.insert("JAVA_HOME".into(), j25.home.to_string_lossy().into_owned());
|
||||
crate::java::cap_java_home(&mut cfg, &jdks);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], j21.home.to_string_lossy());
|
||||
|
||||
// A JAVA_HOME that fits, or no limit: left alone.
|
||||
crate::java::cap_java_home(&mut cfg, &[]);
|
||||
assert_eq!(cfg.env["JAVA_HOME"], j21.home.to_string_lossy());
|
||||
let mut free = server("other");
|
||||
free.env.insert("JAVA_HOME".into(), j25.home.to_string_lossy().into_owned());
|
||||
crate::java::cap_java_home(&mut free, &jdks);
|
||||
assert_eq!(free.env["JAVA_HOME"], j25.home.to_string_lossy());
|
||||
}
|
||||
@@ -503,3 +503,63 @@ webhook
|
||||
webhooks
|
||||
websocket
|
||||
websockets
|
||||
# Systems, graphics and terminal code
|
||||
ascii
|
||||
bools
|
||||
clippy
|
||||
cmd
|
||||
cmds
|
||||
dep
|
||||
dst
|
||||
ffi
|
||||
gif
|
||||
gpui
|
||||
hsl
|
||||
isize
|
||||
jpeg
|
||||
jpg
|
||||
keybinding
|
||||
keybindings
|
||||
keymap
|
||||
keymaps
|
||||
lang
|
||||
langs
|
||||
lifecycle
|
||||
micros
|
||||
millis
|
||||
nanos
|
||||
opengl
|
||||
otf
|
||||
pty
|
||||
ptys
|
||||
realloc
|
||||
refcount
|
||||
refcounted
|
||||
rgb
|
||||
rgba
|
||||
rustup
|
||||
rwlock
|
||||
semaphore
|
||||
serde
|
||||
subdir
|
||||
subdirs
|
||||
tokio
|
||||
ttf
|
||||
tty
|
||||
udp
|
||||
vulkan
|
||||
wgpu
|
||||
woff
|
||||
woff2
|
||||
breakpoint
|
||||
sku
|
||||
dropdown
|
||||
navbar
|
||||
superclass
|
||||
typedef
|
||||
eslint
|
||||
unittest
|
||||
jvm
|
||||
jdk
|
||||
ux
|
||||
endpoint
|
||||
@@ -162,6 +162,14 @@ fn software_terms() -> &'static HashSet<&'static str> {
|
||||
})
|
||||
}
|
||||
|
||||
/// A software term, or its plural (`endpoints`, `regexes`).
|
||||
fn software_term(lower: &str) -> bool {
|
||||
let terms = software_terms();
|
||||
terms.contains(lower)
|
||||
|| lower.strip_suffix('s').is_some_and(|w| terms.contains(w))
|
||||
|| lower.strip_suffix("es").is_some_and(|w| terms.contains(w))
|
||||
}
|
||||
|
||||
/// Words of a custom dictionary file: a plain word list or a Hunspell `.dic`
|
||||
/// (leading count line and `/FLAGS` ignored). Cached by modification time.
|
||||
fn dictionary_file(path: &Path) -> Arc<HashSet<String>> {
|
||||
@@ -230,9 +238,7 @@ impl Speller {
|
||||
}
|
||||
|
||||
fn user_word(&self, lower: &str) -> bool {
|
||||
self.accepted.contains(lower)
|
||||
|| software_terms().contains(lower)
|
||||
|| self.custom.iter().any(|d| d.contains(lower))
|
||||
self.accepted.contains(lower) || software_term(lower) || self.custom.iter().any(|d| d.contains(lower))
|
||||
}
|
||||
|
||||
/// In a dictionary, in any case style.
|
||||
@@ -439,6 +445,14 @@ mod tests {
|
||||
assert!(!s.is_correct("lenght"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn software_terms_and_their_plurals() {
|
||||
let s = Speller::default();
|
||||
for ok in ["breakpoint", "breakpoints", "endpoints", "datasets", "regexes"] {
|
||||
assert!(s.is_correct(ok), "{ok}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_nouns_of_known_words() {
|
||||
let s = Speller::default();
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "ide-ssh"
|
||||
description = "SSH connections: configurations, host keys, authentication, remote commands, SFTP, port forwarding and askpass"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
ide-core.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
specta.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
parking_lot.workspace = true
|
||||
dirs.workspace = true
|
||||
russh = { version = "0.64", default-features = false, features = ["ring", "rsa", "flate2"] }
|
||||
russh-config = "0.58.0"
|
||||
russh-sftp = "2.4"
|
||||
flate2 = "1"
|
||||
|
||||
[features]
|
||||
# `testing`: an in-process SSH / SFTP server for tests of crates built on this one.
|
||||
testing = []
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||
tempfile.workspace = true
|
||||
@@ -0,0 +1,216 @@
|
||||
//! Credential prompts of child processes (`ssh`, `git`) answered by the UI.
|
||||
//!
|
||||
//! The app's own executable doubles as the `SSH_ASKPASS` / `GIT_ASKPASS`
|
||||
//! program: started with [`PORT_ENV`] set, it sends the prompt to the
|
||||
//! [`AskpassServer`] on 127.0.0.1 and prints the answer ([`run_helper`]).
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const PORT_ENV: &str = "OPENIDEA_ASKPASS_PORT";
|
||||
pub const TOKEN_ENV: &str = "OPENIDEA_ASKPASS_TOKEN";
|
||||
/// An unanswered prompt fails after this long.
|
||||
const ANSWER_TIMEOUT: Duration = Duration::from_secs(300);
|
||||
|
||||
pub use ide_core::event::AskpassKind;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AskpassPrompt {
|
||||
pub id: u64,
|
||||
pub prompt: String,
|
||||
pub kind: AskpassKind,
|
||||
}
|
||||
|
||||
pub fn classify(prompt: &str) -> AskpassKind {
|
||||
let p = prompt.to_ascii_lowercase();
|
||||
if p.contains("(yes/no") || p.contains("continue connecting") {
|
||||
AskpassKind::Confirm
|
||||
} else if p.starts_with("username") {
|
||||
AskpassKind::Text
|
||||
} else {
|
||||
AskpassKind::Secret
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Request {
|
||||
token: String,
|
||||
prompt: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct Response {
|
||||
answer: Option<String>,
|
||||
}
|
||||
|
||||
type Pending = Arc<Mutex<HashMap<u64, mpsc::Sender<Option<String>>>>>;
|
||||
|
||||
/// Serves prompts to `on_prompt`; answers come back through [`AskpassServer::answer`].
|
||||
pub struct AskpassServer {
|
||||
port: u16,
|
||||
token: String,
|
||||
exe: std::path::PathBuf,
|
||||
pending: Pending,
|
||||
}
|
||||
|
||||
impl AskpassServer {
|
||||
/// Listens on a free local port. `exe` is the helper program (the app itself).
|
||||
pub fn start(exe: std::path::PathBuf, on_prompt: Arc<dyn Fn(AskpassPrompt) + Send + Sync>) -> IdeResult<Self> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0))?;
|
||||
let port = listener.local_addr()?.port();
|
||||
let token = random_token();
|
||||
let pending: Pending = Arc::default();
|
||||
let (t, p) = (token.clone(), pending.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("askpass".into())
|
||||
.spawn(move || {
|
||||
let next = AtomicU64::new(1);
|
||||
for stream in listener.incoming().flatten() {
|
||||
let id = next.fetch_add(1, Ordering::Relaxed);
|
||||
let (t, p, on_prompt) = (t.clone(), p.clone(), on_prompt.clone());
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("askpass-prompt".into())
|
||||
.spawn(move || serve(stream, id, &t, &p, on_prompt.as_ref()));
|
||||
}
|
||||
})
|
||||
.map_err(|e| IdeError::internal(format!("askpass: {e}")))?;
|
||||
Ok(Self { port, token, exe, pending })
|
||||
}
|
||||
|
||||
/// Answers prompt `id` (`None`: cancelled).
|
||||
pub fn answer(&self, id: u64, answer: Option<String>) {
|
||||
if let Some(tx) = self.pending.lock().remove(&id) {
|
||||
let _ = tx.send(answer);
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment that routes `ssh` / `git` prompts here.
|
||||
pub fn env(&self) -> Vec<(String, String)> {
|
||||
let exe = self.exe.to_string_lossy().into_owned();
|
||||
vec![
|
||||
(PORT_ENV.into(), self.port.to_string()),
|
||||
(TOKEN_ENV.into(), self.token.clone()),
|
||||
("SSH_ASKPASS".into(), exe.clone()),
|
||||
("SSH_ASKPASS_REQUIRE".into(), "force".into()),
|
||||
("GIT_ASKPASS".into(), exe),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
fn serve(
|
||||
stream: TcpStream,
|
||||
id: u64,
|
||||
token: &str,
|
||||
pending: &Pending,
|
||||
on_prompt: &(dyn Fn(AskpassPrompt) + Send + Sync),
|
||||
) {
|
||||
let mut line = String::new();
|
||||
// The helper writes its request at once: an idle connection must not hold a thread.
|
||||
let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(10)));
|
||||
let Ok(mut reader) = stream.try_clone().map(BufReader::new) else { return };
|
||||
if reader.read_line(&mut line).is_err() {
|
||||
return;
|
||||
}
|
||||
let Ok(req) = serde_json::from_str::<Request>(&line) else { return };
|
||||
let answer = if req.token != token {
|
||||
None
|
||||
} else {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
pending.lock().insert(id, tx);
|
||||
let prompt = req.prompt.trim().to_owned();
|
||||
on_prompt(AskpassPrompt { id, kind: classify(&prompt), prompt });
|
||||
let answer = rx.recv_timeout(ANSWER_TIMEOUT).ok().flatten();
|
||||
pending.lock().remove(&id);
|
||||
answer
|
||||
};
|
||||
let mut out = stream;
|
||||
let _ = writeln!(out, "{}", serde_json::to_string(&Response { answer }).unwrap_or_default());
|
||||
}
|
||||
|
||||
fn random_token() -> String {
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
// Two independently seeded SipHash keys: unpredictable to other local users.
|
||||
(0..2)
|
||||
.map(|_| {
|
||||
let mut h = RandomState::new().build_hasher();
|
||||
h.write_u128(
|
||||
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(),
|
||||
);
|
||||
format!("{:016x}", h.finish())
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// When started as the askpass helper (by `ssh` / `git`), forwards the prompt
|
||||
/// (the first argument) and returns the exit code; `None` otherwise.
|
||||
pub fn run_helper() -> Option<i32> {
|
||||
let port: u16 = std::env::var(PORT_ENV).ok()?.parse().ok()?;
|
||||
let token = std::env::var(TOKEN_ENV).unwrap_or_default();
|
||||
let prompt = std::env::args().nth(1).unwrap_or_else(|| "Password:".into());
|
||||
let ask = || -> std::io::Result<Option<String>> {
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", port))?;
|
||||
writeln!(stream, "{}", serde_json::to_string(&Request { token, prompt }).unwrap_or_default())?;
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream).read_line(&mut line)?;
|
||||
Ok(serde_json::from_str::<Response>(&line).ok().and_then(|r| r.answer))
|
||||
};
|
||||
match ask() {
|
||||
Ok(Some(answer)) => {
|
||||
println!("{answer}");
|
||||
Some(0)
|
||||
}
|
||||
_ => Some(1),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn classifies_prompts() {
|
||||
assert_eq!(classify("Enter passphrase for key '/u/.ssh/id_ed25519': "), AskpassKind::Secret);
|
||||
assert_eq!(classify("[email protected]'s password: "), AskpassKind::Secret);
|
||||
assert_eq!(classify("Username for 'https://github.com': "), AskpassKind::Text);
|
||||
assert_eq!(
|
||||
classify("Are you sure you want to continue connecting (yes/no/[fingerprint])? "),
|
||||
AskpassKind::Confirm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips_a_prompt() {
|
||||
let seen: Arc<Mutex<Option<AskpassPrompt>>> = Arc::default();
|
||||
let s = seen.clone();
|
||||
let server = Arc::new(AskpassServer::start("helper".into(), Arc::new(move |p| *s.lock() = Some(p))).unwrap());
|
||||
let port = server.port;
|
||||
let token = server.token.clone();
|
||||
let client = std::thread::spawn(move || {
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
writeln!(stream, "{}", serde_json::to_string(&Request { token, prompt: "Password: ".into() }).unwrap())
|
||||
.unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(stream).read_line(&mut line).unwrap();
|
||||
serde_json::from_str::<Response>(&line).unwrap().answer
|
||||
});
|
||||
let id = loop {
|
||||
if let Some(p) = seen.lock().clone() {
|
||||
assert_eq!((p.prompt.as_str(), p.kind), ("Password:", AskpassKind::Secret));
|
||||
break p.id;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(5));
|
||||
};
|
||||
server.answer(id, Some("hunter2".into()));
|
||||
assert_eq!(client.join().unwrap().as_deref(), Some("hunter2"));
|
||||
assert!(server.env().iter().any(|(k, v)| k == "SSH_ASKPASS" && v == "helper"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! SSH for every remote feature: configurations and `~/.ssh/config` hosts,
|
||||
//! host-key trust, authentication (password, key, agent), remote commands and
|
||||
//! processes, SFTP, local port forwarding, and askpass for `ssh` / `git`.
|
||||
|
||||
pub mod askpass;
|
||||
pub mod manager;
|
||||
pub mod secrets;
|
||||
pub mod session;
|
||||
pub mod sftp;
|
||||
pub mod shell;
|
||||
pub mod target;
|
||||
#[cfg(feature = "testing")]
|
||||
pub mod testing;
|
||||
pub mod tunnel;
|
||||
|
||||
pub use manager::SshManager;
|
||||
pub use russh::Sig;
|
||||
pub use session::{ConnectError, ExecOutput, Output, RemoteProcess, Session, UnknownHostKey};
|
||||
pub use sftp::{RemoteEntry, RemoteFs};
|
||||
pub use target::{SshFiles, SshTarget, expand, openssh_args};
|
||||
pub use tunnel::Tunnel;
|
||||
@@ -0,0 +1,192 @@
|
||||
//! App-wide SSH connections, one per SSH configuration, shared by every
|
||||
//! feature that works on that host (deployment, run targets, data sources).
|
||||
|
||||
use crate::secrets::SecretFile;
|
||||
use crate::session::{ConnectError, Session, UnknownHostKey};
|
||||
use crate::target::{SshFiles, SshTarget};
|
||||
use ide_core::remote_settings::{SshAuth, SshConfiguration};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::{Mutex, RwLock};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct SshManager {
|
||||
files: SshFiles,
|
||||
saved: SecretFile,
|
||||
/// Secrets entered this session (not saved, or not yet), by configuration id.
|
||||
entered: Mutex<HashMap<String, String>>,
|
||||
configs: RwLock<Vec<SshConfiguration>>,
|
||||
sessions: Mutex<HashMap<String, Arc<Session>>>,
|
||||
/// Serialises connecting per configuration so concurrent users share one
|
||||
/// session (an unreachable host doesn't hold up the others).
|
||||
opening: Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
/// Host keys awaiting the user's trust, by key (configuration id or a caller's own).
|
||||
unknown: Mutex<HashMap<String, UnknownHostKey>>,
|
||||
}
|
||||
|
||||
impl SshManager {
|
||||
/// `secrets` is the file of saved SSH passwords.
|
||||
pub fn new(secrets: PathBuf) -> Self {
|
||||
Self::with_files(secrets, SshFiles::default())
|
||||
}
|
||||
|
||||
pub fn with_files(secrets: PathBuf, files: SshFiles) -> Self {
|
||||
Self {
|
||||
files,
|
||||
saved: SecretFile::new(secrets),
|
||||
entered: Mutex::default(),
|
||||
configs: RwLock::default(),
|
||||
sessions: Mutex::default(),
|
||||
opening: Mutex::default(),
|
||||
unknown: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn files(&self) -> &SshFiles {
|
||||
&self.files
|
||||
}
|
||||
|
||||
/// Replaces the configurations (Settings › SSH Configurations); sessions of
|
||||
/// changed or removed ones are closed, secrets of removed ones forgotten.
|
||||
pub fn set_configs(&self, configs: Vec<SshConfiguration>) {
|
||||
let old = std::mem::replace(&mut *self.configs.write(), configs.clone());
|
||||
for o in old {
|
||||
match configs.iter().find(|c| c.id == o.id) {
|
||||
Some(n)
|
||||
if n.host == o.host
|
||||
&& n.port == o.port
|
||||
&& n.user == o.user
|
||||
&& n.auth == o.auth
|
||||
&& n.key_file == o.key_file => {}
|
||||
Some(_) => {
|
||||
self.sessions.lock().remove(&o.id);
|
||||
}
|
||||
None => {
|
||||
self.sessions.lock().remove(&o.id);
|
||||
self.entered.lock().remove(&o.id);
|
||||
self.saved.delete(&o.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configs(&self) -> Vec<SshConfiguration> {
|
||||
self.configs.read().clone()
|
||||
}
|
||||
|
||||
pub fn config(&self, id: &str) -> IdeResult<SshConfiguration> {
|
||||
self.configs
|
||||
.read()
|
||||
.iter()
|
||||
.find(|c| c.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found(format!("no SSH configuration {id}")))
|
||||
}
|
||||
|
||||
/// Remembers the password / passphrase of configuration `id` for the session,
|
||||
/// and in the saved passwords with `save` (`None` forgets it).
|
||||
pub fn set_secret(&self, id: &str, secret: Option<String>, save: bool) {
|
||||
match &secret {
|
||||
Some(s) => {
|
||||
self.entered.lock().insert(id.to_owned(), s.clone());
|
||||
}
|
||||
None => {
|
||||
self.entered.lock().remove(id);
|
||||
}
|
||||
}
|
||||
match (secret, save) {
|
||||
(Some(s), true) => {
|
||||
if let Err(e) = self.saved.set(id, &s) {
|
||||
tracing::warn!("could not save the SSH password: {}", e.message);
|
||||
}
|
||||
}
|
||||
_ => self.saved.delete(id),
|
||||
}
|
||||
}
|
||||
|
||||
/// The known password / passphrase of configuration `id`.
|
||||
pub fn secret(&self, config: &SshConfiguration) -> Option<String> {
|
||||
if let Some(s) = self.entered.lock().get(&config.id) {
|
||||
return Some(s.clone());
|
||||
}
|
||||
config.save_secret.then(|| self.saved.get(&config.id)).flatten()
|
||||
}
|
||||
|
||||
/// The shared session of configuration `id`, connecting first if needed.
|
||||
pub async fn session(&self, id: &str) -> IdeResult<Arc<Session>> {
|
||||
if let Some(s) = self.live(id) {
|
||||
return Ok(s);
|
||||
}
|
||||
let lock = self.opening.lock().entry(id.to_owned()).or_default().clone();
|
||||
let _opening = lock.lock().await;
|
||||
if let Some(s) = self.live(id) {
|
||||
return Ok(s);
|
||||
}
|
||||
let config = self.config(id)?;
|
||||
let secret = self.secret(&config);
|
||||
let session = Arc::new(self.connect(id, &SshTarget::from(&config), secret.as_deref()).await?);
|
||||
self.sessions.lock().insert(id.to_owned(), session.clone());
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
fn live(&self, id: &str) -> Option<Arc<Session>> {
|
||||
let mut sessions = self.sessions.lock();
|
||||
match sessions.get(id) {
|
||||
Some(s) if !s.is_closed() => Some(s.clone()),
|
||||
Some(_) => {
|
||||
sessions.remove(id);
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A private session to `target`; an unknown host key is kept under `key`
|
||||
/// for [`SshManager::trust_host`].
|
||||
pub async fn connect(&self, key: &str, target: &SshTarget, secret: Option<&str>) -> IdeResult<Session> {
|
||||
match Session::connect(target, secret, &self.files).await {
|
||||
Ok(s) => Ok(s),
|
||||
Err(ConnectError::Other(e)) => Err(e),
|
||||
Err(ConnectError::UnknownHostKey(k)) => {
|
||||
let e = k.error();
|
||||
self.unknown.lock().insert(key.to_owned(), *k);
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Connects with `config` as given (not necessarily saved) and describes the host.
|
||||
pub async fn test(&self, config: &SshConfiguration, secret: Option<String>) -> IdeResult<String> {
|
||||
let secret = secret.or_else(|| self.secret(config));
|
||||
let session = self.connect(&config.id, &SshTarget::from(config), secret.as_deref()).await?;
|
||||
let out = session.exec("uname -srm 2>/dev/null || ver", None).await?;
|
||||
let os = out.stdout_str().trim().to_owned();
|
||||
Ok(if os.is_empty() {
|
||||
format!("Connected to {}", session.label())
|
||||
} else {
|
||||
format!("Connected to {}: {os}", session.label())
|
||||
})
|
||||
}
|
||||
|
||||
/// Trusts the host key that failed the last connection under `key` (adds it to `known_hosts`).
|
||||
pub async fn trust_host(&self, key: &str) -> IdeResult<()> {
|
||||
let k = self.unknown.lock().remove(key).ok_or_else(|| IdeError::not_found("no host key to trust"))?;
|
||||
let path = self.files.known_hosts.clone();
|
||||
tokio::task::spawn_blocking(move || k.trust(&path)).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
pub fn disconnect(&self, id: &str) {
|
||||
self.sessions.lock().remove(id);
|
||||
}
|
||||
|
||||
/// Ids of the configurations with an open session.
|
||||
pub fn connected(&self) -> Vec<String> {
|
||||
self.sessions.lock().iter().filter(|(_, s)| !s.is_closed()).map(|(k, _)| k.clone()).collect()
|
||||
}
|
||||
|
||||
/// Whether connecting to `config` will need a secret the user has not given.
|
||||
pub fn needs_secret(&self, config: &SshConfiguration) -> bool {
|
||||
config.auth == SshAuth::Password && self.secret(config).is_none()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Saved SSH passwords / key passphrases of SSH configurations, in the app's
|
||||
//! data directory rather than the OS credential store (no system prompts).
|
||||
//! One JSON object, configuration id → secret; owner-only on Unix.
|
||||
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct SecretFile {
|
||||
path: PathBuf,
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl SecretFile {
|
||||
pub fn new(path: PathBuf) -> Self {
|
||||
Self { path, lock: Mutex::new(()) }
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<String> {
|
||||
let _g = self.lock.lock();
|
||||
self.read().remove(key)
|
||||
}
|
||||
|
||||
pub fn set(&self, key: &str, secret: &str) -> IdeResult<()> {
|
||||
let _g = self.lock.lock();
|
||||
let mut all = self.read();
|
||||
all.insert(key.to_owned(), secret.to_owned());
|
||||
self.write(&all)
|
||||
}
|
||||
|
||||
pub fn delete(&self, key: &str) {
|
||||
let _g = self.lock.lock();
|
||||
let mut all = self.read();
|
||||
if all.remove(key).is_some()
|
||||
&& let Err(e) = self.write(&all)
|
||||
{
|
||||
tracing::warn!("saved SSH passwords: {}", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
fn read(&self) -> BTreeMap<String, String> {
|
||||
match std::fs::read(&self.path) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
||||
tracing::warn!("saved SSH passwords: {}: {e}", self.path.display());
|
||||
BTreeMap::new()
|
||||
}),
|
||||
Err(_) => BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(&self, all: &BTreeMap<String, String>) -> IdeResult<()> {
|
||||
let err = |e: std::io::Error| IdeError::unavailable(format!("saved SSH passwords: {e}"));
|
||||
if let Some(dir) = self.path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(err)?;
|
||||
}
|
||||
let tmp = self.path.with_extension("json.tmp");
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
||||
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
std::io::Write::write_all(&mut options.open(&tmp).map_err(err)?, &bytes).map_err(err)?;
|
||||
std::fs::rename(&tmp, &self.path).map_err(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn stores_and_forgets_secrets() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let file = SecretFile::new(tmp.path().join("ssh-passwords.json"));
|
||||
file.set("a", "pw").unwrap();
|
||||
assert_eq!(file.get("a").as_deref(), Some("pw"));
|
||||
file.delete("a");
|
||||
assert_eq!(file.get("a"), None);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(tmp.path().join("ssh-passwords.json")).unwrap().permissions().mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
//! An authenticated SSH connection (russh), optionally through `ProxyJump`
|
||||
//! hosts, and what runs over it: commands, processes, SFTP and TCP channels.
|
||||
//!
|
||||
//! Host keys are checked against `known_hosts`; an unknown key fails the
|
||||
//! connection with [`ConnectError::UnknownHostKey`] so the UI can ask the user,
|
||||
//! and a changed key is always refused.
|
||||
|
||||
use crate::target::{Hop, SshFiles, SshTarget, expand, home, resolve_hops};
|
||||
use ide_core::remote_settings::SshAuth;
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use parking_lot::Mutex;
|
||||
use russh::client::{self, Handle, Msg};
|
||||
use russh::keys::{self, HashAlg, PrivateKeyWithHashAlg, PublicKey, PublicKeyOrCertificate, known_hosts};
|
||||
use russh::{ChannelMsg, ChannelStream, ChannelWriteHalf, Sig};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
/// Generous: a relayed VPN path (e.g. Tailscale via DERP) can take ~1 s per round trip.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
const DEFAULT_IDENTITIES: [&str; 3] = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/// A host key the user has not trusted yet.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnknownHostKey {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub key: PublicKey,
|
||||
}
|
||||
|
||||
impl UnknownHostKey {
|
||||
/// `ED25519 SHA256:…`, as OpenSSH prints it.
|
||||
pub fn fingerprint(&self) -> String {
|
||||
format!("{} {}", self.key.algorithm().as_str(), self.key.fingerprint(HashAlg::Sha256))
|
||||
}
|
||||
|
||||
/// Records the key in `known_hosts` (blocking).
|
||||
pub fn trust(&self, known_hosts: &Path) -> IdeResult<()> {
|
||||
if let Some(dir) = known_hosts.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
known_hosts::learn_known_hosts_path(&self.host, self.port, &self.key, known_hosts)
|
||||
.map_err(|e| IdeError::new(ErrorKind::Io, format!("cannot update {}: {e}", known_hosts.display())))
|
||||
}
|
||||
|
||||
/// The error shown for it; the UI recognises the `Unknown SSH host key` prefix.
|
||||
pub fn error(&self) -> IdeError {
|
||||
IdeError::new(
|
||||
ErrorKind::Protocol,
|
||||
format!("Unknown SSH host key for {}:{} ({})", self.host, self.port, self.fingerprint()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConnectError {
|
||||
UnknownHostKey(Box<UnknownHostKey>),
|
||||
Other(IdeError),
|
||||
}
|
||||
|
||||
impl From<IdeError> for ConnectError {
|
||||
fn from(e: IdeError) -> Self {
|
||||
ConnectError::Other(e)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ConnectError> for IdeError {
|
||||
fn from(e: ConnectError) -> Self {
|
||||
match e {
|
||||
ConnectError::UnknownHostKey(k) => k.error(),
|
||||
ConnectError::Other(e) => e,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output of a finished remote command.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ExecOutput {
|
||||
/// `None` when the server reported no status (killed by a signal).
|
||||
pub code: Option<u32>,
|
||||
pub stdout: Vec<u8>,
|
||||
pub stderr: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ExecOutput {
|
||||
pub fn success(&self) -> bool {
|
||||
self.code == Some(0)
|
||||
}
|
||||
|
||||
pub fn stdout_str(&self) -> String {
|
||||
String::from_utf8_lossy(&self.stdout).into_owned()
|
||||
}
|
||||
|
||||
/// stdout on success, else an error with stderr.
|
||||
pub fn into_stdout(self, what: &str) -> IdeResult<String> {
|
||||
if self.success() {
|
||||
return Ok(self.stdout_str());
|
||||
}
|
||||
let err = String::from_utf8_lossy(&self.stderr).trim().to_owned();
|
||||
let code = self.code.map_or_else(|| "killed".to_owned(), |c| format!("exit code {c}"));
|
||||
Err(io(if err.is_empty() { format!("{what} failed ({code})") } else { format!("{what} failed: {err}") }))
|
||||
}
|
||||
}
|
||||
|
||||
/// An open SSH connection; dropping it closes the sessions.
|
||||
pub struct Session {
|
||||
handle: Handle<Client>,
|
||||
/// Sessions of the `ProxyJump` hosts, kept alive under `handle`.
|
||||
_jumps: Vec<Handle<Client>>,
|
||||
label: String,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Connects and authenticates; `secret` is the password or key passphrase.
|
||||
pub async fn connect(target: &SshTarget, secret: Option<&str>, files: &SshFiles) -> Result<Session, ConnectError> {
|
||||
let host = format!("{}:{}", target.host, target.port.unwrap_or(22));
|
||||
match tokio::time::timeout(CONNECT_TIMEOUT, Self::connect_inner(target, secret, files)).await {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
Err(io(format!("timed out connecting to the SSH host {host} ({} s)", CONNECT_TIMEOUT.as_secs())).into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect_inner(
|
||||
target: &SshTarget,
|
||||
secret: Option<&str>,
|
||||
files: &SshFiles,
|
||||
) -> Result<Session, ConnectError> {
|
||||
let hops = resolve_hops(target, files.config.as_deref())?;
|
||||
let mut sessions: Vec<Handle<Client>> = Vec::new();
|
||||
for (i, hop) in hops.iter().enumerate() {
|
||||
let last = i == hops.len() - 1;
|
||||
let mut handle = match sessions.last() {
|
||||
None => connect::<tokio::net::TcpStream>(hop, &files.known_hosts, None).await?,
|
||||
Some(prev) => {
|
||||
let channel = prev
|
||||
.channel_open_direct_tcpip(hop.host.as_str(), u32::from(hop.port), "127.0.0.1", 0)
|
||||
.await
|
||||
.map_err(|e| io(format!("cannot reach {} through the jump host: {e}", hop.label())))?;
|
||||
connect(hop, &files.known_hosts, Some(channel.into_stream())).await?
|
||||
}
|
||||
};
|
||||
// Jump hosts always use the agent / identity files; the secret belongs to the target.
|
||||
let mode = if last { target.auth } else { SshAuth::OpenSsh };
|
||||
authenticate(&mut handle, hop, mode, &target.key_file, secret).await?;
|
||||
sessions.push(handle);
|
||||
}
|
||||
let handle = sessions.pop().expect("at least one hop");
|
||||
let label = hops.last().expect("at least one hop").label();
|
||||
Ok(Session { handle, _jumps: sessions, label })
|
||||
}
|
||||
|
||||
/// `user@host:port` of the target.
|
||||
pub fn label(&self) -> &str {
|
||||
&self.label
|
||||
}
|
||||
|
||||
/// The connection ended (network loss, server restart).
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.handle.is_closed()
|
||||
}
|
||||
|
||||
/// A TCP connection to `host:port` as seen from the SSH host.
|
||||
pub async fn direct_tcpip(&self, host: &str, port: u16) -> Result<ChannelStream<Msg>, russh::Error> {
|
||||
let channel = self.handle.channel_open_direct_tcpip(host, u32::from(port), "127.0.0.1", 0).await?;
|
||||
Ok(channel.into_stream())
|
||||
}
|
||||
|
||||
/// Starts `command` and returns its stdin / stdout as one byte stream (stderr is dropped).
|
||||
pub async fn exec_stream(&self, command: &str) -> Result<ChannelStream<Msg>, russh::Error> {
|
||||
let channel = self.handle.channel_open_session().await?;
|
||||
channel.exec(true, command).await?;
|
||||
Ok(channel.into_stream())
|
||||
}
|
||||
|
||||
/// Runs `command` through the remote shell and collects its output.
|
||||
pub async fn exec(&self, command: &str, stdin: Option<&[u8]>) -> IdeResult<ExecOutput> {
|
||||
let mut channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?;
|
||||
channel.exec(true, command).await.map_err(|e| self.failed(e))?;
|
||||
if let Some(input) = stdin {
|
||||
channel.data(input).await.map_err(|e| self.failed(e))?;
|
||||
}
|
||||
channel.eof().await.map_err(|e| self.failed(e))?;
|
||||
let mut out = ExecOutput::default();
|
||||
while let Some(msg) = channel.wait().await {
|
||||
match msg {
|
||||
ChannelMsg::Data { data } => out.stdout.extend_from_slice(&data),
|
||||
ChannelMsg::ExtendedData { data, ext: 1 } => out.stderr.extend_from_slice(&data),
|
||||
ChannelMsg::ExitStatus { exit_status } => out.code = Some(exit_status),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Starts `command` (through the remote shell), streaming its output.
|
||||
/// With `pty` (columns, rows) stdout and stderr arrive merged, as in a terminal.
|
||||
pub async fn spawn(
|
||||
&self,
|
||||
command: &str,
|
||||
pty: Option<(u16, u16)>,
|
||||
on_output: Box<dyn FnMut(Output<'_>) + Send>,
|
||||
) -> IdeResult<RemoteProcess> {
|
||||
let channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?;
|
||||
if let Some((cols, rows)) = pty {
|
||||
channel
|
||||
.request_pty(true, "xterm-256color", u32::from(cols), u32::from(rows), 0, 0, &[])
|
||||
.await
|
||||
.map_err(|e| self.failed(e))?;
|
||||
}
|
||||
if command.is_empty() {
|
||||
channel.request_shell(true).await.map_err(|e| self.failed(e))?;
|
||||
} else {
|
||||
channel.exec(true, command).await.map_err(|e| self.failed(e))?;
|
||||
}
|
||||
let (mut read, write) = channel.split();
|
||||
let (tx, rx) = oneshot::channel();
|
||||
let mut on_output = on_output;
|
||||
tokio::spawn(async move {
|
||||
let mut code = None;
|
||||
while let Some(msg) = read.wait().await {
|
||||
match msg {
|
||||
ChannelMsg::Data { data } => on_output(Output::Stdout(&data)),
|
||||
ChannelMsg::ExtendedData { data, ext: 1 } => on_output(Output::Stderr(&data)),
|
||||
ChannelMsg::ExitStatus { exit_status } => code = Some(exit_status),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let _ = tx.send(code);
|
||||
});
|
||||
Ok(RemoteProcess { write: Arc::new(write), exit: Mutex::new(Some(rx)) })
|
||||
}
|
||||
|
||||
/// Writes `data` to `remote` (absolute; its folder is created) through an
|
||||
/// exec channel, gzip-compressed. Much faster than SFTP for one big file:
|
||||
/// SFTP writes wait for each packet's reply, the channel only for its window.
|
||||
pub async fn put(&self, remote: &str, data: &[u8]) -> IdeResult<()> {
|
||||
self.put_with_progress(remote, data, &|_| {}).await
|
||||
}
|
||||
|
||||
/// [`Self::put`], reporting the fraction sent (0..1) as it goes.
|
||||
pub async fn put_with_progress(
|
||||
&self,
|
||||
remote: &str,
|
||||
data: &[u8],
|
||||
progress: &(dyn Fn(f64) + Send + Sync),
|
||||
) -> IdeResult<()> {
|
||||
use std::io::Write as _;
|
||||
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast());
|
||||
gz.write_all(data)?;
|
||||
let packed = gz.finish()?;
|
||||
let q = crate::shell::quote;
|
||||
let dir = crate::sftp::parent(remote).unwrap_or("/");
|
||||
let command = format!("mkdir -p {} && gzip -dc > {}", q(dir), q(remote));
|
||||
let stderr = Arc::new(Mutex::new(Vec::new()));
|
||||
let sink = stderr.clone();
|
||||
let process = self
|
||||
.spawn(
|
||||
&command,
|
||||
None,
|
||||
Box::new(move |out| {
|
||||
if let Output::Stderr(b) = out {
|
||||
sink.lock().extend_from_slice(b);
|
||||
}
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let mut sent = 0;
|
||||
for chunk in packed.chunks(256 * 1024) {
|
||||
process.write(chunk).await?;
|
||||
sent += chunk.len();
|
||||
progress(sent as f64 / packed.len().max(1) as f64);
|
||||
}
|
||||
process.eof().await?;
|
||||
match process.wait().await {
|
||||
Some(0) => Ok(()),
|
||||
code => {
|
||||
let err = String::from_utf8_lossy(&stderr.lock()).trim().to_owned();
|
||||
Err(io(format!("cannot write {remote} on {} ({code:?}): {err}", self.label)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An SFTP session on its own channel.
|
||||
pub async fn sftp(&self) -> IdeResult<russh_sftp::client::SftpSession> {
|
||||
let channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?;
|
||||
channel.request_subsystem(true, "sftp").await.map_err(|e| self.failed(e))?;
|
||||
russh_sftp::client::SftpSession::new(channel.into_stream())
|
||||
.await
|
||||
.map_err(|e| io(format!("SFTP is not available on {}: {e}", self.label)))
|
||||
}
|
||||
|
||||
fn failed(&self, e: russh::Error) -> IdeError {
|
||||
io(format!("SSH {}: {e}", self.label))
|
||||
}
|
||||
}
|
||||
|
||||
/// A chunk of a remote process's output.
|
||||
pub enum Output<'a> {
|
||||
Stdout(&'a [u8]),
|
||||
Stderr(&'a [u8]),
|
||||
}
|
||||
|
||||
/// A running remote command.
|
||||
pub struct RemoteProcess {
|
||||
write: Arc<ChannelWriteHalf<Msg>>,
|
||||
exit: Mutex<Option<oneshot::Receiver<Option<u32>>>>,
|
||||
}
|
||||
|
||||
impl RemoteProcess {
|
||||
pub async fn write(&self, data: &[u8]) -> IdeResult<()> {
|
||||
self.write.data(data).await.map_err(|e| io(e.to_string()))
|
||||
}
|
||||
|
||||
/// Closes stdin.
|
||||
pub async fn eof(&self) -> IdeResult<()> {
|
||||
self.write.eof().await.map_err(|e| io(e.to_string()))
|
||||
}
|
||||
|
||||
pub async fn resize(&self, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
self.write.window_change(u32::from(cols), u32::from(rows), 0, 0).await.map_err(|e| io(e.to_string()))
|
||||
}
|
||||
|
||||
/// Sends `INT` / `TERM` / `KILL`; servers that ignore signals get the channel closed.
|
||||
pub async fn signal(&self, signal: Sig) -> IdeResult<()> {
|
||||
let kill = matches!(signal, Sig::KILL);
|
||||
let r = self.write.signal(signal).await;
|
||||
if kill || r.is_err() {
|
||||
let _ = self.write.close().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Waits for the exit status (`None`: killed, or no status reported). Once.
|
||||
pub async fn wait(&self) -> Option<u32> {
|
||||
let rx = self.exit.lock().take()?;
|
||||
rx.await.ok().flatten()
|
||||
}
|
||||
}
|
||||
|
||||
fn io(message: impl Into<String>) -> IdeError {
|
||||
IdeError::new(ErrorKind::Io, message)
|
||||
}
|
||||
|
||||
// --- connection ----------------------------------------------------------------
|
||||
|
||||
#[derive(Debug)]
|
||||
enum HostKeyIssue {
|
||||
Unknown(PublicKey),
|
||||
Changed(usize),
|
||||
}
|
||||
|
||||
pub struct Client {
|
||||
host: String,
|
||||
port: u16,
|
||||
known_hosts: PathBuf,
|
||||
issue: Arc<Mutex<Option<HostKeyIssue>>>,
|
||||
}
|
||||
|
||||
impl client::Handler for Client {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
|
||||
let key = match key {
|
||||
PublicKeyOrCertificate::PublicKey { key, .. } => key.clone(),
|
||||
PublicKeyOrCertificate::Certificate(cert) => PublicKey::from(cert.public_key().clone()),
|
||||
};
|
||||
let issue = match known_hosts::check_known_hosts_path(&self.host, self.port, &key, &self.known_hosts) {
|
||||
Ok(true) => return Ok(true),
|
||||
Err(keys::Error::KeyChanged { line }) => HostKeyIssue::Changed(line),
|
||||
Ok(false) | Err(_) => HostKeyIssue::Unknown(key),
|
||||
};
|
||||
*self.issue.lock() = Some(issue);
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect<S>(hop: &Hop, known_hosts: &Path, stream: Option<S>) -> Result<Handle<Client>, ConnectError>
|
||||
where
|
||||
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let config = Arc::new(client::Config {
|
||||
inactivity_timeout: None,
|
||||
keepalive_interval: Some(Duration::from_secs(30)),
|
||||
keepalive_max: 3,
|
||||
nodelay: true,
|
||||
..Default::default()
|
||||
});
|
||||
let issue = Arc::new(Mutex::new(None));
|
||||
let handler =
|
||||
Client { host: hop.host.clone(), port: hop.port, known_hosts: known_hosts.to_owned(), issue: issue.clone() };
|
||||
let result = match stream {
|
||||
None => client::connect(config, (hop.host.as_str(), hop.port), handler).await,
|
||||
Some(s) => client::connect_stream(config, s, handler).await,
|
||||
};
|
||||
result.map_err(|e| match issue.lock().take() {
|
||||
Some(HostKeyIssue::Unknown(key)) => {
|
||||
ConnectError::UnknownHostKey(Box::new(UnknownHostKey { host: hop.host.clone(), port: hop.port, key }))
|
||||
}
|
||||
Some(HostKeyIssue::Changed(line)) => ConnectError::Other(IdeError::new(
|
||||
ErrorKind::PermissionDenied,
|
||||
format!(
|
||||
"SSH host key of {}:{} has changed (known_hosts line {line}); possible man-in-the-middle attack",
|
||||
hop.host, hop.port
|
||||
),
|
||||
)),
|
||||
None => ConnectError::Other(io(format!("cannot connect to SSH host {}:{}: {e}", hop.host, hop.port))),
|
||||
})
|
||||
}
|
||||
|
||||
// --- authentication ------------------------------------------------------------
|
||||
|
||||
fn denied(message: String) -> IdeError {
|
||||
IdeError::new(ErrorKind::PermissionDenied, message)
|
||||
}
|
||||
|
||||
async fn rsa_hash(handle: &Handle<Client>) -> Option<HashAlg> {
|
||||
handle.best_supported_rsa_hash().await.ok().flatten().flatten()
|
||||
}
|
||||
|
||||
async fn with_key(handle: &mut Handle<Client>, user: &str, key: keys::PrivateKey) -> IdeResult<bool> {
|
||||
let key = PrivateKeyWithHashAlg::new(Arc::new(key), rsa_hash(handle).await);
|
||||
let r = handle.authenticate_publickey(user, key).await.map_err(|e| io(e.to_string()))?;
|
||||
Ok(r.success())
|
||||
}
|
||||
|
||||
/// Every identity of the running SSH agent, until one is accepted.
|
||||
async fn with_agent(handle: &mut Handle<Client>, user: &str) -> bool {
|
||||
#[cfg(unix)]
|
||||
let agent = keys::agent::client::AgentClient::connect_env().await;
|
||||
#[cfg(windows)]
|
||||
let agent = keys::agent::client::AgentClient::connect_named_pipe(r"\\.\pipe\openssh-ssh-agent").await;
|
||||
let Ok(mut agent) = agent else { return false };
|
||||
let Ok(identities) = agent.request_identities().await else { return false };
|
||||
let hash = rsa_hash(handle).await;
|
||||
for id in identities {
|
||||
let ok = match id {
|
||||
keys::agent::AgentIdentity::PublicKey { key, .. } => {
|
||||
handle.authenticate_publickey_with(user, key, hash, &mut agent).await.map(|r| r.success())
|
||||
}
|
||||
keys::agent::AgentIdentity::Certificate { certificate, .. } => {
|
||||
handle.authenticate_certificate_with(user, certificate, hash, &mut agent).await.map(|r| r.success())
|
||||
}
|
||||
};
|
||||
if ok.unwrap_or(false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn authenticate(
|
||||
handle: &mut Handle<Client>,
|
||||
hop: &Hop,
|
||||
mode: SshAuth,
|
||||
key_file: &str,
|
||||
secret: Option<&str>,
|
||||
) -> IdeResult<()> {
|
||||
let user = hop.user.as_str();
|
||||
let failed = || denied(format!("SSH authentication failed for {}", hop.label()));
|
||||
match mode {
|
||||
SshAuth::Password => {
|
||||
let Some(password) = secret else {
|
||||
return Err(denied(format!("SSH password required for {}", hop.label())));
|
||||
};
|
||||
let r = handle.authenticate_password(user, password).await.map_err(|e| io(e.to_string()))?;
|
||||
if r.success() { Ok(()) } else { Err(failed()) }
|
||||
}
|
||||
SshAuth::KeyPair => {
|
||||
if key_file.trim().is_empty() {
|
||||
return Err(IdeError::invalid("SSH private key file is not set"));
|
||||
}
|
||||
let path = expand(key_file.trim());
|
||||
let key = load_key(&path, secret)?;
|
||||
if with_key(handle, user, key).await? { Ok(()) } else { Err(failed()) }
|
||||
}
|
||||
SshAuth::OpenSsh => {
|
||||
if with_agent(handle, user).await {
|
||||
return Ok(());
|
||||
}
|
||||
let files: Vec<PathBuf> = if hop.identity_files.is_empty() {
|
||||
home().map(|h| DEFAULT_IDENTITIES.iter().map(|n| h.join(".ssh").join(n)).collect()).unwrap_or_default()
|
||||
} else {
|
||||
hop.identity_files.iter().map(|p| expand(&p.to_string_lossy())).collect()
|
||||
};
|
||||
let mut locked: Option<PathBuf> = None;
|
||||
for path in files.iter().filter(|p| p.is_file()) {
|
||||
match load_key(path, secret) {
|
||||
Ok(key) => {
|
||||
if with_key(handle, user, key).await? {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Err(e) if e.kind == ErrorKind::PermissionDenied => locked = locked.or(Some(path.clone())),
|
||||
Err(e) => tracing::warn!("SSH key {}: {}", path.display(), e.message),
|
||||
}
|
||||
}
|
||||
match locked {
|
||||
// An encrypted key could not be tried: ask for its passphrase.
|
||||
Some(path) if secret.is_none() => {
|
||||
Err(denied(format!("SSH key passphrase required for {}", path.display())))
|
||||
}
|
||||
_ => Err(failed()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads a private key; an encrypted key without (or with a wrong) passphrase is `PermissionDenied`.
|
||||
fn load_key(path: &Path, passphrase: Option<&str>) -> IdeResult<keys::PrivateKey> {
|
||||
match keys::load_secret_key(path, passphrase) {
|
||||
Ok(k) => Ok(k),
|
||||
Err(keys::Error::KeyIsEncrypted) => Err(denied(format!("SSH key passphrase required for {}", path.display()))),
|
||||
Err(e) if passphrase.is_some() => {
|
||||
Err(denied(format!("cannot decrypt SSH key {} (wrong passphrase?): {e}", path.display())))
|
||||
}
|
||||
Err(e) => Err(IdeError::invalid(format!("cannot read SSH key {}: {e}", path.display()))),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
//! Remote files over SFTP. Remote paths are POSIX strings.
|
||||
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use russh_sftp::client::SftpSession;
|
||||
use russh_sftp::client::error::Error as SftpError;
|
||||
use russh_sftp::protocol::{FileAttributes, StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
/// A remote directory entry.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteEntry {
|
||||
pub name: String,
|
||||
/// Absolute remote path.
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
pub is_symlink: bool,
|
||||
pub size: u64,
|
||||
/// Seconds since the Unix epoch.
|
||||
pub mtime: Option<u32>,
|
||||
/// `rwxr-xr-x`.
|
||||
pub permissions: String,
|
||||
}
|
||||
|
||||
/// `dir/name` without doubling the separator.
|
||||
pub fn join(dir: &str, name: &str) -> String {
|
||||
if name.starts_with('/') {
|
||||
return name.to_owned();
|
||||
}
|
||||
let dir = dir.trim_end_matches('/');
|
||||
if dir.is_empty() && !name.is_empty() { format!("/{name}") } else { format!("{dir}/{name}") }
|
||||
}
|
||||
|
||||
/// The parent of a remote path (`/` for top-level entries).
|
||||
pub fn parent(path: &str) -> Option<&str> {
|
||||
let trimmed = path.trim_end_matches('/');
|
||||
let i = trimmed.rfind('/')?;
|
||||
Some(if i == 0 { "/" } else { &trimmed[..i] })
|
||||
}
|
||||
|
||||
fn mode_string(mode: u32) -> String {
|
||||
let bits = [
|
||||
(0o400, 'r'),
|
||||
(0o200, 'w'),
|
||||
(0o100, 'x'),
|
||||
(0o40, 'r'),
|
||||
(0o20, 'w'),
|
||||
(0o10, 'x'),
|
||||
(0o4, 'r'),
|
||||
(0o2, 'w'),
|
||||
(0o1, 'x'),
|
||||
];
|
||||
bits.iter().map(|&(b, c)| if mode & b != 0 { c } else { '-' }).collect()
|
||||
}
|
||||
|
||||
fn entry(path: String, name: String, attrs: &FileAttributes, target_is_dir: bool) -> RemoteEntry {
|
||||
RemoteEntry {
|
||||
name,
|
||||
path,
|
||||
is_dir: attrs.is_dir() || target_is_dir,
|
||||
is_symlink: attrs.is_symlink(),
|
||||
size: attrs.size.unwrap_or(0),
|
||||
mtime: attrs.mtime,
|
||||
permissions: attrs.permissions.map(mode_string).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
fn err(path: &str, e: SftpError) -> IdeError {
|
||||
let kind = match &e {
|
||||
SftpError::Status(s) if s.status_code == StatusCode::NoSuchFile => ErrorKind::NotFound,
|
||||
SftpError::Status(s) if s.status_code == StatusCode::PermissionDenied => ErrorKind::PermissionDenied,
|
||||
_ => ErrorKind::Io,
|
||||
};
|
||||
IdeError::new(kind, format!("{path}: {e}"))
|
||||
}
|
||||
|
||||
/// Higher-level operations on an [`SftpSession`].
|
||||
pub struct RemoteFs {
|
||||
sftp: SftpSession,
|
||||
}
|
||||
|
||||
impl RemoteFs {
|
||||
pub fn new(sftp: SftpSession) -> Self {
|
||||
Self { sftp }
|
||||
}
|
||||
|
||||
/// The login directory.
|
||||
pub async fn home(&self) -> IdeResult<String> {
|
||||
self.sftp.canonicalize(".").await.map_err(|e| err(".", e))
|
||||
}
|
||||
|
||||
/// Absolute form of `path`; `~` and `~/…` are relative to the login directory.
|
||||
pub async fn resolve(&self, path: &str) -> IdeResult<String> {
|
||||
let path = path.trim();
|
||||
if path.is_empty() || path == "~" {
|
||||
return self.home().await;
|
||||
}
|
||||
if let Some(rest) = path.strip_prefix("~/") {
|
||||
return Ok(join(&self.home().await?, rest));
|
||||
}
|
||||
if path.starts_with('/') {
|
||||
return Ok(path.to_owned());
|
||||
}
|
||||
Ok(join(&self.home().await?, path))
|
||||
}
|
||||
|
||||
pub async fn stat(&self, path: &str) -> IdeResult<Option<RemoteEntry>> {
|
||||
match self.sftp.metadata(path).await {
|
||||
Ok(a) => {
|
||||
let name = path.rsplit('/').next().unwrap_or(path).to_owned();
|
||||
Ok(Some(entry(path.to_owned(), name, &a, false)))
|
||||
}
|
||||
Err(e) if err(path, e.clone()).kind == ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(err(path, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Entries of `dir`, folders first, then by name; symlinks to folders count as folders.
|
||||
pub async fn list(&self, dir: &str) -> IdeResult<Vec<RemoteEntry>> {
|
||||
let read = self.sftp.read_dir(dir).await.map_err(|e| err(dir, e))?;
|
||||
let mut out = Vec::new();
|
||||
for e in read {
|
||||
let name = e.file_name();
|
||||
// A name with `/` (from a hostile server) would point elsewhere once joined.
|
||||
if name == "." || name == ".." || name.contains('/') {
|
||||
continue;
|
||||
}
|
||||
let path = join(dir, &name);
|
||||
let attrs = e.metadata();
|
||||
let target_is_dir =
|
||||
attrs.is_symlink() && self.sftp.metadata(path.as_str()).await.map(|m| m.is_dir()).unwrap_or(false);
|
||||
out.push(entry(path, name, &attrs, target_is_dir));
|
||||
}
|
||||
out.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub async fn read(&self, path: &str) -> IdeResult<Vec<u8>> {
|
||||
let mut file = self.sftp.open(path).await.map_err(|e| err(path, e))?;
|
||||
let mut buf = Vec::new();
|
||||
file.read_to_end(&mut buf).await?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Writes `data`, creating missing parent folders.
|
||||
pub async fn write(&self, path: &str, data: &[u8], mtime: Option<u32>) -> IdeResult<()> {
|
||||
if let Some(dir) = parent(path) {
|
||||
self.mkdir_all(dir).await?;
|
||||
}
|
||||
let mut file = self.sftp.create(path).await.map_err(|e| err(path, e))?;
|
||||
file.write_all(data).await?;
|
||||
file.shutdown().await?;
|
||||
if let Some(t) = mtime {
|
||||
let attrs = FileAttributes { atime: Some(t), mtime: Some(t), ..FileAttributes::empty() };
|
||||
if let Err(e) = self.sftp.set_metadata(path, attrs).await {
|
||||
tracing::debug!("SFTP: cannot set the time of {path}: {e}");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn upload(&self, local: &Path, remote: &str, preserve_time: bool) -> IdeResult<()> {
|
||||
let data = tokio::fs::read(local).await?;
|
||||
let mtime = if preserve_time { mtime_of(local) } else { None };
|
||||
self.write(remote, &data, mtime).await
|
||||
}
|
||||
|
||||
pub async fn download(&self, remote: &str, local: &Path) -> IdeResult<()> {
|
||||
let data = self.read(remote).await?;
|
||||
if let Some(dir) = local.parent() {
|
||||
tokio::fs::create_dir_all(dir).await?;
|
||||
}
|
||||
tokio::fs::write(local, data).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `mkdir -p`.
|
||||
pub async fn mkdir_all(&self, dir: &str) -> IdeResult<()> {
|
||||
if dir.is_empty() || dir == "/" {
|
||||
return Ok(());
|
||||
}
|
||||
match self.sftp.metadata(dir).await {
|
||||
Ok(a) if a.is_dir() => return Ok(()),
|
||||
Ok(_) => return Err(IdeError::new(ErrorKind::AlreadyExists, format!("{dir} is not a folder"))),
|
||||
Err(_) => {}
|
||||
}
|
||||
if let Some(p) = parent(dir) {
|
||||
Box::pin(self.mkdir_all(p)).await?;
|
||||
}
|
||||
match self.sftp.create_dir(dir).await {
|
||||
Ok(()) => Ok(()),
|
||||
// Created meanwhile by a concurrent upload.
|
||||
Err(_) if self.sftp.metadata(dir).await.is_ok_and(|a| a.is_dir()) => Ok(()),
|
||||
Err(e) => Err(err(dir, e)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes a file, or a folder with its contents.
|
||||
pub async fn remove(&self, path: &str) -> IdeResult<()> {
|
||||
let attrs = self.sftp.symlink_metadata(path).await.map_err(|e| err(path, e))?;
|
||||
if attrs.is_dir() {
|
||||
for e in self.list(path).await? {
|
||||
if e.is_dir && !e.is_symlink {
|
||||
Box::pin(self.remove(&e.path)).await?;
|
||||
} else {
|
||||
self.sftp.remove_file(e.path.as_str()).await.map_err(|x| err(&e.path, x))?;
|
||||
}
|
||||
}
|
||||
self.sftp.remove_dir(path).await.map_err(|e| err(path, e))
|
||||
} else {
|
||||
self.sftp.remove_file(path).await.map_err(|e| err(path, e))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn rename(&self, from: &str, to: &str) -> IdeResult<()> {
|
||||
self.sftp.rename(from, to).await.map_err(|e| err(from, e))
|
||||
}
|
||||
|
||||
pub async fn close(&self) {
|
||||
let _ = self.sftp.close().await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Seconds since the epoch of a local file's modification.
|
||||
pub fn mtime_of(path: &Path) -> Option<u32> {
|
||||
let t = std::fs::metadata(path).ok()?.modified().ok()?;
|
||||
u32::try_from(t.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs()).ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn joins_and_splits_paths() {
|
||||
assert_eq!(join("/srv/app/", "a.txt"), "/srv/app/a.txt");
|
||||
assert_eq!(join("/", "etc"), "/etc");
|
||||
assert_eq!(join("/x", "/abs"), "/abs");
|
||||
assert_eq!(parent("/srv/app/a.txt"), Some("/srv/app"));
|
||||
assert_eq!(parent("/etc"), Some("/"));
|
||||
assert_eq!(parent("rel"), None);
|
||||
assert_eq!(mode_string(0o755), "rwxr-xr-x");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! POSIX shell command lines for the remote side.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Quotes `s` for a POSIX shell (`'…'`, with `'\''` for quotes inside).
|
||||
pub fn quote(s: &str) -> String {
|
||||
if !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_./=:,+@%".contains(&b)) {
|
||||
return s.to_owned();
|
||||
}
|
||||
format!("'{}'", s.replace('\'', r"'\''"))
|
||||
}
|
||||
|
||||
/// `cd <cwd> && exec env K=V … <program> <args…>`. A program starting with `~/`
|
||||
/// is left to the shell's tilde expansion.
|
||||
pub fn command_line(cwd: Option<&str>, env: &BTreeMap<String, String>, program: &str, args: &[String]) -> String {
|
||||
let mut s = String::new();
|
||||
if let Some(dir) = cwd.filter(|d| !d.is_empty()) {
|
||||
s.push_str("cd ");
|
||||
s.push_str(&tilde_quote(dir));
|
||||
s.push_str(" && ");
|
||||
}
|
||||
s.push_str("exec ");
|
||||
if !env.is_empty() {
|
||||
s.push_str("env");
|
||||
for (k, v) in env {
|
||||
s.push(' ');
|
||||
s.push_str("e(&format!("{k}={v}")));
|
||||
}
|
||||
s.push(' ');
|
||||
}
|
||||
s.push_str(&tilde_quote(program));
|
||||
for a in args {
|
||||
s.push(' ');
|
||||
s.push_str("e(a));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// [`quote`], except that a leading `~/` stays open to tilde expansion.
|
||||
pub fn tilde_quote(path: &str) -> String {
|
||||
match path.strip_prefix("~/") {
|
||||
Some(rest) => format!("~/{}", quote(rest)),
|
||||
None if path == "~" => "~".into(),
|
||||
None => quote(path),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn quotes_only_when_needed() {
|
||||
assert_eq!(quote("plain/path-1.txt"), "plain/path-1.txt");
|
||||
assert_eq!(quote("a b"), "'a b'");
|
||||
assert_eq!(quote("it's"), r"'it'\''s'");
|
||||
assert_eq!(quote(""), "''");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_a_command_line() {
|
||||
let env = BTreeMap::from([("A".to_owned(), "1 2".to_owned())]);
|
||||
assert_eq!(
|
||||
command_line(Some("~/proj dir"), &env, "python3", &["-u".into(), "main.py".into()]),
|
||||
"cd ~/'proj dir' && exec env 'A=1 2' python3 -u main.py"
|
||||
);
|
||||
assert_eq!(command_line(None, &BTreeMap::new(), "ls", &[]), "exec ls");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Where to connect: an [`SshTarget`] and the hosts to go through to reach it
|
||||
//! (`~/.ssh/config` aliases and `ProxyJump` chains).
|
||||
|
||||
use ide_core::remote_settings::{SshAuth, SshConfiguration};
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// An SSH host and how to authenticate to it.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct SshTarget {
|
||||
/// Host name, or a `Host` alias of `~/.ssh/config` with [`SshAuth::OpenSsh`].
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
pub user: String,
|
||||
pub auth: SshAuth,
|
||||
/// Private key for [`SshAuth::KeyPair`]; `~/` is the home directory.
|
||||
pub key_file: String,
|
||||
}
|
||||
|
||||
impl From<&SshConfiguration> for SshTarget {
|
||||
fn from(c: &SshConfiguration) -> Self {
|
||||
Self { host: c.host.clone(), port: c.port, user: c.user.clone(), auth: c.auth, key_file: c.key_file.clone() }
|
||||
}
|
||||
}
|
||||
|
||||
/// The user's OpenSSH files.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SshFiles {
|
||||
pub known_hosts: PathBuf,
|
||||
/// `~/.ssh/config` (used with [`SshAuth::OpenSsh`]).
|
||||
pub config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for SshFiles {
|
||||
fn default() -> Self {
|
||||
let dir = home().unwrap_or_default().join(".ssh");
|
||||
Self { known_hosts: dir.join("known_hosts"), config: Some(dir.join("config")) }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct Hop {
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub user: String,
|
||||
pub identity_files: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
impl Hop {
|
||||
pub fn label(&self) -> String {
|
||||
format!("{}@{}:{}", self.user, self.host, self.port)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn home() -> Option<PathBuf> {
|
||||
dirs::home_dir()
|
||||
}
|
||||
|
||||
/// `~/x` → `$HOME/x`.
|
||||
pub fn expand(path: &str) -> PathBuf {
|
||||
match (path.strip_prefix("~/"), home()) {
|
||||
(Some(rest), Some(h)) => h.join(rest),
|
||||
_ => PathBuf::from(path),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_user() -> String {
|
||||
std::env::var("USER").or_else(|_| std::env::var("USERNAME")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One `~/.ssh/config` lookup (a missing or unreadable file means defaults).
|
||||
fn config_hop(config: Option<&Path>, alias: &str) -> (Hop, Option<String>) {
|
||||
let c = config
|
||||
.and_then(|p| russh_config::parse_path(p, alias).ok())
|
||||
.unwrap_or_else(|| russh_config::Config::default(alias));
|
||||
let identity_files = c.host_config.identity_file.clone().unwrap_or_default();
|
||||
let hop = Hop { host: c.host().to_owned(), port: c.port(), user: c.user(), identity_files };
|
||||
(hop, c.host_config.proxy_jump.clone())
|
||||
}
|
||||
|
||||
/// `[user@]host[:port]` of a `ProxyJump` entry.
|
||||
fn jump_hop(config: Option<&Path>, spec: &str) -> Hop {
|
||||
let (user, rest) = match spec.rsplit_once('@') {
|
||||
Some((u, r)) => (Some(u), r),
|
||||
None => (None, spec),
|
||||
};
|
||||
let (host, port) = match rest.rsplit_once(':') {
|
||||
Some((h, p)) if p.parse::<u16>().is_ok() => (h, p.parse().ok()),
|
||||
_ => (rest, None),
|
||||
};
|
||||
let (mut hop, _) = config_hop(config, host);
|
||||
if let Some(u) = user {
|
||||
hop.user = u.to_owned();
|
||||
}
|
||||
if let Some(p) = port {
|
||||
hop.port = p;
|
||||
}
|
||||
hop
|
||||
}
|
||||
|
||||
/// The SSH hosts to go through, the target last.
|
||||
pub(crate) fn resolve_hops(target: &SshTarget, config: Option<&Path>) -> IdeResult<Vec<Hop>> {
|
||||
let host = target.host.trim();
|
||||
if host.is_empty() {
|
||||
return Err(IdeError::invalid("SSH host is empty"));
|
||||
}
|
||||
let (mut hop, proxy_jump) = if target.auth == SshAuth::OpenSsh {
|
||||
config_hop(config, host)
|
||||
} else {
|
||||
(Hop { host: host.to_owned(), port: 22, user: local_user(), identity_files: Vec::new() }, None)
|
||||
};
|
||||
if let Some(p) = target.port {
|
||||
hop.port = p;
|
||||
}
|
||||
if !target.user.trim().is_empty() {
|
||||
hop.user = target.user.trim().to_owned();
|
||||
}
|
||||
let mut hops: Vec<Hop> = proxy_jump
|
||||
.filter(|j| !j.eq_ignore_ascii_case("none"))
|
||||
.map(|j| j.split(',').map(str::trim).filter(|s| !s.is_empty()).map(|s| jump_hop(config, s)).collect())
|
||||
.unwrap_or_default();
|
||||
hops.push(hop);
|
||||
Ok(hops)
|
||||
}
|
||||
|
||||
/// `ssh` arguments that reach `target` (the system client: terminals, git).
|
||||
pub fn openssh_args(target: &SshTarget) -> Vec<String> {
|
||||
let mut args = Vec::new();
|
||||
if let Some(p) = target.port {
|
||||
args.extend(["-p".into(), p.to_string()]);
|
||||
}
|
||||
if target.auth == SshAuth::KeyPair && !target.key_file.trim().is_empty() {
|
||||
args.extend(["-i".into(), expand(target.key_file.trim()).to_string_lossy().into_owned()]);
|
||||
args.extend(["-o".into(), "IdentitiesOnly=yes".into()]);
|
||||
}
|
||||
if target.auth == SshAuth::Password {
|
||||
args.extend(["-o".into(), "PreferredAuthentications=password,keyboard-interactive".into()]);
|
||||
}
|
||||
let host = target.host.trim();
|
||||
// A host starting with `-` must not be read as an option.
|
||||
args.push("--".into());
|
||||
args.push(if target.user.trim().is_empty() { host.to_owned() } else { format!("{}@{host}", target.user.trim()) });
|
||||
args
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_aliases_and_proxy_jumps_from_ssh_config() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let config = tmp.path().join("config");
|
||||
std::fs::write(
|
||||
&config,
|
||||
"Host db-bastion\n HostName 10.0.0.5\n User ops\n Port 2222\n ProxyJump [email protected]:2200,edge\n IdentityFile /keys/id_bastion\n\nHost edge\n HostName edge.example.com\n User edgeuser\n",
|
||||
)
|
||||
.unwrap();
|
||||
let target = SshTarget { host: "db-bastion".into(), auth: SshAuth::OpenSsh, ..Default::default() };
|
||||
let hops = resolve_hops(&target, Some(&config)).unwrap();
|
||||
let labels: Vec<String> = hops.iter().map(Hop::label).collect();
|
||||
assert_eq!(labels, ["[email protected]:2200", "[email protected]:22", "[email protected]:2222"]);
|
||||
assert_eq!(hops[2].identity_files, [PathBuf::from("/keys/id_bastion")]);
|
||||
|
||||
// Explicit fields win over the config.
|
||||
let target = SshTarget { user: "me".into(), port: Some(22), ..target };
|
||||
assert_eq!(resolve_hops(&target, Some(&config)).unwrap()[2].label(), "[email protected]:22");
|
||||
|
||||
// Without OpenSSH mode the host is used as is.
|
||||
let target = SshTarget { auth: SshAuth::Password, user: "u".into(), port: None, ..target };
|
||||
let hops = resolve_hops(&target, Some(&config)).unwrap();
|
||||
assert_eq!(hops.iter().map(Hop::label).collect::<Vec<_>>(), ["u@db-bastion:22"]);
|
||||
assert!(resolve_hops(&SshTarget::default(), None).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builds_openssh_arguments() {
|
||||
let t = SshTarget { host: "box".into(), user: "me".into(), port: Some(2200), ..Default::default() };
|
||||
assert_eq!(openssh_args(&t), ["-p", "2200", "--", "me@box"]);
|
||||
let t =
|
||||
SshTarget { host: "box".into(), auth: SshAuth::KeyPair, key_file: "/k/id".into(), ..Default::default() };
|
||||
assert_eq!(openssh_args(&t), ["-i", "/k/id", "-o", "IdentitiesOnly=yes", "--", "box"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! An in-process SSH server for tests (feature `testing`): password `dev` /
|
||||
//! `secret`, commands answered by a closure, and SFTP served from a local
|
||||
//! folder (remote `/x` is `<root>/x`).
|
||||
|
||||
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session};
|
||||
use russh::{Channel, ChannelId};
|
||||
use russh_sftp::protocol::{Attrs, Data, File, FileAttributes, Handle, Name, OpenFlags, Status, StatusCode, Version};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Reply of a command: (stdout, exit status).
|
||||
pub type ExecFn = Arc<dyn Fn(&str) -> (String, u32) + Send + Sync>;
|
||||
|
||||
/// Starts the server; returns its port. `root` backs SFTP.
|
||||
pub async fn sshd(root: PathBuf, exec: ExecFn) -> u16 {
|
||||
let config = Arc::new(server::Config {
|
||||
keys: vec![russh::keys::decode_secret_key(include_str!("../tests/fixtures/ssh/host"), None).expect("host key")],
|
||||
auth_rejection_time: std::time::Duration::from_millis(10),
|
||||
auth_rejection_time_initial: Some(std::time::Duration::ZERO),
|
||||
..Default::default()
|
||||
});
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind");
|
||||
let port = listener.local_addr().expect("addr").port();
|
||||
let mut server = Sshd { root, exec };
|
||||
tokio::spawn(async move {
|
||||
let _ = server.run_on_socket(config, &listener).await;
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Sshd {
|
||||
root: PathBuf,
|
||||
exec: ExecFn,
|
||||
}
|
||||
|
||||
impl server::Server for Sshd {
|
||||
type Handler = Conn;
|
||||
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Conn {
|
||||
Conn { root: self.root.clone(), exec: self.exec.clone(), channels: Arc::default() }
|
||||
}
|
||||
}
|
||||
|
||||
struct Conn {
|
||||
root: PathBuf,
|
||||
exec: ExecFn,
|
||||
channels: Arc<Mutex<HashMap<ChannelId, Channel<Msg>>>>,
|
||||
}
|
||||
|
||||
impl server::Handler for Conn {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn channel_open_session(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
reply: ChannelOpenHandle,
|
||||
_session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
self.channels.lock().await.insert(channel.id(), channel);
|
||||
reply.accept().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn exec_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
data: &[u8],
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
let (out, code) = (self.exec)(&String::from_utf8_lossy(data));
|
||||
session.channel_success(channel)?;
|
||||
session.data(channel, out.into_bytes())?;
|
||||
session.exit_status_request(channel, code)?;
|
||||
session.eof(channel)?;
|
||||
session.close(channel)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn subsystem_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
name: &str,
|
||||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
let Some(ch) = self.channels.lock().await.remove(&channel).filter(|_| name == "sftp") else {
|
||||
session.channel_failure(channel)?;
|
||||
return Ok(());
|
||||
};
|
||||
session.channel_success(channel)?;
|
||||
let sftp = Sftp { root: self.root.clone(), files: HashMap::new(), dirs: HashMap::new(), next: 0 };
|
||||
tokio::spawn(russh_sftp::server::run(ch.into_stream(), sftp));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct Sftp {
|
||||
root: PathBuf,
|
||||
files: HashMap<String, std::fs::File>,
|
||||
/// Open directories: entries not yet sent.
|
||||
dirs: HashMap<String, Option<Vec<File>>>,
|
||||
next: u64,
|
||||
}
|
||||
|
||||
fn ok(id: u32) -> Status {
|
||||
Status { id, status_code: StatusCode::Ok, error_message: "Ok".into(), language_tag: "en-US".into() }
|
||||
}
|
||||
|
||||
fn code(e: std::io::Error) -> StatusCode {
|
||||
match e.kind() {
|
||||
std::io::ErrorKind::NotFound => StatusCode::NoSuchFile,
|
||||
std::io::ErrorKind::PermissionDenied => StatusCode::PermissionDenied,
|
||||
_ => StatusCode::Failure,
|
||||
}
|
||||
}
|
||||
|
||||
impl Sftp {
|
||||
fn local(&self, path: &str) -> PathBuf {
|
||||
path.split('/').filter(|s| !s.is_empty() && *s != "." && *s != "..").fold(self.root.clone(), |p, s| p.join(s))
|
||||
}
|
||||
|
||||
fn handle(&mut self) -> String {
|
||||
self.next += 1;
|
||||
format!("h{}", self.next)
|
||||
}
|
||||
|
||||
fn attrs(path: &Path) -> Result<FileAttributes, StatusCode> {
|
||||
std::fs::symlink_metadata(path).map(|m| FileAttributes::from(&m)).map_err(code)
|
||||
}
|
||||
}
|
||||
|
||||
impl russh_sftp::server::Handler for Sftp {
|
||||
type Error = StatusCode;
|
||||
|
||||
fn unimplemented(&self) -> Self::Error {
|
||||
StatusCode::OpUnsupported
|
||||
}
|
||||
|
||||
async fn init(&mut self, _version: u32, _ext: HashMap<String, String>) -> Result<Version, Self::Error> {
|
||||
Ok(Version::new())
|
||||
}
|
||||
|
||||
async fn realpath(&mut self, id: u32, path: String) -> Result<Name, Self::Error> {
|
||||
let p = if path == "." || path.is_empty() { "/home/dev".to_owned() } else { path };
|
||||
Ok(Name { id, files: vec![File::dummy(p)] })
|
||||
}
|
||||
|
||||
async fn open(
|
||||
&mut self,
|
||||
id: u32,
|
||||
filename: String,
|
||||
pflags: OpenFlags,
|
||||
_attrs: FileAttributes,
|
||||
) -> Result<Handle, Self::Error> {
|
||||
let path = self.local(&filename);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(pflags.contains(OpenFlags::READ))
|
||||
.write(pflags.contains(OpenFlags::WRITE))
|
||||
.create(pflags.contains(OpenFlags::CREATE))
|
||||
.truncate(pflags.contains(OpenFlags::TRUNCATE))
|
||||
.open(path)
|
||||
.map_err(code)?;
|
||||
let handle = self.handle();
|
||||
self.files.insert(handle.clone(), file);
|
||||
Ok(Handle { id, handle })
|
||||
}
|
||||
|
||||
async fn close(&mut self, id: u32, handle: String) -> Result<Status, Self::Error> {
|
||||
self.files.remove(&handle);
|
||||
self.dirs.remove(&handle);
|
||||
Ok(ok(id))
|
||||
}
|
||||
|
||||
async fn read(&mut self, id: u32, handle: String, offset: u64, len: u32) -> Result<Data, Self::Error> {
|
||||
let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?;
|
||||
file.seek(SeekFrom::Start(offset)).map_err(code)?;
|
||||
let mut buf = vec![0; len as usize];
|
||||
let n = file.read(&mut buf).map_err(code)?;
|
||||
if n == 0 {
|
||||
return Err(StatusCode::Eof);
|
||||
}
|
||||
buf.truncate(n);
|
||||
Ok(Data { id, data: buf })
|
||||
}
|
||||
|
||||
async fn write(&mut self, id: u32, handle: String, offset: u64, data: Vec<u8>) -> Result<Status, Self::Error> {
|
||||
let file = self.files.get_mut(&handle).ok_or(StatusCode::Failure)?;
|
||||
file.seek(SeekFrom::Start(offset)).map_err(code)?;
|
||||
file.write_all(&data).map_err(code)?;
|
||||
Ok(ok(id))
|
||||
}
|
||||
|
||||
async fn stat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
|
||||
let p = self.local(&path);
|
||||
let attrs = std::fs::metadata(&p).map(|m| FileAttributes::from(&m)).map_err(code)?;
|
||||
Ok(Attrs { id, attrs })
|
||||
}
|
||||
|
||||
async fn lstat(&mut self, id: u32, path: String) -> Result<Attrs, Self::Error> {
|
||||
Ok(Attrs { id, attrs: Self::attrs(&self.local(&path))? })
|
||||
}
|
||||
|
||||
async fn fstat(&mut self, id: u32, handle: String) -> Result<Attrs, Self::Error> {
|
||||
let file = self.files.get(&handle).ok_or(StatusCode::Failure)?;
|
||||
Ok(Attrs { id, attrs: FileAttributes::from(&file.metadata().map_err(code)?) })
|
||||
}
|
||||
|
||||
async fn setstat(&mut self, id: u32, path: String, attrs: FileAttributes) -> Result<Status, Self::Error> {
|
||||
if let Some(t) = attrs.mtime {
|
||||
let time = std::time::UNIX_EPOCH + std::time::Duration::from_secs(t.into());
|
||||
let f = std::fs::File::options().write(true).open(self.local(&path)).map_err(code)?;
|
||||
f.set_modified(time).map_err(code)?;
|
||||
}
|
||||
Ok(ok(id))
|
||||
}
|
||||
|
||||
async fn opendir(&mut self, id: u32, path: String) -> Result<Handle, Self::Error> {
|
||||
let dir = self.local(&path);
|
||||
let mut files = Vec::new();
|
||||
for e in std::fs::read_dir(&dir).map_err(code)?.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
files.push(File::new(name, Self::attrs(&e.path())?));
|
||||
}
|
||||
let handle = self.handle();
|
||||
self.dirs.insert(handle.clone(), Some(files));
|
||||
Ok(Handle { id, handle })
|
||||
}
|
||||
|
||||
async fn readdir(&mut self, id: u32, handle: String) -> Result<Name, Self::Error> {
|
||||
match self.dirs.get_mut(&handle).and_then(Option::take) {
|
||||
Some(files) if !files.is_empty() => Ok(Name { id, files }),
|
||||
_ => Err(StatusCode::Eof),
|
||||
}
|
||||
}
|
||||
|
||||
async fn remove(&mut self, id: u32, filename: String) -> Result<Status, Self::Error> {
|
||||
std::fs::remove_file(self.local(&filename)).map_err(code)?;
|
||||
Ok(ok(id))
|
||||
}
|
||||
|
||||
async fn mkdir(&mut self, id: u32, path: String, _attrs: FileAttributes) -> Result<Status, Self::Error> {
|
||||
std::fs::create_dir(self.local(&path)).map_err(code)?;
|
||||
Ok(ok(id))
|
||||
}
|
||||
|
||||
async fn rmdir(&mut self, id: u32, path: String) -> Result<Status, Self::Error> {
|
||||
std::fs::remove_dir(self.local(&path)).map_err(code)?;
|
||||
Ok(ok(id))
|
||||
}
|
||||
|
||||
async fn rename(&mut self, id: u32, oldpath: String, newpath: String) -> Result<Status, Self::Error> {
|
||||
std::fs::rename(self.local(&oldpath), self.local(&newpath)).map_err(code)?;
|
||||
Ok(ok(id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Local port forwarding: a listener on 127.0.0.1 whose connections are
|
||||
//! forwarded through `direct-tcpip` channels of a [`Session`].
|
||||
|
||||
use crate::session::Session;
|
||||
use ide_core::{IdeError, IdeResult, error::ErrorKind};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
/// An open tunnel; dropping it stops forwarding (the session stays open while
|
||||
/// other users hold it).
|
||||
pub struct Tunnel {
|
||||
port: u16,
|
||||
session: Arc<Session>,
|
||||
accept: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Tunnel {
|
||||
/// Checks that the SSH host reaches `host:port`, then listens on a free local port.
|
||||
pub async fn open(session: Arc<Session>, host: &str, port: u16) -> IdeResult<Tunnel> {
|
||||
// Fail fast with the real reason when the SSH host cannot reach the target
|
||||
// (a refused forward otherwise surfaces as the client's connect timeout).
|
||||
session.direct_tcpip(host, port).await.map_err(|e| unreachable_target(session.label(), host, port, &e))?;
|
||||
Self::listen(session, host, port).await
|
||||
}
|
||||
|
||||
/// [`Tunnel::open`] without the reachability check (the target may start later).
|
||||
pub async fn listen(session: Arc<Session>, host: &str, port: u16) -> IdeResult<Tunnel> {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
|
||||
let local = listener.local_addr()?.port();
|
||||
let accept = tokio::spawn(forward(listener, session.clone(), host.to_owned(), port));
|
||||
Ok(Tunnel { port: local, session, accept })
|
||||
}
|
||||
|
||||
/// The local port to connect to instead of the target.
|
||||
pub fn port(&self) -> u16 {
|
||||
self.port
|
||||
}
|
||||
|
||||
pub fn is_closed(&self) -> bool {
|
||||
self.session.is_closed()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tunnel {
|
||||
fn drop(&mut self) {
|
||||
self.accept.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward(listener: TcpListener, session: Arc<Session>, host: String, port: u16) {
|
||||
loop {
|
||||
let Ok((mut socket, _)) = listener.accept().await else { return };
|
||||
let session = session.clone();
|
||||
let host = host.clone();
|
||||
tokio::spawn(async move {
|
||||
match session.direct_tcpip(&host, port).await {
|
||||
Ok(mut stream) => {
|
||||
let _ = tokio::io::copy_bidirectional(&mut socket, &mut stream).await;
|
||||
}
|
||||
Err(e) => tracing::warn!("SSH tunnel: cannot open a channel to {host}:{port}: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Why the SSH host `via` cannot open a channel to `host:port`.
|
||||
pub fn unreachable_target(via: &str, host: &str, port: u16, e: &russh::Error) -> IdeError {
|
||||
let why = match e {
|
||||
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::ConnectFailed) => {
|
||||
"connection refused or host unreachable"
|
||||
}
|
||||
russh::Error::ChannelOpenFailure(russh::ChannelOpenFailure::AdministrativelyProhibited) => {
|
||||
"port forwarding is not allowed by the SSH server (AllowTcpForwarding)"
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
let why = if why.is_empty() { e.to_string() } else { why.to_owned() };
|
||||
IdeError::new(ErrorKind::Io, format!("SSH host {via} cannot reach {host}:{port}: {why}"))
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,256 @@
|
||||
//! Sessions against an in-process russh server that runs commands (a canned
|
||||
//! reply) and forwards `direct-tcpip` channels to a local echo server.
|
||||
|
||||
use ide_core::error::ErrorKind;
|
||||
use ide_core::remote_settings::SshAuth;
|
||||
use ide_ssh::{ConnectError, Output, Session, SshFiles, SshTarget, Tunnel};
|
||||
use parking_lot::Mutex;
|
||||
use russh::keys::{PrivateKey, PublicKey, decode_secret_key};
|
||||
use russh::server::{self, Auth, ChannelOpenHandle, Msg, Server as _, Session as ServerSession};
|
||||
use russh::{Channel, ChannelId};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
fn fixture(name: &str) -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/ssh").join(name)
|
||||
}
|
||||
|
||||
fn key(name: &str, passphrase: Option<&str>) -> PrivateKey {
|
||||
decode_secret_key(&std::fs::read_to_string(fixture(name)).unwrap(), passphrase).unwrap()
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Sshd {
|
||||
client_key: PublicKey,
|
||||
}
|
||||
|
||||
impl server::Server for Sshd {
|
||||
type Handler = Sshd;
|
||||
fn new_client(&mut self, _: Option<std::net::SocketAddr>) -> Sshd {
|
||||
self.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl server::Handler for Sshd {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn auth_password(&mut self, user: &str, password: &str) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "dev" && password == "secret" { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn auth_publickey(&mut self, user: &str, key: &PublicKey) -> Result<Auth, Self::Error> {
|
||||
Ok(if user == "dev" && key.key_data() == self.client_key.key_data() { Auth::Accept } else { Auth::reject() })
|
||||
}
|
||||
|
||||
async fn channel_open_session(
|
||||
&mut self,
|
||||
_channel: Channel<Msg>,
|
||||
reply: ChannelOpenHandle,
|
||||
_session: &mut ServerSession,
|
||||
) -> Result<(), Self::Error> {
|
||||
reply.accept().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `ran: <command>` on stdout, `oops` on stderr, exit status 3.
|
||||
async fn exec_request(
|
||||
&mut self,
|
||||
channel: ChannelId,
|
||||
data: &[u8],
|
||||
session: &mut ServerSession,
|
||||
) -> Result<(), Self::Error> {
|
||||
session.channel_success(channel)?;
|
||||
session.data(channel, format!("ran: {}", String::from_utf8_lossy(data)).into_bytes())?;
|
||||
session.extended_data(channel, 1, b"oops".to_vec())?;
|
||||
session.exit_status_request(channel, 3)?;
|
||||
session.eof(channel)?;
|
||||
session.close(channel)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn channel_open_direct_tcpip(
|
||||
&mut self,
|
||||
channel: Channel<Msg>,
|
||||
host: &str,
|
||||
port: u32,
|
||||
_originator_address: &str,
|
||||
_originator_port: u32,
|
||||
reply: ChannelOpenHandle,
|
||||
_session: &mut ServerSession,
|
||||
) -> Result<(), Self::Error> {
|
||||
let Ok(mut upstream) = TcpStream::connect((host, port as u16)).await else {
|
||||
return Ok(());
|
||||
};
|
||||
reply.accept().await;
|
||||
tokio::spawn(async move {
|
||||
let mut stream = channel.into_stream();
|
||||
let _ = tokio::io::copy_bidirectional(&mut stream, &mut upstream).await;
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Echo server standing in for a remote service.
|
||||
async fn echo() -> u16 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
while let Ok((mut s, _)) = listener.accept().await {
|
||||
tokio::spawn(async move {
|
||||
let (mut r, mut w) = s.split();
|
||||
let _ = tokio::io::copy(&mut r, &mut w).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
async fn sshd() -> u16 {
|
||||
let config = Arc::new(server::Config {
|
||||
keys: vec![key("host", None)],
|
||||
auth_rejection_time: std::time::Duration::from_millis(10),
|
||||
auth_rejection_time_initial: Some(std::time::Duration::ZERO),
|
||||
..Default::default()
|
||||
});
|
||||
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let mut server = Sshd { client_key: key("client", None).public_key().clone() };
|
||||
tokio::spawn(async move {
|
||||
let _ = server.run_on_socket(config, &listener).await;
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
struct Env {
|
||||
_tmp: tempfile::TempDir,
|
||||
files: SshFiles,
|
||||
ssh_port: u16,
|
||||
echo_port: u16,
|
||||
}
|
||||
|
||||
async fn env() -> Env {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let files = SshFiles { known_hosts: tmp.path().join("known_hosts"), config: None };
|
||||
Env { files, _tmp: tmp, ssh_port: sshd().await, echo_port: echo().await }
|
||||
}
|
||||
|
||||
impl Env {
|
||||
fn target(&self, auth: SshAuth) -> SshTarget {
|
||||
SshTarget {
|
||||
host: "127.0.0.1".into(),
|
||||
port: Some(self.ssh_port),
|
||||
user: "dev".into(),
|
||||
auth,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
async fn connect(&self, target: &SshTarget, secret: Option<&str>) -> Result<Session, ConnectError> {
|
||||
Session::connect(target, secret, &self.files).await
|
||||
}
|
||||
|
||||
/// Trusts the server's key on the first connection, as the UI does.
|
||||
async fn connect_trusted(&self, target: &SshTarget, secret: Option<&str>) -> Result<Session, ConnectError> {
|
||||
match self.connect(target, secret).await {
|
||||
Err(ConnectError::UnknownHostKey(k)) => {
|
||||
k.trust(&self.files.known_hosts).unwrap();
|
||||
self.connect(target, secret).await
|
||||
}
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn round_trip(t: &Tunnel) {
|
||||
let mut s = TcpStream::connect(("127.0.0.1", t.port())).await.unwrap();
|
||||
s.write_all(b"SELECT 1").await.unwrap();
|
||||
let mut buf = [0u8; 8];
|
||||
s.read_exact(&mut buf).await.unwrap();
|
||||
assert_eq!(&buf, b"SELECT 1");
|
||||
}
|
||||
|
||||
fn denied(r: Result<Session, ConnectError>) -> String {
|
||||
match r {
|
||||
Err(ConnectError::Other(e)) if e.kind == ErrorKind::PermissionDenied => e.message,
|
||||
Err(ConnectError::Other(e)) => panic!("expected permission denied, got {e:?}"),
|
||||
Err(ConnectError::UnknownHostKey(k)) => panic!("unexpected unknown host key {}", k.fingerprint()),
|
||||
Ok(_) => panic!("expected permission denied, connected"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_host_key_must_be_trusted_and_changed_key_is_refused() {
|
||||
let env = env().await;
|
||||
let target = env.target(SshAuth::Password);
|
||||
let Err(ConnectError::UnknownHostKey(k)) = env.connect(&target, Some("secret")).await else {
|
||||
panic!("expected an unknown host key");
|
||||
};
|
||||
assert!(k.fingerprint().starts_with("ssh-ed25519 SHA256:"), "{}", k.fingerprint());
|
||||
assert!(k.error().message.starts_with("Unknown SSH host key for 127.0.0.1:"));
|
||||
k.trust(&env.files.known_hosts).unwrap();
|
||||
assert!(env.connect(&target, Some("secret")).await.is_ok());
|
||||
|
||||
// Another key recorded for the same host: refused, never offered for trust.
|
||||
let other = key("client", None).public_key().to_openssh().unwrap();
|
||||
std::fs::write(&env.files.known_hosts, format!("[127.0.0.1]:{} {other}\n", env.ssh_port)).unwrap();
|
||||
let msg = denied(env.connect(&target, Some("secret")).await);
|
||||
assert!(msg.contains("has changed"), "{msg}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn password_authentication_and_forwarding() {
|
||||
let env = env().await;
|
||||
let target = env.target(SshAuth::Password);
|
||||
let session = Arc::new(env.connect_trusted(&target, Some("secret")).await.ok().unwrap());
|
||||
let tunnel = Tunnel::open(session.clone(), "127.0.0.1", env.echo_port).await.unwrap();
|
||||
round_trip(&tunnel).await;
|
||||
round_trip(&tunnel).await;
|
||||
assert!(!tunnel.is_closed());
|
||||
|
||||
assert!(denied(env.connect(&target, None).await).starts_with("SSH password required"));
|
||||
assert!(denied(env.connect(&target, Some("wrong")).await).starts_with("SSH authentication failed"));
|
||||
|
||||
let closed = TcpListener::bind("127.0.0.1:0").await.unwrap().local_addr().unwrap().port();
|
||||
let Err(e) = Tunnel::open(session, "127.0.0.1", closed).await else { panic!("expected an error") };
|
||||
assert!(e.message.contains(&format!("cannot reach 127.0.0.1:{closed}")), "{}", e.message);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn key_pair_authentication_with_and_without_passphrase() {
|
||||
let env = env().await;
|
||||
let target = SshTarget { key_file: fixture("client").to_string_lossy().into(), ..env.target(SshAuth::KeyPair) };
|
||||
assert!(env.connect_trusted(&target, None).await.is_ok());
|
||||
|
||||
// An encrypted key the server does not accept: passphrase first, then the key is refused.
|
||||
let locked = SshTarget { key_file: fixture("locked").to_string_lossy().into(), ..target.clone() };
|
||||
assert!(denied(env.connect(&locked, None).await).starts_with("SSH key passphrase required"));
|
||||
assert!(denied(env.connect(&locked, Some("nope")).await).contains("wrong passphrase"));
|
||||
assert!(denied(env.connect(&locked, Some("pw")).await).starts_with("SSH authentication failed"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runs_commands_and_streams_processes() {
|
||||
let env = env().await;
|
||||
let session = env.connect_trusted(&env.target(SshAuth::Password), Some("secret")).await.ok().unwrap();
|
||||
let out = session.exec("uname -a", None).await.unwrap();
|
||||
assert_eq!((out.code, out.stdout_str().as_str(), out.stderr.as_slice()), (Some(3), "ran: uname -a", &b"oops"[..]));
|
||||
assert!(out.into_stdout("uname").unwrap_err().message.contains("oops"));
|
||||
|
||||
let seen: Arc<Mutex<(String, String)>> = Arc::default();
|
||||
let s = seen.clone();
|
||||
let process = session
|
||||
.spawn(
|
||||
"python3 main.py",
|
||||
None,
|
||||
Box::new(move |o| match o {
|
||||
Output::Stdout(b) => s.lock().0.push_str(&String::from_utf8_lossy(b)),
|
||||
Output::Stderr(b) => s.lock().1.push_str(&String::from_utf8_lossy(b)),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(process.wait().await, Some(3));
|
||||
assert_eq!(*seen.lock(), ("ran: python3 main.py".to_owned(), "oops".to_owned()));
|
||||
}
|
||||
@@ -189,10 +189,9 @@ impl TerminalManager {
|
||||
fn session(&self, id: TerminalId) -> IdeResult<Arc<Session>> {
|
||||
self.sessions.lock().get(&id).cloned().ok_or_else(|| not_found(id))
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalManager {
|
||||
fn drop(&mut self) {
|
||||
/// Terminates every session (shutting down).
|
||||
pub fn kill_all(&self) {
|
||||
let sessions: Vec<_> = self.sessions.lock().drain().map(|(_, s)| s).collect();
|
||||
for s in sessions {
|
||||
terminate(&s);
|
||||
@@ -200,6 +199,12 @@ impl Drop for TerminalManager {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalManager {
|
||||
fn drop(&mut self) {
|
||||
self.kill_all();
|
||||
}
|
||||
}
|
||||
|
||||
fn not_found(id: TerminalId) -> IdeError {
|
||||
IdeError::not_found(format!("no such terminal: {id}"))
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod jdk;
|
||||
pub mod platform;
|
||||
mod process;
|
||||
pub mod registry;
|
||||
pub mod server;
|
||||
pub mod sources;
|
||||
|
||||
use ide_core::error::ErrorKind;
|
||||
@@ -71,6 +72,10 @@ pub struct ToolInfo {
|
||||
pub homepage: Option<String>,
|
||||
/// Why the executable chosen in settings cannot be used (status is then `Missing`).
|
||||
pub custom_error: Option<String>,
|
||||
/// A language server's arguments from its language definition.
|
||||
pub default_args: Vec<String>,
|
||||
/// A language server's `initializationOptions` from its language definition, as JSON text.
|
||||
pub default_initialization_options: Option<String>,
|
||||
}
|
||||
|
||||
/// Streamed install progress.
|
||||
@@ -208,6 +213,8 @@ struct Usage {
|
||||
command: Option<String>,
|
||||
kind: Option<ToolKind>,
|
||||
install_hint: Option<String>,
|
||||
args: Vec<String>,
|
||||
initialization_options: Option<String>,
|
||||
}
|
||||
|
||||
fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
|
||||
@@ -242,6 +249,14 @@ fn usages(langs: &[LanguageConfig]) -> BTreeMap<String, Usage> {
|
||||
let languages = map.get(*host).map(|u| u.languages.clone()).unwrap_or_default();
|
||||
map.entry(plugin.to_string()).or_default().languages = languages;
|
||||
}
|
||||
// A server's defaults come from the first definition using it.
|
||||
for s in langs.iter().flat_map(|l| &l.language_servers).rev() {
|
||||
if let Some(u) = map.get_mut(&s.name) {
|
||||
u.args = s.args.clone();
|
||||
u.initialization_options =
|
||||
s.initialization_options.as_ref().and_then(|v| serde_json::to_value(v).ok()).map(|v| v.to_string());
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
@@ -454,6 +469,8 @@ impl Tools {
|
||||
install_hint,
|
||||
homepage: None,
|
||||
custom_error: None,
|
||||
default_args: u.args.clone(),
|
||||
default_initialization_options: u.initialization_options.clone(),
|
||||
})
|
||||
.map(|info| self.apply_custom(info, None));
|
||||
};
|
||||
@@ -476,6 +493,8 @@ impl Tools {
|
||||
install_hint,
|
||||
homepage: Some(spec.homepage.to_string()),
|
||||
custom_error: None,
|
||||
default_args: usage.map(|u| u.args.clone()).unwrap_or_default(),
|
||||
default_initialization_options: usage.and_then(|u| u.initialization_options.clone()),
|
||||
})
|
||||
.map(|info| self.apply_custom(info, Some(spec)))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! `openidea-server` builds for remote development: the release of this
|
||||
//! version publishes one per supported host platform (see `release.yml`).
|
||||
|
||||
use crate::process::Http;
|
||||
use crate::{InstallEvent, default_search_path, fetch_verified, sources};
|
||||
use ide_core::CancellationToken;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const RELEASES: &str = "https://github.com/l0ng-ai/openidea/releases/download";
|
||||
|
||||
/// Host platforms with a published server (Rust's `OS`-`ARCH` names).
|
||||
pub const PLATFORMS: &[(&str, &str)] =
|
||||
&[("linux", "x86_64"), ("linux", "aarch64"), ("macos", "aarch64"), ("macos", "x86_64")];
|
||||
|
||||
/// The release asset of the server for `os`-`arch` (gzip-compressed binary).
|
||||
pub fn asset_name(os: &str, arch: &str) -> String {
|
||||
format!("openidea-server-{os}-{arch}.gz")
|
||||
}
|
||||
|
||||
/// Where a downloaded server of `version` is kept.
|
||||
pub fn cached_path(app_data: &Path, version: &str, os: &str, arch: &str) -> PathBuf {
|
||||
app_data.join("server").join(version).join(format!("{os}-{arch}")).join("openidea-server")
|
||||
}
|
||||
|
||||
/// The server of `version` for `os`-`arch`: the cached download, or the release
|
||||
/// asset downloaded now (checksum verified).
|
||||
pub fn fetch(
|
||||
app_data: &Path,
|
||||
version: &str,
|
||||
os: &str,
|
||||
arch: &str,
|
||||
cancel: &CancellationToken,
|
||||
emit: &dyn Fn(InstallEvent),
|
||||
) -> IdeResult<PathBuf> {
|
||||
let dest = cached_path(app_data, version, os, arch);
|
||||
if dest.is_file() {
|
||||
return Ok(dest);
|
||||
}
|
||||
if !PLATFORMS.contains(&(os, arch)) {
|
||||
return Err(IdeError::unavailable(format!("remote development does not support {os}-{arch} hosts")));
|
||||
}
|
||||
let name = asset_name(os, arch);
|
||||
let url = format!("{RELEASES}/v{version}/{name}");
|
||||
let dir = dest.parent().expect("cached path has a parent");
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let staging = tempfile_dir(dir)?;
|
||||
let result = (|| {
|
||||
let http = Http::new(&default_search_path())?;
|
||||
// The binary runs on the remote host: never install it unverified.
|
||||
let checksum_url = format!("{url}.sha256");
|
||||
let sha256 = sources::parse_checksum(&http.get_text(&checksum_url, cancel)?, &name)
|
||||
.ok_or_else(|| IdeError::unavailable(format!("no SHA-256 for {name} at {checksum_url}")))?;
|
||||
let dl = sources::Download {
|
||||
version: version.to_owned(),
|
||||
checksum_url: None,
|
||||
name,
|
||||
url,
|
||||
size: None,
|
||||
sha256: Some(sha256),
|
||||
};
|
||||
let archive = fetch_verified(&http, &dl, &staging, cancel, emit)?;
|
||||
let unpacked = staging.join("openidea-server");
|
||||
let mut gz = flate2::read::GzDecoder::new(std::fs::File::open(&archive)?);
|
||||
std::io::copy(&mut gz, &mut std::fs::File::create(&unpacked)?)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&unpacked, std::fs::Permissions::from_mode(0o755))?;
|
||||
}
|
||||
std::fs::rename(&unpacked, &dest)?;
|
||||
Ok(dest.clone())
|
||||
})();
|
||||
let _ = std::fs::remove_dir_all(&staging);
|
||||
result
|
||||
}
|
||||
|
||||
fn tempfile_dir(parent: &Path) -> IdeResult<PathBuf> {
|
||||
// Unique per call: two windows of this process may download at once.
|
||||
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
let n = NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let dir = parent.join(format!(".download-{}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn names_and_cache_layout() {
|
||||
assert_eq!(asset_name("linux", "x86_64"), "openidea-server-linux-x86_64.gz");
|
||||
let p = cached_path(Path::new("/data"), "1.2.3", "linux", "aarch64");
|
||||
assert_eq!(p, Path::new("/data/server/1.2.3/linux-aarch64/openidea-server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_hosts_are_refused_without_downloading() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let e = fetch(tmp.path(), "1.2.3", "freebsd", "x86_64", &CancellationToken::new(), &|_| {}).unwrap_err();
|
||||
assert!(e.message.contains("freebsd-x86_64"), "{e:?}");
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,14 @@ pub fn set_git_executable(path: Option<&Path>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment that routes credential prompts (`ssh` passphrases and host keys,
|
||||
/// HTTPS user names and passwords) to the UI; empty = prompts fail.
|
||||
static ASKPASS: std::sync::RwLock<Vec<(String, String)>> = std::sync::RwLock::new(Vec::new());
|
||||
|
||||
pub fn set_askpass_env(env: Vec<(String, String)>) {
|
||||
*ASKPASS.write().unwrap_or_else(|e| e.into_inner()) = env;
|
||||
}
|
||||
|
||||
/// Locates the auto-detected git executable once per process.
|
||||
///
|
||||
/// GUI apps on macOS start with a minimal `PATH`, so a few well-known install
|
||||
@@ -95,6 +103,9 @@ pub(crate) fn command(root: &Path) -> IdeResult<Command> {
|
||||
.env_remove("GIT_DIR")
|
||||
.env_remove("GIT_WORK_TREE")
|
||||
.env_remove("GIT_INDEX_FILE");
|
||||
for (k, v) in ASKPASS.read().unwrap_or_else(|e| e.into_inner()).iter() {
|
||||
cmd.env(k, v);
|
||||
}
|
||||
ide_core::process::no_window(&mut cmd);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
@@ -452,7 +452,7 @@ pub mod shelf;
|
||||
mod status;
|
||||
mod write;
|
||||
|
||||
pub use cli::{detected_git_executable, git_version, set_git_executable};
|
||||
pub use cli::{detected_git_executable, git_version, set_askpass_env, set_git_executable};
|
||||
pub use diff::build_partial_patch;
|
||||
pub use manage::{MergeOptions, RebaseOptions, RemoteEdit, Worktree};
|
||||
pub use remotes::Remote;
|
||||
|
||||
@@ -106,20 +106,33 @@ fn json_to_toml(value: &Json) -> IdeResult<toml_edit::Value> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets (or removes, for `None`) a dotted key in a TOML document.
|
||||
fn set_in_document(doc: &mut DocumentMut, segments: &[&str], value: Option<toml_edit::Value>) {
|
||||
/// Sets (or removes, for `None`) a dotted key in a TOML document. A new
|
||||
/// `map_entry` object becomes a `[languageServers.ruff]` table rather than an
|
||||
/// inline table under an explicit `[languageServers]` header.
|
||||
fn set_in_document(doc: &mut DocumentMut, segments: &[&str], value: Option<toml_edit::Value>, map_entry: bool) {
|
||||
let (last, parents) = segments.split_last().expect("non-empty key");
|
||||
let mut table: &mut dyn TableLike = doc.as_table_mut();
|
||||
let mut in_inline = false;
|
||||
for seg in parents {
|
||||
if !table.get(seg).is_some_and(Item::is_table_like) {
|
||||
if value.is_none() {
|
||||
return;
|
||||
}
|
||||
table.insert(seg, Item::Table(Table::new()));
|
||||
// Implicit: no `[languageServers]` header above `[languageServers.ruff]`.
|
||||
let mut new = Table::new();
|
||||
new.set_implicit(true);
|
||||
table.insert(seg, Item::Table(new));
|
||||
}
|
||||
table = table.get_mut(seg).and_then(Item::as_table_like_mut).expect("table just ensured");
|
||||
let item = table.get_mut(seg).expect("table just ensured");
|
||||
in_inline |= item.is_inline_table();
|
||||
table = item.as_table_like_mut().expect("table just ensured");
|
||||
}
|
||||
match value {
|
||||
Some(toml_edit::Value::InlineTable(t))
|
||||
if map_entry && !in_inline && !matches!(table.get(last), Some(Item::Value(_))) =>
|
||||
{
|
||||
table.insert(last, Item::Table(t.into_table()));
|
||||
}
|
||||
Some(v) => match table.get_mut(last) {
|
||||
// Keep the key's decor (comments, spacing) when replacing a value.
|
||||
Some(Item::Value(existing)) => {
|
||||
@@ -188,9 +201,19 @@ impl SettingsStore {
|
||||
if segments.iter().any(|s| s.is_empty()) {
|
||||
return Err(IdeError::invalid(format!("invalid settings key {key:?}")));
|
||||
}
|
||||
let mut cursor = &defaults_json();
|
||||
for seg in &segments {
|
||||
cursor = cursor.get(seg).ok_or_else(|| IdeError::invalid(format!("unknown setting {key:?}")))?;
|
||||
let defaults = defaults_json();
|
||||
let mut cursor = &defaults;
|
||||
let mut map_entry = false;
|
||||
for (i, seg) in segments.iter().enumerate() {
|
||||
match cursor.get(seg) {
|
||||
Some(next) => cursor = next,
|
||||
// An entry of a map setting (`languageServers.rust-analyzer`): the merge below validates it.
|
||||
None if cursor.as_object().is_some_and(|m| m.is_empty()) => {
|
||||
map_entry = i + 1 == segments.len();
|
||||
break;
|
||||
}
|
||||
None => return Err(IdeError::invalid(format!("unknown setting {key:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
let text = match std::fs::read_to_string(&file) {
|
||||
@@ -202,7 +225,7 @@ impl SettingsStore {
|
||||
.parse()
|
||||
.map_err(|e| IdeError::invalid(format!("{} is not valid TOML; fix it first: {e}", file.display())))?;
|
||||
let new_value = if value.is_null() { None } else { Some(json_to_toml(&value)?) };
|
||||
set_in_document(&mut doc, &segments, new_value);
|
||||
set_in_document(&mut doc, &segments, new_value, map_entry);
|
||||
let new_text = doc.to_string();
|
||||
|
||||
// Validate the resulting stack before touching the disk.
|
||||
@@ -350,6 +373,47 @@ mod tests {
|
||||
let s =
|
||||
store.set(SettingsScope::Global, "appearance.statusBarWidgets", json!({"editor.caret": false})).unwrap();
|
||||
assert_eq!(s.appearance.status_bar_widgets.len(), 1);
|
||||
// One entry of a map setting, set and removed on its own.
|
||||
let s = store
|
||||
.set(
|
||||
SettingsScope::Global,
|
||||
"languageServers.rust-analyzer",
|
||||
json!({"args": ["--log"], "initializationOptions": {"check": {"command": "clippy"}}}),
|
||||
)
|
||||
.unwrap();
|
||||
let ra = &s.language_servers["rust-analyzer"];
|
||||
assert!(ra.enabled);
|
||||
assert_eq!(ra.args.as_deref(), Some(&["--log".to_string()][..]));
|
||||
assert_eq!(ra.initialization_options, Some(json!({"check": {"command": "clippy"}})));
|
||||
assert_eq!(
|
||||
store
|
||||
.set(SettingsScope::Global, "languageServers.rust-analyzer", json!({"enabled": "no"}))
|
||||
.unwrap_err()
|
||||
.kind,
|
||||
ide_core::error::ErrorKind::InvalidInput
|
||||
);
|
||||
let s = store.set(SettingsScope::Global, "languageServers.rust-analyzer", json!(null)).unwrap();
|
||||
assert!(s.language_servers.is_empty());
|
||||
// New parent tables stay implicit: no empty `[languageServers]` header.
|
||||
let fresh = tmp.path().join("fresh");
|
||||
let s = SettingsStore::load(&fresh, None)
|
||||
.set(SettingsScope::Global, "languageServers.ruff.languages", json!(["python"]))
|
||||
.unwrap();
|
||||
assert!(s.language_servers["ruff"].is_custom());
|
||||
let text = std::fs::read_to_string(fresh.join("settings.toml")).unwrap();
|
||||
assert_eq!(text, "[languageServers.ruff]\nlanguages = [\"python\"]\n");
|
||||
// A whole new entry: a table of its own, still under an implicit parent.
|
||||
let added = tmp.path().join("added");
|
||||
let s = SettingsStore::load(&added, None)
|
||||
.set(
|
||||
SettingsScope::Global,
|
||||
"languageServers.ruff",
|
||||
json!({"command": "ruff", "args": ["server"], "languages": ["python"]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(s.language_servers["ruff"].command, "ruff");
|
||||
let text = std::fs::read_to_string(added.join("settings.toml")).unwrap();
|
||||
assert_eq!(text, "[languageServers.ruff]\nargs = [\"server\"]\ncommand = \"ruff\"\nlanguages = [\"python\"]\n");
|
||||
|
||||
// Project layer is created on demand and wins.
|
||||
let s = store.set(SettingsScope::Project, "editor.fontSize", json!(12)).unwrap();
|
||||
|
||||
@@ -531,7 +531,7 @@ a token.
|
||||
| `--bg-toolbar-pressed` | `#ffffff29` | `#00000020` |
|
||||
| `--bg-toolbar-selected` | `#2e4d89` | `#d0dffe` |
|
||||
| `--bg-toolbar-selected-hover` | `#2f5eb9` | `#bdd3ff` |
|
||||
| `--bg-button-primary-hover` | `#2f5eb9` | `#2f5eb9` |
|
||||
| `--bg-button-primary-hover` | `var(--accent-hover)` | `var(--accent-hover)` |
|
||||
| `--fg-button-primary` | `#ffffff` | `#ffffff` |
|
||||
| `--bg-button` | `transparent` | `#ffffff` |
|
||||
| `--border-button` | `#40434a` | `#d1d3d9` |
|
||||
|
||||
Loaded 100 of 276 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user