* 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
2 changed files with 4238 additions and 176 deletions
321
clients/web/src/hooks/useImportClientConfig.ts
Normal file
321
clients/web/src/hooks/useImportClientConfig.ts
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import {
|
||||
parseClientConfig,
|
||||
planImport,
|
||||
uniqueId,
|
||||
type ConflictResolution,
|
||||
type ImportPlan,
|
||||
type ImportSourceResult,
|
||||
} from "@inspector/core/mcp/import/index.js";
|
||||
import { validateStoreId } from "@inspector/core/storage/store-id.js";
|
||||
import type { MCPConfig, MCPServerConfig } from "@inspector/core/mcp/types.js";
|
||||
|
||||
export type ImportPhase = "select" | "loading" | "review" | "summary";
|
||||
|
||||
export interface ConflictResolutionState {
|
||||
action: ConflictResolution;
|
||||
renameTo: string;
|
||||
}
|
||||
|
||||
/** Per-row choice for a brand-new (non-conflicting) server. */
|
||||
export type AdditionAction = "import" | "skip";
|
||||
|
||||
export interface ImportOutcome {
|
||||
id: string;
|
||||
status: "added" | "overwritten" | "renamed" | "skipped" | "failed";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface UseImportClientConfigOptions {
|
||||
opened: boolean;
|
||||
/** Ids already in the catalog — drives conflict detection + rename defaults. */
|
||||
existingIds: string[];
|
||||
/** Read a client's well-known config on the backend (authed GET). */
|
||||
onFetchSource: (type: string) => Promise<ImportSourceResult>;
|
||||
onAddServer: (id: string, config: MCPServerConfig) => Promise<void>;
|
||||
onUpdateServer: (
|
||||
originalId: string,
|
||||
newId: string,
|
||||
config: MCPServerConfig,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface ImportClientConfigViewModel {
|
||||
phase: ImportPhase;
|
||||
error?: string;
|
||||
notice?: string;
|
||||
plan: ImportPlan | null;
|
||||
resolutions: Record<string, ConflictResolutionState>;
|
||||
additionActions: Record<string, AdditionAction>;
|
||||
outcomes: ImportOutcome[];
|
||||
selectedType: string | null;
|
||||
importCount: number;
|
||||
/** Per-conflict validation message for an invalid/colliding rename target. */
|
||||
renameErrors: Record<string, string>;
|
||||
/** True when there's something to import and no rename is invalid. */
|
||||
canImport: boolean;
|
||||
setSelectedType: (type: string | null) => void;
|
||||
pickSource: (type: string) => Promise<void>;
|
||||
pickFile: (file: File | null) => Promise<void>;
|
||||
setResolution: (id: string, action: ConflictResolution) => void;
|
||||
setRenameTo: (id: string, renameTo: string) => void;
|
||||
setAdditionAction: (id: string, action: AdditionAction) => void;
|
||||
runImport: () => Promise<void>;
|
||||
back: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiring for the "Import from client config" flow (#1348): owns the wizard phase,
|
||||
* the parsed plan, per-server resolutions, and the apply loop. Keeps
|
||||
* `ServerImportConfigModal` a thin display component; the reusable parsing +
|
||||
* merge logic lives in `core/mcp/import`.
|
||||
*/
|
||||
export function useImportClientConfig({
|
||||
opened,
|
||||
existingIds,
|
||||
onFetchSource,
|
||||
onAddServer,
|
||||
onUpdateServer,
|
||||
}: UseImportClientConfigOptions): ImportClientConfigViewModel {
|
||||
const [phase, setPhase] = useState<ImportPhase>("select");
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
const [notice, setNotice] = useState<string | undefined>(undefined);
|
||||
const [incoming, setIncoming] = useState<MCPConfig | null>(null);
|
||||
const [resolutions, setResolutions] = useState<
|
||||
Record<string, ConflictResolutionState>
|
||||
>({});
|
||||
const [additionActions, setAdditionActions] = useState<
|
||||
Record<string, AdditionAction>
|
||||
>({});
|
||||
const [outcomes, setOutcomes] = useState<ImportOutcome[]>([]);
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
|
||||
// Reset the wizard each time the modal transitions to open. Done during
|
||||
// render (React's "adjust state when a prop changes" pattern) rather than in
|
||||
// an effect so there's no extra commit/cascade.
|
||||
const [prevOpened, setPrevOpened] = useState(opened);
|
||||
if (opened !== prevOpened) {
|
||||
setPrevOpened(opened);
|
||||
if (opened) {
|
||||
setPhase("select");
|
||||
setError(undefined);
|
||||
setNotice(undefined);
|
||||
setIncoming(null);
|
||||
setResolutions({});
|
||||
setAdditionActions({});
|
||||
setOutcomes([]);
|
||||
setSelectedType(null);
|
||||
}
|
||||
}
|
||||
|
||||
const plan = useMemo(
|
||||
() => (incoming ? planImport(incoming, existingIds) : null),
|
||||
[incoming, existingIds],
|
||||
);
|
||||
|
||||
function beginReview(config: MCPConfig) {
|
||||
const fresh = planImport(config, existingIds);
|
||||
if (fresh.additions.length === 0 && fresh.conflicts.length === 0) {
|
||||
setError("No servers found in the selected source.");
|
||||
setPhase("select");
|
||||
return;
|
||||
}
|
||||
const taken = [...existingIds, ...fresh.additions.map((a) => a.id)];
|
||||
const initial: Record<string, ConflictResolutionState> = {};
|
||||
for (const conflict of fresh.conflicts) {
|
||||
initial[conflict.id] = {
|
||||
action: "skip",
|
||||
renameTo: uniqueId(conflict.id, taken),
|
||||
};
|
||||
}
|
||||
// New servers default to "import"; the user can opt individual ones out.
|
||||
const initialAdds: Record<string, AdditionAction> = {};
|
||||
for (const add of fresh.additions) {
|
||||
initialAdds[add.id] = "import";
|
||||
}
|
||||
setIncoming(config);
|
||||
setResolutions(initial);
|
||||
setAdditionActions(initialAdds);
|
||||
setError(undefined);
|
||||
setPhase("review");
|
||||
}
|
||||
|
||||
async function pickSource(type: string) {
|
||||
setPhase("loading");
|
||||
setError(undefined);
|
||||
setNotice(undefined);
|
||||
try {
|
||||
const result = await onFetchSource(type);
|
||||
if (result.error) {
|
||||
setError(result.error);
|
||||
setPhase("select");
|
||||
return;
|
||||
}
|
||||
if (!result.found || !result.config) {
|
||||
setNotice(
|
||||
`No config found. Searched: ${result.searched.join(", ")}. Try uploading a file instead.`,
|
||||
);
|
||||
setPhase("select");
|
||||
return;
|
||||
}
|
||||
beginReview(result.config);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setPhase("select");
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFile(file: File | null) {
|
||||
if (!file) return;
|
||||
setError(undefined);
|
||||
setNotice(undefined);
|
||||
try {
|
||||
const raw = await file.text();
|
||||
beginReview(parseClientConfig(raw));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function setResolution(id: string, action: ConflictResolution) {
|
||||
setResolutions((r) => ({ ...r, [id]: { ...r[id], action } }));
|
||||
}
|
||||
|
||||
function setRenameTo(id: string, renameTo: string) {
|
||||
setResolutions((r) => ({ ...r, [id]: { ...r[id], renameTo } }));
|
||||
}
|
||||
|
||||
function setAdditionAction(id: string, action: AdditionAction) {
|
||||
setAdditionActions((a) => ({ ...a, [id]: action }));
|
||||
}
|
||||
|
||||
async function applyEntry(
|
||||
id: string,
|
||||
outcome: ImportOutcome["status"],
|
||||
write: () => Promise<void>,
|
||||
): Promise<ImportOutcome> {
|
||||
try {
|
||||
await write();
|
||||
return { id, status: outcome };
|
||||
} catch (err) {
|
||||
return {
|
||||
id,
|
||||
status: "failed",
|
||||
detail: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function runImport() {
|
||||
if (!plan) return;
|
||||
setPhase("loading");
|
||||
const results: ImportOutcome[] = [];
|
||||
for (const add of plan.additions) {
|
||||
if ((additionActions[add.id] ?? "import") === "skip") {
|
||||
results.push({ id: add.id, status: "skipped" });
|
||||
continue;
|
||||
}
|
||||
results.push(
|
||||
await applyEntry(add.id, "added", () =>
|
||||
Promise.resolve(onAddServer(add.id, add.config)),
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const conflict of plan.conflicts) {
|
||||
const res = resolutions[conflict.id] ?? {
|
||||
action: "skip",
|
||||
renameTo: conflict.id,
|
||||
};
|
||||
if (res.action === "skip") {
|
||||
results.push({ id: conflict.id, status: "skipped" });
|
||||
} else if (res.action === "overwrite") {
|
||||
results.push(
|
||||
await applyEntry(conflict.id, "overwritten", () =>
|
||||
onUpdateServer(conflict.id, conflict.id, conflict.config),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const newId = res.renameTo.trim() || conflict.id;
|
||||
results.push(
|
||||
await applyEntry(newId, "renamed", () =>
|
||||
Promise.resolve(onAddServer(newId, conflict.config)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
setOutcomes(results);
|
||||
setPhase("summary");
|
||||
}
|
||||
|
||||
const importCount = plan
|
||||
? plan.additions.filter(
|
||||
(a) => (additionActions[a.id] ?? "import") === "import",
|
||||
).length +
|
||||
plan.conflicts.filter(
|
||||
(c) => (resolutions[c.id]?.action ?? "skip") !== "skip",
|
||||
).length
|
||||
: 0;
|
||||
|
||||
// Validate each "rename" target inline: it must be a syntactically valid id
|
||||
// that doesn't collide with an existing server, an imported addition, or
|
||||
// another rename target — so the user learns before submitting rather than
|
||||
// getting a 409 in the summary.
|
||||
const renameErrors = useMemo<Record<string, string>>(() => {
|
||||
if (!plan) return {};
|
||||
const errors: Record<string, string> = {};
|
||||
for (const conflict of plan.conflicts) {
|
||||
const res = resolutions[conflict.id];
|
||||
if (res?.action !== "rename") continue;
|
||||
const newId = res.renameTo.trim();
|
||||
if (!newId) {
|
||||
errors[conflict.id] = "A new id is required.";
|
||||
continue;
|
||||
}
|
||||
if (!validateStoreId(newId)) {
|
||||
errors[conflict.id] =
|
||||
"Use only letters, numbers, hyphens, underscores.";
|
||||
continue;
|
||||
}
|
||||
// Ids that would already exist alongside this rename.
|
||||
const taken = new Set<string>(existingIds);
|
||||
for (const add of plan.additions) {
|
||||
if ((additionActions[add.id] ?? "import") === "import") {
|
||||
taken.add(add.id);
|
||||
}
|
||||
}
|
||||
for (const other of plan.conflicts) {
|
||||
if (other.id === conflict.id) continue;
|
||||
const r = resolutions[other.id];
|
||||
if (r?.action === "rename") taken.add(r.renameTo.trim());
|
||||
}
|
||||
if (taken.has(newId)) {
|
||||
errors[conflict.id] = `"${newId}" is already in use.`;
|
||||
}
|
||||
}
|
||||
return errors;
|
||||
}, [plan, resolutions, additionActions, existingIds]);
|
||||
|
||||
const canImport = importCount > 0 && Object.keys(renameErrors).length === 0;
|
||||
|
||||
return {
|
||||
phase,
|
||||
error,
|
||||
notice,
|
||||
plan,
|
||||
resolutions,
|
||||
additionActions,
|
||||
outcomes,
|
||||
selectedType,
|
||||
importCount,
|
||||
renameErrors,
|
||||
canImport,
|
||||
setSelectedType,
|
||||
pickSource,
|
||||
pickFile,
|
||||
setResolution,
|
||||
setRenameTo,
|
||||
setAdditionAction,
|
||||
runImport,
|
||||
back: () => setPhase("select"),
|
||||
};
|
||||
}
|
||||
308
clients/web/src/hooks/useServerJsonImport.ts
Normal file
308
clients/web/src/hooks/useServerJsonImport.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
parseServerJson,
|
||||
buildServerConfigForSelection,
|
||||
selectServerJsonOption,
|
||||
type ParsedServerJson,
|
||||
} from "@inspector/core/mcp/import/index.js";
|
||||
import type {
|
||||
InspectorServerJsonDraft,
|
||||
MCPServerConfig,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import type {
|
||||
EnvVarInfo,
|
||||
PackageInfo,
|
||||
ValidationResult,
|
||||
} from "../components/groups/ImportServerJsonPanel/ImportServerJsonPanel";
|
||||
|
||||
/** Debounce (ms) before a textarea edit re-triggers parse/validation. */
|
||||
const VALIDATE_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Delay (ms) after content is loaded/pasted before the File Contents disclosure
|
||||
* auto-collapses — long enough to read as "the content was accepted".
|
||||
*/
|
||||
const COLLAPSE_DELAY_MS = 1000;
|
||||
|
||||
/**
|
||||
* How long (ms) to flash the disclosure with its hover background just before it
|
||||
* collapses, so the collapse reads as intentional rather than abrupt.
|
||||
*/
|
||||
const HIGHLIGHT_DURATION_MS = 250;
|
||||
|
||||
const EMPTY_DRAFT: InspectorServerJsonDraft = {
|
||||
rawText: "",
|
||||
envOverrides: {},
|
||||
};
|
||||
|
||||
/** Result of parsing the current draft text. */
|
||||
type ParseState =
|
||||
| { ok: true; parsed: ParsedServerJson }
|
||||
| { ok: false; error: string }
|
||||
| { ok: null };
|
||||
|
||||
function parseDraft(rawText: string): ParseState {
|
||||
if (!rawText.trim()) return { ok: null };
|
||||
try {
|
||||
return { ok: true, parsed: parseServerJson(rawText) };
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseServerJsonImportOptions {
|
||||
/** Whether the host modal is open — drives the per-open reset. */
|
||||
opened: boolean;
|
||||
/** Ids already in use — drives the duplicate-id warning. */
|
||||
existingIds: string[];
|
||||
/** Persist the chosen server. Resolves once the catalog has been updated. */
|
||||
onAddServer: (id: string, config: MCPServerConfig) => Promise<void> | void;
|
||||
}
|
||||
|
||||
/** View-model the dumb `ImportServerJsonPanel` renders from. */
|
||||
export interface ServerJsonImportViewModel {
|
||||
draft: InspectorServerJsonDraft;
|
||||
validation: ValidationResult[];
|
||||
packages?: PackageInfo[];
|
||||
envVars: EnvVarInfo[];
|
||||
canAdd: boolean;
|
||||
/** Derived id from the server.json `name` (before any override). */
|
||||
defaultServerName: string;
|
||||
fileContentsOpen: boolean;
|
||||
fileContentsHighlight: boolean;
|
||||
setRawText: (content: string) => void;
|
||||
selectPackage: (index: number) => void;
|
||||
setEnvVar: (name: string, value: string) => void;
|
||||
setServerName: (name: string) => void;
|
||||
pickFile: (file: File | null) => Promise<void>;
|
||||
setFileContentsOpen: (open: boolean) => void;
|
||||
/** Build + persist the selected server. Resolves to true when it was added. */
|
||||
submit: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wiring for the registry `server.json` import (#1348): owns the draft + parse
|
||||
* state, the debounced validation, and the File Contents auto-collapse, and
|
||||
* builds/persists the selected server on submit. Keeps `ServerImportJsonModal`
|
||||
* a thin display component; the reusable parsing/selection lives in
|
||||
* `core/mcp/import`.
|
||||
*/
|
||||
export function useServerJsonImport({
|
||||
opened,
|
||||
existingIds,
|
||||
onAddServer,
|
||||
}: UseServerJsonImportOptions): ServerJsonImportViewModel {
|
||||
const [draft, setDraft] = useState<InspectorServerJsonDraft>(EMPTY_DRAFT);
|
||||
const [submitError, setSubmitError] = useState<string | undefined>(undefined);
|
||||
// Debounced copy of the raw text that drives parsing/validation, so typing
|
||||
// doesn't re-parse on every keystroke. The textarea stays bound to live text.
|
||||
const [debouncedText, setDebouncedText] = useState<string>(
|
||||
EMPTY_DRAFT.rawText,
|
||||
);
|
||||
// The File Contents disclosure starts open and auto-collapses shortly after
|
||||
// content is loaded/pasted; `highlight` flashes its hover bg before collapse.
|
||||
const [fileContentsOpen, setFileContentsOpen] = useState(true);
|
||||
const [fileContentsHighlight, setFileContentsHighlight] = useState(false);
|
||||
|
||||
// Reset each time the modal transitions to open. Done during render (React's
|
||||
// "adjust state when a prop changes" pattern) so there's no extra commit.
|
||||
const [prevOpened, setPrevOpened] = useState(opened);
|
||||
if (opened !== prevOpened) {
|
||||
setPrevOpened(opened);
|
||||
if (opened) {
|
||||
setDraft(EMPTY_DRAFT);
|
||||
setSubmitError(undefined);
|
||||
setDebouncedText(EMPTY_DRAFT.rawText);
|
||||
setFileContentsOpen(true);
|
||||
setFileContentsHighlight(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-validate after the user pauses typing. setState lives in the timeout
|
||||
// callback (not the effect body), so this doesn't cascade-render.
|
||||
useEffect(() => {
|
||||
const id = setTimeout(
|
||||
() => setDebouncedText(draft.rawText),
|
||||
VALIDATE_DEBOUNCE_MS,
|
||||
);
|
||||
return () => clearTimeout(id);
|
||||
}, [draft.rawText]);
|
||||
|
||||
// Once there's content, flash the disclosure's hover highlight and then
|
||||
// collapse it, signalling the paste/file load was accepted and surfacing the
|
||||
// validation / env-var sections. Clearing re-opens it (see setRawText).
|
||||
useEffect(() => {
|
||||
if (!draft.rawText.trim()) return;
|
||||
let collapseTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
const highlightTimer = setTimeout(() => {
|
||||
setFileContentsHighlight(true);
|
||||
collapseTimer = setTimeout(() => {
|
||||
setFileContentsHighlight(false);
|
||||
setFileContentsOpen(false);
|
||||
}, HIGHLIGHT_DURATION_MS);
|
||||
}, COLLAPSE_DELAY_MS);
|
||||
return () => {
|
||||
clearTimeout(highlightTimer);
|
||||
if (collapseTimer) clearTimeout(collapseTimer);
|
||||
};
|
||||
}, [draft.rawText]);
|
||||
|
||||
const parseState = useMemo(() => parseDraft(debouncedText), [debouncedText]);
|
||||
const selection = useMemo(() => {
|
||||
if (parseState.ok !== true) return undefined;
|
||||
return selectServerJsonOption(parseState.parsed, {
|
||||
selectedIndex: draft.selectedPackageIndex,
|
||||
idOverride: draft.nameOverride,
|
||||
existingIds,
|
||||
});
|
||||
}, [parseState, draft.selectedPackageIndex, draft.nameOverride, existingIds]);
|
||||
|
||||
const selectedIndex = selection?.selectedIndex ?? 0;
|
||||
const selectedOption = selection?.selectedOption;
|
||||
const parsed = parseState.ok === true ? parseState.parsed : undefined;
|
||||
|
||||
// The Add button is enabled only when the (debounced) content validates.
|
||||
const canAdd =
|
||||
selection !== undefined && selection.idIsValid && !selection.idIsDuplicate;
|
||||
|
||||
const packages: PackageInfo[] | undefined = parsed?.options.map((o) => ({
|
||||
registryType: o.registryType,
|
||||
identifier: o.identifier,
|
||||
runtimeHint: o.runtimeHint,
|
||||
}));
|
||||
|
||||
const envVars: EnvVarInfo[] = (selectedOption?.envVars ?? []).map((v) => ({
|
||||
name: v.name,
|
||||
description: v.description,
|
||||
required: v.required,
|
||||
value: draft.envOverrides[v.name] ?? v.default ?? "",
|
||||
}));
|
||||
|
||||
const validation: ValidationResult[] = useMemo(() => {
|
||||
if (parseState.ok === null) {
|
||||
return [
|
||||
{ type: "info", message: "Paste server.json content to validate." },
|
||||
];
|
||||
}
|
||||
if (parseState.ok === false) {
|
||||
return [{ type: "error", message: parseState.error }];
|
||||
}
|
||||
const results: ValidationResult[] = [
|
||||
{
|
||||
type: "success",
|
||||
message: `Valid server.json for "${parseState.parsed.fullName}".`,
|
||||
},
|
||||
{
|
||||
type: "info",
|
||||
message: `${parseState.parsed.options.length} runnable option(s) found.`,
|
||||
},
|
||||
];
|
||||
if (selection && !selection.idIsValid) {
|
||||
results.push({
|
||||
type: "error",
|
||||
message:
|
||||
"Server id must use only letters, numbers, hyphens, underscores.",
|
||||
});
|
||||
} else if (selection?.idIsDuplicate) {
|
||||
results.push({
|
||||
type: "warning",
|
||||
message: `A server with id "${selection.serverId}" already exists.`,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, [parseState, selection]);
|
||||
|
||||
const allValidation = submitError
|
||||
? [...validation, { type: "error" as const, message: submitError }]
|
||||
: validation;
|
||||
|
||||
function setRawText(content: string) {
|
||||
setSubmitError(undefined);
|
||||
setDraft((d) => ({ ...d, rawText: content }));
|
||||
// A fresh edit cancels any in-flight pre-collapse highlight.
|
||||
setFileContentsHighlight(false);
|
||||
// Re-open the disclosure when the textarea is cleared so it's ready to paste
|
||||
// into again.
|
||||
if (!content.trim()) setFileContentsOpen(true);
|
||||
}
|
||||
|
||||
function selectPackage(index: number) {
|
||||
setDraft((d) => ({ ...d, selectedPackageIndex: index }));
|
||||
}
|
||||
|
||||
function setEnvVar(name: string, value: string) {
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
envOverrides: { ...d.envOverrides, [name]: value },
|
||||
}));
|
||||
}
|
||||
|
||||
function setServerName(name: string) {
|
||||
setDraft((d) => ({ ...d, nameOverride: name }));
|
||||
}
|
||||
|
||||
async function pickFile(file: File | null) {
|
||||
if (!file) return;
|
||||
setSubmitError(undefined);
|
||||
setFileContentsHighlight(false);
|
||||
try {
|
||||
const text = await file.text();
|
||||
setDraft((d) => ({ ...d, rawText: text }));
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(): Promise<boolean> {
|
||||
setSubmitError(undefined);
|
||||
// Parse the live text (not the debounced copy) so a click in the brief
|
||||
// window after an edit (before the button re-disables) sees the latest
|
||||
// content rather than importing stale input.
|
||||
const live = parseDraft(draft.rawText);
|
||||
if (live.ok !== true) {
|
||||
setSubmitError("Fix the validation errors before adding the server.");
|
||||
return false;
|
||||
}
|
||||
const sel = selectServerJsonOption(live.parsed, {
|
||||
selectedIndex: draft.selectedPackageIndex,
|
||||
idOverride: draft.nameOverride,
|
||||
existingIds,
|
||||
});
|
||||
if (!sel.idIsValid || sel.idIsDuplicate) {
|
||||
setSubmitError("Fix the validation errors before adding the server.");
|
||||
return false;
|
||||
}
|
||||
const config = buildServerConfigForSelection(
|
||||
sel.selectedOption,
|
||||
draft.envOverrides,
|
||||
);
|
||||
try {
|
||||
await onAddServer(sel.serverId, config);
|
||||
return true;
|
||||
} catch (err) {
|
||||
setSubmitError(err instanceof Error ? err.message : String(err));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
draft: { ...draft, selectedPackageIndex: selectedIndex },
|
||||
validation: allValidation,
|
||||
packages,
|
||||
envVars,
|
||||
canAdd,
|
||||
defaultServerName: parsed?.serverName ?? "",
|
||||
fileContentsOpen,
|
||||
fileContentsHighlight,
|
||||
setRawText,
|
||||
selectPackage,
|
||||
setEnvVar,
|
||||
setServerName,
|
||||
pickFile,
|
||||
setFileContentsOpen,
|
||||
submit,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue