diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 1fa72128e..37191b4ff 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -552,7 +552,8 @@ pub async fn build_run( let mut combined: Option = None; for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) { let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective(); - let member_cache = cache.join(crate::project::project_key(member)); + let member_cache = + if member == &root { cache.clone() } else { cache.join(crate::project::project_key(member)) }; let model = with_profiles(load(member, &member_cache, &settings), &member_cache); let plan = prepare_plan( compile::plan(member, &model, kind, file.as_deref(), settings.cmake.profiles.as_deref())?, diff --git a/crates/ide-host/src/ops/build/workspace.rs b/crates/ide-host/src/ops/build/workspace.rs index c05337b1b..fe1bc518c 100644 --- a/crates/ide-host/src/ops/build/workspace.rs +++ b/crates/ide-host/src/ops/build/workspace.rs @@ -12,13 +12,18 @@ pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult> } fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult> { + let window_root = root; Ok(match ide_workspace::workspace::Workspace::load(root)? { Some(workspace) => workspace .projects .into_iter() .filter(|p| p.is_dir()) .map(|root| Scope { - cache: cache.join(crate::project::project_key(&root)), + cache: if root == window_root { + cache.to_owned() + } else { + cache.join(crate::project::project_key(&root)) + }, settings: ide_workspace::SettingsStore::load(config, Some(&root)).effective(), root, }) @@ -36,10 +41,12 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel { let mut combined = BuildModel::default(); for scope in scopes { let mut model = load(&scope.root, &scope.cache, &scope.settings); - if scope.root == root { + if scopes.len() == 1 && scope.root == root { return model; } - if let Some(maven) = &mut model.maven { + if scope.root != root + && let Some(maven) = &mut model.maven + { let owner = ide_core::ipc_path::to_ipc(&scope.root); for p in &mut maven.projects { p.project_root = Some(owner.clone()); @@ -73,7 +80,7 @@ pub(super) fn load_scopes(root: &Path, scopes: &[Scope]) -> BuildModel { } } } - for g in &mut model.gradle { + for g in model.gradle.iter_mut().filter(|_| scope.root != root) { g.dir = absolute(&scope.root, &g.dir); for p in &mut g.projects { p.dir = absolute(&scope.root, &p.dir); @@ -145,6 +152,30 @@ pub(super) fn update_files(root: &Path, scopes: &[Scope], model: &BuildModel, to mod tests { use super::*; + #[test] + fn attached_primary_keeps_its_cache_and_combines_build_models() { + let tmp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let root = base.join("primary"); + let other = base.join("other"); + for path in [&root, &other] { + std::fs::create_dir(path).unwrap(); + std::fs::write(path.join("pom.xml"), "app").unwrap(); + } + let cache = base.join("cache"); + ide_build::save_maven_profiles(&cache, &["kept".into()]).unwrap(); + ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap(); + let scopes = scopes_at(&root, &cache, &base.join("config"), Settings::default()).unwrap(); + assert_eq!(scopes[0].cache, cache); + assert_eq!(ide_build::maven_profiles(&scopes[0].cache), ["kept"]); + assert_eq!(scopes[1].cache, cache.join(crate::project::project_key(&other))); + let model = load_scopes(&root, &scopes); + let projects = model.maven.unwrap().projects; + assert_eq!(projects.len(), 2); + assert_eq!(projects[0].pom, "pom.xml"); + assert_eq!(projects[1].pom, absolute(&other, "pom.xml")); + } + #[test] fn workspace_models_use_member_settings_paths_and_caches() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/ide-host/src/ops/dependencies.rs b/crates/ide-host/src/ops/dependencies.rs index 1e57b5576..04b2e35e2 100644 --- a/crates/ide-host/src/ops/dependencies.rs +++ b/crates/ide-host/src/ops/dependencies.rs @@ -82,7 +82,7 @@ fn go_source(project: &crate::Project, config_dir: &Path, path: &Path) -> IdeRes workspace .projects .into_iter() - .filter(|root| path.starts_with(root)) + .filter(|root| **root != project.root && path.starts_with(root)) .max_by_key(|root| root.components().count()) }); let settings = match &member { diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index 17f09aff8..e211fb5d8 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -13,6 +13,8 @@ pub struct ProjectInfo { pub trusted: bool, pub root: ide_core::IpcPath, pub name: String, + #[serde(default)] + pub display_name: String, pub is_git: bool, pub settings: Settings, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -24,17 +26,23 @@ pub struct ProjectInfo { pub fn info(project: &Project) -> ProjectInfo { let settings = project.settings.effective(); let custom = settings.general.project_name.trim(); + let primary = if custom.is_empty() { + project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() + } else { + custom.to_owned() + }; + let workspace = ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(); ProjectInfo { trusted: project.is_trusted(), - name: if custom.is_empty() { - project.root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default() - } else { - custom.to_owned() - }, + display_name: workspace + .as_ref() + .map(|w| w.display_name(&project.root, &primary)) + .unwrap_or_else(|| primary.clone()), + name: workspace.as_ref().filter(|w| w.is_container(&project.root)).map(|w| w.name.clone()).unwrap_or(primary), is_git: !project.repos.read().is_empty(), settings, root: project.root.clone().into(), - workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(), + workspace, unity: ide_workspace::unity::detect(&project.root), } } @@ -63,38 +71,71 @@ pub fn roots(host: &Host, window: &str) -> IdeResult) -> IdeResult { let project = host.project(window)?; - ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?; + let previous = ide_workspace::workspace::Workspace::load(&project.root)? + .ok_or_else(|| ide_core::IdeError::invalid("This folder is not a workspace"))?; + for path in paths.iter().filter(|p| !previous.projects.contains(p)) { + require_attachment_trust(host, path)?; + } + if !previous.is_container(&project.root) && paths.first() != Some(&project.root) { + return Err(ide_core::IdeError::invalid("The primary project cannot be detached")); + } + if !previous.is_container(&project.root) && paths == [project.root.clone()] { + std::fs::remove_file(project.root.join(ide_workspace::workspace::FILE))?; + } else { + let workspace = ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?; + keep_attachment_private(&project.root, &workspace)?; + } + refresh_workspace(&project); + Ok(info(&project)) +} + +fn require_attachment_trust(host: &Host, path: &Path) -> IdeResult<()> { + if !host.trust.trusted(path)? { + return Err(ide_core::IdeError::invalid("Trust the project before attaching it")); + } + Ok(()) +} + +fn keep_attachment_private(root: &Path, workspace: &ide_workspace::workspace::Workspace) -> IdeResult<()> { + if !workspace.is_container(root) { + ide_vcs::changelists::ensure_ignored(&root.join(".openidea"), &["/workspace.json"])?; + } + Ok(()) +} + +fn refresh_workspace(project: &Project) { project.settings.reload(); project.refresh_index_scope(); project.refresh_repositories(); project.sink.emit(ide_core::IdeEvent::ProjectRootsChanged { root: project.root.clone().into() }); - Ok(info(&project)) } -/// Links a folder, or prepares an automatic workspace for the caller to open in place. +/// Attaches a trusted folder without replacing the current project. pub fn attach_workspace(host: &Host, window: &str, path: &Path) -> IdeResult { let project = host.project(window)?; let path = dunce::canonicalize(path)?; if project.light { return Err(ide_core::IdeError::invalid("Open a project before attaching a folder")); } + if path == project.root { + return Ok(project.root.clone().into()); + } + require_attachment_trust(host, &path)?; if let Some(mut workspace) = ide_workspace::workspace::Workspace::load(&project.root)? { if !workspace.projects.contains(&path) { workspace.projects.push(path); save_workspace(host, window, &workspace.name, workspace.projects)?; } - return Ok(project.root.clone().into()); + } else { + let workspace = ide_workspace::workspace::Workspace::attach( + &project.root, + &info(&project).name, + vec![project.root.clone(), path], + )?; + keep_attachment_private(&project.root, &workspace)?; + refresh_workspace(&project); } - if path == project.root { - return Ok(project.root.clone().into()); - } - Ok(ide_workspace::workspace::Workspace::create_for_project( - &host.data_dir, - &project.root, - &info(&project).name, - &path, - )? - .into()) + Ok(project.root.clone().into()) } /// What [`set_settings`] changed; the caller tells the windows. diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index e04d3c0b7..48d06856b 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -24,7 +24,7 @@ pub(super) fn project_configs( let root_suppressed = ide_run::load_suppressed_config_ids(root)?; let mut all = detected_and_saved(root, settings, &user, &root_suppressed); if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? { - for member in workspace.projects.into_iter().filter(|p| p.is_dir()) { + for member in workspace.projects.into_iter().filter(|p| p != root && p.is_dir()) { let settings = ide_workspace::SettingsStore::load(config_dir, Some(&member)).effective(); let saved = ide_run::load_user_configs(&member)?; let suppressed = ide_run::load_suppressed_config_ids(&member)?; @@ -145,20 +145,21 @@ fn settings_for( config: &mut RunConfig, ) -> IdeResult<(std::path::PathBuf, ide_core::settings::Settings)> { let workspace = ide_workspace::workspace::Workspace::load(&project.root)?; - if workspace.is_some() { + let owner = ide_run::scope::owner(&project.root, config); + let member = workspace.as_ref().and_then(|workspace| { + workspace + .projects + .iter() + .filter(|root| **root != project.root && owner.starts_with(root)) + .max_by_key(|root| root.components().count()) + .cloned() + }); + if workspace.as_ref().is_some_and(|w| w.is_container(&project.root)) || member.is_some() { if config.extras.module.is_none() && !matches!(config.kind.as_str(), "application" | "spring-boot") { config.extras.module = config.cwd.as_deref().map(ide_core::ipc_path::to_ipc); } ide_run::scope::rebase(config, &project.root, None); } - let member = workspace.and_then(|workspace| { - let owner = ide_run::scope::owner(&project.root, config); - workspace - .projects - .into_iter() - .filter(|root| owner.starts_with(root)) - .max_by_key(|root| root.components().count()) - }); Ok(match member { Some(root) => { let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&root)).effective(); diff --git a/crates/ide-host/src/ops/search.rs b/crates/ide-host/src/ops/search.rs index 61d90c345..ab75120dd 100644 --- a/crates/ide-host/src/ops/search.rs +++ b/crates/ide-host/src/ops/search.rs @@ -61,7 +61,7 @@ pub(crate) fn search_project( ide_search::SearchScope::FileSet { set } => Some(set), _ => None, }; - let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some(); + let workspace = ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.is_container(root)); let include_root = !workspace || matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. }); let content_roots = match query.scope { diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index e849b9c81..20f76bdab 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -96,7 +96,7 @@ impl Host { self.wait_detached_closes(); let sink: SharedSink = (self.sink_for)(window); // Before anything the project starts: its children get the project's environment. - let trusted = Arc::new(AtomicBool::new(self.trust.trusted(&root)?)); + let trusted = Arc::new(AtomicBool::new(self.trust.window_trusted(&root)?)); let post_start = trusted.load(Ordering::Acquire).then(|| self.environment.project_opened(&root, &sink)).flatten(); let settings = SettingsStore::load(&self.config_dir, Some(&root)); @@ -198,14 +198,15 @@ impl Host { .as_ref() .map(|p| SettingsStore::load(&config_dir, Some(p)).effective()) .unwrap_or_else(|| settings.effective()); - let root = member.as_ref().unwrap_or(&root); + let window_root = &root; + let root = member.as_ref().unwrap_or(window_root); ide_sdk::apply::server_launch_overrides(cfg, &effective); ide_sdk::apply::configure_server(cfg, root, server_root, &effective); if cfg.name == "jdtls" { // A workspace member keeps its build model under its own key (Build Project). let cache = match &member { - Some(member) => build_cache.join(crate::project::project_key(member)), - None => build_cache.clone(), + Some(member) if member != window_root => build_cache.join(crate::project::project_key(member)), + _ => build_cache.clone(), }; let profiles = ide_build::maven_profiles(&cache); ide_sdk::maven_profiles::configure_jdtls(cfg, root, &effective, &profiles, &cache); @@ -1224,17 +1225,32 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa } } -/// A workspace uses its root only for state, never as indexed content. +/// Only explicit workspace containers exclude their root from indexed content. fn index_roots(root: &Path, folders: Vec) -> ide_index::IndexRoots { - ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders } + let container = + ide_workspace::workspace::Workspace::load(root).ok().flatten().is_some_and(|w| w.is_container(root)); + ide_index::IndexRoots { include_root: !container, folders: folders.into_iter().filter(|p| p != root).collect() } } /// Workspace members in manifest order, or extra Project Structure directories and the /// Gradle project folders outside the project as of the last sync (`build_cache`). fn content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec { if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) { - return workspace.projects; + if workspace.is_container(root) { + return workspace.projects; + } + let mut roots = workspace.projects.into_iter().filter(|p| !p.starts_with(root)).collect::>(); + for path in extra_content_roots(settings, root, build_cache) { + if !roots.contains(&path) { + roots.push(path); + } + } + return roots; } + extra_content_roots(settings, root, build_cache) +} + +fn extra_content_roots(settings: &ide_core::settings::Settings, root: &Path, build_cache: &Path) -> Vec { let mut out: Vec = Vec::new(); let user = settings.project_structure.content_roots.iter().map(|r| PathBuf::from(r.trim())); for p in user.chain(ide_build::gradle_external_roots(root, build_cache, &settings.languages.java.gradle)) { diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index a2c5ba409..c00ba8c7f 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -56,6 +56,22 @@ impl TrustStore { || self.inherited.lock().contains(&root))) } + pub fn window_trusted(&self, root: &Path) -> IdeResult { + if !self.trusted(root)? { + return Ok(false); + } + if let Some(workspace) = ide_workspace::workspace::Workspace::load(root)? + && !workspace.is_container(root) + { + for member in workspace.projects.iter().filter(|p| *p != root && p.exists()) { + if !self.trusted(member)? { + return Ok(false); + } + } + } + Ok(true) + } + /// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]). pub fn inherit(&self, path: &Path) -> IdeResult<()> { let root = dunce::canonicalize(path)?; @@ -134,7 +150,7 @@ impl crate::Host { pub async fn refresh_project_trust(&self) -> IdeResult<()> { for project in self.projects().into_iter().filter(|p| !p.light) { - let trusted = self.trust.trusted(&project.root).unwrap_or(false); + let trusted = self.trust.window_trusted(&project.root).unwrap_or(false); let previous = project.trusted.swap(trusted, std::sync::atomic::Ordering::AcqRel); if previous == trusted { continue; diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index 5ef72d707..accad74b8 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -450,3 +450,33 @@ async fn revocation_still_applies_when_a_project_folder_was_removed() { assert!(!project.is_trusted()); host.shutdown().await; } + +#[test] +fn attached_member_trust_is_required_after_restart_and_revocation() { + let tmp = tempfile::tempdir().unwrap(); + let (config, data) = (tmp.path().join("config"), tmp.path().join("data")); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let root = base.join("primary"); + let other = base.join("other"); + for path in [&root, &other] { + std::fs::create_dir(path).unwrap(); + } + ide_workspace::workspace::Workspace::attach(&root, "Primary", vec![root.clone(), other.clone()]).unwrap(); + let trust = TrustStore::load(&config, &data); + trust.decide(&root, true, false).unwrap(); + trust.inherit(&other).unwrap(); + assert!(trust.window_trusted(&root).unwrap()); + let trust = TrustStore::load(&config, &data); + assert!(trust.status(&root).unwrap().trusted); + assert!(!trust.window_trusted(&root).unwrap()); + assert!(!trust.status(&other).unwrap().decided); + trust.decide(&root, true, false).unwrap(); + assert!(!trust.window_trusted(&root).unwrap(), "trusting only the root does not recover the window"); + trust.decide(&other, true, false).unwrap(); + assert!(trust.window_trusted(&root).unwrap()); + trust.decide(&other, false, false).unwrap(); + assert!(trust.status(&other).unwrap().decided); + assert!(!trust.window_trusted(&root).unwrap()); + trust.decide(&other, true, false).unwrap(); + assert!(trust.window_trusted(&root).unwrap()); +} diff --git a/crates/ide-host/tests/workspaces.rs b/crates/ide-host/tests/workspaces.rs index 077615cfe..813c32769 100644 --- a/crates/ide-host/tests/workspaces.rs +++ b/crates/ide-host/tests/workspaces.rs @@ -32,11 +32,15 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di }); std::fs::write(root.join("internal-needle.txt"), "needle").unwrap(); host.trust.decide(&root, true, false).unwrap(); + for member in &roots { + host.trust.decide(member, true, false).unwrap(); + } let project = host.open_project("main", &root).unwrap(); assert_eq!(ops::project::info(&project).name, "Full stack"); assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), root.as_path()); ops::project::attach_workspace(&host, "main", &roots[1]).unwrap(); assert_eq!(project.content_roots(), roots); + assert!(!root.join(".openidea/.gitignore").exists()); assert!(project.index.wait_idle(Duration::from_secs(10))); assert!(project.index.find_files("internal-needle", 100).is_empty()); assert!(project.index.find_files("workspace.json", 100).is_empty()); @@ -98,20 +102,6 @@ async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_di std::fs::write(invalid.join(ide_workspace::workspace::FILE), "broken").unwrap(); assert!(host.open_project("main", &invalid).is_err()); assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project)); - host.open_project("main", &roots[0]).unwrap(); - let automatic = ops::project::attach_workspace(&host, "main", &roots[1]).unwrap(); - assert_eq!(host.project("main").unwrap().root, roots[0]); - let automatic = host.open_project("main", &automatic).unwrap(); - assert_eq!(automatic.content_roots(), roots); - assert_eq!(ops::project::info(&automatic).name, "frontend"); - assert!(automatic.root.starts_with(base.join("data/workspaces"))); - assert!(automatic.index.wait_idle(Duration::from_secs(10))); - assert!(automatic.index.find_files("workspace.json", 100).is_empty()); - let updated = ops::project::save_workspace(&host, "main", "frontend", vec![roots[1].clone()]).unwrap(); - assert_eq!(updated.workspace.unwrap().projects, vec![roots[1].clone()]); - assert_eq!(automatic.root, updated.root.as_ref()); - assert!(automatic.index.wait_idle(Duration::from_secs(10))); - assert_eq!(automatic.index.find_files("same.txt", 100).len(), 1); host.close_project("main"); } @@ -206,3 +196,133 @@ async fn build_tool_availability_follows_workspace_members_and_trust() { assert_eq!(model.gradle.len(), 1); host.close_project("main"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attach_and_detach_preserve_the_primary_project_session() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let roots = vec![base.join("primary"), base.join("other")]; + for root in &roots { + std::fs::create_dir(root).unwrap(); + std::fs::write(root.join("same.txt"), "needle").unwrap(); + std::fs::write(root.join("package.json"), r#"{"scripts":{"test":"echo test"}}"#).unwrap(); + } + let host = Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + }); + host.trust.decide(&roots[0], true, false).unwrap(); + let project = host.open_project("main", &roots[0]).unwrap(); + assert!(project.index.wait_idle(Duration::from_secs(10))); + let configs = ops::run::configs(&host, "main").unwrap(); + assert!(!configs.is_empty()); + let cache = project.build_cache_dir(); + let doc = ops::doc::open(&host, "main", roots[0].join("same.txt")).await.unwrap(); + ops::doc::edit( + &host, + "main", + doc.id, + doc.version, + ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "unsaved ".into() }] }, + ) + .unwrap(); + assert!(ops::project::attach_workspace(&host, "main", &roots[1]).is_err()); + host.trust.decide(&roots[1], false, false).unwrap(); + assert!(ops::project::attach_workspace(&host, "main", &roots[1]).is_err()); + assert!(!roots[0].join(ide_workspace::workspace::FILE).exists()); + let existing_index = &project.index as *const _; + host.trust.decide(&roots[1], true, false).unwrap(); + assert_eq!(ops::project::attach_workspace(&host, "main", &roots[1]).unwrap().as_ref(), roots[0]); + assert!(Arc::ptr_eq(&host.project("main").unwrap(), &project)); + assert_eq!(&host.project("main").unwrap().index as *const _, existing_index); + assert_eq!(project.root, roots[0]); + assert_eq!(project.build_cache_dir(), cache); + assert!(!base.join("data/workspaces").exists()); + assert_eq!(Workspace::load(&roots[0]).unwrap().unwrap().projects, roots); + assert!( + std::fs::read_to_string(roots[0].join(".openidea/.gitignore")).unwrap().lines().any(|l| l == "/workspace.json") + ); + assert_eq!(project.content_roots(), vec![roots[1].clone()]); + let attached = ops::project::info(&project); + assert_eq!(attached.name, "primary"); + assert_eq!(attached.display_name, "primary, other"); + ops::project::set_settings( + &host, + "main", + ide_workspace::SettingsScope::Project, + "general.projectName", + &serde_json::to_string(&attached.name).unwrap(), + ) + .unwrap(); + assert_eq!(ops::project::info(&project).display_name, "primary, other"); + let restricted = base.join("restricted"); + std::fs::create_dir(&restricted).unwrap(); + host.trust.decide(&restricted, false, false).unwrap(); + assert!( + ops::project::save_workspace(&host, "main", "Primary", vec![roots[0].clone(), roots[1].clone(), restricted]) + .is_err() + ); + let all = ops::run::configs(&host, "main").unwrap(); + assert_eq!(all.len(), configs.len() * 2); + for config in &configs { + assert_eq!(all.iter().filter(|c| c.id == config.id).count(), 1); + } + assert!(project.index.wait_idle(Duration::from_secs(10))); + assert_eq!(project.index.find_files("same.txt", 100).len(), 2); + let hits = Arc::new(Mutex::new(Vec::new())); + let out = hits.clone(); + let query = serde_json::from_value(serde_json::json!({"pattern":"needle", "isRegex":false,"caseSensitive":false,"wholeWord":false,"include":[],"exclude":[],"scope":{"kind":"project"},"maxMatches":100})).unwrap(); + ops::search::start( + &host, + "main", + RequestId::next(), + query, + Arc::new(move |event| { + if let ops::search::SearchEvent::File { file } = event { + out.lock().unwrap().push(file); + } + }), + ) + .await + .unwrap(); + assert_eq!(hits.lock().unwrap().len(), 2); + assert!(ops::project::save_workspace(&host, "main", "Primary", vec![roots[1].clone()]).is_err()); + let updated = ops::project::save_workspace(&host, "main", "Primary", vec![roots[0].clone()]).unwrap(); + assert!(updated.workspace.is_none()); + assert_eq!(updated.name, "primary"); + assert_eq!(updated.root.as_ref(), roots[0]); + assert!(!roots[0].join(ide_workspace::workspace::FILE).exists()); + assert!(project.content_roots().is_empty()); + assert_eq!( + ops::run::configs(&host, "main").unwrap().iter().map(|c| &c.id).collect::>(), + configs.iter().map(|c| &c.id).collect::>() + ); + assert!(project.documents.text_of(&roots[0].join("same.txt")).unwrap().starts_with("unsaved")); + assert!(project.index.wait_idle(Duration::from_secs(10))); + assert_eq!(project.index.find_files("same.txt", 100).len(), 1); + let nested = roots[0].join("nested"); + std::fs::create_dir(&nested).unwrap(); + std::fs::write(nested.join("same.txt"), "needle").unwrap(); + host.trust.decide(&nested, true, false).unwrap(); + Workspace::attach(&nested, "Nested", vec![nested.clone()]).unwrap(); + ops::project::attach_workspace(&host, "main", &nested).unwrap(); + assert!(project.content_roots().is_empty()); + let container = base.join("container"); + Workspace::save(&container, "Container", vec![], true).unwrap(); + host.trust.decide(&container, true, false).unwrap(); + assert!(ops::project::attach_workspace(&host, "main", &container).is_err()); + host.close_project("main"); + let reopened = host.open_project("main", &roots[0]).unwrap(); + assert_eq!(ops::project::info(&reopened).name, "primary"); + assert_eq!(ops::project::info(&reopened).display_name, "primary, nested"); + host.set_project_trust(&nested, false, false).await.unwrap(); + assert!(!reopened.is_trusted()); + host.close_project("main"); +} diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index 37f9d7c8c..6a391df95 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -201,8 +201,9 @@ impl SettingsStore { let mut settings: Settings = serde_json::from_value(merged).unwrap_or_default(); if let Some(root) = self.project_file.as_deref().and_then(Path::parent).and_then(Path::parent) && let Ok(Some(workspace)) = crate::workspace::Workspace::load(root) + && workspace.is_container(root) { - settings.general.project_name = workspace.name; + settings.general.project_name = workspace.name.clone(); settings.project_structure.content_roots = workspace.projects.iter().map(|p| ide_core::ipc_path::to_ipc(p)).collect(); } diff --git a/crates/ide-workspace/src/workspace.rs b/crates/ide-workspace/src/workspace.rs index 91f27858f..1fc9f429f 100644 --- a/crates/ide-workspace/src/workspace.rs +++ b/crates/ide-workspace/src/workspace.rs @@ -1,4 +1,5 @@ -//! A workspace owns its settings and references project folders without changing them. +//! Explicit workspaces reference project folders without changing them. +//! Attaching folders writes only the primary project's `.openidea/` directory. use ide_core::{IdeError, IdeResult}; use serde::{Deserialize, Serialize}; use specta::Type; @@ -22,31 +23,22 @@ fn serialize_projects(projects: &[PathBuf], serializer: S) } impl Workspace { - /// Creates an automatically named workspace outside all linked projects. - pub fn create_for_project(data_dir: &Path, current: &Path, name: &str, attached: &Path) -> IdeResult { - let projects = vec![current.to_path_buf(), attached.to_path_buf()]; - let base = destination(&data_dir.join("workspaces"))?; - let folder = current.file_name().and_then(|n| n.to_str()).unwrap_or("workspace"); - // Validate before creating anything, including when app data is inside a project. - let normalized = normalize_projects(&base, projects.clone(), false)?; - if normalized.iter().any(|p| base.starts_with(p)) { - return Err(IdeError::invalid("Store the workspace outside its linked project folders")); + pub fn is_container(&self, root: &Path) -> bool { + self.projects.first().is_none_or(|p| p != root) + } + + pub fn display_name(&self, root: &Path, primary: &str) -> String { + if self.is_container(root) { + return self.name.clone(); } - std::fs::create_dir_all(&base)?; - for suffix in 0.. { - let root = base.join(if suffix == 0 { folder.to_owned() } else { format!("{folder}-{suffix}") }); - match std::fs::create_dir(&root) { - Ok(()) => {} - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, - Err(e) => return Err(e.into()), - } - if let Err(error) = Self::save(&root, name, projects.clone(), true) { - let _ = std::fs::remove_dir(&root); - return Err(error); - } - return Ok(root); + match self.projects.get(1) { + Some(other) => format!( + "{primary}, {}{}", + other.file_name().unwrap_or_default().to_string_lossy(), + if self.projects.len() > 2 { "..." } else { "" } + ), + None => primary.to_owned(), } - unreachable!() } pub fn load(root: &Path) -> IdeResult> { @@ -61,11 +53,20 @@ impl Workspace { return Err(IdeError::invalid("Unsupported workspace version")); } workspace.name = valid_name(&workspace.name)?; - workspace.projects = normalize_projects(root, workspace.projects, true)?; + workspace.projects = normalize_projects(root, workspace.projects)?; Ok(Some(workspace)) } pub fn save(root: &Path, name: &str, projects: Vec, create: bool) -> IdeResult { + Self::write(root, name, projects, create, false) + } + + /// Creates private attachment metadata in an existing primary project. + pub fn attach(root: &Path, name: &str, projects: Vec) -> IdeResult { + Self::write(root, name, projects, true, true) + } + + fn write(root: &Path, name: &str, projects: Vec, create: bool, attach: bool) -> IdeResult { let name = valid_name(name)?; let root = destination(root)?; let previous = Self::load(&root)?; @@ -75,14 +76,23 @@ impl Workspace { if !create && previous.is_none() { return Err(IdeError::invalid("This folder is not a workspace")); } - let projects = normalize_projects(&root, projects, true)?; + let projects = normalize_projects(&root, projects)?; + if !attach + && previous.as_ref().is_none_or(|w| w.is_container(&root)) + && projects.iter().any(|p| root.starts_with(p)) + { + return Err(IdeError::invalid("Store the workspace outside its linked project folders")); + } + if attach && projects.first() != Some(&root) { + return Err(IdeError::invalid("The primary project must be the first attached folder")); + } for path in &projects { if !path.is_dir() && !previous.as_ref().is_some_and(|w| w.projects.contains(path)) { return Err(IdeError::invalid(format!("Project folder does not exist: {}", path.display()))); } } - if projects.iter().any(|p| p == &root || root.starts_with(p)) { - return Err(IdeError::invalid("Store the workspace outside its linked project folders")); + if previous.as_ref().is_some_and(|w| !w.is_container(&root)) && projects.first() != Some(&root) { + return Err(IdeError::invalid("The primary project cannot be detached")); } std::fs::create_dir_all(&root)?; let workspace = Self { version: 1, name, projects }; @@ -118,22 +128,33 @@ fn valid_name(name: &str) -> IdeResult { Ok(name.to_owned()) } -fn normalize_projects(root: &Path, projects: Vec, allow_missing: bool) -> IdeResult> { +fn normalize_projects(root: &Path, projects: Vec) -> IdeResult> { let mut result = Vec::new(); for path in projects { let path = if path.is_absolute() { path } else { root.join(path) }; let path = match dunce::canonicalize(&path) { Ok(path) if path.is_dir() => path, - Err(e) if allow_missing && e.kind() == std::io::ErrorKind::NotFound => path, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => path, _ => return Err(IdeError::invalid(format!("Project folder does not exist: {}", path.display()))), }; - if path.join(FILE).exists() { - return Err(IdeError::invalid("Add project folders instead of nesting workspaces")); + if path != root && path.join(FILE).exists() { + let mut nested: Workspace = serde_json::from_slice(&std::fs::read(path.join(FILE))?) + .map_err(|e| IdeError::invalid(format!("Invalid workspace: {e}")))?; + let primary = nested.projects.first().map(|p| if p.is_absolute() { p.clone() } else { path.join(p) }); + if let Some(primary) = primary.and_then(|p| dunce::canonicalize(p).ok()) { + nested.projects[0] = primary; + } + if nested.is_container(&path) { + return Err(IdeError::invalid("Add project folders instead of nesting workspaces")); + } } if !result.contains(&path) { result.push(path); } } + if result.iter().enumerate().any(|(i, p)| (p == root && i != 0) || (p != root && root.starts_with(p))) { + return Err(IdeError::invalid("Store the workspace outside its linked project folders")); + } Ok(result) } @@ -142,24 +163,51 @@ mod tests { use super::*; #[test] - fn automatic_workspace_is_unique_and_outside_projects() { + fn update_cannot_turn_a_container_into_an_attachment() { let tmp = tempfile::tempdir().unwrap(); - let base = dunce::canonicalize(tmp.path()).unwrap(); - let a = base.join("a"); - let b = base.join("b"); - std::fs::create_dir(&a).unwrap(); - std::fs::create_dir(&b).unwrap(); - let data = base.join("data"); - let root = Workspace::create_for_project(&data, &a, "A", &b).unwrap(); - let workspace = Workspace::load(&root).unwrap().unwrap(); - assert_eq!(workspace.name, "A"); - assert_eq!(workspace.projects, vec![a.clone(), b.clone()]); - assert_eq!(root, data.join("workspaces/a")); - let other = Workspace::create_for_project(&data, &a, "A", &b).unwrap(); - assert_eq!(other, data.join("workspaces/a-1")); - assert!(Workspace::create_for_project(&a.join("data"), &a, "A", &b).is_err()); - assert!(!a.join("data").exists()); - assert!(!a.join(FILE).exists()); + let root = dunce::canonicalize(tmp.path()).unwrap(); + Workspace::save(&root, "Container", vec![], true).unwrap(); + let before = std::fs::read(root.join(FILE)).unwrap(); + assert!(Workspace::save(&root, "Attached", vec![root.clone()], false).is_err()); + assert_eq!(std::fs::read(root.join(FILE)).unwrap(), before); + } + + #[test] + fn create_workspace_refuses_a_location_equal_to_any_member() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + let other = root.join("other"); + std::fs::create_dir(&other).unwrap(); + for projects in [vec![root.clone(), other.clone()], vec![other, root.clone()]] { + let error = Workspace::save(&root, "Invalid", projects, true).unwrap_err(); + assert_eq!(error.message, "Store the workspace outside its linked project folders"); + assert!(!root.join(FILE).exists()); + } + } + + #[test] + fn attachments_keep_the_primary_and_allow_attached_folders() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + let a = root.join("a"); + let b = root.join("b"); + let c = root.join("c"); + for path in [&a, &b, &c] { + std::fs::create_dir(path).unwrap(); + } + let nested = Workspace::attach(&b, "B", vec![b.clone(), c.clone()]).unwrap(); + assert!(!nested.is_container(&b)); + let saved = Workspace::attach(&a, "A", vec![a.clone(), b.clone()]).unwrap(); + assert_eq!(Workspace::load(&a).unwrap(), Some(saved.clone())); + assert_eq!(saved.display_name(&a, "Primary"), "Primary, b"); + assert!(Workspace::save(&a, "A", vec![b.clone()], false).is_err()); + let saved = Workspace::save(&a, "A", vec![a.clone(), b, c], false).unwrap(); + assert_eq!(saved.display_name(&a, "Primary"), "Primary, b..."); + let container = root.join("container"); + Workspace::save(&container, "Container", vec![], true).unwrap(); + assert!(Workspace::save(&a, "A", vec![a.clone(), container], false).is_err()); + assert!(Workspace::save(&a, "A", vec![root.clone()], false).is_err()); + assert!(Workspace::save(&root.join("other"), "Other", vec![a, root], true).is_err()); } #[test] diff --git a/e2e/attachProject.spec.ts b/e2e/attachProject.spec.ts new file mode 100644 index 000000000..61c969367 --- /dev/null +++ b/e2e/attachProject.spec.ts @@ -0,0 +1,159 @@ +import { expect, ipcCalls, openFromTree, stubDesktop, test } from "./fixtures"; + +test.use({ video: "on" }); + +test("Attach preserves unsaved tabs and detaches back to the plain project", async ({ workbench: page }) => { + const editor = await openFromTree(page, "README.md"); + await editor.locator(".cm-content").click(); + await page.keyboard.press("Meta+End"); + await page.keyboard.type("unsaved attach text"); + await page.screenshot({ path: test.info().outputPath("plain-project.png"), animations: "disabled" }); + await stubDesktop(page); + await page.evaluate(async () => { + const module = "/src/platform/project.ts"; + const { useProject } = await import(/* @vite-ignore */ module); + const project = useProject.getState().project; + useProject.setState({ + settings: { ...project.settings, general: { ...project.settings.general, openProjectIn: "ask" } }, + }); + const attached = { + ...project, + displayName: "acme-api, other", + workspace: { version: 1, name: project.name, projects: [project.root, "/dev/other"] }, + }; + let current = project; + window.__ipc.handlers.project_trust_status = ({ path }) => ({ root: path, trusted: true, decided: true }); + window.__ipc.handlers.workspace_attach = () => { + current = attached; + return project.root; + }; + window.__ipc.handlers.project_current = () => current; + window.__ipc.handlers.menu_refresh = () => null; + window.__ipc.handlers.project_rename = ({ name }) => { + current = { ...attached, name, displayName: `${name}, other` }; + return current; + }; + window.__ipc.handlers.workspace_update = () => { + current = project; + return project; + }; + window.__ipc.handlers.project_roots = () => ({ folders: [] }); + window.__ipc.handlers.fs_read_dir = () => []; + window.__ipc.handlers.run_configs = () => []; + window.__ipc.handlers.recent_projects = () => [ + { path: current.root, name: current.displayName || current.name, branch: null, openedAtMs: Date.now() }, + ]; + const windows = "/src/features/app/windows.ts"; + void (await import(/* @vite-ignore */ windows)).openProjectWithPolicy("/dev/other"); + }); + const dialog = page.getByRole("dialog", { name: "Open Project" }); + await expect(dialog).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("attach-dialog.png"), animations: "disabled" }); + await dialog.getByRole("button", { name: "Attach", exact: true }).click(); + await expect( + page.locator("header").first().getByRole("button", { name: "acme-api, other", exact: true }), + ).toBeVisible(); + await expect(page.getByRole("treeitem", { name: /^other / })).toBeVisible(); + await expect(editor.locator(".cm-content")).toContainText("unsaved attach text"); + expect(await ipcCalls(page, "open_project")).toHaveLength(0); + expect(await ipcCalls(page, "workspace_attach")).toHaveLength(1); + await page.evaluate(async () => { + const actions = "/src/platform/actions.ts"; + void (await import(/* @vite-ignore */ actions)).executeAction("file.renameProject"); + }); + const rename = page.getByRole("dialog", { name: "Rename Project", exact: true }); + await expect(rename.getByRole("textbox")).toHaveValue("acme-api"); + await rename.getByRole("button", { name: "Rename", exact: true }).click(); + await expect( + page.locator("header").first().getByRole("button", { name: "acme-api, other", exact: true }), + ).toBeVisible(); + expect((await ipcCalls(page, "project_rename"))[0]!.name).toBe("acme-api"); + await page.screenshot({ path: test.info().outputPath("attached-project.png"), animations: "disabled" }); + await page.locator("header").first().getByRole("button", { name: "acme-api, other", exact: true }).click(); + await expect(page.getByText("/dev/acme-api", { exact: true }).last()).toBeVisible(); + await page.screenshot({ path: test.info().outputPath("attached-recent.png"), animations: "disabled" }); + await page.keyboard.press("Escape"); + await page.getByRole("treeitem", { name: /^other / }).click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Detach Project", exact: true })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "Remove from Workspace" })).toHaveCount(0); + await page.screenshot({ path: test.info().outputPath("detach-menu.png"), animations: "disabled" }); + await page.getByRole("menuitem", { name: "Detach Project", exact: true }).click(); + const detach = page.getByRole("dialog", { name: "Detach Project" }); + await expect(detach).toBeVisible(); + await detach.getByRole("button", { name: "Detach", exact: true }).click(); + await expect(page.locator("header").first().getByRole("button", { name: "acme-api", exact: true })).toBeVisible(); + await expect(editor.locator(".cm-content")).toContainText("unsaved attach text"); + await expect(page.getByRole("treeitem", { name: /^other / })).toHaveCount(0); + await page.screenshot({ path: test.info().outputPath("detached-project.png"), animations: "disabled" }); +}); + +test("restricting an attached folder leaves the current project untouched", async ({ workbench: page }) => { + await stubDesktop(page); + await page.evaluate(async () => { + const projectModule = "/src/platform/project.ts"; + const { useProject } = await import(/* @vite-ignore */ projectModule); + useProject.setState({ settings: { ...useProject.getState().settings, general: { openProjectIn: "ask" } } }); + let decided = false; + window.__ipc.handlers.project_trust_status = ({ path }) => ({ root: path, trusted: false, decided }); + window.__ipc.handlers.project_trust_set = () => { + decided = true; + }; + const module = "/src/features/app/windows.ts"; + void (await import(/* @vite-ignore */ module)).openProjectWithPolicy("/dev/untrusted"); + }); + await page.getByRole("dialog", { name: "Open Project" }).getByRole("button", { name: "Attach", exact: true }).click(); + const trust = page.getByRole("dialog", { name: "Trust and Open Project?" }); + await expect(trust).toBeVisible(); + await trust.getByRole("button", { name: "Preview in Restricted Mode" }).click(); + await expect(trust).toBeHidden(); + expect(await ipcCalls(page, "workspace_attach")).toHaveLength(0); + expect(await ipcCalls(page, "open_project")).toHaveLength(0); + await expect(page.locator("header").first().getByRole("button", { name: "acme-api", exact: true })).toBeVisible(); +}); + +test("restricted attachments can be trusted individually from the banner", async ({ workbench: page }) => { + await stubDesktop(page); + await page.evaluate(async () => { + const m = "/src/platform/project.ts"; + const { useProject } = await import(/* @vite-ignore */ m); + const primary = useProject.getState().project; + let current = { + ...primary, + trusted: false, + displayName: "acme-api, other...", + workspace: { version: 1, name: primary.name, projects: [primary.root, "/dev/other", "/dev/third"] }, + }; + const trusted = new Set([primary.root]); + window.__ipc.handlers.project_trust_status = ({ path }) => ({ + root: path, + trusted: trusted.has(path), + decided: true, + }); + window.__ipc.handlers.project_trust_set = ({ path, trusted: allow }) => { + if (allow) trusted.add(path); + current = { ...current, trusted: current.workspace.projects.every((path) => trusted.has(path)) }; + }; + window.__ipc.handlers.project_current = () => current; + window.__ipc.handlers.fs_read_dir = () => []; + window.__ipc.handlers.project_roots = () => ({ folders: [] }); + window.__ipc.handlers.run_configs = () => []; + useProject.setState({ project: current }); + }); + const banner = page.getByRole("status").filter({ hasText: "Restricted Mode" }); + await expect(banner).toContainText("/dev/other"); + await page.screenshot({ path: test.info().outputPath("restricted-member.png"), animations: "disabled" }); + await banner.getByRole("button", { name: "Trust Project" }).click(); + const dialog = page.getByRole("dialog", { name: "Trust and Open Project?" }); + await expect(dialog).toContainText("/dev/other"); + await dialog.getByRole("button", { name: "Don't Open" }).click(); + expect(await ipcCalls(page, "project_trust_set")).toHaveLength(0); + await banner.getByRole("button", { name: "Trust Project" }).click(); + await dialog.getByRole("button", { name: "Trust Project", exact: true }).click(); + await expect(banner).toContainText("/dev/third"); + expect(await ipcCalls(page, "project_trust_set")).toEqual([{ path: "/dev/other", trusted: true, folder: false }]); + await banner.getByRole("button", { name: "Trust Project" }).click(); + await expect(dialog).toContainText("/dev/third"); + await dialog.getByRole("button", { name: "Trust Project", exact: true }).click(); + await expect(banner).toBeHidden(); + await page.screenshot({ path: test.info().outputPath("trusted-members.png"), animations: "disabled" }); +}); diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index 50f4a9efc..662f0a2f7 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -85,7 +85,7 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult().sink.emit(ide_core::IdeEvent::WindowGroupsChanged); // Every project window is part of the session reopened at the next start. crate::window_groups::persist(window.app_handle()).await?; @@ -121,14 +121,19 @@ fn project_info(project: &crate::state::Project) -> ProjectInfo { pub fn project_rename(window: Window, state: State<'_, AppState>, name: String) -> IdeResult { let project = state.project(window.label())?; let name = name.trim(); - if let Some(workspace) = ide_workspace::workspace::Workspace::load(&project.root)? { - return ide_host::ops::project::save_workspace(&state.host, window.label(), name, workspace.projects); + if let Some(workspace) = ide_workspace::workspace::Workspace::load(&project.root)? + && workspace.is_container(&project.root) + { + ide_host::ops::project::save_workspace(&state.host, window.label(), name, workspace.projects)?; + } else { + let value = if name.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(name.to_owned()) }; + project.settings.set(SettingsScope::Project, "general.projectName", value)?; } - let value = if name.is_empty() { serde_json::Value::Null } else { serde_json::Value::String(name.to_owned()) }; - project.settings.set(SettingsScope::Project, "general.projectName", value)?; - state.recent.rename(&project.root, (!name.is_empty()).then_some(name))?; - crate::jump_list::refresh(&state.app, state.recent.list()); - Ok(project_info(&project)) + let info = project_info(&project); + state.record_project(&project)?; + let _ = window.set_title(&info.display_name); + state.sink.emit(ide_core::IdeEvent::WindowGroupsChanged); + Ok(info) } // Not on the main thread: closing stops the project's language servers, runs and file watcher. diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index 6c3a5efa2..7dea586d0 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -42,7 +42,10 @@ pub async fn workspace_update(window: WebviewWindow, name: String, projects: Vec }) .await .map_err(|e| IdeError::internal(e.to_string()))??; - let _ = window.set_title(&info.name); + let state = window.state::(); + let project = state.project(window.label())?; + state.record_project(&project)?; + let _ = window.set_title(&info.display_name); window.state::().sink.emit(ide_core::IdeEvent::WindowGroupsChanged); Ok(info) } @@ -52,7 +55,14 @@ pub async fn workspace_update(window: WebviewWindow, name: String, projects: Vec pub async fn workspace_attach(window: WebviewWindow, path: PathBuf) -> IdeResult { let host = window.state::().host.clone(); let label = window.label().to_owned(); - tauri::async_runtime::spawn_blocking(move || ide_host::ops::project::attach_workspace(&host, &label, &path)) - .await - .map_err(|e| IdeError::internal(e.to_string()))? + let root = + tauri::async_runtime::spawn_blocking(move || ide_host::ops::project::attach_workspace(&host, &label, &path)) + .await + .map_err(|e| IdeError::internal(e.to_string()))??; + let state = window.state::(); + let project = state.project(window.label())?; + state.record_project(&project)?; + let _ = window.set_title(&ide_host::ops::project::info(&project).display_name); + state.sink.emit(ide_core::IdeEvent::WindowGroupsChanged); + Ok(root) } diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index f0f5a790a..00b5cea3e 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -224,6 +224,7 @@ pub fn spec(os: Os) -> Vec { act("file.new", "New…"), act("file.newScratchFile", "New Scratch File…"), act("file.openProject", "Open…"), + act("project.attach", "Attach Project…"), act("file.openFile", "Open File…"), act("file.openInNewWindow", "Open in New Window…"), sub( diff --git a/src-tauri/src/remote_dev.rs b/src-tauri/src/remote_dev.rs index 595012e0a..602daee55 100644 --- a/src-tauri/src/remote_dev.rs +++ b/src-tauri/src/remote_dev.rs @@ -528,7 +528,7 @@ pub async fn remote_connect( peer.request("invoke", call, None).await.map_err(ide_host::rpc::error_from_reply)?; // As the server resolved it (symlinks, a trailing `/`): the frontend compares it with the project's. let root = serde_json::to_value(&project.root).ok().and_then(|v| v.as_str().map(str::to_owned)).unwrap_or(root); - let _ = window.set_title(&format!("{} [{title_tag}]", project.name)); + let _ = window.set_title(&format!("{} [{title_tag}]", project.display_name)); let connection = Arc::new(Connection { peer, target: target.clone(), diff --git a/src-tauri/src/state.rs b/src-tauri/src/state.rs index 155e4dd68..b50e7496d 100644 --- a/src-tauri/src/state.rs +++ b/src-tauri/src/state.rs @@ -144,16 +144,24 @@ impl AppState { // A local project replaces a remote one in this window. self.remote_dev.disconnect(window); let project = self.host.open_project(window, root)?; - let branch = project.repos.read().primary().and_then(|r| r.branch_info().ok()).and_then(|b| b.head); - self.recent.touch(&project.root, branch)?; - let display_name = project.settings.effective().general.project_name; - if !display_name.trim().is_empty() { - self.recent.rename(&project.root, Some(display_name.trim()))?; - } - crate::jump_list::refresh(&self.app, self.recent.list()); + self.record_project(&project)?; Ok(project) } + pub fn record_project(&self, project: &Project) -> IdeResult<()> { + let info = ide_host::ops::project::info(project); + let attached = info.workspace.as_ref().is_some_and(|w| !w.is_container(&project.root)); + let branch = if attached { + None + } else { + project.repos.read().primary().and_then(|r| r.branch_info().ok()).and_then(|b| b.head) + }; + self.recent.touch(&project.root, branch)?; + self.recent.rename(&project.root, Some(&info.display_name))?; + crate::jump_list::refresh(&self.app, self.recent.list()); + Ok(()) + } + /// Opens `window` for editing the loose file `file` (LightEdit); not a recent project. pub fn open_light(&self, window: &str, file: &Path) -> IdeResult> { self.remote_dev.disconnect(window); diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs index 4192cc7f1..699a7a00c 100644 --- a/src-tauri/src/window_groups.rs +++ b/src-tauri/src/window_groups.rs @@ -37,7 +37,7 @@ pub fn current(app: &AppHandle, caller: &str) -> Vec { let project = host.project(label).ok(); let name = project .as_ref() - .map(|p| ide_host::ops::project::info(p).name) + .map(|p| ide_host::ops::project::info(p).display_name) .unwrap_or_else(|| window.title().unwrap_or_default()); Some(WindowTab { label: label.clone(), diff --git a/src/bindings.ts b/src/bindings.ts index d03e7e6bf..e18f26c5d 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -66,6 +66,7 @@ export const commands = { trusted: boolean, root: IpcPath, name: string, + displayName: string, isGit: boolean, settings: Settings_Serialize, workspace?: Workspace_Serialize | null, @@ -6941,6 +6942,7 @@ export type ProjectInfo_Deserialize = { trusted: boolean, root: IpcPath, name: string, + displayName?: string, isGit: boolean, settings: Settings_Deserialize, workspace?: Workspace_Deserialize | null, @@ -6951,6 +6953,7 @@ export type ProjectInfo_Serialize = { trusted: boolean, root: IpcPath, name: string, + displayName: string, isGit: boolean, settings: Settings_Serialize, workspace?: Workspace_Serialize | null, diff --git a/src/editor/core/controller.test.ts b/src/editor/core/controller.test.ts index d4130ab0a..8485406ae 100644 --- a/src/editor/core/controller.test.ts +++ b/src/editor/core/controller.test.ts @@ -216,7 +216,7 @@ describe("EditorController", () => { expect(pane().tabs.map((t) => t.input.path)).toEqual(["/p/a.ts", "/p/c.ts"]); }); - it("carries the plain project's editor tabs into its automatic workspace", async () => { + it("keeps editor tabs and unsaved documents through attach and detach", async () => { const settings = {}; useProject.setState({ project: { root: "/attach-a", name: "A", trusted: true, isGit: false, settings }, @@ -227,19 +227,31 @@ describe("EditorController", () => { ctrl.start(); await ctrl.openFile("/attach-a/one.txt", { focus: false, preview: false }); await ctrl.openFile("/attach-a/two.txt", { focus: false, preview: false }); + const slot = ctrl.activeSlot!; + const host = document.createElement("div"); + const view = slot.mount(host); + ctrl.onMounted(slot); + view.dispatch({ changes: { from: 0, insert: "unsaved " }, userEvent: "input.type" }); + const tabs = useTabs.getState(); useProject.setState({ project: { - root: "/attach-workspace", - name: "A", + root: "/attach-a", + name: "A, attach-b", trusted: true, isGit: false, settings, workspace: { version: 1, name: "A", projects: ["/attach-a", "/attach-b"] }, }, }); - await vi.waitFor(() => - expect(openFilePaths(useTabs.getState())).toEqual(["/attach-a/one.txt", "/attach-a/two.txt"]), - ); + expect(useTabs.getState()).toBe(tabs); + expect(ctrl.activeSlot).toBe(slot); + expect(view.state.doc.toString()).toContain("unsaved "); + expect(openFilePaths(useTabs.getState())).toEqual(["/attach-a/one.txt", "/attach-a/two.txt"]); + useProject.setState({ project: { root: "/attach-a", name: "A", trusted: true, isGit: false, settings } }); + expect(useTabs.getState()).toBe(tabs); + expect(ctrl.activeSlot).toBe(slot); + expect(view.state.doc.toString()).toContain("unsaved "); + host.remove(); useProject.setState({ project: null }); }); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index e2e0573a1..d5618cb2e 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -307,15 +307,6 @@ export class EditorController implements SlotHost { prevRoot = root; prevLight = s.lightEdit; this.saveSession(); - if ( - root && - previous.project && - !previous.project.workspace && - s.project?.workspace?.projects[0] === previous.project.root - ) { - const session = loadSession(previous.project.root); - if (session) storeSession(root, session); - } this.sessionRoot = null; const generation = ++this.projectGeneration; const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds); diff --git a/src/features/app/WorkspaceDialog.tsx b/src/features/app/WorkspaceDialog.tsx index 5b41d18c2..c11240dd4 100644 --- a/src/features/app/WorkspaceDialog.tsx +++ b/src/features/app/WorkspaceDialog.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { create } from "zustand"; import { call, commands, isCancelled, nextRequestId, type ProjectDiscovery, type RequestId } from "@/platform/ipc"; import { invokeLocal } from "@/platform/tauriCore"; +import { confirmProjectTrust } from "@/platform/projectTrust"; import { basename, dirname, useProject } from "@/platform/project"; import { isRemoteWindow } from "@/platform/remote"; import { tx } from "@/platform/i18n"; @@ -101,12 +102,21 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose const root = await call(commands.workspaceCreate(path.trim(), name.trim(), included)); await openProjectWithPolicy(root); } else { + for (const path of included.filter((p) => !workspace?.projects.includes(p))) { + await confirmProjectTrust(path); + if (!(await call(commands.projectTrustStatus(path))).trusted) { + setError( + tx`Cannot attach ${path} because it is restricted. Open it separately and choose Trust Project, then attach it again.`, + ); + return; + } + } const updated = await call(commands.workspaceUpdate(name.trim(), included)); useProject.setState({ project: updated, settings: updated.settings }); } onClose(); } catch (e) { - setError(e instanceof Error ? e.message : String(e)); + if (!isCancelled(e)) setError(e instanceof Error ? e.message : String(e)); } finally { setBusy(false); } diff --git a/src/features/app/__tests__/WorkspaceDialog.test.tsx b/src/features/app/__tests__/WorkspaceDialog.test.tsx index ee6d3025e..a769d4b0f 100644 --- a/src/features/app/__tests__/WorkspaceDialog.test.tsx +++ b/src/features/app/__tests__/WorkspaceDialog.test.tsx @@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { open } from "@tauri-apps/plugin-dialog"; import { commands, type ProjectDiscovery } from "@/platform/ipc"; +import { provideProjectTrustPrompt } from "@/platform/projectTrust"; import { invokeLocal } from "@/platform/tauriCore"; import { useProject } from "@/platform/project"; import { WorkspaceDialogHost, showWorkspaceDialog } from "../WorkspaceDialog"; @@ -19,6 +20,10 @@ beforeEach(() => { vi.restoreAllMocks(); vi.mocked(openProjectWithPolicy).mockClear(); vi.mocked(open).mockReset(); + vi.spyOn(commands, "projectTrustStatus").mockResolvedValue({ + status: "ok", + data: { root: "/work", trusted: true, decided: true }, + }); vi.mocked(invokeLocal).mockClear(); useProject.setState({ project: null, settings: null, lightEdit: false }); }); @@ -191,3 +196,39 @@ it("retains the form and linked folders when saving fails", async () => { fireEvent.click(screen.getByRole("button", { name: /^Cancel$/ })); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); + +it.each(["restricted", "cancelled"])("retains Manage Workspace when project trust is %s", async (decision) => { + useProject.setState({ + project: { + root: "/work/primary", + name: "primary", + trusted: true, + isGit: false, + settings: {}, + workspace: { version: 1, name: "primary", projects: ["/work/primary"] }, + }, + } as never); + vi.mocked(commands.projectTrustStatus).mockResolvedValue({ + status: "ok", + data: { root: "/work/restricted", trusted: false, decided: true }, + }); + const update = vi.spyOn(commands, "workspaceUpdate"); + const dispose = provideProjectTrustPrompt(async () => decision !== "cancelled"); + try { + showWorkspaceDialog("manage"); + render(); + fireEvent.change(screen.getByLabelText("Project folder path"), { target: { value: "/work/restricted" } }); + fireEvent.click(screen.getByRole("button", { name: /^Apply$/ })); + await waitFor(() => expect(screen.getByRole("button", { name: /^Apply$/ })).toBeEnabled()); + expect(update).not.toHaveBeenCalled(); + if (decision === "restricted") { + expect(screen.getByRole("alert")).toHaveTextContent( + "Cannot attach /work/restricted because it is restricted. Open it separately and choose Trust Project, then attach it again.", + ); + } else expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Project folder path")).toHaveValue("/work/restricted"); + fireEvent.click(screen.getByRole("button", { name: /^Cancel$/ })); + } finally { + dispose(); + } +}); diff --git a/src/features/app/__tests__/projectName.test.ts b/src/features/app/__tests__/projectName.test.ts new file mode 100644 index 000000000..2d762b96c --- /dev/null +++ b/src/features/app/__tests__/projectName.test.ts @@ -0,0 +1,52 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { useProject } from "@/platform/project"; +import { projectActions } from "../projectActions"; +import { installWindowTitle } from "../title"; + +const { rename, prompt } = vi.hoisted(() => ({ rename: vi.fn(), prompt: vi.fn() })); +vi.mock("@/platform/ipc", async (original) => ({ + ...(await original()), + isTauri: () => true, + commands: { projectRename: rename, menuRefresh: vi.fn() }, +})); +vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ setTitle: async () => {} }) })); +vi.mock("@/workbench/inputDialogs", () => ({ promptInput: prompt })); + +afterEach(() => { + useProject.setState({ project: null, settings: null }); +}); + +it("renames from the primary name while updating the attached window label", async () => { + const project = { + root: "/primary", + name: "primary", + displayName: "primary, other", + trusted: true, + isGit: false, + settings: {}, + workspace: { version: 1, name: "primary", projects: ["/primary", "/other"] }, + }; + useProject.setState({ project }); + const dispose = installWindowTitle(); + try { + expect(document.title).toBe("primary, other"); + prompt.mockImplementation(async ({ initial }) => initial); + rename.mockImplementation(async (name) => ({ + status: "ok", + data: { ...project, name, displayName: `${name}, other` }, + })); + await projectActions() + .find((a) => a.id === "file.renameProject")! + .run({ ctx: {} }); + expect(rename).toHaveBeenCalledWith("primary"); + expect(document.title).toBe("primary, other"); + prompt.mockResolvedValueOnce("Renamed"); + await projectActions() + .find((a) => a.id === "file.renameProject")! + .run({ ctx: {} }); + expect(useProject.getState().project).toMatchObject({ name: "Renamed", displayName: "Renamed, other" }); + expect(document.title).toBe("Renamed, other"); + } finally { + dispose(); + } +}); diff --git a/src/features/app/__tests__/windows.test.ts b/src/features/app/__tests__/windows.test.ts index ea9c9c5ec..62de282d6 100644 --- a/src/features/app/__tests__/windows.test.ts +++ b/src/features/app/__tests__/windows.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const projectTrustStatus = vi.fn(); +vi.mock("@/platform/projectTrust", () => ({ confirmProjectTrust: vi.fn(async () => {}) })); +vi.mock("@/workbench/messages", () => ({ ask: vi.fn() })); const workspaceAttach = vi.fn(); const workspaceUpdate = vi.fn(); const projectCurrent = vi.fn(); @@ -35,6 +38,7 @@ vi.mock("@/platform/ipc", async (orig) => { isTauri: () => true, commands: { ...real.commands, + projectTrustStatus: (...a: unknown[]) => projectTrustStatus(...a), workspaceAttach: (...a: unknown[]) => workspaceAttach(...a), workspaceUpdate: (...a: unknown[]) => workspaceUpdate(...a), projectCurrent: () => projectCurrent(), @@ -50,7 +54,7 @@ vi.mock("@/platform/ipc", async (orig) => { }); vi.mock("@/platform/project", async (orig) => { const real = await orig(); - return { ...real, openProject: (p: string) => openProject(p) }; + return { ...real, confirmLeaveProject: vi.fn(real.confirmLeaveProject), openProject: (p: string) => openProject(p) }; }); const diffs: unknown[] = []; const openDiff = vi.fn(async (data: unknown) => void diffs.push(data)); @@ -66,9 +70,11 @@ const { openProjectWithPolicy, windowActions, } = await import("../windows"); -const { useProject, startupLaunchHandled } = await import("@/platform/project"); +const { useProject, startupLaunchHandled, confirmLeaveProject } = await import("@/platform/project"); const { provideEditorService, provideNotificationService } = await import("@/workbench/contributions"); const { isRemoteWindow, connectRemote } = await import("@/platform/remote"); +const { ask } = await import("@/workbench/messages"); +const { confirmProjectTrust } = await import("@/platform/projectTrust"); const { IpcError } = await import("@/platform/ipc"); provideNotificationService({ notify }); @@ -99,6 +105,12 @@ function deferred() { } beforeEach(() => { + vi.mocked(ask).mockReset().mockResolvedValue({ button: "attach", checked: false }); + vi.mocked(confirmProjectTrust).mockReset().mockResolvedValue(); + projectTrustStatus.mockReset().mockResolvedValue(ok({ trusted: true })); + workspaceAttach.mockReset(); + workspaceUpdate.mockReset(); + projectCurrent.mockReset(); for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch, wslResolve]) f.mockReset(); windowTakeLaunch.mockResolvedValue(null); windowCompleteLaunch.mockReset().mockResolvedValue(ok(null)); @@ -106,6 +118,7 @@ beforeEach(() => { vi.mocked(connectRemote).mockClear(); for (const f of [lightEditOpen, fsReadDir, openProject]) f.mockClear(); cancelClose.mockClear(); + vi.mocked(confirmLeaveProject).mockClear(); notify.mockClear(); listen.mockReset(); openEditorFile.mockClear(); @@ -591,17 +604,54 @@ describe("launch", () => { }); describe("Attach", () => { - it("switches a plain project to the automatic workspace in place", async () => { + it("attaches in place without reopening or asking to leave", async () => { useProject.setState({ ...projectState("/p/a"), settings: { general: { openProjectIn: "attach" } }, } as never); - workspaceAttach.mockResolvedValue(ok("/data/workspaces/a")); - await openProjectWithPolicy("/p/b"); + const project = { + ...projectState("/p/a").project, + workspace: { version: 1, name: "a", projects: ["/p/a", "/p/b"] }, + }; + projectCurrent.mockResolvedValue(project); + workspaceAttach.mockResolvedValue(ok("/p/a")); + await openProjectWithPolicy("/p/b", ["/p/b/file.txt"]); + expect(confirmProjectTrust).toHaveBeenCalledWith("/p/b", "local"); expect(workspaceAttach).toHaveBeenCalledWith("/p/b"); - expect(openProject).toHaveBeenCalledWith("/data/workspaces/a"); + expect(openProject).not.toHaveBeenCalled(); + expect(cancelClose).not.toHaveBeenCalled(); + expect(confirmLeaveProject).not.toHaveBeenCalled(); + expect(useProject.getState().project).toEqual(project); + expect(opened).toEqual(["/p/b/file.txt (focus)"]); + expect(ask).toHaveBeenCalledTimes(1); expect(windowOpenProject).not.toHaveBeenCalled(); }); + it("does not attach when trust is cancelled", async () => { + useProject.setState(projectState("/p/a")); + vi.mocked(confirmProjectTrust).mockRejectedValueOnce(new IpcError({ kind: "cancelled", message: "Cancelled" })); + await openProjectWithPolicy("/p/b"); + expect(workspaceAttach).not.toHaveBeenCalled(); + expect(openProject).not.toHaveBeenCalled(); + expect(notify).not.toHaveBeenCalled(); + }); + it("does not attach a folder previewed in restricted mode", async () => { + useProject.setState(projectState("/p/a")); + projectTrustStatus.mockResolvedValueOnce(ok({ trusted: false })); + await openProjectWithPolicy("/p/b"); + expect(workspaceAttach).not.toHaveBeenCalled(); + }); + it("explains how to attach a folder already decided as restricted", async () => { + useProject.setState(projectState("/p/a")); + projectTrustStatus.mockResolvedValueOnce(ok({ root: "/p/b", trusted: false, decided: true })); + await openProjectWithPolicy("/p/b"); + expect(workspaceAttach).not.toHaveBeenCalled(); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ + level: "warning", + body: "Cannot attach /p/b because it is restricted. Open it separately and choose Trust Project, then attach it again.", + }), + ); + }); it("refreshes an existing workspace without reopening its editor session", async () => { const before = { root: "/workspace", @@ -645,6 +695,25 @@ it("removes the first workspace member while retaining the workspace state root" expect(useProject.getState().project?.workspace?.projects).toEqual(["/b"]); }); +it("confirms detaching an attachment and never removes the primary", async () => { + const project = { ...projectState("/a").project, workspace: { version: 1, name: "A", projects: ["/a", "/b"] } }; + useProject.setState({ project }); + const action = windowActions().find((a) => a.id === "project.detach")!; + expect(action.title).toBe("Detach Project"); + expect(action.when).toBe("projectAttached"); + await action.run({ arg: "/a" } as never); + expect(ask).not.toHaveBeenCalled(); + expect(workspaceUpdate).not.toHaveBeenCalled(); + vi.mocked(ask).mockResolvedValueOnce({ button: "cancel", checked: false }); + await action.run({ arg: "/b" } as never); + expect(workspaceUpdate).not.toHaveBeenCalled(); + vi.mocked(ask).mockResolvedValueOnce({ button: "detach", checked: false }); + workspaceUpdate.mockResolvedValueOnce(ok(projectState("/a").project)); + await action.run({ arg: "/b" } as never); + expect(workspaceUpdate).toHaveBeenCalledWith("A", ["/a"]); + expect(useProject.getState().project?.workspace).toBeUndefined(); +}); + describe("explicit project-window requests", () => { it("honours current-window requests even when a project is already open", async () => { useProject.setState(projectState("/p/old") as never); diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 5155ce9b6..92c1d6599 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -201,7 +201,7 @@ async function onCloseRequested(deps: CloseDeps): Promise { confirmTerminate, processes: confirmTerminate ? await processNames(deps) : [], dirty: dirtyCount(), - project: s.project?.name ?? null, + project: s.project?.displayName || s.project?.name || null, }, deps, ); @@ -223,7 +223,7 @@ async function onCloseRequested(deps: CloseDeps): Promise { */ export async function confirmLeave( deps: CloseDeps = defaultDeps, - project = useProject.getState().project?.name ?? null, + project = (useProject.getState().project?.displayName || useProject.getState().project?.name) ?? null, ): Promise { if (!(await closeDocumentWindows())) return false; const confirmTerminate = workflowSettings().run.confirmTerminate; diff --git a/src/features/app/menuBridge.ts b/src/features/app/menuBridge.ts index 4f9197af7..da7fc668f 100644 --- a/src/features/app/menuBridge.ts +++ b/src/features/app/menuBridge.ts @@ -224,9 +224,9 @@ export function installMenuBridge(): () => void { useMenuSlotItems.subscribe(push), useContextKeys.subscribe(() => pushEnablement()), useActions.subscribe(() => pushEnablement()), - // Opening / closing a project changes the Open Recent list. + // Project membership and names also change the Open Recent list. useProject.subscribe((s, prev) => { - if (s.project?.root !== prev.project?.root) void commands.menuRefresh(); + if (s.project !== prev.project) void commands.menuRefresh(); if (s.lightEdit !== prev.lightEdit) push(); // View › Appearance check marks and Status Bar Widgets. if (s.settings?.appearance !== prev.settings?.appearance) push(); diff --git a/src/features/app/projectActions.ts b/src/features/app/projectActions.ts index 29d1be9b0..7392c7f03 100644 --- a/src/features/app/projectActions.ts +++ b/src/features/app/projectActions.ts @@ -129,7 +129,7 @@ async function renameProject() { if (name === null) return; try { const info = await call(commands.projectRename(name)); - useProject.setState((s) => ({ project: s.project && { ...s.project, name: info.name }, settings: info.settings })); + useProject.setState((s) => ({ project: s.project && info, settings: info.settings })); void commands.menuRefresh(); } catch (e) { notifications().notify({ level: "error", title: "Cannot rename project", body: errorText(e) }); diff --git a/src/features/app/title.ts b/src/features/app/title.ts index 2f586cc5e..895801f8c 100644 --- a/src/features/app/title.ts +++ b/src/features/app/title.ts @@ -35,7 +35,12 @@ export function installWindowTitle(): () => void { const { project, settings } = useProject.getState(); const fullPath = settings?.appearance?.fullPathInTitle === true; const host = useRemote.getState().session?.host ?? null; - const title = windowTitle(project?.name, useEditorRegistry.getState().active?.ctx.path, fullPath, host); + const title = windowTitle( + project?.displayName || project?.name, + useEditorRegistry.getState().active?.ctx.path, + fullPath, + host, + ); if (title === last) return; last = title; if (typeof document !== "undefined") document.title = title; diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index c8aa56dfc..08d1fec0d 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -8,7 +8,7 @@ * editor tabs only. One LightEdit window collects them all. */ import { executeAction, type Action } from "@/platform/actions"; -import { t } from "@/platform/i18n"; +import { t, tx } from "@/platform/i18n"; import { call, commands, isCancelled, isTauri } from "@/platform/ipc"; import { basename, @@ -19,6 +19,8 @@ import { provideStartupLaunch, useProject, } from "@/platform/project"; +import { confirmProjectTrust } from "@/platform/projectTrust"; +import { ask } from "@/workbench/messages"; import { connectRemote, isRemoteWindow } from "@/platform/remote"; import { holdSplash } from "@/platform/splash"; import { editorService, maybeEditorService, notifications } from "@/workbench/contributions"; @@ -160,22 +162,7 @@ export async function openProjectWithPolicy(path: string, files: readonly string if (!mode) return; if (mode === "new") return openProjectInWindow(path, true, files); if (mode === "attach") { - try { - if (!current?.workspace && !(await confirmLeaveProject())) return; - const root = await call(commands.workspaceAttach(path)); - if (root === current?.root) { - if (!current.workspace) maybeEditorService()?.cancelClose?.(); - const project = await commands.projectCurrent(); - if (project) useProject.setState({ project, settings: project.settings }); - } else { - await openProject(root); - } - await executeAction("project.reveal", path); - await openInEditor(files); - } catch (e) { - maybeEditorService()?.cancelClose?.(); - openFailed(path, e); - } + await attachProject(path, files); return; } try { @@ -187,6 +174,27 @@ export async function openProjectWithPolicy(path: string, files: readonly string } } +async function attachProject(path: string, files: readonly string[] = []): Promise { + try { + await confirmProjectTrust(path, "local"); + if (!(await call(commands.projectTrustStatus(path))).trusted) { + notifications().notify({ + level: "warning", + title: tx("Project not attached"), + body: tx`Cannot attach ${path} because it is restricted. Open it separately and choose Trust Project, then attach it again.`, + }); + return; + } + await call(commands.workspaceAttach(path)); + const project = await commands.projectCurrent(); + if (project) useProject.setState({ project, settings: project.settings }); + await executeAction("project.reveal", path); + await openInEditor(files); + } catch (e) { + openFailed(path, e); + } +} + /** LightEdit: opens the current file's folder as a project ("Open project in" applies) and the file there. */ export async function openLightEditFileAsProject(): Promise { if (!useProject.getState().lightEdit) return; @@ -291,24 +299,58 @@ function closeProjectTabs(side: "others" | "left" | "right"): Action["run"] { }; } +const removeProject: Action["run"] = async ({ arg }) => { + const current = useProject.getState().project; + const workspace = current?.workspace; + if (typeof arg !== "string" || !workspace || !workspace.projects.includes(arg)) return; + if (workspace.projects[0] === current.root) { + if (arg === current.root) return; + const result = await ask({ + title: t("app.window.detachTitle"), + body: t("app.window.detachBody", { name: basename(arg) }), + buttons: [ + { id: "cancel", label: t("common.cancel") }, + { id: "detach", label: t("app.window.detach"), primary: true }, + ], + }); + if (result.button !== "detach") return; + } + const project = await call( + commands.workspaceUpdate( + workspace.name, + workspace.projects.filter((p) => p !== arg), + ), + ); + useProject.setState({ project, settings: project.settings }); +}; + export function windowActions(): Action[] { return [ + { + id: "project.attach", + title: t("app.action.attachProject"), + category: t("app.category.file"), + icon: "folder-open", + when: "projectLocal && !projectOpenAsk", + run: async () => { + if (desktopOnly()) return; + const path = await pickFolder(t("app.action.attachProject")); + if (path) await attachProject(path); + }, + }, { id: "workspace.removeProject", title: "Remove from Workspace", category: t("app.category.file"), - when: "workspaceOpen", - run: async ({ arg }) => { - const workspace = useProject.getState().project?.workspace; - if (typeof arg !== "string" || !workspace || !workspace.projects.includes(arg)) return; - const project = await call( - commands.workspaceUpdate( - workspace.name, - workspace.projects.filter((p) => p !== arg), - ), - ); - useProject.setState({ project, settings: project.settings }); - }, + when: "workspaceOpen && !projectAttached", + run: removeProject, + }, + { + id: "project.detach", + title: "Detach Project", + category: t("app.category.file"), + when: "projectAttached", + run: removeProject, }, { id: "workspace.create", @@ -334,7 +376,7 @@ export function windowActions(): Action[] { id: "workspace.manage", title: "Manage Workspace…", category: t("app.category.file"), - when: "workspaceOpen", + when: "workspaceContainer", run: () => showWorkspaceDialog("manage"), }, { diff --git a/src/features/project-tree/contextMenu.test.ts b/src/features/project-tree/contextMenu.test.ts index d3f18a6d5..1f97499ea 100644 --- a/src/features/project-tree/contextMenu.test.ts +++ b/src/features/project-tree/contextMenu.test.ts @@ -247,3 +247,23 @@ it("offers workspace removal for every member root", () => { expect.objectContaining({ action: "workspace.removeProject" }), ); }); + +it("does not offer removal on the attached primary", () => { + useProject.setState({ + project: { + root: "/a", + name: "A", + displayName: "A, b", + trusted: true, + isGit: false, + settings: {}, + workspace: { version: 1, name: "A", projects: ["/a", "/b"] }, + }, + }); + expect(contextMenuItems([node("/a", true, true)])).not.toContainEqual( + expect.objectContaining({ action: "project.detach" }), + ); + expect(contextMenuItems([node("/b", true, true)])).toContainEqual( + expect.objectContaining({ action: "project.detach" }), + ); +}); diff --git a/src/features/project-tree/contextMenu.ts b/src/features/project-tree/contextMenu.ts index dc6f93cff..7f9d81ed0 100644 --- a/src/features/project-tree/contextMenu.ts +++ b/src/features/project-tree/contextMenu.ts @@ -153,8 +153,20 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] { const items: MenuItem[] = []; const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i)); - if (single && project?.workspace && project.workspace.projects.includes(single.path)) { - push({ kind: "action", action: "workspace.removeProject", arg: single.path }, { kind: "separator" }); + if ( + single && + project?.workspace && + single.path !== project.root && + project.workspace.projects.includes(single.path) + ) { + push( + { + kind: "action", + action: project.workspace.projects[0] === project.root ? "project.detach" : "workspace.removeProject", + arg: single.path, + }, + { kind: "separator" }, + ); } push({ id: "new", label: "New", submenu: newMenuItems(folder) }, { kind: "separator" }); diff --git a/src/features/project-tree/index.ts b/src/features/project-tree/index.ts index 87284c5cd..269988cd4 100644 --- a/src/features/project-tree/index.ts +++ b/src/features/project-tree/index.ts @@ -112,7 +112,7 @@ export function activate(): () => void { let shownRoots = ""; const syncContentRoots = () => { const { project, settings } = useProject.getState(); - const own = project && !project.workspace; + const own = project && (!project.workspace || project.workspace.projects[0] === project.root); const paths = own ? validateProjectStructure(settings?.projectStructure).contentRoots : []; // The Build tool window's model (one load per build change), not a load of our own. const build = useBuildModel.getState(); diff --git a/src/features/project-tree/store.test.ts b/src/features/project-tree/store.test.ts index 78a42dec1..ed72b8f5f 100644 --- a/src/features/project-tree/store.test.ts +++ b/src/features/project-tree/store.test.ts @@ -233,3 +233,10 @@ describe("single-click expansion", () => { setViewOption("singleClickExpand", false); }); }); + +it("keeps the primary first and sorts attachments by name", () => { + initProjectTree("/z", "Z", ["/z", "/c", "/a"]); + expect(currentModel().roots.map((n) => n.path)).toEqual(["/z", "/a", "/c"]); + initProjectTree("/ws", "Workspace", ["/z", "/c", "/a"]); + expect(currentModel().roots.map((n) => n.path)).toEqual(["/a", "/c", "/z"]); +}); diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index 8619341c1..5f71a2850 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -221,7 +221,7 @@ export function selectModel(s: ProjectTreeState): TreeModel { const contributed = projectViewRoots(extras); const memberRoots = s.members ?.map((path) => ({ path, label: nameOf(path), icon: "", detail: path })) - .sort((a, b) => compareNames(a.label, b.label)); + .sort((a, b) => (a.path === s.root ? -1 : b.path === s.root ? 1 : compareNames(a.label, b.label))); const project = () => memberRoots ? withExtraRoots(EMPTY_MODEL, memberRoots, s.listings, options, placeholder) diff --git a/src/features/project-trust/index.tsx b/src/features/project-trust/index.tsx index d7d1f8773..4840aaab3 100644 --- a/src/features/project-trust/index.tsx +++ b/src/features/project-trust/index.tsx @@ -83,13 +83,37 @@ function TrustDialog() { ); } +async function untrustedAttachment() { + const project = useProject.getState().project; + if (!project?.workspace || project.workspace.projects[0] !== project.root) return null; + for (const path of project.workspace.projects) { + const status = await call(commands.projectTrustStatus(path)).catch(() => null); + if (status && !status.trusted) return status; + } + return null; +} + function RestrictedBanner() { - const restricted = useProject((s) => s.project?.trusted === false); + const project = useProject((s) => s.project); + const restricted = project?.trusted === false; + const [blocker, setBlocker] = useState<{ project: typeof project; path: string | null } | null>(null); + const path = blocker?.project === project ? blocker.path : null; + useEffect(() => { + let active = true; + if (restricted) + void untrustedAttachment().then((status) => { + if (active) setBlocker({ project, path: status?.root ?? null }); + }); + return () => { + active = false; + }; + }, [project, restricted]); return restricted ? (
Restricted Mode: project commands and tools are disabled. + {path && } @@ -182,6 +206,32 @@ async function decide(trusted: boolean) { pending.finish?.(true); } +function promptForTrust(status: TrustStatus, scope: ProjectTrustScope): Promise { + usePrompt.getState().finish?.(false); + return new Promise((resolve) => + usePrompt.setState({ + path: status.root, + scope, + folder: false, + finish: (open) => { + usePrompt.setState({ path: null, scope: "current", finish: null, folder: false }); + resolve(open); + }, + }), + ); +} + +async function trustProject() { + if (!usePrompt.getState().path) { + const status = await untrustedAttachment(); + if (status) { + await promptForTrust(status, "current"); + return; + } + } + await decide(true); +} + export function activate(): () => void { initializeRemoteTrustMigration(); const disposers = [ @@ -190,24 +240,13 @@ export function activate(): () => void { ? invokeLocal("project_trust_status", { path }) : call(commands.projectTrustStatus(path))); if (status.decided) return true; - usePrompt.getState().finish?.(false); - return new Promise((resolve) => - usePrompt.setState({ - path: status.root, - scope, - folder: false, - finish: (open) => { - usePrompt.setState({ path: null, scope: "current", finish: null, folder: false }); - resolve(open); - }, - }), - ); + return promptForTrust(status, scope); }), registerOverlay({ id: "project.trustDialog", component: TrustDialog }), registerWorkbenchBanner({ id: "project.restrictedMode", component: RestrictedBanner }), registerSettingsPage({ id: "projectTrust", title: "Project Trust", group: "Security", component: TrustSettings }), registerActions([ - { id: "project.trust", title: "Trust Project", category: "Project", run: () => decide(true) }, + { id: "project.trust", title: "Trust Project", category: "Project", run: trustProject }, { id: "project.previewRestricted", title: "Preview in Restricted Mode", diff --git a/src/features/project-trust/trust.test.tsx b/src/features/project-trust/trust.test.tsx index 75756d2f6..187d5a218 100644 --- a/src/features/project-trust/trust.test.tsx +++ b/src/features/project-trust/trust.test.tsx @@ -139,3 +139,40 @@ it.each(["Trust Project", "Preview in Restricted Mode"])( expect(useProject.getState().project).toBe(project); }, ); + +it.each([false, true])("recovers an attached member whose trust is lost (decided=%s)", async (decided) => { + useProject.setState({ + project: { + root: "/primary", + name: "primary", + trusted: false, + isGit: false, + settings: {}, + workspace: { version: 1, name: "primary", projects: ["/primary", "/other"] }, + }, + }); + status.mockImplementation(async (root: string) => ({ + status: "ok", + data: { + root, + trusted: root === "/primary", + decided: root === "/primary" || decided, + }, + })); + const Banner = useContributions.getState().workbenchBanners.get("project.restrictedMode")!.component; + render( + <> + + + , + ); + const button = await screen.findByRole("button", { name: "Trust Project" }); + await screen.findByText("/other"); + fireEvent.click(button); + const dialog = await screen.findByRole("dialog", { name: "Trust and Open Project?" }); + expect(dialog).toHaveTextContent("/other"); + expect(decide).not.toHaveBeenCalled(); + fireEvent.click(screen.getAllByRole("button", { name: "Trust Project" }).at(-1)!); + await act(async () => {}); + expect(decide).toHaveBeenCalledWith("/other", true, false); +}); diff --git a/src/features/run/index.ts b/src/features/run/index.ts index 061b00271..123424845 100644 --- a/src/features/run/index.ts +++ b/src/features/run/index.ts @@ -431,8 +431,8 @@ export function activate(): () => void { // Only while the same project stays open: closing it tears its runs down separately. useProject.subscribe((s, prev) => { if (!s.project || s.project.root !== prev.project?.root) return; - const before = prev.project.workspace?.projects ?? []; - const after = s.project.workspace?.projects ?? []; + const before = prev.project.workspace?.projects ?? [prev.project.root]; + const after = s.project.workspace?.projects ?? [s.project.root]; for (const root of before) if (!after.includes(root)) void cleanupRemovedProject(root); }), registerEditorContribution({ id: "run.targets", order: 55, extensions: runGutter }), @@ -714,6 +714,7 @@ export function activate(): () => void { }, ]), onIdeEvent("runExited", (e) => onRunExited(e.run, e.code)), + onIdeEvent("projectRootsChanged", scheduleReload), // Lets other title items follow the selected configuration (e.g. Build shows only for compiled ones). useRun.subscribe((s) => { const selected = s.configs.find((c) => c.id === s.selectedId); diff --git a/src/features/settings/__tests__/behavior.test.ts b/src/features/settings/__tests__/behavior.test.ts index a3c854cce..9185c194e 100644 --- a/src/features/settings/__tests__/behavior.test.ts +++ b/src/features/settings/__tests__/behavior.test.ts @@ -3,7 +3,7 @@ import { formatPattern, formatTimestamp } from "@/platform/dateFormat"; import { resolveThemeId } from "@/workbench/theme"; import { applyAppearanceOptions, nextZoom, SMALL_TREE_INDENT } from "../appearanceRuntime"; import { validateSettings } from "../model/schema"; -import { validateAppearanceExtras, validateNotifications } from "../model/behaviorSchema"; +import { validateGeneralExtras, validateAppearanceExtras, validateNotifications } from "../model/behaviorSchema"; describe("appearance & behavior settings", () => { it("defaults automatic update checks to on and respects an explicit opt-out", () => { @@ -127,3 +127,7 @@ describe("appearance & behavior settings", () => { }); }); }); + +it("loads an old attach setting as ask", () => { + expect(validateGeneralExtras({ openProjectIn: "attach" }).openProjectIn).toBe("ask"); +}); diff --git a/src/features/settings/__tests__/dialog.test.tsx b/src/features/settings/__tests__/dialog.test.tsx index 7610a1427..e8723a683 100644 --- a/src/features/settings/__tests__/dialog.test.tsx +++ b/src/features/settings/__tests__/dialog.test.tsx @@ -71,6 +71,15 @@ async function openDialog(page?: string) { } describe("Settings dialog", () => { + it("offers only new window, current window and ask, including old attach settings", async () => { + current = setAt(current, "general.openProjectIn", "attach"); + const dialog = await openDialog("general"); + const group = within(dialog).getByRole("radiogroup", { name: "Open project in" }); + expect(within(group).getAllByRole("radio")).toHaveLength(3); + expect(within(group).getByRole("radio", { name: "Ask" })).toBeChecked(); + expect(within(group).queryByRole("radio", { name: "Attach" })).not.toBeInTheDocument(); + }); + it("stages properties conversion through its action and applies the checkbox", async () => { const dialog = await openDialog("editor.fileEncodings"); const checkbox = within(dialog).getByRole("checkbox", { name: "Transparent native-to-ascii conversion" }); @@ -769,3 +778,29 @@ describe("native Settings window background", () => { } }); }); + +it("uses the attached primary name for run-target paths", async () => { + useProject.setState({ project: { name: "primary", displayName: "primary, other", root: "/primary" } as never }); + await openDialog("build.runTargets"); + fireEvent.click( + within(screen.getByRole("toolbar", { name: "Run targets actions" })).getByRole("button", { name: "Add" }), + ); + expect(screen.getByPlaceholderText("~/primary")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Type" })); + fireEvent.click(screen.getByRole("option", { name: "Docker" })); + expect(screen.getByPlaceholderText("/workspaces/primary")).toBeInTheDocument(); +}); + +it("uses the primary name for module labels and artifact defaults", async () => { + useProject.setState({ project: { name: "primary", displayName: "primary, other", root: "/primary" } as never }); + render(); + await act(async () => executeAction("file.projectStructure", "modules")); + expect(screen.getByRole("button", { name: /primary\s*custom/ })).toBeInTheDocument(); + expect(screen.getByRole("tree", { name: "Content root" })).toHaveTextContent("primary"); + fireEvent.click(screen.getByRole("button", { name: "Artifacts" })); + // Packaging is pending, but its default generator must still use the primary name. + screen.getByRole("button", { name: "JAR" }).closest("fieldset")!.removeAttribute("disabled"); + fireEvent.click(screen.getByRole("button", { name: "JAR" })); + expect(screen.getByLabelText("Name")).toHaveValue("primary:jar"); + expect(screen.getByLabelText("Output directory")).toHaveValue("out/artifacts/primary_jar"); +}); diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index 1e0cd1793..43d4a59e9 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -10,7 +10,7 @@ import { ZOOM_LIMITS } from "@/platform/zoom"; import { validateIconThemeOptionsMap } from "@/platform/iconThemeOptions"; export type Antialiasing = "subpixel" | "greyscale" | "none"; -export type OpenProjectIn = "ask" | "new" | "same" | "attach"; +export type OpenProjectIn = "ask" | "new" | "same"; export type NotificationDisplay = "balloon" | "sticky" | "none"; /** Themes selectable as "preferred" when syncing with the OS. */ export type ConcreteTheme = ThemeChoiceId; @@ -180,7 +180,7 @@ export function validateGeneralExtras(raw: unknown): GeneralExtras { const d = DEFAULT_GENERAL_EXTRAS; const df = obj(g.dateFormat); return { - openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same", "attach"] as const, d.openProjectIn), + openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same"] as const, d.openProjectIn), updateSource: oneOf(g.updateSource, ["auto", "github", "cnb"] as const, d.updateSource), autoCheckUpdates: bool(g.autoCheckUpdates, d.autoCheckUpdates), defaultProjectDirectory: typeof g.defaultProjectDirectory === "string" ? g.defaultProjectDirectory : "", diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index 389143545..23a4ff5be 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -390,7 +390,6 @@ function ToolWindowsGrid() { const OPEN_IN: Array<{ value: OpenProjectIn; label: string }> = [ { value: "new", label: "New window" }, - { value: "attach", label: "Attach" }, { value: "same", label: "Current window" }, { value: "ask", label: "Ask" }, ]; @@ -692,7 +691,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [ { kind: "custom", label: "Open project in", - keywords: ["New window", "Current window", "Attach", "Ask"], + keywords: ["New window", "Current window", "Ask"], component: OpenProjectInRow, }, { kind: "custom", label: "Default directory", component: DefaultDirectoryRow }, diff --git a/src/features/welcome/index.ts b/src/features/welcome/index.ts index 9107788fa..c51386770 100644 --- a/src/features/welcome/index.ts +++ b/src/features/welcome/index.ts @@ -1,5 +1,7 @@ /** Welcome screen, recent projects (File › Open Recent) and reopening the last project on startup. */ import { registerActions } from "@/platform/actions"; +import { onIdeEvent } from "@/platform/ipc"; +import { loadRecent } from "./recent"; import { lazyComponent } from "@/ui"; import { provideProjectPopup, provideWelcomeView, registerOverlay } from "@/workbench/contributions"; import { RecentProjectsPopup, showRecentProjectsPopup } from "./RecentProjectsPopup"; @@ -9,6 +11,7 @@ import { WelcomeView } from "./WelcomeView"; export function activate(): () => void { const disposers = [ trackSession(), + onIdeEvent("windowGroupsChanged", () => void loadRecent()), provideWelcomeView(WelcomeView), provideProjectPopup(lazyComponent(() => import("./ProjectPopup").then((m) => m.ProjectPopup))), registerOverlay({ id: "welcome.recentProjects", component: RecentProjectsPopup }), diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index eeb0b3001..8677ee366 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -90,6 +90,10 @@ "app.window.askTitle": "Open Project", "app.window.askBody": "Projects can be opened in a new window, replace the current project, or be attached to the already opened projects.\nHow would you like to open \"{name}\"?", "app.window.askThis": "This Window", + "app.action.attachProject": "Attach Project…", + "app.window.detachTitle": "Detach Project", + "app.window.detachBody": "Would you like to detach the project {name}?", + "app.window.detach": "Detach", "app.window.askAttach": "Attach", "app.window.askNew": "New Window", "app.window.askRemember": "Don't ask again", @@ -181,6 +185,7 @@ "menu.file.new": "New…", "menu.file.newScratchFile": "New Scratch File…", "menu.file.openInNewWindow": "Open in New Window…", + "menu.project.attach": "Attach Project…", "menu.file.openProject": "Open…", "menu.file.save": "Save All", "menu.file.projectStructure": "Project Structure…", diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index 85d721f45..1928d0c15 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -27,6 +27,9 @@ "The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.": "The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.", "Input queue is full (4 MiB or 1024 writes); input was not sent.": "Input queue is full (4 MiB or 1024 writes); input was not sent.", "Cannot write to terminal": "Cannot write to terminal", + "Detach Project": "Detach Project", + "Project not attached": "Project not attached", + "Cannot attach {0} because it is restricted. Open it separately and choose Trust Project, then attach it again.": "Cannot attach {0} because it is restricted. Open it separately and choose Trust Project, then attach it again.", "Docker": "Docker", "Shell": "Shell", "C/C++": "C/C++", diff --git a/src/platform/locales/text/es.json b/src/platform/locales/text/es.json index 8c9e096f7..d4262de67 100644 --- a/src/platform/locales/text/es.json +++ b/src/platform/locales/text/es.json @@ -85,7 +85,6 @@ "Remove Trusted Location": "Quitar ubicación de confianza", "Cannot determine the module for main class '{value}'": "No se puede determinar el módulo para la clase principal '{value}'", "Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "El módulo de Java '{value}' no está importado. Espera a que termine la importación del proyecto Java. Si sigue fallando, verifica el módulo de la configuración de ejecución y si está incluido en la compilación principal de Maven o Gradle, o abre su carpeta como un proyecto.", - "Attach": "Adjuntar", "Remove from Workspace": "Quitar del espacio de trabajo", "Downloading Java {0} language server runtime": "Descargando el entorno de ejecución del servidor de lenguaje Java {0}", "auto-managed; uses an installed JDK or downloads a runtime when needed": "administrado automáticamente; usa un JDK instalado o descarga un entorno de ejecución cuando es necesario", diff --git a/src/platform/locales/text/fr.json b/src/platform/locales/text/fr.json index 4df792978..61d745ed3 100644 --- a/src/platform/locales/text/fr.json +++ b/src/platform/locales/text/fr.json @@ -34,7 +34,6 @@ "Remove Trusted Location": "Retirer l'emplacement de confiance", "Cannot determine the module for main class '{value}'": "Impossible de déterminer le module de la classe principale '{value}'", "Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "Le module Java '{value}' n'est pas importé. Attendez la fin de l'import du projet Java. Si le problème persiste, vérifiez le module de la configuration d'exécution et s'il est inclus dans le build Maven ou Gradle parent, ou ouvrez son dossier comme projet.", - "Attach": "Attacher", "Remove from Workspace": "Retirer de l'espace de travail", "Downloading Java {0} language server runtime": "Téléchargement de l'environnement Java {0} du serveur de langage", "auto-managed; uses an installed JDK or downloads a runtime when needed": "géré automatiquement ; utilise un JDK installé ou télécharge un environnement si nécessaire", diff --git a/src/platform/locales/text/ko.json b/src/platform/locales/text/ko.json index 9e1deb3cd..f2e924534 100644 --- a/src/platform/locales/text/ko.json +++ b/src/platform/locales/text/ko.json @@ -121,7 +121,6 @@ "Remove Trusted Location": "신뢰할 수 있는 위치 제거", "Cannot determine the module for main class '{value}'": "메인 클래스 '{value}'의 모듈을 확인할 수 없습니다", "Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "Java 모듈 '{value}'을(를) 가져오지 않았습니다. Java 프로젝트 가져오기가 완료될 때까지 기다리세요. 그래도 실패하면 실행 구성의 모듈과 해당 모듈이 상위 Maven 또는 Gradle 빌드에 포함되어 있는지 확인하거나, 해당 폴더를 프로젝트로 여세요.", - "Attach": "연결", "Remove from Workspace": "작업 공간에서 제거", "Downloading Java {0} language server runtime": "Java {0} 언어 서버 런타임 다운로드 중", "auto-managed; uses an installed JDK or downloads a runtime when needed": "자동 관리, 설치된 JDK를 사용하거나 필요할 때 런타임을 다운로드합니다", diff --git a/src/platform/locales/text/pl.json b/src/platform/locales/text/pl.json index 40b1efb83..f4c145ef8 100644 --- a/src/platform/locales/text/pl.json +++ b/src/platform/locales/text/pl.json @@ -77,7 +77,6 @@ "Remove Trusted Location": "Usuń zaufaną lokalizację", "Cannot determine the module for main class '{value}'": "Nie można określić modułu dla klasy głównej '{value}'", "Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "Moduł Java '{value}' nie został zaimportowany. Poczekaj na zakończenie importu projektu Java. Jeśli problem nadal występuje, sprawdź moduł w konfiguracji uruchamiania i upewnij się, że jest dołączony do nadrzędnego kompilatu Maven lub Gradle, lub otwórz jego folder jako projekt.", - "Attach": "Dołącz", "Remove from Workspace": "Usuń z obszaru roboczego", "Downloading Java {0} language server runtime": "Pobieranie środowiska uruchomieniowego serwera językowego Java {0}", "auto-managed; uses an installed JDK or downloads a runtime when needed": "zarządzane automatycznie; używa zainstalowanego JDK lub pobiera środowisko uruchomieniowe w razie potrzeby", diff --git a/src/platform/locales/text/pt.json b/src/platform/locales/text/pt.json index 958c677ed..7603b005c 100644 --- a/src/platform/locales/text/pt.json +++ b/src/platform/locales/text/pt.json @@ -77,7 +77,6 @@ "Remove Trusted Location": "Remover local confiável", "Cannot determine the module for main class '{value}'": "Não é possível determinar o módulo para a classe principal '{value}'", "Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "O módulo Java '{value}' não foi importado. Aguarde a conclusão da importação do projeto Java. Se a falha persistir, verifique o módulo da configuração de execução e se ele está incluído no build pai do Maven ou Gradle, ou abra sua pasta como um projeto.", - "Attach": "Anexar", "Remove from Workspace": "Remover do espaço de trabalho", "Downloading Java {0} language server runtime": "Baixando o runtime do servidor de linguagem Java {0}", "auto-managed; uses an installed JDK or downloads a runtime when needed": "gerenciado automaticamente; usa um JDK instalado ou baixa um runtime quando necessário", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index 38d34ef3e..db2851250 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -18,6 +18,9 @@ "The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.": "语言服务器的 Java 可执行文件({path})无法在此机器上运行。请在 {setting} 中选择可正常运行的 JDK,或设为自动。", "Input queue is full (4 MiB or 1024 writes); input was not sent.": "输入队列已满(4 MiB 或 1024 次写入);本次输入未发送。", "Cannot write to terminal": "无法写入终端", + "Detach Project": "分离项目", + "Project not attached": "未附加项目", + "Cannot attach {0} because it is restricted. Open it separately and choose Trust Project, then attach it again.": "无法附加 {0},因为它处于受限模式。请单独打开它并选择“信任项目”,然后再次附加。", "Docker": "Docker", "Shell": "Shell", "C/C++": "C/C++", @@ -213,7 +216,6 @@ "Remove Trusted Location": "移除受信任的位置", "Cannot determine the module for main class '{value}'": "无法确定主类 '{value}' 所属的模块", "Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "Java 模块 '{value}' 尚未导入。请等待 Java 项目导入完成。如果仍然失败,请检查运行配置选择的模块,以及该模块是否已包含在父级 Maven 或 Gradle 构建中,或将其文件夹作为项目打开。", - "Attach": "附加", "Remove from Workspace": "从工作区移除", "Downloading Java {0} language server runtime": "正在下载 Java {0} 语言服务器运行时", "auto-managed; uses an installed JDK or downloads a runtime when needed": "自动管理;使用已安装的 JDK,或在需要时自动下载运行时", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index ff39e3157..84a8fc974 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -90,6 +90,10 @@ "app.window.askTitle": "打开项目", "app.window.askBody": "项目可以在新窗口中打开、替换当前项目,或附加到已打开的项目。\n要如何打开“{name}”?", "app.window.askThis": "当前窗口", + "app.action.attachProject": "附加项目…", + "app.window.detachTitle": "分离项目", + "app.window.detachBody": "是否分离项目“{name}”?", + "app.window.detach": "分离", "app.window.askAttach": "附加", "app.window.askNew": "新窗口", "app.window.askRemember": "不再询问", @@ -181,6 +185,7 @@ "menu.file.new": "新建…", "menu.file.newScratchFile": "新建临时文件…", "menu.file.openInNewWindow": "在新窗口中打开…", + "menu.project.attach": "附加项目…", "menu.file.openProject": "打开…", "menu.file.save": "全部保存", "menu.file.projectStructure": "项目结构…", diff --git a/src/platform/project.test.ts b/src/platform/project.test.ts index c226cef26..f1e2a83dd 100644 --- a/src/platform/project.test.ts +++ b/src/platform/project.test.ts @@ -15,11 +15,12 @@ type BackendProject = NonNullable { vi.clearAllMocks(); diff --git a/src/platform/remote.ts b/src/platform/remote.ts index 1fbaca481..1b02d1f8f 100644 --- a/src/platform/remote.ts +++ b/src/platform/remote.ts @@ -196,7 +196,7 @@ export async function connectRemote(target: RemoteTarget, path: string) { rememberRemote({ target: r.target, path: r.root, - name: r.project.name, + name: r.project.displayName || r.project.name, host: r.host, openedAtMs: Date.now(), }); diff --git a/src/workbench/TitleBar.tsx b/src/workbench/TitleBar.tsx index 459640323..7917de128 100644 --- a/src/workbench/TitleBar.tsx +++ b/src/workbench/TitleBar.tsx @@ -53,7 +53,7 @@ function TitleItem({ item }: { item: TitleItemContribution }) { } function ProjectButton() { - const name = useProject((s) => s.project?.name); + const name = useProject((s) => s.project?.displayName || s.project?.name); const root = useProject((s) => s.project?.root); const [open, setOpen] = useState(false); const ref = useRef(null); diff --git a/src/workbench/activate.ts b/src/workbench/activate.ts index 5e88dcac4..63072abb2 100644 --- a/src/workbench/activate.ts +++ b/src/workbench/activate.ts @@ -2,7 +2,9 @@ * Shell services and built-in contributions. Call before `activateFeatures()` * so features can rely on the tool window and notification services. */ +import { openPolicy } from "./projectWindow"; import { setContext } from "@/platform/context"; +import { isRemoteWindow, useRemote } from "@/platform/remote"; import { useProject } from "@/platform/project"; import { provideNotificationService, @@ -40,15 +42,24 @@ export function activateWorkbench(): () => void { provideNotificationService(notificationServiceImpl); void loadPlatformInfo(); + const syncProjectContext = () => { + const project = useProject.getState().project; + setContext("projectLocal", !!project && !isRemoteWindow() && !useProject.getState().lightEdit); + setContext("workspaceOpen", !!project?.workspace); + setContext("projectAttached", !!project?.workspace && project.workspace.projects[0] === project.root); + setContext("workspaceContainer", !!project?.workspace && project.workspace.projects[0] !== project.root); + setContext("projectOpenAsk", openPolicy() === "ask"); + }; // Synchronous so the frame's first render after opening already has the project's layout. const onProject = (root: string | null) => { setContext("projectOpen", root !== null); - setContext("workspaceOpen", !!useProject.getState().project?.workspace); + syncProjectContext(); setContext("lightEdit", useProject.getState().lightEdit); if (root !== null) loadToolWindowLayout(root); }; onProject(useProject.getState().project?.root ?? null); const unsubProject = useProject.subscribe((s, prev) => { + syncProjectContext(); if (s.project?.root === prev.project?.root && s.lightEdit === prev.lightEdit) return; // The previous project's notifications (and their actions) no longer apply. if (prev.project) clearProjectNotifications(); @@ -57,6 +68,7 @@ export function activateWorkbench(): () => void { const disposers = [ unsubProject, + useRemote.subscribe(syncProjectContext), registerShellActions(), installToolWindowActions(), installToolWindowLayoutSync(), diff --git a/src/workbench/lightEdit.test.tsx b/src/workbench/lightEdit.test.tsx index 0feb9da69..5dee9185d 100644 --- a/src/workbench/lightEdit.test.tsx +++ b/src/workbench/lightEdit.test.tsx @@ -87,6 +87,26 @@ describe("LightEdit window", () => { dispose(); }); + it("updates workspace and policy context without replacing the project root", () => { + const dispose = activateWorkbench(); + const project = { root: "/a", name: "A", trusted: true, isGit: false, settings: {} }; + useProject.setState({ project, lightEdit: false, settings: { general: { openProjectIn: "new" } } } as never); + expect(evaluateWhen("projectLocal && !projectOpenAsk")).toBe(true); + expect(evaluateWhen("workspaceOpen")).toBe(false); + useProject.setState({ project: { ...project, workspace: { version: 1, name: "A", projects: ["/a", "/b"] } } }); + expect(evaluateWhen("workspaceOpen")).toBe(true); + expect(evaluateWhen("workspaceContainer")).toBe(false); + expect(evaluateWhen("projectAttached")).toBe(true); + useProject.setState({ settings: { general: { openProjectIn: "attach" } } } as never); + expect(evaluateWhen("projectOpenAsk")).toBe(true); + useProject.setState({ project: { ...project, workspace: { version: 1, name: "A", projects: ["/b"] } } }); + expect(evaluateWhen("workspaceContainer")).toBe(true); + expect(evaluateWhen("projectAttached")).toBe(false); + useProject.setState({ project }); + expect(evaluateWhen("workspaceOpen")).toBe(false); + dispose(); + }); + it("applies 'Open project in' when opening a project from it", async () => { useProject.setState({ settings: { general: { openProjectIn: "new" } } } as never); expect(await chooseProjectWindow("notes")).toBe("new"); diff --git a/src/workbench/projectWindow.test.ts b/src/workbench/projectWindow.test.ts index 32053d9e6..6ff35fd1c 100644 --- a/src/workbench/projectWindow.test.ts +++ b/src/workbench/projectWindow.test.ts @@ -23,16 +23,16 @@ beforeEach(() => { lightEdit: false, }); }); -it("offers IDEA's four choices with This Window primary and remembers Attach", async () => { +it("offers Attach for this open without remembering it", async () => { vi.mocked(ask).mockResolvedValue({ button: "attach", checked: true }); expect(await chooseProjectWindow("B")).toBe("attach"); const prompt = vi.mocked(ask).mock.calls[0]![0]; expect(prompt.buttons.map((b) => b.id)).toEqual(["cancel", "attach", "new", "same"]); expect(prompt.buttons.filter((b) => b.primary).map((b) => b.id)).toEqual(["same"]); - expect(commands.settingsSet).toHaveBeenCalledWith("global", "general.openProjectIn", '"attach"'); - expect(openPolicy()).toBe("attach"); + expect(commands.settingsSet).not.toHaveBeenCalled(); + expect(openPolicy()).toBe("ask"); expect(await chooseProjectWindow("C")).toBe("attach"); - expect(ask).toHaveBeenCalledTimes(1); + expect(ask).toHaveBeenCalledTimes(2); }); it("does not attach without a project or when opening a remote project", async () => { useProject.setState({ @@ -44,3 +44,13 @@ it("does not attach without a project or when opening a remote project", async ( useProject.setState({ project: null }); expect(await chooseProjectWindow("B")).toBe("same"); }); + +it("treats a stored attach policy as ask", () => { + useProject.setState({ settings: { general: { openProjectIn: "attach" } } } as never); + expect(openPolicy()).toBe("ask"); +}); +it.each(["new", "same"] as const)("remembers %s", async (mode) => { + vi.mocked(ask).mockResolvedValue({ button: mode, checked: true }); + await chooseProjectWindow("B"); + expect(commands.settingsSet).toHaveBeenCalledWith("global", "general.openProjectIn", JSON.stringify(mode)); +}); diff --git a/src/workbench/projectWindow.ts b/src/workbench/projectWindow.ts index a80c05a36..c6ae8b15b 100644 --- a/src/workbench/projectWindow.ts +++ b/src/workbench/projectWindow.ts @@ -6,10 +6,10 @@ import { connectRemote, isRemoteWindow } from "@/platform/remote"; import { ask } from "./messages"; import { maybeEditorService } from "./contributions"; -/** "Open project in": `new` | `same` | `attach` | `ask`. */ -export function openPolicy(): "ask" | "new" | "same" | "attach" { +/** "Open project in": `new` | `same` | `ask`. */ +export function openPolicy(): "ask" | "new" | "same" { const v = useProject.getState().settings?.general?.openProjectIn; - return v === "new" || v === "same" || v === "attach" ? v : "ask"; + return v === "new" || v === "same" ? v : "ask"; } /** @@ -21,8 +21,7 @@ export async function chooseProjectWindow(name: string, allowAttach = true): Pro const { project, lightEdit } = useProject.getState(); if (!project && !lightEdit) return "same"; const canAttach = allowAttach && !!project && !isRemoteWindow(); - const policy = openPolicy(); - const mode = policy === "attach" && !canAttach ? "ask" : policy; + const mode = openPolicy(); if (mode !== "ask") return mode; const r = await ask({ title: t("app.window.askTitle"), @@ -37,7 +36,7 @@ export async function chooseProjectWindow(name: string, allowAttach = true): Pro checkbox: t("app.window.askRemember"), }); if (r.button !== "same" && r.button !== "new" && r.button !== "attach") return null; - if (r.checked) { + if (r.checked && r.button !== "attach") { try { useProject.setState({ settings: await call(commands.settingsSet("global", "general.openProjectIn", JSON.stringify(r.button))),