Compare commits

...
Author SHA1 Message Date
l0ng-ai bcb2728bcd docs(themes): Regenerate the token reference 2026-10-27 22:51:39 +08:00
l0ng-ai 5c50215d39 Merge remote-tracking branch 'origin/main' into feat/remote-development
# Conflicts:
#	crates/ide-jsonrpc/src/lib.rs
2026-10-27 22:47:56 +08:00
l0ng-ai 03ed6be82d fix(ui): Activating a tool window focuses its content, not its first button 2026-10-27 22:39:43 +08:00
l0ng-ai beee4cb633 fix(agents): Report starting the agent separately, and a refused sign-in with how to sign in 2026-10-27 22:36:55 +08:00
l0ng-ai 21c2934a36 fix(terminal): Open OSC 8 hyperlinks in the system browser 2026-10-27 22:22:44 +08:00
l0ng-ai d9ff048a14 feat(app): Closing the last project window shows the welcome screen 2026-10-27 22:20:25 +08:00
l0ng-ai a8ce8048f8 feat(remote): Show the connection progress while reopening a remote project at startup 2026-10-27 22:16:22 +08:00
l0ng-ai 831cd59ab0 feat(remote): Run agent tasks on the remote host; show why the agent list cannot load 2026-10-27 22:12:58 +08:00
l0ng-ai ec41d3c4a0 feat(remote): Show the stages and transfer progress of connecting; primary button hover follows the accent 2026-10-27 22:04:59 +08:00
l0ng-ai 6bcf99778a fix(remote): Reopen and list recent remote projects; missing rustup components and spelling of references
- Remote projects are remembered as ssh:// locations: the last session and
  a reloaded window reconnect, and the welcome screen lists them
- A rustup proxy without its component counts as a missing tool (install
  hint) rather than a server crashing on every restart
- Typo checks the names declarations introduce, not path segments and
  calls; more programming terms in the word list
- The query history records statements in execution order
2026-10-27 21:43:36 +08:00
l0ng-ai 6d59cf377c chore: Update Cargo.lock for 0.1.18 2026-10-27 21:19:25 +08:00
l0ng-ai 0217586e2e Merge remote-tracking branch 'origin/main' into feat/remote-development 2026-10-27 21:18:06 +08:00
l0ng-ai db3b17d863 test(remote): Build the Unix path fixture only on Unix 2026-10-27 21:18:06 +08:00
l0ng-ai f0261719b8 Merge remote-tracking branch 'origin/main' into feat/remote-development
# Conflicts:
#	src-tauri/src/state.rs
2026-10-27 21:13:02 +08:00
l0ng-ai b556a064e3 fix(remote): Map Windows project paths onto run targets and order query history by time 2026-10-27 21:06:46 +08:00
l0ng-ai feb882ed10 fix(remote): Harden deployment paths, server install and remote routing
- Deployment: reject `..` and `/` in remote names and sync paths, keep
  excluded paths root-relative, never delete a mapping's remote root,
  apply excludes to Sync steps
- SSH: connect per configuration so an unreachable host doesn't block
  others; `--` before the host in ssh arguments; read timeout on askpass
- Run targets: export the environment for shell command lines; map the
  project folder only as a whole path
- Remote development: use the server's resolved project root; give
  streaming channels connection-wide ids; per-install `.part` files;
  probe output between markers; require the server's SHA-256; unique
  download staging folders; stop runs, terminals and language servers
  when the server exits
- JSON-RPC: deliver a response after the notifications sent before it
- Release: build the aarch64 musl server with musl-gcc
2026-10-27 20:33:09 +08:00
l0ng-ai e82d766804 Merge remote-tracking branch 'origin/main' into feat/remote-development
# Conflicts:
#	src-tauri/src/commands/build.rs
#	src-tauri/src/commands/debug.rs
#	src-tauri/src/commands/lsp.rs
#	src-tauri/src/commands/run.rs
#	src-tauri/src/commands/tools.rs
#	src-tauri/src/commands/vcs.rs
#	src-tauri/src/state.rs
#	src/bindings.ts
#	src/features/database/SourceDialog.tsx
#	src/features/index.ts
#	src/platform/locales/text/zh.json
#	src/styles/tokens.css
2026-10-27 19:33:02 +08:00
l0ng-ai 5e099ecb5b ci: Build the remote development server for release and check its Linux build 2026-10-27 17:43:48 +08:00
l0ng-ai 7531834b3c feat(remote): Add SSH configurations, SFTP deployment, run targets and remote development
SSH (new crate ide-ssh)
- Shared SSH configurations (Settings › Tools › SSH Configurations) used by
  data source tunnels, deployment servers, run targets and terminals.
- Sessions with password, key pair or OpenSSH config + agent authentication,
  ProxyJump, known-hosts checks with trust prompts, saved secrets.
- Askpass: credential prompts of `ssh` / `git` child processes shown in the IDE.
- Tools › Start SSH Session opens a terminal tab for a configuration.
- The database SSH tunnel moves here and can use a shared configuration.

Deployment (new crate ide-remote)
- Settings › Build › Deployment: SFTP servers, path mappings, excludes,
  automatic upload on save.
- Upload / download / delete / sync with the server, Tools › Deployment
  menu, Remote Host tool window for browsing and editing remote files.

Run targets
- Settings › Build › Run Targets: run configurations can run on an SSH host
  ("Run on"), with project sync before the run and remote interpreters.

Remote development
- New crate ide-host: the backend (projects, documents, LSP, VCS, search,
  terminals, runs, debugger, tools, settings) shared by the desktop shell and
  `openidea-server`, a headless server speaking JSON-RPC over stdio.
- File › Remote Development… connects to an SSH host, installs the server
  (per-build folder, streamed gzip upload; the release build is downloaded
  when there is none locally) and opens a remote folder in the window.
- The window routes the commands the server answers to it; streaming
  channels and events are forwarded. The server takes the login shell's PATH.
2026-10-27 17:43:48 +08:00
l0ng-ai 0ab50e0dd4 fix(theme): Define the chart series colour in the high-contrast dark theme 2026-10-27 17:43:37 +08:00
196 changed files with 17920 additions and 3729 deletions

No files matched your search

+15
View File
@@ -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
+42 -1
View File
@@ -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
+125 -2
View File
@@ -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"
@@ -4061,9 +4073,8 @@ dependencies = [
"dirs 6.0.0",
"futures",
"ide-core",
"ide-ssh",
"parking_lot",
"russh",
"russh-config",
"serde",
"serde_json",
"specta",
@@ -4102,6 +4113,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"
@@ -4265,6 +4317,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 +4406,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 +5968,7 @@ dependencies = [
name = "openidea"
version = "0.1.18"
dependencies = [
"blake3",
"dirs 6.0.0",
"dunce",
"ide-agent",
@@ -5892,16 +5981,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 +7086,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 +7439,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"
+3
View File
@@ -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"] }
+12 -1
View File
@@ -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)),
}
}
+22
View File
@@ -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)]
+1
View File
@@ -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;
+150
View File
@@ -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,
}
+10
View File
@@ -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)]
@@ -45,6 +46,12 @@ pub struct Settings {
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)]
@@ -84,6 +91,9 @@ impl Default for Settings {
tools: BTreeMap::new(),
inspections: InspectionSettings::default(),
spelling: SpellingSettings::default(),
ssh: SshSettings::default(),
deployment: DeploymentSettings::default(),
run_targets: Vec::new(),
}
}
}
+1 -2
View File
@@ -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"] }
+1 -1
View File
@@ -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
View File
@@ -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.
+4 -11
View File
@@ -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>,
-474
View File
@@ -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());
}
}
+2 -1
View File
@@ -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(),
-226
View File
@@ -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);
}
+50
View File
@@ -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
+75
View File
@@ -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
}
+162
View File
@@ -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);
}
}
+114
View File
@@ -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 });
}
+280
View File
@@ -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
});
}
+263
View File
@@ -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
});
}
+32
View File
@@ -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 });
}
+45
View File
@@ -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 });
}
+357
View File
@@ -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 },
);
}
+42
View File
@@ -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::*;
+125
View File
@@ -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")?) });
}
+85
View File
@@ -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
}
+55
View File
@@ -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 },
);
}
+95
View File
@@ -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 });
}
+357
View File
@@ -0,0 +1,357 @@
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 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_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
});
}
+85
View File
@@ -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()))?
}
}
+176
View File
@@ -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
});
}
+135
View File
@@ -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 &current {
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) = &current {
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"].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 })
}
+112
View File
@@ -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
});
}
+91
View File
@@ -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::*;
+48
View File
@@ -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
});
}
+170
View File
@@ -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
});
}
+97
View File
@@ -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::*;
+133
View File
@@ -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")?) });
}
+98
View File
@@ -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, &regions, &|| 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 });
}
+175
View File
@@ -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
});
}
+33
View File
@@ -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)
}
+170
View File
@@ -0,0 +1,170 @@
//! 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()))
}
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 = host.languages.all();
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 = host.languages.all();
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 = host.languages.all();
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
});
}
+24
View File
@@ -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::*;
+919
View File
@@ -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, &current, &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 },
);
}
+767
View File
@@ -0,0 +1,767 @@
//! 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::configure_server(cfg, &root, server_root, &effective);
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);
}
}));
}
{
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)));
}
+302
View File
@@ -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.
+151
View File
@@ -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()
}
}
+8 -2
View File
@@ -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));
}
}
}
+6 -1
View File
@@ -84,8 +84,13 @@ fn is_name_leaf(kind: &str) -> bool {
/// `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();
// Paths and calls name what is declared elsewhere (`use a::b::name`, `a.name()`).
let reference = ["scoped", "qualified", "invocation", "member_access", "use_", "import", "call"]
.iter()
.any(|k| parent.contains(k));
match cursor.field_name() {
Some("name" | "pattern" | "declarator") => true,
Some("name") => !reference,
Some("pattern" | "declarator") => true,
Some("left") => parent.contains("assignment") || parent.contains("declarator"),
_ => parent.contains("parameter"),
}
+10
View File
@@ -1310,4 +1310,14 @@ 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"]);
}
+13 -9
View File
@@ -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);
}
+23
View File
@@ -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
+377
View File
@@ -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);
}
}
+12
View File
@@ -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};
+219
View File
@@ -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"));
}
}
+368
View File
@@ -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()
}
+368
View File
@@ -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");
}
}
+179
View File
@@ -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);
}
+5
View File
@@ -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
}
+5
View File
@@ -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,
}
}
}
+8 -3
View File
@@ -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> {
+48
View File
@@ -503,3 +503,51 @@ 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
+28
View File
@@ -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
+216
View File
@@ -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"));
}
}
+21
View File
@@ -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;
+192
View File
@@ -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()
}
}
+87
View File
@@ -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);
}
}
}
+521
View File
@@ -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()))),
}
}
+247
View File
@@ -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");
}
}
+69
View File
@@ -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(&quote(&format!("{k}={v}")));
}
s.push(' ');
}
s.push_str(&tilde_quote(program));
for a in args {
s.push(' ');
s.push_str(&quote(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");
}
}
+184
View File
@@ -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"]);
}
}
+262
View File
@@ -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))
}
}
+80
View File
@@ -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.
+256
View File
@@ -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()));
}
+8 -3
View File
@@ -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}"))
}
+1
View File
@@ -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;
+105
View File
@@ -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:?}");
}
}
+11
View File
@@ -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)
}
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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` |
+5
View File
@@ -28,6 +28,7 @@ serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
parking_lot.workspace = true
blake3.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
dirs.workspace = true
@@ -57,6 +58,10 @@ ide-agent.workspace = true
ide-db.workspace = true
ide-web.workspace = true
ide-spell.workspace = true
ide-ssh.workspace = true
ide-remote.workspace = true
ide-host.workspace = true
ide-jsonrpc.workspace = true
ide-tools = { path = "../crates/ide-tools" }
ide-plugin = { path = "../crates/ide-plugin" }
ide-docker = { path = "../crates/ide-docker" }
+24 -25
View File
@@ -1,8 +1,11 @@
//! IPC commands of `ide_host::ops::agent` (the shared implementation).
use crate::state::AppState;
use ide_agent::{
AgentChange, AgentCommand, AgentEntry, AgentEvent, AgentPrompt, AgentProvider, AgentQueued, AgentTask, NewAgentTask,
};
use ide_core::{IdeResult, IpcPath};
use ide_host::ops::agent;
use std::sync::Arc;
use tauri::ipc::Channel;
use tauri::{State, Window};
@@ -15,16 +18,19 @@ pub fn agent_subscribe(
state: State<'_, AppState>,
on_event: Channel<AgentEvent>,
) -> IdeResult<Vec<AgentTask>> {
let project = state.project(window.label())?;
Ok(project.agents.subscribe(Arc::new(move |e| {
let _ = on_event.send(e);
})))
agent::subscribe(
&state.host,
window.label(),
Arc::new(move |e| {
let _ = on_event.send(e);
}),
)
}
#[tauri::command]
#[specta::specta]
pub fn agent_providers(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<AgentProvider>> {
Ok(state.project(window.label())?.agents.providers())
agent::providers(&state.host, window.label())
}
/// Slash commands to complete for `provider` in `task`'s directory, or in the project
@@ -37,21 +43,19 @@ pub async fn agent_commands(
provider: String,
task: Option<String>,
) -> IdeResult<Vec<AgentCommand>> {
let project = state.project(window.label())?;
project.agents.commands(&provider, task.as_deref()).await
agent::commands(&state.host, window.label(), provider, task).await
}
#[tauri::command]
#[specta::specta]
pub fn agent_entries(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<Vec<AgentEntry>> {
state.project(window.label())?.agents.entries(&task)
agent::entries(&state.host, window.label(), task)
}
#[tauri::command]
#[specta::specta]
pub async fn agent_create(window: Window, state: State<'_, AppState>, request: NewAgentTask) -> IdeResult<AgentTask> {
let project = state.project(window.label())?;
project.agents.create(request).await
agent::create(&state.host, window.label(), request).await
}
/// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn.
@@ -64,8 +68,7 @@ pub async fn agent_prompt(
prompt: AgentPrompt,
now: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
project.agents.prompt(&task, prompt, now).await
agent::prompt(&state.host, window.label(), task, prompt, now).await
}
/// Drops a queued message and returns it (to edit it).
@@ -77,7 +80,7 @@ pub fn agent_unqueue(
task: String,
queued: String,
) -> IdeResult<AgentQueued> {
state.project(window.label())?.agents.unqueue(&task, &queued)
agent::unqueue(&state.host, window.label(), task, queued)
}
/// Sends a queued message now, interrupting the current turn.
@@ -89,7 +92,7 @@ pub fn agent_send_queued_now(
task: String,
queued: String,
) -> IdeResult<()> {
state.project(window.label())?.agents.send_queued_now(&task, &queued)
agent::send_queued_now(&state.host, window.label(), task, queued)
}
/// Changes a session setting (model, mode, thinking effort…).
@@ -102,14 +105,13 @@ pub async fn agent_set_config(
option: String,
value: String,
) -> IdeResult<()> {
let project = state.project(window.label())?;
project.agents.set_config(&task, &option, value).await
agent::set_config(&state.host, window.label(), task, option, value).await
}
#[tauri::command]
#[specta::specta]
pub fn agent_cancel(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> {
state.project(window.label())?.agents.cancel(&task)
agent::cancel(&state.host, window.label(), task)
}
/// Answers a permission request; `option: null` cancels it.
@@ -122,13 +124,13 @@ pub fn agent_respond(
permission: u32,
option: Option<String>,
) -> IdeResult<()> {
state.project(window.label())?.agents.respond(&task, permission, option)
agent::respond(&state.host, window.label(), task, permission, option)
}
#[tauri::command]
#[specta::specta]
pub fn agent_archive(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<()> {
state.project(window.label())?.agents.archive(&task)
agent::archive(&state.host, window.label(), task)
}
#[tauri::command]
@@ -139,15 +141,13 @@ pub async fn agent_delete(
task: String,
remove_worktree: bool,
) -> IdeResult<()> {
let project = state.project(window.label())?;
project.agents.delete(&task, remove_worktree).await
agent::delete(&state.host, window.label(), task, remove_worktree).await
}
#[tauri::command]
#[specta::specta]
pub async fn agent_changes(window: Window, state: State<'_, AppState>, task: String) -> IdeResult<Vec<AgentChange>> {
let project = state.project(window.label())?;
project.agents.changes(&task).await
agent::changes(&state.host, window.label(), task).await
}
/// Content of `path` when the task started (left side of its diff).
@@ -159,6 +159,5 @@ pub async fn agent_base_text(
task: String,
path: IpcPath,
) -> IdeResult<Option<String>> {
let project = state.project(window.label())?;
project.agents.base_text(&task, path.into_path_buf()).await
agent::base_text(&state.host, window.label(), task, path).await
}
+27 -218
View File
@@ -1,105 +1,14 @@
//! 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`.
//! IPC commands of `ide_host::ops::analysis` (the shared implementation).
use super::doc::blocking;
use crate::state::{AppState, Project};
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;
use crate::state::AppState;
use ide_analysis::{DuplicateOptions, DuplicatesReport, StructuralQuery};
use ide_core::{IdeResult, RequestId};
#[allow(unused_imports)]
pub use ide_host::ops::analysis::*;
use ide_search::SearchScope;
use tauri::ipc::Channel;
use tauri::{State, Window};
/// 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);
}
}
/// Inspect Code: diagnostics of every file in `scope` that a language server
/// serves. Files stream through `on_event` as they finish; ends with `Done`.
#[tauri::command]
@@ -111,72 +20,16 @@ pub async fn inspect_code(
scope: SearchScope,
on_event: Channel<InspectEvent>,
) -> IdeResult<()> {
let started = Instant::now();
let project = state.project(window.label())?;
let cancel = state.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) => {
state.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 host = Arc::new(DocumentsHost(project.documents.clone()));
project
.lsp
.inspect_files(host, 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;
state.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(())
ide_host::ops::analysis::inspect_code(
&state.host,
window.label(),
request,
scope,
crate::commands::sender(on_event),
)
.await
}
// ---- Locate Duplicates ----
/// Locate Duplicates over the files of `scope`.
#[tauri::command]
#[specta::specta]
@@ -187,58 +40,21 @@ pub async fn analysis_locate_duplicates(
scope: SearchScope,
options: DuplicateOptions,
) -> IdeResult<DuplicatesReport> {
let project = state.project(window.label())?;
let cancel = state.begin_request(request);
let languages = project.languages.clone();
let syntax = state.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;
state.end_request(request);
result
}
// ---- 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,
ide_host::ops::analysis::analysis_locate_duplicates(&state.host, window.label(), request, scope, options).await
}
/// Languages Search Structurally supports (those with a bundled grammar).
#[tauri::command]
#[specta::specta]
pub async fn structural_languages(state: State<'_, AppState>) -> IdeResult<Vec<StructuralLanguage>> {
let syntax = state.syntax();
let mut out: Vec<StructuralLanguage> = state
.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)
ide_host::ops::analysis::structural_languages(&state.host, "").await
}
/// Checks a structural search template (and language) while the user types.
#[tauri::command]
#[specta::specta]
pub async fn structural_validate(state: State<'_, AppState>, query: StructuralQuery) -> IdeResult<()> {
let syntax = state.syntax();
blocking(move || ide_analysis::validate_structural(&syntax, &query)).await
ide_host::ops::analysis::structural_validate(&state.host, "", query).await
}
/// Search Structurally over `scope`; files with matches stream through
@@ -254,20 +70,13 @@ pub async fn structural_search(
query: StructuralQuery,
on_event: Channel<StructuralEvent>,
) -> IdeResult<()> {
let project = state.project(window.label())?;
let cancel = state.begin_request(request);
let languages = project.languages.clone();
let syntax = state.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;
state.end_request(request);
let _ = on_event.send(StructuralEvent::Done { summary: result? });
Ok(())
ide_host::ops::analysis::structural_search(
&state.host,
window.label(),
request,
scope,
query,
crate::commands::sender(on_event),
)
.await
}
+10 -84
View File
@@ -17,14 +17,7 @@ pub struct AppInfo {
pub config_dir: ide_core::IpcPath,
}
#[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 use ide_host::ops::project::ProjectInfo;
#[tauri::command]
#[specta::specta]
@@ -69,18 +62,7 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
}
fn project_info(project: &crate::state::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(),
}
ide_host::ops::project::info(project)
}
/// File › Rename Project…: the project's display name (title bar, recent
@@ -122,10 +104,7 @@ pub fn languages(state: State<'_, AppState>) -> Vec<LanguageInfo> {
#[specta::specta]
pub fn settings_get(window: Window, state: State<'_, AppState>) -> Settings {
state.note_ipc();
match state.project(window.label()) {
Ok(p) => p.settings.effective(),
Err(_) => state.global_settings.effective(),
}
ide_host::ops::project::settings(&state.host, window.label())
}
/// Defaults plus the global layer, without any project's values: what new
@@ -148,76 +127,22 @@ pub fn settings_set(
key: String,
value: String,
) -> IdeResult<Settings> {
let value: serde_json::Value =
serde_json::from_str(&value).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let current = state.project(window.label()).ok();
let project_scope = matches!(scope, SettingsScope::Project) && current.is_some();
let settings = match &current {
Some(p) if project_scope => p.settings.set(scope, &key, value)?,
Some(p) => {
state.global_settings.set(SettingsScope::Global, &key, value)?;
p.settings.reload()
}
None => state.global_settings.set(SettingsScope::Global, &key, value)?,
};
if let Some(p) = &current {
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 state.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)));
}
}
// File type associations re-detect open documents (off the command thread).
if key == "files" || key.starts_with("files.fileType") {
let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() };
tauri::async_runtime::spawn_blocking(move || {
for project in affected {
project.apply_file_types(&project.settings.effective());
}
});
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
if ["languages", "tools"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() };
for project in affected {
let lsp = project.lsp.clone();
tauri::async_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 = if project_scope { current.iter().cloned().collect() } else { state.projects() };
tauri::async_runtime::spawn_blocking(move || {
for project in affected {
project.refresh_index_scope();
}
});
}
let applied = ide_host::ops::project::set_settings(&state.host, window.label(), scope, &key, &value)?;
// A project value concerns only that project's window (and its settings window).
let event = ide_core::IdeEvent::SettingsChanged { key };
if project_scope {
if applied.project_scope {
let owner = AppState::owner_window(window.label());
for label in [owner.to_owned(), crate::windows::settings_label(owner)] {
let sink = crate::events::TauriSink { app: state.app.clone(), window: Some(label) };
sink.emit(event.clone());
if let Some(p) = current.as_ref().filter(|_| roots_changed) {
sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: p.root.clone().into() });
if let Some(root) = &applied.roots_changed {
sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: root.clone() });
}
}
} else {
state.sink.emit(event);
}
Ok(settings)
Ok(applied.settings)
}
/// File › Manage IDE Settings › Export Settings…: the global settings plus the
@@ -254,6 +179,7 @@ pub async fn settings_import(window: Window, state: State<'_, AppState>, path: P
let lsp = project.lsp.clone();
tauri::async_runtime::spawn(async move { lsp.reconfigure().await });
}
crate::commands::ssh::apply_ssh_settings(&state);
state.sink.emit(ide_core::IdeEvent::SettingsChanged { key: String::new() });
let settings = match state.project(window.label()) {
Ok(p) => p.settings.effective(),
@@ -267,7 +193,7 @@ pub async fn settings_import(window: Window, state: State<'_, AppState>, path: P
#[tauri::command]
#[specta::specta]
pub fn project_roots(window: Window, state: State<'_, AppState>) -> IdeResult<ide_workspace::ProjectRoots> {
Ok(state.project(window.label())?.roots())
ide_host::ops::project::roots(&state.host, window.label())
}
/// The project open in this window (a settings window: its parent's), if any.
+29 -169
View File
@@ -1,80 +1,20 @@
//! Maven / Gradle tool windows (the build model and its background sync) and
//! Build › Build Project (compiling with the project's build system).
//! IPC commands of `ide_host::ops::build` (the shared implementation).
use crate::state::{AppState, 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;
use crate::state::AppState;
use ide_build::compile::{BuildEvent, BuildKind, BuildResult};
use ide_build::pom_edit::ArtifactKey;
use ide_build::{BuildModel, BuildTool, SyncProgress};
use ide_core::{IdeResult, Location, RequestId};
#[allow(unused_imports)]
pub use ide_host::ops::build::*;
use tauri::ipc::Channel;
use tauri::{State, Window};
fn join_err(e: tauri::Error) -> 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(crate) 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.
#[tauri::command]
#[specta::specta]
pub async fn build_model(window: Window, state: State<'_, AppState>) -> IdeResult<BuildModel> {
let project = state.project(window.label())?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tauri::async_runtime::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
ide_host::ops::build::build_model(&state.host, window.label()).await
}
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
@@ -91,60 +31,16 @@ pub async fn build_sync(
profiles: Vec<String>,
on_progress: Channel<SyncProgress>,
) -> IdeResult<BuildModel> {
let project = state.project(window.label())?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
let token = state.begin_request(request);
let result = tauri::async_runtime::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))
})
ide_host::ops::build::build_sync(
&state.host,
window.label(),
tool,
request,
offline,
profiles,
crate::commands::sender(on_progress),
)
.await
.map_err(join_err);
state.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: &crate::state::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.
@@ -156,13 +52,7 @@ pub async fn maven_dependency_declaration(
pom: String,
dependency: ArtifactKey,
) -> IdeResult<Location> {
let project = state.project(window.label())?;
tauri::async_runtime::spawn_blocking(move || {
let (path, _, range) = declaring_pom(&project, &pom, &dependency)?;
Ok(Location::file(path, range))
})
.await
.map_err(join_err)?
ide_host::ops::build::maven_dependency_declaration(&state.host, window.label(), pom, dependency).await
}
/// Adds an `<exclusion>` of `exclusion` to the declaration of the direct dependency
@@ -177,22 +67,7 @@ pub async fn maven_exclude_dependency(
dependency: ArtifactKey,
exclusion: ArtifactKey,
) -> IdeResult<Location> {
let project = state.project(window.label())?;
tauri::async_runtime::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)?
ide_host::ops::build::maven_exclude_dependency(&state.host, window.label(), pom, dependency, exclusion).await
}
/// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
@@ -210,29 +85,14 @@ pub async fn build_run(
clear: Vec<std::path::PathBuf>,
on_event: Channel<BuildEvent>,
) -> IdeResult<BuildResult> {
let project = state.project(window.label())?;
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
let settings = project.settings.effective();
let token = state.begin_request(request);
let result = tauri::async_runtime::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)
})
ide_host::ops::build::build_run(
&state.host,
window.label(),
kind,
file,
request,
clear,
crate::commands::sender(on_event),
)
.await
.map_err(join_err);
state.end_request(request);
result?
}
+11 -8
View File
@@ -1,27 +1,30 @@
//! Raw file bytes for the image viewer and hex view (see `ide_vfs::bytes`).
//! IPC commands of `ide_host::ops::bytes` (the shared implementation).
use crate::commands::doc::blocking;
use crate::state::AppState;
use ide_core::IdeResult;
#[allow(unused_imports)]
pub use ide_host::ops::bytes::*;
use ide_vfs::{ByteRange, FileProbe};
use std::path::PathBuf;
use tauri::State;
/// Size and text/binary classification, from the file's head only.
#[tauri::command]
#[specta::specta]
pub async fn fs_probe(path: PathBuf) -> IdeResult<FileProbe> {
blocking(move || ide_vfs::probe(&path)).await
pub async fn fs_probe(state: State<'_, AppState>, path: PathBuf) -> IdeResult<FileProbe> {
ide_host::ops::bytes::fs_probe(&state.host, "", path).await
}
/// Up to `len` bytes from `offset` (capped per call), base64-encoded.
#[tauri::command]
#[specta::specta]
pub async fn fs_read_range(path: PathBuf, offset: u64, len: u32) -> IdeResult<ByteRange> {
blocking(move || ide_vfs::read_range(&path, offset, len)).await
pub async fn fs_read_range(state: State<'_, AppState>, path: PathBuf, offset: u64, len: u32) -> IdeResult<ByteRange> {
ide_host::ops::bytes::fs_read_range(&state.host, "", path, offset, len).await
}
/// An image file as a `data:` URL.
#[tauri::command]
#[specta::specta]
pub async fn fs_read_image(path: PathBuf) -> IdeResult<String> {
blocking(move || ide_vfs::read_data_url(&path)).await
pub async fn fs_read_image(state: State<'_, AppState>, path: PathBuf) -> IdeResult<String> {
ide_host::ops::bytes::fs_read_image(&state.host, "", path).await
}
+8 -20
View File
@@ -1,18 +1,13 @@
//! Run with Coverage: launch plans and coverage reports.
//! IPC commands of `ide_host::ops::coverage` (the shared implementation).
use crate::state::AppState;
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_core::{IdeResult, IpcPath};
use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite};
#[allow(unused_imports)]
pub use ide_host::ops::coverage::*;
use ide_run::RunConfig;
use tauri::{State, Window};
/// Output directories kept per kind (older runs' data is deleted).
pub(crate) const KEEP_RUNS: usize = 10;
pub(crate) async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tauri::async_runtime::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.
#[tauri::command]
@@ -22,20 +17,14 @@ pub async fn coverage_prepare(
state: State<'_, AppState>,
config: RunConfig,
) -> IdeResult<CoveragePrepared> {
let project = state.project(window.label())?;
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
ide_host::ops::coverage::coverage_prepare(&state.host, window.label(), config).await
}
/// Reads the report a coverage run left behind.
#[tauri::command]
#[specta::specta]
pub async fn coverage_collect(report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
blocking(move || ide_coverage::collect(&report)).await
pub async fn coverage_collect(state: State<'_, AppState>, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
ide_host::ops::coverage::coverage_collect(&state.host, "", report).await
}
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
@@ -43,6 +32,5 @@ pub async fn coverage_collect(report: CoverageReportSpec) -> IdeResult<CoverageS
#[tauri::command]
#[specta::specta]
pub async fn coverage_import(window: Window, state: State<'_, AppState>, path: IpcPath) -> IdeResult<CoverageSuite> {
let root = state.project(window.label())?.root.clone();
blocking(move || ide_coverage::import(&path, &root)).await
ide_host::ops::coverage::coverage_import(&state.host, window.label(), path).await
}
+35 -46
View File
@@ -1,6 +1,10 @@
//! IPC commands of `ide_host::ops::debug` (the shared implementation).
use crate::state::AppState;
use ide_core::{DebugSessionId, IdeResult};
use ide_dap::*;
#[allow(unused_imports)]
pub use ide_host::ops::debug::*;
use std::path::PathBuf;
use tauri::{State, Window};
@@ -12,18 +16,7 @@ pub async fn debug_start(
state: State<'_, AppState>,
config: LaunchConfig,
) -> IdeResult<DebugSessionId> {
let label = window.label();
let owner = SessionOwner::new(AppState::owner_window(label), state.window_sink(label));
let settings = match state.project(label) {
Ok(project) => project.settings.effective(),
Err(_) => state.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())
};
state.debugger.start_with(config, owner, custom).await
ide_host::ops::debug::debug_start(&state.host, window.label(), config).await
}
#[tauri::command]
@@ -33,13 +26,13 @@ pub async fn debug_set_breakpoints(
path: PathBuf,
breakpoints: Vec<Breakpoint>,
) -> IdeResult<Vec<BreakpointState>> {
state.debugger.set_breakpoints(path, breakpoints).await
ide_host::ops::debug::debug_set_breakpoints(&state.host, "", path, breakpoints).await
}
#[tauri::command]
#[specta::specta]
pub async fn debug_threads(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
state.debugger.threads(session).await
ide_host::ops::debug::debug_threads(&state.host, "", session).await
}
#[tauri::command]
@@ -49,13 +42,13 @@ pub async fn debug_stack_trace(
session: DebugSessionId,
thread_id: i64,
) -> IdeResult<Vec<StackFrame>> {
state.debugger.stack_trace(session, thread_id).await
ide_host::ops::debug::debug_stack_trace(&state.host, "", session, thread_id).await
}
#[tauri::command]
#[specta::specta]
pub async fn debug_scopes(state: State<'_, AppState>, session: DebugSessionId, frame_id: i64) -> IdeResult<Vec<Scope>> {
state.debugger.scopes(session, frame_id).await
ide_host::ops::debug::debug_scopes(&state.host, "", session, frame_id).await
}
#[tauri::command]
@@ -65,7 +58,7 @@ pub async fn debug_variables(
session: DebugSessionId,
reference: i64,
) -> IdeResult<Vec<Variable>> {
state.debugger.variables(session, reference).await
ide_host::ops::debug::debug_variables(&state.host, "", session, reference).await
}
#[tauri::command]
@@ -76,19 +69,19 @@ pub async fn debug_evaluate(
frame_id: Option<i64>,
expression: String,
) -> IdeResult<Variable> {
state.debugger.evaluate(session, frame_id, expression).await
ide_host::ops::debug::debug_evaluate(&state.host, "", session, frame_id, expression).await
}
#[tauri::command]
#[specta::specta]
pub async fn debug_resume(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
state.debugger.resume(session, thread_id).await
ide_host::ops::debug::debug_resume(&state.host, "", session, thread_id).await
}
#[tauri::command]
#[specta::specta]
pub async fn debug_pause(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
state.debugger.pause(session, thread_id).await
ide_host::ops::debug::debug_pause(&state.host, "", session, thread_id).await
}
#[tauri::command]
@@ -99,7 +92,7 @@ pub async fn debug_step(
thread_id: i64,
kind: StepKind,
) -> IdeResult<()> {
state.debugger.step(session, thread_id, kind).await
ide_host::ops::debug::debug_step(&state.host, "", session, thread_id, kind).await
}
#[tauri::command]
@@ -112,13 +105,13 @@ pub async fn debug_run_to(
line: u32,
force: bool,
) -> IdeResult<()> {
state.debugger.run_to(session, thread_id, path, line, force).await
ide_host::ops::debug::debug_run_to(&state.host, "", session, thread_id, path, line, force).await
}
#[tauri::command]
#[specta::specta]
pub async fn debug_stop(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<()> {
state.debugger.stop(session).await
ide_host::ops::debug::debug_stop(&state.host, "", session).await
}
/// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`.
@@ -131,27 +124,27 @@ pub async fn debug_evaluate_in(
expression: String,
context: Option<String>,
) -> IdeResult<Variable> {
state.debugger.evaluate_in(session, frame_id, expression, context).await
ide_host::ops::debug::debug_evaluate_in(&state.host, "", session, frame_id, expression, context).await
}
/// Last known verification states of a file's breakpoints (refetch on `breakpointsChanged`).
#[tauri::command]
#[specta::specta]
pub fn debug_breakpoint_states(state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<BreakpointState>> {
Ok(state.debugger.breakpoint_states(&path))
ide_host::ops::debug::debug_breakpoint_states(&state.host, "", path)
}
/// This window's live sessions, including adapter-started child sessions.
#[tauri::command]
#[specta::specta]
pub fn debug_sessions(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<SessionInfo>> {
Ok(state.debugger.sessions(Some(AppState::owner_window(window.label()))))
ide_host::ops::debug::debug_sessions(&state.host, window.label())
}
#[tauri::command]
#[specta::specta]
pub fn debug_exception_filters(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<SessionExceptionFilters>> {
Ok(state.debugger.exception_filters(Some(AppState::owner_window(window.label()))))
ide_host::ops::debug::debug_exception_filters(&state.host, window.label())
}
#[tauri::command]
@@ -160,7 +153,7 @@ pub async fn debug_set_exception_breakpoints(
state: State<'_, AppState>,
choices: std::collections::BTreeMap<String, bool>,
) -> IdeResult<()> {
state.debugger.set_exception_breakpoints(choices).await
ide_host::ops::debug::debug_set_exception_breakpoints(&state.host, "", choices).await
}
#[tauri::command]
@@ -174,7 +167,8 @@ pub async fn debug_set_variable(
frame_id: Option<i64>,
value: String,
) -> IdeResult<Variable> {
state.debugger.set_variable(session, container, name, evaluate_name, frame_id, value).await
ide_host::ops::debug::debug_set_variable(&state.host, "", session, container, name, evaluate_name, frame_id, value)
.await
}
/// Force Step Over: step over with every breakpoint suspended until the step ends.
@@ -185,7 +179,7 @@ pub async fn debug_force_step_over(
session: DebugSessionId,
thread_id: i64,
) -> IdeResult<()> {
state.debugger.force_step_over(session, thread_id).await
ide_host::ops::debug::debug_force_step_over(&state.host, "", session, thread_id).await
}
/// Smart Step Into: the calls on the current line of `frame_id`.
@@ -196,7 +190,7 @@ pub async fn debug_step_in_targets(
session: DebugSessionId,
frame_id: i64,
) -> IdeResult<Vec<StepInTarget>> {
state.debugger.step_in_targets(session, frame_id).await
ide_host::ops::debug::debug_step_in_targets(&state.host, "", session, frame_id).await
}
#[tauri::command]
@@ -207,20 +201,20 @@ pub async fn debug_step_in_target(
thread_id: i64,
target_id: i64,
) -> IdeResult<()> {
state.debugger.step_in_target(session, thread_id, target_id).await
ide_host::ops::debug::debug_step_in_target(&state.host, "", session, thread_id, target_id).await
}
/// Reset Frame (Drop Frame).
#[tauri::command]
#[specta::specta]
pub async fn debug_restart_frame(state: State<'_, AppState>, session: DebugSessionId, frame_id: i64) -> IdeResult<()> {
state.debugger.restart_frame(session, frame_id).await
ide_host::ops::debug::debug_restart_frame(&state.host, "", session, frame_id).await
}
#[tauri::command]
#[specta::specta]
pub fn debug_capabilities(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
state.debugger.capabilities(session)
ide_host::ops::debug::debug_capabilities(&state.host, "", session)
}
/// Method breakpoints (all files); states aligned with `breakpoints`.
@@ -230,7 +224,7 @@ pub async fn debug_set_function_breakpoints(
state: State<'_, AppState>,
breakpoints: Vec<FunctionBreakpoint>,
) -> IdeResult<Vec<BreakpointState>> {
state.debugger.set_function_breakpoints(breakpoints).await
ide_host::ops::debug::debug_set_function_breakpoints(&state.host, "", breakpoints).await
}
/// Field watchpoints; states aligned with `breakpoints`.
@@ -240,7 +234,7 @@ pub async fn debug_set_data_breakpoints(
state: State<'_, AppState>,
breakpoints: Vec<DataBreakpoint>,
) -> IdeResult<Vec<BreakpointState>> {
state.debugger.set_data_breakpoints(breakpoints).await
ide_host::ops::debug::debug_set_data_breakpoints(&state.host, "", breakpoints).await
}
#[tauri::command]
@@ -252,16 +246,14 @@ pub async fn debug_data_breakpoint_info(
name: String,
frame_id: Option<i64>,
) -> IdeResult<DataBreakpointInfo> {
state.debugger.data_breakpoint_info(session, container, name, frame_id).await
ide_host::ops::debug::debug_data_breakpoint_info(&state.host, "", session, container, name, frame_id).await
}
/// Attach to Process: the user's processes a debugger can attach to.
#[tauri::command]
#[specta::specta]
pub async fn debug_attachable_processes() -> IdeResult<Vec<attach::AttachableProcess>> {
tauri::async_runtime::spawn_blocking(attach::attachable_processes)
.await
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
pub async fn debug_attachable_processes(state: State<'_, AppState>) -> IdeResult<Vec<attach::AttachableProcess>> {
ide_host::ops::debug::debug_attachable_processes(&state.host, "").await
}
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
@@ -274,15 +266,12 @@ pub async fn debug_listen_for_jvm(
host: String,
port: u16,
) -> IdeResult<u16> {
let token = state.begin_request(request);
let result = jdwp::listen_for_jvm(&host, port, token).await;
state.end_request(request);
result
ide_host::ops::debug::debug_listen_for_jvm(&state.host, "", request, host, port).await
}
/// HotSwap (Reload Changed Classes) in a java-debug session.
#[tauri::command]
#[specta::specta]
pub async fn debug_redefine_classes(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<HotSwapResult> {
state.debugger.redefine_classes(session).await
ide_host::ops::debug::debug_redefine_classes(&state.host, "", session).await
}
+182
View File
@@ -0,0 +1,182 @@
use crate::state::AppState;
use ide_core::{IdeEvent, IdeResult, IpcPath, RequestId, SharedSink, TaskId};
use ide_remote::{DeployedPath, SyncEntry, SyncStep, TransferReport};
use ide_ssh::RemoteEntry;
use std::path::PathBuf;
use tauri::{State, Window};
/// Reports a transfer as a background task (status bar progress).
fn progress(sink: SharedSink, title: &str) -> (impl FnMut(&str, usize, usize) + Send, impl FnOnce()) {
let id = TaskId::next();
let title = title.to_owned();
let (s, t) = (sink.clone(), title.clone());
let report = move |path: &str, i: usize, n: usize| {
let fraction = if n == 0 { None } else { Some(i as f32 / n as f32) };
s.emit(IdeEvent::Task { id, title: t.clone(), message: Some(path.to_owned()), fraction, done: false });
};
let done = move || sink.emit(IdeEvent::Task { id, title, message: None, fraction: Some(1.0), done: true });
(report, done)
}
fn local(paths: Vec<IpcPath>) -> Vec<PathBuf> {
paths.into_iter().map(|p| p.to_path_buf()).collect()
}
/// Tools › Deployment › Upload to…: files and folders to `server` (default server when `None`).
#[tauri::command]
#[specta::specta]
pub async fn deploy_upload(
window: Window,
state: State<'_, AppState>,
paths: Vec<IpcPath>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window.label())?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Uploading");
let r = state
.remote
.upload(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
.await;
done();
state.end_request(request);
r
}
/// Download from…: the remote counterparts of files and folders.
#[tauri::command]
#[specta::specta]
pub async fn deploy_download(
window: Window,
state: State<'_, AppState>,
paths: Vec<IpcPath>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window.label())?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Downloading");
let r = state
.remote
.download(&project.settings.effective(), &project.root, server.as_deref(), &local(paths), token, &mut report)
.await;
done();
state.end_request(request);
r
}
/// Deletes the remote counterparts of files and folders.
#[tauri::command]
#[specta::specta]
pub async fn deploy_delete_remote(
window: Window,
state: State<'_, AppState>,
paths: Vec<IpcPath>,
server: Option<String>,
) -> IdeResult<TransferReport> {
let project = state.project(window.label())?;
state.remote.delete_remote(&project.settings.effective(), &project.root, server.as_deref(), &local(paths)).await
}
/// Sync with Deployed: local and remote files under `path`, side by side.
#[tauri::command]
#[specta::specta]
pub async fn deploy_compare(
window: Window,
state: State<'_, AppState>,
path: IpcPath,
server: Option<String>,
request: RequestId,
) -> IdeResult<Vec<SyncEntry>> {
let project = state.project(window.label())?;
let token = state.begin_request(request);
let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await;
state.end_request(request);
r
}
/// Applies the Sync with Deployed choices.
#[tauri::command]
#[specta::specta]
pub async fn deploy_sync(
window: Window,
state: State<'_, AppState>,
steps: Vec<SyncStep>,
server: Option<String>,
request: RequestId,
) -> IdeResult<TransferReport> {
let project = state.project(window.label())?;
let token = state.begin_request(request);
let (mut report, done) = progress(project.sink.clone(), "Synchronizing");
let r = state
.remote
.sync(&project.settings.effective(), &project.root, server.as_deref(), &steps, token, &mut report)
.await;
done();
state.end_request(request);
r
}
/// Where `path` is deployed (remote path, web URL); `None` when not mapped.
#[tauri::command]
#[specta::specta]
pub async fn deploy_info(
window: Window,
state: State<'_, AppState>,
path: IpcPath,
server: Option<String>,
) -> IdeResult<Option<DeployedPath>> {
let project = state.project(window.label())?;
state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path).await
}
/// Remote Host tool window: the server root of `server`, resolved on the host.
#[tauri::command]
#[specta::specta]
pub async fn deploy_server_root(window: Window, state: State<'_, AppState>, server: String) -> IdeResult<String> {
let settings = match state.project(window.label()) {
Ok(p) => p.settings.effective(),
Err(_) => state.global_settings.effective(),
};
state.remote.server_root(&settings, &server).await
}
/// Entries of a remote folder of SSH configuration `ssh` (`~`: the login directory).
#[tauri::command]
#[specta::specta]
pub async fn remote_list(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<Vec<RemoteEntry>> {
state.remote.list(&ssh, &path).await
}
/// Opens a remote file for editing: a local copy, uploaded back on save.
#[tauri::command]
#[specta::specta]
pub async fn remote_open(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<IpcPath> {
Ok(state.remote.open_remote(&ssh, &path).await?.into())
}
/// The remote file a local copy stands for: (SSH configuration id, remote path).
#[tauri::command]
#[specta::specta]
pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(String, String)> {
state.remote.remote_of(&path)
}
#[tauri::command]
#[specta::specta]
pub async fn remote_delete(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> {
state.remote.remote_delete(&ssh, &path).await
}
#[tauri::command]
#[specta::specta]
pub async fn remote_mkdir(state: State<'_, AppState>, ssh: String, path: String) -> IdeResult<()> {
state.remote.remote_mkdir(&ssh, &path).await
}
#[tauri::command]
#[specta::specta]
pub async fn remote_rename(state: State<'_, AppState>, ssh: String, from: String, to: String) -> IdeResult<()> {
state.remote.remote_rename(&ssh, &from, &to).await
}
+26 -83
View File
@@ -1,46 +1,27 @@
//! IPC commands of `ide_host::ops::doc` (the shared implementation).
use crate::state::AppState;
use ide_core::{ChangeSet, DocId, IdeError, IdeResult, OutlineItem, Range};
use ide_core::{ChangeSet, DocId, IdeResult, OutlineItem, Range};
#[allow(unused_imports)]
pub use ide_host::ops::doc::*;
use ide_language::{FoldRange, SyntaxHighlights};
use ide_text::LineEnding;
use ide_workspace::{DocumentInfo, HistoryEntry};
use std::path::PathBuf;
use tauri::{State, Window};
/// 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(crate) async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tauri::async_runtime::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Opens a document. Async: reading and decoding a large file must not block
/// the main thread (synchronous commands run on it).
#[tauri::command]
#[specta::specta]
pub async fn doc_open(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<DocumentInfo> {
let project = state.project(window.label())?;
blocking(move || {
let info = project.documents.open(&path)?;
project.index.mark_recent(&path);
Ok(info)
})
.await
}
/// Opens a read-only virtual document (see `lsp_virtual_document`) as an
/// editor buffer, so syntax features and language-server requests work in it.
#[tauri::command]
#[specta::specta]
pub async fn doc_open_virtual(window: Window, state: State<'_, AppState>, uri: String) -> IdeResult<DocumentInfo> {
let project = state.project(window.label())?;
let vd = project.lsp.virtual_document(&uri).await?;
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
ide_host::ops::doc::open(&state.host, window.label(), path).await
}
#[tauri::command]
#[specta::specta]
pub fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> {
state.project(window.label())?.documents.close(doc);
Ok(())
ide_host::ops::doc::close(&state.host, window.label(), doc)
}
/// Applies a CodeMirror change set; returns the new version. Rejects with
@@ -54,28 +35,32 @@ pub fn doc_edit(
base_version: u32,
changes: ChangeSet,
) -> IdeResult<u32> {
state.project(window.label())?.documents.edit(doc, base_version, changes)
ide_host::ops::doc::edit(&state.host, window.label(), doc, base_version, changes)
}
#[tauri::command]
#[specta::specta]
pub async fn doc_save(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.documents.save(doc)).await
ide_host::ops::doc::save(&state.host, window.label(), doc).await
}
#[tauri::command]
#[specta::specta]
pub async fn doc_save_all(window: Window, state: State<'_, AppState>) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.documents.save_all()).await
ide_host::ops::doc::save_all(&state.host, window.label()).await
}
#[tauri::command]
#[specta::specta]
pub async fn doc_snapshot(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<DocumentInfo> {
let project = state.project(window.label())?;
blocking(move || project.documents.snapshot(doc)).await
ide_host::ops::doc::snapshot(&state.host, window.label(), doc).await
}
/// Opens a read-only virtual document (see `lsp_virtual_document`) as an
/// editor buffer, so syntax features and language-server requests work in it.
#[tauri::command]
#[specta::specta]
pub async fn doc_open_virtual(window: Window, state: State<'_, AppState>, uri: String) -> IdeResult<DocumentInfo> {
ide_host::ops::doc::doc_open_virtual(&state.host, window.label(), uri).await
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
@@ -88,7 +73,7 @@ pub fn doc_set_encoding(
encoding: String,
reload: bool,
) -> IdeResult<String> {
state.project(window.label())?.documents.set_encoding(doc, &encoding, reload)
ide_host::ops::doc::doc_set_encoding(&state.host, window.label(), doc, encoding, reload)
}
#[tauri::command]
@@ -99,7 +84,7 @@ pub fn doc_set_line_ending(
doc: DocId,
line_ending: LineEnding,
) -> IdeResult<()> {
state.project(window.label())?.documents.set_line_ending(doc, line_ending)
ide_host::ops::doc::doc_set_line_ending(&state.host, window.label(), doc, line_ending)
}
/// Structure view: LSP document symbols when available (with tree-sitter
@@ -109,41 +94,13 @@ pub fn doc_set_line_ending(
#[tauri::command]
#[specta::specta]
pub async fn doc_outline(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
let project = state.project(window.label())?;
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
ide_host::ops::doc::doc_outline(&state.host, window.label(), doc).await
}
#[tauri::command]
#[specta::specta]
pub async fn doc_folding(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<Vec<FoldRange>> {
let project = state.project(window.label())?;
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
ide_host::ops::doc::doc_folding(&state.host, window.label(), doc).await
}
/// Tree-sitter highlighting for languages the UI has no CodeMirror mode for;
@@ -151,15 +108,7 @@ pub async fn doc_folding(window: Window, state: State<'_, AppState>, doc: DocId)
#[tauri::command]
#[specta::specta]
pub async fn doc_highlights(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<SyntaxHighlights> {
let project = state.project(window.label())?;
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
ide_host::ops::doc::doc_highlights(&state.host, window.label(), doc).await
}
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
@@ -172,23 +121,17 @@ pub async fn doc_selection_ranges(
doc: DocId,
offset: u32,
) -> IdeResult<Vec<Range>> {
let project = state.project(window.label())?;
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
ide_host::ops::doc::doc_selection_ranges(&state.host, window.label(), doc, offset).await
}
#[tauri::command]
#[specta::specta]
pub fn history_list(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
state.project(window.label())?.history.list(&path)
ide_host::ops::doc::history_list(&state.host, window.label(), path)
}
#[tauri::command]
#[specta::specta]
pub fn history_content(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<String> {
state.project(window.label())?.history.content(&id)
ide_host::ops::doc::history_content(&state.host, window.label(), id)
}
+8 -60
View File
@@ -1,6 +1,6 @@
use crate::commands::doc::blocking;
use crate::state::AppState;
use ide_core::IdeResult;
use ide_host::ops::fs;
use ide_vfs::FileEntry;
use std::path::PathBuf;
use tauri::{State, Window};
@@ -8,23 +8,7 @@ use tauri::{State, Window};
#[tauri::command]
#[specta::specta]
pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
let excludes = state
.project(window.label())
.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)
fs::read_dir(&state.host, window.label(), path)
}
/// Creates an empty file; inside a project, honours the EditorConfig
@@ -32,24 +16,7 @@ pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) ->
#[tauri::command]
#[specta::specta]
pub fn fs_create_file(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
match state.project(window.label()) {
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),
}
fs::create_file(&state.host, window.label(), path)
}
#[tauri::command]
@@ -69,34 +36,20 @@ pub fn fs_rename(from: PathBuf, to: PathBuf) -> IdeResult<()> {
#[tauri::command]
#[specta::specta]
pub fn fs_set_writable(window: Window, state: State<'_, AppState>, path: PathBuf, writable: bool) -> IdeResult<bool> {
match state.project(window.label()) {
Ok(p) => p.documents.set_writable(&path, writable),
Err(_) => ide_vfs::set_writable(&path, writable).map(|()| !writable),
}
fs::set_writable(&state.host, window.label(), path, writable)
}
/// Moving to the trash can take a while (large trees): kept off the main thread.
#[tauri::command]
#[specta::specta]
pub async fn fs_delete(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
// Local History keeps the contents so deleted files can be restored.
let history = state.project(window.label()).ok().map(|p| p.history.clone());
blocking(move || {
if let Some(h) = history {
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
}
ide_vfs::delete(&path)
})
.await
fs::delete(&state.host, window.label(), path).await
}
/// Files of a deleted folder snapshotted in Local History.
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
#[tauri::command]
#[specta::specta]
pub async fn fs_copy(from: PathBuf, to: PathBuf) -> IdeResult<()> {
blocking(move || ide_vfs::copy(&from, &to)).await
pub async fn fs_copy(state: State<'_, AppState>, from: PathBuf, to: PathBuf) -> IdeResult<()> {
fs::copy(&state.host, from, to).await
}
/// Reads a text file without opening it as a document (previews, quick
@@ -104,10 +57,5 @@ pub async fn fs_copy(from: PathBuf, to: PathBuf) -> IdeResult<()> {
#[tauri::command]
#[specta::specta]
pub async fn fs_read_text(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<String> {
let project = state.project(window.label()).ok();
blocking(move || match project {
Some(p) => p.text_of(&path),
None => Ok(ide_vfs::load_text(&path)?.text),
})
.await
fs::read_text(&state.host, window.label(), path).await
}
+6 -26
View File
@@ -1,13 +1,13 @@
//! Local History beyond single files: folders, labels, restoring deleted files.
//! IPC commands of `ide_host::ops::history` (the shared implementation).
use crate::state::AppState;
use ide_core::IdeResult;
#[allow(unused_imports)]
pub use ide_host::ops::history::*;
use ide_workspace::DirectoryHistory;
use std::path::PathBuf;
use tauri::{State, Window};
use super::doc::blocking;
/// Show History on a folder: snapshots of the files under it (deleted ones included) and labels.
#[tauri::command]
#[specta::specta]
@@ -16,16 +16,14 @@ pub async fn history_list_dir(
state: State<'_, AppState>,
path: PathBuf,
) -> IdeResult<DirectoryHistory> {
let project = state.project(window.label())?;
blocking(move || project.history.list_dir(&path)).await
ide_host::ops::history::history_list_dir(&state.host, window.label(), path).await
}
/// Local History › Put Label…: a named point in the history of every file.
#[tauri::command]
#[specta::specta]
pub fn history_put_label(window: Window, state: State<'_, AppState>, label: String) -> IdeResult<()> {
let project = state.project(window.label())?;
project.history.put_label(&project.root, &label)
ide_host::ops::history::history_put_label(&state.host, window.label(), label)
}
/// Revert to a snapshot: writes its content to `path` (re-creating a deleted
@@ -33,23 +31,5 @@ pub fn history_put_label(window: Window, state: State<'_, AppState>, label: Stri
#[tauri::command]
#[specta::specta]
pub async fn history_restore(window: Window, state: State<'_, AppState>, path: PathBuf, id: String) -> IdeResult<()> {
let project = state.project(window.label())?;
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
ide_host::ops::history::history_restore(&state.host, window.label(), path, id).await
}
+7 -58
View File
@@ -1,9 +1,9 @@
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
//! IPC commands of `ide_host::ops::java` (the shared implementation).
use super::build::load;
use crate::state::AppState;
use ide_build::compile;
use ide_core::{CancellationToken, IdeError, IdeResult};
use ide_core::IdeResult;
#[allow(unused_imports)]
pub use ide_host::ops::java::*;
use ide_lsp::jdtls::JavaLaunchInfo;
use std::path::PathBuf;
use tauri::{State, Window};
@@ -20,63 +20,14 @@ pub async fn java_resolve_launch(
test: bool,
build: bool,
) -> IdeResult<JavaLaunchInfo> {
let project = state.project(window.label())?;
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());
tauri::async_runtime::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(&state.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 =
tauri::async_runtime::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)
ide_host::ops::java::java_resolve_launch(&state.host, window.label(), module, test, build).await
}
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
#[tauri::command]
#[specta::specta]
pub async fn java_debug_server(window: Window, state: State<'_, AppState>, module: PathBuf) -> IdeResult<u16> {
let project = state.project(window.label())?;
let module = project.root.join(module);
project.lsp.java_debug_server(&module).await
ide_host::ops::java::java_debug_server(&state.host, window.label(), module).await
}
/// Incremental build of the jdtls workspace serving `module` (e.g. before HotSwap).
@@ -87,7 +38,5 @@ pub async fn java_build_workspace(
state: State<'_, AppState>,
module: PathBuf,
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
let project = state.project(window.label())?;
let module = project.root.join(module);
project.lsp.java_build(&module).await
ide_host::ops::java::java_build_workspace(&state.host, window.label(), module).await
}
+42 -91
View File
@@ -1,14 +1,12 @@
use crate::state::{AppState, apply_workspace_edit as apply_edit};
//! IPC commands of `ide_host::ops::lsp` (the shared implementation).
use crate::state::AppState;
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
#[allow(unused_imports)]
pub use ide_host::ops::lsp::*;
use ide_lsp::types::*;
use tauri::{State, Window};
macro_rules! project {
($window:expr, $state:expr) => {
$state.project($window.label())?
};
}
#[tauri::command]
#[specta::specta]
pub async fn lsp_completion(
@@ -19,11 +17,7 @@ pub async fn lsp_completion(
pos: Pos,
trigger: Option<String>,
) -> IdeResult<CompletionList> {
let lsp = project!(window, state).lsp.clone();
let cancel = state.begin_request(request);
let result = lsp.completion(doc, pos, trigger, cancel).await;
state.end_request(request);
result
ide_host::ops::lsp::lsp_completion(&state.host, window.label(), request, doc, pos, trigger).await
}
/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing.
@@ -37,11 +31,7 @@ pub async fn lsp_inline_completion(
pos: Pos,
automatic: bool,
) -> IdeResult<Vec<InlineCompletionItem>> {
let lsp = project!(window, state).lsp.clone();
let cancel = state.begin_request(request);
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
state.end_request(request);
result
ide_host::ops::lsp::lsp_inline_completion(&state.host, window.label(), request, doc, pos, automatic).await
}
#[tauri::command]
@@ -52,7 +42,7 @@ pub async fn lsp_resolve_completion(
doc: DocId,
item: CompletionItem,
) -> IdeResult<CompletionItem> {
project!(window, state).lsp.resolve_completion(doc, item).await
ide_host::ops::lsp::lsp_resolve_completion(&state.host, window.label(), doc, item).await
}
#[tauri::command]
@@ -64,11 +54,7 @@ pub async fn lsp_hover(
doc: DocId,
pos: Pos,
) -> IdeResult<Option<Hover>> {
let lsp = project!(window, state).lsp.clone();
let cancel = state.begin_request(request);
let result = lsp.hover(doc, pos, cancel).await;
state.end_request(request);
result
ide_host::ops::lsp::lsp_hover(&state.host, window.label(), request, doc, pos).await
}
#[tauri::command]
@@ -79,7 +65,7 @@ pub async fn lsp_signature_help(
doc: DocId,
pos: Pos,
) -> IdeResult<Option<SignatureHelp>> {
project!(window, state).lsp.signature_help(doc, pos).await
ide_host::ops::lsp::lsp_signature_help(&state.host, window.label(), doc, pos).await
}
#[tauri::command]
@@ -91,7 +77,7 @@ pub async fn lsp_goto(
pos: Pos,
kind: GotoKind,
) -> IdeResult<Vec<Location>> {
project!(window, state).lsp.goto(doc, pos, kind).await
ide_host::ops::lsp::lsp_goto(&state.host, window.label(), doc, pos, kind).await
}
/// Implementations of the symbols at `positions`, one list per position (the
@@ -106,11 +92,7 @@ pub async fn lsp_implementations(
doc: DocId,
positions: Vec<Pos>,
) -> IdeResult<Vec<Vec<Location>>> {
let lsp = project!(window, state).lsp.clone();
let cancel = state.begin_request(request);
let result = lsp.implementations(doc, &positions, cancel).await;
state.end_request(request);
result
ide_host::ops::lsp::lsp_implementations(&state.host, window.label(), request, doc, positions).await
}
/// Contents of a virtual document (library class, decompiled code) that a
@@ -122,7 +104,7 @@ pub async fn lsp_virtual_document(
state: State<'_, AppState>,
uri: String,
) -> IdeResult<VirtualDocument> {
project!(window, state).lsp.virtual_document(&uri).await
ide_host::ops::lsp::lsp_virtual_document(&state.host, window.label(), uri).await
}
/// References of the symbol at `pos`. `project_wide` also searches project files
@@ -139,11 +121,16 @@ pub async fn lsp_references(
include_declaration: bool,
project_wide: bool,
) -> IdeResult<References> {
let lsp = project!(window, state).lsp.clone();
let cancel = state.begin_request(request);
let result = lsp.references(doc, pos, include_declaration, project_wide, cancel).await;
state.end_request(request);
result
ide_host::ops::lsp::lsp_references(
&state.host,
window.label(),
request,
doc,
pos,
include_declaration,
project_wide,
)
.await
}
#[tauri::command]
@@ -154,7 +141,7 @@ pub async fn lsp_document_highlights(
doc: DocId,
pos: Pos,
) -> IdeResult<Vec<Highlight>> {
project!(window, state).lsp.document_highlights(doc, pos).await
ide_host::ops::lsp::lsp_document_highlights(&state.host, window.label(), doc, pos).await
}
#[tauri::command]
@@ -165,11 +152,7 @@ pub async fn lsp_workspace_symbols(
request: RequestId,
query: String,
) -> IdeResult<Vec<WorkspaceSymbol>> {
let lsp = project!(window, state).lsp.clone();
let cancel = state.begin_request(request);
let result = lsp.workspace_symbols(&query, cancel).await;
state.end_request(request);
result
ide_host::ops::lsp::lsp_workspace_symbols(&state.host, window.label(), request, query).await
}
#[tauri::command]
@@ -181,7 +164,7 @@ pub async fn lsp_code_actions(
range: Range,
only: Vec<String>,
) -> IdeResult<Vec<CodeAction>> {
project!(window, state).lsp.code_actions(doc, range, only).await
ide_host::ops::lsp::lsp_code_actions(&state.host, window.label(), doc, range, only).await
}
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
@@ -195,11 +178,7 @@ pub async fn lsp_code_action_query(
range: Range,
only: Vec<String>,
) -> IdeResult<CodeActionQuery> {
let lsp = project!(window, state).lsp.clone();
let cancel = state.begin_request(request);
let result = lsp.code_action_query(doc, range, only, cancel).await;
state.end_request(request);
result
ide_host::ops::lsp::lsp_code_action_query(&state.host, window.label(), request, doc, range, only).await
}
/// Resolves a code action's edit without applying it (intention preview).
@@ -211,10 +190,7 @@ pub async fn lsp_resolve_code_action(
doc: DocId,
action: CodeAction,
) -> IdeResult<CodeAction> {
if action.edit.is_some() {
return Ok(action);
}
project!(window, state).lsp.resolve_code_action(doc, action).await
ide_host::ops::lsp::lsp_resolve_code_action(&state.host, window.label(), doc, action).await
}
/// Resolves (if needed), applies the edit, then runs the command of a code action.
@@ -226,19 +202,7 @@ pub async fn lsp_apply_code_action(
doc: DocId,
action: CodeAction,
) -> IdeResult<()> {
let project = project!(window, state);
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(())
ide_host::ops::lsp::lsp_apply_code_action(&state.host, window.label(), doc, action).await
}
#[tauri::command]
@@ -249,7 +213,7 @@ pub async fn lsp_prepare_rename(
doc: DocId,
pos: Pos,
) -> IdeResult<Option<Range>> {
project!(window, state).lsp.prepare_rename(doc, pos).await
ide_host::ops::lsp::lsp_prepare_rename(&state.host, window.label(), doc, pos).await
}
/// Computes the rename edit without applying it, so the UI can preview conflicts.
@@ -262,7 +226,7 @@ pub async fn lsp_rename(
pos: Pos,
new_name: String,
) -> IdeResult<WorkspaceEdit> {
project!(window, state).lsp.rename(doc, pos, new_name).await
ide_host::ops::lsp::lsp_rename(&state.host, window.label(), doc, pos, new_name).await
}
#[tauri::command]
@@ -274,7 +238,7 @@ pub async fn lsp_format(
range: Option<Range>,
options: FormatOptions,
) -> IdeResult<Vec<TextEdit>> {
project!(window, state).lsp.format(doc, range, options).await
ide_host::ops::lsp::lsp_format(&state.host, window.label(), doc, range, options).await
}
#[tauri::command]
@@ -285,7 +249,7 @@ pub async fn lsp_inlay_hints(
doc: DocId,
range: Range,
) -> IdeResult<Vec<InlayHint>> {
project!(window, state).lsp.inlay_hints(doc, range).await
ide_host::ops::lsp::lsp_inlay_hints(&state.host, window.label(), doc, range).await
}
#[tauri::command]
@@ -295,13 +259,13 @@ pub async fn lsp_semantic_tokens(
state: State<'_, AppState>,
doc: DocId,
) -> IdeResult<Option<SemanticTokens>> {
project!(window, state).lsp.semantic_tokens(doc).await
ide_host::ops::lsp::lsp_semantic_tokens(&state.host, window.label(), doc).await
}
#[tauri::command]
#[specta::specta]
pub async fn lsp_code_lens(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<Vec<CodeLens>> {
project!(window, state).lsp.code_lens(doc).await
ide_host::ops::lsp::lsp_code_lens(&state.host, window.label(), doc).await
}
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
@@ -312,7 +276,7 @@ pub async fn lsp_document_links(
state: State<'_, AppState>,
doc: DocId,
) -> IdeResult<Vec<DocumentLink>> {
project!(window, state).lsp.document_links(doc).await
ide_host::ops::lsp::lsp_document_links(&state.host, window.label(), doc).await
}
#[tauri::command]
@@ -324,7 +288,7 @@ pub async fn lsp_call_hierarchy(
pos: Pos,
direction: HierarchyDirection,
) -> IdeResult<Vec<HierarchyItem>> {
project!(window, state).lsp.call_hierarchy(doc, pos, direction).await
ide_host::ops::lsp::lsp_call_hierarchy(&state.host, window.label(), doc, pos, direction).await
}
#[tauri::command]
@@ -336,39 +300,26 @@ pub async fn lsp_type_hierarchy(
pos: Pos,
direction: HierarchyDirection,
) -> IdeResult<Vec<HierarchyItem>> {
project!(window, state).lsp.type_hierarchy(doc, pos, direction).await
ide_host::ops::lsp::lsp_type_hierarchy(&state.host, window.label(), doc, pos, direction).await
}
#[tauri::command]
#[specta::specta]
pub fn lsp_servers(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ServerInfo>> {
Ok(project!(window, state).lsp.servers())
ide_host::ops::lsp::lsp_servers(&state.host, window.label())
}
#[tauri::command]
#[specta::specta]
pub async fn lsp_restart(window: Window, state: State<'_, AppState>, server: ServerId) -> IdeResult<()> {
project!(window, state).lsp.restart(server).await
ide_host::ops::lsp::lsp_restart(&state.host, window.label(), server).await
}
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
#[tauri::command]
#[specta::specta]
pub fn apply_workspace_edit(window: Window, state: State<'_, AppState>, edit: WorkspaceEdit) -> IdeResult<()> {
let project = project!(window, state);
// 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)
ide_host::ops::lsp::apply_workspace_edit(&state.host, window.label(), edit)
}
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
@@ -381,5 +332,5 @@ pub async fn lsp_execute_command(
doc: DocId,
command: Command,
) -> IdeResult<()> {
project!(window, state).lsp.execute_command(doc, command).await
ide_host::ops::lsp::lsp_execute_command(&state.host, window.label(), doc, command).await
}
Loaded 100 of 196 files, more files were not shown because too many files have changed in this diff. Show more