fix(project): keep attachments in the current project (#2320)

Attach adds a trusted folder to the current project without reopening it or moving its root. Attachments live in the primary project's private .openidea/workspace.json, and detaching the last one restores a plain project.

ProjectInfo.name stays the primary project's name; displayName supplies window titles, project tabs and Recent Projects. Attach is no longer a remembered opening policy, and File > Attach Project is available when the policy is not Ask. Attached folders must be trusted, and Restricted Mode names any attached folder that blocks trust. Explicit workspaces keep their behaviour and still cannot be created in or inside a linked project.
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-09 19:23:05 +08:00
1 джерело 023c3f36c2
коміт 1ae8572021
60 files changed
+1155 -233

No files matched your search

+3
Переглянути файл
@@ -66,6 +66,7 @@ export const commands = {
trusted: boolean,
root: IpcPath,
name: string,
displayName: string,
isGit: boolean,
settings: Settings_Serialize,
workspace?: Workspace_Serialize | null,
@@ -6941,6 +6942,7 @@ export type ProjectInfo_Deserialize = {
trusted: boolean,
root: IpcPath,
name: string,
displayName?: string,
isGit: boolean,
settings: Settings_Deserialize,
workspace?: Workspace_Deserialize | null,
@@ -6951,6 +6953,7 @@ export type ProjectInfo_Serialize = {
trusted: boolean,
root: IpcPath,
name: string,
displayName: string,
isGit: boolean,
settings: Settings_Serialize,
workspace?: Workspace_Serialize | null,
+18 -6
Переглянути файл
@@ -216,7 +216,7 @@ 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 () => {
it("keeps editor tabs and unsaved documents through attach and detach", async () => {
const settings = {};
useProject.setState({
project: { root: "/attach-a", name: "A", trusted: true, isGit: false, settings },
@@ -227,19 +227,31 @@ describe("EditorController", () => {
ctrl.start();
await ctrl.openFile("/attach-a/one.txt", { focus: false, preview: false });
await ctrl.openFile("/attach-a/two.txt", { focus: false, preview: false });
const slot = ctrl.activeSlot!;
const host = document.createElement("div");
const view = slot.mount(host);
ctrl.onMounted(slot);
view.dispatch({ changes: { from: 0, insert: "unsaved " }, userEvent: "input.type" });
const tabs = useTabs.getState();
useProject.setState({
project: {
root: "/attach-workspace",
name: "A",
root: "/attach-a",
name: "A, attach-b",
trusted: true,
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"]),
);
expect(useTabs.getState()).toBe(tabs);
expect(ctrl.activeSlot).toBe(slot);
expect(view.state.doc.toString()).toContain("unsaved ");
expect(openFilePaths(useTabs.getState())).toEqual(["/attach-a/one.txt", "/attach-a/two.txt"]);
useProject.setState({ project: { root: "/attach-a", name: "A", trusted: true, isGit: false, settings } });
expect(useTabs.getState()).toBe(tabs);
expect(ctrl.activeSlot).toBe(slot);
expect(view.state.doc.toString()).toContain("unsaved ");
host.remove();
useProject.setState({ project: null });
});
-9
Переглянути файл
@@ -307,15 +307,6 @@ 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);
+11 -1
Переглянути файл
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { create } from "zustand";
import { call, commands, isCancelled, nextRequestId, type ProjectDiscovery, type RequestId } from "@/platform/ipc";
import { invokeLocal } from "@/platform/tauriCore";
import { confirmProjectTrust } from "@/platform/projectTrust";
import { basename, dirname, useProject } from "@/platform/project";
import { isRemoteWindow } from "@/platform/remote";
import { tx } from "@/platform/i18n";
@@ -101,12 +102,21 @@ function WorkspaceDialog({ mode, onClose }: { mode: "create" | "manage"; onClose
const root = await call(commands.workspaceCreate(path.trim(), name.trim(), included));
await openProjectWithPolicy(root);
} else {
for (const path of included.filter((p) => !workspace?.projects.includes(p))) {
await confirmProjectTrust(path);
if (!(await call(commands.projectTrustStatus(path))).trusted) {
setError(
tx`Cannot attach ${path} because it is restricted. Open it separately and choose Trust Project, then attach it again.`,
);
return;
}
}
const updated = await call(commands.workspaceUpdate(name.trim(), included));
useProject.setState({ project: updated, settings: updated.settings });
}
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
if (!isCancelled(e)) setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
+41
Переглянути файл
@@ -2,6 +2,7 @@ import { beforeEach, expect, it, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { open } from "@tauri-apps/plugin-dialog";
import { commands, type ProjectDiscovery } from "@/platform/ipc";
import { provideProjectTrustPrompt } from "@/platform/projectTrust";
import { invokeLocal } from "@/platform/tauriCore";
import { useProject } from "@/platform/project";
import { WorkspaceDialogHost, showWorkspaceDialog } from "../WorkspaceDialog";
@@ -19,6 +20,10 @@ beforeEach(() => {
vi.restoreAllMocks();
vi.mocked(openProjectWithPolicy).mockClear();
vi.mocked(open).mockReset();
vi.spyOn(commands, "projectTrustStatus").mockResolvedValue({
status: "ok",
data: { root: "/work", trusted: true, decided: true },
});
vi.mocked(invokeLocal).mockClear();
useProject.setState({ project: null, settings: null, lightEdit: false });
});
@@ -191,3 +196,39 @@ it("retains the form and linked folders when saving fails", async () => {
fireEvent.click(screen.getByRole("button", { name: /^Cancel$/ }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it.each(["restricted", "cancelled"])("retains Manage Workspace when project trust is %s", async (decision) => {
useProject.setState({
project: {
root: "/work/primary",
name: "primary",
trusted: true,
isGit: false,
settings: {},
workspace: { version: 1, name: "primary", projects: ["/work/primary"] },
},
} as never);
vi.mocked(commands.projectTrustStatus).mockResolvedValue({
status: "ok",
data: { root: "/work/restricted", trusted: false, decided: true },
});
const update = vi.spyOn(commands, "workspaceUpdate");
const dispose = provideProjectTrustPrompt(async () => decision !== "cancelled");
try {
showWorkspaceDialog("manage");
render(<WorkspaceDialogHost />);
fireEvent.change(screen.getByLabelText("Project folder path"), { target: { value: "/work/restricted" } });
fireEvent.click(screen.getByRole("button", { name: /^Apply$/ }));
await waitFor(() => expect(screen.getByRole("button", { name: /^Apply$/ })).toBeEnabled());
expect(update).not.toHaveBeenCalled();
if (decision === "restricted") {
expect(screen.getByRole("alert")).toHaveTextContent(
"Cannot attach /work/restricted because it is restricted. Open it separately and choose Trust Project, then attach it again.",
);
} else expect(screen.queryByRole("alert")).not.toBeInTheDocument();
expect(screen.getByLabelText("Project folder path")).toHaveValue("/work/restricted");
fireEvent.click(screen.getByRole("button", { name: /^Cancel$/ }));
} finally {
dispose();
}
});
+52
Переглянути файл
@@ -0,0 +1,52 @@
import { afterEach, expect, it, vi } from "vitest";
import { useProject } from "@/platform/project";
import { projectActions } from "../projectActions";
import { installWindowTitle } from "../title";
const { rename, prompt } = vi.hoisted(() => ({ rename: vi.fn(), prompt: vi.fn() }));
vi.mock("@/platform/ipc", async (original) => ({
...(await original<typeof import("@/platform/ipc")>()),
isTauri: () => true,
commands: { projectRename: rename, menuRefresh: vi.fn() },
}));
vi.mock("@tauri-apps/api/window", () => ({ getCurrentWindow: () => ({ setTitle: async () => {} }) }));
vi.mock("@/workbench/inputDialogs", () => ({ promptInput: prompt }));
afterEach(() => {
useProject.setState({ project: null, settings: null });
});
it("renames from the primary name while updating the attached window label", async () => {
const project = {
root: "/primary",
name: "primary",
displayName: "primary, other",
trusted: true,
isGit: false,
settings: {},
workspace: { version: 1, name: "primary", projects: ["/primary", "/other"] },
};
useProject.setState({ project });
const dispose = installWindowTitle();
try {
expect(document.title).toBe("primary, other");
prompt.mockImplementation(async ({ initial }) => initial);
rename.mockImplementation(async (name) => ({
status: "ok",
data: { ...project, name, displayName: `${name}, other` },
}));
await projectActions()
.find((a) => a.id === "file.renameProject")!
.run({ ctx: {} });
expect(rename).toHaveBeenCalledWith("primary");
expect(document.title).toBe("primary, other");
prompt.mockResolvedValueOnce("Renamed");
await projectActions()
.find((a) => a.id === "file.renameProject")!
.run({ ctx: {} });
expect(useProject.getState().project).toMatchObject({ name: "Renamed", displayName: "Renamed, other" });
expect(document.title).toBe("Renamed, other");
} finally {
dispose();
}
});
+75 -6
Переглянути файл
@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const projectTrustStatus = vi.fn();
vi.mock("@/platform/projectTrust", () => ({ confirmProjectTrust: vi.fn(async () => {}) }));
vi.mock("@/workbench/messages", () => ({ ask: vi.fn() }));
const workspaceAttach = vi.fn();
const workspaceUpdate = vi.fn();
const projectCurrent = vi.fn();
@@ -35,6 +38,7 @@ vi.mock("@/platform/ipc", async (orig) => {
isTauri: () => true,
commands: {
...real.commands,
projectTrustStatus: (...a: unknown[]) => projectTrustStatus(...a),
workspaceAttach: (...a: unknown[]) => workspaceAttach(...a),
workspaceUpdate: (...a: unknown[]) => workspaceUpdate(...a),
projectCurrent: () => projectCurrent(),
@@ -50,7 +54,7 @@ vi.mock("@/platform/ipc", async (orig) => {
});
vi.mock("@/platform/project", async (orig) => {
const real = await orig<typeof import("@/platform/project")>();
return { ...real, openProject: (p: string) => openProject(p) };
return { ...real, confirmLeaveProject: vi.fn(real.confirmLeaveProject), openProject: (p: string) => openProject(p) };
});
const diffs: unknown[] = [];
const openDiff = vi.fn(async (data: unknown) => void diffs.push(data));
@@ -66,9 +70,11 @@ const {
openProjectWithPolicy,
windowActions,
} = await import("../windows");
const { useProject, startupLaunchHandled } = await import("@/platform/project");
const { useProject, startupLaunchHandled, confirmLeaveProject } = await import("@/platform/project");
const { provideEditorService, provideNotificationService } = await import("@/workbench/contributions");
const { isRemoteWindow, connectRemote } = await import("@/platform/remote");
const { ask } = await import("@/workbench/messages");
const { confirmProjectTrust } = await import("@/platform/projectTrust");
const { IpcError } = await import("@/platform/ipc");
provideNotificationService({ notify });
@@ -99,6 +105,12 @@ function deferred() {
}
beforeEach(() => {
vi.mocked(ask).mockReset().mockResolvedValue({ button: "attach", checked: false });
vi.mocked(confirmProjectTrust).mockReset().mockResolvedValue();
projectTrustStatus.mockReset().mockResolvedValue(ok({ trusted: true }));
workspaceAttach.mockReset();
workspaceUpdate.mockReset();
projectCurrent.mockReset();
for (const f of [windowOpenProject, windowOpenFiles, windowTakeLaunch, wslResolve]) f.mockReset();
windowTakeLaunch.mockResolvedValue(null);
windowCompleteLaunch.mockReset().mockResolvedValue(ok(null));
@@ -106,6 +118,7 @@ beforeEach(() => {
vi.mocked(connectRemote).mockClear();
for (const f of [lightEditOpen, fsReadDir, openProject]) f.mockClear();
cancelClose.mockClear();
vi.mocked(confirmLeaveProject).mockClear();
notify.mockClear();
listen.mockReset();
openEditorFile.mockClear();
@@ -591,17 +604,54 @@ describe("launch", () => {
});
describe("Attach", () => {
it("switches a plain project to the automatic workspace in place", async () => {
it("attaches in place without reopening or asking to leave", async () => {
useProject.setState({
...projectState("/p/a"),
settings: { general: { openProjectIn: "attach" } },
} as never);
workspaceAttach.mockResolvedValue(ok("/data/workspaces/a"));
await openProjectWithPolicy("/p/b");
const project = {
...projectState("/p/a").project,
workspace: { version: 1, name: "a", projects: ["/p/a", "/p/b"] },
};
projectCurrent.mockResolvedValue(project);
workspaceAttach.mockResolvedValue(ok("/p/a"));
await openProjectWithPolicy("/p/b", ["/p/b/file.txt"]);
expect(confirmProjectTrust).toHaveBeenCalledWith("/p/b", "local");
expect(workspaceAttach).toHaveBeenCalledWith("/p/b");
expect(openProject).toHaveBeenCalledWith("/data/workspaces/a");
expect(openProject).not.toHaveBeenCalled();
expect(cancelClose).not.toHaveBeenCalled();
expect(confirmLeaveProject).not.toHaveBeenCalled();
expect(useProject.getState().project).toEqual(project);
expect(opened).toEqual(["/p/b/file.txt (focus)"]);
expect(ask).toHaveBeenCalledTimes(1);
expect(windowOpenProject).not.toHaveBeenCalled();
});
it("does not attach when trust is cancelled", async () => {
useProject.setState(projectState("/p/a"));
vi.mocked(confirmProjectTrust).mockRejectedValueOnce(new IpcError({ kind: "cancelled", message: "Cancelled" }));
await openProjectWithPolicy("/p/b");
expect(workspaceAttach).not.toHaveBeenCalled();
expect(openProject).not.toHaveBeenCalled();
expect(notify).not.toHaveBeenCalled();
});
it("does not attach a folder previewed in restricted mode", async () => {
useProject.setState(projectState("/p/a"));
projectTrustStatus.mockResolvedValueOnce(ok({ trusted: false }));
await openProjectWithPolicy("/p/b");
expect(workspaceAttach).not.toHaveBeenCalled();
});
it("explains how to attach a folder already decided as restricted", async () => {
useProject.setState(projectState("/p/a"));
projectTrustStatus.mockResolvedValueOnce(ok({ root: "/p/b", trusted: false, decided: true }));
await openProjectWithPolicy("/p/b");
expect(workspaceAttach).not.toHaveBeenCalled();
expect(notify).toHaveBeenCalledWith(
expect.objectContaining({
level: "warning",
body: "Cannot attach /p/b because it is restricted. Open it separately and choose Trust Project, then attach it again.",
}),
);
});
it("refreshes an existing workspace without reopening its editor session", async () => {
const before = {
root: "/workspace",
@@ -645,6 +695,25 @@ it("removes the first workspace member while retaining the workspace state root"
expect(useProject.getState().project?.workspace?.projects).toEqual(["/b"]);
});
it("confirms detaching an attachment and never removes the primary", async () => {
const project = { ...projectState("/a").project, workspace: { version: 1, name: "A", projects: ["/a", "/b"] } };
useProject.setState({ project });
const action = windowActions().find((a) => a.id === "project.detach")!;
expect(action.title).toBe("Detach Project");
expect(action.when).toBe("projectAttached");
await action.run({ arg: "/a" } as never);
expect(ask).not.toHaveBeenCalled();
expect(workspaceUpdate).not.toHaveBeenCalled();
vi.mocked(ask).mockResolvedValueOnce({ button: "cancel", checked: false });
await action.run({ arg: "/b" } as never);
expect(workspaceUpdate).not.toHaveBeenCalled();
vi.mocked(ask).mockResolvedValueOnce({ button: "detach", checked: false });
workspaceUpdate.mockResolvedValueOnce(ok(projectState("/a").project));
await action.run({ arg: "/b" } as never);
expect(workspaceUpdate).toHaveBeenCalledWith("A", ["/a"]);
expect(useProject.getState().project?.workspace).toBeUndefined();
});
describe("explicit project-window requests", () => {
it("honours current-window requests even when a project is already open", async () => {
useProject.setState(projectState("/p/old") as never);
+2 -2
Переглянути файл
@@ -201,7 +201,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
confirmTerminate,
processes: confirmTerminate ? await processNames(deps) : [],
dirty: dirtyCount(),
project: s.project?.name ?? null,
project: s.project?.displayName || s.project?.name || null,
},
deps,
);
@@ -223,7 +223,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
*/
export async function confirmLeave(
deps: CloseDeps = defaultDeps,
project = useProject.getState().project?.name ?? null,
project = (useProject.getState().project?.displayName || useProject.getState().project?.name) ?? null,
): Promise<boolean> {
if (!(await closeDocumentWindows())) return false;
const confirmTerminate = workflowSettings().run.confirmTerminate;
+2 -2
Переглянути файл
@@ -224,9 +224,9 @@ export function installMenuBridge(): () => void {
useMenuSlotItems.subscribe(push),
useContextKeys.subscribe(() => pushEnablement()),
useActions.subscribe(() => pushEnablement()),
// Opening / closing a project changes the Open Recent list.
// Project membership and names also change the Open Recent list.
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) void commands.menuRefresh();
if (s.project !== prev.project) void commands.menuRefresh();
if (s.lightEdit !== prev.lightEdit) push();
// View › Appearance check marks and Status Bar Widgets.
if (s.settings?.appearance !== prev.settings?.appearance) push();
+1 -1
Переглянути файл
@@ -129,7 +129,7 @@ async function renameProject() {
if (name === null) return;
try {
const info = await call(commands.projectRename(name));
useProject.setState((s) => ({ project: s.project && { ...s.project, name: info.name }, settings: info.settings }));
useProject.setState((s) => ({ project: s.project && info, settings: info.settings }));
void commands.menuRefresh();
} catch (e) {
notifications().notify({ level: "error", title: "Cannot rename project", body: errorText(e) });
+6 -1
Переглянути файл
@@ -35,7 +35,12 @@ export function installWindowTitle(): () => void {
const { project, settings } = useProject.getState();
const fullPath = settings?.appearance?.fullPathInTitle === true;
const host = useRemote.getState().session?.host ?? null;
const title = windowTitle(project?.name, useEditorRegistry.getState().active?.ctx.path, fullPath, host);
const title = windowTitle(
project?.displayName || project?.name,
useEditorRegistry.getState().active?.ctx.path,
fullPath,
host,
);
if (title === last) return;
last = title;
if (typeof document !== "undefined") document.title = title;
+72 -30
Переглянути файл
@@ -8,7 +8,7 @@
* editor tabs only. One LightEdit window collects them all.
*/
import { executeAction, type Action } from "@/platform/actions";
import { t } from "@/platform/i18n";
import { t, tx } from "@/platform/i18n";
import { call, commands, isCancelled, isTauri } from "@/platform/ipc";
import {
basename,
@@ -19,6 +19,8 @@ import {
provideStartupLaunch,
useProject,
} from "@/platform/project";
import { confirmProjectTrust } from "@/platform/projectTrust";
import { ask } from "@/workbench/messages";
import { connectRemote, isRemoteWindow } from "@/platform/remote";
import { holdSplash } from "@/platform/splash";
import { editorService, maybeEditorService, notifications } from "@/workbench/contributions";
@@ -160,22 +162,7 @@ export async function openProjectWithPolicy(path: string, files: readonly string
if (!mode) return;
if (mode === "new") return openProjectInWindow(path, true, files);
if (mode === "attach") {
try {
if (!current?.workspace && !(await confirmLeaveProject())) return;
const root = await call(commands.workspaceAttach(path));
if (root === current?.root) {
if (!current.workspace) maybeEditorService()?.cancelClose?.();
const project = await commands.projectCurrent();
if (project) useProject.setState({ project, settings: project.settings });
} else {
await openProject(root);
}
await executeAction("project.reveal", path);
await openInEditor(files);
} catch (e) {
maybeEditorService()?.cancelClose?.();
openFailed(path, e);
}
await attachProject(path, files);
return;
}
try {
@@ -187,6 +174,27 @@ export async function openProjectWithPolicy(path: string, files: readonly string
}
}
async function attachProject(path: string, files: readonly string[] = []): Promise<void> {
try {
await confirmProjectTrust(path, "local");
if (!(await call(commands.projectTrustStatus(path))).trusted) {
notifications().notify({
level: "warning",
title: tx("Project not attached"),
body: tx`Cannot attach ${path} because it is restricted. Open it separately and choose Trust Project, then attach it again.`,
});
return;
}
await call(commands.workspaceAttach(path));
const project = await commands.projectCurrent();
if (project) useProject.setState({ project, settings: project.settings });
await executeAction("project.reveal", path);
await openInEditor(files);
} catch (e) {
openFailed(path, e);
}
}
/** LightEdit: opens the current file's folder as a project ("Open project in" applies) and the file there. */
export async function openLightEditFileAsProject(): Promise<void> {
if (!useProject.getState().lightEdit) return;
@@ -291,24 +299,58 @@ function closeProjectTabs(side: "others" | "left" | "right"): Action["run"] {
};
}
const removeProject: Action["run"] = async ({ arg }) => {
const current = useProject.getState().project;
const workspace = current?.workspace;
if (typeof arg !== "string" || !workspace || !workspace.projects.includes(arg)) return;
if (workspace.projects[0] === current.root) {
if (arg === current.root) return;
const result = await ask({
title: t("app.window.detachTitle"),
body: t("app.window.detachBody", { name: basename(arg) }),
buttons: [
{ id: "cancel", label: t("common.cancel") },
{ id: "detach", label: t("app.window.detach"), primary: true },
],
});
if (result.button !== "detach") return;
}
const project = await call(
commands.workspaceUpdate(
workspace.name,
workspace.projects.filter((p) => p !== arg),
),
);
useProject.setState({ project, settings: project.settings });
};
export function windowActions(): Action[] {
return [
{
id: "project.attach",
title: t("app.action.attachProject"),
category: t("app.category.file"),
icon: "folder-open",
when: "projectLocal && !projectOpenAsk",
run: async () => {
if (desktopOnly()) return;
const path = await pickFolder(t("app.action.attachProject"));
if (path) await attachProject(path);
},
},
{
id: "workspace.removeProject",
title: "Remove from Workspace",
category: t("app.category.file"),
when: "workspaceOpen",
run: async ({ arg }) => {
const workspace = useProject.getState().project?.workspace;
if (typeof arg !== "string" || !workspace || !workspace.projects.includes(arg)) return;
const project = await call(
commands.workspaceUpdate(
workspace.name,
workspace.projects.filter((p) => p !== arg),
),
);
useProject.setState({ project, settings: project.settings });
},
when: "workspaceOpen && !projectAttached",
run: removeProject,
},
{
id: "project.detach",
title: "Detach Project",
category: t("app.category.file"),
when: "projectAttached",
run: removeProject,
},
{
id: "workspace.create",
@@ -334,7 +376,7 @@ export function windowActions(): Action[] {
id: "workspace.manage",
title: "Manage Workspace…",
category: t("app.category.file"),
when: "workspaceOpen",
when: "workspaceContainer",
run: () => showWorkspaceDialog("manage"),
},
{
+20
Переглянути файл
@@ -247,3 +247,23 @@ it("offers workspace removal for every member root", () => {
expect.objectContaining({ action: "workspace.removeProject" }),
);
});
it("does not offer removal on the attached primary", () => {
useProject.setState({
project: {
root: "/a",
name: "A",
displayName: "A, b",
trusted: true,
isGit: false,
settings: {},
workspace: { version: 1, name: "A", projects: ["/a", "/b"] },
},
});
expect(contextMenuItems([node("/a", true, true)])).not.toContainEqual(
expect.objectContaining({ action: "project.detach" }),
);
expect(contextMenuItems([node("/b", true, true)])).toContainEqual(
expect.objectContaining({ action: "project.detach" }),
);
});
+14 -2
Переглянути файл
@@ -153,8 +153,20 @@ export function contextMenuItems(nodes: ProjectNode[]): MenuItem[] {
const items: MenuItem[] = [];
const push = (...list: Entry[]) => list.forEach((i) => i && items.push(i));
if (single && project?.workspace && project.workspace.projects.includes(single.path)) {
push({ kind: "action", action: "workspace.removeProject", arg: single.path }, { kind: "separator" });
if (
single &&
project?.workspace &&
single.path !== project.root &&
project.workspace.projects.includes(single.path)
) {
push(
{
kind: "action",
action: project.workspace.projects[0] === project.root ? "project.detach" : "workspace.removeProject",
arg: single.path,
},
{ kind: "separator" },
);
}
push({ id: "new", label: "New", submenu: newMenuItems(folder) }, { kind: "separator" });
+1 -1
Переглянути файл
@@ -112,7 +112,7 @@ export function activate(): () => void {
let shownRoots = "";
const syncContentRoots = () => {
const { project, settings } = useProject.getState();
const own = project && !project.workspace;
const own = project && (!project.workspace || project.workspace.projects[0] === project.root);
const paths = own ? validateProjectStructure(settings?.projectStructure).contentRoots : [];
// The Build tool window's model (one load per build change), not a load of our own.
const build = useBuildModel.getState();
+7
Переглянути файл
@@ -233,3 +233,10 @@ describe("single-click expansion", () => {
setViewOption("singleClickExpand", false);
});
});
it("keeps the primary first and sorts attachments by name", () => {
initProjectTree("/z", "Z", ["/z", "/c", "/a"]);
expect(currentModel().roots.map((n) => n.path)).toEqual(["/z", "/a", "/c"]);
initProjectTree("/ws", "Workspace", ["/z", "/c", "/a"]);
expect(currentModel().roots.map((n) => n.path)).toEqual(["/a", "/c", "/z"]);
});
+1 -1
Переглянути файл
@@ -221,7 +221,7 @@ export function selectModel(s: ProjectTreeState): TreeModel {
const contributed = projectViewRoots(extras);
const memberRoots = s.members
?.map((path) => ({ path, label: nameOf(path), icon: "", detail: path }))
.sort((a, b) => compareNames(a.label, b.label));
.sort((a, b) => (a.path === s.root ? -1 : b.path === s.root ? 1 : compareNames(a.label, b.label)));
const project = () =>
memberRoots
? withExtraRoots(EMPTY_MODEL, memberRoots, s.listings, options, placeholder)
+53 -14
Переглянути файл
@@ -83,13 +83,37 @@ function TrustDialog() {
);
}
async function untrustedAttachment() {
const project = useProject.getState().project;
if (!project?.workspace || project.workspace.projects[0] !== project.root) return null;
for (const path of project.workspace.projects) {
const status = await call(commands.projectTrustStatus(path)).catch(() => null);
if (status && !status.trusted) return status;
}
return null;
}
function RestrictedBanner() {
const restricted = useProject((s) => s.project?.trusted === false);
const project = useProject((s) => s.project);
const restricted = project?.trusted === false;
const [blocker, setBlocker] = useState<{ project: typeof project; path: string | null } | null>(null);
const path = blocker?.project === project ? blocker.path : null;
useEffect(() => {
let active = true;
if (restricted)
void untrustedAttachment().then((status) => {
if (active) setBlocker({ project, path: status?.root ?? null });
});
return () => {
active = false;
};
}, [project, restricted]);
return restricted ? (
<div className={styles.banner} role="status">
<span>
<T>Restricted Mode: project commands and tools are disabled.</T>
</span>
{path && <PathLine path={path} />}
<Button onClick={() => void executeAction("project.trust")}>
<T>Trust Project</T>
</Button>
@@ -182,6 +206,32 @@ async function decide(trusted: boolean) {
pending.finish?.(true);
}
function promptForTrust(status: TrustStatus, scope: ProjectTrustScope): Promise<boolean> {
usePrompt.getState().finish?.(false);
return new Promise<boolean>((resolve) =>
usePrompt.setState({
path: status.root,
scope,
folder: false,
finish: (open) => {
usePrompt.setState({ path: null, scope: "current", finish: null, folder: false });
resolve(open);
},
}),
);
}
async function trustProject() {
if (!usePrompt.getState().path) {
const status = await untrustedAttachment();
if (status) {
await promptForTrust(status, "current");
return;
}
}
await decide(true);
}
export function activate(): () => void {
initializeRemoteTrustMigration();
const disposers = [
@@ -190,24 +240,13 @@ export function activate(): () => void {
? invokeLocal<TrustStatus>("project_trust_status", { path })
: call(commands.projectTrustStatus(path)));
if (status.decided) return true;
usePrompt.getState().finish?.(false);
return new Promise<boolean>((resolve) =>
usePrompt.setState({
path: status.root,
scope,
folder: false,
finish: (open) => {
usePrompt.setState({ path: null, scope: "current", finish: null, folder: false });
resolve(open);
},
}),
);
return promptForTrust(status, scope);
}),
registerOverlay({ id: "project.trustDialog", component: TrustDialog }),
registerWorkbenchBanner({ id: "project.restrictedMode", component: RestrictedBanner }),
registerSettingsPage({ id: "projectTrust", title: "Project Trust", group: "Security", component: TrustSettings }),
registerActions([
{ id: "project.trust", title: "Trust Project", category: "Project", run: () => decide(true) },
{ id: "project.trust", title: "Trust Project", category: "Project", run: trustProject },
{
id: "project.previewRestricted",
title: "Preview in Restricted Mode",
+37
Переглянути файл
@@ -139,3 +139,40 @@ it.each(["Trust Project", "Preview in Restricted Mode"])(
expect(useProject.getState().project).toBe(project);
},
);
it.each([false, true])("recovers an attached member whose trust is lost (decided=%s)", async (decided) => {
useProject.setState({
project: {
root: "/primary",
name: "primary",
trusted: false,
isGit: false,
settings: {},
workspace: { version: 1, name: "primary", projects: ["/primary", "/other"] },
},
});
status.mockImplementation(async (root: string) => ({
status: "ok",
data: {
root,
trusted: root === "/primary",
decided: root === "/primary" || decided,
},
}));
const Banner = useContributions.getState().workbenchBanners.get("project.restrictedMode")!.component;
render(
<>
<Banner />
<OverlayHost />
</>,
);
const button = await screen.findByRole("button", { name: "Trust Project" });
await screen.findByText("/other");
fireEvent.click(button);
const dialog = await screen.findByRole("dialog", { name: "Trust and Open Project?" });
expect(dialog).toHaveTextContent("/other");
expect(decide).not.toHaveBeenCalled();
fireEvent.click(screen.getAllByRole("button", { name: "Trust Project" }).at(-1)!);
await act(async () => {});
expect(decide).toHaveBeenCalledWith("/other", true, false);
});
+3 -2
Переглянути файл
@@ -431,8 +431,8 @@ export function activate(): () => void {
// Only while the same project stays open: closing it tears its runs down separately.
useProject.subscribe((s, prev) => {
if (!s.project || s.project.root !== prev.project?.root) return;
const before = prev.project.workspace?.projects ?? [];
const after = s.project.workspace?.projects ?? [];
const before = prev.project.workspace?.projects ?? [prev.project.root];
const after = s.project.workspace?.projects ?? [s.project.root];
for (const root of before) if (!after.includes(root)) void cleanupRemovedProject(root);
}),
registerEditorContribution({ id: "run.targets", order: 55, extensions: runGutter }),
@@ -714,6 +714,7 @@ export function activate(): () => void {
},
]),
onIdeEvent("runExited", (e) => onRunExited(e.run, e.code)),
onIdeEvent("projectRootsChanged", scheduleReload),
// Lets other title items follow the selected configuration (e.g. Build shows only for compiled ones).
useRun.subscribe((s) => {
const selected = s.configs.find((c) => c.id === s.selectedId);
+5 -1
Переглянути файл
@@ -3,7 +3,7 @@ import { formatPattern, formatTimestamp } from "@/platform/dateFormat";
import { resolveThemeId } from "@/workbench/theme";
import { applyAppearanceOptions, nextZoom, SMALL_TREE_INDENT } from "../appearanceRuntime";
import { validateSettings } from "../model/schema";
import { validateAppearanceExtras, validateNotifications } from "../model/behaviorSchema";
import { validateGeneralExtras, validateAppearanceExtras, validateNotifications } from "../model/behaviorSchema";
describe("appearance & behavior settings", () => {
it("defaults automatic update checks to on and respects an explicit opt-out", () => {
@@ -127,3 +127,7 @@ describe("appearance & behavior settings", () => {
});
});
});
it("loads an old attach setting as ask", () => {
expect(validateGeneralExtras({ openProjectIn: "attach" }).openProjectIn).toBe("ask");
});
+35
Переглянути файл
@@ -71,6 +71,15 @@ async function openDialog(page?: string) {
}
describe("Settings dialog", () => {
it("offers only new window, current window and ask, including old attach settings", async () => {
current = setAt(current, "general.openProjectIn", "attach");
const dialog = await openDialog("general");
const group = within(dialog).getByRole("radiogroup", { name: "Open project in" });
expect(within(group).getAllByRole("radio")).toHaveLength(3);
expect(within(group).getByRole("radio", { name: "Ask" })).toBeChecked();
expect(within(group).queryByRole("radio", { name: "Attach" })).not.toBeInTheDocument();
});
it("stages properties conversion through its action and applies the checkbox", async () => {
const dialog = await openDialog("editor.fileEncodings");
const checkbox = within(dialog).getByRole("checkbox", { name: "Transparent native-to-ascii conversion" });
@@ -769,3 +778,29 @@ describe("native Settings window background", () => {
}
});
});
it("uses the attached primary name for run-target paths", async () => {
useProject.setState({ project: { name: "primary", displayName: "primary, other", root: "/primary" } as never });
await openDialog("build.runTargets");
fireEvent.click(
within(screen.getByRole("toolbar", { name: "Run targets actions" })).getByRole("button", { name: "Add" }),
);
expect(screen.getByPlaceholderText("~/primary")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Type" }));
fireEvent.click(screen.getByRole("option", { name: "Docker" }));
expect(screen.getByPlaceholderText("/workspaces/primary")).toBeInTheDocument();
});
it("uses the primary name for module labels and artifact defaults", async () => {
useProject.setState({ project: { name: "primary", displayName: "primary, other", root: "/primary" } as never });
render(<ProjectStructureDialog />);
await act(async () => executeAction("file.projectStructure", "modules"));
expect(screen.getByRole("button", { name: /primary\s*custom/ })).toBeInTheDocument();
expect(screen.getByRole("tree", { name: "Content root" })).toHaveTextContent("primary");
fireEvent.click(screen.getByRole("button", { name: "Artifacts" }));
// Packaging is pending, but its default generator must still use the primary name.
screen.getByRole("button", { name: "JAR" }).closest("fieldset")!.removeAttribute("disabled");
fireEvent.click(screen.getByRole("button", { name: "JAR" }));
expect(screen.getByLabelText("Name")).toHaveValue("primary:jar");
expect(screen.getByLabelText("Output directory")).toHaveValue("out/artifacts/primary_jar");
});
+2 -2
Переглянути файл
@@ -10,7 +10,7 @@ import { ZOOM_LIMITS } from "@/platform/zoom";
import { validateIconThemeOptionsMap } from "@/platform/iconThemeOptions";
export type Antialiasing = "subpixel" | "greyscale" | "none";
export type OpenProjectIn = "ask" | "new" | "same" | "attach";
export type OpenProjectIn = "ask" | "new" | "same";
export type NotificationDisplay = "balloon" | "sticky" | "none";
/** Themes selectable as "preferred" when syncing with the OS. */
export type ConcreteTheme = ThemeChoiceId;
@@ -180,7 +180,7 @@ export function validateGeneralExtras(raw: unknown): GeneralExtras {
const d = DEFAULT_GENERAL_EXTRAS;
const df = obj(g.dateFormat);
return {
openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same", "attach"] as const, d.openProjectIn),
openProjectIn: oneOf(g.openProjectIn, ["ask", "new", "same"] as const, d.openProjectIn),
updateSource: oneOf(g.updateSource, ["auto", "github", "cnb"] as const, d.updateSource),
autoCheckUpdates: bool(g.autoCheckUpdates, d.autoCheckUpdates),
defaultProjectDirectory: typeof g.defaultProjectDirectory === "string" ? g.defaultProjectDirectory : "",
+1 -2
Переглянути файл
@@ -390,7 +390,6 @@ function ToolWindowsGrid() {
const OPEN_IN: Array<{ value: OpenProjectIn; label: string }> = [
{ value: "new", label: "New window" },
{ value: "attach", label: "Attach" },
{ value: "same", label: "Current window" },
{ value: "ask", label: "Ask" },
];
@@ -692,7 +691,7 @@ export const BEHAVIOR_PAGES: PageSpec[] = [
{
kind: "custom",
label: "Open project in",
keywords: ["New window", "Current window", "Attach", "Ask"],
keywords: ["New window", "Current window", "Ask"],
component: OpenProjectInRow,
},
{ kind: "custom", label: "Default directory", component: DefaultDirectoryRow },
+3
Переглянути файл
@@ -1,5 +1,7 @@
/** Welcome screen, recent projects (File › Open Recent) and reopening the last project on startup. */
import { registerActions } from "@/platform/actions";
import { onIdeEvent } from "@/platform/ipc";
import { loadRecent } from "./recent";
import { lazyComponent } from "@/ui";
import { provideProjectPopup, provideWelcomeView, registerOverlay } from "@/workbench/contributions";
import { RecentProjectsPopup, showRecentProjectsPopup } from "./RecentProjectsPopup";
@@ -9,6 +11,7 @@ import { WelcomeView } from "./WelcomeView";
export function activate(): () => void {
const disposers = [
trackSession(),
onIdeEvent("windowGroupsChanged", () => void loadRecent()),
provideWelcomeView(WelcomeView),
provideProjectPopup(lazyComponent(() => import("./ProjectPopup").then((m) => m.ProjectPopup))),
registerOverlay({ id: "welcome.recentProjects", component: RecentProjectsPopup }),
+5
Переглянути файл
@@ -90,6 +90,10 @@
"app.window.askTitle": "Open Project",
"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.action.attachProject": "Attach Project…",
"app.window.detachTitle": "Detach Project",
"app.window.detachBody": "Would you like to detach the project {name}?",
"app.window.detach": "Detach",
"app.window.askAttach": "Attach",
"app.window.askNew": "New Window",
"app.window.askRemember": "Don't ask again",
@@ -181,6 +185,7 @@
"menu.file.new": "New…",
"menu.file.newScratchFile": "New Scratch File…",
"menu.file.openInNewWindow": "Open in New Window…",
"menu.project.attach": "Attach Project…",
"menu.file.openProject": "Open…",
"menu.file.save": "Save All",
"menu.file.projectStructure": "Project Structure…",
+3
Переглянути файл
@@ -27,6 +27,9 @@
"The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.": "The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.",
"Input queue is full (4 MiB or 1024 writes); input was not sent.": "Input queue is full (4 MiB or 1024 writes); input was not sent.",
"Cannot write to terminal": "Cannot write to terminal",
"Detach Project": "Detach Project",
"Project not attached": "Project not attached",
"Cannot attach {0} because it is restricted. Open it separately and choose Trust Project, then attach it again.": "Cannot attach {0} because it is restricted. Open it separately and choose Trust Project, then attach it again.",
"Docker": "Docker",
"Shell": "Shell",
"C/C++": "C/C++",
-1
Переглянути файл
@@ -85,7 +85,6 @@
"Remove Trusted Location": "Quitar ubicación de confianza",
"Cannot determine the module for main class '{value}'": "No se puede determinar el módulo para la clase principal '{value}'",
"Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "El módulo de Java '{value}' no está importado. Espera a que termine la importación del proyecto Java. Si sigue fallando, verifica el módulo de la configuración de ejecución y si está incluido en la compilación principal de Maven o Gradle, o abre su carpeta como un proyecto.",
"Attach": "Adjuntar",
"Remove from Workspace": "Quitar del espacio de trabajo",
"Downloading Java {0} language server runtime": "Descargando el entorno de ejecución del servidor de lenguaje Java {0}",
"auto-managed; uses an installed JDK or downloads a runtime when needed": "administrado automáticamente; usa un JDK instalado o descarga un entorno de ejecución cuando es necesario",
-1
Переглянути файл
@@ -34,7 +34,6 @@
"Remove Trusted Location": "Retirer l'emplacement de confiance",
"Cannot determine the module for main class '{value}'": "Impossible de déterminer le module de la classe principale '{value}'",
"Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "Le module Java '{value}' n'est pas importé. Attendez la fin de l'import du projet Java. Si le problème persiste, vérifiez le module de la configuration d'exécution et s'il est inclus dans le build Maven ou Gradle parent, ou ouvrez son dossier comme projet.",
"Attach": "Attacher",
"Remove from Workspace": "Retirer de l'espace de travail",
"Downloading Java {0} language server runtime": "Téléchargement de l'environnement Java {0} du serveur de langage",
"auto-managed; uses an installed JDK or downloads a runtime when needed": "géré automatiquement ; utilise un JDK installé ou télécharge un environnement si nécessaire",
-1
Переглянути файл
@@ -121,7 +121,6 @@
"Remove Trusted Location": "신뢰할 수 있는 위치 제거",
"Cannot determine the module for main class '{value}'": "메인 클래스 '{value}'의 모듈을 확인할 수 없습니다",
"Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "Java 모듈 '{value}'을(를) 가져오지 않았습니다. Java 프로젝트 가져오기가 완료될 때까지 기다리세요. 그래도 실패하면 실행 구성의 모듈과 해당 모듈이 상위 Maven 또는 Gradle 빌드에 포함되어 있는지 확인하거나, 해당 폴더를 프로젝트로 여세요.",
"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를 사용하거나 필요할 때 런타임을 다운로드합니다",
-1
Переглянути файл
@@ -77,7 +77,6 @@
"Remove Trusted Location": "Usuń zaufaną lokalizację",
"Cannot determine the module for main class '{value}'": "Nie można określić modułu dla klasy głównej '{value}'",
"Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "Moduł Java '{value}' nie został zaimportowany. Poczekaj na zakończenie importu projektu Java. Jeśli problem nadal występuje, sprawdź moduł w konfiguracji uruchamiania i upewnij się, że jest dołączony do nadrzędnego kompilatu Maven lub Gradle, lub otwórz jego folder jako projekt.",
"Attach": "Dołącz",
"Remove from Workspace": "Usuń z obszaru roboczego",
"Downloading Java {0} language server runtime": "Pobieranie środowiska uruchomieniowego serwera językowego Java {0}",
"auto-managed; uses an installed JDK or downloads a runtime when needed": "zarządzane automatycznie; używa zainstalowanego JDK lub pobiera środowisko uruchomieniowe w razie potrzeby",
-1
Переглянути файл
@@ -77,7 +77,6 @@
"Remove Trusted Location": "Remover local confiável",
"Cannot determine the module for main class '{value}'": "Não é possível determinar o módulo para a classe principal '{value}'",
"Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "O módulo Java '{value}' não foi importado. Aguarde a conclusão da importação do projeto Java. Se a falha persistir, verifique o módulo da configuração de execução e se ele está incluído no build pai do Maven ou Gradle, ou abra sua pasta como um projeto.",
"Attach": "Anexar",
"Remove from Workspace": "Remover do espaço de trabalho",
"Downloading Java {0} language server runtime": "Baixando o runtime do servidor de linguagem Java {0}",
"auto-managed; uses an installed JDK or downloads a runtime when needed": "gerenciado automaticamente; usa um JDK instalado ou baixa um runtime quando necessário",
+3 -1
Переглянути файл
@@ -18,6 +18,9 @@
"The language server Java executable ({path}) cannot run on this machine. Choose a working JDK in {setting}, or set it to Auto.": "语言服务器的 Java 可执行文件({path})无法在此机器上运行。请在 {setting} 中选择可正常运行的 JDK,或设为自动。",
"Input queue is full (4 MiB or 1024 writes); input was not sent.": "输入队列已满(4 MiB 或 1024 次写入);本次输入未发送。",
"Cannot write to terminal": "无法写入终端",
"Detach Project": "分离项目",
"Project not attached": "未附加项目",
"Cannot attach {0} because it is restricted. Open it separately and choose Trust Project, then attach it again.": "无法附加 {0},因为它处于受限模式。请单独打开它并选择“信任项目”,然后再次附加。",
"Docker": "Docker",
"Shell": "Shell",
"C/C++": "C/C++",
@@ -213,7 +216,6 @@
"Remove Trusted Location": "移除受信任的位置",
"Cannot determine the module for main class '{value}'": "无法确定主类 '{value}' 所属的模块",
"Java module '{value}' is not imported. Wait for Java project import to finish. If it still fails, check the run configuration's module and whether it is included in the parent Maven or Gradle build, or open its folder as a project.": "Java 模块 '{value}' 尚未导入。请等待 Java 项目导入完成。如果仍然失败,请检查运行配置选择的模块,以及该模块是否已包含在父级 Maven 或 Gradle 构建中,或将其文件夹作为项目打开。",
"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,或在需要时自动下载运行时",
+5
Переглянути файл
@@ -90,6 +90,10 @@
"app.window.askTitle": "打开项目",
"app.window.askBody": "项目可以在新窗口中打开、替换当前项目,或附加到已打开的项目。\n要如何打开“{name}”?",
"app.window.askThis": "当前窗口",
"app.action.attachProject": "附加项目…",
"app.window.detachTitle": "分离项目",
"app.window.detachBody": "是否分离项目“{name}”?",
"app.window.detach": "分离",
"app.window.askAttach": "附加",
"app.window.askNew": "新窗口",
"app.window.askRemember": "不再询问",
@@ -181,6 +185,7 @@
"menu.file.new": "新建…",
"menu.file.newScratchFile": "新建临时文件…",
"menu.file.openInNewWindow": "在新窗口中打开…",
"menu.project.attach": "附加项目…",
"menu.file.openProject": "打开…",
"menu.file.save": "全部保存",
"menu.file.projectStructure": "项目结构…",
+2 -1
Переглянути файл
@@ -15,11 +15,12 @@ type BackendProject = NonNullable<Awaited<ReturnType<typeof commands.projectCurr
const old: BackendProject = {
root: "/old",
name: "old",
displayName: "old",
trusted: true,
isGit: false,
settings: {} as BackendProject["settings"],
};
const next: BackendProject = { ...old, root: "/new", name: "new" };
const next: BackendProject = { ...old, root: "/new", name: "new", displayName: "new" };
beforeEach(() => {
vi.clearAllMocks();
+1 -1
Переглянути файл
@@ -196,7 +196,7 @@ export async function connectRemote(target: RemoteTarget, path: string) {
rememberRemote({
target: r.target,
path: r.root,
name: r.project.name,
name: r.project.displayName || r.project.name,
host: r.host,
openedAtMs: Date.now(),
});
+1 -1
Переглянути файл
@@ -53,7 +53,7 @@ function TitleItem({ item }: { item: TitleItemContribution }) {
}
function ProjectButton() {
const name = useProject((s) => s.project?.name);
const name = useProject((s) => s.project?.displayName || s.project?.name);
const root = useProject((s) => s.project?.root);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLButtonElement>(null);
+13 -1
Переглянути файл
@@ -2,7 +2,9 @@
* Shell services and built-in contributions. Call before `activateFeatures()`
* so features can rely on the tool window and notification services.
*/
import { openPolicy } from "./projectWindow";
import { setContext } from "@/platform/context";
import { isRemoteWindow, useRemote } from "@/platform/remote";
import { useProject } from "@/platform/project";
import {
provideNotificationService,
@@ -40,15 +42,24 @@ export function activateWorkbench(): () => void {
provideNotificationService(notificationServiceImpl);
void loadPlatformInfo();
const syncProjectContext = () => {
const project = useProject.getState().project;
setContext("projectLocal", !!project && !isRemoteWindow() && !useProject.getState().lightEdit);
setContext("workspaceOpen", !!project?.workspace);
setContext("projectAttached", !!project?.workspace && project.workspace.projects[0] === project.root);
setContext("workspaceContainer", !!project?.workspace && project.workspace.projects[0] !== project.root);
setContext("projectOpenAsk", openPolicy() === "ask");
};
// 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);
syncProjectContext();
setContext("lightEdit", useProject.getState().lightEdit);
if (root !== null) loadToolWindowLayout(root);
};
onProject(useProject.getState().project?.root ?? null);
const unsubProject = useProject.subscribe((s, prev) => {
syncProjectContext();
if (s.project?.root === prev.project?.root && s.lightEdit === prev.lightEdit) return;
// The previous project's notifications (and their actions) no longer apply.
if (prev.project) clearProjectNotifications();
@@ -57,6 +68,7 @@ export function activateWorkbench(): () => void {
const disposers = [
unsubProject,
useRemote.subscribe(syncProjectContext),
registerShellActions(),
installToolWindowActions(),
installToolWindowLayoutSync(),
+20
Переглянути файл
@@ -87,6 +87,26 @@ describe("LightEdit window", () => {
dispose();
});
it("updates workspace and policy context without replacing the project root", () => {
const dispose = activateWorkbench();
const project = { root: "/a", name: "A", trusted: true, isGit: false, settings: {} };
useProject.setState({ project, lightEdit: false, settings: { general: { openProjectIn: "new" } } } as never);
expect(evaluateWhen("projectLocal && !projectOpenAsk")).toBe(true);
expect(evaluateWhen("workspaceOpen")).toBe(false);
useProject.setState({ project: { ...project, workspace: { version: 1, name: "A", projects: ["/a", "/b"] } } });
expect(evaluateWhen("workspaceOpen")).toBe(true);
expect(evaluateWhen("workspaceContainer")).toBe(false);
expect(evaluateWhen("projectAttached")).toBe(true);
useProject.setState({ settings: { general: { openProjectIn: "attach" } } } as never);
expect(evaluateWhen("projectOpenAsk")).toBe(true);
useProject.setState({ project: { ...project, workspace: { version: 1, name: "A", projects: ["/b"] } } });
expect(evaluateWhen("workspaceContainer")).toBe(true);
expect(evaluateWhen("projectAttached")).toBe(false);
useProject.setState({ project });
expect(evaluateWhen("workspaceOpen")).toBe(false);
dispose();
});
it("applies 'Open project in' when opening a project from it", async () => {
useProject.setState({ settings: { general: { openProjectIn: "new" } } } as never);
expect(await chooseProjectWindow("notes")).toBe("new");
+14 -4
Переглянути файл
@@ -23,16 +23,16 @@ beforeEach(() => {
lightEdit: false,
});
});
it("offers IDEA's four choices with This Window primary and remembers Attach", async () => {
it("offers Attach for this open without remembering it", 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(commands.settingsSet).not.toHaveBeenCalled();
expect(openPolicy()).toBe("ask");
expect(await chooseProjectWindow("C")).toBe("attach");
expect(ask).toHaveBeenCalledTimes(1);
expect(ask).toHaveBeenCalledTimes(2);
});
it("does not attach without a project or when opening a remote project", async () => {
useProject.setState({
@@ -44,3 +44,13 @@ it("does not attach without a project or when opening a remote project", async (
useProject.setState({ project: null });
expect(await chooseProjectWindow("B")).toBe("same");
});
it("treats a stored attach policy as ask", () => {
useProject.setState({ settings: { general: { openProjectIn: "attach" } } } as never);
expect(openPolicy()).toBe("ask");
});
it.each(["new", "same"] as const)("remembers %s", async (mode) => {
vi.mocked(ask).mockResolvedValue({ button: mode, checked: true });
await chooseProjectWindow("B");
expect(commands.settingsSet).toHaveBeenCalledWith("global", "general.openProjectIn", JSON.stringify(mode));
});
+5 -6
Переглянути файл
@@ -6,10 +6,10 @@ import { connectRemote, isRemoteWindow } from "@/platform/remote";
import { ask } from "./messages";
import { maybeEditorService } from "./contributions";
/** "Open project in": `new` | `same` | `attach` | `ask`. */
export function openPolicy(): "ask" | "new" | "same" | "attach" {
/** "Open project in": `new` | `same` | `ask`. */
export function openPolicy(): "ask" | "new" | "same" {
const v = useProject.getState().settings?.general?.openProjectIn;
return v === "new" || v === "same" || v === "attach" ? v : "ask";
return v === "new" || v === "same" ? v : "ask";
}
/**
@@ -21,8 +21,7 @@ export async function chooseProjectWindow(name: string, allowAttach = true): Pro
const { project, lightEdit } = useProject.getState();
if (!project && !lightEdit) return "same";
const canAttach = allowAttach && !!project && !isRemoteWindow();
const policy = openPolicy();
const mode = policy === "attach" && !canAttach ? "ask" : policy;
const mode = openPolicy();
if (mode !== "ask") return mode;
const r = await ask({
title: t("app.window.askTitle"),
@@ -37,7 +36,7 @@ export async function chooseProjectWindow(name: string, allowAttach = true): Pro
checkbox: t("app.window.askRemember"),
});
if (r.button !== "same" && r.button !== "new" && r.button !== "attach") return null;
if (r.checked) {
if (r.checked && r.button !== "attach") {
try {
useProject.setState({
settings: await call(commands.settingsSet("global", "general.openProjectIn", JSON.stringify(r.button))),