inspector/clients/web/src/hooks/useServerJsonImport.ts
Cliff Hall 4defa32ac6
chore: close a batch of quality-gate & tidy gaps (#1785 #1787 #1789 #1790 #1767) (#1792)
* chore: close a batch of quality-gate & tidy gaps (#1785 #1787 #1789 #1790 #1767)

Bundles five small, related tidy/gate items into one PR:

- #1785: delete the dead src/types/navigation.ts InspectorTab (0 importers;
  duplicated/drifted from utils/inspectorTabs InspectorTabId). src/types/
  now holds only ambient .d.ts stubs.
- #1787: add src/hooks/** to the web coverage `include` (it was silently
  outside the ≥90 gate) and add tests for the two untested hooks,
  useServerJsonImport and useImportClientConfig, bringing every hook to
  ≥90 on lines/statements/functions/branches.
- #1789: widen clients/{cli,tui,launcher} format/format:check to their
  top-level configs; add root format:shared / format:check:shared covering
  test-servers/src/**, vitest.shared.mts, and the repo-root eslint.config.js.
- #1790: pin prettier to an exact version (3.8.4, dropping the ^3.8.1
  caret) in all five package.json files so the format gate's verdict can't
  shift with an in-range patch bump; lockfiles synced.
- #1767: its root-file format+lint gap is closed here (format:shared +
  a new lint:shared via a second files block in the root eslint.config.js;
  web/server/ was already gated in #1788). Its larger cli/tui __tests__
  typecheck piece (76 type errors) is split out to #1791.

Prettier-normalized the newly-covered files (pure formatting). No runtime
behavior change. `npm run ci` green.

Closes #1785
Closes #1787
Closes #1789
Closes #1790
Closes #1767

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore: address PR #1792 review findings

- #1789 gap: widen clients/web format to include .storybook and clients/
  launcher format to include scripts/; reformat the 3 .storybook files
  (were non-conforming under the pinned prettier 3.8.4). Audited all 885
  tracked source files — now fully prettier-clean.
- Export VALIDATE_DEBOUNCE_MS / COLLAPSE_DELAY_MS / HIGHLIGHT_DURATION_MS
  from useServerJsonImport and use them in the test instead of hardcoded
  300/1000/250 (so a tuned constant can't silently desync the test).
- Assert the symmetric renameErrors["alpha"] alongside ["beta"] so a
  one-directional collision-check regression fails.
- Comment the blank-rename test as exercising a UI-unreachable defensive
  branch (canImport gates submit).
- Un-stale the two specification/v2_ux_interfaces_plan.md references to the
  now-removed navigation.ts InspectorTab.

npm run ci green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* docs: sync AGENTS.md format-surface description with the widened globs (#1792 review r2)

1ce3b64 added .storybook to clients/web's format glob and scripts/ to
clients/launcher's, but AGENTS.md:222 still described the pre-fix scope.
Bring it in line and note the non-recursive top-level glob is why nested
dirs are named explicitly. Docs only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore: handle PR #1792 round-3 follow-ups in-place (format-coverage guard, uniform globs, theme gate)

Rather than defer these to tickets, fold them into this PR:

- Obs 2: widen every client + root format glob to a uniform
  *.{ts,tsx,mts,cts,js,jsx,mjs,cjs} so a future .mts/.cjs/.tsx top-level
  file can't silently slip the gate.
- Obs 1: add scripts/verify-format-coverage.mjs (wired into `validate`) —
  it parses the format:check globs out of every package.json, enumerates
  all tracked source files, and fails listing any not covered by a glob.
  The durable, standing version of #1789's one-shot audit; negative-tested.
- Obs 3: bring src/theme/** under the coverage gate (21/23 files were
  already ≥90 transitively; add Paper.test.tsx for the one laggard,
  Paper.ts). src/App.tsx (~4.5k lines, ~42% branches) is converted from a
  silent omission into an explicit, documented exclusion (comment on the
  coverage `include` + AGENTS.md) — gating it is a dedicated effort, not a
  whitelist tweak.

npm run ci green (the new guard reports 885/885 tracked source files gated).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore: address PR #1792 round-4 findings (guard hardening, docs, exclusion list)

- Harden verify-format-coverage: only harvest format:check globs from
  scripts transitively reachable from `validate` (via npm run refs), so an
  unwired/renamed format:check no longer counts as coverage. Negative-tested
  — renaming clients/web format:check to an unreferenced name now fails the
  guard (627 files flagged) instead of passing.
- Name src/main.tsx and src/index.ts (browser + bin bootstraps) alongside
  src/App.tsx in the coverage-include comment and AGENTS.md, so the
  documented exclusion list is complete rather than claiming App.tsx is the
  only one.
- Doc sync: document verify:format-coverage (AGENTS.md + README script table
  + scripts/ layout line), fix the `validate` ordering (verify:format-
  coverage now runs before validate:core) in AGENTS.md and README, and
  update the quoted globs to the uniform *.{ts,tsx,mts,cts,js,jsx,mjs,cjs} /
  test-servers *.{ts,tsx,mts,cts}.

npm run ci green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

* chore: address PR #1792 round-5 findings (airtight guard + doc/config sync)

- verify-format-coverage: assert the root `validate` chain invokes each
  client's `validate` (cd <dir> && npm run validate), closing the last
  reachability edge — a client dropped from the root chain now fails the
  guard even though its own globs still exist. Negative-tested.
- eslint.config.js: widen the shared `files` block to
  test-servers/src/**/*.{ts,tsx,mts,cts} to match the widened lint:shared
  glob, so a future .mts/.cts there is lint-gated, not just prettier-gated.
- AGENTS.md: scope the "uniform extension set" claim to client globs
  (core stays {ts,tsx}, shared {ts,tsx,mts,cts}), with verify:format-coverage
  as the backstop — removing the self-contradiction with the line below.

npm run ci green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 10:43:11 -04:00

308 lines
10 KiB
TypeScript

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. */
export 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".
*/
export 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.
*/
export 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,
};
}