mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
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:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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(/^\.\//, "")}`;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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": [],
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": "无法附加调试器",
|
||||
|
||||
@@ -139,6 +139,7 @@ const UNTRANSLATED = new Set<string>([
|
||||
"Go",
|
||||
"Godot",
|
||||
"Gradle",
|
||||
"GraphQL",
|
||||
"Greenplum",
|
||||
"Java",
|
||||
"java-debug (Java)",
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
Reference in New Issue
Block a user