дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
fix(project): keep attachments in the current project (#2320)
Attach adds a trusted folder to the current project without reopening it or moving its root. Attachments live in the primary project's private .openidea/workspace.json, and detaching the last one restores a plain project. ProjectInfo.name stays the primary project's name; displayName supplies window titles, project tabs and Recent Projects. Attach is no longer a remembered opening policy, and File > Attach Project is available when the policy is not Ask. Attached folders must be trusted, and Restricted Mode names any attached folder that blocks trust. Explicit workspaces keep their behaviour and still cannot be created in or inside a linked project.
Цей коміт міститься в:
1 джерело
023c3f36c2
коміт
1ae8572021
60 files changed
+1155
-233
No files matched your search
@@ -552,7 +552,8 @@ pub async fn build_run(
|
||||
let mut combined: Option<compile::BuildPlan> = 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())?,
|
||||
|
||||
@@ -12,13 +12,18 @@ pub(super) fn scopes(project: &Project, config: &Path) -> IdeResult<Vec<Scope>>
|
||||
}
|
||||
|
||||
fn scopes_at(root: &Path, cache: &Path, config: &Path, settings: Settings) -> IdeResult<Vec<Scope>> {
|
||||
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"), "<project><artifactId>app</artifactId></project>").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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ide_workspace::ProjectRoots
|
||||
|
||||
pub fn save_workspace(host: &Host, window: &str, name: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<ProjectInfo> {
|
||||
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<ide_core::IpcPath> {
|
||||
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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<PathBuf>) -> 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<PathBuf> {
|
||||
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::<Vec<_>>();
|
||||
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<PathBuf> {
|
||||
let mut out: Vec<PathBuf> = 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)) {
|
||||
|
||||
@@ -56,6 +56,22 @@ impl TrustStore {
|
||||
|| self.inherited.lock().contains(&root)))
|
||||
}
|
||||
|
||||
pub fn window_trusted(&self, root: &Path) -> IdeResult<bool> {
|
||||
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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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::<Vec<_>>(),
|
||||
configs.iter().map(|c| &c.id).collect::<Vec<_>>()
|
||||
);
|
||||
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");
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<S: serde::Serializer>(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<PathBuf> {
|
||||
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<Option<Self>> {
|
||||
@@ -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<PathBuf>, create: bool) -> IdeResult<Self> {
|
||||
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<PathBuf>) -> IdeResult<Self> {
|
||||
Self::write(root, name, projects, true, true)
|
||||
}
|
||||
|
||||
fn write(root: &Path, name: &str, projects: Vec<PathBuf>, create: bool, attach: bool) -> IdeResult<Self> {
|
||||
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<String> {
|
||||
Ok(name.to_owned())
|
||||
}
|
||||
|
||||
fn normalize_projects(root: &Path, projects: Vec<PathBuf>, allow_missing: bool) -> IdeResult<Vec<PathBuf>> {
|
||||
fn normalize_projects(root: &Path, projects: Vec<PathBuf>) -> IdeResult<Vec<PathBuf>> {
|
||||
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]
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -85,7 +85,7 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
let info = project_info(&project);
|
||||
let _ = window.set_title(&info.name);
|
||||
let _ = window.set_title(&info.display_name);
|
||||
window.state::<AppState>().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<ProjectInfo> {
|
||||
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.
|
||||
|
||||
@@ -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::<AppState>();
|
||||
let project = state.project(window.label())?;
|
||||
state.record_project(&project)?;
|
||||
let _ = window.set_title(&info.display_name);
|
||||
window.state::<AppState>().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<ide_core::IpcPath> {
|
||||
let host = window.state::<AppState>().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::<AppState>();
|
||||
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)
|
||||
}
|
||||
@@ -224,6 +224,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
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(
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<Arc<Project>> {
|
||||
self.remote_dev.disconnect(window);
|
||||
|
||||
@@ -37,7 +37,7 @@ pub fn current(app: &AppHandle, caller: &str) -> Vec<WindowTab> {
|
||||
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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(<WorkspaceDialogHost />);
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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<typeof import("@/platform/ipc")>()),
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -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<typeof import("@/platform/project")>();
|
||||
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);
|
||||
|
||||
@@ -201,7 +201,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
|
||||
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<boolean> {
|
||||
*/
|
||||
export async function confirmLeave(
|
||||
deps: CloseDeps = defaultDeps,
|
||||
project = useProject.getState().project?.name ?? null,
|
||||
project = (useProject.getState().project?.displayName || useProject.getState().project?.name) ?? null,
|
||||
): Promise<boolean> {
|
||||
if (!(await closeDocumentWindows())) return false;
|
||||
const confirmTerminate = workflowSettings().run.confirmTerminate;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
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"),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
@@ -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" });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ? (
|
||||
<div className={styles.banner} role="status">
|
||||
<span>
|
||||
<T>Restricted Mode: project commands and tools are disabled.</T>
|
||||
</span>
|
||||
{path && <PathLine path={path} />}
|
||||
<Button onClick={() => void executeAction("project.trust")}>
|
||||
<T>Trust Project</T>
|
||||
</Button>
|
||||
@@ -182,6 +206,32 @@ async function decide(trusted: boolean) {
|
||||
pending.finish?.(true);
|
||||
}
|
||||
|
||||
function promptForTrust(status: TrustStatus, scope: ProjectTrustScope): Promise<boolean> {
|
||||
usePrompt.getState().finish?.(false);
|
||||
return new Promise<boolean>((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<TrustStatus>("project_trust_status", { path })
|
||||
: call(commands.projectTrustStatus(path)));
|
||||
if (status.decided) return true;
|
||||
usePrompt.getState().finish?.(false);
|
||||
return new Promise<boolean>((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",
|
||||
|
||||
@@ -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(
|
||||
<>
|
||||
<Banner />
|
||||
<OverlayHost />
|
||||
</>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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(<ProjectStructureDialog />);
|
||||
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");
|
||||
});
|
||||
@@ -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 : "",
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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…",
|
||||
|
||||
@@ -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++",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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를 사용하거나 필요할 때 런타임을 다운로드합니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,或在需要时自动下载运行时",
|
||||
|
||||
@@ -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": "项目结构…",
|
||||
|
||||
@@ -15,11 +15,12 @@ type BackendProject = NonNullable<Awaited<ReturnType<typeof commands.projectCurr
|
||||
const old: BackendProject = {
|
||||
root: "/old",
|
||||
name: "old",
|
||||
displayName: "old",
|
||||
trusted: true,
|
||||
isGit: false,
|
||||
settings: {} as BackendProject["settings"],
|
||||
};
|
||||
const next: BackendProject = { ...old, root: "/new", name: "new" };
|
||||
const next: BackendProject = { ...old, root: "/new", name: "new", displayName: "new" };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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<HTMLButtonElement>(null);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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))),
|
||||
|
||||
Reference in new issue
Заблокувати користувача