дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02: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
Цей коміт міститься в:
1 джерело
5bab140968
коміт
01defdefe4
88 files changed
+2689
-151
No files matched your search
@@ -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();
|
||||
};
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача