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]>
Цей коміт міститься в:
Arnaud Ritti authored and GitHub committed 2026-11-07 22:59:21 +08:00
1 джерело 4ef4adf9b3
коміт 668206ddfd
86 files changed
+1709 -97

No files matched your search

+40 -1
Переглянути файл
@@ -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([]);
});
+6 -1
Переглянути файл
@@ -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) => {
+4 -1
Переглянути файл
@@ -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;
+38
Переглянути файл
@@ -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 }) =>