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

+2
Переглянути файл
@@ -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"],
+2
Переглянути файл
@@ -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"],
+2 -1
Переглянути файл
@@ -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",
+28
Переглянути файл
@@ -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 安装目录",
+2 -1
Переглянути файл
@@ -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": "取消",
+10
Переглянути файл
@@ -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;
}
+24
Переглянути файл
@@ -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 });
});
+11 -5
Переглянути файл
@@ -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],
);
}
+16
Переглянути файл
@@ -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"]);
});
});
+55
Переглянути файл
@@ -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();
};
}