Merge remote-tracking branch 'origin/main' into fix/cli-launcher-all-platforms

# Conflicts:
#	src/platform/locales/text/en.json
#	src/platform/locales/text/zh.json
Цей коміт міститься в:
l0ng-ai committed 2026-11-07 22:24:10 +08:00
коміт 5890e8abc5
51 files changed
+516 -33

No files matched your search

+8 -1
Переглянути файл
@@ -42,6 +42,11 @@ export const commands = {
windowGroupCloseTabs: (keep: string, labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_close_tabs", { keep, labels })),
/** This window kept its project open (unsaved-changes prompt cancelled); stops a batch close. */
windowGroupCancelClose: () => __TAURI_INVOKE<void>("window_group_cancel_close"),
/**
* This window closes next (its prompts passed): a fullscreen group's host first hands its Space
* to the tab that takes over.
*/
windowGroupBeforeClose: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_before_close")),
windowGroupReorder: (labels: string[]) => typedError<null, IdeError>(__TAURI_INVOKE("window_group_reorder", { labels })),
windowGroupRestore: () => typedError<null, IdeError>(__TAURI_INVOKE("window_group_restore")),
/**
@@ -5983,6 +5988,8 @@ export type PluginEvent =
{ type: "copyToClipboard"; pluginId: string; text: string } |
/** Open `url` (`http(s)://`) in the system browser. */
{ type: "openUrl"; pluginId: string; url: string } |
/** A window command/event requests the normal File > Open flow. */
{ type: "openProject"; pluginId: string; path: string; newWindow: boolean } |
/**
* A command waits for the user's answer: show `prompt`, then report it with
* `plugin_answer_prompt(prompt_id, …)`.
@@ -6388,7 +6395,7 @@ export type ProjectStructureSettings = {
* What a plugin asks the user (`ui.pick`, `ui.pick-many`, `ui.input`,
* `ui.input-multiline`, `ui.confirm`).
*/
export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null; multiline: boolean } | { kind: "confirm"; title: string; message: string };
export type Prompt = { kind: "quickPick"; title: string; items: PickItem[]; multiple: boolean } | { kind: "input"; prompt: string; value: string | null; multiline: boolean } | { kind: "confirm"; title: string; message: string } | { kind: "pickDirectory"; title: string; initial: string | null };
export type ProxyKind = "http" | "socks";
+33
Переглянути файл
@@ -388,3 +388,36 @@ it("removes the first workspace member while retaining the workspace state root"
expect(useProject.getState().project?.root).toBe("/workspace");
expect(useProject.getState().project?.workspace?.projects).toEqual(["/b"]);
});
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);
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
const action = windowActions().find((a) => a.id === "file.openProjectPath")!;
await action.run({ arg: { path: "/p/new", newWindow: false } } as never);
expect(windowOpenProject).toHaveBeenCalledWith("/p/new", false, []);
expect(openProject).toHaveBeenCalledWith("/p/new");
});
it("opens a new window without replacing the current project", async () => {
useProject.setState(projectState("/p/old") as never);
windowOpenProject.mockResolvedValue(ok({ kind: "opened", label: "project-1" }));
const action = windowActions().find((a) => a.id === "file.openProjectPath")!;
await action.run({ arg: { path: "/p/new", newWindow: true } } as never);
expect(windowOpenProject).toHaveBeenCalledWith("/p/new", true, []);
expect(openProject).not.toHaveBeenCalled();
expect(useProject.getState().project?.root).toBe("/p/old");
});
it("keeps the ordinary unsaved-change guard for current-window opens", async () => {
const { provideLeaveProjectGuard } = await import("@/platform/project");
const dispose = provideLeaveProjectGuard(async () => false);
useProject.setState(projectState("/p/old") as never);
windowOpenProject.mockResolvedValue(ok({ kind: "current" }));
try {
const action = windowActions().find((a) => a.id === "file.openProjectPath")!;
await action.run({ arg: { path: "/p/new", newWindow: false } } as never);
expect(openProject).not.toHaveBeenCalled();
} finally {
dispose();
}
});
});
+1
Переглянути файл
@@ -205,6 +205,7 @@ async function onCloseRequested(deps: CloseDeps): Promise<boolean> {
deps,
);
if (!ok) await (ctx.quitting ? commands.appCancelQuit() : commands.windowGroupCancelClose());
else if (!ctx.quitting) await commands.windowGroupBeforeClose();
return ok;
} catch (e) {
maybeEditorService()?.cancelClose?.();
+9 -1
Переглянути файл
@@ -445,7 +445,15 @@ export function windowActions(): Action[] {
category: t("app.category.file"),
hidden: true,
run: async (c) => {
if (typeof c.arg === "string") await openProjectWithPolicy(c.arg);
if (typeof c.arg === "string") return openProjectWithPolicy(c.arg);
const arg = c.arg as { path?: unknown; newWindow?: unknown } | null;
if (!arg || typeof arg.path !== "string" || typeof arg.newWindow !== "boolean") return;
try {
const outcome = await call(commands.windowOpenProject(arg.path, arg.newWindow, []));
if (outcome.kind === "current") await openHere(arg.path);
} catch (error) {
openFailed(arg.path, error);
}
},
},
{
+76
Переглянути файл
@@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { waitFor } from "@testing-library/react";
import { registerAction } from "@/platform/actions";
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
import { createMemoryPluginsBackend, setPluginsBackend } from "../backend";
import { handleRuntimeEvent } from "../runtimeUi";
const nativeOpen = vi.hoisted(() => vi.fn());
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: nativeOpen }));
const answerPrompt = vi.fn().mockResolvedValue(undefined);
let notes: Parameters<NotificationService["notify"]>[0][];
let dispose: (() => void) | undefined;
beforeEach(() => {
vi.clearAllMocks();
dispose = undefined;
notes = [];
provideNotificationService({ notify: (note) => void notes.push(note) });
setPluginsBackend({ ...createMemoryPluginsBackend(), answerPrompt });
});
afterEach(() => dispose?.());
const ask = (id: string) =>
handleRuntimeEvent({
type: "promptRequested",
pluginId: "p",
promptId: id,
prompt: { kind: "pickDirectory", title: "Clone target", initial: "C:/work" },
});
describe("project UI requests", () => {
it("returns an absolute directory as an IPC path using the supplied title and initial path", async () => {
nativeOpen.mockResolvedValue("C:\\work\\repo");
ask("directory");
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("directory", ["C:/work/repo"]));
expect(nativeOpen).toHaveBeenCalledWith({
directory: true,
multiple: false,
title: "Clone target",
defaultPath: "C:/work",
});
});
it("reports cancellation and picker errors without leaving the plugin waiting", async () => {
nativeOpen.mockResolvedValueOnce(null).mockRejectedValueOnce(new Error("picker unavailable"));
ask("cancel");
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("cancel", null));
ask("failed");
await waitFor(() => expect(answerPrompt).toHaveBeenCalledWith("failed", null));
expect(notes).toMatchObject([
{ level: "error", title: "Cannot choose a directory", body: "Error: picker unavailable" },
]);
});
it("ignores a native result after the host closes its prompt", async () => {
let finish!: (path: string) => void;
nativeOpen.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
finish = resolve;
}),
);
ask("late");
await waitFor(() => expect(nativeOpen).toHaveBeenCalled());
handleRuntimeEvent({ type: "promptClosed", promptId: "late" });
finish("C:/work/late");
await new Promise((resolve) => setTimeout(resolve, 0));
expect(answerPrompt).not.toHaveBeenCalled();
});
it("routes project opens through the normal window action", async () => {
const run = vi.fn();
dispose = registerAction({ id: "file.openProjectPath", title: "Open Project Path", run });
for (const newWindow of [false, true]) {
handleRuntimeEvent({ type: "openProject", pluginId: "p", path: "/work/repo", newWindow });
await waitFor(() =>
expect(run).toHaveBeenCalledWith(expect.objectContaining({ arg: { path: "/work/repo", newWindow } })),
);
}
});
});
+25
Переглянути файл
@@ -22,9 +22,11 @@ import { onIdeEvent } from "@/platform/ipc";
import { openExternalUrl } from "@/platform/openUrl";
import { tx, VerbatimTextContext } from "@/platform/i18n";
import { useProject } from "@/platform/project";
import { ipcPath } from "@/platform/paths";
import { shortcutLabel } from "@/platform/keymap";
import { BarButton } from "@/ui/BarButton";
import {
notifications,
maybeCommitService,
maybeEditorService,
registerStatusItem,
@@ -322,6 +324,26 @@ const openPrompts = new Map<string, AbortController>();
/** The user's answer in the shape `plugin_answer_prompt` takes: `null` when dismissed. */
async function askUser(prompt: Prompt, signal: AbortSignal): Promise<string[] | null> {
switch (prompt.kind) {
case "pickDirectory": {
if (signal.aborted) return null;
try {
const { open } = await import("@tauri-apps/plugin-dialog");
if (signal.aborted) return null;
const selected = await open({
directory: true,
multiple: false,
title: prompt.title,
defaultPath: prompt.initial ?? undefined,
});
if (signal.aborted) return null;
const path = Array.isArray(selected) ? selected[0] : selected;
return path ? [ipcPath(path)] : null;
} catch (error) {
if (!signal.aborted)
notifications().notify({ level: "error", title: tx("Cannot choose a directory"), body: String(error) });
return null;
}
}
case "quickPick":
return pickItems({ title: prompt.title, items: prompt.items, multiple: prompt.multiple, signal, verbatim: true });
case "input": {
@@ -409,6 +431,9 @@ export function handleRuntimeEvent(e: PluginEvent): boolean {
case "copyToClipboard":
void navigator.clipboard?.writeText(e.text);
return true;
case "openProject":
void executeAction("file.openProjectPath", { path: e.path, newWindow: e.newWindow });
return true;
case "openUrl":
void openExternalUrl(e.url);
return true;
+2 -1
Переглянути файл
@@ -192,5 +192,6 @@
"Cannot install the openidea command": "Cannot install the openidea command",
"Cannot uninstall the openidea command": "Cannot uninstall the openidea command",
"Open a new terminal to use the openidea command.": "Open a new terminal to use the openidea command.",
"Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal."
"Add {path} to the beginning of PATH, then open a new terminal.": "Add {path} to the beginning of PATH, then open a new terminal.",
"Cannot choose a directory": "Cannot choose a directory"
}
+2 -1
Переглянути файл
@@ -6571,5 +6571,6 @@
"Cannot install the openidea command": "无法安装 openidea 命令",
"Cannot uninstall the openidea command": "无法卸载 openidea 命令",
"Open a new terminal to use the openidea command.": "请打开新终端以使用 openidea 命令。",
"Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。"
"Add {path} to the beginning of PATH, then open a new terminal.": "请将 {path} 添加到 PATH 开头,然后打开新终端。",
"Cannot choose a directory": "无法选择目录"
}