дзеркало
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
@@ -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
Заблокувати користувача