diff --git a/Cargo.lock b/Cargo.lock index 8c80426d5..4ce321ded 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4095,6 +4095,46 @@ dependencies = [ "which", ] +[[package]] +name = "ide-host" +version = "0.1.15" +dependencies = [ + "dirs 6.0.0", + "dunce", + "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.15" @@ -4350,6 +4390,7 @@ name = "ide-ssh" version = "0.1.15" dependencies = [ "dirs 6.0.0", + "flate2", "ide-core", "parking_lot", "russh", @@ -5906,6 +5947,7 @@ dependencies = [ name = "openidea" version = "0.1.15" dependencies = [ + "blake3", "dirs 6.0.0", "dunce", "ide-analysis", @@ -5917,8 +5959,10 @@ dependencies = [ "ide-db", "ide-docker", "ide-github", + "ide-host", "ide-http", "ide-index", + "ide-jsonrpc", "ide-language", "ide-lsp", "ide-plugin", diff --git a/Cargo.toml b/Cargo.toml index 81a659f65..5d932145e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ ide-spell = { path = "crates/ide-spell" } 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"] } diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index 8304329c8..8fff7d062 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -20,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}; @@ -28,7 +29,6 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; -use ide_ssh::{SshManager, SshTarget, Tunnel}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -281,7 +281,10 @@ pub fn split_target(target: &str) -> (&str, Option<&str>) { 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 {via})", e.message, source.host)) + IdeError::new( + ErrorKind::Io, + format!("{} through the SSH tunnel ({}:{port} as seen from {via})", e.message, source.host), + ) } /// Options › Run keep-alive query: pings the pool until it is closed or dropped @@ -517,9 +520,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().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref()))?; + 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()", @@ -556,7 +563,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().then(|| self.ssh_label(source.ssh.as_ref().expect("tunneled"))).as_deref())); + 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. diff --git a/crates/ide-host/Cargo.toml b/crates/ide-host/Cargo.toml new file mode 100644 index 000000000..e27397037 --- /dev/null +++ b/crates/ide-host/Cargo.toml @@ -0,0 +1,49 @@ +[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-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 diff --git a/crates/ide-host/src/bin/server.rs b/crates/ide-host/src/bin/server.rs new file mode 100644 index 000000000..9802fca99 --- /dev/null +++ b/crates/ide-host/src/bin/server.rs @@ -0,0 +1,71 @@ +//! `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 = 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, Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await; + 0 +} diff --git a/crates/ide-host/src/lib.rs b/crates/ide-host/src/lib.rs new file mode 100644 index 000000000..7021ec6ba --- /dev/null +++ b/crates/ide-host/src/lib.rs @@ -0,0 +1,149 @@ +//! 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 SharedSink + Send + Sync>; + +pub struct HostConfig { + pub config_dir: PathBuf, + pub data_dir: PathBuf, + pub languages: Arc, + /// Grammars, possibly still being built on another thread. + pub syntax: Arc>>, + 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, + syntax: Arc>>, + pub global_settings: SettingsStore, + pub terminals: TerminalManager, + pub processes: ProcessManager, + /// Debug sessions (adapters run on this machine, next to the project). + pub debugger: Arc, + /// SSH connections of Settings › SSH Configurations. + pub ssh: Arc, + /// SFTP deployment, remote file editing and runs on run targets. + pub remote: Arc, + pub(crate) sink_for: SinkFactory, + owner: fn(&str) -> &str, + pub runtime: tokio::runtime::Handle, + /// One project per window, keyed by window label. + projects: RwLock>>, + requests: Mutex>, +} + +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 { + self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone() + } + + /// Every open project (all windows). + pub fn projects(&self) -> Vec> { + self.projects.read().values().cloned().collect() + } + + /// The project of `window` (or of the window owning it). + pub fn project(&self, window: &str) -> IdeResult> { + 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) { + let lsp = project.lsp.clone(); + self.runtime.spawn(async move { 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) { + self.projects.write().insert(window.to_owned(), project); + } +} diff --git a/crates/ide-host/src/ops/analysis.rs b/crates/ide-host/src/ops/analysis.rs new file mode 100644 index 000000000..7d693b52e --- /dev/null +++ b/crates/ide-host/src/ops/analysis.rs @@ -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> { + 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, +} + +#[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); + +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, +) -> 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 { + 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> { + let syntax = host.syntax(); + let mut out: Vec = 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, +) -> 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 + }); +} diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs new file mode 100644 index 000000000..cde72997c --- /dev/null +++ b/crates/ide-host/src/ops/build.rs @@ -0,0 +1,192 @@ +//! Maven / Gradle tool windows (the build model and its background sync) and +//! Build › Build Project (compiling with the project's build system). +use crate::Host; +use crate::ops::Channel; +use crate::rpc::Dispatcher; + +use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult}; +use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress}; +use ide_core::settings::Settings; +use ide_core::{IdeError, IdeEvent, IdeResult, RequestId}; +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) -> BTreeMap { + 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| match jdk { + Some(_) => None, + None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release), + }; + let explain = |error: &mut Option, hint: Option| { + 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 { + 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, + on_progress: Channel, +) -> IdeResult { + 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? +} + +/// 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, + request: RequestId, + clear: Vec, + on_event: Channel, +) -> IdeResult { + 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("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 + }); +} diff --git a/crates/ide-host/src/ops/bytes.rs b/crates/ide-host/src/ops/bytes.rs new file mode 100644 index 000000000..8a2e6896e --- /dev/null +++ b/crates/ide-host/src/ops/bytes.rs @@ -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 { + 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 { + 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 { + 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 }); +} diff --git a/crates/ide-host/src/ops/coverage.rs b/crates/ide-host/src/ops/coverage.rs new file mode 100644 index 000000000..adaabadf4 --- /dev/null +++ b/crates/ide-host/src/ops/coverage.rs @@ -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(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + 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 { + 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 { + 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 { + 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 }); +} diff --git a/crates/ide-host/src/ops/debug.rs b/crates/ide-host/src/ops/debug.rs new file mode 100644 index 000000000..1a2aed0ae --- /dev/null +++ b/crates/ide-host/src/ops/debug.rs @@ -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 { + 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::setting_path(&root, settings.tool_path(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, +) -> IdeResult> { + host.debugger.set_breakpoints(path, breakpoints).await +} + +pub async fn debug_threads(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult> { + host.debugger.threads(session).await +} + +pub async fn debug_stack_trace( + host: &Host, + _window: &str, + session: DebugSessionId, + thread_id: i64, +) -> IdeResult> { + host.debugger.stack_trace(session, thread_id).await +} + +pub async fn debug_scopes(host: &Host, _window: &str, session: DebugSessionId, frame_id: i64) -> IdeResult> { + host.debugger.scopes(session, frame_id).await +} + +pub async fn debug_variables( + host: &Host, + _window: &str, + session: DebugSessionId, + reference: i64, +) -> IdeResult> { + host.debugger.variables(session, reference).await +} + +pub async fn debug_evaluate( + host: &Host, + _window: &str, + session: DebugSessionId, + frame_id: Option, + expression: String, +) -> IdeResult { + 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, + expression: String, + context: Option, +) -> IdeResult { + 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> { + 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> { + Ok(host.debugger.sessions(Some(host.owner_window(window)))) +} + +pub fn debug_exception_filters(host: &Host, window: &str) -> IdeResult> { + 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, +) -> 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, + frame_id: Option, + value: String, +) -> IdeResult { + 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> { + 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 { + 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, +) -> IdeResult> { + 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, +) -> IdeResult> { + 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, +) -> IdeResult { + 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> { + 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 { + 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 { + 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 }, + ); +} diff --git a/crates/ide-host/src/ops/doc.rs b/crates/ide-host/src/ops/doc.rs new file mode 100644 index 000000000..d47bfd607 --- /dev/null +++ b/crates/ide-host/src/ops/doc.rs @@ -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 { + 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 { + 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 { + let project = host.project(window)?; + host.blocking(move || project.documents.snapshot(doc)).await +} + +mod commands; +pub use commands::*; diff --git a/crates/ide-host/src/ops/doc/commands.rs b/crates/ide-host/src/ops/doc/commands.rs new file mode 100644 index 000000000..b8156c56f --- /dev/null +++ b/crates/ide-host/src/ops/doc/commands.rs @@ -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(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + 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 { + 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 { + 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> { + 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> { + 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 { + 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> { + 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> { + host.project(window)?.history.list(&path) +} + +pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult { + 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")?) }); +} diff --git a/crates/ide-host/src/ops/fs.rs b/crates/ide-host/src/ops/fs.rs new file mode 100644 index 000000000..b9ef8af82 --- /dev/null +++ b/crates/ide-host/src/ops/fs.rs @@ -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> { + 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 { + 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 { + 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 +} diff --git a/crates/ide-host/src/ops/history.rs b/crates/ide-host/src/ops/history.rs new file mode 100644 index 000000000..2037a6b05 --- /dev/null +++ b/crates/ide-host/src/ops/history.rs @@ -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 { + 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 }, + ); +} diff --git a/crates/ide-host/src/ops/java.rs b/crates/ide-host/src/ops/java.rs new file mode 100644 index 000000000..89a4e99c4 --- /dev/null +++ b/crates/ide-host/src/ops/java.rs @@ -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 { + 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 = 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 { + 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 { + 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 }); +} diff --git a/crates/ide-host/src/ops/lsp.rs b/crates/ide-host/src/ops/lsp.rs new file mode 100644 index 000000000..dad9ec855 --- /dev/null +++ b/crates/ide-host/src/ops/lsp.rs @@ -0,0 +1,319 @@ +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, +) -> IdeResult { + 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> { + 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 { + 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> { + 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> { + 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> { + host.project(window)?.lsp.goto(doc, pos, kind).await +} + +/// 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 { + host.project(window)?.lsp.virtual_document(&uri).await +} + +pub async fn lsp_references( + host: &Host, + window: &str, + doc: DocId, + pos: Pos, + include_declaration: bool, +) -> IdeResult> { + host.project(window)?.lsp.references(doc, pos, include_declaration).await +} + +pub async fn lsp_document_highlights(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult> { + host.project(window)?.lsp.document_highlights(doc, pos).await +} + +pub async fn lsp_workspace_symbols( + host: &Host, + window: &str, + request: RequestId, + query: String, +) -> IdeResult> { + 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, +) -> IdeResult> { + 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, +) -> IdeResult { + 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 { + 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> { + 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 { + host.project(window)?.lsp.rename(doc, pos, new_name).await +} + +pub async fn lsp_format( + host: &Host, + window: &str, + doc: DocId, + range: Option, + options: FormatOptions, +) -> IdeResult> { + host.project(window)?.lsp.format(doc, range, options).await +} + +pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult> { + host.project(window)?.lsp.inlay_hints(doc, range).await +} + +pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult> { + host.project(window)?.lsp.semantic_tokens(doc).await +} + +pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult> { + 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> { + 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> { + 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> { + host.project(window)?.lsp.type_hierarchy(doc, pos, direction).await +} + +pub fn lsp_servers(host: &Host, window: &str) -> IdeResult> { + 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_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("doc")?, c.arg("pos")?, c.arg("includeDeclaration")?).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 + }); +} diff --git a/crates/ide-host/src/ops/mod.rs b/crates/ide-host/src/ops/mod.rs new file mode 100644 index 000000000..b7cbe1d58 --- /dev/null +++ b/crates/ide-host/src/ops/mod.rs @@ -0,0 +1,83 @@ +//! 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 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 = Arc; + +/// A streaming argument: what the desktop shell passes as a Tauri channel and +/// the server as `channel` notifications. +pub struct Channel(Emit); + +impl Channel { + 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 Clone for Channel { + 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) { + 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(&self, f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))? + } +} diff --git a/crates/ide-host/src/ops/nav.rs b/crates/ide-host/src/ops/nav.rs new file mode 100644 index 000000000..5a4c3b32b --- /dev/null +++ b/crates/ide-host/src/ops/nav.rs @@ -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, +} + +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) { + 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 { + Ok(host.project(window)?.index.status()) +} + +pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult> { + 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> { + 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> { + 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> { + 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 = 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> { + 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> { + 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> { + 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 + }); +} diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs new file mode 100644 index 000000000..2e624f25c --- /dev/null +++ b/crates/ide-host/src/ops/project.rs @@ -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, window: &str, path: &Path) -> IdeResult> { + 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 { + 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 { + 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, +} + +/// 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 { + let value: serde_json::Value = + serde_json::from_str(value).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?; + let current = host.project(window).ok(); + let project_scope = matches!(scope, SettingsScope::Project) && current.is_some(); + let settings = match ¤t { + Some(p) if project_scope => p.settings.set(scope, key, value)?, + Some(p) => { + host.global_settings.set(SettingsScope::Global, key, value)?; + p.settings.reload() + } + None => host.global_settings.set(SettingsScope::Global, key, value)?, + }; + if let Some(p) = ¤t { + p.documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings, Some(&p.root))); + } + // A global value reaches every other open project too. + if !project_scope { + for project in host.projects() { + if current.as_ref().is_some_and(|c| c.root == project.root) { + continue; + } + let effective = project.settings.reload(); + project + .documents + .set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root))); + } + } + let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() }; + // File type associations re-detect open documents (off the command thread). + if key == "files" || key.starts_with("files.fileType") { + let affected = affected(); + host.runtime.spawn_blocking(move || { + for project in affected { + project.apply_file_types(&project.settings.effective()); + } + }); + } + if key == "ssh" || key.starts_with("ssh.") { + host.ssh.set_configs(host.global_settings.effective().ssh.configs); + } + // A new interpreter / toolchain / server executable restarts the language servers it affects. + if ["languages", "tools"].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 }) +} diff --git a/crates/ide-host/src/ops/refactor.rs b/crates/ide-host/src/ops/refactor.rs new file mode 100644 index 000000000..806f0f3dc --- /dev/null +++ b/crates/ide-host/src/ops/refactor.rs @@ -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, +) -> IdeResult { + 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, + edit: WorkspaceEdit, + label: String, +) -> IdeResult { + 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 { + 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 { + 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, +) -> IdeResult { + 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 + }); +} diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs new file mode 100644 index 000000000..867151892 --- /dev/null +++ b/crates/ide-host/src/ops/run.rs @@ -0,0 +1,84 @@ +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> { + let project = host.project(window)?; + let root = project.root.clone(); + let user = ide_run::load_user_configs(&root)?; + let mut all: Vec = + 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) -> IdeResult { + 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 encoding = project.settings.effective().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 { + let mut runs = host.processes.list(); + runs.extend(host.remote.runs.list()); + runs +} + +mod commands; +pub use commands::*; diff --git a/crates/ide-host/src/ops/run/commands.rs b/crates/ide-host/src/ops/run/commands.rs new file mode 100644 index 000000000..ec0075e28 --- /dev/null +++ b/crates/ide-host/src/ops/run/commands.rs @@ -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> { + let root = host.project(window)?.root.clone(); + ide_run::load_templates(&root) +} + +pub fn run_save_templates(host: &Host, window: &str, templates: Vec) -> 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 { + 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, + since_ms: f64, +) -> IdeResult> { + 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 + }); +} diff --git a/crates/ide-host/src/ops/sdk.rs b/crates/ide-host/src/ops/sdk.rs new file mode 100644 index 000000000..0efe04b02 --- /dev/null +++ b/crates/ide-host/src/ops/sdk.rs @@ -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, + 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> { + 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> { + 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> { + 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_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, + near: Option, +) -> IdeResult { + 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, +) -> IdeResult { + 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> { + 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, +) -> 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 + }); +} diff --git a/crates/ide-host/src/ops/search.rs b/crates/ide-host/src/ops/search.rs new file mode 100644 index 000000000..094f8279a --- /dev/null +++ b/crates/ide-host/src/ops/search.rs @@ -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, +) -> 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::*; diff --git a/crates/ide-host/src/ops/search/commands.rs b/crates/ide-host/src/ops/search/commands.rs new file mode 100644 index 000000000..a5e22049c --- /dev/null +++ b/crates/ide-host/src/ops/search/commands.rs @@ -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>) -> IdeResult { + 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, + on_event: Channel, +) -> 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>, +) -> IdeResult { + 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")?) }); +} diff --git a/crates/ide-host/src/ops/spell.rs b/crates/ide-host/src/ops/spell.rs new file mode 100644 index 000000000..dd741da92 --- /dev/null +++ b/crates/ide-host/src/ops/spell.rs @@ -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 { + 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> { + let project = host.project(window)?; + let syntax = host.syntax(); + let cancel = host.begin_request(request); + let result = blocking(move || { + let snap = project.documents.text_snapshot(doc)?; + if snap.large { + return Ok(Vec::new()); + } + let regions = if PROSE.contains(&snap.language.as_str()) { + ide_spell::prose_regions(&snap.text) + } else { + let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) }; + found + .into_iter() + .map(|r| Region { + start: r.start, + end: r.end, + kind: match r.kind { + SpellingKind::Comment => RegionKind::Comment, + SpellingKind::String => RegionKind::String, + SpellingKind::Name => RegionKind::Name, + }, + }) + .filter(|r| scope.includes(r.kind)) + .collect() + }; + if cancel.is_cancelled() { + return Err(IdeError::cancelled()); + } + speller(&project).check(&snap.text, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled) + }) + .await; + host.end_request(request); + result +} + +/// Replacements for a misspelt `word`, best first (Typo › Change to…). +pub async fn spell_suggest(host: &Host, window: &str, word: String) -> IdeResult> { + 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 }); +} diff --git a/crates/ide-host/src/ops/syntax.rs b/crates/ide-host/src/ops/syntax.rs new file mode 100644 index 000000000..027444b97 --- /dev/null +++ b/crates/ide-host/src/ops/syntax.rs @@ -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( + host: &Host, + window: &str, + doc: DocId, + request: Option, + f: impl FnOnce(&StructureService, &Snapshot, &CancellationToken) -> StructureResult + Send + 'static, +) -> IdeResult { + let project: Arc = 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 { + 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, +) -> IdeResult> { + 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, +) -> IdeResult { + 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, +) -> IdeResult { + 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, +) -> IdeResult { + 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, +) -> IdeResult> { + 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, + name: String, + request: RequestId, +) -> IdeResult> { + 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, +) -> IdeResult { + 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 + }); +} diff --git a/crates/ide-host/src/ops/term.rs b/crates/ide-host/src/ops/term.rs new file mode 100644 index 000000000..0fc9956a1 --- /dev/null +++ b/crates/ide-host/src/ops/term.rs @@ -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) -> IdeResult { + // 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 { + host.terminals.is_busy(id) +} diff --git a/crates/ide-host/src/ops/tools.rs b/crates/ide-host/src/ops/tools.rs new file mode 100644 index 000000000..c11db0eb5 --- /dev/null +++ b/crates/ide-host/src/ops/tools.rs @@ -0,0 +1,168 @@ +//! 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 { + 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(); + Ok(Tools::new(&data).with_custom_paths(custom)) +} + +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> { + 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 `/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, +) -> IdeResult { + 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 { + 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 { + 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 { + 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 { + 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, + request: RequestId, + on_event: Channel, +) -> IdeResult { + 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 + }); +} diff --git a/crates/ide-host/src/ops/vcs.rs b/crates/ide-host/src/ops/vcs.rs new file mode 100644 index 000000000..0fe319e30 --- /dev/null +++ b/crates/ide-host/src/ops/vcs.rs @@ -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> { + 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::*; diff --git a/crates/ide-host/src/ops/vcs/commands.rs b/crates/ide-host/src/ops/vcs/commands.rs new file mode 100644 index 000000000..c83f22d92 --- /dev/null +++ b/crates/ide-host/src/ops/vcs/commands.rs @@ -0,0 +1,912 @@ +#[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, +} + +#[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, + }, + /// 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, + force_with_lease: bool, + #[serde(default)] + target: Option, + }, + /// 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, + 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, + }, + /// Commits newest first. + Revert { + commits: Vec, + }, + Reset { + commit: String, + mode: ResetMode, + }, + /// Rebases `base..HEAD` (`base: None` = from the root) following `entries`, oldest first. + InteractiveRebase { + base: Option, + entries: Vec, + }, +} + +/// 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(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { + 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, reverse: bool }, + /// Changes made by these commits (oldest first). + Commits { commits: Vec, 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> { + let repos = host.project(window)?.repos(); + let primary = repos.primary().map(|r| r.root().to_path_buf()); + let mut all: Vec<&std::sync::Arc> = repos.all().iter().collect(); + all.sort_by_key(|r| Some(r.root()) != primary.as_deref()); + Ok(all + .into_iter() + .map(|r| VcsRoot { root: r.root().to_path_buf().into(), operation: r.operation().ok().flatten() }) + .collect()) +} + +pub async fn vcs_branch_info(host: &Host, window: &str, repo: Option) -> IdeResult { + 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> { + 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) -> IdeResult> { + 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> { + 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, query: LogQuery) -> IdeResult> { + 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, + commit: String, +) -> IdeResult> { + 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, + commits: Vec, +) -> IdeResult> { + 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) -> IdeResult> { + 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) -> IdeResult> { + 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) -> 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) -> 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) -> 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, + message: String, + options: CommitOptions, +) -> IdeResult { + // 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, + branch: String, + create: bool, + options: Option, +) -> IdeResult { + 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, + name: String, + start: Option, + 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, commit: String) -> IdeResult { + 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, + branch: String, +) -> IdeResult { + 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, + 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, + 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, 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, 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, + message: Option, + 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, + index: u32, + pop: bool, + reinstate_index: bool, + branch: Option, +) -> 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, 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, + request: RequestId, + op: RemoteOp, + on_output: Channel, +) -> 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 { + 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 { + 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 { + 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) -> 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 = hunks.iter().map(|&h| h as usize).collect(); + let patch = ide_vcs::build_partial_patch(&rel, &index_text, ¤t, &selected); + if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) } + }) +} + +/// Clones a repository (Get from VCS). Works without an open project. +pub async fn vcs_clone( + host: &Host, + _window: &str, + request: RequestId, + url: String, + dest: PathBuf, + on_output: Channel, +) -> 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 { + 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 { + ide_vcs::detected_git_executable().map(|p| p.to_string_lossy().into_owned()) +} + +/// `git config --get ` in the project repository; `None` when unset. +pub async fn vcs_config_get( + host: &Host, + window: &str, + repo: Option, + key: String, +) -> IdeResult> { + 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, + request: RequestId, + op: GitOperation, + on_output: Channel, +) -> 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, + name: String, + commit: String, + message: Option, + 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) -> IdeResult> { + 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, + branches: Vec, + 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, + 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) -> IdeResult> { + 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, 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) -> IdeResult> { + 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, + path: PathBuf, + commitish: String, + new_branch: Option, +) -> 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, + 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, + rev: String, +) -> IdeResult> { + 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, + from: String, + to: String, +) -> IdeResult> { + 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 { + let project = host.project(window)?; + blocking(move || { + project.with_repo(|r| match source { + PatchSource::Changes { paths, reverse } => { + let paths: Vec = 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> { + 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 { + 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) -> IdeResult { + 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> { + 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 { + 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 { + 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 { + 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 }, + ); +} diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs new file mode 100644 index 000000000..621046343 --- /dev/null +++ b/crates/ide-host/src/project.rs @@ -0,0 +1,714 @@ +//! 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::{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, + pub settings: Arc, + pub documents: Arc, + pub history: Arc, + /// Undo stack of file move / rename refactorings. + pub moves: FileMoves, + pub index: Arc, + pub lsp: Arc, + /// Git repositories: the one containing the project root and those nested below it. + pub repos: Arc>, + /// Incrementally maintained syntax trees of the open documents. + pub syntax: Arc, + /// Tree-sitter structure queries (LSP fallback) over `syntax`. + pub structure: Arc, + /// Roots auto-detected from build files; see [`Project::roots`]. + pub detected_roots: Arc>>, + /// Per-project cache directory (build tool sync results…). + pub cache_dir: PathBuf, + /// Database tool window: data sources and their connections. + pub db: Arc, + /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. + pub http_history: Arc, + pub(crate) watcher: Watcher, + /// Watchers of the Project Structure content roots outside the project folder. + pub(crate) content_watchers: Mutex>, + pub(crate) _scratch_watcher: Option, +} + +impl Host { + pub fn open_project(&self, window: &str, root: &Path) -> IdeResult> { + 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 = + 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(), + ); + } + })); + } + { + 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 = 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 = 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 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, + watcher, + content_watchers: Mutex::new(content_watchers), + _scratch_watcher: scratch_watcher, + }); + 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, 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(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + 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(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + 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(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + 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(&self, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { + 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( + &self, + at: Option<&Path>, + operation: Option<&str>, + f: impl FnOnce(&Repo) -> IdeResult, + ) -> IdeResult { + 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 { + let mut out: Vec = + 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 + } + + /// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected). + pub fn exclude_globs(&self) -> Vec { + 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 { + 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 { + 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 { + let mut out: Vec = 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, + documents: &Arc, + history: &Arc, +) -> Option { + 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 = 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 { + 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); + +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); + +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); + +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, + settings: Arc, + 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, + settings: Arc, + root: PathBuf, + removed: Vec, + 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) { + 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::>().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))); +} diff --git a/crates/ide-host/src/rpc.rs b/crates/ide-host/src/rpc.rs new file mode 100644 index 000000000..859d222ca --- /dev/null +++ b/crates/ide-host/src/rpc.rs @@ -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": }`; +//! * 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, + 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, + pub window: String, + pub args: Value, + peer: Arc>, +} + +impl Call { + /// Argument `name` (missing = `null`, so `Option`s may be omitted). + pub fn arg(&self, name: &str) -> IdeResult { + 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(&self, name: &str) -> IdeResult> { + let emit = self.channel(name)?; + Ok(ops::Channel::new(move |v| emit(v))) + } + + /// Streaming argument `name` (`{ "$channel": id }`). + pub fn channel(&self, name: &str) -> IdeResult> { + 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 = Pin + Send>>; +type HandlerFn = Arc BoxFuture> + Send + Sync>; + +/// Commands the server answers, by name. +#[derive(Default, Clone)] +pub struct Dispatcher { + handlers: HashMap<&'static str, HandlerFn>, +} + +impl Dispatcher { + pub fn add(&mut self, name: &'static str, f: F) -> &mut Self + where + F: Fn(Call) -> Fut + Send + Sync + 'static, + Fut: Future> + 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::("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::("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::("path")?) }) + .add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::("from")?, &c.arg::("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::("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::>("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::("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(host: Arc, dispatcher: Dispatcher, peer: Arc>, 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>, window: Option) -> ide_core::SharedSink { + Arc::new(PeerSink { peer, window }) +} + +struct PeerSink { + peer: Arc>, + window: Option, +} + +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, + dispatcher: Dispatcher, + peer: Arc>, +} + +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 { + 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(), + } +} diff --git a/src-tauri/src/shell_env.rs b/crates/ide-host/src/shell_env.rs similarity index 100% rename from src-tauri/src/shell_env.rs rename to crates/ide-host/src/shell_env.rs diff --git a/crates/ide-host/tests/rpc.rs b/crates/ide-host/tests/rpc.rs new file mode 100644 index 000000000..bfb6abf4c --- /dev/null +++ b/crates/ide-host/tests/rpc.rs @@ -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>, + events: Mutex>, +} + +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 { + Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned())) + } +} + +async fn invoke(peer: &Peer, cmd: &str, args: Value) -> Result { + 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::>(); + 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() + } +} diff --git a/crates/ide-remote/src/deploy.rs b/crates/ide-remote/src/deploy.rs index 41b2732d7..2bbac24e8 100644 --- a/crates/ide-remote/src/deploy.rs +++ b/crates/ide-remote/src/deploy.rs @@ -159,7 +159,11 @@ impl Deployment<'_> { Ok(()) } - pub async fn upload(&self, paths: &[PathBuf], progress: &mut (dyn FnMut(&str, usize, usize) + Send)) -> TransferReport { + 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() { @@ -181,7 +185,11 @@ impl Deployment<'_> { } /// Downloads the remote counterparts of `paths` (folders recursively). - pub async fn download(&self, paths: &[PathBuf], progress: &mut (dyn FnMut(&str, usize, usize) + Send)) -> TransferReport { + 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 { @@ -202,7 +210,9 @@ impl Deployment<'_> { } } 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"))), + 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), } } @@ -256,7 +266,13 @@ impl Deployment<'_> { 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 }, + SyncEntry { + path: self.rel(&f), + remote_path, + local: Some(stamp), + remote: None, + status: SyncStatus::LocalOnly, + }, ); } let mut remote = Vec::new(); @@ -272,10 +288,13 @@ impl Deployment<'_> { } 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 }); + 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() @@ -287,7 +306,11 @@ impl Deployment<'_> { } /// Applies the chosen Sync with Deployed actions. - pub async fn apply(&self, steps: &[SyncStep], progress: &mut (dyn FnMut(&str, usize, usize) + Send)) -> TransferReport { + 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() { @@ -315,7 +338,11 @@ impl Deployment<'_> { } /// 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 { + pub async fn push_changes( + &self, + local: &Path, + progress: &mut (dyn FnMut(&str, usize, usize) + Send), + ) -> IdeResult { let steps: Vec = self .compare(local) .await? diff --git a/crates/ide-remote/src/mapping.rs b/crates/ide-remote/src/mapping.rs index 3dfe426a7..ec2bbece6 100644 --- a/crates/ide-remote/src/mapping.rs +++ b/crates/ide-remote/src/mapping.rs @@ -38,8 +38,7 @@ impl Mappings { 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) }; + 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(); @@ -47,7 +46,11 @@ impl Mappings { 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() } + 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 { @@ -92,7 +95,8 @@ impl Mappings { } 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::>().join("/"); + let path = + [web.as_str(), rel.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::>().join("/"); Some(if path.is_empty() { format!("{}/", self.web_url) } else { format!("{}/{path}", self.web_url) }) }) } @@ -139,7 +143,10 @@ impl Excludes { 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('/'))) + || self + .remote + .iter() + .any(|r| path == r || path.strip_prefix(r.as_str()).is_some_and(|x| x.starts_with('/'))) } } @@ -184,7 +191,8 @@ mod tests { #[test] fn excludes_names_and_remote_paths() { - let settings = DeploymentSettings { exclude: vec![".git;*.pyc".into(), "node_modules".into()], ..Default::default() }; + 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"))); diff --git a/crates/ide-remote/src/service.rs b/crates/ide-remote/src/service.rs index f19f0bf93..9be7444b5 100644 --- a/crates/ide-remote/src/service.rs +++ b/crates/ide-remote/src/service.rs @@ -98,7 +98,13 @@ impl RemoteService { }; 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 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) @@ -130,12 +136,26 @@ impl RemoteService { 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 { + pub async fn delete_remote( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + paths: &[PathBuf], + ) -> IdeResult { let server = server(settings, server_id)?; - self.with_deployment(settings, root, &server, CancellationToken::new(), async |d| d.delete_remote(paths).await).await + 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> { + pub async fn compare( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + path: &Path, + cancel: CancellationToken, + ) -> IdeResult> { let server = server(settings, server_id)?; self.with_deployment(settings, root, &server, cancel, async |d| d.compare(path).await).await? } @@ -154,11 +174,21 @@ impl RemoteService { } /// 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> { + pub async fn deployed( + &self, + settings: &Settings, + root: &Path, + server_id: Option<&str>, + path: &Path, + ) -> IdeResult> { 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) }) + d.map.remote(path).map(|remote_path| DeployedPath { + server: name, + remote_path, + web_url: d.map.web_url(path), + }) }) .await } @@ -189,7 +219,9 @@ impl RemoteService { 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() }); + self.edits + .lock() + .insert(local.clone(), RemoteEdit { ssh_config: ssh_config.to_owned(), remote: remote.to_owned() }); Ok(local) } @@ -234,14 +266,24 @@ impl RemoteService { .await; return Some(r); } - if settings.deployment.auto_upload == AutoUpload::Never || settings.deployment.default_server.is_empty() || !path.starts_with(root) { + 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) + 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> { + pub async fn on_deleted( + &self, + settings: &Settings, + root: &Path, + paths: &[PathBuf], + ) -> Option> { let d = &settings.deployment; if d.auto_upload == AutoUpload::Never || !d.delete_remote || d.default_server.is_empty() { return None; @@ -265,7 +307,10 @@ impl RemoteService { 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())) + format!( + "~/{}", + root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| "project".into()) + ) } else { target.project_root.clone() }; @@ -286,14 +331,25 @@ impl RemoteService { 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 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) }); + 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))), + Some(e) => Err(IdeError::new( + ide_core::error::ErrorKind::Io, + format!("upload of {} failed: {}", e.path, e.message), + )), None => Ok(()), } }) diff --git a/crates/ide-remote/src/target.rs b/crates/ide-remote/src/target.rs index 7c67aea6d..8a2fbf7a5 100644 --- a/crates/ide-remote/src/target.rs +++ b/crates/ide-remote/src/target.rs @@ -58,7 +58,9 @@ fn file_name(program: &str) -> &str { /// 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() }); + 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" { @@ -79,7 +81,9 @@ pub fn remote_command(config: &RunConfig, target: &RunTarget, root: &Path, remot } s.replace(&root_str, remote_root.trim_end_matches('/')).replace('\\', "/") }; - let map_text = |s: &str| -> String { if root_str.is_empty() { s.to_owned() } else { s.replace(&root_str, remote_root.trim_end_matches('/')) } }; + let map_text = |s: &str| -> String { + if root_str.is_empty() { s.to_owned() } else { s.replace(&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)), @@ -91,7 +95,13 @@ pub fn remote_command(config: &RunConfig, target: &RunTarget, root: &Path, remot 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)) }; + 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(), @@ -143,10 +153,17 @@ impl RemoteRuns { /// 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) -> RunInfo { + pub fn start( + &self, + start: RemoteStart, + config: &RunConfig, + sink: SharedSink, + on_output: Box, + ) -> 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 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), @@ -196,7 +213,8 @@ impl RemoteRuns { /// `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}")))?; + 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(()) }; @@ -212,14 +230,21 @@ impl RemoteRuns { } fn process(&self, id: RunId) -> IdeResult<(Arc, tokio::runtime::Handle)> { - let run = self.runs.lock().get(&id).cloned().ok_or_else(|| IdeError::not_found(format!("no such run: {id}")))?; + 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> { +async fn run_remote( + run: &RemoteRun, + start: RemoteStart, + command: RemoteCommand, + pty: bool, + on_output: OutputFn, +) -> IdeResult> { on_output(OutputChunk::System { text: format!("[{}] {}\n", start.target.name, start.session.label()) }); if let Some(sync) = start.sync { tokio::select! { @@ -283,7 +308,10 @@ mod tests { 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"); + 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 && npm run dev"); diff --git a/crates/ide-remote/tests/remote.rs b/crates/ide-remote/tests/remote.rs index 4c1c1b983..8d6f76013 100644 --- a/crates/ide-remote/tests/remote.rs +++ b/crates/ide-remote/tests/remote.rs @@ -27,7 +27,13 @@ async fn env() -> Env { 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 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(); @@ -62,7 +68,7 @@ async fn uploads_compares_syncs_and_downloads() { 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(&[env.project.clone()], &mut no_progress()).await; + 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"); @@ -121,7 +127,8 @@ impl ide_core::EventSink for Sink { 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 target = + RunTarget { id: "t".into(), name: "box".into(), python: "/usr/bin/python3".into(), ..Default::default() }; let config = RunConfig { id: "main".into(), name: "main".into(), @@ -151,7 +158,9 @@ async fn runs_a_configuration_on_a_target() { &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), + OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => { + out.lock().push_str(&text) + } }), ); assert!(runs.contains(info.id)); @@ -164,10 +173,7 @@ async fn runs_a_configuration_on_a_target() { 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("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); } diff --git a/crates/ide-ssh/Cargo.toml b/crates/ide-ssh/Cargo.toml index 8dae8423f..feb93e126 100644 --- a/crates/ide-ssh/Cargo.toml +++ b/crates/ide-ssh/Cargo.toml @@ -17,6 +17,7 @@ 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. diff --git a/crates/ide-ssh/src/askpass.rs b/crates/ide-ssh/src/askpass.rs index af22f5456..0c391212d 100644 --- a/crates/ide-ssh/src/askpass.rs +++ b/crates/ide-ssh/src/askpass.rs @@ -105,7 +105,13 @@ impl AskpassServer { } } -fn serve(stream: TcpStream, id: u64, token: &str, pending: &Pending, on_prompt: &(dyn Fn(AskpassPrompt) + Send + Sync)) { +fn serve( + stream: TcpStream, + id: u64, + token: &str, + pending: &Pending, + on_prompt: &(dyn Fn(AskpassPrompt) + Send + Sync), +) { let mut line = String::new(); let Ok(mut reader) = stream.try_clone().map(BufReader::new) else { return }; if reader.read_line(&mut line).is_err() { @@ -134,7 +140,9 @@ fn random_token() -> String { (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()); + h.write_u128( + std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_nanos(), + ); format!("{:016x}", h.finish()) }) .collect() @@ -181,9 +189,7 @@ mod tests { fn round_trips_a_prompt() { let seen: Arc>> = Arc::default(); let s = seen.clone(); - let server = Arc::new( - AskpassServer::start("helper".into(), Arc::new(move |p| *s.lock() = Some(p))).unwrap(), - ); + 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 || { diff --git a/crates/ide-ssh/src/manager.rs b/crates/ide-ssh/src/manager.rs index d112d1c12..c7a34796b 100644 --- a/crates/ide-ssh/src/manager.rs +++ b/crates/ide-ssh/src/manager.rs @@ -52,7 +52,12 @@ impl SshManager { 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(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); } @@ -155,7 +160,11 @@ impl SshManager { 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()) }) + 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`). diff --git a/crates/ide-ssh/src/session.rs b/crates/ide-ssh/src/session.rs index 104ea980d..9449d1963 100644 --- a/crates/ide-ssh/src/session.rs +++ b/crates/ide-ssh/src/session.rs @@ -117,15 +117,17 @@ impl Session { 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()), + 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 { + async fn connect_inner( + target: &SshTarget, + secret: Option<&str>, + files: &SshFiles, + ) -> Result { let hops = resolve_hops(target, files.config.as_deref())?; let mut sessions: Vec> = Vec::new(); for (i, hop) in hops.iter().enumerate() { @@ -166,6 +168,13 @@ impl Session { 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, 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 { let mut channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?; @@ -224,6 +233,43 @@ impl Session { 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<()> { + 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?; + for chunk in packed.chunks(256 * 1024) { + process.write(chunk).await?; + } + 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 { let channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?; diff --git a/crates/ide-ssh/src/sftp.rs b/crates/ide-ssh/src/sftp.rs index 4defd6189..7ee9a9ed8 100644 --- a/crates/ide-ssh/src/sftp.rs +++ b/crates/ide-ssh/src/sftp.rs @@ -42,7 +42,17 @@ pub fn parent(path: &str) -> Option<&str> { } 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')]; + 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() } diff --git a/crates/ide-ssh/src/shell.rs b/crates/ide-ssh/src/shell.rs index de11468d1..827640204 100644 --- a/crates/ide-ssh/src/shell.rs +++ b/crates/ide-ssh/src/shell.rs @@ -36,7 +36,8 @@ pub fn command_line(cwd: Option<&str>, env: &BTreeMap, program: s } -fn tilde_quote(path: &str) -> String { +/// [`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(), diff --git a/crates/ide-ssh/src/target.rs b/crates/ide-ssh/src/target.rs index 0fb75de75..702e643ef 100644 --- a/crates/ide-ssh/src/target.rs +++ b/crates/ide-ssh/src/target.rs @@ -175,7 +175,8 @@ mod tests { 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() }; + 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"]); } } diff --git a/crates/ide-ssh/src/testing.rs b/crates/ide-ssh/src/testing.rs index 8e3f10f80..2ad98509f 100644 --- a/crates/ide-ssh/src/testing.rs +++ b/crates/ide-ssh/src/testing.rs @@ -4,9 +4,7 @@ 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 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}; @@ -71,7 +69,12 @@ impl server::Handler for Conn { Ok(()) } - async fn exec_request(&mut self, channel: ChannelId, data: &[u8], session: &mut Session) -> Result<(), Self::Error> { + 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())?; @@ -81,7 +84,12 @@ impl server::Handler for Conn { Ok(()) } - async fn subsystem_request(&mut self, channel: ChannelId, name: &str, session: &mut Session) -> Result<(), Self::Error> { + 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(()); @@ -144,7 +152,13 @@ impl russh_sftp::server::Handler for Sftp { Ok(Name { id, files: vec![File::dummy(p)] }) } - async fn open(&mut self, id: u32, filename: String, pflags: OpenFlags, _attrs: FileAttributes) -> Result { + async fn open( + &mut self, + id: u32, + filename: String, + pflags: OpenFlags, + _attrs: FileAttributes, + ) -> Result { let path = self.local(&filename); let file = std::fs::OpenOptions::new() .read(pflags.contains(OpenFlags::READ)) diff --git a/crates/ide-tools/src/lib.rs b/crates/ide-tools/src/lib.rs index 3e00a6cd6..38b69931d 100644 --- a/crates/ide-tools/src/lib.rs +++ b/crates/ide-tools/src/lib.rs @@ -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; diff --git a/crates/ide-tools/src/server.rs b/crates/ide-tools/src/server.rs new file mode 100644 index 000000000..7f8f8384c --- /dev/null +++ b/crates/ide-tools/src/server.rs @@ -0,0 +1,98 @@ +//! `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 { + 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 dl = sources::Download { + version: version.to_owned(), + checksum_url: Some(format!("{url}.sha256")), + name, + url, + size: None, + sha256: None, + }; + 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())?; + 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 { + let dir = parent.join(format!(".download-{}", 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:?}"); + } +} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index daeb60306..27e145a33 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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 @@ -58,6 +59,8 @@ 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" } diff --git a/src-tauri/src/commands/analysis.rs b/src-tauri/src/commands/analysis.rs index 3646f8310..937b2f1c1 100644 --- a/src-tauri/src/commands/analysis.rs +++ b/src-tauri/src/commands/analysis.rs @@ -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> { - 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, -} - -#[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); - -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, ) -> 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 { - 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> { - let syntax = state.syntax(); - let mut out: Vec = 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, ) -> 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 } diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 2885097ec..6456fbf75 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -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 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 { #[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,79 +127,22 @@ pub fn settings_set( key: String, value: String, ) -> IdeResult { - 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 ¤t { - 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) = ¤t { - p.documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings, Some(&p.root))); - } - // A global value reaches every other open project too. - if !project_scope { - for project in 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()); - } - }); - } - if key == "ssh" || key.starts_with("ssh.") { - crate::commands::ssh::apply_ssh_settings(&state); - } - // 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 @@ -271,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 { - 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. diff --git a/src-tauri/src/commands/build.rs b/src-tauri/src/commands/build.rs index 05bb1336a..a0d71490e 100644 --- a/src-tauri/src/commands/build.rs +++ b/src-tauri/src/commands/build.rs @@ -1,79 +1,19 @@ -//! 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; -use ide_build::compile::{self, BuildEvent, BuildKind, BuildResult}; -use ide_build::{BuildModel, BuildTool, SyncOptions, SyncProgress}; -use ide_core::settings::Settings; -use ide_core::{IdeError, IdeEvent, IdeResult, RequestId}; -use std::collections::BTreeMap; -use std::path::Path; +use ide_build::compile::{BuildEvent, BuildKind, BuildResult}; +use ide_build::{BuildModel, BuildTool, SyncProgress}; +use ide_core::{IdeResult, 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) -> BTreeMap { - 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| match jdk { - Some(_) => None, - None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release), - }; - let explain = |error: &mut Option, hint: Option| { - 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 { - 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 @@ -90,40 +30,16 @@ pub async fn build_sync( profiles: Vec, on_progress: Channel, ) -> IdeResult { - 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? } /// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the @@ -141,29 +57,14 @@ pub async fn build_run( clear: Vec, on_event: Channel, ) -> IdeResult { - 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? } diff --git a/src-tauri/src/commands/bytes.rs b/src-tauri/src/commands/bytes.rs index 62bc3b344..8f798ad88 100644 --- a/src-tauri/src/commands/bytes.rs +++ b/src-tauri/src/commands/bytes.rs @@ -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 { - blocking(move || ide_vfs::probe(&path)).await +pub async fn fs_probe(state: State<'_, AppState>, path: PathBuf) -> IdeResult { + 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 { - 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 { + 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 { - blocking(move || ide_vfs::read_data_url(&path)).await +pub async fn fs_read_image(state: State<'_, AppState>, path: PathBuf) -> IdeResult { + ide_host::ops::bytes::fs_read_image(&state.host, "", path).await } diff --git a/src-tauri/src/commands/coverage.rs b/src-tauri/src/commands/coverage.rs index 8fe66f2d7..ea3312872 100644 --- a/src-tauri/src/commands/coverage.rs +++ b/src-tauri/src/commands/coverage.rs @@ -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(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { - 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 { - 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 { - blocking(move || ide_coverage::collect(&report)).await +pub async fn coverage_collect(state: State<'_, AppState>, report: CoverageReportSpec) -> IdeResult { + 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, path: IpcPath) -> IdeResult { - 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 } diff --git a/src-tauri/src/commands/debug.rs b/src-tauri/src/commands/debug.rs index e435b115f..d9cffa65e 100644 --- a/src-tauri/src/commands/debug.rs +++ b/src-tauri/src/commands/debug.rs @@ -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 { - 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::setting_path(&root, settings.tool_path(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, ) -> IdeResult> { - 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> { - 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> { - 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> { - 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> { - 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, expression: String, ) -> IdeResult { - 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, ) -> IdeResult { - 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> { - 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> { - 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> { - 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, ) -> 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, value: String, ) -> IdeResult { - 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> { - 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 { - 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, ) -> IdeResult> { - 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, ) -> IdeResult> { - 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, ) -> IdeResult { - 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> { - 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> { + 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 { - 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 { - state.debugger.redefine_classes(session).await + ide_host::ops::debug::debug_redefine_classes(&state.host, "", session).await } diff --git a/src-tauri/src/commands/deploy.rs b/src-tauri/src/commands/deploy.rs index 00a6ce185..f72a4d6d8 100644 --- a/src-tauri/src/commands/deploy.rs +++ b/src-tauri/src/commands/deploy.rs @@ -91,10 +91,7 @@ pub async fn deploy_compare( ) -> IdeResult> { 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.to_path_buf(), token) - .await; + let r = state.remote.compare(&project.settings.effective(), &project.root, server.as_deref(), &path, token).await; state.end_request(request); r } @@ -131,7 +128,7 @@ pub async fn deploy_info( server: Option, ) -> IdeResult> { let project = state.project(window.label())?; - state.remote.deployed(&project.settings.effective(), &project.root, server.as_deref(), &path.to_path_buf()).await + 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. @@ -163,7 +160,7 @@ pub async fn remote_open(state: State<'_, AppState>, ssh: String, path: String) #[tauri::command] #[specta::specta] pub fn remote_of(state: State<'_, AppState>, path: IpcPath) -> Option<(String, String)> { - state.remote.remote_of(&path.to_path_buf()) + state.remote.remote_of(&path) } #[tauri::command] diff --git a/src-tauri/src/commands/doc.rs b/src-tauri/src/commands/doc.rs index 0a69fad34..027ba3247 100644 --- a/src-tauri/src/commands/doc.rs +++ b/src-tauri/src/commands/doc.rs @@ -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(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { - 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 { - 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 { - 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 { - 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 { - 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 { + 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 { - 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> { - 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> { - 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 { - 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> { - 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> { - 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 { - state.project(window.label())?.history.content(&id) + ide_host::ops::doc::history_content(&state.host, window.label(), id) } diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index 5a8f6636e..d8cc93823 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -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> { - 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 { - 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 { - 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 } diff --git a/src-tauri/src/commands/history.rs b/src-tauri/src/commands/history.rs index f33d12f15..d12bc7986 100644 --- a/src-tauri/src/commands/history.rs +++ b/src-tauri/src/commands/history.rs @@ -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 { - 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 } diff --git a/src-tauri/src/commands/java.rs b/src-tauri/src/commands/java.rs index c75dd7b39..c1794b33c 100644 --- a/src-tauri/src/commands/java.rs +++ b/src-tauri/src/commands/java.rs @@ -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 { - 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 = 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 { - 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 { - 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 } diff --git a/src-tauri/src/commands/lsp.rs b/src-tauri/src/commands/lsp.rs index 9a11ac0b8..23e4b563f 100644 --- a/src-tauri/src/commands/lsp.rs +++ b/src-tauri/src/commands/lsp.rs @@ -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, ) -> IdeResult { - 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> { - 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 { - 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> { - 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> { - 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> { - project!(window, state).lsp.goto(doc, pos, kind).await + ide_host::ops::lsp::lsp_goto(&state.host, window.label(), doc, pos, kind).await } /// Contents of a virtual document (library class, decompiled code) that a @@ -103,7 +89,7 @@ pub async fn lsp_virtual_document( state: State<'_, AppState>, uri: String, ) -> IdeResult { - project!(window, state).lsp.virtual_document(&uri).await + ide_host::ops::lsp::lsp_virtual_document(&state.host, window.label(), uri).await } #[tauri::command] @@ -115,7 +101,7 @@ pub async fn lsp_references( pos: Pos, include_declaration: bool, ) -> IdeResult> { - project!(window, state).lsp.references(doc, pos, include_declaration).await + ide_host::ops::lsp::lsp_references(&state.host, window.label(), doc, pos, include_declaration).await } #[tauri::command] @@ -126,7 +112,7 @@ pub async fn lsp_document_highlights( doc: DocId, pos: Pos, ) -> IdeResult> { - 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] @@ -137,11 +123,7 @@ pub async fn lsp_workspace_symbols( request: RequestId, query: String, ) -> IdeResult> { - 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] @@ -153,7 +135,7 @@ pub async fn lsp_code_actions( range: Range, only: Vec, ) -> IdeResult> { - 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). @@ -167,11 +149,7 @@ pub async fn lsp_code_action_query( range: Range, only: Vec, ) -> IdeResult { - 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). @@ -183,10 +161,7 @@ pub async fn lsp_resolve_code_action( doc: DocId, action: CodeAction, ) -> IdeResult { - 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. @@ -198,19 +173,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] @@ -221,7 +184,7 @@ pub async fn lsp_prepare_rename( doc: DocId, pos: Pos, ) -> IdeResult> { - 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. @@ -234,7 +197,7 @@ pub async fn lsp_rename( pos: Pos, new_name: String, ) -> IdeResult { - 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] @@ -246,7 +209,7 @@ pub async fn lsp_format( range: Option, options: FormatOptions, ) -> IdeResult> { - 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] @@ -257,7 +220,7 @@ pub async fn lsp_inlay_hints( doc: DocId, range: Range, ) -> IdeResult> { - 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] @@ -267,13 +230,13 @@ pub async fn lsp_semantic_tokens( state: State<'_, AppState>, doc: DocId, ) -> IdeResult> { - 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> { - 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. @@ -284,7 +247,7 @@ pub async fn lsp_document_links( state: State<'_, AppState>, doc: DocId, ) -> IdeResult> { - project!(window, state).lsp.document_links(doc).await + ide_host::ops::lsp::lsp_document_links(&state.host, window.label(), doc).await } #[tauri::command] @@ -296,7 +259,7 @@ pub async fn lsp_call_hierarchy( pos: Pos, direction: HierarchyDirection, ) -> IdeResult> { - 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] @@ -308,39 +271,26 @@ pub async fn lsp_type_hierarchy( pos: Pos, direction: HierarchyDirection, ) -> IdeResult> { - 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> { - 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 @@ -353,5 +303,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 } diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index bb937ab56..219f54c01 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -43,6 +43,15 @@ pub mod window; use tauri_specta::collect_commands; +/// A Tauri channel as the shared implementation's streaming argument. +pub(crate) fn sender( + channel: tauri::ipc::Channel, +) -> ide_host::ops::Channel { + ide_host::ops::Channel::new(move |v| { + let _ = channel.send(v); + }) +} + pub fn builder() -> tauri_specta::Builder { tauri_specta::Builder::::new().commands(collect_commands![ app::app_info, @@ -379,6 +388,10 @@ pub fn builder() -> tauri_specta::Builder { deploy::remote_delete, deploy::remote_mkdir, deploy::remote_rename, + crate::remote_dev::remote_connect, + crate::remote_dev::remote_current, + crate::remote_dev::remote_disconnect, + crate::remote_dev::remote_invoke, db::db_test, db::db_connected, db::db_connect, diff --git a/src-tauri/src/commands/nav.rs b/src-tauri/src/commands/nav.rs index 252d0f656..669fe850a 100644 --- a/src-tauri/src/commands/nav.rs +++ b/src-tauri/src/commands/nav.rs @@ -1,24 +1,25 @@ -use super::doc::blocking; -use crate::state::{AppState, Project}; -use ide_core::{DocId, IdeResult, IpcPath, Location, Pos}; +//! IPC commands of `ide_host::ops::nav` (the shared implementation). + +use crate::state::AppState; +use ide_core::{DocId, IdeResult, IpcPath, Pos}; +#[allow(unused_imports)] +pub use ide_host::ops::nav::*; 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 ide_language::FileReference; +use ide_workspace::file_refs::PathCompletion; use std::path::PathBuf; use tauri::{State, Window}; #[tauri::command] #[specta::specta] pub fn index_status(window: Window, state: State<'_, AppState>) -> IdeResult { - Ok(state.project(window.label())?.index.status()) + ide_host::ops::nav::index_status(&state.host, window.label()) } #[tauri::command] #[specta::specta] pub fn find_files(window: Window, state: State<'_, AppState>, query: String, limit: u32) -> IdeResult> { - Ok(state.project(window.label())?.index.find_files(&query, limit as usize)) + ide_host::ops::nav::find_files(&state.host, window.label(), query, limit) } #[tauri::command] @@ -30,85 +31,33 @@ pub fn find_symbols( filter: SymbolFilter, limit: u32, ) -> IdeResult> { - Ok(state.project(window.label())?.index.find_symbols(&query, filter, limit as usize)) + ide_host::ops::nav::find_symbols(&state.host, window.label(), query, filter, limit) } #[tauri::command] #[specta::specta] pub fn mark_recent(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { - state.project(window.label())?.index.mark_recent(&path); - Ok(()) + ide_host::ops::nav::mark_recent(&state.host, window.label(), path) } #[tauri::command] #[specta::specta] pub fn remove_recent(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> { - state.project(window.label())?.index.remove_recent(&path); - Ok(()) + ide_host::ops::nav::remove_recent(&state.host, window.label(), path) } /// Recently opened files, most recent first (⌘E). #[tauri::command] #[specta::specta] pub fn recent_files(window: Window, state: State<'_, AppState>) -> IdeResult> { - Ok(state.project(window.label())?.index.recent_files().into_iter().map(Into::into).collect()) + ide_host::ops::nav::recent_files(&state.host, window.label()) } -/// 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, -} - -const MAX_WEB_TARGETS: usize = 200; - /// The web reference at `pos` of `doc` and its targets; `None` when there is no reference. #[tauri::command] #[specta::specta] pub async fn web_goto(window: Window, state: State<'_, AppState>, doc: DocId, pos: Pos) -> IdeResult> { - let project = state.project(window.label())?; - let syntax = state.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 = 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 project root and its resource roots (served at `/`), for file references. -fn reference_roots(project: &Project) -> (PathBuf, Vec) { - let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect(); - (project.root.clone(), resources) + ide_host::ops::nav::web_goto(&state.host, window.label(), doc, pos).await } /// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal). @@ -120,13 +69,7 @@ pub async fn fs_resolve_reference( from: IpcPath, value: String, ) -> IdeResult> { - let project = state.project(window.label()).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 + ide_host::ops::nav::fs_resolve_reference(&state.host, window.label(), from, value).await } /// Files and folders that continue `prefix`, a path being typed in `from`. @@ -138,13 +81,7 @@ pub async fn path_completions( from: IpcPath, prefix: String, ) -> IdeResult> { - let project = state.project(window.label()).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 + ide_host::ops::nav::path_completions(&state.host, window.label(), from, prefix).await } /// Paths in an HTML document or stylesheet that name no existing file. @@ -155,23 +92,5 @@ pub async fn web_unresolved_references( state: State<'_, AppState>, doc: DocId, ) -> IdeResult> { - let project = state.project(window.label())?; - let syntax = state.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 + ide_host::ops::nav::web_unresolved_references(&state.host, window.label(), doc).await } diff --git a/src-tauri/src/commands/refactor.rs b/src-tauri/src/commands/refactor.rs index bcb5ec50f..b471e44fa 100644 --- a/src-tauri/src/commands/refactor.rs +++ b/src-tauri/src/commands/refactor.rs @@ -1,19 +1,13 @@ -//! File move / rename refactoring: reference updates from the language -//! servers, the move itself (one undoable step) and its undo. +//! IPC commands of `ide_host::ops::refactor` (the shared implementation). -use crate::state::{AppState, apply_workspace_edit}; +use crate::state::AppState; use ide_core::{IdeResult, RequestId, WorkspaceEdit}; +#[allow(unused_imports)] +pub use ide_host::ops::refactor::*; use ide_lsp::types::FileRenameEdits; use ide_workspace::{FileMove, UndoOutcome}; -use std::path::PathBuf; use tauri::{State, Window}; -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. #[tauri::command] #[specta::specta] @@ -23,11 +17,7 @@ pub async fn refactor_file_move_edits( request: RequestId, moves: Vec, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.begin_request(request); - let result = project.lsp.will_rename_files(&pairs(&moves), token).await; - state.end_request(request); - result + ide_host::ops::refactor::refactor_file_move_edits(&state.host, window.label(), request, moves).await } /// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the @@ -41,34 +31,21 @@ pub async fn refactor_move_files( edit: WorkspaceEdit, label: String, ) -> IdeResult { - let project = state.project(window.label())?; - 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) + ide_host::ops::refactor::refactor_move_files(&state.host, window.label(), moves, edit, label).await } /// Undoes a file move: moves back and reverts the reference updates. #[tauri::command] #[specta::specta] pub async fn refactor_undo_file_move(window: Window, state: State<'_, AppState>, id: u32) -> IdeResult { - let project = state.project(window.label())?; - 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) + ide_host::ops::refactor::refactor_undo_file_move(&state.host, window.label(), id).await } /// Whether the file move `id` can still be undone. #[tauri::command] #[specta::specta] pub fn refactor_can_undo_file_move(window: Window, state: State<'_, AppState>, id: u32) -> IdeResult { - Ok(state.project(window.label())?.moves.can_undo(id)) + ide_host::ops::refactor::refactor_can_undo_file_move(&state.host, window.label(), id) } /// "Search in comments and strings": textual occurrences of the moved files' @@ -81,20 +58,5 @@ pub async fn refactor_file_move_text_edits( request: RequestId, moves: Vec, ) -> IdeResult { - let project = state.project(window.label())?; - let token = state.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; - state.end_request(request); - result + ide_host::ops::refactor::refactor_file_move_text_edits(&state.host, window.label(), request, moves).await } diff --git a/src-tauri/src/commands/run.rs b/src-tauri/src/commands/run.rs index 99ce8f03d..eccc12bfe 100644 --- a/src-tauri/src/commands/run.rs +++ b/src-tauri/src/commands/run.rs @@ -1,6 +1,10 @@ +//! IPC commands of `ide_host::ops::run` (the shared implementation). + use crate::state::AppState; -use ide_core::{IdeError, IdeResult, RunId}; -use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions}; +use ide_core::{IdeResult, RunId}; +#[allow(unused_imports)] +pub use ide_host::ops::run::*; +use ide_run::{OutputChunk, RunConfig, RunInfo}; use tauri::ipc::Channel; use tauri::{State, Window}; @@ -8,44 +12,13 @@ use tauri::{State, Window}; #[tauri::command] #[specta::specta] pub fn run_configs(window: Window, state: State<'_, AppState>) -> IdeResult> { - let project = state.project(window.label())?; - let root = project.root.clone(); - let user = ide_run::load_user_configs(&root)?; - let mut all: Vec = - 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) + ide_host::ops::run::configs(&state.host, window.label()) } #[tauri::command] #[specta::specta] pub fn run_save_configs(window: Window, state: State<'_, AppState>, configs: Vec) -> IdeResult<()> { - let root = state.project(window.label())?.root.clone(); - ide_run::save_user_configs(&root, &configs) -} - -/// Configuration templates: per-type defaults applied to new configurations. -#[tauri::command] -#[specta::specta] -pub fn run_templates(window: Window, state: State<'_, AppState>) -> IdeResult> { - let root = state.project(window.label())?.root.clone(); - ide_run::load_templates(&root) -} - -#[tauri::command] -#[specta::specta] -pub fn run_save_templates(window: Window, state: State<'_, AppState>, templates: Vec) -> IdeResult<()> { - let root = state.project(window.label())?.root.clone(); - ide_run::save_templates(&root, &templates) -} - -/// The environment runs inherit when "Include system environment variables" is on. -#[tauri::command] -#[specta::specta] -pub fn run_system_env() -> std::collections::BTreeMap { - ide_run::system_env() + ide_host::ops::run::save_configs(&state.host, window.label(), &configs) } #[tauri::command] @@ -56,88 +29,60 @@ pub async fn run_start( config: RunConfig, on_output: Channel, ) -> IdeResult { - let project = state.project(window.label())?; - let root = project.root.clone(); - 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 remote = state.remote.clone(); - let settings = project.settings.effective(); - return remote - .run( - &settings, - &root, - &config, - project.sink.clone(), - Box::new(move |chunk| { - let _ = on_output.send(chunk); - }), - ) - .await; - } - let encoding = project.settings.effective().console.encoding; - let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()) }; - state.processes.start( - &root, - &config, - &options, - project.sink.clone(), - Box::new(move |chunk| { - let _ = on_output.send(chunk); - }), - ) + let emit = std::sync::Arc::new(move |chunk| { + let _ = on_output.send(chunk); + }); + ide_host::ops::run::start(&state.host, window.label(), config, emit).await } #[tauri::command] #[specta::specta] pub fn run_stop(window: Window, state: State<'_, AppState>, id: RunId) -> IdeResult<()> { - let settings = match state.project(window.label()) { - Ok(p) => p.settings.effective(), - Err(_) => state.global_settings.effective(), - }; - let grace = std::time::Duration::from_millis(u64::from(settings.run.termination_timeout_ms)); - if state.remote.runs.contains(id) { - return state.remote.runs.stop(id, grace); - } - state.processes.stop_with_grace(id, grace) + ide_host::ops::run::stop(&state.host, window.label(), id) } #[tauri::command] #[specta::specta] pub fn run_stdin(state: State<'_, AppState>, id: RunId, data: String) -> IdeResult<()> { - if state.remote.runs.contains(id) { - return state.remote.runs.write_stdin(id, &data); - } - state.processes.write_stdin(id, &data) + ide_host::ops::run::stdin(&state.host, id, &data) } /// Terminal size of a run started with "Emulate terminal in output console". #[tauri::command] #[specta::specta] pub fn run_resize(state: State<'_, AppState>, id: RunId, cols: u16, rows: u16) -> IdeResult<()> { - if state.remote.runs.contains(id) { - return state.remote.runs.resize(id, cols, rows); - } - state.processes.resize(id, cols, rows) + ide_host::ops::run::resize(&state.host, id, cols, rows) } #[tauri::command] #[specta::specta] pub fn run_list(state: State<'_, AppState>) -> Vec { - let mut runs = state.processes.list(); - runs.extend(state.remote.runs.list()); - runs + ide_host::ops::run::list(&state.host) } #[tauri::command] #[specta::specta] pub fn run_close_stdin(state: State<'_, AppState>, id: RunId) -> IdeResult<()> { - if state.remote.runs.contains(id) { - return state.remote.runs.close_stdin(id); - } - state.processes.close_stdin(id) + ide_host::ops::run::close_stdin(&state.host, id) +} +/// Configuration templates: per-type defaults applied to new configurations. +#[tauri::command] +#[specta::specta] +pub fn run_templates(window: Window, state: State<'_, AppState>) -> IdeResult> { + ide_host::ops::run::run_templates(&state.host, window.label()) +} + +#[tauri::command] +#[specta::specta] +pub fn run_save_templates(window: Window, state: State<'_, AppState>, templates: Vec) -> IdeResult<()> { + ide_host::ops::run::run_save_templates(&state.host, window.label(), templates) +} + +/// The environment runs inherit when "Include system environment variables" is on. +#[tauri::command] +#[specta::specta] +pub fn run_system_env(state: State<'_, AppState>) -> std::collections::BTreeMap { + ide_host::ops::run::run_system_env(&state.host, "") } /// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at @@ -150,12 +95,5 @@ pub async fn run_test_reports( module: Option, since_ms: f64, ) -> IdeResult> { - let root = state.project(window.label())?.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); - tauri::async_runtime::spawn_blocking(move || { - ide_run::junit::read_reports(&ide_run::junit::report_dirs(&dir), since) - }) - .await - .map_err(|e| IdeError::internal(e.to_string())) + ide_host::ops::run::run_test_reports(&state.host, window.label(), module, since_ms).await } diff --git a/src-tauri/src/commands/sdk.rs b/src-tauri/src/commands/sdk.rs index 009ae004a..92a8ea471 100644 --- a/src-tauri/src/commands/sdk.rs +++ b/src-tauri/src/commands/sdk.rs @@ -1,31 +1,27 @@ -//! Interpreters / toolchains (`ide-sdk`): discovery, Python virtualenvs and packages. +//! IPC commands of `ide_host::ops::sdk` (the shared implementation). use crate::state::AppState; -use ide_core::{IdeError, IdeResult, RequestId}; +use ide_core::{IdeResult, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::sdk::*; use ide_sdk::python::PythonPackage; use ide_sdk::{Sdk, SdkKind}; use std::path::PathBuf; use tauri::ipc::Channel; use tauri::{State, Window}; -fn join_err(e: impl std::fmt::Display) -> IdeError { - IdeError::internal(e.to_string()) -} - /// Installed interpreters / toolchains of `kind`; project-local ones first. #[tauri::command] #[specta::specta] pub async fn sdk_detect(window: Window, state: State<'_, AppState>, kind: SdkKind) -> IdeResult> { - let root = state.project(window.label()).ok().map(|p| p.root.clone()); - tauri::async_runtime::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err) + ide_host::ops::sdk::sdk_detect(&state.host, window.label(), kind).await } /// The interpreter the project uses: the configured one, else its virtualenv. #[tauri::command] #[specta::specta] pub fn sdk_python_effective(window: Window, state: State<'_, AppState>) -> IdeResult> { - let project = state.project(window.label())?; - Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into)) + ide_host::ops::sdk::sdk_python_effective(&state.host, window.label()) } /// SDK / library source folders shown under "External Libraries" in the Project view, @@ -36,43 +32,18 @@ pub async fn sdk_project_libraries( window: Window, state: State<'_, AppState>, ) -> IdeResult> { - 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 || { - let model = crate::commands::build::load(&root, &cache, &settings); - let deps: Vec = 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) -} - -/// 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, - pub compile_dir: ide_core::IpcPath, + ide_host::ops::sdk::sdk_project_libraries(&state.host, window.label()).await } /// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path. #[tauri::command] #[specta::specta] -pub async fn sdk_locate_tools(names: Vec, near: Option) -> IdeResult { - tauri::async_runtime::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) +pub async fn sdk_locate_tools( + state: State<'_, AppState>, + names: Vec, + near: Option, +) -> IdeResult { + ide_host::ops::sdk::sdk_locate_tools(&state.host, "", names, near).await } /// `base -m venv location`; output lines stream through `on_output`. @@ -85,16 +56,15 @@ pub async fn sdk_python_create_venv( request: RequestId, on_output: Channel, ) -> IdeResult { - let cancel = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || { - ide_sdk::python::create_venv(&base, &location, &cancel, &|line| { - let _ = on_output.send(line); - }) - }) + ide_host::ops::sdk::sdk_python_create_venv( + &state.host, + "", + base, + location, + request, + crate::commands::sender(on_output), + ) .await - .map_err(join_err); - state.end_request(request); - result? } #[tauri::command] @@ -104,12 +74,7 @@ pub async fn sdk_python_packages( python: PathBuf, request: RequestId, ) -> IdeResult> { - let cancel = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)) - .await - .map_err(join_err); - state.end_request(request); - result? + ide_host::ops::sdk::sdk_python_packages(&state.host, "", python, request).await } /// Installs (`uninstall = false`) or removes a package; output streams through `on_output`. @@ -124,19 +89,15 @@ pub async fn sdk_python_pip( request: RequestId, on_output: Channel, ) -> IdeResult<()> { - let cancel = state.begin_request(request); - let result = tauri::async_runtime::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) - } - }) + ide_host::ops::sdk::sdk_python_pip( + &state.host, + "", + python, + requirement, + uninstall, + upgrade, + request, + crate::commands::sender(on_output), + ) .await - .map_err(join_err); - state.end_request(request); - result? } diff --git a/src-tauri/src/commands/search.rs b/src-tauri/src/commands/search.rs index 212014d51..0ec1ec10a 100644 --- a/src-tauri/src/commands/search.rs +++ b/src-tauri/src/commands/search.rs @@ -1,22 +1,14 @@ +//! IPC commands of `ide_host::ops::search` (the shared implementation). + use crate::state::AppState; -use ide_core::{IdeResult, Pos, RequestId, TextEdit}; -use ide_search::{ - FileMatches, ProjectFileSet, ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceTarget, SearchQuery, SearchSummary, -}; -use ide_workspace::Documents; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::path::{Path, PathBuf}; +use ide_core::{IdeResult, Pos, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::search::*; +use ide_search::{ReplaceEvent, ReplacePreview, ReplaceTarget, SearchQuery}; +use std::path::PathBuf; use tauri::ipc::Channel; use tauri::{State, Window}; -#[derive(Clone, Serialize, Deserialize, Type)] -#[serde(tag = "type", rename_all = "camelCase")] -pub enum SearchEvent { - File { file: FileMatches }, - Done { summary: SearchSummary }, -} - /// Find in Files. Results stream through `on_event`; ends with `Done`. /// Cancel with `cancel_request(request)`. #[tauri::command] @@ -25,74 +17,14 @@ pub async fn search_start( window: Window, state: State<'_, AppState>, request: RequestId, - mut query: SearchQuery, + query: SearchQuery, on_event: Channel, ) -> IdeResult<()> { - let project = state.project(window.label())?; - // 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 = state.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 = state.syntax(); - let events = on_event.clone(); - let summary = tauri::async_runtime::spawn_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 = ide_search::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| { - let _ = events.send(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::<_, ide_core::IdeError>(total) - }) - .await - .map_err(|e| ide_core::IdeError::internal(e.to_string()))?; - state.end_request(request); - let _ = on_event.send(SearchEvent::Done { summary: summary? }); - Ok(()) + let emit = std::sync::Arc::new(move |e| { + let _ = on_event.send(e); + }); + ide_host::ops::search::start(&state.host, window.label(), request, query, emit).await } - /// Replace in Files. Runs off the command thread; progress streams through /// `on_event` and ends with `Done`. Cancel with `cancel_request(request)`. /// @@ -113,21 +45,17 @@ pub async fn replace_in_files( targets: Vec, on_event: Channel, ) -> IdeResult<()> { - let project = state.project(window.label())?; - let cancel = state.begin_request(request); - let events = on_event.clone(); - let summary = tauri::async_runtime::spawn_blocking(move || { - let host = DocumentsHost(&project.documents); - ide_search::replace_files(&host, &query, &replacement, preserve_case, &targets, &cancel, &|e| { - let _ = events.send(e); - }) - }) + ide_host::ops::search::replace_in_files( + &state.host, + window.label(), + request, + query, + replacement, + preserve_case, + targets, + crate::commands::sender(on_event), + ) .await - .map_err(|e| ide_core::IdeError::internal(e.to_string())); - state.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). @@ -142,61 +70,13 @@ pub async fn replace_preview( path: PathBuf, only: Option>, ) -> IdeResult { - let project = state.project(window.label())?; - tauri::async_runtime::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()))? -} - -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 } -} - -struct DocumentsHost<'a>(&'a Documents); - -impl ReplaceHost for DocumentsHost<'_> { - fn edit_file(&self, path: &Path, compute: &mut dyn FnMut(&str) -> IdeResult>) -> IdeResult { - 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 - } + ide_host::ops::search::replace_preview(&state.host, window.label(), query, replacement, preserve_case, path, only) + .await } /// Validates a query (e.g. regex syntax) while the user types. #[tauri::command] #[specta::specta] -pub fn search_validate(query: SearchQuery) -> IdeResult<()> { - ide_search::validate_query(&query) +pub fn search_validate(state: State<'_, AppState>, query: SearchQuery) -> IdeResult<()> { + ide_host::ops::search::search_validate(&state.host, "", query) } diff --git a/src-tauri/src/commands/spell.rs b/src-tauri/src/commands/spell.rs index 715f94faf..12a5388de 100644 --- a/src-tauri/src/commands/spell.rs +++ b/src-tauri/src/commands/spell.rs @@ -1,37 +1,12 @@ -//! Proofreading › Typo: spell checking of open documents. +//! IPC commands of `ide_host::ops::spell` (the shared implementation). -use super::doc::blocking; -use crate::state::{AppState, Project}; -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}; +use crate::state::AppState; +use ide_core::{DocId, IdeResult, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::spell::*; +use ide_spell::{SpellScope, Typo}; use tauri::{State, Window}; -/// 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 { - 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)`. @@ -44,50 +19,12 @@ pub async fn spell_check( scope: SpellScope, request: RequestId, ) -> IdeResult> { - let project = state.project(window.label())?; - let syntax = state.syntax(); - let cancel = state.begin_request(request); - let result = blocking(move || { - let snap = project.documents.text_snapshot(doc)?; - if snap.large { - return Ok(Vec::new()); - } - let regions = if PROSE.contains(&snap.language.as_str()) { - ide_spell::prose_regions(&snap.text) - } else { - let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) }; - found - .into_iter() - .map(|r| Region { - start: r.start, - end: r.end, - kind: match r.kind { - SpellingKind::Comment => RegionKind::Comment, - SpellingKind::String => RegionKind::String, - SpellingKind::Name => RegionKind::Name, - }, - }) - .filter(|r| scope.includes(r.kind)) - .collect() - }; - if cancel.is_cancelled() { - return Err(IdeError::cancelled()); - } - speller(&project).check(&snap.text, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled) - }) - .await; - state.end_request(request); - result + ide_host::ops::spell::spell_check(&state.host, window.label(), doc, scope, request).await } /// Replacements for a misspelt `word`, best first (Typo › Change to…). #[tauri::command] #[specta::specta] pub async fn spell_suggest(window: Window, state: State<'_, AppState>, word: String) -> IdeResult> { - let project = state.project(window.label()).ok(); - blocking(move || { - let speller = project.as_deref().map(speller).unwrap_or_default(); - Ok(speller.suggest(&word, 8)) - }) - .await + ide_host::ops::spell::spell_suggest(&state.host, window.label(), word).await } diff --git a/src-tauri/src/commands/ssh.rs b/src-tauri/src/commands/ssh.rs index a5f3314cb..0c58ebc75 100644 --- a/src-tauri/src/commands/ssh.rs +++ b/src-tauri/src/commands/ssh.rs @@ -14,7 +14,11 @@ pub(crate) fn apply_ssh_settings(state: &AppState) { /// Test Connection: connects with `config` as edited (not necessarily saved). #[tauri::command] #[specta::specta] -pub async fn ssh_test(state: State<'_, AppState>, config: SshConfiguration, secret: Option) -> IdeResult { +pub async fn ssh_test( + state: State<'_, AppState>, + config: SshConfiguration, + secret: Option, +) -> IdeResult { let ssh = state.ssh.clone(); ssh.test(&config, secret).await } diff --git a/src-tauri/src/commands/syntax.rs b/src-tauri/src/commands/syntax.rs index a43a3dd6b..d3292ceee 100644 --- a/src-tauri/src/commands/syntax.rs +++ b/src-tauri/src/commands/syntax.rs @@ -1,54 +1,22 @@ -//! 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`. +//! IPC commands of `ide_host::ops::syntax` (the shared implementation). -use super::doc::blocking; -use crate::state::{AppState, Project}; -use ide_core::{CancellationToken, DocId, IdeError, IdeResult, LanguageId, Pos, Range, RequestId}; +use crate::state::AppState; +use ide_core::{DocId, IdeResult, LanguageId, Pos, Range, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::syntax::*; use ide_syntax::{ - ExpressionAt, FileIdentifiers, IdentifierOccurrence, Locals, NodeAt, Snapshot, StructuralRanges, StructureResult, - StructureService, StructureSupport, SyntaxErrors, + ExpressionAt, FileIdentifiers, IdentifierOccurrence, Locals, NodeAt, StructuralRanges, StructureSupport, + SyntaxErrors, }; use std::path::PathBuf; -use std::sync::Arc; use tauri::{State, Window}; -/// Runs `f` on the current snapshot of `doc` off the command thread. -async fn on_doc( - state: &AppState, - window: &Window, - doc: DocId, - request: Option, - f: impl FnOnce(&StructureService, &Snapshot, &CancellationToken) -> StructureResult + Send + 'static, -) -> IdeResult { - let project: Arc = state.project(window.label())?; - let cancel = request.map_or_else(CancellationToken::new, |id| state.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 { - state.end_request(id); - } - result -} - /// What the structure service supports for a language (`tree`: every query, /// `locals`: scope analysis). #[tauri::command] #[specta::specta] pub fn syntax_support(window: Window, state: State<'_, AppState>, language: LanguageId) -> IdeResult { - Ok(state.project(window.label())?.structure.support(&language)) + ide_host::ops::syntax::syntax_support(&state.host, window.label(), language) } /// The smallest named node covering `range`, with its ancestors (parent first). @@ -61,7 +29,7 @@ pub async fn syntax_node_at( range: Range, request: Option, ) -> IdeResult> { - on_doc(&state, &window, doc, request, move |s, snap, c| s.node_at(snap, range, c)).await + ide_host::ops::syntax::syntax_node_at(&state.host, window.label(), doc, range, request).await } /// The expression a selection denotes (exact or minimally enclosing), with its @@ -75,7 +43,7 @@ pub async fn syntax_expression_at( range: Range, request: Option, ) -> IdeResult { - on_doc(&state, &window, doc, request, move |s, snap, c| s.expression_at(snap, range, c)).await + ide_host::ops::syntax::syntax_expression_at(&state.host, window.label(), doc, range, request).await } /// `ERROR` / `MISSING` nodes (e.g. a missing `;`) in document order. @@ -87,7 +55,7 @@ pub async fn syntax_errors( doc: DocId, request: Option, ) -> IdeResult { - on_doc(&state, &window, doc, request, |s, snap, c| s.syntax_errors(snap, c)).await + ide_host::ops::syntax::syntax_errors(&state.host, window.label(), doc, request).await } /// Scope analysis of the identifier at `pos`: its local definition and @@ -101,7 +69,7 @@ pub async fn syntax_locals( pos: Pos, request: Option, ) -> IdeResult { - on_doc(&state, &window, doc, request, move |s, snap, c| s.locals(snap, pos, c)).await + ide_host::ops::syntax::syntax_locals(&state.host, window.label(), doc, pos, request).await } /// Identifier tokens spelled `name` in a document, excluding comments and strings. @@ -114,7 +82,7 @@ pub async fn syntax_identifiers( name: String, request: Option, ) -> IdeResult> { - on_doc(&state, &window, doc, request, move |s, snap, c| s.identifiers_named(snap, &name, c)).await + ide_host::ops::syntax::syntax_identifiers(&state.host, window.label(), doc, name, request).await } /// [`syntax_identifiers`] over files (open buffers win over disk); files @@ -128,24 +96,7 @@ pub async fn syntax_identifiers_in_files( name: String, request: RequestId, ) -> IdeResult> { - let project = state.project(window.label())?; - let languages = project.languages.clone(); - let cancel = state.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; - state.end_request(request); - result + ide_host::ops::syntax::syntax_identifiers_in_files(&state.host, window.label(), paths, name, request).await } /// Extend Selection chain plus statement / declaration ranges at `pos` @@ -159,5 +110,5 @@ pub async fn syntax_structural_ranges( pos: Pos, request: Option, ) -> IdeResult { - on_doc(&state, &window, doc, request, move |s, snap, c| s.structural_ranges(snap, pos, c)).await + ide_host::ops::syntax::syntax_structural_ranges(&state.host, window.label(), doc, pos, request).await } diff --git a/src-tauri/src/commands/term.rs b/src-tauri/src/commands/term.rs index 07401d208..23e195264 100644 --- a/src-tauri/src/commands/term.rs +++ b/src-tauri/src/commands/term.rs @@ -12,22 +12,14 @@ pub fn term_spawn( options: TerminalOptions, on_output: Channel, ) -> IdeResult { - // Python › "Activate virtualenv": typed into the new shell, like PyCharm. - let activation = state - .project(window.label()) - .ok() - .and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref())); - let info = state.terminals.spawn( + ide_host::ops::term::spawn( + &state.host, + window.label(), options, - state.window_sink(window.label()), - Box::new(move |chunk| { + std::sync::Arc::new(move |chunk| { let _ = on_output.send(chunk); }), - )?; - if let Some(line) = activation { - let _ = state.terminals.write(info.id, &format!(" {line}\r")); - } - Ok(info) + ) } #[tauri::command] diff --git a/src-tauri/src/commands/tools.rs b/src-tauri/src/commands/tools.rs index 184f7ec71..4570968da 100644 --- a/src-tauri/src/commands/tools.rs +++ b/src-tauri/src/commands/tools.rs @@ -1,40 +1,21 @@ -//! Language server / debug adapter installer and JDK downloads (`ide-tools`). +//! IPC commands of `ide_host::ops::tools` (the shared implementation). use crate::state::AppState; -use ide_core::{IdeError, IdeResult, RequestId}; +use ide_core::{IdeResult, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::tools::*; use ide_sdk::Sdk; use ide_tools::jdk::{JdkReleases, JdkVendor}; -use ide_tools::{InstallEvent, ToolInfo, Tools}; +use ide_tools::{InstallEvent, ToolInfo}; use std::path::PathBuf; use tauri::ipc::Channel; -use tauri::{AppHandle, Manager, State, Window}; - -/// The installer, reporting the executables chosen in the window's settings. -fn tools(app: &AppHandle, state: &AppState, window: &Window) -> IdeResult { - let data = app.path().app_data_dir().map_err(|e| IdeError::internal(e.to_string()))?; - let (settings, root) = match state.project(window.label()) { - Ok(project) => (project.settings.effective(), project.root.clone()), - Err(_) => (state.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(); - Ok(Tools::new(&data).with_custom_paths(custom)) -} - -fn join_err(e: impl std::fmt::Display) -> IdeError { - IdeError::internal(e.to_string()) -} +use tauri::{State, Window}; /// Status of every known language server / debug adapter and the languages using it. #[tauri::command] #[specta::specta] -pub async fn tools_list(app: AppHandle, window: Window, state: State<'_, AppState>) -> IdeResult> { - let tools = tools(&app, &state, &window)?; - let langs = state.languages.all(); - tauri::async_runtime::spawn_blocking(move || tools.list(&langs)).await.map_err(join_err) +pub async fn tools_list(window: Window, state: State<'_, AppState>) -> IdeResult> { + ide_host::ops::tools::tools_list(&state.host, window.label()).await } /// Installs (or reinstalls) a tool into `/tools`, streaming output through @@ -42,79 +23,47 @@ pub async fn tools_list(app: AppHandle, window: Window, state: State<'_, AppStat #[tauri::command] #[specta::specta] pub async fn tools_install( - app: AppHandle, window: Window, state: State<'_, AppState>, name: String, request: RequestId, on_event: Channel, ) -> IdeResult { - let tools = tools(&app, &state, &window)?; - let langs = state.languages.all(); - let cancel = state.begin_request(request); - let result = tauri::async_runtime::spawn_blocking(move || { - tools.install(&name, &langs, &cancel, &|e| { - let _ = on_event.send(e); - }) - }) - .await - .map_err(join_err); - state.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(&state); - Ok(info) + ide_host::ops::tools::tools_install(&state.host, window.label(), name, request, crate::commands::sender(on_event)) + .await } /// Removes a tool the IDE installed. #[tauri::command] #[specta::specta] -pub async fn tools_uninstall( - app: AppHandle, - window: Window, - state: State<'_, AppState>, - name: String, -) -> IdeResult { - let tools = tools(&app, &state, &window)?; - let langs = state.languages.all(); - let info = - tauri::async_runtime::spawn_blocking(move || tools.uninstall(&name, &langs)).await.map_err(join_err)??; - reconfigure_servers(&state); - Ok(info) +pub async fn tools_uninstall(window: Window, state: State<'_, AppState>, name: String) -> IdeResult { + ide_host::ops::tools::tools_uninstall(&state.host, window.label(), name).await } /// JDK distributions Download JDK offers. #[tauri::command] #[specta::specta] -pub fn jdk_download_vendors() -> Vec { - ide_tools::jdk::vendors() +pub fn jdk_download_vendors(state: State<'_, AppState>) -> Vec { + ide_host::ops::tools::jdk_download_vendors(&state.host, "") } /// Java versions `vendor` publishes; cancellable with `cancel_request(request)`. #[tauri::command] #[specta::specta] pub async fn jdk_download_releases( - app: AppHandle, window: Window, state: State<'_, AppState>, vendor: String, request: RequestId, ) -> IdeResult { - let tools = tools(&app, &state, &window)?; - let cancel = state.begin_request(request); - let result = - tauri::async_runtime::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err); - state.end_request(request); - result? + ide_host::ops::tools::jdk_download_releases(&state.host, window.label(), vendor, request).await } /// Where Download JDK puts JDKs unless told otherwise. #[tauri::command] #[specta::specta] -pub fn jdk_download_location(app: AppHandle) -> IdeResult { - let data = app.path().app_data_dir().map_err(|e| IdeError::internal(e.to_string()))?; - Ok(ide_tools::jdk::jdks_dir(&data).into()) +pub fn jdk_download_location(state: State<'_, AppState>) -> IdeResult { + ide_host::ops::tools::jdk_download_location(&state.host, "") } /// Downloads the latest `vendor` JDK of Java `version` into `location` (default: @@ -125,7 +74,6 @@ pub fn jdk_download_location(app: AppHandle) -> IdeResult { #[specta::specta] #[allow(clippy::too_many_arguments)] pub async fn jdk_download( - app: AppHandle, window: Window, state: State<'_, AppState>, vendor: String, @@ -134,34 +82,14 @@ pub async fn jdk_download( request: RequestId, on_event: Channel, ) -> IdeResult { - let tools = tools(&app, &state, &window)?; - let location = location.filter(|p| !p.as_os_str().is_empty()); - let cancel = state.begin_request(request); - let result = tauri::async_runtime::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)) - }) + ide_host::ops::tools::jdk_download( + &state.host, + window.label(), + vendor, + version, + location, + request, + crate::commands::sender(on_event), + ) .await - .map_err(join_err); - state.end_request(request); - let sdk = result??; - reconfigure_servers(&state); - Ok(sdk) -} - -fn reconfigure_servers(state: &AppState) { - for project in state.projects() { - let lsp = project.lsp.clone(); - tauri::async_runtime::spawn(async move { lsp.reconfigure().await }); - } } diff --git a/src-tauri/src/commands/vcs.rs b/src-tauri/src/commands/vcs.rs index 7f3a8ebfd..32e9c1057 100644 --- a/src-tauri/src/commands/vcs.rs +++ b/src-tauri/src/commands/vcs.rs @@ -1,60 +1,25 @@ -use crate::state::{AppState, Project}; +//! IPC commands of `ide_host::ops::vcs` (the shared implementation). + +use crate::state::AppState; use ide_core::{IdeResult, RequestId}; +#[allow(unused_imports)] +pub use ide_host::ops::vcs::*; use ide_vcs::*; -use serde::{Deserialize, Serialize}; -use specta::Type; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use tauri::ipc::Channel; use tauri::{State, Window}; -/// Settings › Version Control › Git › Path to Git executable (empty = auto-detect). -pub(crate) 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))); -} - /// Changes of every repository of the project. #[tauri::command] #[specta::specta] pub async fn vcs_status(window: Window, state: State<'_, AppState>) -> IdeResult> { - let project = state.project(window.label())?; - 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 + ide_host::ops::vcs::status(&state.host, window.label()).await } - -/// 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, -} - /// The project's repositories, the primary one first. #[tauri::command] #[specta::specta] pub fn vcs_roots(window: Window, state: State<'_, AppState>) -> IdeResult> { - let repos = state.project(window.label())?.repos(); - let primary = repos.primary().map(|r| r.root().to_path_buf()); - let mut all: Vec<&std::sync::Arc> = repos.all().iter().collect(); - all.sort_by_key(|r| Some(r.root()) != primary.as_deref()); - Ok(all - .into_iter() - .map(|r| VcsRoot { root: r.root().to_path_buf().into(), operation: r.operation().ok().flatten() }) - .collect()) + ide_host::ops::vcs::vcs_roots(&state.host, window.label()) } #[tauri::command] @@ -64,17 +29,14 @@ pub async fn vcs_branch_info( state: State<'_, AppState>, repo: Option, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await + ide_host::ops::vcs::vcs_branch_info(&state.host, window.label(), repo).await } /// Gutter change markers for a file, computed against its current (unsaved) text. #[tauri::command] #[specta::specta] pub fn vcs_line_hunks(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { - let project = state.project(window.label())?; - let text = project.text_of(&path)?; - project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false)) + ide_host::ops::vcs::vcs_line_hunks(&state.host, window.label(), path) } #[tauri::command] @@ -85,16 +47,13 @@ pub async fn vcs_file_at( path: PathBuf, rev: Option, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await + ide_host::ops::vcs::vcs_file_at(&state.host, window.label(), path, rev).await } #[tauri::command] #[specta::specta] pub async fn vcs_blame(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult> { - let project = state.project(window.label())?; - let text = project.documents.text_of(&path); - blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await + ide_host::ops::vcs::vcs_blame(&state.host, window.label(), path).await } #[tauri::command] @@ -105,8 +64,7 @@ pub async fn vcs_log( repo: Option, query: LogQuery, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await + ide_host::ops::vcs::vcs_log(&state.host, window.label(), repo, query).await } #[tauri::command] @@ -117,8 +75,7 @@ pub async fn vcs_commit_files( repo: Option, commit: String, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await + ide_host::ops::vcs::vcs_commit_files(&state.host, window.label(), repo, commit).await } /// Combined changes of several commits, `commits` oldest first. @@ -130,50 +87,43 @@ pub async fn vcs_commits_changes( repo: Option, commits: Vec, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await + ide_host::ops::vcs::vcs_commits_changes(&state.host, window.label(), repo, commits).await } #[tauri::command] #[specta::specta] pub async fn vcs_branches(window: Window, state: State<'_, AppState>, repo: Option) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await + ide_host::ops::vcs::vcs_branches(&state.host, window.label(), repo).await } #[tauri::command] #[specta::specta] pub async fn vcs_stashes(window: Window, state: State<'_, AppState>, repo: Option) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stashes())).await + ide_host::ops::vcs::vcs_stashes(&state.host, window.label(), repo).await } #[tauri::command] #[specta::specta] pub async fn vcs_stage(window: Window, state: State<'_, AppState>, paths: Vec) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await + ide_host::ops::vcs::vcs_stage(&state.host, window.label(), paths).await } #[tauri::command] #[specta::specta] pub async fn vcs_unstage(window: Window, state: State<'_, AppState>, paths: Vec) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await + ide_host::ops::vcs::vcs_unstage(&state.host, window.label(), paths).await } #[tauri::command] #[specta::specta] pub async fn vcs_stage_patch(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo(|r| r.stage_patch(&patch))).await + ide_host::ops::vcs::vcs_stage_patch(&state.host, window.label(), patch).await } #[tauri::command] #[specta::specta] pub async fn vcs_discard(window: Window, state: State<'_, AppState>, paths: Vec) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await + ide_host::ops::vcs::vcs_discard(&state.host, window.label(), paths).await } #[tauri::command] @@ -185,9 +135,7 @@ pub async fn vcs_commit( message: String, options: CommitOptions, ) -> IdeResult { - // Commit hooks may run for a long time. - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await + ide_host::ops::vcs::vcs_commit(&state.host, window.label(), repo, message, options).await } #[tauri::command] @@ -200,13 +148,7 @@ pub async fn vcs_checkout( create: bool, options: Option, ) -> IdeResult { - let project = state.project(window.label())?; - 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 + ide_host::ops::vcs::vcs_checkout(&state.host, window.label(), repo, branch, create, options).await } /// New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out. @@ -220,12 +162,7 @@ pub async fn vcs_create_branch( start: Option, checkout: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - 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 + ide_host::ops::vcs::vcs_create_branch(&state.host, window.label(), repo, name, start, checkout).await } /// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged. @@ -238,8 +175,7 @@ pub async fn vcs_undo_commit( repo: Option, commit: String, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await + ide_host::ops::vcs::vcs_undo_commit(&state.host, window.label(), repo, commit).await } #[tauri::command] @@ -250,8 +186,7 @@ pub async fn vcs_delete_branch( repo: Option, branch: String, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await + ide_host::ops::vcs::vcs_delete_branch(&state.host, window.label(), repo, branch).await } /// "Restore" of the branch-deleted notification. @@ -263,8 +198,7 @@ pub async fn vcs_restore_branch( repo: Option, deleted: ide_vcs::DeletedBranch, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await + ide_host::ops::vcs::vcs_restore_branch(&state.host, window.label(), repo, deleted).await } #[tauri::command] @@ -276,8 +210,7 @@ pub async fn vcs_rename_branch( from: String, to: String, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await + ide_host::ops::vcs::vcs_rename_branch(&state.host, window.label(), repo, from, to).await } #[tauri::command] @@ -288,8 +221,7 @@ pub async fn vcs_merge( repo: Option, branch: String, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await + ide_host::ops::vcs::vcs_merge(&state.host, window.label(), repo, branch).await } #[tauri::command] @@ -300,8 +232,7 @@ pub async fn vcs_rebase( repo: Option, onto: String, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await + ide_host::ops::vcs::vcs_rebase(&state.host, window.label(), repo, onto).await } #[tauri::command] @@ -314,13 +245,7 @@ pub async fn vcs_stash_push( include_untracked: bool, keep_index: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| { - r.stash_push(message.as_deref(), include_untracked, keep_index) - }) - }) - .await + ide_host::ops::vcs::vcs_stash_push(&state.host, window.label(), repo, message, include_untracked, keep_index).await } #[tauri::command] @@ -334,14 +259,7 @@ pub async fn vcs_stash_apply( reinstate_index: bool, branch: Option, ) -> IdeResult<()> { - let project = state.project(window.label())?; - 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 + ide_host::ops::vcs::vcs_stash_apply(&state.host, window.label(), repo, index, pop, reinstate_index, branch).await } #[tauri::command] @@ -352,48 +270,7 @@ pub async fn vcs_stash_drop( repo: Option, index: u32, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await -} - -#[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, - }, - /// 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, - force_with_lease: bool, - #[serde(default)] - target: Option, - }, - /// 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, - rebase: bool, - options: MergeOptions, - }, + ide_host::ops::vcs::vcs_stash_drop(&state.host, window.label(), repo, index).await } /// Network operation; progress lines stream through `on_output`. Cancellable. @@ -407,36 +284,8 @@ pub async fn vcs_remote( op: RemoteOp, on_output: Channel, ) -> IdeResult<()> { - let project = state.project(window.label())?; - let cancel = state.begin_request(request); - let result = tauri::async_runtime::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()))?; - state.end_request(request); - result + ide_host::ops::vcs::vcs_remote(&state.host, window.label(), repo, request, op, crate::commands::sender(on_output)) + .await } /// The user's side is `ours` (sides swapped during a rebase, as in IDEA). @@ -447,8 +296,7 @@ pub async fn vcs_conflict_versions( state: State<'_, AppState>, path: PathBuf, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path))).await + ide_host::ops::vcs::vcs_conflict_versions(&state.host, window.label(), path).await } #[tauri::command] @@ -459,15 +307,14 @@ pub async fn vcs_diff_text( path: PathBuf, staged: bool, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await + ide_host::ops::vcs::vcs_diff_text(&state.host, window.label(), path, staged).await } /// Line hunks between two arbitrary texts (diff viewer, commit dialog). #[tauri::command] #[specta::specta] -pub fn vcs_diff_hunks(old_text: String, new_text: String) -> Vec { - ide_vcs::diff_hunks(&old_text, &new_text) +pub fn vcs_diff_hunks(state: State<'_, AppState>, old_text: String, new_text: String) -> Vec { + ide_host::ops::vcs::vcs_diff_hunks(&state.host, "", old_text, new_text) } /// Stages only the selected hunks of a file's working-tree changes. @@ -475,15 +322,7 @@ pub fn vcs_diff_hunks(old_text: String, new_text: String) -> Vec { #[tauri::command] #[specta::specta] pub fn vcs_stage_hunks(window: Window, state: State<'_, AppState>, path: PathBuf, hunks: Vec) -> IdeResult<()> { - let project = state.project(window.label())?; - 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 = hunks.iter().map(|&h| h as usize).collect(); - let patch = ide_vcs::build_partial_patch(&rel, &index_text, ¤t, &selected); - if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) } - }) + ide_host::ops::vcs::vcs_stage_hunks(&state.host, window.label(), path, hunks) } /// Clones a repository (Get from VCS). Works without an open project. @@ -496,61 +335,29 @@ pub async fn vcs_clone( dest: PathBuf, on_output: Channel, ) -> IdeResult<()> { - apply_git_executable(&state.global_settings.effective()); - let cancel = state.begin_request(request); - let result = tauri::async_runtime::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()))?; - state.end_request(request); - result + ide_host::ops::vcs::vcs_clone(&state.host, "", request, url, dest, crate::commands::sender(on_output)).await } /// `git init` in `dir` (File › New Project… "Create Git repository"). #[tauri::command] #[specta::specta] pub async fn vcs_init(state: State<'_, AppState>, dir: PathBuf) -> IdeResult<()> { - apply_git_executable(&state.global_settings.effective()); - blocking(move || ide_vcs::init(&dir)).await -} - -#[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, + ide_host::ops::vcs::vcs_init(&state.host, "", dir).await } /// Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected /// executable when `path` is empty. #[tauri::command] #[specta::specta] -pub async fn vcs_git_version(path: String) -> IdeResult { - tauri::async_runtime::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()))? +pub async fn vcs_git_version(state: State<'_, AppState>, path: String) -> IdeResult { + ide_host::ops::vcs::vcs_git_version(&state.host, "", path).await } /// The auto-detected git executable (shown as the field's placeholder). #[tauri::command] #[specta::specta] -pub fn vcs_detected_git() -> Option { - ide_vcs::detected_git_executable().map(|p| p.to_string_lossy().into_owned()) +pub fn vcs_detected_git(state: State<'_, AppState>) -> Option { + ide_host::ops::vcs::vcs_detected_git(&state.host, "") } /// `git config --get ` in the project repository; `None` when unset. @@ -562,36 +369,7 @@ pub async fn vcs_config_get( repo: Option, key: String, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await -} - -// ---- 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, - }, - /// Commits newest first. - Revert { - commits: Vec, - }, - Reset { - commit: String, - mode: ResetMode, - }, - /// Rebases `base..HEAD` (`base: None` = from the root) following `entries`, oldest first. - InteractiveRebase { - base: Option, - entries: Vec, - }, + ide_host::ops::vcs::vcs_config_get(&state.host, window.label(), repo, key).await } /// Local history operation that may take a while (hooks, large checkouts); git's @@ -606,35 +384,15 @@ pub async fn vcs_operation( op: GitOperation, on_output: Channel, ) -> IdeResult<()> { - let project = state.project(window.label())?; - let cancel = state.begin_request(request); - let result = tauri::async_runtime::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) - } - }) - }) + ide_host::ops::vcs::vcs_operation( + &state.host, + window.label(), + repo, + request, + op, + crate::commands::sender(on_output), + ) .await - .map_err(|e| ide_core::IdeError::internal(e.to_string()))?; - state.end_request(request); - result } #[tauri::command] @@ -648,16 +406,13 @@ pub async fn vcs_create_tag( message: Option, force: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force))) - .await + ide_host::ops::vcs::vcs_create_tag(&state.host, window.label(), repo, name, commit, message, force).await } #[tauri::command] #[specta::specta] pub async fn vcs_tags(window: Window, state: State<'_, AppState>, repo: Option) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await + ide_host::ops::vcs::vcs_tags(&state.host, window.label(), repo).await } /// Merge dialog: merges `branches` into the current branch. @@ -670,11 +425,7 @@ pub async fn vcs_merge_with( branches: Vec, options: MergeOptions, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options)) - }) - .await + ide_host::ops::vcs::vcs_merge_with(&state.host, window.label(), repo, branches, options).await } /// Rebase dialog: rebases the current branch onto `upstream` (or `--onto`). @@ -687,18 +438,13 @@ pub async fn vcs_rebase_with( upstream: String, options: RebaseOptions, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options)) - }) - .await + ide_host::ops::vcs::vcs_rebase_with(&state.host, window.label(), repo, upstream, options).await } #[tauri::command] #[specta::specta] pub async fn vcs_remotes(window: Window, state: State<'_, AppState>, repo: Option) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await + ide_host::ops::vcs::vcs_remotes(&state.host, window.label(), repo).await } #[tauri::command] @@ -709,8 +455,7 @@ pub async fn vcs_edit_remote( repo: Option, edit: RemoteEdit, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await + ide_host::ops::vcs::vcs_edit_remote(&state.host, window.label(), repo, edit).await } #[tauri::command] @@ -720,8 +465,7 @@ pub async fn vcs_worktrees( state: State<'_, AppState>, repo: Option, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.worktrees())).await + ide_host::ops::vcs::vcs_worktrees(&state.host, window.label(), repo).await } #[tauri::command] @@ -734,11 +478,7 @@ pub async fn vcs_add_worktree( commitish: String, new_branch: Option, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref())) - }) - .await + ide_host::ops::vcs::vcs_add_worktree(&state.host, window.label(), repo, path, commitish, new_branch).await } #[tauri::command] @@ -750,8 +490,7 @@ pub async fn vcs_remove_worktree( path: PathBuf, force: bool, ) -> IdeResult<()> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await + ide_host::ops::vcs::vcs_remove_worktree(&state.host, window.label(), repo, path, force).await } /// Compare with Local: tracked files that differ between `rev` and the working tree. @@ -763,8 +502,7 @@ pub async fn vcs_diff_with_worktree( repo: Option, rev: String, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await + ide_host::ops::vcs::vcs_diff_with_worktree(&state.host, window.label(), repo, rev).await } /// Files that differ between two revisions (the "N files updated" of Update Project). @@ -777,99 +515,25 @@ pub async fn vcs_diff_revisions( from: String, to: String, ) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await -} - -/// 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(f: impl FnOnce() -> IdeResult + Send + 'static) -> IdeResult { - tauri::async_runtime::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, reverse: bool }, - /// Changes made by these commits (oldest first). - Commits { commits: Vec, reverse: bool }, + ide_host::ops::vcs::vcs_diff_revisions(&state.host, window.label(), repo, from, to).await } #[tauri::command] #[specta::specta] pub async fn vcs_create_patch(window: Window, state: State<'_, AppState>, source: PatchSource) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo(|r| match source { - PatchSource::Changes { paths, reverse } => { - let paths: Vec = 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 + ide_host::ops::vcs::vcs_create_patch(&state.host, window.label(), source).await } #[tauri::command] #[specta::specta] pub async fn vcs_patch_preview(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult> { - let project = state.project(window.label())?; - blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await + ide_host::ops::vcs::vcs_patch_preview(&state.host, window.label(), patch).await } #[tauri::command] #[specta::specta] pub async fn vcs_apply_patch(window: Window, state: State<'_, AppState>, patch: String) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || project.with_repo(|r| r.apply_patch(&patch))).await -} - -// ---- 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}"); - } + ide_host::ops::vcs::vcs_apply_patch(&state.host, window.label(), patch).await } /// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them. @@ -881,20 +545,13 @@ pub async fn vcs_shelve( name: String, paths: Vec, ) -> IdeResult { - let project = state.project(window.label())?; - 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 + ide_host::ops::vcs::vcs_shelve(&state.host, window.label(), name, paths).await } #[tauri::command] #[specta::specta] pub fn vcs_shelves(window: Window, state: State<'_, AppState>) -> IdeResult> { - let project = state.project(window.label())?; - ide_vcs::shelf::list(&shelf_dir(&project.root)) + ide_host::ops::vcs::vcs_shelves(&state.host, window.label()) } /// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the @@ -907,59 +564,42 @@ pub async fn vcs_unshelve( id: String, remove: bool, ) -> IdeResult { - let project = state.project(window.label())?; - blocking(move || { - project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove)) - }) - .await + ide_host::ops::vcs::vcs_unshelve(&state.host, window.label(), id, remove).await } #[tauri::command] #[specta::specta] pub fn vcs_shelf_delete(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<()> { - let project = state.project(window.label())?; - ide_vcs::shelf::delete(&shelf_dir(&project.root), &id) + ide_host::ops::vcs::vcs_shelf_delete(&state.host, window.label(), id) } #[tauri::command] #[specta::specta] pub fn vcs_shelf_rename(window: Window, state: State<'_, AppState>, id: String, name: String) -> IdeResult<()> { - let project = state.project(window.label())?; - ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name) + ide_host::ops::vcs::vcs_shelf_rename(&state.host, window.label(), id, name) } #[tauri::command] #[specta::specta] pub fn vcs_shelf_patch(window: Window, state: State<'_, AppState>, id: String) -> IdeResult { - let project = state.project(window.label())?; - ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id) + ide_host::ops::vcs::vcs_shelf_patch(&state.host, window.label(), id) } #[tauri::command] #[specta::specta] pub fn vcs_changelists_load(window: Window, state: State<'_, AppState>) -> IdeResult { - let project = state.project(window.label())?; - Ok(ide_vcs::changelists::load(&ide_core::project_dir::path(&project.root, "changelists.json"))) + ide_host::ops::vcs::vcs_changelists_load(&state.host, window.label()) } #[tauri::command] #[specta::specta] pub fn vcs_changelists_save(window: Window, state: State<'_, AppState>, changelists: ChangelistState) -> IdeResult<()> { - let project = state.project(window.label())?; - keep_private(&project.root); - ide_vcs::changelists::save(&ide_core::project_dir::path(&project.root, "changelists.json"), &changelists) + ide_host::ops::vcs::vcs_changelists_save(&state.host, window.label(), changelists) } /// Create Patch › Save to file. #[tauri::command] #[specta::specta] -pub async fn vcs_write_patch(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 +pub async fn vcs_write_patch(state: State<'_, AppState>, path: PathBuf, patch: String) -> IdeResult<()> { + ide_host::ops::vcs::vcs_write_patch(&state.host, "", path, patch).await } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 463721f43..30161dada 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,7 +7,8 @@ mod commands; mod events; mod logging; mod menu; -mod shell_env; +mod remote_dev; + mod state; mod windows; @@ -36,7 +37,7 @@ pub fn run() { let context = tauri::generate_context!(); logging::init(&context.config().identifier); - let refresh_shell_path = shell_env::inherit_login_shell_path(&context.config().identifier); + let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path(&context.config().identifier); // Managed LSP/DAP installs; must run before other threads start and before PATH is cached. if let Some(data) = dirs::data_dir() { ide_tools::extend_process_path(&data.join(&context.config().identifier)); diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 6bc4aa344..4b1fc5f6d 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -216,6 +216,9 @@ pub fn spec(os: Os) -> Vec { act("file.closeProject", "Close Project"), act("file.renameProject", "Rename Project…"), SEP, + act("remote.connect", "Remote Development…"), + act("remote.disconnect", "Disconnect from Remote Host"), + SEP, act("file.save", "Save All"), act("file.reloadAllFromDisk", "Reload All from Disk"), sub("fileProperties", "File Properties", vec![act("file.toggleReadOnly", "Make File Read-only / Writable")]), @@ -631,6 +634,26 @@ pub fn spec(os: Os) -> Vec { act("db.newDataSource", "New Data Source…"), act("db.openConsole", "Open Query Console"), SEP, + act("ssh.startSession", "Start SSH Session…"), + act("ssh.configurations", "SSH Configurations…"), + sub( + "deployment", + "Deployment", + vec![ + act("deployment.uploadToDefault", "Upload to Default Server"), + act("deployment.uploadTo", "Upload to…"), + act("deployment.downloadFromDefault", "Download from Default Server"), + act("deployment.downloadFrom", "Download from…"), + act("deployment.syncWith", "Sync with Deployed…"), + act("deployment.deleteFromServer", "Delete from Server"), + SEP, + act("deployment.toggleAutoUpload", "Automatic Upload"), + act("deployment.browseRemoteHost", "Browse Remote Host"), + act("deployment.openInBrowser", "Open in Browser"), + act("deployment.configure", "Configuration…"), + ], + ), + SEP, act("vim.toggle", "Vim"), ], )); diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs new file mode 100644 index 000000000..9560eabf0 --- /dev/null +++ b/src-tauri/src/remote_dev.rs @@ -0,0 +1,381 @@ +//! Remote development (client side): a window whose project lives on another +//! machine. The backend there is `openidea-server`, started over SSH; the +//! frontend's remote-capable commands go through [`remote_invoke`] instead of +//! running locally. + +use crate::events::TauriSink; +use crate::state::AppState; +use ide_core::{EventSink, IdeError, IdeResult}; +use ide_host::rpc::{ChannelMessage, EventMessage, Invoke, ServerInfo}; +use ide_jsonrpc::{Handler, Peer}; +use parking_lot::Mutex; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use specta::Type; +use std::collections::HashMap; +use std::sync::Arc; +use tauri::ipc::Channel; +use tauri::{AppHandle, Manager, State, Window}; + +/// Where the server is installed on the remote host (per version). +fn install_dir(version: &str) -> String { + format!("~/.openidea/server/{version}") +} + +/// One remote window's connection. +pub struct Connection { + peer: Peer, + pub ssh_config: String, + pub root: String, + pub host: String, + pub commands: Vec, + /// Streaming arguments by id: the frontend channel and the id it expects. + channels: Arc>>>, + _session: Arc, +} + +#[derive(Default)] +pub struct Connections(Mutex>>); + +impl Connections { + pub fn get(&self, window: &str) -> Option> { + self.0.lock().get(window).cloned() + } + + pub fn disconnect(&self, window: &str) { + if let Some(c) = self.0.lock().remove(window) { + c.peer.kill(); + } + } +} + +/// A value for one streaming argument of a remote command (`data`: JSON text). +#[derive(Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct ChannelFrame { + pub id: u64, + pub data: String, +} + +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RemoteProject { + pub ssh_config: String, + pub root: String, + /// `user@host:port` of the SSH configuration. + pub host: String, + pub server_version: String, + pub os: String, + /// Commands the server answers (the rest run locally). + pub commands: Vec, + pub project: ide_host::ops::project::ProjectInfo, +} + +struct ClientHandler { + app: AppHandle, + window: String, + channels: Arc>>>, +} + +impl Handler for ClientHandler { + fn on_notification(&self, method: &str, params: Value) { + match method { + "event" => match serde_json::from_value::(params) { + Ok(m) => { + // The server's windows are this window. + let _ = m.window; + TauriSink { app: self.app.clone(), window: Some(self.window.clone()) }.emit(m.event); + } + Err(e) => tracing::warn!("remote event: {e}"), + }, + "channel" => match serde_json::from_value::(params) { + Ok(m) => { + let channel = self.channels.lock().get(&m.id).cloned(); + if let Some(c) = channel { + let _ = c.send(ChannelFrame { id: m.id, data: m.data.to_string() }); + } + } + Err(e) => tracing::warn!("remote channel: {e}"), + }, + _ => {} + } + } + + fn on_request(&self, method: &str, _: Value) -> Result { + Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unhandled method {method}"))) + } +} + +/// The server binary for a remote `os`/`arch`: `OPENIDEA_SERVER_BINARY`, then +/// `/server/-/openidea-server`, then one next to this app +/// when the platforms match. +fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option { + if let Some(p) = std::env::var_os("OPENIDEA_SERVER_BINARY").map(std::path::PathBuf::from).filter(|p| p.is_file()) { + return Some(p); + } + let bundled = state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server"); + if bundled.is_file() { + return Some(bundled); + } + if os == std::env::consts::OS && arch == std::env::consts::ARCH { + let exe = std::env::current_exe().ok()?; + let name = if cfg!(windows) { "openidea-server.exe" } else { "openidea-server" }; + let sibling = exe.parent()?.join(name); + if sibling.is_file() { + return Some(sibling); + } + } + None +} + +/// The release build of the server for `os`/`arch`, downloaded once into the data dir. +async fn download_server(state: &AppState, version: &str, os: &str, arch: &str) -> IdeResult { + let (data, version, os, arch) = (state.data_dir.clone(), version.to_owned(), os.to_owned(), arch.to_owned()); + tokio::task::spawn_blocking(move || { + ide_tools::server::fetch(&data, &version, &os, &arch, &ide_core::CancellationToken::new(), &|e| { + if let ide_tools::InstallEvent::Line { text } = e { + tracing::info!("openidea-server download: {text}"); + } + }) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + +/// `uname -sm` → (`linux` | `macos`, `x86_64` | `aarch64`), as Rust names them. +fn platform(uname: &str) -> (String, String) { + let mut parts = uname.split_whitespace(); + let os = match parts.next().unwrap_or_default().to_ascii_lowercase().as_str() { + "darwin" => "macos".to_owned(), + other => other.to_owned(), + }; + let arch = match parts.next().unwrap_or_default() { + "arm64" => "aarch64".to_owned(), + "amd64" => "x86_64".to_owned(), + other => other.to_owned(), + }; + (os, arch) +} + +/// Makes sure this version of the server is installed on the host; returns its path. +async fn ensure_server(state: &AppState, session: &ide_ssh::Session) -> IdeResult { + let version = env!("CARGO_PKG_VERSION"); + let dir = install_dir(version); + let q = ide_ssh::shell::tilde_quote; + let probe = + session.exec(&format!("uname -sm; ls -d {}/*/openidea-server 2>/dev/null", q(&dir)), None).await?.stdout_str(); + let mut lines = probe.lines(); + let (os, arch) = platform(lines.next().unwrap_or_default()); + let installed: Vec<&str> = lines.collect(); + let local = match local_server_binary(state, &os, &arch) { + Some(local) => local, + None => match download_server(state, version, &os, &arch).await { + Ok(local) => local, + // Offline: any build of this version already on the host will do. + Err(e) => { + return installed.first().map(|b| b.to_string()).ok_or_else(|| { + IdeError::unavailable(format!( + "no openidea-server {version} for {os}-{arch} ({}). Build it with \ + `cargo build --release -p ide-host --bin openidea-server` for that platform and put it at {} \ + (or set OPENIDEA_SERVER_BINARY)", + e.message, + state.data_dir.join("server").join(format!("{os}-{arch}")).join("openidea-server").display() + )) + }); + } + }, + }; + // One folder per build, so a rebuilt server of the same version is installed again. + let build = tokio::task::spawn_blocking({ + let local = local.clone(); + move || build_id(&local) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))??; + if let Some(bin) = installed.iter().find(|b| b.ends_with(&format!("/{build}/openidea-server"))) { + return Ok(bin.to_string()); + } + let fs = ide_ssh::RemoteFs::new(session.sftp().await?); + let remote_dir = fs.resolve(&dir).await; + fs.close().await; + let remote_dir = remote_dir?; + let remote_bin = ide_ssh::sftp::join(&ide_ssh::sftp::join(&remote_dir, &build), "openidea-server"); + let tmp = format!("{remote_bin}.part"); + let started = std::time::Instant::now(); + session.put(&tmp, &tokio::fs::read(&local).await?).await?; + tracing::info!("installed openidea-server on {} in {:?}", session.label(), started.elapsed()); + let q = ide_ssh::shell::quote; + // Older builds of this version go (a running one keeps its open file). + session + .exec( + &format!( + "chmod 755 {tmp} && mv -f {tmp} {bin} && find {dir} -mindepth 1 -maxdepth 1 ! -name {build} -exec rm -rf {{}} +", + tmp = q(&tmp), + bin = q(&remote_bin), + dir = q(&remote_dir), + build = q(&build), + ), + None, + ) + .await? + .into_stdout("installing openidea-server")?; + Ok(remote_bin) +} + +/// A short content hash of the server binary (cached per path, size and time). +fn build_id(path: &std::path::Path) -> IdeResult { + static CACHE: Mutex> = Mutex::new(None); + let meta = std::fs::metadata(path)?; + let modified = meta.modified()?; + if let Some((p, len, time, id)) = &*CACHE.lock() + && p == path + && *len == meta.len() + && *time == modified + { + return Ok(id.clone()); + } + let id = blake3::hash(&std::fs::read(path)?).to_hex()[..16].to_owned(); + *CACHE.lock() = Some((path.to_path_buf(), meta.len(), modified, id.clone())); + Ok(id) +} + +/// File › Remote Development: opens `path` on the host of SSH configuration +/// `ssh_config` in this window (installs / starts the server as needed). +#[tauri::command] +#[specta::specta] +pub async fn remote_connect( + window: Window, + state: State<'_, AppState>, + ssh_config: String, + path: String, +) -> IdeResult { + let label = AppState::owner_window(window.label()).to_owned(); + state.remote_dev.disconnect(&label); + state.host.close_project(&label); + let config = state.ssh.config(&ssh_config)?; + let session = state.ssh.session(&ssh_config).await?; + let bin = ensure_server(&state, &session).await?; + let stream = session + .exec_stream(&format!("{} --stdio", ide_ssh::shell::tilde_quote(&bin))) + .await + .map_err(|e| IdeError::unavailable(format!("cannot start openidea-server: {e}")))?; + let (reader, writer) = tokio::io::split(stream); + let channels: Arc>>> = Arc::default(); + let handler = + Arc::new(ClientHandler { app: window.app_handle().clone(), window: label.clone(), channels: channels.clone() }); + let peer = Peer::connect(reader, writer, handler); + let info: ServerInfo = peer + .request("initialize", serde_json::json!({ "version": ide_host::rpc::PROTOCOL_VERSION }), None) + .await + .map_err(ide_host::rpc::error_from_reply)?; + if info.protocol != ide_host::rpc::PROTOCOL_VERSION { + return Err(IdeError::unavailable(format!( + "openidea-server {} speaks protocol {}, this app {}", + info.version, + info.protocol, + ide_host::rpc::PROTOCOL_VERSION + ))); + } + let commands: Vec = + peer.request("commands", Value::Null, None).await.map_err(ide_host::rpc::error_from_reply)?; + let root = if path.trim().is_empty() || path.trim() == "~" { + info.home.clone() + } else if let Some(rest) = path.trim().strip_prefix("~/") { + format!("{}/{rest}", info.home.trim_end_matches('/')) + } else { + path.trim().to_owned() + }; + let call = Invoke { window: label.clone(), cmd: "open_project".into(), args: serde_json::json!({ "path": root }) }; + let project: ide_host::ops::project::ProjectInfo = + peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?; + let _ = window.set_title(&format!("{} [{}]", project.name, config.label())); + let connection = Arc::new(Connection { + peer, + ssh_config: ssh_config.clone(), + root: root.clone(), + host: config.label(), + commands: commands.clone(), + channels, + _session: session, + }); + state.remote_dev.0.lock().insert(label, connection); + Ok(RemoteProject { + ssh_config, + root, + host: config.label(), + server_version: info.version, + os: info.os, + commands, + project, + }) +} + +#[derive(Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct RemoteSession { + pub ssh_config: String, + pub root: String, + pub host: String, + pub commands: Vec, +} + +/// The remote session of this window (a settings window: its parent's), if any. +#[tauri::command] +#[specta::specta] +pub fn remote_current(window: Window, state: State<'_, AppState>) -> Option { + let c = state.remote_dev.get(AppState::owner_window(window.label()))?; + Some(RemoteSession { + ssh_config: c.ssh_config.clone(), + root: c.root.clone(), + host: c.host.clone(), + commands: c.commands.clone(), + }) +} + +#[tauri::command] +#[specta::specta] +pub fn remote_disconnect(window: Window, state: State<'_, AppState>) { + state.remote_dev.disconnect(AppState::owner_window(window.label())); +} + +/// Runs a command on the window's remote server; `args` and the result are JSON +/// text. Streaming arguments are `{ "$channel": id }`; their values arrive on +/// `on_channel` tagged with the id. +#[tauri::command] +#[specta::specta] +pub async fn remote_invoke( + window: Window, + state: State<'_, AppState>, + cmd: String, + args: String, + on_channel: Channel, +) -> IdeResult { + let args: Value = serde_json::from_str(&args).map_err(|e| IdeError::invalid(format!("arguments: {e}")))?; + let label = AppState::owner_window(window.label()).to_owned(); + let c = state + .remote_dev + .get(&label) + .ok_or_else(|| IdeError::unavailable("this window is not connected to a remote host"))?; + if let Value::Object(map) = &args { + for v in map.values() { + if let Some(id) = v.get("$channel").and_then(Value::as_u64) { + c.channels.lock().insert(id, on_channel.clone()); + } + } + } + let call = Invoke { window: label, cmd, args }; + let result: Value = c.peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?; + Ok(result.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_uname_to_rust_platform_names() { + assert_eq!(platform("Linux x86_64"), ("linux".into(), "x86_64".into())); + assert_eq!(platform("Darwin arm64"), ("macos".into(), "aarch64".into())); + assert_eq!(platform("Linux aarch64"), ("linux".into(), "aarch64".into())); + } +} diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 48885b29c..7508e8494 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -1,89 +1,38 @@ use crate::events::TauriSink; -use ide_core::{CancellationToken, EventSink, IdeError, IdeEvent, IdeResult, RequestId, SharedSink}; -use ide_dap::DebugManager; -use ide_index::ProjectIndex; -use ide_language::{FileTypes, LanguageRegistry, Syntax, SyntaxCache}; -use ide_lsp::LspManager; -use ide_run::ProcessManager; -use ide_syntax::StructureService; -use ide_terminal::TerminalManager; -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, RecentProjects, SettingsStore, SourceFolder, -}; -use parking_lot::{Mutex, RwLock}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; +use ide_core::{EventSink, IdeEvent, IdeResult, SharedSink}; +use ide_language::{LanguageRegistry, Syntax}; +use ide_workspace::RecentProjects; +use std::path::Path; use std::sync::{Arc, OnceLock}; use std::time::Duration; use tauri::{AppHandle, Manager}; -/// Process-wide state shared by all windows. +pub use ide_host::{Host, Project}; + +/// Process-wide state shared by all windows: the backend [`Host`] (reachable +/// through `Deref`) plus what only the desktop shell has. pub struct AppState { pub app: AppHandle, - pub config_dir: PathBuf, - pub data_dir: PathBuf, - pub languages: Arc, - /// Built on a background thread at start-up; see [`AppState::syntax`]. - syntax: Arc>>, + pub host: Arc, pub startup: Startup, pub recent: RecentProjects, - pub global_settings: SettingsStore, - pub terminals: TerminalManager, - pub processes: ProcessManager, - pub debugger: Arc, /// WASM plugins (`ide-plugin`), shared by all windows. pub plugins: Arc, /// The built-in web server (Open In › Browser, the editor's browser bar). pub web: ide_web::WebServer, - /// SSH connections of Settings › SSH Configurations, shared by all windows. - pub ssh: Arc, /// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start). pub askpass: Option, - /// SFTP deployment, remote file editing and runs on run targets. - pub remote: Arc, + /// Remote development connections, by window label. + pub remote_dev: crate::remote_dev::Connections, /// Broadcasts to every window. pub sink: SharedSink, - /// One project per window, keyed by window label. - projects: RwLock>>, - requests: Mutex>, } -/// 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, - pub settings: Arc, - pub documents: Arc, - pub history: Arc, - /// Undo stack of file move / rename refactorings. - pub moves: FileMoves, - pub index: Arc, - pub lsp: Arc, - /// Git repositories: the one containing the project root and those nested below it. - pub repos: Arc>, - /// Incrementally maintained syntax trees of the open documents. - pub syntax: Arc, - /// Tree-sitter structure queries (LSP fallback) over `syntax`. - pub structure: Arc, - /// Roots auto-detected from build files; see [`Project::roots`]. - pub detected_roots: Arc>>, - /// Per-project cache directory (build tool sync results…). - pub cache_dir: PathBuf, - /// Database tool window: data sources and their connections. - pub db: Arc, - /// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir. - pub http_history: Arc, - watcher: Watcher, - /// Watchers of the Project Structure content roots outside the project folder. - content_watchers: Mutex>, - _scratch_watcher: Option, +impl std::ops::Deref for AppState { + type Target = Host; + fn deref(&self) -> &Host { + &self.host + } } /// Start-up timings (`app_metrics`). @@ -124,43 +73,36 @@ impl AppState { } let global_sink: SharedSink = Arc::new(TauriSink { app: app.clone(), window: None }); let languages = Arc::new(LanguageRegistry::load(Some(&config_dir.join("languages")))?); - 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); + let sink_app = app.clone(); + let host = Arc::new(Host::new(ide_host::HostConfig { + config_dir: config_dir.clone(), + data_dir: data_dir.clone(), + languages: languages.clone(), + syntax, + sink_for: Box::new(move |window| { + Arc::new(TauriSink { app: sink_app.clone(), window: Some(window.to_owned()) }) + }), + owner: Self::owner_window, + runtime: tauri::async_runtime::handle().inner().clone(), + })); Ok(Self { recent: RecentProjects::load(&config_dir), - global_settings, - remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))), - ssh, askpass: start_askpass(&app), - // Sessions, runs and terminals report to the window that started them (`window_sink`). - terminals: TerminalManager::new(), - processes: ProcessManager::new(), - debugger: DebugManager::new(languages.clone()), sink: global_sink, plugins: crate::commands::plugin::create_host(&app, &data_dir)?, web: ide_web::WebServer::new(), - syntax, + remote_dev: crate::remote_dev::Connections::default(), + host, startup: Startup { process_to_main, process_to_setup, setup: started.elapsed(), first_ipc: OnceLock::new(), }, - languages, - config_dir, - data_dir, app, - 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 { - self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone() - } - /// Records the first IPC call for the start-up metric. pub fn note_ipc(&self) { self.startup.first_ipc.get_or_init(|| { @@ -177,711 +119,29 @@ impl AppState { }); } - /// Every open project (all windows). - pub fn projects(&self) -> Vec> { - self.projects.read().values().cloned().collect() - } - - /// The project of `window`. A settings window (`settings-`) shares - /// its parent window's project. - pub fn project(&self, window: &str) -> IdeResult> { - let window = Self::owner_window(window); - self.projects - .read() - .get(window) - .cloned() - .ok_or_else(|| IdeError::not_found("no project is open in this window")) - } - /// The window that owns what `window` starts: a settings window's parent, else `window`. pub fn owner_window(window: &str) -> &str { crate::windows::settings_parent(window).unwrap_or(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(_) => { - Arc::new(TauriSink { app: self.app.clone(), window: Some(Self::owner_window(window).to_owned()) }) - } - } - } - + /// Opens `root` in `window` and records it in the recent projects. pub fn open_project(&self, window: &str, root: &Path) -> IdeResult> { - 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 = Arc::new(TauriSink { app: self.app.clone(), window: Some(window.to_owned()) }); - 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 = - 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(), - ); - } - })); - } - { - 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(), - })); - - 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(); - Watcher::start( - &root.clone(), - excludes.clone(), - sink.clone(), - Box::new(move |batch| { - let changed: Vec = 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(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 = 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 branch = repos.read().primary().and_then(|r| r.branch_info().ok()).and_then(|b| b.head); - { - 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"); - self.recent.touch(&root, branch)?; - let display_name = settings.effective().general.project_name; + // A local project replaces a remote one in this window. + self.remote_dev.disconnect(window); + let project = self.host.open_project(window, root)?; + let branch = project.repos.read().primary().and_then(|r| r.branch_info().ok()).and_then(|b| b.head); + self.recent.touch(&project.root, branch)?; + let display_name = project.settings.effective().general.project_name; if !display_name.trim().is_empty() { - self.recent.rename(&root, Some(display_name.trim()))?; + self.recent.rename(&project.root, Some(display_name.trim()))?; } - mark("recent"); - 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 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, - watcher, - content_watchers: Mutex::new(content_watchers), - _scratch_watcher: scratch_watcher, - }); - self.projects.write().insert(window.to_owned(), project.clone()); Ok(project) } pub fn close_project(&self, window: &str) { - if let Some(project) = self.projects.write().remove(window) { - let lsp = project.lsp.clone(); - tauri::async_runtime::spawn(async move { lsp.shutdown_all().await }); - } + self.remote_dev.disconnect(window); + self.host.close_project(window); } - - // ---- 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(); - } - } -} - -/// 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, 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(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { - 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(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { - 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(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { - let settings = self.settings.effective(); - crate::commands::vcs::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(&self, f: impl FnOnce(&Repo) -> IdeResult) -> IdeResult { - 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( - &self, - at: Option<&Path>, - operation: Option<&str>, - f: impl FnOnce(&Repo) -> IdeResult, - ) -> IdeResult { - 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 { - let mut out: Vec = - 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 - } - - /// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected). - pub fn exclude_globs(&self) -> Vec { - 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 { - 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 { - 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 { - let mut out: Vec = 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, - documents: &Arc, - history: &Arc, -) -> Option { - 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 = 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 { - 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); - -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); - -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); - -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, - settings: Arc, - root: PathBuf, - sink: SharedSink, -} - -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()); - tauri::async_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( - remote: Arc, - settings: Arc, - root: PathBuf, - removed: Vec, - sink: SharedSink, -) { - tauri::async_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) { - 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::>().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) }); } /// Serves askpass prompts to the focused window and routes `git`'s prompts there. @@ -889,7 +149,8 @@ fn start_askpass(app: &AppHandle) -> Option { let exe = std::env::current_exe().ok()?; let handle = app.clone(); let on_prompt = Arc::new(move |p: ide_ssh::askpass::AskpassPrompt| { - let window = crate::windows::target_window(&handle).map(|w| w.label().to_owned()); + // A focused Settings window hands the prompt to its project window. + let window = crate::windows::target_window(&handle).map(|w| AppState::owner_window(w.label()).to_owned()); TauriSink { app: handle.clone(), window }.emit(IdeEvent::Askpass { id: p.id, prompt: p.prompt, kind: p.kind }); }); match ide_ssh::askpass::AskpassServer::start(exe, on_prompt) { diff --git a/src/bindings.ts b/src/bindings.ts index 7723000fb..9a9d68064 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -800,6 +800,25 @@ export const commands = { remoteDelete: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_delete", { ssh, path })), remoteMkdir: (ssh: string, path: string) => typedError(__TAURI_INVOKE("remote_mkdir", { ssh, path })), remoteRename: (ssh: string, from: string, to: string) => typedError(__TAURI_INVOKE("remote_rename", { ssh, from, to })), + /** + * File › Remote Development: opens `path` on the host of SSH configuration + * `ssh_config` in this window (installs / starts the server as needed). + */ + remoteConnect: (sshConfig: string, path: string) => typedError(__TAURI_INVOKE("remote_connect", { sshConfig, path })), + /** The remote session of this window (a settings window: its parent's), if any. */ + remoteCurrent: () => __TAURI_INVOKE<{ + sshConfig: string, + root: string, + host: string, + commands: string[], +} | null>("remote_current"), + remoteDisconnect: () => __TAURI_INVOKE("remote_disconnect"), + /** + * Runs a command on the window's remote server; `args` and the result are JSON + * text. Streaming arguments are `{ "$channel": id }`; their values arrive on + * `on_channel` tagged with the id. + */ + remoteInvoke: (cmd: string, args: string, onChannel: Channel) => typedError(__TAURI_INVOKE("remote_invoke", { cmd, args, onChannel })), /** Test Connection: connects with unsaved settings and returns the server version. */ dbTest: (source: DataSource, password: string | null, sshSecret: string | null) => typedError(__TAURI_INVOKE("db_test", { source, password, sshSecret })), dbConnected: () => typedError(__TAURI_INVOKE("db_connected")), @@ -1171,6 +1190,12 @@ export type ChangelistState = { files?: { [key in string]: string }, }; +/** A value for one streaming argument of a remote command (`data`: JSON text). */ +export type ChannelFrame = { + id: number, + data: string, +}; + export type CheckStatus = "pending" | "success" | "failure" | "cancelled" | "skipped" | "neutral"; /** @@ -4114,6 +4139,39 @@ tags?: PushTags | null } | /** Pull dialog: `branches` of `remote` (none = the configured merge branch). */ { op: "pullFrom"; remote: string; branches: string[]; rebase: boolean; options: MergeOptions }; +export type RemoteProject = RemoteProject_Serialize | RemoteProject_Deserialize; + +export type RemoteProject_Deserialize = { + sshConfig: string, + root: string, + /** `user@host:port` of the SSH configuration. */ + host: string, + serverVersion: string, + os: string, + /** Commands the server answers (the rest run locally). */ + commands: string[], + project: ProjectInfo_Deserialize, +}; + +export type RemoteProject_Serialize = { + sshConfig: string, + root: string, + /** `user@host:port` of the SSH configuration. */ + host: string, + serverVersion: string, + os: string, + /** Commands the server answers (the rest run locally). */ + commands: string[], + project: ProjectInfo_Serialize, +}; + +export type RemoteSession = { + sshConfig: string, + root: string, + host: string, + commands: string[], +}; + export type ReplaceEvent = /** A file was changed (`replacements` > 0) or had nothing left to replace. */ { type: "file"; path: IpcPath; replacements: number; done: number; total: number } | { type: "failed"; path: IpcPath; message: string; done: number; total: number } | { type: "done"; summary: ReplaceSummary }; diff --git a/src/features/database/SourceDialog.tsx b/src/features/database/SourceDialog.tsx index db874f9c8..adffa38b7 100644 --- a/src/features/database/SourceDialog.tsx +++ b/src/features/database/SourceDialog.tsx @@ -17,9 +17,22 @@ import { type DbSslMode, type DbTxMode, } from "@/platform/ipc"; +import { executeAction } from "@/platform/actions"; import { useTx, useTxTemplate } from "@/platform/i18n"; +import { sshLabel as configLabel } from "@/platform/remoteSettings"; +import { useWorkflowSettings } from "@/platform/workflowSettings"; import { Button, Checkbox, Dialog, IconButton, Input, Select, Tabs, T, type SelectOption } from "@/ui"; -import { colorVar, connectRemedy, fileSourceName, newSourceId, parseSourceUrl, sshLabel } from "./model"; +import { + colorVar, + connectRemedy, + effectiveSsh, + fileSourceName, + newSourceId, + parseSourceUrl, + sshConfigOf, + sshLabel, + sshTunnelReady, +} from "./model"; import { closeDialog, loadDatabases, loadSchemas, saveSource, useDatabase } from "./store"; import { VENDORS, sourceUrl, vendorById, vendorOf } from "./vendors"; import styles from "./Database.module.css"; @@ -335,6 +348,11 @@ export function SourceDialog({ const set = (patch: Partial) => setS((x) => ({ ...x, ...patch })); const ssh: DbSsh = { ...blankSsh(), ...s.ssh }; + const sshConfigs = useWorkflowSettings((w) => w.ssh.configs); + const sshConfigId = ssh.config?.trim() ?? ""; + const sshConfig = sshConfigOf(ssh, sshConfigs); + // The host / user / authentication in effect (the chosen SSH configuration's). + const sshUsed: DbSsh = effectiveSsh(ssh, sshConfigs) ?? ssh; const setSsh = (patch: Partial) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } })); const options: DbOptions = { ...blankOptions(), ...s.options }; const setOptions = (patch: Partial) => @@ -357,7 +375,9 @@ export function SourceDialog({ const name = s.name?.trim() || (sqlite ? fileSourceName(s.file ?? "") : `${database || s.user || "db"}@${host || "localhost"}`); const sshOn = !sqlite && !!ssh.enabled; - const valid = sqlite ? !!s.file?.trim() : (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || !!ssh.host?.trim()); + const valid = sqlite + ? !!s.file?.trim() + : (urlOnly ? urlValid : !!s.host?.trim()) && (!sshOn || sshTunnelReady(ssh, sshConfigs)); const sslOn = (s.ssl ?? "prefer") !== "disable"; /** What is saved / tested: a preset's port when the field was left empty. */ @@ -403,7 +423,7 @@ export function SourceDialog({ setTest({ ok: true, text: `Succeeded: ${v}` }); } catch (e) { const text = e instanceof Error ? e.message : String(e); - const trust = e instanceof IpcError && connectRemedy(e.kind, e.message) === "trustHostKey"; + const trust = e instanceof IpcError && connectRemedy(e.kind, e.message, { ssh: sshUsed }) === "trustHostKey"; setTest({ ok: false, text, trust }); } finally { setBusy(false); @@ -411,7 +431,8 @@ export function SourceDialog({ }; const trustAndRetry = async () => { - await call(commands.dbTrustSshHost(s.id ?? "")); + // A shared SSH configuration records the failed host key under its own id. + await call(sshConfigId ? commands.sshTrustHost(sshConfigId) : commands.dbTrustSshHost(s.id ?? "")); await runTest(); }; @@ -421,7 +442,7 @@ export function SourceDialog({ await saveSource( { ...effective(), properties: properties.filter((p) => p.name?.trim()) }, sqlite ? undefined : password === undefined ? undefined : password || null, - !sshOn || sshSecret === undefined || ssh.auth === "openSsh" ? undefined : sshSecret || null, + !sshOn || sshSecret === undefined || sshUsed.auth === "openSsh" ? undefined : sshSecret || null, ); closeDialog(); } catch (e) { @@ -508,7 +529,7 @@ export function SourceDialog({ {sshOn && ( <> - Reached from the SSH host {sshLabel({ ssh })}. + Reached from the SSH host {sshLabel({ ssh: sshUsed })}. )}