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

+62
Переглянути файл
@@ -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);
}
+39
Переглянути файл
@@ -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();
});
+109
Переглянути файл
@@ -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>
);
}
+2
Переглянути файл
@@ -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 />}
</>
+1
Переглянути файл
@@ -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);
};
+2
Переглянути файл
@@ -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). */
+46
Переглянути файл
@@ -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");
});
+13 -9
Переглянути файл
@@ -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));