mirror of
https://github.com/mountain-loop/yaak.git
synced 2026-08-25 12:54:09 +02:00
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:
co-authored by
Claude Opus 4.7
Gregory Schier
parent
5d1d24870a
commit
b31c066717
@@ -37,12 +37,15 @@ import { ErrorBoundary } from "../ErrorBoundary";
|
|||||||
import { Button } from "./Button";
|
import { Button } from "./Button";
|
||||||
import { Hotkey } from "./Hotkey";
|
import { Hotkey } from "./Hotkey";
|
||||||
import { IconButton } from "./IconButton";
|
import { IconButton } from "./IconButton";
|
||||||
|
import type { SeparatorAction } from "./Separator";
|
||||||
import { Separator } from "./Separator";
|
import { Separator } from "./Separator";
|
||||||
|
|
||||||
export type DropdownItemSeparator = {
|
export type DropdownItemSeparator = {
|
||||||
type: "separator";
|
type: "separator";
|
||||||
label?: ReactNode;
|
label?: ReactNode;
|
||||||
hidden?: boolean;
|
hidden?: boolean;
|
||||||
|
/** A control shown beside the label, eg. revealing the labelled file on disk. */
|
||||||
|
action?: SeparatorAction;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DropdownItemContent = {
|
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
|
// oxlint-disable-next-line no-array-index-key -- Nothing else available
|
||||||
key={i}
|
key={i}
|
||||||
className={classNames("my-1.5", item.label ? "ml-2" : null)}
|
className={classNames("my-1.5", item.label ? "ml-2" : null)}
|
||||||
|
action={item.action}
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.label}
|
||||||
</Separator>
|
</Separator>
|
||||||
|
|||||||
@@ -1,13 +1,31 @@
|
|||||||
import type { Color } from "@yaakapp-internal/plugins";
|
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 classNames from "classnames";
|
||||||
import type { ReactNode } from "react";
|
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 {
|
interface Props {
|
||||||
orientation?: "horizontal" | "vertical";
|
orientation?: "horizontal" | "vertical";
|
||||||
dashed?: boolean;
|
dashed?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
color?: Color;
|
color?: Color;
|
||||||
|
action?: SeparatorAction;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Separator({
|
export function Separator({
|
||||||
@@ -16,15 +34,31 @@ export function Separator({
|
|||||||
dashed,
|
dashed,
|
||||||
orientation = "horizontal",
|
orientation = "horizontal",
|
||||||
children,
|
children,
|
||||||
|
action,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
<div role="presentation" className={classNames(className, "flex items-center w-full")}>
|
||||||
{children && (
|
{children && (
|
||||||
<div className="text-sm text-text-subtlest mr-2 whitespace-nowrap">{children}</div>
|
<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
|
<div
|
||||||
className={classNames(
|
className={classNames(
|
||||||
"opacity-60",
|
"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 == null && "border-border",
|
||||||
color === "primary" && "border-primary",
|
color === "primary" && "border-primary",
|
||||||
color === "secondary" && "border-secondary",
|
color === "secondary" && "border-secondary",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { HttpRequest } from "@yaakapp-internal/models";
|
import type { HttpRequest } from "@yaakapp-internal/models";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
|
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { useCallback, useEffect, useMemo } from "react";
|
import { useCallback, useEffect, useMemo } from "react";
|
||||||
@@ -11,6 +12,7 @@ import type { DropdownItem } from "../core/Dropdown";
|
|||||||
import { Dropdown } from "../core/Dropdown";
|
import { Dropdown } from "../core/Dropdown";
|
||||||
import type { EditorProps } from "../core/Editor/Editor";
|
import type { EditorProps } from "../core/Editor/Editor";
|
||||||
import { Editor } from "../core/Editor/LazyEditor";
|
import { Editor } from "../core/Editor/LazyEditor";
|
||||||
|
import { IconButton } from "../core/IconButton";
|
||||||
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
import type { RadioDropdownItem } from "../core/RadioDropdown";
|
||||||
import { RadioDropdown } from "../core/RadioDropdown";
|
import { RadioDropdown } from "../core/RadioDropdown";
|
||||||
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
import { Banner, FormattedError, Icon } from "@yaakapp-internal/ui";
|
||||||
@@ -18,6 +20,7 @@ import { Separator } from "../core/Separator";
|
|||||||
import { tryFormatGraphql } from "../../lib/formatters";
|
import { tryFormatGraphql } from "../../lib/formatters";
|
||||||
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
import { parseGraphQLOperationNames } from "../../lib/graphqlOperationNames";
|
||||||
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
import { normalizeGraphQLBody } from "../../lib/requestBodyConversion";
|
||||||
|
import { revealInFinderText } from "../../lib/reveal";
|
||||||
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
import { showGraphQLDocExplorerAtom } from "./graphqlAtoms";
|
||||||
|
|
||||||
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> & {
|
||||||
@@ -28,6 +31,10 @@ type Props = Pick<EditorProps, "heightMode" | "className" | "forceUpdateKey"> &
|
|||||||
|
|
||||||
const OPERATION_NAME_NOT_SPECIFIED = "";
|
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) {
|
export function GraphQLEditor(props: Props) {
|
||||||
// There's some weirdness with stale onChange being called when switching requests, so we'll
|
// 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.
|
// key on the request ID as a workaround for now.
|
||||||
@@ -38,9 +45,41 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
const [autoIntrospectDisabled, setAutoIntrospectDisabled] = useLocalStorage<
|
||||||
Record<string, boolean>
|
Record<string, boolean>
|
||||||
>("graphQLAutoIntrospectDisabled", {});
|
>("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],
|
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<{
|
const [currentBody, setCurrentBody] = useStateWithDeps<{
|
||||||
query: string;
|
query: string;
|
||||||
variables: string | undefined;
|
variables: string | undefined;
|
||||||
@@ -160,14 +199,37 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
...((schema != null
|
...((schema != null
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
label: "Clear",
|
label: "Clear Schema",
|
||||||
onSelect: clear,
|
onSelect: clear,
|
||||||
color: "danger",
|
color: "danger",
|
||||||
leftSlot: <Icon icon="trash" />,
|
leftSlot: <Icon icon="trash" />,
|
||||||
},
|
},
|
||||||
{ type: "separator" },
|
|
||||||
]
|
]
|
||||||
: []) satisfies DropdownItem[]),
|
: []) 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,
|
hidden: !error,
|
||||||
label: (
|
label: (
|
||||||
@@ -210,25 +272,33 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
type: "content",
|
type: "content",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
hidden: schema == null,
|
// One refresh action for either source: re-read the file, or
|
||||||
label: `${isDocOpen ? "Hide" : "Show"} Documentation`,
|
// re-introspect the server.
|
||||||
leftSlot: <Icon icon="book_open_text" />,
|
label: "Reload Schema",
|
||||||
onSelect: () => {
|
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
||||||
setGraphqlDocStateAtomValue((v) => ({
|
keepOpenOnSelect: true,
|
||||||
...v,
|
// Failures surface through the hook's error state either way.
|
||||||
[request.id]: isDocOpen ? undefined : null,
|
onSelect: async () => {
|
||||||
}));
|
if (filePath != null) await reloadFromFile();
|
||||||
|
else await refetch();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Introspect Schema",
|
label: filePath == null ? "Load Schema from File…" : "Load a Different File…",
|
||||||
leftSlot: <Icon icon="refresh" spin={isLoading} />,
|
leftSlot: <Icon icon="import" />,
|
||||||
keepOpenOnSelect: true,
|
onSelect: handleLoadFromFile,
|
||||||
onSelect: refetch,
|
|
||||||
},
|
},
|
||||||
{ 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,
|
keepOpenOnSelect: true,
|
||||||
onSelect: () => {
|
onSelect: () => {
|
||||||
setAutoIntrospectDisabled({
|
setAutoIntrospectDisabled({
|
||||||
@@ -261,6 +331,29 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
</Dropdown>
|
</Dropdown>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</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,
|
schema,
|
||||||
@@ -272,6 +365,11 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp
|
|||||||
isLoading,
|
isLoading,
|
||||||
operationNames,
|
operationNames,
|
||||||
refetch,
|
refetch,
|
||||||
|
handleLoadFromFile,
|
||||||
|
reloadFromFile,
|
||||||
|
removeSchemaFile,
|
||||||
|
filePath,
|
||||||
|
fileName,
|
||||||
autoIntrospectDisabled,
|
autoIntrospectDisabled,
|
||||||
baseRequest.id,
|
baseRequest.id,
|
||||||
setGraphqlDocStateAtomValue,
|
setGraphqlDocStateAtomValue,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { useKeyValue } from "./useKeyValue";
|
||||||
|
|
||||||
|
// The file a request's GraphQL schema is loaded from, or null when the schema
|
||||||
|
// comes from an introspection request.
|
||||||
|
//
|
||||||
|
// This is the *source*, not the schema. The introspection row it produces is a
|
||||||
|
// cache that expires on its own; this outlives it and regenerates it, the same
|
||||||
|
// way gRPC keeps its proto file list separate from a reflection result.
|
||||||
|
export function graphqlSchemaFileArgs(requestId: string | null) {
|
||||||
|
return {
|
||||||
|
namespace: "global" as const,
|
||||||
|
key: ["graphql_schema_file", requestId ?? "n/a"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGraphQLSchemaFile(requestId: string | null) {
|
||||||
|
return useKeyValue<string | null>({ ...graphqlSchemaFileArgs(requestId), fallback: null });
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
|
import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models";
|
||||||
|
import { platform } from "@yaakapp-internal/platform";
|
||||||
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
||||||
import { buildClientSchema, getIntrospectionQuery } from "graphql";
|
import { buildClientSchema, getIntrospectionQuery } from "graphql";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { tryBuildIntrospectionFromFile } from "../lib/graphqlSchema";
|
||||||
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
import { minPromiseMillis } from "../lib/minPromiseMillis";
|
||||||
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
|
import { sendEphemeralRequest } from "../lib/sendEphemeralRequest";
|
||||||
import { useActiveEnvironment } from "./useActiveEnvironment";
|
import { useActiveEnvironment } from "./useActiveEnvironment";
|
||||||
|
import { useGraphQLSchemaFile } from "./useGraphQLSchemaFile";
|
||||||
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
import { useDebouncedValue } from "@yaakapp-internal/ui";
|
||||||
import { rpc } from "../lib/rpc";
|
import { rpc } from "../lib/rpc";
|
||||||
|
|
||||||
@@ -30,6 +32,11 @@ export function useIntrospectGraphQL(
|
|||||||
|
|
||||||
const introspection = useIntrospectionResult(baseRequest);
|
const introspection = useIntrospectionResult(baseRequest);
|
||||||
|
|
||||||
|
// The schema's source. Outlives the introspection row it produces, so a
|
||||||
|
// request configured with a file keeps working after the row is swept.
|
||||||
|
const schemaFile = useGraphQLSchemaFile(baseRequest.id);
|
||||||
|
const filePath = schemaFile.value ?? null;
|
||||||
|
|
||||||
const upsertIntrospection = useCallback(
|
const upsertIntrospection = useCallback(
|
||||||
async (content: string | null) => {
|
async (content: string | null) => {
|
||||||
const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", {
|
const v = await rpc<GraphQlIntrospection>("models_upsert_graphql_introspection", {
|
||||||
@@ -92,15 +99,109 @@ export function useIntrospectGraphQL(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
refetch().catch(console.error);
|
// A request pointed at a file gets its schema from that file. Introspecting
|
||||||
}, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]);
|
// here would overwrite it on the next URL edit.
|
||||||
|
if (filePath != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refetch().catch(console.error);
|
||||||
|
}, [
|
||||||
|
baseRequest.id,
|
||||||
|
debouncedRequest.url,
|
||||||
|
debouncedRequest.method,
|
||||||
|
activeEnvironment?.id,
|
||||||
|
filePath,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Clears the schema, not the source. Removing a file source is a separate
|
||||||
|
// action, because the source is what would rebuild this a moment later.
|
||||||
const clear = useCallback(async () => {
|
const clear = useCallback(async () => {
|
||||||
setError("");
|
setError("");
|
||||||
setSchema(null);
|
setSchema(null);
|
||||||
await upsertIntrospection(null);
|
await upsertIntrospection(null);
|
||||||
}, [upsertIntrospection]);
|
}, [upsertIntrospection]);
|
||||||
|
|
||||||
|
// Reads a schema file and produces an introspection row from it, the same way
|
||||||
|
// `refetch` produces one from a server. Does not touch the stored source.
|
||||||
|
const introspectFromFile = useCallback(
|
||||||
|
async (path: string): Promise<{ ok: true } | { ok: false; error: string }> => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(undefined);
|
||||||
|
|
||||||
|
const fileContent = await platform.files.readText(path);
|
||||||
|
const result = tryBuildIntrospectionFromFile(fileContent);
|
||||||
|
|
||||||
|
if ("error" in result) {
|
||||||
|
setError(result.error);
|
||||||
|
return { ok: false, error: result.error };
|
||||||
|
}
|
||||||
|
|
||||||
|
await upsertIntrospection(result.content);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
// The host rejects with a bare string for a missing or unreadable path,
|
||||||
|
// so this can't assume an Error.
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
setError(message);
|
||||||
|
return { ok: false, error: message };
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[upsertIntrospection],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Points the request at a file and immediately builds its schema from it.
|
||||||
|
const loadFromFile = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
const result = await introspectFromFile(path);
|
||||||
|
if (result.ok) await schemaFile.set(path);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
[introspectFromFile, schemaFile],
|
||||||
|
);
|
||||||
|
|
||||||
|
const reloadFromFile = useCallback(async () => {
|
||||||
|
if (filePath == null) return { ok: false as const, error: "No schema file to reload" };
|
||||||
|
return introspectFromFile(filePath);
|
||||||
|
}, [filePath, introspectFromFile]);
|
||||||
|
|
||||||
|
// The file-source counterpart of automatic introspection: re-read the file
|
||||||
|
// when the request is opened, so an edited schema is picked up without asking.
|
||||||
|
//
|
||||||
|
// A missing row is repaired even with the setting off — that is recovering
|
||||||
|
// from the 7-day sweep, not keeping the schema fresh, and skipping it would
|
||||||
|
// make the schema disappear with no visible cause.
|
||||||
|
const reloadedFor = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (filePath == null || introspection.isLoading) return;
|
||||||
|
// Only attempt once per path, so an unreadable file doesn't spin.
|
||||||
|
if (reloadedFor.current === filePath) return;
|
||||||
|
|
||||||
|
const hasContent = (introspection.data?.content ?? "") !== "";
|
||||||
|
if (hasContent && options.disabled) return;
|
||||||
|
|
||||||
|
reloadedFor.current = filePath;
|
||||||
|
introspectFromFile(filePath).catch(console.error);
|
||||||
|
}, [
|
||||||
|
filePath,
|
||||||
|
introspection.data?.content,
|
||||||
|
introspection.isLoading,
|
||||||
|
introspectFromFile,
|
||||||
|
options.disabled,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Stops using the file. The schema goes with it, since the file is what
|
||||||
|
// produced it; introspection repopulates if it's set to run automatically.
|
||||||
|
const removeSchemaFile = useCallback(async () => {
|
||||||
|
setError("");
|
||||||
|
setSchema(null);
|
||||||
|
await schemaFile.set(null);
|
||||||
|
await upsertIntrospection(null);
|
||||||
|
}, [schemaFile, upsertIntrospection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (introspection.data?.content == null || introspection.data.content === "") {
|
if (introspection.data?.content == null || introspection.data.content === "") {
|
||||||
return;
|
return;
|
||||||
@@ -114,7 +215,17 @@ export function useIntrospectGraphQL(
|
|||||||
}
|
}
|
||||||
}, [introspection.data?.content]);
|
}, [introspection.data?.content]);
|
||||||
|
|
||||||
return { schema, isLoading, error, refetch, clear };
|
return {
|
||||||
|
schema,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
refetch,
|
||||||
|
clear,
|
||||||
|
loadFromFile,
|
||||||
|
reloadFromFile,
|
||||||
|
removeSchemaFile,
|
||||||
|
filePath,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function useIntrospectionResult(request: HttpRequest) {
|
function useIntrospectionResult(request: HttpRequest) {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { buildSchema, introspectionFromSchema } from "graphql";
|
||||||
|
import { describe, expect, test } from "vite-plus/test";
|
||||||
|
import { tryBuildIntrospectionFromFile } from "./graphqlSchema";
|
||||||
|
|
||||||
|
const sdl = `
|
||||||
|
type Query {
|
||||||
|
hello: String!
|
||||||
|
user(id: ID!): User
|
||||||
|
}
|
||||||
|
|
||||||
|
type User {
|
||||||
|
id: ID!
|
||||||
|
name: String
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const introspection = introspectionFromSchema(buildSchema(sdl));
|
||||||
|
|
||||||
|
describe("tryBuildIntrospectionFromFile", () => {
|
||||||
|
test("accepts introspection JSON wrapped in { data: ... }", () => {
|
||||||
|
const input = JSON.stringify({ data: introspection });
|
||||||
|
const result = tryBuildIntrospectionFromFile(input);
|
||||||
|
|
||||||
|
expect("schema" in result).toBe(true);
|
||||||
|
if ("schema" in result) {
|
||||||
|
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("hello");
|
||||||
|
// Output content is the normalized, persistable shape.
|
||||||
|
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts bare introspection JSON without a data wrapper", () => {
|
||||||
|
const input = JSON.stringify(introspection);
|
||||||
|
const result = tryBuildIntrospectionFromFile(input);
|
||||||
|
|
||||||
|
expect("schema" in result).toBe(true);
|
||||||
|
if ("schema" in result) {
|
||||||
|
expect(result.schema.getQueryType()?.getFields()).toHaveProperty("user");
|
||||||
|
// Bare input is wrapped on the way out.
|
||||||
|
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("accepts a GraphQL SDL string", () => {
|
||||||
|
const result = tryBuildIntrospectionFromFile(sdl);
|
||||||
|
|
||||||
|
expect("schema" in result).toBe(true);
|
||||||
|
if ("schema" in result) {
|
||||||
|
const fields = result.schema.getQueryType()?.getFields() ?? {};
|
||||||
|
expect(fields).toHaveProperty("hello");
|
||||||
|
expect(fields).toHaveProperty("user");
|
||||||
|
// SDL is converted to introspection JSON for storage.
|
||||||
|
expect(JSON.parse(result.content)).toHaveProperty("data.__schema");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns an error for JSON that is neither introspection nor SDL", () => {
|
||||||
|
const result = tryBuildIntrospectionFromFile('{"unrelated":"value"}');
|
||||||
|
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
if ("error" in result) {
|
||||||
|
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns an error for content that is neither valid JSON nor valid SDL", () => {
|
||||||
|
const result = tryBuildIntrospectionFromFile("not a schema!@#$");
|
||||||
|
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
if ("error" in result) {
|
||||||
|
expect(result.error).toMatch(/Could not parse file as introspection JSON or GraphQL SDL/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns an error when introspection JSON has a malformed __schema", () => {
|
||||||
|
// Has the data.__schema shape but the contents are invalid for buildClientSchema.
|
||||||
|
const input = JSON.stringify({ data: { __schema: { broken: true } } });
|
||||||
|
const result = tryBuildIntrospectionFromFile(input);
|
||||||
|
|
||||||
|
expect("error" in result).toBe(true);
|
||||||
|
if ("error" in result) {
|
||||||
|
expect(result.error).toMatch(/Failed to build schema from introspection JSON/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { GraphQLSchema, IntrospectionQuery } from "graphql";
|
||||||
|
import { buildClientSchema, buildSchema, introspectionFromSchema } from "graphql";
|
||||||
|
|
||||||
|
// Accepts either a GraphQL introspection JSON ({ data: { __schema } } or
|
||||||
|
// { __schema }) or an SDL string and normalizes both into the wrapped
|
||||||
|
// { data: <introspection> } JSON shape used by the introspection store.
|
||||||
|
export function tryBuildIntrospectionFromFile(
|
||||||
|
fileContent: string,
|
||||||
|
): { schema: GraphQLSchema; content: string } | { error: string } {
|
||||||
|
let parsedJson: unknown;
|
||||||
|
try {
|
||||||
|
parsedJson = JSON.parse(fileContent);
|
||||||
|
} catch {
|
||||||
|
parsedJson = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsedJson != null && typeof parsedJson === "object") {
|
||||||
|
const candidates: unknown[] = [(parsedJson as { data?: unknown }).data, parsedJson];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (
|
||||||
|
candidate != null &&
|
||||||
|
typeof candidate === "object" &&
|
||||||
|
"__schema" in (candidate as Record<string, unknown>)
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const schema = buildClientSchema(candidate as IntrospectionQuery, {});
|
||||||
|
return { schema, content: JSON.stringify({ data: candidate }) };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
error: `Failed to build schema from introspection JSON: ${errorMessage(e)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const schema = buildSchema(fileContent);
|
||||||
|
const introspection = introspectionFromSchema(schema);
|
||||||
|
return { schema, content: JSON.stringify({ data: introspection }) };
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
error: `Could not parse file as introspection JSON or GraphQL SDL: ${errorMessage(e)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(e: unknown): string {
|
||||||
|
return e instanceof Error ? e.message : String(e);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { basename, resolveResource } from "@tauri-apps/api/path";
|
|||||||
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
|
||||||
import { clear, readText, writeText } from "@tauri-apps/plugin-clipboard-manager";
|
import { clear, readText, writeText } from "@tauri-apps/plugin-clipboard-manager";
|
||||||
import { open, save } from "@tauri-apps/plugin-dialog";
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
import { readDir, readFile } from "@tauri-apps/plugin-fs";
|
import { readDir, readFile, readTextFile } from "@tauri-apps/plugin-fs";
|
||||||
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
|
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
|
||||||
import { type as osType } from "@tauri-apps/plugin-os";
|
import { type as osType } from "@tauri-apps/plugin-os";
|
||||||
import type {
|
import type {
|
||||||
@@ -137,6 +137,7 @@ export function createTauriPlatform(): Platform {
|
|||||||
|
|
||||||
files: {
|
files: {
|
||||||
readDir: (path) => readDir(path),
|
readDir: (path) => readDir(path),
|
||||||
|
readText: (path) => readTextFile(path),
|
||||||
url: (path) => convertFileSrc(path),
|
url: (path) => convertFileSrc(path),
|
||||||
basename: (path) => basename(path),
|
basename: (path) => basename(path),
|
||||||
resolveResource: (path) => resolveResource(path),
|
resolveResource: (path) => resolveResource(path),
|
||||||
|
|||||||
@@ -141,6 +141,15 @@ export interface PlatformDialog {
|
|||||||
export interface PlatformFiles {
|
export interface PlatformFiles {
|
||||||
readDir(path: string): Promise<DirEntry[]>;
|
readDir(path: string): Promise<DirEntry[]>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The text of a file the user picked, decoded as UTF-8.
|
||||||
|
*
|
||||||
|
* Only for paths the host itself handed us — a dialog result or a drag-drop
|
||||||
|
* payload — never one the page assembled. A host without a filesystem reads
|
||||||
|
* whatever its own handle points at.
|
||||||
|
*/
|
||||||
|
readText(path: string): Promise<string>;
|
||||||
|
|
||||||
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
|
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
|
||||||
url(path: string): string;
|
url(path: string): string;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user