diff --git a/languages/graphql.toml b/languages/graphql.toml index a26bb6ac..7e1c1f43 100644 --- a/languages/graphql.toml +++ b/languages/graphql.toml @@ -10,5 +10,5 @@ tree-sitter = "graphql" name = "graphql-lsp" command = "graphql-lsp" args = ["server", "-m", "stream"] -root-markers = [".graphqlrc", ".graphqlrc.yml", ".graphqlrc.yaml", ".graphqlrc.json", "graphql.config.js", "graphql.config.ts", "graphql.config.yml"] +root-markers = [".graphqlrc", ".graphqlrc.yml", ".graphqlrc.yaml", ".graphqlrc.json", "graphql.config.js", "graphql.config.ts", "graphql.config.yml", "graphql.config.yaml", "graphql.config.json"] install-hint = "npm i -g graphql-language-service-cli" diff --git a/src/features/graphql/EnvironmentDialog.tsx b/src/features/graphql/EnvironmentDialog.tsx new file mode 100644 index 00000000..6ffdd080 --- /dev/null +++ b/src/features/graphql/EnvironmentDialog.tsx @@ -0,0 +1,106 @@ +/** + * GraphQL Environment Variables (IDEA's dialog): the `${NAME}` variables a graphql-config + * uses, one row each with its value; Copy / Paste exchange them as `NAME=value` lines. + */ +import { useState } from "react"; +import { readClipboardText } from "@/platform/clipboard"; +import { useTx } from "@/platform/i18n"; +import { Dialog, IconButton, T } from "@/ui"; +import { copyText } from "@/ui/selectableText"; +import { closeEnvironmentDialog, environmentOf, saveEnvironment, useGraphQL } from "./store"; +import styles from "./GraphQL.module.css"; + +/** `NAME=value` lines (a `.env` file's shape). */ +export function formatEnvironment(names: readonly string[], values: Readonly>): string { + return names.map((n) => `${n}=${values[n] ?? ""}`).join("\n"); +} + +/** Values of `names` found in `NAME=value` lines; other lines are ignored. */ +export function parseEnvironment(text: string, names: readonly string[]): Record { + const out: Record = {}; + for (const line of text.split(/\r?\n/)) { + const at = line.indexOf("="); + if (at <= 0) continue; + const name = line.slice(0, at).trim(); + if (names.includes(name)) out[name] = line.slice(at + 1).trim(); + } + return out; +} + +export function EnvironmentDialogOverlay() { + const dialog = useGraphQL((s) => s.envDialog); + return dialog ? : null; +} + +function EnvironmentDialog({ configPath, variables }: { configPath: string; variables: string[] }) { + const tx = useTx(); + const [values, setValues] = useState(() => ({ ...environmentOf(configPath) })); + const ok = () => { + saveEnvironment(configPath, values); + closeEnvironmentDialog(true); + }; + return ( + closeEnvironmentDialog(false)} + width={480} + buttons={[ + { label: "OK", role: "default", onClick: ok }, + { label: "Cancel", role: "cancel" }, + ]} + > +
+
+ void copyText(formatEnvironment(variables, values))} + /> + + void readClipboardText().then((text) => + setValues((v) => ({ ...v, ...parseEnvironment(text, variables) })), + ) + } + /> +
+ + + + + + + + + {variables.map((name) => ( + + + + + ))} + +
+ Name + + Value +
{name} + setValues((v) => ({ ...v, [name]: e.target.value }))} + onKeyDown={(e) => { + if (e.key === "Enter") ok(); + }} + /> +
+
+
+ ); +} diff --git a/src/features/graphql/GraphQL.module.css b/src/features/graphql/GraphQL.module.css new file mode 100644 index 00000000..c6057e0d --- /dev/null +++ b/src/features/graphql/GraphQL.module.css @@ -0,0 +1,323 @@ +.view { + height: 100%; + display: flex; + min-height: 0; +} +.tabs { + min-width: 0; + border: 0; + background: none; +} +.toolbar { + flex: none; + width: 32px; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + padding: 4px 0; + border-right: 1px solid var(--border); +} +.toolbarSep { + width: 16px; + height: 1px; + margin: 3px 0; + background: var(--border); +} +.body { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} +.tree { + flex: 1; + min-height: 0; +} +.status { + height: 26px; + flex: none; + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + font-size: var(--fs-ui); + color: var(--fg-subtle); + white-space: nowrap; +} +.row { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 6px; + padding-right: 10px; + white-space: nowrap; +} +.label { + overflow: hidden; + text-overflow: ellipsis; +} +.muted { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--fg-subtle); +} +.ok { + color: var(--success); +} +.code { + font-weight: 600; +} +.result { + height: 100%; + display: flex; + flex-direction: column; + min-height: 0; +} +.resultHeader { + height: 28px; + flex: none; + display: flex; + align-items: center; + gap: 6px; + padding: 0 10px; + white-space: nowrap; + border-bottom: 1px solid var(--border); +} +/* Result header: ✓ on success, a red dot with errors (IDEA). */ +.dotFailed { + flex: none; + width: 7px; + height: 7px; + margin: 0 4px; + border-radius: 50%; + background: var(--danger); +} +/* The response with line numbers, scrolling together (IDEA's result editor). */ +.outputBox { + flex: 1; + min-height: 0; + display: flex; + align-items: flex-start; + overflow: auto; + background: var(--bg-editor); +} +.outputGutter, +.output { + margin: 0; + padding: 6px 10px; + font-family: var(--font-mono); + font-size: var(--fs-ui); + line-height: 1.6; +} +.outputGutter { + position: sticky; + left: 0; + flex: none; + min-width: 36px; + min-height: 100%; + padding-left: 8px; + background: var(--bg-editor); + color: var(--fg-line-number); + text-align: right; + user-select: none; +} +.output { + flex: 1; + white-space: pre; + user-select: text; +} + +/* Editor header. */ +.editorToolbar { + display: flex; + align-items: center; + gap: 4px; + height: var(--h-header); + padding: 0 8px; + background: var(--bg-editor); + border-bottom: 1px solid var(--border); + font-family: var(--font-ui); + font-size: var(--fs-ui); + color: var(--fg-secondary); +} +.editorToolbarSep { + width: 1px; + height: 16px; + margin: 0 4px; + background: var(--border); +} +.endpoint { + min-width: 160px; + max-width: 420px; +} +.variablesBox { + position: relative; + border-bottom: 1px solid var(--border); + background: var(--bg-editor); +} +.variablesLayer, +.variables { + box-sizing: border-box; + margin: 0; + padding: 6px 10px; + border: 0; + font-family: var(--font-mono); + font-size: var(--fs-ui); + line-height: 1.5; + white-space: pre; + tab-size: 2; +} +.variablesLayer { + position: absolute; + inset: 0; + overflow: hidden; + color: var(--fg); + pointer-events: none; +} +.variables { + position: relative; + display: block; + width: 100%; + height: 96px; + min-height: 48px; + resize: vertical; + overflow: auto; + outline: none; + background: transparent; + color: transparent; + caret-color: var(--fg); +} +.variables::placeholder { + color: var(--fg-subtle); +} +.variables::selection { + background: var(--bg-editor-selection); + color: transparent; +} + +/* + * graphql-config gutter: ▶ on each endpoint. As wide as the run gutter plus the Spring + * icon gutter beside it in Spring files (14px each), so ▶ keeps the same gap to the VCS bar. + */ +.runGutter { + width: 28px; +} +.runGutter :global(.cm-gutterElement) { + display: flex; + align-items: center; + justify-content: flex-start; +} +.runMarker { + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-small); + cursor: pointer; +} +.runMarker:hover { + background: var(--bg-hover); +} + +/* Query result and variables: JSON in the editor's syntax colours. */ +.output :global(.tok-propertyName), +.variablesLayer :global(.tok-propertyName) { + color: var(--syn-property); +} +.output :global(.tok-string), +.variablesLayer :global(.tok-string), +.output :global(.tok-string2), +.variablesLayer :global(.tok-string2) { + color: var(--syn-string); +} +.output :global(.tok-number), +.variablesLayer :global(.tok-number) { + color: var(--syn-number); +} +.output :global(.tok-bool), +.variablesLayer :global(.tok-bool), +.output :global(.tok-null), +.variablesLayer :global(.tok-null), +.output :global(.tok-atom), +.variablesLayer :global(.tok-atom), +.output :global(.tok-keyword), +.variablesLayer :global(.tok-keyword) { + color: var(--syn-keyword); +} +.output :global(.tok-punctuation), +.variablesLayer :global(.tok-punctuation), +.output :global(.tok-separator), +.variablesLayer :global(.tok-separator), +.output :global(.tok-squareBracket), +.variablesLayer :global(.tok-squareBracket), +.output :global(.tok-brace), +.variablesLayer :global(.tok-brace) { + color: var(--syn-punct); +} + +/* Text the last Execute GraphQL did not send (IDEA greys it out). */ +.unusedContext { + opacity: 0.45; +} + +/* GraphQL Environment Variables dialog: a Name / Value table (IDEA). */ +.envBox { + display: flex; + flex-direction: column; + height: 300px; + border: 1px solid var(--border); +} +.envToolbar { + flex: none; + display: flex; + gap: 2px; + padding: 2px 4px; + border-bottom: 1px solid var(--border); +} +.envTable { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + font-size: var(--fs-ui); +} +.envTable th { + height: 24px; + padding: 0 8px; + text-align: left; + font-weight: normal; + color: var(--fg-secondary); + border-bottom: 1px solid var(--border); +} +.envTable th + th { + border-left: 1px solid var(--border); +} +.envTable td { + height: 24px; + padding: 0; +} +.envName { + padding: 0 8px !important; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.envValue { + box-sizing: border-box; + width: 100%; + height: 24px; + padding: 0 8px; + border: 0; + outline: none; + background: transparent; + color: var(--fg); + font: inherit; + font-family: var(--font-mono); +} +.envValue:focus { + background: var(--bg-selected); +} diff --git a/src/features/graphql/GraphQLView.tsx b/src/features/graphql/GraphQLView.tsx new file mode 100644 index 00000000..e6d787a3 --- /dev/null +++ b/src/features/graphql/GraphQLView.tsx @@ -0,0 +1,371 @@ +/** + * GraphQL tool window (IDEA's): "Schemas and Project Structure" lists each schema + * with its discovery summary, the errors the language server reports for its + * files and its endpoints; "Query Result" shows the last query's response. + */ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Diagnostic } from "@/bindings"; +import { diagnosticsFor, subscribeDiagnostics } from "@/editor/lsp/diagnostics"; +import { highlightInto } from "@/editor/lsp/markdown"; +import { formatMessage, useTx } from "@/platform/i18n"; +import { openExternalUrl } from "@/platform/openUrl"; +import { basename, relativeDir, useProject } from "@/platform/project"; +import { EmptyState, Icon, IconButton, Spinner, Tabs, Tree, fileIconName, T } from "@/ui"; +import { editorService } from "@/workbench/contributions"; +import { + childNodes, + formatBody, + formatSize, + projectNodes, + responseErrorCount, + schemaProblems, + schemaStats, + type SchemaNode, +} from "./model"; +import { configurationFor, discoverSchemasNow, openConfiguration, useGraphQL, type GraphQLTab } from "./store"; +import styles from "./GraphQL.module.css"; + +const HELP_URL = "https://the-guild.dev/graphql/config/docs/user/usage"; + +export function GraphQLTabs() { + const tab = useGraphQL((s) => s.tab); + return ( + useGraphQL.setState({ tab: id as GraphQLTab })} + items={[ + { id: "schemas", label: "Schemas and Project Structure" }, + { id: "result", label: "Query Result" }, + ]} + /> + ); +} + +export function GraphQLView() { + const tab = useGraphQL((s) => s.tab); + return tab === "result" ? : ; +} + +function expandableKeys(nodes: readonly SchemaNode[], children: (n: SchemaNode) => SchemaNode[] | undefined): string[] { + return nodes.flatMap((n) => { + const c = children(n); + return c?.length ? [n.key, ...expandableKeys(c, children)] : []; + }); +} + +type DiagnosticsByPath = ReadonlyMap; + +function readDiagnostics(paths: readonly string[]): DiagnosticsByPath { + return new Map(paths.map((p) => [p, diagnosticsFor(p)])); +} + +/** Diagnostics of `paths`, updated as the language server reports them. */ +function useDiagnostics(paths: readonly string[]): DiagnosticsByPath { + const [diagnostics, setDiagnostics] = useState(() => readDiagnostics(paths)); + useEffect(() => { + const update = () => setDiagnostics(readDiagnostics(paths)); + update(); + const offs = paths.map((p) => subscribeDiagnostics(p, update)); + return () => offs.forEach((off) => off()); + }, [paths]); + return diagnostics; +} + +function SchemasView() { + const tx = useTx(); + const root = useProject((s) => s.project?.root ?? ""); + const projects = useGraphQL((s) => s.projects); + const discovering = useGraphQL((s) => s.discovering); + const expanded = useGraphQL((s) => s.expanded); + + useEffect(() => { + if (root && !projects && !discovering) void discoverSchemasNow(); + }, [root, projects, discovering]); + + const paths = useMemo(() => (projects ?? []).flatMap((p) => p.files.map((f) => f.path)), [projects]); + const diagnostics = useDiagnostics(paths); + const problemsOf = useCallback((path: string) => diagnostics.get(path) ?? [], [diagnostics]); + const children = useCallback((n: SchemaNode) => childNodes(n, problemsOf), [problemsOf]); + const roots = useMemo(() => projectNodes(projects ?? []), [projects]); + // IDEA opens each schema with its errors and endpoints. + const defaultExpanded = useMemo( + () => new Set(roots.flatMap((n) => [n.key, `${n.key}#errors`, `${n.key}#endpoints`])), + [roots], + ); + const effectiveExpanded = expanded ?? defaultExpanded; + + const renderNode = (n: SchemaNode) => { + switch (n.kind) { + case "project": { + const p = n.project; + return ( + + + {p.configPath ? basename(p.root) : tx("Default project-wide schema")} + {p.root} + + ); + } + case "summary": { + const p = n.project; + const detail = p.dynamic + ? tx("the configuration is code and is not evaluated") + : !p.files.length && !p.remote.length + ? tx("schema is empty") + : formatMessage(tx("{types} types, {inputs} inputs, {enums} enums, {directives} directives"), { + ...schemaStats(p), + }); + return ( + + + {tx("Schema discovery summary")} + - {detail} + + ); + } + case "file": + return ( + + + {basename(n.file.path)} + {relativeDir(root, n.file.path)} + + ); + case "remote": + return ( + + + {n.url} + + ); + case "errors": { + const count = schemaProblems(n.project, problemsOf).length; + return ( + + + {tx("Schema errors")} + {count > 0 && {count}} + + ); + } + case "error": { + const d = n.diagnostic; + return ( + + + {d.message} + + {basename(n.path)}:{d.range.start.line + 1} + + + ); + } + case "noErrors": + return ( + + + {tx("No errors found")} + + ); + case "endpoints": + return ( + + + {tx("Endpoints")} + + ); + case "endpoint": + return ( + + {n.endpoint.name} + {n.endpoint.url} + + ); + case "noEndpoints": + return ( + + + + {n.project.configPath + ? tx("No endpoints available in this schema") + : tx("No endpoints available in the default schema")} + + + ); + } + }; + + const labelOf = (n: SchemaNode): string => { + switch (n.kind) { + case "project": + return n.project.configPath ? basename(n.project.root) : tx("Default project-wide schema"); + case "summary": + return tx("Schema discovery summary"); + case "file": + return basename(n.file.path); + case "remote": + return n.url; + case "errors": + return tx("Schema errors"); + case "error": + return n.diagnostic.message; + case "noErrors": + return tx("No errors found"); + case "endpoints": + return tx("Endpoints"); + case "endpoint": + return n.endpoint.name; + case "noEndpoints": + return tx("No endpoints available in the default schema"); + } + }; + + return ( +
+
+ + + void openConfiguration()} + /> + + void openExternalUrl(HELP_URL)} + /> + + useGraphQL.setState({ expanded: new Set(expandableKeys(roots, children)) })} + /> + useGraphQL.setState({ expanded: new Set() })} + /> +
+
+ {!projects ? ( +
+ Discovering GraphQL schemas… +
+ ) : ( + + aria-label="GraphQL schemas" + className={styles.tree} + roots={roots} + getKey={(n) => n.key} + getLabel={labelOf} + getChildren={children} + isExpandable={(n) => !!children(n)?.length} + expanded={effectiveExpanded} + onExpandedChange={(next) => useGraphQL.setState({ expanded: next })} + renderNode={renderNode} + multiSelect={false} + onActivate={(n) => { + if (n.kind === "project" && n.project.configPath) void editorService().openFile(n.project.configPath); + else if (n.kind === "file") void editorService().openFile(n.file.path); + else if (n.kind === "error") { + const { start } = n.diagnostic.range; + void editorService().openFile(n.path, { selection: { line: start.line, col: start.col } }); + } + }} + /> + )} +
+
+ ); +} + +function QueryResultView() { + const tx = useTx(); + const result = useGraphQL((s) => s.result); + const running = useGraphQL((s) => s.running !== null); + if (!result) { + return ( + + {!running && ( + + Run a query with Execute GraphQL in the toolbar of a GraphQL file. + + )} + + ); + } + const errors = responseErrorCount(result.body); + const failed = !!result.error || errors > 0 || (result.status !== null && result.status >= 400); + const ms = result.totalMs === null ? "?" : Math.round(result.totalMs); + const size = formatSize(result.size ?? result.body?.length ?? 0); + return ( +
+
+ {running ? ( + + ) : failed ? ( + + ) : ( + + )} + + {result.error + ? formatMessage(tx("{name}: {error}"), { name: basename(result.path), error: result.error }) + : formatMessage(tx("{file}: {ms} ms execution time, {size} response"), { + file: basename(result.path), + ms, + size, + })} + {!result.error && errors === 1 && tx(", 1 error")} + {!result.error && errors > 1 && formatMessage(tx(", {count} errors"), { count: errors })} + +
+ +
+ ); +} + +/** The response in the editor's colours (JSON), like IDEA's result viewer. */ +function ResultBody({ text, json }: { text: string; json: boolean }) { + const tx = useTx(); + const ref = useRef(null); + useEffect(() => { + const el = ref.current; + if (!el) return; + el.textContent = text; + if (json) void highlightInto(el, text, "JSON"); + }, [text, json]); + const lines = text.split("\n").length; + return ( +
+
+ {Array.from({ length: lines }, (_, i) => ( +
{i + 1}
+ ))} +
+
+    
+ ); +} diff --git a/src/features/graphql/editor.tsx b/src/features/graphql/editor.tsx new file mode 100644 index 00000000..61adbf3e --- /dev/null +++ b/src/features/graphql/editor.tsx @@ -0,0 +1,284 @@ +/** + * GraphQL in editors, after IDEA's GraphQL plugin (JetBrains/js-graphql-intellij-plugin): + * - the toolbar above GraphQL files: Open Configuration File, Edit Environment Variables, + * Toggle Variables Editor, the endpoint, Execute GraphQL, Rerun Introspection Query and + * Open Introspection Schema; the variables editor (JSON) shows below it; + * - schema files re-checked as they are edited (unresolved types); + * - in graphql-config files, ▶ on each endpoint runs its introspection query. + */ +import { RangeSet, StateEffect, StateField, type EditorState, type Extension } from "@codemirror/state"; +import { + Decoration, + EditorView, + GutterMarker, + ViewPlugin, + gutter, + showPanel, + type DecorationSet, + type Panel, + type ViewUpdate, +} from "@codemirror/view"; +import { useEffect, useRef } from "react"; +import { createRoot } from "react-dom/client"; +import type { EditorDocContext } from "@/editor/api"; +import { highlightInto } from "@/editor/lsp/markdown"; +import { useTx } from "@/platform/i18n"; +import { shortcutLabel } from "@/platform/keymap"; +import { basename } from "@/platform/project"; +import { isVirtualUri } from "@/platform/virtualDocs"; +import { IconButton, Select, gutterIconMarkup } from "@/ui"; +import { endpointLines, isConfigFile, outsideRanges, queryContext } from "./model"; +import { + chooseEndpoint, + configurationFor, + discoverSchemasNow, + editEnvironmentVariables, + endpointFor, + introspectEndpoint, + openConfiguration, + openIntrospectionSchema, + rerunIntrospection, + revalidateFile, + runQuery, + schemaOf, + setVariables, + toggleVariables, + useGraphQL, +} from "./store"; +import styles from "./GraphQL.module.css"; + +export const GRAPHQL_LANGUAGE = "graphql"; + +// --- query context: what Execute GraphQL sent, the rest greyed out (IDEA) --------------------- + +const setSentRanges = StateEffect.define>(); +const unusedMark = Decoration.mark({ class: styles.unusedContext ?? "" }); + +/** Greys out the text the last Execute GraphQL left out, until the text or selection changes. */ +const unusedContext = StateField.define({ + create: () => Decoration.none, + update(value, tr) { + for (const e of tr.effects) + if (e.is(setSentRanges)) + return Decoration.set( + outsideRanges(tr.state.doc.length, e.value) + .filter((r) => r.to > r.from) + .map((r) => unusedMark.range(r.from, r.to)), + ); + return tr.docChanged || tr.selection ? Decoration.none : value; + }, + provide: (f) => EditorView.decorations.from(f), +}); + +/** Execute GraphQL in `view`: the selection, else the operation at the caret (with its fragments). */ +export function runFromView(path: string, view: EditorView): Promise { + const { state } = view; + const { from, to } = state.selection.main; + const context = queryContext(state.doc.toString(), { from, to }); + if (view.state.field(unusedContext, false) !== undefined) + view.dispatch({ effects: setSentRanges.of(context.ranges) }); + return runQuery(path, context.text); +} + +function Toolbar({ path, view }: { path: string; view: EditorView }) { + const tx = useTx(); + const projects = useGraphQL((s) => s.projects); + const discovering = useGraphQL((s) => s.discovering); + const running = useGraphQL((s) => s.running !== null); + const endpoint = useGraphQL((s) => endpointFor(s, path)); + const variablesShown = useGraphQL((s) => s.variablesShown.has(path)); + useEffect(() => { + if (!projects && !discovering) void discoverSchemasNow(); + }, [projects, discovering]); + + const config = configurationFor(projects, path); + const schema = projects ? schemaOf(projects, path) : null; + const endpoints = schema?.endpoints ?? []; + return ( +
+ void openConfiguration(path)} + /> + void editEnvironmentVariables(path)} + /> + + toggleVariables(path)} + /> +