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

+26
Переглянути файл
@@ -170,6 +170,32 @@ describe("EditorController", () => {
expect(pane().tabs.map((t) => t.input.path)).toEqual(["/p/a.ts", "/p/c.ts"]);
});
it("carries the plain project's editor tabs into its automatic workspace", async () => {
const settings = {};
useProject.setState({
project: { root: "/attach-a", name: "A", isGit: false, settings },
settings,
lightEdit: false,
});
ctrl = new EditorController(createMemoryBackend({ "/attach-a/one.txt": "one", "/attach-a/two.txt": "two" }));
ctrl.start();
await ctrl.openFile("/attach-a/one.txt", { focus: false, preview: false });
await ctrl.openFile("/attach-a/two.txt", { focus: false, preview: false });
useProject.setState({
project: {
root: "/attach-workspace",
name: "A",
isGit: false,
settings,
workspace: { version: 1, name: "A", projects: ["/attach-a", "/attach-b"] },
},
});
await vi.waitFor(() =>
expect(openFilePaths(useTabs.getState())).toEqual(["/attach-a/one.txt", "/attach-a/two.txt"]),
);
useProject.setState({ project: null });
});
it("reopens the LightEdit files the next time a window switches to LightEdit", async () => {
ctrl = new EditorController(createMemoryBackend({ "/x/a.txt": "a", "/y/b.md": "b" }));
ctrl.start();
+9
Переглянути файл
@@ -262,6 +262,15 @@ export class EditorController implements SlotHost {
prevRoot = root;
prevLight = s.lightEdit;
this.saveSession();
if (
root &&
previous.project &&
!previous.project.workspace &&
s.project?.workspace?.projects[0] === previous.project.root
) {
const session = loadSession(previous.project.root);
if (session) storeSession(root, session);
}
this.sessionRoot = null;
const generation = ++this.projectGeneration;
const documents = customDocuments(useTabs.getState(), useContributions.getState().editorKinds);
+1
Переглянути файл
@@ -40,6 +40,7 @@ export function activate(): void | (() => void) {
activePath: () => ctrl.activePath(),
activeCursor: () => ctrl.activeCursor(),
saveAll: () => ctrl.saveAll(),
dirtyDocumentIds: () => [...ctrl.manager.byId.values()].filter((m) => m.getMeta().dirty).map((m) => m.doc),
focus: () => ctrl.focus(),
openPaths: () => ctrl.openPaths(),
openTabs: () => ctrl.openTabs(),
+3 -2
Переглянути файл
@@ -4,7 +4,7 @@ import clsx from "clsx";
import { useShallow } from "zustand/react/shallow";
import { executeAction } from "@/platform/actions";
import { shortcutLabel } from "@/platform/keymap";
import { basename, dirname, relativePath, useProject } from "@/platform/project";
import { basename, dirname, projectFolder, relativePath, useProject } from "@/platform/project";
import type { LineEnding, OutlineItem } from "@/platform/ipc";
import { BarButton, Icon, Menu, badgeKind, fileIconName } from "@/ui";
import { notifications } from "@/workbench/contributions";
@@ -56,7 +56,8 @@ interface Crumb {
*/
export function Breadcrumbs() {
const path = useEditorStatus((s) => s.status?.path ?? null);
const project = useProject((s) => s.project);
const currentProject = useProject((s) => s.project);
const project = projectFolder(currentProject, path ?? "");
const outline = useOutline((s) => (path ? s[path] : undefined));
const chain = useCaretSymbols(path, outline) ?? NO_SYMBOLS;
const [popup, setPopup] = useState<{ nav: NavSegment; anchor: HTMLElement } | null>(null);
+18
Переглянути файл
@@ -74,3 +74,21 @@ describe("caret-driven status widgets", () => {
expect(commits).toBeGreaterThan(0);
});
});
it("shows the member and its directories in workspace breadcrumbs", async () => {
const { useProject } = await import("@/platform/project");
useProject.setState({
project: {
root: "/workspace",
name: "Workspace",
settings: {},
isGit: false,
workspace: { version: 1, name: "Workspace", projects: ["/a", "/b"] },
},
});
useEditorStatus.setState({ status: { ...caret(0, 0), path: "/b/src/file.ts" } });
const view = render(<Breadcrumbs />);
expect(screen.getByRole("navigation", { name: "Breadcrumbs" }).textContent).toBe("bsrcfile.ts");
view.unmount();
useProject.setState({ project: null });
});