Load GraphQL schema from file for autocomplete (#462)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Gregory Schier <gschier1990@gmail.com>
This commit is contained in:
VDShelk
2026-08-15 10:14:13 -07:00
committed by GitHub
co-authored by Claude Opus 4.7 Gregory Schier
parent 5d1d24870a
commit b31c066717
9 changed files with 434 additions and 23 deletions
@@ -37,12 +37,15 @@ import { ErrorBoundary } from "../ErrorBoundary";
import { Button } from "./Button";
import { Hotkey } from "./Hotkey";
import { IconButton } from "./IconButton";
import type { SeparatorAction } from "./Separator";
import { Separator } from "./Separator";
export type DropdownItemSeparator = {
type: "separator";
label?: ReactNode;
hidden?: boolean;
/** A control shown beside the label, eg. revealing the labelled file on disk. */
action?: SeparatorAction;
};
export type DropdownItemContent = {
@@ -791,6 +794,7 @@ const Menu = forwardRef<Omit<DropdownRef, "open" | "isOpen" | "toggle" | "items"
// oxlint-disable-next-line no-array-index-key -- Nothing else available
key={i}
className={classNames("my-1.5", item.label ? "ml-2" : null)}
action={item.action}
>
{item.label}
</Separator>
@@ -1,13 +1,31 @@
import type { Color } from "@yaakapp-internal/plugins";
import type { IconProps } from "@yaakapp-internal/ui";
import { IconButton } from "@yaakapp-internal/ui";
import classNames from "classnames";
import type { ReactNode } from "react";
/**
* A single control attached to a labelled separator, rendered between the label
* and the rule.
*
* Declared rather than passed as a node so the separator keeps ownership of the
* things that are easy to get wrong by hand: matching the label's colour, and
* staying out of the rule's way when the label is long.
*/
export interface SeparatorAction {
icon: IconProps["icon"];
/** Tooltip and accessible name. Required — the control is icon-only. */
title: string;
onClick: () => void;
}
interface Props {
orientation?: "horizontal" | "vertical";
dashed?: boolean;
className?: string;
children?: ReactNode;
color?: Color;
action?: SeparatorAction;
}
export function Separator({
@@ -16,15 +34,31 @@ export function Separator({
dashed,
orientation = "horizontal",
children,
action,
}: Props) {
return (
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
{children && (
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
)}
{action && (
<IconButton
size="2xs"
iconSize="xs"
className="shrink-0 mr-2 -ml-1"
// Forced, because the button itself sets `text-text` at full strength.
iconClassName="text-text-subtlest!"
icon={action.icon}
title={action.title}
onClick={action.onClick}
/>
)}
<div
className={classNames(
"opacity-60",
// Keep a stub of the line visible no matter how long the label is —
// `w-full` alone gets squeezed to nothing by a wide label.
orientation === "horizontal" && "min-w-8",
color == null && "border-border",
color === "primary" && "border-primary",
color === "secondary" && "border-secondary",
@@ -1,4 +1,5 @@
import type { HttpRequest } from "@yaakapp-internal/models";
import { platform } from "@yaakapp-internal/platform";
import { useAtom } from "jotai";
import { useCallback, useEffect, useMemo } from "react";
@@ -11,6 +12,7 @@ import type { DropdownItem } from "../core/Dropdown";
import { Dropdown } from "../core/Dropdown";
import type { EditorProps } from "../core/Editor/Editor";
import { Editor } from "../core/Editor/LazyEditor";
import { IconButton } from "../core/IconButton";
import type { RadioDropdownItem } from "../core/RadioDropdown";
import { RadioDropdown } from "../core/RadioDropdown";
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
@@ -18,6 +20,7 @@ import { Separator } from "../core/Separator";
import { tryFormatGraphql } from "../../lib/formatters";
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
import { revealInFinderText } from "../../lib/reveal";
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
@@ -28,6 +31,10 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
const OPERATION_NAME_NOT_SPECIFIED = "";
// How much of the end of a schema filename is pinned when middle-truncating it.
// Enough to keep the extension and a little of the name before it.
const FILE_NAME_TAIL_CHARS = 12;
export function GraphQLEditor(props: Props) {
// There's some weirdness with stale onChange being called when switching requests, so we'll
// key on the request ID as a workaround for now.
@@ -38,9 +45,41 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
Record<string, boolean>
>("graphQLAutoIntrospectDisabled", {});
const { schema, isLoading, error, refetch, clear } = useIntrospectGraphQL(baseRequest, {
const {
schema,
isLoading,
error,
refetch,
clear,
loadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
} = useIntrospectGraphQL(baseRequest, {
disabled: autoIntrospectDisabled?.[baseRequest.id],
});
// Last path segment, for display only. The host owns real path semantics; this
// just needs something short enough to label the divider with.
const fileName = useMemo(() => filePath?.split(/[/\\]/).pop() || filePath, [filePath]);
// Selecting a file is all it takes — the request's source becomes that file,
// which is what keeps automatic introspection from overwriting it.
const handleLoadFromFile = useCallback(async () => {
const selected = await platform.dialog.open({
title: "Load GraphQL Schema",
multiple: false,
filters: [
{
name: "GraphQL Schema",
extensions: ["graphql", "graphqls", "gql", "json"],
},
],
});
if (selected == null) return;
await loadFromFile(selected);
}, [loadFromFile]);
const [currentBody, setCurrentBody] = useStateWithDeps<{
query: string;
variables: string | undefined;
@@ -160,14 +199,37 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
...((schema != null
? [
{
label: "Clear",
label: "Clear Schema",
onSelect: clear,
color: "danger",
leftSlot: <Icon icon="trash" />,
},
{ type: "separator" },
]
: []) satisfies DropdownItem[]),
{
// Labels the source actions below it, so the menu says where the
// schema came from without spending a row on it.
type: "separator",
hidden: schema == null && filePath == null,
label:
fileName == null || filePath == null ? undefined : (
// Middle truncation: the head shrinks and ellipsizes while the
// tail is pinned, so the extension always survives. Full path
// on hover.
<div className="flex min-w-0 max-w-[16rem] font-mono text-xs" title={filePath}>
<span className="truncate">{fileName.slice(0, -FILE_NAME_TAIL_CHARS)}</span>
<span className="shrink-0">{fileName.slice(-FILE_NAME_TAIL_CHARS)}</span>
</div>
),
action:
filePath == null
? undefined
: {
icon: "folder_symlink",
title: revealInFinderText,
onClick: () => platform.revealItemInDir(filePath),
},
},
{
hidden: !error,
label: (
@@ -210,25 +272,33 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
type: "content",
},
{
hidden: schema == null,
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
leftSlot: <Icon icon="book_open_text" />,
onSelect: () => {
setGraphqlDocStateAtomValue((v) => ({
...v,
[request.id]: isDocOpen ? undefined : null,
}));
// One refresh action for either source: re-read the file, or
// re-introspect the server.
label: "Reload Schema",
leftSlot: <Icon icon="refresh" spin={isLoading} />,
keepOpenOnSelect: true,
// Failures surface through the hook's error state either way.
onSelect: async () => {
if (filePath != null) await reloadFromFile();
else await refetch();
},
},
{
label: "Introspect Schema",
leftSlot: <Icon icon="refresh" spin={isLoading} />,
keepOpenOnSelect: true,
onSelect: refetch,
label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
leftSlot: <Icon icon="import" />,
onSelect: handleLoadFromFile,
},
{ type: "separator", label: "Setting" },
{
label: "Automatic Introspection",
hidden: filePath == null,
label: "Stop Using File",
leftSlot: <Icon icon="x" />,
onSelect: removeSchemaFile,
},
{ type: "separator", label: "Settings" },
{
// Governs both sources: re-introspecting the server, and
// re-reading the file when the request is opened.
label: filePath == null ? "Automatic Introspection" : "Automatic Reload",
keepOpenOnSelect: true,
onSelect: () => {
setAutoIntrospectDisabled({
@@ -261,6 +331,29 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
</Dropdown>
)}
</div>,
// Sits after the schema control it depends on. Always rendered, disabled
// without a schema, so the row never changes shape.
<div key="documentation" className="opacity-100!">
<IconButton
size="sm"
variant="border"
icon="book_open_text"
disabled={schema == null}
title={
schema == null
? "Documentation unavailable without a schema"
: isDocOpen
? "Hide Documentation"
: "Show Documentation"
}
onClick={() => {
setGraphqlDocStateAtomValue((v) => ({
...v,
[request.id]: isDocOpen ? undefined : null,
}));
}}
/>
</div>,
],
[
schema,
@@ -272,6 +365,11 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
isLoading,
operationNames,
refetch,
handleLoadFromFile,
reloadFromFile,
removeSchemaFile,
filePath,
fileName,
autoIntrospectDisabled,
baseRequest.id,
setGraphqlDocStateAtomValue,