* feat(web,core): import client configs and registry server.json (#1348) Wire up the two server-import affordances that were previously todoNoop: - Import config: source picker (Claude Desktop / Cursor / Cline / VS Code) read on the backend via a new GET /api/import-source, plus a file-upload fallback. Per-server review with import/skip on new servers and overwrite/skip/rename on id conflicts, then a per-server outcome summary. - Import server.json: parse the MCP registry single-server format (npm/pypi/oci packages + remotes, env vars, package/runtime args, URL template vars) into a runnable config, with a file picker and debounced live validation; Add Server is disabled until validation passes. Core gains an isomorphic import strategy layer (core/mcp/import) with a strategy registry, client-config + server.json parsers, a strategy-agnostic merge, and a dependency-injected source resolver. useServers exposes importSource(). Modal headers are bold and the redundant server.json section header is folded into the modal title. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): collapse the server.json textarea into a File Contents disclosure Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): auto-collapse File Contents disclosure after content loads Once content is pasted or a file is loaded, collapse the File Contents disclosure after ~1s with Mantine's built-in accordion animation, signaling the input was accepted and surfacing the validation/env-var sections. The disclosure becomes controlled by the modal; clearing the textarea re-opens it. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): flash File Contents hover highlight before auto-collapse Before the File Contents disclosure auto-collapses, paint it with its hover background for 250ms, then run the collapse over a 325ms animation, so the collapse reads as intentional. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): rename "Import server.json" to "Import from registry config" Rename the Add Servers menu entry and the modal header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): rename "Import config" to "Import client config" Rename the Add Servers menu entry and the modal header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(web,core): move import orchestration into core helpers + web hooks Make the import logic reusable by CLI/TUI and keep the modals display-only: - core/mcp/import: add parseClientConfig (mcpServers→servers fallback), resolveServerId, selectServerJsonOption, and buildServerConfigForSelection, so the full import (parse → select → build → merge) is callable from any environment. - core/storage: extract the pure validateStoreId into a Node-free store-id module (store-io re-exports it) so the isomorphic import layer can validate ids without pulling Node deps into the browser bundle. - clients/web/hooks: useServerJsonImport and useImportClientConfig own all the wizard/parse/debounce/apply state; ServerImportConfigModal and ServerImportJsonModal are now thin display shells over the dumb panel. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): rename "Import client config" to "Import from client config" Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): remove the Cancel button from the registry import modal The modal header X (and Escape) still dismiss it; drop the redundant Cancel button (and the panel onCancel prop). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): hide validation results + name override until content is present In the registry import modal, the Validation Results section and the Server Name override field stay hidden until something has been pasted/loaded into the textarea. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): make Validation Results a collapsible disclosure Wrap the registry import modal's validation results in a Mantine accordion disclosure (default open), matching the File Contents section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): Server Name disclosure with read-only derived name + override Make Server Name a collapsible disclosure containing a read-only "From configuration" field (the id derived from the server.json name) and the editable Override field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): scroll to and highlight a freshly-added server After a manual add/clone or an import (client config / registry), the new server's card scrolls into view and draws an animated border (Mantine Border Animate); clicking anywhere on the card dismisses it. - App tracks highlightedServerId; addServerHighlighted sets it (edits and conflict-overwrites don't). Threaded through InspectorView -> ServerListScreen -> ServerCard. - ServerCard scrolls into view on highlight and wraps in <BorderAnimate>; onClick clears the highlight. - Pin @gfazioli/mantine-border-animate@2.0.3 (Mantine 8 compatible; 2.0.4+ requires Mantine 9). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web): highlighted card fills its cell and its controls work on first click - The BorderAnimate wrapper is display:flex, so the card was shrinking to content width while the border spanned the cell. Stretch the card with w=100% (and the wrapper). - Always render BorderAnimate and toggle show/animate instead of conditionally wrapping: swapping the element type on clear remounted the card, swallowing the click that triggered it (e.g. the connect toggle fired clear but not connect, needing a second click). Verified: one click now connects + clears. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(web,core): address review — OCI env, untrack fixture, rename validation - Untrack the stray inspector-v1-multi.server.json (accidentally committed) and gitignore root *.server.json fixtures. - core/mcp/import/serverJson: forward declared env vars into OCI containers via '-e KEY' docker args (they previously only set the docker CLI process env); add a test. Clarify buildServerConfig (destructure env out instead of spread-then-delete) and document value-vs-default env handling. - useImportClientConfig: validate 'rename' targets inline (invalid charset / collision with existing, additions, or other renames) and gate the Import button on canImport; surface the error on the field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(web): highlight all servers from a batch import, not just the last Track highlighted servers as a set of ids instead of one. addServerHighlighted accumulates each id (a multi-server client-config import highlights them all); opening an add/import modal starts a fresh batch. Clicking a card clears only that card's highlight; only the first highlighted card scrolls into view. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5efe9ddc7b
commit
97b1a7fbff
3 changed files with 4238 additions and 176 deletions
|
|
@ -0,0 +1,53 @@
|
|||
import { AppShell } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { useArgs } from "storybook/preview-api";
|
||||
import {
|
||||
ServerImportJsonModal,
|
||||
type ServerImportJsonModalProps,
|
||||
} from "./ServerImportJsonModal";
|
||||
|
||||
function InteractiveRender(args: ServerImportJsonModalProps) {
|
||||
const [, updateArgs] = useArgs<ServerImportJsonModalProps>();
|
||||
return (
|
||||
<AppShell>
|
||||
<AppShell.Main>
|
||||
<ServerImportJsonModal
|
||||
{...args}
|
||||
onClose={() => {
|
||||
args.onClose();
|
||||
updateArgs({ opened: false });
|
||||
}}
|
||||
/>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof ServerImportJsonModal> = {
|
||||
title: "Groups/ServerImportJsonModal",
|
||||
component: ServerImportJsonModal,
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: InteractiveRender,
|
||||
args: {
|
||||
opened: true,
|
||||
existingIds: [],
|
||||
onClose: fn(),
|
||||
onAddServer: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ServerImportJsonModal>;
|
||||
|
||||
export const Empty: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await expect(
|
||||
await body.findByText("Import from registry config"),
|
||||
).toBeInTheDocument();
|
||||
await expect(
|
||||
body.getByRole("button", { name: "Add Server" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import {
|
||||
renderWithMantine,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from "../../../test/renderWithMantine";
|
||||
import { ServerImportJsonModal } from "./ServerImportJsonModal";
|
||||
|
||||
const npmJson = JSON.stringify({
|
||||
name: "io.github.me/weather",
|
||||
packages: [
|
||||
{
|
||||
registryType: "npm",
|
||||
identifier: "@me/weather",
|
||||
version: "1.0.0",
|
||||
environmentVariables: [
|
||||
{ name: "API_KEY", isRequired: true },
|
||||
{ name: "LOG_LEVEL", default: "info" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const multiPackageJson = JSON.stringify({
|
||||
name: "io.github.me/multi",
|
||||
packages: [
|
||||
{ registryType: "npm", identifier: "@me/multi" },
|
||||
{ registryType: "pypi", identifier: "multi-py" },
|
||||
],
|
||||
});
|
||||
|
||||
/** The JSON textarea is the first textbox the panel renders. */
|
||||
function pasteJson(text: string) {
|
||||
const textarea = screen.getAllByRole("textbox")[0];
|
||||
fireEvent.change(textarea, { target: { value: text } });
|
||||
}
|
||||
|
||||
describe("ServerImportJsonModal", () => {
|
||||
it("renders nothing actionable when closed", () => {
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened={false}
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("Import from registry config"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides validation results and the name override before any JSON is pasted", () => {
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Validation Results")).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("Override")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("validates a pasted npm server.json and surfaces env vars", async () => {
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
pasteJson(npmJson);
|
||||
// Validation is debounced, so it appears after a short pause.
|
||||
expect(
|
||||
await screen.findByText(/Valid server.json for "io.github.me\/weather"/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 runnable option/)).toBeInTheDocument();
|
||||
// Env var inputs are rendered (required one + defaulted one).
|
||||
expect(screen.getByLabelText(/API_KEY/)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/LOG_LEVEL/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reports a parse error for malformed JSON", async () => {
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
pasteJson("{not json");
|
||||
expect(await screen.findByText(/Invalid JSON/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns when the derived id already exists", async () => {
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={["weather"]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
pasteJson(npmJson);
|
||||
expect(
|
||||
await screen.findByText(/A server with id "weather" already exists/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("builds the config with env overrides and calls onAddServer", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddServer = vi.fn().mockResolvedValue(undefined);
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={onClose}
|
||||
onAddServer={onAddServer}
|
||||
/>,
|
||||
);
|
||||
pasteJson(npmJson);
|
||||
// The env-var inputs appear after the debounced parse.
|
||||
await user.type(await screen.findByLabelText(/API_KEY/), "secret");
|
||||
await user.click(screen.getByRole("button", { name: "Add Server" }));
|
||||
await waitFor(() => expect(onAddServer).toHaveBeenCalledTimes(1));
|
||||
const [id, config] = onAddServer.mock.calls[0];
|
||||
expect(id).toBe("weather");
|
||||
expect(config).toMatchObject({
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@me/weather@1.0.0"],
|
||||
env: { API_KEY: "secret", LOG_LEVEL: "info" },
|
||||
});
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("honors a server name override", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddServer = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={onAddServer}
|
||||
/>,
|
||||
);
|
||||
pasteJson(npmJson);
|
||||
await user.type(screen.getByLabelText("Override"), "my-weather");
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Add Server" })).toBeEnabled(),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Add Server" }));
|
||||
await waitFor(() => expect(onAddServer).toHaveBeenCalled());
|
||||
expect(onAddServer.mock.calls[0][0]).toBe("my-weather");
|
||||
});
|
||||
|
||||
it("lets the user pick among multiple packages", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddServer = vi.fn().mockResolvedValue(undefined);
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={onAddServer}
|
||||
/>,
|
||||
);
|
||||
pasteJson(multiPackageJson);
|
||||
// The package radios appear after the debounced parse.
|
||||
await user.click(await screen.findByLabelText(/pypi: multi-py/));
|
||||
await user.click(screen.getByRole("button", { name: "Add Server" }));
|
||||
await waitFor(() => expect(onAddServer).toHaveBeenCalled());
|
||||
expect(onAddServer.mock.calls[0][1]).toMatchObject({ command: "uvx" });
|
||||
});
|
||||
|
||||
it("surfaces an onAddServer rejection instead of closing", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddServer = vi.fn().mockRejectedValue(new Error("disk full"));
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={onClose}
|
||||
onAddServer={onAddServer}
|
||||
/>,
|
||||
);
|
||||
pasteJson(npmJson);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Add Server" })).toBeEnabled(),
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "Add Server" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/disk full/)).toBeInTheDocument(),
|
||||
);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Add Server until valid content is present", () => {
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "Add Server" })).toBeDisabled();
|
||||
});
|
||||
|
||||
it("guards against a live edit made before the debounce re-validates", async () => {
|
||||
const onAddServer = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={onAddServer}
|
||||
/>,
|
||||
);
|
||||
pasteJson(npmJson);
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("button", { name: "Add Server" })).toBeEnabled(),
|
||||
);
|
||||
// Replace with invalid content; the button hasn't re-disabled yet (the
|
||||
// debounce is still pending), so clicking exercises the submit-time guard.
|
||||
pasteJson("{not json");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add Server" }));
|
||||
expect(onAddServer).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await screen.findByText(/Fix the validation errors/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("loads server.json from a chosen file", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const file = new File([npmJson], "server.json", {
|
||||
type: "application/json",
|
||||
});
|
||||
// The Modal is portaled, so query the file input from the document.
|
||||
const input = document.querySelector(
|
||||
'input[type="file"]',
|
||||
) as HTMLInputElement;
|
||||
await user.upload(input, file);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/Valid server.json for "io.github.me\/weather"/),
|
||||
).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it("auto-collapses the File Contents disclosure after content loads", async () => {
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
const disclosure = screen.getByRole("button", { name: "File Contents" });
|
||||
expect(disclosure).toHaveAttribute("aria-expanded", "true");
|
||||
pasteJson(npmJson);
|
||||
await waitFor(
|
||||
() => expect(disclosure).toHaveAttribute("aria-expanded", "false"),
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("re-opens File Contents when the textarea is cleared", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
pasteJson(npmJson);
|
||||
const disclosure = screen.getByRole("button", { name: "File Contents" });
|
||||
// Clear via the textarea's Clear button while still expanded.
|
||||
await user.click(screen.getAllByRole("button", { name: "Clear" })[0]);
|
||||
expect(disclosure).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("rejects an invalid id override and blocks Add Server", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onAddServer = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={vi.fn()}
|
||||
onAddServer={onAddServer}
|
||||
/>,
|
||||
);
|
||||
pasteJson(npmJson);
|
||||
await user.type(screen.getByLabelText("Override"), "bad id!");
|
||||
expect(
|
||||
await screen.findByText(/Server id must use only letters/),
|
||||
).toBeInTheDocument();
|
||||
// An invalid id keeps the Add button disabled.
|
||||
expect(screen.getByRole("button", { name: "Add Server" })).toBeDisabled();
|
||||
expect(onAddServer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes via the Escape key (no Cancel button)", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<ServerImportJsonModal
|
||||
opened
|
||||
existingIds={[]}
|
||||
onClose={onClose}
|
||||
onAddServer={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Cancel" }),
|
||||
).not.toBeInTheDocument();
|
||||
await user.keyboard("{Escape}");
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { Modal, Text } from "@mantine/core";
|
||||
import type { MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
import { useServerJsonImport } from "../../../hooks/useServerJsonImport";
|
||||
import { ImportServerJsonPanel } from "../ImportServerJsonPanel/ImportServerJsonPanel";
|
||||
|
||||
const ModalTitle = Text.withProps({ fw: 700, span: true });
|
||||
|
||||
export interface ServerImportJsonModalProps {
|
||||
opened: boolean;
|
||||
/** Ids already in use — drives the duplicate-id warning. */
|
||||
existingIds: string[];
|
||||
onClose: () => void;
|
||||
/** Persist the chosen server. Resolves once the catalog has been updated. */
|
||||
onAddServer: (id: string, config: MCPServerConfig) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display shell for the registry `server.json` import (#1348). All orchestration
|
||||
* (parsing, debounced validation, selection, auto-collapse, submit) lives in
|
||||
* `useServerJsonImport`; this component only renders the modal + dumb panel.
|
||||
*/
|
||||
export function ServerImportJsonModal({
|
||||
opened,
|
||||
existingIds,
|
||||
onClose,
|
||||
onAddServer,
|
||||
}: ServerImportJsonModalProps) {
|
||||
const vm = useServerJsonImport({ opened, existingIds, onAddServer });
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
centered
|
||||
title={<ModalTitle>Import from registry config</ModalTitle>}
|
||||
>
|
||||
<ImportServerJsonPanel
|
||||
draft={vm.draft}
|
||||
validation={vm.validation}
|
||||
packages={vm.packages}
|
||||
envVars={vm.envVars}
|
||||
addDisabled={!vm.canAdd}
|
||||
fileContentsOpen={vm.fileContentsOpen}
|
||||
onFileContentsChange={vm.setFileContentsOpen}
|
||||
fileContentsHighlight={vm.fileContentsHighlight}
|
||||
onJsonChange={vm.setRawText}
|
||||
onSelectPackage={vm.selectPackage}
|
||||
onEnvVarChange={vm.setEnvVar}
|
||||
onServerNameChange={vm.setServerName}
|
||||
defaultServerName={vm.defaultServerName}
|
||||
onAddServer={() => {
|
||||
void vm.submit().then((added) => {
|
||||
if (added) onClose();
|
||||
});
|
||||
}}
|
||||
onPickFile={(file) => void vm.pickFile(file)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in a new issue