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
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-30 22:45:42 +08:00
1 джерело 5bab140968
коміт 01defdefe4
88 files changed
+2689 -151

No files matched your search

+15
Переглянути файл
@@ -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]
+12
Переглянути файл
@@ -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 -9
Переглянути файл
@@ -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.
+56
Переглянути файл
@@ -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
}
+42
Переглянути файл
@@ -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()))?
}
+5
Переглянути файл
@@ -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()));
}
}
_ => {}
})
+7
Переглянути файл
@@ -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.
+254
Переглянути файл
@@ -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(())
}
+26 -1
Переглянути файл
@@ -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>()?;