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]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-07 22:02:38 +08:00
1 джерело c720024b42
коміт f3837e0fe7
45 files changed
+397 -25

No files matched your search

+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;