feat(window): graphql window (#1166)

Adds a GraphQL tool window: schema discovery from graphql-config files (or every GraphQL file without one), schema errors and endpoints, running the operation at the caret against a configured endpoint with ${NAME} environment variables, Query Result, and introspection into the schema file. The editor gets a GraphQL toolbar with endpoint selection, variables and run.

The tool window is listed on the stripe once used, so projects without GraphQL get no extra button.

Closes #1168

Co-authored-by: kidp2h <40386174+kidp2h@users.noreply.github.com>
This commit is contained in:
Nguyen Thinh
2026-11-04 13:02:01 +08:00
committed by GitHub
parent 8687f6b1e3
commit 3d7768f47f
24 changed files with 3187 additions and 2 deletions
+1 -1
View File
@@ -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"
+106
View File
@@ -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<Record<string, string>>): 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<string, string> {
const out: Record<string, string> = {};
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 ? <EnvironmentDialog key={dialog.configPath} {...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 (
<Dialog
open
title="GraphQL Environment Variables"
onClose={() => closeEnvironmentDialog(false)}
width={480}
buttons={[
{ label: "OK", role: "default", onClick: ok },
{ label: "Cancel", role: "cancel" },
]}
>
<div className={styles.envBox}>
<div className={styles.envToolbar} role="toolbar" aria-label={tx("Environment variables actions")}>
<IconButton
icon="copy"
size="small"
title="Copy"
onClick={() => void copyText(formatEnvironment(variables, values))}
/>
<IconButton
icon="paste"
size="small"
title="Paste"
onClick={() =>
void readClipboardText().then((text) =>
setValues((v) => ({ ...v, ...parseEnvironment(text, variables) })),
)
}
/>
</div>
<table className={styles.envTable} aria-label={tx("Environment variables")}>
<thead>
<tr>
<th>
<T>Name</T>
</th>
<th>
<T>Value</T>
</th>
</tr>
</thead>
<tbody>
{variables.map((name) => (
<tr key={name}>
<td className={styles.envName}>{name}</td>
<td>
<input
className={styles.envValue}
aria-label={name}
spellCheck={false}
value={values[name] ?? ""}
onChange={(e) => setValues((v) => ({ ...v, [name]: e.target.value }))}
onKeyDown={(e) => {
if (e.key === "Enter") ok();
}}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Dialog>
);
}
+323
View File
@@ -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);
}
+371
View File
@@ -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 (
<Tabs
variant="compact"
aria-label="GraphQL views"
className={styles.tabs}
activeId={tab}
onSelect={(id) => 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" ? <QueryResultView /> : <SchemasView />;
}
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<string, readonly Diagnostic[]>;
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 (
<span className={styles.row}>
<Icon name={p.configPath ? fileIconName(basename(p.configPath)) : "file-graphql"} size={16} />
<span className={styles.label}>{p.configPath ? basename(p.root) : tx("Default project-wide schema")}</span>
<span className={styles.muted}>{p.root}</span>
</span>
);
}
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 (
<span className={styles.row}>
<Icon name="text" size={16} />
<span className={styles.label}>{tx("Schema discovery summary")}</span>
<span className={styles.muted}>- {detail}</span>
</span>
);
}
case "file":
return (
<span className={styles.row}>
<Icon name={fileIconName(basename(n.file.path))} size={16} />
<span className={styles.label}>{basename(n.file.path)}</span>
<span className={styles.muted}>{relativeDir(root, n.file.path)}</span>
</span>
);
case "remote":
return (
<span className={styles.row}>
<Icon name="web" size={16} />
<span className={styles.label}>{n.url}</span>
</span>
);
case "errors": {
const count = schemaProblems(n.project, problemsOf).length;
return (
<span className={styles.row}>
<Icon name="folder" size={16} />
<span className={styles.label}>{tx("Schema errors")}</span>
{count > 0 && <span className={styles.muted}>{count}</span>}
</span>
);
}
case "error": {
const d = n.diagnostic;
return (
<span className={styles.row}>
<Icon name={d.severity === "error" ? "error" : "warning"} size={16} />
<span className={styles.label}>{d.message}</span>
<span className={styles.muted}>
{basename(n.path)}:{d.range.start.line + 1}
</span>
</span>
);
}
case "noErrors":
return (
<span className={styles.row}>
<Icon name="check" size={16} className={styles.ok} />
<span className={styles.label}>{tx("No errors found")}</span>
</span>
);
case "endpoints":
return (
<span className={styles.row}>
<Icon name="folder" size={16} />
<span className={styles.label}>{tx("Endpoints")}</span>
</span>
);
case "endpoint":
return (
<span className={styles.row}>
<span className={styles.label}>{n.endpoint.name}</span>
<span className={styles.muted}>{n.endpoint.url}</span>
</span>
);
case "noEndpoints":
return (
<span className={styles.row}>
<Icon name="info" size={16} />
<span className={styles.label}>
{n.project.configPath
? tx("No endpoints available in this schema")
: tx("No endpoints available in the default schema")}
</span>
</span>
);
}
};
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 (
<div className={styles.view}>
<div className={styles.toolbar} role="toolbar" aria-orientation="vertical" aria-label={tx("GraphQL actions")}>
<IconButton icon="plus" size="small" iconSize={16} action="graphql.addConfiguration" />
<span className={styles.toolbarSep} />
<IconButton
icon="settings"
size="small"
iconSize={16}
title="Open Configuration File"
disabled={!configurationFor(projects)}
onClick={() => void openConfiguration()}
/>
<IconButton
icon="refresh"
size="small"
iconSize={16}
action="graphql.restartDiscovery"
disabled={discovering}
/>
<IconButton
icon="help"
size="small"
iconSize={16}
title="Help"
onClick={() => void openExternalUrl(HELP_URL)}
/>
<span className={styles.toolbarSep} />
<IconButton
icon="expand-all"
size="small"
iconSize={16}
title="Expand All"
disabled={!roots.length}
onClick={() => useGraphQL.setState({ expanded: new Set(expandableKeys(roots, children)) })}
/>
<IconButton
icon="collapse-all"
size="small"
iconSize={16}
title="Collapse All"
disabled={!roots.length}
onClick={() => useGraphQL.setState({ expanded: new Set() })}
/>
</div>
<div className={styles.body}>
{!projects ? (
<div className={styles.status} role="status">
<Spinner size={12} /> <T>Discovering GraphQL schemas</T>…
</div>
) : (
<Tree<SchemaNode>
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 } });
}
}}
/>
)}
</div>
</div>
);
}
function QueryResultView() {
const tx = useTx();
const result = useGraphQL((s) => s.result);
const running = useGraphQL((s) => s.running !== null);
if (!result) {
return (
<EmptyState title={running ? "Running GraphQL query" : "No query results"}>
{!running && (
<span>
<T>Run a query with Execute GraphQL in the toolbar of a GraphQL file.</T>
</span>
)}
</EmptyState>
);
}
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 (
<div className={styles.result}>
<div className={styles.resultHeader} title={`${result.endpoint.name} - ${result.endpoint.url}`}>
{running ? (
<Spinner size={12} />
) : failed ? (
<span className={styles.dotFailed} aria-hidden />
) : (
<Icon name="check" size={16} className={styles.ok} />
)}
<span className={styles.label}>
{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 })}
</span>
</div>
<ResultBody text={result.error ?? formatBody(result.body)} json={!result.error} />
</div>
);
}
/** 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<HTMLPreElement>(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 (
<div className={styles.outputBox}>
<div className={styles.outputGutter} aria-hidden>
{Array.from({ length: lines }, (_, i) => (
<div key={i}>{i + 1}</div>
))}
</div>
<pre ref={ref} className={styles.output} aria-label={tx("Query result")} />
</div>
);
}
+284
View File
@@ -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<Array<{ from: number; to: number }>>();
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<DecorationSet>({
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<void> {
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 (
<div className={styles.editorToolbar} role="toolbar" aria-label={tx("GraphQL editor actions")}>
<IconButton
icon="settings"
title="Open Configuration File"
disabled={!config}
onClick={() => void openConfiguration(path)}
/>
<IconButton
icon="sliders"
title="Edit Environment Variables"
disabled={!schema?.variables.length}
onClick={() => void editEnvironmentVariables(path)}
/>
<span className={styles.editorToolbarSep} />
<IconButton
icon="graphql-variables"
title="Toggle Variables Editor"
pressed={variablesShown}
onClick={() => toggleVariables(path)}
/>
<Select
aria-label="GraphQL endpoint"
className={styles.endpoint}
value={endpoint?.name ?? null}
placeholder={tx("No endpoints")}
disabled={!endpoints.length}
options={endpoints.map((e) => ({ value: e.name, label: `${e.name} - ${e.url}` }))}
onChange={(name) => chooseEndpoint(path, name)}
/>
<IconButton
icon="run"
title="Execute GraphQL"
shortcut={shortcutLabel("graphql.runQuery")}
disabled={!endpoint || running}
onClick={() => void runFromView(path, view)}
/>
<IconButton
icon="refresh"
title="Rerun Introspection Query"
disabled={!endpoint || running}
onClick={() => void rerunIntrospection(path)}
/>
<IconButton
icon="external-link"
title="Open Introspection Schema"
disabled={!schema}
onClick={() => void openIntrospectionSchema(path)}
/>
</div>
);
}
/** The variables editor: JSON in the editor's colours (a highlighted layer under a transparent textarea). */
function VariablesEditor({ path }: { path: string }) {
const tx = useTx();
const shown = useGraphQL((s) => s.variablesShown.has(path));
const text = useGraphQL((s) => s.variables[path] ?? "");
const layer = useRef<HTMLPreElement>(null);
useEffect(() => {
const el = layer.current;
if (!el) return;
// A trailing newline keeps the layer as tall as the textarea's last (empty) line.
const shownText = `${text}\n`;
el.textContent = shownText;
if (text) void highlightInto(el, shownText, "JSON");
}, [text, shown]);
if (!shown) return null;
return (
<div className={styles.variablesBox}>
<pre ref={layer} className={styles.variablesLayer} aria-hidden />
<textarea
className={styles.variables}
aria-label={tx("GraphQL variables")}
placeholder='{ "code": "VN" }'
spellCheck={false}
value={text}
onChange={(e) => setVariables(path, e.target.value)}
onScroll={(e) => {
const el = layer.current;
if (!el) return;
el.scrollTop = e.currentTarget.scrollTop;
el.scrollLeft = e.currentTarget.scrollLeft;
}}
/>
</div>
);
}
function headerPanel(path: string) {
return (view: EditorView): Panel => {
const dom = document.createElement("div");
const root = createRoot(dom);
root.render(
<>
<Toolbar path={path} view={view} />
<VariablesEditor path={path} />
</>,
);
return { dom, top: true, destroy: () => queueMicrotask(() => root.unmount()) };
};
}
const REVALIDATE_DELAY = 300;
/** Re-checks the schema from the editor's text as it is edited. */
function revalidation(path: string) {
return ViewPlugin.fromClass(
class {
timer: ReturnType<typeof setTimeout> | undefined;
constructor(readonly view: EditorView) {}
update(u: ViewUpdate) {
if (!u.docChanged) return;
clearTimeout(this.timer);
this.timer = setTimeout(() => revalidateFile(path, this.view.state.doc.toString()), REVALIDATE_DELAY);
}
destroy() {
clearTimeout(this.timer);
}
},
);
}
// --- graphql-config: ▶ on each endpoint (Get GraphQL Schema from Endpoint) -------------------
class EndpointMarker extends GutterMarker {
constructor(readonly endpoint: string) {
super();
}
override eq(other: GutterMarker): boolean {
return other instanceof EndpointMarker && other.endpoint === this.endpoint;
}
override toDOM(): Node {
const el = document.createElement("div");
el.className = styles.runMarker ?? "";
el.title = "Run Introspection Query";
el.setAttribute("role", "button");
el.setAttribute("aria-label", el.title);
el.innerHTML = gutterIconMarkup("gutter-run") ?? "";
return el;
}
}
function endpointMarkers(name: string) {
const compute = (state: EditorState) =>
RangeSet.of(
endpointLines(name, state.doc.toString())
.filter((e) => e.line < state.doc.lines)
.map((e) => new EndpointMarker(e.endpoint).range(state.doc.line(e.line + 1).from)),
true,
);
return StateField.define<RangeSet<GutterMarker>>({
create: compute,
update: (value, tr) => (tr.docChanged ? compute(tr.state) : value),
});
}
function configExtension(path: string): Extension {
const field = endpointMarkers(basename(path));
return [
field,
gutter({
class: `${styles.runGutter} cm-openidea-gutter-graphql`,
markers: (view) => view.state.field(field),
initialSpacer: () => new EndpointMarker(""),
domEventHandlers: {
mousedown: (view, block, event) => {
if ((event as MouseEvent).button !== 0) return false;
let endpoint: string | null = null;
view.state.field(field).between(block.from, block.from, (_from, _to, m) => {
endpoint = (m as EndpointMarker).endpoint;
});
if (endpoint === null) return false;
event.preventDefault();
void introspectEndpoint(path, endpoint);
return true;
},
},
}),
];
}
export function graphqlEditorExtension(ctx: EditorDocContext): Extension {
if (ctx.virtual || isVirtualUri(ctx.path)) return [];
if (isConfigFile(basename(ctx.path))) return configExtension(ctx.path);
if (ctx.language !== GRAPHQL_LANGUAGE) return [];
return [showPanel.of(headerPanel(ctx.path)), revalidation(ctx.path), unusedContext];
}
+458
View File
@@ -0,0 +1,458 @@
import { describe, expect, it } from "vitest";
import type { Diagnostic } from "@/bindings";
import {
analyzeSchema,
childNodes,
endpointLines,
expandEndpoint,
discoverSchemas,
globToRegExp,
httpRequestText,
introspectionTarget,
introspectionToSdl,
jsonWithoutTemplates,
parseConfig,
parseVariables,
outsideRanges,
parseYaml,
projectNodes,
queryContext,
queryText,
responseErrorCount,
schemaStats,
unresolvedTypes,
type SchemaFile,
} from "./model";
import { schemaOf } from "./store";
describe("parseYaml", () => {
it("reads nested maps, lists and flow lists", () => {
expect(
parseYaml(`# graphql-config
schema:
- schema.graphql # main
- 'types/**/*.graphql'
documents: [a.graphql, "b.graphql"]
extensions:
endpoints:
default:
url: http://localhost:4000/graphql
headers:
Authorization: Bearer x
`),
).toEqual({
schema: ["schema.graphql", "types/**/*.graphql"],
documents: ["a.graphql", "b.graphql"],
extensions: {
endpoints: {
default: { url: "http://localhost:4000/graphql", headers: { Authorization: "Bearer x" } },
},
},
});
});
it("reads lists of maps", () => {
expect(parseYaml("items:\n - name: a\n url: x\n - b\n")).toEqual({ items: [{ name: "a", url: "x" }, "b"] });
});
});
describe("parseConfig", () => {
it("reads schema pointers and endpoints from YAML", () => {
const c = parseConfig(
"graphql.config.yml",
"schema: schema.graphql\nextensions:\n endpoints:\n local: http://localhost/graphql\n",
);
expect(c).toEqual({
schema: ["schema.graphql"],
endpoints: [{ name: "local", url: "http://localhost/graphql", headers: {} }],
dynamic: false,
variables: [],
});
});
it("reads JSON, a `.graphqlrc` holding JSON and the legacy schemaPath", () => {
expect(parseConfig("graphql.config.json", '{"schema":["a.graphql"]}').schema).toEqual(["a.graphql"]);
expect(parseConfig(".graphqlrc", '{ "schemaPath": "s.graphqls" }').schema).toEqual(["s.graphqls"]);
});
it("takes the keys of a schema map as pointers", () => {
expect(parseConfig(".graphqlrc.json", '{"schema":{"https://api.dev/graphql":{"headers":{}}}}').schema).toEqual([
"https://api.dev/graphql",
]);
});
it("does not evaluate JS/TS configs and survives broken ones", () => {
expect(parseConfig("graphql.config.ts", "export default {}").dynamic).toBe(true);
expect(parseConfig("graphql.config.json", "{").schema).toEqual([]);
});
});
describe("globToRegExp", () => {
it("matches graphql-config globs", () => {
expect(globToRegExp("**/*.graphql").test("schema.graphql")).toBe(true);
expect(globToRegExp("**/*.graphql").test("a/b/c.graphql")).toBe(true);
expect(globToRegExp("src/*.{graphql,gql}").test("src/q.gql")).toBe(true);
expect(globToRegExp("src/*.graphql").test("src/a/q.graphql")).toBe(false);
expect(globToRegExp("./schema.graphql").test("schema.graphql")).toBe(true);
});
});
describe("analyzeSchema", () => {
const text = `
"""
type NotAType
"""
type Query { a(x: In): Int, color: Color }
# type Commented
extend type Query { b: [Missing!]! }
enum Color { RED }
input In { x: Int, d: Date }
scalar Date
union U = Query | Gone
directive @auth on FIELD_DEFINITION
`;
it("counts definitions as IDEA's summary does, not comments or descriptions", () => {
const a = analyzeSchema(text);
expect(a.stats).toEqual({ types: 3, inputs: 1, enums: 1, directives: 1 });
expect(a.defined.sort()).toEqual(["Color", "Date", "In", "Query", "U"]);
});
it("reports types used but defined nowhere, at their position", () => {
const file: SchemaFile = { path: "/p/s.graphql", ...analyzeSchema(text) };
const [project] = discoverSchemas("/p", [], [file]);
const errors = unresolvedTypes(project!).map((e) => [e.diagnostic.message, e.diagnostic.range.start]);
expect(errors).toEqual([
["The field type 'Missing' is not present when resolving type 'Query'", { line: 6, col: 24 }],
["The field type 'Gone' is not present when resolving type 'U'", { line: 10, col: 18 }],
]);
expect(schemaStats(project!).types).toBe(3);
});
});
const file = (path: string): SchemaFile => ({
path,
stats: { types: 0, inputs: 0, enums: 0, directives: 0 },
defined: [],
references: [],
});
const files = [file("/p/schema.graphql"), file("/p/api/user.graphqls"), file("/p/web/query.graphql")];
describe("discoverSchemas", () => {
it("puts every file in the default schema without a config", () => {
const p = discoverSchemas("/p", [], files)[0]!;
expect(p.configPath).toBeNull();
expect(p.files.map((f) => f.path)).toEqual(["/p/api/user.graphqls", "/p/schema.graphql", "/p/web/query.graphql"]);
});
it("picks a config's files by its globs, relative to its folder", () => {
const p = discoverSchemas(
"/p",
[
{
path: "/p/api/.graphqlrc.yml",
config: { schema: ["*.graphqls", "https://x.dev/graphql"], endpoints: [], dynamic: false, variables: [] },
},
],
files,
)[0]!;
expect(p.root).toBe("/p/api");
expect(p.files.map((f) => f.path)).toEqual(["/p/api/user.graphqls"]);
expect(p.remote).toEqual(["https://x.dev/graphql"]);
});
});
describe("tree", () => {
const diag = (severity: Diagnostic["severity"]): Diagnostic => ({
range: { start: { line: 1, col: 2 }, end: { line: 1, col: 4 } },
severity,
message: "Unknown type",
source: "graphql",
code: null,
});
it("shows IDEA's sections: summary, errors and endpoints", () => {
const root = projectNodes(discoverSchemas("/p", [], []))[0]!;
const none = () => [];
const [summary, errors, endpoints] = childNodes(root, none)!;
expect([summary!.kind, errors!.kind, endpoints!.kind]).toEqual(["summary", "errors", "endpoints"]);
expect(childNodes(summary!, none)).toEqual([]);
expect(childNodes(errors!, none)?.map((n) => n.kind)).toEqual(["noErrors"]);
expect(childNodes(endpoints!, none)?.map((n) => n.kind)).toEqual(["noEndpoints"]);
});
it("lists errors and warnings of the schema's files", () => {
const root = projectNodes(discoverSchemas("/p", [], files))[0]!;
const errors = childNodes(root, () => [])![1]!;
const found = childNodes(errors, (path) =>
path === "/p/schema.graphql" ? [diag("error"), diag("hint"), diag("warning")] : [],
);
expect(found?.map((n) => (n.kind === "error" ? n.diagnostic.severity : n.kind))).toEqual(["error", "warning"]);
});
});
describe("queries", () => {
it("runs the selection, else the document", () => {
expect(queryText(" { a }\n", null)).toBe("{ a }");
expect(queryText("{ a } { b }", { from: 6, to: 11 })).toBe("{ b }");
expect(queryText("{ a }", { from: 2, to: 2 })).toBe("{ a }");
});
it("runs the operation around the caret with the fragments it uses, as IDEA does", () => {
const doc = `# { not: "this" }
query A { ...F }
query B($id: ID!) { node(id: $id) { ... on User { name } ...G } }
fragment F on Query { x }
fragment G on Node { id ...H }
fragment H on Node { "}" }
`;
const inB = doc.indexOf("node(");
expect(queryText(doc, { from: inB, to: inB })).toBe(
'query B($id: ID!) { node(id: $id) { ... on User { name } ...G } }\n\nfragment G on Node { id ...H }\n\nfragment H on Node { "}" }',
);
const inA = doc.indexOf("...F");
expect(queryText(doc, { from: inA, to: inA })).toBe("query A { ...F }\n\nfragment F on Query { x }");
// Outside every operation (in the comment): the whole document.
expect(queryText(doc, { from: 3, to: 3 })).toBe(doc.trim());
});
it("reports what was sent so the rest can be greyed out", () => {
const doc = "query A { a }\nquery B { b }\n";
expect(queryContext(doc, { from: 2, to: 5 }).ranges).toEqual([{ from: 2, to: 5 }]);
const inB = doc.indexOf("b }");
expect(queryContext(doc, { from: inB, to: inB }).ranges).toEqual([{ from: 14, to: 27 }]);
expect(outsideRanges(doc.length, [{ from: 14, to: 27 }])).toEqual([
{ from: 0, to: 14 },
{ from: 27, to: 28 },
]);
});
it("counts the errors of a GraphQL response", () => {
expect(responseErrorCount('{"errors":[{"message":"a"},{"message":"b"}]}')).toBe(2);
expect(responseErrorCount('{"data":{}}')).toBe(0);
expect(responseErrorCount("<html>")).toBe(0);
});
it("escapes braces in strings so the HTTP client does not expand them", () => {
const json = jsonWithoutTemplates({ query: "{ a { b }}", variables: { o: { p: 1 } } });
expect(json).not.toContain("{{");
expect(JSON.parse(json)).toEqual({ query: "{ a { b }}", variables: { o: { p: 1 } } });
});
it("reads variables as a JSON object", () => {
expect(parseVariables(" ")).toEqual({ variables: null });
expect(parseVariables('{"code":"VN"}')).toEqual({ variables: { code: "VN" } });
expect("error" in parseVariables("[1]")).toBe(true);
expect("error" in parseVariables("{")).toBe(true);
});
it("posts the query as JSON with the endpoint's headers", () => {
expect(
httpRequestText({ name: "x", url: "http://h/graphql", headers: { Authorization: "Bearer t" } }, "{ a }"),
).toBe(
'POST http://h/graphql\nContent-Type: application/json\nAuthorization: Bearer t\n\n{"query":"\\u007b a \\u007d"}\n',
);
});
it("uses the schema listing the file, else the closest config folder", () => {
const projects = discoverSchemas(
"/p",
[
{
path: "/p/.graphqlrc.yml",
config: { schema: ["schema.graphql"], endpoints: [], dynamic: false, variables: [] },
},
{
path: "/p/api/.graphqlrc.yml",
config: { schema: ["*.graphqls"], endpoints: [], dynamic: false, variables: [] },
},
],
files,
);
expect(schemaOf(projects, "/p/schema.graphql")?.configPath).toBe("/p/.graphqlrc.yml");
expect(schemaOf(projects, "/p/api/ops/q.graphql")?.configPath).toBe("/p/api/.graphqlrc.yml");
});
});
describe("introspection", () => {
const ref = (name: string, kind = "OBJECT") => ({ kind, name, ofType: null });
const nonNull = (t: object) => ({ kind: "NON_NULL", name: null, ofType: t });
const list = (t: object) => ({ kind: "LIST", name: null, ofType: t });
const body = JSON.stringify({
data: {
__schema: {
queryType: { name: "Query" },
mutationType: null,
subscriptionType: null,
directives: [{ name: "include", locations: ["FIELD"], args: [] }],
types: [
{
kind: "OBJECT",
name: "Query",
description: "Root",
interfaces: [],
fields: [
{
name: "countries",
args: [{ name: "filter", type: ref("Filter", "INPUT_OBJECT"), defaultValue: null }],
type: nonNull(list(nonNull(ref("Country")))),
},
{
name: "old",
args: [],
type: ref("String", "SCALAR"),
isDeprecated: true,
deprecationReason: "Use countries",
},
],
},
{
kind: "OBJECT",
name: "Country",
interfaces: [],
fields: [{ name: "code", args: [], type: nonNull(ref("ID", "SCALAR")) }],
},
{
kind: "INPUT_OBJECT",
name: "Filter",
inputFields: [{ name: "code", type: ref("String", "SCALAR"), defaultValue: '"VN"' }],
},
{ kind: "ENUM", name: "Color", enumValues: [{ name: "RED" }] },
{ kind: "SCALAR", name: "String" },
{ kind: "OBJECT", name: "__Type", fields: [] },
],
},
},
});
it("prints the schema as IDEA writes it: header, kinds then names, sorted fields", () => {
expect(introspectionToSdl(body)).toBe(`# This file was generated. Do not edit manually.
schema {
query: Query
}
type Country {
code: ID!
}
"Root"
type Query {
countries(filter: Filter): [Country!]!
old: String @deprecated(reason: "Use countries")
}
enum Color {
RED
}
input Filter {
code: String = "VN"
}
`);
});
it("fails on a response without a schema", () => {
expect(() => introspectionToSdl('{"errors":[{"message":"Introspection disabled"}]}')).toThrow(
"Introspection disabled",
);
});
it("writes to the config's first schema file, else schema.graphql", () => {
const [p] = discoverSchemas(
"/p",
[
{
path: "/p/graphql.config.yml",
config: { schema: ["types/*.graphqls", "./api.graphql"], endpoints: [], dynamic: false, variables: [] },
},
],
[],
);
expect(introspectionTarget(p!)).toBe("/p/api.graphql");
const [d] = discoverSchemas("/p", [], []);
expect(introspectionTarget(d!)).toBe("/p/schema.graphql");
});
});
describe("endpointLines", () => {
it("finds each endpoint's line in YAML and JSON configs", () => {
const yaml = `schema: s.graphql
extensions:
endpoints:
countries:
url: https://c/
local: http://l/
`;
expect(endpointLines("graphql.config.yml", yaml)).toEqual([
{ endpoint: "countries", line: 3 },
{ endpoint: "local", line: 5 },
]);
const json = `{
"extensions": {
"endpoints": {
"dev": "http://d/"
}
}
}`;
expect(endpointLines("graphql.config.json", json)).toEqual([{ endpoint: "dev", line: 3 }]);
expect(endpointLines("graphql.config.yml", "schema: s.graphql")).toEqual([]);
});
});
describe("environment variables", () => {
it("lists the ${NAME} variables a config uses and expands endpoints with them", () => {
const c = parseConfig(
"graphql.config.yml",
`extensions:
endpoints:
api:
url: \${API_URL:http://localhost:4000}/graphql
headers:
Authorization: Bearer \${TOKEN}
`,
);
expect(c.variables).toEqual(["API_URL", "TOKEN"]);
const api = c.endpoints[0]!;
expect(expandEndpoint(api, { TOKEN: "t" })).toEqual({
name: "api",
url: "http://localhost:4000/graphql",
headers: { Authorization: "Bearer t" },
});
expect(expandEndpoint(api, { API_URL: "https://x.dev" }).url).toBe("https://x.dev/graphql");
});
});
describe("environment variables dialog", () => {
it("copies and pastes values as NAME=value lines", async () => {
const { formatEnvironment, parseEnvironment } = await import("./EnvironmentDialog");
const names = ["API_TOKEN", "COUNTRIES_HOST"];
expect(formatEnvironment(names, { API_TOKEN: "t" })).toBe("API_TOKEN=t\nCOUNTRIES_HOST=");
expect(parseEnvironment("# comment\nAPI_TOKEN = a=b\r\nOTHER=x\nCOUNTRIES_HOST=https://h\n", names)).toEqual({
API_TOKEN: "a=b",
COUNTRIES_HOST: "https://h",
});
});
});
describe("running an endpoint with environment variables", () => {
it("needs the variables that have neither a value nor a default", async () => {
const { missingVariables } = await import("./model");
const endpoint = {
name: "env",
url: "${HOST:https://h}/graphql",
headers: { Authorization: "Bearer ${TOKEN}", "X-Team": "${TEAM}" },
};
expect(missingVariables(endpoint, {})).toEqual(["TOKEN", "TEAM"]);
expect(missingVariables(endpoint, { TOKEN: "t" })).toEqual(["TEAM"]);
expect(missingVariables(endpoint, { TOKEN: "t", TEAM: "a" })).toEqual([]);
// An empty value counts as not set.
expect(missingVariables(endpoint, { TOKEN: "", TEAM: "a" })).toEqual(["TOKEN"]);
});
it("shows sizes as IDEA does", async () => {
const { formatSize } = await import("./model");
expect(formatSize(286)).toBe("286 bytes");
expect(formatSize(1741)).toBe("1.7 kb");
});
});
+125
View File
@@ -0,0 +1,125 @@
/**
* GraphQL (IDEA's GraphQL plugin, scoped): the GraphQL tool window listing the
* project's schemas from graphql-config (or the default project-wide schema),
* their errors and endpoints, and running a query against an endpoint. Editing
* support (completion, diagnostics, navigation) comes from `graphql-lsp`,
* declared in `languages/graphql.toml`.
*/
import { activeEditor, registerEditorContribution } from "@/editor/api";
import { registerActions } from "@/platform/actions";
import { onFileOperation } from "@/platform/fileOps";
import { basename, useProject } from "@/platform/project";
import { registerOverlay, registerToolWindow, toolWindowService } from "@/workbench/contributions";
import { EnvironmentDialogOverlay } from "./EnvironmentDialog";
import { GRAPHQL_LANGUAGE, graphqlEditorExtension, runFromView } from "./editor";
import { GraphQLTabs, GraphQLView } from "./GraphQLView";
import { isConfigFile, isSchemaFile } from "./model";
import {
TOOL_WINDOW,
addSchemaConfiguration,
discoverSchemasNow,
openConfiguration,
rerunIntrospection,
resetGraphQL,
useGraphQL,
} from "./store";
function activeGraphQLEditor() {
const e = activeEditor();
return e && e.ctx.language === GRAPHQL_LANGUAGE ? e : null;
}
export function activate(): () => void {
const disposers = [
registerToolWindow({
id: TOOL_WINDOW,
title: "GraphQL",
icon: "toolwindow-graphql",
anchor: "bottom",
// IDEA's stripe: right above Git (order 5).
order: 4,
stripe: "onUse",
component: GraphQLView,
headerContent: GraphQLTabs,
}),
registerEditorContribution({ id: "graphql.toolbar", order: 58, extensions: graphqlEditorExtension }),
registerOverlay({ id: "graphql.environment", component: EnvironmentDialogOverlay }),
// A reopened project starts over.
useProject.subscribe((s, prev) => {
if (s.project?.root !== prev.project?.root) resetGraphQL();
}),
// New or deleted schema / config files change what discovery finds.
onFileOperation((op) => {
if (!useGraphQL.getState().projects) return;
if (op.paths.some((p) => isSchemaFile(basename(p)) || isConfigFile(basename(p)))) void discoverSchemasNow();
}),
registerActions([
{
id: "graphql.showToolWindow",
title: "GraphQL",
category: "Tools",
icon: "toolwindow-graphql",
keywords: ["schema", "endpoints", "graphql-config"],
when: "projectOpen",
run: () => toolWindowService().show(TOOL_WINDOW, true),
},
{
id: "graphql.addConfiguration",
title: "Add Schema Configuration",
description: "Open the project's graphql-config, creating graphql.config.yml when there is none",
category: "Tools",
icon: "plus",
keywords: ["graphql", "graphql-config", "graphqlrc", "schema"],
when: "projectOpen",
run: () => addSchemaConfiguration(),
},
{
id: "graphql.editConfiguration",
title: "Open Configuration File",
description: "Opens the GraphQL config file for the current file's schema",
category: "Tools",
icon: "settings",
keywords: ["graphql", "graphql-config", "graphqlrc", "settings"],
when: "projectOpen",
run: () => openConfiguration(activeGraphQLEditor()?.ctx.path),
},
{
id: "graphql.introspect",
title: "Rerun Introspection Query",
description: "Reruns the latest introspection query to update the local schema file",
category: "Tools",
icon: "refresh",
keywords: ["graphql", "introspection", "schema", "endpoint", "download"],
when: "editorLangId == graphql",
run: async () => {
const e = activeGraphQLEditor();
if (e) await rerunIntrospection(e.ctx.path);
},
},
{
id: "graphql.restartDiscovery",
title: "Restart Schema Discovery",
description: "Find the project's GraphQL schemas and configurations again",
category: "Tools",
icon: "refresh",
keywords: ["graphql", "schema", "refresh", "reload"],
when: "projectOpen",
run: () => discoverSchemasNow(),
},
{
id: "graphql.runQuery",
title: "Execute GraphQL",
description: "Executes the current GraphQL file against the specified GraphQL endpoint",
category: "Run",
icon: "run",
keywords: ["graphql", "run", "execute", "query", "endpoint"],
when: "editorTextFocus && editorLangId == graphql",
run: async () => {
const e = activeGraphQLEditor();
if (e) await runFromView(e.ctx.path, e.view);
},
},
]),
];
return () => disposers.forEach((d) => d());
}
+882
View File
@@ -0,0 +1,882 @@
/**
* GraphQL schema discovery (IDEA's GraphQL plugin, scoped): which files make up
* a schema, read from graphql-config files (`.graphqlrc*`, `graphql.config.*`)
* or, without one, every GraphQL file of the project ("Default project-wide
* schema"), and which endpoints a configuration declares. Pure: the store reads
* the files, this module interprets them.
*/
import type { Diagnostic } from "@/bindings";
import { tx } from "@/platform/i18n";
export const SCHEMA_EXTENSIONS = ["graphql", "graphqls", "gql"];
/** graphql-config file names, most specific first (as `languages/graphql.toml` root markers). */
export const CONFIG_NAMES = [
"graphql.config.yml",
"graphql.config.yaml",
"graphql.config.json",
"graphql.config.js",
"graphql.config.ts",
".graphqlrc",
".graphqlrc.yml",
".graphqlrc.yaml",
".graphqlrc.json",
];
/** Written by "Add Schema Configuration" (IDEA's template). */
export const CONFIG_TEMPLATE = `schema: schema.graphql
documents: '**/*.graphql'
`;
export interface Endpoint {
name: string;
url: string;
headers: Record<string, string>;
}
export interface GraphQLConfig {
/** Globs (relative to the config's folder) or URLs of introspected schemas. */
schema: string[];
endpoints: Endpoint[];
/** A JS/TS config: its contents are code and are not evaluated. */
dynamic: boolean;
/** Environment variables it uses (`${NAME}`, `${NAME:default}`). */
variables: string[];
}
/** Definitions counted the way IDEA's "Schema discovery summary" reports them. */
export interface SchemaStats {
/** Object, interface, union and scalar types. */
types: number;
inputs: number;
enums: number;
directives: number;
}
/** A named type a definition refers to, checked against the whole schema. */
export interface TypeReference {
name: string;
/** The type declaring the field (or the union / implementing type). */
owner: string;
line: number;
col: number;
}
export interface SchemaFile {
path: string;
stats: SchemaStats;
/** Type names it defines. */
defined: string[];
references: TypeReference[];
}
/** One schema: a config file's, or the project-wide default one. */
export interface SchemaProject {
key: string;
/** The config file; null for the default schema. */
configPath: string | null;
/** The folder its globs are relative to. */
root: string;
/** The config's local schema pointers (globs or files), relative to `root`. */
pointers: string[];
files: SchemaFile[];
/** Schema pointers that are URLs (introspected remotely, not read here). */
remote: string[];
endpoints: Endpoint[];
dynamic: boolean;
/** Environment variables its config uses. */
variables: string[];
}
export function isSchemaFile(name: string): boolean {
const dot = name.lastIndexOf(".");
return dot > 0 && SCHEMA_EXTENSIONS.includes(name.slice(dot + 1).toLowerCase());
}
export function isConfigFile(name: string): boolean {
return CONFIG_NAMES.includes(name);
}
// --- config parsing --------------------------------------------------------------------------
type Value = string | Value[] | { [key: string]: Value };
/** The YAML a graphql-config uses: nested maps, `-` lists, scalars and flow lists. */
export function parseYaml(text: string): Value {
const lines = text
.split(/\r?\n/)
.map((raw) => ({ indent: raw.search(/\S/), text: stripComment(raw).trim() }))
.filter((l) => l.indent >= 0 && l.text && l.text !== "---");
let i = 0;
const isItem = (t: string) => t === "-" || t.startsWith("- ");
function block(indent: number): Value {
const line = lines[i];
if (!line || line.indent < indent) return "";
return isItem(line.text) ? list(line.indent) : map(line.indent);
}
function list(indent: number): Value[] {
const out: Value[] = [];
for (let line = lines[i]; line && line.indent === indent && isItem(line.text); line = lines[i]) {
const rest = line.text.slice(1).trim();
if (!rest) {
i++;
out.push(block(indent + 1));
} else if (keyOf(rest)) {
// "- key: value" opens a map whose further keys are indented past the dash.
lines[i] = { indent: indent + 2, text: rest };
out.push(map(indent + 2));
} else {
i++;
out.push(scalar(rest));
}
}
return out;
}
function map(indent: number): Value {
const out: { [key: string]: Value } = {};
for (let line = lines[i]; line && line.indent === indent; line = lines[i]) {
const kv = keyOf(line.text);
if (!kv) break;
i++;
out[kv.key] = kv.value ? scalar(kv.value) : block(indent + 1);
}
return out;
}
return block(0);
}
function stripComment(line: string): string {
let quote: string | null = null;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (quote) {
if (c === quote) quote = null;
} else if (c === "'" || c === '"') quote = c;
else if (c === "#" && (i === 0 || /\s/.test(line[i - 1] ?? ""))) return line.slice(0, i);
}
return line;
}
function keyOf(text: string): { key: string; value: string } | null {
const m = /^("[^"]*"|'[^']*'|[^\s:'"][^:]*?)\s*:(?:\s+(.*))?$/.exec(text);
return m ? { key: unquote(m[1] ?? ""), value: m[2]?.trim() ?? "" } : null;
}
function scalar(text: string): Value {
if (text.startsWith("[") && text.endsWith("]")) {
const inner = text.slice(1, -1).trim();
return inner ? inner.split(",").map((s) => unquote(s.trim())) : [];
}
return unquote(text);
}
function unquote(s: string): string {
return s.length >= 2 && (s[0] === '"' || s[0] === "'") && s.at(-1) === s[0] ? s.slice(1, -1) : s;
}
function asStrings(v: unknown): string[] {
if (typeof v === "string") return v ? [v] : [];
if (Array.isArray(v)) return v.flatMap(asStrings);
// `schema: { "https://api/graphql": { headers } }`: the pointer is the key.
if (v && typeof v === "object") return Object.keys(v);
return [];
}
function asHeaders(v: unknown): Record<string, string> {
if (!v || typeof v !== "object" || Array.isArray(v)) return {};
return Object.fromEntries(Object.entries(v).filter((e): e is [string, string] => typeof e[1] === "string"));
}
function asEndpoints(v: unknown): Endpoint[] {
if (!v || typeof v !== "object" || Array.isArray(v)) return [];
return Object.entries(v).flatMap(([name, e]): Endpoint[] => {
if (typeof e === "string") return [{ name, url: e, headers: {} }];
if (e && typeof e === "object" && !Array.isArray(e) && typeof (e as { url?: unknown }).url === "string") {
const { url, headers } = e as { url: string; headers?: unknown };
return [{ name, url, headers: asHeaders(headers) }];
}
return [];
});
}
/** Reads a graphql-config file (graphql-config v3+, and the legacy `schemaPath`). */
export function parseConfig(name: string, text: string): GraphQLConfig {
const variables = [...new Set([...text.matchAll(ENV_VARIABLE)].map((m) => m[1] ?? ""))];
const empty = { schema: [], endpoints: [], variables };
if (/\.(js|ts)$/.test(name)) return { ...empty, dynamic: true };
let data: unknown;
try {
data = name.endsWith(".json") || /^\s*\{/.test(text) ? JSON.parse(text) : parseYaml(text);
} catch {
return { ...empty, dynamic: false };
}
if (!data || typeof data !== "object" || Array.isArray(data)) return { ...empty, dynamic: false };
const o = data as Record<string, unknown>;
const extensions = (o.extensions ?? {}) as Record<string, unknown>;
return {
schema: [...asStrings(o.schema), ...asStrings(o.schemaPath)],
endpoints: asEndpoints(extensions.endpoints),
dynamic: false,
variables,
};
}
/** graphql-config's environment variables: `${NAME}` or `${NAME:default}`. */
const ENV_VARIABLE = /\$\{([A-Za-z_]\w*)(?::([^}]*))?\}/g;
/** `text` with its `${NAME}` / `${NAME:default}` replaced from `values` (else the default, else ""). */
export function expandVariables(text: string, values: Readonly<Record<string, string>>): string {
return text.replace(ENV_VARIABLE, (_m, name: string, fallback?: string) => values[name] ?? fallback ?? "");
}
/** Variables `endpoint` uses that have neither a value nor a default: it cannot run before they are set. */
export function missingVariables(endpoint: Endpoint, values: Readonly<Record<string, string>>): string[] {
const missing = new Set<string>();
for (const text of [endpoint.url, ...Object.values(endpoint.headers)])
for (const [, name = "", fallback] of text.matchAll(ENV_VARIABLE))
if (!values[name] && fallback === undefined) missing.add(name);
return [...missing];
}
/** A response size as IDEA's result header shows it: "286 bytes", "1.7 kb". */
export function formatSize(bytes: number): string {
return bytes < 1024 ? `${bytes} bytes` : `${(bytes / 1024).toFixed(1)} kb`;
}
/** `endpoint` with its URL and headers expanded. */
export function expandEndpoint(endpoint: Endpoint, values: Readonly<Record<string, string>>): Endpoint {
return {
...endpoint,
url: expandVariables(endpoint.url, values),
headers: Object.fromEntries(Object.entries(endpoint.headers).map(([k, v]) => [k, expandVariables(v, values)])),
};
}
/** 0-based lines declaring the endpoints of a config file, for its ▶ gutter (YAML or JSON). */
export function endpointLines(name: string, text: string): Array<{ endpoint: string; line: number }> {
const endpoints = parseConfig(name, text).endpoints;
if (!endpoints.length) return [];
const lines = text.split(/\r?\n/);
const section = lines.findIndex((l) => /^\s*["']?endpoints["']?\s*:/.test(l));
if (section < 0) return [];
const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return endpoints.flatMap(({ name: endpoint }) => {
const key = new RegExp(`^\\s*["']?${escape(endpoint)}["']?\\s*:`);
const at = lines.findIndex((l, i) => i > section && key.test(l));
return at < 0 ? [] : [{ endpoint, line: at }];
});
}
// --- schema files ----------------------------------------------------------------------------
/** `**`, `*`, `?` and `{a,b}` globs, matched against a `/`-separated relative path. */
export function globToRegExp(pattern: string): RegExp {
const glob = pattern.replace(/^\.\//, "");
let re = "";
for (let i = 0; i < glob.length; i++) {
const c = glob.charAt(i);
if (c === "*" && glob[i + 1] === "*") {
i++;
if (glob[i + 1] === "/") {
i++;
re += "(?:.*/)?";
} else re += ".*";
} else if (c === "*") re += "[^/]*";
else if (c === "?") re += "[^/]";
else if (c === "{") {
const end = glob.indexOf("}", i);
if (end < 0) re += "\\{";
else {
re += `(?:${glob
.slice(i + 1, end)
.split(",")
.map((s) => s.replace(/[.+^$()|[\]\\]/g, "\\$&"))
.join("|")})`;
i = end;
}
} else re += c.replace(/[.+^$()|[\]\\]/g, "\\$&");
}
return new RegExp(`^${re}$`);
}
export function isUrl(pointer: string): boolean {
return /^https?:\/\//i.test(pointer);
}
const BUILTIN_TYPES = ["String", "Int", "Float", "Boolean", "ID"];
const DEFINITION =
/\b(extend[ \t]+)?(type|interface|enum|input|union|scalar)[ \t]+([_A-Za-z]\w*)|\bdirective[ \t]+@([_A-Za-z]\w*)/g;
/** Comments and strings blanked out, offsets kept (so positions still match `text`). */
function codeOnly(text: string): string {
const blank = (s: string) => s.replace(/[^\n]/g, " ");
return text.replace(/"""[\s\S]*?"""|"(?:[^"\\\n]|\\.)*"|#[^\n]*/g, blank);
}
/** The body of the `{ … }` block starting at `open`, or "" when there is none. */
function blockAt(code: string, open: number): { body: string; start: number } {
const close = code.indexOf("}", open);
return close < 0 ? { body: "", start: open } : { body: code.slice(open + 1, close), start: open + 1 };
}
/** Definitions and type references of a schema file (a light parse, enough for discovery). */
export function analyzeSchema(text: string): Pick<SchemaFile, "stats" | "defined" | "references"> {
const code = codeOnly(text);
const lineStarts = [0];
for (let i = 0; i < code.length; i++) if (code[i] === "\n") lineStarts.push(i + 1);
const position = (offset: number) => {
let line = 0;
while (line + 1 < lineStarts.length && (lineStarts[line + 1] ?? Infinity) <= offset) line++;
return { line, col: offset - (lineStarts[line] ?? 0) };
};
const stats: SchemaStats = { types: 0, inputs: 0, enums: 0, directives: 0 };
const defined = new Set<string>();
const references: TypeReference[] = [];
const refer = (name: string, owner: string, offset: number) => references.push({ name, owner, ...position(offset) });
for (const m of code.matchAll(DEFINITION)) {
const [whole, extend, kind, name, directive] = m;
const at = m.index ?? 0;
if (directive) {
stats.directives++;
continue;
}
if (!kind || !name) continue;
if (!extend && !defined.has(name)) {
defined.add(name);
if (kind === "input") stats.inputs++;
else if (kind === "enum") stats.enums++;
else stats.types++;
}
const after = at + whole.length;
const headerEnd = (() => {
const brace = code.indexOf("{", after);
const next = code
.slice(after)
.search(/\n[ \t]*(?:extend\b|type\b|interface\b|enum\b|input\b|union\b|scalar\b|directive\b|schema\b)/);
const nextAt = next < 0 ? code.length : after + next;
return brace >= 0 && brace < nextAt ? brace : nextAt;
})();
const header = code.slice(after, headerEnd);
if (kind === "union" || /\bimplements\b/.test(header)) {
const list =
kind === "union" ? header.slice(header.indexOf("=") + 1) : header.slice(header.indexOf("implements") + 10);
const listAt = after + header.length - list.length;
for (const r of list.matchAll(/[_A-Za-z]\w*/g)) refer(r[0], name, listAt + (r.index ?? 0));
}
if (kind === "enum" || kind === "scalar" || kind === "union" || code[headerEnd] !== "{") continue;
// Field and argument types: the name after each `:` (past `[`) in the block.
const { body, start } = blockAt(code, headerEnd);
for (const r of body.matchAll(/:\s*\[*\s*([_A-Za-z]\w*)/g)) {
const typeName = r[1] ?? "";
refer(typeName, name, start + (r.index ?? 0) + r[0].length - typeName.length);
}
}
return { stats, defined: [...defined], references };
}
/** Named types used but defined nowhere in the schema (IDEA's "Schema errors"). */
export function unresolvedTypes(project: SchemaProject): Array<{ path: string; diagnostic: Diagnostic }> {
const known = new Set([...BUILTIN_TYPES, ...project.files.flatMap((f) => f.defined)]);
return project.files.flatMap((f) =>
f.references
.filter((r) => !known.has(r.name))
.map((r) => ({
path: f.path,
diagnostic: {
range: { start: { line: r.line, col: r.col }, end: { line: r.line, col: r.col + r.name.length } },
severity: "error" as const,
message: tx`The field type '${r.name}' is not present when resolving type '${r.owner}'`,
source: "graphql",
code: null,
},
})),
);
}
export function schemaStats(project: SchemaProject): SchemaStats {
const total: SchemaStats = { types: 0, inputs: 0, enums: 0, directives: 0 };
for (const f of project.files) {
total.types += f.stats.types;
total.inputs += f.stats.inputs;
total.enums += f.stats.enums;
total.directives += f.stats.directives;
}
return total;
}
function relativeTo(root: string, path: string): string | null {
if (root === "/") return path.replace(/^\//, "");
return path.startsWith(root + "/") ? path.slice(root.length + 1) : null;
}
/**
* The schemas of a project: one per config file, its `schema` globs picking files under
* the config's folder; with no config, every GraphQL file is in the default schema.
*/
export function discoverSchemas(
root: string,
configs: ReadonlyArray<{ path: string; config: GraphQLConfig }>,
files: readonly SchemaFile[],
): SchemaProject[] {
if (!configs.length) {
return [
{
key: "default",
configPath: null,
root,
pointers: [],
files: [...files].sort((a, b) => a.path.localeCompare(b.path)),
remote: [],
endpoints: [],
dynamic: false,
variables: [],
},
];
}
return [...configs]
.sort((a, b) => a.path.localeCompare(b.path))
.map(({ path, config }) => {
const dir = path.slice(0, path.lastIndexOf("/")) || "/";
const globs = config.schema.filter((p) => !isUrl(p)).map(globToRegExp);
return {
key: path,
configPath: path,
root: dir,
pointers: config.schema.filter((p) => !isUrl(p)),
files: files
.filter((f) => {
const rel = relativeTo(dir, f.path);
return rel !== null && globs.some((g) => g.test(rel));
})
.sort((a, b) => a.path.localeCompare(b.path)),
remote: config.schema.filter(isUrl),
endpoints: config.endpoints,
dynamic: config.dynamic,
variables: config.variables,
};
});
}
// --- tool window tree ------------------------------------------------------------------------
export type SchemaNode =
| { kind: "project"; key: string; project: SchemaProject }
| { kind: "summary"; key: string; project: SchemaProject }
| { kind: "file"; key: string; file: SchemaFile }
| { kind: "remote"; key: string; url: string }
| { kind: "errors"; key: string; project: SchemaProject }
| { kind: "error"; key: string; path: string; diagnostic: Diagnostic }
| { kind: "noErrors"; key: string }
| { kind: "endpoints"; key: string; project: SchemaProject }
| { kind: "endpoint"; key: string; endpoint: Endpoint }
| { kind: "noEndpoints"; key: string; project: SchemaProject };
export function projectNodes(projects: readonly SchemaProject[]): SchemaNode[] {
return projects.map((project) => ({ kind: "project", key: project.key, project }));
}
/**
* A schema's errors: unresolved types found by discovery, and the errors and warnings
* the language server reports for its open files (the same problem only once).
*/
export function schemaProblems(
project: SchemaProject,
diagnosticsFor: (path: string) => readonly Diagnostic[],
): Array<{ path: string; diagnostic: Diagnostic }> {
const own = unresolvedTypes(project);
const seen = new Set(own.map(({ path, diagnostic: d }) => `${path}:${d.range.start.line}:${d.range.start.col}`));
const reported = project.files.flatMap((f) =>
diagnosticsFor(f.path)
.filter((d) => d.severity === "error" || d.severity === "warning")
.filter((d) => !seen.has(`${f.path}:${d.range.start.line}:${d.range.start.col}`))
.map((diagnostic) => ({ path: f.path, diagnostic })),
);
return [...own, ...reported];
}
export function childNodes(
node: SchemaNode,
diagnosticsFor: (path: string) => readonly Diagnostic[],
): SchemaNode[] | undefined {
const k = node.key;
switch (node.kind) {
case "project":
return [
{ kind: "summary", key: `${k}#summary`, project: node.project },
{ kind: "errors", key: `${k}#errors`, project: node.project },
{ kind: "endpoints", key: `${k}#endpoints`, project: node.project },
];
case "summary":
return [
...node.project.files.map((file): SchemaNode => ({ kind: "file", key: `${k}:${file.path}`, file })),
...node.project.remote.map((url): SchemaNode => ({ kind: "remote", key: `${k}:${url}`, url })),
];
case "errors": {
const problems = schemaProblems(node.project, diagnosticsFor);
if (!problems.length) return [{ kind: "noErrors", key: `${k}:none` }];
return problems.map(({ path, diagnostic: d }, i) => ({
kind: "error",
key: `${k}:${i}:${path}:${d.range.start.line}:${d.range.start.col}`,
path,
diagnostic: d,
}));
}
case "endpoints":
if (!node.project.endpoints.length) return [{ kind: "noEndpoints", key: `${k}:none`, project: node.project }];
return node.project.endpoints.map((endpoint) => ({ kind: "endpoint", key: `${k}:${endpoint.name}`, endpoint }));
default:
return undefined;
}
}
// --- query execution -------------------------------------------------------------------------
/** A top-level definition of an executable document. */
export interface Definition {
kind: "operation" | "fragment" | "other";
name: string | null;
from: number;
to: number;
}
/** Top-level definitions of `doc`, found by brace matching outside comments and strings. */
export function topLevelDefinitions(doc: string): Definition[] {
const code = codeOnly(doc);
const out: Definition[] = [];
let i = 0;
while (i < code.length) {
while (i < code.length && /[\s,]/.test(code.charAt(i))) i++;
if (i >= code.length) break;
const from = i;
const open = code.indexOf("{", i);
if (open < 0) break;
let depth = 0;
let j = open;
for (; j < code.length; j++) {
if (code[j] === "{") depth++;
else if (code[j] === "}" && --depth === 0) break;
}
const to = Math.min(j + 1, code.length);
const header = code.slice(from, open).trim();
const m = /^(query|mutation|subscription|fragment)\b\s*([_A-Za-z]\w*)?/.exec(header);
const kind = !header || (m && m[1] !== "fragment") ? "operation" : m ? "fragment" : "other";
out.push({ kind, name: m?.[2] ?? null, from, to });
i = to;
}
return out;
}
export interface QueryContext {
/** What is sent. */
text: string;
/** The parts of the document it comes from (the rest is greyed out, as in IDEA). */
ranges: Array<{ from: number; to: number }>;
}
/**
* What Execute GraphQL sends, as IDEA's GraphQL plugin picks it: the selection; else the
* operation around the caret with the fragments it uses; else the whole document.
*/
export function queryContext(doc: string, selection: { from: number; to: number } | null): QueryContext {
if (selection && selection.to > selection.from)
return { text: doc.slice(selection.from, selection.to).trim(), ranges: [selection] };
const whole = { text: doc.trim(), ranges: [{ from: 0, to: doc.length }] };
const defs = topLevelDefinitions(doc);
const caret = selection?.from ?? -1;
const op = defs.find((d) => d.kind === "operation" && d.from <= caret && caret <= d.to);
if (!op) return whole;
const code = codeOnly(doc);
const fragments = new Map(defs.filter((d) => d.kind === "fragment" && d.name).map((d) => [d.name!, d]));
const used = new Set<string>();
const visit = (d: Definition) => {
for (const s of code.slice(d.from, d.to).matchAll(/\.\.\.\s*([_A-Za-z]\w*)/g)) {
const name = s[1] ?? "";
const fragment = fragments.get(name);
if (name === "on" || !fragment || used.has(name)) continue;
used.add(name);
visit(fragment);
}
};
visit(op);
const sent = defs.filter((d) => d === op || (d.kind === "fragment" && used.has(d.name ?? "")));
return {
text: sent.map((d) => doc.slice(d.from, d.to)).join("\n\n"),
ranges: sent.map(({ from, to }) => ({ from, to })),
};
}
export function queryText(doc: string, selection: { from: number; to: number } | null): string {
return queryContext(doc, selection).text;
}
/** The parts of `[0, length)` outside `ranges` (sorted, non-overlapping). */
export function outsideRanges(
length: number,
ranges: ReadonlyArray<{ from: number; to: number }>,
): Array<{ from: number; to: number }> {
const out: Array<{ from: number; to: number }> = [];
let pos = 0;
for (const r of [...ranges].sort((a, b) => a.from - b.from)) {
if (r.from > pos) out.push({ from: pos, to: r.from });
pos = Math.max(pos, r.to);
}
if (pos < length) out.push({ from: pos, to: length });
return out;
}
/** Errors in a GraphQL response body (`errors` array), 0 when it is not a GraphQL response. */
export function responseErrorCount(body: string | null): number {
if (!body) return 0;
try {
const errors = (JSON.parse(body) as { errors?: unknown }).errors;
return Array.isArray(errors) ? errors.length : 0;
} catch {
return 0;
}
}
/** An `.http` request posting `query` (and `variables`) to `endpoint`, for the HTTP client to execute. */
export function httpRequestText(
endpoint: Endpoint,
query: string,
variables: Record<string, unknown> | null = null,
): string {
const headers = { "Content-Type": "application/json", ...endpoint.headers };
return [
`POST ${endpoint.url}`,
...Object.entries(headers).map(([k, v]) => `${k}: ${v}`),
"",
jsonWithoutTemplates(variables ? { query, variables } : { query }),
"",
].join("\n");
}
/**
* JSON the HTTP client sends as written: it expands `{{name}}`, which a GraphQL
* selection (`{ a { b }}`) can contain, so braces inside strings are escaped.
*/
export function jsonWithoutTemplates(value: unknown): string {
return JSON.stringify(value).replace(/"(?:[^"\\]|\\.)*"/g, (s) =>
s.replace(/[{}]/g, (c) => (c === "{" ? "\\u007b" : "\\u007d")),
);
}
/** The variables editor's text: empty, or a JSON object. */
export function parseVariables(text: string): { variables: Record<string, unknown> | null } | { error: string } {
if (!text.trim()) return { variables: null };
try {
const v: unknown = JSON.parse(text);
if (v && typeof v === "object" && !Array.isArray(v)) return { variables: v as Record<string, unknown> };
} catch (e) {
return { error: e instanceof Error ? e.message : String(e) };
}
return { error: "Variables must be a JSON object" };
}
/** Pretty-prints a JSON body; other bodies are shown as received. */
export function formatBody(body: string | null): string {
if (body === null) return "";
try {
return JSON.stringify(JSON.parse(body), null, 2);
} catch {
return body;
}
}
// --- introspection ---------------------------------------------------------------------------
/** graphql-js `getIntrospectionQuery()` with descriptions. */
export const INTROSPECTION_QUERY = `query IntrospectionQuery {
__schema {
queryType { name }
mutationType { name }
subscriptionType { name }
types { ...FullType }
directives { name description locations args { ...InputValue } }
}
}
fragment FullType on __Type {
kind name description
fields(includeDeprecated: true) {
name description args { ...InputValue } type { ...TypeRef } isDeprecated deprecationReason
}
inputFields { ...InputValue }
interfaces { ...TypeRef }
enumValues(includeDeprecated: true) { name description isDeprecated deprecationReason }
possibleTypes { ...TypeRef }
}
fragment InputValue on __InputValue { name description type { ...TypeRef } defaultValue }
fragment TypeRef on __Type {
kind name ofType { kind name ofType { kind name ofType { kind name ofType {
kind name ofType { kind name ofType { kind name ofType { kind name } } } } } } }
}`;
interface TypeRef {
kind: string;
name: string | null;
ofType?: TypeRef | null;
}
interface InputValue {
name: string;
description?: string | null;
type: TypeRef;
defaultValue?: string | null;
}
interface Deprecation {
isDeprecated?: boolean;
deprecationReason?: string | null;
}
interface Field extends Deprecation {
name: string;
description?: string | null;
args?: InputValue[];
type: TypeRef;
}
interface EnumValue extends Deprecation {
name: string;
description?: string | null;
}
interface FullType {
kind: string;
name: string;
description?: string | null;
fields?: Field[] | null;
inputFields?: InputValue[] | null;
interfaces?: TypeRef[] | null;
enumValues?: EnumValue[] | null;
possibleTypes?: TypeRef[] | null;
}
interface IntrospectionSchema {
queryType?: { name: string } | null;
mutationType?: { name: string } | null;
subscriptionType?: { name: string } | null;
types: FullType[];
directives?: Array<{ name: string; description?: string | null; locations: string[]; args?: InputValue[] }>;
}
const BUILTIN_SCALARS = new Set(["String", "Int", "Float", "Boolean", "ID"]);
/** Spec directives every server has; `@oneOf` is printed, as IDEA does. */
const BUILTIN_DIRECTIVES = new Set(["include", "skip", "deprecated", "specifiedBy"]);
/** First line of a schema file written by "Get GraphQL Schema from Endpoint" (IDEA's). */
export const GENERATED_HEADER = "# This file was generated. Do not edit manually.";
/** IDEA (graphql-java's SchemaPrinter) indents by four spaces. */
const INDENT = " ";
/** IDEA prints types grouped by kind, each group by name. */
const KIND_ORDER = ["INTERFACE", "UNION", "OBJECT", "ENUM", "SCALAR", "INPUT_OBJECT"];
const byName = <T extends { name: string }>(list: readonly T[] | null | undefined): T[] =>
[...(list ?? [])].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
function printTypeRef(t: TypeRef): string {
if (t.kind === "NON_NULL" && t.ofType) return `${printTypeRef(t.ofType)}!`;
if (t.kind === "LIST" && t.ofType) return `[${printTypeRef(t.ofType)}]`;
return t.name ?? "";
}
function printDescription(text: string | null | undefined, indent: string): string {
if (!text) return "";
if (!text.includes("\n")) return `${indent}${JSON.stringify(text)}\n`;
const body = text
.replace(/"""/g, '\\"""')
.split("\n")
.map((l) => (l ? indent + l : l))
.join("\n");
return `${indent}"""\n${body}\n${indent}"""\n`;
}
function printDeprecated(d: Deprecation): string {
if (!d.isDeprecated) return "";
return d.deprecationReason && d.deprecationReason !== "No longer supported"
? ` @deprecated(reason: ${JSON.stringify(d.deprecationReason)})`
: " @deprecated";
}
function printInputValue(v: InputValue): string {
return `${v.name}: ${printTypeRef(v.type)}${v.defaultValue != null ? ` = ${v.defaultValue}` : ""}`;
}
function printArgs(list: InputValue[] | undefined): string {
return list?.length ? `(${list.map(printInputValue).join(", ")})` : "";
}
function printBlock(lines: string[]): string {
return lines.length ? ` {\n${lines.join("\n")}\n}` : "";
}
function printType(t: FullType): string {
const head = printDescription(t.description, "");
switch (t.kind) {
case "SCALAR":
return `${head}scalar ${t.name}`;
case "OBJECT":
case "INTERFACE": {
const impl = t.interfaces?.length ? ` implements ${t.interfaces.map(printTypeRef).join(" & ")}` : "";
const fields = byName(t.fields).map(
(f) =>
`${printDescription(f.description, INDENT)}${INDENT}${f.name}${printArgs(f.args)}: ${printTypeRef(f.type)}${printDeprecated(f)}`,
);
return `${head}${t.kind === "OBJECT" ? "type" : "interface"} ${t.name}${impl}${printBlock(fields)}`;
}
case "UNION":
return `${head}union ${t.name} = ${(t.possibleTypes ?? []).map(printTypeRef).join(" | ")}`;
case "ENUM":
return `${head}enum ${t.name}${printBlock(
(t.enumValues ?? []).map(
(v) => `${printDescription(v.description, INDENT)}${INDENT}${v.name}${printDeprecated(v)}`,
),
)}`;
case "INPUT_OBJECT":
return `${head}input ${t.name}${printBlock(
byName(t.inputFields).map((v) => `${printDescription(v.description, INDENT)}${INDENT}${printInputValue(v)}`),
)}`;
default:
return "";
}
}
/**
* The schema file "Get GraphQL Schema from Endpoint" writes for an introspection response
* body (`{"data":{"__schema":…}}`), laid out as IDEA writes it. Throws when there is no schema.
*/
export function introspectionToSdl(body: string): string {
const json = JSON.parse(body) as { data?: { __schema?: IntrospectionSchema }; errors?: Array<{ message: string }> };
const schema = json.data?.__schema;
if (!schema) throw new Error(json.errors?.map((e) => e.message).join("\n") || "The response has no __schema");
const roots: Array<[string, string | undefined]> = [
["query", schema.queryType?.name],
["mutation", schema.mutationType?.name],
["subscription", schema.subscriptionType?.name],
];
const ops = roots.filter(([, name]) => name).map(([op, name]) => `${INDENT}${op}: ${name}`);
const parts = [`schema {\n${ops.join("\n")}\n}`];
for (const d of byName(schema.directives)) {
if (BUILTIN_DIRECTIVES.has(d.name)) continue;
parts.push(
`${printDescription(d.description, "")}directive @${d.name}${printArgs(d.args)} on ${d.locations.join(" | ")}`,
);
}
const types = schema.types.filter(
(t) => !t.name.startsWith("__") && !(t.kind === "SCALAR" && BUILTIN_SCALARS.has(t.name)),
);
for (const kind of KIND_ORDER) for (const t of byName(types.filter((x) => x.kind === kind))) parts.push(printType(t));
return `${GENERATED_HEADER}\n\n${parts.filter(Boolean).join("\n\n")}\n`;
}
/**
* Where "Get GraphQL Schema from Endpoint" writes (IDEA): the config's first schema
* pointer naming a file, else `schema.graphql` next to the config.
*/
export function introspectionTarget(project: SchemaProject): string {
const file = project.pointers.find((p) => !/[*?{[]/.test(p)) ?? "schema.graphql";
const base = project.root === "/" ? "" : project.root;
return `${base}/${file.replace(/^\.\//, "")}`;
}
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { _addEditor, _removeEditor, type TextEditorHandle } from "@/editor/api";
let onOpen: (path: string) => void = () => {};
const openFile = vi.fn(async (path: string) => onOpen(path));
vi.mock("@/workbench/contributions", async (orig) => {
const actual = await orig<typeof import("@/workbench/contributions")>();
return { ...actual, editorService: () => ({ openFile }) };
});
const { replaceFileText } = await import("./store");
function editorOf(path: string, text: string): TextEditorHandle {
const view = new EditorView({ state: EditorState.create({ doc: text }) });
return { view, ctx: { path } as TextEditorHandle["ctx"] };
}
describe("replaceFileText", () => {
it("replaces the text once the tab just opened has created its editor", async () => {
const editor = editorOf("/p/schema.graphql", "type Query { a: Int }");
// The tab mounts its editor after openFile resolves, as for a file that was not open.
onOpen = () => void setTimeout(() => _addEditor(editor), 10);
try {
expect(await replaceFileText("/p/schema.graphql", "type Query { b: Int }")).toBe(true);
expect(editor.view.state.doc.toString()).toBe("type Query { b: Int }");
} finally {
_removeEditor(editor);
editor.view.destroy();
}
});
it("reports when no editor shows the file", async () => {
onOpen = () => {};
expect(await replaceFileText("/p/missing.graphql", "x", 50)).toBe(false);
});
});
+507
View File
@@ -0,0 +1,507 @@
/**
* GraphQL state: discovered schemas, the tool window's tab and last query result, and
* per file the endpoint and variables the editor toolbar runs queries with.
*/
import { create } from "zustand";
import { editorsFor, useEditorRegistry, type TextEditorHandle } from "@/editor/api";
import { setFileDiagnostics } from "@/editor/lsp/diagnostics";
import { tx } from "@/platform/i18n";
import type { Diagnostic } from "@/bindings";
import { call, commands, isCancelled, nextRequestId, type RequestId } from "@/platform/ipc";
import { basename, dirname, useProject } from "@/platform/project";
import { editorService, notifications, toolWindowService } from "@/workbench/contributions";
import { confirm, showError } from "@/workbench/messages";
import {
CONFIG_NAMES,
CONFIG_TEMPLATE,
GENERATED_HEADER,
INTROSPECTION_QUERY,
analyzeSchema,
discoverSchemas,
expandEndpoint,
missingVariables,
httpRequestText,
introspectionTarget,
introspectionToSdl,
isConfigFile,
isSchemaFile,
parseConfig,
parseVariables,
unresolvedTypes,
type Endpoint,
type SchemaFile,
type SchemaProject,
} from "./model";
export const TOOL_WINDOW = "graphql";
export type GraphQLTab = "schemas" | "result";
export interface QueryResult {
/** The GraphQL file the query came from. */
path: string;
endpoint: Endpoint;
status: number | null;
totalMs: number | null;
body: string | null;
/** Response size in bytes. */
size: number | null;
error: string | null;
}
interface GraphQLState {
tab: GraphQLTab;
/** null until the first discovery of the open project. */
projects: SchemaProject[] | null;
discovering: boolean;
/** Expanded tree rows; null: the default (schemas and their sections open). */
expanded: Set<string> | null;
result: QueryResult | null;
running: RequestId | null;
/** Endpoint chosen in the editor toolbar, by schema key. */
endpointBySchema: Record<string, string>;
/** Files whose editor shows the variables editor. */
variablesShown: ReadonlySet<string>;
/** Variables editor text (JSON), by file. */
variables: Record<string, string>;
/** The last introspection, for "Rerun Introspection Query". */
lastIntrospection: { path: string; endpoint: string } | null;
/** The open GraphQL Environment Variables dialog; `done` hears whether it was confirmed. */
envDialog: { configPath: string; variables: string[]; done?: (ok: boolean) => void } | null;
}
const INITIAL: GraphQLState = {
tab: "schemas",
projects: null,
discovering: false,
expanded: null,
result: null,
running: null,
endpointBySchema: {},
variablesShown: new Set(),
variables: {},
lastIntrospection: null,
envDialog: null,
};
export const useGraphQL = create<GraphQLState>(() => ({ ...INITIAL }));
export function resetGraphQL() {
publishSchemaDiagnostics([]);
useGraphQL.setState({ ...INITIAL });
}
// --- schema errors in editors ------------------------------------------------------------------
const DIAGNOSTICS_SOURCE = "graphql-schema";
let published = new Set<string>();
/** Shows discovery's schema errors (unresolved types) in editors and Problems, as IDEA does. */
function publishSchemaDiagnostics(projects: readonly SchemaProject[]) {
const byPath = new Map<string, Diagnostic[]>();
for (const p of projects)
for (const { path, diagnostic } of unresolvedTypes(p)) {
const list = byPath.get(path) ?? [];
list.push(diagnostic);
byPath.set(path, list);
}
for (const path of published) if (!byPath.has(path)) setFileDiagnostics(path, DIAGNOSTICS_SOURCE, []);
for (const [path, items] of byPath) setFileDiagnostics(path, DIAGNOSTICS_SOURCE, items);
published = new Set(byPath.keys());
}
function setProjects(projects: SchemaProject[]) {
useGraphQL.setState({ projects });
publishSchemaDiagnostics(projects);
}
/** Re-checks a schema file from its editor text while it is edited (not yet saved). */
export function revalidateFile(path: string, text: string) {
const projects = useGraphQL.getState().projects;
if (!projects?.some((p) => p.files.some((f) => f.path === path))) return;
const file: SchemaFile = { path, ...analyzeSchema(text) };
setProjects(projects.map((p) => ({ ...p, files: p.files.map((f) => (f.path === path ? file : f)) })));
}
// --- environment variables ---------------------------------------------------------------------
const ENV_KEY = "openidea.graphql.environment";
function readEnvironment(): Record<string, Record<string, string>> {
try {
const raw = localStorage.getItem(ENV_KEY);
return raw ? (JSON.parse(raw) as Record<string, Record<string, string>>) : {};
} catch {
return {};
}
}
export function environmentOf(configPath: string | null): Record<string, string> {
return configPath ? (readEnvironment()[configPath] ?? {}) : {};
}
export function saveEnvironment(configPath: string, values: Record<string, string>) {
try {
localStorage.setItem(ENV_KEY, JSON.stringify({ ...readEnvironment(), [configPath]: values }));
} catch {
// Storage unavailable (private window): the values are not kept.
}
}
/** Opens IDEA's GraphQL Environment Variables dialog for a config; resolves true once confirmed. */
function requestEnvironment(schema: SchemaProject): Promise<boolean> {
const configPath = schema.configPath;
if (!configPath || !schema.variables.length) return Promise.resolve(false);
useGraphQL.getState().envDialog?.done?.(false);
return new Promise((done) =>
useGraphQL.setState({ envDialog: { configPath, variables: [...schema.variables].sort(), done } }),
);
}
/** "Edit Environment Variables": the `${NAME}` variables of `path`'s config, in IDEA's dialog. */
export async function editEnvironmentVariables(path: string): Promise<void> {
const schema = schemaOf(await ensureDiscovered(), path);
if (schema) await requestEnvironment(schema);
}
export function closeEnvironmentDialog(ok = false) {
const done = useGraphQL.getState().envDialog?.done;
useGraphQL.setState({ envDialog: null });
done?.(ok);
}
/**
* Before running against `endpoint`: when it uses variables with no value (and no default),
* asks for them as IDEA does. False when the dialog is cancelled.
*/
async function ensureEnvironment(endpoint: Endpoint): Promise<boolean> {
const schema = (useGraphQL.getState().projects ?? []).find((p) => p.endpoints.includes(endpoint));
if (!schema?.configPath || !missingVariables(endpoint, environmentOf(schema.configPath)).length) return true;
return requestEnvironment(schema);
}
/** Files are found through the file index's fuzzy search, then matched exactly. */
const FILE_QUERIES = [".graphql", ".gql", "graphqlrc", "graphql.config"];
const FILE_LIMIT = 10000;
/** Schema files read for discovery; more are listed without their definitions. */
const READ_LIMIT = 500;
async function readText(path: string): Promise<string | null> {
try {
return await call(commands.fsReadText(path));
} catch {
return null;
}
}
let generation = 0;
/** (Re)discovers the open project's schemas ("Restart Schema Discovery"). */
export async function discoverSchemasNow(): Promise<void> {
const root = useProject.getState().project?.root;
if (!root) return;
const run = ++generation;
useGraphQL.setState({ discovering: true });
try {
const hits = await Promise.all(FILE_QUERIES.map((q) => call(commands.findFiles(q, FILE_LIMIT))));
const paths = [...new Set(hits.flat().map((m) => m.path))];
const schemaPaths = paths.filter((p) => isSchemaFile(basename(p)));
const configPaths = paths.filter((p) => isConfigFile(basename(p)));
const [files, configs] = await Promise.all([
Promise.all(
schemaPaths.map(async (path, i): Promise<SchemaFile> => {
const text = i < READ_LIMIT ? await readText(path) : null;
return { path, ...analyzeSchema(text ?? "") };
}),
),
Promise.all(
configPaths.map(async (path) => ({ path, config: parseConfig(basename(path), (await readText(path)) ?? "") })),
),
]);
if (run !== generation) return;
setProjects(discoverSchemas(root, configs, files));
} catch (e) {
if (run === generation && !isCancelled(e))
notifications().notify({ level: "error", title: "GraphQL schema discovery failed", body: String(e) });
} finally {
if (run === generation) useGraphQL.setState({ discovering: false });
}
}
async function ensureDiscovered(): Promise<SchemaProject[]> {
if (!useGraphQL.getState().projects) await discoverSchemasNow();
return useGraphQL.getState().projects ?? [];
}
/** The configuration a toolbar or tool window acts on: the file's schema's, else the first one. */
export function configurationFor(projects: readonly SchemaProject[] | null, path?: string): string | null {
if (!projects) return null;
return (path ? schemaOf(projects, path)?.configPath : null) ?? projects.find((p) => p.configPath)?.configPath ?? null;
}
/** "Edit GraphQL Configuration": opens the configuration `path` (or the project) uses. */
export async function openConfiguration(path?: string): Promise<void> {
const config = configurationFor(await ensureDiscovered(), path);
if (config) await editorService().openFile(config);
}
/** Opens the project's graphql-config, creating IDEA's template when there is none. */
export async function addSchemaConfiguration(): Promise<void> {
const root = useProject.getState().project?.root;
if (!root) return;
const existing = configurationFor(await ensureDiscovered());
if (existing) return editorService().openFile(existing);
const path = `${root}/${CONFIG_NAMES[0]}`;
try {
await call(commands.fsCreateFileBytes(path, [...new TextEncoder().encode(CONFIG_TEMPLATE)]));
} catch (e) {
notifications().notify({ level: "error", title: "Cannot create the GraphQL configuration", body: String(e) });
return;
}
await editorService().openFile(path);
await discoverSchemasNow();
}
export function showGraphQL(tab: GraphQLTab) {
useGraphQL.setState({ tab });
toolWindowService().show(TOOL_WINDOW, true);
}
/** The schema `path` belongs to: one listing it, else the closest config folder above it. */
export function schemaOf(projects: readonly SchemaProject[], path: string): SchemaProject | null {
const listed = projects.find((p) => p.files.some((f) => f.path === path));
if (listed) return listed;
const dir = dirname(path);
return (
projects
.filter((p) => p.configPath && (dir === p.root || dir.startsWith(p.root + "/")))
.sort((a, b) => b.root.length - a.root.length)[0] ?? null
);
}
/** The endpoint the toolbar of `path` runs against: the chosen one, else the schema's first. */
export function endpointFor(state: Pick<GraphQLState, "projects" | "endpointBySchema">, path: string) {
const schema = state.projects ? schemaOf(state.projects, path) : null;
if (!schema) return null;
const chosen = state.endpointBySchema[schema.key];
return schema.endpoints.find((e) => e.name === chosen) ?? schema.endpoints[0] ?? null;
}
export function chooseEndpoint(path: string, name: string) {
useGraphQL.setState((s) => {
const schema = s.projects ? schemaOf(s.projects, path) : null;
return schema ? { endpointBySchema: { ...s.endpointBySchema, [schema.key]: name } } : s;
});
}
export function toggleVariables(path: string) {
useGraphQL.setState((s) => {
const shown = new Set(s.variablesShown);
if (!shown.delete(path)) shown.add(path);
return { variablesShown: shown };
});
}
export function setVariables(path: string, text: string) {
useGraphQL.setState((s) => ({ variables: { ...s.variables, [path]: text } }));
}
function noEndpoint() {
notifications().notify({
level: "info",
title: "No GraphQL endpoint",
body: "Declare one under extensions.endpoints in graphql.config.yml.",
key: "graphql-run",
});
}
/** `configured` with its config's environment variables expanded. */
function resolveEndpoint(configured: Endpoint): Endpoint {
const projects = useGraphQL.getState().projects ?? [];
const config = projects.find((p) => p.endpoints.includes(configured))?.configPath ?? null;
return expandEndpoint(configured, environmentOf(config));
}
/** IDEA's "GraphQL error" balloon for a request that failed, with Retry and Show error. */
function notifyRequestFailed(configured: Endpoint, error: string, retry: () => void) {
const url = resolveEndpoint(configured).url;
notifications().notify({
level: "warning",
title: "GraphQL error",
body: tx`GraphQL request to ${url} failed.`,
actions: [
{ label: "Retry", run: retry },
{ label: "Show error", run: () => void showError(tx`GraphQL request to ${url} failed.`, error) },
],
});
}
async function send(configured: Endpoint, query: string, variables: Record<string, unknown> | null) {
const endpoint = resolveEndpoint(configured);
const busy = useGraphQL.getState().running;
if (busy) void commands.cancelRequest(busy);
const request = nextRequestId();
useGraphQL.setState({ running: request });
try {
return await call(commands.httpExecute(request, null, httpRequestText(endpoint, query, variables), 0, null));
} finally {
if (useGraphQL.getState().running === request) useGraphQL.setState({ running: null });
}
}
/** "Execute GraphQL": runs `query` from `path` with its variables; the response shows in "Query Result". */
export async function runQuery(path: string, query: string): Promise<void> {
if (!query) return;
await ensureDiscovered();
const endpoint = endpointFor(useGraphQL.getState(), path);
if (!endpoint) return noEndpoint();
if (!(await ensureEnvironment(endpoint))) return;
const parsed = parseVariables(useGraphQL.getState().variables[path] ?? "");
if ("error" in parsed) {
notifications().notify({
level: "error",
title: "Invalid GraphQL variables",
body: parsed.error,
key: "graphql-run",
});
return;
}
const retry = () => void runQuery(path, query);
try {
const ex = await send(endpoint, query, parsed.variables);
if (ex.entry.error) notifyRequestFailed(endpoint, ex.entry.error, retry);
useGraphQL.setState({
result: {
path,
endpoint,
status: ex.response?.status ?? null,
totalMs: ex.response?.totalMs ?? ex.entry.totalMs,
body: ex.response?.body ?? null,
size: ex.response?.size ?? null,
error: ex.entry.error,
},
});
} catch (e) {
if (isCancelled(e)) return;
notifyRequestFailed(endpoint, String(e), retry);
useGraphQL.setState({
result: { path, endpoint, status: null, totalMs: null, body: null, size: null, error: String(e) },
});
}
showGraphQL("result");
}
/**
* "Get GraphQL Schema from Endpoint": introspects an endpoint (`name`, else the one chosen for
* `path`) and writes the schema as SDL to the config's schema file. An existing file's text is
* replaced in its editor (undoable); one not written this way is only replaced once confirmed.
*/
export async function introspectEndpoint(path: string, name?: string): Promise<void> {
const projects = await ensureDiscovered();
const schema = schemaOf(projects, path);
const endpoint =
(name ? schema?.endpoints.find((e) => e.name === name) : null) ?? endpointFor(useGraphQL.getState(), path);
if (!schema || !endpoint) return noEndpoint();
if (!(await ensureEnvironment(endpoint))) return;
useGraphQL.setState({ lastIntrospection: { path, endpoint: endpoint.name } });
const target = introspectionTarget(schema);
let sdl: string;
try {
const ex = await send(endpoint, INTROSPECTION_QUERY, null);
if (ex.entry.error || !ex.response?.body) throw new Error(ex.entry.error ?? `HTTP ${ex.response?.status ?? "?"}`);
sdl = introspectionToSdl(ex.response.body);
} catch (e) {
if (!isCancelled(e))
notifyRequestFailed(
endpoint,
e instanceof Error ? e.message : String(e),
() => void introspectEndpoint(path, name),
);
return;
}
const current = await readText(target);
if (current !== null) {
const generated = current.startsWith(GENERATED_HEADER);
const ok =
generated ||
(await confirm({
title: "Get GraphQL Schema from Endpoint",
message: tx`Replace the contents of ${basename(target)} with the schema of ${endpoint.name}?`,
ok: "Replace",
}));
if (!ok) return;
if (!(await replaceFileText(target, sdl))) {
notifications().notify({
level: "error",
title: "Cannot write the GraphQL schema",
body: tx`${basename(target)} did not open in an editor`,
});
return;
}
} else {
try {
await call(commands.fsCreateFileBytes(target, [...new TextEncoder().encode(sdl)]));
} catch (e) {
notifications().notify({ level: "error", title: "Cannot write the GraphQL schema", body: String(e) });
return;
}
await editorService().openFile(target);
}
notifications().notify({
level: "info",
title: "GraphQL schema updated",
body: `${basename(target)} ← ${endpoint.url}`,
key: "graphql-introspect",
});
await discoverSchemasNow();
}
/**
* Opens `path` and replaces its text in the editor (one undoable edit). A file that was not open
* gets its editor only after `openFile` resolves, so this waits for it; false when none appears.
*/
export async function replaceFileText(path: string, text: string, timeoutMs = 5000): Promise<boolean> {
await editorService().openFile(path);
const editor =
editorsFor(path)[0] ??
(await new Promise<TextEditorHandle | null>((resolve) => {
const done = (e: TextEditorHandle | null) => {
clearTimeout(timer);
unsubscribe();
resolve(e);
};
const timer = setTimeout(() => done(null), timeoutMs);
const unsubscribe = useEditorRegistry.subscribe(() => {
const e = editorsFor(path)[0];
if (e) done(e);
});
}));
if (!editor) return false;
editor.view.dispatch({ changes: { from: 0, to: editor.view.state.doc.length, insert: text } });
return true;
}
/** "Rerun Introspection Query": the last introspection of this schema, else its chosen endpoint. */
export async function rerunIntrospection(path: string): Promise<void> {
const projects = await ensureDiscovered();
const last = useGraphQL.getState().lastIntrospection;
const same = last && schemaOf(projects, last.path) === schemaOf(projects, path);
await introspectEndpoint(path, same ? last.endpoint : undefined);
}
/** "Open Introspection Schema": the schema file introspection writes for `path`'s schema. */
export async function openIntrospectionSchema(path: string): Promise<void> {
const schema = schemaOf(await ensureDiscovered(), path);
if (!schema) return;
const target = introspectionTarget(schema);
if ((await readText(target)) === null) {
notifications().notify({
level: "info",
title: "No introspection schema yet",
body: "Run Introspection Query on an endpoint to generate it.",
key: "graphql-introspect",
});
return;
}
await editorService().openFile(target);
}
+2
View File
@@ -44,6 +44,7 @@ import { activate as services } from "./services";
import { activate as github } from "./github";
import { activate as httpClient } from "./http-client";
import { activate as database } from "./database";
import { activate as graphql } from "./graphql";
import { activate as deployment, activateDeploymentWindow } from "./deployment";
import { activate as agents, activateSettingsPages as agentsPages } from "./agents";
import { activate as vim, activateSettingsPages as vimPages } from "./vim";
@@ -88,6 +89,7 @@ const features: Array<[string, Activate]> = [
["github", github],
["http-client", httpClient],
["database", database],
["graphql", graphql],
["deployment", deployment],
["agents", agents],
["vim", vim],
+1
View File
@@ -346,6 +346,7 @@ export const intellijMacos: Record<string, string[]> = {
"markdown.previewOnly": [],
// HTTP Client / Database console: run the request or statement at the caret.
"http.runRequest": ["Meta+Enter"],
"graphql.runQuery": ["Meta+Enter"],
"http.runAll": [],
"db.execute": ["Meta+Enter"],
"db.showToolWindow": [],
+1
View File
@@ -344,6 +344,7 @@ export const intellijWindows: Record<string, string[]> = {
"markdown.previewOnly": [],
// HTTP Client / Database console: run the request or statement at the caret.
"http.runRequest": ["Ctrl+Enter"],
"graphql.runQuery": ["Ctrl+Enter"],
"http.runAll": [],
"db.execute": ["Ctrl+Enter"],
"db.showToolWindow": [],
+1
View File
@@ -339,6 +339,7 @@ export const vscodeMacos: Record<string, string[]> = {
"diff.jumpToSource": [],
// HTTP Client / Database console: run the request or statement at the caret.
"http.runRequest": ["Meta+Enter"],
"graphql.runQuery": ["Meta+Enter"],
"http.runAll": [],
"db.execute": ["Meta+Enter"],
"db.showToolWindow": [],
+1 -1
View File
@@ -74,7 +74,7 @@ function actionTitles(): Map<string, string> {
const titles = actionTitles();
/** Product names shown as they are in every language. */
const PROPER_NAMES = new Set(["Vim"]);
const PROPER_NAMES = new Set(["GraphQL", "Vim"]);
it("has a Chinese entry for every literal action title, including action factory arguments", () => {
// Ensure the helper-argument regression cannot disappear through a scanner change.
+57
View File
@@ -5856,6 +5856,63 @@
"Copy {addr}": "复制 {addr}",
"Stop Forwarding Port {remotePort}": "停止转发端口 {remotePort}",
"Ports": "端口",
"GraphQL views": "GraphQL 视图",
"Schemas and Project Structure": "架构和项目结构",
"Query Result": "查询结果",
"Default project-wide schema": "默认项目范围架构",
"the configuration is code and is not evaluated": "配置为代码,不会被求值",
"schema is empty": "架构为空",
"Schema discovery summary": "架构发现摘要",
"Schema errors": "架构错误",
"No errors found": "未发现错误",
"Endpoints": "端点",
"No endpoints available in this schema": "此架构中没有可用的端点",
"No endpoints available in the default schema": "默认架构中没有可用的端点",
"GraphQL actions": "GraphQL 操作",
"Discovering GraphQL schemas": "正在发现 GraphQL 架构",
"GraphQL schemas": "GraphQL 架构",
"Running GraphQL query": "正在运行 GraphQL 查询",
"No query results": "没有查询结果",
"Add Schema Configuration": "添加架构配置",
"Open the project's graphql-config, creating graphql.config.yml when there is none": "打开项目的 graphql-config,没有时创建 graphql.config.yml",
"Restart Schema Discovery": "重新发现架构",
"Find the project's GraphQL schemas and configurations again": "重新查找项目的 GraphQL 架构和配置",
"GraphQL schema discovery failed": "GraphQL 架构发现失败",
"Cannot create the GraphQL configuration": "无法创建 GraphQL 配置",
"No GraphQL endpoint": "没有 GraphQL 端点",
"Declare one under extensions.endpoints in graphql.config.yml.": "请在 graphql.config.yml 的 extensions.endpoints 下声明端点。",
"The field type '{name}' is not present when resolving type '{owner}'": "解析类型 '{owner}' 时,字段类型 '{name}' 不存在",
"GraphQL editor actions": "GraphQL 编辑器操作",
"Open Configuration File": "打开配置文件",
"Toggle Variables Editor": "切换变量编辑器",
"GraphQL endpoint": "GraphQL 端点",
"No endpoints": "没有端点",
"Execute GraphQL": "执行 GraphQL",
"Rerun Introspection Query": "重新运行内省查询",
"Open Introspection Schema": "打开内省架构",
"GraphQL variables": "GraphQL 变量",
"{types} types, {inputs} inputs, {enums} enums, {directives} directives": "{types} 个类型,{inputs} 个输入,{enums} 个枚举,{directives} 个指令",
"Run a query with Execute GraphQL in the toolbar of a GraphQL file.": "在 GraphQL 文件的工具栏中使用“执行 GraphQL”运行查询。",
"Query result": "查询结果",
"Opens the GraphQL config file for the current file's schema": "打开当前文件所属架构的 GraphQL 配置文件",
"Reruns the latest introspection query to update the local schema file": "重新运行最近的内省查询以更新本地架构文件",
"Executes the current GraphQL file against the specified GraphQL endpoint": "针对指定的 GraphQL 端点执行当前 GraphQL 文件",
"Invalid GraphQL variables": "无效的 GraphQL 变量",
"Get GraphQL Schema from Endpoint": "从端点获取 GraphQL 架构",
"Replace the contents of {target} with the schema of {name}?": "要用 {name} 的架构替换 {target} 的内容吗?",
"Cannot write the GraphQL schema": "无法写入 GraphQL 架构",
"{target} did not open in an editor": "{target} 未能在编辑器中打开",
"GraphQL schema updated": "GraphQL 架构已更新",
"No introspection schema yet": "尚无内省架构",
"Run Introspection Query on an endpoint to generate it.": "对端点运行内省查询以生成它。",
", 1 error": ",1 个错误",
", {count} errors": ",{count} 个错误",
"GraphQL Environment Variables": "GraphQL 环境变量",
"Environment variables actions": "环境变量操作",
"{file}: {ms} ms execution time, {size} response": "{file}: 执行时间 {ms} 毫秒,响应 {size}",
"GraphQL error": "GraphQL 错误",
"GraphQL request to {url} failed.": "对 {url} 的 GraphQL 请求失败。",
"Show error": "显示错误",
"Run to Cursor failed": "运行到光标处失败",
"Cannot start debugging": "无法开始调试",
"Cannot attach the debugger": "无法附加调试器",
+1
View File
@@ -139,6 +139,7 @@ const UNTRANSLATED = new Set<string>([
"Go",
"Godot",
"Gradle",
"GraphQL",
"Greenplum",
"Java",
"java-debug (Java)",
+4
View File
@@ -183,6 +183,7 @@ const ICONS = {
"toolwindow-changes": "toolwindows/changes",
"toolwindow-database": "openidea/toolwindows/database",
"toolwindow-agents": "openidea/toolwindows/agents",
"toolwindow-graphql": "openidea/toolwindows/graphql",
// Coding agents (logos via Paseo)
"agent-claude": "openidea/agents/claude",
"agent-codex": "openidea/agents/codex",
@@ -223,6 +224,9 @@ const ICONS = {
filter: "general/filter",
refresh: "general/refresh",
settings: "general/settings",
help: "openidea/general/help",
sliders: "openidea/general/sliders",
"graphql-variables": "openidea/general/graphqlVariables",
layout: "general/layout",
split: "general/splitVertically",
"split-horizontal": "general/splitHorizontally",
@@ -0,0 +1,4 @@
<!-- OpenIDEA: GraphQL variables editor toggle (V badge) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="1.5" width="13" height="13" rx="2" fill="#FFF4EB" stroke="#E66D17"/><path d="M5 4.5L8 11.5L11 4.5" stroke="#E66D17" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 365 B

@@ -0,0 +1,4 @@
<!-- OpenIDEA: GraphQL variables editor toggle (V badge) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="1.5" width="13" height="13" rx="2" fill="#45322B" stroke="#E08855"/><path d="M5 4.5L8 11.5L11 4.5" stroke="#E08855" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 365 B

+4
View File
@@ -0,0 +1,4 @@
<!-- OpenIDEA: Help (question mark) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M5.5 6C5.5 4.62 6.62 3.5 8 3.5C9.38 3.5 10.5 4.62 10.5 6C10.5 7.1 9.79 7.6 9.1 8.08C8.5 8.5 8 8.85 8 9.75V10.25" stroke="#6C707E" stroke-linecap="round"/><circle cx="8" cy="12.5" r="0.75" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 357 B

+4
View File
@@ -0,0 +1,4 @@
<!-- OpenIDEA: Settings sliders (options) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2 4.5H8M11 4.5H14M2 11.5H4M7 11.5H14" stroke="#6C707E" stroke-linecap="round"/><circle cx="9.5" cy="4.5" r="1.5" stroke="#6C707E"/><circle cx="5.5" cy="11.5" r="1.5" stroke="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 344 B

+4
View File
@@ -0,0 +1,4 @@
<!-- OpenIDEA: GraphQL tool window (hexagon with inscribed triangle) -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 2L13.2 5V11L8 14L2.8 11V5L8 2Z" stroke="#6C707E" stroke-linejoin="round"/><path d="M8 2L13.2 11H2.8L8 2Z" stroke="#6C707E" stroke-linejoin="round"/><circle cx="8" cy="2" r="1.25" fill="#6C707E"/><circle cx="13.2" cy="5" r="1.25" fill="#6C707E"/><circle cx="13.2" cy="11" r="1.25" fill="#6C707E"/><circle cx="8" cy="14" r="1.25" fill="#6C707E"/><circle cx="2.8" cy="11" r="1.25" fill="#6C707E"/><circle cx="2.8" cy="5" r="1.25" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 632 B

@@ -0,0 +1,4 @@
<!-- OpenIDEA: GraphQL tool window (hexagon with inscribed triangle), stripe size -->
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 2.5L16.5 6.25V13.75L10 17.5L3.5 13.75V6.25L10 2.5Z" stroke="#6C707E" stroke-width="1.2" stroke-linejoin="round"/><path d="M10 2.5L16.5 13.75H3.5L10 2.5Z" stroke="#6C707E" stroke-width="1.2" stroke-linejoin="round"/><circle cx="10" cy="2.5" r="1.5" fill="#6C707E"/><circle cx="16.5" cy="6.25" r="1.5" fill="#6C707E"/><circle cx="16.5" cy="13.75" r="1.5" fill="#6C707E"/><circle cx="10" cy="17.5" r="1.5" fill="#6C707E"/><circle cx="3.5" cy="13.75" r="1.5" fill="#6C707E"/><circle cx="3.5" cy="6.25" r="1.5" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 725 B