Порівняти коміти

...
Автор SHA1 Повідомлення Дата
arookieofc 606aff1613 fix(remote): sync outside-root classpath entries for SSH Java launches (#2641) 2026-11-11 14:58:49 +08:00
Gabriel Gondim 4d60921f24 feat(database): list all databases on a SQL Server host (#2629)
SQL Server data sources without single-database mode now show a database level in the tree, like PostgreSQL: every online database the login can access, each expanding to its schemas, with the Schemas tab database picker and the Single database mode option. The backend already listed them; listsDatabases() only enabled the level for Postgres. A failed connect shows an error under the node instead of leaving it on Loading, and saving Properties reloads what the tree had loaded for that source so expanded nodes don't stay on Loading.

Closes #2600

Co-authored-by: GGVidal <[email protected]>
2026-11-11 14:40:52 +08:00
Paulem 7784f3cdef fix(dialog): fall back to a GTK file chooser on Linux when no portal or zenity is available (#2394)
On Linux sessions with neither an xdg-desktop-portal nor zenity (antiX), rfd's xdg-portal backend returned nothing and every Open / Save picker silently did nothing. All pickers now go through src/platform/dialog.ts; on Linux the backend checks once for a reachable portal or zenity and otherwise shows a GTK FileChooserDialog. Other platforms and sessions keep the desktop's dialog. dialog.test.ts keeps every picker (including Move Worktree, keymap import, Mermaid export and library sources) on the wrapper.

Fixes #2386

Co-authored-by: Paulem79 <[email protected]>
2026-11-11 14:25:03 +08:00
misswellтаmisswell b010397711 fix(lsp): mark progress tasks that stop reporting (#757)
A $/progress task that reports nothing for 3 minutes (jdtls's Maven import waiting on a repository that never answers) kept the server Busy forever, which also held wait_idle and ReferencesIncomplete::Indexing. Such a task now stays open with its last message and fraction, marked "no progress for 180 s", and no longer counts as busy; a late report clears the mark and the same task finishes normally.

Co-authored-by: misswell <[email protected]>
2026-11-11 14:18:16 +08:00
55 змінених файлів з 887 додано та 75 видалено

No files matched your search

+93 -2
Переглянути файл
@@ -11,10 +11,29 @@ use ide_jsonrpc::{Handler, codes};
use lsp_types as lsp;
use serde_json::{Value, json};
use std::sync::{Arc, Weak};
use std::time::Duration;
/// Progress token standing for `angular/projectLoadingStart` … `Finish`.
const ANGULAR_LOADING: &str = "angular/projectLoading";
/// How often a running progress token's silence is checked.
const PROGRESS_SWEEP: Duration = Duration::from_secs(15);
/// A token that reports nothing this long is marked stuck: jdtls's `Importing Maven
/// project(s)` waits minutes on one repository that never answers (`DefaultMetadataResolver`
/// awaits its whole batch), and a task frozen at one percent is not progress. It stays
/// visible, but stops holding the server busy while the work behind it keeps running.
const PROGRESS_STALE: Duration = Duration::from_secs(180);
/// Task message after [`PROGRESS_STALE`] of silence: keep the server's last message and
/// say how long nothing happened.
fn stalled_message(message: Option<&str>) -> String {
let secs = PROGRESS_STALE.as_secs();
match message.filter(|m| !m.is_empty()) {
Some(message) => format!("{message} — no progress for {secs} s"),
None => format!("No progress for {secs} s"),
}
}
pub struct ClientHandler {
mgr: Weak<LspManager>,
server: Arc<Server>,
@@ -48,13 +67,14 @@ impl ClientHandler {
other => other.to_string(),
};
let value = &params["value"];
let kind = value.get("kind").and_then(Value::as_str);
let message = value.get("message").and_then(Value::as_str).map(str::to_owned);
let fraction = value.get("percentage").and_then(Value::as_f64).map(|p| (p / 100.0).clamp(0.0, 1.0) as f32);
let cancellable = value.get("cancellable").and_then(Value::as_bool);
let mut replaced = None;
let event = {
let mut progress = self.server.progress.lock();
match value.get("kind").and_then(Value::as_str) {
match kind {
Some("begin") => {
let title = value
.get("title")
@@ -69,7 +89,18 @@ impl ClientHandler {
// (PHPantom: begin, begin, end, end). The task it replaces ends now, or no
// `end` would ever reach it.
replaced = progress
.insert(token, Progress { task, title: title.clone(), message: message.clone(), cancel })
.insert(
token.clone(),
Progress {
task,
title: title.clone(),
message: message.clone(),
cancel,
last: tokio::time::Instant::now(),
stalled: false,
fraction,
},
)
.map(|p| IdeEvent::Task {
id: p.task,
title: p.title,
@@ -82,9 +113,14 @@ impl ClientHandler {
}
Some("report") => {
let Some(p) = progress.get_mut(&token) else { return };
p.last = tokio::time::Instant::now();
p.stalled = false;
if message.is_some() {
p.message = message;
}
if fraction.is_some() {
p.fraction = fraction;
}
match cancellable {
Some(true) if p.cancel.is_none() => {
p.cancel = Some(self.cancel_handle(p.task, &params["token"]))
@@ -123,9 +159,64 @@ impl ClientHandler {
self.server.refresh_busy_status(mgr.sink());
if ended {
self.server.progress_ended();
} else if kind == Some("begin") {
self.watch_progress(mgr, token);
}
}
/// A task that reports nothing for [`PROGRESS_STALE`] is stuck: the work behind it is
/// blocked on something that never answers, and a bar frozen at one percent is not
/// progress. Keep its task visible but mark it, and stop counting it as busy: a slow
/// import can resume and finish in the same task, while the IDE stays usable.
fn watch_progress(&self, mgr: &LspManager, token: String) {
let (weak, server, generation) = (self.mgr.clone(), self.server.clone(), self.generation);
// The notification runs on a reader thread, not inside a runtime.
mgr.runtime.spawn(async move {
loop {
tokio::time::sleep(PROGRESS_SWEEP).await;
let Some(mgr) = weak.upgrade() else { return };
if server.running_gen(generation).is_none() || !server.has_progress(&token) {
return; // the instance is gone, or the task ended normally
}
if server.progress_silent(&token, PROGRESS_STALE) {
let handler = ClientHandler::new(Arc::downgrade(&mgr), server.clone(), generation);
if handler.mark_progress_stalled(&mgr, &token) {
tracing::warn!(
"{}: progress '{token}' reported nothing for {} s; marking it stuck",
server.name,
PROGRESS_STALE.as_secs()
);
}
}
}
});
}
/// Marks a silent task as possibly stuck without ending it. Returns whether it was
/// newly marked; a later `report` clears the mark and makes the server busy again.
fn mark_progress_stalled(&self, mgr: &LspManager, token: &str) -> bool {
let event = {
let mut progress = self.server.progress.lock();
let Some(p) = progress.get_mut(token) else { return false };
if p.stalled || !p.silent(PROGRESS_STALE) {
return false;
}
p.stalled = true;
IdeEvent::Task {
id: p.task,
title: p.title.clone(),
message: Some(stalled_message(p.message.as_deref())),
fraction: p.fraction,
done: false,
cancellable: p.cancel.is_some(),
}
};
self.server.emit(mgr.sink(), event);
self.server.refresh_busy_status(mgr.sink());
self.server.progress_ended();
true
}
fn publish_diagnostics(&self, mgr: &LspManager, params: Value) {
// Problems come only from servers used for `diagnostics`.
if self.server.running_gen(self.generation).is_some_and(|run| !run.caps.read().restriction.diagnostics()) {
+81 -3
Переглянути файл
@@ -74,6 +74,22 @@ pub struct Progress {
pub message: Option<String>,
/// Set while the server accepts `window/workDoneProgress/cancel` for it.
pub cancel: Option<ide_core::task::CancelHandle>,
/// When the server last reported on this token; a task that stops reporting is stuck.
pub last: tokio::time::Instant,
/// Last percentage, so a stalled task does not lose its bar.
pub fraction: Option<f32>,
/// Marked after the stale window of silence. The task stays visible, but no longer
/// counts as busy: the work may resume and finish in the same task.
pub stalled: bool,
}
impl Progress {
/// Nothing came for `stale` and the task is not already marked: the work behind it is
/// blocked on something that never answers (an HTTP read with no response header, Maven
/// waiting for a repository). Its task must not hold the IDE while that lasts.
pub fn silent(&self, stale: Duration) -> bool {
!self.stalled && tokio::time::Instant::now() >= self.last + stale
}
}
pub struct Server {
@@ -542,9 +558,10 @@ impl Server {
self.publish(sink);
}
/// Whether the server reports work in progress (indexing, analysis).
/// Whether the server reports work in progress (indexing, analysis). A task marked
/// stuck stays visible but no longer holds the server busy.
pub fn is_busy(&self) -> bool {
!self.progress.lock().is_empty() || *self.loading.lock() == Some(true)
self.progress.lock().values().any(|p| !p.stalled) || *self.loading.lock() == Some(true)
}
/// Whether the server says it is still loading the project, so an empty
@@ -564,6 +581,16 @@ impl Server {
self.phase_ended();
}
/// Whether progress token `token` is still running on this instance.
pub fn has_progress(&self, token: &str) -> bool {
self.progress.lock().contains_key(token)
}
/// Whether token `token` has reported nothing for `stale`.
pub fn progress_silent(&self, token: &str, stale: Duration) -> bool {
self.progress.lock().get(token).is_some_and(|p| p.silent(stale))
}
/// Called after a `$/progress` task ended.
pub fn progress_ended(&self) {
self.phase_ended();
@@ -830,7 +857,15 @@ mod tests {
s.set_diagnostics(path.clone(), vec![lsp::Diagnostic::default()]);
s.progress.lock().insert(
"old".into(),
Progress { task: TaskId::next(), title: "Old work".into(), message: None, cancel: None },
Progress {
task: TaskId::next(),
title: "Old work".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: None,
stalled: false,
},
);
let seen = Arc::new(Mutex::new(Vec::new()));
let manager = Arc::new(Mutex::new(std::sync::Weak::<crate::LspManager>::new()));
@@ -869,6 +904,49 @@ mod tests {
Server::new(cfg, PathBuf::from("/p"), LanguageId::new("vue"))
}
#[tokio::test(start_paused = true)]
async fn a_progress_task_turns_silent_once_its_reports_stop() {
let stale = Duration::from_secs(180);
let mut p = Progress {
task: TaskId::next(),
title: "Importing Maven project(s)".into(),
message: Some("https://maven.example/".into()),
cancel: None,
last: tokio::time::Instant::now(),
fraction: Some(0.44),
stalled: false,
};
assert!(!p.silent(stale));
tokio::time::advance(stale).await;
assert!(p.silent(stale), "no report for the whole window");
// A report puts the task back to work, whatever it was doing.
p.last = tokio::time::Instant::now();
p.stalled = true;
assert!(!p.silent(stale));
p.stalled = false;
assert!(!p.silent(stale));
}
#[test]
fn a_stalled_progress_task_does_not_keep_the_server_busy() {
let s = server();
s.progress.lock().insert(
"p".into(),
Progress {
task: TaskId::next(),
title: "Importing Maven project(s)".into(),
message: None,
cancel: None,
last: tokio::time::Instant::now(),
fraction: None,
stalled: true,
},
);
assert!(!s.is_busy(), "a stuck task must not hold wait_idle or ReferencesIncomplete::Indexing");
s.progress.lock().get_mut("p").unwrap().stalled = false;
assert!(s.is_busy(), "a reporting task keeps the server busy");
}
#[test]
fn crashes_of_short_lived_instances_exhaust_the_budget_whatever_the_spacing() {
let s = server();
+53
Переглянути файл
@@ -1685,6 +1685,59 @@ async fn progress_tasks_and_busy_status() {
assert!(tasks[2].3);
}
#[tokio::test(start_paused = true)]
async fn a_progress_task_that_stops_reporting_is_marked_but_stays_open() {
let h = harness(&[("mock", incremental_caps())]);
h.open("a.mk", "x");
h.wait_status("mock", ServerStatus::Ready).await;
let peer = h.launcher.state("mock").peer();
peer.notify(
"$/progress",
json!({ "token": "mvn", "value": {
"kind": "begin",
"title": "Initialize Workspace",
"message": "Importing Maven project(s) - https://maven.example/",
"percentage": 44,
} }),
)
.unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
// The import is blocked on a repository that never answers: nothing reports again.
tokio::time::advance(Duration::from_secs(200)).await;
h.wait_status("mock", ServerStatus::Ready).await;
let marked = h.events.lock().iter().rev().find_map(|e| match e {
IdeEvent::Task { title, message, fraction, done, .. } if title == "Initialize Workspace" && !done => {
Some((message.clone(), *fraction))
}
_ => None,
});
let (message, fraction) = marked.expect("the task stays open");
let message = message.expect("the last message is kept");
assert!(message.contains("no progress for 180 s"), "{message}");
assert_eq!(fraction, Some(0.44), "the bar keeps its last value");
assert!(
!h.events
.lock()
.iter()
.any(|e| matches!(e, IdeEvent::Task { title, done: true, .. } if title == "Initialize Workspace")),
"a stalled task must not be closed"
);
// A late report brings it back to Busy and finishes in the same task.
peer.notify(
"$/progress",
json!({ "token": "mvn", "value": { "kind": "report", "message": "halfway", "percentage": 60 } }),
)
.unwrap();
h.wait_status("mock", ServerStatus::Busy).await;
peer.notify("$/progress", json!({ "token": "mvn", "value": { "kind": "end" } })).unwrap();
h.wait_status("mock", ServerStatus::Ready).await;
assert!(
h.events.lock().iter().any(|e| matches!(e, IdeEvent::Task { title, message: Some(m), done: true, .. } if title == "Initialize Workspace" && m == "halfway")),
"the same task ends with the final message"
);
}
/// While a server says it is loading the project (rust-analyzer: `serverStatus`
/// not quiescent) an empty or withdrawn answer is asked again once loading moves
/// on. `$/progress` alone (background indexing, `cargo check`) doesn't wait.
+84 -5
Переглянути файл
@@ -1,13 +1,14 @@
//! Deployment over SFTP or local folders, and SSH run targets.
//! Shared by IPC commands and project save/delete hooks.
use crate::deploy::{Deployment, SyncEntry, SyncStep, TransferReport};
use crate::mapping::{Excludes, Mappings, ResolvedMapping};
use crate::target::{RemoteRuns, RemoteStart, SyncFn};
use crate::deploy::{Deployment, FileStamp, SyncEntry, SyncStatus, SyncStep, TransferReport, status};
use crate::mapping::{Excludes, Mappings, ResolvedMapping, posix};
use crate::target::{RemoteRuns, RemoteStart, SyncFn, rewrite_external_classpath};
use ide_core::remote_settings::{AutoUpload, DeploymentServer, DeploymentType, RunTarget};
use ide_core::settings::Settings;
use ide_core::{CancellationToken, IdeError, IdeResult, SharedSink};
use ide_run::{OutputChunk, RunConfig, RunInfo};
use ide_ssh::sftp::{join, mtime_of};
use ide_ssh::{FsConnection, RemoteEntry, RemoteFs, SshManager};
use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
@@ -359,10 +360,29 @@ impl RemoteService {
return Err(e);
}
};
// #2641: the resolved `-cp` may reference jars outside the project root
// (`~/.m2`), which deployment sync never uploads. Mirror them under the
// deployment and rewrite the command onto the remote copies. Entries inside
// the project root are untouched: `target_command` maps those already.
let (args, libraries) = rewrite_external_classpath(&config.args, root, &remote_root);
let mut config = config.clone();
config.args = args;
for (local, _) in &libraries {
if !local.exists() {
fs.close().await;
return Err(IdeError::invalid(format!(
"cannot run on '{}': classpath entry {} does not exist on this machine; \
package the application (for example `mvn package`) or fix the launch classpath",
target.name,
local.display()
)));
}
}
let sync: Option<SyncFn> = if target.sync_before_run {
let deployment = settings.deployment.clone();
let root = root.to_path_buf();
let remote_root = remote_root.clone();
let preserve_time = settings.deployment.preserve_timestamps;
Some(Box::new(move |out| {
Box::pin(async move {
let server = DeploymentServer { root_path: remote_root.clone(), ..Default::default() };
@@ -377,6 +397,7 @@ impl RemoteService {
};
let mut progress = |_: &str, _: usize, _: usize| {};
let report = d.push_changes(&root, &mut progress).await;
let synced = sync_external_libraries(&fs, &libraries, preserve_time).await;
fs.close().await;
let report = report?;
out(OutputChunk::System {
@@ -387,7 +408,17 @@ impl RemoteService {
ide_core::error::ErrorKind::Io,
format!("upload of {} failed: {}", e.path, e.message),
)),
None => Ok(()),
None => {
let synced = synced?;
if synced > 0 {
out(OutputChunk::System {
text: format!(
"Synced {synced} external classpath file(s) to {remote_root}/.remote-libs\n"
),
});
}
Ok(())
}
}
})
}))
@@ -396,10 +427,58 @@ impl RemoteService {
None
};
let start = RemoteStart { session, target, root: root.to_path_buf(), remote_root, sync };
Ok(self.runs.start(start, config, sink, on_output))
Ok(self.runs.start(start, &config, sink, on_output))
}
}
/// Uploads outside-root classpath entries to their mirrored remote copies,
/// skipping files the remote already has (same size and time). Returns the
/// uploaded count. Missing locals are rejected in `run` before the sync starts.
async fn sync_external_libraries(
fs: &RemoteFs,
libraries: &[(PathBuf, String)],
preserve_time: bool,
) -> IdeResult<usize> {
fn fail(path: &str, e: IdeError) -> IdeError {
IdeError::new(ide_core::error::ErrorKind::Io, format!("sync of {path} failed: {}", e.message))
}
let mut synced = 0;
for (local, remote_base) in libraries {
let mut files = Vec::new();
if local.is_dir() {
let walk = ignore::WalkBuilder::new(local).standard_filters(false).follow_links(false).build();
for entry in walk.flatten() {
let path = entry.path();
// `is_file` (not the entry type): symlinked jars count.
if !path.is_file() {
continue;
}
let Ok(rel) = path.strip_prefix(local) else { continue };
files.push((path.to_path_buf(), join(remote_base, &posix(rel))));
}
} else {
files.push((local.clone(), remote_base.clone()));
}
for (file, remote) in files {
let size = std::fs::metadata(&file).map_err(|e| fail(&remote, e.into()))?.len();
let local_stamp = FileStamp { size, mtime: mtime_of(&file) };
let remote_stamp = match fs.stat(&remote).await.map_err(|e| fail(&remote, e))? {
Some(e) if e.is_dir => {
return Err(IdeError::invalid(format!("cannot sync onto the folder {remote}")));
}
Some(e) => Some(FileStamp { size: e.size, mtime: e.mtime }),
None => None,
};
if matches!(status(Some(local_stamp), remote_stamp), SyncStatus::Same) {
continue;
}
fs.upload(&file, &remote, preserve_time).await.map_err(|e| fail(&remote, e))?;
synced += 1;
}
}
Ok(synced)
}
fn sanitize(s: &str) -> String {
s.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect()
}
+147 -1
Переглянути файл
@@ -10,7 +10,7 @@ use ide_ssh::{Output, RemoteProcess, Session, Sig};
use parking_lot::Mutex;
use std::collections::BTreeMap;
use std::future::Future;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
@@ -118,6 +118,105 @@ pub fn argv_command(config: &RunConfig, target: &RunTarget, root: &Path, remote_
target_command(config, target, root, remote_root, false)
}
/// `java` flags whose value is a path list that may point outside the project
/// (`~/.m2` jars, `--module-path` entries): deployment sync only uploads the
/// project root, so these entries need their own sync + rewrite (#2641).
const PATH_LIST_FLAGS: [&str; 6] =
["-cp", "-classpath", "--class-path", "-p", "--module-path", "--upgrade-module-path"];
/// The separator of a `-cp` value on this (client) machine.
pub fn classpath_separator() -> char {
if cfg!(windows) { ';' } else { ':' }
}
fn split_path_list(value: &str) -> Vec<&str> {
value.split(classpath_separator()).filter(|e| !e.is_empty()).collect()
}
/// Absolute classpath entries of `args` that live outside the project root.
/// Unresolved placeholders (`${…}`), wildcards and relative entries pass through:
///
/// callers map the project root already, and only concrete local files can be synced.
pub fn external_classpath_entries(args: &[String], root: &Path) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut i = 0;
while i < args.len() {
if PATH_LIST_FLAGS.contains(&args[i].as_str()) && i + 1 < args.len() {
let value = &args[i + 1];
if !value.contains("${") {
for entry in split_path_list(value) {
if entry.contains('*') {
continue;
}
let path = PathBuf::from(entry);
if path.is_absolute() && !path.starts_with(root) && !out.contains(&path) {
out.push(path);
}
}
}
i += 2;
} else {
i += 1;
}
}
out
}
/// The remote home of an outside-root dependency: mirrored under `.remote-libs`
/// (never `.openidea*`: deployment excludes those names), so the layout stays
/// debuggable and collision-free per client machine.
pub fn external_lib_remote(remote_root: &str, local: &Path) -> String {
let spelling = local.to_string_lossy().replace('\\', "/");
let stripped = spelling.trim_start_matches('/').replace(':', "");
join(remote_root.trim_end_matches('/'), &format!(".remote-libs/{stripped}"))
}
/// Rewrites the outside-root classpath entries of `args` onto their synced remote
/// copies; returns the new args and the (local entry, remote base) pairs to upload.
/// Entries inside the project root are untouched: `target_command` maps those.
pub fn rewrite_external_classpath(
args: &[String],
root: &Path,
remote_root: &str,
) -> (Vec<String>, Vec<(PathBuf, String)>) {
let mut table: Vec<(PathBuf, String)> = Vec::new();
let mut out = args.to_vec();
let mut i = 0;
while i < out.len() {
if PATH_LIST_FLAGS.contains(&out[i].as_str()) && i + 1 < out.len() {
let value = out[i + 1].clone();
if !value.contains("${") {
let mut rewritten = Vec::new();
let mut changed = false;
for entry in split_path_list(&value) {
let path = PathBuf::from(entry);
if !entry.contains('*') && path.is_absolute() && !path.starts_with(root) {
let remote = match table.iter().find(|(p, _)| p == &path) {
Some((_, r)) => r.clone(),
None => {
let r = external_lib_remote(remote_root, &path);
table.push((path, r.clone()));
r
}
};
rewritten.push(remote);
changed = true;
} else {
rewritten.push(entry.to_owned());
}
}
if changed {
out[i + 1] = rewritten.join(&classpath_separator().to_string());
}
}
i += 2;
} else {
i += 1;
}
}
(out, table)
}
fn target_command(
config: &RunConfig,
target: &RunTarget,
@@ -381,4 +480,51 @@ mod tests {
assert_eq!(replace_root("/work/proj", "/work/proj", "/srv"), "/srv");
assert_eq!(replace_root(r"C:\p\src/a.py C:\p2", r"C:\p", "/srv"), r"/srv/src/a.py C:\p2");
}
/// Unix/Windows spellings of the same layout: one inside-root entry, one
/// `~/.m2` jar twice, a placeholder value, a wildcard and a module path.
fn classpath_fixture() -> (PathBuf, Vec<String>, String, String) {
let (root, classes, m2) = if cfg!(windows) {
(r"C:\proj", r"C:\proj\target\classes", r"C:\Users\me\.m2\a.jar")
} else {
("/proj", "/proj/target/classes", "/home/me/.m2/a.jar")
};
let sep = classpath_separator();
let args = vec![
"-cp".to_owned(),
format!("{classes}{sep}{m2}{sep}{m2}"),
"-p".to_owned(),
m2.to_owned(),
"-cp".to_owned(),
"${classpath}".to_owned(),
"-cp".to_owned(),
"/elsewhere/lib/*".to_owned(),
];
(PathBuf::from(root), args, m2.to_owned(), classes.to_owned())
}
#[test]
fn collects_outside_root_classpath_entries_once() {
let (root, args, m2, _) = classpath_fixture();
let entries = external_classpath_entries(&args, &root);
assert_eq!(entries, vec![PathBuf::from(m2)]);
}
#[test]
fn rewrites_outside_root_entries_onto_remote_lib_copies() {
let (root, args, m2, classes) = classpath_fixture();
let (rewritten, table) = rewrite_external_classpath(&args, &root, "/srv/app");
let remote = if cfg!(windows) {
"/srv/app/.remote-libs/C/Users/me/.m2/a.jar".to_owned()
} else {
"/srv/app/.remote-libs/home/me/.m2/a.jar".to_owned()
};
assert_eq!(table, vec![(PathBuf::from(m2), remote.clone())]);
let sep = classpath_separator();
assert_eq!(rewritten[1], format!("{classes}{sep}{remote}{sep}{remote}"));
assert_eq!(rewritten[3], remote);
// Placeholders and wildcards pass through untouched.
assert_eq!(rewritten[5], "${classpath}");
assert_eq!(rewritten[7], "/elsewhere/lib/*");
}
}
+224
Переглянути файл
@@ -0,0 +1,224 @@
//! GTK file chooser for Linux sessions where the dialog plugin has nothing to show.
//!
//! The dialog plugin is built with rfd's `xdg-portal` backend so KDE and GNOME show their own
//! chooser. Without a portal service and without `zenity`, rfd returns nothing and the UI reads
//! that as a cancelled dialog. The frontend asks `file_dialog_needs_gtk` once, and routes the
//! pickers through `gtk_pick_paths` when it answers true. GTK3 is already linked by the webview.
use ide_core::{IdeError, IdeResult, IpcPath};
use serde::Deserialize;
use specta::Type;
use std::path::PathBuf;
#[derive(Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub struct GtkFilter {
pub name: String,
pub extensions: Vec<String>,
}
#[derive(Deserialize, Type)]
#[serde(rename_all = "camelCase")]
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
pub struct GtkPickOptions {
pub title: Option<String>,
pub default_path: Option<PathBuf>,
pub directory: bool,
pub multiple: bool,
/// A save dialog: one path, with overwrite confirmation.
pub save: bool,
pub filters: Vec<GtkFilter>,
}
/// True when the plugin's dialogs can't be shown here: Linux with no portal service on the session
/// bus (running or activatable) and no `zenity`.
#[tauri::command]
#[specta::specta]
pub async fn file_dialog_needs_gtk() -> bool {
#[cfg(target_os = "linux")]
{
tauri::async_runtime::spawn_blocking(|| !linux::portal_reachable() && !linux::zenity_installed())
.await
.unwrap_or(false)
}
#[cfg(not(target_os = "linux"))]
{
false
}
}
/// Shows a GTK file chooser attached to `window`. `None` when the user cancels.
#[tauri::command]
#[specta::specta]
pub async fn gtk_pick_paths(window: tauri::Window, options: GtkPickOptions) -> IdeResult<Option<Vec<IpcPath>>> {
#[cfg(target_os = "linux")]
{
let (tx, rx) = std::sync::mpsc::channel();
window
.run_on_main_thread({
let window = window.clone();
move || {
let _ = tx.send(linux::pick(
window.gtk_window().ok().map(gtk::prelude::Cast::upcast::<gtk::Window>).as_ref(),
&options,
));
}
})
.map_err(|e| IdeError::internal(e.to_string()))?;
let picked = tauri::async_runtime::spawn_blocking(move || rx.recv())
.await
.map_err(|e| IdeError::internal(e.to_string()))?;
Ok(picked.map_err(|e| IdeError::internal(e.to_string()))?.map(|paths| paths.into_iter().map(IpcPath).collect()))
}
#[cfg(not(target_os = "linux"))]
{
let _ = (window, options);
Err(IdeError::unavailable("The GTK file chooser is only available on Linux"))
}
}
#[cfg(target_os = "linux")]
mod linux {
use super::GtkPickOptions;
use gtk::gio::{self, prelude::*};
use gtk::prelude::*;
use std::path::PathBuf;
const PORTAL: &str = "org.freedesktop.portal.Desktop";
fn bus_call(
connection: &gio::DBusConnection,
method: &str,
parameters: Option<&gtk::glib::Variant>,
) -> Option<gtk::glib::Variant> {
connection
.call_sync(
Some("org.freedesktop.DBus"),
"/org/freedesktop/DBus",
"org.freedesktop.DBus",
method,
parameters,
None,
gio::DBusCallFlags::NONE,
2000,
gio::Cancellable::NONE,
)
.ok()
}
/// Whether `org.freedesktop.portal.Desktop` is running or can be started on demand.
pub fn portal_reachable() -> bool {
let Ok(connection) = gio::bus_get_sync(gio::BusType::Session, gio::Cancellable::NONE) else {
return false;
};
let running = bus_call(&connection, "NameHasOwner", Some(&(PORTAL,).to_variant()))
.and_then(|v| v.child_value(0).get::<bool>())
.unwrap_or(false);
running
|| bus_call(&connection, "ListActivatableNames", None)
.and_then(|v| v.child_value(0).get::<Vec<String>>())
.is_some_and(|names| names.iter().any(|n| n == PORTAL))
}
pub fn zenity_installed() -> bool {
std::env::var_os("PATH")
.is_some_and(|path| std::env::split_paths(&path).any(|dir| dir.join("zenity").is_file()))
}
pub fn pick(parent: Option<&gtk::Window>, options: &GtkPickOptions) -> Option<Vec<PathBuf>> {
let action = match (options.save, options.directory) {
(true, _) => gtk::FileChooserAction::Save,
(false, true) => gtk::FileChooserAction::SelectFolder,
(false, false) => gtk::FileChooserAction::Open,
};
let accept = if options.save { "_Save" } else { "_Open" };
let dialog = gtk::FileChooserDialog::with_buttons(
Some(options.title.as_deref().unwrap_or(if options.save { "Save" } else { "Open" })),
parent,
action,
&[("_Cancel", gtk::ResponseType::Cancel), (accept, gtk::ResponseType::Accept)],
);
dialog.set_select_multiple(options.multiple && !options.save);
dialog.set_do_overwrite_confirmation(options.save);
if let Some(default) = &options.default_path {
if default.is_dir() {
let _ = dialog.set_current_folder(default);
} else {
if let Some(parent) = default.parent().filter(|p| p.is_dir()) {
let _ = dialog.set_current_folder(parent);
}
if options.save {
if let Some(name) = default.file_name() {
dialog.set_current_name(&name.to_string_lossy());
}
} else if default.is_file() {
let _ = dialog.set_filename(default);
}
}
}
for filter in options.filters.iter().filter(|f| !f.extensions.is_empty()) {
let gtk_filter = gtk::FileFilter::new();
gtk_filter.set_name(Some(&filter.name));
for extension in &filter.extensions {
if extension == "*" {
gtk_filter.add_pattern("*");
} else {
// GTK patterns are case sensitive; match `.TXT` as well as `.txt`.
let ext: String = extension
.chars()
.map(|c| {
if c.is_ascii_alphabetic() {
format!("[{}{}]", c.to_ascii_lowercase(), c.to_ascii_uppercase())
} else {
c.to_string()
}
})
.collect();
gtk_filter.add_pattern(&format!("*.{ext}"));
}
}
dialog.add_filter(gtk_filter);
}
let response = dialog.run();
let paths = (response == gtk::ResponseType::Accept).then(|| dialog.filenames());
// SAFETY: the dialog is ours, and not used after this.
unsafe { dialog.destroy() };
paths.filter(|p| !p.is_empty())
}
}
#[cfg(all(test, target_os = "linux"))]
mod tests {
use super::*;
use gtk::prelude::*;
/// Needs a display and no portal service, e.g. `xvfb-run` with no session bus.
#[test]
#[ignore = "needs a display"]
fn gtk_chooser_opens_without_a_portal_or_zenity() {
gtk::init().expect("a display");
assert!(!linux::portal_reachable(), "run without a session bus");
let dir = std::env::temp_dir();
let options = GtkPickOptions {
title: Some("Open Folder".into()),
default_path: Some(dir.clone()),
directory: true,
multiple: false,
save: false,
filters: vec![],
};
// Accept the chooser once it is on screen, and note its title.
gtk::glib::timeout_add_local(std::time::Duration::from_millis(500), || {
let chooser =
gtk::Window::list_toplevels().into_iter().find_map(|w| w.downcast::<gtk::FileChooserDialog>().ok());
let chooser = chooser.expect("the chooser is shown");
assert_eq!(chooser.title().as_deref(), Some("Open Folder"));
assert!(chooser.is_visible());
chooser.response(gtk::ResponseType::Accept);
gtk::glib::ControlFlow::Break
});
let picked = linux::pick(None, &options).expect("the chooser returned the accepted folder");
assert_eq!(picked, vec![dir]);
}
}
+3
Переглянути файл
@@ -22,6 +22,7 @@ pub mod deploy;
pub mod doc;
pub mod docker;
pub mod env;
pub mod file_dialog;
pub mod fonts;
pub mod fs;
pub mod github;
@@ -66,6 +67,8 @@ pub(crate) fn sender<T: tauri::ipc::IpcResponse + Send + Sync + 'static>(
pub fn builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::<tauri::Wry>::new().commands(collect_commands![
app::app_info,
file_dialog::file_dialog_needs_gtk,
file_dialog::gtk_pick_paths,
mcp::mcp_ui_ready,
mcp::mcp_respond,
mcp::mcp_server_status,
+22
Переглянути файл
@@ -8,6 +8,13 @@ import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core";
/** Commands */
export const commands = {
appInfo: () => __TAURI_INVOKE<AppInfo>("app_info"),
/**
* True when the plugin's dialogs can't be shown here: Linux with no portal service on the session
* bus (running or activatable) and no `zenity`.
*/
fileDialogNeedsGtk: () => __TAURI_INVOKE<boolean>("file_dialog_needs_gtk"),
/** Shows a GTK file chooser attached to `window`. `None` when the user cancels. */
gtkPickPaths: (options: GtkPickOptions) => typedError<IpcPath[] | null, IdeError>(__TAURI_INVOKE("gtk_pick_paths", { options })),
mcpUiReady: (ready: boolean) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_ui_ready", { ready })),
mcpRespond: (id: number, allow: boolean, error: string | null) => typedError<null, IdeError>(__TAURI_INVOKE("mcp_respond", { id, allow, error })),
mcpServerStatus: () => typedError<McpServerStatus, IdeError>(__TAURI_INVOKE("mcp_server_status")),
@@ -4894,6 +4901,21 @@ export type GradleTask = {
description: string | null,
};
export type GtkFilter = {
name: string,
extensions: string[],
};
export type GtkPickOptions = {
title: string | null,
defaultPath: string | null,
directory: boolean,
multiple: boolean,
/** A save dialog: one path, with overwrite confirmation. */
save: boolean,
filters: GtkFilter[],
};
/** GutterAction data. */
export type GutterAction = {
line: number,
+1 -1
Переглянути файл
@@ -26,7 +26,7 @@ async function retrieve(path: string, choose: boolean) {
try {
let sources: string | null = null;
if (choose) {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const selected = await open({
title: tx("Choose Sources…"),
filters: [{ name: tx("Source archives"), extensions: ["jar", "zip"] }],
+1 -1
Переглянути файл
@@ -1,5 +1,5 @@
/** Agents of the ACP Registry: listing, install / update / remove (`agent_registry_*`). */
import { open } from "@tauri-apps/plugin-dialog";
import { open } from "@/platform/dialog";
import { Channel } from "@tauri-apps/api/core";
import type {
AgentProvider,
+1 -1
Переглянути файл
@@ -40,7 +40,7 @@ export function AnalysisDialogHost() {
async function browseDirectory(initial: string): Promise<string | null> {
if (!isTauri()) return null;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ directory: true, multiple: false, defaultPath: initial || undefined });
return typeof picked === "string" ? picked : null;
}
+1 -1
Переглянути файл
@@ -82,7 +82,7 @@ function NewProjectDialog({ onClose }: { onClose: () => void }) {
const browse = async () => {
if (!isTauri()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: true,
multiple: false,
+1 -1
Переглянути файл
@@ -55,7 +55,7 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
setAdding("");
};
const browse = async (target: "workspace" | "project") => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const result = await open({
directory: true,
multiple: target === "project",
+2 -2
Переглянути файл
@@ -56,7 +56,7 @@ function errorText(e: unknown) {
}
async function exportSettings() {
const { save } = await import("@tauri-apps/plugin-dialog");
const { save } = await import("@/platform/dialog");
const path = await save({
title: "Export Settings",
defaultPath: "openidea-settings.json",
@@ -77,7 +77,7 @@ async function exportSettings() {
}
async function importSettings() {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
title: "Import Settings",
multiple: false,
+2 -2
Переглянути файл
@@ -208,14 +208,14 @@ export function defaultProjectDirectory(): string | undefined {
}
async function pickFolder(title: string): Promise<string | null> {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ directory: true, multiple: false, title, defaultPath: defaultProjectDirectory() });
const path = Array.isArray(picked) ? picked[0] : picked;
return path ?? null;
}
async function pickFiles(title: string): Promise<string[]> {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ directory: false, multiple: true, title, defaultPath: defaultProjectDirectory() });
return picked === null ? [] : Array.isArray(picked) ? picked : [picked];
}
+7 -3
Переглянути файл
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { executeAction, registerAction, allActions, isActionEnabled } from "@/platform/actions";
import { setContext } from "@/platform/context";
import type { BuildModel, GradleModel, ProjectInfo, RunConfig } from "@/platform/ipc";
@@ -233,8 +233,12 @@ it("re-adds a picked POM using the plus button and preserves other unlinked root
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Add Maven Projects" }));
});
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ defaultPath: "/p", directory: false }));
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", '["b/pom.xml"]');
await waitFor(() =>
expect(mocks.open).toHaveBeenCalledWith(expect.objectContaining({ defaultPath: "/p", directory: false })),
);
await waitFor(() =>
expect(mocks.set).toHaveBeenCalledWith("project", "languages.java.maven.unlinkedProjects", '["b/pom.xml"]'),
);
});
it.each([null, "/outside/pom.xml", "/p/a/build.xml", "/p/../outside/pom.xml", "/p/a/pom.xml"])(
+2 -2
Переглянути файл
@@ -334,7 +334,7 @@ function projectPoms(root: string, picked: readonly string[]): { inside: string[
export async function addProjects() {
const root = useProject.getState().project?.root;
if (!root || useProject.getState().project?.workspace || !isTauri()) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
title: tx`Add Maven Projects`,
directory: false,
@@ -368,7 +368,7 @@ export async function linkGradleProjects() {
!isTauri()
)
return;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
title: tx`Link Gradle Projects`,
directory: false,
+1 -1
Переглянути файл
@@ -27,7 +27,7 @@ import styles from "./Coverage.module.css";
const errorText = (e: unknown) => (e instanceof Error ? e.message : String(e));
async function pickReport(): Promise<string | null> {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const file = await open({
multiple: false,
directory: false,
+1 -1
Переглянути файл
@@ -149,7 +149,7 @@ export function ImportDialog({ id, schema, table }: { id: string; schema: string
const choose = async () => {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const f = await open({
multiple: false,
directory: false,
+13 -2
Переглянути файл
@@ -319,11 +319,22 @@ export function DatabaseView() {
const load = async (n: Node) => {
if (n.kind === "source") {
const id = n.source.id ?? "";
if (!(await connect(id))) return;
if (!(await connect(id))) {
const err = { status: "error" as const, message: tx("Cannot connect") };
if (listsDatabases(n.source)) useDatabase.setState((s) => ({ databases: { ...s.databases, [id]: err } }));
else useDatabase.setState((s) => ({ schemas: { ...s.schemas, [id]: err } }));
return;
}
if (listsDatabases(n.source)) await loadDatabases(id);
else await loadSchemas(id);
} else if (n.kind === "database") {
if (await connect(n.id)) await loadSchemas(n.id);
if (!(await connect(n.id))) {
useDatabase.setState((s) => ({
schemas: { ...s.schemas, [n.id]: { status: "error", message: tx("Cannot connect") } },
}));
return;
}
await loadSchemas(n.id);
} else if (n.kind === "schema") await loadTables(n.id, n.schema);
else if (n.kind === "table") await loadDetails(n.id, n.schema, n.table.name);
};
+1 -1
Переглянути файл
@@ -77,7 +77,7 @@ export function ExportDialog({
const browse = async () => {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const { save } = await import("@/platform/dialog");
const f = EXPORT_FORMATS.find((x) => x.id === format)!;
const path = await save({ defaultPath: file, filters: [{ name: f.label, extensions: [f.extension] }] });
if (path) setFile(path);
+6 -4
Переглянути файл
@@ -175,7 +175,7 @@ function blank(): DataSource {
}
async function pickFile(title: string): Promise<string | null> {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const file = await open({ multiple: false, directory: false, title });
return typeof file === "string" ? file : null;
}
@@ -384,6 +384,8 @@ export function SourceDialog({
const vendor = vendorOf(s);
const sqlite = s.driver === "sqlite";
const postgres = s.driver === "postgres";
/** Drivers that can list every database on the host (tree level + Schemas tab). */
const serverDatabases = postgres || s.driver === "sqlServer";
const clickhouse = s.driver === "clickhouse";
const oracle = s.driver === "oracle";
const urlOnly = !sqlite && !!s.urlOnly;
@@ -487,7 +489,7 @@ export function SourceDialog({
const loadNames = async () => {
const id = initial?.id;
if (!id) return;
await Promise.all([postgres ? loadDatabases(id) : Promise.resolve(), loadSchemas(id)]);
await Promise.all([serverDatabases ? loadDatabases(id) : Promise.resolve(), loadSchemas(id)]);
};
const tabs: Array<{ id: Tab; label: string }> = [
@@ -702,7 +704,7 @@ export function SourceDialog({
fallback={300}
onChange={(autoDisconnectSecs) => setOptions({ autoDisconnectSecs })}
/>
{postgres && (
{serverDatabases && (
<>
<span />
<Checkbox
@@ -927,7 +929,7 @@ export function SourceDialog({
const schemasTab = (
<>
<div className={styles.formWide}>
{postgres && (
{serverDatabases && (
<NamePicker
allLabel={tx("All databases")}
addLabel={tx("Add databases pattern")}
+10
Переглянути файл
@@ -20,6 +20,7 @@ import {
toCsv,
toTsv,
} from "./model";
import { listsDatabases } from "./store";
import { VENDORS, sourceUrl, vendorById, vendorOf, vendorPreset } from "./vendors";
describe("clipboard formats", () => {
@@ -160,6 +161,15 @@ describe("connection targets", () => {
expect(splitTarget("pg")).toEqual({ id: "pg", database: null });
expect(splitTarget("pg:my:db")).toEqual({ id: "pg", database: "my:db" });
});
it("lists databases under PostgreSQL and SQL Server hosts unless single-database mode", () => {
expect(listsDatabases({ driver: "postgres" })).toBe(true);
expect(listsDatabases({ driver: "sqlServer" })).toBe(true);
expect(listsDatabases({ driver: "postgres", options: { singleDatabase: true } })).toBe(false);
expect(listsDatabases({ driver: "sqlServer", options: { singleDatabase: true } })).toBe(false);
expect(listsDatabases({ driver: "mysql" })).toBe(false);
expect(listsDatabases(undefined)).toBe(false);
});
});
describe("parseSourceUrl", () => {
+1 -1
Переглянути файл
@@ -47,7 +47,7 @@ interface InTable {
export type DbNode =
| { kind: "source"; key: string; source: DataSource }
/** A database of a PostgreSQL server; `id` is its connection target. */
/** A database of a PostgreSQL / SQL Server host; `id` is its connection target. */
| { kind: "database"; key: string; id: string; name: string; current: boolean }
| { kind: "schema"; key: string; id: string; schema: string }
| { kind: "folder"; key: string; id: string; schema: string; folder: Folder }
+8 -3
Переглянути файл
@@ -319,8 +319,13 @@ export async function saveSource(
const id = source.id ?? "";
if (password !== undefined) await call(commands.dbSetPassword(id, password, !!source.savePassword));
if (sshSecret !== undefined) await call(commands.dbSetSshSecret(id, sshSecret, !!source.ssh?.saveSecret));
forget(id);
// What the tree had loaded for this source is reloaded with the new settings, so its
// expanded nodes don't stay on Loading.
const st = useDatabase.getState();
const loaded = id in st.databases || Object.keys(st.schemas).some((t) => splitTarget(t).id === id);
await loadSources();
if (loaded) await refresh(id);
else forget(id);
}
/** Copy/Paste › Duplicate: a copy of data source `id` named `<name> [2]` (its password is not copied). */
@@ -497,9 +502,9 @@ export function consoleTints(s: Pick<DatabaseState, "consoles" | "sources">): Ma
return out;
}
/** PostgreSQL data sources show their server's databases (IDEA's data source › database › schema). */
/** PostgreSQL / SQL Server data sources show their server's databases (IDEA's data source › database › schema). */
export function listsDatabases(source: DataSource | undefined): boolean {
return source?.driver === "postgres" && !source.options?.singleDatabase;
return (source?.driver === "postgres" || source?.driver === "sqlServer") && !source.options?.singleDatabase;
}
/**
+1 -1
Переглянути файл
@@ -149,7 +149,7 @@ export async function insertScript(n: DbNode, kind: ScriptKind) {
/** SQL Scripts › Run SQL Script…: picks a `.sql` file and runs all its statements. */
async function runScriptFile(id: string) {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const file = await open({
multiple: false,
directory: false,
+1 -1
Переглянути файл
@@ -21,7 +21,7 @@ const act = (
const sep: MenuItem = { kind: "separator" };
async function sqliteFromFile() {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const file = await open({ multiple: false, directory: false, title: "SQLite Database File" });
if (typeof file === "string") openSourceDialog(null, { driver: "sqlite", file, name: fileSourceName(file) });
}
+6 -6
Переглянути файл
@@ -214,7 +214,7 @@ export const tauriPluginsBackend: PluginsBackend = {
},
program: (id, program, scope) => call(commands.pluginProgram(id, program, scope)),
pickProgram: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ directory: false, multiple: false, title: tx("Choose plugin executable") });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
@@ -274,7 +274,7 @@ export const tauriPluginsBackend: PluginsBackend = {
unpackPackage: (path) => call(commands.pluginUnpackPackage(path)),
rollBack: (id) => call(commands.pluginRollBack(id)),
pickPackage: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
multiple: false,
title: tx("Install Plugin from Package File"),
@@ -283,7 +283,7 @@ export const tauriPluginsBackend: PluginsBackend = {
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
pickIntellijTheme: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
multiple: false,
title: tx("Import IntelliJ Theme"),
@@ -318,14 +318,14 @@ export const tauriPluginsBackend: PluginsBackend = {
return webSchemeUrl(page.path);
},
saveWebFile: async (name, filters, bytes) => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { save } = await import("@/platform/dialog");
const path = await save({ defaultPath: name, filters });
if (!path) return null;
await call(commands.pluginWebSaveFile(path, toBase64(bytes)));
return path;
},
openWebFile: async (filters) => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ multiple: false, filters });
const path = (Array.isArray(picked) ? picked[0] : picked) ?? null;
if (!path) return null;
@@ -345,7 +345,7 @@ export const tauriPluginsBackend: PluginsBackend = {
await call(commands.pluginSetThemeFrame(color));
},
pickFolder: async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ directory: true, multiple: false, title: "Install Plugin from Disk" });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
+1 -1
Переглянути файл
@@ -374,7 +374,7 @@ async function askUser(prompt: Prompt, signal: AbortSignal): Promise<string[] |
case "pickDirectory": {
if (signal.aborted) return null;
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
if (signal.aborted) return null;
const selected = await open({
directory: true,
+1 -1
Переглянути файл
@@ -141,7 +141,7 @@ function TrustSettings() {
};
const addLocation = async () => {
try {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ directory: true, multiple: false, title: "Add Trusted Location" });
const path = Array.isArray(picked) ? picked[0] : picked;
if (!path) return;
+1 -1
Переглянути файл
@@ -203,7 +203,7 @@ export interface ConfigFormProps {
/** Picks files or a directory in the local file dialog, opened at `from`; empty when cancelled. */
async function choosePaths(opts: { directory?: boolean; multiple?: boolean; from: string | null }): Promise<string[]> {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: !!opts.directory,
multiple: !!opts.multiple,
+1 -1
Переглянути файл
@@ -72,7 +72,7 @@ function serializeEnvPairs(rows: ReadonlyArray<{ name: string; value: string }>)
async function pickLocalPath(directory: boolean, defaultPath?: string): Promise<string | null> {
if (!isTauri() || isRemoteWindow()) return null;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ directory, multiple: false, defaultPath: defaultPath || undefined });
return typeof picked === "string" ? picked : Array.isArray(picked) ? (picked[0] ?? null) : null;
}
+1 -1
Переглянути файл
@@ -89,7 +89,7 @@ import styles from "./FindInFiles.module.css";
/** The Directory scope's browse button: a folder chooser opened at the current directory. */
async function chooseDirectory(current: string): Promise<string | null> {
if (!isTauri()) return null;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: true,
multiple: false,
+1 -1
Переглянути файл
@@ -90,7 +90,7 @@ function PromptDialog(props: {
}
async function pickDirectory(title: string): Promise<string | null> {
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
const { open: openDialog } = await import("@/platform/dialog");
const dir = await openDialog({ multiple: false, directory: true, title });
return typeof dir === "string" ? dir : null;
}
+4 -2
Переглянути файл
@@ -1,4 +1,4 @@
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import { PageContext } from "../components/context";
@@ -50,7 +50,9 @@ describe("Java formatting profile", () => {
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "config/style.xml" } });
mocks.open.mockResolvedValue("/project/custom.xml");
await act(async () => fireEvent.click(screen.getByRole("button", { name: "Browse…" })));
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "/project/custom.xml" } });
await waitFor(() =>
expect(value()).toEqual({ java: { formatter: "custom", formatterPath: "/project/custom.xml" } }),
);
expect(mocks.open).toHaveBeenCalledWith(
expect.objectContaining({ filters: [{ name: "XML", extensions: ["xml"] }] }),
);
+1 -1
Переглянути файл
@@ -231,7 +231,7 @@ export function KeymapPage() {
};
const importIdeaKeymap = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const path = await open({ multiple: false, filters: [{ name: tx("IntelliJ IDEA Keymap"), extensions: ["xml"] }] });
if (!path) return;
let parsed;
+1 -1
Переглянути файл
@@ -135,7 +135,7 @@ export async function choosePath(opts: {
defaultPath?: string;
}): Promise<string | null> {
if (!isTauri()) return null;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: opts.directory,
multiple: false,
+1 -1
Переглянути файл
@@ -431,7 +431,7 @@ function DefaultDirectoryRow() {
const [dir, setDir] = useSetting<string>("general.defaultProjectDirectory");
const id = useId();
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: true,
multiple: false,
+1 -1
Переглянути файл
@@ -254,7 +254,7 @@ function JavaFormatter({
}) {
const id = useId();
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const path = await open({
multiple: false,
title: tx("Eclipse formatter XML"),
+2 -2
Переглянути файл
@@ -1153,7 +1153,7 @@ function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landi
useEffect(() => {
handlers.current = {
import: async (file) => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const path =
file ??
(await open({
@@ -1184,7 +1184,7 @@ function ColorSchemePage({ scopes, landing }: { scopes: readonly string[]; landi
setReport(tx`Applied ${parsed.applied} values; skipped ${parsed.skipped} unsupported or invalid values.`);
},
export: async (file) => {
const { save } = await import("@tauri-apps/plugin-dialog");
const { save } = await import("@/platform/dialog");
const path =
file ??
(await save({
+1 -1
Переглянути файл
@@ -103,7 +103,7 @@ function KeyFileRow({ value, onChange }: { value: string; onChange: (v: string)
const id = useId();
const tx = useTx();
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ multiple: false, title: tx("Private Key File") });
const path = Array.isArray(picked) ? picked[0] : picked;
if (path) onChange(path);
+1 -1
Переглянути файл
@@ -127,7 +127,7 @@ function ShellPathRow() {
void commands.termAvailableShells().then(setShells, () => undefined);
}, []);
const browse = async () => {
const { open: pick } = await import("@tauri-apps/plugin-dialog");
const { open: pick } = await import("@/platform/dialog");
const picked = await pick({ multiple: false, title: "Shell Path" });
const path = Array.isArray(picked) ? picked[0] : picked;
if (path) set(quotePath(path));
+1 -1
Переглянути файл
@@ -38,7 +38,7 @@ export const tauriToolsBackend: ToolsBackend = {
await call(commands.lspRestartNamed(name));
},
chooseExecutable: async (title, defaultPath) => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ directory: false, multiple: false, title, defaultPath: defaultPath || undefined });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
},
+1 -3
Переглянути файл
@@ -38,9 +38,7 @@ interface RevisionData {
export async function openExternalLog() {
const picked = isTauri()
? await (
await import("@tauri-apps/plugin-dialog")
).open({ title: tx("Open Git Log"), directory: true, multiple: true })
? await (await import("@/platform/dialog")).open({ title: tx("Open Git Log"), directory: true, multiple: true })
: await choosePath({ title: "Open Git Log", directory: true });
const paths = Array.isArray(picked) ? picked : picked ? [picked] : [];
if (!paths.length) return;
+1 -1
Переглянути файл
@@ -156,7 +156,7 @@ function CloneDialog({ onClose, initialUrl }: { onClose: () => void; initialUrl:
};
const browse = async () => {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: true,
multiple: false,
+2 -2
Переглянути файл
@@ -75,7 +75,7 @@ function info(title: string) {
async function pickFile(near: string): Promise<string | null> {
if (!isTauri()) return null;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const dir = near.slice(0, near.lastIndexOf("/")) || undefined;
const picked = await open({ multiple: false, directory: false, title: "Compare With", defaultPath: dir });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
@@ -129,7 +129,7 @@ async function pickDirectory(near: string): Promise<string | null> {
const picked = await promptText({ title: "Compare With", label: "Directory path", initial: near, mono: true });
return picked?.value ?? null;
}
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({ multiple: false, directory: true, title: tx("Compare With"), defaultPath: near });
return (Array.isArray(picked) ? picked[0] : picked) ?? null;
}
+1 -1
Переглянути файл
@@ -693,7 +693,7 @@ export function WorktreesPanel({ className, listClassName }: { className?: strin
const move = async () => {
if (!current || !currentGroup || current.main || containsProject) return;
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
directory: true,
multiple: false,
+2 -2
Переглянути файл
@@ -76,7 +76,7 @@ export function showCreatePatchForShelf(entry: ShelvedChangeList, path?: string)
async function browseSave(current: string): Promise<string | null> {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const { save } = await import("@/platform/dialog");
return (await save({ defaultPath: current, filters: [{ name: "Patch", extensions: ["patch", "diff"] }] })) ?? null;
} catch {
return null;
@@ -228,7 +228,7 @@ export async function showApplyPatch(from: "file" | "clipboard", path?: string)
} else {
let file = path ?? null;
if (!file) {
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const picked = await open({
multiple: false,
directory: false,
+1 -1
Переглянути файл
@@ -386,7 +386,7 @@ export async function importShelfPatches(paths?: string[]) {
const repo = currentRepo();
const picked =
paths ??
(await import("@tauri-apps/plugin-dialog").then(({ open }) =>
(await import("@/platform/dialog").then(({ open }) =>
open({ multiple: true, directory: false, filters: [{ name: "Patch", extensions: ["patch", "diff"] }] }),
));
if (!picked) return;
+1 -1
Переглянути файл
@@ -156,7 +156,7 @@ function naturalSize(svg: SVGSVGElement): { w: number; h: number } {
async function saveDiagram(svg: SVGSVGElement): Promise<void> {
try {
const { save } = await import("@tauri-apps/plugin-dialog");
const { save } = await import("@/platform/dialog");
const path = await save({
title: tx("Save diagram"),
defaultPath: "mermaid-diagram.png",
+16
Переглянути файл
@@ -0,0 +1,16 @@
// @vitest-environment node
import { readdirSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { expect, it } from "vitest";
it("opens every file picker through the platform wrapper", () => {
const src = resolve(import.meta.dirname, "..");
const offenders = readdirSync(src, { recursive: true, encoding: "utf8" }).filter(
(file) =>
/\.[jt]sx?$/.test(file) &&
!/\.test\.[jt]sx?$/.test(file) &&
file !== "platform/dialog.ts" &&
readFileSync(resolve(src, file), "utf8").includes("@tauri-apps/plugin-" + "dialog"),
);
expect(offenders).toEqual([]);
});
+66
Переглянути файл
@@ -0,0 +1,66 @@
/**
* File and folder pickers. Same calls as `@tauri-apps/plugin-dialog`, which shows the desktop's own
* chooser through xdg-desktop-portal on Linux. When a Linux session has neither a portal nor
* `zenity`, the plugin returns nothing and a picker looks cancelled, so those sessions get a GTK
* chooser from the backend instead.
*/
import {
open as pluginOpen,
save as pluginSave,
type OpenDialogOptions,
type OpenDialogReturn,
type SaveDialogOptions,
} from "@tauri-apps/plugin-dialog";
import { call, commands } from "@/platform/ipc";
let needsGtk: boolean | undefined;
// Asked once when the module loads, so a picker only waits for the answer if it opens first.
const probe = (async () => {
try {
needsGtk = await commands.fileDialogNeedsGtk();
} catch {
needsGtk = false;
}
})();
async function pluginDialogsUnavailable(): Promise<boolean> {
if (needsGtk === undefined) await probe;
return needsGtk === true;
}
type Filters = OpenDialogOptions["filters"];
function gtkFilters(filters: Filters) {
return (filters ?? []).map(({ name, extensions }) => ({ name, extensions }));
}
async function gtkPick(options: {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
save?: boolean;
filters?: Filters;
}): Promise<string[] | null> {
return call(
commands.gtkPickPaths({
title: options.title ?? null,
defaultPath: options.defaultPath ?? null,
directory: options.directory ?? false,
multiple: options.multiple ?? false,
save: options.save ?? false,
filters: gtkFilters(options.filters),
}),
);
}
export async function open<T extends OpenDialogOptions>(options: T = {} as T): Promise<OpenDialogReturn<T>> {
if (needsGtk === false || !(await pluginDialogsUnavailable())) return pluginOpen(options);
const paths = await gtkPick(options);
return (options.multiple ? paths : (paths?.[0] ?? null)) as OpenDialogReturn<T>;
}
export async function save(options: SaveDialogOptions = {}): Promise<string | null> {
if (needsGtk === false || !(await pluginDialogsUnavailable())) return pluginSave(options);
return (await gtkPick({ ...options, save: true }))?.[0] ?? null;
}
+1
Переглянути файл
@@ -4,6 +4,7 @@
"They go back to how they were when the task started, in the task's worktree.": "They go back to how they were when the task started, in the task's worktree.",
"They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.": "They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.",
"Cannot roll back the changes": "Cannot roll back the changes",
"Cannot connect": "Cannot connect",
"Rollback Selected Changes…": "Rollback Selected Changes…",
"Rollback All Changes…": "Rollback All Changes…",
"JUnit": "JUnit",
+1
Переглянути файл
@@ -4,6 +4,7 @@
"They go back to how they were when the task started, in the task's worktree.": "它们会在任务的工作树中恢复为任务开始时的内容。",
"They go back to how they were when the task started. Edits of yours made to them since then are lost too; edits from before the task are kept.": "它们会恢复为任务开始时的内容。任务开始后你对它们做的修改也会丢失;任务开始前的修改会保留。",
"Cannot roll back the changes": "无法回滚更改",
"Cannot connect": "无法连接",
"Rollback Selected Changes…": "回滚所选更改…",
"Rollback All Changes…": "回滚全部更改…",
"JUnit": "JUnit",
+1 -1
Переглянути файл
@@ -22,7 +22,7 @@ export async function openProjectDialog() {
});
return;
}
const { open } = await import("@tauri-apps/plugin-dialog");
const { open } = await import("@/platform/dialog");
const defaultPath = useProject.getState().settings?.general?.defaultProjectDirectory?.trim() || undefined;
const picked = await open({ directory: true, multiple: false, title: "Open Folder", defaultPath });
const path = Array.isArray(picked) ? picked[0] : picked;