mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-11 02:31:38 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606aff1613 | ||
|
|
4d60921f24 | ||
|
|
7784f3cdef | ||
|
|
b010397711 |
No files matched your search
@@ -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 = ¶ms["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, ¶ms["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()) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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/*");
|
||||
}
|
||||
}
|
||||
@@ -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<>k::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<>k::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]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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"])(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"] }] }),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user