дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(plugins): share project HTTP endpoints with the HTTP client (#1991)
Adds the API 1.3 `endpoints-provider` export and `[[contributes.endpoints]]` (file globs, ^1.3, `project.read`). A plugin's routes join the Spring ones in Search Everywhere, `.http` URL completion and the declaration's Open in HTTP Client gutter action. The UI asks `plugin_endpoints` on demand; the host runs the plugin on a blocking thread, validates the whole list (bounds, project-relative locations, UTF-16 ranges) and caches it per project until a file matching the globs changes, then emits `EndpointsChanged`. A failed or invalid run keeps the last list; disabling, crashing or closing the project withdraws it, and late results can't restore it. Closes #1960. Co-authored-by: arnaud-ritti <[email protected]>
Цей коміт міститься в:
1 джерело
4ef4adf9b3
коміт
668206ddfd
86 files changed
+1709
-97
No files matched your search
@@ -10,7 +10,12 @@ import { useProject } from "@/platform/project";
|
||||
import { pluginThemeRef, useContributedIconThemes } from "@/platform/themes";
|
||||
import { ContextMenuHost, hideContextMenu } from "@/ui";
|
||||
import { InputDialogsHost } from "@/workbench/inputDialogs";
|
||||
import { provideNotificationService, type NotificationService } from "@/workbench/contributions";
|
||||
import {
|
||||
onEndpointsChanged,
|
||||
provideNotificationService,
|
||||
searchEndpoints,
|
||||
type NotificationService,
|
||||
} from "@/workbench/contributions";
|
||||
import { applyThemeAttributes, previewTheme, useThemeOverride } from "@/workbench/theme";
|
||||
import { createMemoryPluginsBackend, sampleCatalog, setPluginsBackend, type PluginsBackend } from "../backend";
|
||||
import {
|
||||
@@ -1292,3 +1297,37 @@ describe("plugin context keys", () => {
|
||||
await waitFor(() => expect(evaluateWhen("plugin.p.f")).toBe(true));
|
||||
});
|
||||
});
|
||||
|
||||
it("asks the plugin for its routes once per plugin and again when they change", async () => {
|
||||
const plugins = [plugin({ id: "routes" })];
|
||||
const endpoints = vi.fn(async () => [
|
||||
{
|
||||
method: null,
|
||||
path: "/users/{id}",
|
||||
framework: "Rails",
|
||||
handler: "Users#show",
|
||||
location: { path: "/p/config/routes.rb", range: { start: { line: 2, col: 4 }, end: { line: 2, col: 8 } } },
|
||||
},
|
||||
]);
|
||||
setPluginsBackend({ ...createMemoryPluginsBackend(), endpoints });
|
||||
const decl = (key: string) => ({ pluginId: "routes", point: "endpoints", key, decl: { files: [key] } });
|
||||
syncUiContributions([decl("config/routes.rb"), decl("routes/*.rb")], plugins);
|
||||
expect(await searchEndpoints("/p")).toMatchObject([
|
||||
{ method: "*", url: "/users/{id}", handler: "Users#show", rel: "config/routes.rb", line: 2, col: 4 },
|
||||
]);
|
||||
expect(endpoints).toHaveBeenCalledWith("routes");
|
||||
syncUiContributions([decl("config/routes.rb")], plugins);
|
||||
expect(await searchEndpoints("/p")).toHaveLength(1);
|
||||
|
||||
const changed = vi.fn();
|
||||
const stop = onEndpointsChanged(changed);
|
||||
useProject.setState({ project: { root: "/p" } as never });
|
||||
handlePluginEvent({ type: "endpointsChanged", pluginId: "routes", root: "/other" });
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
handlePluginEvent({ type: "endpointsChanged", pluginId: "routes", root: "/p" });
|
||||
expect(changed).toHaveBeenCalledTimes(1);
|
||||
stop();
|
||||
|
||||
syncUiContributions([], plugins);
|
||||
expect(await searchEndpoints("/p")).toEqual([]);
|
||||
});
|
||||
@@ -20,11 +20,12 @@ import type {
|
||||
PluginCommitCheckResult,
|
||||
PluginContextKey,
|
||||
PluginEditor,
|
||||
PluginEndpoint,
|
||||
PluginEvent,
|
||||
PluginInfo,
|
||||
PluginOutputLine,
|
||||
PluginsSnapshot,
|
||||
PluginSecretState,
|
||||
PluginsSnapshot,
|
||||
PluginStatusItem,
|
||||
ServiceTab,
|
||||
UiContribution,
|
||||
@@ -101,6 +102,8 @@ export interface PluginsBackend {
|
||||
viewEvent(pluginId: string, view: string, event: ViewEvent, doc: number | null): Promise<void>;
|
||||
/** The Services pane of node `key` of a plugin's services view. */
|
||||
serviceDetail(pluginId: string, view: string, key: string): Promise<ServiceTab[]>;
|
||||
/** The HTTP routes a plugin reports for this window's project (`[[contributes.endpoints]]`). */
|
||||
endpoints(pluginId: string): Promise<PluginEndpoint[]>;
|
||||
/**
|
||||
* Runs the program of node `key`'s log tab `tab` (with the toggles in `on`) and streams its
|
||||
* lines until it exits (`onExit`) or the returned stop function is called.
|
||||
@@ -235,6 +238,7 @@ export const tauriPluginsBackend: PluginsBackend = {
|
||||
await call(commands.pluginViewEvent(pluginId, view, event, doc, doc == null ? editorContext() : null));
|
||||
},
|
||||
serviceDetail: (pluginId, view, key) => call(commands.pluginServiceDetail(pluginId, view, key)),
|
||||
endpoints: (pluginId) => call(commands.pluginEndpoints(pluginId)),
|
||||
serviceLog: (pluginId, view, key, tab, on, onLine, onError, onExit) => {
|
||||
const request = nextRequestId();
|
||||
const channel = new Channel<PluginOutputLine>();
|
||||
@@ -588,6 +592,7 @@ export function createMemoryPluginsBackend(
|
||||
}),
|
||||
viewEvent: async () => {},
|
||||
serviceDetail: async () => [],
|
||||
endpoints: async () => [],
|
||||
answerCommit: async () => {},
|
||||
commitChecks: async () => [],
|
||||
serviceLog: (pluginId, view, key, _tab, _on, _onLine, onError) => {
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import type { ActiveTheme, PluginEvent, PluginUpdate } from "@/bindings";
|
||||
import { useEditorRegistry } from "@/editor/api";
|
||||
import { refreshLanguageInfos } from "@/editor/core/cm/languages";
|
||||
import { notifications } from "@/workbench/contributions";
|
||||
import { endpointsChanged, notifications } from "@/workbench/contributions";
|
||||
import { useI18n } from "@/platform/i18n";
|
||||
import { useProducerNames } from "@/platform/inspections";
|
||||
import { languageListChanged } from "@/platform/languageList";
|
||||
@@ -280,6 +280,9 @@ export function handlePluginEvent(e: PluginEvent) {
|
||||
case "commandsChanged":
|
||||
scheduleRefresh();
|
||||
break;
|
||||
case "endpointsChanged":
|
||||
if (e.root === useProject.getState().project?.root) endpointsChanged();
|
||||
break;
|
||||
case "updatesAvailable":
|
||||
announceUpdates(e.updates);
|
||||
break;
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* declaration is projected onto the workbench, and how its items appear on
|
||||
* the plugin details page. One `defineUiPoint` per contribution type.
|
||||
*/
|
||||
import { registerEndpointProvider, type EndpointEntry } from "@/workbench/contributions";
|
||||
import type { PluginInfo, ThemeDef } from "@/bindings";
|
||||
import { registerAction } from "@/platform/actions";
|
||||
import { tx } from "@/platform/i18n";
|
||||
@@ -14,6 +15,7 @@ import { addIconTheme, iconThemeKey, selectIconTheme, useSelectedIconTheme } fro
|
||||
import { defineUiPoint, type UiItem } from "./projection";
|
||||
import { SecretsDetails, type SecretDecl } from "./secrets";
|
||||
import { defineViewUiPoints, pluginSettingsPageId } from "./uiPoints";
|
||||
import { pluginsBackend } from "./backend";
|
||||
import { isLive, runPluginCommand } from "./store";
|
||||
import styles from "./PluginsPage.module.css";
|
||||
|
||||
@@ -141,9 +143,45 @@ function IconThemesDetails({ plugin, items }: { plugin: PluginInfo; items: UiIte
|
||||
);
|
||||
}
|
||||
|
||||
const endpointPlugins = new Map<string, { declarations: number; dispose: () => void }>();
|
||||
|
||||
/** One endpoint provider per plugin, however many `[[contributes.endpoints]]` it declares. */
|
||||
function providePluginEndpoints(pluginId: string): () => void {
|
||||
let entry = endpointPlugins.get(pluginId);
|
||||
if (!entry) {
|
||||
const dispose = registerEndpointProvider({
|
||||
id: `plugin:${pluginId}`,
|
||||
endpoints: async (root): Promise<EndpointEntry[]> =>
|
||||
(await pluginsBackend().endpoints(pluginId)).map((e) => ({
|
||||
method: e.method ?? "*",
|
||||
url: e.path,
|
||||
framework: e.framework,
|
||||
handler: e.handler ?? undefined,
|
||||
controller: e.framework,
|
||||
methodName: e.handler ?? "",
|
||||
pluginId,
|
||||
path: e.location.path,
|
||||
rel: e.location.path.startsWith(`${root}/`) ? e.location.path.slice(root.length + 1) : e.location.path,
|
||||
line: e.location.range.start.line,
|
||||
col: e.location.range.start.col,
|
||||
})),
|
||||
});
|
||||
entry = { declarations: 0, dispose };
|
||||
endpointPlugins.set(pluginId, entry);
|
||||
}
|
||||
const held = entry;
|
||||
held.declarations++;
|
||||
return () => {
|
||||
if (--held.declarations > 0) return;
|
||||
held.dispose();
|
||||
endpointPlugins.delete(pluginId);
|
||||
};
|
||||
}
|
||||
|
||||
/** Registers the client side of every first-party UI point. */
|
||||
export function defineBuiltinUiPoints(): () => void {
|
||||
const disposers = [
|
||||
defineUiPoint("endpoints", { project: ({ pluginId }) => providePluginEndpoints(pluginId) }),
|
||||
defineUiPoint<CommandDecl>("commands", {
|
||||
title: "Commands",
|
||||
project: ({ pluginId, decl }, { plugin }) =>
|
||||
|
||||
Reference in new issue
Заблокувати користувача