дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 19:25:54 +02:00
feat(plugins): let window commands choose folders and open projects (#1998)
Adds API 1.3 `ui.pick-directory` (native folder chooser, same window/budget rules as `pick`) and `workspace.open-project(path, new-window)`, which goes through the normal File › Open flow with its trust prompt, window reuse and unsaved-change handling. Paths are absolute on the UI machine and validated by the host whatever its OS. Closes #1959. Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
1 джерело
c720024b42
коміт
f3837e0fe7
45 files changed
+397
-25
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Заблокувати користувача