mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
feat: multi-project workspaces and project window groups (#448)
Workspaces open several local project folders in one IDE session (File › New / Open / Manage Workspace…, or Attach when opening a project, which creates a workspace automatically for a plain project window). Member folders are the Project view roots and take part in indexing, Find in Files, Git, language servers, run configurations and builds; agent tasks choose which member they work in. Window › Merge All Project Windows groups project windows into one window with project tabs (switch, reorder, close, move to a new window), restored on reopen. Closes #241 Closes #198 Closes #286
This commit is contained in:
@@ -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() }],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<IpcPath>,
|
||||
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<IpcPath>,
|
||||
pub prompt: AgentPrompt,
|
||||
pub provider: String,
|
||||
/// Settings for the new session (model, mode…).
|
||||
|
||||
@@ -56,6 +56,7 @@ fn config(provider: &str) -> Vec<AgentConfigChoice> {
|
||||
|
||||
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),
|
||||
|
||||
@@ -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::<Vec<_>>(), ["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::<AgentTask>(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);
|
||||
}
|
||||
|
||||
@@ -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<crate::IpcPath>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -87,8 +87,14 @@ pub fn entries(host: &Host, window: &str, task: String, subagent: Option<String>
|
||||
host.project(window)?.agents.entries(&task, subagent)
|
||||
}
|
||||
|
||||
pub async fn create(host: &Host, window: &str, request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
host.project(window)?.agents.create(request).await
|
||||
pub async fn create(host: &Host, window: &str, mut request: NewAgentTask) -> IdeResult<AgentTask> {
|
||||
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<std::path::PathBuf> {
|
||||
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:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<compile::BuildPlan> = None;
|
||||
for member in workspace.projects.iter().filter(|p| file.as_ref().is_none_or(|f| f.starts_with(p))) {
|
||||
let settings = ide_workspace::SettingsStore::load(&config_dir, Some(member)).effective();
|
||||
let member_cache = cache.join(crate::project::project_key(member));
|
||||
let 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);
|
||||
})?;
|
||||
|
||||
@@ -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<ide_workspace::workspace::Workspace>,
|
||||
}
|
||||
|
||||
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<ide_workspace::ProjectRoots
|
||||
Ok(host.project(window)?.roots())
|
||||
}
|
||||
|
||||
pub fn save_workspace(host: &Host, window: &str, name: &str, paths: Vec<std::path::PathBuf>) -> IdeResult<ProjectInfo> {
|
||||
let project = host.project(window)?;
|
||||
ide_workspace::workspace::Workspace::save(&project.root, name, paths, false)?;
|
||||
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<ide_core::IpcPath> {
|
||||
let project = host.project(window)?;
|
||||
let path = dunce::canonicalize(path)?;
|
||||
if project.light {
|
||||
return Err(ide_core::IdeError::invalid("Open a project before attaching a folder"));
|
||||
}
|
||||
if 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,
|
||||
|
||||
@@ -9,22 +9,52 @@ pub fn configs(host: &Host, window: &str) -> IdeResult<Vec<RunConfig>> {
|
||||
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<RunConfig> = 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<RunConfig> {
|
||||
let excluded_poms = crate::project::unlinked_maven_poms(root, settings);
|
||||
let mut all: Vec<RunConfig> = 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)))
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PathBuf> =
|
||||
@@ -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<Repos>, 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<Repos>, 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<PathBuf>) -> 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<PathBuf> {
|
||||
if let Ok(Some(workspace)) = ide_workspace::workspace::Workspace::load(root) {
|
||||
return workspace.projects;
|
||||
}
|
||||
let mut out: Vec<PathBuf> = 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;
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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");
|
||||
}
|
||||
@@ -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 `<folder name>/<path>`.
|
||||
extra: Vec<ExtraRoot>,
|
||||
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<PathBuf>, Vec<String>),
|
||||
config: (crate::IndexRoots, Vec<String>),
|
||||
}
|
||||
|
||||
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<ExtraRoot> = 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<Item = &Path> {
|
||||
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<String> {
|
||||
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<String> =
|
||||
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<String> = 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()]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
}
|
||||
|
||||
impl From<Vec<PathBuf>> for IndexRoots {
|
||||
fn from(folders: Vec<PathBuf>) -> 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<PathBuf>,
|
||||
content_roots: impl Into<IndexRoots>,
|
||||
excludes: Vec<String>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
syntax: Arc<Syntax>,
|
||||
@@ -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<Arc<Self>> {
|
||||
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<PathBuf>, excludes: Vec<String>) -> bool {
|
||||
pub fn set_scope(&self, content_roots: impl Into<IndexRoots>, excludes: Vec<String>) -> 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<PathBuf> {
|
||||
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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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<FileEntry> =
|
||||
records.into_par_iter().map(|(rel, rec)| FileEntry::new(root.join(&rel), rel, rec)).collect();
|
||||
let entries: Vec<FileEntry> = 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);
|
||||
|
||||
@@ -157,6 +157,7 @@ impl DocSnap {
|
||||
pub struct LspManager {
|
||||
me: Weak<LspManager>,
|
||||
project_root: PathBuf,
|
||||
content_roots: RwLock<Vec<PathBuf>>,
|
||||
languages: Arc<LanguageRegistry>,
|
||||
sink: SharedSink,
|
||||
launcher: Arc<dyn Launcher>,
|
||||
@@ -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<PathBuf>) {
|
||||
*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<Server> {
|
||||
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);
|
||||
|
||||
@@ -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())]);
|
||||
|
||||
@@ -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<Self> {
|
||||
let mut repos = Vec::new();
|
||||
for root in roots {
|
||||
for repo in Self::discover(root, skip)?.repos {
|
||||
if !repos.iter().any(|r: &Arc<Repo>| 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).
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<String>,
|
||||
pub selected: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WindowGroups {
|
||||
groups: Vec<WindowGroup>,
|
||||
}
|
||||
|
||||
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<String>, 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<WindowGroup> {
|
||||
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<String> {
|
||||
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<String>) -> 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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -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<String>)]
|
||||
pub projects: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
fn serialize_projects<S: serde::Serializer>(projects: &[PathBuf], serializer: S) -> Result<S::Ok, S::Error> {
|
||||
projects.iter().map(|path| ide_core::ipc_path::to_ipc(path)).collect::<Vec<_>>().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<PathBuf> {
|
||||
let projects = vec![current.to_path_buf(), attached.to_path_buf()];
|
||||
let base = destination(&data_dir.join("workspaces"))?;
|
||||
let folder = current.file_name().and_then(|n| n.to_str()).unwrap_or("workspace");
|
||||
// Validate before creating anything, including when app data is inside a project.
|
||||
let normalized = normalize_projects(&base, projects.clone(), false)?;
|
||||
if normalized.iter().any(|p| base.starts_with(p)) {
|
||||
return Err(IdeError::invalid("Store the workspace outside its linked project folders"));
|
||||
}
|
||||
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<Option<Self>> {
|
||||
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<PathBuf>, create: bool) -> IdeResult<Self> {
|
||||
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<PathBuf> {
|
||||
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<String> {
|
||||
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<PathBuf>, allow_missing: bool) -> IdeResult<Vec<PathBuf>> {
|
||||
let mut result = Vec::new();
|
||||
for path in projects {
|
||||
let path = if path.is_absolute() { path } else { root.join(path) };
|
||||
let path = match dunce::canonicalize(&path) {
|
||||
Ok(path) if path.is_dir() => path,
|
||||
Err(e) if allow_missing && e.kind() == std::io::ErrorKind::NotFound => path,
|
||||
_ => 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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -58,6 +58,11 @@ pub async fn open_project(window: Window, path: PathBuf) -> IdeResult<ProjectInf
|
||||
.map_err(|e| IdeError::internal(e.to_string()))??;
|
||||
let info = project_info(&project);
|
||||
let _ = window.set_title(&info.name);
|
||||
window.state::<AppState>().sink.emit(ide_core::IdeEvent::WindowGroupsChanged);
|
||||
let grouped = window.state::<crate::windows::WindowsState>().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<Setting
|
||||
let label = window.label().to_owned();
|
||||
if app.state::<AppState>().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::<AppState>().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<ProjectInfo> {
|
||||
let project = state.project(window.label())?;
|
||||
let name = name.trim();
|
||||
if let Some(workspace) = ide_workspace::workspace::Workspace::load(&project.root)? {
|
||||
return ide_host::ops::project::save_workspace(&state.host, window.label(), name, workspace.projects);
|
||||
}
|
||||
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]
|
||||
|
||||
@@ -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<tauri::Wry> {
|
||||
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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Vec<groups::WindowTab>> {
|
||||
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<String>) -> IdeResult<()> {
|
||||
window.state::<WindowsState>().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
|
||||
}
|
||||
@@ -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<PathBuf>) -> IdeResult<ide_core::IpcPath> {
|
||||
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<PathBuf>) -> IdeResult<ProjectInfo> {
|
||||
let host = window.state::<AppState>().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::<AppState>().sink.emit(ide_core::IdeEvent::WindowGroupsChanged);
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn workspace_attach(window: WebviewWindow, path: PathBuf) -> IdeResult<ide_core::IpcPath> {
|
||||
let host = window.state::<AppState>().host.clone();
|
||||
let label = window.label().to_owned();
|
||||
tauri::async_runtime::spawn_blocking(move || ide_host::ops::project::attach_workspace(&host, &label, &path))
|
||||
.await
|
||||
.map_err(|e| IdeError::internal(e.to_string()))?
|
||||
}
|
||||
@@ -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::<WindowsState>() {
|
||||
w.forget(window.label());
|
||||
}
|
||||
if !windows::is_settings_window(window.label()) {
|
||||
windows::continue_quit(window.app_handle(), Some(window.label()));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
})
|
||||
|
||||
@@ -204,6 +204,9 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
|
||||
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<Entry> {
|
||||
),
|
||||
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.
|
||||
|
||||
@@ -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<ide_core::IpcPath>,
|
||||
pub current: bool,
|
||||
}
|
||||
|
||||
pub fn current(app: &AppHandle, caller: &str) -> Vec<WindowTab> {
|
||||
let state = app.state::<WindowsState>();
|
||||
let Some(group) = state.groups.lock().group(caller).cloned() else { return Vec::new() };
|
||||
let host = app.state::<AppState>();
|
||||
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::<AppState>().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::<WindowsState>();
|
||||
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::<AppState>().project(label).is_ok_and(|p| !p.light))
|
||||
.collect::<Vec<_>>();
|
||||
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::<WindowsState>().groups.lock().merge(members, caller)?;
|
||||
select(app, caller, caller)
|
||||
}
|
||||
|
||||
pub fn detach(app: &AppHandle, caller: &str) -> IdeResult<()> {
|
||||
let state = app.state::<WindowsState>();
|
||||
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::<WindowsState>() 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<PathBuf>,
|
||||
selected: usize,
|
||||
}
|
||||
|
||||
pub async fn persist(app: &AppHandle) -> IdeResult<()> {
|
||||
let state = app.state::<WindowsState>();
|
||||
let _write = state.groups_persist.lock().await;
|
||||
if state.quitting() {
|
||||
return Ok(());
|
||||
}
|
||||
let groups = state.groups.lock().groups().to_vec();
|
||||
let host = app.state::<AppState>();
|
||||
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::<WindowsState>();
|
||||
let host = app.state::<AppState>();
|
||||
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<SavedGroup> = 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(())
|
||||
}
|
||||
@@ -59,6 +59,9 @@ pub struct RemoteLaunch {
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct WindowsState {
|
||||
pub groups: Mutex<ide_workspace::window_groups::WindowGroups>,
|
||||
pub groups_restored: AtomicBool,
|
||||
pub groups_persist: tokio::sync::Mutex<()>,
|
||||
launches: Mutex<HashMap<String, Launch>>,
|
||||
/// Most recently focused window label (menu events go there).
|
||||
last_focused: Mutex<Option<String>>,
|
||||
@@ -84,7 +87,7 @@ impl WindowsState {
|
||||
launch.files.extend(files.into_iter().map(Into::into));
|
||||
}
|
||||
|
||||
fn pending(&self, label: &str) -> Option<Launch> {
|
||||
pub(crate) fn pending(&self, label: &str) -> Option<Launch> {
|
||||
self.launches.lock().get(label).cloned()
|
||||
}
|
||||
|
||||
@@ -193,11 +196,33 @@ pub fn cli_path(args: &[String], cwd: &Path) -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
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::<WindowsState>().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::<WindowsState>().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<WebviewWindow> {
|
||||
let state = app.try_state::<AppState>()?;
|
||||
|
||||
+41
-1
@@ -11,6 +11,16 @@ export const commands = {
|
||||
recentProjects: () => __TAURI_INVOKE<RecentProject[]>("recent_projects"),
|
||||
removeRecentProject: (path: string) => typedError<null, IdeError>(__TAURI_INVOKE("remove_recent_project", { path })),
|
||||
openProject: (path: string) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("open_project", { path })),
|
||||
workspaceCreate: (path: string, name: string, projects: string[]) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_create", { path, name, projects })),
|
||||
workspaceAttach: (path: string) => typedError<IpcPath, IdeError>(__TAURI_INVOKE("workspace_attach", { path })),
|
||||
workspaceUpdate: (name: string, projects: string[]) => typedError<ProjectInfo_Serialize, IdeError>(__TAURI_INVOKE("workspace_update", { name, projects })),
|
||||
windowGroupCurrent: () => typedError<WindowTab[], IdeError>(__TAURI_INVOKE("window_group_current")),
|
||||
windowGroupMerge: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_merge")),
|
||||
windowGroupSelect: (label: string) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_select", { label })),
|
||||
windowGroupDetach: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_detach")),
|
||||
windowGroupClose: (label: string) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close", { label })),
|
||||
windowGroupReorder: (labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_reorder", { labels })),
|
||||
windowGroupRestore: () => typedError<null, IdeError>(__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<void>("close_project"),
|
||||
languages: () => __TAURI_INVOKE<LanguageInfo_Serialize[]>("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. */
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(<Breadcrumbs />);
|
||||
expect(screen.getByRole("navigation", { name: "Breadcrumbs" }).textContent).toBe("bsrcfile.ts");
|
||||
view.unmount();
|
||||
useProject.setState({ project: null });
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) && (
|
||||
<span className={styles.unreadDot} role="img" aria-label={tx("Unread")} title={tx("Unread")} />
|
||||
)}
|
||||
{project?.workspace && (
|
||||
<span className={styles.taskProject} title={n.task.projectRoot ?? project.root}>
|
||||
{basename(n.task.projectRoot ?? project.root)}
|
||||
</span>
|
||||
)}
|
||||
<span className={styles.detail}>
|
||||
{providerName(n.task.provider)}
|
||||
{n.task.isolation === "worktree" ? ` · ${branchLabel(n.task.branch)}` : ""}
|
||||
|
||||
@@ -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<Branch[]>([]);
|
||||
const [current, setCurrent] = useState<string | null>(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<string | null>(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() {
|
||||
}
|
||||
/>
|
||||
<div className={styles.where}>
|
||||
<span className={styles.project}>{project?.name}</span>
|
||||
{members.length > 1 ? (
|
||||
<Select
|
||||
variant="ghost"
|
||||
aria-label={tx("Project")}
|
||||
value={root}
|
||||
options={members.map((path) => ({ value: path, label: basename(path), detail: path }))}
|
||||
onChange={(path) => {
|
||||
setSelected(path);
|
||||
updateDraft({ base: null });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span className={styles.project}>{members.length ? basename(root) : project?.name}</span>
|
||||
)}
|
||||
{isGit && (
|
||||
<Select
|
||||
variant="ghost"
|
||||
|
||||
@@ -72,7 +72,11 @@ export function SessionEditor({ input }: { input: EditorInput }) {
|
||||
const task = useAgents((s) => 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<string[]>([]);
|
||||
@@ -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<string | null>(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({
|
||||
<span className={styles.headerText}>
|
||||
{providerName(task.provider)}
|
||||
{task.model ? ` · ${task.model}` : ""}
|
||||
{projectName ? ` · ${projectName}` : ""}
|
||||
{" · "}
|
||||
{where ?? (
|
||||
<>
|
||||
|
||||
@@ -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<typeof import("@/platform/ipc")>();
|
||||
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(<HomeEditor />);
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<typeof projectFolder>[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));
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
@@ -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<void> {
|
||||
export async function startTask(
|
||||
isolation: Isolation = useAgents.getState().draft.isolation,
|
||||
projectRoot: string | null = null,
|
||||
): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 ? <WorkspaceDialog mode={mode} onClose={() => 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<string[]>(
|
||||
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 (
|
||||
<Dialog
|
||||
open
|
||||
onClose={() => {
|
||||
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(),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div className={styles.body}>
|
||||
<label className={styles.field}>
|
||||
<T>Name</T>
|
||||
<Input aria-label="Workspace name" value={name} disabled={busy} onChange={(e) => setName(e.target.value)} />
|
||||
</label>
|
||||
<label className={styles.field}>
|
||||
<T>Location</T>
|
||||
<div className={styles.row}>
|
||||
<Input
|
||||
aria-label="Workspace location"
|
||||
value={path}
|
||||
disabled={busy || mode === "manage"}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
/>
|
||||
{mode === "create" && (
|
||||
<IconButton
|
||||
icon="folder-open"
|
||||
title="Choose workspace location"
|
||||
onClick={() => void browse("workspace").catch((e) => setError(String(e)))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
<div className={styles.heading}>
|
||||
<T>Linked projects</T>
|
||||
<Button
|
||||
size="small"
|
||||
icon="plus"
|
||||
disabled={busy}
|
||||
onClick={() => void browse("project").catch((e) => setError(String(e)))}
|
||||
>
|
||||
Add Projects…
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.projects} role="list" aria-label="Linked projects">
|
||||
{projects.map((root) => (
|
||||
<div className={styles.project} role="listitem" key={root}>
|
||||
<Icon name="folder" size={16} />
|
||||
<div className={styles.path}>
|
||||
<strong>{basename(root)}</strong>
|
||||
<span title={root}>{root}</span>
|
||||
</div>
|
||||
<IconButton
|
||||
icon="close"
|
||||
title={tx`Remove ${basename(root)}`}
|
||||
disabled={busy}
|
||||
onClick={() => setProjects(projects.filter((p) => p !== root))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{!projects.length && (
|
||||
<p className={styles.hint}>
|
||||
<T>Add the projects you want to work on together.</T>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<Input
|
||||
aria-label="Project folder path"
|
||||
placeholder="Project folder path"
|
||||
value={adding}
|
||||
disabled={busy}
|
||||
onChange={(e) => setAdding(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
add(adding);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button disabled={busy || !adding.trim()} onClick={() => add(adding)}>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
<p className={styles.hint}>
|
||||
<T>Project folders stay in place. Removing a project here keeps its files.</T>
|
||||
</p>
|
||||
{error && (
|
||||
<div className={styles.error} role="alert">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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(<WorkspaceDialogHost />);
|
||||
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(<WorkspaceDialogHost />);
|
||||
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();
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -16,6 +16,10 @@ import { ask, type Ask } from "@/workbench/messages";
|
||||
/** Document ids the backend reports dirty in this window. */
|
||||
const dirtyDocs = new Set<number>();
|
||||
|
||||
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<boolean> {
|
||||
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<boolean> {
|
||||
export function confirmLeave(deps: CloseDeps = defaultDeps): Promise<boolean> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+127
-1
@@ -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<void> {
|
||||
|
||||
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"),
|
||||
|
||||
@@ -32,9 +32,11 @@
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.rootLabel {
|
||||
.rootName {
|
||||
flex: none;
|
||||
max-width: 60%;
|
||||
}
|
||||
.rootLabel {
|
||||
color: var(--fg-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -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"]));
|
||||
});
|
||||
|
||||
@@ -279,6 +279,7 @@ const Row = memo(function Row({
|
||||
<HighlightedText
|
||||
className={clsx(
|
||||
styles.label,
|
||||
node.root && styles.rootName,
|
||||
// Only the project itself is bold (IDEA); contributed roots like Scratches are not.
|
||||
node.root && !node.icon && styles.rootLabel,
|
||||
faded && !deco.color && styles.excluded,
|
||||
@@ -604,7 +605,6 @@ export function ProjectView() {
|
||||
};
|
||||
|
||||
const cutPaths = useMemo(() => 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;
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -87,6 +87,7 @@ export interface Clipboard {
|
||||
interface ProjectTreeState {
|
||||
root: string | null;
|
||||
name: string;
|
||||
members: readonly string[] | null;
|
||||
listings: ReadonlyMap<string, readonly FileEntry[]>;
|
||||
expanded: ReadonlySet<string>;
|
||||
selected: ReadonlySet<string>;
|
||||
@@ -130,6 +131,7 @@ function writeJson(key: string, value: unknown) {
|
||||
export const useProjectTree = create<ProjectTreeState>(() => ({
|
||||
root: null,
|
||||
name: "",
|
||||
members: null,
|
||||
listings: new Map(),
|
||||
expanded: new Set(),
|
||||
selected: new Set(),
|
||||
@@ -158,6 +160,7 @@ const set = (patch: Partial<ProjectTreeState>) => 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<string, Promise<void>>();
|
||||
let persistTimer: ReturnType<typeof setTimeout> | 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<ViewMode, ReadonlySet<string>>();
|
||||
const autoExpanded = new Set<string>();
|
||||
|
||||
/** "Project" restores the persisted expansion; other panes start with their roots open. */
|
||||
function initialExpanded(root: string | null, mode: ViewMode): Set<string> {
|
||||
function initialExpanded(root: string | null, mode: ViewMode, members = get().members): Set<string> {
|
||||
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<string> {
|
||||
),
|
||||
);
|
||||
const persisted = mode === "project" ? (readJson<string[]>(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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<FindOptions>) => 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() {
|
||||
<T>replaced ·</T>{" "}
|
||||
</span>
|
||||
)}
|
||||
{basename(r.relPath)} <span className={styles.rowLine}>{r.line + 1}</span>
|
||||
{workspace ? r.relPath : basename(r.relPath)} <span className={styles.rowLine}>{r.line + 1}</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -186,6 +186,8 @@ export const intellijMacos: Record<string, string[]> = {
|
||||
"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"],
|
||||
|
||||
@@ -187,6 +187,8 @@ export const intellijWindows: Record<string, string[]> = {
|
||||
"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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 安装目录",
|
||||
|
||||
@@ -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": "取消",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<typeof import("@/platform/actions")>()),
|
||||
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(<WindowGroupTabs />);
|
||||
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(<WindowGroupTabs />);
|
||||
expect(screen.queryByRole("tablist")).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -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<unknown>) {
|
||||
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<string | null>(null);
|
||||
const tx = useTx();
|
||||
if (tabs.length < 2) return null;
|
||||
return (
|
||||
<div className={styles.bar} data-window-group>
|
||||
<div className={styles.tabs} role="tablist" aria-label={tx("Project windows")}>
|
||||
{tabs.map((tab) => (
|
||||
<div
|
||||
className={`${styles.item} ${tab.current ? styles.selected : ""} ${dragging === tab.label ? styles.dragging : ""}`}
|
||||
key={tab.label}
|
||||
data-project-tab={tab.label}
|
||||
onPointerDown={(e) => {
|
||||
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<HTMLElement>("[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) },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
className={styles.tab}
|
||||
role="tab"
|
||||
aria-selected={tab.current}
|
||||
tabIndex={tab.current ? 0 : -1}
|
||||
title={tab.root ?? tab.name}
|
||||
onClick={() => run(executeAction("window.selectTab", tab.label))}
|
||||
onKeyDown={(e) => {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(e.key)) return;
|
||||
e.preventDefault();
|
||||
const index = tabs.indexOf(tab);
|
||||
const next =
|
||||
e.key === "Home"
|
||||
? 0
|
||||
: e.key === "End"
|
||||
? tabs.length - 1
|
||||
: (index + (e.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length;
|
||||
run(executeAction("window.selectTab", tabs[next]!.label));
|
||||
}}
|
||||
>
|
||||
<Icon name="folder" size={14} />
|
||||
<span>{tab.name}</span>
|
||||
</button>
|
||||
<IconButton
|
||||
icon="close"
|
||||
size="small"
|
||||
title={tx`Close ${tab.name}`}
|
||||
onClick={() => run(executeAction("window.closeTab", tab.label))}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<IconButton icon="external-link" title="Move Tab to New Window" action="window.detachTab" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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. */}
|
||||
<TitleBar bare={distractionFree} />
|
||||
{!distractionFree && <WindowGroupTabs />}
|
||||
<ToolWindowLayout key={root} editor={<EditorArea />} />
|
||||
{!distractionFree && <StatusBar />}
|
||||
</>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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<void>;
|
||||
/** 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). */
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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<unknown> = () => connectRemote(sshConfig, path),
|
||||
): Promise<boolean> {
|
||||
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));
|
||||
|
||||
Reference in New Issue
Block a user