дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
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
Цей коміт міститься в:
коміт
5890e8abc5
51 files changed
+516
-33
No files matched your search
@@ -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";
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 } })),
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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": "无法选择目录"
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача