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

+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));