дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(remote): Add SSH configurations, SFTP deployment, run targets and remote development
SSH (new crate ide-ssh)
- Shared SSH configurations (Settings › Tools › SSH Configurations) used by
data source tunnels, deployment servers, run targets and terminals.
- Sessions with password, key pair or OpenSSH config + agent authentication,
ProxyJump, known-hosts checks with trust prompts, saved secrets.
- Askpass: credential prompts of `ssh` / `git` child processes shown in the IDE.
- Tools › Start SSH Session opens a terminal tab for a configuration.
- The database SSH tunnel moves here and can use a shared configuration.
Deployment (new crate ide-remote)
- Settings › Build › Deployment: SFTP servers, path mappings, excludes,
automatic upload on save.
- Upload / download / delete / sync with the server, Tools › Deployment
menu, Remote Host tool window for browsing and editing remote files.
Run targets
- Settings › Build › Run Targets: run configurations can run on an SSH host
("Run on"), with project sync before the run and remote interpreters.
Remote development
- New crate ide-host: the backend (projects, documents, LSP, VCS, search,
terminals, runs, debugger, tools, settings) shared by the desktop shell and
`openidea-server`, a headless server speaking JSON-RPC over stdio.
- File › Remote Development… connects to an SSH host, installs the server
(per-build folder, streamed gzip upload; the release build is downloaded
when there is none locally) and opens a remote folder in the window.
- The window routes the commands the server answers to it; streaming
channels and events are forwarded. The server takes the login shell's PATH.
Цей коміт міститься в:
1 джерело
0ab50e0dd4
коміт
7531834b3c
143 files changed
+12372
-2909
No files matched your search
Створено
+44
@@ -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",
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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<String> = std::env::args().skip(1).collect();
|
||||
if args.iter().any(|a| a == "--version") {
|
||||
println!("openidea-server {} (protocol {})", env!("CARGO_PKG_VERSION"), rpc::PROTOCOL_VERSION);
|
||||
return;
|
||||
}
|
||||
if !args.iter().any(|a| a == "--stdio") {
|
||||
eprintln!("usage: openidea-server --stdio | --version");
|
||||
std::process::exit(2);
|
||||
}
|
||||
// stdout carries the protocol: logs go to stderr.
|
||||
tracing_subscriber::fmt()
|
||||
.with_writer(std::io::stderr)
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::try_from_env("OPENIDEA_LOG").unwrap_or_else(|_| "info".into()))
|
||||
.init();
|
||||
let home = dirs::home_dir().unwrap_or_default();
|
||||
let base = std::env::var_os("OPENIDEA_SERVER_HOME").map(PathBuf::from).unwrap_or_else(|| home.join(".openidea"));
|
||||
// An SSH exec channel gets a minimal PATH: take the login shell's, as the
|
||||
// desktop app does, plus the IDE-managed tools. Before any other thread starts.
|
||||
let refresh_shell_path = ide_host::shell_env::inherit_login_shell_path("openidea-server");
|
||||
let data_dir = base.join("data");
|
||||
ide_tools::extend_process_path(&data_dir);
|
||||
ide_dap::adapter::set_tools_root(ide_tools::tools_dir(&data_dir));
|
||||
ide_sdk::java::add_jdk_root(&ide_tools::jdk::jdks_dir(&data_dir));
|
||||
if let Some(refresh) = refresh_shell_path {
|
||||
refresh();
|
||||
}
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread().enable_all().build().expect("tokio runtime");
|
||||
let code = runtime.block_on(run(base));
|
||||
std::process::exit(code);
|
||||
}
|
||||
|
||||
async fn run(base: PathBuf) -> i32 {
|
||||
let (config_dir, data_dir) = (base.join("config"), base.join("data"));
|
||||
for dir in [&config_dir, &data_dir] {
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
eprintln!("cannot create {}: {e}", dir.display());
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
let languages = match LanguageRegistry::load(Some(&config_dir.join("languages"))) {
|
||||
Ok(l) => Arc::new(l),
|
||||
Err(e) => {
|
||||
eprintln!("language definitions: {e}");
|
||||
return 1;
|
||||
}
|
||||
};
|
||||
let peer = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir,
|
||||
data_dir,
|
||||
languages,
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |window| rpc::event_sink(sink_peer.clone(), Some(window.to_owned()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
}));
|
||||
tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION"));
|
||||
rpc::serve(host, Dispatcher::standard(), peer, tokio::io::stdin(), tokio::io::stdout()).await;
|
||||
0
|
||||
}
|
||||
@@ -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<dyn Fn(&str) -> SharedSink + Send + Sync>;
|
||||
|
||||
pub struct HostConfig {
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
/// Grammars, possibly still being built on another thread.
|
||||
pub syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
pub sink_for: SinkFactory,
|
||||
/// The window that owns what `window` starts (e.g. a settings window's parent).
|
||||
pub owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
pub struct Host {
|
||||
pub config_dir: PathBuf,
|
||||
pub data_dir: PathBuf,
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
pub global_settings: SettingsStore,
|
||||
pub terminals: TerminalManager,
|
||||
pub processes: ProcessManager,
|
||||
/// Debug sessions (adapters run on this machine, next to the project).
|
||||
pub debugger: Arc<ide_dap::DebugManager>,
|
||||
/// SSH connections of Settings › SSH Configurations.
|
||||
pub ssh: Arc<ide_ssh::SshManager>,
|
||||
/// SFTP deployment, remote file editing and runs on run targets.
|
||||
pub remote: Arc<ide_remote::RemoteService>,
|
||||
pub(crate) sink_for: SinkFactory,
|
||||
owner: fn(&str) -> &str,
|
||||
pub runtime: tokio::runtime::Handle,
|
||||
/// One project per window, keyed by window label.
|
||||
projects: RwLock<HashMap<String, Arc<Project>>>,
|
||||
requests: Mutex<HashMap<RequestId, CancellationToken>>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn new(config: HostConfig) -> Self {
|
||||
let HostConfig { config_dir, data_dir, languages, syntax, sink_for, owner, runtime } = config;
|
||||
let global_settings = SettingsStore::load(&config_dir, None);
|
||||
let ssh = Arc::new(ide_ssh::SshManager::new(data_dir.join("ssh-passwords.json")));
|
||||
ssh.set_configs(global_settings.effective().ssh.configs);
|
||||
Self {
|
||||
remote: Arc::new(ide_remote::RemoteService::new(ssh.clone(), data_dir.join("remote"))),
|
||||
ssh,
|
||||
global_settings,
|
||||
// Sessions, runs and terminals report to the window that started them (`window_sink`).
|
||||
terminals: TerminalManager::new(),
|
||||
processes: ProcessManager::new(),
|
||||
debugger: ide_dap::DebugManager::new(languages.clone()),
|
||||
languages,
|
||||
syntax,
|
||||
config_dir,
|
||||
data_dir,
|
||||
sink_for,
|
||||
owner,
|
||||
runtime,
|
||||
projects: RwLock::default(),
|
||||
requests: Mutex::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The tree-sitter grammars (blocks only if start-up warm-up is still running).
|
||||
pub fn syntax(&self) -> Arc<Syntax> {
|
||||
self.syntax.get_or_init(|| Arc::new(Syntax::new())).clone()
|
||||
}
|
||||
|
||||
/// Every open project (all windows).
|
||||
pub fn projects(&self) -> Vec<Arc<Project>> {
|
||||
self.projects.read().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// The project of `window` (or of the window owning it).
|
||||
pub fn project(&self, window: &str) -> IdeResult<Arc<Project>> {
|
||||
self.projects
|
||||
.read()
|
||||
.get((self.owner)(window))
|
||||
.cloned()
|
||||
.ok_or_else(|| IdeError::not_found("no project is open in this window"))
|
||||
}
|
||||
|
||||
/// The window that owns what `window` starts.
|
||||
pub fn owner_window<'a>(&self, window: &'a str) -> &'a str {
|
||||
(self.owner)(window)
|
||||
}
|
||||
|
||||
/// Event sink for things `window` starts (debug sessions, runs, terminals): its
|
||||
/// project's window-scoped sink, or a sink for that window when no project is open.
|
||||
pub fn window_sink(&self, window: &str) -> SharedSink {
|
||||
match self.project(window) {
|
||||
Ok(p) => p.sink.clone(),
|
||||
Err(_) => (self.sink_for)((self.owner)(window)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn close_project(&self, window: &str) {
|
||||
if let Some(project) = self.projects.write().remove(window) {
|
||||
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<Project>) {
|
||||
self.projects.write().insert(window.to_owned(), project);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Code analysis over a scope of files: Inspect Code (batch LSP diagnostics),
|
||||
//! Locate Duplicates and Search / Replace Structurally (`ide-analysis`).
|
||||
//!
|
||||
//! Scopes are Find in Files scopes; excluded roots are never analysed. Every
|
||||
//! command takes a UI-allocated `RequestId` and is cancelled via `cancel_request`.
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::doc::blocking;
|
||||
use ide_analysis::{
|
||||
DuplicateOptions, DuplicatesReport, SourceFile, StructuralFile, StructuralQuery, StructuralSummary,
|
||||
};
|
||||
use ide_core::{CancellationToken, Diagnostic, DocId, IdeResult, IpcPath, LanguageId, RequestId};
|
||||
use ide_language::LanguageRegistry;
|
||||
use ide_lsp::{FileInspection, InspectHost, InspectOptions};
|
||||
use ide_search::{FileMask, SearchScope};
|
||||
use ide_workspace::Documents;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Files of `scope`, minus excluded roots and ignored names.
|
||||
fn files_in_scope(project: &Project, scope: &SearchScope, cancel: &CancellationToken) -> IdeResult<Vec<SourceFile>> {
|
||||
let mask = FileMask::new(&[], &project.search_excludes())?;
|
||||
Ok(ide_search::scope_files(&project.root, scope, &mask, None, cancel)?
|
||||
.into_iter()
|
||||
.map(|f| SourceFile { path: f.path, rel_path: f.rel_path })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Current text of a file (the unsaved buffer when open) and its language.
|
||||
fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<(LanguageId, String)> {
|
||||
if let Some(doc) = project.documents.by_path(path) {
|
||||
let snap = project.documents.text_snapshot(doc).ok()?;
|
||||
return (!snap.large).then_some((snap.language, snap.text));
|
||||
}
|
||||
let loaded = ide_vfs::load_text(path).ok()?;
|
||||
if loaded.binary || loaded.large || loaded.too_large {
|
||||
return None;
|
||||
}
|
||||
let language = languages.detect(path, loaded.text.lines().next());
|
||||
Some((language, loaded.text))
|
||||
}
|
||||
|
||||
// ---- Inspect Code ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InspectedFile {
|
||||
pub path: IpcPath,
|
||||
pub rel_path: String,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InspectSummary {
|
||||
/// Files inspected by a language server.
|
||||
pub files: u32,
|
||||
pub problems: u32,
|
||||
/// Files in scope no language server serves.
|
||||
pub skipped: u32,
|
||||
/// Files whose server failed or never became ready.
|
||||
pub failed: u32,
|
||||
pub cancelled: bool,
|
||||
pub elapsed_ms: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum InspectEvent {
|
||||
/// Files about to be inspected.
|
||||
Started {
|
||||
total: u32,
|
||||
},
|
||||
/// A file finished (with or without problems).
|
||||
File {
|
||||
file: InspectedFile,
|
||||
done: u32,
|
||||
},
|
||||
Done {
|
||||
summary: InspectSummary,
|
||||
},
|
||||
}
|
||||
|
||||
struct DocumentsHost(Arc<Documents>);
|
||||
|
||||
impl InspectHost for DocumentsHost {
|
||||
fn open(&self, path: &Path) -> IdeResult<(DocId, bool)> {
|
||||
let was_open = self.0.by_path(path).is_some();
|
||||
Ok((self.0.open(path)?.id, was_open))
|
||||
}
|
||||
|
||||
fn close(&self, doc: DocId) {
|
||||
self.0.close(doc);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Locate Duplicates ----
|
||||
|
||||
// ---- Structural Search ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum StructuralEvent {
|
||||
File { file: StructuralFile },
|
||||
Done { summary: StructuralSummary },
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StructuralLanguage {
|
||||
pub id: LanguageId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Inspect Code: diagnostics of every file in `scope` that a language server
|
||||
/// serves. Files stream through `on_event` as they finish; ends with `Done`.
|
||||
pub async fn inspect_code(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
scope: SearchScope,
|
||||
on_event: Channel<InspectEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let started = Instant::now();
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let listed = {
|
||||
let (project, cancel) = (project.clone(), cancel.clone());
|
||||
blocking(move || files_in_scope(&project, &scope, &cancel)).await
|
||||
};
|
||||
let files = match listed {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
host.end_request(request);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let total_in_scope = files.len() as u32;
|
||||
let rel: std::collections::HashMap<_, _> = files
|
||||
.iter()
|
||||
.filter(|f| project.lsp.serves_path(&f.path))
|
||||
.map(|f| (f.path.clone(), f.rel_path.clone()))
|
||||
.collect();
|
||||
let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() };
|
||||
let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 });
|
||||
|
||||
let mut paths: Vec<_> = rel.keys().cloned().collect();
|
||||
paths.sort();
|
||||
let tally = Mutex::new(summary.clone());
|
||||
let done = std::sync::atomic::AtomicU32::new(0);
|
||||
let docs = Arc::new(DocumentsHost(project.documents.clone()));
|
||||
project
|
||||
.lsp
|
||||
.inspect_files(docs, paths, InspectOptions::default(), cancel.clone(), &|path, outcome| {
|
||||
let done = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let diagnostics = {
|
||||
let mut t = tally.lock();
|
||||
match outcome {
|
||||
FileInspection::Inspected(items) => {
|
||||
t.files += 1;
|
||||
t.problems += items.len() as u32;
|
||||
items
|
||||
}
|
||||
FileInspection::NoServer => {
|
||||
t.skipped += 1;
|
||||
Vec::new()
|
||||
}
|
||||
FileInspection::Failed(message) => {
|
||||
tracing::debug!("inspect {}: {message}", path.display());
|
||||
t.failed += 1;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
let rel_path = rel.get(&path).cloned().unwrap_or_default();
|
||||
let file = InspectedFile { path: IpcPath(path), rel_path, diagnostics };
|
||||
let _ = on_event.send(InspectEvent::File { file, done });
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
summary = tally.into_inner();
|
||||
summary.cancelled = cancel.is_cancelled();
|
||||
summary.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
|
||||
let _ = on_event.send(InspectEvent::Done { summary });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Locate Duplicates over the files of `scope`.
|
||||
pub async fn analysis_locate_duplicates(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
scope: SearchScope,
|
||||
options: DuplicateOptions,
|
||||
) -> IdeResult<DuplicatesReport> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let result = blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
let loader = |p: &Path| load(&project, &languages, p);
|
||||
Ok(ide_analysis::find_duplicates(&syntax, &files, &loader, &options, &cancel))
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Languages Search Structurally supports (those with a bundled grammar).
|
||||
pub async fn structural_languages(host: &Host, _window: &str) -> IdeResult<Vec<StructuralLanguage>> {
|
||||
let syntax = host.syntax();
|
||||
let mut out: Vec<StructuralLanguage> = host
|
||||
.languages
|
||||
.all()
|
||||
.into_iter()
|
||||
.filter(|c| syntax.supports(&c.id))
|
||||
.map(|c| StructuralLanguage { id: c.id, name: c.name })
|
||||
.collect();
|
||||
out.sort_by_key(|l| l.name.to_lowercase());
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Checks a structural search template (and language) while the user types.
|
||||
pub async fn structural_validate(host: &Host, _window: &str, query: StructuralQuery) -> IdeResult<()> {
|
||||
let syntax = host.syntax();
|
||||
blocking(move || ide_analysis::validate_structural(&syntax, &query)).await
|
||||
}
|
||||
|
||||
/// Search Structurally over `scope`; files with matches stream through
|
||||
/// `on_event`, ending with `Done`. With a replacement, matches carry their
|
||||
/// replacement text (applied by the UI as a workspace edit).
|
||||
pub async fn structural_search(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
scope: SearchScope,
|
||||
query: StructuralQuery,
|
||||
on_event: Channel<StructuralEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
let result = blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
let loader = |p: &Path| load(&project, &languages, p);
|
||||
ide_analysis::search_structurally(&syntax, &files, &loader, &query, &cancel, &|file| {
|
||||
let _ = events.send(StructuralEvent::File { file });
|
||||
})
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
let _ = on_event.send(StructuralEvent::Done { summary: result? });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("inspect_code", |c| async move {
|
||||
inspect_code(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.sender("onEvent")?).await
|
||||
});
|
||||
d.add("analysis_locate_duplicates", |c| async move {
|
||||
analysis_locate_duplicates(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("structural_languages", |c| async move { structural_languages(&c.host, &c.window).await });
|
||||
d.add("structural_validate", |c| async move { structural_validate(&c.host, &c.window, c.arg("query")?).await });
|
||||
d.add("structural_search", |c| async move {
|
||||
structural_search(&c.host, &c.window, c.arg("request")?, c.arg("scope")?, c.arg("query")?, c.sender("onEvent")?)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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<u32>) -> BTreeMap<String, String> {
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r))));
|
||||
jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
|
||||
/// the Maven home and options from Settings › Maven.
|
||||
pub fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings));
|
||||
let jdk = ide_sdk::apply::project_jdk(root, settings);
|
||||
if let Some(m) = &mut model.maven {
|
||||
let mvn = ide_sdk::apply::maven_command(root, settings);
|
||||
let options = ide_sdk::apply::maven_options(root, settings);
|
||||
for p in &mut m.projects {
|
||||
p.env = java_env(jdk.as_ref(), p.java_version);
|
||||
if let Some(mvn) = &mvn {
|
||||
p.command = mvn.to_string_lossy().into_owned();
|
||||
}
|
||||
p.options = options.clone();
|
||||
}
|
||||
}
|
||||
if let Some(g) = &mut model.gradle {
|
||||
g.env = java_env(jdk.as_ref(), g.java_version);
|
||||
}
|
||||
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
|
||||
let missing = |release: Option<u32>| match jdk {
|
||||
Some(_) => None,
|
||||
None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release),
|
||||
};
|
||||
let explain = |error: &mut Option<String>, hint: Option<String>| {
|
||||
if let (Some(e), Some(hint)) = (error.as_mut(), hint) {
|
||||
*e = format!("{hint}\n{e}");
|
||||
}
|
||||
};
|
||||
if let Some(m) = &mut model.maven
|
||||
&& m.sync_error.is_some()
|
||||
{
|
||||
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
|
||||
explain(&mut m.sync_error, hint);
|
||||
}
|
||||
if let Some(g) = &mut model.gradle
|
||||
&& g.sync_error.is_some()
|
||||
{
|
||||
let hint = missing(g.java_version);
|
||||
explain(&mut g.sync_error, hint);
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
|
||||
pub async fn build_model(host: &Host, window: &str) -> IdeResult<BuildModel> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
|
||||
/// background, reporting progress on `on_progress`; cancellable with `cancel_request(request)`.
|
||||
/// Returns the refreshed model; a failed build is reported in its `syncError`.
|
||||
pub async fn build_sync(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
tool: BuildTool,
|
||||
request: RequestId,
|
||||
offline: bool,
|
||||
profiles: Vec<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
BuildTool::Maven => current
|
||||
.maven
|
||||
.as_ref()
|
||||
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
|
||||
.map(|p| p.env.clone())
|
||||
.unwrap_or_default(),
|
||||
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
};
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
profiles,
|
||||
env,
|
||||
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(&root, &settings),
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
|
||||
}
|
||||
Ok(load(&root, &cache, &settings))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Build › Build Project / Rebuild Project / Build Module (`file`: a file or folder of the
|
||||
/// module) / Build Artifacts, streaming the console and messages on `on_event`; cancellable
|
||||
/// with `cancel_request(request)`. Diagnostics of `clear` (the files of the previous build)
|
||||
/// are cleared first; the new messages are published as `build` diagnostics.
|
||||
pub async fn build_run(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
kind: BuildKind,
|
||||
file: Option<std::path::PathBuf>,
|
||||
request: RequestId,
|
||||
clear: Vec<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
|
||||
let settings = project.settings.effective();
|
||||
let token = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
source: compile::DIAGNOSTICS_SOURCE.into(),
|
||||
items: Vec::new(),
|
||||
});
|
||||
}
|
||||
let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?;
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
for (path, items) in compile::diagnostics(&result.messages) {
|
||||
sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items });
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("build_model", |c| async move { build_model(&c.host, &c.window).await });
|
||||
d.add("build_sync", |c| async move {
|
||||
build_sync(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("tool")?,
|
||||
c.arg("request")?,
|
||||
c.arg("offline")?,
|
||||
c.arg("profiles")?,
|
||||
c.sender("onProgress")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("build_run", |c| async move {
|
||||
build_run(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("kind")?,
|
||||
c.arg("file")?,
|
||||
c.arg("request")?,
|
||||
c.arg("clear")?,
|
||||
c.sender("onEvent")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! Raw file bytes for the image viewer and hex view (see `ide_vfs::bytes`).
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use crate::ops::doc::blocking;
|
||||
use ide_core::IdeResult;
|
||||
use ide_vfs::{ByteRange, FileProbe};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Size and text/binary classification, from the file's head only.
|
||||
pub async fn fs_probe(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<FileProbe> {
|
||||
blocking(move || ide_vfs::probe(&path)).await
|
||||
}
|
||||
|
||||
/// Up to `len` bytes from `offset` (capped per call), base64-encoded.
|
||||
pub async fn fs_read_range(_host: &Host, _window: &str, path: PathBuf, offset: u64, len: u32) -> IdeResult<ByteRange> {
|
||||
blocking(move || ide_vfs::read_range(&path, offset, len)).await
|
||||
}
|
||||
|
||||
/// An image file as a `data:` URL.
|
||||
pub async fn fs_read_image(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<String> {
|
||||
blocking(move || ide_vfs::read_data_url(&path)).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("fs_probe", |c| async move { fs_probe(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("fs_read_range", |c| async move {
|
||||
fs_read_range(&c.host, &c.window, c.arg("path")?, c.arg("offset")?, c.arg("len")?).await
|
||||
});
|
||||
d.add("fs_read_image", |c| async move { fs_read_image(&c.host, &c.window, c.arg("path")?).await });
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
//! Run with Coverage: launch plans and coverage reports.
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite};
|
||||
use ide_run::RunConfig;
|
||||
|
||||
/// Output directories kept per kind (older runs' data is deleted).
|
||||
pub const KEEP_RUNS: usize = 10;
|
||||
|
||||
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// How to run `config` with coverage: the command to launch and where its report will be,
|
||||
/// or which tool is missing.
|
||||
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
|
||||
blocking(move || {
|
||||
let out = ide_run::wrap::new_output_dir(&cache, KEEP_RUNS)?;
|
||||
Ok(ide_coverage::prepare(&root, &config, &out))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reads the report a coverage run left behind.
|
||||
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
}
|
||||
|
||||
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
|
||||
/// with paths mapped into this project.
|
||||
pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult<CoverageSuite> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
blocking(move || ide_coverage::import(&path, &root)).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("coverage_prepare", |c| async move { coverage_prepare(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("coverage_collect", |c| async move { coverage_collect(&c.host, &c.window, c.arg("report")?).await });
|
||||
d.add("coverage_import", |c| async move { coverage_import(&c.host, &c.window, c.arg("path")?).await });
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DebugSessionId, IdeResult};
|
||||
use ide_dap::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Starts a session owned by this window: its events (and those of child sessions) go here only.
|
||||
pub async fn debug_start(host: &Host, window: &str, config: LaunchConfig) -> IdeResult<DebugSessionId> {
|
||||
let label = window;
|
||||
let owner = SessionOwner::new(host.owner_window(label), host.window_sink(label));
|
||||
let settings = match host.project(label) {
|
||||
Ok(project) => project.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
};
|
||||
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let custom = |command: &str| {
|
||||
let path = ide_sdk::apply::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<Breakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
host.debugger.set_breakpoints(path, breakpoints).await
|
||||
}
|
||||
|
||||
pub async fn debug_threads(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
|
||||
host.debugger.threads(session).await
|
||||
}
|
||||
|
||||
pub async fn debug_stack_trace(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
) -> IdeResult<Vec<StackFrame>> {
|
||||
host.debugger.stack_trace(session, thread_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_scopes(host: &Host, _window: &str, session: DebugSessionId, frame_id: i64) -> IdeResult<Vec<Scope>> {
|
||||
host.debugger.scopes(session, frame_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_variables(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
reference: i64,
|
||||
) -> IdeResult<Vec<Variable>> {
|
||||
host.debugger.variables(session, reference).await
|
||||
}
|
||||
|
||||
pub async fn debug_evaluate(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
frame_id: Option<i64>,
|
||||
expression: String,
|
||||
) -> IdeResult<Variable> {
|
||||
host.debugger.evaluate(session, frame_id, expression).await
|
||||
}
|
||||
|
||||
pub async fn debug_resume(host: &Host, _window: &str, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
host.debugger.resume(session, thread_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_pause(host: &Host, _window: &str, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
host.debugger.pause(session, thread_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_step(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
kind: StepKind,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.step(session, thread_id, kind).await
|
||||
}
|
||||
|
||||
pub async fn debug_run_to(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
path: PathBuf,
|
||||
line: u32,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.run_to(session, thread_id, path, line, force).await
|
||||
}
|
||||
|
||||
pub async fn debug_stop(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<()> {
|
||||
host.debugger.stop(session).await
|
||||
}
|
||||
|
||||
/// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`.
|
||||
pub async fn debug_evaluate_in(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
frame_id: Option<i64>,
|
||||
expression: String,
|
||||
context: Option<String>,
|
||||
) -> IdeResult<Variable> {
|
||||
host.debugger.evaluate_in(session, frame_id, expression, context).await
|
||||
}
|
||||
|
||||
/// Last known verification states of a file's breakpoints (refetch on `breakpointsChanged`).
|
||||
pub fn debug_breakpoint_states(host: &Host, _window: &str, path: PathBuf) -> IdeResult<Vec<BreakpointState>> {
|
||||
Ok(host.debugger.breakpoint_states(&path))
|
||||
}
|
||||
|
||||
/// This window's live sessions, including adapter-started child sessions.
|
||||
pub fn debug_sessions(host: &Host, window: &str) -> IdeResult<Vec<SessionInfo>> {
|
||||
Ok(host.debugger.sessions(Some(host.owner_window(window))))
|
||||
}
|
||||
|
||||
pub fn debug_exception_filters(host: &Host, window: &str) -> IdeResult<Vec<SessionExceptionFilters>> {
|
||||
Ok(host.debugger.exception_filters(Some(host.owner_window(window))))
|
||||
}
|
||||
|
||||
pub async fn debug_set_exception_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
choices: std::collections::BTreeMap<String, bool>,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.set_exception_breakpoints(choices).await
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn debug_set_variable(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
container: i64,
|
||||
name: String,
|
||||
evaluate_name: Option<String>,
|
||||
frame_id: Option<i64>,
|
||||
value: String,
|
||||
) -> IdeResult<Variable> {
|
||||
host.debugger.set_variable(session, container, name, evaluate_name, frame_id, value).await
|
||||
}
|
||||
|
||||
/// Force Step Over: step over with every breakpoint suspended until the step ends.
|
||||
pub async fn debug_force_step_over(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.force_step_over(session, thread_id).await
|
||||
}
|
||||
|
||||
/// Smart Step Into: the calls on the current line of `frame_id`.
|
||||
pub async fn debug_step_in_targets(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
frame_id: i64,
|
||||
) -> IdeResult<Vec<StepInTarget>> {
|
||||
host.debugger.step_in_targets(session, frame_id).await
|
||||
}
|
||||
|
||||
pub async fn debug_step_in_target(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
target_id: i64,
|
||||
) -> IdeResult<()> {
|
||||
host.debugger.step_in_target(session, thread_id, target_id).await
|
||||
}
|
||||
|
||||
/// Reset Frame (Drop Frame).
|
||||
pub async fn debug_restart_frame(host: &Host, _window: &str, session: DebugSessionId, frame_id: i64) -> IdeResult<()> {
|
||||
host.debugger.restart_frame(session, frame_id).await
|
||||
}
|
||||
|
||||
pub fn debug_capabilities(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
host.debugger.capabilities(session)
|
||||
}
|
||||
|
||||
/// Method breakpoints (all files); states aligned with `breakpoints`.
|
||||
pub async fn debug_set_function_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
breakpoints: Vec<FunctionBreakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
host.debugger.set_function_breakpoints(breakpoints).await
|
||||
}
|
||||
|
||||
/// Field watchpoints; states aligned with `breakpoints`.
|
||||
pub async fn debug_set_data_breakpoints(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
breakpoints: Vec<DataBreakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
host.debugger.set_data_breakpoints(breakpoints).await
|
||||
}
|
||||
|
||||
pub async fn debug_data_breakpoint_info(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
session: DebugSessionId,
|
||||
container: i64,
|
||||
name: String,
|
||||
frame_id: Option<i64>,
|
||||
) -> IdeResult<DataBreakpointInfo> {
|
||||
host.debugger.data_breakpoint_info(session, container, name, frame_id).await
|
||||
}
|
||||
|
||||
/// Attach to Process: the user's processes a debugger can attach to.
|
||||
pub async fn debug_attachable_processes(_host: &Host, _window: &str) -> IdeResult<Vec<attach::AttachableProcess>> {
|
||||
tokio::task::spawn_blocking(attach::attachable_processes)
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
|
||||
}
|
||||
|
||||
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
|
||||
/// JVM to connect to `host:port`; returns the loopback port java-debug attaches to.
|
||||
pub async fn debug_listen_for_jvm(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
request: ide_core::RequestId,
|
||||
jvm_host: String,
|
||||
port: u16,
|
||||
) -> IdeResult<u16> {
|
||||
let token = host.begin_request(request);
|
||||
let result = jdwp::listen_for_jvm(&jvm_host, port, token).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// HotSwap (Reload Changed Classes) in a java-debug session.
|
||||
pub async fn debug_redefine_classes(host: &Host, _window: &str, session: DebugSessionId) -> IdeResult<HotSwapResult> {
|
||||
host.debugger.redefine_classes(session).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("debug_start", |c| async move { debug_start(&c.host, &c.window, c.arg("config")?).await });
|
||||
d.add("debug_set_breakpoints", |c| async move {
|
||||
debug_set_breakpoints(&c.host, &c.window, c.arg("path")?, c.arg("breakpoints")?).await
|
||||
});
|
||||
d.add("debug_threads", |c| async move { debug_threads(&c.host, &c.window, c.arg("session")?).await });
|
||||
d.add("debug_stack_trace", |c| async move {
|
||||
debug_stack_trace(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await
|
||||
});
|
||||
d.add(
|
||||
"debug_scopes",
|
||||
|c| async move { debug_scopes(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await },
|
||||
);
|
||||
d.add("debug_variables", |c| async move {
|
||||
debug_variables(&c.host, &c.window, c.arg("session")?, c.arg("reference")?).await
|
||||
});
|
||||
d.add("debug_evaluate", |c| async move {
|
||||
debug_evaluate(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?, c.arg("expression")?).await
|
||||
});
|
||||
d.add(
|
||||
"debug_resume",
|
||||
|c| async move { debug_resume(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
|
||||
);
|
||||
d.add(
|
||||
"debug_pause",
|
||||
|c| async move { debug_pause(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await },
|
||||
);
|
||||
d.add("debug_step", |c| async move {
|
||||
debug_step(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("kind")?).await
|
||||
});
|
||||
d.add("debug_run_to", |c| async move {
|
||||
debug_run_to(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("threadId")?,
|
||||
c.arg("path")?,
|
||||
c.arg("line")?,
|
||||
c.arg("force")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_stop", |c| async move { debug_stop(&c.host, &c.window, c.arg("session")?).await });
|
||||
d.add("debug_evaluate_in", |c| async move {
|
||||
debug_evaluate_in(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("frameId")?,
|
||||
c.arg("expression")?,
|
||||
c.arg("context")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_breakpoint_states", |c| async move { debug_breakpoint_states(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("debug_sessions", |c| async move { debug_sessions(&c.host, &c.window) });
|
||||
d.add("debug_exception_filters", |c| async move { debug_exception_filters(&c.host, &c.window) });
|
||||
d.add("debug_set_exception_breakpoints", |c| async move {
|
||||
debug_set_exception_breakpoints(&c.host, &c.window, c.arg("choices")?).await
|
||||
});
|
||||
d.add("debug_set_variable", |c| async move {
|
||||
debug_set_variable(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("container")?,
|
||||
c.arg("name")?,
|
||||
c.arg("evaluateName")?,
|
||||
c.arg("frameId")?,
|
||||
c.arg("value")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_force_step_over", |c| async move {
|
||||
debug_force_step_over(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?).await
|
||||
});
|
||||
d.add("debug_step_in_targets", |c| async move {
|
||||
debug_step_in_targets(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
|
||||
});
|
||||
d.add("debug_step_in_target", |c| async move {
|
||||
debug_step_in_target(&c.host, &c.window, c.arg("session")?, c.arg("threadId")?, c.arg("targetId")?).await
|
||||
});
|
||||
d.add("debug_restart_frame", |c| async move {
|
||||
debug_restart_frame(&c.host, &c.window, c.arg("session")?, c.arg("frameId")?).await
|
||||
});
|
||||
d.add("debug_capabilities", |c| async move { debug_capabilities(&c.host, &c.window, c.arg("session")?) });
|
||||
d.add("debug_set_function_breakpoints", |c| async move {
|
||||
debug_set_function_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
|
||||
});
|
||||
d.add("debug_set_data_breakpoints", |c| async move {
|
||||
debug_set_data_breakpoints(&c.host, &c.window, c.arg("breakpoints")?).await
|
||||
});
|
||||
d.add("debug_data_breakpoint_info", |c| async move {
|
||||
debug_data_breakpoint_info(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("session")?,
|
||||
c.arg("container")?,
|
||||
c.arg("name")?,
|
||||
c.arg("frameId")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("debug_attachable_processes", |c| async move { debug_attachable_processes(&c.host, &c.window).await });
|
||||
d.add("debug_listen_for_jvm", |c| async move {
|
||||
debug_listen_for_jvm(&c.host, &c.window, c.arg("request")?, c.arg("host")?, c.arg("port")?).await
|
||||
});
|
||||
d.add(
|
||||
"debug_redefine_classes",
|
||||
|c| async move { debug_redefine_classes(&c.host, &c.window, c.arg("session")?).await },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
use crate::Host;
|
||||
use ide_core::{ChangeSet, DocId, IdeResult};
|
||||
use ide_workspace::DocumentInfo;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub async fn open(host: &Host, window: &str, path: PathBuf) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let info = project.documents.open(&path)?;
|
||||
project.index.mark_recent(&path);
|
||||
Ok(info)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
host.project(window)?.documents.close(doc);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
|
||||
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
|
||||
host.project(window)?.documents.edit(doc, base_version, changes)
|
||||
}
|
||||
|
||||
pub async fn save(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.save(doc)).await
|
||||
}
|
||||
|
||||
pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.save_all()).await
|
||||
}
|
||||
|
||||
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || project.documents.snapshot(doc)).await
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,125 @@
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeError, IdeResult, OutlineItem, Range};
|
||||
use ide_language::{FoldRange, SyntaxHighlights};
|
||||
use ide_text::LineEnding;
|
||||
use ide_workspace::{DocumentInfo, HistoryEntry};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool,
|
||||
/// keeping it off the main thread and the async runtime's workers.
|
||||
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Opens a read-only virtual document (see `lsp_virtual_document`) as an
|
||||
/// editor buffer, so syntax features and language-server requests work in it.
|
||||
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
|
||||
let project = host.project(window)?;
|
||||
let vd = project.lsp.virtual_document(&uri).await?;
|
||||
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
|
||||
}
|
||||
|
||||
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
|
||||
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
|
||||
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
|
||||
}
|
||||
|
||||
pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
|
||||
host.project(window)?.documents.set_line_ending(doc, line_ending)
|
||||
}
|
||||
|
||||
/// Structure view: LSP document symbols when available (with tree-sitter
|
||||
/// visibility), tree-sitter otherwise.
|
||||
/// Tree-sitter answers come from the per-document tree cache (incremental
|
||||
/// re-parse); large documents get no syntax structure.
|
||||
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
|
||||
let project = host.project(window)?;
|
||||
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
|
||||
blocking(move || {
|
||||
let snap = match project.documents.text_snapshot(doc) {
|
||||
Ok(snap) => snap,
|
||||
Err(e) => return lsp.ok_or(e),
|
||||
};
|
||||
let syntax = || project.syntax.outline(doc, &snap.language, snap.version, &snap.text);
|
||||
Ok(match lsp {
|
||||
// Server symbols carry no visibility; borrow it from the syntax tree.
|
||||
Some(mut items) => {
|
||||
if !snap.large {
|
||||
ide_language::merge_visibility(&mut items, &syntax());
|
||||
}
|
||||
items
|
||||
}
|
||||
None if snap.large => Vec::new(),
|
||||
None => syntax(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<FoldRange>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Tree-sitter highlighting for languages the UI has no CodeMirror mode for;
|
||||
/// empty when the grammar has no highlights query or the document is large.
|
||||
pub async fn doc_highlights(host: &Host, window: &str, doc: DocId) -> IdeResult<SyntaxHighlights> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(SyntaxHighlights::default());
|
||||
}
|
||||
Ok(project.syntax.highlights(doc, &snap.language, snap.version, &snap.text))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
|
||||
/// Large documents fall back to word / line / document ranges.
|
||||
pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: u32) -> IdeResult<Vec<Range>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
|
||||
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn history_list(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
|
||||
host.project(window)?.history.list(&path)
|
||||
}
|
||||
|
||||
pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult<String> {
|
||||
host.project(window)?.history.content(&id)
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
|
||||
d.add("doc_set_encoding", |c| async move {
|
||||
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
|
||||
});
|
||||
d.add("doc_set_line_ending", |c| async move {
|
||||
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
|
||||
});
|
||||
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_highlights", |c| async move { doc_highlights(&c.host, &c.window, c.arg("doc")?).await });
|
||||
d.add("doc_selection_ranges", |c| async move {
|
||||
doc_selection_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?).await
|
||||
});
|
||||
d.add("history_list", |c| async move { history_list(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("history_content", |c| async move { history_content(&c.host, &c.window, c.arg("id")?) });
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use crate::Host;
|
||||
use ide_core::IdeResult;
|
||||
use ide_vfs::FileEntry;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Files of a deleted folder snapshotted in Local History.
|
||||
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
|
||||
|
||||
pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
|
||||
let excludes = host
|
||||
.project(window)
|
||||
.map(|p| {
|
||||
let globs = p.exclude_globs();
|
||||
// `read_dir` matches relative to the enclosing repository: re-anchor
|
||||
// project-root patterns (`/build`) when the project is a repo subfolder.
|
||||
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
|
||||
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
|
||||
Some(rel) => {
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
|
||||
}
|
||||
None => globs,
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ide_vfs::read_dir(&path, &excludes)
|
||||
}
|
||||
|
||||
/// Creates an empty file; inside a project, honours the EditorConfig
|
||||
/// `charset` (writes the BOM for `utf-8-bom` / UTF-16).
|
||||
pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
match host.project(window) {
|
||||
Ok(p) => {
|
||||
let defaults = p.documents.file_defaults();
|
||||
if defaults.editorconfig {
|
||||
ide_workspace::editorconfig::create_file(p.documents.editorconfig(), &path)?;
|
||||
} else {
|
||||
ide_vfs::create_file(&path)?;
|
||||
}
|
||||
// No EditorConfig charset: File Encodings › "Create UTF-8 files with BOM".
|
||||
let empty = std::fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(false);
|
||||
let bom = defaults.new_file_bytes();
|
||||
if empty && !bom.is_empty() {
|
||||
std::fs::write(&path, bom)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => ide_vfs::create_file(&path),
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
|
||||
/// state). Returns whether the file is read-only now.
|
||||
pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult<bool> {
|
||||
match host.project(window) {
|
||||
Ok(p) => p.documents.set_writable(&path, writable),
|
||||
Err(_) => ide_vfs::set_writable(&path, writable).map(|()| !writable),
|
||||
}
|
||||
}
|
||||
|
||||
/// Moves to the trash; Local History keeps the contents so deleted files can be restored.
|
||||
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
let history = host.project(window).ok().map(|p| p.history.clone());
|
||||
host.blocking(move || {
|
||||
if let Some(h) = history {
|
||||
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
ide_vfs::delete(&path)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
host.blocking(move || ide_vfs::copy(&from, &to)).await
|
||||
}
|
||||
|
||||
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
|
||||
pub async fn read_text(host: &Host, window: &str, path: PathBuf) -> IdeResult<String> {
|
||||
let project = host.project(window).ok();
|
||||
host.blocking(move || match project {
|
||||
Some(p) => p.text_of(&path),
|
||||
None => Ok(ide_vfs::load_text(&path)?.text),
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
//! Local History beyond single files: folders, labels, restoring deleted files.
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::IdeResult;
|
||||
use ide_workspace::DirectoryHistory;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::doc::blocking;
|
||||
|
||||
/// Show History on a folder: snapshots of the files under it (deleted ones included) and labels.
|
||||
pub async fn history_list_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<DirectoryHistory> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.history.list_dir(&path)).await
|
||||
}
|
||||
|
||||
/// Local History › Put Label…: a named point in the history of every file.
|
||||
pub fn history_put_label(host: &Host, window: &str, label: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
project.history.put_label(&project.root, &label)
|
||||
}
|
||||
|
||||
/// Revert to a snapshot: writes its content to `path` (re-creating a deleted
|
||||
/// file and its folders); an open editor updates in place.
|
||||
pub async fn history_restore(host: &Host, window: &str, path: PathBuf, id: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
let text = project.history.content(&id)?;
|
||||
if let Some(doc) = project.documents.by_path(&path) {
|
||||
project.documents.replace_all(doc, &text)?;
|
||||
return project.documents.save(doc);
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if !path.exists() {
|
||||
ide_vfs::create_file(&path)?;
|
||||
}
|
||||
let doc = project.documents.open(&path)?.id;
|
||||
let result = project.documents.replace_all(doc, &text).and_then(|_| project.documents.save(doc));
|
||||
project.documents.close(doc);
|
||||
result
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("history_list_dir", |c| async move { history_list_dir(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("history_put_label", |c| async move { history_put_label(&c.host, &c.window, c.arg("label")?) });
|
||||
d.add(
|
||||
"history_restore",
|
||||
|c| async move { history_restore(&c.host, &c.window, c.arg("path")?, c.arg("id")?).await },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::build::load;
|
||||
use ide_build::compile;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_lsp::jdtls::JavaLaunchInfo;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Builds the workspace (when `build`), then resolves the runtime (or test)
|
||||
/// classpath and the JDK of the module at `module` (absolute, or relative to the project root).
|
||||
/// A module with Kotlin / Groovy / Scala sources is built by its build system, whose class directories come first.
|
||||
pub async fn java_resolve_launch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
test: bool,
|
||||
build: bool,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
let built = if build && compile::has_non_java_sources(&module) {
|
||||
let (root, cache, module, settings) =
|
||||
(project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut info = project.lsp.java_launch(&module, test, build).await?;
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.contains(&p) {
|
||||
class_paths.push(p);
|
||||
}
|
||||
}
|
||||
info.class_paths = class_paths;
|
||||
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
|
||||
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
|
||||
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
|
||||
let release = ide_sdk::java::project_java_version(&module);
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
|
||||
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
|
||||
});
|
||||
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
|
||||
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
|
||||
let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
|
||||
info.shorten_command_line = Some(shorten.as_str().to_owned());
|
||||
if info.error_count > 0 {
|
||||
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
|
||||
let managed = ide_tools::managed_bundle(&host.data_dir, "lombok");
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
|
||||
let root = project.root.clone();
|
||||
// Detection may read the project's sources.
|
||||
let lombok = tokio::task::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
info.hints.extend(lombok.problem());
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
|
||||
pub async fn java_debug_server(host: &Host, window: &str, module: PathBuf) -> IdeResult<u16> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_debug_server(&module).await
|
||||
}
|
||||
|
||||
/// Incremental build of the jdtls workspace serving `module` (e.g. before HotSwap).
|
||||
pub async fn java_build_workspace(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: PathBuf,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
let project = host.project(window)?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("java_resolve_launch", |c| async move {
|
||||
java_resolve_launch(&c.host, &c.window, c.arg("module")?, c.arg("test")?, c.arg("build")?).await
|
||||
});
|
||||
d.add("java_debug_server", |c| async move { java_debug_server(&c.host, &c.window, c.arg("module")?).await });
|
||||
d.add("java_build_workspace", |c| async move { java_build_workspace(&c.host, &c.window, c.arg("module")?).await });
|
||||
}
|
||||
@@ -0,0 +1,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<String>,
|
||||
) -> IdeResult<CompletionList> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.completion(doc, pos, trigger, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing.
|
||||
pub async fn lsp_inline_completion(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
automatic: bool,
|
||||
) -> IdeResult<Vec<InlineCompletionItem>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_resolve_completion(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
item: CompletionItem,
|
||||
) -> IdeResult<CompletionItem> {
|
||||
host.project(window)?.lsp.resolve_completion(doc, item).await
|
||||
}
|
||||
|
||||
pub async fn lsp_hover(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<Hover>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.hover(doc, pos, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_signature_help(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<SignatureHelp>> {
|
||||
host.project(window)?.lsp.signature_help(doc, pos).await
|
||||
}
|
||||
|
||||
pub async fn lsp_goto(host: &Host, window: &str, doc: DocId, pos: Pos, kind: GotoKind) -> IdeResult<Vec<Location>> {
|
||||
host.project(window)?.lsp.goto(doc, pos, kind).await
|
||||
}
|
||||
|
||||
/// Contents of a virtual document (library class, decompiled code) that a
|
||||
/// language server returned a non-`file:` URI for. Cached per project.
|
||||
pub async fn lsp_virtual_document(host: &Host, window: &str, uri: String) -> IdeResult<VirtualDocument> {
|
||||
host.project(window)?.lsp.virtual_document(&uri).await
|
||||
}
|
||||
|
||||
pub async fn lsp_references(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
include_declaration: bool,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
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<Vec<Highlight>> {
|
||||
host.project(window)?.lsp.document_highlights(doc, pos).await
|
||||
}
|
||||
|
||||
pub async fn lsp_workspace_symbols(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
query: String,
|
||||
) -> IdeResult<Vec<WorkspaceSymbol>> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.workspace_symbols(&query, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn lsp_code_actions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<Vec<CodeAction>> {
|
||||
host.project(window)?.lsp.code_actions(doc, range, only).await
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
pub async fn lsp_code_action_query(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<CodeActionQuery> {
|
||||
let lsp = host.project(window)?.lsp.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = lsp.code_action_query(doc, range, only, cancel).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Resolves a code action's edit without applying it (intention preview).
|
||||
pub async fn lsp_resolve_code_action(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
action: CodeAction,
|
||||
) -> IdeResult<CodeAction> {
|
||||
if action.edit.is_some() {
|
||||
return Ok(action);
|
||||
}
|
||||
host.project(window)?.lsp.resolve_code_action(doc, action).await
|
||||
}
|
||||
|
||||
/// Resolves (if needed), applies the edit, then runs the command of a code action.
|
||||
pub async fn lsp_apply_code_action(host: &Host, window: &str, doc: DocId, action: CodeAction) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let action = if action.edit.is_none() && action.command.is_none() {
|
||||
project.lsp.resolve_code_action(doc, action).await?
|
||||
} else {
|
||||
action
|
||||
};
|
||||
if let Some(edit) = &action.edit {
|
||||
apply_edit(&project.documents, edit)?;
|
||||
}
|
||||
if let Some(command) = action.command {
|
||||
project.lsp.execute_command(doc, command).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn lsp_prepare_rename(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<Range>> {
|
||||
host.project(window)?.lsp.prepare_rename(doc, pos).await
|
||||
}
|
||||
|
||||
/// Computes the rename edit without applying it, so the UI can preview conflicts.
|
||||
pub async fn lsp_rename(host: &Host, window: &str, doc: DocId, pos: Pos, new_name: String) -> IdeResult<WorkspaceEdit> {
|
||||
host.project(window)?.lsp.rename(doc, pos, new_name).await
|
||||
}
|
||||
|
||||
pub async fn lsp_format(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
host.project(window)?.lsp.format(doc, range, options).await
|
||||
}
|
||||
|
||||
pub async fn lsp_inlay_hints(host: &Host, window: &str, doc: DocId, range: Range) -> IdeResult<Vec<InlayHint>> {
|
||||
host.project(window)?.lsp.inlay_hints(doc, range).await
|
||||
}
|
||||
|
||||
pub async fn lsp_semantic_tokens(host: &Host, window: &str, doc: DocId) -> IdeResult<Option<SemanticTokens>> {
|
||||
host.project(window)?.lsp.semantic_tokens(doc).await
|
||||
}
|
||||
|
||||
pub async fn lsp_code_lens(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<CodeLens>> {
|
||||
host.project(window)?.lsp.code_lens(doc).await
|
||||
}
|
||||
|
||||
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
|
||||
pub async fn lsp_document_links(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<DocumentLink>> {
|
||||
host.project(window)?.lsp.document_links(doc).await
|
||||
}
|
||||
|
||||
pub async fn lsp_call_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
host.project(window)?.lsp.call_hierarchy(doc, pos, direction).await
|
||||
}
|
||||
|
||||
pub async fn lsp_type_hierarchy(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
host.project(window)?.lsp.type_hierarchy(doc, pos, direction).await
|
||||
}
|
||||
|
||||
pub fn lsp_servers(host: &Host, window: &str) -> IdeResult<Vec<ServerInfo>> {
|
||||
Ok(host.project(window)?.lsp.servers())
|
||||
}
|
||||
|
||||
pub async fn lsp_restart(host: &Host, window: &str, server: ServerId) -> IdeResult<()> {
|
||||
host.project(window)?.lsp.restart(server).await
|
||||
}
|
||||
|
||||
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
|
||||
pub fn apply_workspace_edit(host: &Host, window: &str, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
// Multi-file refactorings leave a Local History point to go back to.
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
apply_edit(&project.documents, &edit)
|
||||
}
|
||||
|
||||
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
|
||||
/// follow-up command. Edits the server requests arrive via `workspace/applyEdit`.
|
||||
pub async fn lsp_execute_command(host: &Host, window: &str, doc: DocId, command: Command) -> IdeResult<()> {
|
||||
host.project(window)?.lsp.execute_command(doc, command).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("lsp_completion", |c| async move {
|
||||
lsp_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("trigger")?).await
|
||||
});
|
||||
d.add("lsp_inline_completion", |c| async move {
|
||||
lsp_inline_completion(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?, c.arg("automatic")?)
|
||||
.await
|
||||
});
|
||||
d.add("lsp_resolve_completion", |c| async move {
|
||||
lsp_resolve_completion(&c.host, &c.window, c.arg("doc")?, c.arg("item")?).await
|
||||
});
|
||||
d.add("lsp_hover", |c| async move {
|
||||
lsp_hover(&c.host, &c.window, c.arg("request")?, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add("lsp_signature_help", |c| async move {
|
||||
lsp_signature_help(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await
|
||||
});
|
||||
d.add(
|
||||
"lsp_goto",
|
||||
|c| async move { lsp_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("kind")?).await },
|
||||
);
|
||||
d.add("lsp_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
|
||||
});
|
||||
}
|
||||
@@ -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<T> = Arc<dyn Fn(T) + Send + Sync>;
|
||||
|
||||
/// A streaming argument: what the desktop shell passes as a Tauri channel and
|
||||
/// the server as `channel` notifications.
|
||||
pub struct Channel<T>(Emit<T>);
|
||||
|
||||
impl<T> Channel<T> {
|
||||
pub fn new(send: impl Fn(T) + Send + Sync + 'static) -> Self {
|
||||
Self(Arc::new(send))
|
||||
}
|
||||
|
||||
/// Sends one value; never fails (a closed receiver drops it).
|
||||
pub fn send(&self, value: T) -> IdeResult<()> {
|
||||
(self.0)(value);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Clone for Channel<T> {
|
||||
fn clone(&self) -> Self {
|
||||
Self(self.0.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers the commands of every module with the remote server.
|
||||
pub fn register_all(d: &mut crate::rpc::Dispatcher) {
|
||||
lsp::register(d);
|
||||
nav::register(d);
|
||||
refactor::register(d);
|
||||
analysis::register(d);
|
||||
syntax::register(d);
|
||||
spell::register(d);
|
||||
history::register(d);
|
||||
coverage::register(d);
|
||||
build::register(d);
|
||||
tools::register(d);
|
||||
sdk::register(d);
|
||||
java::register(d);
|
||||
debug::register(d);
|
||||
bytes::register(d);
|
||||
vcs::register(d);
|
||||
doc::register(d);
|
||||
search::register(d);
|
||||
run::register(d);
|
||||
}
|
||||
|
||||
impl Host {
|
||||
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool.
|
||||
pub async fn blocking<T: Send + 'static>(&self, f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
self.runtime.spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
use super::doc::blocking;
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
|
||||
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
|
||||
use ide_language::{FileReference, WebReference};
|
||||
use ide_workspace::file_refs::{self, PathCompletion};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// A web reference no language server resolves and where it points: `class="card"` →
|
||||
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
|
||||
/// `--x:` declarations.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct WebGoto {
|
||||
pub reference: WebReference,
|
||||
pub targets: Vec<Location>,
|
||||
}
|
||||
|
||||
const MAX_WEB_TARGETS: usize = 200;
|
||||
|
||||
/// The project root and its resource roots (served at `/`), for file references.
|
||||
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
|
||||
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
|
||||
(project.root.clone(), resources)
|
||||
}
|
||||
|
||||
pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
|
||||
Ok(host.project(window)?.index.status())
|
||||
}
|
||||
|
||||
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
|
||||
Ok(host.project(window)?.index.find_files(&query, limit as usize))
|
||||
}
|
||||
|
||||
pub fn find_symbols(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
query: String,
|
||||
filter: SymbolFilter,
|
||||
limit: u32,
|
||||
) -> IdeResult<Vec<SymbolMatch>> {
|
||||
Ok(host.project(window)?.index.find_symbols(&query, filter, limit as usize))
|
||||
}
|
||||
|
||||
pub fn mark_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
host.project(window)?.index.mark_recent(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
|
||||
host.project(window)?.index.remove_recent(&path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recently opened files, most recent first (⌘E).
|
||||
pub fn recent_files(host: &Host, window: &str) -> IdeResult<Vec<ide_core::IpcPath>> {
|
||||
Ok(host.project(window)?.index.recent_files().into_iter().map(Into::into).collect())
|
||||
}
|
||||
|
||||
/// The web reference at `pos` of `doc` and its targets; `None` when there is no reference.
|
||||
pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<WebGoto>> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(reference) = syntax.web_reference_at(&snap.language, &snap.text, pos) else { return Ok(None) };
|
||||
let here = |range| Location { path: snap.path.clone(), range, uri: None };
|
||||
let targets = match reference.symbol() {
|
||||
None => syntax.web_anchors(&snap.language, &snap.text, &reference.name).into_iter().map(here).collect(),
|
||||
Some((name, kind)) => {
|
||||
// The open document from its (possibly unsaved) text, other files from the index.
|
||||
let mut targets: Vec<Location> = syntax
|
||||
.web_definitions(&snap.language, &snap.text)
|
||||
.into_iter()
|
||||
.filter(|t| t.name == name && t.kind == kind)
|
||||
.map(|t| here(t.range))
|
||||
.collect();
|
||||
targets.extend(
|
||||
project
|
||||
.index
|
||||
.symbols_named(&name, Some(kind), MAX_WEB_TARGETS)
|
||||
.into_iter()
|
||||
.filter(|m| *m.path != *snap.path)
|
||||
.map(|m| Location { path: m.path, range: m.range, uri: None }),
|
||||
);
|
||||
targets
|
||||
}
|
||||
};
|
||||
Ok(Some(WebGoto { reference, targets }))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
|
||||
pub async fn fs_resolve_reference(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
from: IpcPath,
|
||||
value: String,
|
||||
) -> IdeResult<Option<IpcPath>> {
|
||||
let project = host.project(window).ok();
|
||||
blocking(move || {
|
||||
let (root, resources) = project.as_deref().map(reference_roots).unzip();
|
||||
let resources = resources.unwrap_or_default();
|
||||
Ok(file_refs::resolve(&value, &from, root.as_deref(), &resources).map(Into::into))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Files and folders that continue `prefix`, a path being typed in `from`.
|
||||
pub async fn path_completions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
from: IpcPath,
|
||||
prefix: String,
|
||||
) -> IdeResult<Vec<PathCompletion>> {
|
||||
let project = host.project(window).ok();
|
||||
blocking(move || {
|
||||
let (root, resources) = project.as_deref().map(reference_roots).unzip();
|
||||
let resources = resources.unwrap_or_default();
|
||||
Ok(file_refs::complete(&prefix, &from, root.as_deref(), &resources))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Paths in an HTML document or stylesheet that name no existing file.
|
||||
pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<FileReference>> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
blocking(move || {
|
||||
let snap = project.documents.snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let (root, resources) = reference_roots(&project);
|
||||
Ok(syntax
|
||||
.web_file_references(&snap.language, &snap.text)
|
||||
.into_iter()
|
||||
.filter(|r| {
|
||||
file_refs::is_file_reference(&r.value)
|
||||
&& !file_refs::path_part(&r.value).is_empty()
|
||||
&& file_refs::resolve(&r.value, &snap.path, Some(&root), &resources).is_none()
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
|
||||
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
|
||||
d.add("find_symbols", |c| async move {
|
||||
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
|
||||
});
|
||||
d.add("mark_recent", |c| async move { mark_recent(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) });
|
||||
d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await });
|
||||
d.add("fs_resolve_reference", |c| async move {
|
||||
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
|
||||
});
|
||||
d.add("path_completions", |c| async move {
|
||||
path_completions(&c.host, &c.window, c.arg("from")?, c.arg("prefix")?).await
|
||||
});
|
||||
d.add("web_unresolved_references", |c| async move {
|
||||
web_unresolved_references(&c.host, &c.window, c.arg("doc")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use crate::{Host, Project};
|
||||
use ide_core::IdeResult;
|
||||
use ide_core::settings::Settings;
|
||||
use ide_workspace::settings::SettingsScope;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectInfo {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub name: String,
|
||||
pub is_git: bool,
|
||||
pub settings: Settings,
|
||||
}
|
||||
|
||||
pub fn info(project: &Project) -> ProjectInfo {
|
||||
let settings = project.settings.effective();
|
||||
let custom = settings.general.project_name.trim();
|
||||
ProjectInfo {
|
||||
name: if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
custom.to_owned()
|
||||
},
|
||||
is_git: !project.repos.read().is_empty(),
|
||||
settings,
|
||||
root: project.root.clone().into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens `path` in `window` (off the async workers: it touches the disk).
|
||||
pub async fn open(host: &Arc<Host>, window: &str, path: &Path) -> IdeResult<Arc<Project>> {
|
||||
let (h, window, path) = (host.clone(), window.to_owned(), path.to_path_buf());
|
||||
host.blocking(move || h.open_project(&window, &path)).await
|
||||
}
|
||||
|
||||
pub fn current(host: &Host, window: &str) -> Option<ProjectInfo> {
|
||||
host.project(window).ok().map(|p| info(&p))
|
||||
}
|
||||
|
||||
/// Effective settings for the window's project, or global settings without one.
|
||||
pub fn settings(host: &Host, window: &str) -> Settings {
|
||||
match host.project(window) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => host.global_settings.effective(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots> {
|
||||
Ok(host.project(window)?.roots())
|
||||
}
|
||||
|
||||
/// What [`set_settings`] changed; the caller tells the windows.
|
||||
pub struct SettingsApplied {
|
||||
pub settings: Settings,
|
||||
/// A project value (else a global one).
|
||||
pub project_scope: bool,
|
||||
/// The project's root, when its content roots changed.
|
||||
pub roots_changed: Option<ide_core::IpcPath>,
|
||||
}
|
||||
|
||||
/// Sets `key` to `value` (JSON text, e.g. `14` or `"dark"`) for the window's
|
||||
/// project or globally, and applies it to the open projects: file defaults,
|
||||
/// file types, language servers, index scope and SSH configurations.
|
||||
pub fn set_settings(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
scope: SettingsScope,
|
||||
key: &str,
|
||||
value: &str,
|
||||
) -> IdeResult<SettingsApplied> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(value).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
|
||||
let current = host.project(window).ok();
|
||||
let project_scope = matches!(scope, SettingsScope::Project) && current.is_some();
|
||||
let settings = match ¤t {
|
||||
Some(p) if project_scope => p.settings.set(scope, key, value)?,
|
||||
Some(p) => {
|
||||
host.global_settings.set(SettingsScope::Global, key, value)?;
|
||||
p.settings.reload()
|
||||
}
|
||||
None => host.global_settings.set(SettingsScope::Global, key, value)?,
|
||||
};
|
||||
if let Some(p) = ¤t {
|
||||
p.documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings, Some(&p.root)));
|
||||
}
|
||||
// A global value reaches every other open project too.
|
||||
if !project_scope {
|
||||
for project in host.projects() {
|
||||
if current.as_ref().is_some_and(|c| c.root == project.root) {
|
||||
continue;
|
||||
}
|
||||
let effective = project.settings.reload();
|
||||
project
|
||||
.documents
|
||||
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
|
||||
}
|
||||
}
|
||||
let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() };
|
||||
// File type associations re-detect open documents (off the command thread).
|
||||
if key == "files" || key.starts_with("files.fileType") {
|
||||
let affected = affected();
|
||||
host.runtime.spawn_blocking(move || {
|
||||
for project in affected {
|
||||
project.apply_file_types(&project.settings.effective());
|
||||
}
|
||||
});
|
||||
}
|
||||
if key == "ssh" || key.starts_with("ssh.") {
|
||||
host.ssh.set_configs(host.global_settings.effective().ssh.configs);
|
||||
}
|
||||
// A new interpreter / toolchain / server executable restarts the language servers it affects.
|
||||
if ["languages", "tools"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
|
||||
for project in affected() {
|
||||
let lsp = project.lsp.clone();
|
||||
host.runtime.spawn(async move { lsp.reconfigure().await });
|
||||
}
|
||||
}
|
||||
let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.");
|
||||
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
|
||||
// index and the watchers without reopening the project (the rescan runs in the background).
|
||||
if roots_changed || key == "files" || key.starts_with("files.") {
|
||||
let affected = affected();
|
||||
host.runtime.spawn_blocking(move || {
|
||||
for project in affected {
|
||||
project.refresh_index_scope();
|
||||
}
|
||||
});
|
||||
}
|
||||
let roots_changed = current.as_ref().filter(|_| roots_changed && project_scope).map(|p| p.root.clone().into());
|
||||
Ok(SettingsApplied { settings, project_scope, roots_changed })
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//! File move / rename refactoring: reference updates from the language
|
||||
//! servers, the move itself (one undoable step) and its undo.
|
||||
use crate::Host;
|
||||
use crate::apply_workspace_edit;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeResult, RequestId, WorkspaceEdit};
|
||||
use ide_lsp::types::FileRenameEdits;
|
||||
use ide_workspace::{FileMove, UndoOutcome};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::doc::blocking;
|
||||
|
||||
fn pairs(moves: &[FileMove]) -> Vec<(PathBuf, PathBuf)> {
|
||||
moves.iter().map(|m| (m.from.to_path_buf(), m.to.to_path_buf())).collect()
|
||||
}
|
||||
|
||||
/// `workspace/willRenameFiles` on every interested server (before the move). Cancellable.
|
||||
pub async fn refactor_file_move_edits(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
moves: Vec<FileMove>,
|
||||
) -> IdeResult<FileRenameEdits> {
|
||||
let project = host.project(window)?;
|
||||
let token = host.begin_request(request);
|
||||
let result = project.lsp.will_rename_files(&pairs(&moves), token).await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Applies `edit` (from [`refactor_file_move_edits`], possibly empty), moves the
|
||||
/// files and notifies the servers. Returns the id for [`refactor_undo_file_move`].
|
||||
pub async fn refactor_move_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
moves: Vec<FileMove>,
|
||||
edit: WorkspaceEdit,
|
||||
label: String,
|
||||
) -> IdeResult<u32> {
|
||||
let project = host.project(window)?;
|
||||
let p = project.clone();
|
||||
let moved = moves.clone();
|
||||
let id = blocking(move || {
|
||||
let apply = |e: &WorkspaceEdit| apply_workspace_edit(&p.documents, e);
|
||||
p.moves.perform(&p.documents, Some(&p.history), &moved, &edit, &label, &apply)
|
||||
})
|
||||
.await?;
|
||||
project.lsp.did_rename_files(&pairs(&moves));
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Undoes a file move: moves back and reverts the reference updates.
|
||||
pub async fn refactor_undo_file_move(host: &Host, window: &str, id: u32) -> IdeResult<UndoOutcome> {
|
||||
let project = host.project(window)?;
|
||||
let p = project.clone();
|
||||
let outcome = blocking(move || p.moves.undo(&p.documents, id)).await?;
|
||||
project.lsp.did_rename_files(&pairs(&outcome.moves));
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Whether the file move `id` can still be undone.
|
||||
pub fn refactor_can_undo_file_move(host: &Host, window: &str, id: u32) -> IdeResult<bool> {
|
||||
Ok(host.project(window)?.moves.can_undo(id))
|
||||
}
|
||||
|
||||
/// "Search in comments and strings": textual occurrences of the moved files'
|
||||
/// names, qualified names and paths (before the move). Cancellable.
|
||||
pub async fn refactor_file_move_text_edits(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
moves: Vec<FileMove>,
|
||||
) -> IdeResult<WorkspaceEdit> {
|
||||
let project = host.project(window)?;
|
||||
let token = host.begin_request(request);
|
||||
let cancel = token.clone();
|
||||
let result = blocking(move || {
|
||||
let replacements = ide_workspace::text_refs::move_replacements(&project.root, &project.roots(), &moves);
|
||||
let edit = ide_workspace::text_refs::find_text_occurrences(
|
||||
&project.root,
|
||||
&project.exclude_globs(),
|
||||
&replacements,
|
||||
&|| cancel.is_cancelled(),
|
||||
);
|
||||
if cancel.is_cancelled() { Err(ide_core::IdeError::cancelled()) } else { Ok(edit) }
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("refactor_file_move_edits", |c| async move {
|
||||
refactor_file_move_edits(&c.host, &c.window, c.arg("request")?, c.arg("moves")?).await
|
||||
});
|
||||
d.add("refactor_move_files", |c| async move {
|
||||
refactor_move_files(&c.host, &c.window, c.arg("moves")?, c.arg("edit")?, c.arg("label")?).await
|
||||
});
|
||||
d.add(
|
||||
"refactor_undo_file_move",
|
||||
|c| async move { refactor_undo_file_move(&c.host, &c.window, c.arg("id")?).await },
|
||||
);
|
||||
d.add(
|
||||
"refactor_can_undo_file_move",
|
||||
|c| async move { refactor_can_undo_file_move(&c.host, &c.window, c.arg("id")?) },
|
||||
);
|
||||
d.add("refactor_file_move_text_edits", |c| async move {
|
||||
refactor_file_move_text_edits(&c.host, &c.window, c.arg("request")?, c.arg("moves")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,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<Vec<RunConfig>> {
|
||||
let project = host.project(window)?;
|
||||
let root = project.root.clone();
|
||||
let user = ide_run::load_user_configs(&root)?;
|
||||
let mut all: Vec<RunConfig> =
|
||||
ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect();
|
||||
all.extend(user);
|
||||
// Project interpreter / package manager / toolchains from Languages & Frameworks.
|
||||
ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective());
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn save_configs(host: &Host, window: &str, configs: &[RunConfig]) -> IdeResult<()> {
|
||||
ide_run::save_user_configs(&host.project(window)?.root, configs)
|
||||
}
|
||||
|
||||
pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit<OutputChunk>) -> IdeResult<RunInfo> {
|
||||
let project = host.project(window)?;
|
||||
if config.command.is_empty() {
|
||||
return Err(IdeError::invalid("run configuration has no command"));
|
||||
}
|
||||
// Settings › Run Targets: over SSH.
|
||||
if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) {
|
||||
let settings = project.settings.effective();
|
||||
let out = on_output.clone();
|
||||
return host
|
||||
.remote
|
||||
.run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c)))
|
||||
.await;
|
||||
}
|
||||
let 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<RunInfo> {
|
||||
let mut runs = host.processes.list();
|
||||
runs.extend(host.remote.runs.list());
|
||||
runs
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,48 @@
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::Host;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeError, IdeResult};
|
||||
use ide_run::RunConfig;
|
||||
|
||||
/// Configuration templates: per-type defaults applied to new configurations.
|
||||
pub fn run_templates(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
ide_run::load_templates(&root)
|
||||
}
|
||||
|
||||
pub fn run_save_templates(host: &Host, window: &str, templates: Vec<RunConfig>) -> IdeResult<()> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
ide_run::save_templates(&root, &templates)
|
||||
}
|
||||
|
||||
/// The environment runs inherit when "Include system environment variables" is on.
|
||||
pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap<String, String> {
|
||||
ide_run::system_env()
|
||||
}
|
||||
|
||||
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
|
||||
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
|
||||
pub async fn run_test_reports(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
module: Option<std::path::PathBuf>,
|
||||
since_ms: f64,
|
||||
) -> IdeResult<Vec<ide_run::junit::TestCaseReport>> {
|
||||
let root = host.project(window)?.root.clone();
|
||||
let dir = module.map(|m| root.join(m)).unwrap_or(root);
|
||||
let since = std::time::UNIX_EPOCH + std::time::Duration::from_millis(since_ms.max(0.0) as u64);
|
||||
tokio::task::spawn_blocking(move || ide_run::junit::read_reports(&ide_run::junit::report_dirs(&dir), since))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
|
||||
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
|
||||
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
|
||||
d.add("run_test_reports", |c| async move {
|
||||
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Interpreters / toolchains (`ide-sdk`): discovery, Python virtualenvs and packages.
|
||||
use crate::Host;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use ide_core::{IdeError, IdeResult, RequestId};
|
||||
use ide_sdk::python::PythonPackage;
|
||||
use ide_sdk::{Sdk, SdkKind};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn join_err(e: impl std::fmt::Display) -> IdeError {
|
||||
IdeError::internal(e.to_string())
|
||||
}
|
||||
|
||||
/// Executables found for "run this file", and where single files are compiled to.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocatedTools {
|
||||
pub tools: Vec<ide_sdk::locate::LocatedTool>,
|
||||
pub compile_dir: ide_core::IpcPath,
|
||||
}
|
||||
|
||||
/// Installed interpreters / toolchains of `kind`; project-local ones first.
|
||||
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
|
||||
let root = host.project(window).ok().map(|p| p.root.clone());
|
||||
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// The interpreter the project uses: the configured one, else its virtualenv.
|
||||
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
|
||||
let project = host.project(window)?;
|
||||
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
|
||||
}
|
||||
|
||||
/// SDK / library source folders shown under "External Libraries" in the Project view,
|
||||
/// with the Maven / Gradle dependency jars of the build model.
|
||||
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
let project = host.project(window)?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let model = crate::ops::build::load(&root, &cache, &settings);
|
||||
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
|
||||
.into_iter()
|
||||
.map(|c| ide_sdk::libraries::BuildDependency {
|
||||
gradle: c.gradle,
|
||||
group: c.group_id,
|
||||
artifact: c.artifact_id,
|
||||
version: c.version,
|
||||
})
|
||||
.collect();
|
||||
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
|
||||
pub async fn sdk_locate_tools(
|
||||
_host: &Host,
|
||||
_window: &str,
|
||||
names: Vec<String>,
|
||||
near: Option<PathBuf>,
|
||||
) -> IdeResult<LocatedTools> {
|
||||
tokio::task::spawn_blocking(move || LocatedTools {
|
||||
tools: ide_sdk::locate::locate_tools(&names, near.as_deref()),
|
||||
compile_dir: ide_sdk::locate::compile_dir().into(),
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// `base -m venv location`; output lines stream through `on_output`.
|
||||
pub async fn sdk_python_create_venv(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
base: PathBuf,
|
||||
location: PathBuf,
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
pub async fn sdk_python_packages(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
python: PathBuf,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<PythonPackage>> {
|
||||
let cancel = host.begin_request(request);
|
||||
let result =
|
||||
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Installs (`uninstall = false`) or removes a package; output streams through `on_output`.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn sdk_python_pip(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
python: PathBuf,
|
||||
requirement: String,
|
||||
uninstall: bool,
|
||||
upgrade: bool,
|
||||
request: RequestId,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let send = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
};
|
||||
if uninstall {
|
||||
ide_sdk::python::uninstall_package(&python, &requirement, &cancel, &send)
|
||||
} else {
|
||||
ide_sdk::python::install_package(&python, &requirement, upgrade, &cancel, &send)
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
|
||||
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
|
||||
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
|
||||
d.add("sdk_locate_tools", |c| async move {
|
||||
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
|
||||
});
|
||||
d.add("sdk_python_create_venv", |c| async move {
|
||||
sdk_python_create_venv(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("base")?,
|
||||
c.arg("location")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onOutput")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("sdk_python_packages", |c| async move {
|
||||
sdk_python_packages(&c.host, &c.window, c.arg("python")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("sdk_python_pip", |c| async move {
|
||||
sdk_python_pip(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("python")?,
|
||||
c.arg("requirement")?,
|
||||
c.arg("uninstall")?,
|
||||
c.arg("upgrade")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onOutput")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use super::Emit;
|
||||
use crate::Host;
|
||||
use ide_core::{IdeResult, RequestId};
|
||||
use ide_search::{FileMatches, ProjectFileSet, SearchQuery, SearchSummary};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum SearchEvent {
|
||||
File { file: FileMatches },
|
||||
Done { summary: SearchSummary },
|
||||
}
|
||||
|
||||
pub fn literal_range(l: ide_language::Literal) -> ide_search::LiteralRange {
|
||||
let kind = match l.kind {
|
||||
ide_language::LiteralKind::Comment => ide_search::LiteralKind::Comment,
|
||||
ide_language::LiteralKind::String => ide_search::LiteralKind::String,
|
||||
};
|
||||
ide_search::LiteralRange { start: l.start, end: l.end, kind }
|
||||
}
|
||||
|
||||
/// Find in Files. Results stream through `on_event`; ends with `Done`.
|
||||
/// Cancel with `cancel_request(request)`.
|
||||
pub async fn start(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
mut query: SearchQuery,
|
||||
on_event: Emit<SearchEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
|
||||
// ignored names are never searched.
|
||||
query.exclude.extend(project.search_excludes());
|
||||
let roots = project.roots();
|
||||
let set = match query.scope {
|
||||
ide_search::SearchScope::FileSet { set } => Some(set),
|
||||
_ => None,
|
||||
};
|
||||
let cancel = host.begin_request(request);
|
||||
let documents = project.documents.clone();
|
||||
let root = project.root.clone();
|
||||
// The whole project also covers the Project Structure content roots.
|
||||
let content_roots = match query.scope {
|
||||
ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => {
|
||||
project.content_roots()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let languages = project.languages.clone();
|
||||
let syntax = host.syntax();
|
||||
let events = on_event.clone();
|
||||
let summary = host
|
||||
.blocking(move || {
|
||||
let filter = |rel: &str| match set {
|
||||
Some(ProjectFileSet::Production) => !roots.is_test(rel),
|
||||
Some(ProjectFileSet::Test) => roots.is_test(rel),
|
||||
Some(ProjectFileSet::All) | None => true,
|
||||
};
|
||||
// Comments and string literals for the search context filter.
|
||||
let literals = |path: &Path, text: &str| {
|
||||
let language = languages.detect(path, text.lines().next());
|
||||
let found = syntax.literals(&language, text)?;
|
||||
Some(found.into_iter().map(literal_range).collect())
|
||||
};
|
||||
let mut total = SearchSummary::default();
|
||||
for dir in std::iter::once(&root).chain(&content_roots) {
|
||||
let s = ide_search::search_in_context(
|
||||
dir,
|
||||
&query,
|
||||
Some(&filter),
|
||||
Some(&literals),
|
||||
&|p| documents.text_of(p),
|
||||
&cancel,
|
||||
&|file| events(SearchEvent::File { file }),
|
||||
)?;
|
||||
total.files += s.files;
|
||||
total.matches += s.matches;
|
||||
total.elapsed_ms += s.elapsed_ms;
|
||||
total.limit_hit |= s.limit_hit;
|
||||
total.cancelled |= s.cancelled;
|
||||
if total.limit_hit || total.cancelled {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(total)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
on_event(SearchEvent::Done { summary: summary? });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,133 @@
|
||||
#[allow(unused_imports)]
|
||||
use super::*;
|
||||
use crate::Host;
|
||||
use crate::ops::Channel;
|
||||
use crate::rpc::Dispatcher;
|
||||
use ide_core::{IdeResult, Pos, RequestId, TextEdit};
|
||||
use ide_search::{ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceTarget, SearchQuery};
|
||||
use ide_workspace::Documents;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct DocumentsHost<'a>(&'a Documents);
|
||||
|
||||
impl ReplaceHost for DocumentsHost<'_> {
|
||||
fn edit_file(&self, path: &Path, compute: &mut dyn FnMut(&str) -> IdeResult<Vec<TextEdit>>) -> IdeResult<u32> {
|
||||
let docs = self.0;
|
||||
let (doc, transient) = match docs.by_path(path) {
|
||||
Some(doc) => (doc, false),
|
||||
None => (docs.open(path)?.id, true),
|
||||
};
|
||||
let result = (|| {
|
||||
// Recompute if the user typed between reading the text and applying.
|
||||
for _ in 0..3 {
|
||||
let snap = docs.text_snapshot(doc)?;
|
||||
let edits = compute(&snap.text)?;
|
||||
if edits.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
if docs.text_snapshot(doc)?.version != snap.version {
|
||||
continue;
|
||||
}
|
||||
docs.apply_edits(doc, &edits)?;
|
||||
if transient {
|
||||
docs.save(doc)?;
|
||||
}
|
||||
return Ok(edits.len() as u32);
|
||||
}
|
||||
Err(ide_core::IdeError::unavailable("the file kept changing during Replace"))
|
||||
})();
|
||||
if transient {
|
||||
docs.close(doc);
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace in Files. Runs off the command thread; progress streams through
|
||||
/// `on_event` and ends with `Done`. Cancel with `cancel_request(request)`.
|
||||
///
|
||||
/// Edits go through the document layer as one patch per file, so open editors
|
||||
/// update live and undo the whole file's replacements in one step. Unsaved
|
||||
/// editor buffers are replaced in place and left unsaved; files that were not
|
||||
/// open are opened transiently, saved and closed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn replace_in_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
request: RequestId,
|
||||
query: SearchQuery,
|
||||
replacement: String,
|
||||
preserve_case: bool,
|
||||
targets: Vec<ReplaceTarget>,
|
||||
on_event: Channel<ReplaceEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let events = on_event.clone();
|
||||
let summary = tokio::task::spawn_blocking(move || {
|
||||
let host = DocumentsHost(&project.documents);
|
||||
ide_search::replace_files(&host, &query, &replacement, preserve_case, &targets, &cancel, &|e| {
|
||||
let _ = events.send(e);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()));
|
||||
host.end_request(request);
|
||||
let summary = summary??;
|
||||
let _ = on_event.send(ReplaceEvent::Done { summary });
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Before / after text of `path` for Replace › Preview (unsaved buffer included).
|
||||
pub async fn replace_preview(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
query: SearchQuery,
|
||||
replacement: String,
|
||||
preserve_case: bool,
|
||||
path: PathBuf,
|
||||
only: Option<Vec<Pos>>,
|
||||
) -> IdeResult<ReplacePreview> {
|
||||
let project = host.project(window)?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let text = project.text_of(&path)?;
|
||||
ide_search::preview(&text, &query, &replacement, preserve_case, only.as_deref())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Validates a query (e.g. regex syntax) while the user types.
|
||||
pub fn search_validate(_host: &Host, _window: &str, query: SearchQuery) -> IdeResult<()> {
|
||||
ide_search::validate_query(&query)
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("replace_in_files", |c| async move {
|
||||
replace_in_files(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("request")?,
|
||||
c.arg("query")?,
|
||||
c.arg("replacement")?,
|
||||
c.arg("preserveCase")?,
|
||||
c.arg("targets")?,
|
||||
c.sender("onEvent")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("replace_preview", |c| async move {
|
||||
replace_preview(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("query")?,
|
||||
c.arg("replacement")?,
|
||||
c.arg("preserveCase")?,
|
||||
c.arg("path")?,
|
||||
c.arg("only")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("search_validate", |c| async move { search_validate(&c.host, &c.window, c.arg("query")?) });
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Proofreading › Typo: spell checking of open documents.
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::doc::blocking;
|
||||
use ide_core::settings::SpellingSettings;
|
||||
use ide_core::{DocId, IdeError, IdeResult, RequestId};
|
||||
use ide_language::SpellingKind;
|
||||
use ide_spell::{Region, RegionKind, SpellScope, Speller, Typo};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Languages checked as prose (all text but code spans).
|
||||
const PROSE: &[&str] = &["plaintext", "markdown", "restructuredtext", "asciidoc", "latex"];
|
||||
|
||||
/// Custom dictionary paths: `~/…` from home, relative ones from the project root.
|
||||
fn dictionary_paths(settings: &SpellingSettings, root: &Path) -> Vec<PathBuf> {
|
||||
settings
|
||||
.custom_dictionaries
|
||||
.iter()
|
||||
.map(|p| p.trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| match p.strip_prefix("~/") {
|
||||
Some(rest) => dirs::home_dir().unwrap_or_default().join(rest),
|
||||
None => root.join(p),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn speller(project: &Project) -> Speller {
|
||||
let s = project.settings.effective().spelling;
|
||||
let words = s.ide_words.iter().chain(&s.project_words);
|
||||
Speller::new(words, &dictionary_paths(&s, &project.root))
|
||||
}
|
||||
|
||||
/// Misspelt words in the comments, string literals and declared names of
|
||||
/// `doc` that `scope` includes (all of its text for prose). Cancel with
|
||||
/// `cancel_request(request)`.
|
||||
pub async fn spell_check(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
scope: SpellScope,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<Typo>> {
|
||||
let project = host.project(window)?;
|
||||
let syntax = host.syntax();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let regions = if PROSE.contains(&snap.language.as_str()) {
|
||||
ide_spell::prose_regions(&snap.text)
|
||||
} else {
|
||||
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) };
|
||||
found
|
||||
.into_iter()
|
||||
.map(|r| Region {
|
||||
start: r.start,
|
||||
end: r.end,
|
||||
kind: match r.kind {
|
||||
SpellingKind::Comment => RegionKind::Comment,
|
||||
SpellingKind::String => RegionKind::String,
|
||||
SpellingKind::Name => RegionKind::Name,
|
||||
},
|
||||
})
|
||||
.filter(|r| scope.includes(r.kind))
|
||||
.collect()
|
||||
};
|
||||
if cancel.is_cancelled() {
|
||||
return Err(IdeError::cancelled());
|
||||
}
|
||||
speller(&project).check(&snap.text, ®ions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Replacements for a misspelt `word`, best first (Typo › Change to…).
|
||||
pub async fn spell_suggest(host: &Host, window: &str, word: String) -> IdeResult<Vec<String>> {
|
||||
let project = host.project(window).ok();
|
||||
blocking(move || {
|
||||
let speller = project.as_deref().map(speller).unwrap_or_default();
|
||||
Ok(speller.suggest(&word, 8))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("spell_check", |c| async move {
|
||||
spell_check(&c.host, &c.window, c.arg("doc")?, c.arg("scope")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("spell_suggest", |c| async move { spell_suggest(&c.host, &c.window, c.arg("word")?).await });
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Tree-sitter structure queries (`ide-syntax`): the fallback editor features
|
||||
//! use when the language server is unavailable, not ready or failing.
|
||||
//!
|
||||
//! All positions are 0-based lines / UTF-16 columns. Document queries run on
|
||||
//! the blocking pool against the document's cached, incrementally re-parsed
|
||||
//! tree; an optional `request` id makes them cancellable via `cancel_request`.
|
||||
//! Languages without a grammar (and documents too large to parse) reject with
|
||||
//! `unavailable`.
|
||||
use crate::Host;
|
||||
use crate::Project;
|
||||
use crate::rpc::Dispatcher;
|
||||
|
||||
use super::doc::blocking;
|
||||
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, LanguageId, Pos, Range, RequestId};
|
||||
use ide_syntax::{
|
||||
ExpressionAt, FileIdentifiers, IdentifierOccurrence, Locals, NodeAt, Snapshot, StructuralRanges, StructureResult,
|
||||
StructureService, StructureSupport, SyntaxErrors,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Runs `f` on the current snapshot of `doc` off the command thread.
|
||||
async fn on_doc<T: Send + 'static>(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
request: Option<RequestId>,
|
||||
f: impl FnOnce(&StructureService, &Snapshot, &CancellationToken) -> StructureResult<T> + Send + 'static,
|
||||
) -> IdeResult<T> {
|
||||
let project: Arc<Project> = host.project(window)?;
|
||||
let cancel = request.map_or_else(CancellationToken::new, |id| host.begin_request(id));
|
||||
let result = blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Err(IdeError::unavailable("file is too large for syntax analysis"));
|
||||
}
|
||||
let s = Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
|
||||
Ok(f(&project.structure, &s, &cancel)?)
|
||||
})
|
||||
.await;
|
||||
if let Some(id) = request {
|
||||
host.end_request(id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// What the structure service supports for a language (`tree`: every query,
|
||||
/// `locals`: scope analysis).
|
||||
pub fn syntax_support(host: &Host, window: &str, language: LanguageId) -> IdeResult<StructureSupport> {
|
||||
Ok(host.project(window)?.structure.support(&language))
|
||||
}
|
||||
|
||||
/// The smallest named node covering `range`, with its ancestors (parent first).
|
||||
pub async fn syntax_node_at(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<Option<NodeAt>> {
|
||||
on_doc(host, window, doc, request, move |s, snap, c| s.node_at(snap, range, c)).await
|
||||
}
|
||||
|
||||
/// The expression a selection denotes (exact or minimally enclosing), with its
|
||||
/// statement, block and caret candidates.
|
||||
pub async fn syntax_expression_at(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<ExpressionAt> {
|
||||
on_doc(host, window, doc, request, move |s, snap, c| s.expression_at(snap, range, c)).await
|
||||
}
|
||||
|
||||
/// `ERROR` / `MISSING` nodes (e.g. a missing `;`) in document order.
|
||||
pub async fn syntax_errors(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<SyntaxErrors> {
|
||||
on_doc(host, window, doc, request, |s, snap, c| s.syntax_errors(snap, c)).await
|
||||
}
|
||||
|
||||
/// Scope analysis of the identifier at `pos`: its local definition and
|
||||
/// references, or why it is not a local.
|
||||
pub async fn syntax_locals(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<Locals> {
|
||||
on_doc(host, window, doc, request, move |s, snap, c| s.locals(snap, pos, c)).await
|
||||
}
|
||||
|
||||
/// Identifier tokens spelled `name` in a document, excluding comments and strings.
|
||||
pub async fn syntax_identifiers(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
name: String,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<Vec<IdentifierOccurrence>> {
|
||||
on_doc(host, window, doc, request, move |s, snap, c| s.identifiers_named(snap, &name, c)).await
|
||||
}
|
||||
|
||||
/// [`syntax_identifiers`] over files (open buffers win over disk); files
|
||||
/// without occurrences or without a grammar are left out. Cancellable.
|
||||
pub async fn syntax_identifiers_in_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
paths: Vec<PathBuf>,
|
||||
name: String,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<FileIdentifiers>> {
|
||||
let project = host.project(window)?;
|
||||
let languages = project.languages.clone();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = blocking(move || {
|
||||
let load = |path: &std::path::Path| {
|
||||
if let Some(doc) = project.documents.by_path(path) {
|
||||
let snap = project.documents.text_snapshot(doc).ok()?;
|
||||
return (!snap.large).then_some((snap.language, Some((doc, snap.version)), snap.text));
|
||||
}
|
||||
let text = ide_vfs::load_text(path).ok()?.text;
|
||||
let first_line = text.lines().next();
|
||||
Some((languages.detect(path, first_line), None, text))
|
||||
};
|
||||
Ok(project.structure.identifiers_in_files(&paths, &name, load, &cancel)?)
|
||||
})
|
||||
.await;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// Extend Selection chain plus statement / declaration ranges at `pos`
|
||||
/// (Move Statement, Complete Statement for languages without a Lezer grammar).
|
||||
pub async fn syntax_structural_ranges(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
request: Option<RequestId>,
|
||||
) -> IdeResult<StructuralRanges> {
|
||||
on_doc(host, window, doc, request, move |s, snap, c| s.structural_ranges(snap, pos, c)).await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("syntax_support", |c| async move { syntax_support(&c.host, &c.window, c.arg("language")?) });
|
||||
d.add("syntax_node_at", |c| async move {
|
||||
syntax_node_at(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("syntax_expression_at", |c| async move {
|
||||
syntax_expression_at(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("request")?).await
|
||||
});
|
||||
d.add(
|
||||
"syntax_errors",
|
||||
|c| async move { syntax_errors(&c.host, &c.window, c.arg("doc")?, c.arg("request")?).await },
|
||||
);
|
||||
d.add("syntax_locals", |c| async move {
|
||||
syntax_locals(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("syntax_identifiers", |c| async move {
|
||||
syntax_identifiers(&c.host, &c.window, c.arg("doc")?, c.arg("name")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("syntax_identifiers_in_files", |c| async move {
|
||||
syntax_identifiers_in_files(&c.host, &c.window, c.arg("paths")?, c.arg("name")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("syntax_structural_ranges", |c| async move {
|
||||
syntax_structural_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("request")?).await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
use super::Emit;
|
||||
use crate::Host;
|
||||
use ide_core::{IdeResult, TerminalId};
|
||||
use ide_terminal::{TerminalInfo, TerminalOptions};
|
||||
|
||||
pub fn spawn(host: &Host, window: &str, options: TerminalOptions, on_output: Emit<String>) -> IdeResult<TerminalInfo> {
|
||||
// Python › "Activate virtualenv": typed into the new shell, like PyCharm.
|
||||
let activation = host
|
||||
.project(window)
|
||||
.ok()
|
||||
.and_then(|p| ide_sdk::apply::terminal_activation(&p.root, &p.settings.effective(), options.shell.as_deref()));
|
||||
let info = host.terminals.spawn(options, host.window_sink(window), Box::new(move |chunk| on_output(chunk)))?;
|
||||
if let Some(line) = activation {
|
||||
let _ = host.terminals.write(info.id, &format!(" {line}\r"));
|
||||
}
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
pub fn write(host: &Host, id: TerminalId, data: &str) -> IdeResult<()> {
|
||||
host.terminals.write(id, data)
|
||||
}
|
||||
|
||||
pub fn resize(host: &Host, id: TerminalId, cols: u16, rows: u16) -> IdeResult<()> {
|
||||
host.terminals.resize(id, cols, rows)
|
||||
}
|
||||
|
||||
pub fn kill(host: &Host, id: TerminalId) -> IdeResult<()> {
|
||||
host.terminals.kill(id)
|
||||
}
|
||||
|
||||
pub fn is_busy(host: &Host, id: TerminalId) -> IdeResult<bool> {
|
||||
host.terminals.is_busy(id)
|
||||
}
|
||||
@@ -0,0 +1,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<Tools> {
|
||||
let data = host.data_dir.clone();
|
||||
let (settings, root) = match host.project(window) {
|
||||
Ok(project) => (project.settings.effective(), project.root.clone()),
|
||||
Err(_) => (host.global_settings.effective(), Default::default()),
|
||||
};
|
||||
let custom = settings
|
||||
.tools
|
||||
.iter()
|
||||
.filter_map(|(command, t)| Some((command.clone(), ide_sdk::apply::setting_path(&root, &t.path)?)))
|
||||
.collect();
|
||||
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<Vec<ToolInfo>> {
|
||||
let tools = tools(host, window)?;
|
||||
let langs = host.languages.all();
|
||||
tokio::task::spawn_blocking(move || tools.list(&langs)).await.map_err(join_err)
|
||||
}
|
||||
|
||||
/// Installs (or reinstalls) a tool into `<app-data>/tools`, streaming output through
|
||||
/// `on_event`. Cancel with `cancel_request(request)`.
|
||||
pub async fn tools_install(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
name: String,
|
||||
request: RequestId,
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> IdeResult<ToolInfo> {
|
||||
let tools = tools(host, window)?;
|
||||
let langs = host.languages.all();
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
tools.install(&name, &langs, &cancel, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
let info = result??;
|
||||
// Language servers whose launch depends on installed tools (jdtls loads the
|
||||
// java-debug bundle) pick the change up.
|
||||
reconfigure_servers(host);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// Removes a tool the IDE installed.
|
||||
pub async fn tools_uninstall(host: &Host, window: &str, name: String) -> IdeResult<ToolInfo> {
|
||||
let tools = tools(host, window)?;
|
||||
let langs = host.languages.all();
|
||||
let info = tokio::task::spawn_blocking(move || tools.uninstall(&name, &langs)).await.map_err(join_err)??;
|
||||
reconfigure_servers(host);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
/// JDK distributions Download JDK offers.
|
||||
pub fn jdk_download_vendors(_host: &Host, _window: &str) -> Vec<JdkVendor> {
|
||||
ide_tools::jdk::vendors()
|
||||
}
|
||||
|
||||
/// Java versions `vendor` publishes; cancellable with `cancel_request(request)`.
|
||||
pub async fn jdk_download_releases(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
vendor: String,
|
||||
request: RequestId,
|
||||
) -> IdeResult<JdkReleases> {
|
||||
let tools = tools(host, window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || tools.jdk_releases(&vendor, &cancel)).await.map_err(join_err);
|
||||
host.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// Where Download JDK puts JDKs unless told otherwise.
|
||||
pub fn jdk_download_location(host: &Host, _window: &str) -> IdeResult<ide_core::IpcPath> {
|
||||
Ok(ide_tools::jdk::jdks_dir(&host.data_dir).into())
|
||||
}
|
||||
|
||||
/// Downloads the latest `vendor` JDK of Java `version` into `location` (default:
|
||||
/// [`jdk_download_location`]), streaming progress through `on_event`; cancellable
|
||||
/// with `cancel_request(request)`. The JDK is then detected like an installed one,
|
||||
/// and language servers waiting for a JDK (jdtls) start on it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn jdk_download(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
vendor: String,
|
||||
version: u32,
|
||||
location: Option<PathBuf>,
|
||||
request: RequestId,
|
||||
on_event: Channel<InstallEvent>,
|
||||
) -> IdeResult<Sdk> {
|
||||
let tools = tools(host, window)?;
|
||||
let location = location.filter(|p| !p.as_os_str().is_empty());
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let jdk = tools.download_jdk(&vendor, version, location.as_deref(), &cancel, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
let home = jdk.home.into_path_buf();
|
||||
// A chosen location is scanned from now on too (the default one is from startup).
|
||||
if let Some(location) = &location {
|
||||
ide_sdk::java::add_jdk_root(location);
|
||||
}
|
||||
ide_sdk::java::rescan_jdks();
|
||||
let jdk = ide_sdk::java::jdk_at(&home)
|
||||
.ok_or_else(|| IdeError::internal(format!("{} is not a JDK home", home.display())))?;
|
||||
IdeResult::Ok(ide_sdk::jdk_sdk(&jdk))
|
||||
})
|
||||
.await
|
||||
.map_err(join_err);
|
||||
host.end_request(request);
|
||||
let sdk = result??;
|
||||
reconfigure_servers(host);
|
||||
Ok(sdk)
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("tools_list", |c| async move { tools_list(&c.host, &c.window).await });
|
||||
d.add("tools_install", |c| async move {
|
||||
tools_install(&c.host, &c.window, c.arg("name")?, c.arg("request")?, c.sender("onEvent")?).await
|
||||
});
|
||||
d.add("tools_uninstall", |c| async move { tools_uninstall(&c.host, &c.window, c.arg("name")?).await });
|
||||
d.add("jdk_download_vendors", |c| async move { Ok(jdk_download_vendors(&c.host, &c.window)) });
|
||||
d.add("jdk_download_releases", |c| async move {
|
||||
jdk_download_releases(&c.host, &c.window, c.arg("vendor")?, c.arg("request")?).await
|
||||
});
|
||||
d.add("jdk_download_location", |c| async move { jdk_download_location(&c.host, &c.window) });
|
||||
d.add("jdk_download", |c| async move {
|
||||
jdk_download(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("vendor")?,
|
||||
c.arg("version")?,
|
||||
c.arg("location")?,
|
||||
c.arg("request")?,
|
||||
c.sender("onEvent")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use crate::{Host, apply_git_executable};
|
||||
use ide_core::IdeResult;
|
||||
use ide_vcs::FileStatus;
|
||||
|
||||
/// Changes of every repository of the project.
|
||||
pub async fn status(host: &Host, window: &str) -> IdeResult<Vec<FileStatus>> {
|
||||
let project = host.project(window)?;
|
||||
host.blocking(move || {
|
||||
let settings = project.settings.effective();
|
||||
apply_git_executable(&settings);
|
||||
let repos = project.repos();
|
||||
if repos.is_empty() {
|
||||
return Err(ide_core::IdeError::unavailable("project is not a git repository"));
|
||||
}
|
||||
for r in repos.all() {
|
||||
r.set_ignored_names(settings.ignored_names());
|
||||
}
|
||||
repos.status()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
mod commands;
|
||||
pub use commands::*;
|
||||
@@ -0,0 +1,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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "op", rename_all = "camelCase")]
|
||||
pub enum RemoteOp {
|
||||
Fetch,
|
||||
Pull {
|
||||
rebase: bool,
|
||||
},
|
||||
Push {
|
||||
force_with_lease: bool,
|
||||
set_upstream: bool,
|
||||
/// Push dialog › Push tags.
|
||||
#[serde(default)]
|
||||
tags: Option<PushTags>,
|
||||
},
|
||||
/// Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`).
|
||||
DeleteBranch {
|
||||
branch: String,
|
||||
},
|
||||
/// Pushes local `branch` (only up to `revision` when set) to its upstream, or to
|
||||
/// remote branch `target` of the same remote.
|
||||
PushBranch {
|
||||
branch: String,
|
||||
revision: Option<String>,
|
||||
force_with_lease: bool,
|
||||
#[serde(default)]
|
||||
target: Option<String>,
|
||||
},
|
||||
/// Fast-forwards local `branch` (not checked out) to its upstream.
|
||||
UpdateBranch {
|
||||
branch: String,
|
||||
},
|
||||
/// Pull dialog: `branches` of `remote` (none = the configured merge branch).
|
||||
PullFrom {
|
||||
remote: String,
|
||||
branches: Vec<String>,
|
||||
rebase: bool,
|
||||
options: MergeOptions,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct GitExecutableInfo {
|
||||
/// `git version …` output.
|
||||
pub version: String,
|
||||
/// The executable that was run.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
// ---- in-progress operations, history rewriting (Git Log actions) ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "op", rename_all = "camelCase")]
|
||||
pub enum GitOperation {
|
||||
/// Continue the merge / rebase / cherry-pick / revert in progress.
|
||||
Continue,
|
||||
Abort,
|
||||
Skip,
|
||||
/// Commits oldest first.
|
||||
CherryPick {
|
||||
commits: Vec<String>,
|
||||
},
|
||||
/// Commits newest first.
|
||||
Revert {
|
||||
commits: Vec<String>,
|
||||
},
|
||||
Reset {
|
||||
commit: String,
|
||||
mode: ResetMode,
|
||||
},
|
||||
/// Rebases `base..HEAD` (`base: None` = from the root) following `entries`, oldest first.
|
||||
InteractiveRebase {
|
||||
base: Option<String>,
|
||||
entries: Vec<RebaseEntry>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Runs `f` once per repository with the `paths` that belong to it.
|
||||
fn per_repo(
|
||||
project: &Project,
|
||||
paths: &[PathBuf],
|
||||
recorded: Option<&str>,
|
||||
f: impl Fn(&Repo, &[PathBuf]) -> IdeResult<()>,
|
||||
) -> IdeResult<()> {
|
||||
let groups = project.repos().group(paths);
|
||||
if groups.is_empty() {
|
||||
return project.with_repo_recorded(None, recorded, |r| f(r, paths));
|
||||
}
|
||||
for (repo, paths) in groups {
|
||||
project.with_repo_recorded(Some(repo.root()), recorded, |r| f(r, &paths))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Shelf and patches work in the primary repository only: changes of a nested one
|
||||
/// fail instead of being left out silently.
|
||||
fn in_primary_repo(project: &Project, paths: &[PathBuf]) -> IdeResult<()> {
|
||||
let repos = project.repos();
|
||||
let primary = repos.primary().map(|r| r.root());
|
||||
match paths.iter().find(|p| repos.for_path(p).map(|r| r.root()) != primary) {
|
||||
Some(p) => Err(ide_core::IdeError::invalid(format!(
|
||||
"{} is in another git repository; shelving and patches support the project's main repository only",
|
||||
p.display()
|
||||
))),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tokio::task::spawn_blocking(f).await.map_err(|e| ide_core::IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
// ---- patches ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "from", rename_all = "camelCase")]
|
||||
pub enum PatchSource {
|
||||
/// Local changes of these files (absolute paths).
|
||||
Changes { paths: Vec<ide_core::IpcPath>, reverse: bool },
|
||||
/// Changes made by these commits (oldest first).
|
||||
Commits { commits: Vec<String>, reverse: bool },
|
||||
}
|
||||
|
||||
// ---- shelf (`.openidea/shelf`) and change lists (`.openidea/changelists.json`) ----
|
||||
|
||||
const PRIVATE_ENTRIES: [&str; 2] = ["/shelf/", "/changelists.json"];
|
||||
|
||||
fn shelf_dir(root: &Path) -> PathBuf {
|
||||
ide_core::project_dir::path(root, "shelf")
|
||||
}
|
||||
|
||||
fn keep_private(root: &Path) {
|
||||
if let Err(e) = ide_vcs::changelists::ensure_ignored(&ide_core::project_dir::path(root, ""), &PRIVATE_ENTRIES) {
|
||||
tracing::warn!("could not update .openidea/.gitignore: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The project's repositories, the primary one first.
|
||||
pub fn vcs_roots(host: &Host, window: &str) -> IdeResult<Vec<VcsRoot>> {
|
||||
let repos = host.project(window)?.repos();
|
||||
let primary = repos.primary().map(|r| r.root().to_path_buf());
|
||||
let mut all: Vec<&std::sync::Arc<Repo>> = repos.all().iter().collect();
|
||||
all.sort_by_key(|r| Some(r.root()) != primary.as_deref());
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.map(|r| 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<PathBuf>) -> IdeResult<BranchInfo> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branch_info())).await
|
||||
}
|
||||
|
||||
/// Gutter change markers for a file, computed against its current (unsaved) text.
|
||||
pub fn vcs_line_hunks(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<LineHunk>> {
|
||||
let project = host.project(window)?;
|
||||
let text = project.text_of(&path)?;
|
||||
project.with_repo_of(&path, |r| r.line_hunks(&path, &text, false))
|
||||
}
|
||||
|
||||
pub async fn vcs_file_at(host: &Host, window: &str, path: PathBuf, rev: Option<String>) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.file_at(&path, rev.as_deref()))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_blame(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<BlameLine>> {
|
||||
let project = host.project(window)?;
|
||||
let text = project.documents.text_of(&path);
|
||||
blocking(move || project.with_repo_of(&path, |r| r.blame(&path, text.as_deref()))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_log(host: &Host, window: &str, repo: Option<PathBuf>, query: LogQuery) -> IdeResult<Vec<Commit>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.log(&query))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_commit_files(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
commit: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_files(&commit))).await
|
||||
}
|
||||
|
||||
/// Combined changes of several commits, `commits` oldest first.
|
||||
pub async fn vcs_commits_changes(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
commits: Vec<String>,
|
||||
) -> IdeResult<Vec<RevisionChange>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commits_changes(&commits))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_branches(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Branch>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.branches())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stashes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Stash>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stashes())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.stage(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_unstage(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, None, |r, paths| r.unstage(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stage_patch(host: &Host, window: &str, patch: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.stage_patch(&patch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_discard(host: &Host, window: &str, paths: Vec<PathBuf>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || per_repo(&project, &paths, Some("Rollback"), |r, paths| r.discard(paths))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_commit(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
message: String,
|
||||
options: CommitOptions,
|
||||
) -> IdeResult<String> {
|
||||
// Commit hooks may run for a long time.
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.commit_with(&message, &options))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_checkout(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
branch: String,
|
||||
create: bool,
|
||||
options: Option<ide_vcs::CheckoutOptions>,
|
||||
) -> IdeResult<ide_vcs::CheckoutOutcome> {
|
||||
let project = host.project(window)?;
|
||||
let options = options.unwrap_or_default();
|
||||
blocking(move || {
|
||||
project
|
||||
.with_repo_recorded(repo.as_deref(), Some("Git checkout"), |r| r.checkout_with(&branch, create, &options))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// New Branch: creates `name` at `start` (HEAD when absent), optionally checking it out.
|
||||
pub async fn vcs_create_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
name: String,
|
||||
start: Option<String>,
|
||||
checkout: bool,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let recorded = checkout.then_some("Git checkout");
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| r.create_branch(&name, start.as_deref(), checkout))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Undo Commit: moves HEAD back to the commit's parent, keeping its changes staged.
|
||||
/// Returns the undone commit's message.
|
||||
pub async fn vcs_undo_commit(host: &Host, window: &str, repo: Option<PathBuf>, commit: String) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.undo_commit(&commit))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_delete_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
branch: String,
|
||||
) -> IdeResult<ide_vcs::DeletedBranch> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.delete_branch_restorable(&branch))).await
|
||||
}
|
||||
|
||||
/// "Restore" of the branch-deleted notification.
|
||||
pub async fn vcs_restore_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
deleted: ide_vcs::DeletedBranch,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.restore_branch(&deleted))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_rename_branch(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.rename_branch(&from, &to))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_merge(host: &Host, window: &str, repo: Option<PathBuf>, branch: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge(&branch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_rebase(host: &Host, window: &str, repo: Option<PathBuf>, onto: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase(&onto))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_stash_push(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
message: Option<String>,
|
||||
include_untracked: bool,
|
||||
keep_index: bool,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git stash"), |r| {
|
||||
r.stash_push(message.as_deref(), include_untracked, keep_index)
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_stash_apply(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
index: u32,
|
||||
pop: bool,
|
||||
reinstate_index: bool,
|
||||
branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git unstash"), |r| match branch.as_deref() {
|
||||
Some(name) => r.stash_branch(index, name),
|
||||
None => r.stash_apply(index, pop, reinstate_index),
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_stash_drop(host: &Host, window: &str, repo: Option<PathBuf>, index: u32) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.stash_drop(index))).await
|
||||
}
|
||||
|
||||
/// Network operation; progress lines stream through `on_output`. Cancellable.
|
||||
pub async fn vcs_remote(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
request: RequestId,
|
||||
op: RemoteOp,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let out = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
};
|
||||
let recorded = matches!(op, RemoteOp::Pull { .. } | RemoteOp::PullFrom { .. }).then_some("Git pull");
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
|
||||
RemoteOp::Fetch => r.fetch(&cancel, &out),
|
||||
RemoteOp::Pull { rebase } => r.pull(rebase, &cancel, &out),
|
||||
RemoteOp::Push { force_with_lease, set_upstream, tags } => {
|
||||
r.push_with_tags(force_with_lease, set_upstream, tags, &cancel, &out)
|
||||
}
|
||||
RemoteOp::DeleteBranch { branch } => r.delete_remote_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PushBranch { branch, revision, force_with_lease, target } => match target {
|
||||
Some(target) => {
|
||||
r.push_branch_to(&branch, revision.as_deref(), &target, force_with_lease, &cancel, &out)
|
||||
}
|
||||
None => r.push_branch(&branch, revision.as_deref(), force_with_lease, &cancel, &out),
|
||||
},
|
||||
RemoteOp::UpdateBranch { branch } => r.update_branch(&branch, &cancel, &out),
|
||||
RemoteOp::PullFrom { remote, branches, rebase, options } => {
|
||||
r.pull_from(&remote, &branches, rebase, &options, &cancel, &out)
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// The user's side is `ours` (sides swapped during a rebase, as in IDEA).
|
||||
pub async fn vcs_conflict_versions(host: &Host, window: &str, path: PathBuf) -> IdeResult<ide_vcs::MergeVersions> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.merge_versions(&path))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_diff_text(host: &Host, window: &str, path: PathBuf, staged: bool) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_of(&path, |r| r.diff_text(&path, staged))).await
|
||||
}
|
||||
|
||||
/// Line hunks between two arbitrary texts (diff viewer, commit dialog).
|
||||
pub fn vcs_diff_hunks(_host: &Host, _window: &str, old_text: String, new_text: String) -> Vec<LineHunk> {
|
||||
ide_vcs::diff_hunks(&old_text, &new_text)
|
||||
}
|
||||
|
||||
/// Stages only the selected hunks of a file's working-tree changes.
|
||||
/// Indices refer to `vcs_diff_hunks(index_text, worktree_text)`.
|
||||
pub fn vcs_stage_hunks(host: &Host, window: &str, path: PathBuf, hunks: Vec<u32>) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let current = ide_vfs::load_text(&path)?.text;
|
||||
project.with_repo_of(&path, |r| {
|
||||
let index_text = r.file_at(&path, Some(":0"))?.unwrap_or_default();
|
||||
let rel = path.strip_prefix(r.root()).unwrap_or(&path).to_string_lossy().replace('\\', "/");
|
||||
let selected: Vec<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
let patch = ide_vcs::build_partial_patch(&rel, &index_text, ¤t, &selected);
|
||||
if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) }
|
||||
})
|
||||
}
|
||||
|
||||
/// Clones a repository (Get from VCS). Works without an open project.
|
||||
pub async fn vcs_clone(
|
||||
host: &Host,
|
||||
_window: &str,
|
||||
request: RequestId,
|
||||
url: String,
|
||||
dest: PathBuf,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
apply_git_executable(&host.global_settings.effective());
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
ide_vcs::clone(&url, &dest, &cancel, &|line| {
|
||||
let _ = on_output.send(line);
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
/// `git init` in `dir` (File › New Project… "Create Git repository").
|
||||
pub async fn vcs_init(host: &Host, _window: &str, dir: PathBuf) -> IdeResult<()> {
|
||||
apply_git_executable(&host.global_settings.effective());
|
||||
blocking(move || ide_vcs::init(&dir)).await
|
||||
}
|
||||
|
||||
/// Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected
|
||||
/// executable when `path` is empty.
|
||||
pub async fn vcs_git_version(_host: &Host, _window: &str, path: String) -> IdeResult<GitExecutableInfo> {
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let explicit = path.trim();
|
||||
let exe = if explicit.is_empty() {
|
||||
ide_vcs::detected_git_executable()
|
||||
.ok_or_else(|| ide_core::IdeError::unavailable("git executable not found on PATH"))?
|
||||
} else {
|
||||
PathBuf::from(explicit)
|
||||
};
|
||||
let version = ide_vcs::git_version(Some(&exe))?;
|
||||
Ok(GitExecutableInfo { version, path: exe.to_string_lossy().into_owned() })
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// The auto-detected git executable (shown as the field's placeholder).
|
||||
pub fn vcs_detected_git(_host: &Host, _window: &str) -> Option<String> {
|
||||
ide_vcs::detected_git_executable().map(|p| p.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// `git config --get <key>` in the project repository; `None` when unset.
|
||||
pub async fn vcs_config_get(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
key: String,
|
||||
) -> IdeResult<Option<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
|
||||
}
|
||||
|
||||
/// Local history operation that may take a while (hooks, large checkouts); git's
|
||||
/// output streams through `on_output`. Cancellable.
|
||||
pub async fn vcs_operation(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
request: RequestId,
|
||||
op: GitOperation,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
let cancel = host.begin_request(request);
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let out = |line: String| {
|
||||
let _ = on_output.send(line);
|
||||
};
|
||||
let recorded = match &op {
|
||||
GitOperation::CherryPick { .. } => Some("Git cherry-pick"),
|
||||
GitOperation::Revert { .. } => Some("Git revert"),
|
||||
GitOperation::Reset { .. } => Some("Git reset"),
|
||||
GitOperation::InteractiveRebase { .. } => Some("Git rebase"),
|
||||
_ => None,
|
||||
};
|
||||
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
|
||||
GitOperation::Continue => r.continue_operation(&cancel, &out),
|
||||
GitOperation::Abort => r.abort_operation(&cancel, &out),
|
||||
GitOperation::Skip => r.skip_operation(&cancel, &out),
|
||||
GitOperation::CherryPick { commits } => r.cherry_pick(&commits, &cancel, &out),
|
||||
GitOperation::Revert { commits } => r.revert(&commits, &cancel, &out),
|
||||
GitOperation::Reset { commit, mode } => r.reset(&commit, mode, &cancel, &out),
|
||||
GitOperation::InteractiveRebase { base, entries } => {
|
||||
r.interactive_rebase(base.as_deref(), &entries, &cancel, &out)
|
||||
}
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
|
||||
host.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn vcs_create_tag(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
name: String,
|
||||
commit: String,
|
||||
message: Option<String>,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force)))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_tags(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
|
||||
}
|
||||
|
||||
/// Merge dialog: merges `branches` into the current branch.
|
||||
pub async fn vcs_merge_with(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
branches: Vec<String>,
|
||||
options: MergeOptions,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Rebase dialog: rebases the current branch onto `upstream` (or `--onto`).
|
||||
pub async fn vcs_rebase_with(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
upstream: String,
|
||||
options: RebaseOptions,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_worktrees(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Worktree>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.worktrees())).await
|
||||
}
|
||||
|
||||
pub async fn vcs_add_worktree(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
path: PathBuf,
|
||||
commitish: String,
|
||||
new_branch: Option<String>,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_remove_worktree(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
path: PathBuf,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
|
||||
}
|
||||
|
||||
/// Compare with Local: tracked files that differ between `rev` and the working tree.
|
||||
pub async fn vcs_diff_with_worktree(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
rev: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
|
||||
}
|
||||
|
||||
/// Files that differ between two revisions (the "N files updated" of Update Project).
|
||||
pub async fn vcs_diff_revisions(
|
||||
host: &Host,
|
||||
window: &str,
|
||||
repo: Option<PathBuf>,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo(|r| match source {
|
||||
PatchSource::Changes { paths, reverse } => {
|
||||
let paths: Vec<PathBuf> = paths.into_iter().map(Into::into).collect();
|
||||
in_primary_repo(&project, &paths)?;
|
||||
r.create_patch(&paths, reverse)
|
||||
}
|
||||
PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse),
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn vcs_patch_preview(host: &Host, window: &str, patch: String) -> IdeResult<Vec<PatchFile>> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await
|
||||
}
|
||||
|
||||
pub async fn vcs_apply_patch(host: &Host, window: &str, patch: String) -> IdeResult<ApplyOutcome> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || project.with_repo(|r| r.apply_patch(&patch))).await
|
||||
}
|
||||
|
||||
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
|
||||
pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec<PathBuf>) -> IdeResult<ShelvedChangeList> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
in_primary_repo(&project, &paths)?;
|
||||
keep_private(&project.root);
|
||||
project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList>> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::list(&shelf_dir(&project.root))
|
||||
}
|
||||
|
||||
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
|
||||
/// merge UI); with `remove`, deletes it from the shelf.
|
||||
pub async fn vcs_unshelve(host: &Host, window: &str, id: String, remove: bool) -> IdeResult<ApplyOutcome> {
|
||||
let project = host.project(window)?;
|
||||
blocking(move || {
|
||||
project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_delete(host: &Host, window: &str, id: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::delete(&shelf_dir(&project.root), &id)
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name)
|
||||
}
|
||||
|
||||
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String) -> IdeResult<String> {
|
||||
let project = host.project(window)?;
|
||||
ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id)
|
||||
}
|
||||
|
||||
pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult<ChangelistState> {
|
||||
let project = host.project(window)?;
|
||||
Ok(ide_vcs::changelists::load(&ide_core::project_dir::path(&project.root, "changelists.json")))
|
||||
}
|
||||
|
||||
pub fn vcs_changelists_save(host: &Host, window: &str, changelists: ChangelistState) -> IdeResult<()> {
|
||||
let project = host.project(window)?;
|
||||
keep_private(&project.root);
|
||||
ide_vcs::changelists::save(&ide_core::project_dir::path(&project.root, "changelists.json"), &changelists)
|
||||
}
|
||||
|
||||
/// Create Patch › Save to file.
|
||||
pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: String) -> IdeResult<()> {
|
||||
blocking(move || {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(&path, patch)?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Commands of this module the remote server answers.
|
||||
pub(crate) fn register(d: &mut Dispatcher) {
|
||||
d.add("vcs_roots", |c| async move { vcs_roots(&c.host, &c.window) });
|
||||
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?) });
|
||||
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
|
||||
d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
|
||||
d.add("vcs_commit_files", |c| async move {
|
||||
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
|
||||
});
|
||||
d.add("vcs_commits_changes", |c| async move {
|
||||
vcs_commits_changes(&c.host, &c.window, c.arg("repo")?, c.arg("commits")?).await
|
||||
});
|
||||
d.add("vcs_branches", |c| async move { vcs_branches(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_stashes", |c| async move { vcs_stashes(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_stage", |c| async move { vcs_stage(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
|
||||
d.add("vcs_commit", |c| async move {
|
||||
vcs_commit(&c.host, &c.window, c.arg("repo")?, c.arg("message")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("vcs_checkout", |c| async move {
|
||||
vcs_checkout(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?, c.arg("create")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("vcs_create_branch", |c| async move {
|
||||
vcs_create_branch(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("start")?, c.arg("checkout")?).await
|
||||
});
|
||||
d.add(
|
||||
"vcs_undo_commit",
|
||||
|c| async move { vcs_undo_commit(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await },
|
||||
);
|
||||
d.add("vcs_delete_branch", |c| async move {
|
||||
vcs_delete_branch(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await
|
||||
});
|
||||
d.add("vcs_restore_branch", |c| async move {
|
||||
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
|
||||
});
|
||||
d.add("vcs_rename_branch", |c| async move {
|
||||
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
|
||||
d.add("vcs_rebase", |c| async move { vcs_rebase(&c.host, &c.window, c.arg("repo")?, c.arg("onto")?).await });
|
||||
d.add("vcs_stash_push", |c| async move {
|
||||
vcs_stash_push(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("repo")?,
|
||||
c.arg("message")?,
|
||||
c.arg("includeUntracked")?,
|
||||
c.arg("keepIndex")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_stash_apply", |c| async move {
|
||||
vcs_stash_apply(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("repo")?,
|
||||
c.arg("index")?,
|
||||
c.arg("pop")?,
|
||||
c.arg("reinstateIndex")?,
|
||||
c.arg("branch")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add(
|
||||
"vcs_stash_drop",
|
||||
|c| async move { vcs_stash_drop(&c.host, &c.window, c.arg("repo")?, c.arg("index")?).await },
|
||||
);
|
||||
d.add("vcs_remote", |c| async move {
|
||||
vcs_remote(&c.host, &c.window, c.arg("repo")?, c.arg("request")?, c.arg("op")?, c.sender("onOutput")?).await
|
||||
});
|
||||
d.add("vcs_conflict_versions", |c| async move { vcs_conflict_versions(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add(
|
||||
"vcs_diff_text",
|
||||
|c| async move { vcs_diff_text(&c.host, &c.window, c.arg("path")?, c.arg("staged")?).await },
|
||||
);
|
||||
d.add(
|
||||
"vcs_diff_hunks",
|
||||
|c| async move { Ok(vcs_diff_hunks(&c.host, &c.window, c.arg("oldText")?, c.arg("newText")?)) },
|
||||
);
|
||||
d.add("vcs_stage_hunks", |c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?) });
|
||||
d.add("vcs_clone", |c| async move {
|
||||
vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await
|
||||
});
|
||||
d.add("vcs_init", |c| async move { vcs_init(&c.host, &c.window, c.arg("dir")?).await });
|
||||
d.add("vcs_git_version", |c| async move { vcs_git_version(&c.host, &c.window, c.arg("path")?).await });
|
||||
d.add("vcs_detected_git", |c| async move { Ok(vcs_detected_git(&c.host, &c.window)) });
|
||||
d.add("vcs_config_get", |c| async move { vcs_config_get(&c.host, &c.window, c.arg("repo")?, c.arg("key")?).await });
|
||||
d.add("vcs_operation", |c| async move {
|
||||
vcs_operation(&c.host, &c.window, c.arg("repo")?, c.arg("request")?, c.arg("op")?, c.sender("onOutput")?).await
|
||||
});
|
||||
d.add("vcs_create_tag", |c| async move {
|
||||
vcs_create_tag(
|
||||
&c.host,
|
||||
&c.window,
|
||||
c.arg("repo")?,
|
||||
c.arg("name")?,
|
||||
c.arg("commit")?,
|
||||
c.arg("message")?,
|
||||
c.arg("force")?,
|
||||
)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_tags", |c| async move { vcs_tags(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_merge_with", |c| async move {
|
||||
vcs_merge_with(&c.host, &c.window, c.arg("repo")?, c.arg("branches")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("vcs_rebase_with", |c| async move {
|
||||
vcs_rebase_with(&c.host, &c.window, c.arg("repo")?, c.arg("upstream")?, c.arg("options")?).await
|
||||
});
|
||||
d.add("vcs_remotes", |c| async move { vcs_remotes(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add(
|
||||
"vcs_edit_remote",
|
||||
|c| async move { vcs_edit_remote(&c.host, &c.window, c.arg("repo")?, c.arg("edit")?).await },
|
||||
);
|
||||
d.add("vcs_worktrees", |c| async move { vcs_worktrees(&c.host, &c.window, c.arg("repo")?).await });
|
||||
d.add("vcs_add_worktree", |c| async move {
|
||||
vcs_add_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("commitish")?, c.arg("newBranch")?)
|
||||
.await
|
||||
});
|
||||
d.add("vcs_remove_worktree", |c| async move {
|
||||
vcs_remove_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("force")?).await
|
||||
});
|
||||
d.add("vcs_diff_with_worktree", |c| async move {
|
||||
vcs_diff_with_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("rev")?).await
|
||||
});
|
||||
d.add("vcs_diff_revisions", |c| async move {
|
||||
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
|
||||
});
|
||||
d.add("vcs_create_patch", |c| async move { vcs_create_patch(&c.host, &c.window, c.arg("source")?).await });
|
||||
d.add("vcs_patch_preview", |c| async move { vcs_patch_preview(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_apply_patch", |c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("patch")?).await });
|
||||
d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await });
|
||||
d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) });
|
||||
d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await });
|
||||
d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) });
|
||||
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?) });
|
||||
d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) });
|
||||
d.add("vcs_changelists_save", |c| async move { vcs_changelists_save(&c.host, &c.window, c.arg("changelists")?) });
|
||||
d.add(
|
||||
"vcs_write_patch",
|
||||
|c| async move { vcs_write_patch(&c.host, &c.window, c.arg("path")?, c.arg("patch")?).await },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,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<LanguageRegistry>,
|
||||
pub settings: Arc<SettingsStore>,
|
||||
pub documents: Arc<Documents>,
|
||||
pub history: Arc<LocalHistory>,
|
||||
/// Undo stack of file move / rename refactorings.
|
||||
pub moves: FileMoves,
|
||||
pub index: Arc<ProjectIndex>,
|
||||
pub lsp: Arc<LspManager>,
|
||||
/// Git repositories: the one containing the project root and those nested below it.
|
||||
pub repos: Arc<RwLock<Repos>>,
|
||||
/// Incrementally maintained syntax trees of the open documents.
|
||||
pub syntax: Arc<SyntaxCache>,
|
||||
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
|
||||
pub structure: Arc<StructureService>,
|
||||
/// Roots auto-detected from build files; see [`Project::roots`].
|
||||
pub detected_roots: Arc<RwLock<Vec<SourceFolder>>>,
|
||||
/// Per-project cache directory (build tool sync results…).
|
||||
pub cache_dir: PathBuf,
|
||||
/// Database tool window: data sources and their connections.
|
||||
pub db: Arc<ide_db::DbManager>,
|
||||
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
|
||||
pub http_history: Arc<ide_http::History>,
|
||||
pub(crate) watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
pub(crate) content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
pub(crate) _scratch_watcher: Option<Watcher>,
|
||||
}
|
||||
|
||||
impl Host {
|
||||
pub fn open_project(&self, window: &str, root: &Path) -> IdeResult<Arc<Project>> {
|
||||
let started = std::time::Instant::now();
|
||||
let mut lap = started;
|
||||
let mut laps: Vec<(&str, u128)> = Vec::new();
|
||||
let mut mark = |name: &'static str| {
|
||||
laps.push((name, lap.elapsed().as_millis()));
|
||||
lap = std::time::Instant::now();
|
||||
};
|
||||
let root = dunce::canonicalize(root)?;
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
// it, with its warm index, language servers and unsaved documents.
|
||||
if let Ok(current) = self.project(window)
|
||||
&& current.root == root
|
||||
{
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
let sink: SharedSink = (self.sink_for)(window);
|
||||
let settings = SettingsStore::load(&self.config_dir, Some(&root));
|
||||
let detected_roots = Arc::new(RwLock::new(ide_workspace::roots::detect(&root)));
|
||||
let excludes = exclude_globs(&settings.effective(), &detected_roots.read());
|
||||
let extra_roots = content_roots(&settings.effective(), &root);
|
||||
mark("roots");
|
||||
|
||||
// The project's own registry: File Types associations may be project-scoped.
|
||||
let languages = Arc::new(self.languages.fork());
|
||||
languages.set_file_types(FileTypes::from_settings(&settings.effective().files, Some(&root)));
|
||||
let documents = {
|
||||
let languages = languages.clone();
|
||||
Arc::new(Documents::new(
|
||||
sink.clone(),
|
||||
Box::new(move |p: &Path, first: Option<&str>| languages.detect(p, first)),
|
||||
))
|
||||
};
|
||||
documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings.effective(), Some(&root)));
|
||||
let history = Arc::new(LocalHistory::open(&self.data_dir.join("history"), &root)?);
|
||||
{
|
||||
let history = history.clone();
|
||||
let retention = history_retention(&settings.effective());
|
||||
std::thread::Builder::new().name("openidea-history-prune".into()).spawn(move || {
|
||||
if let Err(e) = history.apply_retention(retention) {
|
||||
tracing::warn!("local history: {e}");
|
||||
}
|
||||
})?;
|
||||
}
|
||||
let project_key = project_key(&root);
|
||||
let index = ProjectIndex::open(
|
||||
&root,
|
||||
&self.data_dir.join("index").join(&project_key),
|
||||
extra_roots.clone(),
|
||||
excludes.clone(),
|
||||
languages.clone(),
|
||||
self.syntax(),
|
||||
sink.clone(),
|
||||
)?;
|
||||
mark("index");
|
||||
let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone());
|
||||
let settings = Arc::new(settings);
|
||||
{
|
||||
let settings = settings.clone();
|
||||
let root = root.clone();
|
||||
let data_dir = self.data_dir.clone();
|
||||
lsp.set_config_hook(Box::new(move |cfg, server_root| {
|
||||
let effective = settings.effective();
|
||||
ide_sdk::apply::configure_server(cfg, &root, server_root, &effective);
|
||||
if cfg.name == "jdtls" {
|
||||
let bundles: Vec<PathBuf> =
|
||||
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
|
||||
let min_java = ide_sdk::java::jdtls_min_java(cfg);
|
||||
let jdks = ide_sdk::java::jdtls_jdks(min_java);
|
||||
let lombok = ide_sdk::lombok::lombok_agent(
|
||||
server_root,
|
||||
ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)),
|
||||
ide_tools::managed_bundle(&data_dir, "lombok"),
|
||||
ide_sdk::apply::maven_local_repository(&root, &effective),
|
||||
);
|
||||
if let Some(problem) = lombok.problem() {
|
||||
tracing::warn!("{problem}");
|
||||
}
|
||||
let server_jdk = ide_sdk::apply::jdtls_jdk(&root, &effective).unwrap_or_else(|e| {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
&data_dir,
|
||||
&jdks,
|
||||
ide_sdk::apply::project_jdk(&root, &effective).as_ref(),
|
||||
server_jdk.as_ref(),
|
||||
min_java,
|
||||
&bundles,
|
||||
lombok.jar(),
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
{
|
||||
let documents = documents.clone();
|
||||
lsp.set_apply_edit_hook(Box::new(move |edit| apply_workspace_edit(&documents, &edit).is_ok()));
|
||||
}
|
||||
let syntax = Arc::new(SyntaxCache::new(self.syntax()));
|
||||
documents.add_observer(Arc::new(SyntaxObserver(syntax.clone())));
|
||||
documents.add_observer(Arc::new(LspObserver(lsp.clone())));
|
||||
documents.add_observer(Arc::new(HistoryObserver(history.clone())));
|
||||
documents.add_observer(Arc::new(DeployObserver {
|
||||
remote: self.remote.clone(),
|
||||
settings: settings.clone(),
|
||||
root: root.clone(),
|
||||
sink: sink.clone(),
|
||||
runtime: self.runtime.clone(),
|
||||
}));
|
||||
|
||||
let repos = Arc::new(RwLock::new(Repos::enclosing(&root)?));
|
||||
let watcher = {
|
||||
let repos = repos.clone();
|
||||
let settings = settings.clone();
|
||||
let index = index.clone();
|
||||
let documents = documents.clone();
|
||||
let sink = sink.clone();
|
||||
let root = root.clone();
|
||||
let detected_roots = detected_roots.clone();
|
||||
let history = history.clone();
|
||||
let remote = self.remote.clone();
|
||||
let runtime = self.runtime.clone();
|
||||
Watcher::start(
|
||||
&root.clone(),
|
||||
excludes.clone(),
|
||||
sink.clone(),
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
// Atomic saves by external tools surface as `created`.
|
||||
documents.on_disk_changed(&changed);
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
if !batch.removed.is_empty() {
|
||||
delete_deployed(
|
||||
&runtime,
|
||||
remote.clone(),
|
||||
settings.clone(),
|
||||
root.clone(),
|
||||
batch.removed.clone(),
|
||||
sink.clone(),
|
||||
);
|
||||
}
|
||||
// Build files / conventional folders changed: re-detect roots.
|
||||
let touched = changed.iter().chain(&batch.removed).filter_map(|p| p.strip_prefix(&root).ok());
|
||||
if touched.into_iter().any(ide_workspace::roots::affects_roots) {
|
||||
let fresh = ide_workspace::roots::detect(&root);
|
||||
if *detected_roots.read() != fresh {
|
||||
// Detected excluded folders (`target`, `build`) leave the index too.
|
||||
let effective = settings.effective();
|
||||
index.set_scope(content_roots(&effective, &root), exclude_globs(&effective, &fresh));
|
||||
*detected_roots.write() = fresh;
|
||||
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
|
||||
}
|
||||
}
|
||||
// A repository was cloned / initialised / removed below the project: its
|
||||
// `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went.
|
||||
let known = repos.read().roots();
|
||||
let git_dirs = changed.iter().chain(&batch.removed).any(|p| {
|
||||
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
known.iter().any(|r| r == dir) != p.is_file()
|
||||
});
|
||||
if git_dirs {
|
||||
scan_repositories(&root, &repos, &settings, &sink);
|
||||
}
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}),
|
||||
)?
|
||||
};
|
||||
|
||||
// Scratch files live outside the project; watch them too so the
|
||||
// Project view and open editors follow changes made there.
|
||||
let scratch_watcher = {
|
||||
let dir = self.data_dir.join("scratches");
|
||||
let documents = documents.clone();
|
||||
std::fs::create_dir_all(&dir).ok().and_then(|()| {
|
||||
Watcher::start(
|
||||
&dir,
|
||||
Vec::new(),
|
||||
sink.clone(),
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
documents.on_disk_changed(&changed);
|
||||
}),
|
||||
)
|
||||
.map_err(|e| tracing::warn!("scratches watcher: {e}"))
|
||||
.ok()
|
||||
})
|
||||
};
|
||||
|
||||
let content_watchers = extra_roots
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?;
|
||||
Some((r.clone(), w))
|
||||
})
|
||||
.collect();
|
||||
mark("watcher");
|
||||
{
|
||||
let (root, repos, settings, sink) = (root.clone(), repos.clone(), settings.clone(), sink.clone());
|
||||
std::thread::Builder::new()
|
||||
.name("openidea-vcs-roots".into())
|
||||
.spawn(move || scan_repositories(&root, &repos, &settings, &sink))?;
|
||||
}
|
||||
mark("git");
|
||||
tracing::info!("opened {} in window {window} in {} ms {laps:?}", root.display(), started.elapsed().as_millis());
|
||||
|
||||
let db = Arc::new(ide_db::DbManager::new(
|
||||
root.clone(),
|
||||
self.data_dir.join("db-passwords.json"),
|
||||
self.data_dir.join("db-history.json"),
|
||||
self.ssh.clone(),
|
||||
));
|
||||
let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(&project_key)));
|
||||
let 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<Repos>, settings: &SettingsStore, sink: &SharedSink) {
|
||||
let ignored = settings.effective().ignored_names();
|
||||
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
|
||||
match Repos::discover(root, &skip) {
|
||||
Ok(found) => {
|
||||
if found.roots() != repos.read().roots() {
|
||||
*repos.write() = found;
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("looking for git repositories: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// The project's git repositories.
|
||||
pub fn repos(&self) -> Repos {
|
||||
self.repos.read().clone()
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository whose root is `at`, the project's primary repository
|
||||
/// when `None`; fails when there is none (a repository-scoped command never falls back
|
||||
/// to another repository).
|
||||
pub fn with_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let repos = self.repos();
|
||||
match at {
|
||||
Some(root) => match repos.at_root(root) {
|
||||
Some(repo) => self.run_in(repo, f),
|
||||
None => {
|
||||
Err(IdeError::unavailable(format!("{} is not a git repository of the project", root.display())))
|
||||
}
|
||||
},
|
||||
None => match repos.primary() {
|
||||
Some(repo) => self.run_in(repo, f),
|
||||
None => Err(IdeError::unavailable("project is not a git repository")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository `path` (a file or folder) belongs to.
|
||||
pub fn with_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
match self.repos().for_path(path) {
|
||||
Some(repo) => self.run_in(repo, f),
|
||||
None => Err(IdeError::unavailable(format!("{} is not in a git repository", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let settings = self.settings.effective();
|
||||
apply_git_executable(&settings);
|
||||
repo.set_ignored_names(settings.ignored_names());
|
||||
f(repo)
|
||||
}
|
||||
|
||||
/// Runs `f` with the project's primary git repository, or fails when the project is not under git.
|
||||
pub fn with_repo<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_at(None, f)
|
||||
}
|
||||
|
||||
/// Runs a VCS operation that rewrites the working tree (checkout, merge,
|
||||
/// rebase, pull, reset, stash, rollback): Local History gets a label and a
|
||||
/// snapshot of every locally changed file before, and a label after.
|
||||
/// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree).
|
||||
pub fn with_repo_recorded<T>(
|
||||
&self,
|
||||
at: Option<&Path>,
|
||||
operation: Option<&str>,
|
||||
f: impl FnOnce(&Repo) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
let Some(name) = operation else { return self.with_repo_at(at, f) };
|
||||
self.history_label(&format!("Before {name}"));
|
||||
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
|
||||
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
|
||||
if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) {
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = self.with_repo_at(at, f);
|
||||
self.history_label(&format!("After {name}"));
|
||||
result
|
||||
}
|
||||
|
||||
fn history_label(&self, label: &str) {
|
||||
if let Err(e) = self.history.put_label(&self.root, label) {
|
||||
tracing::warn!("local history: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Local History snapshots of `paths` before a refactoring changes them.
|
||||
pub fn record_before_refactoring(&self, paths: &[&Path], name: &str) {
|
||||
let label = format!("Before refactoring: {name}");
|
||||
for path in paths {
|
||||
let result = match self.documents.text_of(path) {
|
||||
Some(text) => self.history.record_event(path, &text, &label),
|
||||
None => self.history.record_file(path, &label, true).map(|_| ()),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
|
||||
pub fn roots(&self) -> ProjectRoots {
|
||||
ProjectRoots::compute(self.detected_roots.read().clone(), &self.settings.effective().project_structure)
|
||||
}
|
||||
|
||||
/// Where the Maven / Gradle tool windows cache what the build tools reported.
|
||||
pub fn build_cache_dir(&self) -> PathBuf {
|
||||
self.cache_dir.join("build")
|
||||
}
|
||||
|
||||
/// File masks (`ide_search::FileMask`) for Find in Files and inspections: the
|
||||
/// excluded roots, the ignored names and the "Exclude files" patterns (files,
|
||||
/// and everything under such folders).
|
||||
pub fn search_excludes(&self) -> Vec<String> {
|
||||
let mut out: Vec<String> =
|
||||
self.roots().excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/'))).collect();
|
||||
let settings = self.settings.effective();
|
||||
let names =
|
||||
settings.files.ignored_names.iter().cloned().chain(settings.project_structure.exclude_file_patterns());
|
||||
for name in names {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() && !name.contains(['/', ',']) {
|
||||
out.push(name.to_owned());
|
||||
out.push(format!("**/{name}/**"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected).
|
||||
pub fn exclude_globs(&self) -> Vec<String> {
|
||||
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
|
||||
}
|
||||
|
||||
/// Project Structure content roots besides the project folder (existing directories).
|
||||
pub fn content_roots(&self) -> Vec<PathBuf> {
|
||||
content_roots(&self.settings.effective(), &self.root)
|
||||
}
|
||||
|
||||
/// Applies Settings › File Types; open documents whose language changed are
|
||||
/// re-detected (language servers and syntax trees follow) and the index
|
||||
/// re-parses the files whose language changed.
|
||||
pub fn apply_file_types(&self, settings: &ide_core::settings::Settings) {
|
||||
let before = Arc::new(self.languages.snapshot());
|
||||
if self.languages.set_file_types(FileTypes::from_settings(&settings.files, Some(&self.root))) {
|
||||
self.documents.redetect_languages();
|
||||
self.index.languages_changed(before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies changed content roots and exclude settings to the index (which
|
||||
/// rescans in the background) and the file watchers, without reopening the project.
|
||||
pub fn refresh_index_scope(&self) {
|
||||
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
|
||||
self.index.set_scope(roots.clone(), excludes.clone());
|
||||
self.watcher.set_excludes(&excludes);
|
||||
let mut watchers = self.content_watchers.lock();
|
||||
watchers.retain(|(p, _)| roots.contains(p));
|
||||
for (_, w) in watchers.iter() {
|
||||
w.set_excludes(&excludes);
|
||||
}
|
||||
for r in roots {
|
||||
if watchers.iter().any(|(p, _)| *p == r) {
|
||||
continue;
|
||||
}
|
||||
if let Some(w) =
|
||||
content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history)
|
||||
{
|
||||
watchers.push((r, w));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current text of `path`: the open buffer when available, else the file on disk.
|
||||
pub fn text_of(&self, path: &Path) -> IdeResult<String> {
|
||||
match self.documents.text_of(path) {
|
||||
Some(text) => Ok(text),
|
||||
None => Ok(ide_vfs::load_text(path)?.text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a multi-file edit through the document layer so open editors update live.
|
||||
pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> {
|
||||
use ide_core::FileEdit;
|
||||
for file in &edit.files {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
// Files not open in an editor are opened transiently and closed again.
|
||||
let (doc, transient) = match documents.by_path(path) {
|
||||
Some(doc) => (doc, false),
|
||||
None => (documents.open(path)?.id, true),
|
||||
};
|
||||
let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc));
|
||||
if transient {
|
||||
documents.close(doc);
|
||||
}
|
||||
result?;
|
||||
}
|
||||
FileEdit::Create { path, overwrite } => {
|
||||
if path.exists() {
|
||||
if *overwrite {
|
||||
ide_vfs::save_text(path, "", "utf-8", false)?;
|
||||
}
|
||||
} else {
|
||||
ide_vfs::create_file(path)?;
|
||||
}
|
||||
}
|
||||
FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?,
|
||||
FileEdit::Delete { path, .. } => ide_vfs::delete(path)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Files the watcher reports per batch that Local History snapshots.
|
||||
const HISTORY_BATCH_LIMIT: usize = 500;
|
||||
|
||||
/// Local History for changes made outside the IDE (other tools, VCS, deletions).
|
||||
/// Contents equal to the last snapshot (e.g. the IDE's own saves) are skipped.
|
||||
fn record_external_changes(history: &LocalHistory, changed: &[PathBuf], removed: &[PathBuf]) {
|
||||
for path in changed.iter().take(HISTORY_BATCH_LIMIT) {
|
||||
if let Err(e) = history.record_file(path, "External change", false) {
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
if !removed.is_empty()
|
||||
&& let Err(e) = history.note_removed(removed)
|
||||
{
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspace::Retention {
|
||||
ide_workspace::Retention {
|
||||
working_days: settings.files.local_history_days,
|
||||
max_bytes: u64::from(settings.files.local_history_max_mb.max(1)) * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Project Structure content roots outside `root` (existing absolute directories).
|
||||
fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for r in &settings.project_structure.content_roots {
|
||||
let p = PathBuf::from(r.trim());
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Watches a content root outside the project: the index, open documents and
|
||||
/// Local History follow its changes like the project folder's.
|
||||
fn content_root_watcher(
|
||||
dir: &Path,
|
||||
excludes: &[String],
|
||||
sink: &SharedSink,
|
||||
index: &Arc<ProjectIndex>,
|
||||
documents: &Arc<Documents>,
|
||||
history: &Arc<LocalHistory>,
|
||||
) -> Option<Watcher> {
|
||||
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
|
||||
Watcher::start(
|
||||
dir,
|
||||
excludes.to_vec(),
|
||||
sink.clone(),
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
documents.on_disk_changed(&changed);
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
}),
|
||||
)
|
||||
.map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display()))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec<String> {
|
||||
let mut globs = settings.file_excludes();
|
||||
globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs());
|
||||
globs
|
||||
}
|
||||
|
||||
fn project_key(root: &Path) -> String {
|
||||
// Stable FNV-1a directory name per project for caches.
|
||||
let s = root.to_string_lossy();
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for b in s.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
struct LspObserver(Arc<LspManager>);
|
||||
|
||||
impl DocumentObserver for LspObserver {
|
||||
fn opened(&self, info: &DocumentInfo) {
|
||||
// Language servers choke on huge files (IDEA disables code insight too);
|
||||
// changes to documents never opened on a server are ignored.
|
||||
if info.large {
|
||||
return;
|
||||
}
|
||||
match &info.uri {
|
||||
Some(uri) => self.0.did_open_virtual(info.id, uri, &info.language, info.version, &info.text),
|
||||
None => self.0.did_open(info.id, &info.path, &info.language, info.version, &info.text),
|
||||
}
|
||||
}
|
||||
fn changed(
|
||||
&self,
|
||||
doc: ide_core::DocId,
|
||||
_path: &Path,
|
||||
version: u32,
|
||||
changes: &[AppliedChange],
|
||||
full_text: &dyn Fn() -> String,
|
||||
) {
|
||||
self.0.did_change(doc, version, changes, full_text);
|
||||
}
|
||||
fn saved(&self, doc: ide_core::DocId, _path: &Path, text: &str) {
|
||||
self.0.did_save(doc, text);
|
||||
}
|
||||
fn closed(&self, doc: ide_core::DocId, _path: &Path) {
|
||||
self.0.did_close(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds document edits into the per-project syntax tree cache.
|
||||
struct SyntaxObserver(Arc<SyntaxCache>);
|
||||
|
||||
impl DocumentObserver for SyntaxObserver {
|
||||
fn opened(&self, info: &DocumentInfo) {
|
||||
if !info.large {
|
||||
self.0.open(info.id, &info.language, info.version);
|
||||
}
|
||||
}
|
||||
fn edited(&self, doc: ide_core::DocId, version: u32, edits: &[ByteEdit]) {
|
||||
self.0.edit(doc, version, edits);
|
||||
}
|
||||
fn closed(&self, doc: ide_core::DocId, _path: &Path) {
|
||||
self.0.close(doc);
|
||||
}
|
||||
}
|
||||
|
||||
struct HistoryObserver(Arc<LocalHistory>);
|
||||
|
||||
impl DocumentObserver for HistoryObserver {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, text: &str) {
|
||||
if let Err(e) = self.0.record(path, text, "Saved") {
|
||||
tracing::warn!("local history: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads saved files: remote files' local copies, and automatic upload to the default server.
|
||||
struct DeployObserver {
|
||||
remote: Arc<ide_remote::RemoteService>,
|
||||
settings: Arc<SettingsStore>,
|
||||
root: PathBuf,
|
||||
sink: SharedSink,
|
||||
runtime: tokio::runtime::Handle,
|
||||
}
|
||||
|
||||
impl DocumentObserver for DeployObserver {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, _text: &str) {
|
||||
let (remote, settings, root, sink, path) =
|
||||
(self.remote.clone(), self.settings.clone(), self.root.clone(), self.sink.clone(), path.to_path_buf());
|
||||
self.runtime.spawn(async move {
|
||||
if let Some(result) = remote.on_saved(&settings.effective(), &root, &path).await {
|
||||
notify_transfer(&sink, "Upload", &path, result);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn delete_deployed(
|
||||
runtime: &tokio::runtime::Handle,
|
||||
remote: Arc<ide_remote::RemoteService>,
|
||||
settings: Arc<SettingsStore>,
|
||||
root: PathBuf,
|
||||
removed: Vec<PathBuf>,
|
||||
sink: SharedSink,
|
||||
) {
|
||||
runtime.spawn(async move {
|
||||
if let Some(result) = remote.on_deleted(&settings.effective(), &root, &removed).await {
|
||||
let first = removed.first().cloned().unwrap_or_default();
|
||||
notify_transfer(&sink, "Delete on server", &first, result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Reports a failed automatic transfer (successes stay quiet).
|
||||
fn notify_transfer(sink: &SharedSink, what: &str, path: &Path, result: IdeResult<ide_remote::TransferReport>) {
|
||||
let body = match result {
|
||||
Ok(r) if r.errors.is_empty() => return,
|
||||
Ok(r) => r.errors.iter().map(|e| format!("{}: {}", e.path, e.message)).collect::<Vec<_>>().join("\n"),
|
||||
Err(e) => e.message,
|
||||
};
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
sink.emit(IdeEvent::Notify {
|
||||
level: ide_core::event::NotifyLevel::Error,
|
||||
title: format!("{what} failed: {name}"),
|
||||
body: Some(body),
|
||||
});
|
||||
}
|
||||
|
||||
/// Settings › Version Control › Git › Path to Git executable (empty = auto-detect).
|
||||
pub fn apply_git_executable(settings: &ide_core::settings::Settings) {
|
||||
let exe = settings.vcs.git.executable.trim();
|
||||
ide_vcs::set_git_executable((!exe.is_empty()).then(|| Path::new(exe)));
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
//! Remote development protocol: JSON-RPC 2.0 (`Content-Length` framing) between
|
||||
//! the desktop app and `openidea-server`, over the server's stdio (an SSH exec
|
||||
//! channel).
|
||||
//!
|
||||
//! * client → server request `initialize` `{ version }` → [`ServerInfo`];
|
||||
//! * client → server request `invoke` `{ window, cmd, args }` → the command's
|
||||
//! result, `args` as the desktop command takes them (camelCase names). A
|
||||
//! streaming argument is `{ "$channel": <id> }`;
|
||||
//! * server → client notification `event` `{ window, event }` (an `IdeEvent`
|
||||
//! for that window, or every window when `window` is null);
|
||||
//! * server → client notification `channel` `{ id, data }`: one value of a
|
||||
//! streaming argument.
|
||||
|
||||
use crate::Host;
|
||||
use crate::ops::{self, Emit};
|
||||
use ide_core::{IdeError, IdeEvent, IdeResult, RequestId};
|
||||
use ide_jsonrpc::{Handler, Peer, codes};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ServerInfo {
|
||||
pub version: String,
|
||||
pub protocol: u32,
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub home: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Invoke {
|
||||
pub window: String,
|
||||
pub cmd: String,
|
||||
#[serde(default)]
|
||||
pub args: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EventMessage {
|
||||
pub window: Option<String>,
|
||||
pub event: IdeEvent,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelMessage {
|
||||
pub id: u64,
|
||||
pub data: Value,
|
||||
}
|
||||
|
||||
/// One `invoke`: its arguments and a way to stream values back.
|
||||
pub struct Call {
|
||||
pub host: Arc<Host>,
|
||||
pub window: String,
|
||||
pub args: Value,
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
}
|
||||
|
||||
impl Call {
|
||||
/// Argument `name` (missing = `null`, so `Option`s may be omitted).
|
||||
pub fn arg<T: DeserializeOwned>(&self, name: &str) -> IdeResult<T> {
|
||||
let v = self.args.get(name).cloned().unwrap_or(Value::Null);
|
||||
serde_json::from_value(v).map_err(|e| IdeError::invalid(format!("argument {name}: {e}")))
|
||||
}
|
||||
|
||||
/// Streaming argument `name` as an [`ops::Channel`].
|
||||
pub fn sender<T: Serialize + 'static>(&self, name: &str) -> IdeResult<ops::Channel<T>> {
|
||||
let emit = self.channel(name)?;
|
||||
Ok(ops::Channel::new(move |v| emit(v)))
|
||||
}
|
||||
|
||||
/// Streaming argument `name` (`{ "$channel": id }`).
|
||||
pub fn channel<T: Serialize + 'static>(&self, name: &str) -> IdeResult<Emit<T>> {
|
||||
let id = self
|
||||
.args
|
||||
.get(name)
|
||||
.and_then(|v| v.get("$channel"))
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| IdeError::invalid(format!("argument {name}: expected a channel")))?;
|
||||
let peer = self.peer.clone();
|
||||
Ok(Arc::new(move |data: T| {
|
||||
let Some(peer) = peer.get() else { return };
|
||||
match serde_json::to_value(&data) {
|
||||
Ok(data) => {
|
||||
let _ = peer.notify("channel", ChannelMessage { id, data });
|
||||
}
|
||||
Err(e) => tracing::warn!("channel {id}: {e}"),
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
|
||||
type HandlerFn = Arc<dyn Fn(Call) -> BoxFuture<IdeResult<Value>> + Send + Sync>;
|
||||
|
||||
/// Commands the server answers, by name.
|
||||
#[derive(Default, Clone)]
|
||||
pub struct Dispatcher {
|
||||
handlers: HashMap<&'static str, HandlerFn>,
|
||||
}
|
||||
|
||||
impl Dispatcher {
|
||||
pub fn add<F, Fut, T>(&mut self, name: &'static str, f: F) -> &mut Self
|
||||
where
|
||||
F: Fn(Call) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = IdeResult<T>> + Send + 'static,
|
||||
T: Serialize,
|
||||
{
|
||||
let f = Arc::new(f);
|
||||
self.handlers.insert(
|
||||
name,
|
||||
Arc::new(move |call| {
|
||||
let fut = f(call);
|
||||
Box::pin(async move {
|
||||
let value = fut.await?;
|
||||
serde_json::to_value(value).map_err(|e| IdeError::internal(e.to_string()))
|
||||
})
|
||||
}),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn commands(&self) -> Vec<&'static str> {
|
||||
let mut out: Vec<_> = self.handlers.keys().copied().collect();
|
||||
out.sort_unstable();
|
||||
out
|
||||
}
|
||||
|
||||
/// The commands of the core remote slice: project, files, documents, search,
|
||||
/// terminals, runs and VCS status.
|
||||
pub fn standard() -> Self {
|
||||
let mut d = Self::default();
|
||||
d.add("open_project", |c| async move {
|
||||
let path: PathBuf = c.arg("path")?;
|
||||
let project = ops::project::open(&c.host, &c.window, &path).await?;
|
||||
Ok(ops::project::info(&project))
|
||||
})
|
||||
.add("project_current", |c| async move { Ok(ops::project::current(&c.host, &c.window)) })
|
||||
.add("close_project", |c| async move {
|
||||
c.host.close_project(&c.window);
|
||||
Ok(())
|
||||
})
|
||||
.add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) })
|
||||
.add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) })
|
||||
.add("settings_set", |c| async move {
|
||||
let key: String = c.arg("key")?;
|
||||
let scope = c.arg("scope")?;
|
||||
let applied = ops::project::set_settings(&c.host, &c.window, scope, &key, &c.arg::<String>("value")?)?;
|
||||
let sink = c.host.window_sink(&c.window);
|
||||
sink.emit(IdeEvent::SettingsChanged { key });
|
||||
if let Some(root) = applied.roots_changed {
|
||||
sink.emit(IdeEvent::ProjectRootsChanged { root });
|
||||
}
|
||||
Ok(applied.settings)
|
||||
})
|
||||
.add("cancel_request", |c| async move {
|
||||
c.host.cancel_request(c.arg::<RequestId>("id")?);
|
||||
Ok(())
|
||||
})
|
||||
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
|
||||
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
|
||||
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
|
||||
.add("fs_set_writable", |c| async move {
|
||||
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
|
||||
})
|
||||
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
|
||||
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
|
||||
.add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) })
|
||||
.add("doc_edit", |c| async move {
|
||||
ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?)
|
||||
})
|
||||
.add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await })
|
||||
.add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await })
|
||||
.add("doc_snapshot", |c| async move { ops::doc::snapshot(&c.host, &c.window, c.arg("doc")?).await })
|
||||
.add("search_start", |c| async move {
|
||||
let on_event = c.channel("onEvent")?;
|
||||
ops::search::start(&c.host, &c.window, c.arg("request")?, c.arg("query")?, on_event).await
|
||||
})
|
||||
.add("term_spawn", |c| async move {
|
||||
let on_output = c.channel("onOutput")?;
|
||||
ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output)
|
||||
})
|
||||
.add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
|
||||
.add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
|
||||
.add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) })
|
||||
.add("term_is_busy", |c| async move { ops::term::is_busy(&c.host, c.arg("id")?) })
|
||||
.add("term_default_shell", |_| async move { Ok(ide_terminal::default_shell_path()) })
|
||||
.add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) })
|
||||
.add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) })
|
||||
.add("run_save_configs", |c| async move {
|
||||
ops::run::save_configs(&c.host, &c.window, &c.arg::<Vec<ide_run::RunConfig>>("configs")?)
|
||||
})
|
||||
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
|
||||
.add("run_start", |c| async move {
|
||||
let on_output = c.channel("onOutput")?;
|
||||
ops::run::start(&c.host, &c.window, c.arg("config")?, on_output).await
|
||||
})
|
||||
.add("run_stop", |c| async move { ops::run::stop(&c.host, &c.window, c.arg("id")?) })
|
||||
.add("run_stdin", |c| async move { ops::run::stdin(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
|
||||
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
|
||||
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
|
||||
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
|
||||
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
|
||||
ops::register_all(&mut d);
|
||||
d
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves `host` over `reader` / `writer` until the client disconnects.
|
||||
/// `peer` must be the one whose events `host` sends ([`event_sink`]).
|
||||
pub async fn serve<R, W>(host: Arc<Host>, dispatcher: Dispatcher, peer: Arc<OnceLock<Peer>>, reader: R, writer: W)
|
||||
where
|
||||
R: tokio::io::AsyncRead + Unpin + Send + 'static,
|
||||
W: tokio::io::AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
let handler = Arc::new(ServerHandler { host: host.clone(), dispatcher, peer: peer.clone() });
|
||||
let p = Peer::connect(reader, writer, handler);
|
||||
let _ = peer.set(p.clone());
|
||||
p.closed().await;
|
||||
}
|
||||
|
||||
/// Sends a window's events to the client as `event` notifications.
|
||||
pub fn event_sink(peer: Arc<OnceLock<Peer>>, window: Option<String>) -> ide_core::SharedSink {
|
||||
Arc::new(PeerSink { peer, window })
|
||||
}
|
||||
|
||||
struct PeerSink {
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
window: Option<String>,
|
||||
}
|
||||
|
||||
impl ide_core::EventSink for PeerSink {
|
||||
fn emit(&self, event: IdeEvent) {
|
||||
if let Some(peer) = self.peer.get() {
|
||||
let _ = peer.notify("event", EventMessage { window: self.window.clone(), event });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerHandler {
|
||||
host: Arc<Host>,
|
||||
dispatcher: Dispatcher,
|
||||
peer: Arc<OnceLock<Peer>>,
|
||||
}
|
||||
|
||||
pub fn server_info() -> ServerInfo {
|
||||
ServerInfo {
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
protocol: PROTOCOL_VERSION,
|
||||
os: std::env::consts::OS.into(),
|
||||
arch: std::env::consts::ARCH.into(),
|
||||
home: dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
impl Handler for ServerHandler {
|
||||
fn on_notification(&self, _method: &str, _params: Value) {}
|
||||
|
||||
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
|
||||
let internal = |e: serde_json::Error| (codes::INTERNAL_ERROR, e.to_string());
|
||||
match method {
|
||||
"initialize" => serde_json::to_value(server_info()).map_err(internal),
|
||||
"commands" => serde_json::to_value(self.dispatcher.commands()).map_err(internal),
|
||||
"invoke" => {
|
||||
let call: Invoke =
|
||||
serde_json::from_value(params).map_err(|e| (codes::INVALID_PARAMS, e.to_string()))?;
|
||||
let Some(handler) = self.dispatcher.handlers.get(call.cmd.as_str()).cloned() else {
|
||||
return Err((codes::METHOD_NOT_FOUND, format!("command {} is not available remotely", call.cmd)));
|
||||
};
|
||||
let c = Call { host: self.host.clone(), window: call.window, args: call.args, peer: self.peer.clone() };
|
||||
// Requests run on blocking-capable threads: wait for the command here.
|
||||
self.host.runtime.block_on(handler(c)).map_err(error_reply)
|
||||
}
|
||||
_ => Err((codes::METHOD_NOT_FOUND, format!("unknown method {method}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An `IdeError` as a JSON-RPC error: the JSON of the error is the message.
|
||||
fn error_reply(e: IdeError) -> (i64, String) {
|
||||
(codes::INTERNAL_ERROR, serde_json::to_string(&e).unwrap_or(e.message))
|
||||
}
|
||||
|
||||
/// The `IdeError` of an `invoke` reply (see [`error_reply`]).
|
||||
pub fn error_from_reply(e: ide_jsonrpc::RpcError) -> IdeError {
|
||||
match e {
|
||||
ide_jsonrpc::RpcError::Server { message, .. } => serde_json::from_str(&message)
|
||||
.unwrap_or_else(|_| IdeError::new(ide_core::error::ErrorKind::Protocol, message)),
|
||||
other => other.into(),
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,151 @@
|
||||
//! The remote protocol end to end: a server on one side of an in-memory pipe,
|
||||
//! a JSON-RPC client on the other.
|
||||
|
||||
use ide_host::rpc::{self, ChannelMessage, Dispatcher, EventMessage, Invoke, ServerInfo};
|
||||
use ide_host::{Host, HostConfig};
|
||||
use ide_jsonrpc::{Handler, Peer};
|
||||
use parking_lot::Mutex;
|
||||
use serde_json::{Value, json};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Client {
|
||||
channels: Mutex<Vec<ChannelMessage>>,
|
||||
events: Mutex<Vec<EventMessage>>,
|
||||
}
|
||||
|
||||
impl Handler for Client {
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
match method {
|
||||
"channel" => self.channels.lock().push(serde_json::from_value(params).unwrap()),
|
||||
"event" => self.events.lock().push(serde_json::from_value(params).unwrap()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn on_request(&self, method: &str, _: Value) -> Result<Value, (i64, String)> {
|
||||
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn invoke(peer: &Peer, cmd: &str, args: Value) -> Result<Value, ide_core::IdeError> {
|
||||
let call = Invoke { window: "main".into(), cmd: cmd.into(), args };
|
||||
peer.request::<_, Value>("invoke", call, None).await.map_err(rpc::error_from_reply)
|
||||
}
|
||||
|
||||
async fn until(what: &str, mut done: impl FnMut() -> bool) {
|
||||
for _ in 0..500 {
|
||||
if done() {
|
||||
return;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||
}
|
||||
panic!("timed out waiting for {what}");
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn serves_projects_files_documents_search_and_runs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let project = tmp.path().join("demo");
|
||||
std::fs::create_dir_all(project.join("src")).unwrap();
|
||||
std::fs::write(project.join("src/main.py"), "print('needle')\n").unwrap();
|
||||
let base = tmp.path().join("server");
|
||||
|
||||
let peer_slot = Arc::new(OnceLock::new());
|
||||
let sink_peer = peer_slot.clone();
|
||||
let host = Arc::new(Host::new(HostConfig {
|
||||
config_dir: base.join("config"),
|
||||
data_dir: base.join("data"),
|
||||
languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()),
|
||||
syntax: Arc::default(),
|
||||
sink_for: Box::new(move |w| rpc::event_sink(sink_peer.clone(), Some(w.to_owned()))),
|
||||
owner: |w| w,
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
}));
|
||||
let (client_io, server_io) = tokio::io::duplex(1 << 20);
|
||||
let (sr, sw) = tokio::io::split(server_io);
|
||||
tokio::spawn(rpc::serve(host, Dispatcher::standard(), peer_slot, sr, sw));
|
||||
let (cr, cw) = tokio::io::split(client_io);
|
||||
let client = Arc::new(Client::default());
|
||||
let peer = Peer::connect(cr, cw, client.clone());
|
||||
|
||||
let info: ServerInfo = peer.request("initialize", json!({ "version": 1 }), None).await.unwrap();
|
||||
assert_eq!(info.protocol, rpc::PROTOCOL_VERSION);
|
||||
|
||||
let opened = invoke(&peer, "open_project", json!({ "path": project })).await.unwrap();
|
||||
assert_eq!(opened["name"], "demo");
|
||||
let current = invoke(&peer, "project_current", json!({})).await.unwrap();
|
||||
assert_eq!(current["name"], "demo");
|
||||
|
||||
let entries = invoke(&peer, "fs_read_dir", json!({ "path": project })).await.unwrap();
|
||||
assert!(entries.as_array().unwrap().iter().any(|e| e["name"] == "src"), "{entries}");
|
||||
|
||||
// Edit and save a document.
|
||||
let file = project.join("src/main.py");
|
||||
let doc = invoke(&peer, "doc_open", json!({ "path": file })).await.unwrap();
|
||||
let id = doc["id"].clone();
|
||||
let version = doc["version"].as_u64().unwrap();
|
||||
let changes = json!({ "changes": [{ "from": 16, "to": 16, "insert": "print('more')\n" }] });
|
||||
invoke(&peer, "doc_edit", json!({ "doc": id, "baseVersion": version, "changes": changes })).await.unwrap();
|
||||
invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "print('needle')\nprint('more')\n");
|
||||
|
||||
// Commands shared with the desktop shell (camelCase argument names, as Tauri takes them).
|
||||
invoke(&peer, "doc_set_line_ending", json!({ "doc": id, "lineEnding": "crLf" })).await.unwrap();
|
||||
invoke(&peer, "doc_save", json!({ "doc": id })).await.unwrap();
|
||||
assert_eq!(std::fs::read_to_string(&file).unwrap(), "print('needle')\r\nprint('more')\r\n");
|
||||
let settings = invoke(
|
||||
&peer,
|
||||
"settings_set",
|
||||
json!({ "scope": "project", "key": "deployment.autoUpload",
|
||||
"value": "\"always\"" }),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(settings["deployment"]["autoUpload"], "always");
|
||||
assert!(std::fs::read_to_string(project.join(".openidea/settings.toml")).unwrap().contains("autoUpload"));
|
||||
invoke(&peer, "vcs_init", json!({ "dir": project })).await.unwrap();
|
||||
until("git repository", || project.join(".git").is_dir()).await;
|
||||
|
||||
// Unknown commands and bad arguments come back as IdeErrors.
|
||||
let e = invoke(&peer, "window_minimize", json!({})).await.unwrap_err();
|
||||
assert!(e.message.contains("not available remotely"), "{e:?}");
|
||||
let e = invoke(&peer, "doc_save", json!({ "doc": "nope" })).await.unwrap_err();
|
||||
assert_eq!(e.kind, ide_core::error::ErrorKind::InvalidInput, "{e:?}");
|
||||
|
||||
// Find in Files streams through a channel.
|
||||
let query = json!({ "pattern": "needle", "isRegex": false, "caseSensitive": false, "wholeWord": false,
|
||||
"include": [], "exclude": [], "scope": { "kind": "project" }, "maxMatches": 0 });
|
||||
invoke(&peer, "search_start", json!({ "request": 7, "query": query, "onEvent": { "$channel": 1 } })).await.unwrap();
|
||||
let found = client.channels.lock().iter().filter(|c| c.id == 1).map(|c| c.data.clone()).collect::<Vec<_>>();
|
||||
assert!(found.iter().any(|d| d["type"] == "file"), "{found:?}");
|
||||
assert_eq!(found.last().unwrap()["type"], "done");
|
||||
|
||||
// A run's output streams through a channel and its exit is an event.
|
||||
let config = json!({ "id": "echo", "name": "echo", "kind": "shell", "command": "echo", "args": ["remote-hi"],
|
||||
"cwd": null, "env": {}, "detected": false, "isTest": false, "debugAdapter": null });
|
||||
invoke(&peer, "run_start", json!({ "config": config, "onOutput": { "$channel": 2 } })).await.unwrap();
|
||||
until("run exit", || client.events.lock().iter().any(|e| e.event_type() == "runExited")).await;
|
||||
let out: String = client
|
||||
.channels
|
||||
.lock()
|
||||
.iter()
|
||||
.filter(|c| c.id == 2)
|
||||
.filter_map(|c| c.data["text"].as_str().map(str::to_owned))
|
||||
.collect();
|
||||
assert!(out.contains("remote-hi"), "{out}");
|
||||
assert_eq!(
|
||||
client.events.lock().iter().find(|e| e.event_type() == "runExited").unwrap().window.as_deref(),
|
||||
Some("main")
|
||||
);
|
||||
}
|
||||
|
||||
trait EventType {
|
||||
fn event_type(&self) -> String;
|
||||
}
|
||||
|
||||
impl EventType for EventMessage {
|
||||
fn event_type(&self) -> String {
|
||||
serde_json::to_value(&self.event).unwrap()["type"].as_str().unwrap_or_default().to_owned()
|
||||
}
|
||||
}
|
||||
@@ -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<TransferReport> {
|
||||
pub async fn push_changes(
|
||||
&self,
|
||||
local: &Path,
|
||||
progress: &mut (dyn FnMut(&str, usize, usize) + Send),
|
||||
) -> IdeResult<TransferReport> {
|
||||
let steps: Vec<SyncStep> = self
|
||||
.compare(local)
|
||||
.await?
|
||||
|
||||
@@ -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::<Vec<_>>().join("/");
|
||||
let path =
|
||||
[web.as_str(), rel.as_str()].iter().filter(|s| !s.is_empty()).copied().collect::<Vec<_>>().join("/");
|
||||
Some(if path.is_empty() { format!("{}/", self.web_url) } else { format!("{}/{path}", self.web_url) })
|
||||
})
|
||||
}
|
||||
@@ -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")));
|
||||
|
||||
@@ -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<TransferReport> {
|
||||
pub async fn delete_remote(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
server_id: Option<&str>,
|
||||
paths: &[PathBuf],
|
||||
) -> IdeResult<TransferReport> {
|
||||
let server = server(settings, server_id)?;
|
||||
self.with_deployment(settings, root, &server, CancellationToken::new(), async |d| d.delete_remote(paths).await).await
|
||||
self.with_deployment(settings, root, &server, CancellationToken::new(), async |d| d.delete_remote(paths).await)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn compare(&self, settings: &Settings, root: &Path, server_id: Option<&str>, path: &Path, cancel: CancellationToken) -> IdeResult<Vec<SyncEntry>> {
|
||||
pub async fn compare(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
server_id: Option<&str>,
|
||||
path: &Path,
|
||||
cancel: CancellationToken,
|
||||
) -> IdeResult<Vec<SyncEntry>> {
|
||||
let server = server(settings, server_id)?;
|
||||
self.with_deployment(settings, root, &server, cancel, async |d| d.compare(path).await).await?
|
||||
}
|
||||
@@ -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<Option<DeployedPath>> {
|
||||
pub async fn deployed(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
server_id: Option<&str>,
|
||||
path: &Path,
|
||||
) -> IdeResult<Option<DeployedPath>> {
|
||||
let server = server(settings, server_id)?;
|
||||
let name = server.name.clone();
|
||||
self.with_deployment(settings, root, &server, CancellationToken::new(), async |d| {
|
||||
d.map.remote(path).map(|remote_path| DeployedPath { server: name, remote_path, web_url: d.map.web_url(path) })
|
||||
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<IdeResult<TransferReport>> {
|
||||
pub async fn on_deleted(
|
||||
&self,
|
||||
settings: &Settings,
|
||||
root: &Path,
|
||||
paths: &[PathBuf],
|
||||
) -> Option<IdeResult<TransferReport>> {
|
||||
let d = &settings.deployment;
|
||||
if d.auto_upload == AutoUpload::Never || !d.delete_remote || d.default_server.is_empty() {
|
||||
return None;
|
||||
@@ -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(()),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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<dyn Fn(OutputChunk) + Send + Sync>) -> RunInfo {
|
||||
pub fn start(
|
||||
&self,
|
||||
start: RemoteStart,
|
||||
config: &RunConfig,
|
||||
sink: SharedSink,
|
||||
on_output: Box<dyn Fn(OutputChunk) + Send + Sync>,
|
||||
) -> RunInfo {
|
||||
let id = RunId::next();
|
||||
let pty = config.extras.emulate_terminal;
|
||||
let info = RunInfo { id, config_id: config.id.clone(), name: config.name.clone(), pid: None, running: true, pty };
|
||||
let 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<RemoteProcess>, 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<Option<u32>> {
|
||||
async fn run_remote(
|
||||
run: &RemoteRun,
|
||||
start: RemoteStart,
|
||||
command: RemoteCommand,
|
||||
pty: bool,
|
||||
on_output: OutputFn,
|
||||
) -> IdeResult<Option<u32>> {
|
||||
on_output(OutputChunk::System { text: format!("[{}] {}\n", start.target.name, start.session.label()) });
|
||||
if let Some(sync) = start.sync {
|
||||
tokio::select! {
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Mutex<Option<AskpassPrompt>>> = Arc::default();
|
||||
let s = seen.clone();
|
||||
let server = Arc::new(
|
||||
AskpassServer::start("helper".into(), Arc::new(move |p| *s.lock() = Some(p))).unwrap(),
|
||||
);
|
||||
let 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 || {
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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<Session, ConnectError> {
|
||||
async fn connect_inner(
|
||||
target: &SshTarget,
|
||||
secret: Option<&str>,
|
||||
files: &SshFiles,
|
||||
) -> Result<Session, ConnectError> {
|
||||
let hops = resolve_hops(target, files.config.as_deref())?;
|
||||
let mut sessions: Vec<Handle<Client>> = Vec::new();
|
||||
for (i, hop) in hops.iter().enumerate() {
|
||||
@@ -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<ChannelStream<Msg>, russh::Error> {
|
||||
let channel = self.handle.channel_open_session().await?;
|
||||
channel.exec(true, command).await?;
|
||||
Ok(channel.into_stream())
|
||||
}
|
||||
|
||||
/// Runs `command` through the remote shell and collects its output.
|
||||
pub async fn exec(&self, command: &str, stdin: Option<&[u8]>) -> IdeResult<ExecOutput> {
|
||||
let mut channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?;
|
||||
@@ -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<russh_sftp::client::SftpSession> {
|
||||
let channel = self.handle.channel_open_session().await.map_err(|e| self.failed(e))?;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,8 @@ pub fn command_line(cwd: Option<&str>, env: &BTreeMap<String, String>, 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(),
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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<Handle, Self::Error> {
|
||||
async fn open(
|
||||
&mut self,
|
||||
id: u32,
|
||||
filename: String,
|
||||
pflags: OpenFlags,
|
||||
_attrs: FileAttributes,
|
||||
) -> Result<Handle, Self::Error> {
|
||||
let path = self.local(&filename);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(pflags.contains(OpenFlags::READ))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
let dest = cached_path(app_data, version, os, arch);
|
||||
if dest.is_file() {
|
||||
return Ok(dest);
|
||||
}
|
||||
if !PLATFORMS.contains(&(os, arch)) {
|
||||
return Err(IdeError::unavailable(format!("remote development does not support {os}-{arch} hosts")));
|
||||
}
|
||||
let name = asset_name(os, arch);
|
||||
let url = format!("{RELEASES}/v{version}/{name}");
|
||||
let 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<PathBuf> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -1,105 +1,14 @@
|
||||
//! Code analysis over a scope of files: Inspect Code (batch LSP diagnostics),
|
||||
//! Locate Duplicates and Search / Replace Structurally (`ide-analysis`).
|
||||
//!
|
||||
//! Scopes are Find in Files scopes; excluded roots are never analysed. Every
|
||||
//! command takes a UI-allocated `RequestId` and is cancelled via `cancel_request`.
|
||||
//! IPC commands of `ide_host::ops::analysis` (the shared implementation).
|
||||
|
||||
use super::doc::blocking;
|
||||
use crate::state::{AppState, Project};
|
||||
use ide_analysis::{
|
||||
DuplicateOptions, DuplicatesReport, SourceFile, StructuralFile, StructuralQuery, StructuralSummary,
|
||||
};
|
||||
use ide_core::{CancellationToken, Diagnostic, DocId, IdeResult, IpcPath, LanguageId, RequestId};
|
||||
use ide_language::LanguageRegistry;
|
||||
use ide_lsp::{FileInspection, InspectHost, InspectOptions};
|
||||
use ide_search::{FileMask, SearchScope};
|
||||
use ide_workspace::Documents;
|
||||
use parking_lot::Mutex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use specta::Type;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use crate::state::AppState;
|
||||
use ide_analysis::{DuplicateOptions, DuplicatesReport, StructuralQuery};
|
||||
use ide_core::{IdeResult, RequestId};
|
||||
#[allow(unused_imports)]
|
||||
pub use ide_host::ops::analysis::*;
|
||||
use ide_search::SearchScope;
|
||||
use tauri::ipc::Channel;
|
||||
use tauri::{State, Window};
|
||||
|
||||
/// Files of `scope`, minus excluded roots and ignored names.
|
||||
fn files_in_scope(project: &Project, scope: &SearchScope, cancel: &CancellationToken) -> IdeResult<Vec<SourceFile>> {
|
||||
let mask = FileMask::new(&[], &project.search_excludes())?;
|
||||
Ok(ide_search::scope_files(&project.root, scope, &mask, None, cancel)?
|
||||
.into_iter()
|
||||
.map(|f| SourceFile { path: f.path, rel_path: f.rel_path })
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Current text of a file (the unsaved buffer when open) and its language.
|
||||
fn load(project: &Project, languages: &LanguageRegistry, path: &Path) -> Option<(LanguageId, String)> {
|
||||
if let Some(doc) = project.documents.by_path(path) {
|
||||
let snap = project.documents.text_snapshot(doc).ok()?;
|
||||
return (!snap.large).then_some((snap.language, snap.text));
|
||||
}
|
||||
let loaded = ide_vfs::load_text(path).ok()?;
|
||||
if loaded.binary || loaded.large || loaded.too_large {
|
||||
return None;
|
||||
}
|
||||
let language = languages.detect(path, loaded.text.lines().next());
|
||||
Some((language, loaded.text))
|
||||
}
|
||||
|
||||
// ---- Inspect Code ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InspectedFile {
|
||||
pub path: IpcPath,
|
||||
pub rel_path: String,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InspectSummary {
|
||||
/// Files inspected by a language server.
|
||||
pub files: u32,
|
||||
pub problems: u32,
|
||||
/// Files in scope no language server serves.
|
||||
pub skipped: u32,
|
||||
/// Files whose server failed or never became ready.
|
||||
pub failed: u32,
|
||||
pub cancelled: bool,
|
||||
pub elapsed_ms: u32,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum InspectEvent {
|
||||
/// Files about to be inspected.
|
||||
Started {
|
||||
total: u32,
|
||||
},
|
||||
/// A file finished (with or without problems).
|
||||
File {
|
||||
file: InspectedFile,
|
||||
done: u32,
|
||||
},
|
||||
Done {
|
||||
summary: InspectSummary,
|
||||
},
|
||||
}
|
||||
|
||||
struct DocumentsHost(Arc<Documents>);
|
||||
|
||||
impl InspectHost for DocumentsHost {
|
||||
fn open(&self, path: &Path) -> IdeResult<(DocId, bool)> {
|
||||
let was_open = self.0.by_path(path).is_some();
|
||||
Ok((self.0.open(path)?.id, was_open))
|
||||
}
|
||||
|
||||
fn close(&self, doc: DocId) {
|
||||
self.0.close(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inspect Code: diagnostics of every file in `scope` that a language server
|
||||
/// serves. Files stream through `on_event` as they finish; ends with `Done`.
|
||||
#[tauri::command]
|
||||
@@ -111,72 +20,16 @@ pub async fn inspect_code(
|
||||
scope: SearchScope,
|
||||
on_event: Channel<InspectEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let started = Instant::now();
|
||||
let project = state.project(window.label())?;
|
||||
let cancel = state.begin_request(request);
|
||||
let listed = {
|
||||
let (project, cancel) = (project.clone(), cancel.clone());
|
||||
blocking(move || files_in_scope(&project, &scope, &cancel)).await
|
||||
};
|
||||
let files = match listed {
|
||||
Ok(files) => files,
|
||||
Err(e) => {
|
||||
state.end_request(request);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
let total_in_scope = files.len() as u32;
|
||||
let rel: std::collections::HashMap<_, _> = files
|
||||
.iter()
|
||||
.filter(|f| project.lsp.serves_path(&f.path))
|
||||
.map(|f| (f.path.clone(), f.rel_path.clone()))
|
||||
.collect();
|
||||
let mut summary = InspectSummary { skipped: total_in_scope - rel.len() as u32, ..Default::default() };
|
||||
let _ = on_event.send(InspectEvent::Started { total: rel.len() as u32 });
|
||||
|
||||
let mut paths: Vec<_> = rel.keys().cloned().collect();
|
||||
paths.sort();
|
||||
let tally = Mutex::new(summary.clone());
|
||||
let done = std::sync::atomic::AtomicU32::new(0);
|
||||
let host = Arc::new(DocumentsHost(project.documents.clone()));
|
||||
project
|
||||
.lsp
|
||||
.inspect_files(host, paths, InspectOptions::default(), cancel.clone(), &|path, outcome| {
|
||||
let done = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
|
||||
let diagnostics = {
|
||||
let mut t = tally.lock();
|
||||
match outcome {
|
||||
FileInspection::Inspected(items) => {
|
||||
t.files += 1;
|
||||
t.problems += items.len() as u32;
|
||||
items
|
||||
}
|
||||
FileInspection::NoServer => {
|
||||
t.skipped += 1;
|
||||
Vec::new()
|
||||
}
|
||||
FileInspection::Failed(message) => {
|
||||
tracing::debug!("inspect {}: {message}", path.display());
|
||||
t.failed += 1;
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
};
|
||||
let rel_path = rel.get(&path).cloned().unwrap_or_default();
|
||||
let file = InspectedFile { path: IpcPath(path), rel_path, diagnostics };
|
||||
let _ = on_event.send(InspectEvent::File { file, done });
|
||||
})
|
||||
.await;
|
||||
state.end_request(request);
|
||||
summary = tally.into_inner();
|
||||
summary.cancelled = cancel.is_cancelled();
|
||||
summary.elapsed_ms = u32::try_from(started.elapsed().as_millis()).unwrap_or(u32::MAX);
|
||||
let _ = on_event.send(InspectEvent::Done { summary });
|
||||
Ok(())
|
||||
ide_host::ops::analysis::inspect_code(
|
||||
&state.host,
|
||||
window.label(),
|
||||
request,
|
||||
scope,
|
||||
crate::commands::sender(on_event),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
// ---- Locate Duplicates ----
|
||||
|
||||
/// Locate Duplicates over the files of `scope`.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -187,58 +40,21 @@ pub async fn analysis_locate_duplicates(
|
||||
scope: SearchScope,
|
||||
options: DuplicateOptions,
|
||||
) -> IdeResult<DuplicatesReport> {
|
||||
let project = state.project(window.label())?;
|
||||
let cancel = state.begin_request(request);
|
||||
let languages = project.languages.clone();
|
||||
let syntax = state.syntax();
|
||||
let result = blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
let loader = |p: &Path| load(&project, &languages, p);
|
||||
Ok(ide_analysis::find_duplicates(&syntax, &files, &loader, &options, &cancel))
|
||||
})
|
||||
.await;
|
||||
state.end_request(request);
|
||||
result
|
||||
}
|
||||
|
||||
// ---- Structural Search ----
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(tag = "type", rename_all = "camelCase")]
|
||||
pub enum StructuralEvent {
|
||||
File { file: StructuralFile },
|
||||
Done { summary: StructuralSummary },
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StructuralLanguage {
|
||||
pub id: LanguageId,
|
||||
pub name: String,
|
||||
ide_host::ops::analysis::analysis_locate_duplicates(&state.host, window.label(), request, scope, options).await
|
||||
}
|
||||
|
||||
/// Languages Search Structurally supports (those with a bundled grammar).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn structural_languages(state: State<'_, AppState>) -> IdeResult<Vec<StructuralLanguage>> {
|
||||
let syntax = state.syntax();
|
||||
let mut out: Vec<StructuralLanguage> = state
|
||||
.languages
|
||||
.all()
|
||||
.into_iter()
|
||||
.filter(|c| syntax.supports(&c.id))
|
||||
.map(|c| StructuralLanguage { id: c.id, name: c.name })
|
||||
.collect();
|
||||
out.sort_by_key(|l| l.name.to_lowercase());
|
||||
Ok(out)
|
||||
ide_host::ops::analysis::structural_languages(&state.host, "").await
|
||||
}
|
||||
|
||||
/// Checks a structural search template (and language) while the user types.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn structural_validate(state: State<'_, AppState>, query: StructuralQuery) -> IdeResult<()> {
|
||||
let syntax = state.syntax();
|
||||
blocking(move || ide_analysis::validate_structural(&syntax, &query)).await
|
||||
ide_host::ops::analysis::structural_validate(&state.host, "", query).await
|
||||
}
|
||||
|
||||
/// Search Structurally over `scope`; files with matches stream through
|
||||
@@ -254,20 +70,13 @@ pub async fn structural_search(
|
||||
query: StructuralQuery,
|
||||
on_event: Channel<StructuralEvent>,
|
||||
) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
let cancel = state.begin_request(request);
|
||||
let languages = project.languages.clone();
|
||||
let syntax = state.syntax();
|
||||
let events = on_event.clone();
|
||||
let result = blocking(move || {
|
||||
let files = files_in_scope(&project, &scope, &cancel)?;
|
||||
let loader = |p: &Path| load(&project, &languages, p);
|
||||
ide_analysis::search_structurally(&syntax, &files, &loader, &query, &cancel, &|file| {
|
||||
let _ = events.send(StructuralEvent::File { file });
|
||||
})
|
||||
})
|
||||
.await;
|
||||
state.end_request(request);
|
||||
let _ = on_event.send(StructuralEvent::Done { summary: result? });
|
||||
Ok(())
|
||||
ide_host::ops::analysis::structural_search(
|
||||
&state.host,
|
||||
window.label(),
|
||||
request,
|
||||
scope,
|
||||
query,
|
||||
crate::commands::sender(on_event),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -17,14 +17,7 @@ pub struct AppInfo {
|
||||
pub config_dir: ide_core::IpcPath,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProjectInfo {
|
||||
pub root: ide_core::IpcPath,
|
||||
pub name: String,
|
||||
pub is_git: bool,
|
||||
pub settings: Settings,
|
||||
}
|
||||
pub use ide_host::ops::project::ProjectInfo;
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -69,18 +62,7 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
|
||||
}
|
||||
|
||||
fn project_info(project: &crate::state::Project) -> ProjectInfo {
|
||||
let settings = project.settings.effective();
|
||||
let custom = settings.general.project_name.trim();
|
||||
ProjectInfo {
|
||||
name: if custom.is_empty() {
|
||||
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
|
||||
} else {
|
||||
custom.to_owned()
|
||||
},
|
||||
is_git: !project.repos.read().is_empty(),
|
||||
settings,
|
||||
root: project.root.clone().into(),
|
||||
}
|
||||
ide_host::ops::project::info(project)
|
||||
}
|
||||
|
||||
/// File › Rename Project…: the project's display name (title bar, recent
|
||||
@@ -122,10 +104,7 @@ pub fn languages(state: State<'_, AppState>) -> Vec<LanguageInfo> {
|
||||
#[specta::specta]
|
||||
pub fn settings_get(window: Window, state: State<'_, AppState>) -> Settings {
|
||||
state.note_ipc();
|
||||
match state.project(window.label()) {
|
||||
Ok(p) => p.settings.effective(),
|
||||
Err(_) => state.global_settings.effective(),
|
||||
}
|
||||
ide_host::ops::project::settings(&state.host, window.label())
|
||||
}
|
||||
|
||||
/// Defaults plus the global layer, without any project's values: what new
|
||||
@@ -148,79 +127,22 @@ pub fn settings_set(
|
||||
key: String,
|
||||
value: String,
|
||||
) -> IdeResult<Settings> {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(&value).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
|
||||
let current = state.project(window.label()).ok();
|
||||
let project_scope = matches!(scope, SettingsScope::Project) && current.is_some();
|
||||
let settings = match ¤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<ide_workspace::ProjectRoots> {
|
||||
Ok(state.project(window.label())?.roots())
|
||||
ide_host::ops::project::roots(&state.host, window.label())
|
||||
}
|
||||
|
||||
/// The project open in this window (a settings window: its parent's), if any.
|
||||
|
||||
@@ -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<u32>) -> BTreeMap<String, String> {
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
let jdk = project_jdk.or_else(|| release.and_then(|r| ide_sdk::java::pick_jdk(&jdks, Some(r))));
|
||||
jdk.map(|j| BTreeMap::from([("JAVA_HOME".to_owned(), j.home.to_string_lossy().into_owned())])).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The build model with the launch settings applied: the JDK (`JAVA_HOME`), and
|
||||
/// the Maven home and options from Settings › Maven.
|
||||
pub(crate) fn load(root: &Path, cache: &Path, settings: &Settings) -> BuildModel {
|
||||
let mut model = ide_build::load(root, cache, ide_sdk::apply::maven_local_repository(root, settings));
|
||||
let jdk = ide_sdk::apply::project_jdk(root, settings);
|
||||
if let Some(m) = &mut model.maven {
|
||||
let mvn = ide_sdk::apply::maven_command(root, settings);
|
||||
let options = ide_sdk::apply::maven_options(root, settings);
|
||||
for p in &mut m.projects {
|
||||
p.env = java_env(jdk.as_ref(), p.java_version);
|
||||
if let Some(mvn) = &mvn {
|
||||
p.command = mvn.to_string_lossy().into_owned();
|
||||
}
|
||||
p.options = options.clone();
|
||||
}
|
||||
}
|
||||
if let Some(g) = &mut model.gradle {
|
||||
g.env = java_env(jdk.as_ref(), g.java_version);
|
||||
}
|
||||
// A failed sync of a project no installed JDK can build says so first (with a Download JDK action).
|
||||
let missing = |release: Option<u32>| match jdk {
|
||||
Some(_) => None,
|
||||
None => ide_sdk::java::missing_jdk_message(&ide_sdk::java::installed_jdks(), release),
|
||||
};
|
||||
let explain = |error: &mut Option<String>, hint: Option<String>| {
|
||||
if let (Some(e), Some(hint)) = (error.as_mut(), hint) {
|
||||
*e = format!("{hint}\n{e}");
|
||||
}
|
||||
};
|
||||
if let Some(m) = &mut model.maven
|
||||
&& m.sync_error.is_some()
|
||||
{
|
||||
let hint = m.projects.iter().find_map(|p| missing(p.java_version));
|
||||
explain(&mut m.sync_error, hint);
|
||||
}
|
||||
if let Some(g) = &mut model.gradle
|
||||
&& g.sync_error.is_some()
|
||||
{
|
||||
let hint = missing(g.java_version);
|
||||
explain(&mut g.sync_error, hint);
|
||||
}
|
||||
model
|
||||
}
|
||||
|
||||
/// Maven and Gradle projects of the window's project, from the build files plus the last sync.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn build_model(window: Window, state: State<'_, AppState>) -> IdeResult<BuildModel> {
|
||||
let project = state.project(window.label())?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
tauri::async_runtime::spawn_blocking(move || load(&root, &cache, &settings)).await.map_err(join_err)
|
||||
ide_host::ops::build::build_model(&state.host, window.label()).await
|
||||
}
|
||||
|
||||
/// Runs Maven (`help:effective-pom dependency:tree`) or Gradle (`tasks --all`) in the
|
||||
@@ -90,40 +30,16 @@ pub async fn build_sync(
|
||||
profiles: Vec<String>,
|
||||
on_progress: Channel<SyncProgress>,
|
||||
) -> IdeResult<BuildModel> {
|
||||
let project = state.project(window.label())?;
|
||||
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
|
||||
let token = state.begin_request(request);
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
let current = load(&root, &cache, &settings);
|
||||
let env = match tool {
|
||||
BuildTool::Maven => current
|
||||
.maven
|
||||
.as_ref()
|
||||
.and_then(|m| m.projects.iter().find(|p| !p.env.is_empty()))
|
||||
.map(|p| p.env.clone())
|
||||
.unwrap_or_default(),
|
||||
BuildTool::Gradle => current.gradle.as_ref().map(|g| g.env.clone()).unwrap_or_default(),
|
||||
};
|
||||
let options = SyncOptions {
|
||||
offline,
|
||||
profiles,
|
||||
env,
|
||||
maven_command: ide_sdk::apply::maven_command(&root, &settings).map(|p| p.to_string_lossy().into_owned()),
|
||||
maven_options: ide_sdk::apply::maven_options(&root, &settings),
|
||||
};
|
||||
let progress = |p: SyncProgress| {
|
||||
let _ = on_progress.send(p);
|
||||
};
|
||||
match tool {
|
||||
BuildTool::Maven => ide_build::sync_maven(&root, &cache, &options, &token, &progress)?,
|
||||
BuildTool::Gradle => ide_build::sync_gradle(&root, &cache, &options, &token, &progress)?,
|
||||
}
|
||||
Ok(load(&root, &cache, &settings))
|
||||
})
|
||||
ide_host::ops::build::build_sync(
|
||||
&state.host,
|
||||
window.label(),
|
||||
tool,
|
||||
request,
|
||||
offline,
|
||||
profiles,
|
||||
crate::commands::sender(on_progress),
|
||||
)
|
||||
.await
|
||||
.map_err(join_err);
|
||||
state.end_request(request);
|
||||
result?
|
||||
}
|
||||
|
||||
/// 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<std::path::PathBuf>,
|
||||
on_event: Channel<BuildEvent>,
|
||||
) -> IdeResult<BuildResult> {
|
||||
let project = state.project(window.label())?;
|
||||
let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone());
|
||||
let settings = project.settings.effective();
|
||||
let token = state.begin_request(request);
|
||||
let result = tauri::async_runtime::spawn_blocking(move || {
|
||||
for path in clear {
|
||||
sink.emit(IdeEvent::Diagnostics {
|
||||
path: path.into(),
|
||||
source: compile::DIAGNOSTICS_SOURCE.into(),
|
||||
items: Vec::new(),
|
||||
});
|
||||
}
|
||||
let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?;
|
||||
let result = compile::run(&root, &plan, &token, &|e| {
|
||||
let _ = on_event.send(e);
|
||||
})?;
|
||||
for (path, items) in compile::diagnostics(&result.messages) {
|
||||
sink.emit(IdeEvent::Diagnostics { path: path.into(), source: compile::DIAGNOSTICS_SOURCE.into(), items });
|
||||
}
|
||||
Ok(result)
|
||||
})
|
||||
ide_host::ops::build::build_run(
|
||||
&state.host,
|
||||
window.label(),
|
||||
kind,
|
||||
file,
|
||||
request,
|
||||
clear,
|
||||
crate::commands::sender(on_event),
|
||||
)
|
||||
.await
|
||||
.map_err(join_err);
|
||||
state.end_request(request);
|
||||
result?
|
||||
}
|
||||
@@ -1,27 +1,30 @@
|
||||
//! Raw file bytes for the image viewer and hex view (see `ide_vfs::bytes`).
|
||||
//! IPC commands of `ide_host::ops::bytes` (the shared implementation).
|
||||
|
||||
use crate::commands::doc::blocking;
|
||||
use crate::state::AppState;
|
||||
use ide_core::IdeResult;
|
||||
#[allow(unused_imports)]
|
||||
pub use ide_host::ops::bytes::*;
|
||||
use ide_vfs::{ByteRange, FileProbe};
|
||||
use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
|
||||
/// Size and text/binary classification, from the file's head only.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fs_probe(path: PathBuf) -> IdeResult<FileProbe> {
|
||||
blocking(move || ide_vfs::probe(&path)).await
|
||||
pub async fn fs_probe(state: State<'_, AppState>, path: PathBuf) -> IdeResult<FileProbe> {
|
||||
ide_host::ops::bytes::fs_probe(&state.host, "", path).await
|
||||
}
|
||||
|
||||
/// Up to `len` bytes from `offset` (capped per call), base64-encoded.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fs_read_range(path: PathBuf, offset: u64, len: u32) -> IdeResult<ByteRange> {
|
||||
blocking(move || ide_vfs::read_range(&path, offset, len)).await
|
||||
pub async fn fs_read_range(state: State<'_, AppState>, path: PathBuf, offset: u64, len: u32) -> IdeResult<ByteRange> {
|
||||
ide_host::ops::bytes::fs_read_range(&state.host, "", path, offset, len).await
|
||||
}
|
||||
|
||||
/// An image file as a `data:` URL.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fs_read_image(path: PathBuf) -> IdeResult<String> {
|
||||
blocking(move || ide_vfs::read_data_url(&path)).await
|
||||
pub async fn fs_read_image(state: State<'_, AppState>, path: PathBuf) -> IdeResult<String> {
|
||||
ide_host::ops::bytes::fs_read_image(&state.host, "", path).await
|
||||
}
|
||||
@@ -1,18 +1,13 @@
|
||||
//! Run with Coverage: launch plans and coverage reports.
|
||||
//! IPC commands of `ide_host::ops::coverage` (the shared implementation).
|
||||
|
||||
use crate::state::AppState;
|
||||
use ide_core::{IdeError, IdeResult, IpcPath};
|
||||
use ide_core::{IdeResult, IpcPath};
|
||||
use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite};
|
||||
#[allow(unused_imports)]
|
||||
pub use ide_host::ops::coverage::*;
|
||||
use ide_run::RunConfig;
|
||||
use tauri::{State, Window};
|
||||
|
||||
/// Output directories kept per kind (older runs' data is deleted).
|
||||
pub(crate) const KEEP_RUNS: usize = 10;
|
||||
|
||||
pub(crate) async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tauri::async_runtime::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// How to run `config` with coverage: the command to launch and where its report will be,
|
||||
/// or which tool is missing.
|
||||
#[tauri::command]
|
||||
@@ -22,20 +17,14 @@ pub async fn coverage_prepare(
|
||||
state: State<'_, AppState>,
|
||||
config: RunConfig,
|
||||
) -> IdeResult<CoveragePrepared> {
|
||||
let project = state.project(window.label())?;
|
||||
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
|
||||
blocking(move || {
|
||||
let out = ide_run::wrap::new_output_dir(&cache, KEEP_RUNS)?;
|
||||
Ok(ide_coverage::prepare(&root, &config, &out))
|
||||
})
|
||||
.await
|
||||
ide_host::ops::coverage::coverage_prepare(&state.host, window.label(), config).await
|
||||
}
|
||||
|
||||
/// Reads the report a coverage run left behind.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn coverage_collect(report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
blocking(move || ide_coverage::collect(&report)).await
|
||||
pub async fn coverage_collect(state: State<'_, AppState>, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
|
||||
ide_host::ops::coverage::coverage_collect(&state.host, "", report).await
|
||||
}
|
||||
|
||||
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
|
||||
@@ -43,6 +32,5 @@ pub async fn coverage_collect(report: CoverageReportSpec) -> IdeResult<CoverageS
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn coverage_import(window: Window, state: State<'_, AppState>, path: IpcPath) -> IdeResult<CoverageSuite> {
|
||||
let root = state.project(window.label())?.root.clone();
|
||||
blocking(move || ide_coverage::import(&path, &root)).await
|
||||
ide_host::ops::coverage::coverage_import(&state.host, window.label(), path).await
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
//! IPC commands of `ide_host::ops::debug` (the shared implementation).
|
||||
|
||||
use crate::state::AppState;
|
||||
use ide_core::{DebugSessionId, IdeResult};
|
||||
use ide_dap::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use ide_host::ops::debug::*;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{State, Window};
|
||||
|
||||
@@ -12,18 +16,7 @@ pub async fn debug_start(
|
||||
state: State<'_, AppState>,
|
||||
config: LaunchConfig,
|
||||
) -> IdeResult<DebugSessionId> {
|
||||
let label = window.label();
|
||||
let owner = SessionOwner::new(AppState::owner_window(label), state.window_sink(label));
|
||||
let settings = match state.project(label) {
|
||||
Ok(project) => project.settings.effective(),
|
||||
Err(_) => state.global_settings.effective(),
|
||||
};
|
||||
let root = config.cwd.clone().map(PathBuf::from).unwrap_or_default();
|
||||
let custom = |command: &str| {
|
||||
let path = ide_sdk::apply::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<Breakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
state.debugger.set_breakpoints(path, breakpoints).await
|
||||
ide_host::ops::debug::debug_set_breakpoints(&state.host, "", path, breakpoints).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_threads(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<Vec<ThreadInfo>> {
|
||||
state.debugger.threads(session).await
|
||||
ide_host::ops::debug::debug_threads(&state.host, "", session).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -49,13 +42,13 @@ pub async fn debug_stack_trace(
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
) -> IdeResult<Vec<StackFrame>> {
|
||||
state.debugger.stack_trace(session, thread_id).await
|
||||
ide_host::ops::debug::debug_stack_trace(&state.host, "", session, thread_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_scopes(state: State<'_, AppState>, session: DebugSessionId, frame_id: i64) -> IdeResult<Vec<Scope>> {
|
||||
state.debugger.scopes(session, frame_id).await
|
||||
ide_host::ops::debug::debug_scopes(&state.host, "", session, frame_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -65,7 +58,7 @@ pub async fn debug_variables(
|
||||
session: DebugSessionId,
|
||||
reference: i64,
|
||||
) -> IdeResult<Vec<Variable>> {
|
||||
state.debugger.variables(session, reference).await
|
||||
ide_host::ops::debug::debug_variables(&state.host, "", session, reference).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -76,19 +69,19 @@ pub async fn debug_evaluate(
|
||||
frame_id: Option<i64>,
|
||||
expression: String,
|
||||
) -> IdeResult<Variable> {
|
||||
state.debugger.evaluate(session, frame_id, expression).await
|
||||
ide_host::ops::debug::debug_evaluate(&state.host, "", session, frame_id, expression).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_resume(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
state.debugger.resume(session, thread_id).await
|
||||
ide_host::ops::debug::debug_resume(&state.host, "", session, thread_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_pause(state: State<'_, AppState>, session: DebugSessionId, thread_id: i64) -> IdeResult<()> {
|
||||
state.debugger.pause(session, thread_id).await
|
||||
ide_host::ops::debug::debug_pause(&state.host, "", session, thread_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -99,7 +92,7 @@ pub async fn debug_step(
|
||||
thread_id: i64,
|
||||
kind: StepKind,
|
||||
) -> IdeResult<()> {
|
||||
state.debugger.step(session, thread_id, kind).await
|
||||
ide_host::ops::debug::debug_step(&state.host, "", session, thread_id, kind).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -112,13 +105,13 @@ pub async fn debug_run_to(
|
||||
line: u32,
|
||||
force: bool,
|
||||
) -> IdeResult<()> {
|
||||
state.debugger.run_to(session, thread_id, path, line, force).await
|
||||
ide_host::ops::debug::debug_run_to(&state.host, "", session, thread_id, path, line, force).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_stop(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<()> {
|
||||
state.debugger.stop(session).await
|
||||
ide_host::ops::debug::debug_stop(&state.host, "", session).await
|
||||
}
|
||||
|
||||
/// Evaluate with an explicit DAP context: `repl`, `watch`, `hover` or `clipboard`.
|
||||
@@ -131,27 +124,27 @@ pub async fn debug_evaluate_in(
|
||||
expression: String,
|
||||
context: Option<String>,
|
||||
) -> IdeResult<Variable> {
|
||||
state.debugger.evaluate_in(session, frame_id, expression, context).await
|
||||
ide_host::ops::debug::debug_evaluate_in(&state.host, "", session, frame_id, expression, context).await
|
||||
}
|
||||
|
||||
/// Last known verification states of a file's breakpoints (refetch on `breakpointsChanged`).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn debug_breakpoint_states(state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<BreakpointState>> {
|
||||
Ok(state.debugger.breakpoint_states(&path))
|
||||
ide_host::ops::debug::debug_breakpoint_states(&state.host, "", path)
|
||||
}
|
||||
|
||||
/// This window's live sessions, including adapter-started child sessions.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn debug_sessions(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<SessionInfo>> {
|
||||
Ok(state.debugger.sessions(Some(AppState::owner_window(window.label()))))
|
||||
ide_host::ops::debug::debug_sessions(&state.host, window.label())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn debug_exception_filters(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<SessionExceptionFilters>> {
|
||||
Ok(state.debugger.exception_filters(Some(AppState::owner_window(window.label()))))
|
||||
ide_host::ops::debug::debug_exception_filters(&state.host, window.label())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -160,7 +153,7 @@ pub async fn debug_set_exception_breakpoints(
|
||||
state: State<'_, AppState>,
|
||||
choices: std::collections::BTreeMap<String, bool>,
|
||||
) -> IdeResult<()> {
|
||||
state.debugger.set_exception_breakpoints(choices).await
|
||||
ide_host::ops::debug::debug_set_exception_breakpoints(&state.host, "", choices).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -174,7 +167,8 @@ pub async fn debug_set_variable(
|
||||
frame_id: Option<i64>,
|
||||
value: String,
|
||||
) -> IdeResult<Variable> {
|
||||
state.debugger.set_variable(session, container, name, evaluate_name, frame_id, value).await
|
||||
ide_host::ops::debug::debug_set_variable(&state.host, "", session, container, name, evaluate_name, frame_id, value)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Force Step Over: step over with every breakpoint suspended until the step ends.
|
||||
@@ -185,7 +179,7 @@ pub async fn debug_force_step_over(
|
||||
session: DebugSessionId,
|
||||
thread_id: i64,
|
||||
) -> IdeResult<()> {
|
||||
state.debugger.force_step_over(session, thread_id).await
|
||||
ide_host::ops::debug::debug_force_step_over(&state.host, "", session, thread_id).await
|
||||
}
|
||||
|
||||
/// Smart Step Into: the calls on the current line of `frame_id`.
|
||||
@@ -196,7 +190,7 @@ pub async fn debug_step_in_targets(
|
||||
session: DebugSessionId,
|
||||
frame_id: i64,
|
||||
) -> IdeResult<Vec<StepInTarget>> {
|
||||
state.debugger.step_in_targets(session, frame_id).await
|
||||
ide_host::ops::debug::debug_step_in_targets(&state.host, "", session, frame_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -207,20 +201,20 @@ pub async fn debug_step_in_target(
|
||||
thread_id: i64,
|
||||
target_id: i64,
|
||||
) -> IdeResult<()> {
|
||||
state.debugger.step_in_target(session, thread_id, target_id).await
|
||||
ide_host::ops::debug::debug_step_in_target(&state.host, "", session, thread_id, target_id).await
|
||||
}
|
||||
|
||||
/// Reset Frame (Drop Frame).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_restart_frame(state: State<'_, AppState>, session: DebugSessionId, frame_id: i64) -> IdeResult<()> {
|
||||
state.debugger.restart_frame(session, frame_id).await
|
||||
ide_host::ops::debug::debug_restart_frame(&state.host, "", session, frame_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn debug_capabilities(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<DebugCapabilities> {
|
||||
state.debugger.capabilities(session)
|
||||
ide_host::ops::debug::debug_capabilities(&state.host, "", session)
|
||||
}
|
||||
|
||||
/// Method breakpoints (all files); states aligned with `breakpoints`.
|
||||
@@ -230,7 +224,7 @@ pub async fn debug_set_function_breakpoints(
|
||||
state: State<'_, AppState>,
|
||||
breakpoints: Vec<FunctionBreakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
state.debugger.set_function_breakpoints(breakpoints).await
|
||||
ide_host::ops::debug::debug_set_function_breakpoints(&state.host, "", breakpoints).await
|
||||
}
|
||||
|
||||
/// Field watchpoints; states aligned with `breakpoints`.
|
||||
@@ -240,7 +234,7 @@ pub async fn debug_set_data_breakpoints(
|
||||
state: State<'_, AppState>,
|
||||
breakpoints: Vec<DataBreakpoint>,
|
||||
) -> IdeResult<Vec<BreakpointState>> {
|
||||
state.debugger.set_data_breakpoints(breakpoints).await
|
||||
ide_host::ops::debug::debug_set_data_breakpoints(&state.host, "", breakpoints).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -252,16 +246,14 @@ pub async fn debug_data_breakpoint_info(
|
||||
name: String,
|
||||
frame_id: Option<i64>,
|
||||
) -> IdeResult<DataBreakpointInfo> {
|
||||
state.debugger.data_breakpoint_info(session, container, name, frame_id).await
|
||||
ide_host::ops::debug::debug_data_breakpoint_info(&state.host, "", session, container, name, frame_id).await
|
||||
}
|
||||
|
||||
/// Attach to Process: the user's processes a debugger can attach to.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_attachable_processes() -> IdeResult<Vec<attach::AttachableProcess>> {
|
||||
tauri::async_runtime::spawn_blocking(attach::attachable_processes)
|
||||
.await
|
||||
.map_err(|e| ide_core::IdeError::internal(format!("listing processes failed: {e}")))?
|
||||
pub async fn debug_attachable_processes(state: State<'_, AppState>) -> IdeResult<Vec<attach::AttachableProcess>> {
|
||||
ide_host::ops::debug::debug_attachable_processes(&state.host, "").await
|
||||
}
|
||||
|
||||
/// Remote JVM Debug in listen mode: waits (cancellable through `request`) for the remote
|
||||
@@ -274,15 +266,12 @@ pub async fn debug_listen_for_jvm(
|
||||
host: String,
|
||||
port: u16,
|
||||
) -> IdeResult<u16> {
|
||||
let token = state.begin_request(request);
|
||||
let result = jdwp::listen_for_jvm(&host, port, token).await;
|
||||
state.end_request(request);
|
||||
result
|
||||
ide_host::ops::debug::debug_listen_for_jvm(&state.host, "", request, host, port).await
|
||||
}
|
||||
|
||||
/// HotSwap (Reload Changed Classes) in a java-debug session.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn debug_redefine_classes(state: State<'_, AppState>, session: DebugSessionId) -> IdeResult<HotSwapResult> {
|
||||
state.debugger.redefine_classes(session).await
|
||||
ide_host::ops::debug::debug_redefine_classes(&state.host, "", session).await
|
||||
}
|
||||
@@ -91,10 +91,7 @@ pub async fn deploy_compare(
|
||||
) -> IdeResult<Vec<SyncEntry>> {
|
||||
let project = state.project(window.label())?;
|
||||
let token = state.begin_request(request);
|
||||
let r = state
|
||||
.remote
|
||||
.compare(&project.settings.effective(), &project.root, server.as_deref(), &path.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<String>,
|
||||
) -> IdeResult<Option<DeployedPath>> {
|
||||
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]
|
||||
|
||||
@@ -1,46 +1,27 @@
|
||||
//! IPC commands of `ide_host::ops::doc` (the shared implementation).
|
||||
|
||||
use crate::state::AppState;
|
||||
use ide_core::{ChangeSet, DocId, IdeError, IdeResult, OutlineItem, Range};
|
||||
use ide_core::{ChangeSet, DocId, IdeResult, OutlineItem, Range};
|
||||
#[allow(unused_imports)]
|
||||
pub use ide_host::ops::doc::*;
|
||||
use ide_language::{FoldRange, SyntaxHighlights};
|
||||
use ide_text::LineEnding;
|
||||
use ide_workspace::{DocumentInfo, HistoryEntry};
|
||||
use std::path::PathBuf;
|
||||
use tauri::{State, Window};
|
||||
|
||||
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool,
|
||||
/// keeping it off the main thread and the async runtime's workers.
|
||||
pub(crate) async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
tauri::async_runtime::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
|
||||
/// Opens a document. Async: reading and decoding a large file must not block
|
||||
/// the main thread (synchronous commands run on it).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_open(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<DocumentInfo> {
|
||||
let project = state.project(window.label())?;
|
||||
blocking(move || {
|
||||
let info = project.documents.open(&path)?;
|
||||
project.index.mark_recent(&path);
|
||||
Ok(info)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Opens a read-only virtual document (see `lsp_virtual_document`) as an
|
||||
/// editor buffer, so syntax features and language-server requests work in it.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_open_virtual(window: Window, state: State<'_, AppState>, uri: String) -> IdeResult<DocumentInfo> {
|
||||
let project = state.project(window.label())?;
|
||||
let vd = project.lsp.virtual_document(&uri).await?;
|
||||
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
|
||||
ide_host::ops::doc::open(&state.host, window.label(), path).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> {
|
||||
state.project(window.label())?.documents.close(doc);
|
||||
Ok(())
|
||||
ide_host::ops::doc::close(&state.host, window.label(), doc)
|
||||
}
|
||||
|
||||
/// Applies a CodeMirror change set; returns the new version. Rejects with
|
||||
@@ -54,28 +35,32 @@ pub fn doc_edit(
|
||||
base_version: u32,
|
||||
changes: ChangeSet,
|
||||
) -> IdeResult<u32> {
|
||||
state.project(window.label())?.documents.edit(doc, base_version, changes)
|
||||
ide_host::ops::doc::edit(&state.host, window.label(), doc, base_version, changes)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_save(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
blocking(move || project.documents.save(doc)).await
|
||||
ide_host::ops::doc::save(&state.host, window.label(), doc).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_save_all(window: Window, state: State<'_, AppState>) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
blocking(move || project.documents.save_all()).await
|
||||
ide_host::ops::doc::save_all(&state.host, window.label()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_snapshot(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<DocumentInfo> {
|
||||
let project = state.project(window.label())?;
|
||||
blocking(move || project.documents.snapshot(doc)).await
|
||||
ide_host::ops::doc::snapshot(&state.host, window.label(), doc).await
|
||||
}
|
||||
/// Opens a read-only virtual document (see `lsp_virtual_document`) as an
|
||||
/// editor buffer, so syntax features and language-server requests work in it.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_open_virtual(window: Window, state: State<'_, AppState>, uri: String) -> IdeResult<DocumentInfo> {
|
||||
ide_host::ops::doc::doc_open_virtual(&state.host, window.label(), uri).await
|
||||
}
|
||||
|
||||
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
|
||||
@@ -88,7 +73,7 @@ pub fn doc_set_encoding(
|
||||
encoding: String,
|
||||
reload: bool,
|
||||
) -> IdeResult<String> {
|
||||
state.project(window.label())?.documents.set_encoding(doc, &encoding, reload)
|
||||
ide_host::ops::doc::doc_set_encoding(&state.host, window.label(), doc, encoding, reload)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -99,7 +84,7 @@ pub fn doc_set_line_ending(
|
||||
doc: DocId,
|
||||
line_ending: LineEnding,
|
||||
) -> IdeResult<()> {
|
||||
state.project(window.label())?.documents.set_line_ending(doc, line_ending)
|
||||
ide_host::ops::doc::doc_set_line_ending(&state.host, window.label(), doc, line_ending)
|
||||
}
|
||||
|
||||
/// Structure view: LSP document symbols when available (with tree-sitter
|
||||
@@ -109,41 +94,13 @@ pub fn doc_set_line_ending(
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_outline(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
|
||||
let project = state.project(window.label())?;
|
||||
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
|
||||
blocking(move || {
|
||||
let snap = match project.documents.text_snapshot(doc) {
|
||||
Ok(snap) => snap,
|
||||
Err(e) => return lsp.ok_or(e),
|
||||
};
|
||||
let syntax = || project.syntax.outline(doc, &snap.language, snap.version, &snap.text);
|
||||
Ok(match lsp {
|
||||
// Server symbols carry no visibility; borrow it from the syntax tree.
|
||||
Some(mut items) => {
|
||||
if !snap.large {
|
||||
ide_language::merge_visibility(&mut items, &syntax());
|
||||
}
|
||||
items
|
||||
}
|
||||
None if snap.large => Vec::new(),
|
||||
None => syntax(),
|
||||
})
|
||||
})
|
||||
.await
|
||||
ide_host::ops::doc::doc_outline(&state.host, window.label(), doc).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_folding(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<Vec<FoldRange>> {
|
||||
let project = state.project(window.label())?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
|
||||
})
|
||||
.await
|
||||
ide_host::ops::doc::doc_folding(&state.host, window.label(), doc).await
|
||||
}
|
||||
|
||||
/// Tree-sitter highlighting for languages the UI has no CodeMirror mode for;
|
||||
@@ -151,15 +108,7 @@ pub async fn doc_folding(window: Window, state: State<'_, AppState>, doc: DocId)
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn doc_highlights(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<SyntaxHighlights> {
|
||||
let project = state.project(window.label())?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
if snap.large {
|
||||
return Ok(SyntaxHighlights::default());
|
||||
}
|
||||
Ok(project.syntax.highlights(doc, &snap.language, snap.version, &snap.text))
|
||||
})
|
||||
.await
|
||||
ide_host::ops::doc::doc_highlights(&state.host, window.label(), doc).await
|
||||
}
|
||||
|
||||
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
|
||||
@@ -172,23 +121,17 @@ pub async fn doc_selection_ranges(
|
||||
doc: DocId,
|
||||
offset: u32,
|
||||
) -> IdeResult<Vec<Range>> {
|
||||
let project = state.project(window.label())?;
|
||||
blocking(move || {
|
||||
let snap = project.documents.text_snapshot(doc)?;
|
||||
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
|
||||
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
|
||||
})
|
||||
.await
|
||||
ide_host::ops::doc::doc_selection_ranges(&state.host, window.label(), doc, offset).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn history_list(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
|
||||
state.project(window.label())?.history.list(&path)
|
||||
ide_host::ops::doc::history_list(&state.host, window.label(), path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn history_content(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<String> {
|
||||
state.project(window.label())?.history.content(&id)
|
||||
ide_host::ops::doc::history_content(&state.host, window.label(), id)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::commands::doc::blocking;
|
||||
use crate::state::AppState;
|
||||
use ide_core::IdeResult;
|
||||
use ide_host::ops::fs;
|
||||
use ide_vfs::FileEntry;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{State, Window};
|
||||
@@ -8,23 +8,7 @@ use tauri::{State, Window};
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
|
||||
let excludes = state
|
||||
.project(window.label())
|
||||
.map(|p| {
|
||||
let globs = p.exclude_globs();
|
||||
// `read_dir` matches relative to the enclosing repository: re-anchor
|
||||
// project-root patterns (`/build`) when the project is a repo subfolder.
|
||||
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
|
||||
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
|
||||
Some(rel) => {
|
||||
let rel = rel.to_string_lossy().replace('\\', "/");
|
||||
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
|
||||
}
|
||||
None => globs,
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
ide_vfs::read_dir(&path, &excludes)
|
||||
fs::read_dir(&state.host, window.label(), path)
|
||||
}
|
||||
|
||||
/// Creates an empty file; inside a project, honours the EditorConfig
|
||||
@@ -32,24 +16,7 @@ pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) ->
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn fs_create_file(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
|
||||
match state.project(window.label()) {
|
||||
Ok(p) => {
|
||||
let defaults = p.documents.file_defaults();
|
||||
if defaults.editorconfig {
|
||||
ide_workspace::editorconfig::create_file(p.documents.editorconfig(), &path)?;
|
||||
} else {
|
||||
ide_vfs::create_file(&path)?;
|
||||
}
|
||||
// No EditorConfig charset: File Encodings › "Create UTF-8 files with BOM".
|
||||
let empty = std::fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(false);
|
||||
let bom = defaults.new_file_bytes();
|
||||
if empty && !bom.is_empty() {
|
||||
std::fs::write(&path, bom)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(_) => ide_vfs::create_file(&path),
|
||||
}
|
||||
fs::create_file(&state.host, window.label(), path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -69,34 +36,20 @@ pub fn fs_rename(from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn fs_set_writable(window: Window, state: State<'_, AppState>, path: PathBuf, writable: bool) -> IdeResult<bool> {
|
||||
match state.project(window.label()) {
|
||||
Ok(p) => p.documents.set_writable(&path, writable),
|
||||
Err(_) => ide_vfs::set_writable(&path, writable).map(|()| !writable),
|
||||
}
|
||||
fs::set_writable(&state.host, window.label(), path, writable)
|
||||
}
|
||||
|
||||
/// Moving to the trash can take a while (large trees): kept off the main thread.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fs_delete(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
|
||||
// Local History keeps the contents so deleted files can be restored.
|
||||
let history = state.project(window.label()).ok().map(|p| p.history.clone());
|
||||
blocking(move || {
|
||||
if let Some(h) = history {
|
||||
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
|
||||
}
|
||||
ide_vfs::delete(&path)
|
||||
})
|
||||
.await
|
||||
fs::delete(&state.host, window.label(), path).await
|
||||
}
|
||||
|
||||
/// Files of a deleted folder snapshotted in Local History.
|
||||
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fs_copy(from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
blocking(move || ide_vfs::copy(&from, &to)).await
|
||||
pub async fn fs_copy(state: State<'_, AppState>, from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
fs::copy(&state.host, from, to).await
|
||||
}
|
||||
|
||||
/// Reads a text file without opening it as a document (previews, quick
|
||||
@@ -104,10 +57,5 @@ pub async fn fs_copy(from: PathBuf, to: PathBuf) -> IdeResult<()> {
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn fs_read_text(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<String> {
|
||||
let project = state.project(window.label()).ok();
|
||||
blocking(move || match project {
|
||||
Some(p) => p.text_of(&path),
|
||||
None => Ok(ide_vfs::load_text(&path)?.text),
|
||||
})
|
||||
.await
|
||||
fs::read_text(&state.host, window.label(), path).await
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
//! Local History beyond single files: folders, labels, restoring deleted files.
|
||||
//! IPC commands of `ide_host::ops::history` (the shared implementation).
|
||||
|
||||
use crate::state::AppState;
|
||||
use ide_core::IdeResult;
|
||||
#[allow(unused_imports)]
|
||||
pub use ide_host::ops::history::*;
|
||||
use ide_workspace::DirectoryHistory;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{State, Window};
|
||||
|
||||
use super::doc::blocking;
|
||||
|
||||
/// Show History on a folder: snapshots of the files under it (deleted ones included) and labels.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
@@ -16,16 +16,14 @@ pub async fn history_list_dir(
|
||||
state: State<'_, AppState>,
|
||||
path: PathBuf,
|
||||
) -> IdeResult<DirectoryHistory> {
|
||||
let project = state.project(window.label())?;
|
||||
blocking(move || project.history.list_dir(&path)).await
|
||||
ide_host::ops::history::history_list_dir(&state.host, window.label(), path).await
|
||||
}
|
||||
|
||||
/// Local History › Put Label…: a named point in the history of every file.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn history_put_label(window: Window, state: State<'_, AppState>, label: String) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
project.history.put_label(&project.root, &label)
|
||||
ide_host::ops::history::history_put_label(&state.host, window.label(), label)
|
||||
}
|
||||
|
||||
/// Revert to a snapshot: writes its content to `path` (re-creating a deleted
|
||||
@@ -33,23 +31,5 @@ pub fn history_put_label(window: Window, state: State<'_, AppState>, label: Stri
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn history_restore(window: Window, state: State<'_, AppState>, path: PathBuf, id: String) -> IdeResult<()> {
|
||||
let project = state.project(window.label())?;
|
||||
blocking(move || {
|
||||
let text = project.history.content(&id)?;
|
||||
if let Some(doc) = project.documents.by_path(&path) {
|
||||
project.documents.replace_all(doc, &text)?;
|
||||
return project.documents.save(doc);
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
if !path.exists() {
|
||||
ide_vfs::create_file(&path)?;
|
||||
}
|
||||
let doc = project.documents.open(&path)?.id;
|
||||
let result = project.documents.replace_all(doc, &text).and_then(|_| project.documents.save(doc));
|
||||
project.documents.close(doc);
|
||||
result
|
||||
})
|
||||
.await
|
||||
ide_host::ops::history::history_restore(&state.host, window.label(), path, id).await
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Java launch support: classpath / JDK resolution and the java-debug server (jdtls).
|
||||
//! IPC commands of `ide_host::ops::java` (the shared implementation).
|
||||
|
||||
use super::build::load;
|
||||
use crate::state::AppState;
|
||||
use ide_build::compile;
|
||||
use ide_core::{CancellationToken, IdeError, IdeResult};
|
||||
use ide_core::IdeResult;
|
||||
#[allow(unused_imports)]
|
||||
pub use ide_host::ops::java::*;
|
||||
use ide_lsp::jdtls::JavaLaunchInfo;
|
||||
use std::path::PathBuf;
|
||||
use tauri::{State, Window};
|
||||
@@ -20,63 +20,14 @@ pub async fn java_resolve_launch(
|
||||
test: bool,
|
||||
build: bool,
|
||||
) -> IdeResult<JavaLaunchInfo> {
|
||||
let project = state.project(window.label())?;
|
||||
let module = project.root.join(module);
|
||||
let settings = project.settings.effective();
|
||||
let built = if build && compile::has_non_java_sources(&module) {
|
||||
let (root, cache, module, settings) =
|
||||
(project.root.clone(), project.build_cache_dir(), module.clone(), settings.clone());
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
compile::build_for_launch(&root, &load(&root, &cache, &settings), &module, test, &CancellationToken::new())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mut info = project.lsp.java_launch(&module, test, build).await?;
|
||||
let mut class_paths: Vec<String> = built.iter().map(|p| p.to_string_lossy().into_owned()).collect();
|
||||
for p in std::mem::take(&mut info.class_paths) {
|
||||
if !class_paths.contains(&p) {
|
||||
class_paths.push(p);
|
||||
}
|
||||
}
|
||||
info.class_paths = class_paths;
|
||||
// The project JDK, else the JDK jdtls compiles the module against, else one matching its Java version.
|
||||
let project_jdk = ide_sdk::apply::project_jdk(&project.root, &settings);
|
||||
let from_jdtls = info.java_home.as_deref().map(ide_sdk::java::java_executable).filter(|p| p.is_file());
|
||||
let java = project_jdk.map(|j| ide_sdk::java::java_executable(&j.home)).or(from_jdtls).or_else(|| {
|
||||
let release = ide_sdk::java::project_java_version(&module);
|
||||
let jdks = ide_sdk::java::installed_jdks();
|
||||
info.hints.extend(ide_sdk::java::missing_jdk_message(&jdks, release));
|
||||
ide_sdk::java::pick_jdk(&jdks, release).map(|j| ide_sdk::java::java_executable(&j.home))
|
||||
});
|
||||
info.java_exec = Some(java.map(|p| p.to_string_lossy().into_owned()).unwrap_or_else(|| "java".to_owned()));
|
||||
let exec = PathBuf::from(info.java_exec.as_deref().unwrap_or("java"));
|
||||
let shorten = ide_run::java_cmdline::shortening_for(&exec, &info.class_paths, &info.module_paths, cfg!(windows));
|
||||
info.shorten_command_line = Some(shorten.as_str().to_owned());
|
||||
if info.error_count > 0 {
|
||||
let own = ide_sdk::lombok::agent_in_args(settings.languages.java.jdtls_vm_args.split_whitespace());
|
||||
let managed = ide_tools::managed_bundle(&state.data_dir, "lombok");
|
||||
let repo = ide_sdk::apply::maven_local_repository(&project.root, &settings);
|
||||
let root = project.root.clone();
|
||||
// Detection may read the project's sources.
|
||||
let lombok =
|
||||
tauri::async_runtime::spawn_blocking(move || ide_sdk::lombok::lombok_agent(&root, own, managed, repo))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?;
|
||||
info.hints.extend(lombok.problem());
|
||||
}
|
||||
Ok(info)
|
||||
ide_host::ops::java::java_resolve_launch(&state.host, window.label(), module, test, build).await
|
||||
}
|
||||
|
||||
/// Port of java-debug's DAP server inside the jdtls serving `module` (started on demand).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn java_debug_server(window: Window, state: State<'_, AppState>, module: PathBuf) -> IdeResult<u16> {
|
||||
let project = state.project(window.label())?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_debug_server(&module).await
|
||||
ide_host::ops::java::java_debug_server(&state.host, window.label(), module).await
|
||||
}
|
||||
|
||||
/// Incremental build of the jdtls workspace serving `module` (e.g. before HotSwap).
|
||||
@@ -87,7 +38,5 @@ pub async fn java_build_workspace(
|
||||
state: State<'_, AppState>,
|
||||
module: PathBuf,
|
||||
) -> IdeResult<ide_lsp::jdtls::JavaBuildStatus> {
|
||||
let project = state.project(window.label())?;
|
||||
let module = project.root.join(module);
|
||||
project.lsp.java_build(&module).await
|
||||
ide_host::ops::java::java_build_workspace(&state.host, window.label(), module).await
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
use crate::state::{AppState, apply_workspace_edit as apply_edit};
|
||||
//! IPC commands of `ide_host::ops::lsp` (the shared implementation).
|
||||
|
||||
use crate::state::AppState;
|
||||
use ide_core::{DocId, IdeResult, Location, Pos, Range, RequestId, ServerId, TextEdit, WorkspaceEdit};
|
||||
#[allow(unused_imports)]
|
||||
pub use ide_host::ops::lsp::*;
|
||||
use ide_lsp::types::*;
|
||||
use tauri::{State, Window};
|
||||
|
||||
macro_rules! project {
|
||||
($window:expr, $state:expr) => {
|
||||
$state.project($window.label())?
|
||||
};
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lsp_completion(
|
||||
@@ -19,11 +17,7 @@ pub async fn lsp_completion(
|
||||
pos: Pos,
|
||||
trigger: Option<String>,
|
||||
) -> IdeResult<CompletionList> {
|
||||
let lsp = project!(window, state).lsp.clone();
|
||||
let cancel = state.begin_request(request);
|
||||
let result = lsp.completion(doc, pos, trigger, cancel).await;
|
||||
state.end_request(request);
|
||||
result
|
||||
ide_host::ops::lsp::lsp_completion(&state.host, window.label(), request, doc, pos, trigger).await
|
||||
}
|
||||
|
||||
/// Inline (ghost text) suggestions at `pos`; `automatic` when triggered by typing.
|
||||
@@ -37,11 +31,7 @@ pub async fn lsp_inline_completion(
|
||||
pos: Pos,
|
||||
automatic: bool,
|
||||
) -> IdeResult<Vec<InlineCompletionItem>> {
|
||||
let lsp = project!(window, state).lsp.clone();
|
||||
let cancel = state.begin_request(request);
|
||||
let result = lsp.inline_completion(doc, pos, automatic, cancel).await;
|
||||
state.end_request(request);
|
||||
result
|
||||
ide_host::ops::lsp::lsp_inline_completion(&state.host, window.label(), request, doc, pos, automatic).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -52,7 +42,7 @@ pub async fn lsp_resolve_completion(
|
||||
doc: DocId,
|
||||
item: CompletionItem,
|
||||
) -> IdeResult<CompletionItem> {
|
||||
project!(window, state).lsp.resolve_completion(doc, item).await
|
||||
ide_host::ops::lsp::lsp_resolve_completion(&state.host, window.label(), doc, item).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -64,11 +54,7 @@ pub async fn lsp_hover(
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<Hover>> {
|
||||
let lsp = project!(window, state).lsp.clone();
|
||||
let cancel = state.begin_request(request);
|
||||
let result = lsp.hover(doc, pos, cancel).await;
|
||||
state.end_request(request);
|
||||
result
|
||||
ide_host::ops::lsp::lsp_hover(&state.host, window.label(), request, doc, pos).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -79,7 +65,7 @@ pub async fn lsp_signature_help(
|
||||
doc: DocId,
|
||||
pos: Pos,
|
||||
) -> IdeResult<Option<SignatureHelp>> {
|
||||
project!(window, state).lsp.signature_help(doc, pos).await
|
||||
ide_host::ops::lsp::lsp_signature_help(&state.host, window.label(), doc, pos).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -91,7 +77,7 @@ pub async fn lsp_goto(
|
||||
pos: Pos,
|
||||
kind: GotoKind,
|
||||
) -> IdeResult<Vec<Location>> {
|
||||
project!(window, state).lsp.goto(doc, pos, kind).await
|
||||
ide_host::ops::lsp::lsp_goto(&state.host, window.label(), doc, pos, kind).await
|
||||
}
|
||||
|
||||
/// 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<VirtualDocument> {
|
||||
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<Vec<Location>> {
|
||||
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<Vec<Highlight>> {
|
||||
project!(window, state).lsp.document_highlights(doc, pos).await
|
||||
ide_host::ops::lsp::lsp_document_highlights(&state.host, window.label(), doc, pos).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -137,11 +123,7 @@ pub async fn lsp_workspace_symbols(
|
||||
request: RequestId,
|
||||
query: String,
|
||||
) -> IdeResult<Vec<WorkspaceSymbol>> {
|
||||
let lsp = project!(window, state).lsp.clone();
|
||||
let cancel = state.begin_request(request);
|
||||
let result = lsp.workspace_symbols(&query, cancel).await;
|
||||
state.end_request(request);
|
||||
result
|
||||
ide_host::ops::lsp::lsp_workspace_symbols(&state.host, window.label(), request, query).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -153,7 +135,7 @@ pub async fn lsp_code_actions(
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<Vec<CodeAction>> {
|
||||
project!(window, state).lsp.code_actions(doc, range, only).await
|
||||
ide_host::ops::lsp::lsp_code_actions(&state.host, window.label(), doc, range, only).await
|
||||
}
|
||||
|
||||
/// Cancellable code actions with the kinds the servers advertise (Refactor This, refactoring availability).
|
||||
@@ -167,11 +149,7 @@ pub async fn lsp_code_action_query(
|
||||
range: Range,
|
||||
only: Vec<String>,
|
||||
) -> IdeResult<CodeActionQuery> {
|
||||
let lsp = project!(window, state).lsp.clone();
|
||||
let cancel = state.begin_request(request);
|
||||
let result = lsp.code_action_query(doc, range, only, cancel).await;
|
||||
state.end_request(request);
|
||||
result
|
||||
ide_host::ops::lsp::lsp_code_action_query(&state.host, window.label(), request, doc, range, only).await
|
||||
}
|
||||
|
||||
/// Resolves a code action's edit without applying it (intention preview).
|
||||
@@ -183,10 +161,7 @@ pub async fn lsp_resolve_code_action(
|
||||
doc: DocId,
|
||||
action: CodeAction,
|
||||
) -> IdeResult<CodeAction> {
|
||||
if action.edit.is_some() {
|
||||
return Ok(action);
|
||||
}
|
||||
project!(window, state).lsp.resolve_code_action(doc, action).await
|
||||
ide_host::ops::lsp::lsp_resolve_code_action(&state.host, window.label(), doc, action).await
|
||||
}
|
||||
|
||||
/// Resolves (if needed), applies the edit, then runs the command of a code action.
|
||||
@@ -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<Option<Range>> {
|
||||
project!(window, state).lsp.prepare_rename(doc, pos).await
|
||||
ide_host::ops::lsp::lsp_prepare_rename(&state.host, window.label(), doc, pos).await
|
||||
}
|
||||
|
||||
/// Computes the rename edit without applying it, so the UI can preview conflicts.
|
||||
@@ -234,7 +197,7 @@ pub async fn lsp_rename(
|
||||
pos: Pos,
|
||||
new_name: String,
|
||||
) -> IdeResult<WorkspaceEdit> {
|
||||
project!(window, state).lsp.rename(doc, pos, new_name).await
|
||||
ide_host::ops::lsp::lsp_rename(&state.host, window.label(), doc, pos, new_name).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -246,7 +209,7 @@ pub async fn lsp_format(
|
||||
range: Option<Range>,
|
||||
options: FormatOptions,
|
||||
) -> IdeResult<Vec<TextEdit>> {
|
||||
project!(window, state).lsp.format(doc, range, options).await
|
||||
ide_host::ops::lsp::lsp_format(&state.host, window.label(), doc, range, options).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -257,7 +220,7 @@ pub async fn lsp_inlay_hints(
|
||||
doc: DocId,
|
||||
range: Range,
|
||||
) -> IdeResult<Vec<InlayHint>> {
|
||||
project!(window, state).lsp.inlay_hints(doc, range).await
|
||||
ide_host::ops::lsp::lsp_inlay_hints(&state.host, window.label(), doc, range).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -267,13 +230,13 @@ pub async fn lsp_semantic_tokens(
|
||||
state: State<'_, AppState>,
|
||||
doc: DocId,
|
||||
) -> IdeResult<Option<SemanticTokens>> {
|
||||
project!(window, state).lsp.semantic_tokens(doc).await
|
||||
ide_host::ops::lsp::lsp_semantic_tokens(&state.host, window.label(), doc).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lsp_code_lens(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<Vec<CodeLens>> {
|
||||
project!(window, state).lsp.code_lens(doc).await
|
||||
ide_host::ops::lsp::lsp_code_lens(&state.host, window.label(), doc).await
|
||||
}
|
||||
|
||||
/// Links in a document (`href`s, Markdown links, `#include`s…) from its language servers.
|
||||
@@ -284,7 +247,7 @@ pub async fn lsp_document_links(
|
||||
state: State<'_, AppState>,
|
||||
doc: DocId,
|
||||
) -> IdeResult<Vec<DocumentLink>> {
|
||||
project!(window, state).lsp.document_links(doc).await
|
||||
ide_host::ops::lsp::lsp_document_links(&state.host, window.label(), doc).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -296,7 +259,7 @@ pub async fn lsp_call_hierarchy(
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
project!(window, state).lsp.call_hierarchy(doc, pos, direction).await
|
||||
ide_host::ops::lsp::lsp_call_hierarchy(&state.host, window.label(), doc, pos, direction).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -308,39 +271,26 @@ pub async fn lsp_type_hierarchy(
|
||||
pos: Pos,
|
||||
direction: HierarchyDirection,
|
||||
) -> IdeResult<Vec<HierarchyItem>> {
|
||||
project!(window, state).lsp.type_hierarchy(doc, pos, direction).await
|
||||
ide_host::ops::lsp::lsp_type_hierarchy(&state.host, window.label(), doc, pos, direction).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn lsp_servers(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<ServerInfo>> {
|
||||
Ok(project!(window, state).lsp.servers())
|
||||
ide_host::ops::lsp::lsp_servers(&state.host, window.label())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn lsp_restart(window: Window, state: State<'_, AppState>, server: ServerId) -> IdeResult<()> {
|
||||
project!(window, state).lsp.restart(server).await
|
||||
ide_host::ops::lsp::lsp_restart(&state.host, window.label(), server).await
|
||||
}
|
||||
|
||||
/// Applies a (previewed) workspace edit, e.g. after rename confirmation.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn apply_workspace_edit(window: Window, state: State<'_, AppState>, edit: WorkspaceEdit) -> IdeResult<()> {
|
||||
let project = project!(window, state);
|
||||
// Multi-file refactorings leave a Local History point to go back to.
|
||||
let paths: Vec<&std::path::Path> = edit
|
||||
.files
|
||||
.iter()
|
||||
.filter_map(|f| match f {
|
||||
ide_core::FileEdit::Edit { path, .. } => Some(&**path),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
if edit.files.len() > 1 {
|
||||
project.record_before_refactoring(&paths, "Apply changes");
|
||||
}
|
||||
apply_edit(&project.documents, &edit)
|
||||
ide_host::ops::lsp::apply_workspace_edit(&state.host, window.label(), edit)
|
||||
}
|
||||
|
||||
/// Runs a server command (`workspace/executeCommand`), e.g. a completion item's
|
||||
@@ -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
|
||||
}
|
||||
@@ -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<T: tauri::ipc::IpcResponse + Send + Sync + 'static>(
|
||||
channel: tauri::ipc::Channel<T>,
|
||||
) -> ide_host::ops::Channel<T> {
|
||||
ide_host::ops::Channel::new(move |v| {
|
||||
let _ = channel.send(v);
|
||||
})
|
||||
}
|
||||
|
||||
pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
tauri_specta::Builder::<tauri::Wry>::new().commands(collect_commands![
|
||||
app::app_info,
|
||||
@@ -379,6 +388,10 @@ pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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<IndexStatus> {
|
||||
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<Vec<FileMatch>> {
|
||||
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<Vec<SymbolMatch>> {
|
||||
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<Vec<ide_core::IpcPath>> {
|
||||
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<Location>,
|
||||
}
|
||||
|
||||
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<Option<WebGoto>> {
|
||||
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<Location> = syntax
|
||||
.web_definitions(&snap.language, &snap.text)
|
||||
.into_iter()
|
||||
.filter(|t| t.name == name && t.kind == kind)
|
||||
.map(|t| here(t.range))
|
||||
.collect();
|
||||
targets.extend(
|
||||
project
|
||||
.index
|
||||
.symbols_named(&name, Some(kind), MAX_WEB_TARGETS)
|
||||
.into_iter()
|
||||
.filter(|m| *m.path != *snap.path)
|
||||
.map(|m| Location { path: m.path, range: m.range, uri: None }),
|
||||
);
|
||||
targets
|
||||
}
|
||||
};
|
||||
Ok(Some(WebGoto { reference, targets }))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The project root and its resource roots (served at `/`), for file references.
|
||||
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
|
||||
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
|
||||
(project.root.clone(), resources)
|
||||
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<Option<IpcPath>> {
|
||||
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<Vec<PathCompletion>> {
|
||||
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<Vec<FileReference>> {
|
||||
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
|
||||
}
|
||||
@@ -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<FileMove>,
|
||||
) -> IdeResult<FileRenameEdits> {
|
||||
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<u32> {
|
||||
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<UndoOutcome> {
|
||||
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<bool> {
|
||||
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<FileMove>,
|
||||
) -> IdeResult<WorkspaceEdit> {
|
||||
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
|
||||
}
|
||||
@@ -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<Vec<RunConfig>> {
|
||||
let project = state.project(window.label())?;
|
||||
let root = project.root.clone();
|
||||
let user = ide_run::load_user_configs(&root)?;
|
||||
let mut all: Vec<RunConfig> =
|
||||
ide_run::detect(&root).into_iter().filter(|d| !user.iter().any(|u| u.id == d.id)).collect();
|
||||
all.extend(user);
|
||||
// Project interpreter / package manager / toolchains from Languages & Frameworks.
|
||||
ide_sdk::apply::apply_to_run_configs(&mut all, &root, &project.settings.effective());
|
||||
Ok(all)
|
||||
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<RunConfig>) -> 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<Vec<RunConfig>> {
|
||||
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<RunConfig>) -> 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<String, String> {
|
||||
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<OutputChunk>,
|
||||
) -> IdeResult<RunInfo> {
|
||||
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<RunInfo> {
|
||||
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<Vec<RunConfig>> {
|
||||
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<RunConfig>) -> 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<String, String> {
|
||||
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<std::path::PathBuf>,
|
||||
since_ms: f64,
|
||||
) -> IdeResult<Vec<ide_run::junit::TestCaseReport>> {
|
||||
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
|
||||
}
|
||||
@@ -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<Vec<Sdk>> {
|
||||
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<Option<ide_core::IpcPath>> {
|
||||
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<Vec<ide_sdk::libraries::LibraryGroup>> {
|
||||
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_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
|
||||
.into_iter()
|
||||
.map(|c| ide_sdk::libraries::BuildDependency {
|
||||
gradle: c.gradle,
|
||||
group: c.group_id,
|
||||
artifact: c.artifact_id,
|
||||
version: c.version,
|
||||
})
|
||||
.collect();
|
||||
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
|
||||
})
|
||||
.await
|
||||
.map_err(join_err)
|
||||
}
|
||||
|
||||
/// Executables found for "run this file", and where single files are compiled to.
|
||||
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LocatedTools {
|
||||
pub tools: Vec<ide_sdk::locate::LocatedTool>,
|
||||
pub compile_dir: ide_core::IpcPath,
|
||||
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<String>, near: Option<PathBuf>) -> IdeResult<LocatedTools> {
|
||||
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<String>,
|
||||
near: Option<PathBuf>,
|
||||
) -> IdeResult<LocatedTools> {
|
||||
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<String>,
|
||||
) -> IdeResult<Sdk> {
|
||||
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<Vec<PythonPackage>> {
|
||||
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<String>,
|
||||
) -> 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?
|
||||
}
|
||||
@@ -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<SearchEvent>,
|
||||
) -> 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<ReplaceTarget>,
|
||||
on_event: Channel<ReplaceEvent>,
|
||||
) -> 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<Vec<Pos>>,
|
||||
) -> IdeResult<ReplacePreview> {
|
||||
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<Vec<TextEdit>>) -> IdeResult<u32> {
|
||||
let docs = self.0;
|
||||
let (doc, transient) = match docs.by_path(path) {
|
||||
Some(doc) => (doc, false),
|
||||
None => (docs.open(path)?.id, true),
|
||||
};
|
||||
let result = (|| {
|
||||
// Recompute if the user typed between reading the text and applying.
|
||||
for _ in 0..3 {
|
||||
let snap = docs.text_snapshot(doc)?;
|
||||
let edits = compute(&snap.text)?;
|
||||
if edits.is_empty() {
|
||||
return Ok(0);
|
||||
}
|
||||
if docs.text_snapshot(doc)?.version != snap.version {
|
||||
continue;
|
||||
}
|
||||
docs.apply_edits(doc, &edits)?;
|
||||
if transient {
|
||||
docs.save(doc)?;
|
||||
}
|
||||
return Ok(edits.len() as u32);
|
||||
}
|
||||
Err(ide_core::IdeError::unavailable("the file kept changing during Replace"))
|
||||
})();
|
||||
if transient {
|
||||
docs.close(doc);
|
||||
}
|
||||
result
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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<PathBuf> {
|
||||
settings
|
||||
.custom_dictionaries
|
||||
.iter()
|
||||
.map(|p| p.trim())
|
||||
.filter(|p| !p.is_empty())
|
||||
.map(|p| match p.strip_prefix("~/") {
|
||||
Some(rest) => dirs::home_dir().unwrap_or_default().join(rest),
|
||||
None => root.join(p),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn speller(project: &Project) -> Speller {
|
||||
let s = project.settings.effective().spelling;
|
||||
let words = s.ide_words.iter().chain(&s.project_words);
|
||||
Speller::new(words, &dictionary_paths(&s, &project.root))
|
||||
}
|
||||
|
||||
/// Misspelt words in the comments, string literals and declared names of
|
||||
/// `doc` that `scope` includes (all of its text for prose). Cancel with
|
||||
/// `cancel_request(request)`.
|
||||
@@ -44,50 +19,12 @@ pub async fn spell_check(
|
||||
scope: SpellScope,
|
||||
request: RequestId,
|
||||
) -> IdeResult<Vec<Typo>> {
|
||||
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<Vec<String>> {
|
||||
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
|
||||
}
|
||||
@@ -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<String>) -> IdeResult<String> {
|
||||
pub async fn ssh_test(
|
||||
state: State<'_, AppState>,
|
||||
config: SshConfiguration,
|
||||
secret: Option<String>,
|
||||
) -> IdeResult<String> {
|
||||
let ssh = state.ssh.clone();
|
||||
ssh.test(&config, secret).await
|
||||
}
|
||||
|
||||
@@ -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<T: Send + 'static>(
|
||||
state: &AppState,
|
||||
window: &Window,
|
||||
doc: DocId,
|
||||
request: Option<RequestId>,
|
||||
f: impl FnOnce(&StructureService, &Snapshot, &CancellationToken) -> StructureResult<T> + Send + 'static,
|
||||
) -> IdeResult<T> {
|
||||
let project: Arc<Project> = 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<StructureSupport> {
|
||||
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<RequestId>,
|
||||
) -> IdeResult<Option<NodeAt>> {
|
||||
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<RequestId>,
|
||||
) -> IdeResult<ExpressionAt> {
|
||||
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<RequestId>,
|
||||
) -> IdeResult<SyntaxErrors> {
|
||||
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<RequestId>,
|
||||
) -> IdeResult<Locals> {
|
||||
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<RequestId>,
|
||||
) -> IdeResult<Vec<IdentifierOccurrence>> {
|
||||
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<Vec<FileIdentifiers>> {
|
||||
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<RequestId>,
|
||||
) -> IdeResult<StructuralRanges> {
|
||||
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
|
||||
}
|
||||
@@ -12,22 +12,14 @@ pub fn term_spawn(
|
||||
options: TerminalOptions,
|
||||
on_output: Channel<String>,
|
||||
) -> IdeResult<TerminalInfo> {
|
||||
// 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]
|
||||
|
||||
@@ -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<Tools> {
|
||||
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<Vec<ToolInfo>> {
|
||||
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<Vec<ToolInfo>> {
|
||||
ide_host::ops::tools::tools_list(&state.host, window.label()).await
|
||||
}
|
||||
|
||||
/// Installs (or reinstalls) a tool into `<app-data>/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<InstallEvent>,
|
||||
) -> IdeResult<ToolInfo> {
|
||||
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<ToolInfo> {
|
||||
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<ToolInfo> {
|
||||
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<JdkVendor> {
|
||||
ide_tools::jdk::vendors()
|
||||
pub fn jdk_download_vendors(state: State<'_, AppState>) -> Vec<JdkVendor> {
|
||||
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<JdkReleases> {
|
||||
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<ide_core::IpcPath> {
|
||||
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_core::IpcPath> {
|
||||
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<ide_core::IpcPath> {
|
||||
#[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<InstallEvent>,
|
||||
) -> IdeResult<Sdk> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<Vec<FileStatus>> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// The project's repositories, the primary one first.
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn vcs_roots(window: Window, state: State<'_, AppState>) -> IdeResult<Vec<VcsRoot>> {
|
||||
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<Repo>> = 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<PathBuf>,
|
||||
) -> IdeResult<BranchInfo> {
|
||||
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<Vec<LineHunk>> {
|
||||
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<String>,
|
||||
) -> IdeResult<Option<String>> {
|
||||
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<Vec<BlameLine>> {
|
||||
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<PathBuf>,
|
||||
query: LogQuery,
|
||||
) -> IdeResult<Vec<Commit>> {
|
||||
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<PathBuf>,
|
||||
commit: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
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<PathBuf>,
|
||||
commits: Vec<String>,
|
||||
) -> IdeResult<Vec<RevisionChange>> {
|
||||
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<PathBuf>) -> IdeResult<Vec<Branch>> {
|
||||
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<PathBuf>) -> IdeResult<Vec<Stash>> {
|
||||
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<PathBuf>) -> 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<PathBuf>) -> 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<PathBuf>) -> 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<String> {
|
||||
// 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<ide_vcs::CheckoutOptions>,
|
||||
) -> IdeResult<ide_vcs::CheckoutOutcome> {
|
||||
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<String>,
|
||||
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<PathBuf>,
|
||||
commit: String,
|
||||
) -> IdeResult<String> {
|
||||
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<PathBuf>,
|
||||
branch: String,
|
||||
) -> IdeResult<ide_vcs::DeletedBranch> {
|
||||
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<PathBuf>,
|
||||
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<PathBuf>,
|
||||
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<PathBuf>,
|
||||
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<String>,
|
||||
) -> 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<PathBuf>,
|
||||
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<PushTags>,
|
||||
},
|
||||
/// Deletes a remote branch; `branch` is the remote-tracking name (`origin/feature`).
|
||||
DeleteBranch {
|
||||
branch: String,
|
||||
},
|
||||
/// Pushes local `branch` (only up to `revision` when set) to its upstream, or to
|
||||
/// remote branch `target` of the same remote.
|
||||
PushBranch {
|
||||
branch: String,
|
||||
revision: Option<String>,
|
||||
force_with_lease: bool,
|
||||
#[serde(default)]
|
||||
target: Option<String>,
|
||||
},
|
||||
/// Fast-forwards local `branch` (not checked out) to its upstream.
|
||||
UpdateBranch {
|
||||
branch: String,
|
||||
},
|
||||
/// Pull dialog: `branches` of `remote` (none = the configured merge branch).
|
||||
PullFrom {
|
||||
remote: String,
|
||||
branches: Vec<String>,
|
||||
rebase: bool,
|
||||
options: MergeOptions,
|
||||
},
|
||||
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<String>,
|
||||
) -> 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<ide_vcs::MergeVersions> {
|
||||
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<String> {
|
||||
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<LineHunk> {
|
||||
ide_vcs::diff_hunks(&old_text, &new_text)
|
||||
pub fn vcs_diff_hunks(state: State<'_, AppState>, old_text: String, new_text: String) -> Vec<LineHunk> {
|
||||
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<LineHunk> {
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub fn vcs_stage_hunks(window: Window, state: State<'_, AppState>, path: PathBuf, hunks: Vec<u32>) -> 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<usize> = hunks.iter().map(|&h| h as usize).collect();
|
||||
let patch = ide_vcs::build_partial_patch(&rel, &index_text, ¤t, &selected);
|
||||
if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) }
|
||||
})
|
||||
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<String>,
|
||||
) -> 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<GitExecutableInfo> {
|
||||
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<GitExecutableInfo> {
|
||||
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<String> {
|
||||
ide_vcs::detected_git_executable().map(|p| p.to_string_lossy().into_owned())
|
||||
pub fn vcs_detected_git(state: State<'_, AppState>) -> Option<String> {
|
||||
ide_host::ops::vcs::vcs_detected_git(&state.host, "")
|
||||
}
|
||||
|
||||
/// `git config --get <key>` in the project repository; `None` when unset.
|
||||
@@ -562,36 +369,7 @@ pub async fn vcs_config_get(
|
||||
repo: Option<PathBuf>,
|
||||
key: String,
|
||||
) -> IdeResult<Option<String>> {
|
||||
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<String>,
|
||||
},
|
||||
/// Commits newest first.
|
||||
Revert {
|
||||
commits: Vec<String>,
|
||||
},
|
||||
Reset {
|
||||
commit: String,
|
||||
mode: ResetMode,
|
||||
},
|
||||
/// Rebases `base..HEAD` (`base: None` = from the root) following `entries`, oldest first.
|
||||
InteractiveRebase {
|
||||
base: Option<String>,
|
||||
entries: Vec<RebaseEntry>,
|
||||
},
|
||||
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<String>,
|
||||
) -> 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<String>,
|
||||
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<PathBuf>) -> IdeResult<Vec<String>> {
|
||||
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<String>,
|
||||
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<PathBuf>) -> IdeResult<Vec<Remote>> {
|
||||
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<PathBuf>,
|
||||
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<PathBuf>,
|
||||
) -> IdeResult<Vec<Worktree>> {
|
||||
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<String>,
|
||||
) -> 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<PathBuf>,
|
||||
rev: String,
|
||||
) -> IdeResult<Vec<ChangedFile>> {
|
||||
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<Vec<ChangedFile>> {
|
||||
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<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
|
||||
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<ide_core::IpcPath>, reverse: bool },
|
||||
/// Changes made by these commits (oldest first).
|
||||
Commits { commits: Vec<String>, 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<String> {
|
||||
let project = state.project(window.label())?;
|
||||
blocking(move || {
|
||||
project.with_repo(|r| match source {
|
||||
PatchSource::Changes { paths, reverse } => {
|
||||
let paths: Vec<PathBuf> = paths.into_iter().map(Into::into).collect();
|
||||
in_primary_repo(&project, &paths)?;
|
||||
r.create_patch(&paths, reverse)
|
||||
}
|
||||
PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse),
|
||||
})
|
||||
})
|
||||
.await
|
||||
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<Vec<PatchFile>> {
|
||||
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<ApplyOutcome> {
|
||||
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<PathBuf>,
|
||||
) -> IdeResult<ShelvedChangeList> {
|
||||
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<Vec<ShelvedChangeList>> {
|
||||
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<ApplyOutcome> {
|
||||
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<String> {
|
||||
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<ChangelistState> {
|
||||
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
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -216,6 +216,9 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
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<Entry> {
|
||||
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"),
|
||||
],
|
||||
));
|
||||
|
||||
@@ -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<String>,
|
||||
/// Streaming arguments by id: the frontend channel and the id it expects.
|
||||
channels: Arc<Mutex<HashMap<u64, Channel<ChannelFrame>>>>,
|
||||
_session: Arc<ide_ssh::Session>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Connections(Mutex<HashMap<String, Arc<Connection>>>);
|
||||
|
||||
impl Connections {
|
||||
pub fn get(&self, window: &str) -> Option<Arc<Connection>> {
|
||||
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<String>,
|
||||
pub project: ide_host::ops::project::ProjectInfo,
|
||||
}
|
||||
|
||||
struct ClientHandler {
|
||||
app: AppHandle,
|
||||
window: String,
|
||||
channels: Arc<Mutex<HashMap<u64, Channel<ChannelFrame>>>>,
|
||||
}
|
||||
|
||||
impl Handler for ClientHandler {
|
||||
fn on_notification(&self, method: &str, params: Value) {
|
||||
match method {
|
||||
"event" => match serde_json::from_value::<EventMessage>(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::<ChannelMessage>(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<Value, (i64, String)> {
|
||||
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, format!("unhandled method {method}")))
|
||||
}
|
||||
}
|
||||
|
||||
/// The server binary for a remote `os`/`arch`: `OPENIDEA_SERVER_BINARY`, then
|
||||
/// `<data dir>/server/<os>-<arch>/openidea-server`, then one next to this app
|
||||
/// when the platforms match.
|
||||
fn local_server_binary(state: &AppState, os: &str, arch: &str) -> Option<std::path::PathBuf> {
|
||||
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<std::path::PathBuf> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
static CACHE: Mutex<Option<(std::path::PathBuf, u64, std::time::SystemTime, String)>> = 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<RemoteProject> {
|
||||
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<Mutex<HashMap<u64, Channel<ChannelFrame>>>> = 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<String> =
|
||||
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<String>,
|
||||
}
|
||||
|
||||
/// 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<RemoteSession> {
|
||||
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<ChannelFrame>,
|
||||
) -> IdeResult<String> {
|
||||
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()));
|
||||
}
|
||||
}
|
||||
+42
-781
@@ -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<LanguageRegistry>,
|
||||
/// Built on a background thread at start-up; see [`AppState::syntax`].
|
||||
syntax: Arc<OnceLock<Arc<Syntax>>>,
|
||||
pub host: Arc<Host>,
|
||||
pub startup: Startup,
|
||||
pub recent: RecentProjects,
|
||||
pub global_settings: SettingsStore,
|
||||
pub terminals: TerminalManager,
|
||||
pub processes: ProcessManager,
|
||||
pub debugger: Arc<DebugManager>,
|
||||
/// WASM plugins (`ide-plugin`), shared by all windows.
|
||||
pub plugins: Arc<ide_plugin::PluginHost>,
|
||||
/// 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<ide_ssh::SshManager>,
|
||||
/// Credential prompts of `ssh` / `git` child processes (`None` if it failed to start).
|
||||
pub askpass: Option<ide_ssh::askpass::AskpassServer>,
|
||||
/// SFTP deployment, remote file editing and runs on run targets.
|
||||
pub remote: Arc<ide_remote::RemoteService>,
|
||||
/// 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<HashMap<String, Arc<Project>>>,
|
||||
requests: Mutex<HashMap<RequestId, CancellationToken>>,
|
||||
}
|
||||
|
||||
/// Everything bound to one open project folder.
|
||||
pub struct Project {
|
||||
pub root: PathBuf,
|
||||
/// Window-scoped event sink, for commands that emit project events.
|
||||
pub sink: SharedSink,
|
||||
/// The app's language definitions with this project's File Types applied.
|
||||
pub languages: Arc<LanguageRegistry>,
|
||||
pub settings: Arc<SettingsStore>,
|
||||
pub documents: Arc<Documents>,
|
||||
pub history: Arc<LocalHistory>,
|
||||
/// Undo stack of file move / rename refactorings.
|
||||
pub moves: FileMoves,
|
||||
pub index: Arc<ProjectIndex>,
|
||||
pub lsp: Arc<LspManager>,
|
||||
/// Git repositories: the one containing the project root and those nested below it.
|
||||
pub repos: Arc<RwLock<Repos>>,
|
||||
/// Incrementally maintained syntax trees of the open documents.
|
||||
pub syntax: Arc<SyntaxCache>,
|
||||
/// Tree-sitter structure queries (LSP fallback) over `syntax`.
|
||||
pub structure: Arc<StructureService>,
|
||||
/// Roots auto-detected from build files; see [`Project::roots`].
|
||||
pub detected_roots: Arc<RwLock<Vec<SourceFolder>>>,
|
||||
/// Per-project cache directory (build tool sync results…).
|
||||
pub cache_dir: PathBuf,
|
||||
/// Database tool window: data sources and their connections.
|
||||
pub db: Arc<ide_db::DbManager>,
|
||||
/// HTTP Client responses (IDEA's `.idea/httpRequests`), in the app data dir.
|
||||
pub http_history: Arc<ide_http::History>,
|
||||
watcher: Watcher,
|
||||
/// Watchers of the Project Structure content roots outside the project folder.
|
||||
content_watchers: Mutex<Vec<(PathBuf, Watcher)>>,
|
||||
_scratch_watcher: Option<Watcher>,
|
||||
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<Syntax> {
|
||||
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<Arc<Project>> {
|
||||
self.projects.read().values().cloned().collect()
|
||||
}
|
||||
|
||||
/// The project of `window`. A settings window (`settings-<parent>`) shares
|
||||
/// its parent window's project.
|
||||
pub fn project(&self, window: &str) -> IdeResult<Arc<Project>> {
|
||||
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<Arc<Project>> {
|
||||
let started = std::time::Instant::now();
|
||||
let mut lap = started;
|
||||
let mut laps: Vec<(&str, u128)> = Vec::new();
|
||||
let mut mark = |name: &'static str| {
|
||||
laps.push((name, lap.elapsed().as_millis()));
|
||||
lap = std::time::Instant::now();
|
||||
};
|
||||
let root = dunce::canonicalize(root)?;
|
||||
// The window reloaded (or asked again) for the project it already has: keep
|
||||
// it, with its warm index, language servers and unsaved documents.
|
||||
if let Ok(current) = self.project(window)
|
||||
&& current.root == root
|
||||
{
|
||||
return Ok(current);
|
||||
}
|
||||
self.close_project(window);
|
||||
let sink: SharedSink = 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<PathBuf> =
|
||||
ide_tools::managed_bundle(&data_dir, "java-debug").into_iter().collect();
|
||||
let min_java = ide_sdk::java::jdtls_min_java(cfg);
|
||||
let jdks = ide_sdk::java::jdtls_jdks(min_java);
|
||||
let lombok = ide_sdk::lombok::lombok_agent(
|
||||
server_root,
|
||||
ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)),
|
||||
ide_tools::managed_bundle(&data_dir, "lombok"),
|
||||
ide_sdk::apply::maven_local_repository(&root, &effective),
|
||||
);
|
||||
if let Some(problem) = lombok.problem() {
|
||||
tracing::warn!("{problem}");
|
||||
}
|
||||
let server_jdk = ide_sdk::apply::jdtls_jdk(&root, &effective).unwrap_or_else(|e| {
|
||||
cfg.launch_error = Some(e);
|
||||
None
|
||||
});
|
||||
ide_sdk::java::configure_jdtls(
|
||||
cfg,
|
||||
server_root,
|
||||
&data_dir,
|
||||
&jdks,
|
||||
ide_sdk::apply::project_jdk(&root, &effective).as_ref(),
|
||||
server_jdk.as_ref(),
|
||||
min_java,
|
||||
&bundles,
|
||||
lombok.jar(),
|
||||
);
|
||||
}
|
||||
}));
|
||||
}
|
||||
{
|
||||
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<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
// Atomic saves by external tools surface as `created`.
|
||||
documents.on_disk_changed(&changed);
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
if !batch.removed.is_empty() {
|
||||
delete_deployed(remote.clone(), settings.clone(), root.clone(), batch.removed.clone(), sink.clone());
|
||||
}
|
||||
// Build files / conventional folders changed: re-detect roots.
|
||||
let touched = changed.iter().chain(&batch.removed).filter_map(|p| p.strip_prefix(&root).ok());
|
||||
if touched.into_iter().any(ide_workspace::roots::affects_roots) {
|
||||
let fresh = ide_workspace::roots::detect(&root);
|
||||
if *detected_roots.read() != fresh {
|
||||
// Detected excluded folders (`target`, `build`) leave the index too.
|
||||
let effective = settings.effective();
|
||||
index.set_scope(content_roots(&effective, &root), exclude_globs(&effective, &fresh));
|
||||
*detected_roots.write() = fresh;
|
||||
sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() });
|
||||
}
|
||||
}
|
||||
// A repository was cloned / initialised / removed below the project: its
|
||||
// `.git/HEAD` (the watcher reports nothing else of `.git`) appeared or went.
|
||||
let known = repos.read().roots();
|
||||
let git_dirs = changed.iter().chain(&batch.removed).any(|p| {
|
||||
let Some(dir) = p.ends_with(".git/HEAD").then(|| p.parent().and_then(Path::parent)).flatten()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
known.iter().any(|r| r == dir) != p.is_file()
|
||||
});
|
||||
if git_dirs {
|
||||
scan_repositories(&root, &repos, &settings, &sink);
|
||||
}
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.clone().into() });
|
||||
}),
|
||||
)?
|
||||
};
|
||||
|
||||
// Scratch files live outside the project; watch them too so the
|
||||
// Project view and open editors follow changes made there.
|
||||
let scratch_watcher = {
|
||||
let dir = self.data_dir.join("scratches");
|
||||
let documents = documents.clone();
|
||||
std::fs::create_dir_all(&dir).ok().and_then(|()| {
|
||||
Watcher::start(
|
||||
&dir,
|
||||
Vec::new(),
|
||||
sink.clone(),
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
documents.on_disk_changed(&changed);
|
||||
}),
|
||||
)
|
||||
.map_err(|e| tracing::warn!("scratches watcher: {e}"))
|
||||
.ok()
|
||||
})
|
||||
};
|
||||
|
||||
let content_watchers = extra_roots
|
||||
.iter()
|
||||
.filter_map(|r| {
|
||||
let w = content_root_watcher(r, &excludes, &sink, &index, &documents, &history)?;
|
||||
Some((r.clone(), w))
|
||||
})
|
||||
.collect();
|
||||
mark("watcher");
|
||||
let 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<Repos>, settings: &SettingsStore, sink: &SharedSink) {
|
||||
let ignored = settings.effective().ignored_names();
|
||||
let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy()));
|
||||
match Repos::discover(root, &skip) {
|
||||
Ok(found) => {
|
||||
if found.roots() != repos.read().roots() {
|
||||
*repos.write() = found;
|
||||
sink.emit(IdeEvent::VcsChanged { root: root.to_path_buf().into() });
|
||||
}
|
||||
}
|
||||
Err(e) => tracing::warn!("looking for git repositories: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Project {
|
||||
/// The project's git repositories.
|
||||
pub fn repos(&self) -> Repos {
|
||||
self.repos.read().clone()
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository whose root is `at`, the project's primary repository
|
||||
/// when `None`; fails when there is none (a repository-scoped command never falls back
|
||||
/// to another repository).
|
||||
pub fn with_repo_at<T>(&self, at: Option<&Path>, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let repos = self.repos();
|
||||
match at {
|
||||
Some(root) => match repos.at_root(root) {
|
||||
Some(repo) => self.run_in(repo, f),
|
||||
None => {
|
||||
Err(IdeError::unavailable(format!("{} is not a git repository of the project", root.display())))
|
||||
}
|
||||
},
|
||||
None => match repos.primary() {
|
||||
Some(repo) => self.run_in(repo, f),
|
||||
None => Err(IdeError::unavailable("project is not a git repository")),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `f` with the repository `path` (a file or folder) belongs to.
|
||||
pub fn with_repo_of<T>(&self, path: &Path, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
match self.repos().for_path(path) {
|
||||
Some(repo) => self.run_in(repo, f),
|
||||
None => Err(IdeError::unavailable(format!("{} is not in a git repository", path.display()))),
|
||||
}
|
||||
}
|
||||
|
||||
fn run_in<T>(&self, repo: &Repo, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
let settings = self.settings.effective();
|
||||
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<T>(&self, f: impl FnOnce(&Repo) -> IdeResult<T>) -> IdeResult<T> {
|
||||
self.with_repo_at(None, f)
|
||||
}
|
||||
|
||||
/// Runs a VCS operation that rewrites the working tree (checkout, merge,
|
||||
/// rebase, pull, reset, stash, rollback): Local History gets a label and a
|
||||
/// snapshot of every locally changed file before, and a label after.
|
||||
/// `None` runs `f` like [`with_repo`](Self::with_repo) (operations that keep the working tree).
|
||||
pub fn with_repo_recorded<T>(
|
||||
&self,
|
||||
at: Option<&Path>,
|
||||
operation: Option<&str>,
|
||||
f: impl FnOnce(&Repo) -> IdeResult<T>,
|
||||
) -> IdeResult<T> {
|
||||
let Some(name) = operation else { return self.with_repo_at(at, f) };
|
||||
self.history_label(&format!("Before {name}"));
|
||||
if let Ok(changed) = self.with_repo_at(at, |r| r.status()) {
|
||||
for file in changed.iter().take(HISTORY_BATCH_LIMIT) {
|
||||
if let Err(e) = self.history.record_file(&file.path, &format!("Before {name}"), false) {
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
let result = self.with_repo_at(at, f);
|
||||
self.history_label(&format!("After {name}"));
|
||||
result
|
||||
}
|
||||
|
||||
fn history_label(&self, label: &str) {
|
||||
if let Err(e) = self.history.put_label(&self.root, label) {
|
||||
tracing::warn!("local history: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Local History snapshots of `paths` before a refactoring changes them.
|
||||
pub fn record_before_refactoring(&self, paths: &[&Path], name: &str) {
|
||||
let label = format!("Before refactoring: {name}");
|
||||
for path in paths {
|
||||
let result = match self.documents.text_of(path) {
|
||||
Some(text) => self.history.record_event(path, &text, &label),
|
||||
None => self.history.record_file(path, &label, true).map(|_| ()),
|
||||
};
|
||||
if let Err(e) = result {
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Effective source / test / resource / excluded roots (detected + Project Structure marks).
|
||||
pub fn roots(&self) -> ProjectRoots {
|
||||
ProjectRoots::compute(self.detected_roots.read().clone(), &self.settings.effective().project_structure)
|
||||
}
|
||||
|
||||
/// Where the Maven / Gradle tool windows cache what the build tools reported.
|
||||
pub fn build_cache_dir(&self) -> PathBuf {
|
||||
self.cache_dir.join("build")
|
||||
}
|
||||
|
||||
/// File masks (`ide_search::FileMask`) for Find in Files and inspections: the
|
||||
/// excluded roots, the ignored names and the "Exclude files" patterns (files,
|
||||
/// and everything under such folders).
|
||||
pub fn search_excludes(&self) -> Vec<String> {
|
||||
let mut out: Vec<String> =
|
||||
self.roots().excluded_globs().iter().map(|d| format!("{}/**", d.trim_start_matches('/'))).collect();
|
||||
let settings = self.settings.effective();
|
||||
let names =
|
||||
settings.files.ignored_names.iter().cloned().chain(settings.project_structure.exclude_file_patterns());
|
||||
for name in names {
|
||||
let name = name.trim();
|
||||
if !name.is_empty() && !name.contains(['/', ',']) {
|
||||
out.push(name.to_owned());
|
||||
out.push(format!("**/{name}/**"));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `files.exclude`, the ignored names and the effective excluded roots (user-marked and detected).
|
||||
pub fn exclude_globs(&self) -> Vec<String> {
|
||||
exclude_globs(&self.settings.effective(), &self.detected_roots.read())
|
||||
}
|
||||
|
||||
/// Project Structure content roots besides the project folder (existing directories).
|
||||
pub fn content_roots(&self) -> Vec<PathBuf> {
|
||||
content_roots(&self.settings.effective(), &self.root)
|
||||
}
|
||||
|
||||
/// Applies Settings › File Types; open documents whose language changed are
|
||||
/// re-detected (language servers and syntax trees follow) and the index
|
||||
/// re-parses the files whose language changed.
|
||||
pub fn apply_file_types(&self, settings: &ide_core::settings::Settings) {
|
||||
let before = Arc::new(self.languages.snapshot());
|
||||
if self.languages.set_file_types(FileTypes::from_settings(&settings.files, Some(&self.root))) {
|
||||
self.documents.redetect_languages();
|
||||
self.index.languages_changed(before);
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies changed content roots and exclude settings to the index (which
|
||||
/// rescans in the background) and the file watchers, without reopening the project.
|
||||
pub fn refresh_index_scope(&self) {
|
||||
let (roots, excludes) = (self.content_roots(), self.exclude_globs());
|
||||
self.index.set_scope(roots.clone(), excludes.clone());
|
||||
self.watcher.set_excludes(&excludes);
|
||||
let mut watchers = self.content_watchers.lock();
|
||||
watchers.retain(|(p, _)| roots.contains(p));
|
||||
for (_, w) in watchers.iter() {
|
||||
w.set_excludes(&excludes);
|
||||
}
|
||||
for r in roots {
|
||||
if watchers.iter().any(|(p, _)| *p == r) {
|
||||
continue;
|
||||
}
|
||||
if let Some(w) =
|
||||
content_root_watcher(&r, &excludes, &self.sink, &self.index, &self.documents, &self.history)
|
||||
{
|
||||
watchers.push((r, w));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Current text of `path`: the open buffer when available, else the file on disk.
|
||||
pub fn text_of(&self, path: &Path) -> IdeResult<String> {
|
||||
match self.documents.text_of(path) {
|
||||
Some(text) => Ok(text),
|
||||
None => Ok(ide_vfs::load_text(path)?.text),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies a multi-file edit through the document layer so open editors update live.
|
||||
pub fn apply_workspace_edit(documents: &Documents, edit: &ide_core::WorkspaceEdit) -> IdeResult<()> {
|
||||
use ide_core::FileEdit;
|
||||
for file in &edit.files {
|
||||
match file {
|
||||
FileEdit::Edit { path, edits } => {
|
||||
// Files not open in an editor are opened transiently and closed again.
|
||||
let (doc, transient) = match documents.by_path(path) {
|
||||
Some(doc) => (doc, false),
|
||||
None => (documents.open(path)?.id, true),
|
||||
};
|
||||
let result = documents.apply_edits(doc, edits).and_then(|_| documents.save(doc));
|
||||
if transient {
|
||||
documents.close(doc);
|
||||
}
|
||||
result?;
|
||||
}
|
||||
FileEdit::Create { path, overwrite } => {
|
||||
if path.exists() {
|
||||
if *overwrite {
|
||||
ide_vfs::save_text(path, "", "utf-8", false)?;
|
||||
}
|
||||
} else {
|
||||
ide_vfs::create_file(path)?;
|
||||
}
|
||||
}
|
||||
FileEdit::Rename { from, to } => ide_vfs::rename(from, to)?,
|
||||
FileEdit::Delete { path, .. } => ide_vfs::delete(path)?,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Files the watcher reports per batch that Local History snapshots.
|
||||
const HISTORY_BATCH_LIMIT: usize = 500;
|
||||
|
||||
/// Local History for changes made outside the IDE (other tools, VCS, deletions).
|
||||
/// Contents equal to the last snapshot (e.g. the IDE's own saves) are skipped.
|
||||
fn record_external_changes(history: &LocalHistory, changed: &[PathBuf], removed: &[PathBuf]) {
|
||||
for path in changed.iter().take(HISTORY_BATCH_LIMIT) {
|
||||
if let Err(e) = history.record_file(path, "External change", false) {
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
if !removed.is_empty()
|
||||
&& let Err(e) = history.note_removed(removed)
|
||||
{
|
||||
tracing::debug!("local history: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspace::Retention {
|
||||
ide_workspace::Retention {
|
||||
working_days: settings.files.local_history_days,
|
||||
max_bytes: u64::from(settings.files.local_history_max_mb.max(1)) * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Project Structure content roots outside `root` (existing absolute directories).
|
||||
fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = Vec::new();
|
||||
for r in &settings.project_structure.content_roots {
|
||||
let p = PathBuf::from(r.trim());
|
||||
if p.is_absolute() && p.is_dir() && !p.starts_with(root) && !out.contains(&p) {
|
||||
out.push(p);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Watches a content root outside the project: the index, open documents and
|
||||
/// Local History follow its changes like the project folder's.
|
||||
fn content_root_watcher(
|
||||
dir: &Path,
|
||||
excludes: &[String],
|
||||
sink: &SharedSink,
|
||||
index: &Arc<ProjectIndex>,
|
||||
documents: &Arc<Documents>,
|
||||
history: &Arc<LocalHistory>,
|
||||
) -> Option<Watcher> {
|
||||
let (index, documents, history) = (index.clone(), documents.clone(), history.clone());
|
||||
Watcher::start(
|
||||
dir,
|
||||
excludes.to_vec(),
|
||||
sink.clone(),
|
||||
Box::new(move |batch| {
|
||||
let changed: Vec<PathBuf> = batch.created.iter().chain(&batch.modified).cloned().collect();
|
||||
index.update(&changed, &batch.removed);
|
||||
documents.on_disk_changed(&changed);
|
||||
record_external_changes(&history, &changed, &batch.removed);
|
||||
}),
|
||||
)
|
||||
.map_err(|e| tracing::warn!("content root watcher {}: {e}", dir.display()))
|
||||
.ok()
|
||||
}
|
||||
|
||||
fn exclude_globs(settings: &ide_core::settings::Settings, detected: &[SourceFolder]) -> Vec<String> {
|
||||
let mut globs = settings.file_excludes();
|
||||
globs.extend(ProjectRoots::compute(detected.to_vec(), &settings.project_structure).excluded_globs());
|
||||
globs
|
||||
}
|
||||
|
||||
fn project_key(root: &Path) -> String {
|
||||
// Stable FNV-1a directory name per project for caches.
|
||||
let s = root.to_string_lossy();
|
||||
let mut h: u64 = 0xcbf29ce484222325;
|
||||
for b in s.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
format!("{h:016x}")
|
||||
}
|
||||
|
||||
struct LspObserver(Arc<LspManager>);
|
||||
|
||||
impl DocumentObserver for LspObserver {
|
||||
fn opened(&self, info: &DocumentInfo) {
|
||||
// Language servers choke on huge files (IDEA disables code insight too);
|
||||
// changes to documents never opened on a server are ignored.
|
||||
if info.large {
|
||||
return;
|
||||
}
|
||||
match &info.uri {
|
||||
Some(uri) => self.0.did_open_virtual(info.id, uri, &info.language, info.version, &info.text),
|
||||
None => self.0.did_open(info.id, &info.path, &info.language, info.version, &info.text),
|
||||
}
|
||||
}
|
||||
fn changed(
|
||||
&self,
|
||||
doc: ide_core::DocId,
|
||||
_path: &Path,
|
||||
version: u32,
|
||||
changes: &[AppliedChange],
|
||||
full_text: &dyn Fn() -> String,
|
||||
) {
|
||||
self.0.did_change(doc, version, changes, full_text);
|
||||
}
|
||||
fn saved(&self, doc: ide_core::DocId, _path: &Path, text: &str) {
|
||||
self.0.did_save(doc, text);
|
||||
}
|
||||
fn closed(&self, doc: ide_core::DocId, _path: &Path) {
|
||||
self.0.did_close(doc);
|
||||
}
|
||||
}
|
||||
|
||||
/// Feeds document edits into the per-project syntax tree cache.
|
||||
struct SyntaxObserver(Arc<SyntaxCache>);
|
||||
|
||||
impl DocumentObserver for SyntaxObserver {
|
||||
fn opened(&self, info: &DocumentInfo) {
|
||||
if !info.large {
|
||||
self.0.open(info.id, &info.language, info.version);
|
||||
}
|
||||
}
|
||||
fn edited(&self, doc: ide_core::DocId, version: u32, edits: &[ByteEdit]) {
|
||||
self.0.edit(doc, version, edits);
|
||||
}
|
||||
fn closed(&self, doc: ide_core::DocId, _path: &Path) {
|
||||
self.0.close(doc);
|
||||
}
|
||||
}
|
||||
|
||||
struct HistoryObserver(Arc<LocalHistory>);
|
||||
|
||||
impl DocumentObserver for HistoryObserver {
|
||||
fn saved(&self, _doc: ide_core::DocId, path: &Path, text: &str) {
|
||||
if let Err(e) = self.0.record(path, text, "Saved") {
|
||||
tracing::warn!("local history: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Uploads saved files: remote files' local copies, and automatic upload to the default server.
|
||||
struct DeployObserver {
|
||||
remote: Arc<ide_remote::RemoteService>,
|
||||
settings: Arc<SettingsStore>,
|
||||
root: PathBuf,
|
||||
sink: SharedSink,
|
||||
}
|
||||
|
||||
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<ide_remote::RemoteService>,
|
||||
settings: Arc<SettingsStore>,
|
||||
root: PathBuf,
|
||||
removed: Vec<PathBuf>,
|
||||
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<ide_remote::TransferReport>) {
|
||||
let body = match result {
|
||||
Ok(r) if r.errors.is_empty() => return,
|
||||
Ok(r) => r.errors.iter().map(|e| format!("{}: {}", e.path, e.message)).collect::<Vec<_>>().join("\n"),
|
||||
Err(e) => e.message,
|
||||
};
|
||||
let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
|
||||
sink.emit(IdeEvent::Notify { level: ide_core::event::NotifyLevel::Error, title: format!("{what} failed: {name}"), body: Some(body) });
|
||||
}
|
||||
|
||||
/// Serves askpass prompts to the focused window and routes `git`'s prompts there.
|
||||
@@ -889,7 +149,8 @@ fn start_askpass(app: &AppHandle) -> Option<ide_ssh::askpass::AskpassServer> {
|
||||
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) {
|
||||
|
||||
@@ -800,6 +800,25 @@ export const commands = {
|
||||
remoteDelete: (ssh: string, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_delete", { ssh, path })),
|
||||
remoteMkdir: (ssh: string, path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_mkdir", { ssh, path })),
|
||||
remoteRename: (ssh: string, from: string, to: string) => typedError<null, IdeError>(__TAURI_INVOKE("remote_rename", { ssh, from, to })),
|
||||
/**
|
||||
* 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<RemoteProject_Serialize, IdeError>(__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<void>("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<ChannelFrame>) => typedError<string, IdeError>(__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<string, IdeError>(__TAURI_INVOKE("db_test", { source, password, sshSecret })),
|
||||
dbConnected: () => typedError<string[], IdeError>(__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 };
|
||||
|
||||
@@ -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<DataSource>) => 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<DbSsh>) => setS((x) => ({ ...x, ssh: { ...blankSsh(), ...x.ssh, ...patch } }));
|
||||
const options: DbOptions = { ...blankOptions(), ...s.options };
|
||||
const setOptions = (patch: Partial<DbOptions>) =>
|
||||
@@ -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 && (
|
||||
<>
|
||||
<span />
|
||||
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh })}.</span>
|
||||
<span className={styles.formHint}>Reached from the SSH host {sshLabel({ ssh: sshUsed })}.</span>
|
||||
</>
|
||||
)}
|
||||
<label className={styles.formLabel} htmlFor="db-user">
|
||||
@@ -662,67 +683,105 @@ export function SourceDialog({
|
||||
</>
|
||||
);
|
||||
|
||||
const sshConfigOptions: SelectOption[] = [
|
||||
{ value: "", label: "Custom (fields below)" },
|
||||
...sshConfigs.map((c) => ({ value: c.id, label: c.name.trim() || configLabel(c) || c.id, detail: configLabel(c) })),
|
||||
...(sshConfigId && !sshConfig ? [{ value: sshConfigId, label: `${sshConfigId} (missing)` }] : []),
|
||||
];
|
||||
const sshTab = (
|
||||
<>
|
||||
<span />
|
||||
<Checkbox checked={!!ssh.enabled} onChange={(enabled) => setSsh({ enabled })} label="Use SSH tunnel" />
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-host">
|
||||
<T>Host:</T>
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
id="db-ssh-host"
|
||||
className={styles.fill}
|
||||
disabled={!sshOn}
|
||||
value={ssh.host ?? ""}
|
||||
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
|
||||
onChange={(e) => setSsh({ host: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-port">
|
||||
<T>Port:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-ssh-port"
|
||||
className={styles.portInput}
|
||||
inputMode="numeric"
|
||||
disabled={!sshOn}
|
||||
value={ssh.port ?? ""}
|
||||
placeholder="22"
|
||||
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-user">
|
||||
<T>User:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-ssh-user"
|
||||
disabled={!sshOn}
|
||||
value={ssh.user ?? ""}
|
||||
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
|
||||
onChange={(e) => setSsh({ user: e.target.value })}
|
||||
/>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Authentication:</T>
|
||||
<T>Configuration:</T>
|
||||
</span>
|
||||
<Select
|
||||
aria-label="SSH authentication type"
|
||||
aria-label="SSH configuration"
|
||||
disabled={!sshOn}
|
||||
value={ssh.auth ?? "openSsh"}
|
||||
options={SSH_AUTH}
|
||||
onChange={(auth) => setSsh({ auth })}
|
||||
value={sshConfigId}
|
||||
options={sshConfigOptions}
|
||||
onChange={(config) => setSsh({ config })}
|
||||
/>
|
||||
{ssh.auth === "keyPair" && (
|
||||
<FileField
|
||||
id="db-ssh-key"
|
||||
label="Private key:"
|
||||
value={ssh.keyFile ?? ""}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
disabled={!sshOn}
|
||||
title="Private Key File"
|
||||
onChange={(keyFile) => setSsh({ keyFile })}
|
||||
/>
|
||||
{sshConfigId && (
|
||||
<>
|
||||
<span />
|
||||
<span className={styles.formHint}>
|
||||
{sshConfig ? (
|
||||
<>{txTemplate(`Connects to ${configLabel(sshConfig) || sshConfig.name}.`)} </>
|
||||
) : (
|
||||
<span className={styles.bad}>{txTemplate(`SSH configuration ${sshConfigId} no longer exists.`)} </span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={styles.linkButton}
|
||||
onClick={() => void executeAction("file.settings", "tools.sshConfigurations")}
|
||||
>
|
||||
<T>Edit SSH Configurations…</T>
|
||||
</button>
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{ssh.auth === "openSsh" ? (
|
||||
{!sshConfigId && (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-host">
|
||||
<T>Host:</T>
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
id="db-ssh-host"
|
||||
className={styles.fill}
|
||||
disabled={!sshOn}
|
||||
value={ssh.host ?? ""}
|
||||
placeholder={ssh.auth === "openSsh" ? "host or ~/.ssh/config alias" : "bastion.example.com"}
|
||||
onChange={(e) => setSsh({ host: e.target.value })}
|
||||
/>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-port">
|
||||
<T>Port:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-ssh-port"
|
||||
className={styles.portInput}
|
||||
inputMode="numeric"
|
||||
disabled={!sshOn}
|
||||
value={ssh.port ?? ""}
|
||||
placeholder="22"
|
||||
onChange={(e) => setSsh({ port: parsePort(e.target.value) })}
|
||||
/>
|
||||
</div>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-user">
|
||||
<T>User:</T>
|
||||
</label>
|
||||
<Input
|
||||
id="db-ssh-user"
|
||||
disabled={!sshOn}
|
||||
value={ssh.user ?? ""}
|
||||
placeholder={ssh.auth === "openSsh" ? "from ~/.ssh/config" : ""}
|
||||
onChange={(e) => setSsh({ user: e.target.value })}
|
||||
/>
|
||||
<span className={styles.formLabel}>
|
||||
<T>Authentication:</T>
|
||||
</span>
|
||||
<Select
|
||||
aria-label="SSH authentication type"
|
||||
disabled={!sshOn}
|
||||
value={ssh.auth ?? "openSsh"}
|
||||
options={SSH_AUTH}
|
||||
onChange={(auth) => setSsh({ auth })}
|
||||
/>
|
||||
{ssh.auth === "keyPair" && (
|
||||
<FileField
|
||||
id="db-ssh-key"
|
||||
label="Private key:"
|
||||
value={ssh.keyFile ?? ""}
|
||||
placeholder="~/.ssh/id_ed25519"
|
||||
disabled={!sshOn}
|
||||
title="Private Key File"
|
||||
onChange={(keyFile) => setSsh({ keyFile })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{sshUsed.auth === "openSsh" && !sshConfigId && (
|
||||
<>
|
||||
<span />
|
||||
<span className={styles.formHint}>
|
||||
@@ -731,10 +790,11 @@ export function SourceDialog({
|
||||
</T>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
)}
|
||||
{sshUsed.auth !== "openSsh" && (
|
||||
<>
|
||||
<label className={styles.formLabel} htmlFor="db-ssh-secret">
|
||||
{tx(ssh.auth === "password" ? "Password:" : "Passphrase:")}
|
||||
{tx(sshUsed.auth === "password" ? "Password:" : "Passphrase:")}
|
||||
</label>
|
||||
<div className={styles.formRow}>
|
||||
<Input
|
||||
@@ -746,12 +806,14 @@ export function SourceDialog({
|
||||
placeholder={initial && sshSecret === undefined ? "<unchanged>" : ""}
|
||||
onChange={(e) => setSshSecret(e.target.value)}
|
||||
/>
|
||||
<Checkbox
|
||||
checked={!!ssh.saveSecret}
|
||||
disabled={!sshOn}
|
||||
onChange={(saveSecret) => setSsh({ saveSecret })}
|
||||
label="Save password"
|
||||
/>
|
||||
{!sshConfigId && (
|
||||
<Checkbox
|
||||
checked={!!ssh.saveSecret}
|
||||
disabled={!sshOn}
|
||||
onChange={(saveSecret) => setSsh({ saveSecret })}
|
||||
label="Save password"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
/** Pure helpers of the database tools: clipboard formats, edits, labels. */
|
||||
import type { DbCellValue, DbColor, DbProperty, DbRowEdit, DbSortKey, DbSslMode, DataSource } from "@/platform/ipc";
|
||||
import type {
|
||||
DbCellValue,
|
||||
DbColor,
|
||||
DbProperty,
|
||||
DbRowEdit,
|
||||
DbSortKey,
|
||||
DbSsh,
|
||||
DbSslMode,
|
||||
DataSource,
|
||||
} from "@/platform/ipc";
|
||||
import type { SshConfigValid } from "@/platform/remoteSettings";
|
||||
import { vendorOf, vendorOfScheme } from "./vendors";
|
||||
|
||||
export type Cell = string | null;
|
||||
@@ -253,6 +263,31 @@ export function sshLabel(source: Pick<DataSource, "ssh">): string {
|
||||
return `${ssh.user ? `${ssh.user}@` : ""}${ssh.host}${ssh.port ? `:${ssh.port}` : ""}`;
|
||||
}
|
||||
|
||||
/** The SSH configuration a tunnel connects with; undefined for the inline fields or a missing one. */
|
||||
export function sshConfigOf(
|
||||
ssh: DbSsh | null | undefined,
|
||||
configs: readonly SshConfigValid[],
|
||||
): SshConfigValid | undefined {
|
||||
const id = ssh?.config?.trim();
|
||||
return id ? configs.find((c) => c.id === id) : undefined;
|
||||
}
|
||||
|
||||
/** The SSH settings in effect: those of the chosen SSH configuration, else the inline fields. */
|
||||
export function effectiveSsh(ssh: DbSsh | null | undefined, configs: readonly SshConfigValid[]): DbSsh | null {
|
||||
if (!ssh) return null;
|
||||
const c = sshConfigOf(ssh, configs);
|
||||
return c
|
||||
? { ...ssh, host: c.host, port: c.port, user: c.user, auth: c.auth, keyFile: c.keyFile, saveSecret: c.saveSecret }
|
||||
: ssh;
|
||||
}
|
||||
|
||||
/** Whether the SSH tab names a host to tunnel through (always, when the tunnel is off). */
|
||||
export function sshTunnelReady(ssh: DbSsh | null | undefined, configs: readonly SshConfigValid[]): boolean {
|
||||
if (!ssh?.enabled) return true;
|
||||
if (ssh.config?.trim()) return !!sshConfigOf(ssh, configs)?.host.trim();
|
||||
return !!ssh.host?.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Connection target (backend `split_target`): a data source id for its configured
|
||||
* database, or `<id>:<database>` for another database of the same server.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { DbSsh } from "@/platform/ipc";
|
||||
import { DEFAULT_SSH_CONFIG, type SshConfigValid } from "@/platform/remoteSettings";
|
||||
import { connectRemedy, effectiveSsh, sshConfigOf, sshLabel, sshTunnelReady } from "./model";
|
||||
|
||||
const configs: SshConfigValid[] = [
|
||||
{ ...DEFAULT_SSH_CONFIG, id: "dev", name: "Dev", host: "box", port: 2222, user: "me", auth: "password" },
|
||||
{ ...DEFAULT_SSH_CONFIG, id: "blank", name: "Blank" },
|
||||
];
|
||||
const inline: DbSsh = { enabled: true, config: "", host: "bastion", port: null, user: "ops", auth: "openSsh" };
|
||||
|
||||
describe("data source SSH configuration", () => {
|
||||
it("uses the chosen SSH configuration instead of the inline fields", () => {
|
||||
const ssh = { ...inline, config: "dev" };
|
||||
expect(sshConfigOf(ssh, configs)?.id).toBe("dev");
|
||||
const used = effectiveSsh(ssh, configs)!;
|
||||
expect(sshLabel({ ssh: used })).toBe("me@box:2222");
|
||||
expect(used.auth).toBe("password");
|
||||
expect(used.config).toBe("dev");
|
||||
});
|
||||
|
||||
it("keeps the inline fields when no configuration (or a missing one) is chosen", () => {
|
||||
expect(effectiveSsh(inline, configs)).toBe(inline);
|
||||
expect(sshConfigOf({ ...inline, config: "gone" }, configs)).toBeUndefined();
|
||||
expect(effectiveSsh({ ...inline, config: "gone" }, configs)?.host).toBe("bastion");
|
||||
expect(effectiveSsh(null, configs)).toBeNull();
|
||||
});
|
||||
|
||||
it("needs a host to tunnel through only when the tunnel is on", () => {
|
||||
expect(sshTunnelReady({ ...inline, enabled: false, host: "" }, configs)).toBe(true);
|
||||
expect(sshTunnelReady(inline, configs)).toBe(true);
|
||||
expect(sshTunnelReady({ ...inline, host: " " }, configs)).toBe(false);
|
||||
expect(sshTunnelReady({ ...inline, host: "", config: "dev" }, configs)).toBe(true);
|
||||
expect(sshTunnelReady({ ...inline, config: "blank" }, configs)).toBe(false);
|
||||
expect(sshTunnelReady({ ...inline, config: "gone" }, configs)).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies auth failures by the configuration's authentication", () => {
|
||||
const msg = "SSH authentication failed for me@box:2222";
|
||||
expect(connectRemedy("permissionDenied", msg, { ssh: inline })).toBeNull();
|
||||
expect(connectRemedy("permissionDenied", msg, { ssh: effectiveSsh({ ...inline, config: "dev" }, configs) })).toBe(
|
||||
"sshSecret",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -31,8 +31,9 @@ import {
|
||||
toolWindowService,
|
||||
type FileDecoration,
|
||||
} from "@/workbench/contributions";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { ask, confirm } from "@/workbench/messages";
|
||||
import { colorVar, connectRemedy, databaseKey, idleTargets, newSourceId, splitTarget } from "./model";
|
||||
import { colorVar, connectRemedy, databaseKey, effectiveSsh, idleTargets, newSourceId, splitTarget } from "./model";
|
||||
import { tableFolder, type DbNode } from "./nodes";
|
||||
import type { ExportData, ExportFormat } from "./export";
|
||||
import type { DbObjectRef } from "./objects";
|
||||
@@ -330,11 +331,13 @@ function askTrustHost(prompt: string): Promise<boolean> {
|
||||
/** Asks the user what a failed connection needs (password, SSH secret, host key trust); false = give up. */
|
||||
export async function resolveConnectError(e: unknown, source: DataSource): Promise<boolean> {
|
||||
if (!(e instanceof IpcError)) return false;
|
||||
switch (connectRemedy(e.kind, e.message, source)) {
|
||||
// Prompts show the SSH configuration's host and authentication when the tunnel uses one.
|
||||
const resolved = { ...source, ssh: effectiveSsh(source.ssh, workflowSettings().ssh.configs) };
|
||||
switch (connectRemedy(e.kind, e.message, resolved)) {
|
||||
case "password":
|
||||
return askPassword(source);
|
||||
case "sshSecret":
|
||||
return askSshSecret(source, e.message);
|
||||
return askSshSecret(resolved, e.message);
|
||||
case "trustHostKey":
|
||||
if (!(await askTrustHost(e.message))) return false;
|
||||
await call(commands.dbTrustSshHost(source.id ?? ""));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** Debugger popovers and dialogs: breakpoint properties, View Breakpoints, Evaluate, config chooser. */
|
||||
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type MutableRefObject } from "react";
|
||||
import { basename, relativePath, useProject } from "@/platform/project";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { executeAction, getAction } from "@/platform/actions";
|
||||
import { call, commands, type AttachableProcess, type RunConfig, type Variable } from "@/platform/ipc";
|
||||
import { maybeEditorService, notifications } from "@/workbench/contributions";
|
||||
import { Button, Checkbox, Dialog, Icon, IconButton, Input, Kbd, List, Menu, Popup, Toggle, T } from "@/ui";
|
||||
@@ -819,7 +819,8 @@ function ConfigChooser() {
|
||||
const close = () => useOverlays.setState({ chooser: null });
|
||||
const pick = (c: RunConfig) => {
|
||||
close();
|
||||
void executeAction("debug.startConfig", c);
|
||||
// Through Run (before-launch tasks, run-target checks) when it is loaded.
|
||||
void executeAction(getAction("run.debugConfig") ? "run.debugConfig" : "debug.startConfig", c);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/* Remote Host tool window */
|
||||
.view {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.toolbar {
|
||||
height: 32px;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
.serverSelect {
|
||||
min-width: 0;
|
||||
max-width: 220px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.rootPath {
|
||||
flex: none;
|
||||
padding: 0 10px 4px;
|
||||
color: var(--fg-subtle);
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-small);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.treeHost {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.treeHost > * {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.viewError {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
}
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.error {
|
||||
color: var(--danger);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.note {
|
||||
color: var(--fg-subtle);
|
||||
font-size: var(--fs-small);
|
||||
line-height: 1.4;
|
||||
word-break: break-word;
|
||||
}
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
/* Notifications */
|
||||
.report {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.reportErrors {
|
||||
margin: 0;
|
||||
padding-left: 16px;
|
||||
}
|
||||
|
||||
/* Sync dialog */
|
||||
.sync {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-height: 360px;
|
||||
}
|
||||
.syncTableHost {
|
||||
flex: 1;
|
||||
max-height: 460px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control, 4px);
|
||||
}
|
||||
.syncTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
.syncTable th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
text-align: left;
|
||||
font-weight: normal;
|
||||
color: var(--fg-muted);
|
||||
background: var(--bg-island);
|
||||
height: var(--h-list-row);
|
||||
padding: 0 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.syncTable td {
|
||||
height: var(--h-list-row);
|
||||
padding: 0 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.syncTable tbody tr:hover {
|
||||
background: var(--bg-tree-hover);
|
||||
}
|
||||
.choiceCol {
|
||||
width: 128px;
|
||||
}
|
||||
.sizeCol {
|
||||
width: 80px;
|
||||
}
|
||||
.timeCol {
|
||||
width: 128px;
|
||||
}
|
||||
.statusCol {
|
||||
width: 112px;
|
||||
}
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.choice {
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
color: var(--fg);
|
||||
background: var(--bg-input, var(--bg-island));
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-control, 4px);
|
||||
font: inherit;
|
||||
}
|
||||
.skipped td:not(:first-child) {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.emptyCell {
|
||||
text-align: center;
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
.status-localOnly,
|
||||
.status-localNewer {
|
||||
color: var(--vcs-added, var(--accent));
|
||||
}
|
||||
.status-remoteOnly,
|
||||
.status-remoteNewer {
|
||||
color: var(--vcs-modified, var(--accent));
|
||||
}
|
||||
.status-different {
|
||||
color: var(--warning);
|
||||
}
|
||||
.status-same {
|
||||
color: var(--fg-subtle);
|
||||
}
|
||||
|
||||
/* SSH password prompt */
|
||||
.secret {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { SyncEntry } from "@/platform/ipc";
|
||||
import { DeploymentDialogs } from "./Dialogs";
|
||||
import { initialSyncChoices } from "./model";
|
||||
import { useDeployment } from "./store";
|
||||
|
||||
const entry = (path: string, status: SyncEntry["status"]): SyncEntry => ({
|
||||
path,
|
||||
remotePath: `/srv/app/${path}`,
|
||||
local: status === "remoteOnly" ? null : { size: 2048, mtime: 1_700_000_000 },
|
||||
remote: status === "localOnly" ? null : { size: 10, mtime: 1_700_000_100 },
|
||||
status,
|
||||
});
|
||||
|
||||
function openSync(entries: SyncEntry[]) {
|
||||
useDeployment.setState({
|
||||
sync: {
|
||||
server: "s1",
|
||||
serverName: "Staging",
|
||||
path: "/work/app",
|
||||
request: null,
|
||||
entries,
|
||||
error: null,
|
||||
choices: initialSyncChoices(entries),
|
||||
hideSame: true,
|
||||
running: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
useDeployment.setState({ sync: null });
|
||||
});
|
||||
|
||||
describe("Sync dialog", () => {
|
||||
it("lists the differing files with their default actions and hides identical ones", () => {
|
||||
openSync([entry("a.php", "localOnly"), entry("b.php", "remoteNewer"), entry("c.php", "same")]);
|
||||
render(<DeploymentDialogs />);
|
||||
expect(screen.getByText("a.php")).toBeTruthy();
|
||||
expect(screen.queryByText("c.php")).toBeNull();
|
||||
expect((screen.getByLabelText("Action a.php") as HTMLSelectElement).value).toBe("upload");
|
||||
expect((screen.getByLabelText("Action b.php") as HTMLSelectElement).value).toBe("download");
|
||||
|
||||
fireEvent.click(screen.getByLabelText(/Hide identical files/));
|
||||
expect(screen.getByText("c.php")).toBeTruthy();
|
||||
expect((screen.getByLabelText("Action c.php") as HTMLSelectElement).value).toBe("skip");
|
||||
});
|
||||
|
||||
it("updates the choice of a row", () => {
|
||||
openSync([entry("a.php", "localOnly")]);
|
||||
render(<DeploymentDialogs />);
|
||||
fireEvent.change(screen.getByLabelText("Action a.php"), { target: { value: "skip" } });
|
||||
expect(useDeployment.getState().sync?.choices["a.php"]).toBe("skip");
|
||||
expect((screen.getByRole("button", { name: "Synchronize" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/** Deployment dialogs: Sync with Deployed and the SSH password prompt. */
|
||||
import { useState } from "react";
|
||||
import clsx from "clsx";
|
||||
import type { SyncEntry, SyncStatus } from "@/platform/ipc";
|
||||
import { useTx, useTxTemplate } from "@/platform/i18n";
|
||||
import { relativePath, useProject } from "@/platform/project";
|
||||
import { Checkbox, Dialog, Input, Spinner, T } from "@/ui";
|
||||
import { closeSync, runSync } from "./actions";
|
||||
import { formatSize, formatTime, syncChoices, syncSteps, type SyncChoice } from "./model";
|
||||
import { patchSync, useDeployment } from "./store";
|
||||
import styles from "./Deployment.module.css";
|
||||
|
||||
const STATUS_LABELS: Record<SyncStatus, string> = {
|
||||
localOnly: "Local only",
|
||||
remoteOnly: "Remote only",
|
||||
localNewer: "Local is newer",
|
||||
remoteNewer: "Remote is newer",
|
||||
different: "Different size",
|
||||
same: "Identical",
|
||||
};
|
||||
|
||||
const CHOICE_LABELS: Record<SyncChoice, string> = {
|
||||
skip: "Skip",
|
||||
upload: "Upload",
|
||||
download: "Download",
|
||||
deleteRemote: "Delete remote",
|
||||
deleteLocal: "Delete local",
|
||||
};
|
||||
|
||||
function SyncRow({
|
||||
entry,
|
||||
choice,
|
||||
onChoice,
|
||||
disabled,
|
||||
}: {
|
||||
entry: SyncEntry;
|
||||
choice: SyncChoice;
|
||||
onChoice: (c: SyncChoice) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
const tx = useTx();
|
||||
return (
|
||||
<tr className={clsx(choice === "skip" && styles.skipped)}>
|
||||
<td>
|
||||
<select
|
||||
className={styles.choice}
|
||||
value={choice}
|
||||
disabled={disabled}
|
||||
aria-label={`${tx("Action")} ${entry.path}`}
|
||||
onChange={(e) => onChoice(e.target.value as SyncChoice)}
|
||||
>
|
||||
{syncChoices(entry).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{tx(CHOICE_LABELS[c])}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className={styles.mono} title={entry.remotePath}>
|
||||
{entry.path}
|
||||
</td>
|
||||
<td className={styles.num}>{entry.local ? formatSize(entry.local.size) : ""}</td>
|
||||
<td>{formatTime(entry.local?.mtime)}</td>
|
||||
<td className={styles.num}>{entry.remote ? formatSize(entry.remote.size) : ""}</td>
|
||||
<td>{formatTime(entry.remote?.mtime)}</td>
|
||||
<td className={styles[`status-${entry.status}`]}>{tx(STATUS_LABELS[entry.status])}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function SyncDialog() {
|
||||
const sync = useDeployment((s) => s.sync);
|
||||
const root = useProject((s) => s.project?.root ?? null);
|
||||
const tx = useTx();
|
||||
const txt = useTxTemplate();
|
||||
if (!sync) return null;
|
||||
const entries = sync.entries;
|
||||
const shown = entries?.filter((e) => !sync.hideSame || e.status !== "same") ?? [];
|
||||
const steps = syncSteps(sync.choices);
|
||||
const same = entries?.filter((e) => e.status === "same").length ?? 0;
|
||||
const where = root ? relativePath(root, sync.path) || "/" : sync.path;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={txt(`Sync with ${sync.serverName}`)}
|
||||
onClose={closeSync}
|
||||
width={920}
|
||||
footerStart={
|
||||
entries && (
|
||||
<Checkbox
|
||||
checked={sync.hideSame}
|
||||
onChange={(hideSame) => patchSync({ hideSame })}
|
||||
label={txt(`Hide identical files (${same})`)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
buttons={[
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{
|
||||
label: "Synchronize",
|
||||
role: "default",
|
||||
disabled: !entries || sync.running || steps.length === 0,
|
||||
onClick: () => void runSync(steps),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className={styles.sync}>
|
||||
<div className={styles.note}>
|
||||
<span className={styles.mono}>{where}</span>
|
||||
{entries && <> · {txt(`${steps.length} of ${entries.length} files will change`)}</>}
|
||||
</div>
|
||||
{sync.error && <div className={styles.error}>{sync.error}</div>}
|
||||
{!entries && !sync.error && (
|
||||
<div className={styles.center}>
|
||||
<Spinner /> <T>Comparing local and remote files…</T>
|
||||
</div>
|
||||
)}
|
||||
{entries && (
|
||||
<div className={styles.syncTableHost}>
|
||||
<table className={styles.syncTable} aria-label={tx("Files to synchronize")}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th className={styles.choiceCol}>{tx("Action")}</th>
|
||||
<th>{tx("Path")}</th>
|
||||
<th className={styles.sizeCol}>{tx("Local size")}</th>
|
||||
<th className={styles.timeCol}>{tx("Local modified")}</th>
|
||||
<th className={styles.sizeCol}>{tx("Remote size")}</th>
|
||||
<th className={styles.timeCol}>{tx("Remote modified")}</th>
|
||||
<th className={styles.statusCol}>{tx("Status")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{shown.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={7} className={styles.emptyCell}>
|
||||
<T>Local and remote files are identical</T>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{shown.map((e) => (
|
||||
<SyncRow
|
||||
key={e.path}
|
||||
entry={e}
|
||||
choice={sync.choices[e.path] ?? "skip"}
|
||||
disabled={sync.running}
|
||||
onChoice={(c) => patchSync({ choices: { ...sync.choices, [e.path]: c } })}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{sync.running && (
|
||||
<div className={styles.center}>
|
||||
<Spinner /> <T>Synchronizing…</T>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SecretDialog() {
|
||||
const prompt = useDeployment((s) => s.secret);
|
||||
const [secret, setSecret] = useState("");
|
||||
const [save, setSave] = useState(false);
|
||||
if (!prompt) return null;
|
||||
const finish = (answer: { secret: string; save: boolean } | null) => {
|
||||
prompt.resolve(answer);
|
||||
setSecret("");
|
||||
setSave(false);
|
||||
};
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title="SSH Password"
|
||||
onClose={() => finish(null)}
|
||||
width={420}
|
||||
buttons={[
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
{ label: "OK", role: "default", disabled: !secret, onClick: () => finish({ secret, save }) },
|
||||
]}
|
||||
>
|
||||
<form
|
||||
className={styles.secret}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (secret) finish({ secret, save });
|
||||
}}
|
||||
>
|
||||
<div className={styles.note}>{prompt.message}</div>
|
||||
<Input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={secret}
|
||||
aria-label="Password or passphrase"
|
||||
placeholder="Password or passphrase"
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
/>
|
||||
<Checkbox checked={save} onChange={setSave} label="Save password" />
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeploymentDialogs() {
|
||||
return (
|
||||
<>
|
||||
<SyncDialog />
|
||||
<SecretDialog />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/** Remote Host tool window: the files of a deployment server, lazily listed over SFTP. */
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import type { RemoteEntry } from "@/platform/ipc";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import {
|
||||
Button,
|
||||
EmptyState,
|
||||
IconButton,
|
||||
Select,
|
||||
Spinner,
|
||||
T,
|
||||
Tree,
|
||||
fileIconName,
|
||||
showContextMenu,
|
||||
type MenuItem,
|
||||
} from "@/ui";
|
||||
import { resolveServer, serverLabel } from "./model";
|
||||
import {
|
||||
copyRemotePath,
|
||||
deleteEntries,
|
||||
downloadToProject,
|
||||
ensureRoot,
|
||||
loadFolder,
|
||||
newFolder,
|
||||
openRemote,
|
||||
refreshAll,
|
||||
renameEntry,
|
||||
} from "./remoteHost";
|
||||
import { EMPTY_TREE, patchTree, useDeployment } from "./store";
|
||||
import styles from "./Deployment.module.css";
|
||||
|
||||
function useCurrentServer() {
|
||||
const settings = useWorkflowSettings((s) => s.deployment);
|
||||
const chosen = useDeployment((s) => s.remoteServer);
|
||||
const server =
|
||||
settings.servers.find((s) => s.id === chosen) ?? resolveServer(settings, null) ?? settings.servers[0] ?? null;
|
||||
return { servers: settings.servers, server };
|
||||
}
|
||||
|
||||
export function RemoteHostView() {
|
||||
const tx = useTx();
|
||||
const { servers, server } = useCurrentServer();
|
||||
const tree = useDeployment((s) => (server ? s.trees[server.id] : undefined)) ?? EMPTY_TREE;
|
||||
const [selected, setSelected] = useState<ReadonlySet<string>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (server) void ensureRoot(server);
|
||||
}, [server]);
|
||||
|
||||
const byPath = useMemo(() => {
|
||||
const map = new Map<string, RemoteEntry>();
|
||||
for (const list of Object.values(tree.listings)) for (const e of list) map.set(e.path, e);
|
||||
return map;
|
||||
}, [tree.listings]);
|
||||
|
||||
if (!servers.length || !server)
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<EmptyState title="No deployment servers">
|
||||
<Button variant="ghost" onClick={() => void executeAction("deployment.configure")}>
|
||||
<T>Configure…</T>
|
||||
</Button>
|
||||
</EmptyState>
|
||||
</div>
|
||||
);
|
||||
|
||||
const roots = tree.root ? tree.listings[tree.root] : undefined;
|
||||
|
||||
const menuFor = (node: RemoteEntry | null, selection: Set<string>): MenuItem[] => {
|
||||
const picked = [...selection].map((p) => byPath.get(p)).filter((e): e is RemoteEntry => !!e);
|
||||
const entries = node && !selection.has(node.path) ? [node] : picked;
|
||||
const single = entries.length === 1 ? entries[0]! : null;
|
||||
const folder = single?.isDir ? single.path : single ? null : tree.root;
|
||||
return [
|
||||
{
|
||||
id: "open",
|
||||
label: "Open",
|
||||
icon: "edit",
|
||||
disabled: !single || single.isDir,
|
||||
run: () => single && void openRemote(server, single),
|
||||
},
|
||||
{
|
||||
id: "download",
|
||||
label: "Download to Project",
|
||||
disabled: !entries.length,
|
||||
run: () => void downloadToProject(server, entries),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
id: "newFolder",
|
||||
label: "New Folder…",
|
||||
icon: "folder",
|
||||
disabled: !folder,
|
||||
run: () => folder && void newFolder(server, folder),
|
||||
},
|
||||
{ id: "rename", label: "Rename…", disabled: !single, run: () => single && void renameEntry(server, single) },
|
||||
{
|
||||
id: "delete",
|
||||
label: "Delete…",
|
||||
icon: "trash",
|
||||
danger: true,
|
||||
disabled: !entries.length,
|
||||
run: () => void deleteEntries(server, entries),
|
||||
},
|
||||
{ kind: "separator" },
|
||||
{
|
||||
id: "refresh",
|
||||
label: "Refresh",
|
||||
icon: "refresh",
|
||||
run: () => void (single?.isDir ? loadFolder(server, single.path) : refreshAll(server)),
|
||||
},
|
||||
{
|
||||
id: "copyPath",
|
||||
label: "Copy Path",
|
||||
icon: "copy",
|
||||
disabled: !single,
|
||||
run: () => single && copyRemotePath(single),
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.view}>
|
||||
<div className={styles.toolbar} role="toolbar" aria-label="Remote host actions">
|
||||
<Select
|
||||
className={styles.serverSelect}
|
||||
aria-label="Deployment server"
|
||||
value={server.id}
|
||||
options={servers.map((s) => ({ value: s.id, label: serverLabel(s) }))}
|
||||
onChange={(id) => useDeployment.setState({ remoteServer: id })}
|
||||
/>
|
||||
<IconButton icon="refresh" size="small" title="Refresh" onClick={() => void refreshAll(server)} />
|
||||
<IconButton
|
||||
icon="collapse-all"
|
||||
size="small"
|
||||
title="Collapse All"
|
||||
onClick={() => patchTree(server.id, { expanded: new Set() })}
|
||||
/>
|
||||
<IconButton
|
||||
icon="settings"
|
||||
size="small"
|
||||
title="Deployment Configuration…"
|
||||
onClick={() => void executeAction("deployment.configure")}
|
||||
/>
|
||||
</div>
|
||||
{tree.root && (
|
||||
<div className={styles.rootPath} title={tree.root}>
|
||||
{tree.root}
|
||||
</div>
|
||||
)}
|
||||
{tree.error ? (
|
||||
<div className={styles.viewError}>
|
||||
<span className={styles.error}>{tree.error}</span>
|
||||
<Button size="small" onClick={() => void ensureRoot(server, true)}>
|
||||
<T>Retry</T>
|
||||
</Button>
|
||||
</div>
|
||||
) : !roots ? (
|
||||
<div className={styles.center}>
|
||||
<Spinner /> <T>Connecting…</T>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.treeHost}>
|
||||
<Tree<RemoteEntry>
|
||||
aria-label={tx("Remote files")}
|
||||
roots={roots}
|
||||
getKey={(e) => e.path}
|
||||
getLabel={(e) => e.name}
|
||||
getChildren={(e) => tree.listings[e.path]}
|
||||
isExpandable={(e) => e.isDir}
|
||||
loadChildren={(e) => loadFolder(server, e.path)}
|
||||
getIcon={(e) => (e.isDir ? "folder" : fileIconName(e.name))}
|
||||
getDetail={(e) => tree.folderErrors[e.path]}
|
||||
expanded={tree.expanded}
|
||||
onExpandedChange={(expanded) => patchTree(server.id, { expanded })}
|
||||
selected={selected}
|
||||
onSelectionChange={(s) => setSelected(s)}
|
||||
multiSelect
|
||||
speedSearch
|
||||
onActivate={(e) => !e.isDir && void openRemote(server, e)}
|
||||
onContextMenu={(node, selection, e) => showContextMenu(e, menuFor(node, selection))}
|
||||
empty={<T>Empty folder</T>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
/** Deployment actions: upload / download / delete / sync / open the deployed URL. */
|
||||
import { executeAction, registerActions } from "@/platform/actions";
|
||||
import { getContext } from "@/platform/context";
|
||||
import { call, commands, isCancelled, nextRequestId, type SyncAction, type TransferReport } from "@/platform/ipc";
|
||||
import { txTemplate, useTxTemplate } from "@/platform/i18n";
|
||||
import { openExternalUrl } from "@/platform/openUrl";
|
||||
import { relativePath, useProject } from "@/platform/project";
|
||||
import type { AutoUploadValid, DeploymentServerValid } from "@/platform/remoteSettings";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { notifications, projectViewSelection, toolWindowService } from "@/workbench/contributions";
|
||||
import { chooseItem } from "@/workbench/inputDialogs";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { initialSyncChoices, resolveServer, serverLabel, summarizeReport, targetPaths } from "./model";
|
||||
import { withSsh } from "./ssh";
|
||||
import { REMOTE_HOST_WINDOW, patchSync, useDeployment } from "./store";
|
||||
import styles from "./Deployment.module.css";
|
||||
|
||||
const GROUP = "tools";
|
||||
const CATEGORY = "Deployment";
|
||||
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
const projectRoot = () => useProject.getState().project?.root ?? null;
|
||||
const deployment = () => workflowSettings().deployment;
|
||||
|
||||
/** The files an action works on (menu argument, project view selection or the editor's file). */
|
||||
export function actionTargets(arg: unknown): string[] {
|
||||
const editorPath = getContext().editorPath;
|
||||
return targetPaths(arg, {
|
||||
part: useDeployment.getState().part,
|
||||
selection: projectViewSelection(),
|
||||
editorPath: typeof editorPath === "string" ? editorPath : null,
|
||||
projectRoot: projectRoot(),
|
||||
});
|
||||
}
|
||||
|
||||
function notifyNoServers() {
|
||||
notifications().notify({
|
||||
level: "warning",
|
||||
title: "No deployment server configured",
|
||||
group: GROUP,
|
||||
key: "deployment.noServer",
|
||||
actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }],
|
||||
});
|
||||
}
|
||||
|
||||
function notifyNoTargets() {
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: "Select files in the Project view or open a file in the editor",
|
||||
group: GROUP,
|
||||
key: "deployment.noTarget",
|
||||
});
|
||||
}
|
||||
|
||||
/** Asks which server to use (the only one without asking). */
|
||||
async function pickServer(title: string): Promise<DeploymentServerValid | null> {
|
||||
const servers = deployment().servers;
|
||||
if (!servers.length) {
|
||||
notifyNoServers();
|
||||
return null;
|
||||
}
|
||||
if (servers.length === 1) return servers[0]!;
|
||||
const labels = servers.map(serverLabel);
|
||||
const picked = await chooseItem({ title, items: labels });
|
||||
return picked === null ? null : (servers[labels.indexOf(picked)] ?? null);
|
||||
}
|
||||
|
||||
/** The default server; asks when none is marked default and there are several. */
|
||||
async function defaultServer(title: string): Promise<DeploymentServerValid | null> {
|
||||
return resolveServer(deployment(), null) ?? pickServer(title);
|
||||
}
|
||||
|
||||
function ReportBody({ report }: { report: TransferReport }) {
|
||||
const txt = useTxTemplate();
|
||||
const root = projectRoot();
|
||||
const shown = report.errors.slice(0, 10);
|
||||
return (
|
||||
<div className={styles.report}>
|
||||
{report.skipped > 0 && <div>{txt(`Skipped: ${report.skipped}`)}</div>}
|
||||
{shown.length > 0 && (
|
||||
<ul className={styles.reportErrors}>
|
||||
{shown.map((e, i) => (
|
||||
<li key={i}>
|
||||
<span className={styles.mono}>{root ? relativePath(root, e.path) : e.path}</span>: {e.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{report.errors.length > shown.length && <div>{txt(`and ${report.errors.length - shown.length} more`)}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function showReport(
|
||||
report: TransferReport,
|
||||
verb: "upload" | "download" | "delete" | "sync",
|
||||
server: DeploymentServerValid,
|
||||
) {
|
||||
const s = summarizeReport(report, verb, serverLabel(server));
|
||||
notifications().notify({
|
||||
level: s.level,
|
||||
title: s.title.replace("{n}", String(s.params.n)).replace("{server}", s.params.server),
|
||||
body: report.errors.length || report.skipped ? <ReportBody report={report} /> : undefined,
|
||||
group: GROUP,
|
||||
key: `deployment.${verb}`,
|
||||
});
|
||||
}
|
||||
|
||||
function showFailure(title: string, server: DeploymentServerValid, e: unknown) {
|
||||
if (isCancelled(e)) return;
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: title.replace("{server}", serverLabel(server)),
|
||||
body: errorText(e),
|
||||
group: GROUP,
|
||||
key: "deployment.failure",
|
||||
});
|
||||
}
|
||||
|
||||
async function transfer(direction: "upload" | "download", server: DeploymentServerValid | null, paths: string[]) {
|
||||
if (!server) return;
|
||||
const request = nextRequestId();
|
||||
try {
|
||||
const report = await withSsh(server.sshConfig, () =>
|
||||
call(
|
||||
direction === "upload"
|
||||
? commands.deployUpload(paths, server.id, request)
|
||||
: commands.deployDownload(paths, server.id, request),
|
||||
),
|
||||
);
|
||||
showReport(report, direction, server);
|
||||
} catch (e) {
|
||||
showFailure(direction === "upload" ? "Cannot upload to {server}" : "Cannot download from {server}", server, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Downloads the remote counterparts of `paths` (Remote Host › Download to Project). */
|
||||
export function downloadPaths(server: DeploymentServerValid, paths: string[]) {
|
||||
return transfer("download", server, paths);
|
||||
}
|
||||
|
||||
async function deleteRemote(paths: string[]) {
|
||||
const server = await defaultServer("Delete from Server");
|
||||
if (!server) return;
|
||||
const root = projectRoot();
|
||||
const names = paths.map((p) => (root ? relativePath(root, p) || "/" : p));
|
||||
const ok = await confirm({
|
||||
title: "Delete from Server",
|
||||
message: txTemplate(
|
||||
names.length === 1
|
||||
? `Delete the remote copy of ${names[0]} from ${serverLabel(server)}?`
|
||||
: `Delete the remote copies of ${names.length} items from ${serverLabel(server)}?`,
|
||||
),
|
||||
ok: "Delete",
|
||||
icon: "warning",
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
const report = await withSsh(server.sshConfig, () => call(commands.deployDeleteRemote(paths, server.id)));
|
||||
showReport(report, "delete", server);
|
||||
} catch (e) {
|
||||
showFailure("Cannot delete from {server}", server, e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens the Sync dialog for `path` and compares it with `server`. */
|
||||
export async function openSync(server: DeploymentServerValid, path: string) {
|
||||
const request = nextRequestId();
|
||||
useDeployment.setState({
|
||||
sync: {
|
||||
server: server.id,
|
||||
serverName: serverLabel(server),
|
||||
path,
|
||||
request,
|
||||
entries: null,
|
||||
error: null,
|
||||
choices: {},
|
||||
hideSame: true,
|
||||
running: false,
|
||||
},
|
||||
});
|
||||
try {
|
||||
const entries = await withSsh(server.sshConfig, () => call(commands.deployCompare(path, server.id, request)));
|
||||
if (useDeployment.getState().sync?.request !== request) return;
|
||||
patchSync({ entries, choices: initialSyncChoices(entries), request: null });
|
||||
} catch (e) {
|
||||
if (useDeployment.getState().sync?.request !== request) return;
|
||||
if (isCancelled(e)) useDeployment.setState({ sync: null });
|
||||
else patchSync({ error: errorText(e), request: null });
|
||||
}
|
||||
}
|
||||
|
||||
/** Closes the Sync dialog, cancelling a running comparison. */
|
||||
export function closeSync() {
|
||||
const request = useDeployment.getState().sync?.request;
|
||||
if (request != null) void commands.cancelRequest(request);
|
||||
useDeployment.setState({ sync: null });
|
||||
}
|
||||
|
||||
export async function runSync(steps: { path: string; action: SyncAction }[]) {
|
||||
const sync = useDeployment.getState().sync;
|
||||
const server = sync && deployment().servers.find((s) => s.id === sync.server);
|
||||
if (!sync || !server) return;
|
||||
const request = nextRequestId();
|
||||
patchSync({ running: true, request });
|
||||
try {
|
||||
const report = await withSsh(server.sshConfig, () => call(commands.deploySync(steps, server.id, request)));
|
||||
useDeployment.setState({ sync: null });
|
||||
showReport(report, "sync", server);
|
||||
} catch (e) {
|
||||
if (isCancelled(e)) patchSync({ running: false, request: null });
|
||||
else patchSync({ running: false, request: null, error: errorText(e) });
|
||||
}
|
||||
}
|
||||
|
||||
async function openInBrowser(paths: string[]) {
|
||||
const path = paths[0];
|
||||
if (!path) return notifyNoTargets();
|
||||
try {
|
||||
const info = await call(commands.deployInfo(path, null));
|
||||
if (!info?.webUrl) {
|
||||
notifications().notify({
|
||||
level: "info",
|
||||
title: info ? "No web server URL for this file" : "This file is not mapped to the default server",
|
||||
group: GROUP,
|
||||
key: "deployment.openInBrowser",
|
||||
actions: [{ label: "Configure…", run: () => void executeAction("deployment.configure") }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
await openExternalUrl(info.webUrl);
|
||||
} catch (e) {
|
||||
notifications().notify({ level: "error", title: "Cannot open in browser", body: errorText(e), group: GROUP });
|
||||
}
|
||||
}
|
||||
|
||||
export function autoUploadOn(): boolean {
|
||||
return deployment().autoUpload !== "never";
|
||||
}
|
||||
|
||||
async function setAutoUpload(value: AutoUploadValid) {
|
||||
try {
|
||||
const settings = await call(commands.settingsSet("project", "deployment.autoUpload", JSON.stringify(value)));
|
||||
useProject.setState({ settings });
|
||||
} catch (e) {
|
||||
notifications().notify({
|
||||
level: "error",
|
||||
title: "Cannot change automatic upload",
|
||||
body: errorText(e),
|
||||
group: GROUP,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs `fn` with the targets, or explains how to pick some. */
|
||||
const withTargets = (fn: (paths: string[]) => unknown) => (c: { arg?: unknown }) => {
|
||||
const paths = actionTargets(c.arg);
|
||||
if (!paths.length) return notifyNoTargets();
|
||||
void fn(paths);
|
||||
};
|
||||
|
||||
export function registerDeploymentActions(): () => void {
|
||||
const when = "projectOpen";
|
||||
return registerActions([
|
||||
{
|
||||
id: "deployment.uploadToDefault",
|
||||
title: "Upload to Default Server",
|
||||
description: "Upload the selected files and folders to the default deployment server",
|
||||
category: CATEGORY,
|
||||
icon: "export",
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "upload", "remote"],
|
||||
run: withTargets(async (paths) => transfer("upload", await defaultServer("Upload to"), paths)),
|
||||
},
|
||||
{
|
||||
id: "deployment.uploadTo",
|
||||
title: "Upload to…",
|
||||
description: "Upload the selected files and folders to a deployment server",
|
||||
category: CATEGORY,
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "upload", "remote"],
|
||||
run: withTargets(async (paths) => transfer("upload", await pickServer("Upload to"), paths)),
|
||||
},
|
||||
{
|
||||
id: "deployment.downloadFromDefault",
|
||||
title: "Download from Default Server",
|
||||
description: "Replace the selected files and folders with their copies on the default deployment server",
|
||||
category: CATEGORY,
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "download", "remote"],
|
||||
run: withTargets(async (paths) => transfer("download", await defaultServer("Download from"), paths)),
|
||||
},
|
||||
{
|
||||
id: "deployment.downloadFrom",
|
||||
title: "Download from…",
|
||||
description: "Replace the selected files and folders with their copies on a deployment server",
|
||||
category: CATEGORY,
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "download", "remote"],
|
||||
run: withTargets(async (paths) => transfer("download", await pickServer("Download from"), paths)),
|
||||
},
|
||||
{
|
||||
id: "deployment.syncWith",
|
||||
title: "Sync with Deployed to…",
|
||||
description: "Compare local files with a deployment server and synchronize them",
|
||||
category: CATEGORY,
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "compare", "synchronize", "remote", "diff"],
|
||||
run: ({ arg }) => {
|
||||
const paths = actionTargets(arg);
|
||||
const target = paths[0] ?? projectRoot();
|
||||
if (!target) return;
|
||||
void pickServer("Sync with Deployed to").then((server) => server && openSync(server, target));
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "deployment.deleteFromServer",
|
||||
title: "Delete from Server",
|
||||
description: "Delete the remote copies of the selected files and folders on the default server",
|
||||
category: CATEGORY,
|
||||
icon: "trash",
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "remove", "remote"],
|
||||
run: withTargets(deleteRemote),
|
||||
},
|
||||
{
|
||||
id: "deployment.toggleAutoUpload",
|
||||
title: "Automatic Upload",
|
||||
description: "Upload changed files to the default server automatically",
|
||||
category: CATEGORY,
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "auto upload", "on save", "toggle"],
|
||||
toggled: autoUploadOn,
|
||||
run: () => setAutoUpload(autoUploadOn() ? "never" : "always"),
|
||||
},
|
||||
{
|
||||
id: "deployment.browseRemoteHost",
|
||||
title: "Browse Remote Host",
|
||||
description: "Show the files of a deployment server in the Remote Host tool window",
|
||||
category: CATEGORY,
|
||||
icon: "web",
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "remote host", "server files"],
|
||||
run: () => toolWindowService().show(REMOTE_HOST_WINDOW, true),
|
||||
},
|
||||
{
|
||||
id: "deployment.openInBrowser",
|
||||
title: "Open in Browser (Deployed)",
|
||||
description: "Open the deployed URL of the file on the default server's web server",
|
||||
category: CATEGORY,
|
||||
icon: "web",
|
||||
when,
|
||||
keywords: ["deploy", "web server", "url", "browser"],
|
||||
run: withTargets(openInBrowser),
|
||||
},
|
||||
{
|
||||
id: "deployment.configure",
|
||||
title: "Deployment Configuration…",
|
||||
description: "Edit the deployment servers and options",
|
||||
category: CATEGORY,
|
||||
icon: "settings",
|
||||
when,
|
||||
keywords: ["deploy", "sftp", "servers", "settings"],
|
||||
run: () => void executeAction("file.settings", "build.deployment"),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Deployment over SFTP: upload / download / sync project files with the
|
||||
* servers of Settings › Build, Execution, Deployment › Deployment, and the
|
||||
* Remote Host tool window. Transfers, automatic upload and remote deletes run
|
||||
* in Rust (`ide-remote`).
|
||||
*/
|
||||
import { useContextKeys } from "@/platform/context";
|
||||
import { call, commands } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { lazyComponent, type MenuItem } from "@/ui";
|
||||
import { registerMenuGroup, registerOverlay, registerToolWindow } from "@/workbench/contributions";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { autoUploadOn, registerDeploymentActions } from "./actions";
|
||||
import { DeploymentDialogs } from "./Dialogs";
|
||||
import { REMOTE_HOST_WINDOW, useDeployment } from "./store";
|
||||
|
||||
/** Deployed URL of the path the last menu was prepared for. */
|
||||
let webUrlFor: { path: string; url: string | null } | null = null;
|
||||
|
||||
function deploymentMenu(paths: string[]): MenuItem[] {
|
||||
const root = useProject.getState().project?.root;
|
||||
const { servers } = workflowSettings().deployment;
|
||||
if (!root || !servers.length || !paths.length) return [];
|
||||
if (!paths.every((p) => p === root || p.startsWith(`${root}/`))) return [];
|
||||
const arg = { paths };
|
||||
const single = paths.length === 1 ? paths[0]! : null;
|
||||
const webUrl = single && webUrlFor?.path === single ? webUrlFor.url : null;
|
||||
return [
|
||||
{
|
||||
id: "deployment",
|
||||
label: "Deployment",
|
||||
icon: "web",
|
||||
submenu: [
|
||||
{ kind: "action", action: "deployment.uploadToDefault", arg },
|
||||
{ kind: "action", action: "deployment.uploadTo", arg },
|
||||
{ kind: "action", action: "deployment.downloadFromDefault", arg },
|
||||
{ kind: "action", action: "deployment.downloadFrom", arg },
|
||||
{ kind: "action", action: "deployment.syncWith", arg },
|
||||
{ kind: "action", action: "deployment.deleteFromServer", arg },
|
||||
{ kind: "separator" },
|
||||
...(webUrl ? [{ kind: "action", action: "deployment.openInBrowser", arg } as MenuItem] : []),
|
||||
{ kind: "action", action: "deployment.browseRemoteHost" },
|
||||
{
|
||||
id: "deployment.toggleAutoUpload",
|
||||
label: "Automatic Upload",
|
||||
checked: autoUploadOn(),
|
||||
run: () => void executeAction("deployment.toggleAutoUpload"),
|
||||
},
|
||||
{ kind: "action", action: "deployment.configure", label: "Configuration…" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function activate(): () => void {
|
||||
// Actions run from Find Action target what had focus before: the project view or the editor.
|
||||
const unsubFocus = useContextKeys.subscribe((ctx, prev) => {
|
||||
if (ctx.projectViewFocus && !prev.projectViewFocus) useDeployment.setState({ part: "project" });
|
||||
else if ((ctx.editorFocus && !prev.editorFocus) || (ctx.editorTextFocus && !prev.editorTextFocus))
|
||||
useDeployment.setState({ part: "editor" });
|
||||
});
|
||||
// A reopened project starts with a fresh backend: forget the old one's remote trees.
|
||||
const unsubProject = useProject.subscribe((s, prev) => {
|
||||
if (s.project?.root !== prev.project?.root) useDeployment.setState({ trees: {}, remoteServer: null, sync: null });
|
||||
});
|
||||
const disposers = [
|
||||
unsubFocus,
|
||||
unsubProject,
|
||||
registerDeploymentActions(),
|
||||
registerToolWindow({
|
||||
id: REMOTE_HOST_WINDOW,
|
||||
title: "Remote Host",
|
||||
icon: "web",
|
||||
anchor: "right",
|
||||
order: 30,
|
||||
requiresProject: true,
|
||||
stripe: "onUse",
|
||||
component: lazyComponent(() => import("./RemoteHostView").then((m) => m.RemoteHostView)),
|
||||
}),
|
||||
registerOverlay({ id: "deployment.dialogs", component: DeploymentDialogs }),
|
||||
registerMenuGroup({
|
||||
id: "deployment",
|
||||
menus: ["projectView", "editor", "editorTab"],
|
||||
order: 60,
|
||||
items: (_menu, { paths }) => deploymentMenu(paths),
|
||||
prepare: async (_menu, { paths }) => {
|
||||
const single = paths.length === 1 ? paths[0]! : null;
|
||||
if (!single || !workflowSettings().deployment.servers.length) return;
|
||||
const info = await call(commands.deployInfo(single, null)).catch(() => null);
|
||||
webUrlFor = { path: single, url: info?.webUrl ?? null };
|
||||
},
|
||||
}),
|
||||
];
|
||||
return () => disposers.reverse().forEach((d) => d());
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SyncEntry } from "@/platform/ipc";
|
||||
import { DEFAULT_DEPLOYMENT, DEFAULT_DEPLOYMENT_SERVER } from "@/platform/remoteSettings";
|
||||
import {
|
||||
defaultSyncAction,
|
||||
initialSyncChoices,
|
||||
joinRemote,
|
||||
localPathForRemote,
|
||||
pathsFromArg,
|
||||
remoteName,
|
||||
remoteParent,
|
||||
resolveServer,
|
||||
sshRemedy,
|
||||
summarizeReport,
|
||||
syncChoices,
|
||||
syncSteps,
|
||||
targetPaths,
|
||||
} from "./model";
|
||||
|
||||
const ROOT = "/work/app";
|
||||
|
||||
describe("targetPaths", () => {
|
||||
const ctx = { part: null, selection: [], editorPath: null, projectRoot: ROOT } as const;
|
||||
|
||||
it("reads paths passed by menus", () => {
|
||||
expect(pathsFromArg(`${ROOT}/a.php`)).toEqual([`${ROOT}/a.php`]);
|
||||
expect(pathsFromArg([`${ROOT}/a`, 3, ""])).toEqual([`${ROOT}/a`]);
|
||||
expect(pathsFromArg({ paths: [`${ROOT}/b`] })).toEqual([`${ROOT}/b`]);
|
||||
expect(pathsFromArg(undefined)).toBeNull();
|
||||
expect(pathsFromArg({ paths: [] })).toBeNull();
|
||||
});
|
||||
|
||||
it("prefers the passed paths over the selection and the editor", () => {
|
||||
expect(
|
||||
targetPaths(`${ROOT}/x`, { ...ctx, part: "project", selection: [`${ROOT}/y`], editorPath: `${ROOT}/z` }),
|
||||
).toEqual([`${ROOT}/x`]);
|
||||
});
|
||||
|
||||
it("uses the project view selection when it had focus last, else the editor file", () => {
|
||||
const both = { ...ctx, selection: [`${ROOT}/src`], editorPath: `${ROOT}/index.php` };
|
||||
expect(targetPaths(undefined, { ...both, part: "project" })).toEqual([`${ROOT}/src`]);
|
||||
expect(targetPaths(undefined, { ...both, part: "editor" })).toEqual([`${ROOT}/index.php`]);
|
||||
expect(targetPaths(undefined, { ...ctx, part: "editor", selection: [`${ROOT}/src`] })).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops paths outside the project and paths inside selected folders", () => {
|
||||
expect(targetPaths([`${ROOT}/src`, `${ROOT}/src/a.php`, "/elsewhere/b", `${ROOT}/srcx`], ctx)).toEqual([
|
||||
`${ROOT}/src`,
|
||||
`${ROOT}/srcx`,
|
||||
]);
|
||||
expect(targetPaths(`${ROOT}/a`, { ...ctx, projectRoot: null })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sync defaults", () => {
|
||||
it("picks the action from the status", () => {
|
||||
expect(defaultSyncAction("localOnly")).toBe("upload");
|
||||
expect(defaultSyncAction("localNewer")).toBe("upload");
|
||||
expect(defaultSyncAction("different")).toBe("upload");
|
||||
expect(defaultSyncAction("remoteOnly")).toBe("download");
|
||||
expect(defaultSyncAction("remoteNewer")).toBe("download");
|
||||
expect(defaultSyncAction("same")).toBe("skip");
|
||||
});
|
||||
|
||||
const entry = (path: string, status: SyncEntry["status"], local: boolean, remote: boolean): SyncEntry => ({
|
||||
path,
|
||||
remotePath: `/srv/${path}`,
|
||||
local: local ? { size: 1, mtime: 1 } : null,
|
||||
remote: remote ? { size: 2, mtime: 2 } : null,
|
||||
status,
|
||||
});
|
||||
|
||||
it("offers only actions possible for the row", () => {
|
||||
expect(syncChoices(entry("a", "localOnly", true, false))).toEqual(["skip", "upload", "deleteLocal"]);
|
||||
expect(syncChoices(entry("b", "remoteOnly", false, true))).toEqual(["skip", "download", "deleteRemote"]);
|
||||
});
|
||||
|
||||
it("turns choices into steps without the skipped rows", () => {
|
||||
const entries = [
|
||||
entry("a", "localOnly", true, false),
|
||||
entry("b", "same", true, true),
|
||||
entry("c", "remoteNewer", true, true),
|
||||
];
|
||||
const choices = initialSyncChoices(entries);
|
||||
expect(choices).toEqual({ a: "upload", b: "skip", c: "download" });
|
||||
expect(syncSteps({ ...choices, b: "deleteRemote" })).toEqual([
|
||||
{ path: "a", action: "upload" },
|
||||
{ path: "b", action: "deleteRemote" },
|
||||
{ path: "c", action: "download" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("servers and reports", () => {
|
||||
const a = { ...DEFAULT_DEPLOYMENT_SERVER, id: "a", name: "Staging" };
|
||||
const b = { ...DEFAULT_DEPLOYMENT_SERVER, id: "b", name: "" };
|
||||
|
||||
it("resolves the default server", () => {
|
||||
expect(resolveServer({ ...DEFAULT_DEPLOYMENT, servers: [a, b], defaultServer: "b" }, null)?.id).toBe("b");
|
||||
expect(resolveServer({ ...DEFAULT_DEPLOYMENT, servers: [a, b], defaultServer: "" }, null)).toBeNull();
|
||||
expect(resolveServer({ ...DEFAULT_DEPLOYMENT, servers: [a], defaultServer: "" }, null)?.id).toBe("a");
|
||||
expect(resolveServer({ ...DEFAULT_DEPLOYMENT, servers: [a, b], defaultServer: "b" }, "a")?.id).toBe("a");
|
||||
});
|
||||
|
||||
it("summarizes transfer reports", () => {
|
||||
expect(summarizeReport({ files: 3, skipped: 1, errors: [] }, "upload", "Staging")).toEqual({
|
||||
level: "info",
|
||||
title: "Uploaded {n} files to {server}",
|
||||
params: { n: 3, server: "Staging", skipped: 1 },
|
||||
});
|
||||
expect(
|
||||
summarizeReport({ files: 2, skipped: 0, errors: [{ path: "x", message: "denied" }] }, "download", "S").level,
|
||||
).toBe("warning");
|
||||
expect(
|
||||
summarizeReport({ files: 0, skipped: 0, errors: [{ path: "x", message: "denied" }] }, "sync", "S").level,
|
||||
).toBe("error");
|
||||
});
|
||||
|
||||
it("classifies SSH failures", () => {
|
||||
expect(sshRemedy("protocol", "Unknown SSH host key for example.com", "openSsh")).toBe("trustHostKey");
|
||||
expect(sshRemedy("permissionDenied", "SSH password required for [email protected]", "password")).toBe("secret");
|
||||
expect(sshRemedy("permissionDenied", "SSH key passphrase required for ~/.ssh/id", "keyPair")).toBe("secret");
|
||||
expect(sshRemedy("permissionDenied", "SSH host key of box:22 has changed", "password")).toBeNull();
|
||||
expect(sshRemedy("permissionDenied", "SSH authentication failed", "openSsh")).toBeNull();
|
||||
expect(sshRemedy("permissionDenied", "SSH authentication failed", "password")).toBe("secret");
|
||||
expect(sshRemedy("io", "Connection refused", "password")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("remote paths", () => {
|
||||
it("joins and splits remote paths", () => {
|
||||
expect(joinRemote("/var/www", "app/")).toBe("/var/www/app");
|
||||
expect(joinRemote("/", "etc")).toBe("/etc");
|
||||
expect(joinRemote("/var/www", "")).toBe("/var/www");
|
||||
expect(remoteParent("/var/www/app")).toBe("/var/www");
|
||||
expect(remoteParent("/var")).toBe("/");
|
||||
expect(remoteParent("/")).toBe("/");
|
||||
expect(remoteName("/var/www/app/")).toBe("app");
|
||||
});
|
||||
|
||||
it("maps remote paths back to project paths through the most specific mapping", () => {
|
||||
const mappings = [
|
||||
{ local: "", deployment: "", web: "" },
|
||||
{ local: "public", deployment: "htdocs", web: "" },
|
||||
{ local: "shared", deployment: "/opt/shared", web: "" },
|
||||
];
|
||||
const map = (p: string) => localPathForRemote(p, "/var/www", mappings, ROOT);
|
||||
expect(map("/var/www/index.php")).toBe(`${ROOT}/index.php`);
|
||||
expect(map("/var/www/htdocs/css/a.css")).toBe(`${ROOT}/public/css/a.css`);
|
||||
expect(map("/var/www/htdocs")).toBe(`${ROOT}/public`);
|
||||
expect(map("/opt/shared/lib")).toBe(`${ROOT}/shared/lib`);
|
||||
expect(map("/var/wwwx/a")).toBeNull();
|
||||
expect(map("/etc/passwd")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/** Pure helpers of the deployment feature: target paths, sync defaults, reports, remote paths. */
|
||||
import type { SyncAction, SyncEntry, SyncStatus, TransferReport } from "@/platform/ipc";
|
||||
import type { DeploymentServerValid, DeploymentSettingsValid, SshAuthValid } from "@/platform/remoteSettings";
|
||||
|
||||
/** Which part had focus last: the project view's selection or the editor's file is the target. */
|
||||
export type FocusedPart = "project" | "editor" | null;
|
||||
|
||||
/** Paths passed to an action: a path, a list of paths or `{ paths }`; null when none were passed. */
|
||||
export function pathsFromArg(arg: unknown): string[] | null {
|
||||
if (typeof arg === "string") return arg ? [arg] : null;
|
||||
const list = Array.isArray(arg) ? arg : (arg as { paths?: unknown } | null | undefined)?.paths;
|
||||
if (!Array.isArray(list)) return null;
|
||||
const paths = list.filter((p): p is string => typeof p === "string" && !!p);
|
||||
return paths.length ? paths : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Files an action works on: the paths passed by a menu, else the project view
|
||||
* selection when it had focus last, else the active editor's file. Paths
|
||||
* outside the project are dropped.
|
||||
*/
|
||||
export function targetPaths(
|
||||
arg: unknown,
|
||||
ctx: { part: FocusedPart; selection: readonly string[]; editorPath: string | null; projectRoot: string | null },
|
||||
): string[] {
|
||||
const passed = pathsFromArg(arg);
|
||||
const picked = passed
|
||||
? passed
|
||||
: ctx.part === "project" && ctx.selection.length
|
||||
? [...ctx.selection]
|
||||
: ctx.editorPath
|
||||
? [ctx.editorPath]
|
||||
: ctx.selection.length && ctx.part !== "editor"
|
||||
? [...ctx.selection]
|
||||
: [];
|
||||
const root = ctx.projectRoot;
|
||||
if (!root) return [];
|
||||
const inside = picked.filter((p) => p === root || p.startsWith(`${root}/`));
|
||||
// A folder and something inside it: the folder covers both.
|
||||
return inside.filter((p) => !inside.some((q) => q !== p && p.startsWith(`${q}/`)));
|
||||
}
|
||||
|
||||
export type SyncChoice = SyncAction | "skip";
|
||||
|
||||
/** The Sync dialog's preselected action for a row. */
|
||||
export function defaultSyncAction(status: SyncStatus): SyncChoice {
|
||||
switch (status) {
|
||||
case "localOnly":
|
||||
case "localNewer":
|
||||
case "different":
|
||||
return "upload";
|
||||
case "remoteOnly":
|
||||
case "remoteNewer":
|
||||
return "download";
|
||||
case "same":
|
||||
return "skip";
|
||||
}
|
||||
}
|
||||
|
||||
/** Actions that make sense for a row (deleting needs the file on that side). */
|
||||
export function syncChoices(entry: Pick<SyncEntry, "local" | "remote">): SyncChoice[] {
|
||||
const out: SyncChoice[] = ["skip"];
|
||||
if (entry.local) out.push("upload");
|
||||
if (entry.remote) out.push("download");
|
||||
if (entry.remote) out.push("deleteRemote");
|
||||
if (entry.local) out.push("deleteLocal");
|
||||
return out;
|
||||
}
|
||||
|
||||
export function initialSyncChoices(entries: readonly SyncEntry[]): Record<string, SyncChoice> {
|
||||
return Object.fromEntries(entries.map((e) => [e.path, defaultSyncAction(e.status)]));
|
||||
}
|
||||
|
||||
/** Steps to send to `deploySync` (skipped rows left out). */
|
||||
export function syncSteps(choices: Record<string, SyncChoice>): { path: string; action: SyncAction }[] {
|
||||
return Object.entries(choices)
|
||||
.filter((e): e is [string, SyncAction] => e[1] !== "skip")
|
||||
.map(([path, action]) => ({ path, action }));
|
||||
}
|
||||
|
||||
/** The server an action uses: `id`, else the default server, else the only one. */
|
||||
export function resolveServer(settings: DeploymentSettingsValid, id: string | null): DeploymentServerValid | null {
|
||||
const { servers, defaultServer } = settings;
|
||||
if (id) return servers.find((s) => s.id === id) ?? null;
|
||||
return servers.find((s) => s.id === defaultServer) ?? (servers.length === 1 ? servers[0]! : null);
|
||||
}
|
||||
|
||||
export const serverLabel = (s: Pick<DeploymentServerValid, "id" | "name">) => s.name.trim() || s.id;
|
||||
|
||||
export interface ReportSummary {
|
||||
level: "info" | "warning" | "error";
|
||||
/** English source text with `{n}` / `{server}` placeholders (translated by the caller). */
|
||||
title: string;
|
||||
params: { n: number; server: string; skipped: number };
|
||||
}
|
||||
|
||||
/** Notification for a finished transfer. */
|
||||
export function summarizeReport(
|
||||
report: TransferReport,
|
||||
verb: "upload" | "download" | "delete" | "sync",
|
||||
server: string,
|
||||
): ReportSummary {
|
||||
const params = { n: report.files, server, skipped: report.skipped };
|
||||
const failed = report.errors.length > 0;
|
||||
const titles = {
|
||||
upload: failed ? "Upload to {server} finished with errors" : "Uploaded {n} files to {server}",
|
||||
download: failed ? "Download from {server} finished with errors" : "Downloaded {n} files from {server}",
|
||||
delete: failed ? "Delete from {server} finished with errors" : "Deleted {n} files from {server}",
|
||||
sync: failed ? "Sync with {server} finished with errors" : "Synchronized {n} files with {server}",
|
||||
};
|
||||
return { level: failed ? (report.files ? "warning" : "error") : "info", title: titles[verb], params };
|
||||
}
|
||||
|
||||
/** Joins an absolute remote folder and a name / relative path. */
|
||||
export function joinRemote(dir: string, rel: string): string {
|
||||
const r = rel.replace(/^\/+|\/+$/g, "");
|
||||
if (!r) return dir;
|
||||
return dir.endsWith("/") ? `${dir}${r}` : `${dir}/${r}`;
|
||||
}
|
||||
|
||||
export function remoteParent(path: string): string {
|
||||
const trimmed = path.length > 1 ? path.replace(/\/+$/, "") : path;
|
||||
const i = trimmed.lastIndexOf("/");
|
||||
return i <= 0 ? "/" : trimmed.slice(0, i);
|
||||
}
|
||||
|
||||
export function remoteName(path: string): string {
|
||||
const trimmed = path.replace(/\/+$/, "");
|
||||
return trimmed.slice(trimmed.lastIndexOf("/") + 1) || "/";
|
||||
}
|
||||
|
||||
/**
|
||||
* The local project path a remote path is deployed from, through the server's
|
||||
* mappings (the most specific deployment path wins); null when not mapped.
|
||||
* `root` is the server's root path as resolved on the host.
|
||||
*/
|
||||
export function localPathForRemote(
|
||||
remotePath: string,
|
||||
root: string,
|
||||
mappings: DeploymentServerValid["mappings"],
|
||||
projectRoot: string,
|
||||
): string | null {
|
||||
let best: { len: number; local: string } | null = null;
|
||||
for (const m of mappings) {
|
||||
const deployed = m.deployment.startsWith("/")
|
||||
? m.deployment.replace(/\/+$/, "") || "/"
|
||||
: joinRemote(root, m.deployment);
|
||||
const within =
|
||||
remotePath === deployed || remotePath.startsWith(deployed === "/" ? "/" : `${deployed}/`) ? deployed : null;
|
||||
if (within === null || (best && best.len >= deployed.length)) continue;
|
||||
const rest = remotePath.slice(deployed.length).replace(/^\/+/, "");
|
||||
const base = joinRemote(projectRoot, m.local);
|
||||
best = { len: deployed.length, local: joinRemote(base, rest) };
|
||||
}
|
||||
return best?.local ?? null;
|
||||
}
|
||||
|
||||
export type SshRemedy = "trustHostKey" | "secret" | null;
|
||||
|
||||
/**
|
||||
* What the user can do about a failed SSH connection (`ide-ssh` errors): trust
|
||||
* an unknown host key (never a changed one) or type a missing / rejected secret.
|
||||
*/
|
||||
export function sshRemedy(kind: string | undefined, message: string, auth: SshAuthValid): SshRemedy {
|
||||
if (/^Unknown SSH host key/.test(message)) return "trustHostKey";
|
||||
if (kind !== "permissionDenied") return null;
|
||||
if (/^SSH host key .* has changed/.test(message)) return null;
|
||||
if (/^SSH (password|key passphrase) required|^cannot decrypt SSH key/.test(message)) return "secret";
|
||||
// With OpenSSH config + agent there is nothing to type unless a key is encrypted.
|
||||
if (/^SSH authentication failed/.test(message)) return auth === "openSsh" ? null : "secret";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let v = bytes / 1024;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v < 10 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
|
||||
}
|
||||
|
||||
/** `2026-10-27 14:03` from seconds since the epoch (local time). */
|
||||
export function formatTime(seconds: number | null | undefined): string {
|
||||
if (seconds == null) return "";
|
||||
const d = new Date(seconds * 1000);
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
/** Remote Host tool window operations: list, open, create, rename, delete and download remote files. */
|
||||
import { txTemplate } from "@/platform/i18n";
|
||||
import { call, commands, type RemoteEntry } from "@/platform/ipc";
|
||||
import { useProject } from "@/platform/project";
|
||||
import type { DeploymentServerValid } from "@/platform/remoteSettings";
|
||||
import { copyText } from "@/ui";
|
||||
import { editorService, notifications } from "@/workbench/contributions";
|
||||
import { promptInput } from "@/workbench/inputDialogs";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { downloadPaths } from "./actions";
|
||||
import { joinRemote, localPathForRemote, remoteName, remoteParent } from "./model";
|
||||
import { withSsh } from "./ssh";
|
||||
import { EMPTY_TREE, patchTree, useDeployment } from "./store";
|
||||
|
||||
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
||||
|
||||
const tree = (server: string) => useDeployment.getState().trees[server] ?? EMPTY_TREE;
|
||||
|
||||
function sortEntries(entries: RemoteEntry[]): RemoteEntry[] {
|
||||
return [...entries].sort((a, b) => (a.isDir === b.isDir ? a.name.localeCompare(b.name) : a.isDir ? -1 : 1));
|
||||
}
|
||||
|
||||
function fail(title: string, e: unknown) {
|
||||
notifications().notify({ level: "error", title, body: errorText(e), group: "tools", key: "deployment.remoteHost" });
|
||||
}
|
||||
|
||||
export async function loadFolder(server: DeploymentServerValid, path: string) {
|
||||
try {
|
||||
const entries = await withSsh(server.sshConfig, () => call(commands.remoteList(server.sshConfig, path)));
|
||||
patchTree(server.id, (t) => {
|
||||
const folderErrors = { ...t.folderErrors };
|
||||
delete folderErrors[path];
|
||||
return { listings: { ...t.listings, [path]: sortEntries(entries) }, folderErrors };
|
||||
});
|
||||
} catch (e) {
|
||||
patchTree(server.id, (t) =>
|
||||
path === t.root ? { error: errorText(e) } : { folderErrors: { ...t.folderErrors, [path]: errorText(e) } },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolves the server root once per session and lists it. */
|
||||
export async function ensureRoot(server: DeploymentServerValid, force = false) {
|
||||
const current = tree(server.id);
|
||||
if (current.root && !force && current.listings[current.root]) return;
|
||||
patchTree(server.id, { error: null });
|
||||
try {
|
||||
const root =
|
||||
current.root && !force
|
||||
? current.root
|
||||
: await withSsh(server.sshConfig, () => call(commands.deployServerRoot(server.id)));
|
||||
patchTree(server.id, { root });
|
||||
await loadFolder(server, root);
|
||||
} catch (e) {
|
||||
patchTree(server.id, { error: errorText(e) });
|
||||
}
|
||||
}
|
||||
|
||||
/** Reloads the root and the expanded folders. */
|
||||
export async function refreshAll(server: DeploymentServerValid) {
|
||||
const t = tree(server.id);
|
||||
if (!t.root) return ensureRoot(server, true);
|
||||
patchTree(server.id, { listings: {}, folderErrors: {}, error: null });
|
||||
await loadFolder(server, t.root);
|
||||
await Promise.all([...t.expanded].map((p) => loadFolder(server, p)));
|
||||
}
|
||||
|
||||
export async function openRemote(server: DeploymentServerValid, entry: RemoteEntry) {
|
||||
try {
|
||||
const local = await withSsh(server.sshConfig, () => call(commands.remoteOpen(server.sshConfig, entry.path)));
|
||||
await editorService().openFile(local, { focus: true });
|
||||
} catch (e) {
|
||||
fail(`Cannot open ${entry.name}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function newFolder(server: DeploymentServerValid, parent: string) {
|
||||
const name = await promptInput({
|
||||
title: "New Folder",
|
||||
label: "Name:",
|
||||
validate: (v) => (!v.trim() ? "Enter a name" : /\//.test(v) ? "A name cannot contain “/”" : null),
|
||||
});
|
||||
if (!name) return;
|
||||
try {
|
||||
await withSsh(server.sshConfig, () => call(commands.remoteMkdir(server.sshConfig, joinRemote(parent, name))));
|
||||
patchTree(server.id, (t) => ({ expanded: new Set([...t.expanded, parent]) }));
|
||||
await loadFolder(server, parent);
|
||||
} catch (e) {
|
||||
fail("Cannot create folder", e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameEntry(server: DeploymentServerValid, entry: RemoteEntry) {
|
||||
const name = await promptInput({
|
||||
title: "Rename",
|
||||
label: "New name:",
|
||||
initial: entry.name,
|
||||
validate: (v) => (!v.trim() ? "Enter a name" : /\//.test(v) ? "A name cannot contain “/”" : null),
|
||||
});
|
||||
if (!name || name === entry.name) return;
|
||||
const parent = remoteParent(entry.path);
|
||||
try {
|
||||
await withSsh(server.sshConfig, () =>
|
||||
call(commands.remoteRename(server.sshConfig, entry.path, joinRemote(parent, name))),
|
||||
);
|
||||
await loadFolder(server, parent);
|
||||
} catch (e) {
|
||||
fail(`Cannot rename ${entry.name}`, e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteEntries(server: DeploymentServerValid, entries: RemoteEntry[]) {
|
||||
if (!entries.length) return;
|
||||
const ok = await confirm({
|
||||
title: "Delete",
|
||||
message: txTemplate(
|
||||
entries.length === 1
|
||||
? entries[0]!.isDir
|
||||
? `Delete folder “${entries[0]!.path}” from the server?`
|
||||
: `Delete file “${entries[0]!.path}” from the server?`
|
||||
: `Delete ${entries.length} items from the server?`,
|
||||
),
|
||||
ok: "Delete",
|
||||
icon: "warning",
|
||||
});
|
||||
if (!ok) return;
|
||||
const parents = new Set<string>();
|
||||
for (const entry of entries) {
|
||||
try {
|
||||
await withSsh(server.sshConfig, () => call(commands.remoteDelete(server.sshConfig, entry.path)));
|
||||
parents.add(remoteParent(entry.path));
|
||||
} catch (e) {
|
||||
fail(`Cannot delete ${entry.name}`, e);
|
||||
}
|
||||
}
|
||||
await Promise.all([...parents].map((p) => loadFolder(server, p)));
|
||||
}
|
||||
|
||||
/** Downloads mapped entries to their local paths; unmapped ones are reported. */
|
||||
export async function downloadToProject(server: DeploymentServerValid, entries: RemoteEntry[]) {
|
||||
const projectRoot = useProject.getState().project?.root;
|
||||
const root = tree(server.id).root;
|
||||
if (!projectRoot || !root) return;
|
||||
const locals: string[] = [];
|
||||
const unmapped: string[] = [];
|
||||
for (const e of entries) {
|
||||
const local = localPathForRemote(e.path, root, server.mappings, projectRoot);
|
||||
if (local) locals.push(local);
|
||||
else unmapped.push(remoteName(e.path));
|
||||
}
|
||||
if (unmapped.length)
|
||||
notifications().notify({
|
||||
level: "warning",
|
||||
title: "Not mapped to a project path",
|
||||
body: unmapped.join(", "),
|
||||
group: "tools",
|
||||
key: "deployment.unmapped",
|
||||
});
|
||||
if (locals.length) await downloadPaths(server, locals);
|
||||
}
|
||||
|
||||
export const copyRemotePath = (entry: RemoteEntry) => void copyText(entry.path);
|
||||
@@ -0,0 +1,32 @@
|
||||
/** Runs remote work, offering to trust an unknown host key or asking for a password when that is what failed. */
|
||||
import { call, commands, IpcError } from "@/platform/ipc";
|
||||
import { workflowSettings } from "@/platform/workflowSettings";
|
||||
import { confirm } from "@/workbench/messages";
|
||||
import { sshRemedy } from "./model";
|
||||
import { promptSecret } from "./store";
|
||||
|
||||
export async function withSsh<T>(ssh: string, run: () => Promise<T>): Promise<T> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await run();
|
||||
} catch (e) {
|
||||
if (attempt >= 3 || !(e instanceof IpcError) || !ssh) throw e;
|
||||
const auth = workflowSettings().ssh.configs.find((c) => c.id === ssh)?.auth ?? "openSsh";
|
||||
const remedy = sshRemedy(e.kind, e.message, auth);
|
||||
if (remedy === "trustHostKey") {
|
||||
const ok = await confirm({
|
||||
title: "Unknown Host Key",
|
||||
message: e.message,
|
||||
ok: "Trust and Connect",
|
||||
icon: "warning",
|
||||
});
|
||||
if (!ok) throw e;
|
||||
await call(commands.sshTrustHost(ssh));
|
||||
} else if (remedy === "secret") {
|
||||
const answer = await promptSecret(ssh, e.message);
|
||||
if (!answer) throw e;
|
||||
await commands.sshSetSecret(ssh, answer.secret, answer.save);
|
||||
} else throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/** Session state of the deployment feature: the Sync dialog, the SSH secret prompt and the Remote Host tree. */
|
||||
import { create } from "zustand";
|
||||
import type { RemoteEntry, RequestId, SyncEntry } from "@/platform/ipc";
|
||||
import type { FocusedPart, SyncChoice } from "./model";
|
||||
|
||||
export const REMOTE_HOST_WINDOW = "remoteHost";
|
||||
|
||||
export interface SyncDialogState {
|
||||
server: string;
|
||||
serverName: string;
|
||||
/** Absolute local path compared (file or folder). */
|
||||
path: string;
|
||||
request: RequestId | null;
|
||||
entries: SyncEntry[] | null;
|
||||
error: string | null;
|
||||
choices: Record<string, SyncChoice>;
|
||||
hideSame: boolean;
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
export interface SecretPrompt {
|
||||
ssh: string;
|
||||
message: string;
|
||||
resolve: (answer: { secret: string; save: boolean } | null) => void;
|
||||
}
|
||||
|
||||
/** Remote Host tool window, per server: resolved root, loaded folders, expanded folders. */
|
||||
export interface RemoteTreeState {
|
||||
root: string | null;
|
||||
error: string | null;
|
||||
listings: Record<string, RemoteEntry[]>;
|
||||
folderErrors: Record<string, string>;
|
||||
expanded: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface DeploymentState {
|
||||
sync: SyncDialogState | null;
|
||||
secret: SecretPrompt | null;
|
||||
/** Which workbench part had focus last (targets of actions run from Find Action). */
|
||||
part: FocusedPart;
|
||||
/** Server shown in the Remote Host tool window. */
|
||||
remoteServer: string | null;
|
||||
trees: Record<string, RemoteTreeState>;
|
||||
}
|
||||
|
||||
export const useDeployment = create<DeploymentState>(() => ({
|
||||
sync: null,
|
||||
secret: null,
|
||||
part: null,
|
||||
remoteServer: null,
|
||||
trees: {},
|
||||
}));
|
||||
|
||||
export const EMPTY_TREE: RemoteTreeState = {
|
||||
root: null,
|
||||
error: null,
|
||||
listings: {},
|
||||
folderErrors: {},
|
||||
expanded: new Set(),
|
||||
};
|
||||
|
||||
export function patchTree(
|
||||
server: string,
|
||||
patch: Partial<RemoteTreeState> | ((t: RemoteTreeState) => Partial<RemoteTreeState>),
|
||||
) {
|
||||
useDeployment.setState((s) => {
|
||||
const current = s.trees[server] ?? EMPTY_TREE;
|
||||
const next = typeof patch === "function" ? patch(current) : patch;
|
||||
return { trees: { ...s.trees, [server]: { ...current, ...next } } };
|
||||
});
|
||||
}
|
||||
|
||||
export function patchSync(patch: Partial<SyncDialogState>) {
|
||||
useDeployment.setState((s) => (s.sync ? { sync: { ...s.sync, ...patch } } : {}));
|
||||
}
|
||||
|
||||
/** Asks for the password / passphrase of SSH configuration `ssh`. */
|
||||
export function promptSecret(ssh: string, message: string): Promise<{ secret: string; save: boolean } | null> {
|
||||
return new Promise((resolve) => {
|
||||
useDeployment.getState().secret?.resolve(null);
|
||||
useDeployment.setState({
|
||||
secret: {
|
||||
ssh,
|
||||
message,
|
||||
resolve: (answer) => {
|
||||
useDeployment.setState({ secret: null });
|
||||
resolve(answer);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -35,7 +35,10 @@ import { activate as services } from "./services";
|
||||
import { activate as github } from "./github";
|
||||
import { activate as httpClient } from "./http-client";
|
||||
import { activate as database } from "./database";
|
||||
import { activate as deployment } from "./deployment";
|
||||
import { activate as vim, activateSettingsPages as vimPages } from "./vim";
|
||||
import { activate as ssh } from "./ssh";
|
||||
import { activate as remoteDev } from "./remote-dev";
|
||||
|
||||
type Activate = () => void | (() => void);
|
||||
|
||||
@@ -72,7 +75,10 @@ const features: Array<[string, Activate]> = [
|
||||
["github", github],
|
||||
["http-client", httpClient],
|
||||
["database", database],
|
||||
["deployment", deployment],
|
||||
["vim", vim],
|
||||
["ssh", ssh],
|
||||
["remote-dev", remoteDev],
|
||||
];
|
||||
|
||||
export function activateFeatures(): () => void {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { initProjectRoots, useProjectRoots } from "@/platform/projectRoots";
|
||||
import { folderIconName } from "@/ui";
|
||||
import {
|
||||
localChangedPaths,
|
||||
provideProjectViewSelection,
|
||||
registerOverlay,
|
||||
registerProjectViewRoot,
|
||||
registerToolWindow,
|
||||
@@ -19,6 +20,7 @@ import { NewPopupHost } from "./NewPopup";
|
||||
import { RefactoringPreviewHost } from "./RefactoringPreview";
|
||||
import { ProjectHeaderActions, useProjectHeaderMenu, useProjectTitleMenu } from "./HeaderActions";
|
||||
import { loadHomeDir } from "./home";
|
||||
import { selectedNodes } from "./operations";
|
||||
import { ProjectView } from "./ProjectView";
|
||||
import { applyFsChange, initProjectTree, reloadAll, setChangedFiles, setOpenFiles, useProjectTree } from "./store";
|
||||
|
||||
@@ -128,6 +130,11 @@ export function activate(): () => void {
|
||||
registerOverlay({ id: "project.refactoringPreview", component: RefactoringPreviewHost }),
|
||||
registerOverlay({ id: "project.newPopup", component: NewPopupHost }),
|
||||
registerProjectActions(),
|
||||
provideProjectViewSelection(() =>
|
||||
selectedNodes()
|
||||
.filter((n) => !n.group)
|
||||
.map((n) => n.path),
|
||||
),
|
||||
onIdeEvent("fsChanged", (e) => void applyFsChange(e)),
|
||||
];
|
||||
return () => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
/** Picks an SSH configuration and a folder on that host, then opens it in this window. */
|
||||
import { useState } from "react";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import { useTx } from "@/platform/i18n";
|
||||
import { call, commands, type RemoteEntry } from "@/platform/ipc";
|
||||
import { connectRemote } from "@/platform/remote";
|
||||
import { sshLabel } from "@/platform/remoteSettings";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { Button, Dialog, Icon, Input, Select } from "@/ui";
|
||||
import { browsableFolders, closeConnectDialog, listedDirectory, remoteParent, useConnectDialog } from "./model";
|
||||
import styles from "./RemoteDev.module.css";
|
||||
|
||||
export function ConnectHost() {
|
||||
const open = useConnectDialog((s) => s.open);
|
||||
return open ? <ConnectDialog /> : null;
|
||||
}
|
||||
|
||||
const SSH_SETTINGS_PAGE = "tools.sshConfigurations";
|
||||
|
||||
function ConnectDialog() {
|
||||
const tx = useTx();
|
||||
const configs = useWorkflowSettings((s) => s.ssh.configs);
|
||||
const [ssh, setSsh] = useState(configs[0]?.id ?? "");
|
||||
const [path, setPath] = useState("~");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [browse, setBrowse] = useState<{ dir: string; folders: RemoteEntry[] } | null>(null);
|
||||
|
||||
const list = async (dir: string) => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
const entries = await call(commands.remoteList(ssh, dir));
|
||||
const shown = listedDirectory(dir, entries);
|
||||
setBrowse({ dir: shown, folders: browsableFolders(entries) });
|
||||
setPath(shown);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
setError(null);
|
||||
setBusy(true);
|
||||
try {
|
||||
await connectRemote(ssh, path.trim() || "~");
|
||||
closeConnectDialog();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const options = configs.map((c) => ({ value: c.id, label: c.name.trim() || sshLabel(c), detail: sshLabel(c) }));
|
||||
const ready = !!ssh && !busy && !!path.trim();
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
title={tx("Remote Development")}
|
||||
onClose={closeConnectDialog}
|
||||
width={560}
|
||||
buttons={[
|
||||
{ label: busy ? "Connecting…" : "Open", role: "default", disabled: !ready, onClick: () => void connect() },
|
||||
{ label: "Cancel", role: "cancel" },
|
||||
]}
|
||||
>
|
||||
<form
|
||||
className={styles.form}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (ready) void connect();
|
||||
}}
|
||||
>
|
||||
<p className={styles.hint}>
|
||||
{tx(
|
||||
"The project runs on the remote host; this window shows it. The IDE backend is installed there on first use.",
|
||||
)}
|
||||
</p>
|
||||
<label className={styles.label}>{tx("SSH configuration:")}</label>
|
||||
<div className={styles.row}>
|
||||
<Select
|
||||
className={styles.grow}
|
||||
value={ssh || null}
|
||||
options={options}
|
||||
placeholder={tx("No SSH configurations")}
|
||||
onChange={(id) => {
|
||||
setSsh(id);
|
||||
setBrowse(null);
|
||||
}}
|
||||
aria-label={tx("SSH configuration")}
|
||||
/>
|
||||
<Button onClick={() => void executeAction("file.settings", SSH_SETTINGS_PAGE)}>{tx("Configure…")}</Button>
|
||||
</div>
|
||||
<label className={styles.label} htmlFor="remote-dev-path">
|
||||
{tx("Project directory:")}
|
||||
</label>
|
||||
<div className={styles.row}>
|
||||
<Input
|
||||
id="remote-dev-path"
|
||||
className={styles.grow}
|
||||
data-autofocus
|
||||
value={path}
|
||||
spellCheck={false}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
/>
|
||||
<Button disabled={!ssh || busy} onClick={() => void list(path.trim() || "~")}>
|
||||
{tx("Browse")}
|
||||
</Button>
|
||||
</div>
|
||||
{browse && (
|
||||
<ul className={styles.folders} aria-label={tx("Remote folders")}>
|
||||
{browse.dir !== "/" && (
|
||||
<li>
|
||||
<button type="button" className={styles.folder} onClick={() => void list(remoteParent(browse.dir))}>
|
||||
<Icon name="folder" />
|
||||
..
|
||||
</button>
|
||||
</li>
|
||||
)}
|
||||
{browse.folders.map((f) => (
|
||||
<li key={f.path}>
|
||||
<button type="button" className={styles.folder} onClick={() => void list(f.path)}>
|
||||
<Icon name="folder" />
|
||||
{f.name}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 143 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Заблокувати користувача