diff --git a/crates/ide-agent/src/acp/e2e.rs b/crates/ide-agent/src/acp/e2e.rs index b3536951..224f00f9 100644 --- a/crates/ide-agent/src/acp/e2e.rs +++ b/crates/ide-agent/src/acp/e2e.rs @@ -147,6 +147,7 @@ async fn a_task_runs_on_a_v2_agent_and_resumes_its_session() { let mgr = manager(tmp.path(), agent.clone()); let task = mgr .create(NewAgentTask { + project_root: None, prompt: text("Use the TodoWrite tool"), provider: "claude-acp".into(), config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }], diff --git a/crates/ide-agent/src/lib.rs b/crates/ide-agent/src/lib.rs index 0b9c3208..f2fb12a5 100644 --- a/crates/ide-agent/src/lib.rs +++ b/crates/ide-agent/src/lib.rs @@ -366,11 +366,12 @@ impl AgentManager { .ok_or_else(|| IdeError::invalid(format!("unknown agent {}", req.provider)))?; let prompt = clean(req.prompt).ok_or_else(|| IdeError::invalid("describe the task first"))?; let title = title_of(&prompt); - let root = self.shared.paths.project_root.clone(); + let root = req.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()); + let project_root = Some(IpcPath(root.clone())); let (cwd, branch, base, base_commit, before) = match req.isolation { Isolation::Worktree => { // The UI offers worktrees for git projects only; state it when it isn't one. - if Repo::discover(&self.shared.paths.project_root)?.is_none() { + if Repo::discover(&root)?.is_none() { return Err(IdeError::invalid( "worktrees need a git repository: run the task in the local checkout", )); @@ -398,6 +399,7 @@ impl AgentManager { } let now = now_ms(); let task = AgentTask { + project_root, id: uuid::Uuid::new_v4().simple().to_string(), title, provider: spec.id.clone(), @@ -795,14 +797,17 @@ impl AgentManager { pub async fn delete(&self, id: &str, remove_worktree: bool) -> IdeResult<()> { let task = self.task(id)?; task.kill(); - let (isolation, cwd) = { + let (isolation, cwd, root) = { let s = task.state.lock(); - (s.task.isolation, s.task.cwd.0.clone()) + ( + s.task.isolation, + s.task.cwd.0.clone(), + s.task.project_root.clone().map(|p| p.0).unwrap_or_else(|| self.shared.paths.project_root.clone()), + ) }; // A copy of the task (a fork) may work in the same worktree. let shared = self.tasks.read().values().any(|t| t.id != id && t.state.lock().task.cwd.0 == cwd); if remove_worktree && !shared && isolation == Isolation::Worktree && cwd.exists() { - let root = self.shared.paths.project_root.clone(); blocking(move || worktree::remove(&root, &cwd)).await?; } self.tasks.write().remove(id); diff --git a/crates/ide-agent/src/model.rs b/crates/ide-agent/src/model.rs index d0984aab..67b2b619 100644 --- a/crates/ide-agent/src/model.rs +++ b/crates/ide-agent/src/model.rs @@ -47,6 +47,9 @@ pub enum Isolation { #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct AgentTask { + /// Original project folder; absent in tasks saved before per-task roots. + #[serde(default)] + pub project_root: Option, pub id: String, pub title: String, /// [`AgentProvider::id`]. @@ -364,6 +367,9 @@ pub struct AgentProbe { #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] pub struct NewAgentTask { + /// Validated by the host; defaults to the window project root. + #[serde(default)] + pub project_root: Option, pub prompt: AgentPrompt, pub provider: String, /// Settings for the new session (model, mode…). diff --git a/crates/ide-agent/src/replay_tests.rs b/crates/ide-agent/src/replay_tests.rs index 7c2881b9..d02f4876 100644 --- a/crates/ide-agent/src/replay_tests.rs +++ b/crates/ide-agent/src/replay_tests.rs @@ -56,6 +56,7 @@ fn config(provider: &str) -> Vec { async fn create(mgr: &AgentManager, provider: &str, prompt: &str) -> String { let req = NewAgentTask { + project_root: None, prompt: text(prompt), provider: provider.into(), config: config(provider), diff --git a/crates/ide-agent/src/tests.rs b/crates/ide-agent/src/tests.rs index af27b307..b4625197 100644 --- a/crates/ide-agent/src/tests.rs +++ b/crates/ide-agent/src/tests.rs @@ -214,6 +214,7 @@ async fn commands_are_remembered_per_project_and_merged_with_skills_on_disk() { assert_eq!(names(mgr_a.commands("claude", None).await.unwrap()), ["triage"]); let task = mgr_a .create(NewAgentTask { + project_root: None, prompt: text("Fix the lexer"), provider: "claude".into(), config: Vec::new(), @@ -253,6 +254,7 @@ async fn builtin_commands_only_fill_verified_gaps_and_preserve_advertised_metada assert_eq!(commands.iter().map(|c| c.name.as_str()).collect::>(), ["compact"]); let task = mgr .create(NewAgentTask { + project_root: None, prompt: text("Hello"), provider: "opencode".into(), config: Vec::new(), @@ -309,6 +311,7 @@ async fn a_task_runs_asks_for_permission_and_survives_a_restart() { let task = mgr .create(NewAgentTask { + project_root: None, prompt: text("Fix the lexer\nmore detail"), provider: "claude".into(), config: vec![AgentConfigChoice { id: "model".into(), value: "small".into() }], @@ -415,6 +418,7 @@ async fn messages_sent_while_busy_are_queued_and_send_now_interrupts() { let mgr = manager(tmp.path(), agent.clone()); let task = mgr .create(NewAgentTask { + project_root: None, prompt: text("wait"), provider: "claude".into(), config: vec![], @@ -469,6 +473,7 @@ async fn subagents_get_their_own_conversation() { let mgr = manager(tmp.path(), agent.clone()); let task = mgr .create(NewAgentTask { + project_root: None, prompt: text("delegate"), provider: "claude".into(), config: vec![], @@ -562,6 +567,7 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() { let mgr = manager(tmp.path(), Arc::new(FakeAgent::default())); let task = mgr .create(NewAgentTask { + project_root: None, prompt: text("do it"), provider: "codex".into(), config: vec![], @@ -583,6 +589,7 @@ async fn cancelling_resolves_the_open_permission_and_delete_forgets_the_task() { assert!(!tmp.path().join("store").join(format!("{}.json", task.id)).exists()); assert!( mgr.create(NewAgentTask { + project_root: None, prompt: text(" "), provider: "codex".into(), config: vec![], @@ -628,6 +635,7 @@ async fn real_claude_task_in_a_worktree() { ); let task = mgr .create(NewAgentTask { + project_root: None, prompt: text("Create a file named hello.txt containing the single word hi. Do nothing else."), provider: "claude".into(), config: vec![AgentConfigChoice { id: "model".into(), value: "haiku".into() }], @@ -676,6 +684,7 @@ async fn forks_rollbacks_and_steering() { let agent = Arc::new(FakeAgent::default()); let mgr = manager(tmp.path(), agent.clone()); let new = |prompt: &str| NewAgentTask { + project_root: None, prompt: text(prompt), provider: "claude".into(), config: Vec::new(), @@ -726,6 +735,7 @@ async fn usage_limits_and_late_settings() { let mgr = manager(tmp.path(), agent.clone()); let task = mgr .create(NewAgentTask { + project_root: None, prompt: text("limit"), provider: "claude".into(), // `effort` isn't offered until the session runs. @@ -768,6 +778,7 @@ async fn cursor_approval_switch_resolves_pending_and_survives_restart() { let agent = Arc::new(FakeAgent::default()); let mgr = manager(tmp.path(), agent.clone()); let new = || NewAgentTask { + project_root: None, prompt: text("Fix"), provider: "cursor".into(), config: Vec::new(), @@ -813,6 +824,7 @@ async fn cursor_approval_choice_applies_before_first_prompt() { let mgr = manager(tmp.path(), Arc::new(FakeAgent::default())); let task = mgr .create(NewAgentTask { + project_root: None, prompt: text("Fix"), provider: "cursor".into(), config: vec![AgentConfigChoice { id: config::CURSOR_APPROVAL.into(), value: "bypass".into() }], @@ -832,6 +844,7 @@ async fn archived_tasks_can_be_restored_and_continue_their_session() { let mgr = manager(tmp.path(), agent.clone()); let task = mgr .create(NewAgentTask { + project_root: None, prompt: text("again"), provider: "claude".into(), config: vec![], @@ -857,3 +870,75 @@ async fn archived_tasks_can_be_restored_and_continue_their_session() { assert_eq!(mgr.entries(&task.id, None).unwrap().len(), entries.len() + 2); mgr.unarchive(&task.id).unwrap(); } + +#[tokio::test(flavor = "multi_thread")] +async fn task_project_root_controls_local_and_worktree_repositories() { + let tmp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(tmp.path()).unwrap(); + let member = root.join("project-b"); + std::fs::create_dir(&member).unwrap(); + let git = |args: &[&str]| { + let out = std::process::Command::new("git") + .args(["-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]) + .args(args) + .current_dir(&member) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + String::from_utf8_lossy(&out.stdout).trim().to_owned() + }; + git(&["init", "-q", "-b", "member-main"]); + std::fs::write(member.join("file.txt"), "original\n").unwrap(); + git(&["add", "."]); + git(&["commit", "-qm", "initial"]); + let mgr = manager(&root, Arc::new(FakeAgent::default())); + for isolation in [Isolation::Local, Isolation::Worktree] { + let task = mgr + .create(NewAgentTask { + project_root: Some(IpcPath(member.clone())), + prompt: text("member task"), + provider: "claude".into(), + config: vec![], + isolation, + base: None, + }) + .await + .unwrap(); + wait_for(&mgr, &task.id, AgentStatus::NeedsInput).await; + let permission = mgr + .entries(&task.id, None) + .unwrap() + .iter() + .find_map(|e| match e { + AgentEntry::Permission { id, .. } => Some(*id), + _ => None, + }) + .unwrap(); + mgr.respond(&task.id, permission, Some("yes".into())).unwrap(); + wait_for(&mgr, &task.id, AgentStatus::Ready).await; + assert_eq!(task.project_root, Some(IpcPath(member.clone()))); + assert_eq!(task.base.as_deref(), Some("member-main")); + if isolation == Isolation::Local { + assert_eq!(task.cwd.0, member); + } else { + assert_ne!(task.cwd.0, member); + assert_eq!(std::fs::read_to_string(task.cwd.0.join("file.txt")).unwrap(), "original\n"); + } + std::fs::write(task.cwd.0.join("file.txt"), "changed\n").unwrap(); + let changes = mgr.changes(&task.id).await.unwrap(); + assert_eq!(changes.len(), 1); + assert_eq!(changes[0].path.0, task.cwd.0.join("file.txt")); + assert_eq!(mgr.base_text(&task.id, changes[0].path.0.clone()).await.unwrap().as_deref(), Some("original\n")); + let saved = std::fs::read(root.join("store").join(format!("{}.json", task.id))).unwrap(); + assert!(String::from_utf8(saved).unwrap().contains("projectRoot")); + let mut old = serde_json::to_value(&task).unwrap(); + old.as_object_mut().unwrap().remove("projectRoot"); + assert!(serde_json::from_value::(old).unwrap().project_root.is_none()); + mgr.delete(&task.id, true).await.unwrap(); + if isolation == Isolation::Worktree { + assert!(!task.cwd.0.exists()); + } + std::fs::write(member.join("file.txt"), "original\n").unwrap(); + } + assert_eq!(git(&["worktree", "list", "--porcelain"]).matches("worktree ").count(), 1); +} diff --git a/crates/ide-core/src/event.rs b/crates/ide-core/src/event.rs index 1261a077..1684b27a 100644 --- a/crates/ide-core/src/event.rs +++ b/crates/ide-core/src/event.rs @@ -13,6 +13,8 @@ use std::sync::Arc; #[derive(Clone, Debug, Serialize, Deserialize, Type)] #[serde(tag = "type", rename_all = "camelCase", rename_all_fields = "camelCase")] pub enum IdeEvent { + /// Project window membership or selection changed. + WindowGroupsChanged, /// Files or directories changed on disk (after debouncing, ignore rules applied). FsChanged { created: Vec, diff --git a/crates/ide-core/src/settings.rs b/crates/ide-core/src/settings.rs index 0a6ff2ec..434f5a30 100644 --- a/crates/ide-core/src/settings.rs +++ b/crates/ide-core/src/settings.rs @@ -178,7 +178,7 @@ pub struct CustomKeymap { pub struct GeneralSettings { pub reopen_last_project: bool, pub confirm_exit: bool, - /// Where Open… puts a project while one is open: `ask` | `new` | `same`. + /// Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`. pub open_project_in: String, /// Initial folder of the Open / Clone dialogs; empty = the OS default. pub default_project_directory: String, diff --git a/crates/ide-host/src/ops/agent.rs b/crates/ide-host/src/ops/agent.rs index 8aef1efa..70838e2c 100644 --- a/crates/ide-host/src/ops/agent.rs +++ b/crates/ide-host/src/ops/agent.rs @@ -87,8 +87,14 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option host.project(window)?.agents.entries(&task, subagent) } -pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult { - host.project(window)?.agents.create(request).await +pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult { + let project = host.project(window)?; + let root = project.root.clone(); + let requested = request.project_root.take(); + request.project_root = Some(IpcPath( + host.blocking(move || validate_project_root(&root, requested.as_ref().map(|p| p.0.as_path()))).await?, + )); + project.agents.create(request).await } /// Sends a message; while the agent works it is queued, or with `now` it interrupts the turn. @@ -196,3 +202,43 @@ pub(crate) fn register(d: &mut Dispatcher) { d.add("agent_changes", |c| async move { changes(&c.host, &c.window, c.arg("task")?).await }); d.add("agent_base_text", |c| async move { base_text(&c.host, &c.window, c.arg("task")?, c.arg("path")?).await }); } + +fn validate_project_root(root: &std::path::Path, requested: Option<&std::path::Path>) -> IdeResult { + let Some(requested) = requested else { + return Ok(root.to_path_buf()); + }; + if !requested.is_absolute() { + return Err(IdeError::invalid("Choose an absolute project root")); + } + let requested = dunce::canonicalize(requested)?; + let window_root = dunce::canonicalize(root)?; + if requested == window_root + || ide_workspace::workspace::Workspace::load(root)?.is_some_and(|w| w.projects.contains(&requested)) + { + return Ok(requested); + } + Err(IdeError::invalid("Agent project must be the window project or a workspace member")) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn agent_roots_are_exact_window_or_workspace_members() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join("workspace"); + let member = tmp.path().join("member"); + let other = tmp.path().join("other"); + for dir in [&root, &member, &other, &member.join("child")] { + std::fs::create_dir_all(dir).unwrap(); + } + assert_eq!(validate_project_root(&root, None).unwrap(), root); + assert_eq!(validate_project_root(&root, Some(&root)).unwrap(), dunce::canonicalize(&root).unwrap()); + assert!(validate_project_root(&root, Some(&member)).is_err()); + ide_workspace::workspace::Workspace::save(&root, "Workspace", vec![member.clone()], true).unwrap(); + assert_eq!(validate_project_root(&root, Some(&member)).unwrap(), dunce::canonicalize(&member).unwrap()); + for rejected in [other, member.join("child"), member.join("../other"), "relative".into()] { + assert!(validate_project_root(&root, Some(&rejected)).is_err(), "{rejected:?}"); + } + } +} diff --git a/crates/ide-host/src/ops/build.rs b/crates/ide-host/src/ops/build.rs index 82f3d433..d921a295 100644 --- a/crates/ide-host/src/ops/build.rs +++ b/crates/ide-host/src/ops/build.rs @@ -226,6 +226,7 @@ pub async fn build_run( let project = host.project(window)?; let (root, cache, sink) = (project.root.clone(), project.build_cache_dir(), project.sink.clone()); let settings = project.settings.effective(); + let config_dir = host.config_dir.clone(); let token = host.begin_request(request); let result = tokio::task::spawn_blocking(move || { for path in clear { @@ -235,7 +236,23 @@ pub async fn build_run( items: Vec::new(), }); } - let plan = compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())?; + let plan = if let Some(workspace) = ide_workspace::workspace::Workspace::load(&root)? { + let mut combined: Option = None; + for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) { + let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective(); + let member_cache = cache.join(crate::project::project_key(member)); + let plan = compile::plan(member, &load(member, &member_cache, &settings), kind, file.as_deref())?; + match &mut combined { + Some(combined) => combined.steps.extend(plan.steps), + None => combined = Some(plan), + } + } + let mut plan = combined.ok_or_else(|| IdeError::invalid("No workspace project to build"))?; + plan.title = workspace.name; + plan + } else { + compile::plan(&root, &load(&root, &cache, &settings), kind, file.as_deref())? + }; let result = compile::run(&root, &plan, &token, &|e| { let _ = on_event.send(e); })?; diff --git a/crates/ide-host/src/ops/project.rs b/crates/ide-host/src/ops/project.rs index 035bce38..b4ee7b74 100644 --- a/crates/ide-host/src/ops/project.rs +++ b/crates/ide-host/src/ops/project.rs @@ -14,6 +14,8 @@ pub struct ProjectInfo { pub name: String, pub is_git: bool, pub settings: Settings, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, } pub fn info(project: &Project) -> ProjectInfo { @@ -28,6 +30,7 @@ pub fn info(project: &Project) -> ProjectInfo { is_git: !project.repos.read().is_empty(), settings, root: project.root.clone().into(), + workspace: ide_workspace::workspace::Workspace::load(&project.root).ok().flatten(), } } @@ -53,6 +56,42 @@ pub fn roots(host: &Host, window: &str) -> IdeResult) -> IdeResult { + let project = host.project(window)?; + ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?; + 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. +pub fn attach_workspace(host: &Host, window: &str, path: &Path) -> IdeResult { + let project = host.project(window)?; + let path = dunce::canonicalize(path)?; + if project.light { + return Err(ide_core::IdeError::invalid("Open a project before attaching a folder")); + } + if 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()); + } + 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()) +} + /// What [`set_settings`] changed; the caller tells the windows. pub struct SettingsApplied { pub settings: Settings, diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index d9c8fadb..8188b9ce 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -9,22 +9,52 @@ pub fn configs(host: &Host, window: &str) -> IdeResult> { let project = host.project(window)?; let root = project.root.clone(); let user = ide_run::load_user_configs(&root)?; - let suppressed = ide_run::load_suppressed_config_ids(&root)?; + let root_suppressed = ide_run::load_suppressed_config_ids(&root)?; let settings = project.settings.effective(); - let excluded_poms = crate::project::unlinked_maven_poms(&root, &settings); - let mut all: Vec = ide_run::detect_excluding(&root, &excluded_poms) - .into_iter() - .filter(|d| !suppressed.contains(&d.id) && !user.iter().any(|u| u.id == d.id)) - .collect(); - all.extend(user); - // Project interpreter / package manager / toolchains from Languages & Frameworks. - // Keep Gradle choices unmodified in the editor: derived defaults must not be saved as explicit env. - for config in all.iter_mut().filter(|c| c.kind != "gradle") { - ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), &root, &settings); + 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()) { + let settings = ide_workspace::SettingsStore::load(&host.config_dir, Some(&member)).effective(); + let saved = ide_run::load_user_configs(&member)?; + let suppressed = ide_run::load_suppressed_config_ids(&member)?; + let configs = detected_and_saved(&member, &settings, &saved, &suppressed); + let key = crate::project::project_key(&member); + let name = member.file_name().unwrap_or_default().to_string_lossy(); + for mut config in configs { + config.id = format!("workspace:{}:{}", key, config.id); + config.name = format!("{name} / {}", config.name); + config.cwd = Some(config.cwd.map(|p| member.join(&*p)).unwrap_or_else(|| member.clone()).into()); + config.detected = true; + if !user.iter().any(|u| u.id == config.id) && !root_suppressed.contains(&config.id) { + all.push(config); + } + } + } } Ok(all) } +/// A project's detected configurations (minus removed ones and unlinked Maven projects), then +/// its saved ones; toolchains from Languages & Frameworks applied. Gradle choices stay as saved: +/// derived defaults must not be written back as explicit environment. +fn detected_and_saved( + root: &std::path::Path, + settings: &ide_core::settings::Settings, + saved: &[RunConfig], + suppressed: &[String], +) -> Vec { + let excluded_poms = crate::project::unlinked_maven_poms(root, settings); + let mut all: Vec = ide_run::detect_excluding(root, &excluded_poms) + .into_iter() + .filter(|d| !suppressed.contains(&d.id) && !saved.iter().any(|u| u.id == d.id)) + .collect(); + all.extend(saved.iter().cloned()); + for config in all.iter_mut().filter(|c| c.kind != "gradle") { + ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(config), root, settings); + } + all +} + pub fn save_configs( host: &Host, window: &str, @@ -51,13 +81,21 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit .run(&settings, &project.root, &config, project.sink.clone(), Box::new(move |c| out(c))) .await; } - let settings = project.settings.effective(); + let member = ide_workspace::workspace::Workspace::load(&project.root)?.and_then(|workspace| { + let cwd = config.cwd.as_deref()?; + workspace.projects.into_iter().filter(|root| cwd.starts_with(root)).max_by_key(|root| root.components().count()) + }); + let root = member.as_ref().unwrap_or(&project.root); + let settings = match &member { + Some(root) => ide_workspace::SettingsStore::load(&host.config_dir, Some(root)).effective(), + None => project.settings.effective(), + }; // Configurations made in the UI name bare tools (`godot`) that may live outside PATH. let mut config = config; if matches!(config.kind.as_str(), "gradle" | "tomcat") { - ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), &project.root, &settings); + ide_sdk::apply::apply_to_run_configs(std::slice::from_mut(&mut config), root, &settings); } else { - ide_sdk::apply::resolve_run_command(&mut config, &project.root, &settings); + ide_sdk::apply::resolve_run_command(&mut config, root, &settings); } if config.command == ide_sdk::godot::COMMAND { return Err(IdeError::unavailable(ide_sdk::godot::NOT_FOUND)); @@ -66,7 +104,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit if host.processes.list().iter().any(|r| r.running && r.config_id == config.id) { return Err(IdeError::invalid("This Tomcat configuration is already running")); } - let (root, cache) = (project.root.clone(), project.cache_dir.clone()); + let (root, cache) = (root.clone(), project.cache_dir.clone()); config = tokio::task::spawn_blocking(move || { ide_run::tomcat::prepare(&root, &cache, &mut config)?; Ok::<_, IdeError>(config) @@ -77,7 +115,7 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit let encoding = settings.console.encoding; let options = StartOptions { encoding: Some(encoding).filter(|e| !e.trim().is_empty()), - python_paths: super::python::paths(&project), + python_paths: if member.is_some() { vec![root.clone()] } else { super::python::paths(&project) }, }; host.processes.start(&project.root, &config, &options, project.sink.clone(), Box::new(move |c| on_output(c))) } diff --git a/crates/ide-host/src/ops/search.rs b/crates/ide-host/src/ops/search.rs index 094f8279..40122b0a 100644 --- a/crates/ide-host/src/ops/search.rs +++ b/crates/ide-host/src/ops/search.rs @@ -42,13 +42,16 @@ pub async fn start( let cancel = host.begin_request(request); let documents = project.documents.clone(); let root = project.root.clone(); + let workspace = ide_workspace::workspace::Workspace::load(&root)?.is_some(); // The whole project also covers the Project Structure content roots. let content_roots = match query.scope { - ide_search::SearchScope::Project | ide_search::SearchScope::FileSet { set: ProjectFileSet::All } => { - project.content_roots() - } + ide_search::SearchScope::Project + | ide_search::SearchScope::FileSet { .. } + | ide_search::SearchScope::Pattern { .. } => project.content_roots(), _ => Vec::new(), }; + let include_root = !workspace + || matches!(query.scope, ide_search::SearchScope::Directory { .. } | ide_search::SearchScope::Files { .. }); let languages = project.languages.clone(); let syntax = host.syntax(); let events = on_event.clone(); @@ -66,7 +69,7 @@ pub async fn start( Some(found.into_iter().map(literal_range).collect()) }; let mut total = SearchSummary::default(); - for dir in std::iter::once(&root).chain(&content_roots) { + for dir in std::iter::once(&root).filter(|_| include_root).chain(&content_roots) { let s = ide_search::search_in_context( dir, &query, @@ -74,7 +77,13 @@ pub async fn start( Some(&literals), &|p| documents.text_of(p), &cancel, - &|file| events(SearchEvent::File { file }), + &|mut file| { + if dir != &root { + let name = dir.file_name().unwrap_or_default().to_string_lossy(); + file.rel_path = format!("{name}/{}", file.rel_path); + } + events(SearchEvent::File { file }); + }, )?; total.files += s.files; total.matches += s.matches; diff --git a/crates/ide-host/src/project.rs b/crates/ide-host/src/project.rs index d8f61655..3bafc8a0 100644 --- a/crates/ide-host/src/project.rs +++ b/crates/ide-host/src/project.rs @@ -67,6 +67,8 @@ impl Host { lap = std::time::Instant::now(); }; let root = dunce::canonicalize(root)?; + // Reject a damaged manifest before replacing the currently open project. + ide_workspace::workspace::Workspace::load(&root)?; // The window reloaded (or asked again) for the project it already has: keep // it, with its warm index, language servers and unsaved documents. if let Ok(current) = self.project(window) @@ -104,7 +106,7 @@ impl Host { let index = ProjectIndex::open( &root, &self.data_dir.join("index").join(&project_key), - extra_roots.clone(), + index_roots(&root, extra_roots.clone()), excludes.clone(), languages.clone(), self.syntax(), @@ -112,15 +114,24 @@ impl Host { )?; mark("index"); let lsp = LspManager::new(root.clone(), languages.clone(), sink.clone()); + lsp.set_content_roots(extra_roots.clone()); let settings = Arc::new(settings); { let settings = settings.clone(); let root = root.clone(); let data_dir = self.data_dir.clone(); + let config_dir = self.config_dir.clone(); lsp.set_config_hook(Box::new(move |cfg, server_root| { - let effective = settings.effective(); + let member = ide_workspace::workspace::Workspace::load(&root).ok().flatten().and_then(|w| { + w.projects.into_iter().filter(|p| server_root.starts_with(p)).max_by_key(|p| p.components().count()) + }); + let effective = member + .as_ref() + .map(|p| SettingsStore::load(&config_dir, Some(p)).effective()) + .unwrap_or_else(|| settings.effective()); + let root = member.as_ref().unwrap_or(&root); ide_sdk::apply::server_launch_overrides(cfg, &effective); - ide_sdk::apply::configure_server(cfg, &root, server_root, &effective); + ide_sdk::apply::configure_server(cfg, root, server_root, &effective); ide_sdk::java::cap_java_home(cfg, &ide_sdk::java::installed_jdks()); if cfg.name == "jdtls" { let bundles: Vec = @@ -131,12 +142,12 @@ impl Host { server_root, ide_sdk::lombok::agent_in_args(cfg.args.iter().map(String::as_str)), ide_tools::managed_bundle(&data_dir, "lombok"), - ide_sdk::apply::maven_local_repository(&root, &effective), + ide_sdk::apply::maven_local_repository(root, &effective), ); if let Some(problem) = lombok.problem() { tracing::warn!("{problem}"); } - let server_jdk = ide_sdk::apply::jdtls_jdk(&root, &effective).unwrap_or_else(|e| { + let server_jdk = ide_sdk::apply::jdtls_jdk(root, &effective).unwrap_or_else(|e| { cfg.launch_error = Some(e); None }); @@ -145,7 +156,7 @@ impl Host { server_root, &data_dir, &jdks, - ide_sdk::apply::project_jdk(&root, &effective).as_ref(), + ide_sdk::apply::project_jdk(root, &effective).as_ref(), server_jdk.as_ref(), min_java, &bundles, @@ -154,8 +165,8 @@ impl Host { ide_sdk::jdtls::launch_directly(cfg); } ide_sdk::apply::server_protocol_overrides(cfg, &effective); - let excluded_poms = unlinked_maven_poms(&root, &effective); - ide_sdk::apply::configure_maven_import(cfg, &root, &excluded_poms); + let excluded_poms = unlinked_maven_poms(root, &effective); + ide_sdk::apply::configure_maven_import(cfg, root, &excluded_poms); })); } { @@ -245,7 +256,10 @@ impl Host { if *detected_roots.read() != fresh { // Detected excluded folders (`target`, `build`) leave the index too. let effective = settings.effective(); - index.set_scope(content_roots(&effective, &root), exclude_globs(&root, &effective, &fresh)); + index.set_scope( + index_roots(&root, content_roots(&effective, &root)), + exclude_globs(&root, &effective, &fresh), + ); *detected_roots.write() = fresh; sink.emit(IdeEvent::ProjectRootsChanged { root: root.clone().into() }); } @@ -508,7 +522,9 @@ fn light_watcher( fn scan_repositories(root: &Path, repos: &RwLock, settings: &SettingsStore, sink: &SharedSink) { let ignored = settings.effective().ignored_names(); let skip = |p: &Path| p.file_name().is_some_and(|n| ignored.is_ignored_name(&n.to_string_lossy())); - match Repos::discover(root, &skip) { + let mut roots = vec![root.to_path_buf()]; + roots.extend(content_roots(&settings.effective(), root)); + match Repos::discover_roots(&roots, &skip) { Ok(found) => { if found.roots() != repos.read().roots() { *repos.write() = found; @@ -520,6 +536,10 @@ fn scan_repositories(root: &Path, repos: &RwLock, settings: &SettingsStor } impl Project { + pub fn refresh_repositories(&self) { + scan_repositories(&self.root, &self.repos, &self.settings, &self.sink); + } + /// The project's git repositories. pub fn repos(&self) -> Repos { self.repos.read().clone() @@ -645,17 +665,23 @@ impl Project { q: &ide_lsp::CandidateQuery, cancel: &CancellationToken, ) -> ide_lsp::CandidateFiles { - if !q.root.starts_with(&self.root) { + let root = self + .content_roots() + .into_iter() + .chain(std::iter::once(self.root.clone())) + .filter(|root| q.root.starts_with(root)) + .max_by_key(|root| root.components().count()); + let Some(root) = root else { return ide_lsp::CandidateFiles::default(); - } + }; let query = ide_search::WordQuery { word: q.word.clone(), dir: q.root.clone(), exclude: self.search_excludes(), limit: q.limit, }; - let filter = |rel: &str| q.languages.contains(&self.languages.detect(&self.root.join(rel), None)); - match ide_search::files_with_word(&self.root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) { + let filter = |rel: &str| q.languages.contains(&self.languages.detect(&root.join(rel), None)); + match ide_search::files_with_word(&root, &query, Some(&filter), &|p| self.documents.text_of(p), cancel) { Ok(found) => ide_lsp::CandidateFiles { files: found.files, limit_hit: found.limit_hit }, Err(e) => { tracing::debug!("reference candidates: {}", e.message); @@ -692,7 +718,8 @@ impl Project { return; } let (roots, excludes) = (self.content_roots(), self.exclude_globs()); - self.index.set_scope(roots.clone(), excludes.clone()); + self.lsp.set_content_roots(roots.clone()); + self.index.set_scope(index_roots(&self.root, roots.clone()), excludes.clone()); self.watcher.set_excludes(&excludes); let mut watchers = self.content_watchers.lock(); watchers.retain(|(p, _)| roots.contains(p)); @@ -796,8 +823,16 @@ pub fn history_retention(settings: &ide_core::settings::Settings) -> ide_workspa } } -/// Project Structure content roots outside `root` (existing absolute directories). +/// A workspace uses its root only for state, never as indexed content. +fn index_roots(root: &Path, folders: Vec) -> ide_index::IndexRoots { + ide_index::IndexRoots { include_root: !root.join(ide_workspace::workspace::FILE).is_file(), folders } +} + +/// Workspace members in manifest order, or extra Project Structure directories. fn content_roots(settings: &ide_core::settings::Settings, root: &Path) -> Vec { + if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) { + return workspace.projects; + } let mut out: Vec = Vec::new(); for r in &settings.project_structure.content_roots { let p = PathBuf::from(r.trim()); @@ -854,7 +889,7 @@ fn exclude_globs(root: &Path, settings: &ide_core::settings::Settings, detected: globs } -fn project_key(root: &Path) -> String { +pub(crate) fn project_key(root: &Path) -> String { // Stable FNV-1a directory name per project for caches. let s = root.to_string_lossy(); let mut h: u64 = 0xcbf29ce484222325; diff --git a/crates/ide-host/tests/workspaces.rs b/crates/ide-host/tests/workspaces.rs new file mode 100644 index 00000000..42742cc3 --- /dev/null +++ b/crates/ide-host/tests/workspaces.rs @@ -0,0 +1,114 @@ +use ide_core::{ChangeSet, NullSink, RequestId, TextChange}; +use ide_host::{Host, HostConfig, ops}; +use ide_workspace::workspace::Workspace; +use std::{ + sync::{Arc, Mutex}, + time::Duration, +}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn linked_projects_share_navigation_but_keep_files_repositories_and_run_directories() { + let temp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(temp.path()).unwrap(); + let roots = vec![base.join("frontend"), base.join("backend")]; + for (index, root) in roots.iter().enumerate() { + std::fs::create_dir(root).unwrap(); + assert!(std::process::Command::new("git").args(["init", "-q"]).current_dir(root).status().unwrap().success()); + std::fs::write(root.join("same.txt"), format!("shared needle {index}\n")).unwrap(); + std::fs::write(root.join("package.json"), r#"{"scripts":{"test":"echo test"}}"#).unwrap(); + } + let root = base.join("workspace"); + Workspace::save(&root, "Full stack", vec![roots[0].clone()], true).unwrap(); + let host = Host::new(HostConfig { + config_dir: base.join("config"), + 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, + }); + std::fs::write(root.join("internal-needle.txt"), "needle").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!(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()); + assert_eq!(project.index.find_files("same.txt", 100).len(), 2); + for _ in 0..500 { + if project.repos().all().len() == 2 { + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + assert_eq!(project.repos().all().len(), 2); + let configs = ops::run::configs(&host, "main").unwrap(); + for member in &roots { + assert!(configs.iter().any(|c| c.cwd.as_ref().is_some_and(|cwd| &**cwd == member))); + } + let mut ids = configs.iter().map(|c| c.id.clone()).collect::>(); + ids.sort(); + ids.dedup(); + assert_eq!(ids.len(), configs.len()); + let a = ops::doc::open(&host, "main", roots[0].join("same.txt")).await.unwrap(); + let b = ops::doc::open(&host, "main", roots[1].join("same.txt")).await.unwrap(); + assert_ne!(a.id, b.id); + ops::doc::edit( + &host, + "main", + a.id, + a.version, + ChangeSet { changes: vec![TextChange { from: 0, to: 0, insert: "unsaved ".into() }] }, + ) + .unwrap(); + 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); + let found = hits.lock().unwrap().iter().map(|f| f.rel_path.clone()).collect::>(); + assert!(found.contains(&"frontend/same.txt".to_owned())); + assert!(found.contains(&"backend/same.txt".to_owned())); + let updated = ops::project::save_workspace(&host, "main", "Frontend", vec![roots[0].clone()]).unwrap(); + assert_eq!(updated.workspace.unwrap().projects, vec![roots[0].clone()]); + assert_eq!(project.content_roots(), vec![roots[0].clone()]); + assert!(project.documents.text_of(&roots[0].join("same.txt")).unwrap().starts_with("unsaved")); + assert!(roots[1].join("same.txt").exists()); + assert!(!roots[1].join(".openidea").exists()); + let invalid = base.join("invalid"); + std::fs::create_dir_all(invalid.join(".openidea")).unwrap(); + 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"); +} diff --git a/crates/ide-index/src/filter.rs b/crates/ide-index/src/filter.rs index eb875610..b826df87 100644 --- a/crates/ide-index/src/filter.rs +++ b/crates/ide-index/src/filter.rs @@ -15,6 +15,7 @@ const ALWAYS_SKIP: &[&str] = &[".git", ".hg", ".svn", ".jj"]; pub(crate) struct PathFilter { root: PathBuf, + include_root: bool, /// Content roots outside `root`, indexed as `/`. extra: Vec, excludes: GlobSet, @@ -22,7 +23,7 @@ pub(crate) struct PathFilter { /// applies nested ignore files itself). gitignore: Gitignore, /// What the filter was built from: `(extra roots, exclude globs)`. - config: (Vec, Vec), + config: (crate::IndexRoots, Vec), } struct ExtraRoot { @@ -68,7 +69,7 @@ pub(crate) fn mtime_of(meta: &std::fs::Metadata) -> u64 { } impl PathFilter { - pub(crate) fn new(root: &Path, extra_roots: &[PathBuf], exclude_globs: &[String]) -> Self { + pub(crate) fn new(root: &Path, extra_roots: &crate::IndexRoots, exclude_globs: &[String]) -> Self { let mut set = GlobSetBuilder::new(); for raw in exclude_globs { let p = raw.trim().trim_end_matches('/'); @@ -92,10 +93,9 @@ impl PathFilter { } let excludes = set.build().unwrap_or_else(|_| GlobSet::empty()); let mut extra: Vec = Vec::new(); - for r in extra_roots { + for r in &extra_roots.folders { let path = dunce::canonicalize(r).unwrap_or_else(|_| r.clone()); - let nested = path.starts_with(root) - || root.starts_with(&path) + let nested = (extra_roots.include_root && (path.starts_with(root) || root.starts_with(&path))) || extra.iter().any(|e| path.starts_with(&e.path) || e.path.starts_with(&path)); if nested || !path.is_dir() { continue; @@ -105,16 +105,17 @@ impl PathFilter { } Self { root: root.to_owned(), + include_root: extra_roots.include_root, extra, excludes, gitignore: root_ignores(root), - config: (extra_roots.to_vec(), exclude_globs.to_vec()), + config: (extra_roots.clone(), exclude_globs.to_vec()), } } /// Whether the filter was built from these roots and globs. - pub(crate) fn same_config(&self, extra_roots: &[PathBuf], excludes: &[String]) -> bool { - self.config.0 == extra_roots && self.config.1 == excludes + pub(crate) fn same_config(&self, extra_roots: &crate::IndexRoots, excludes: &[String]) -> bool { + &self.config.0 == extra_roots && self.config.1 == excludes } pub(crate) fn root(&self) -> &Path { @@ -123,12 +124,14 @@ impl PathFilter { /// The project root, then the extra content roots. pub(crate) fn roots(&self) -> impl Iterator { - std::iter::once(self.root.as_path()).chain(self.extra.iter().map(|e| e.path.as_path())) + std::iter::once(self.root.as_path()) + .filter(|_| self.include_root) + .chain(self.extra.iter().map(|e| e.path.as_path())) } /// The root containing `path`, its display prefix (`None` for the project root) and ignore rules. fn base_of(&self, path: &Path) -> Option<(&Path, Option<&str>, &Gitignore)> { - if path.starts_with(&self.root) { + if self.include_root && path.starts_with(&self.root) { return Some((&self.root, None, &self.gitignore)); } self.extra @@ -159,6 +162,9 @@ impl PathFilter { /// Key of `path` in the persistent store: only files below the project root /// are persisted (extra content roots are rescanned when the project opens). pub(crate) fn store_key(&self, path: &Path) -> Option { + if !self.include_root { + return None; + } let rel = rel_string(path.strip_prefix(&self.root).ok()?); (!rel.is_empty()).then_some(rel) } @@ -290,7 +296,8 @@ mod tests { touch(&root, f); } std::fs::write(root.join(".gitignore"), "build/\n").unwrap(); - let filter = PathFilter::new(&root, &[], &["**/node_modules".into(), "target".into(), "docs/**".into()]); + let filter = + PathFilter::new(&root, &vec![].into(), &["**/node_modules".into(), "target".into(), "docs/**".into()]); let mut found: Vec = filter.walk(&root, &AtomicBool::new(false)).iter().filter_map(|f| filter.rel(&f.path)).collect(); found.sort(); @@ -313,7 +320,7 @@ mod tests { touch(&base, f); } std::fs::write(shared.join(".gitignore"), "out/\n").unwrap(); - let filter = PathFilter::new(&root, &[shared.clone(), root.join("src")], &["target".into()]); + let filter = PathFilter::new(&root, &vec![shared.clone(), root.join("src")].into(), &["target".into()]); assert_eq!(filter.roots().count(), 2, "roots inside the project are not extra roots"); let mut found: Vec = filter .roots() @@ -327,7 +334,7 @@ mod tests { assert!(!filter.is_excluded(&shared.join("src/new.rs"), false)); assert!(filter.is_excluded(&shared.join("target/y.rs"), false)); assert!(filter.is_excluded(&shared.join("out/y.rs"), false)); - assert!(filter.same_config(&[shared.clone(), root.join("src")], &["target".into()])); - assert!(!filter.same_config(&[shared], &["target".into()])); + assert!(filter.same_config(&vec![shared.clone(), root.join("src")].into(), &["target".into()])); + assert!(!filter.same_config(&vec![shared].into(), &["target".into()])); } } diff --git a/crates/ide-index/src/lib.rs b/crates/ide-index/src/lib.rs index d0b08799..88923dec 100644 --- a/crates/ide-index/src/lib.rs +++ b/crates/ide-index/src/lib.rs @@ -70,6 +70,19 @@ pub struct ProjectIndex { root: PathBuf, } +/// Folders indexed alongside the internal state root. +#[derive(Clone, PartialEq)] +pub struct IndexRoots { + pub include_root: bool, + pub folders: Vec, +} + +impl From> for IndexRoots { + fn from(folders: Vec) -> Self { + Self { include_root: true, folders } + } +} + impl ProjectIndex { /// Opens (or creates) the persistent index at `cache_dir` and starts a /// background full scan that skips files whose content hash is unchanged. @@ -77,7 +90,7 @@ impl ProjectIndex { pub fn open( root: &Path, cache_dir: &Path, - content_roots: Vec, + content_roots: impl Into, excludes: Vec, languages: Arc, syntax: Arc, @@ -88,7 +101,7 @@ impl ProjectIndex { return Err(ide_core::IdeError::not_found(format!("{} is not a directory", root.display()))); } let store = store::Store::open(cache_dir, &root, &syntax.fingerprint()); - let filter = filter::PathFilter::new(&root, &content_roots, &excludes); + let filter = filter::PathFilter::new(&root, &content_roots.into(), &excludes); let shared = Arc::new(Shared::new(filter, languages, syntax, sink, store)); Self::start(shared, root, true) } @@ -102,7 +115,7 @@ impl ProjectIndex { sink: SharedSink, ) -> IdeResult> { let root = dunce::canonicalize(root).unwrap_or_else(|_| root.to_owned()); - let filter = filter::PathFilter::new(&root, &[], &[]); + let filter = filter::PathFilter::new(&root, &Vec::new().into(), &[]); let shared = Arc::new(Shared::new(filter, languages, syntax, sink, None)); Self::start(shared, root, false) } @@ -121,7 +134,8 @@ impl ProjectIndex { /// Changes what is indexed besides the root (content roots) and the exclude /// globs, rescanning in the background (unchanged files are not re-read). /// Returns whether anything changed. - pub fn set_scope(&self, content_roots: Vec, excludes: Vec) -> bool { + pub fn set_scope(&self, content_roots: impl Into, excludes: Vec) -> bool { + let content_roots = content_roots.into(); if self.shared.filter().same_config(&content_roots, &excludes) { return false; } @@ -222,7 +236,14 @@ impl ProjectIndex { /// the IDE was closed) are left out. Virtual documents are always listed. pub fn recent_files(&self) -> Vec { let recent = self.shared.recent.lock().clone(); - recent.into_iter().filter(|p| p.to_str().is_some_and(ide_core::text::is_virtual_uri) || p.exists()).collect() + let filter = self.shared.filter(); + recent + .into_iter() + .filter(|p| { + p.to_str().is_some_and(ide_core::text::is_virtual_uri) + || (p.exists() && (!p.starts_with(&self.root) || filter.contains(p))) + }) + .collect() } /// Blocks until the initial scan and every queued update has been applied diff --git a/crates/ide-index/src/tests.rs b/crates/ide-index/src/tests.rs index 3e45fc50..97ba3c7c 100644 --- a/crates/ide-index/src/tests.rs +++ b/crates/ide-index/src/tests.rs @@ -508,3 +508,38 @@ fn unindexed_index_never_scans_or_persists() { assert!(!fx.cache.exists()); assert!(!events.lock().iter().any(|e| matches!(e, IdeEvent::Task { .. })), "no indexing task"); } + +#[test] +fn workspace_scope_excludes_state_from_scans_updates_and_cached_results() { + let fx = Fixture::new(&[(".openidea/workspace.json", "{}"), ("internal.rs", "fn hidden() {}")]); + // Simulate a cache produced before the settings root was excluded. + let old = fx.open(); + old.mark_recent(&fx.root.join("internal.rs")); + assert!(old.wait_idle(WAIT)); + drop(old); + let member = fx.root.join("member"); + std::fs::create_dir_all(&member).unwrap(); + std::fs::write(member.join("visible.rs"), "fn visible() {}").unwrap(); + let idx = ProjectIndex::open( + &fx.root, + &fx.cache, + crate::IndexRoots { include_root: false, folders: vec![member.clone()] }, + Vec::new(), + Arc::new(LanguageRegistry::load(None).unwrap()), + Arc::new(Syntax::new()), + Arc::new(NullSink), + ) + .unwrap(); + assert!(idx.wait_idle(WAIT)); + assert!(idx.find_files("internal", 100).is_empty()); + assert!(idx.find_files("workspace.json", 100).is_empty()); + assert!(idx.recent_files().is_empty()); + assert_eq!(idx.find_files("visible", 100).len(), 1); + idx.update(&[fx.root.join("internal.rs"), fx.root.join(".openidea/workspace.json")], &[]); + assert!(idx.wait_idle(WAIT)); + assert!(symbols(&idx, "hidden", SymbolFilter::All).is_empty()); + assert_eq!(symbols(&idx, "visible", SymbolFilter::All), ["visible"]); + idx.set_scope(crate::IndexRoots { include_root: false, folders: Vec::new() }, Vec::new()); + assert!(idx.wait_idle(WAIT)); + assert!(idx.find_files("", 100).is_empty()); +} diff --git a/crates/ide-index/src/worker.rs b/crates/ide-index/src/worker.rs index 04fbcb73..a6947271 100644 --- a/crates/ide-index/src/worker.rs +++ b/crates/ide-index/src/worker.rs @@ -168,11 +168,15 @@ impl Shared { fn load_persisted(&self) { let Some(store) = &self.store else { return }; - let root = self.filter().root().to_owned(); + let filter = self.filter(); + let root = filter.root().to_owned(); match store.load() { Ok(records) => { - let entries: Vec = - records.into_par_iter().map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec)).collect(); + let entries: Vec = records + .into_par_iter() + .filter(|(rel, _)| !filter.is_excluded(&root.join(rel), false)) + .map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec)) + .collect(); let mut d = self.data.write(); for e in entries { d.insert(e); diff --git a/crates/ide-lsp/src/lib.rs b/crates/ide-lsp/src/lib.rs index f164c156..b47896fe 100644 --- a/crates/ide-lsp/src/lib.rs +++ b/crates/ide-lsp/src/lib.rs @@ -157,6 +157,7 @@ impl DocSnap { pub struct LspManager { me: Weak, project_root: PathBuf, + content_roots: RwLock>, languages: Arc, sink: SharedSink, launcher: Arc, @@ -286,6 +287,7 @@ impl LspManager { Arc::new_cyclic(|me| Self { me: me.clone(), project_root, + content_roots: RwLock::new(Vec::new()), languages, sink, launcher, @@ -306,9 +308,24 @@ impl LspManager { // ---- server lookup & lifecycle ---- + pub fn set_content_roots(&self, roots: Vec) { + *self.content_roots.write() = roots; + } + + fn project_root_for(&self, path: &Path) -> PathBuf { + self.content_roots + .read() + .iter() + .chain(std::iter::once(&self.project_root)) + .filter(|root| path.starts_with(root)) + .max_by_key(|root| root.components().count()) + .cloned() + .unwrap_or_else(|| self.project_root.clone()) + } + fn server_for(&self, cfg: &LanguageServerConfig, language: &LanguageId, path: &Path) -> Arc { let mut servers = self.servers.lock(); - let root = launch::find_root(&self.project_root, path, &cfg.root_markers).unwrap_or_else(|| { + let root = launch::find_root(&self.project_root_for(path), path, &cfg.root_markers).unwrap_or_else(|| { // Outside the project (library sources, …): reuse a running instance. servers .iter() @@ -336,7 +353,9 @@ impl LspManager { if sc.fallback && available_before { continue; } - if sc.require_root_marker && launch::marked_root(&self.project_root, path, &sc.root_markers).is_none() { + if sc.require_root_marker + && launch::marked_root(&self.project_root_for(path), path, &sc.root_markers).is_none() + { continue; } let server = self.server_for(sc, language, path); diff --git a/crates/ide-lsp/tests/mock_server.rs b/crates/ide-lsp/tests/mock_server.rs index 5ad879f5..9d0952f3 100644 --- a/crates/ide-lsp/tests/mock_server.rs +++ b/crates/ide-lsp/tests/mock_server.rs @@ -359,6 +359,29 @@ fn change(range: Range, text: &str) -> AppliedChange { // ------------------------------------------------------------------ tests +#[tokio::test(flavor = "multi_thread")] +async fn linked_projects_initialize_separate_language_server_roots() { + let h = harness(&[("mock", incremental_caps())]); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let roots = vec![first.path().to_path_buf(), second.path().to_path_buf()]; + h.mgr.set_content_roots(roots.clone()); + for root in &roots { + let file = root.join("same.mk"); + std::fs::write(&file, "hello").unwrap(); + h.mgr.did_open(DocId::next(), &file, &h.lang, 1, "hello"); + } + let server = h.launcher.state("mock"); + eventually(|| server.requested("initialize").len() == 2).await; + let mut actual: Vec<_> = + server.requested("initialize").iter().map(|r| r["rootUri"].as_str().unwrap().to_owned()).collect(); + let mut expected: Vec<_> = roots.iter().map(|p| uri_of(p)).collect(); + actual.sort(); + expected.sort(); + assert_eq!(actual, expected); + h.mgr.shutdown_all().await; +} + #[tokio::test(flavor = "multi_thread")] async fn initialize_and_incremental_sync() { let h = harness(&[("mock", incremental_caps())]); diff --git a/crates/ide-vcs/src/roots.rs b/crates/ide-vcs/src/roots.rs index ff3a71c5..280611d1 100644 --- a/crates/ide-vcs/src/roots.rs +++ b/crates/ide-vcs/src/roots.rs @@ -20,6 +20,21 @@ pub struct Repos { } impl Repos { + /// Discovers repositories in independent project folders of a workspace. + pub fn discover_roots(roots: &[PathBuf], skip: &dyn Fn(&Path) -> bool) -> IdeResult { + let mut repos = Vec::new(); + for root in roots { + for repo in Self::discover(root, skip)?.repos { + if !repos.iter().any(|r: &Arc| r.root() == repo.root()) { + repos.push(repo); + } + } + } + repos.sort_by(|a, b| a.root().cmp(b.root())); + let primary = (!repos.is_empty()).then_some(0); + Ok(Self { repos, primary }) + } + /// The repository containing `project_root` and every repository whose worktree /// root lies below it (up to [`MAX_DEPTH`] levels; hidden and dependency folders and /// folders `skip` rejects are not searched). diff --git a/crates/ide-workspace/src/lib.rs b/crates/ide-workspace/src/lib.rs index 41c05bc9..1ab611e6 100644 --- a/crates/ide-workspace/src/lib.rs +++ b/crates/ide-workspace/src/lib.rs @@ -14,6 +14,8 @@ pub mod recent; pub mod roots; pub mod settings; pub mod text_refs; +pub mod window_groups; +pub mod workspace; pub use documents::{DetectLanguage, DocumentInfo, Documents, TextSnapshot}; pub use editorconfig::{EditorConfig, EditorConfigProps}; diff --git a/crates/ide-workspace/src/settings.rs b/crates/ide-workspace/src/settings.rs index 7e661697..62abb99d 100644 --- a/crates/ide-workspace/src/settings.rs +++ b/crates/ide-workspace/src/settings.rs @@ -185,7 +185,15 @@ impl SettingsStore { tracing::warn!("{} has invalid settings, ignoring it: {e}", file.display()); } } - serde_json::from_value(merged).unwrap_or_default() + 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) + { + settings.general.project_name = workspace.name; + settings.project_structure.content_roots = + workspace.projects.iter().map(|p| ide_core::ipc_path::to_ipc(p)).collect(); + } + settings } /// Effective (merged) settings. diff --git a/crates/ide-workspace/src/window_groups.rs b/crates/ide-workspace/src/window_groups.rs new file mode 100644 index 00000000..a2a088d4 --- /dev/null +++ b/crates/ide-workspace/src/window_groups.rs @@ -0,0 +1,120 @@ +//! Window grouping is independent of a window's project or workspace. +use ide_core::{IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WindowGroup { + pub members: Vec, + pub selected: String, +} + +#[derive(Debug, Default)] +pub struct WindowGroups { + groups: Vec, +} + +impl WindowGroups { + pub fn group(&self, member: &str) -> Option<&WindowGroup> { + self.groups.iter().find(|g| g.members.iter().any(|m| m == member)) + } + + pub fn groups(&self) -> &[WindowGroup] { + &self.groups + } + + pub fn merge(&mut self, members: Vec, selected: &str) -> IdeResult<()> { + let mut unique = Vec::new(); + for member in members { + if !unique.contains(&member) { + unique.push(member); + } + } + if unique.len() < 2 || !unique.iter().any(|m| m == selected) { + return Err(IdeError::invalid("Open at least two project windows to create a window group")); + } + for member in &unique { + self.detach(member); + } + self.groups.push(WindowGroup { members: unique, selected: selected.to_owned() }); + Ok(()) + } + + pub fn select(&mut self, caller: &str, target: &str) -> IdeResult { + let group = self + .groups + .iter_mut() + .find(|g| g.members.iter().any(|m| m == caller)) + .ok_or_else(|| IdeError::not_found("This window is not grouped"))?; + if !group.members.iter().any(|m| m == target) { + return Err(IdeError::invalid("The window is not in this group")); + } + let before = group.clone(); + group.selected = target.to_owned(); + Ok(before) + } + + /// Returns the remaining active member, including when the last pair dissolves. + pub fn detach(&mut self, member: &str) -> Option { + let i = self.groups.iter().position(|g| g.members.iter().any(|m| m == member))?; + let group = &mut self.groups[i]; + let index = group.members.iter().position(|m| m == member).unwrap(); + group.members.remove(index); + if group.selected == member { + group.selected = group.members[index.min(group.members.len() - 1)].clone(); + } + let selected = group.selected.clone(); + if group.members.len() < 2 { + self.groups.remove(i); + } + Some(selected) + } + + pub fn reorder(&mut self, caller: &str, members: Vec) -> IdeResult<()> { + let group = self + .groups + .iter_mut() + .find(|g| g.members.iter().any(|m| m == caller)) + .ok_or_else(|| IdeError::not_found("This window is not grouped"))?; + let mut expected = group.members.clone(); + let mut actual = members.clone(); + expected.sort(); + actual.sort(); + if expected != actual { + return Err(IdeError::invalid("Reordering must preserve every window exactly once")); + } + group.members = members; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn members(values: &[&str]) -> Vec { + values.iter().map(|s| (*s).into()).collect() + } + + #[test] + fn selecting_and_detaching_preserve_the_other_sessions() { + let mut groups = WindowGroups::default(); + groups.merge(members(&["a", "b", "c"]), "b").unwrap(); + assert!(groups.select("a", "unrelated").is_err()); + groups.select("a", "c").unwrap(); + assert_eq!(groups.detach("c"), Some("b".into())); + assert_eq!(groups.group("a").unwrap().members, members(&["a", "b"])); + assert_eq!(groups.detach("b"), Some("a".into())); + assert!(groups.group("a").is_none()); + } + + #[test] + fn merging_groups_and_reordering_keep_unique_members() { + let mut groups = WindowGroups::default(); + groups.merge(members(&["a", "b"]), "a").unwrap(); + groups.merge(members(&["c", "d"]), "c").unwrap(); + groups.merge(members(&["a", "b", "c", "d", "a"]), "a").unwrap(); + assert_eq!(groups.groups().len(), 1); + assert!(groups.reorder("a", members(&["a", "a", "c", "d"])).is_err()); + groups.reorder("a", members(&["d", "c", "b", "a"])).unwrap(); + assert_eq!(groups.group("a").unwrap().members[0], "d"); + } +} diff --git a/crates/ide-workspace/src/workspace.rs b/crates/ide-workspace/src/workspace.rs new file mode 100644 index 00000000..91f27858 --- /dev/null +++ b/crates/ide-workspace/src/workspace.rs @@ -0,0 +1,203 @@ +//! A workspace owns its settings and references project folders without changing them. +use ide_core::{IdeError, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::io::Write; +use std::path::{Path, PathBuf}; + +pub const FILE: &str = ".openidea/workspace.json"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct Workspace { + pub version: u32, + pub name: String, + #[serde(serialize_with = "serialize_projects")] + #[specta(type = Vec)] + pub projects: Vec, +} + +fn serialize_projects(projects: &[PathBuf], serializer: S) -> Result { + projects.iter().map(|path| ide_core::ipc_path::to_ipc(path)).collect::>().serialize(serializer) +} + +impl Workspace { + /// Creates an automatically named workspace outside all linked projects. + pub fn create_for_project(data_dir: &Path, current: &Path, name: &str, attached: &Path) -> IdeResult { + let projects = vec![current.to_path_buf(), attached.to_path_buf()]; + let base = destination(&data_dir.join("workspaces"))?; + let folder = current.file_name().and_then(|n| n.to_str()).unwrap_or("workspace"); + // Validate before creating anything, including when app data is inside a project. + let normalized = normalize_projects(&base, projects.clone(), false)?; + if normalized.iter().any(|p| base.starts_with(p)) { + return Err(IdeError::invalid("Store the workspace outside its linked project folders")); + } + 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); + } + unreachable!() + } + + pub fn load(root: &Path) -> IdeResult> { + let bytes = match std::fs::read(root.join(FILE)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + let mut workspace: Self = + serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(format!("Invalid workspace: {e}")))?; + if workspace.version != 1 { + return Err(IdeError::invalid("Unsupported workspace version")); + } + workspace.name = valid_name(&workspace.name)?; + workspace.projects = normalize_projects(root, workspace.projects, true)?; + Ok(Some(workspace)) + } + + pub fn save(root: &Path, name: &str, projects: Vec, create: bool) -> IdeResult { + let name = valid_name(name)?; + let root = destination(root)?; + let previous = Self::load(&root)?; + if create && previous.is_some() { + return Err(IdeError::invalid("This folder already contains a workspace")); + } + if !create && previous.is_none() { + return Err(IdeError::invalid("This folder is not a workspace")); + } + let projects = normalize_projects(&root, projects, true)?; + 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")); + } + std::fs::create_dir_all(&root)?; + let workspace = Self { version: 1, name, projects }; + let bytes = serde_json::to_vec_pretty(&workspace).map_err(|e| IdeError::internal(e.to_string()))?; + let file = root.join(FILE); + std::fs::create_dir_all(file.parent().expect("workspace metadata directory"))?; + if create { + std::fs::OpenOptions::new().write(true).create_new(true).open(file)?.write_all(&bytes)?; + } else { + ide_vfs::write_atomic(&file, &bytes)?; + } + Ok(workspace) + } +} + +fn destination(path: &Path) -> IdeResult { + if !path.is_absolute() { + return Err(IdeError::invalid("Choose an absolute workspace location")); + } + if path.exists() { + return Ok(dunce::canonicalize(path)?); + } + let parent = path.parent().ok_or_else(|| IdeError::invalid("Invalid workspace location"))?; + let name = path.file_name().ok_or_else(|| IdeError::invalid("Invalid workspace location"))?; + Ok(destination(parent)?.join(name)) +} + +fn valid_name(name: &str) -> IdeResult { + let name = name.trim(); + if name.is_empty() || name.contains(['\n', '\r']) { + return Err(IdeError::invalid("Enter a workspace name")); + } + Ok(name.to_owned()) +} + +fn normalize_projects(root: &Path, projects: Vec, allow_missing: bool) -> IdeResult> { + let mut result = Vec::new(); + for path in projects { + let path = if path.is_absolute() { path } else { root.join(path) }; + let path = match dunce::canonicalize(&path) { + Ok(path) if path.is_dir() => path, + Err(e) if allow_missing && e.kind() == std::io::ErrorKind::NotFound => path, + _ => 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 !result.contains(&path) { + result.push(path); + } + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn automatic_workspace_is_unique_and_outside_projects() { + 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()); + } + + #[test] + fn round_trip_deduplicates_projects_without_changing_them() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + std::fs::create_dir(&project).unwrap(); + let root = tmp.path().join("workspace"); + let saved = Workspace::save(&root, " Full stack ", vec![project.clone(), project.clone()], true).unwrap(); + assert_eq!(saved.projects.len(), 1); + assert_eq!(Workspace::load(&root).unwrap(), Some(saved)); + assert!(!project.join(".openidea").exists()); + assert!(Workspace::save(&root, "Other", vec![], true).is_err()); + assert_eq!(Workspace::load(&root).unwrap().unwrap().name, "Full stack"); + } + + #[test] + fn invalid_update_leaves_manifest_intact_and_missing_projects_can_reopen() { + let tmp = tempfile::tempdir().unwrap(); + let project = tmp.path().join("project"); + std::fs::create_dir(&project).unwrap(); + let root = tmp.path().join("workspace"); + Workspace::save(&root, "Workspace", vec![project.clone()], true).unwrap(); + let before = std::fs::read(root.join(FILE)).unwrap(); + assert!(Workspace::save(&root, "Changed", vec![tmp.path().join("missing")], false).is_err()); + assert_eq!(std::fs::read(root.join(FILE)).unwrap(), before); + std::fs::remove_dir(project).unwrap(); + assert_eq!(Workspace::load(&root).unwrap().unwrap().projects.len(), 1); + let missing = Workspace::load(&root).unwrap().unwrap().projects; + Workspace::save(&root, "Renamed", missing, false).unwrap(); + } + + #[test] + fn invalid_location_does_not_create_a_folder_inside_a_project() { + let tmp = tempfile::tempdir().unwrap(); + let project = dunce::canonicalize(tmp.path()).unwrap(); + let root = project.join("workspace"); + assert!(Workspace::save(&root, "Invalid", vec![project], true).is_err()); + assert!(!root.exists()); + } +} diff --git a/docs/workspaces-and-window-groups.md b/docs/workspaces-and-window-groups.md new file mode 100644 index 00000000..be9cf6a2 --- /dev/null +++ b/docs/workspaces-and-window-groups.md @@ -0,0 +1,39 @@ +# Workspaces and project window groups + +A workspace opens several local project folders in one IDE session. A window group switches between independent IDE sessions; a member can itself contain a workspace. + +## Workspace + +Use **File → New Workspace…**. Choose a name and a separate directory for the workspace, then add project folders. **File → Open Workspace…** opens that directory again. **File → Manage Workspace…** changes its name or linked projects. Removing a link leaves the project's files and open editor buffers intact. + +When opening another local project, choose **Attach** to add it to this window. A plain project becomes a workspace automatically under the IDE app data directory's `workspaces` folder, named after the current project (with a unique directory suffix when needed). The existing editor session carries over after the unsaved-changes guard. **Don't ask again** remembers Attach; change it in **Settings → System Settings → Project → Open project in**. Already linked projects are revealed in Project view. Right-click a non-first linked root and choose **Remove from Workspace** to unlink it without deleting files. + +The workspace stores its manifest in `.openidea/workspace.json`: + +```json +{ + "version": 1, + "name": "Full stack", + "projects": ["/work/frontend", "/work/backend"] +} +``` + +Paths in an existing manifest may be relative to the workspace directory. New and updated manifests use canonical absolute paths. Missing previously linked folders are retained so they can be reconnected or removed. Nested workspace manifests are rejected. + +Linked folders appear as roots in Project view and participate in indexing, Find in Files, Git repository discovery and language-server root selection. Detected and saved run configurations have project-qualified names and use their member's working directory and launch settings. Build Project combines the linked projects' build plans; Compile File targets its owning project. Workspace editor/settings state belongs to its own directory. Each member retains its repository and project configuration. + +The specialized Maven/Gradle tool-window model still describes the workspace directory itself; it does not yet aggregate independent member models. Workspace creation and management currently target local folders. + +## Window group + +Open two project windows, then use **Window → Merge All Project Windows**. The project tabs occupy their own row below the main toolbar. Click a tab or use **Next/Previous Project Tab** to switch; drag tabs to reorder them. The default shortcuts are ⌥⌘PageDown/PageUp on macOS and Ctrl+Alt+PageDown/PageUp on Windows/Linux. + +Use the tab's close button to close that project, or **Move Tab to New Window** to detach the current tab. Closing uses the existing unsaved-changes guard. Quit visits project windows in sequence so a hidden project's prompt is visible. Groups restore when **Reopen last project** is enabled. + +The grouping model lives in `ide-workspace`, independently of project ownership. The desktop adapter keeps each member's WebView and backend services alive, presents the selected window at the group's position/size, and hides its peers. This is an application-drawn tab strip, not the macOS native tab strip. Its persistence is application-local in `window-groups.json`. Language servers, terminals, editor buffers and settings remain independent between group members. + +## Validation + +Automated coverage includes workspace manifest validation and persistence, missing members, independent language-server initialization roots, cross-root search/repositories/run directories, document preservation after unlinking, window-group membership/order, dialog failures and tab keyboard controls. + +Tauri MCP validation on macOS covered workspace creation and management, opening same-named files from both projects, cross-project search, running both projects, tab selection/reordering/detachment, unsaved close cancellation, full-screen switching and application restart restoration. Windows/Linux native-window behavior has not been visually tested. diff --git a/src-tauri/src/commands/app.rs b/src-tauri/src/commands/app.rs index b158bda0..529a398e 100644 --- a/src-tauri/src/commands/app.rs +++ b/src-tauri/src/commands/app.rs @@ -58,6 +58,11 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult().sink.emit(ide_core::IdeEvent::WindowGroupsChanged); + let grouped = window.state::().groups.lock().group(window.label()).is_some(); + if grouped { + crate::window_groups::persist(window.app_handle()).await?; + } Ok(info) } @@ -70,6 +75,8 @@ pub async fn light_edit_open(window: Window, path: PathBuf) -> IdeResult().project(&label).is_ok_and(|p| !p.light) { crate::windows::close_settings_window(&app, &label); + crate::window_groups::detach(&app, &label)?; + crate::window_groups::persist(&app).await?; } let project = tauri::async_runtime::spawn_blocking(move || app.state::().open_light(&label, &path)) .await @@ -88,6 +95,9 @@ fn project_info(project: &crate::state::Project) -> ProjectInfo { pub fn project_rename(window: Window, state: State<'_, AppState>, name: String) -> IdeResult { let project = state.project(window.label())?; let name = name.trim(); + if let Some(workspace) = ide_workspace::workspace::Workspace::load(&project.root)? { + return ide_host::ops::project::save_workspace(&state.host, window.label(), name, workspace.projects); + } 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))?; @@ -98,7 +108,12 @@ pub fn project_rename(window: Window, state: State<'_, AppState>, name: String) #[specta::specta] pub fn close_project(window: Window, state: State<'_, AppState>) { crate::windows::close_settings_window(window.app_handle(), window.label()); + let _ = crate::window_groups::detach(window.app_handle(), window.label()); state.close_project(window.label()); + let app = window.app_handle().clone(); + tauri::async_runtime::spawn(async move { + let _ = crate::window_groups::persist(&app).await; + }); } #[tauri::command] diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 8085a112..59cbd746 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -44,6 +44,8 @@ pub mod tools; pub mod vcs; pub mod web; pub mod window; +pub mod window_group; +pub mod workspace; use tauri_specta::collect_commands; @@ -62,6 +64,16 @@ pub fn builder() -> tauri_specta::Builder { app::recent_projects, app::remove_recent_project, app::open_project, + workspace::workspace_create, + workspace::workspace_attach, + workspace::workspace_update, + window_group::window_group_current, + window_group::window_group_merge, + window_group::window_group_select, + window_group::window_group_detach, + window_group::window_group_close, + window_group::window_group_reorder, + window_group::window_group_restore, app::light_edit_open, app::project_current, app::close_project, diff --git a/src-tauri/src/commands/window.rs b/src-tauri/src/commands/window.rs index 3d675207..23f4b030 100644 --- a/src-tauri/src/commands/window.rs +++ b/src-tauri/src/commands/window.rs @@ -9,7 +9,7 @@ use specta::Type; use std::path::PathBuf; use std::sync::atomic::{AtomicBool, Ordering}; use tauri::ipc::Channel; -use tauri::{AppHandle, Manager, State, WebviewWindow}; +use tauri::{AppHandle, State, WebviewWindow}; /// Opens `path` in a new window (`new_window`) or tells the caller to open it /// itself (`current`). A project already open in some window is focused instead. @@ -145,21 +145,21 @@ pub fn window_close_context(app: AppHandle, windows: State<'_, WindowsState>) -> #[tauri::command] #[specta::specta] pub fn app_quit(app: AppHandle, windows: State<'_, WindowsState>) { + if windows.quitting() { + return; + } windows.set_quitting(true); - let all = app.webview_windows(); - if all.is_empty() { - app.exit(0); - } - for w in all.values() { - let _ = w.close(); - } + windows::continue_quit(&app, None); } /// A window vetoed the quit (user cancelled its unsaved-changes prompt). #[tauri::command] #[specta::specta] -pub fn app_cancel_quit(windows: State<'_, WindowsState>) { +pub fn app_cancel_quit(app: AppHandle, windows: State<'_, WindowsState>) { windows.set_quitting(false); + tauri::async_runtime::spawn(async move { + let _ = crate::window_groups::persist(&app).await; + }); } /// Sets the menu's labels / shortcut text (on locale or keymap change) and rebuilds it. diff --git a/src-tauri/src/commands/window_group.rs b/src-tauri/src/commands/window_group.rs new file mode 100644 index 00000000..210797dc --- /dev/null +++ b/src-tauri/src/commands/window_group.rs @@ -0,0 +1,56 @@ +use crate::{window_groups as groups, windows::WindowsState}; +use ide_core::{IdeError, IdeResult}; +use tauri::{Manager, WebviewWindow}; + +#[tauri::command] +#[specta::specta] +pub fn window_group_current(window: WebviewWindow) -> IdeResult> { + Ok(groups::current(window.app_handle(), window.label())) +} + +#[tauri::command] +#[specta::specta] +pub async fn window_group_merge(window: WebviewWindow) -> IdeResult<()> { + groups::merge(window.app_handle(), window.label())?; + groups::persist(window.app_handle()).await +} + +#[tauri::command] +#[specta::specta] +pub async fn window_group_select(window: WebviewWindow, label: String) -> IdeResult<()> { + groups::select(window.app_handle(), window.label(), &label)?; + groups::persist(window.app_handle()).await +} + +#[tauri::command] +#[specta::specta] +pub async fn window_group_detach(window: WebviewWindow) -> IdeResult<()> { + groups::detach(window.app_handle(), window.label())?; + groups::persist(window.app_handle()).await +} + +#[tauri::command] +#[specta::specta] +pub fn window_group_close(window: WebviewWindow, label: String) -> IdeResult<()> { + groups::select(window.app_handle(), window.label(), &label)?; + window + .app_handle() + .get_webview_window(&label) + .ok_or_else(|| IdeError::not_found("Window closed"))? + .close() + .map_err(|e| IdeError::internal(e.to_string())) +} + +#[tauri::command] +#[specta::specta] +pub async fn window_group_reorder(window: WebviewWindow, labels: Vec) -> IdeResult<()> { + window.state::().groups.lock().reorder(window.label(), labels)?; + groups::select(window.app_handle(), window.label(), window.label())?; + groups::persist(window.app_handle()).await +} + +#[tauri::command] +#[specta::specta] +pub async fn window_group_restore(window: WebviewWindow) -> IdeResult<()> { + groups::restore(window.app_handle(), window.label()).await +} diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs new file mode 100644 index 00000000..77408f7b --- /dev/null +++ b/src-tauri/src/commands/workspace.rs @@ -0,0 +1,42 @@ +use crate::state::AppState; +use ide_core::{IdeError, IdeResult}; +use ide_host::ops::project::ProjectInfo; +use ide_workspace::workspace::Workspace; +use std::path::PathBuf; +use tauri::{Manager, WebviewWindow}; + +#[tauri::command] +#[specta::specta] +pub async fn workspace_create(path: PathBuf, name: String, projects: Vec) -> IdeResult { + tauri::async_runtime::spawn_blocking(move || { + Workspace::save(&path, &name, projects, true)?; + Ok(dunce::canonicalize(path)?.into()) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + +#[tauri::command] +#[specta::specta] +pub async fn workspace_update(window: WebviewWindow, name: String, projects: Vec) -> IdeResult { + let host = window.state::().host.clone(); + let label = window.label().to_owned(); + let info = tauri::async_runtime::spawn_blocking(move || { + ide_host::ops::project::save_workspace(&host, &label, &name, projects) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))??; + let _ = window.set_title(&info.name); + window.state::().sink.emit(ide_core::IdeEvent::WindowGroupsChanged); + Ok(info) +} + +#[tauri::command] +#[specta::specta] +pub async fn workspace_attach(window: WebviewWindow, path: PathBuf) -> IdeResult { + let host = window.state::().host.clone(); + let label = window.label().to_owned(); + tauri::async_runtime::spawn_blocking(move || ide_host::ops::project::attach_workspace(&host, &label, &path)) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 099c2949..660b8943 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -13,6 +13,7 @@ mod remote_dev; mod state; mod theme_frame; +mod window_groups; mod windows; use parking_lot::Mutex; @@ -112,6 +113,7 @@ pub fn run() { } } tauri::WindowEvent::Destroyed => { + window_groups::destroyed(window.app_handle(), window.label()); if let Some(settings) = window.app_handle().get_webview_window(&windows::settings_label(window.label())) { let _ = settings.destroy(); @@ -122,6 +124,9 @@ pub fn run() { if let Some(w) = window.try_state::() { w.forget(window.label()); } + if !windows::is_settings_window(window.label()) { + windows::continue_quit(window.app_handle(), Some(window.label())); + } } _ => {} }) diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index 3e4d00ec..0937bfce 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -204,6 +204,9 @@ pub fn spec(os: Os) -> Vec { let mut file = vec![ act("file.newProject", "New Project…"), + act("workspace.create", "New Workspace…"), + act("workspace.open", "Open Workspace…"), + act("workspace.manage", "Manage Workspace…"), act("file.new", "New…"), act("file.newScratchFile", "New Scratch File…"), act("file.openProject", "Open…"), @@ -792,6 +795,10 @@ pub fn spec(os: Os) -> Vec { ), SEP, act("window.new", "New Window"), + act("window.mergeAllWindows", "Merge All Project Windows"), + act("window.detachTab", "Move Tab to New Window"), + act("window.nextTab", "Next Project Tab"), + act("window.previousTab", "Previous Project Tab"), ]); // ⌘` is macOS's window cycling; elsewhere Ctrl+` is Quick Switch Scheme and the keymap's // Ctrl+Alt+] / Ctrl+Alt+[ switch windows. diff --git a/src-tauri/src/window_groups.rs b/src-tauri/src/window_groups.rs new file mode 100644 index 00000000..4aaea46d --- /dev/null +++ b/src-tauri/src/window_groups.rs @@ -0,0 +1,254 @@ +//! Desktop adapter for application-drawn project tabs. Each member retains its WebView and services. +use crate::{ + state::AppState, + windows::{self, WindowsState}, +}; +use ide_core::{IdeError, IdeEvent, IdeResult}; +use serde::{Deserialize, Serialize}; +use specta::Type; +use std::path::PathBuf; +use std::sync::atomic::Ordering; +use tauri::{AppHandle, Manager, WebviewWindow}; + +#[derive(Clone, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub struct WindowTab { + pub label: String, + pub name: String, + pub root: Option, + pub current: bool, +} + +pub fn current(app: &AppHandle, caller: &str) -> Vec { + let state = app.state::(); + let Some(group) = state.groups.lock().group(caller).cloned() else { return Vec::new() }; + let host = app.state::(); + group + .members + .iter() + .filter_map(|label| { + let window = app.get_webview_window(label)?; + let project = host.project(label).ok(); + let name = project + .as_ref() + .map(|p| ide_host::ops::project::info(p).name) + .unwrap_or_else(|| window.title().unwrap_or_default()); + Some(WindowTab { + label: label.clone(), + name, + root: project.map(|p| p.root.clone().into()), + current: label == caller, + }) + }) + .collect() +} + +fn changed(app: &AppHandle) { + app.state::().sink.emit(IdeEvent::WindowGroupsChanged); +} + +fn copy_frame(from: &WebviewWindow, to: &WebviewWindow) -> IdeResult<()> { + let err = |e: tauri::Error| IdeError::internal(e.to_string()); + let fullscreen = from.is_fullscreen().map_err(err)?; + if fullscreen { + from.set_fullscreen(false).map_err(err)?; + to.set_fullscreen(true).map_err(err)?; + return Ok(()); + } + to.set_fullscreen(false).map_err(err)?; + let maximized = from.is_maximized().map_err(err)?; + if !maximized { + to.unmaximize().map_err(err)?; + to.set_position(from.outer_position().map_err(err)?).map_err(err)?; + to.set_size(from.inner_size().map_err(err)?).map_err(err)?; + } else { + to.maximize().map_err(err)?; + } + Ok(()) +} + +pub fn select(app: &AppHandle, caller: &str, target: &str) -> IdeResult<()> { + let to = app.get_webview_window(target).ok_or_else(|| IdeError::not_found("Window closed"))?; + let state = app.state::(); + let previous = state.groups.lock().select(caller, target)?; + let result = (|| { + if previous.selected != target + && let Some(from) = app.get_webview_window(&previous.selected) + { + copy_frame(&from, &to)?; + } + to.unminimize().map_err(|e| IdeError::internal(e.to_string()))?; + to.show().map_err(|e| IdeError::internal(e.to_string()))?; + to.set_focus().map_err(|e| IdeError::internal(e.to_string()))?; + for member in &previous.members { + if member != target + && let Some(window) = app.get_webview_window(member) + { + window.hide().map_err(|e| IdeError::internal(e.to_string()))?; + } + } + Ok(()) + })(); + if result.is_err() { + let _ = state.groups.lock().select(caller, &previous.selected); + for member in &previous.members { + if let Some(window) = app.get_webview_window(member) { + if member == &previous.selected { + let _ = window.show(); + let _ = window.set_focus(); + } else { + let _ = window.hide(); + } + } + } + } + changed(app); + result +} + +pub fn merge(app: &AppHandle, caller: &str) -> IdeResult<()> { + let members = windows::project_window_labels(app) + .into_iter() + .filter(|label| app.state::().project(label).is_ok_and(|p| !p.light)) + .collect::>(); + if members.len() < 2 || !members.iter().any(|label| label == caller) { + return Err(IdeError::invalid("Open at least two project windows to create a window group")); + } + // Native fullscreen puts each window in a separate Space; leave it before grouping. + for label in &members { + if let Some(window) = app.get_webview_window(label) { + window.set_fullscreen(false).map_err(|e| IdeError::internal(e.to_string()))?; + } + } + app.state::().groups.lock().merge(members, caller)?; + select(app, caller, caller) +} + +pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> { + let state = app.state::(); + let next = state.groups.lock().detach(caller); + if let Some(next) = next { + if let Some(window) = app.get_webview_window(&next) { + window.show().map_err(|e| IdeError::internal(e.to_string()))?; + } + if let Some(window) = app.get_webview_window(caller) { + if let Ok(position) = window.outer_position() { + let _ = window.set_position(tauri::PhysicalPosition::new(position.x + 32, position.y + 32)); + } + windows::focus(&window); + } + } + changed(app); + Ok(()) +} + +pub fn destroyed(app: &AppHandle, caller: &str) { + let Some(state) = app.try_state::() else { return }; + let next = state.groups.lock().detach(caller); + if !state.quitting() { + if let Some(window) = next.and_then(|label| app.get_webview_window(&label)) { + windows::focus(&window); + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + let _ = persist(&app).await; + }); + } + changed(app); +} + +#[derive(Serialize, Deserialize)] +struct SavedGroup { + projects: Vec, + selected: usize, +} + +pub async fn persist(app: &AppHandle) -> IdeResult<()> { + let state = app.state::(); + let _write = state.groups_persist.lock().await; + if state.quitting() { + return Ok(()); + } + let groups = state.groups.lock().groups().to_vec(); + let host = app.state::(); + let saved: Vec<_> = groups + .iter() + .filter_map(|group| { + let projects: Vec<_> = group + .members + .iter() + .filter_map(|label| { + host.project(label) + .ok() + .map(|p| p.root.clone()) + .or_else(|| state.pending(label).and_then(|l| l.project).map(Into::into)) + }) + .collect(); + (projects.len() == group.members.len()).then(|| SavedGroup { + projects, + selected: group.members.iter().position(|l| l == &group.selected).unwrap_or(0), + }) + }) + .collect(); + // A restored member may have taken its launch but not finished opening yet. + if saved.len() != groups.len() { + return Ok(()); + } + let path = host.config_dir.join("window-groups.json"); + tauri::async_runtime::spawn_blocking(move || { + let bytes = serde_json::to_vec(&saved).map_err(|e| IdeError::internal(e.to_string()))?; + ide_vfs::write_atomic(&path, &bytes).map(|_| ()) + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))? +} + +pub async fn restore(app: &AppHandle, caller: &str) -> IdeResult<()> { + if caller != "main" { + return Ok(()); + } + let state = app.state::(); + let host = app.state::(); + let project = host.project(caller)?; + if !project.settings.effective().general.reopen_last_project || state.groups_restored.swap(true, Ordering::SeqCst) { + return Ok(()); + } + let path = host.config_dir.join("window-groups.json"); + let saved: Vec = tauri::async_runtime::spawn_blocking(move || -> IdeResult<_> { + match std::fs::read(path) { + Ok(bytes) => serde_json::from_slice(&bytes).map_err(|e| IdeError::invalid(e.to_string())), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(e.into()), + } + }) + .await + .map_err(|e| IdeError::internal(e.to_string()))??; + if let Some(group) = saved.iter().find(|g| g.projects.contains(&project.root)) { + let mut labels = Vec::new(); + let mut selected = caller.to_owned(); + for path in &group.projects { + let label = if path == &project.root { + Some(caller.to_owned()) + } else if path.is_dir() { + let window = match windows::find_project_window(app, path) { + Some(w) => w, + None => windows::create_window(app, Some(path.clone()))?, + }; + Some(window.label().to_owned()) + } else { + None + }; + if let Some(label) = label { + if group.projects.get(group.selected) == Some(path) { + selected = label.clone(); + } + labels.push(label); + } + } + if labels.len() > 1 { + state.groups.lock().merge(labels, caller)?; + select(app, caller, &selected)?; + } + } + Ok(()) +} diff --git a/src-tauri/src/windows.rs b/src-tauri/src/windows.rs index 133e3b70..c0076a1f 100644 --- a/src-tauri/src/windows.rs +++ b/src-tauri/src/windows.rs @@ -59,6 +59,9 @@ pub struct RemoteLaunch { #[derive(Default)] pub struct WindowsState { + pub groups: Mutex, + pub groups_restored: AtomicBool, + pub groups_persist: tokio::sync::Mutex<()>, launches: Mutex>, /// Most recently focused window label (menu events go there). last_focused: Mutex>, @@ -84,7 +87,7 @@ impl WindowsState { launch.files.extend(files.into_iter().map(Into::into)); } - fn pending(&self, label: &str) -> Option { + pub(crate) fn pending(&self, label: &str) -> Option { self.launches.lock().get(label).cloned() } @@ -193,11 +196,33 @@ pub fn cli_path(args: &[String], cwd: &Path) -> Option { } pub fn focus(window: &WebviewWindow) { + if crate::window_groups::select(window.app_handle(), window.label(), window.label()).is_ok() { + return; + } let _ = window.unminimize(); let _ = window.show(); let _ = window.set_focus(); } +/// Present one project's save prompt at a time, including hidden group members. +pub fn continue_quit(app: &AppHandle, closed: Option<&str>) { + if !app.state::().quitting() { + return; + } + let next = project_window_labels(app) + .into_iter() + .filter(|label| Some(label.as_str()) != closed) + .find_map(|label| app.get_webview_window(&label)); + if let Some(window) = next { + focus(&window); + if window.close().is_err() { + app.state::().set_quitting(false); + } + } else { + app.exit(0); + } +} + /// The window whose open project is `root` (already canonical). pub fn find_project_window(app: &AppHandle, root: &Path) -> Option { let state = app.try_state::()?; diff --git a/src/bindings.ts b/src/bindings.ts index ed088c48..425416d1 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -11,6 +11,16 @@ export const commands = { recentProjects: () => __TAURI_INVOKE("recent_projects"), removeRecentProject: (path: string) => typedError(__TAURI_INVOKE("remove_recent_project", { path })), openProject: (path: string) => typedError(__TAURI_INVOKE("open_project", { path })), + workspaceCreate: (path: string, name: string, projects: string[]) => typedError(__TAURI_INVOKE("workspace_create", { path, name, projects })), + workspaceAttach: (path: string) => typedError(__TAURI_INVOKE("workspace_attach", { path })), + workspaceUpdate: (name: string, projects: string[]) => typedError(__TAURI_INVOKE("workspace_update", { name, projects })), + windowGroupCurrent: () => typedError(__TAURI_INVOKE("window_group_current")), + windowGroupMerge: () => typedError(__TAURI_INVOKE("window_group_merge")), + windowGroupSelect: (label: string) => typedError(__TAURI_INVOKE("window_group_select", { label })), + windowGroupDetach: () => typedError(__TAURI_INVOKE("window_group_detach")), + windowGroupClose: (label: string) => typedError(__TAURI_INVOKE("window_group_close", { label })), + windowGroupReorder: (labels: string[]) => typedError(__TAURI_INVOKE("window_group_reorder", { labels })), + windowGroupRestore: () => typedError(__TAURI_INVOKE("window_group_restore")), /** * Edits the loose file `path` in this window without a project (LightEdit); * a window already in LightEdit keeps its files. Returns the effective (global) settings. @@ -22,6 +32,7 @@ export const commands = { name: string, isGit: boolean, settings: Settings_Serialize, + workspace?: Workspace_Serialize | null, } | null>("project_current"), closeProject: () => __TAURI_INVOKE("close_project"), languages: () => __TAURI_INVOKE("languages"), @@ -1270,6 +1281,8 @@ export type AgentSubagentStatus = "running" | "needsInput" | "done" | "cancelled" | "failed"; export type AgentTask = { + /** Original project folder; absent in tasks saved before per-task roots. */ + projectRoot?: IpcPath | null, id: string, title: string, /** [`AgentProvider::id`]. */ @@ -3084,7 +3097,7 @@ export type FunctionBreakpoint = { export type GeneralSettings = { reopenLastProject?: boolean, confirmExit?: boolean, - /** Where Open… puts a project while one is open: `ask` | `new` | `same`. */ + /** Where Open… puts a project while one is open: `ask` | `new` | `same` | `attach`. */ openProjectIn?: string, /** Initial folder of the Open / Clone dialogs; empty = the OS default. */ defaultProjectDirectory?: string, @@ -3355,6 +3368,8 @@ export type IdeError = { }; export type IdeEvent = +/** Project window membership or selection changed. */ +{ type: "windowGroupsChanged" } | /** Files or directories changed on disk (after debouncing, ignore rules applied). */ { type: "fsChanged"; created: IpcPath[]; modified: IpcPath[]; removed: IpcPath[] } | /** @@ -4220,6 +4235,8 @@ export type MethodStat = { }; export type NewAgentTask = { + /** Validated by the host; defaults to the window project root. */ + projectRoot?: IpcPath | null, prompt: AgentPrompt, provider: string, /** Settings for the new session (model, mode…). */ @@ -4646,6 +4663,7 @@ export type ProjectInfo_Deserialize = { name: string, isGit: boolean, settings: Settings_Deserialize, + workspace?: Workspace_Deserialize | null, }; export type ProjectInfo_Serialize = { @@ -4653,6 +4671,7 @@ export type ProjectInfo_Serialize = { name: string, isGit: boolean, settings: Settings_Serialize, + workspace?: Workspace_Serialize | null, }; export type ProjectRoots = { @@ -6121,6 +6140,15 @@ export type WebReferenceKind = /** `var(--name)`: the custom property's declarations. */ "cssVariable"; +export type WindowTab = { + label: string, + name: string, + root: IpcPath | null, + current: boolean, +}; + +export type Workspace = Workspace_Serialize | Workspace_Deserialize; + /** Edits to many files, applied atomically from the user's point of view. */ export type WorkspaceEdit = { files: FileEdit[], @@ -6133,6 +6161,18 @@ export type WorkspaceSymbol = { location: Location, }; +export type Workspace_Deserialize = { + version: number, + name: string, + projects: string[], +}; + +export type Workspace_Serialize = { + version: number, + name: string, + projects: string[], +}; + export type Worktree = { path: IpcPath, /** Checked-out commit; `None` for a bare main repository. */ diff --git a/src/editor/core/controller.test.ts b/src/editor/core/controller.test.ts index 761bb64c..1d17e2af 100644 --- a/src/editor/core/controller.test.ts +++ b/src/editor/core/controller.test.ts @@ -170,6 +170,32 @@ 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 () => { + const settings = {}; + useProject.setState({ + project: { root: "/attach-a", name: "A", isGit: false, settings }, + settings, + lightEdit: false, + }); + ctrl = new EditorController(createMemoryBackend({ "/attach-a/one.txt": "one", "/attach-a/two.txt": "two" })); + ctrl.start(); + await ctrl.openFile("/attach-a/one.txt", { focus: false, preview: false }); + await ctrl.openFile("/attach-a/two.txt", { focus: false, preview: false }); + useProject.setState({ + project: { + root: "/attach-workspace", + name: "A", + 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"]), + ); + useProject.setState({ project: null }); + }); + it("reopens the LightEdit files the next time a window switches to LightEdit", async () => { ctrl = new EditorController(createMemoryBackend({ "/x/a.txt": "a", "/y/b.md": "b" })); ctrl.start(); diff --git a/src/editor/core/controller.ts b/src/editor/core/controller.ts index ff1a6859..9188fcb8 100644 --- a/src/editor/core/controller.ts +++ b/src/editor/core/controller.ts @@ -262,6 +262,15 @@ export class EditorController implements SlotHost { prevRoot = root; prevLight = s.lightEdit; this.saveSession(); + if ( + root && + previous.project && + !previous.project.workspace && + s.project?.workspace?.projects[0] === previous.project.root + ) { + const session = loadSession(previous.project.root); + if (session) storeSession(root, session); + } this.sessionRoot = null; const generation = ++this.projectGeneration; const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds); diff --git a/src/editor/core/index.ts b/src/editor/core/index.ts index e584d2ac..68018d91 100644 --- a/src/editor/core/index.ts +++ b/src/editor/core/index.ts @@ -40,6 +40,7 @@ export function activate(): void | (() => void) { activePath: () => ctrl.activePath(), activeCursor: () => ctrl.activeCursor(), saveAll: () => ctrl.saveAll(), + dirtyDocumentIds: () => [...ctrl.manager.byId.values()].filter((m) => m.getMeta().dirty).map((m) => m.doc), focus: () => ctrl.focus(), openPaths: () => ctrl.openPaths(), openTabs: () => ctrl.openTabs(), diff --git a/src/editor/core/view/StatusItems.tsx b/src/editor/core/view/StatusItems.tsx index 99776feb..3026e7d8 100644 --- a/src/editor/core/view/StatusItems.tsx +++ b/src/editor/core/view/StatusItems.tsx @@ -4,7 +4,7 @@ import clsx from "clsx"; import { useShallow } from "zustand/react/shallow"; import { executeAction } from "@/platform/actions"; import { shortcutLabel } from "@/platform/keymap"; -import { basename, dirname, relativePath, useProject } from "@/platform/project"; +import { basename, dirname, projectFolder, relativePath, useProject } from "@/platform/project"; import type { LineEnding, OutlineItem } from "@/platform/ipc"; import { BarButton, Icon, Menu, badgeKind, fileIconName } from "@/ui"; import { notifications } from "@/workbench/contributions"; @@ -56,7 +56,8 @@ interface Crumb { */ export function Breadcrumbs() { const path = useEditorStatus((s) => s.status?.path ?? null); - const project = useProject((s) => s.project); + const currentProject = useProject((s) => s.project); + const project = projectFolder(currentProject, path ?? ""); const outline = useOutline((s) => (path ? s[path] : undefined)); const chain = useCaretSymbols(path, outline) ?? NO_SYMBOLS; const [popup, setPopup] = useState<{ nav: NavSegment; anchor: HTMLElement } | null>(null); diff --git a/src/editor/core/view/statusItems.test.tsx b/src/editor/core/view/statusItems.test.tsx index 6e3cbcf2..bb21b74c 100644 --- a/src/editor/core/view/statusItems.test.tsx +++ b/src/editor/core/view/statusItems.test.tsx @@ -74,3 +74,21 @@ describe("caret-driven status widgets", () => { expect(commits).toBeGreaterThan(0); }); }); + +it("shows the member and its directories in workspace breadcrumbs", async () => { + const { useProject } = await import("@/platform/project"); + useProject.setState({ + project: { + root: "/workspace", + name: "Workspace", + settings: {}, + isGit: false, + workspace: { version: 1, name: "Workspace", projects: ["/a", "/b"] }, + }, + }); + useEditorStatus.setState({ status: { ...caret(0, 0), path: "/b/src/file.ts" } }); + const view = render(); + expect(screen.getByRole("navigation", { name: "Breadcrumbs" }).textContent).toBe("bsrcfile.ts"); + view.unmount(); + useProject.setState({ project: null }); +}); diff --git a/src/features/agents/Agents.module.css b/src/features/agents/Agents.module.css index 39722d6f..d4c0a47a 100644 --- a/src/features/agents/Agents.module.css +++ b/src/features/agents/Agents.module.css @@ -50,6 +50,14 @@ border-radius: 50%; background: var(--accent); } +.taskProject { + flex: 0 0 auto; + max-width: 35%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--fg-subtle); +} .detail { flex: 1 1 0; min-width: 0; diff --git a/src/features/agents/AgentsView.tsx b/src/features/agents/AgentsView.tsx index b5b50ca3..97b8885c 100644 --- a/src/features/agents/AgentsView.tsx +++ b/src/features/agents/AgentsView.tsx @@ -7,6 +7,7 @@ import { SESSION_KIND, age, branchLabel, groupTasks, type TaskGroup } from "./mo import { openHome, openTask, useAgents } from "./store"; import { taskMenu } from "./taskActions"; import styles from "./Agents.module.css"; +import { basename, useProject } from "@/platform/project"; import { useTx } from "@/platform/i18n"; export function AgentsHeaderActions() { @@ -43,6 +44,7 @@ export function TaskStatusIcon({ status }: { status: Status }) { } export function AgentsView() { + const project = useProject((s) => s.project); const tx = useTx(); const loaded = useAgents((s) => s.loaded); const error = useAgents((s) => s.error); @@ -129,6 +131,11 @@ export function AgentsView() { {unread.has(n.task.id) && ( )} + {project?.workspace && ( + + {basename(n.task.projectRoot ?? project.root)} + + )} {providerName(n.task.provider)} {n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""} diff --git a/src/features/agents/HomeEditor.tsx b/src/features/agents/HomeEditor.tsx index c4d6a4b4..0d0b1392 100644 --- a/src/features/agents/HomeEditor.tsx +++ b/src/features/agents/HomeEditor.tsx @@ -4,8 +4,10 @@ import type { AgentProbe, Branch } from "@/bindings"; import { executeAction } from "@/platform/actions"; import { call, commands } from "@/platform/ipc"; import { useTx, useTxTemplate } from "@/platform/i18n"; -import { useProject } from "@/platform/project"; +import { basename, useProject } from "@/platform/project"; import { Button, Icon, Select, T, type SelectOption } from "@/ui"; +import { defaultTaskProject } from "./project"; +import { maybeEditorService } from "@/workbench/contributions"; import { Composer } from "./Composer"; import { canSend, emptyDraft } from "./composerModel"; import { ConfigPickers } from "./ConfigPickers"; @@ -22,8 +24,19 @@ export function HomeEditor() { const history = useAgents((s) => s.history); const composer = useAgents((s) => s.composers.get(HOME)) ?? emptyDraft(); const project = useProject((s) => s.project); - const [branches, setBranches] = useState([]); - const [current, setCurrent] = useState(null); + const [selected, setSelected] = useState(() => + defaultTaskProject(project, maybeEditorService()?.activePath() ?? null, draft.projectRoot), + ); + const members = project?.workspace?.projects ?? []; + const root = members.includes(selected ?? "") ? selected! : (members[0] ?? project?.root ?? ""); + const [repoState, setRepoState] = useState<{ + root: string; + branches: Branch[]; + current: string | null; + isGit: boolean; + } | null>(null); + const branches = repoState?.root === root ? repoState.branches : []; + const current = repoState?.root === root ? repoState.current : null; const [error, setError] = useState(null); const [starting, setStarting] = useState(false); @@ -35,31 +48,37 @@ export function HomeEditor() { }, []); useEffect(() => { - call(commands.vcsBranches(null)) - .then((list) => { - const local = list.filter((b) => !b.remote); - setBranches(local); - setCurrent(local.find((b) => b.current)?.name ?? null); + let live = true; + const repo = project?.workspace ? root : null; + void Promise.all([call(commands.vcsBranchInfo(repo)), call(commands.vcsBranches(repo))]) + .then(([info, list]) => { + if (!live) return; + setRepoState({ root, branches: list.filter((b) => !b.remote), current: info.head, isGit: true }); }) - .catch(() => setBranches([])); - }, []); + .catch(() => { + if (live) setRepoState({ root, branches: [], current: null, isGit: false }); + }); + return () => { + live = false; + }; + }, [root, project?.workspace]); const provider = providers.find((p) => p.id === draft.provider) ?? providers[0]; const probe = useProbe(provider?.installed ? provider.id : null); const agentCommands = useAgentCommands(provider?.id, null, provider?.commands ?? NO_COMMANDS); const providerOptions: SelectOption[] = agentOptions(providers, tx, providerIcon); // Without git under the project there are no worktrees: force the local checkout. - const isGit = project?.isGit ?? false; + const isGit = project?.workspace ? repoState?.root === root && repoState.isGit : (project?.isGit ?? false); const isolation = isGit ? draft.isolation : "local"; const base = draft.base ?? current; - const canStart = canSend(composer) && !!provider?.installed && !starting; - const root = project?.root ?? ""; + const canStart = + canSend(composer) && !!provider?.installed && !starting && (!project?.workspace || repoState?.root === root); const start = () => { if (!canStart) return; setStarting(true); setError(null); - startTask(isolation) + startTask(isolation, project?.workspace ? root : null) .catch((e: unknown) => setError(e instanceof Error ? e.message : String(e))) .finally(() => setStarting(false)); }; @@ -104,7 +123,20 @@ export function HomeEditor() { } />
- {project?.name} + {members.length > 1 ? ( + s.tasks.get(id)); const entries = useAgents((s) => s.entries.get(id)); const changes = useAgents((s) => s.changes.get(id)); - const head = useLocalHead(task?.isolation === "local" ? task.status : null); + const project = useProject((s) => s.project); + const head = useLocalHead( + task?.isolation === "local" ? task.status : null, + project?.workspace ? (task?.projectRoot ?? project.root) : null, + ); const [changesOpen, setChangesOpen] = useChangesOpen(id, changes ?? null); // The subagent conversation shown, with the subagents that lead to it (empty: the task's own). const [path, setPath] = useState([]); @@ -160,14 +164,14 @@ function providerName(id: string) { * Current branch of the project's repository for a local-checkout task (`status` is * `null` otherwise); followed as git changes, since the agent or the user may switch it. */ -function useLocalHead(status: AgentTask["status"] | null): string | null { +function useLocalHead(status: AgentTask["status"] | null, root: string | null): string | null { const [head, setHead] = useState(null); const local = status !== null; useEffect(() => { if (!local) return; let live = true; const load = () => - void call(commands.vcsBranchInfo(null)).then( + void call(commands.vcsBranchInfo(root)).then( (b) => live && setHead(b.head ?? b.headCommit?.slice(0, 8) ?? null), () => live && setHead(null), ); @@ -177,7 +181,7 @@ function useLocalHead(status: AgentTask["status"] | null): string | null { live = false; off(); }; - }, [local, status]); + }, [local, status, root]); return local ? head : null; } @@ -196,6 +200,8 @@ function Header({ changesOpen: boolean; onToggleChanges: () => void; }) { + const project = useProject((s) => s.project); + const projectName = project?.workspace ? basename(task.projectRoot ?? project.root) : null; const count = changes?.length ?? 0; const busy = isBusy(task.status); const where = task.isolation === "worktree" ? branchLabel(task.branch) : null; @@ -216,6 +222,7 @@ function Header({ {providerName(task.provider)} {task.model ? ` · ${task.model}` : ""} + {projectName ? ` · ${projectName}` : ""} {" · "} {where ?? ( <> diff --git a/src/features/agents/composerCommands.test.tsx b/src/features/agents/composerCommands.test.tsx index 5b96c859..c79b1245 100644 --- a/src/features/agents/composerCommands.test.tsx +++ b/src/features/agents/composerCommands.test.tsx @@ -5,17 +5,20 @@ import type { AgentCommand, AgentProvider } from "@/bindings"; const agentCommands = vi.fn(); const agentCreate = vi.fn(); +const vcsBranches = vi.fn(); +const vcsBranchInfo = vi.fn(); vi.mock("@/platform/ipc", async (importOriginal) => { const real = await importOriginal(); return { ...real, - isTauri: () => true, + isTauri: vi.fn(() => true), commands: { ...real.commands, agentCommands: (provider: string, task: string | null) => agentCommands(provider, task), agentCreate: (request: unknown) => agentCreate(request), - vcsBranches: () => Promise.resolve({ status: "ok", data: [] }), + vcsBranchInfo: (root: string | null) => vcsBranchInfo(root), + vcsBranches: (root: string | null) => vcsBranches(root), }, }; }); @@ -23,6 +26,10 @@ vi.mock("@tauri-apps/api/webview", () => ({ getCurrentWebview: () => ({ onDragDropEvent: () => Promise.resolve(() => {}) }), })); +import { isTauri } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import type { ProjectInfo } from "@/bindings"; +import { defaultTaskProject } from "./project"; import { HomeEditor } from "./HomeEditor"; import { HOME, updateComposer, useAgents } from "./store"; import { emptyDraft } from "./composerModel"; @@ -40,6 +47,14 @@ const claude: AgentProvider = { }; beforeEach(() => { + vi.mocked(isTauri).mockReturnValue(true); + useProject.setState({ project: null }); + vcsBranches.mockReset().mockResolvedValue({ status: "ok", data: [] }); + vcsBranchInfo + .mockReset() + .mockImplementation((root: string | null) => + Promise.resolve({ status: "ok", data: { head: root === "/project-b" ? "b-main" : "a-main" } }), + ); agentCommands.mockReset(); agentCreate.mockReset().mockResolvedValue({ status: "error", error: { kind: "invalid", message: "stop here" } }); useAgents.setState({ @@ -105,3 +120,40 @@ describe("New Task composer commands", () => { expect(agentCreate).not.toHaveBeenCalled(); }); }); + +describe("workspace task project", () => { + const project = { + root: "/workspace", + name: "Workspace", + isGit: false, + workspace: { version: 1, name: "Workspace", projects: ["/project-a", "/project-b"] }, + } as ProjectInfo; + it("defaults to the active member, last used member, then first member", () => { + expect(defaultTaskProject(project, "/project-b/src/main.ts", "/project-a")).toBe("/project-b"); + expect(defaultTaskProject(project, null, "/project-b")).toBe("/project-b"); + expect(defaultTaskProject(project, "/project-b-other/file", "/removed")).toBe("/project-a"); + expect(defaultTaskProject({ ...project, workspace: null }, "/project-b/file", "/project-b")).toBe("/workspace"); + }); + it.each(["local", "worktree"] as const)("sends the selected project with %s isolation", async (isolation) => { + vi.mocked(isTauri).mockReturnValue(false); + useProject.setState({ project }); + agentCommands.mockResolvedValue({ status: "ok", data: [] }); + useAgents.setState({ draft: { ...useAgents.getState().draft, projectRoot: "/project-b", isolation } }); + render(); + const selector = screen.getByRole("button", { name: "Project" }); + expect(selector).toHaveTextContent("project-b"); + await waitFor(() => expect(vcsBranchInfo).toHaveBeenCalledWith("/project-b")); + expect(vcsBranches).toHaveBeenCalledWith("/project-b"); + fireEvent.click(selector); + expect(within(screen.getByRole("listbox")).getByText("/project-a")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("option", { name: /project-a/ })); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "Update member" } }); + await waitFor(() => expect(screen.getByRole("button", { name: "Start" })).toBeEnabled()); + fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); + await waitFor(() => expect(agentCreate).toHaveBeenCalled()); + expect(agentCreate.mock.calls[0]![0]).toMatchObject({ projectRoot: "/project-a", isolation }); + expect(vcsBranchInfo).toHaveBeenLastCalledWith("/project-a"); + expect(vcsBranches).toHaveBeenLastCalledWith("/project-a"); + await vi.dynamicImportSettled(); + }); +}); diff --git a/src/features/agents/project.ts b/src/features/agents/project.ts new file mode 100644 index 00000000..885dc292 --- /dev/null +++ b/src/features/agents/project.ts @@ -0,0 +1,13 @@ +import { projectFolder } from "@/platform/project"; + +/** Prefer the active file's member, then the last task's member, then manifest order. */ +export function defaultTaskProject( + project: Parameters[0], + activePath: string | null, + last: string | null | undefined, +): string | null { + const members = project?.workspace?.projects; + if (!members?.length) return project?.root ?? null; + const active = activePath ? projectFolder(project, activePath)?.root : null; + return active ?? (last && members.includes(last) ? last : (members[0] ?? null)); +} diff --git a/src/features/agents/store.ts b/src/features/agents/store.ts index 8d52a074..8983037f 100644 --- a/src/features/agents/store.ts +++ b/src/features/agents/store.ts @@ -12,6 +12,8 @@ import type { AgentTask, Isolation, } from "@/bindings"; +import { useProject } from "@/platform/project"; +import { defaultTaskProject } from "./project"; import { call, commands, isTauri } from "@/platform/ipc"; import { editorService, notifications } from "@/workbench/contributions"; import { bypassDefaults, canSend, emptyDraft, toPrompt, type ComposerDraft, type FileRef } from "./composerModel"; @@ -29,6 +31,7 @@ import { /** New Task page settings (its text lives in the `HOME` composer). */ export interface Draft { + projectRoot?: string | null; provider: string; /** Settings chosen for the next task, by option id (reset when the agent changes). */ config: Record; @@ -343,18 +346,28 @@ export function openTask(task: string) { /** Opens the New Task page; `prompt` pre-fills it. */ export function openHome(prompt?: string) { + const project = useProject.getState().project; + if (project?.workspace) { + const previous = useAgents.getState().draft.projectRoot; + const projectRoot = defaultTaskProject(project, editorService().activePath(), previous); + updateDraft({ projectRoot, ...(projectRoot !== previous ? { base: null } : {}) }); + } if (prompt !== undefined) updateComposer(HOME, { text: prompt }); void editorService().openInput({ kind: HOME_KIND, key: HOME_KEY, data: {} }, { focus: true }); } /** `isolation` overrides the draft's (a project without git can only use the local checkout). */ -export async function startTask(isolation: Isolation = useAgents.getState().draft.isolation): Promise { +export async function startTask( + isolation: Isolation = useAgents.getState().draft.isolation, + projectRoot: string | null = null, +): Promise { const { draft, providers } = useAgents.getState(); const composer = composerDraft(HOME); if (!canSend(composer)) return; const config = { ...bypassDefaults(providers.find((p) => p.id === draft.provider)?.config ?? []), ...draft.config }; const task = await call( commands.agentCreate({ + projectRoot, prompt: toPrompt(composer), provider: draft.provider, config: Object.entries(config).map(([id, value]) => ({ id, value })), @@ -367,6 +380,7 @@ export async function startTask(isolation: Isolation = useAgents.getState().draf tasks: s.tasks.has(task.id) ? s.tasks : new Map(s.tasks).set(task.id, task), entries: new Map(s.entries).set(task.id, s.entries.get(task.id) ?? []), })); + updateDraft({ projectRoot }); remember(composer.text.trim()); updateComposer(HOME, emptyDraft()); if ((await editorService().closeInput?.(HOME_KEY)) !== false) openTask(task.id); diff --git a/src/features/app/WorkspaceDialog.module.css b/src/features/app/WorkspaceDialog.module.css new file mode 100644 index 00000000..2bf82c56 --- /dev/null +++ b/src/features/app/WorkspaceDialog.module.css @@ -0,0 +1,68 @@ +.body { + display: flex; + flex-direction: column; + gap: 16px; + padding: 20px; +} +.field { + display: grid; + grid-template-columns: 80px 1fr; + align-items: center; + gap: 12px; +} +.row { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} +.row > input { + flex: 1; + min-width: 0; +} +.heading { + display: flex; + align-items: center; + justify-content: space-between; + font-weight: 600; +} +.projects { + min-height: 130px; + max-height: 260px; + overflow: auto; + border: 1px solid var(--border); + border-radius: var(--radius-small); +} +.project { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 12px; +} +.project + .project { + border-top: 1px solid var(--border); +} +.path { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + gap: 3px; +} +.path span { + color: var(--fg-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.hint { + color: var(--fg-muted); + margin: 0; + line-height: 1.5; +} +.projects > .hint { + padding: 16px; +} +.error { + color: var(--danger); +} diff --git a/src/features/app/WorkspaceDialog.tsx b/src/features/app/WorkspaceDialog.tsx new file mode 100644 index 00000000..df5529f4 --- /dev/null +++ b/src/features/app/WorkspaceDialog.tsx @@ -0,0 +1,172 @@ +import { useState } from "react"; +import { create } from "zustand"; +import { call, commands } from "@/platform/ipc"; +import { basename, dirname, useProject } from "@/platform/project"; +import { isRemoteWindow } from "@/platform/remote"; +import { tx } from "@/platform/i18n"; +import { Button, Dialog, Icon, IconButton, Input, T } from "@/ui"; +import { openProjectInWindow } from "./windows"; +import styles from "./WorkspaceDialog.module.css"; + +const useWorkspaceDialog = create<{ mode: "create" | "manage" | null }>(() => ({ mode: null })); +export const showWorkspaceDialog = (mode: "create" | "manage") => useWorkspaceDialog.setState({ mode }); + +export function WorkspaceDialogHost() { + const mode = useWorkspaceDialog((s) => s.mode); + return mode ? useWorkspaceDialog.setState({ mode: null })} /> : null; +} + +function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose: () => void }) { + const project = useProject.getState().project; + const workspace = mode === "manage" ? project?.workspace : null; + const [name, setName] = useState(workspace?.name ?? "Workspace"); + const [path, setPath] = useState( + workspace ? project!.root : project && !isRemoteWindow() ? `${dirname(project.root)}/workspace` : "", + ); + const [projects, setProjects] = useState( + workspace?.projects ?? (project && !isRemoteWindow() && !project.workspace ? [project.root] : []), + ); + const [adding, setAdding] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const add = (value: string) => { + const path = value.trim(); + if (path && !projects.includes(path)) setProjects([...projects, path]); + setAdding(""); + }; + const browse = async (target: "workspace" | "project") => { + const { open } = await import("@tauri-apps/plugin-dialog"); + const result = await open({ + directory: true, + multiple: target === "project", + title: tx(target === "project" ? "Add Projects" : "Workspace Location"), + }); + const paths = result === null ? [] : Array.isArray(result) ? result : [result]; + if (target === "workspace" && paths[0]) setPath(paths[0]); + else setProjects((current) => [...new Set([...current, ...paths])]); + }; + const save = async () => { + if (busy) return; + setBusy(true); + setError(""); + try { + const included = adding.trim() ? [...new Set([...projects, adding.trim()])] : projects; + if (mode === "create") { + const root = await call(commands.workspaceCreate(path.trim(), name.trim(), included)); + await openProjectInWindow(root, !!project); + } else { + 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)); + } finally { + setBusy(false); + } + }; + return ( + { + if (!busy) onClose(); + }} + title={mode === "create" ? "New Workspace" : "Manage Workspace"} + width={640} + buttons={[ + { label: "Cancel", role: "cancel", disabled: busy }, + { + label: mode === "create" ? "Create" : "Apply", + role: "default", + disabled: busy || !name.trim() || !path.trim(), + onClick: () => void save(), + }, + ]} + > +
+ + +
+ Linked projects + +
+
+ {projects.map((root) => ( +
+ +
+ {basename(root)} + {root} +
+ setProjects(projects.filter((p) => p !== root))} + /> +
+ ))} + {!projects.length && ( +

+ Add the projects you want to work on together. +

+ )} +
+
+ setAdding(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + e.stopPropagation(); + add(adding); + } + }} + /> + +
+

+ Project folders stay in place. Removing a project here keeps its files. +

+ {error && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/src/features/app/__tests__/WorkspaceDialog.test.tsx b/src/features/app/__tests__/WorkspaceDialog.test.tsx new file mode 100644 index 00000000..262cfb27 --- /dev/null +++ b/src/features/app/__tests__/WorkspaceDialog.test.tsx @@ -0,0 +1,42 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { commands } from "@/platform/ipc"; +import { useProject } from "@/platform/project"; +import { WorkspaceDialogHost, showWorkspaceDialog } from "../WorkspaceDialog"; +import { openProjectInWindow } from "../windows"; + +vi.mock("../windows", () => ({ openProjectInWindow: vi.fn(async () => {}) })); +vi.mock("@/platform/remote", () => ({ isRemoteWindow: () => false })); + +beforeEach(() => { + vi.restoreAllMocks(); + useProject.setState({ project: null, settings: null, lightEdit: false }); +}); + +it("creates a workspace from the pending project field without an extra Add click", async () => { + const save = vi.spyOn(commands, "workspaceCreate").mockResolvedValue({ status: "ok", data: "/work/full" }); + showWorkspaceDialog("create"); + render(); + fireEvent.change(screen.getByLabelText("Workspace location"), { target: { value: "/work/full" } }); + fireEvent.change(screen.getByLabelText("Project folder path"), { target: { value: "/work/frontend" } }); + fireEvent.click(screen.getByRole("button", { name: /^Create$/ })); + await waitFor(() => expect(save).toHaveBeenCalledWith("/work/full", "Workspace", ["/work/frontend"])); + expect(openProjectInWindow).toHaveBeenCalledWith("/work/full", false); + await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); +}); + +it("retains the form and linked folders when saving fails", async () => { + vi.spyOn(commands, "workspaceCreate").mockResolvedValue({ + status: "error", + error: { kind: "invalidArgument", message: "Folder does not exist" }, + } as never); + showWorkspaceDialog("create"); + render(); + fireEvent.change(screen.getByLabelText("Workspace location"), { target: { value: "/work/full" } }); + fireEvent.change(screen.getByLabelText("Project folder path"), { target: { value: "/work/missing" } }); + fireEvent.click(screen.getByRole("button", { name: /^Create$/ })); + expect(await screen.findByRole("alert")).toHaveTextContent("Folder does not exist"); + expect(screen.getByLabelText("Project folder path")).toHaveValue("/work/missing"); + fireEvent.click(screen.getByRole("button", { name: /^Cancel$/ })); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); +}); diff --git a/src/features/app/__tests__/closeGuard.test.ts b/src/features/app/__tests__/closeGuard.test.ts index 596365ac..9774d8a4 100644 --- a/src/features/app/__tests__/closeGuard.test.ts +++ b/src/features/app/__tests__/closeGuard.test.ts @@ -158,3 +158,24 @@ describe("decideClose", () => { expect(text).toBe("disk baseline"); }); }); + +it("includes pending local edits and restored dirty buffers before backend dirty events", async () => { + const service = await import("@/workbench/contributions"); + const { dirtyCount, installCloseGuard, confirmLeave } = await import("../closeGuard"); + const { emitLocalIdeEvent } = await import("@/platform/ipc"); + const { d, asked } = deps([{ button: "cancel", checked: false }]); + const uninstall = installCloseGuard(d); + const spy = vi.spyOn(service, "maybeEditorService").mockReturnValue({ dirtyDocumentIds: () => [10, 11] } as never); + try { + emitLocalIdeEvent({ type: "docDirty", doc: 11, dirty: true }); + emitLocalIdeEvent({ type: "docDirty", doc: 12, dirty: true }); + expect(dirtyCount()).toBe(3); + await expect(confirmLeave(d)).resolves.toBe(false); + expect(asked[0]?.body).toContain("3"); + } finally { + emitLocalIdeEvent({ type: "docDirty", doc: 11, dirty: false }); + emitLocalIdeEvent({ type: "docDirty", doc: 12, dirty: false }); + uninstall(); + spy.mockRestore(); + } +}); diff --git a/src/features/app/__tests__/windows.test.ts b/src/features/app/__tests__/windows.test.ts index 59dabc3f..bf63ac8a 100644 --- a/src/features/app/__tests__/windows.test.ts +++ b/src/features/app/__tests__/windows.test.ts @@ -1,5 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +const workspaceAttach = vi.fn(); +const workspaceUpdate = vi.fn(); +const projectCurrent = vi.fn(); const windowOpenProject = vi.fn(); const windowOpenFiles = vi.fn(); const windowTakeLaunch = vi.fn(); @@ -19,6 +22,9 @@ vi.mock("@/platform/ipc", async (orig) => { isTauri: () => true, commands: { ...real.commands, + workspaceAttach: (...a: unknown[]) => workspaceAttach(...a), + workspaceUpdate: (...a: unknown[]) => workspaceUpdate(...a), + projectCurrent: () => projectCurrent(), windowOpenProject: (...a: unknown[]) => windowOpenProject(...a), windowOpenFiles: (...a: unknown[]) => windowOpenFiles(...a), windowTakeLaunch: () => windowTakeLaunch(), @@ -32,8 +38,15 @@ vi.mock("@/platform/project", async (orig) => { return { ...real, openProject: (p: string) => openProject(p) }; }); -const { openProjectInWindow, consumeLaunch, openFiles, openPaths, openLightEditFileAsProject, windowActions } = - await import("../windows"); +const { + openProjectInWindow, + consumeLaunch, + openFiles, + openPaths, + openLightEditFileAsProject, + openProjectWithPolicy, + windowActions, +} = await import("../windows"); const { useProject } = await import("@/platform/project"); const { provideEditorService } = await import("@/workbench/contributions"); @@ -216,3 +229,56 @@ describe("launch", () => { expect(opened).toEqual(["/x/notes/a.txt (focus)"]); }); }); + +describe("Attach", () => { + it("switches a plain project to the automatic workspace in place", async () => { + useProject.setState({ + ...projectState("/p/a"), + settings: { general: { openProjectIn: "attach" } }, + } as never); + workspaceAttach.mockResolvedValue(ok("/data/workspaces/a")); + await openProjectWithPolicy("/p/b"); + expect(workspaceAttach).toHaveBeenCalledWith("/p/b"); + expect(openProject).toHaveBeenCalledWith("/data/workspaces/a"); + expect(windowOpenProject).not.toHaveBeenCalled(); + }); + it("refreshes an existing workspace without reopening its editor session", async () => { + const before = { + root: "/workspace", + name: "A", + isGit: false, + settings: {}, + workspace: { version: 1, name: "A", projects: ["/p/a"] }, + }; + useProject.setState({ + project: before, + settings: { general: { openProjectIn: "attach" } }, + } as never); + workspaceAttach.mockResolvedValue(ok("/workspace")); + projectCurrent.mockResolvedValue({ + ...before, + workspace: { ...before.workspace, projects: ["/p/a", "/p/b"] }, + }); + await openProjectWithPolicy("/p/b"); + expect(useProject.getState().project?.workspace?.projects).toEqual(["/p/a", "/p/b"]); + expect(openProject).not.toHaveBeenCalled(); + }); +}); + +it("removes the first workspace member while retaining the workspace state root", async () => { + const project = { + root: "/workspace", + name: "A", + isGit: false, + settings: {}, + workspace: { version: 1, name: "A", projects: ["/a", "/b"] }, + }; + useProject.setState({ project }); + workspaceUpdate.mockResolvedValue(ok({ ...project, workspace: { ...project.workspace, projects: ["/b"] } })); + await windowActions() + .find((a) => a.id === "workspace.removeProject")! + .run({ arg: "/a" } as never); + expect(workspaceUpdate).toHaveBeenCalledWith("A", ["/b"]); + expect(useProject.getState().project?.root).toBe("/workspace"); + expect(useProject.getState().project?.workspace?.projects).toEqual(["/b"]); +}); diff --git a/src/features/app/closeGuard.ts b/src/features/app/closeGuard.ts index 29739ab4..4194a168 100644 --- a/src/features/app/closeGuard.ts +++ b/src/features/app/closeGuard.ts @@ -16,6 +16,10 @@ import { ask, type Ask } from "@/workbench/messages"; /** Document ids the backend reports dirty in this window. */ const dirtyDocs = new Set(); +export function dirtyCount(): number { + return new Set([...dirtyDocs, ...(maybeEditorService()?.dirtyDocumentIds?.() ?? [])]).size; +} + export function confirmExitEnabled(settings: Settings | null | undefined): boolean { return settings?.general?.confirmExit === true; } @@ -116,7 +120,7 @@ async function onCloseRequested(deps: CloseDeps): Promise { quitting: ctx.quitting, windows: ctx.windows, confirmExit: confirmExitEnabled(s.settings), - dirty: dirtyDocs.size, + dirty: dirtyCount(), project: s.project?.name ?? null, }, deps, @@ -136,7 +140,7 @@ async function onCloseRequested(deps: CloseDeps): Promise { export function confirmLeave(deps: CloseDeps = defaultDeps): Promise { const s = useProject.getState(); return decideClose( - { quitting: false, windows: 2, confirmExit: false, dirty: dirtyDocs.size, project: s.project?.name ?? null }, + { quitting: false, windows: 2, confirmExit: false, dirty: dirtyCount(), project: s.project?.name ?? null }, deps, ); } diff --git a/src/features/app/index.ts b/src/features/app/index.ts index 27dbe5e2..57cffc85 100644 --- a/src/features/app/index.ts +++ b/src/features/app/index.ts @@ -17,6 +17,8 @@ import { NewProjectOverlay } from "./NewProject"; import { projectActions, registerPowerSaveBanner } from "./projectActions"; import { installWindowTitle } from "./title"; import { installLaunchHandling, installLightEditAutoClose, windowActions } from "./windows"; +import { WorkspaceDialogHost } from "./WorkspaceDialog"; +import { installWindowGroups } from "@/platform/windowGroups"; /** The UI locale of a secondary (Settings) window. */ export const activateLocale = installLocaleSync; @@ -43,6 +45,8 @@ export function activate(): () => void { installWindowTitle(), installCloseGuard(), installLaunchHandling(), + installWindowGroups(), + registerOverlay({ id: "app.workspace", component: WorkspaceDialogHost }), installLightEditAutoClose(), ]; let disposed = false; diff --git a/src/features/app/windows.ts b/src/features/app/windows.ts index 38c03b08..cc53050d 100644 --- a/src/features/app/windows.ts +++ b/src/features/app/windows.ts @@ -7,7 +7,7 @@ * Open File…, a file path on the command line) open in LightEdit: a window with * editor tabs only. One LightEdit window collects them all. */ -import type { Action } from "@/platform/actions"; +import { executeAction, type Action } from "@/platform/actions"; import { t } from "@/platform/i18n"; import { call, commands, isTauri } from "@/platform/ipc"; import { @@ -23,6 +23,8 @@ import { connectRemote, isRemoteWindow } from "@/platform/remote"; import { holdSplash } from "@/platform/splash"; import { editorService, maybeEditorService, notifications } from "@/workbench/contributions"; import { chooseProjectWindow } from "@/workbench/projectWindow"; +import { cycleWindowTab } from "@/platform/windowGroups"; +import { showWorkspaceDialog } from "./WorkspaceDialog"; export const LAUNCH_EVENT = "openidea://launch"; @@ -137,9 +139,32 @@ export async function openProjectWithPolicy(path: string, files: readonly string const { project: current, lightEdit } = useProject.getState(); if (!current && !lightEdit) return openProjectInWindow(path, false, files); if (current?.root === path) return openInEditor(files); + if (current?.workspace?.projects.includes(path)) { + await executeAction("project.reveal", path); + return openInEditor(files); + } const mode = await chooseProjectWindow(basename(path)); 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); + } + return; + } try { // A window that already has the project is focused instead. const outcome = await call(commands.windowOpenProject(path, false, [...files])); @@ -197,6 +222,107 @@ export async function consumeLaunch(): Promise { export function windowActions(): Action[] { return [ + { + 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 }); + }, + }, + { + id: "workspace.create", + title: "New Workspace…", + category: t("app.category.file"), + icon: "folder", + run: () => { + if (!desktopOnly()) showWorkspaceDialog("create"); + }, + }, + { + id: "workspace.open", + title: "Open Workspace…", + category: t("app.category.file"), + icon: "folder-open", + run: async () => { + if (desktopOnly()) return; + const path = await pickFolder("Open Workspace"); + if (path) await openProjectWithPolicy(path); + }, + }, + { + id: "workspace.manage", + title: "Manage Workspace…", + category: t("app.category.file"), + when: "workspaceOpen", + run: () => showWorkspaceDialog("manage"), + }, + { + id: "window.mergeAllWindows", + title: "Merge All Project Windows", + category: t("app.category.window"), + when: "projectOpen", + run: async () => { + if (!desktopOnly()) await call(commands.windowGroupMerge()); + }, + }, + { + id: "window.detachTab", + title: "Move Tab to New Window", + category: t("app.category.window"), + when: "windowGrouped", + run: () => call(commands.windowGroupDetach()), + }, + { + id: "window.selectTab", + title: "Select Project Tab", + hidden: true, + when: "windowGrouped", + run: async ({ arg }) => { + if (typeof arg === "string") await call(commands.windowGroupSelect(arg)); + }, + }, + { + id: "window.closeTab", + title: "Close Project Tab", + hidden: true, + when: "windowGrouped", + run: async ({ arg }) => { + if (typeof arg === "string") await call(commands.windowGroupClose(arg)); + }, + }, + { + id: "window.reorderTabs", + title: "Reorder Project Tabs", + hidden: true, + when: "windowGrouped", + run: async ({ arg }) => { + if (Array.isArray(arg) && arg.every((v) => typeof v === "string")) await call(commands.windowGroupReorder(arg)); + }, + }, + { + id: "window.nextTab", + title: "Next Project Tab", + category: t("app.category.window"), + when: "windowGrouped", + run: () => cycleWindowTab(true), + }, + { + id: "window.previousTab", + title: "Previous Project Tab", + category: t("app.category.window"), + when: "windowGrouped", + run: () => cycleWindowTab(false), + }, { id: "file.openInNewWindow", title: t("app.action.openInNewWindow"), diff --git a/src/features/project-tree/ProjectView.module.css b/src/features/project-tree/ProjectView.module.css index fb4a141a..e947b4ec 100644 --- a/src/features/project-tree/ProjectView.module.css +++ b/src/features/project-tree/ProjectView.module.css @@ -32,9 +32,11 @@ overflow: hidden; text-overflow: ellipsis; } -.rootLabel { +.rootName { flex: none; max-width: 60%; +} +.rootLabel { color: var(--fg-strong); font-weight: 600; } diff --git a/src/features/project-tree/ProjectView.test.tsx b/src/features/project-tree/ProjectView.test.tsx index 897995bc..610dd2e2 100644 --- a/src/features/project-tree/ProjectView.test.tsx +++ b/src/features/project-tree/ProjectView.test.tsx @@ -243,3 +243,11 @@ describe("ProjectView", () => { expect(rendered).toBeLessThan(80); }); }); + +it("renders workspace member roots with their own paths, without the settings directory", async () => { + fs = new MemoryFs({ "/z/first.txt": "", "/a/second.txt": "", "/workspace/.openidea/workspace.json": "{}" }); + setProjectFsForTests(fs, "/workspace"); + initProjectTree("/workspace", "Workspace", ["/z", "/a"]); + const { rows } = setup(); + await waitFor(() => expect(rows()).toEqual(["z/z", "first.txt", "a/a", "second.txt"])); +}); diff --git a/src/features/project-tree/ProjectView.tsx b/src/features/project-tree/ProjectView.tsx index 329ac6c1..2bf0b703 100644 --- a/src/features/project-tree/ProjectView.tsx +++ b/src/features/project-tree/ProjectView.tsx @@ -279,6 +279,7 @@ const Row = memo(function Row({ new Set(clipboard?.cut ? clipboard.paths : []), [clipboard]); - const rootDetail = root ? tildify(root, home) : null; const renderNode = useCallback( (node: ProjectNode, state: TreeRowState) => ( @@ -614,14 +614,14 @@ export function ProjectView() { editing={editing} cut={cutPaths.has(node.path)} dropTarget={dropTarget === node.key} - detail={node.root && !node.icon ? rootDetail : (node.detail ?? null)} + detail={node.root && !node.icon ? tildify(node.path, home) : (node.detail ?? null)} onEditDone={focusTree} onClick={onRowClick} onDoubleClick={onRowDoubleClick} onPointerDown={onRowPointerDown} /> ), - [editing, cutPaths, dropTarget, rootDetail, focusTree, onRowClick, onRowDoubleClick, onRowPointerDown], + [editing, cutPaths, dropTarget, home, focusTree, onRowClick, onRowDoubleClick, onRowPointerDown], ); if (!root) return null; diff --git a/src/features/project-tree/contextMenu.test.ts b/src/features/project-tree/contextMenu.test.ts index 80cebc34..4e31c1b5 100644 --- a/src/features/project-tree/contextMenu.test.ts +++ b/src/features/project-tree/contextMenu.test.ts @@ -201,3 +201,27 @@ describe("project view context menu", () => { expect(l).not.toContain("Find in Files…"); }); }); + +it("offers workspace removal for every member root", () => { + useProject.setState({ + project: { + root: "/workspace", + name: "A", + settings: {}, + isGit: false, + workspace: { version: 1, name: "A", projects: ["/a", "/b"] }, + }, + }); + const removal = { + kind: "action", + action: "workspace.removeProject", + arg: "/b", + }; + expect(contextMenuItems([node("/b", true, true)])).toContainEqual(removal); + expect(contextMenuItems([node("/a", true, true)])).toContainEqual( + expect.objectContaining({ action: "workspace.removeProject" }), + ); + expect(contextMenuItems([node("/b/src", true)])).not.toContainEqual( + expect.objectContaining({ action: "workspace.removeProject" }), + ); +}); diff --git a/src/features/project-tree/contextMenu.ts b/src/features/project-tree/contextMenu.ts index 7a9ddbfe..c5fee7d2 100644 --- a/src/features/project-tree/contextMenu.ts +++ b/src/features/project-tree/contextMenu.ts @@ -152,6 +152,10 @@ 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" }); + } + push({ id: "new", label: "New", submenu: newMenuItems(folder) }, { kind: "separator" }); // Cut / Copy / Copy Path/Reference… / Paste diff --git a/src/features/project-tree/index.ts b/src/features/project-tree/index.ts index ae71cb97..27ce39ac 100644 --- a/src/features/project-tree/index.ts +++ b/src/features/project-tree/index.ts @@ -29,7 +29,8 @@ export function activate(): () => void { const sync = () => { const p = useProject.getState().project; const s = useProjectTree.getState(); - if ((p?.root ?? null) !== s.root) initProjectTree(p?.root ?? null, p?.name ?? ""); + if ((p?.root ?? null) !== s.root || p?.workspace?.projects !== (s.members ?? undefined)) + initProjectTree(p?.root ?? null, p?.name ?? "", p?.workspace?.projects ?? null); else if (p && p.name !== s.name) useProjectTree.setState({ name: p.name }); }; sync(); @@ -60,7 +61,8 @@ export function activate(): () => void { let shownRoots = ""; const syncContentRoots = () => { const { project, settings } = useProject.getState(); - const paths = project ? validateProjectStructure(settings?.projectStructure).contentRoots : []; + const paths = + project && !project.workspace ? validateProjectStructure(settings?.projectStructure).contentRoots : []; const key = JSON.stringify(paths); if (key === shownRoots) return; shownRoots = key; diff --git a/src/features/project-tree/operations.ts b/src/features/project-tree/operations.ts index e53f2261..4767fce9 100644 --- a/src/features/project-tree/operations.ts +++ b/src/features/project-tree/operations.ts @@ -90,7 +90,7 @@ export function topLevelPaths(nodes: ProjectNode[] = selectedNodes()): string[] export function targetFolder(node: ProjectNode | null = leadNode()): string | null { const { root } = get(); if (!root) return null; - if (!node) return root; + if (!node) return get().members?.[0] ?? (get().members ? null : root); return node.dir ? node.path : parentOf(node.path); } diff --git a/src/features/project-tree/roots.test.ts b/src/features/project-tree/roots.test.ts index 6f16cf41..b4f1885b 100644 --- a/src/features/project-tree/roots.test.ts +++ b/src/features/project-tree/roots.test.ts @@ -9,7 +9,7 @@ import { MemoryFs, setProjectFsForTests } from "./fs"; import { beginNewPackage, checkEditingName, commitEditing } from "./operations"; import { packageKindAt } from "./packages"; import { packageSegments } from "./paths"; -import { initProjectTree, loadDir } from "./store"; +import { initProjectTree, loadDir, selectModel, useProjectTree } from "./store"; const root = (path: string, kind: RootFolder["kind"], detectedFrom: string | null): RootFolder => ({ path, @@ -103,3 +103,20 @@ describe("creating packages", () => { expect(fs.nodes.has("/p/pkg/api/v1/__init__.py")).toBe(true); }); }); + +it("uses only manifest members in order in workspace Project and Project Files views", () => { + for (const mode of ["project", "projectFiles"] as const) { + initProjectTree("/workspace", "Workspace", ["/z", "/a"]); + useProjectTree.setState({ mode }); + const model = selectModel(useProjectTree.getState()); + expect(model.roots.map((r) => r.path)).toEqual(["/z", "/a"]); + expect(model.byKey.has("/workspace")).toBe(false); + initProjectTree("/workspace", "Workspace", ["/a"]); + expect(selectModel(useProjectTree.getState()).roots.map((r) => r.path)).toEqual(["/a"]); + initProjectTree("/workspace", "Workspace", []); + expect(selectModel(useProjectTree.getState()).roots).toEqual([]); + } + initProjectTree("/a", "A"); + expect(selectModel(useProjectTree.getState()).roots.map((r) => r.path)).toEqual(["/a"]); + initProjectTree(null); +}); diff --git a/src/features/project-tree/store.ts b/src/features/project-tree/store.ts index 9fad9fc8..11366c41 100644 --- a/src/features/project-tree/store.ts +++ b/src/features/project-tree/store.ts @@ -87,6 +87,7 @@ export interface Clipboard { interface ProjectTreeState { root: string | null; name: string; + members: readonly string[] | null; listings: ReadonlyMap; expanded: ReadonlySet; selected: ReadonlySet; @@ -130,6 +131,7 @@ function writeJson(key: string, value: unknown) { export const useProjectTree = create(() => ({ root: null, name: "", + members: null, listings: new Map(), expanded: new Set(), selected: new Set(), @@ -158,6 +160,7 @@ const set = (patch: Partial) => useProjectTree.setState(patch) let memo: { root: string | null; name: string; + members: readonly string[] | null; listings: ProjectTreeState["listings"]; options: ViewOptions; editing: Editing | null; @@ -178,6 +181,7 @@ export function selectModel(s: ProjectTreeState): TreeModel { memo && memo.root === s.root && memo.name === s.name && + memo.members === s.members && memo.listings === s.listings && memo.options === s.options && memo.editing === s.editing && @@ -190,7 +194,16 @@ export function selectModel(s: ProjectTreeState): TreeModel { s.editing?.kind === "new" ? { parent: s.editing.parent, kind: s.editing.nodeKind } : null; const extras = useProjectViewRoots.getState().roots; const contributed = projectViewRoots(extras); - const project = () => buildModel({ path: s.root!, name: s.name }, s.listings, s.options, placeholder); + const memberRoots = s.members?.map((path) => ({ + path, + label: path.slice(path.lastIndexOf("/") + 1), + icon: "", + detail: path, + })); + const project = () => + memberRoots + ? withExtraRoots(EMPTY_MODEL, memberRoots, s.listings, s.options, placeholder) + : buildModel({ path: s.root!, name: s.name }, s.listings, s.options, placeholder); let model = EMPTY_MODEL; if (s.root) { if (s.mode === "projectFiles") model = project(); @@ -204,7 +217,7 @@ export function selectModel(s: ProjectTreeState): TreeModel { ); else if (files) model = buildPathsModel( - [{ path: s.root, label: s.name, icon: "" }, ...extraFolders(contributed)], + [...(memberRoots ?? [{ path: s.root, label: s.name, icon: "" }]), ...extraFolders(contributed)], files, s.options.foldersFirst, ); @@ -217,6 +230,7 @@ export function selectModel(s: ProjectTreeState): TreeModel { memo = { root: s.root, name: s.name, + members: s.members, listings: s.listings, options: s.options, editing: s.editing, @@ -238,7 +252,7 @@ const inflight = new Map>(); let persistTimer: ReturnType | undefined; /** Resets the view for a newly opened project, restoring its expansion state. */ -export function initProjectTree(root: string | null, name = "") { +export function initProjectTree(root: string | null, name = "", members: readonly string[] | null = null) { generation++; inflight.clear(); clearTimeout(persistTimer); @@ -247,8 +261,9 @@ export function initProjectTree(root: string | null, name = "") { set({ root, name, + members, listings: new Map(), - expanded: initialExpanded(root, get().mode), + expanded: initialExpanded(root, get().mode, members), selected: new Set(), lead: null, editing: null, @@ -264,7 +279,7 @@ const modeExpanded = new Map>(); const autoExpanded = new Set(); /** "Project" restores the persisted expansion; other panes start with their roots open. */ -function initialExpanded(root: string | null, mode: ViewMode): Set { +function initialExpanded(root: string | null, mode: ViewMode, members = get().members): Set { if (!root || isFilteredMode(mode)) return new Set(); if (mode === "scratches") return new Set( @@ -273,8 +288,9 @@ function initialExpanded(root: string | null, mode: ViewMode): Set { ), ); const persisted = mode === "project" ? (readJson(expandedKey(root)) ?? []) : []; - const expanded = new Set(persisted.filter((p) => isSameOrInside(p, root))); - expanded.add(root); + const roots = members ?? [root]; + const expanded = new Set(persisted.filter((p) => roots.some((r) => isSameOrInside(p, r)))); + roots.forEach((r) => expanded.add(r)); return expanded; } diff --git a/src/features/search/__tests__/findScopes.test.ts b/src/features/search/__tests__/findScopes.test.ts index a4ae9f8f..e4ce3848 100644 --- a/src/features/search/__tests__/findScopes.test.ts +++ b/src/features/search/__tests__/findScopes.test.ts @@ -32,6 +32,10 @@ describe("Find in Files module and custom scopes", () => { const base = useFindPopup.getState(); it("a module searches its folder, the root module the project", () => { + expect(resolveFindScope({ ...base, scopeTab: "module", module: "/member" }, inputs)).toEqual({ + kind: "directory", + path: "/member", + }); expect(resolveFindScope({ ...base, scopeTab: "module", module: "web" }, inputs)).toEqual({ kind: "directory", path: "/p/web", diff --git a/src/features/search/find/FindInFiles.tsx b/src/features/search/find/FindInFiles.tsx index 63f740d5..d086fe6a 100644 --- a/src/features/search/find/FindInFiles.tsx +++ b/src/features/search/find/FindInFiles.tsx @@ -105,7 +105,9 @@ function displayDirectory(dir: string, root: string | null): string { function FindInFilesDialog() { const s = useFindPopup(); const txt = useTxTemplate(); - const root = useProject((p) => p.project?.root ?? null); + const project = useProject((p) => p.project); + const root = project?.workspace?.projects[0] ?? (project?.workspace ? null : (project?.root ?? null)); + const workspace = useProject((p) => !!p.project?.workspace); const editors = useEditorRegistry((r) => r.editors); const modules = useProjectModules(); const customScopes = useCustomScopeList(); @@ -133,6 +135,10 @@ function FindInFilesDialog() { const set = (patch: Partial) => useFindPopup.setState(patch); useEffect(() => () => session.dispose(), [session]); + useEffect(() => { + if (modules.length && !modules.some((m) => m.path === s.module)) + useFindPopup.setState({ module: modules[0]!.path }); + }, [modules, s.module]); // Hidden popup actions (⌘G, F4, ⌥↓ …) win over the editor's bindings while this is set. useEffect(() => { @@ -590,7 +596,8 @@ function FindInFilesDialog() { // IDEA starts the Directory scope at the current file's folder. if (id === "directory" && !s.directory.trim()) { const active = maybeEditorService()?.activePath(); - const dir = active && root && active.startsWith(root + "/") ? dirname(active) : root; + const folders = project?.workspace?.projects ?? (root ? [root] : []); + const dir = active && folders.some((r) => active.startsWith(r + "/")) ? dirname(active) : root; set({ scopeTab: "directory", directory: dir ?? "" }); } else set({ scopeTab: id as ScopeTab }); }} @@ -698,7 +705,7 @@ function FindInFilesDialog() { replaced ·{" "}
)} - {basename(r.relPath)} {r.line + 1} + {workspace ? r.relPath : basename(r.relPath)} {r.line + 1} ); diff --git a/src/features/search/find/store.ts b/src/features/search/find/store.ts index 5d01c9f8..a2883b21 100644 --- a/src/features/search/find/store.ts +++ b/src/features/search/find/store.ts @@ -37,7 +37,7 @@ export interface FindOptions { maskEnabled: boolean; mask: string; scopeTab: ScopeTab; - /** Module tab: project-relative folder of the module (`""`: the project's root module). */ + /** Module tab: relative module folder or absolute workspace member (`""`: plain project root). */ module: string; directory: string; namedScope: NamedScope; @@ -186,7 +186,9 @@ export function resolveFindScope(o: FindOptions, inputs: ScopeInputs): SearchSco if (o.scopeTab === "directory" && o.directory.trim()) return { kind: "directory", path: resolveDirectory(o.directory, root) }; if (o.scopeTab === "module") - return o.module && root ? { kind: "directory", path: `${root}/${o.module}` } : { kind: "project" }; + return o.module && root + ? { kind: "directory", path: o.module.startsWith("/") ? o.module : `${root}/${o.module}` } + : { kind: "project" }; if (o.scopeTab !== "scope") return { kind: "project" }; const custom = customScopeName(o.namedScope); if (custom !== null) { diff --git a/src/features/settings/model/behaviorSchema.ts b/src/features/settings/model/behaviorSchema.ts index eb64cfe5..73adc3cc 100644 --- a/src/features/settings/model/behaviorSchema.ts +++ b/src/features/settings/model/behaviorSchema.ts @@ -7,7 +7,7 @@ import { BUILTIN_THEME_IDS, type BuiltinThemeId } from "@/platform/themes"; export type Antialiasing = "subpixel" | "greyscale" | "none"; -export type OpenProjectIn = "ask" | "new" | "same"; +export type OpenProjectIn = "ask" | "new" | "same" | "attach"; export type NotificationDisplay = "balloon" | "sticky" | "none"; /** Themes selectable as "preferred" when syncing with the OS. */ export type ConcreteTheme = BuiltinThemeId; @@ -140,7 +140,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"] as const, d.openProjectIn), + openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same", "attach"] as const, d.openProjectIn), defaultProjectDirectory: typeof g.defaultProjectDirectory === "string" ? g.defaultProjectDirectory : "", saveOnIdle: bool(g.saveOnIdle, d.saveOnIdle), idleSeconds: int(g.idleSeconds, d.idleSeconds, IDLE_LIMITS), diff --git a/src/features/settings/pages/behavior.tsx b/src/features/settings/pages/behavior.tsx index 8c65d352..5b0a5fb6 100644 --- a/src/features/settings/pages/behavior.tsx +++ b/src/features/settings/pages/behavior.tsx @@ -281,6 +281,7 @@ 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" }, ]; @@ -566,7 +567,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [ { kind: "custom", label: "Open project in", - keywords: ["New window", "Current window", "Ask"], + keywords: ["New window", "Current window", "Attach", "Ask"], component: OpenProjectInRow, }, { kind: "custom", label: "Default directory", component: DefaultDirectoryRow }, diff --git a/src/platform/keymaps/intellij-macos.ts b/src/platform/keymaps/intellij-macos.ts index 89fa0b25..28cb7f8c 100644 --- a/src/platform/keymaps/intellij-macos.ts +++ b/src/platform/keymaps/intellij-macos.ts @@ -186,6 +186,8 @@ export const intellijMacos: Record = { "editor.nextSplitter": ["Alt+Tab"], "editor.prevSplitter": ["Alt+Shift+Tab"], "window.toggleFullScreen": ["Meta+Ctrl+F"], + "window.nextTab": ["Meta+Alt+PageDown"], + "window.previousTab": ["Meta+Alt+PageUp"], "window.next": ["Meta+Alt+`"], "window.previous": ["Meta+Alt+Shift+`"], "file.reloadFromDisk": ["Meta+Alt+Y"], diff --git a/src/platform/keymaps/intellij-windows.ts b/src/platform/keymaps/intellij-windows.ts index ce4536e1..3fb590e7 100644 --- a/src/platform/keymaps/intellij-windows.ts +++ b/src/platform/keymaps/intellij-windows.ts @@ -187,6 +187,8 @@ export const intellijWindows: Record = { "editor.nextSplitter": ["Alt+Tab"], "editor.prevSplitter": ["Alt+Shift+Tab"], "window.toggleFullScreen": [], + "window.nextTab": ["Ctrl+Alt+PageDown"], + "window.previousTab": ["Ctrl+Alt+PageUp"], "window.next": ["Ctrl+Alt+]"], "window.previous": ["Ctrl+Alt+["], "file.reloadFromDisk": ["Ctrl+Alt+Y"], diff --git a/src/platform/locales/en.json b/src/platform/locales/en.json index 26285f06..2cc4a124 100644 --- a/src/platform/locales/en.json +++ b/src/platform/locales/en.json @@ -77,8 +77,9 @@ "app.window.pickFileTitle": "Open File", "app.window.pickTitle": "Open in New Window", "app.window.askTitle": "Open Project", - "app.window.askBody": "Projects can either be opened in a new window or replace the project in the current window.\nHow would you like to open \"{name}\"?", + "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.window.askAttach": "Attach", "app.window.askNew": "New Window", "app.window.askRemember": "Don't ask again", "common.cancel": "Cancel", diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index a451e337..65e1983f 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -1,4 +1,6 @@ { + "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,或在需要时自动下载运行时", "Runs jdtls only; the project keeps its Project JDK. When empty, a suitable installed JDK is used first, otherwise Eclipse Temurin is downloaded automatically into a separate language-server folder. The jdtls executable itself is set in Language Servers.": "仅用于运行 jdtls,项目仍使用项目 JDK。留空时优先使用符合要求的已安装 JDK,否则自动将 Eclipse Temurin 下载到独立的语言服务器目录。jdtls 可执行文件在语言服务器设置中配置。", @@ -4919,6 +4921,32 @@ "Unarchive Agent Tasks": "取消归档 Agent 任务", "Cannot unarchive agent tasks": "无法取消归档 Agent 任务", "Unarchive ({archived})": "取消归档({archived})", + "New Workspace…": "新建工作区…", + "New Workspace": "新建工作区", + "Open Workspace…": "打开工作区…", + "Open Workspace": "打开工作区", + "Manage Workspace…": "管理工作区…", + "Manage Workspace": "管理工作区", + "Merge All Project Windows": "合并所有项目窗口", + "Move Tab to New Window": "将标签页移到新窗口", + "Next Project Tab": "下一个项目标签页", + "Previous Project Tab": "上一个项目标签页", + "Project windows": "项目窗口", + "Linked projects": "关联项目", + "Workspace name": "工作区名称", + "Workspace location": "工作区位置", + "Workspace Location": "工作区位置", + "Choose workspace location": "选择工作区位置", + "Add Projects": "添加项目", + "Add Projects…": "添加项目…", + "Project folder path": "项目文件夹路径", + "Add the projects you want to work on together.": "添加需要一起工作的项目。", + "Project folders stay in place. Removing a project here keeps its files.": "项目文件夹保留在原位置。从此处移除项目不会删除文件。", + "Window group": "窗口组", + "Select Project Tab": "选择项目标签页", + "Close Project Tab": "关闭项目标签页", + "Reorder Project Tabs": "重新排列项目标签页", + "Close {name}": "关闭 {name}", "Tomcat Server": "Tomcat 服务器", "Run a WAR application in a local Tomcat server": "在本地 Tomcat 服务器中运行 WAR 应用", "Tomcat installation directory": "Tomcat 安装目录", diff --git a/src/platform/locales/zh.json b/src/platform/locales/zh.json index 3191894f..98e06d87 100644 --- a/src/platform/locales/zh.json +++ b/src/platform/locales/zh.json @@ -77,8 +77,9 @@ "app.window.pickFileTitle": "打开文件", "app.window.pickTitle": "在新窗口中打开", "app.window.askTitle": "打开项目", - "app.window.askBody": "项目可以在新窗口中打开,也可以替换当前窗口中的项目。\n要如何打开“{name}”?", + "app.window.askBody": "项目可以在新窗口中打开、替换当前项目,或附加到已打开的项目。\n要如何打开“{name}”?", "app.window.askThis": "当前窗口", + "app.window.askAttach": "附加", "app.window.askNew": "新窗口", "app.window.askRemember": "不再询问", "common.cancel": "取消", diff --git a/src/platform/project.ts b/src/platform/project.ts index 6430bb37..81a94d7a 100644 --- a/src/platform/project.ts +++ b/src/platform/project.ts @@ -98,3 +98,13 @@ export function dirname(path: string): string { const i = path.lastIndexOf("/"); return i <= 0 ? "/" : path.slice(0, i); } + +/** The user-visible folder containing a file; workspace state is never a content root. */ +export function projectFolder(project: ProjectInfo | null, path: string): { root: string; name: string } | null { + if (!project) return null; + if (!project.workspace) return project; + const root = project.workspace.projects + .filter((r) => path === r || path.startsWith(r + "/")) + .sort((a, b) => b.length - a.length)[0]; + return root ? { root, name: basename(root) } : null; +} diff --git a/src/platform/projectRoots.test.ts b/src/platform/projectRoots.test.ts index 6d58a865..4ad0b30a 100644 --- a/src/platform/projectRoots.test.ts +++ b/src/platform/projectRoots.test.ts @@ -113,3 +113,27 @@ describe("project roots store", () => { expect(currentRoots().map((r) => r.path)).toEqual(["out"]); }); }); + +it("lists workspace members as scopes and admits their files without admitting workspace state", async () => { + const { renderHook } = await import("@testing-library/react"); + const { useProjectModules } = await import("./projectRoots"); + useProject.setState({ + project: { + root: "/workspace", + name: "Workspace", + settings: {}, + isGit: false, + workspace: { version: 1, name: "Workspace", projects: ["/z", "/a"] }, + }, + settings: {} as never, + }); + const hook = renderHook(() => useProjectModules()); + expect(hook.result.current).toEqual([ + { name: "z", path: "/z" }, + { name: "a", path: "/a" }, + ]); + expect(inFileSet("all", "/z/src/main.ts")).toBe(true); + expect(inFileSet("all", "/workspace/.openidea/workspace.json")).toBe(false); + hook.unmount(); + useProject.setState({ project: null }); +}); diff --git a/src/platform/projectRoots.ts b/src/platform/projectRoots.ts index d41ee51a..49c38136 100644 --- a/src/platform/projectRoots.ts +++ b/src/platform/projectRoots.ts @@ -15,7 +15,7 @@ import { validateProjectStructure, type RootFolder, } from "./languageSettings"; -import { useProject } from "./project"; +import { basename, projectFolder, useProject } from "./project"; interface RootsState { /** Project the roots belong to. */ @@ -108,7 +108,7 @@ const EMPTY: RootFolder[] = []; /** Project-relative path of an absolute `path`, or null outside the project. */ export function projectRelative(path: string): string | null { - const root = useProject.getState().project?.root; + const root = projectFolder(useProject.getState().project, path)?.root; if (!root) return null; if (path === root) return ""; return path.startsWith(root + "/") ? path.slice(root.length + 1) : null; @@ -142,7 +142,7 @@ export function inFileSet(set: ProjectFileSet, path: string, roots: readonly Roo /** A module: the project itself or a folder with a build file roots were detected from. */ export interface ProjectModule { name: string; - /** Project-relative, `/`-separated; `""` is the project root. */ + /** Project-relative, or absolute for a workspace member; `""` is a plain project root. */ path: string; } @@ -162,6 +162,12 @@ export function projectModules(detected: readonly RootFolder[], projectName: str /** Modules of the open project (reactive). */ export function useProjectModules(): ProjectModule[] { const detected = useDetectedRoots(); - const name = useProject((s) => s.project?.name ?? ""); - return useMemo(() => projectModules(detected, name), [detected, name]); + const project = useProject((s) => s.project); + return useMemo( + () => + project?.workspace + ? project.workspace.projects.map((path) => ({ path, name: basename(path) })) + : projectModules(detected, project?.name ?? ""), + [detected, project], + ); } diff --git a/src/platform/windowGroups.test.ts b/src/platform/windowGroups.test.ts new file mode 100644 index 00000000..dd52f398 --- /dev/null +++ b/src/platform/windowGroups.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { reorderedTabs } from "./windowGroups"; + +describe("project window order", () => { + const tabs = ["a", "b", "c"].map((label) => ({ label, name: label, current: label === "b", root: `/${label}` })); + it("moves a tab across its neighbours without changing the source or active window", () => { + expect(reorderedTabs(tabs, "a", "c")).toEqual(["b", "c", "a"]); + expect(reorderedTabs(tabs, "c", "a")).toEqual(["c", "a", "b"]); + expect(tabs.map((t) => t.label)).toEqual(["a", "b", "c"]); + expect(tabs.find((t) => t.current)?.label).toBe("b"); + }); + it("ignores external drags and missing targets", () => { + expect(reorderedTabs(tabs, "outside", "b")).toEqual(["a", "b", "c"]); + expect(reorderedTabs(tabs, "a", "outside")).toEqual(["a", "b", "c"]); + }); +}); diff --git a/src/platform/windowGroups.ts b/src/platform/windowGroups.ts new file mode 100644 index 00000000..601bc10f --- /dev/null +++ b/src/platform/windowGroups.ts @@ -0,0 +1,55 @@ +import { create } from "zustand"; +import { call, commands, isTauri, onIdeEvent, type WindowTab } from "./ipc"; +import { startupLaunchHandled, useProject } from "./project"; +import { setContext } from "./context"; + +export const useWindowGroup = create<{ tabs: WindowTab[] }>(() => ({ tabs: [] })); + +export function reorderedTabs(tabs: readonly WindowTab[], source: string, target: string): string[] { + const labels = tabs.map((tab) => tab.label); + const from = labels.indexOf(source); + const to = labels.indexOf(target); + if (from < 0 || to < 0 || from === to) return labels; + labels.splice(to, 0, labels.splice(from, 1)[0]!); + return labels; +} + +export async function refreshWindowGroup() { + if (!isTauri()) return; + useWindowGroup.setState({ tabs: await call(commands.windowGroupCurrent()) }); + setContext("windowGrouped", useWindowGroup.getState().tabs.length > 1); +} + +export async function selectWindowTab(label: string) { + await call(commands.windowGroupSelect(label)); +} + +export async function cycleWindowTab(forward: boolean) { + const tabs = useWindowGroup.getState().tabs; + if (tabs.length < 2) return; + const index = tabs.findIndex((tab) => tab.current); + await selectWindowTab(tabs[(index + (forward ? 1 : -1) + tabs.length) % tabs.length]!.label); +} + +export function installWindowGroups(): () => void { + if (!isTauri()) return () => {}; + let disposed = false; + const refresh = () => void refreshWindowGroup().catch(console.warn); + const restore = async () => { + await startupLaunchHandled(); + if (disposed || !useProject.getState().project) return; + await call(commands.windowGroupRestore()); + if (!disposed) await refreshWindowGroup(); + }; + const offEvent = onIdeEvent("windowGroupsChanged", refresh); + const offProject = useProject.subscribe((state, prev) => { + if (state.project?.root !== prev.project?.root) void restore().catch(console.warn); + }); + refresh(); + void restore().catch(console.warn); + return () => { + disposed = true; + offEvent(); + offProject(); + }; +} diff --git a/src/workbench/WindowGroupTabs.module.css b/src/workbench/WindowGroupTabs.module.css new file mode 100644 index 00000000..8eb7ae68 --- /dev/null +++ b/src/workbench/WindowGroupTabs.module.css @@ -0,0 +1,62 @@ +.bar { + display: flex; + flex: none; + align-items: center; + gap: var(--island-gap); + padding: 0 var(--island-gap) var(--island-gap); + min-width: 0; +} +.tabs { + display: flex; + flex: 1; + gap: 4px; + overflow-x: auto; + min-width: 0; +} +.item { + display: flex; + align-items: center; + flex: 0 1 220px; + min-width: 100px; + border-radius: var(--radius-small); + color: var(--fg-muted); +} +.item:hover { + background: var(--bg-hover); +} +.item { + touch-action: none; + user-select: none; +} +.dragging { + opacity: 0.55; +} +.selected { + background: var(--bg-island); + color: var(--fg); + box-shadow: inset 0 -2px var(--accent); +} +.tab { + display: flex; + align-items: center; + gap: 8px; + flex: 1; + min-width: 0; + height: 30px; + padding: 0 10px; + border: 0; + color: inherit; + background: transparent; + font: inherit; + cursor: pointer; +} +.tab span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tab:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + border-radius: var(--radius-small); +} diff --git a/src/workbench/WindowGroupTabs.test.tsx b/src/workbench/WindowGroupTabs.test.tsx new file mode 100644 index 00000000..6db78e67 --- /dev/null +++ b/src/workbench/WindowGroupTabs.test.tsx @@ -0,0 +1,39 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { executeAction } from "@/platform/actions"; +import { useWindowGroup } from "@/platform/windowGroups"; +import { WindowGroupTabs } from "./WindowGroupTabs"; + +vi.mock("@/platform/actions", async (importOriginal) => ({ + ...(await importOriginal()), + executeAction: vi.fn(async () => {}), +})); + +beforeEach(() => { + vi.mocked(executeAction).mockClear(); + useWindowGroup.setState({ + tabs: [ + { label: "main", name: "Frontend", root: "/front", current: true }, + { label: "project-1", name: "Backend", root: "/back", current: false }, + ], + }); +}); + +it("switches and closes the intended project with mouse and keyboard", () => { + render(); + const first = screen.getByRole("tab", { name: "Frontend" }); + expect(first).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tab", { name: "Backend" })).toHaveAttribute("tabindex", "-1"); + fireEvent.keyDown(first, { key: "ArrowLeft" }); + expect(executeAction).toHaveBeenLastCalledWith("window.selectTab", "project-1"); + fireEvent.click(screen.getByRole("button", { name: "Close Backend" })); + expect(executeAction).toHaveBeenLastCalledWith("window.closeTab", "project-1"); + fireEvent.click(first); + expect(executeAction).toHaveBeenLastCalledWith("window.selectTab", "main"); +}); + +it("removes the tab row when the group dissolves", () => { + useWindowGroup.setState({ tabs: [] }); + render(); + expect(screen.queryByRole("tablist")).not.toBeInTheDocument(); +}); diff --git a/src/workbench/WindowGroupTabs.tsx b/src/workbench/WindowGroupTabs.tsx new file mode 100644 index 00000000..f10f28e1 --- /dev/null +++ b/src/workbench/WindowGroupTabs.tsx @@ -0,0 +1,109 @@ +import { useRef, useState } from "react"; +import { executeAction } from "@/platform/actions"; +import { useTx } from "@/platform/i18n"; +import { reorderedTabs, useWindowGroup } from "@/platform/windowGroups"; +import { Icon, IconButton, showContextMenu } from "@/ui"; +import { notifications } from "./contributions"; +import styles from "./WindowGroupTabs.module.css"; + +function run(work: Promise) { + void work.catch((e) => notifications().notify({ level: "error", title: "Window group", body: String(e) })); +} + +export function WindowGroupTabs() { + const tabs = useWindowGroup((state) => state.tabs); + const drag = useRef<{ label: string; x: number; moved: boolean } | null>(null); + const [dragging, setDragging] = useState(null); + const tx = useTx(); + if (tabs.length < 2) return null; + return ( +
+
+ {tabs.map((tab) => ( +
{ + drag.current = null; + if (e.button === 0 && (e.target as Element).closest('[role="tab"]')) + drag.current = { label: tab.label, x: e.clientX, moved: false }; + }} + onPointerMove={(e) => { + if (drag.current && Math.abs(e.clientX - drag.current.x) > 5) { + drag.current.moved = true; + e.currentTarget.setPointerCapture(e.pointerId); + setDragging(tab.label); + } + }} + onPointerUp={(e) => { + const source = drag.current; + if (source?.moved) { + const target = document + .elementFromPoint(e.clientX, e.clientY) + ?.closest("[data-project-tab]")?.dataset.projectTab; + if (target) run(executeAction("window.reorderTabs", reorderedTabs(tabs, source.label, target))); + } + setDragging(null); + // Keep the moved flag through the click following pointerup. + if (!source?.moved) drag.current = null; + }} + onPointerCancel={() => { + drag.current = null; + setDragging(null); + }} + onClickCapture={(e) => { + if (drag.current?.moved) { + e.preventDefault(); + e.stopPropagation(); + drag.current = null; + } + }} + onContextMenu={(e) => { + e.preventDefault(); + showContextMenu(e, [ + { + label: "Move Tab to New Window", + disabled: !tab.current, + run: () => executeAction("window.detachTab"), + }, + { label: "Close Tab", run: () => executeAction("window.closeTab", tab.label) }, + ]); + }} + > + + run(executeAction("window.closeTab", tab.label))} + /> +
+ ))} +
+ +
+ ); +} diff --git a/src/workbench/Workbench.tsx b/src/workbench/Workbench.tsx index 4ed2a3f4..f771087d 100644 --- a/src/workbench/Workbench.tsx +++ b/src/workbench/Workbench.tsx @@ -12,6 +12,7 @@ import { OverlayHost } from "./OverlayHost"; import { projectColorVars } from "./projectColor"; import { StatusBar } from "./StatusBar"; import { TitleBar } from "./TitleBar"; +import { WindowGroupTabs } from "./WindowGroupTabs"; import { useApplyTheme } from "./theme"; import { ToolWindowLayout } from "./toolwindows/ToolWindows"; import { Welcome } from "./Welcome"; @@ -52,6 +53,7 @@ export function Workbench() { <> {/* Distraction Free Mode keeps the bar for the traffic lights and window dragging only. */} + {!distractionFree && } } /> {!distractionFree && } diff --git a/src/workbench/activate.ts b/src/workbench/activate.ts index 61563359..71ea2b99 100644 --- a/src/workbench/activate.ts +++ b/src/workbench/activate.ts @@ -43,6 +43,7 @@ export function activateWorkbench(): () => void { // 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); setContext("lightEdit", useProject.getState().lightEdit); if (root !== null) loadToolWindowLayout(root); }; diff --git a/src/workbench/contributions.ts b/src/workbench/contributions.ts index 78bf02f2..c1450e9a 100644 --- a/src/workbench/contributions.ts +++ b/src/workbench/contributions.ts @@ -497,6 +497,8 @@ export interface EditorService { activeCursor(): { doc: number; path: string; line: number; col: number; offset: number } | null; /** Save all dirty documents; rejects with the failures once every save was tried. */ saveAll(): Promise; + /** Includes local edits not yet acknowledged by the backend. */ + dirtyDocumentIds?(): number[]; /** Move keyboard focus to the active editor (used by Escape from tool windows). */ focus?(): void; /** Paths of the documents open in editor tabs (all panes, tab order). */ diff --git a/src/workbench/projectWindow.test.ts b/src/workbench/projectWindow.test.ts new file mode 100644 index 00000000..efbb2ecc --- /dev/null +++ b/src/workbench/projectWindow.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { useProject } from "@/platform/project"; +import { chooseProjectWindow, openPolicy } from "./projectWindow"; +import { ask } from "./messages"; +import { commands } from "@/platform/ipc"; + +vi.mock("./messages", () => ({ ask: vi.fn() })); +vi.mock("@/platform/remote", () => ({ + isRemoteWindow: () => false, + connectRemote: vi.fn(), +})); +vi.mock("@/platform/ipc", () => ({ + call: (p: unknown) => p, + commands: { + settingsSet: vi.fn(async () => ({ general: { openProjectIn: "attach" } })), + }, +})); +beforeEach(() => { + vi.clearAllMocks(); + useProject.setState({ + project: { root: "/a", name: "A", settings: {}, isGit: false }, + settings: {}, + lightEdit: false, + }); +}); +it("offers IDEA's four choices with This Window primary and remembers Attach", 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(await chooseProjectWindow("C")).toBe("attach"); + expect(ask).toHaveBeenCalledTimes(1); +}); +it("does not attach without a project or when opening a remote project", async () => { + useProject.setState({ + settings: { general: { openProjectIn: "attach" } } as never, + }); + vi.mocked(ask).mockResolvedValue({ button: "cancel", checked: false }); + expect(await chooseProjectWindow("remote", false)).toBeNull(); + expect(vi.mocked(ask).mock.calls[0]![0].buttons.map((b) => b.id)).not.toContain("attach"); + useProject.setState({ project: null }); + expect(await chooseProjectWindow("B")).toBe("same"); +}); diff --git a/src/workbench/projectWindow.ts b/src/workbench/projectWindow.ts index 701147f6..a64c8b13 100644 --- a/src/workbench/projectWindow.ts +++ b/src/workbench/projectWindow.ts @@ -2,14 +2,14 @@ import { t } from "@/platform/i18n"; import { call, commands } from "@/platform/ipc"; import { confirmLeaveProject, useProject } from "@/platform/project"; -import { connectRemote } from "@/platform/remote"; +import { connectRemote, isRemoteWindow } from "@/platform/remote"; import { ask } from "./messages"; import { maybeEditorService } from "./contributions"; -/** "Open project in": `new` | `same` | `ask`. */ -export function openPolicy(): "ask" | "new" | "same" { +/** "Open project in": `new` | `same` | `attach` | `ask`. */ +export function openPolicy(): "ask" | "new" | "same" | "attach" { const v = useProject.getState().settings?.general?.openProjectIn; - return v === "new" || v === "same" ? v : "ask"; + return v === "new" || v === "same" || v === "attach" ? v : "ask"; } /** @@ -17,22 +17,26 @@ export function openPolicy(): "ask" | "new" | "same" { * per "Open project in", asking when set to (and remembering "Don't ask again"). * `null`: cancelled. */ -export async function chooseProjectWindow(name: string): Promise<"new" | "same" | null> { +export async function chooseProjectWindow(name: string, allowAttach = true): Promise<"new" | "same" | "attach" | null> { const { project, lightEdit } = useProject.getState(); if (!project && !lightEdit) return "same"; - const mode = openPolicy(); + const canAttach = allowAttach && !!project && !isRemoteWindow(); + const policy = openPolicy(); + const mode = policy === "attach" && !canAttach ? "ask" : policy; if (mode !== "ask") return mode; const r = await ask({ title: t("app.window.askTitle"), + width: canAttach ? 640 : undefined, body: t("app.window.askBody", { name }), buttons: [ + { id: "cancel", label: t("common.cancel") }, + ...(canAttach ? [{ id: "attach", label: t("app.window.askAttach") }] : []), { id: "new", label: t("app.window.askNew") }, { id: "same", label: t("app.window.askThis"), primary: true }, - { id: "cancel", label: t("common.cancel") }, ], checkbox: t("app.window.askRemember"), }); - if (r.button !== "same" && r.button !== "new") return null; + if (r.button !== "same" && r.button !== "new" && r.button !== "attach") return null; if (r.checked) { try { useProject.setState({ @@ -56,7 +60,7 @@ export async function openRemoteProject( here: () => Promise = () => connectRemote(sshConfig, path), ): Promise { if ((await call(commands.windowOpenRemote(sshConfig, path, false))).kind === "focused") return true; - const mode = await chooseProjectWindow(path.replace(/\/+$/, "").split("/").pop() || path); + const mode = await chooseProjectWindow(path.replace(/\/+$/, "").split("/").pop() || path, false); if (!mode) return false; if (mode === "new") { await call(commands.windowOpenRemote(sshConfig, path, true));