diff --git a/crates/ide-db/src/lib.rs b/crates/ide-db/src/lib.rs index f12f6a63..27717f28 100644 --- a/crates/ide-db/src/lib.rs +++ b/crates/ide-db/src/lib.rs @@ -213,6 +213,8 @@ pub struct DbManager { /// App-wide SSH connections and host-key trust. ssh: Arc, saved_passwords: SecretStore, + /// Directory of the global data sources and their saved passwords ([`DbManager::with_global_sources`]). + global: Option<(PathBuf, SecretStore)>, history: Arc, /// Records executed statements in the history off the async threads, in order. recorder: std::sync::mpsc::Sender<(String, String, f64)>, @@ -339,6 +341,7 @@ impl DbManager { } Self { saved_passwords: SecretStore::new(passwords, &root), + global: None, history, recorder, root, @@ -351,8 +354,42 @@ impl DbManager { } } + /// Also lists the global data sources stored in `dir` (the app data directory). + pub fn with_global_sources(mut self, dir: PathBuf) -> Self { + let passwords = self.saved_passwords.scoped(&dir); + self.global = Some((dir, passwords)); + self + } + + /// The project's data sources, then the global ones (a project one wins on the same id). pub fn sources(&self) -> IdeResult> { - source::load(&self.root) + let mut all = source::load(&self.root)?; + let global: Vec<_> = self.global_sources().into_iter().filter(|g| all.iter().all(|s| s.id != g.id)).collect(); + all.extend(global); + Ok(all) + } + + fn global_sources(&self) -> Vec { + let Some((dir, _)) = &self.global else { return Vec::new() }; + source::load_global(dir).unwrap_or_else(|e| { + tracing::warn!("global data sources: {}", e.message); + Vec::new() + }) + } + + /// Where the saved passwords of data source `id` live: app-wide for a global one. + fn saved_for(&self, id: &str) -> &SecretStore { + match &self.global { + Some((_, store)) if self.global_sources().iter().any(|s| s.id == id) => store, + _ => &self.saved_passwords, + } + } + + fn store_of(&self, source: &DataSource) -> &SecretStore { + match &self.global { + Some((_, store)) if source.global => store, + _ => &self.saved_passwords, + } } /// The connection settings of target `target` ([`DataSource::resolved`]), with @@ -372,18 +409,43 @@ impl DbManager { } /// Saves the data source list; connections of removed or changed sources are closed. + /// Global ones (`global`) go to the app-wide list, the others to the project's. pub async fn save_sources(&self, sources: Vec) -> IdeResult<()> { let old = self.sources().unwrap_or_default(); - source::save(&self.root, &sources)?; + let (global, project): (Vec<_>, Vec<_>) = sources.iter().cloned().partition(|s| s.global); + match &self.global { + // Rewritten only when it changes: other projects' windows edit the same file. + Some((dir, _)) => { + if global != old.iter().filter(|s| s.global).cloned().collect::>() { + source::save_global(dir, &global)?; + } + } + None if !global.is_empty() => return Err(IdeError::invalid("global data sources are not available here")), + None => {} + } + source::save(&self.root, &project)?; for o in old { match sources.iter().find(|s| s.id == o.id) { Some(n) if *n == o => {} gone_or_changed => { - if gone_or_changed.is_none() { - for k in [o.id.clone(), secrets::ssh_key(&o.id)] { - self.session_passwords.lock().remove(&k); - self.saved_passwords.delete(&k); + let keys = [o.id.clone(), secrets::ssh_key(&o.id)]; + match gone_or_changed { + None => { + for k in &keys { + self.session_passwords.lock().remove(k); + self.store_of(&o).delete(k); + } } + // Made global or project-level: its saved passwords move along. + Some(n) if n.global != o.global => { + for k in &keys { + if let Some(p) = self.store_of(&o).get(k) { + self.store_of(n).set(k, &p)?; + self.store_of(&o).delete(k); + } + } + } + Some(_) => {} } self.disconnect_source(&o.id).await; } @@ -395,7 +457,7 @@ impl DbManager { /// Remembers `password` for the session, and in the saved passwords when /// the data source saves passwords (`None` forgets it). pub async fn set_password(&self, id: &str, password: Option, save: bool) -> IdeResult<()> { - self.remember(id.to_owned(), password, save).await + self.remember(id, id.to_owned(), password, save).await } /// [`DbManager::set_password`] for the SSH password / key passphrase. @@ -407,10 +469,10 @@ impl DbManager { self.ssh.set_secret(&c, secret, save); return Ok(()); } - self.remember(secrets::ssh_key(id), secret, save).await + self.remember(id, secrets::ssh_key(id), secret, save).await } - async fn remember(&self, key: String, secret: Option, save: bool) -> IdeResult<()> { + async fn remember(&self, id: &str, key: String, secret: Option, save: bool) -> IdeResult<()> { match &secret { Some(p) => { self.session_passwords.lock().insert(key.clone(), p.clone()); @@ -419,10 +481,11 @@ impl DbManager { self.session_passwords.lock().remove(&key); } } + let store = self.saved_for(id); let result = match (secret, save) { - (Some(p), true) => self.saved_passwords.set(&key, &p), + (Some(p), true) => store.set(&key, &p), _ => { - self.saved_passwords.delete(&key); + store.delete(&key); Ok(()) } }; @@ -433,18 +496,18 @@ impl DbManager { Ok(()) } - async fn secret(&self, key: String, saved: bool) -> Option { + async fn secret(&self, source: &DataSource, key: String, saved: bool) -> Option { if let Some(p) = self.session_passwords.lock().get(&key) { return Some(p.clone()); } if !saved { return None; } - self.saved_passwords.get(&key) + self.store_of(source).get(&key) } async fn password(&self, source: &DataSource) -> Option { - match self.secret(source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await { + match self.secret(source, source.id.clone(), source.driver != DbDriver::Sqlite && source.save_password).await { Some(p) => Some(p), None => source.url_password(), } @@ -500,7 +563,7 @@ impl DbManager { let session = if config.is_empty() { let secret = match ssh_secret { Some(s) => Some(s), - None => self.secret(secrets::ssh_key(&source.id), ssh.save_secret).await, + None => self.secret(source, secrets::ssh_key(&source.id), ssh.save_secret).await, }; let target = SshTarget { host: ssh.host.clone(), @@ -1099,6 +1162,41 @@ impl DbManager { mod tests { use super::*; + #[tokio::test] + async fn global_data_sources_are_shared_by_projects_with_their_passwords() { + let tmp = tempfile::tempdir().unwrap(); + let (app, one, two) = (tmp.path().join("app"), tmp.path().join("one"), tmp.path().join("two")); + let manager = |root: &std::path::Path| { + let ssh = Arc::new(SshManager::new(app.join("ssh-passwords.json"))); + DbManager::new(root.to_path_buf(), app.join("passwords.json"), app.join("history.json"), ssh) + .with_global_sources(app.clone()) + }; + let (a, b) = (manager(&one), manager(&two)); + let source = |id: &str, global: bool| DataSource { + id: id.into(), + name: id.into(), + driver: DbDriver::Postgres, + save_password: true, + global, + ..Default::default() + }; + a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap(); + a.set_password("shared", Some("pw".into()), true).await.unwrap(); + // Made global: listed in the other project too, with its saved password. + a.save_sources(vec![source("local", false), source("shared", true)]).await.unwrap(); + let ids = |m: &DbManager| m.sources().unwrap().into_iter().map(|s| (s.id, s.global)).collect::>(); + assert_eq!(ids(&a), [("local".into(), false), ("shared".into(), true)]); + assert_eq!(ids(&b), [("shared".into(), true)]); + assert_eq!(b.password(&b.source("shared").unwrap()).await.as_deref(), Some("pw")); + let project_file = std::fs::read_to_string(one.join(".openidea/data-sources.toml")).unwrap(); + assert!(!project_file.contains("shared") && !project_file.contains("global"), "{project_file}"); + assert!(!std::fs::read_to_string(app.join("data-sources.toml")).unwrap().contains("global")); + // Made project-level again: gone from the other project, the password stays with it. + a.save_sources(vec![source("local", false), source("shared", false)]).await.unwrap(); + assert!(b.sources().unwrap().is_empty()); + assert_eq!(a.password(&a.source("shared").unwrap()).await.as_deref(), Some("pw")); + } + #[tokio::test] async fn clickhouse_rejects_manual_transactions_and_table_edits_without_connecting() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-db/src/secrets.rs b/crates/ide-db/src/secrets.rs index 8de033f8..43a571eb 100644 --- a/crates/ide-db/src/secrets.rs +++ b/crates/ide-db/src/secrets.rs @@ -28,6 +28,11 @@ impl SecretStore { Self { path, project: root.to_string_lossy().into_owned() } } + /// The same file, keyed by `scope` instead (global data sources: the app data directory). + pub fn scoped(&self, scope: &Path) -> Self { + Self::new(self.path.clone(), scope) + } + pub fn get(&self, key: &str) -> Option { let _g = LOCK.lock(); self.read().remove(&self.project)?.remove(key) diff --git a/crates/ide-db/src/source.rs b/crates/ide-db/src/source.rs index c048430d..1acb4ea4 100644 --- a/crates/ide-db/src/source.rs +++ b/crates/ide-db/src/source.rs @@ -1,6 +1,7 @@ -//! Data sources: per-project connection settings in `.openidea/data-sources.toml`. -//! Passwords never go there: saved ones live in [`crate::secrets`], the others -//! only in memory for the session. +//! Data sources: per-project connection settings in `.openidea/data-sources.toml`, and +//! global ones (IDEA's Make Global: listed in every project) in `data-sources.toml` of the +//! app data directory. Passwords never go there: saved ones live in [`crate::secrets`], +//! the others only in memory for the session. use ide_core::{IdeError, IdeResult, IpcPath, project_dir}; use serde::{Deserialize, Serialize}; @@ -181,6 +182,9 @@ pub struct DataSource { pub options: DbOptions, pub introspection: DbIntrospection, pub properties: Vec, + /// Stored app-wide and listed in every project. Implied by the file it is read from, + /// so not written to it. + pub global: bool, } impl Default for DataSource { @@ -209,6 +213,7 @@ impl Default for DataSource { options: DbOptions::default(), introspection: DbIntrospection::default(), properties: Vec::new(), + global: false, } } } @@ -397,9 +402,32 @@ struct SourcesFile { sources: Vec, } +/// The project's data sources. pub fn load(root: &Path) -> IdeResult> { - let path = project_dir::path(root, FILE); - let text = match std::fs::read_to_string(&path) { + read(&project_dir::path(root, FILE)) +} + +/// Saves the project's data sources. +pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> { + write(&project_dir::path(root, FILE), sources) +} + +/// The global data sources stored in `dir` (the app data directory). +pub fn load_global(dir: &Path) -> IdeResult> { + let mut sources = read(&dir.join(FILE))?; + for s in &mut sources { + s.global = true; + } + Ok(sources) +} + +/// Saves the global data sources in `dir`. +pub fn save_global(dir: &Path, sources: &[DataSource]) -> IdeResult<()> { + write(&dir.join(FILE), sources) +} + +fn read(path: &Path) -> IdeResult> { + let text = match std::fs::read_to_string(path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), Err(e) => return Err(e.into()), @@ -408,19 +436,23 @@ pub fn load(root: &Path) -> IdeResult> { Ok(file.sources) } -pub fn save(root: &Path, sources: &[DataSource]) -> IdeResult<()> { +fn write(path: &Path, sources: &[DataSource]) -> IdeResult<()> { for s in sources { if s.id.is_empty() || s.name.trim().is_empty() { return Err(IdeError::invalid("every data source needs an id and a name")); } } - let path = project_dir::path(root, FILE); if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; } - let text = toml::to_string_pretty(&SourcesFile { sources: sources.to_vec() }) - .map_err(|e| IdeError::internal(e.to_string()))?; - std::fs::write(path, text)?; + let internal = |e: &dyn std::fmt::Display| IdeError::internal(e.to_string()); + let mut value = toml::Value::try_from(SourcesFile { sources: sources.to_vec() }).map_err(|e| internal(&e))?; + if let Some(list) = value.get_mut("data-source").and_then(toml::Value::as_array_mut) { + for table in list.iter_mut().filter_map(toml::Value::as_table_mut) { + table.remove("global"); + } + } + std::fs::write(path, toml::to_string_pretty(&value).map_err(|e| internal(&e))?)?; Ok(()) } diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index 1213db96..f946d9a3 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -520,12 +520,15 @@ impl Host { root: &Path, project_key: &str, ) -> (Arc, Arc, Arc) { - let db = Arc::new(ide_db::DbManager::new( - root.to_path_buf(), - self.data_dir.join("db-passwords.json"), - self.data_dir.join("db-history.json"), - self.ssh.clone(), - )); + let db = Arc::new( + ide_db::DbManager::new( + root.to_path_buf(), + self.data_dir.join("db-passwords.json"), + self.data_dir.join("db-history.json"), + self.ssh.clone(), + ) + .with_global_sources(self.data_dir.clone()), + ); let http_history = Arc::new(ide_http::History::new(self.data_dir.join("http-history").join(project_key))); let agents = Arc::new(ide_agent::AgentManager::new( ide_agent::AgentPaths { diff --git a/src/bindings.ts b/src/bindings.ts index ef2d2dab..eacf7e40 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -2480,6 +2480,11 @@ export type DataSource = { options?: DbOptions, introspection?: DbIntrospection, properties?: DbProperty[], + /** + * Stored app-wide and listed in every project. Implied by the file it is read from, + * so not written to it. + */ + global?: boolean, }; /** System Settings › Date Formats (Git log, Local History, blame). */ diff --git a/src/features/database/DatabaseView.tsx b/src/features/database/DatabaseView.tsx index 63367cd8..36abea84 100644 --- a/src/features/database/DatabaseView.tsx +++ b/src/features/database/DatabaseView.tsx @@ -432,6 +432,11 @@ export function DatabaseView() { )} {label(n)} + {n.kind === "source" && n.source.global && ( + + {tx("global")} + + )} {d && ( )} /> + {/* IDEA's Make Global: stored with the IDE and listed in every project. */} + + set({ global })} label="Global" /> + {showComment ? ( diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 2ce88371..3bae28ce 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -4242,6 +4242,8 @@ "Root markers": "根标记", "Initialization options": "初始化选项", "Global": "全局", + "global": "全局", + "Available in all projects": "在所有项目中可用", "This project": "此项目", "Executable name or path": "可执行文件名或路径", "Select the server executable": "选择服务器可执行文件",