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.
Цей коміт міститься в:
l0ng-ai committed 2026-10-27 17:43:48 +08:00
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",
+1
Переглянути файл
@@ -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"] }
+17 -6
Переглянути файл
@@ -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.
+49
Переглянути файл
@@ -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
+71
Переглянути файл
@@ -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
}
+149
Переглянути файл
@@ -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);
}
}
+280
Переглянути файл
@@ -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
});
}
+192
Переглянути файл
@@ -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
});
}
+32
Переглянути файл
@@ -0,0 +1,32 @@
//! Raw file bytes for the image viewer and hex view (see `ide_vfs::bytes`).
use crate::Host;
use crate::rpc::Dispatcher;
use crate::ops::doc::blocking;
use ide_core::IdeResult;
use ide_vfs::{ByteRange, FileProbe};
use std::path::PathBuf;
/// Size and text/binary classification, from the file's head only.
pub async fn fs_probe(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<FileProbe> {
blocking(move || ide_vfs::probe(&path)).await
}
/// Up to `len` bytes from `offset` (capped per call), base64-encoded.
pub async fn fs_read_range(_host: &Host, _window: &str, path: PathBuf, offset: u64, len: u32) -> IdeResult<ByteRange> {
blocking(move || ide_vfs::read_range(&path, offset, len)).await
}
/// An image file as a `data:` URL.
pub async fn fs_read_image(_host: &Host, _window: &str, path: PathBuf) -> IdeResult<String> {
blocking(move || ide_vfs::read_data_url(&path)).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("fs_probe", |c| async move { fs_probe(&c.host, &c.window, c.arg("path")?).await });
d.add("fs_read_range", |c| async move {
fs_read_range(&c.host, &c.window, c.arg("path")?, c.arg("offset")?, c.arg("len")?).await
});
d.add("fs_read_image", |c| async move { fs_read_image(&c.host, &c.window, c.arg("path")?).await });
}
+45
Переглянути файл
@@ -0,0 +1,45 @@
//! Run with Coverage: launch plans and coverage reports.
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite};
use ide_run::RunConfig;
/// Output directories kept per kind (older runs' data is deleted).
pub const KEEP_RUNS: usize = 10;
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
/// How to run `config` with coverage: the command to launch and where its report will be,
/// or which tool is missing.
pub async fn coverage_prepare(host: &Host, window: &str, config: RunConfig) -> IdeResult<CoveragePrepared> {
let project = host.project(window)?;
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
blocking(move || {
let out = ide_run::wrap::new_output_dir(&cache, KEEP_RUNS)?;
Ok(ide_coverage::prepare(&root, &config, &out))
})
.await
}
/// Reads the report a coverage run left behind.
pub async fn coverage_collect(_host: &Host, _window: &str, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
blocking(move || ide_coverage::collect(&report)).await
}
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
/// with paths mapped into this project.
pub async fn coverage_import(host: &Host, window: &str, path: IpcPath) -> IdeResult<CoverageSuite> {
let root = host.project(window)?.root.clone();
blocking(move || ide_coverage::import(&path, &root)).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("coverage_prepare", |c| async move { coverage_prepare(&c.host, &c.window, c.arg("config")?).await });
d.add("coverage_collect", |c| async move { coverage_collect(&c.host, &c.window, c.arg("report")?).await });
d.add("coverage_import", |c| async move { coverage_import(&c.host, &c.window, c.arg("path")?).await });
}
+357
Переглянути файл
@@ -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 },
);
}
+42
Переглянути файл
@@ -0,0 +1,42 @@
use crate::Host;
use ide_core::{ChangeSet, DocId, IdeResult};
use ide_workspace::DocumentInfo;
use std::path::PathBuf;
pub async fn open(host: &Host, window: &str, path: PathBuf) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
host.blocking(move || {
let info = project.documents.open(&path)?;
project.index.mark_recent(&path);
Ok(info)
})
.await
}
pub fn close(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
host.project(window)?.documents.close(doc);
Ok(())
}
/// Applies a CodeMirror change set; returns the new version (`outOfSync` when `base_version` is stale).
pub fn edit(host: &Host, window: &str, doc: DocId, base_version: u32, changes: ChangeSet) -> IdeResult<u32> {
host.project(window)?.documents.edit(doc, base_version, changes)
}
pub async fn save(host: &Host, window: &str, doc: DocId) -> IdeResult<()> {
let project = host.project(window)?;
host.blocking(move || project.documents.save(doc)).await
}
pub async fn save_all(host: &Host, window: &str) -> IdeResult<()> {
let project = host.project(window)?;
host.blocking(move || project.documents.save_all()).await
}
pub async fn snapshot(host: &Host, window: &str, doc: DocId) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
host.blocking(move || project.documents.snapshot(doc)).await
}
mod commands;
pub use commands::*;
+125
Переглянути файл
@@ -0,0 +1,125 @@
#[allow(unused_imports)]
use super::*;
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeError, IdeResult, OutlineItem, Range};
use ide_language::{FoldRange, SyntaxHighlights};
use ide_text::LineEnding;
use ide_workspace::{DocumentInfo, HistoryEntry};
use std::path::PathBuf;
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool,
/// keeping it off the main thread and the async runtime's workers.
pub async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tokio::task::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Opens a read-only virtual document (see `lsp_virtual_document`) as an
/// editor buffer, so syntax features and language-server requests work in it.
pub async fn doc_open_virtual(host: &Host, window: &str, uri: String) -> IdeResult<DocumentInfo> {
let project = host.project(window)?;
let vd = project.lsp.virtual_document(&uri).await?;
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
pub fn doc_set_encoding(host: &Host, window: &str, doc: DocId, encoding: String, reload: bool) -> IdeResult<String> {
host.project(window)?.documents.set_encoding(doc, &encoding, reload)
}
pub fn doc_set_line_ending(host: &Host, window: &str, doc: DocId, line_ending: LineEnding) -> IdeResult<()> {
host.project(window)?.documents.set_line_ending(doc, line_ending)
}
/// Structure view: LSP document symbols when available (with tree-sitter
/// visibility), tree-sitter otherwise.
/// Tree-sitter answers come from the per-document tree cache (incremental
/// re-parse); large documents get no syntax structure.
pub async fn doc_outline(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
let project = host.project(window)?;
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
blocking(move || {
let snap = match project.documents.text_snapshot(doc) {
Ok(snap) => snap,
Err(e) => return lsp.ok_or(e),
};
let syntax = || project.syntax.outline(doc, &snap.language, snap.version, &snap.text);
Ok(match lsp {
// Server symbols carry no visibility; borrow it from the syntax tree.
Some(mut items) => {
if !snap.large {
ide_language::merge_visibility(&mut items, &syntax());
}
items
}
None if snap.large => Vec::new(),
None => syntax(),
})
})
.await
}
pub async fn doc_folding(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<FoldRange>> {
let project = host.project(window)?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(Vec::new());
}
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
})
.await
}
/// Tree-sitter highlighting for languages the UI has no CodeMirror mode for;
/// empty when the grammar has no highlights query or the document is large.
pub async fn doc_highlights(host: &Host, window: &str, doc: DocId) -> IdeResult<SyntaxHighlights> {
let project = host.project(window)?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(SyntaxHighlights::default());
}
Ok(project.syntax.highlights(doc, &snap.language, snap.version, &snap.text))
})
.await
}
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
/// Large documents fall back to word / line / document ranges.
pub async fn doc_selection_ranges(host: &Host, window: &str, doc: DocId, offset: u32) -> IdeResult<Vec<Range>> {
let project = host.project(window)?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
})
.await
}
pub fn history_list(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
host.project(window)?.history.list(&path)
}
pub fn history_content(host: &Host, window: &str, id: String) -> IdeResult<String> {
host.project(window)?.history.content(&id)
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("doc_open_virtual", |c| async move { doc_open_virtual(&c.host, &c.window, c.arg("uri")?).await });
d.add("doc_set_encoding", |c| async move {
doc_set_encoding(&c.host, &c.window, c.arg("doc")?, c.arg("encoding")?, c.arg("reload")?)
});
d.add("doc_set_line_ending", |c| async move {
doc_set_line_ending(&c.host, &c.window, c.arg("doc")?, c.arg("lineEnding")?)
});
d.add("doc_outline", |c| async move { doc_outline(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_folding", |c| async move { doc_folding(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_highlights", |c| async move { doc_highlights(&c.host, &c.window, c.arg("doc")?).await });
d.add("doc_selection_ranges", |c| async move {
doc_selection_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("offset")?).await
});
d.add("history_list", |c| async move { history_list(&c.host, &c.window, c.arg("path")?) });
d.add("history_content", |c| async move { history_content(&c.host, &c.window, c.arg("id")?) });
}
+85
Переглянути файл
@@ -0,0 +1,85 @@
use crate::Host;
use ide_core::IdeResult;
use ide_vfs::FileEntry;
use std::path::PathBuf;
/// Files of a deleted folder snapshotted in Local History.
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
pub fn read_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
let excludes = host
.project(window)
.map(|p| {
let globs = p.exclude_globs();
// `read_dir` matches relative to the enclosing repository: re-anchor
// project-root patterns (`/build`) when the project is a repo subfolder.
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
Some(rel) => {
let rel = rel.to_string_lossy().replace('\\', "/");
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
}
None => globs,
}
})
.unwrap_or_default();
ide_vfs::read_dir(&path, &excludes)
}
/// Creates an empty file; inside a project, honours the EditorConfig
/// `charset` (writes the BOM for `utf-8-bom` / UTF-16).
pub fn create_file(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
match host.project(window) {
Ok(p) => {
let defaults = p.documents.file_defaults();
if defaults.editorconfig {
ide_workspace::editorconfig::create_file(p.documents.editorconfig(), &path)?;
} else {
ide_vfs::create_file(&path)?;
}
// No EditorConfig charset: File Encodings › "Create UTF-8 files with BOM".
let empty = std::fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(false);
let bom = defaults.new_file_bytes();
if empty && !bom.is_empty() {
std::fs::write(&path, bom)?;
}
Ok(())
}
Err(_) => ide_vfs::create_file(&path),
}
}
/// Toggle Read-only Attribute: sets the writable bit (and the open document's
/// state). Returns whether the file is read-only now.
pub fn set_writable(host: &Host, window: &str, path: PathBuf, writable: bool) -> IdeResult<bool> {
match host.project(window) {
Ok(p) => p.documents.set_writable(&path, writable),
Err(_) => ide_vfs::set_writable(&path, writable).map(|()| !writable),
}
}
/// Moves to the trash; Local History keeps the contents so deleted files can be restored.
pub async fn delete(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
let history = host.project(window).ok().map(|p| p.history.clone());
host.blocking(move || {
if let Some(h) = history {
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
}
ide_vfs::delete(&path)
})
.await
}
pub async fn copy(host: &Host, from: PathBuf, to: PathBuf) -> IdeResult<()> {
host.blocking(move || ide_vfs::copy(&from, &to)).await
}
/// Reads a text file without opening it as a document; prefers the unsaved buffer.
pub async fn read_text(host: &Host, window: &str, path: PathBuf) -> IdeResult<String> {
let project = host.project(window).ok();
host.blocking(move || match project {
Some(p) => p.text_of(&path),
None => Ok(ide_vfs::load_text(&path)?.text),
})
.await
}
+55
Переглянути файл
@@ -0,0 +1,55 @@
//! Local History beyond single files: folders, labels, restoring deleted files.
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::IdeResult;
use ide_workspace::DirectoryHistory;
use std::path::PathBuf;
use super::doc::blocking;
/// Show History on a folder: snapshots of the files under it (deleted ones included) and labels.
pub async fn history_list_dir(host: &Host, window: &str, path: PathBuf) -> IdeResult<DirectoryHistory> {
let project = host.project(window)?;
blocking(move || project.history.list_dir(&path)).await
}
/// Local History › Put Label…: a named point in the history of every file.
pub fn history_put_label(host: &Host, window: &str, label: String) -> IdeResult<()> {
let project = host.project(window)?;
project.history.put_label(&project.root, &label)
}
/// Revert to a snapshot: writes its content to `path` (re-creating a deleted
/// file and its folders); an open editor updates in place.
pub async fn history_restore(host: &Host, window: &str, path: PathBuf, id: String) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
let text = project.history.content(&id)?;
if let Some(doc) = project.documents.by_path(&path) {
project.documents.replace_all(doc, &text)?;
return project.documents.save(doc);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if !path.exists() {
ide_vfs::create_file(&path)?;
}
let doc = project.documents.open(&path)?.id;
let result = project.documents.replace_all(doc, &text).and_then(|_| project.documents.save(doc));
project.documents.close(doc);
result
})
.await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("history_list_dir", |c| async move { history_list_dir(&c.host, &c.window, c.arg("path")?).await });
d.add("history_put_label", |c| async move { history_put_label(&c.host, &c.window, c.arg("label")?) });
d.add(
"history_restore",
|c| async move { history_restore(&c.host, &c.window, c.arg("path")?, c.arg("id")?).await },
);
}
+95
Переглянути файл
@@ -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 });
}
+319
Переглянути файл
@@ -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
});
}
+83
Переглянути файл
@@ -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()))?
}
}
+176
Переглянути файл
@@ -0,0 +1,176 @@
use super::doc::blocking;
use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use ide_core::{DocId, IdeResult, IpcPath, Location, Pos};
use ide_index::{FileMatch, IndexStatus, SymbolFilter, SymbolMatch};
use ide_language::{FileReference, WebReference};
use ide_workspace::file_refs::{self, PathCompletion};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::PathBuf;
/// A web reference no language server resolves and where it points: `class="card"` →
/// `.card` selectors, `id` → `#id` selectors, `href="#top"` → the element, `var(--x)` →
/// `--x:` declarations.
#[derive(Clone, Debug, Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct WebGoto {
pub reference: WebReference,
pub targets: Vec<Location>,
}
const MAX_WEB_TARGETS: usize = 200;
/// The project root and its resource roots (served at `/`), for file references.
fn reference_roots(project: &Project) -> (PathBuf, Vec<PathBuf>) {
let resources = project.roots().resource_dirs().iter().map(|d| project.root.join(d)).collect();
(project.root.clone(), resources)
}
pub fn index_status(host: &Host, window: &str) -> IdeResult<IndexStatus> {
Ok(host.project(window)?.index.status())
}
pub fn find_files(host: &Host, window: &str, query: String, limit: u32) -> IdeResult<Vec<FileMatch>> {
Ok(host.project(window)?.index.find_files(&query, limit as usize))
}
pub fn find_symbols(
host: &Host,
window: &str,
query: String,
filter: SymbolFilter,
limit: u32,
) -> IdeResult<Vec<SymbolMatch>> {
Ok(host.project(window)?.index.find_symbols(&query, filter, limit as usize))
}
pub fn mark_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
host.project(window)?.index.mark_recent(&path);
Ok(())
}
pub fn remove_recent(host: &Host, window: &str, path: PathBuf) -> IdeResult<()> {
host.project(window)?.index.remove_recent(&path);
Ok(())
}
/// Recently opened files, most recent first (⌘E).
pub fn recent_files(host: &Host, window: &str) -> IdeResult<Vec<ide_core::IpcPath>> {
Ok(host.project(window)?.index.recent_files().into_iter().map(Into::into).collect())
}
/// The web reference at `pos` of `doc` and its targets; `None` when there is no reference.
pub async fn web_goto(host: &Host, window: &str, doc: DocId, pos: Pos) -> IdeResult<Option<WebGoto>> {
let project = host.project(window)?;
let syntax = host.syntax();
blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large {
return Ok(None);
}
let Some(reference) = syntax.web_reference_at(&snap.language, &snap.text, pos) else { return Ok(None) };
let here = |range| Location { path: snap.path.clone(), range, uri: None };
let targets = match reference.symbol() {
None => syntax.web_anchors(&snap.language, &snap.text, &reference.name).into_iter().map(here).collect(),
Some((name, kind)) => {
// The open document from its (possibly unsaved) text, other files from the index.
let mut targets: Vec<Location> = syntax
.web_definitions(&snap.language, &snap.text)
.into_iter()
.filter(|t| t.name == name && t.kind == kind)
.map(|t| here(t.range))
.collect();
targets.extend(
project
.index
.symbols_named(&name, Some(kind), MAX_WEB_TARGETS)
.into_iter()
.filter(|m| *m.path != *snap.path)
.map(|m| Location { path: m.path, range: m.range, uri: None }),
);
targets
}
};
Ok(Some(WebGoto { reference, targets }))
})
.await
}
/// The existing file or directory a path written in `from` names (`href`, `url()`, a string literal).
pub async fn fs_resolve_reference(
host: &Host,
window: &str,
from: IpcPath,
value: String,
) -> IdeResult<Option<IpcPath>> {
let project = host.project(window).ok();
blocking(move || {
let (root, resources) = project.as_deref().map(reference_roots).unzip();
let resources = resources.unwrap_or_default();
Ok(file_refs::resolve(&value, &from, root.as_deref(), &resources).map(Into::into))
})
.await
}
/// Files and folders that continue `prefix`, a path being typed in `from`.
pub async fn path_completions(
host: &Host,
window: &str,
from: IpcPath,
prefix: String,
) -> IdeResult<Vec<PathCompletion>> {
let project = host.project(window).ok();
blocking(move || {
let (root, resources) = project.as_deref().map(reference_roots).unzip();
let resources = resources.unwrap_or_default();
Ok(file_refs::complete(&prefix, &from, root.as_deref(), &resources))
})
.await
}
/// Paths in an HTML document or stylesheet that name no existing file.
pub async fn web_unresolved_references(host: &Host, window: &str, doc: DocId) -> IdeResult<Vec<FileReference>> {
let project = host.project(window)?;
let syntax = host.syntax();
blocking(move || {
let snap = project.documents.snapshot(doc)?;
if snap.large {
return Ok(Vec::new());
}
let (root, resources) = reference_roots(&project);
Ok(syntax
.web_file_references(&snap.language, &snap.text)
.into_iter()
.filter(|r| {
file_refs::is_file_reference(&r.value)
&& !file_refs::path_part(&r.value).is_empty()
&& file_refs::resolve(&r.value, &snap.path, Some(&root), &resources).is_none()
})
.collect())
})
.await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("index_status", |c| async move { index_status(&c.host, &c.window) });
d.add("find_files", |c| async move { find_files(&c.host, &c.window, c.arg("query")?, c.arg("limit")?) });
d.add("find_symbols", |c| async move {
find_symbols(&c.host, &c.window, c.arg("query")?, c.arg("filter")?, c.arg("limit")?)
});
d.add("mark_recent", |c| async move { mark_recent(&c.host, &c.window, c.arg("path")?) });
d.add("remove_recent", |c| async move { remove_recent(&c.host, &c.window, c.arg("path")?) });
d.add("recent_files", |c| async move { recent_files(&c.host, &c.window) });
d.add("web_goto", |c| async move { web_goto(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?).await });
d.add("fs_resolve_reference", |c| async move {
fs_resolve_reference(&c.host, &c.window, c.arg("from")?, c.arg("value")?).await
});
d.add("path_completions", |c| async move {
path_completions(&c.host, &c.window, c.arg("from")?, c.arg("prefix")?).await
});
d.add("web_unresolved_references", |c| async move {
web_unresolved_references(&c.host, &c.window, c.arg("doc")?).await
});
}
+135
Переглянути файл
@@ -0,0 +1,135 @@
use crate::{Host, Project};
use ide_core::IdeResult;
use ide_core::settings::Settings;
use ide_workspace::settings::SettingsScope;
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
use std::sync::Arc;
#[derive(Serialize, Deserialize, Type)]
#[serde(rename_all = "camelCase")]
pub struct ProjectInfo {
pub root: ide_core::IpcPath,
pub name: String,
pub is_git: bool,
pub settings: Settings,
}
pub fn info(project: &Project) -> ProjectInfo {
let settings = project.settings.effective();
let custom = settings.general.project_name.trim();
ProjectInfo {
name: if custom.is_empty() {
project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default()
} else {
custom.to_owned()
},
is_git: !project.repos.read().is_empty(),
settings,
root: project.root.clone().into(),
}
}
/// Opens `path` in `window` (off the async workers: it touches the disk).
pub async fn open(host: &Arc<Host>, window: &str, path: &Path) -> IdeResult<Arc<Project>> {
let (h, window, path) = (host.clone(), window.to_owned(), path.to_path_buf());
host.blocking(move || h.open_project(&window, &path)).await
}
pub fn current(host: &Host, window: &str) -> Option<ProjectInfo> {
host.project(window).ok().map(|p| info(&p))
}
/// Effective settings for the window's project, or global settings without one.
pub fn settings(host: &Host, window: &str) -> Settings {
match host.project(window) {
Ok(p) => p.settings.effective(),
Err(_) => host.global_settings.effective(),
}
}
pub fn roots(host: &Host, window: &str) -> IdeResult<ide_workspace::ProjectRoots> {
Ok(host.project(window)?.roots())
}
/// What [`set_settings`] changed; the caller tells the windows.
pub struct SettingsApplied {
pub settings: Settings,
/// A project value (else a global one).
pub project_scope: bool,
/// The project's root, when its content roots changed.
pub roots_changed: Option<ide_core::IpcPath>,
}
/// Sets `key` to `value` (JSON text, e.g. `14` or `"dark"`) for the window's
/// project or globally, and applies it to the open projects: file defaults,
/// file types, language servers, index scope and SSH configurations.
pub fn set_settings(
host: &Host,
window: &str,
scope: SettingsScope,
key: &str,
value: &str,
) -> IdeResult<SettingsApplied> {
let value: serde_json::Value =
serde_json::from_str(value).map_err(|e| ide_core::IdeError::invalid(e.to_string()))?;
let current = host.project(window).ok();
let project_scope = matches!(scope, SettingsScope::Project) && current.is_some();
let settings = match &current {
Some(p) if project_scope => p.settings.set(scope, key, value)?,
Some(p) => {
host.global_settings.set(SettingsScope::Global, key, value)?;
p.settings.reload()
}
None => host.global_settings.set(SettingsScope::Global, key, value)?,
};
if let Some(p) = &current {
p.documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings, Some(&p.root)));
}
// A global value reaches every other open project too.
if !project_scope {
for project in host.projects() {
if current.as_ref().is_some_and(|c| c.root == project.root) {
continue;
}
let effective = project.settings.reload();
project
.documents
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
}
}
let affected = || if project_scope { current.iter().cloned().collect() } else { host.projects() };
// File type associations re-detect open documents (off the command thread).
if key == "files" || key.starts_with("files.fileType") {
let affected = affected();
host.runtime.spawn_blocking(move || {
for project in affected {
project.apply_file_types(&project.settings.effective());
}
});
}
if key == "ssh" || key.starts_with("ssh.") {
host.ssh.set_configs(host.global_settings.effective().ssh.configs);
}
// A new interpreter / toolchain / server executable restarts the language servers it affects.
if ["languages", "tools"].iter().any(|k| key == *k || key.starts_with(&format!("{k}."))) {
for project in affected() {
let lsp = project.lsp.clone();
host.runtime.spawn(async move { lsp.reconfigure().await });
}
}
let roots_changed = key == "projectStructure" || key.starts_with("projectStructure.");
// Content roots and excludes (files.exclude, ignored names, excluded folders) reach the
// index and the watchers without reopening the project (the rescan runs in the background).
if roots_changed || key == "files" || key.starts_with("files.") {
let affected = affected();
host.runtime.spawn_blocking(move || {
for project in affected {
project.refresh_index_scope();
}
});
}
let roots_changed = current.as_ref().filter(|_| roots_changed && project_scope).map(|p| p.root.clone().into());
Ok(SettingsApplied { settings, project_scope, roots_changed })
}
+112
Переглянути файл
@@ -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
});
}
+84
Переглянути файл
@@ -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::*;
+48
Переглянути файл
@@ -0,0 +1,48 @@
#[allow(unused_imports)]
use super::*;
use crate::Host;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult};
use ide_run::RunConfig;
/// Configuration templates: per-type defaults applied to new configurations.
pub fn run_templates(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
let root = host.project(window)?.root.clone();
ide_run::load_templates(&root)
}
pub fn run_save_templates(host: &Host, window: &str, templates: Vec<RunConfig>) -> IdeResult<()> {
let root = host.project(window)?.root.clone();
ide_run::save_templates(&root, &templates)
}
/// The environment runs inherit when "Include system environment variables" is on.
pub fn run_system_env(_host: &Host, _window: &str) -> std::collections::BTreeMap<String, String> {
ide_run::system_env()
}
/// Results from the JUnit XML reports (Surefire, Failsafe, Gradle) of the module at
/// `module` (relative to the project root) written since `since_ms` (Unix epoch, ms).
pub async fn run_test_reports(
host: &Host,
window: &str,
module: Option<std::path::PathBuf>,
since_ms: f64,
) -> IdeResult<Vec<ide_run::junit::TestCaseReport>> {
let root = host.project(window)?.root.clone();
let dir = module.map(|m| root.join(m)).unwrap_or(root);
let since = std::time::UNIX_EPOCH + std::time::Duration::from_millis(since_ms.max(0.0) as u64);
tokio::task::spawn_blocking(move || ide_run::junit::read_reports(&ide_run::junit::report_dirs(&dir), since))
.await
.map_err(|e| IdeError::internal(e.to_string()))
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("run_templates", |c| async move { run_templates(&c.host, &c.window) });
d.add("run_save_templates", |c| async move { run_save_templates(&c.host, &c.window, c.arg("templates")?) });
d.add("run_system_env", |c| async move { Ok(run_system_env(&c.host, &c.window)) });
d.add("run_test_reports", |c| async move {
run_test_reports(&c.host, &c.window, c.arg("module")?, c.arg("sinceMs")?).await
});
}
+170
Переглянути файл
@@ -0,0 +1,170 @@
//! Interpreters / toolchains (`ide-sdk`): discovery, Python virtualenvs and packages.
use crate::Host;
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use ide_core::{IdeError, IdeResult, RequestId};
use ide_sdk::python::PythonPackage;
use ide_sdk::{Sdk, SdkKind};
use std::path::PathBuf;
fn join_err(e: impl std::fmt::Display) -> IdeError {
IdeError::internal(e.to_string())
}
/// Executables found for "run this file", and where single files are compiled to.
#[derive(Clone, Debug, serde::Serialize, specta::Type)]
#[serde(rename_all = "camelCase")]
pub struct LocatedTools {
pub tools: Vec<ide_sdk::locate::LocatedTool>,
pub compile_dir: ide_core::IpcPath,
}
/// Installed interpreters / toolchains of `kind`; project-local ones first.
pub async fn sdk_detect(host: &Host, window: &str, kind: SdkKind) -> IdeResult<Vec<Sdk>> {
let root = host.project(window).ok().map(|p| p.root.clone());
tokio::task::spawn_blocking(move || ide_sdk::detect(kind, root.as_deref())).await.map_err(join_err)
}
/// The interpreter the project uses: the configured one, else its virtualenv.
pub fn sdk_python_effective(host: &Host, window: &str) -> IdeResult<Option<ide_core::IpcPath>> {
let project = host.project(window)?;
Ok(ide_sdk::apply::python_interpreter(&project.root, &project.settings.effective()).map(Into::into))
}
/// SDK / library source folders shown under "External Libraries" in the Project view,
/// with the Maven / Gradle dependency jars of the build model.
pub async fn sdk_project_libraries(host: &Host, window: &str) -> IdeResult<Vec<ide_sdk::libraries::LibraryGroup>> {
let project = host.project(window)?;
let (root, cache, settings) = (project.root.clone(), project.build_cache_dir(), project.settings.effective());
tokio::task::spawn_blocking(move || {
let model = crate::ops::build::load(&root, &cache, &settings);
let deps: Vec<ide_sdk::libraries::BuildDependency> = ide_build::dependency_coordinates(&root, &model)
.into_iter()
.map(|c| ide_sdk::libraries::BuildDependency {
gradle: c.gradle,
group: c.group_id,
artifact: c.artifact_id,
version: c.version,
})
.collect();
ide_sdk::libraries::project_libraries(&root, &settings, &deps)
})
.await
.map_err(join_err)
}
/// Looks up `names` (bare command names): `node_modules/.bin` above `near`, then the search path.
pub async fn sdk_locate_tools(
_host: &Host,
_window: &str,
names: Vec<String>,
near: Option<PathBuf>,
) -> IdeResult<LocatedTools> {
tokio::task::spawn_blocking(move || LocatedTools {
tools: ide_sdk::locate::locate_tools(&names, near.as_deref()),
compile_dir: ide_sdk::locate::compile_dir().into(),
})
.await
.map_err(join_err)
}
/// `base -m venv location`; output lines stream through `on_output`.
pub async fn sdk_python_create_venv(
host: &Host,
_window: &str,
base: PathBuf,
location: PathBuf,
request: RequestId,
on_output: Channel<String>,
) -> IdeResult<Sdk> {
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
ide_sdk::python::create_venv(&base, &location, &cancel, &|line| {
let _ = on_output.send(line);
})
})
.await
.map_err(join_err);
host.end_request(request);
result?
}
pub async fn sdk_python_packages(
host: &Host,
_window: &str,
python: PathBuf,
request: RequestId,
) -> IdeResult<Vec<PythonPackage>> {
let cancel = host.begin_request(request);
let result =
tokio::task::spawn_blocking(move || ide_sdk::python::list_packages(&python, &cancel)).await.map_err(join_err);
host.end_request(request);
result?
}
/// Installs (`uninstall = false`) or removes a package; output streams through `on_output`.
#[allow(clippy::too_many_arguments)]
pub async fn sdk_python_pip(
host: &Host,
_window: &str,
python: PathBuf,
requirement: String,
uninstall: bool,
upgrade: bool,
request: RequestId,
on_output: Channel<String>,
) -> IdeResult<()> {
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
let send = |line: String| {
let _ = on_output.send(line);
};
if uninstall {
ide_sdk::python::uninstall_package(&python, &requirement, &cancel, &send)
} else {
ide_sdk::python::install_package(&python, &requirement, upgrade, &cancel, &send)
}
})
.await
.map_err(join_err);
host.end_request(request);
result?
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("sdk_detect", |c| async move { sdk_detect(&c.host, &c.window, c.arg("kind")?).await });
d.add("sdk_python_effective", |c| async move { sdk_python_effective(&c.host, &c.window) });
d.add("sdk_project_libraries", |c| async move { sdk_project_libraries(&c.host, &c.window).await });
d.add("sdk_locate_tools", |c| async move {
sdk_locate_tools(&c.host, &c.window, c.arg("names")?, c.arg("near")?).await
});
d.add("sdk_python_create_venv", |c| async move {
sdk_python_create_venv(
&c.host,
&c.window,
c.arg("base")?,
c.arg("location")?,
c.arg("request")?,
c.sender("onOutput")?,
)
.await
});
d.add("sdk_python_packages", |c| async move {
sdk_python_packages(&c.host, &c.window, c.arg("python")?, c.arg("request")?).await
});
d.add("sdk_python_pip", |c| async move {
sdk_python_pip(
&c.host,
&c.window,
c.arg("python")?,
c.arg("requirement")?,
c.arg("uninstall")?,
c.arg("upgrade")?,
c.arg("request")?,
c.sender("onOutput")?,
)
.await
});
}
+97
Переглянути файл
@@ -0,0 +1,97 @@
use super::Emit;
use crate::Host;
use ide_core::{IdeResult, RequestId};
use ide_search::{FileMatches, ProjectFileSet, SearchQuery, SearchSummary};
use serde::{Deserialize, Serialize};
use specta::Type;
use std::path::Path;
#[derive(Clone, Serialize, Deserialize, Type)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum SearchEvent {
File { file: FileMatches },
Done { summary: SearchSummary },
}
pub fn literal_range(l: ide_language::Literal) -> ide_search::LiteralRange {
let kind = match l.kind {
ide_language::LiteralKind::Comment => ide_search::LiteralKind::Comment,
ide_language::LiteralKind::String => ide_search::LiteralKind::String,
};
ide_search::LiteralRange { start: l.start, end: l.end, kind }
}
/// Find in Files. Results stream through `on_event`; ends with `Done`.
/// Cancel with `cancel_request(request)`.
pub async fn start(
host: &Host,
window: &str,
request: RequestId,
mut query: SearchQuery,
on_event: Emit<SearchEvent>,
) -> IdeResult<()> {
let project = host.project(window)?;
// Excluded roots (Project Structure marks and detected `target`, `node_modules`, …) and
// ignored names are never searched.
query.exclude.extend(project.search_excludes());
let roots = project.roots();
let set = match query.scope {
ide_search::SearchScope::FileSet { set } => Some(set),
_ => None,
};
let cancel = host.begin_request(request);
let documents = project.documents.clone();
let root = project.root.clone();
// The whole project also covers the Project Structure content roots.
let content_roots = match query.scope {
ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => {
project.content_roots()
}
_ => Vec::new(),
};
let languages = project.languages.clone();
let syntax = host.syntax();
let events = on_event.clone();
let summary = host
.blocking(move || {
let filter = |rel: &str| match set {
Some(ProjectFileSet::Production) => !roots.is_test(rel),
Some(ProjectFileSet::Test) => roots.is_test(rel),
Some(ProjectFileSet::All) | None => true,
};
// Comments and string literals for the search context filter.
let literals = |path: &Path, text: &str| {
let language = languages.detect(path, text.lines().next());
let found = syntax.literals(&language, text)?;
Some(found.into_iter().map(literal_range).collect())
};
let mut total = SearchSummary::default();
for dir in std::iter::once(&root).chain(&content_roots) {
let s = ide_search::search_in_context(
dir,
&query,
Some(&filter),
Some(&literals),
&|p| documents.text_of(p),
&cancel,
&|file| events(SearchEvent::File { file }),
)?;
total.files += s.files;
total.matches += s.matches;
total.elapsed_ms += s.elapsed_ms;
total.limit_hit |= s.limit_hit;
total.cancelled |= s.cancelled;
if total.limit_hit || total.cancelled {
break;
}
}
Ok(total)
})
.await;
host.end_request(request);
on_event(SearchEvent::Done { summary: summary? });
Ok(())
}
mod commands;
pub use commands::*;
+133
Переглянути файл
@@ -0,0 +1,133 @@
#[allow(unused_imports)]
use super::*;
use crate::Host;
use crate::ops::Channel;
use crate::rpc::Dispatcher;
use ide_core::{IdeResult, Pos, RequestId, TextEdit};
use ide_search::{ReplaceEvent, ReplaceHost, ReplacePreview, ReplaceTarget, SearchQuery};
use ide_workspace::Documents;
use std::path::{Path, PathBuf};
struct DocumentsHost<'a>(&'a Documents);
impl ReplaceHost for DocumentsHost<'_> {
fn edit_file(&self, path: &Path, compute: &mut dyn FnMut(&str) -> IdeResult<Vec<TextEdit>>) -> IdeResult<u32> {
let docs = self.0;
let (doc, transient) = match docs.by_path(path) {
Some(doc) => (doc, false),
None => (docs.open(path)?.id, true),
};
let result = (|| {
// Recompute if the user typed between reading the text and applying.
for _ in 0..3 {
let snap = docs.text_snapshot(doc)?;
let edits = compute(&snap.text)?;
if edits.is_empty() {
return Ok(0);
}
if docs.text_snapshot(doc)?.version != snap.version {
continue;
}
docs.apply_edits(doc, &edits)?;
if transient {
docs.save(doc)?;
}
return Ok(edits.len() as u32);
}
Err(ide_core::IdeError::unavailable("the file kept changing during Replace"))
})();
if transient {
docs.close(doc);
}
result
}
}
/// Replace in Files. Runs off the command thread; progress streams through
/// `on_event` and ends with `Done`. Cancel with `cancel_request(request)`.
///
/// Edits go through the document layer as one patch per file, so open editors
/// update live and undo the whole file's replacements in one step. Unsaved
/// editor buffers are replaced in place and left unsaved; files that were not
/// open are opened transiently, saved and closed.
#[allow(clippy::too_many_arguments)]
pub async fn replace_in_files(
host: &Host,
window: &str,
request: RequestId,
query: SearchQuery,
replacement: String,
preserve_case: bool,
targets: Vec<ReplaceTarget>,
on_event: Channel<ReplaceEvent>,
) -> IdeResult<()> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let events = on_event.clone();
let summary = tokio::task::spawn_blocking(move || {
let host = DocumentsHost(&project.documents);
ide_search::replace_files(&host, &query, &replacement, preserve_case, &targets, &cancel, &|e| {
let _ = events.send(e);
})
})
.await
.map_err(|e| ide_core::IdeError::internal(e.to_string()));
host.end_request(request);
let summary = summary??;
let _ = on_event.send(ReplaceEvent::Done { summary });
Ok(())
}
/// Before / after text of `path` for Replace › Preview (unsaved buffer included).
pub async fn replace_preview(
host: &Host,
window: &str,
query: SearchQuery,
replacement: String,
preserve_case: bool,
path: PathBuf,
only: Option<Vec<Pos>>,
) -> IdeResult<ReplacePreview> {
let project = host.project(window)?;
tokio::task::spawn_blocking(move || {
let text = project.text_of(&path)?;
ide_search::preview(&text, &query, &replacement, preserve_case, only.as_deref())
})
.await
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?
}
/// Validates a query (e.g. regex syntax) while the user types.
pub fn search_validate(_host: &Host, _window: &str, query: SearchQuery) -> IdeResult<()> {
ide_search::validate_query(&query)
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("replace_in_files", |c| async move {
replace_in_files(
&c.host,
&c.window,
c.arg("request")?,
c.arg("query")?,
c.arg("replacement")?,
c.arg("preserveCase")?,
c.arg("targets")?,
c.sender("onEvent")?,
)
.await
});
d.add("replace_preview", |c| async move {
replace_preview(
&c.host,
&c.window,
c.arg("query")?,
c.arg("replacement")?,
c.arg("preserveCase")?,
c.arg("path")?,
c.arg("only")?,
)
.await
});
d.add("search_validate", |c| async move { search_validate(&c.host, &c.window, c.arg("query")?) });
}
+98
Переглянути файл
@@ -0,0 +1,98 @@
//! Proofreading › Typo: spell checking of open documents.
use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use super::doc::blocking;
use ide_core::settings::SpellingSettings;
use ide_core::{DocId, IdeError, IdeResult, RequestId};
use ide_language::SpellingKind;
use ide_spell::{Region, RegionKind, SpellScope, Speller, Typo};
use std::path::{Path, PathBuf};
/// Languages checked as prose (all text but code spans).
const PROSE: &[&str] = &["plaintext", "markdown", "restructuredtext", "asciidoc", "latex"];
/// Custom dictionary paths: `~/…` from home, relative ones from the project root.
fn dictionary_paths(settings: &SpellingSettings, root: &Path) -> Vec<PathBuf> {
settings
.custom_dictionaries
.iter()
.map(|p| p.trim())
.filter(|p| !p.is_empty())
.map(|p| match p.strip_prefix("~/") {
Some(rest) => dirs::home_dir().unwrap_or_default().join(rest),
None => root.join(p),
})
.collect()
}
fn speller(project: &Project) -> Speller {
let s = project.settings.effective().spelling;
let words = s.ide_words.iter().chain(&s.project_words);
Speller::new(words, &dictionary_paths(&s, &project.root))
}
/// Misspelt words in the comments, string literals and declared names of
/// `doc` that `scope` includes (all of its text for prose). Cancel with
/// `cancel_request(request)`.
pub async fn spell_check(
host: &Host,
window: &str,
doc: DocId,
scope: SpellScope,
request: RequestId,
) -> IdeResult<Vec<Typo>> {
let project = host.project(window)?;
let syntax = host.syntax();
let cancel = host.begin_request(request);
let result = blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(Vec::new());
}
let regions = if PROSE.contains(&snap.language.as_str()) {
ide_spell::prose_regions(&snap.text)
} else {
let Some(found) = syntax.spelling_regions(&snap.language, &snap.text) else { return Ok(Vec::new()) };
found
.into_iter()
.map(|r| Region {
start: r.start,
end: r.end,
kind: match r.kind {
SpellingKind::Comment => RegionKind::Comment,
SpellingKind::String => RegionKind::String,
SpellingKind::Name => RegionKind::Name,
},
})
.filter(|r| scope.includes(r.kind))
.collect()
};
if cancel.is_cancelled() {
return Err(IdeError::cancelled());
}
speller(&project).check(&snap.text, &regions, &|| cancel.is_cancelled()).ok_or_else(IdeError::cancelled)
})
.await;
host.end_request(request);
result
}
/// Replacements for a misspelt `word`, best first (Typo › Change to…).
pub async fn spell_suggest(host: &Host, window: &str, word: String) -> IdeResult<Vec<String>> {
let project = host.project(window).ok();
blocking(move || {
let speller = project.as_deref().map(speller).unwrap_or_default();
Ok(speller.suggest(&word, 8))
})
.await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("spell_check", |c| async move {
spell_check(&c.host, &c.window, c.arg("doc")?, c.arg("scope")?, c.arg("request")?).await
});
d.add("spell_suggest", |c| async move { spell_suggest(&c.host, &c.window, c.arg("word")?).await });
}
+175
Переглянути файл
@@ -0,0 +1,175 @@
//! Tree-sitter structure queries (`ide-syntax`): the fallback editor features
//! use when the language server is unavailable, not ready or failing.
//!
//! All positions are 0-based lines / UTF-16 columns. Document queries run on
//! the blocking pool against the document's cached, incrementally re-parsed
//! tree; an optional `request` id makes them cancellable via `cancel_request`.
//! Languages without a grammar (and documents too large to parse) reject with
//! `unavailable`.
use crate::Host;
use crate::Project;
use crate::rpc::Dispatcher;
use super::doc::blocking;
use ide_core::{CancellationToken, DocId, IdeError, IdeResult, LanguageId, Pos, Range, RequestId};
use ide_syntax::{
ExpressionAt, FileIdentifiers, IdentifierOccurrence, Locals, NodeAt, Snapshot, StructuralRanges, StructureResult,
StructureService, StructureSupport, SyntaxErrors,
};
use std::path::PathBuf;
use std::sync::Arc;
/// Runs `f` on the current snapshot of `doc` off the command thread.
async fn on_doc<T: Send + 'static>(
host: &Host,
window: &str,
doc: DocId,
request: Option<RequestId>,
f: impl FnOnce(&StructureService, &Snapshot, &CancellationToken) -> StructureResult<T> + Send + 'static,
) -> IdeResult<T> {
let project: Arc<Project> = host.project(window)?;
let cancel = request.map_or_else(CancellationToken::new, |id| host.begin_request(id));
let result = blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Err(IdeError::unavailable("file is too large for syntax analysis"));
}
let s = Snapshot::doc(doc, snap.version, &snap.language, &snap.text);
Ok(f(&project.structure, &s, &cancel)?)
})
.await;
if let Some(id) = request {
host.end_request(id);
}
result
}
/// What the structure service supports for a language (`tree`: every query,
/// `locals`: scope analysis).
pub fn syntax_support(host: &Host, window: &str, language: LanguageId) -> IdeResult<StructureSupport> {
Ok(host.project(window)?.structure.support(&language))
}
/// The smallest named node covering `range`, with its ancestors (parent first).
pub async fn syntax_node_at(
host: &Host,
window: &str,
doc: DocId,
range: Range,
request: Option<RequestId>,
) -> IdeResult<Option<NodeAt>> {
on_doc(host, window, doc, request, move |s, snap, c| s.node_at(snap, range, c)).await
}
/// The expression a selection denotes (exact or minimally enclosing), with its
/// statement, block and caret candidates.
pub async fn syntax_expression_at(
host: &Host,
window: &str,
doc: DocId,
range: Range,
request: Option<RequestId>,
) -> IdeResult<ExpressionAt> {
on_doc(host, window, doc, request, move |s, snap, c| s.expression_at(snap, range, c)).await
}
/// `ERROR` / `MISSING` nodes (e.g. a missing `;`) in document order.
pub async fn syntax_errors(
host: &Host,
window: &str,
doc: DocId,
request: Option<RequestId>,
) -> IdeResult<SyntaxErrors> {
on_doc(host, window, doc, request, |s, snap, c| s.syntax_errors(snap, c)).await
}
/// Scope analysis of the identifier at `pos`: its local definition and
/// references, or why it is not a local.
pub async fn syntax_locals(
host: &Host,
window: &str,
doc: DocId,
pos: Pos,
request: Option<RequestId>,
) -> IdeResult<Locals> {
on_doc(host, window, doc, request, move |s, snap, c| s.locals(snap, pos, c)).await
}
/// Identifier tokens spelled `name` in a document, excluding comments and strings.
pub async fn syntax_identifiers(
host: &Host,
window: &str,
doc: DocId,
name: String,
request: Option<RequestId>,
) -> IdeResult<Vec<IdentifierOccurrence>> {
on_doc(host, window, doc, request, move |s, snap, c| s.identifiers_named(snap, &name, c)).await
}
/// [`syntax_identifiers`] over files (open buffers win over disk); files
/// without occurrences or without a grammar are left out. Cancellable.
pub async fn syntax_identifiers_in_files(
host: &Host,
window: &str,
paths: Vec<PathBuf>,
name: String,
request: RequestId,
) -> IdeResult<Vec<FileIdentifiers>> {
let project = host.project(window)?;
let languages = project.languages.clone();
let cancel = host.begin_request(request);
let result = blocking(move || {
let load = |path: &std::path::Path| {
if let Some(doc) = project.documents.by_path(path) {
let snap = project.documents.text_snapshot(doc).ok()?;
return (!snap.large).then_some((snap.language, Some((doc, snap.version)), snap.text));
}
let text = ide_vfs::load_text(path).ok()?.text;
let first_line = text.lines().next();
Some((languages.detect(path, first_line), None, text))
};
Ok(project.structure.identifiers_in_files(&paths, &name, load, &cancel)?)
})
.await;
host.end_request(request);
result
}
/// Extend Selection chain plus statement / declaration ranges at `pos`
/// (Move Statement, Complete Statement for languages without a Lezer grammar).
pub async fn syntax_structural_ranges(
host: &Host,
window: &str,
doc: DocId,
pos: Pos,
request: Option<RequestId>,
) -> IdeResult<StructuralRanges> {
on_doc(host, window, doc, request, move |s, snap, c| s.structural_ranges(snap, pos, c)).await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("syntax_support", |c| async move { syntax_support(&c.host, &c.window, c.arg("language")?) });
d.add("syntax_node_at", |c| async move {
syntax_node_at(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("request")?).await
});
d.add("syntax_expression_at", |c| async move {
syntax_expression_at(&c.host, &c.window, c.arg("doc")?, c.arg("range")?, c.arg("request")?).await
});
d.add(
"syntax_errors",
|c| async move { syntax_errors(&c.host, &c.window, c.arg("doc")?, c.arg("request")?).await },
);
d.add("syntax_locals", |c| async move {
syntax_locals(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("request")?).await
});
d.add("syntax_identifiers", |c| async move {
syntax_identifiers(&c.host, &c.window, c.arg("doc")?, c.arg("name")?, c.arg("request")?).await
});
d.add("syntax_identifiers_in_files", |c| async move {
syntax_identifiers_in_files(&c.host, &c.window, c.arg("paths")?, c.arg("name")?, c.arg("request")?).await
});
d.add("syntax_structural_ranges", |c| async move {
syntax_structural_ranges(&c.host, &c.window, c.arg("doc")?, c.arg("pos")?, c.arg("request")?).await
});
}
+33
Переглянути файл
@@ -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)
}
+168
Переглянути файл
@@ -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
});
}
+24
Переглянути файл
@@ -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::*;
+912
Переглянути файл
@@ -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, &current, &selected);
if patch.is_empty() { Ok(()) } else { r.stage_patch(&patch) }
})
}
/// Clones a repository (Get from VCS). Works without an open project.
pub async fn vcs_clone(
host: &Host,
_window: &str,
request: RequestId,
url: String,
dest: PathBuf,
on_output: Channel<String>,
) -> IdeResult<()> {
apply_git_executable(&host.global_settings.effective());
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
ide_vcs::clone(&url, &dest, &cancel, &|line| {
let _ = on_output.send(line);
})
})
.await
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
host.end_request(request);
result
}
/// `git init` in `dir` (File › New Project… "Create Git repository").
pub async fn vcs_init(host: &Host, _window: &str, dir: PathBuf) -> IdeResult<()> {
apply_git_executable(&host.global_settings.effective());
blocking(move || ide_vcs::init(&dir)).await
}
/// Settings › Git › Test: runs `git --version` on `path`, or on the auto-detected
/// executable when `path` is empty.
pub async fn vcs_git_version(_host: &Host, _window: &str, path: String) -> IdeResult<GitExecutableInfo> {
tokio::task::spawn_blocking(move || {
let explicit = path.trim();
let exe = if explicit.is_empty() {
ide_vcs::detected_git_executable()
.ok_or_else(|| ide_core::IdeError::unavailable("git executable not found on PATH"))?
} else {
PathBuf::from(explicit)
};
let version = ide_vcs::git_version(Some(&exe))?;
Ok(GitExecutableInfo { version, path: exe.to_string_lossy().into_owned() })
})
.await
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?
}
/// The auto-detected git executable (shown as the field's placeholder).
pub fn vcs_detected_git(_host: &Host, _window: &str) -> Option<String> {
ide_vcs::detected_git_executable().map(|p| p.to_string_lossy().into_owned())
}
/// `git config --get <key>` in the project repository; `None` when unset.
pub async fn vcs_config_get(
host: &Host,
window: &str,
repo: Option<PathBuf>,
key: String,
) -> IdeResult<Option<String>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.config_get(&key))).await
}
/// Local history operation that may take a while (hooks, large checkouts); git's
/// output streams through `on_output`. Cancellable.
pub async fn vcs_operation(
host: &Host,
window: &str,
repo: Option<PathBuf>,
request: RequestId,
op: GitOperation,
on_output: Channel<String>,
) -> IdeResult<()> {
let project = host.project(window)?;
let cancel = host.begin_request(request);
let result = tokio::task::spawn_blocking(move || {
let out = |line: String| {
let _ = on_output.send(line);
};
let recorded = match &op {
GitOperation::CherryPick { .. } => Some("Git cherry-pick"),
GitOperation::Revert { .. } => Some("Git revert"),
GitOperation::Reset { .. } => Some("Git reset"),
GitOperation::InteractiveRebase { .. } => Some("Git rebase"),
_ => None,
};
project.with_repo_recorded(repo.as_deref(), recorded, |r| match op {
GitOperation::Continue => r.continue_operation(&cancel, &out),
GitOperation::Abort => r.abort_operation(&cancel, &out),
GitOperation::Skip => r.skip_operation(&cancel, &out),
GitOperation::CherryPick { commits } => r.cherry_pick(&commits, &cancel, &out),
GitOperation::Revert { commits } => r.revert(&commits, &cancel, &out),
GitOperation::Reset { commit, mode } => r.reset(&commit, mode, &cancel, &out),
GitOperation::InteractiveRebase { base, entries } => {
r.interactive_rebase(base.as_deref(), &entries, &cancel, &out)
}
})
})
.await
.map_err(|e| ide_core::IdeError::internal(e.to_string()))?;
host.end_request(request);
result
}
pub async fn vcs_create_tag(
host: &Host,
window: &str,
repo: Option<PathBuf>,
name: String,
commit: String,
message: Option<String>,
force: bool,
) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.create_tag(&name, &commit, message.as_deref(), force)))
.await
}
pub async fn vcs_tags(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<String>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.tags())).await
}
/// Merge dialog: merges `branches` into the current branch.
pub async fn vcs_merge_with(
host: &Host,
window: &str,
repo: Option<PathBuf>,
branches: Vec<String>,
options: MergeOptions,
) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(repo.as_deref(), Some("Git merge"), |r| r.merge_with(&branches, &options))
})
.await
}
/// Rebase dialog: rebases the current branch onto `upstream` (or `--onto`).
pub async fn vcs_rebase_with(
host: &Host,
window: &str,
repo: Option<PathBuf>,
upstream: String,
options: RebaseOptions,
) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(repo.as_deref(), Some("Git rebase"), |r| r.rebase_with(&upstream, &options))
})
.await
}
pub async fn vcs_remotes(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Remote>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remotes())).await
}
pub async fn vcs_edit_remote(host: &Host, window: &str, repo: Option<PathBuf>, edit: RemoteEdit) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.edit_remote(&edit))).await
}
pub async fn vcs_worktrees(host: &Host, window: &str, repo: Option<PathBuf>) -> IdeResult<Vec<Worktree>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.worktrees())).await
}
pub async fn vcs_add_worktree(
host: &Host,
window: &str,
repo: Option<PathBuf>,
path: PathBuf,
commitish: String,
new_branch: Option<String>,
) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || {
project.with_repo_at(repo.as_deref(), |r| r.add_worktree(&path, &commitish, new_branch.as_deref()))
})
.await
}
pub async fn vcs_remove_worktree(
host: &Host,
window: &str,
repo: Option<PathBuf>,
path: PathBuf,
force: bool,
) -> IdeResult<()> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.remove_worktree(&path, force))).await
}
/// Compare with Local: tracked files that differ between `rev` and the working tree.
pub async fn vcs_diff_with_worktree(
host: &Host,
window: &str,
repo: Option<PathBuf>,
rev: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_with_worktree(&rev))).await
}
/// Files that differ between two revisions (the "N files updated" of Update Project).
pub async fn vcs_diff_revisions(
host: &Host,
window: &str,
repo: Option<PathBuf>,
from: String,
to: String,
) -> IdeResult<Vec<ChangedFile>> {
let project = host.project(window)?;
blocking(move || project.with_repo_at(repo.as_deref(), |r| r.diff_revisions(&from, &to))).await
}
pub async fn vcs_create_patch(host: &Host, window: &str, source: PatchSource) -> IdeResult<String> {
let project = host.project(window)?;
blocking(move || {
project.with_repo(|r| match source {
PatchSource::Changes { paths, reverse } => {
let paths: Vec<PathBuf> = paths.into_iter().map(Into::into).collect();
in_primary_repo(&project, &paths)?;
r.create_patch(&paths, reverse)
}
PatchSource::Commits { commits, reverse } => r.create_commits_patch(&commits, reverse),
})
})
.await
}
pub async fn vcs_patch_preview(host: &Host, window: &str, patch: String) -> IdeResult<Vec<PatchFile>> {
let project = host.project(window)?;
blocking(move || project.with_repo(|r| r.patch_preview(&patch))).await
}
pub async fn vcs_apply_patch(host: &Host, window: &str, patch: String) -> IdeResult<ApplyOutcome> {
let project = host.project(window)?;
blocking(move || project.with_repo(|r| r.apply_patch(&patch))).await
}
/// Shelve Changes: saves the changes of `paths` as a patch on the shelf and reverts them.
pub async fn vcs_shelve(host: &Host, window: &str, name: String, paths: Vec<PathBuf>) -> IdeResult<ShelvedChangeList> {
let project = host.project(window)?;
blocking(move || {
in_primary_repo(&project, &paths)?;
keep_private(&project.root);
project.with_repo_recorded(None, Some("Shelve"), |r| r.shelve(&shelf_dir(&project.root), &name, &paths))
})
.await
}
pub fn vcs_shelves(host: &Host, window: &str) -> IdeResult<Vec<ShelvedChangeList>> {
let project = host.project(window)?;
ide_vcs::shelf::list(&shelf_dir(&project.root))
}
/// Unshelve: applies the shelved patch (3-way merging; conflicts are left for the
/// merge UI); with `remove`, deletes it from the shelf.
pub async fn vcs_unshelve(host: &Host, window: &str, id: String, remove: bool) -> IdeResult<ApplyOutcome> {
let project = host.project(window)?;
blocking(move || {
project.with_repo_recorded(None, Some("Unshelve"), |r| r.unshelve(&shelf_dir(&project.root), &id, remove))
})
.await
}
pub fn vcs_shelf_delete(host: &Host, window: &str, id: String) -> IdeResult<()> {
let project = host.project(window)?;
ide_vcs::shelf::delete(&shelf_dir(&project.root), &id)
}
pub fn vcs_shelf_rename(host: &Host, window: &str, id: String, name: String) -> IdeResult<()> {
let project = host.project(window)?;
ide_vcs::shelf::rename(&shelf_dir(&project.root), &id, &name)
}
pub fn vcs_shelf_patch(host: &Host, window: &str, id: String) -> IdeResult<String> {
let project = host.project(window)?;
ide_vcs::shelf::patch_of(&shelf_dir(&project.root), &id)
}
pub fn vcs_changelists_load(host: &Host, window: &str) -> IdeResult<ChangelistState> {
let project = host.project(window)?;
Ok(ide_vcs::changelists::load(&ide_core::project_dir::path(&project.root, "changelists.json")))
}
pub fn vcs_changelists_save(host: &Host, window: &str, changelists: ChangelistState) -> IdeResult<()> {
let project = host.project(window)?;
keep_private(&project.root);
ide_vcs::changelists::save(&ide_core::project_dir::path(&project.root, "changelists.json"), &changelists)
}
/// Create Patch › Save to file.
pub async fn vcs_write_patch(_host: &Host, _window: &str, path: PathBuf, patch: String) -> IdeResult<()> {
blocking(move || {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(&path, patch)?;
Ok(())
})
.await
}
/// Commands of this module the remote server answers.
pub(crate) fn register(d: &mut Dispatcher) {
d.add("vcs_roots", |c| async move { vcs_roots(&c.host, &c.window) });
d.add("vcs_branch_info", |c| async move { vcs_branch_info(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_line_hunks", |c| async move { vcs_line_hunks(&c.host, &c.window, c.arg("path")?) });
d.add("vcs_file_at", |c| async move { vcs_file_at(&c.host, &c.window, c.arg("path")?, c.arg("rev")?).await });
d.add("vcs_blame", |c| async move { vcs_blame(&c.host, &c.window, c.arg("path")?).await });
d.add("vcs_log", |c| async move { vcs_log(&c.host, &c.window, c.arg("repo")?, c.arg("query")?).await });
d.add("vcs_commit_files", |c| async move {
vcs_commit_files(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await
});
d.add("vcs_commits_changes", |c| async move {
vcs_commits_changes(&c.host, &c.window, c.arg("repo")?, c.arg("commits")?).await
});
d.add("vcs_branches", |c| async move { vcs_branches(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_stashes", |c| async move { vcs_stashes(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_stage", |c| async move { vcs_stage(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_unstage", |c| async move { vcs_unstage(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_stage_patch", |c| async move { vcs_stage_patch(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_discard", |c| async move { vcs_discard(&c.host, &c.window, c.arg("paths")?).await });
d.add("vcs_commit", |c| async move {
vcs_commit(&c.host, &c.window, c.arg("repo")?, c.arg("message")?, c.arg("options")?).await
});
d.add("vcs_checkout", |c| async move {
vcs_checkout(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?, c.arg("create")?, c.arg("options")?).await
});
d.add("vcs_create_branch", |c| async move {
vcs_create_branch(&c.host, &c.window, c.arg("repo")?, c.arg("name")?, c.arg("start")?, c.arg("checkout")?).await
});
d.add(
"vcs_undo_commit",
|c| async move { vcs_undo_commit(&c.host, &c.window, c.arg("repo")?, c.arg("commit")?).await },
);
d.add("vcs_delete_branch", |c| async move {
vcs_delete_branch(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await
});
d.add("vcs_restore_branch", |c| async move {
vcs_restore_branch(&c.host, &c.window, c.arg("repo")?, c.arg("deleted")?).await
});
d.add("vcs_rename_branch", |c| async move {
vcs_rename_branch(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
});
d.add("vcs_merge", |c| async move { vcs_merge(&c.host, &c.window, c.arg("repo")?, c.arg("branch")?).await });
d.add("vcs_rebase", |c| async move { vcs_rebase(&c.host, &c.window, c.arg("repo")?, c.arg("onto")?).await });
d.add("vcs_stash_push", |c| async move {
vcs_stash_push(
&c.host,
&c.window,
c.arg("repo")?,
c.arg("message")?,
c.arg("includeUntracked")?,
c.arg("keepIndex")?,
)
.await
});
d.add("vcs_stash_apply", |c| async move {
vcs_stash_apply(
&c.host,
&c.window,
c.arg("repo")?,
c.arg("index")?,
c.arg("pop")?,
c.arg("reinstateIndex")?,
c.arg("branch")?,
)
.await
});
d.add(
"vcs_stash_drop",
|c| async move { vcs_stash_drop(&c.host, &c.window, c.arg("repo")?, c.arg("index")?).await },
);
d.add("vcs_remote", |c| async move {
vcs_remote(&c.host, &c.window, c.arg("repo")?, c.arg("request")?, c.arg("op")?, c.sender("onOutput")?).await
});
d.add("vcs_conflict_versions", |c| async move { vcs_conflict_versions(&c.host, &c.window, c.arg("path")?).await });
d.add(
"vcs_diff_text",
|c| async move { vcs_diff_text(&c.host, &c.window, c.arg("path")?, c.arg("staged")?).await },
);
d.add(
"vcs_diff_hunks",
|c| async move { Ok(vcs_diff_hunks(&c.host, &c.window, c.arg("oldText")?, c.arg("newText")?)) },
);
d.add("vcs_stage_hunks", |c| async move { vcs_stage_hunks(&c.host, &c.window, c.arg("path")?, c.arg("hunks")?) });
d.add("vcs_clone", |c| async move {
vcs_clone(&c.host, &c.window, c.arg("request")?, c.arg("url")?, c.arg("dest")?, c.sender("onOutput")?).await
});
d.add("vcs_init", |c| async move { vcs_init(&c.host, &c.window, c.arg("dir")?).await });
d.add("vcs_git_version", |c| async move { vcs_git_version(&c.host, &c.window, c.arg("path")?).await });
d.add("vcs_detected_git", |c| async move { Ok(vcs_detected_git(&c.host, &c.window)) });
d.add("vcs_config_get", |c| async move { vcs_config_get(&c.host, &c.window, c.arg("repo")?, c.arg("key")?).await });
d.add("vcs_operation", |c| async move {
vcs_operation(&c.host, &c.window, c.arg("repo")?, c.arg("request")?, c.arg("op")?, c.sender("onOutput")?).await
});
d.add("vcs_create_tag", |c| async move {
vcs_create_tag(
&c.host,
&c.window,
c.arg("repo")?,
c.arg("name")?,
c.arg("commit")?,
c.arg("message")?,
c.arg("force")?,
)
.await
});
d.add("vcs_tags", |c| async move { vcs_tags(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_merge_with", |c| async move {
vcs_merge_with(&c.host, &c.window, c.arg("repo")?, c.arg("branches")?, c.arg("options")?).await
});
d.add("vcs_rebase_with", |c| async move {
vcs_rebase_with(&c.host, &c.window, c.arg("repo")?, c.arg("upstream")?, c.arg("options")?).await
});
d.add("vcs_remotes", |c| async move { vcs_remotes(&c.host, &c.window, c.arg("repo")?).await });
d.add(
"vcs_edit_remote",
|c| async move { vcs_edit_remote(&c.host, &c.window, c.arg("repo")?, c.arg("edit")?).await },
);
d.add("vcs_worktrees", |c| async move { vcs_worktrees(&c.host, &c.window, c.arg("repo")?).await });
d.add("vcs_add_worktree", |c| async move {
vcs_add_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("commitish")?, c.arg("newBranch")?)
.await
});
d.add("vcs_remove_worktree", |c| async move {
vcs_remove_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("path")?, c.arg("force")?).await
});
d.add("vcs_diff_with_worktree", |c| async move {
vcs_diff_with_worktree(&c.host, &c.window, c.arg("repo")?, c.arg("rev")?).await
});
d.add("vcs_diff_revisions", |c| async move {
vcs_diff_revisions(&c.host, &c.window, c.arg("repo")?, c.arg("from")?, c.arg("to")?).await
});
d.add("vcs_create_patch", |c| async move { vcs_create_patch(&c.host, &c.window, c.arg("source")?).await });
d.add("vcs_patch_preview", |c| async move { vcs_patch_preview(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_apply_patch", |c| async move { vcs_apply_patch(&c.host, &c.window, c.arg("patch")?).await });
d.add("vcs_shelve", |c| async move { vcs_shelve(&c.host, &c.window, c.arg("name")?, c.arg("paths")?).await });
d.add("vcs_shelves", |c| async move { vcs_shelves(&c.host, &c.window) });
d.add("vcs_unshelve", |c| async move { vcs_unshelve(&c.host, &c.window, c.arg("id")?, c.arg("remove")?).await });
d.add("vcs_shelf_delete", |c| async move { vcs_shelf_delete(&c.host, &c.window, c.arg("id")?) });
d.add("vcs_shelf_rename", |c| async move { vcs_shelf_rename(&c.host, &c.window, c.arg("id")?, c.arg("name")?) });
d.add("vcs_shelf_patch", |c| async move { vcs_shelf_patch(&c.host, &c.window, c.arg("id")?) });
d.add("vcs_changelists_load", |c| async move { vcs_changelists_load(&c.host, &c.window) });
d.add("vcs_changelists_save", |c| async move { vcs_changelists_save(&c.host, &c.window, c.arg("changelists")?) });
d.add(
"vcs_write_patch",
|c| async move { vcs_write_patch(&c.host, &c.window, c.arg("path")?, c.arg("patch")?).await },
);
}
+714
Переглянути файл
@@ -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)));
}
+302
Переглянути файл
@@ -0,0 +1,302 @@
//! Remote development protocol: JSON-RPC 2.0 (`Content-Length` framing) between
//! the desktop app and `openidea-server`, over the server's stdio (an SSH exec
//! channel).
//!
//! * client → server request `initialize` `{ version }` → [`ServerInfo`];
//! * client → server request `invoke` `{ window, cmd, args }` → the command's
//! result, `args` as the desktop command takes them (camelCase names). A
//! streaming argument is `{ "$channel": <id> }`;
//! * server → client notification `event` `{ window, event }` (an `IdeEvent`
//! for that window, or every window when `window` is null);
//! * server → client notification `channel` `{ id, data }`: one value of a
//! streaming argument.
use crate::Host;
use crate::ops::{self, Emit};
use ide_core::{IdeError, IdeEvent, IdeResult, RequestId};
use ide_jsonrpc::{Handler, Peer, codes};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::future::Future;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::{Arc, OnceLock};
pub const PROTOCOL_VERSION: u32 = 1;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerInfo {
pub version: String,
pub protocol: u32,
pub os: String,
pub arch: String,
pub home: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Invoke {
pub window: String,
pub cmd: String,
#[serde(default)]
pub args: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EventMessage {
pub window: Option<String>,
pub event: IdeEvent,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelMessage {
pub id: u64,
pub data: Value,
}
/// One `invoke`: its arguments and a way to stream values back.
pub struct Call {
pub host: Arc<Host>,
pub window: String,
pub args: Value,
peer: Arc<OnceLock<Peer>>,
}
impl Call {
/// Argument `name` (missing = `null`, so `Option`s may be omitted).
pub fn arg<T: DeserializeOwned>(&self, name: &str) -> IdeResult<T> {
let v = self.args.get(name).cloned().unwrap_or(Value::Null);
serde_json::from_value(v).map_err(|e| IdeError::invalid(format!("argument {name}: {e}")))
}
/// Streaming argument `name` as an [`ops::Channel`].
pub fn sender<T: Serialize + 'static>(&self, name: &str) -> IdeResult<ops::Channel<T>> {
let emit = self.channel(name)?;
Ok(ops::Channel::new(move |v| emit(v)))
}
/// Streaming argument `name` (`{ "$channel": id }`).
pub fn channel<T: Serialize + 'static>(&self, name: &str) -> IdeResult<Emit<T>> {
let id = self
.args
.get(name)
.and_then(|v| v.get("$channel"))
.and_then(Value::as_u64)
.ok_or_else(|| IdeError::invalid(format!("argument {name}: expected a channel")))?;
let peer = self.peer.clone();
Ok(Arc::new(move |data: T| {
let Some(peer) = peer.get() else { return };
match serde_json::to_value(&data) {
Ok(data) => {
let _ = peer.notify("channel", ChannelMessage { id, data });
}
Err(e) => tracing::warn!("channel {id}: {e}"),
}
}))
}
}
type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
type HandlerFn = Arc<dyn Fn(Call) -> BoxFuture<IdeResult<Value>> + Send + Sync>;
/// Commands the server answers, by name.
#[derive(Default, Clone)]
pub struct Dispatcher {
handlers: HashMap<&'static str, HandlerFn>,
}
impl Dispatcher {
pub fn add<F, Fut, T>(&mut self, name: &'static str, f: F) -> &mut Self
where
F: Fn(Call) -> Fut + Send + Sync + 'static,
Fut: Future<Output = IdeResult<T>> + Send + 'static,
T: Serialize,
{
let f = Arc::new(f);
self.handlers.insert(
name,
Arc::new(move |call| {
let fut = f(call);
Box::pin(async move {
let value = fut.await?;
serde_json::to_value(value).map_err(|e| IdeError::internal(e.to_string()))
})
}),
);
self
}
pub fn commands(&self) -> Vec<&'static str> {
let mut out: Vec<_> = self.handlers.keys().copied().collect();
out.sort_unstable();
out
}
/// The commands of the core remote slice: project, files, documents, search,
/// terminals, runs and VCS status.
pub fn standard() -> Self {
let mut d = Self::default();
d.add("open_project", |c| async move {
let path: PathBuf = c.arg("path")?;
let project = ops::project::open(&c.host, &c.window, &path).await?;
Ok(ops::project::info(&project))
})
.add("project_current", |c| async move { Ok(ops::project::current(&c.host, &c.window)) })
.add("close_project", |c| async move {
c.host.close_project(&c.window);
Ok(())
})
.add("project_roots", |c| async move { ops::project::roots(&c.host, &c.window) })
.add("settings_get", |c| async move { Ok(ops::project::settings(&c.host, &c.window)) })
.add("settings_set", |c| async move {
let key: String = c.arg("key")?;
let scope = c.arg("scope")?;
let applied = ops::project::set_settings(&c.host, &c.window, scope, &key, &c.arg::<String>("value")?)?;
let sink = c.host.window_sink(&c.window);
sink.emit(IdeEvent::SettingsChanged { key });
if let Some(root) = applied.roots_changed {
sink.emit(IdeEvent::ProjectRootsChanged { root });
}
Ok(applied.settings)
})
.add("cancel_request", |c| async move {
c.host.cancel_request(c.arg::<RequestId>("id")?);
Ok(())
})
.add("fs_read_dir", |c| async move { ops::fs::read_dir(&c.host, &c.window, c.arg("path")?) })
.add("fs_create_file", |c| async move { ops::fs::create_file(&c.host, &c.window, c.arg("path")?) })
.add("fs_create_dir", |c| async move { ide_vfs::create_dir(&c.arg::<PathBuf>("path")?) })
.add("fs_rename", |c| async move { ide_vfs::rename(&c.arg::<PathBuf>("from")?, &c.arg::<PathBuf>("to")?) })
.add("fs_set_writable", |c| async move {
ops::fs::set_writable(&c.host, &c.window, c.arg("path")?, c.arg("writable")?)
})
.add("fs_delete", |c| async move { ops::fs::delete(&c.host, &c.window, c.arg("path")?).await })
.add("fs_copy", |c| async move { ops::fs::copy(&c.host, c.arg("from")?, c.arg("to")?).await })
.add("fs_read_text", |c| async move { ops::fs::read_text(&c.host, &c.window, c.arg("path")?).await })
.add("doc_open", |c| async move { ops::doc::open(&c.host, &c.window, c.arg("path")?).await })
.add("doc_close", |c| async move { ops::doc::close(&c.host, &c.window, c.arg("doc")?) })
.add("doc_edit", |c| async move {
ops::doc::edit(&c.host, &c.window, c.arg("doc")?, c.arg("baseVersion")?, c.arg("changes")?)
})
.add("doc_save", |c| async move { ops::doc::save(&c.host, &c.window, c.arg("doc")?).await })
.add("doc_save_all", |c| async move { ops::doc::save_all(&c.host, &c.window).await })
.add("doc_snapshot", |c| async move { ops::doc::snapshot(&c.host, &c.window, c.arg("doc")?).await })
.add("search_start", |c| async move {
let on_event = c.channel("onEvent")?;
ops::search::start(&c.host, &c.window, c.arg("request")?, c.arg("query")?, on_event).await
})
.add("term_spawn", |c| async move {
let on_output = c.channel("onOutput")?;
ops::term::spawn(&c.host, &c.window, c.arg("options")?, on_output)
})
.add("term_write", |c| async move { ops::term::write(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
.add("term_resize", |c| async move { ops::term::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("term_kill", |c| async move { ops::term::kill(&c.host, c.arg("id")?) })
.add("term_is_busy", |c| async move { ops::term::is_busy(&c.host, c.arg("id")?) })
.add("term_default_shell", |_| async move { Ok(ide_terminal::default_shell_path()) })
.add("term_available_shells", |_| async move { Ok(ide_terminal::available_shells()) })
.add("run_configs", |c| async move { ops::run::configs(&c.host, &c.window) })
.add("run_save_configs", |c| async move {
ops::run::save_configs(&c.host, &c.window, &c.arg::<Vec<ide_run::RunConfig>>("configs")?)
})
.add("run_system_env", |_| async move { Ok(ide_run::system_env()) })
.add("run_start", |c| async move {
let on_output = c.channel("onOutput")?;
ops::run::start(&c.host, &c.window, c.arg("config")?, on_output).await
})
.add("run_stop", |c| async move { ops::run::stop(&c.host, &c.window, c.arg("id")?) })
.add("run_stdin", |c| async move { ops::run::stdin(&c.host, c.arg("id")?, &c.arg::<String>("data")?) })
.add("run_resize", |c| async move { ops::run::resize(&c.host, c.arg("id")?, c.arg("cols")?, c.arg("rows")?) })
.add("run_close_stdin", |c| async move { ops::run::close_stdin(&c.host, c.arg("id")?) })
.add("run_list", |c| async move { Ok(ops::run::list(&c.host)) })
.add("vcs_status", |c| async move { ops::vcs::status(&c.host, &c.window).await });
ops::register_all(&mut d);
d
}
}
/// Serves `host` over `reader` / `writer` until the client disconnects.
/// `peer` must be the one whose events `host` sends ([`event_sink`]).
pub async fn serve<R, W>(host: Arc<Host>, dispatcher: Dispatcher, peer: Arc<OnceLock<Peer>>, reader: R, writer: W)
where
R: tokio::io::AsyncRead + Unpin + Send + 'static,
W: tokio::io::AsyncWrite + Unpin + Send + 'static,
{
let handler = Arc::new(ServerHandler { host: host.clone(), dispatcher, peer: peer.clone() });
let p = Peer::connect(reader, writer, handler);
let _ = peer.set(p.clone());
p.closed().await;
}
/// Sends a window's events to the client as `event` notifications.
pub fn event_sink(peer: Arc<OnceLock<Peer>>, window: Option<String>) -> ide_core::SharedSink {
Arc::new(PeerSink { peer, window })
}
struct PeerSink {
peer: Arc<OnceLock<Peer>>,
window: Option<String>,
}
impl ide_core::EventSink for PeerSink {
fn emit(&self, event: IdeEvent) {
if let Some(peer) = self.peer.get() {
let _ = peer.notify("event", EventMessage { window: self.window.clone(), event });
}
}
}
struct ServerHandler {
host: Arc<Host>,
dispatcher: Dispatcher,
peer: Arc<OnceLock<Peer>>,
}
pub fn server_info() -> ServerInfo {
ServerInfo {
version: env!("CARGO_PKG_VERSION").into(),
protocol: PROTOCOL_VERSION,
os: std::env::consts::OS.into(),
arch: std::env::consts::ARCH.into(),
home: dirs::home_dir().map(|h| h.to_string_lossy().into_owned()).unwrap_or_default(),
}
}
impl Handler for ServerHandler {
fn on_notification(&self, _method: &str, _params: Value) {}
fn on_request(&self, method: &str, params: Value) -> Result<Value, (i64, String)> {
let internal = |e: serde_json::Error| (codes::INTERNAL_ERROR, e.to_string());
match method {
"initialize" => serde_json::to_value(server_info()).map_err(internal),
"commands" => serde_json::to_value(self.dispatcher.commands()).map_err(internal),
"invoke" => {
let call: Invoke =
serde_json::from_value(params).map_err(|e| (codes::INVALID_PARAMS, e.to_string()))?;
let Some(handler) = self.dispatcher.handlers.get(call.cmd.as_str()).cloned() else {
return Err((codes::METHOD_NOT_FOUND, format!("command {} is not available remotely", call.cmd)));
};
let c = Call { host: self.host.clone(), window: call.window, args: call.args, peer: self.peer.clone() };
// Requests run on blocking-capable threads: wait for the command here.
self.host.runtime.block_on(handler(c)).map_err(error_reply)
}
_ => Err((codes::METHOD_NOT_FOUND, format!("unknown method {method}"))),
}
}
}
/// An `IdeError` as a JSON-RPC error: the JSON of the error is the message.
fn error_reply(e: IdeError) -> (i64, String) {
(codes::INTERNAL_ERROR, serde_json::to_string(&e).unwrap_or(e.message))
}
/// The `IdeError` of an `invoke` reply (see [`error_reply`]).
pub fn error_from_reply(e: ide_jsonrpc::RpcError) -> IdeError {
match e {
ide_jsonrpc::RpcError::Server { message, .. } => serde_json::from_str(&message)
.unwrap_or_else(|_| IdeError::new(ide_core::error::ErrorKind::Protocol, message)),
other => other.into(),
}
}
Переглянути файл
File renamed without changes.
+151
Переглянути файл
@@ -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()
}
}
+37 -10
Переглянути файл
@@ -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?
+14 -6
Переглянути файл
@@ -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")));
+70 -14
Переглянути файл
@@ -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(()),
}
})
+37 -9
Переглянути файл
@@ -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");
+14 -8
Переглянути файл
@@ -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);
}
+1
Переглянути файл
@@ -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.
+11 -5
Переглянути файл
@@ -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 || {
+11 -2
Переглянути файл
@@ -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`).
+52 -6
Переглянути файл
@@ -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))?;
+11 -1
Переглянути файл
@@ -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()
}
+2 -1
Переглянути файл
@@ -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(),
+2 -1
Переглянути файл
@@ -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"]);
}
}
+20 -6
Переглянути файл
@@ -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))
+1
Переглянути файл
@@ -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;
+98
Переглянути файл
@@ -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:?}");
}
}
+3
Переглянути файл
@@ -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" }
+27 -218
Переглянути файл
@@ -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
}
+9 -87
Переглянути файл
@@ -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 &current {
Some(p) if project_scope => p.settings.set(scope, &key, value)?,
Some(p) => {
state.global_settings.set(SettingsScope::Global, &key, value)?;
p.settings.reload()
}
None => state.global_settings.set(SettingsScope::Global, &key, value)?,
};
if let Some(p) = &current {
p.documents.set_file_defaults(ide_workspace::FileDefaults::from_settings(&settings, Some(&p.root)));
}
// A global value reaches every other open project too.
if !project_scope {
for project in state.projects() {
if current.as_ref().is_some_and(|c| c.root == project.root) {
continue;
}
let effective = project.settings.reload();
project
.documents
.set_file_defaults(ide_workspace::FileDefaults::from_settings(&effective, Some(&project.root)));
}
}
// File type associations re-detect open documents (off the command thread).
if key == "files" || key.starts_with("files.fileType") {
let affected = if project_scope { current.iter().cloned().collect() } else { state.projects() };
tauri::async_runtime::spawn_blocking(move || {
for project in affected {
project.apply_file_types(&project.settings.effective());
}
});
}
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.
+25 -124
Переглянути файл
@@ -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?
}
+11 -8
Переглянути файл
@@ -1,27 +1,30 @@
//! Raw file bytes for the image viewer and hex view (see `ide_vfs::bytes`).
//! IPC commands of `ide_host::ops::bytes` (the shared implementation).
use crate::commands::doc::blocking;
use crate::state::AppState;
use ide_core::IdeResult;
#[allow(unused_imports)]
pub use ide_host::ops::bytes::*;
use ide_vfs::{ByteRange, FileProbe};
use std::path::PathBuf;
use tauri::State;
/// Size and text/binary classification, from the file's head only.
#[tauri::command]
#[specta::specta]
pub async fn fs_probe(path: PathBuf) -> IdeResult<FileProbe> {
blocking(move || ide_vfs::probe(&path)).await
pub async fn fs_probe(state: State<'_, AppState>, path: PathBuf) -> IdeResult<FileProbe> {
ide_host::ops::bytes::fs_probe(&state.host, "", path).await
}
/// Up to `len` bytes from `offset` (capped per call), base64-encoded.
#[tauri::command]
#[specta::specta]
pub async fn fs_read_range(path: PathBuf, offset: u64, len: u32) -> IdeResult<ByteRange> {
blocking(move || ide_vfs::read_range(&path, offset, len)).await
pub async fn fs_read_range(state: State<'_, AppState>, path: PathBuf, offset: u64, len: u32) -> IdeResult<ByteRange> {
ide_host::ops::bytes::fs_read_range(&state.host, "", path, offset, len).await
}
/// An image file as a `data:` URL.
#[tauri::command]
#[specta::specta]
pub async fn fs_read_image(path: PathBuf) -> IdeResult<String> {
blocking(move || ide_vfs::read_data_url(&path)).await
pub async fn fs_read_image(state: State<'_, AppState>, path: PathBuf) -> IdeResult<String> {
ide_host::ops::bytes::fs_read_image(&state.host, "", path).await
}
+8 -20
Переглянути файл
@@ -1,18 +1,13 @@
//! Run with Coverage: launch plans and coverage reports.
//! IPC commands of `ide_host::ops::coverage` (the shared implementation).
use crate::state::AppState;
use ide_core::{IdeError, IdeResult, IpcPath};
use ide_core::{IdeResult, IpcPath};
use ide_coverage::{CoveragePrepared, CoverageReportSpec, CoverageSuite};
#[allow(unused_imports)]
pub use ide_host::ops::coverage::*;
use ide_run::RunConfig;
use tauri::{State, Window};
/// Output directories kept per kind (older runs' data is deleted).
pub(crate) const KEEP_RUNS: usize = 10;
pub(crate) async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tauri::async_runtime::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
/// How to run `config` with coverage: the command to launch and where its report will be,
/// or which tool is missing.
#[tauri::command]
@@ -22,20 +17,14 @@ pub async fn coverage_prepare(
state: State<'_, AppState>,
config: RunConfig,
) -> IdeResult<CoveragePrepared> {
let project = state.project(window.label())?;
let (root, cache) = (project.root.clone(), project.cache_dir.join("coverage"));
blocking(move || {
let out = ide_run::wrap::new_output_dir(&cache, KEEP_RUNS)?;
Ok(ide_coverage::prepare(&root, &config, &out))
})
.await
ide_host::ops::coverage::coverage_prepare(&state.host, window.label(), config).await
}
/// Reads the report a coverage run left behind.
#[tauri::command]
#[specta::specta]
pub async fn coverage_collect(report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
blocking(move || ide_coverage::collect(&report)).await
pub async fn coverage_collect(state: State<'_, AppState>, report: CoverageReportSpec) -> IdeResult<CoverageSuite> {
ide_host::ops::coverage::coverage_collect(&state.host, "", report).await
}
/// Reads a report collected elsewhere (CI): lcov, a Go cover profile or JaCoCo XML,
@@ -43,6 +32,5 @@ pub async fn coverage_collect(report: CoverageReportSpec) -> IdeResult<CoverageS
#[tauri::command]
#[specta::specta]
pub async fn coverage_import(window: Window, state: State<'_, AppState>, path: IpcPath) -> IdeResult<CoverageSuite> {
let root = state.project(window.label())?.root.clone();
blocking(move || ide_coverage::import(&path, &root)).await
ide_host::ops::coverage::coverage_import(&state.host, window.label(), path).await
}
+35 -46
Переглянути файл
@@ -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
}
+3 -6
Переглянути файл
@@ -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]
+26 -83
Переглянути файл
@@ -1,46 +1,27 @@
//! IPC commands of `ide_host::ops::doc` (the shared implementation).
use crate::state::AppState;
use ide_core::{ChangeSet, DocId, IdeError, IdeResult, OutlineItem, Range};
use ide_core::{ChangeSet, DocId, IdeResult, OutlineItem, Range};
#[allow(unused_imports)]
pub use ide_host::ops::doc::*;
use ide_language::{FoldRange, SyntaxHighlights};
use ide_text::LineEnding;
use ide_workspace::{DocumentInfo, HistoryEntry};
use std::path::PathBuf;
use tauri::{State, Window};
/// Runs blocking work (disk I/O, parsing, large copies) on the blocking pool,
/// keeping it off the main thread and the async runtime's workers.
pub(crate) async fn blocking<T: Send + 'static>(f: impl FnOnce() -> IdeResult<T> + Send + 'static) -> IdeResult<T> {
tauri::async_runtime::spawn_blocking(f).await.map_err(|e| IdeError::internal(e.to_string()))?
}
/// Opens a document. Async: reading and decoding a large file must not block
/// the main thread (synchronous commands run on it).
#[tauri::command]
#[specta::specta]
pub async fn doc_open(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<DocumentInfo> {
let project = state.project(window.label())?;
blocking(move || {
let info = project.documents.open(&path)?;
project.index.mark_recent(&path);
Ok(info)
})
.await
}
/// Opens a read-only virtual document (see `lsp_virtual_document`) as an
/// editor buffer, so syntax features and language-server requests work in it.
#[tauri::command]
#[specta::specta]
pub async fn doc_open_virtual(window: Window, state: State<'_, AppState>, uri: String) -> IdeResult<DocumentInfo> {
let project = state.project(window.label())?;
let vd = project.lsp.virtual_document(&uri).await?;
blocking(move || project.documents.open_virtual(&vd.uri, vd.language_id, &vd.text)).await
ide_host::ops::doc::open(&state.host, window.label(), path).await
}
#[tauri::command]
#[specta::specta]
pub fn doc_close(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> {
state.project(window.label())?.documents.close(doc);
Ok(())
ide_host::ops::doc::close(&state.host, window.label(), doc)
}
/// Applies a CodeMirror change set; returns the new version. Rejects with
@@ -54,28 +35,32 @@ pub fn doc_edit(
base_version: u32,
changes: ChangeSet,
) -> IdeResult<u32> {
state.project(window.label())?.documents.edit(doc, base_version, changes)
ide_host::ops::doc::edit(&state.host, window.label(), doc, base_version, changes)
}
#[tauri::command]
#[specta::specta]
pub async fn doc_save(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.documents.save(doc)).await
ide_host::ops::doc::save(&state.host, window.label(), doc).await
}
#[tauri::command]
#[specta::specta]
pub async fn doc_save_all(window: Window, state: State<'_, AppState>) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || project.documents.save_all()).await
ide_host::ops::doc::save_all(&state.host, window.label()).await
}
#[tauri::command]
#[specta::specta]
pub async fn doc_snapshot(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<DocumentInfo> {
let project = state.project(window.label())?;
blocking(move || project.documents.snapshot(doc)).await
ide_host::ops::doc::snapshot(&state.host, window.label(), doc).await
}
/// Opens a read-only virtual document (see `lsp_virtual_document`) as an
/// editor buffer, so syntax features and language-server requests work in it.
#[tauri::command]
#[specta::specta]
pub async fn doc_open_virtual(window: Window, state: State<'_, AppState>, uri: String) -> IdeResult<DocumentInfo> {
ide_host::ops::doc::doc_open_virtual(&state.host, window.label(), uri).await
}
/// "Reload with Encoding" (`reload`) or "Convert to Encoding"; returns the new encoding label.
@@ -88,7 +73,7 @@ pub fn doc_set_encoding(
encoding: String,
reload: bool,
) -> IdeResult<String> {
state.project(window.label())?.documents.set_encoding(doc, &encoding, reload)
ide_host::ops::doc::doc_set_encoding(&state.host, window.label(), doc, encoding, reload)
}
#[tauri::command]
@@ -99,7 +84,7 @@ pub fn doc_set_line_ending(
doc: DocId,
line_ending: LineEnding,
) -> IdeResult<()> {
state.project(window.label())?.documents.set_line_ending(doc, line_ending)
ide_host::ops::doc::doc_set_line_ending(&state.host, window.label(), doc, line_ending)
}
/// Structure view: LSP document symbols when available (with tree-sitter
@@ -109,41 +94,13 @@ pub fn doc_set_line_ending(
#[tauri::command]
#[specta::specta]
pub async fn doc_outline(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<Vec<OutlineItem>> {
let project = state.project(window.label())?;
let lsp = project.lsp.document_symbols(doc).await.ok().flatten();
blocking(move || {
let snap = match project.documents.text_snapshot(doc) {
Ok(snap) => snap,
Err(e) => return lsp.ok_or(e),
};
let syntax = || project.syntax.outline(doc, &snap.language, snap.version, &snap.text);
Ok(match lsp {
// Server symbols carry no visibility; borrow it from the syntax tree.
Some(mut items) => {
if !snap.large {
ide_language::merge_visibility(&mut items, &syntax());
}
items
}
None if snap.large => Vec::new(),
None => syntax(),
})
})
.await
ide_host::ops::doc::doc_outline(&state.host, window.label(), doc).await
}
#[tauri::command]
#[specta::specta]
pub async fn doc_folding(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<Vec<FoldRange>> {
let project = state.project(window.label())?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(Vec::new());
}
Ok(project.syntax.folding(doc, &snap.language, snap.version, &snap.text))
})
.await
ide_host::ops::doc::doc_folding(&state.host, window.label(), doc).await
}
/// Tree-sitter highlighting for languages the UI has no CodeMirror mode for;
@@ -151,15 +108,7 @@ pub async fn doc_folding(window: Window, state: State<'_, AppState>, doc: DocId)
#[tauri::command]
#[specta::specta]
pub async fn doc_highlights(window: Window, state: State<'_, AppState>, doc: DocId) -> IdeResult<SyntaxHighlights> {
let project = state.project(window.label())?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
if snap.large {
return Ok(SyntaxHighlights::default());
}
Ok(project.syntax.highlights(doc, &snap.language, snap.version, &snap.text))
})
.await
ide_host::ops::doc::doc_highlights(&state.host, window.label(), doc).await
}
/// Extend Selection (⌥↑): syntax ranges around `offset`, innermost first.
@@ -172,23 +121,17 @@ pub async fn doc_selection_ranges(
doc: DocId,
offset: u32,
) -> IdeResult<Vec<Range>> {
let project = state.project(window.label())?;
blocking(move || {
let snap = project.documents.text_snapshot(doc)?;
let language = if snap.large { ide_core::LanguageId::plain_text() } else { snap.language };
Ok(project.syntax.selection_ranges(doc, &language, snap.version, &snap.text, offset))
})
.await
ide_host::ops::doc::doc_selection_ranges(&state.host, window.label(), doc, offset).await
}
#[tauri::command]
#[specta::specta]
pub fn history_list(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<HistoryEntry>> {
state.project(window.label())?.history.list(&path)
ide_host::ops::doc::history_list(&state.host, window.label(), path)
}
#[tauri::command]
#[specta::specta]
pub fn history_content(window: Window, state: State<'_, AppState>, id: String) -> IdeResult<String> {
state.project(window.label())?.history.content(&id)
ide_host::ops::doc::history_content(&state.host, window.label(), id)
}
+8 -60
Переглянути файл
@@ -1,6 +1,6 @@
use crate::commands::doc::blocking;
use crate::state::AppState;
use ide_core::IdeResult;
use ide_host::ops::fs;
use ide_vfs::FileEntry;
use std::path::PathBuf;
use tauri::{State, Window};
@@ -8,23 +8,7 @@ use tauri::{State, Window};
#[tauri::command]
#[specta::specta]
pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<Vec<FileEntry>> {
let excludes = state
.project(window.label())
.map(|p| {
let globs = p.exclude_globs();
// `read_dir` matches relative to the enclosing repository: re-anchor
// project-root patterns (`/build`) when the project is a repo subfolder.
let repo = p.root.ancestors().find(|d| d.join(".git").exists());
match repo.and_then(|r| p.root.strip_prefix(r).ok()).filter(|rel| !rel.as_os_str().is_empty()) {
Some(rel) => {
let rel = rel.to_string_lossy().replace('\\', "/");
globs.into_iter().map(|g| if g.starts_with('/') { format!("/{rel}{g}") } else { g }).collect()
}
None => globs,
}
})
.unwrap_or_default();
ide_vfs::read_dir(&path, &excludes)
fs::read_dir(&state.host, window.label(), path)
}
/// Creates an empty file; inside a project, honours the EditorConfig
@@ -32,24 +16,7 @@ pub fn fs_read_dir(window: Window, state: State<'_, AppState>, path: PathBuf) ->
#[tauri::command]
#[specta::specta]
pub fn fs_create_file(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
match state.project(window.label()) {
Ok(p) => {
let defaults = p.documents.file_defaults();
if defaults.editorconfig {
ide_workspace::editorconfig::create_file(p.documents.editorconfig(), &path)?;
} else {
ide_vfs::create_file(&path)?;
}
// No EditorConfig charset: File Encodings › "Create UTF-8 files with BOM".
let empty = std::fs::metadata(&path).map(|m| m.len() == 0).unwrap_or(false);
let bom = defaults.new_file_bytes();
if empty && !bom.is_empty() {
std::fs::write(&path, bom)?;
}
Ok(())
}
Err(_) => ide_vfs::create_file(&path),
}
fs::create_file(&state.host, window.label(), path)
}
#[tauri::command]
@@ -69,34 +36,20 @@ pub fn fs_rename(from: PathBuf, to: PathBuf) -> IdeResult<()> {
#[tauri::command]
#[specta::specta]
pub fn fs_set_writable(window: Window, state: State<'_, AppState>, path: PathBuf, writable: bool) -> IdeResult<bool> {
match state.project(window.label()) {
Ok(p) => p.documents.set_writable(&path, writable),
Err(_) => ide_vfs::set_writable(&path, writable).map(|()| !writable),
}
fs::set_writable(&state.host, window.label(), path, writable)
}
/// Moving to the trash can take a while (large trees): kept off the main thread.
#[tauri::command]
#[specta::specta]
pub async fn fs_delete(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<()> {
// Local History keeps the contents so deleted files can be restored.
let history = state.project(window.label()).ok().map(|p| p.history.clone());
blocking(move || {
if let Some(h) = history {
h.record_deletion(&path, DELETE_SNAPSHOT_LIMIT);
}
ide_vfs::delete(&path)
})
.await
fs::delete(&state.host, window.label(), path).await
}
/// Files of a deleted folder snapshotted in Local History.
const DELETE_SNAPSHOT_LIMIT: usize = 2000;
#[tauri::command]
#[specta::specta]
pub async fn fs_copy(from: PathBuf, to: PathBuf) -> IdeResult<()> {
blocking(move || ide_vfs::copy(&from, &to)).await
pub async fn fs_copy(state: State<'_, AppState>, from: PathBuf, to: PathBuf) -> IdeResult<()> {
fs::copy(&state.host, from, to).await
}
/// Reads a text file without opening it as a document (previews, quick
@@ -104,10 +57,5 @@ pub async fn fs_copy(from: PathBuf, to: PathBuf) -> IdeResult<()> {
#[tauri::command]
#[specta::specta]
pub async fn fs_read_text(window: Window, state: State<'_, AppState>, path: PathBuf) -> IdeResult<String> {
let project = state.project(window.label()).ok();
blocking(move || match project {
Some(p) => p.text_of(&path),
None => Ok(ide_vfs::load_text(&path)?.text),
})
.await
fs::read_text(&state.host, window.label(), path).await
}
+6 -26
Переглянути файл
@@ -1,13 +1,13 @@
//! Local History beyond single files: folders, labels, restoring deleted files.
//! IPC commands of `ide_host::ops::history` (the shared implementation).
use crate::state::AppState;
use ide_core::IdeResult;
#[allow(unused_imports)]
pub use ide_host::ops::history::*;
use ide_workspace::DirectoryHistory;
use std::path::PathBuf;
use tauri::{State, Window};
use super::doc::blocking;
/// Show History on a folder: snapshots of the files under it (deleted ones included) and labels.
#[tauri::command]
#[specta::specta]
@@ -16,16 +16,14 @@ pub async fn history_list_dir(
state: State<'_, AppState>,
path: PathBuf,
) -> IdeResult<DirectoryHistory> {
let project = state.project(window.label())?;
blocking(move || project.history.list_dir(&path)).await
ide_host::ops::history::history_list_dir(&state.host, window.label(), path).await
}
/// Local History › Put Label…: a named point in the history of every file.
#[tauri::command]
#[specta::specta]
pub fn history_put_label(window: Window, state: State<'_, AppState>, label: String) -> IdeResult<()> {
let project = state.project(window.label())?;
project.history.put_label(&project.root, &label)
ide_host::ops::history::history_put_label(&state.host, window.label(), label)
}
/// Revert to a snapshot: writes its content to `path` (re-creating a deleted
@@ -33,23 +31,5 @@ pub fn history_put_label(window: Window, state: State<'_, AppState>, label: Stri
#[tauri::command]
#[specta::specta]
pub async fn history_restore(window: Window, state: State<'_, AppState>, path: PathBuf, id: String) -> IdeResult<()> {
let project = state.project(window.label())?;
blocking(move || {
let text = project.history.content(&id)?;
if let Some(doc) = project.documents.by_path(&path) {
project.documents.replace_all(doc, &text)?;
return project.documents.save(doc);
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
if !path.exists() {
ide_vfs::create_file(&path)?;
}
let doc = project.documents.open(&path)?.id;
let result = project.documents.replace_all(doc, &text).and_then(|_| project.documents.save(doc));
project.documents.close(doc);
result
})
.await
ide_host::ops::history::history_restore(&state.host, window.label(), path, id).await
}
+7 -58
Переглянути файл
@@ -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
}
+32 -82
Переглянути файл
@@ -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
}
+13
Переглянути файл
@@ -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,
+18 -99
Переглянути файл
@@ -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
}
+9 -47
Переглянути файл
@@ -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
}
+37 -99
Переглянути файл
@@ -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
}
+32 -71
Переглянути файл
@@ -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?
}
+26 -146
Переглянути файл
@@ -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)
}
+8 -71
Переглянути файл
@@ -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, &regions, &|| 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
}
+5 -1
Переглянути файл
@@ -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
}
+15 -64
Переглянути файл
@@ -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
}
+5 -13
Переглянути файл
@@ -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]
+26 -98
Переглянути файл
@@ -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 });
}
}
+79 -439
Переглянути файл
@@ -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, &current, &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
}
+3 -2
Переглянути файл
@@ -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));
+23
Переглянути файл
@@ -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"),
],
));
+381
Переглянути файл
@@ -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) {
+58
Переглянути файл
@@ -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 };
+127 -65
Переглянути файл
@@ -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>
</>
)}
+36 -1
Переглянути файл
@@ -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.
+45
Переглянути файл
@@ -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",
);
});
});
+6 -3
Переглянути файл
@@ -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 ?? ""));
+3 -2
Переглянути файл
@@ -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
+178
Переглянути файл
@@ -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;
}
+58
Переглянути файл
@@ -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);
});
});
+214
Переглянути файл
@@ -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 />
</>
);
}
+190
Переглянути файл
@@ -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>
);
}
+366
Переглянути файл
@@ -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"),
},
]);
}
+96
Переглянути файл
@@ -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());
}
+155
Переглянути файл
@@ -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();
});
});
+192
Переглянути файл
@@ -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())}`;
}
+162
Переглянути файл
@@ -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);
+32
Переглянути файл
@@ -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;
}
}
}
+92
Переглянути файл
@@ -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);
},
},
});
});
}
+6
Переглянути файл
@@ -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
Переглянути файл
@@ -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 () => {
+141
Переглянути файл
@@ -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