diff --git a/apps/yaak-client/components/core/Dropdown.tsx b/apps/yaak-client/components/core/Dropdown.tsx index 47ed8114..8b3b94e9 100644 --- a/apps/yaak-client/components/core/Dropdown.tsx +++ b/apps/yaak-client/components/core/Dropdown.tsx @@ -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 {item.label} diff --git a/apps/yaak-client/components/core/Separator.tsx b/apps/yaak-client/components/core/Separator.tsx index 42c90c8d..40ae5960 100644 --- a/apps/yaak-client/components/core/Separator.tsx +++ b/apps/yaak-client/components/core/Separator.tsx @@ -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 (
{children && (
{children}
)} + {action && ( + + )}
& { @@ -28,6 +31,10 @@ type Props = Pick & 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 >("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: , }, - { 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. +
+ {fileName.slice(0, -FILE_NAME_TAIL_CHARS)} + {fileName.slice(-FILE_NAME_TAIL_CHARS)} +
+ ), + 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: , - 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: , + 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: , - keepOpenOnSelect: true, - onSelect: refetch, + label: filePath == null ? "Load Schema from File…" : "Load a Different File…", + leftSlot: , + onSelect: handleLoadFromFile, }, - { type: "separator", label: "Setting" }, { - label: "Automatic Introspection", + hidden: filePath == null, + label: "Stop Using File", + leftSlot: , + 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 )}
, + // Sits after the schema control it depends on. Always rendered, disabled + // without a schema, so the row never changes shape. +
+ { + setGraphqlDocStateAtomValue((v) => ({ + ...v, + [request.id]: isDocOpen ? undefined : null, + })); + }} + /> +
, ], [ schema, @@ -272,6 +365,11 @@ function GraphQLEditorInner({ request, onChange, baseRequest, ...extraEditorProp isLoading, operationNames, refetch, + handleLoadFromFile, + reloadFromFile, + removeSchemaFile, + filePath, + fileName, autoIntrospectDisabled, baseRequest.id, setGraphqlDocStateAtomValue, diff --git a/apps/yaak-client/hooks/useGraphQLSchemaFile.ts b/apps/yaak-client/hooks/useGraphQLSchemaFile.ts new file mode 100644 index 00000000..9cdebe28 --- /dev/null +++ b/apps/yaak-client/hooks/useGraphQLSchemaFile.ts @@ -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({ ...graphqlSchemaFileArgs(requestId), fallback: null }); +} diff --git a/apps/yaak-client/hooks/useIntrospectGraphQL.ts b/apps/yaak-client/hooks/useIntrospectGraphQL.ts index c316b57f..40550ee5 100644 --- a/apps/yaak-client/hooks/useIntrospectGraphQL.ts +++ b/apps/yaak-client/hooks/useIntrospectGraphQL.ts @@ -1,12 +1,14 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; - import type { GraphQlIntrospection, HttpRequest } from "@yaakapp-internal/models"; +import { platform } from "@yaakapp-internal/platform"; import type { GraphQLSchema, IntrospectionQuery } 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 { sendEphemeralRequest } from "../lib/sendEphemeralRequest"; import { useActiveEnvironment } from "./useActiveEnvironment"; +import { useGraphQLSchemaFile } from "./useGraphQLSchemaFile"; import { useDebouncedValue } from "@yaakapp-internal/ui"; import { rpc } from "../lib/rpc"; @@ -30,6 +32,11 @@ export function useIntrospectGraphQL( 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( async (content: string | null) => { const v = await rpc("models_upsert_graphql_introspection", { @@ -92,15 +99,109 @@ export function useIntrospectGraphQL( return; } - refetch().catch(console.error); - }, [baseRequest.id, debouncedRequest.url, debouncedRequest.method, activeEnvironment?.id]); + // A request pointed at a file gets its schema from that file. Introspecting + // 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 () => { setError(""); setSchema(null); await upsertIntrospection(null); }, [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(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(() => { if (introspection.data?.content == null || introspection.data.content === "") { return; @@ -114,7 +215,17 @@ export function useIntrospectGraphQL( } }, [introspection.data?.content]); - return { schema, isLoading, error, refetch, clear }; + return { + schema, + isLoading, + error, + refetch, + clear, + loadFromFile, + reloadFromFile, + removeSchemaFile, + filePath, + }; } function useIntrospectionResult(request: HttpRequest) { diff --git a/apps/yaak-client/lib/graphqlSchema.test.ts b/apps/yaak-client/lib/graphqlSchema.test.ts new file mode 100644 index 00000000..32b459f5 --- /dev/null +++ b/apps/yaak-client/lib/graphqlSchema.test.ts @@ -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/); + } + }); +}); diff --git a/apps/yaak-client/lib/graphqlSchema.ts b/apps/yaak-client/lib/graphqlSchema.ts new file mode 100644 index 00000000..8e7bf30a --- /dev/null +++ b/apps/yaak-client/lib/graphqlSchema.ts @@ -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: } 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) + ) { + 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); +} diff --git a/packages/platform/src/tauri/index.ts b/packages/platform/src/tauri/index.ts index dc7b29d5..9d8c1005 100644 --- a/packages/platform/src/tauri/index.ts +++ b/packages/platform/src/tauri/index.ts @@ -5,7 +5,7 @@ import { basename, resolveResource } from "@tauri-apps/api/path"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; import { clear, readText, writeText } from "@tauri-apps/plugin-clipboard-manager"; 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 { type as osType } from "@tauri-apps/plugin-os"; import type { @@ -137,6 +137,7 @@ export function createTauriPlatform(): Platform { files: { readDir: (path) => readDir(path), + readText: (path) => readTextFile(path), url: (path) => convertFileSrc(path), basename: (path) => basename(path), resolveResource: (path) => resolveResource(path), diff --git a/packages/platform/src/types.ts b/packages/platform/src/types.ts index ec3f50ad..b34fd7a8 100644 --- a/packages/platform/src/types.ts +++ b/packages/platform/src/types.ts @@ -141,6 +141,15 @@ export interface PlatformDialog { export interface PlatformFiles { readDir(path: string): Promise; + /** + * 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; + /** A URL the page can load a file from, for ``, `