diff options
| author | netop://ウィビ <paul@webb.page> | 2026-04-24 11:33:25 -0700 |
|---|---|---|
| committer | netop://ウィビ <paul@webb.page> | 2026-04-24 11:33:25 -0700 |
| commit | 8a59f92d031963e23ecc84b75feecf43eb4dd146 (patch) | |
| tree | 75de5768885583897061a3b1795e4c987ce90039 /source/library | |
| download | graphiql-8a59f92d031963e23ecc84b75feecf43eb4dd146.tar.gz graphiql-8a59f92d031963e23ecc84b75feecf43eb4dd146.zip | |
Initial commit: @eol/graphiql v0.3
Svelte 5 GraphiQL alternative for JSR. Covers:
- HTTP fetcher with injectable fetch; SSE/WS stubs
- Session store with tabs, auto-titling, persistence, rename
- Operation detection via graphql parse(); Toolbar picker
- CodeMirror 6 editor via cm6-graphql with theme prop
- Light theme preset (hand-rolled EditorView.theme)
- Doc explorer with breadcrumb nav and type guards
- History panel with 100-entry cap, favorite pinning
- Deno tests for operations, storage, and history eviction
Diffstat (limited to 'source/library')
24 files changed, 2335 insertions, 0 deletions
diff --git a/source/library/GraphiQL.svelte b/source/library/GraphiQL.svelte new file mode 100644 index 0000000..0a8f01a --- /dev/null +++ b/source/library/GraphiQL.svelte @@ -0,0 +1,322 @@ +<script lang="ts"> + /*** IMPORT ------------------------------------------- ***/ + + import { onMount } from "svelte"; + + /*** UTILITY ------------------------------------------ ***/ + + import DocExplorer from "./components/DocExplorer.svelte"; + import Editor from "./components/Editor.svelte"; + import HeadersEditor from "./components/HeadersEditor.svelte"; + import HistoryPanel from "./components/HistoryPanel.svelte"; + import ResultViewer from "./components/ResultViewer.svelte"; + import TabBar from "./components/TabBar.svelte"; + import Toolbar from "./components/Toolbar.svelte"; + import type { Extension } from "@codemirror/state"; + import type { Fetcher } from "./fetcher/types.ts"; + import { HistoryStore } from "./state/history.svelte.ts"; + import { SchemaStore } from "./state/schema.svelte.ts"; + import { SessionStore } from "./state/session.svelte.ts"; + import { createLocalStorage, createMemoryStorage } from "./state/storage.ts"; + import type { Storage } from "./state/storage.ts"; + + type Props = { + fetcher: Fetcher; + initialQuery?: string; + namespace?: string; + storage?: Storage; + theme?: Extension; + }; + + let { + fetcher, + initialQuery = "", + namespace = "eol-graphiql", + storage, + theme + }: Props = $props(); + + const resolvedStorage = storage ?? + (typeof globalThis.localStorage !== "undefined" ? + createLocalStorage(namespace) : + createMemoryStorage()); + + const PERSIST_DEBOUNCE_MS = 300; + const history = new HistoryStore(resolvedStorage); + const schema = new SchemaStore(); + const session = new SessionStore(resolvedStorage); + + if (initialQuery && session.active && session.active.query === "") + session.updateQuery(session.active.id, initialQuery); + + let bottomPane = $state<"variables" | "headers">("variables"); + let docsOpen = $state(resolvedStorage.get<boolean>("docExplorer") ?? false); + let historyOpen = $state(resolvedStorage.get<boolean>("historyPanel") ?? false); + let running = $state(false); + + $effect(() => { + void session.tabs; + void session.activeId; + + const timer = setTimeout(() => { + session.persist(); + }, PERSIST_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }); + + $effect(() => { + void history.entries; + + const timer = setTimeout(() => { + history.persist(); + }, PERSIST_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }); + + $effect(() => { + resolvedStorage.set("docExplorer", docsOpen); + }); + + $effect(() => { + resolvedStorage.set("historyPanel", historyOpen); + }); + + onMount(() => { + schema.introspect(fetcher); + }); + + async function run() { + if (running) + return; + + running = true; + + try { + const tab = session.active; + const ok = await session.run(fetcher); + + if (ok && tab) { + history.add({ + headers: tab.headers, + operationName: tab.operationName, + query: tab.query, + title: tab.title, + variables: tab.variables + }); + } + } finally { + running = false; + } + } + + function loadHistory(id: string, inNewTab: boolean) { + const entry = history.entries.find((e) => e.id === id); + + if (!entry) + return; + + const seed = { + headers: entry.headers, + operationName: entry.operationName, + query: entry.query, + title: entry.title, + variables: entry.variables + }; + + if (inNewTab) + session.addTab(seed); + else + session.overwriteActive(seed); + } + + function onQueryChange(value: string) { + if (session.active) + session.updateQuery(session.active.id, value); + } + + function onVariablesChange(value: string) { + if (session.active) + session.active.variables = value; + } + + function onHeadersChange(value: string) { + if (session.active) + session.active.headers = value; + } + + function onKeydown(event: KeyboardEvent) { + const meta = event.metaKey || event.ctrlKey; + + if (meta && event.key === "Enter") { + event.preventDefault(); + run(); + } + } + + function onBeforeUnload() { + session.persist(); + } +</script> + +<style lang="scss"> + .graphiql { + width: 100%; height: 100%; + + background-color: var(--graphiql-bg, #1e1e1e); + color: var(--graphiql-fg, #d4d4d4); + display: grid; + font-family: var(--graphiql-font, ui-monospace, SFMono-Regular, monospace); + grid-template-rows: auto auto 1fr; + } + + .panes { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 0; + + &.history-open { + grid-template-columns: 260px 1fr 1fr; + } + + &.docs-open { + grid-template-columns: 1fr 1fr 320px; + } + + &.history-open.docs-open { + grid-template-columns: 260px 1fr 1fr 320px; + } + } + + .left { + border-right: 1px solid var(--graphiql-border, #333); + display: grid; + grid-template-rows: 2fr auto 1fr; + min-height: 0; + } + + .query { + min-height: 0; + } + + .switcher { + background-color: var(--graphiql-panel, #252526); + border-top: 1px solid var(--graphiql-border, #333); + display: flex; + } + + .switch { + background: none; + border: none; + cursor: pointer; + font-size: 0.75rem; + letter-spacing: 0.05em; + padding: 0.375rem 0.75rem; + text-transform: uppercase; + + &:not(.active) { + color: var(--graphiql-muted, #858585); + } + + &.active { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .bottom { + min-height: 0; + } + + .right { + min-height: 0; + } + + .status { + background-color: var(--graphiql-panel, #252526); + border-top: 1px solid var(--graphiql-border, #333); + font-size: 0.75rem; + padding: 0.25rem 0.75rem; + } +</style> + +<svelte:window onbeforeunload={onBeforeUnload} onkeydown={onKeydown}/> + +<div class="graphiql"> + <Toolbar + disabled={running || !session.active} + docsAvailable={schema.schema !== null} + {docsOpen} + {historyOpen} + onRun={run} + onSelectOperation={(name) => { + if (session.active) + session.selectOperation(session.active.id, name); + }} + onToggleDocs={() => (docsOpen = !docsOpen)} + onToggleHistory={() => (historyOpen = !historyOpen)} + operationName={session.active?.operationName ?? null} + operations={session.active?.operations ?? []} + {running} + schemaLoading={schema.loading}/> + <TabBar + activeId={session.activeId} + onAdd={() => session.addTab()} + onClose={(id) => session.closeTab(id)} + onRename={(id, title) => session.renameTab(id, title)} + onSelect={(id) => session.selectTab(id)} + tabs={session.tabs}/> + <div class="panes" class:docs-open={docsOpen && schema.schema} class:history-open={historyOpen}> + {#if historyOpen} + <HistoryPanel + entries={history.entries} + onClear={() => history.clear()} + onFavorite={(id) => history.favorite(id)} + onLoad={loadHistory} + onRemove={(id) => history.remove(id)}/> + {/if} + <div class="left"> + <div class="query"> + <Editor + language="graphql" + onChange={onQueryChange} + schema={schema.sdl} + {theme} + value={session.active?.query ?? ""}/> + </div> + <div class="switcher"> + <button + class="switch" + class:active={bottomPane === "variables"} + onclick={() => (bottomPane = "variables")}>Variables</button> + <button + class="switch" + class:active={bottomPane === "headers"} + onclick={() => (bottomPane = "headers")}>Headers</button> + </div> + <div class="bottom"> + {#if bottomPane === "variables"} + <Editor + language="json" + onChange={onVariablesChange} + {theme} + value={session.active?.variables ?? "{}"}/> + {:else} + <HeadersEditor + onChange={onHeadersChange} + {theme} + value={session.active?.headers ?? "{}"}/> + {/if} + </div> + </div> + <div class="right"> + <ResultViewer {theme} value={session.active?.result ?? ""}/> + </div> + {#if docsOpen && schema.schema} + <DocExplorer schema={schema.schema}/> + {/if} + </div> + {#if schema.error} + <div class="status">Schema error: {schema.error}</div> + {/if} +</div> diff --git a/source/library/components/DocExplorer.svelte b/source/library/components/DocExplorer.svelte new file mode 100644 index 0000000..536cb2a --- /dev/null +++ b/source/library/components/DocExplorer.svelte @@ -0,0 +1,226 @@ +<script lang="ts"> + import FieldView from "./DocExplorer/FieldView.svelte"; + import TypeView from "./DocExplorer/TypeView.svelte"; + import { + isInputObjectType, + isInterfaceType, + isObjectType + } from "graphql"; + import type { + GraphQLField, + GraphQLInputField, + GraphQLNamedType, + GraphQLSchema + } from "graphql"; + + type NavEntry = + | { kind: "field"; fieldName: string; typeName: string } + | { kind: "type"; name: string }; + + type Props = { + schema: GraphQLSchema; + }; + + let { schema }: Props = $props(); + + let stack = $state<NavEntry[]>([]); + + const current = $derived<NavEntry | null>(stack.length > 0 ? stack[stack.length - 1] : null); + + const rootTypes = $derived.by(() => { + const out: { label: string; type: GraphQLNamedType }[] = []; + const q = schema.getQueryType(); + const m = schema.getMutationType(); + const s = schema.getSubscriptionType(); + + if (q) + out.push({ label: "Query", type: q }); + + if (m) + out.push({ label: "Mutation", type: m }); + + if (s) + out.push({ label: "Subscription", type: s }); + + return out; + }); + + const currentType = $derived.by<GraphQLNamedType | null>(() => { + if (!current) + return null; + + const name = current.kind === "type" ? current.name : current.typeName; + return schema.getType(name) ?? null; + }); + + const currentField = $derived.by<GraphQLField<unknown, unknown> | GraphQLInputField | null>(() => { + if (!current || current.kind !== "field" || !currentType) + return null; + + if (isObjectType(currentType) || isInterfaceType(currentType) || isInputObjectType(currentType)) + return currentType.getFields()[current.fieldName] ?? null; + + return null; + }); + + function crumbLabel(entry: NavEntry): string { + return entry.kind === "type" ? entry.name : entry.fieldName; + } + + function gotoRoot() { + stack = []; + } + + function gotoIndex(index: number) { + stack = stack.slice(0, index + 1); + } + + function pushType(name: string) { + if (!schema.getType(name)) + return; + + stack = [...stack, { kind: "type", name }]; + } + + function pushField(fieldName: string) { + const typeName = current?.kind === "type" ? current.name : null; + + if (!typeName) + return; + + stack = [...stack, { fieldName, kind: "field", typeName }]; + } +</script> + +<style lang="scss"> + .explorer { + background: var(--graphiql-panel, #252526); + border-left: 1px solid var(--graphiql-border, #333); + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + overflow: hidden; + } + + .breadcrumbs { + align-items: center; + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + flex-wrap: wrap; + font-size: 0.8125rem; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + } + + .crumb { + background: none; + border: none; + color: var(--graphiql-link, #79b8ff); + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + + &:hover { + text-decoration: underline; + } + + &.current { + color: var(--graphiql-fg, #d4d4d4); + cursor: default; + + &:hover { + text-decoration: none; + } + } + } + + .separator { + color: var(--graphiql-muted, #858585); + } + + .body { + min-height: 0; + overflow-y: auto; + } + + .root { + display: grid; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .section-label { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; + text-transform: uppercase; + } + + .root-list { + display: grid; + gap: 0.375rem; + } + + .root-link { + background: none; + border: none; + color: var(--graphiql-link, #79b8ff); + cursor: pointer; + font-family: inherit; + font-size: 0.875rem; + padding: 0; + text-align: left; + + &:hover { + text-decoration: underline; + } + } + + .empty { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + padding: 0.75rem 1rem; + } +</style> + +<div class="explorer"> + <div class="breadcrumbs"> + <button + class="crumb" + class:current={stack.length === 0} + onclick={gotoRoot}>Docs</button> + {#each stack as entry, i} + <span class="separator">/</span> + <button + class="crumb" + class:current={i === stack.length - 1} + onclick={() => gotoIndex(i)}>{crumbLabel(entry)}</button> + {/each} + </div> + <div class="body"> + {#if stack.length === 0} + <div class="root"> + <div class="section-label">Root Types</div> + <div class="root-list"> + {#each rootTypes as entry} + <button class="root-link" onclick={() => pushType(entry.type.name)}> + {entry.label}: {entry.type.name} + </button> + {/each} + </div> + </div> + {:else if currentField} + <FieldView field={currentField} onNavigate={pushType}/> + {:else if currentType} + <TypeView + onNavigateField={pushField} + onNavigateType={pushType} + type={currentType}/> + {:else} + <div class="empty">Type not found in schema.</div> + {/if} + </div> +</div> diff --git a/source/library/components/DocExplorer/FieldView.svelte b/source/library/components/DocExplorer/FieldView.svelte new file mode 100644 index 0000000..71d215c --- /dev/null +++ b/source/library/components/DocExplorer/FieldView.svelte @@ -0,0 +1,87 @@ +<script lang="ts"> + import TypeLink from "./TypeLink.svelte"; + import type { GraphQLField, GraphQLInputField } from "graphql"; + + type Props = { + field: GraphQLField<unknown, unknown> | GraphQLInputField; + onNavigate: (typeName: string) => void; + }; + + let { field, onNavigate }: Props = $props(); + + const args = $derived("args" in field ? field.args : []); +</script> + +<style lang="scss"> + .field { + display: grid; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .heading { + font-size: 0.95rem; + font-weight: 600; + } + + .section-label { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; + text-transform: uppercase; + } + + .description { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + line-height: 1.4; + } + + .args { + display: grid; + gap: 0.375rem; + } + + .arg { + font-size: 0.8125rem; + } + + .arg-name { + color: var(--graphiql-fg, #d4d4d4); + } + + .arg-description { + color: var(--graphiql-muted, #858585); + font-size: 0.75rem; + margin-left: 1rem; + margin-top: 0.125rem; + } +</style> + +<div class="field"> + <div class="heading">{field.name}</div> + {#if field.description} + <div class="description">{field.description}</div> + {/if} + <div> + <div class="section-label">Type</div> + <TypeLink {onNavigate} type={field.type}/> + </div> + {#if args.length > 0} + <div> + <div class="section-label">Arguments</div> + <div class="args"> + {#each args as arg} + <div class="arg"> + <span class="arg-name">{arg.name}</span>: + <TypeLink {onNavigate} type={arg.type}/> + {#if arg.description} + <div class="arg-description">{arg.description}</div> + {/if} + </div> + {/each} + </div> + </div> + {/if} +</div> diff --git a/source/library/components/DocExplorer/TypeLink.svelte b/source/library/components/DocExplorer/TypeLink.svelte new file mode 100644 index 0000000..253d16e --- /dev/null +++ b/source/library/components/DocExplorer/TypeLink.svelte @@ -0,0 +1,42 @@ +<script lang="ts"> + import { getNamedType, isListType, isNonNullType } from "graphql"; + import type { GraphQLType } from "graphql"; + + type Props = { + onNavigate: (typeName: string) => void; + type: GraphQLType; + }; + + let { onNavigate, type }: Props = $props(); + + function label(t: GraphQLType): string { + if (isNonNullType(t)) + return `${label(t.ofType)}!`; + + if (isListType(t)) + return `[${label(t.ofType)}]`; + + return t.name; + } + + const named = $derived(getNamedType(type)); + const text = $derived(label(type)); +</script> + +<style lang="scss"> + .link { + background: none; + border: none; + color: var(--graphiql-link, #79b8ff); + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + + &:hover { + text-decoration: underline; + } + } +</style> + +<button class="link" onclick={() => onNavigate(named.name)}>{text}</button> diff --git a/source/library/components/DocExplorer/TypeView.svelte b/source/library/components/DocExplorer/TypeView.svelte new file mode 100644 index 0000000..31a1ca3 --- /dev/null +++ b/source/library/components/DocExplorer/TypeView.svelte @@ -0,0 +1,199 @@ +<script lang="ts"> + import TypeLink from "./TypeLink.svelte"; + import { + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, + isScalarType, + isUnionType + } from "graphql"; + import type { GraphQLNamedType } from "graphql"; + + type Props = { + onNavigateField: (fieldName: string) => void; + onNavigateType: (typeName: string) => void; + type: GraphQLNamedType; + }; + + let { onNavigateField, onNavigateType, type }: Props = $props(); + + const fields = $derived.by(() => { + if (isObjectType(type) || isInterfaceType(type) || isInputObjectType(type)) + return Object.values(type.getFields()); + + return []; + }); + + const interfaces = $derived.by(() => { + if (isObjectType(type) || isInterfaceType(type)) + return type.getInterfaces(); + + return []; + }); + + const unionMembers = $derived.by(() => { + if (isUnionType(type)) + return type.getTypes(); + + return []; + }); + + const enumValues = $derived.by(() => { + if (isEnumType(type)) + return type.getValues(); + + return []; + }); + + const kindLabel = $derived.by(() => { + if (isObjectType(type)) + return "type"; + + if (isInterfaceType(type)) + return "interface"; + + if (isUnionType(type)) + return "union"; + + if (isEnumType(type)) + return "enum"; + + if (isInputObjectType(type)) + return "input"; + + if (isScalarType(type)) + return "scalar"; + + return ""; + }); +</script> + +<style lang="scss"> + .type { + display: grid; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .heading { + font-size: 0.95rem; + font-weight: 600; + } + + .kind { + color: var(--graphiql-muted, #858585); + font-weight: normal; + margin-right: 0.375rem; + } + + .description { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + line-height: 1.4; + } + + .section-label { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; + text-transform: uppercase; + } + + .list { + display: grid; + gap: 0.375rem; + } + + .entry { + font-size: 0.8125rem; + } + + .field-button { + background: none; + border: none; + color: var(--graphiql-fg, #d4d4d4); + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + + &:hover { + text-decoration: underline; + } + } + + .entry-description { + color: var(--graphiql-muted, #858585); + font-size: 0.75rem; + margin-left: 1rem; + margin-top: 0.125rem; + } +</style> + +<div class="type"> + <div class="heading"> + {#if kindLabel}<span class="kind">{kindLabel}</span>{/if}{type.name} + </div> + {#if type.description} + <div class="description">{type.description}</div> + {/if} + {#if interfaces.length > 0} + <div> + <div class="section-label">Implements</div> + <div class="list"> + {#each interfaces as iface} + <div class="entry"> + <TypeLink onNavigate={onNavigateType} type={iface}/> + </div> + {/each} + </div> + </div> + {/if} + {#if fields.length > 0} + <div> + <div class="section-label">Fields</div> + <div class="list"> + {#each fields as field} + <div class="entry"> + <button + class="field-button" + onclick={() => onNavigateField(field.name)}>{field.name}</button>: + <TypeLink onNavigate={onNavigateType} type={field.type}/> + {#if field.description} + <div class="entry-description">{field.description}</div> + {/if} + </div> + {/each} + </div> + </div> + {/if} + {#if unionMembers.length > 0} + <div> + <div class="section-label">Members</div> + <div class="list"> + {#each unionMembers as member} + <div class="entry"> + <TypeLink onNavigate={onNavigateType} type={member}/> + </div> + {/each} + </div> + </div> + {/if} + {#if enumValues.length > 0} + <div> + <div class="section-label">Values</div> + <div class="list"> + {#each enumValues as value} + <div class="entry"> + <span>{value.name}</span> + {#if value.description} + <div class="entry-description">{value.description}</div> + {/if} + </div> + {/each} + </div> + </div> + {/if} +</div> diff --git a/source/library/components/Editor.svelte b/source/library/components/Editor.svelte new file mode 100644 index 0000000..f2bf82d --- /dev/null +++ b/source/library/components/Editor.svelte @@ -0,0 +1,136 @@ +<script lang="ts"> + /*** IMPORT ------------------------------------------- ***/ + + import { browser } from "$app/environment"; + import { onMount } from "svelte"; + import type { Extension } from "@codemirror/state"; + import type { EditorView } from "@codemirror/view"; + import type { GraphQLSchema } from "graphql"; + + /*** UTILITY ------------------------------------------ ***/ + + type Props = { + language?: "graphql" | "json"; + onChange: (value: string) => void; + readOnly?: boolean; + schema?: string; + theme?: Extension; + value: string; + }; + + let { + language = "graphql", + onChange, + readOnly = false, + schema, + theme, + value + }: Props = $props(); + + let buildSchemaFn = $state<((sdl: string) => GraphQLSchema) | null>(null); + let container: HTMLDivElement; + let updateSchemaFn = $state<((v: EditorView, s: GraphQLSchema) => void) | null>(null); + let view = $state<EditorView | null>(null); + + onMount(() => { + if (!browser) + return; + + let disposed = false; + + (async () => { + const [ + { EditorView: EV, keymap, lineNumbers, highlightActiveLine }, + { EditorState }, + { defaultKeymap, history, historyKeymap }, + { syntaxHighlighting, defaultHighlightStyle, bracketMatching, indentOnInput }, + { closeBrackets, closeBracketsKeymap }, + { graphql, updateSchema }, + { json }, + { buildSchema } + ] = await Promise.all([ + import("@codemirror/view"), + import("@codemirror/state"), + import("@codemirror/commands"), + import("@codemirror/language"), + import("@codemirror/autocomplete"), + import("cm6-graphql"), + import("@codemirror/lang-json"), + import("graphql") + ]); + + if (disposed) + return; + + const themeExt: Extension = theme ?? (await import("@codemirror/theme-one-dark")).oneDark; + + const languageExt = language === "graphql" ? + graphql(schema ? buildSchema(schema) : undefined) : + json(); + + const instance = new EV({ + parent: container, + state: EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), + highlightActiveLine(), + history(), + bracketMatching(), + closeBrackets(), + indentOnInput(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap]), + languageExt, + themeExt, + EV.editable.of(!readOnly), + EV.updateListener.of((u) => { + if (u.docChanged) + onChange(u.state.doc.toString()); + }) + ] + }) + }); + + view = instance; + updateSchemaFn = updateSchema; + buildSchemaFn = buildSchema; + })(); + + return () => { + disposed = true; + view?.destroy(); + }; + }); + + $effect(() => { + if (!view || !updateSchemaFn || !buildSchemaFn) + return; + + if (language !== "graphql" || !schema) + return; + + try { + updateSchemaFn(view, buildSchemaFn(schema)); + } catch { + // Invalid SDL — silently skip; editor keeps working without schema awareness + } + }); +</script> + +<style lang="scss"> + .editor { + height: 100%; + width: 100%; + + :global(.cm-editor) { + height: 100%; + } + + :global(.cm-scroller) { + font-family: inherit; + } + } +</style> + +<div bind:this={container} class="editor"></div> diff --git a/source/library/components/HeadersEditor.svelte b/source/library/components/HeadersEditor.svelte new file mode 100644 index 0000000..fc3a193 --- /dev/null +++ b/source/library/components/HeadersEditor.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import Editor from "./Editor.svelte"; + import type { Extension } from "@codemirror/state"; + + type Props = { + onChange: (value: string) => void; + theme?: Extension; + value: string; + }; + + let { onChange, theme, value }: Props = $props(); +</script> + +<style lang="scss"> + .headers { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + } + + .label { + background-color: var(--graphiql-panel, #252526); + font-size: 0.75rem; + letter-spacing: 0.05em; + padding: 0.25rem 0.75rem; + text-transform: uppercase; + } +</style> + +<div class="headers"> + <div class="label">Headers</div> + <Editor language="json" {onChange} {theme} {value}/> +</div> diff --git a/source/library/components/HistoryPanel.svelte b/source/library/components/HistoryPanel.svelte new file mode 100644 index 0000000..b7f5c4c --- /dev/null +++ b/source/library/components/HistoryPanel.svelte @@ -0,0 +1,187 @@ +<script lang="ts"> + import type { HistoryEntry } from "../state/history.svelte.ts"; + + type Props = { + entries: HistoryEntry[]; + onClear: () => void; + onFavorite: (id: string) => void; + onLoad: (id: string, inNewTab: boolean) => void; + onRemove: (id: string) => void; + }; + + let { entries, onClear, onFavorite, onLoad, onRemove }: Props = $props(); + + const sorted = $derived([...entries].sort((a, b) => { + if (a.favorite !== b.favorite) + return a.favorite ? -1 : 1; + + return b.timestamp - a.timestamp; + })); + + function formatTimestamp(ms: number): string { + const d = new Date(ms); + return d.toLocaleString(); + } + + function onEntryClick(event: MouseEvent, id: string) { + onLoad(id, event.shiftKey); + } + + function onEntryKey(event: KeyboardEvent, id: string) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onLoad(id, event.shiftKey); + } + } +</script> + +<style lang="scss"> + .panel { + background: var(--graphiql-panel, #252526); + border-right: 1px solid var(--graphiql-border, #333); + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + overflow: hidden; + } + + .header { + align-items: center; + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + justify-content: space-between; + padding: 0.5rem 0.75rem; + } + + .title { + font-size: 0.8125rem; + font-weight: 600; + } + + .clear { + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-family: inherit; + font-size: 0.75rem; + padding: 0; + + &:hover { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .list { + display: grid; + gap: 0.125rem; + min-height: 0; + overflow-y: auto; + padding: 0.375rem 0; + } + + .entry { + align-items: flex-start; + cursor: pointer; + display: grid; + gap: 0.125rem; + grid-template-columns: auto 1fr auto; + padding: 0.375rem 0.75rem; + + &:hover { + background: var(--graphiql-bg, #1e1e1e); + } + } + + .star { + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-size: 0.875rem; + padding: 0 0.375rem 0 0; + + &.active { + color: var(--graphiql-accent, #e3b341); + } + } + + .meta { + align-self: center; + min-width: 0; + } + + .entry-title { + font-size: 0.8125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .entry-time { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + } + + .remove { + align-self: center; + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0 0.25rem; + + &:hover { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .empty { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + padding: 0.75rem; + } +</style> + +<div class="panel"> + <div class="header"> + <span class="title">History</span> + {#if entries.length > 0} + <button class="clear" onclick={onClear} type="button">Clear</button> + {/if} + </div> + <div class="list"> + {#if sorted.length === 0} + <div class="empty">No history yet.</div> + {:else} + {#each sorted as entry (entry.id)} + <div + aria-label="Load history entry" + class="entry" + onclick={(e) => onEntryClick(e, entry.id)} + onkeydown={(e) => onEntryKey(e, entry.id)} + role="button" + tabindex="0"> + <button + aria-label={entry.favorite ? "Unfavorite" : "Favorite"} + class="star" + class:active={entry.favorite} + onclick={(e) => { e.stopPropagation(); onFavorite(entry.id); }} + type="button">{entry.favorite ? "★" : "☆"}</button> + <div class="meta"> + <div class="entry-title">{entry.title}</div> + <div class="entry-time">{formatTimestamp(entry.timestamp)}</div> + </div> + <button + aria-label="Remove entry" + class="remove" + onclick={(e) => { e.stopPropagation(); onRemove(entry.id); }} + type="button">×</button> + </div> + {/each} + {/if} + </div> +</div> diff --git a/source/library/components/ResultViewer.svelte b/source/library/components/ResultViewer.svelte new file mode 100644 index 0000000..e2c74fe --- /dev/null +++ b/source/library/components/ResultViewer.svelte @@ -0,0 +1,35 @@ +<script lang="ts"> + import Editor from "./Editor.svelte"; + import type { Extension } from "@codemirror/state"; + + type Props = { + theme?: Extension; + value: string; + }; + + let { theme, value }: Props = $props(); + + function noop(_v: string) {} +</script> + +<style lang="scss"> + .result { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + } + + .label { + background: var(--graphiql-panel, #252526); + font-size: 0.75rem; + letter-spacing: 0.05em; + padding: 0.25rem 0.75rem; + text-transform: uppercase; + } +</style> + +<div class="result"> + <div class="label">Response</div> + <Editor language="json" onChange={noop} readOnly {theme} {value}/> +</div> diff --git a/source/library/components/TabBar.svelte b/source/library/components/TabBar.svelte new file mode 100644 index 0000000..d87449d --- /dev/null +++ b/source/library/components/TabBar.svelte @@ -0,0 +1,161 @@ +<script lang="ts"> + import { tick } from "svelte"; + import type { Tab } from "../state/session.svelte.ts"; + + type Props = { + activeId: string; + onAdd: () => void; + onClose: (id: string) => void; + onRename: (id: string, title: string) => void; + onSelect: (id: string) => void; + tabs: Tab[]; + }; + + let { activeId, onAdd, onClose, onRename, onSelect, tabs }: Props = $props(); + + let editingId = $state<string | null>(null); + let draft = $state<string>(""); + let inputEl = $state<HTMLInputElement | null>(null); + + async function startEditing(tab: Tab) { + editingId = tab.id; + draft = tab.title; + await tick(); + inputEl?.select(); + } + + function commit() { + if (editingId === null) return; + onRename(editingId, draft); + editingId = null; + draft = ""; + } + + function cancel() { + editingId = null; + draft = ""; + } + + function onKeydown(event: KeyboardEvent) { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancel(); + } + } + + function handleClose(event: MouseEvent, id: string) { + event.stopPropagation(); + onClose(id); + } +</script> + +<style lang="scss"> + .tabbar { + align-items: stretch; + background: var(--graphiql-panel, #252526); + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + font-size: 0.8125rem; + min-height: 2rem; + overflow-x: auto; + } + + .tab { + align-items: center; + background: transparent; + border: none; + border-right: 1px solid var(--graphiql-border, #333); + color: var(--graphiql-muted, #858585); + cursor: pointer; + display: flex; + gap: 0.5rem; + padding: 0 0.75rem; + + &.active { + background: var(--graphiql-bg, #1e1e1e); + color: var(--graphiql-fg, #d4d4d4); + } + + &:hover:not(.active) { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .title { + white-space: nowrap; + } + + .edit { + background: var(--graphiql-bg, #1e1e1e); + border: 1px solid var(--graphiql-accent, #0e639c); + border-radius: 2px; + color: var(--graphiql-fg, #d4d4d4); + font-family: inherit; + font-size: inherit; + min-width: 6rem; + outline: none; + padding: 0.125rem 0.25rem; + } + + .close { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + line-height: 1; + opacity: 0.6; + padding: 0; + + &:hover { + opacity: 1; + } + } + + .add { + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-size: 1rem; + padding: 0 0.75rem; + + &:hover { + color: var(--graphiql-fg, #d4d4d4); + } + } +</style> + +<div class="tabbar"> + {#each tabs as tab (tab.id)} + <button + class="tab" + class:active={tab.id === activeId} + ondblclick={() => startEditing(tab)} + onclick={() => onSelect(tab.id)} + > + {#if editingId === tab.id} + <input + bind:this={inputEl} + bind:value={draft} + class="edit" + onblur={commit} + onclick={(e) => e.stopPropagation()} + onkeydown={onKeydown} + type="text" + /> + {:else} + <span class="title">{tab.title}</span> + {/if} + <button + aria-label="Close tab" + class="close" + onclick={(e) => handleClose(e, tab.id)} + >×</button> + </button> + {/each} + <button aria-label="New tab" class="add" onclick={onAdd}>+</button> +</div> diff --git a/source/library/components/Toolbar.svelte b/source/library/components/Toolbar.svelte new file mode 100644 index 0000000..a17191c --- /dev/null +++ b/source/library/components/Toolbar.svelte @@ -0,0 +1,150 @@ +<script lang="ts"> + import type { OperationInfo } from "../graphql/operations.ts"; + + type Props = { + disabled: boolean; + docsAvailable?: boolean; + docsOpen?: boolean; + historyOpen?: boolean; + onRun: () => void; + onSelectOperation?: (name: string | null) => void; + onToggleDocs?: () => void; + onToggleHistory?: () => void; + operationName?: string | null; + operations?: OperationInfo[]; + running: boolean; + schemaLoading: boolean; + }; + + let { + disabled, + docsAvailable = false, + docsOpen = false, + historyOpen = false, + onRun, + onSelectOperation, + onToggleDocs, + onToggleHistory, + operationName = null, + operations = [], + running, + schemaLoading + }: Props = $props(); + + const namedOperations = $derived(operations.filter((o) => o.name !== null)); + + function onPick(event: Event) { + const value = (event.currentTarget as HTMLSelectElement).value; + onSelectOperation?.(value || null); + } +</script> + +<style lang="scss"> + .toolbar { + align-items: center; + background: var(--graphiql-panel, #252526); + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + } + + .run { + background: var(--graphiql-accent, #0e639c); + border: none; + border-radius: 3px; + color: #fff; + cursor: pointer; + font-size: 0.875rem; + padding: 0.375rem 1rem; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + + .hint { + color: var(--graphiql-muted, #858585); + font-size: 0.75rem; + } + + .picker { + background: var(--graphiql-bg, #1e1e1e); + border: 1px solid var(--graphiql-border, #333); + border-radius: 3px; + color: var(--graphiql-fg, #d4d4d4); + font-family: inherit; + font-size: 0.8125rem; + padding: 0.25rem 0.5rem; + } + + .spacer { + flex: 1; + } + + .toggle { + background: none; + border: 1px solid var(--graphiql-border, #333); + border-radius: 3px; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-family: inherit; + font-size: 0.75rem; + padding: 0.25rem 0.625rem; + + &.active { + background: var(--graphiql-bg, #1e1e1e); + color: var(--graphiql-fg, #d4d4d4); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } + + &:hover:not(:disabled) { + color: var(--graphiql-fg, #d4d4d4); + } + } +</style> + +<div class="toolbar"> + <button class="run" {disabled} onclick={onRun}> + {running ? "Running…" : "Run"} + </button> + {#if namedOperations.length > 1} + <select + aria-label="Operation" + class="picker" + onchange={onPick} + value={operationName ?? ""}> + <option value="">Select operation…</option> + {#each namedOperations as op} + <option value={op.name}>{op.type} {op.name}</option> + {/each} + </select> + {/if} + <span class="hint">⌘/Ctrl + Enter</span> + {#if schemaLoading} + <span class="hint">Loading schema…</span> + {/if} + <span class="spacer"></span> + {#if onToggleHistory} + <button + aria-pressed={historyOpen} + class="toggle" + class:active={historyOpen} + onclick={onToggleHistory} + type="button">History</button> + {/if} + {#if onToggleDocs} + <button + aria-pressed={docsOpen} + class="toggle" + class:active={docsOpen} + disabled={!docsAvailable} + onclick={onToggleDocs} + type="button">Docs</button> + {/if} +</div> diff --git a/source/library/fetcher/http.ts b/source/library/fetcher/http.ts new file mode 100644 index 0000000..3138226 --- /dev/null +++ b/source/library/fetcher/http.ts @@ -0,0 +1,30 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { Fetcher, FetcherOptions } from "./types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +export function createHttpFetcher(options: FetcherOptions): Fetcher { + const fetchImpl = options.fetch ?? globalThis.fetch; + + return async (req) => { + const response = await fetchImpl(options.url, { + body: JSON.stringify({ + operationName: req.operationName, + query: req.query, + variables: req.variables + }), + headers: { + "Content-Type": "application/json", + ...options.headers, + ...req.headers + }, + method: "POST" + }); + + return await response.json(); + }; +} diff --git a/source/library/fetcher/sse.ts b/source/library/fetcher/sse.ts new file mode 100644 index 0000000..b6805dc --- /dev/null +++ b/source/library/fetcher/sse.ts @@ -0,0 +1,18 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { Fetcher, FetcherOptions } from "./types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +/** + * Server-Sent Events fetcher for graphql-sse protocol. + * Stub implementation — see PLAN.md stage v0.4 for full implementation. + */ +export function createSseFetcher(_options: FetcherOptions): Fetcher { + return () => { + throw new Error("SSE fetcher not yet implemented — see PLAN.md v0.4"); + }; +} diff --git a/source/library/fetcher/types.ts b/source/library/fetcher/types.ts new file mode 100644 index 0000000..af849a9 --- /dev/null +++ b/source/library/fetcher/types.ts @@ -0,0 +1,20 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export type FetcherRequest = { + headers?: Record<string, string>; + operationName?: string | null; + query: string; + variables?: Record<string, unknown>; +}; + +export type FetcherResult = Record<string, unknown>; +export type Fetcher = (req: FetcherRequest) => Promise<FetcherResult> | AsyncIterable<FetcherResult>; + +export type FetcherOptions = { + fetch?: typeof globalThis.fetch; + headers?: Record<string, string>; + url: string; +}; diff --git a/source/library/fetcher/websocket.ts b/source/library/fetcher/websocket.ts new file mode 100644 index 0000000..6376e76 --- /dev/null +++ b/source/library/fetcher/websocket.ts @@ -0,0 +1,18 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { Fetcher, FetcherOptions } from "./types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +/** + * WebSocket fetcher for graphql-ws protocol. + * Stub implementation — see PLAN.md stage v0.4 for full implementation. + */ +export function createWsFetcher(_options: FetcherOptions): Fetcher { + return () => { + throw new Error("WebSocket fetcher not yet implemented — see PLAN.md v0.4"); + }; +} diff --git a/source/library/graphql/operations.ts b/source/library/graphql/operations.ts new file mode 100644 index 0000000..b34aeee --- /dev/null +++ b/source/library/graphql/operations.ts @@ -0,0 +1,56 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { parse } from "graphql"; + +/*** EXPORT ------------------------------------------- ***/ + +export type OperationInfo = { + name: string | null; + type: "mutation" | "query" | "subscription"; +}; + +export function deriveTitle(query: string, ops: OperationInfo[]): string { + const first = ops[0]; + + if (first && first.name) + return first.name; + + if (first) + return first.type; + + const trimmed = query.trim(); + + if (!trimmed) + return "untitled"; + + return trimmed.slice(0, 20); +} + +export function parseOperations(query: string): OperationInfo[] { + const trimmed = query.trim(); + + if (!trimmed) + return []; + + try { + const doc = parse(trimmed); + const ops: OperationInfo[] = []; + + for (const def of doc.definitions) { + if (def.kind !== "OperationDefinition") + continue; + + ops.push({ + name: def.name?.value ?? null, + type: def.operation + }); + } + + return ops; + } catch { + return []; + } +} diff --git a/source/library/index.ts b/source/library/index.ts new file mode 100644 index 0000000..5aecff1 --- /dev/null +++ b/source/library/index.ts @@ -0,0 +1,28 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export { createHttpFetcher } from "./fetcher/http.ts"; +export { createLocalStorage, createMemoryStorage } from "./state/storage.ts"; +export { createSseFetcher } from "./fetcher/sse.ts"; +export { createWsFetcher } from "./fetcher/websocket.ts"; +export { default as GraphiQL } from "./GraphiQL.svelte"; +export { HistoryStore } from "./state/history.svelte.ts"; +export { lightTheme } from "./themes/light.ts"; +export { SchemaStore } from "./state/schema.svelte.ts"; +export { SessionStore } from "./state/session.svelte.ts"; + +export type { Extension } from "@codemirror/state"; + +export type { + Fetcher, + FetcherOptions, + FetcherRequest, + FetcherResult +} from "./fetcher/types.ts"; + +export type { HistoryEntry, HistoryInput } from "./state/history.svelte.ts"; +export type { OperationInfo } from "./graphql/operations.ts"; +export type { Storage } from "./state/storage.ts"; +export type { Tab, TabSeed } from "./state/session.svelte.ts"; diff --git a/source/library/runes.d.ts b/source/library/runes.d.ts new file mode 100644 index 0000000..4b73482 --- /dev/null +++ b/source/library/runes.d.ts @@ -0,0 +1,37 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +/** + * Ambient declarations for Svelte 5 runes so `deno check` can type-check + * `.svelte.ts` files. The runtime forms are injected by the Svelte compiler. + */ + +declare function $state<T>(initial: T): T; +declare function $state<T>(): T | undefined; + +declare namespace $state { + function raw<T>(initial: T): T; + function raw<T>(): T | undefined; + function snapshot<T>(value: T): T; +} + +declare function $derived<T>(expression: T): T; + +declare namespace $derived { + function by<T>(fn: () => T): T; +} + +declare function $effect(fn: () => void | (() => void)): void; + +declare namespace $effect { + function pre(fn: () => void | (() => void)): void; + function root(fn: () => void | (() => void)): () => void; + function tracking(): boolean; +} + +declare function $props<T = Record<string, unknown>>(): T; +declare function $bindable<T>(fallback?: T): T; +declare function $inspect<T>(...values: T[]): { with: (fn: (type: "init" | "update", ...values: T[]) => void) => void }; +declare function $host<T extends HTMLElement = HTMLElement>(): T; diff --git a/source/library/state/history-logic.ts b/source/library/state/history-logic.ts new file mode 100644 index 0000000..5fce766 --- /dev/null +++ b/source/library/state/history-logic.ts @@ -0,0 +1,20 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export type HistoryEvictable = { + favorite: boolean; + timestamp: number; +}; + +export function evict<T extends HistoryEvictable>(entries: T[], max: number): T[] { + if (entries.length <= max) + return entries; + + const favorites = entries.filter((e) => e.favorite); + const regular = entries.filter((e) => !e.favorite); + const keepRegular = regular.slice(0, Math.max(0, max - favorites.length)); + + return [...favorites, ...keepRegular].sort((a, b) => b.timestamp - a.timestamp); +} diff --git a/source/library/state/history.svelte.ts b/source/library/state/history.svelte.ts new file mode 100644 index 0000000..2726283 --- /dev/null +++ b/source/library/state/history.svelte.ts @@ -0,0 +1,94 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { evict } from "./history-logic.ts"; +import type { Storage } from "./storage.ts"; + +const MAX_ENTRIES = 100; +const STORAGE_KEY = "history"; + +/*** EXPORT ------------------------------------------- ***/ + +export type HistoryEntry = { + favorite: boolean; + headers: string; + id: string; + operationName: string | null; + query: string; + timestamp: number; + title: string; + variables: string; +}; + +export type HistoryInput = { + headers: string; + operationName: string | null; + query: string; + title: string; + variables: string; +}; + +export class HistoryStore { + entries = $state<HistoryEntry[]>([]); + + #storage: Storage; + + constructor(storage: Storage) { + this.#storage = storage; + + const restored = storage.get<HistoryEntry[]>(STORAGE_KEY); + + if (Array.isArray(restored)) + this.entries = restored.map((e) => ({ + favorite: Boolean(e.favorite), + headers: e.headers ?? "{}", + id: e.id, + operationName: e.operationName ?? null, + query: e.query ?? "", + timestamp: e.timestamp ?? Date.now(), + title: e.title ?? "untitled", + variables: e.variables ?? "{}" + })); + } + + add(input: HistoryInput) { + const entry: HistoryEntry = { + favorite: false, + headers: input.headers, + id: crypto.randomUUID(), + operationName: input.operationName, + query: input.query, + timestamp: Date.now(), + title: input.title, + variables: input.variables + }; + + this.entries = [entry, ...this.entries]; + this.#evict(); + } + + clear() { + this.entries = this.entries.filter((e) => e.favorite); + } + + favorite(id: string) { + const entry = this.entries.find((e) => e.id === id); + + if (entry) + entry.favorite = !entry.favorite; + } + + persist() { + this.#storage.set<HistoryEntry[]>(STORAGE_KEY, this.entries); + } + + remove(id: string) { + this.entries = this.entries.filter((e) => e.id !== id); + } + + #evict() { + this.entries = evict(this.entries, MAX_ENTRIES); + } +} diff --git a/source/library/state/schema.svelte.ts b/source/library/state/schema.svelte.ts new file mode 100644 index 0000000..c5f148f --- /dev/null +++ b/source/library/state/schema.svelte.ts @@ -0,0 +1,41 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { + buildClientSchema, + getIntrospectionQuery, + printSchema, + type GraphQLSchema, + type IntrospectionQuery +} from "graphql"; + +/*** UTILITY ------------------------------------------ ***/ + +import type { Fetcher } from "../fetcher/types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +export class SchemaStore { + error = $state<string | null>(null); + loading = $state(false); + schema = $state<GraphQLSchema | null>(null); + sdl = $state<string>(""); + + async introspect(fetcher: Fetcher) { + this.loading = true; + this.error = null; + + try { + const result = await fetcher({ query: getIntrospectionQuery() }); + const data = (result as { data: IntrospectionQuery }).data; + this.schema = buildClientSchema(data); + this.sdl = printSchema(this.schema); + } catch(err) { + this.error = String(err); + } finally { + this.loading = false; + } + } +} diff --git a/source/library/state/session.svelte.ts b/source/library/state/session.svelte.ts new file mode 100644 index 0000000..d9f52ff --- /dev/null +++ b/source/library/state/session.svelte.ts @@ -0,0 +1,242 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { Fetcher } from "../fetcher/types.ts"; +import { + deriveTitle, + parseOperations, + type OperationInfo +} from "../graphql/operations.ts"; +import type { Storage } from "./storage.ts"; + +const STORAGE_KEY = "session"; + +type Snapshot = { + activeId: string; + tabs: Tab[]; +}; + +/*** EXPORT ------------------------------------------- ***/ + +export type Tab = { + headers: string; + id: string; + operationName: string | null; + operations: OperationInfo[]; + query: string; + result: string; + title: string; + titleDirty: boolean; + variables: string; +}; + +export type TabSeed = { + headers?: string; + operationName?: string | null; + query?: string; + title?: string; + variables?: string; +}; + +export class SessionStore { + activeId = $state<string>(""); + tabs = $state<Tab[]>([]); + active = $derived(this.tabs.find((t) => t.id === this.activeId)); + + #storage: Storage; + + constructor(storage: Storage) { + this.#storage = storage; + const restored = storage.get<Snapshot>(STORAGE_KEY); + + if (restored && restored.tabs.length > 0) { + this.tabs = restored.tabs.map((t) => this.#hydrate(t)); + + this.activeId = this.tabs.some((t) => t.id === restored.activeId) ? + restored.activeId : + this.tabs[0].id; + } else { + const tab = this.#blank(); + this.tabs = [tab]; + this.activeId = tab.id; + } + } + + addTab(seed?: TabSeed) { + const tab = seed ? this.#seeded(seed) : this.#blank(); + this.tabs.push(tab); + this.activeId = tab.id; + } + + closeTab(id: string) { + const idx = this.tabs.findIndex((t) => t.id === id); + + if (idx === -1) + return; + + if (this.tabs.length === 1) { + const fresh = this.#blank(); + this.tabs = [fresh]; + this.activeId = fresh.id; + + return; + } + + this.tabs.splice(idx, 1); + + if (this.activeId === id) + this.activeId = this.tabs[Math.max(0, idx - 1)].id; + } + + persist() { + this.#storage.set<Snapshot>(STORAGE_KEY, { + activeId: this.activeId, + tabs: this.tabs + }); + } + + renameActive(title: string) { + if (!this.active) + return; + + this.active.title = title.trim() || "untitled"; + this.active.titleDirty = true; + } + + renameTab(id: string, title: string) { + const tab = this.tabs.find((t) => t.id === id); + + if (!tab) + return; + + tab.title = title.trim() || "untitled"; + tab.titleDirty = true; + } + + async run(fetcher: Fetcher): Promise<boolean> { + const tab = this.active; + + if (!tab) + return false; + + try { + const variables = tab.variables.trim() ? JSON.parse(tab.variables) : {}; + const headers = tab.headers.trim() ? JSON.parse(tab.headers) : {}; + + const result = await fetcher({ + headers, + operationName: tab.operationName, + query: tab.query, + variables + }); + + tab.result = JSON.stringify(result, null, 2); + return true; + } catch(err) { + tab.result = JSON.stringify({ error: String(err) }, null, 2); + return false; + } + } + + overwriteActive(seed: TabSeed) { + const tab = this.active; + + if (!tab) + return; + + const query = seed.query ?? ""; + tab.headers = seed.headers ?? "{}"; + tab.operations = parseOperations(query); + tab.operationName = seed.operationName ?? null; + tab.query = query; + tab.result = ""; + tab.variables = seed.variables ?? "{}"; + + if (seed.title && !tab.titleDirty) + tab.title = seed.title; + else if (!tab.titleDirty) + tab.title = deriveTitle(query, tab.operations); + } + + selectOperation(id: string, name: string | null) { + const tab = this.tabs.find((t) => t.id === id); + + if (tab) + tab.operationName = name; + } + + selectTab(id: string) { + if (this.tabs.some((t) => t.id === id)) + this.activeId = id; + } + + updateQuery(id: string, query: string) { + const tab = this.tabs.find((t) => t.id === id); + + if (!tab) + return; + + tab.query = query; + + const ops = parseOperations(query); + tab.operations = ops; + + if (ops.length === 0) + tab.operationName = null; + else if (ops.length === 1) + tab.operationName = ops[0].name; + else if (tab.operationName && !ops.some((o) => o.name === tab.operationName)) + tab.operationName = null; + + if (!tab.titleDirty) + tab.title = deriveTitle(query, ops); + } + + #blank(): Tab { + return { + headers: "{}", + id: crypto.randomUUID(), + operationName: null, + operations: [], + query: "", + result: "", + title: "untitled", + titleDirty: false, + variables: "{}" + }; + } + + #hydrate(raw: Tab): Tab { + return { + headers: raw.headers ?? "{}", + id: raw.id, + operationName: raw.operationName ?? null, + operations: raw.operations ?? parseOperations(raw.query ?? ""), + query: raw.query ?? "", + result: raw.result ?? "", + title: raw.title ?? "untitled", + titleDirty: raw.titleDirty ?? raw.title !== "untitled", + variables: raw.variables ?? "{}" + }; + } + + #seeded(seed: TabSeed): Tab { + const query = seed.query ?? ""; + const operations = parseOperations(query); + const title = seed.title ?? deriveTitle(query, operations); + + return { + headers: seed.headers ?? "{}", + id: crypto.randomUUID(), + operationName: seed.operationName ?? null, + operations, + query, + result: "", + title, + titleDirty: Boolean(seed.title), + variables: seed.variables ?? "{}" + }; + } +} diff --git a/source/library/state/storage.ts b/source/library/state/storage.ts new file mode 100644 index 0000000..a24096a --- /dev/null +++ b/source/library/state/storage.ts @@ -0,0 +1,77 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export type Storage = { + get<T>(key: string): T | null; + remove(key: string): void; + set<T>(key: string, value: T): void; +}; + +export function createLocalStorage(namespace: string): Storage { + const prefix = `${namespace}:`; + + function available(): boolean { + try { + return typeof globalThis.localStorage !== "undefined"; + } catch { + return false; + } + } + + return { + get<T>(key: string): T | null { + if (!available()) + return null; + + const raw = globalThis.localStorage.getItem(prefix + key); + + if (raw === null) + return null; + + try { + return JSON.parse(raw) as T; + } catch { + return null; + } + }, + remove(key: string): void { + if (!available()) + return; + + globalThis.localStorage.removeItem(prefix + key); + }, + set<T>(key: string, value: T): void { + if (!available()) + return; + + globalThis.localStorage.setItem(prefix + key, JSON.stringify(value)); + } + }; +} + +export function createMemoryStorage(): Storage { + const store = new Map<string, string>(); + + return { + get<T>(key: string): T | null { + const raw = store.get(key); + + if (raw === undefined) + return null; + + try { + return JSON.parse(raw) as T; + } catch { + return null; + } + }, + remove(key: string): void { + store.delete(key); + }, + set<T>(key: string, value: T): void { + store.set(key, JSON.stringify(value)); + } + }; +} diff --git a/source/library/themes/light.ts b/source/library/themes/light.ts new file mode 100644 index 0000000..daaede2 --- /dev/null +++ b/source/library/themes/light.ts @@ -0,0 +1,75 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { EditorView } from "@codemirror/view"; +import { tags as t } from "@lezer/highlight"; + +/*** EXPORT ------------------------------------------- ***/ + +const BG = "#fafafa"; +const BORDER = "#e0e0e0"; +const FG = "#24292e"; +const GUTTER_BG = "#f3f3f3"; +const GUTTER_FG = "#9ca3af"; +const SELECTION = "#b3d4fc"; + +const base = EditorView.theme({ + "&": { + backgroundColor: BG, + color: FG + }, + "&.cm-focused": { + outline: "none" + }, + ".cm-activeLine": { + backgroundColor: "#f0f0f0" + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + color: FG + }, + ".cm-content": { + caretColor: "#1f6feb" + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "#1f6feb" + }, + ".cm-gutters": { + backgroundColor: GUTTER_BG, + border: "none", + borderRight: `1px solid ${BORDER}`, + color: GUTTER_FG + }, + ".cm-matchingBracket": { + backgroundColor: "#dbeafe", + outline: "1px solid #93c5fd" + }, + ".cm-selectionBackground, &.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": { + backgroundColor: SELECTION + } +}, { dark: false }); + +const highlight = HighlightStyle.define([ + { color: "#d73a49", tag: t.keyword }, + { color: "#6f42c1", tag: [t.name, t.deleted, t.character, t.macroName] }, + { color: "#6f42c1", tag: [t.propertyName] }, + { color: "#032f62", tag: [t.string, t.special(t.string)] }, + { color: "#005cc5", tag: [t.number, t.bool, t.null, t.atom] }, + { color: "#6a737d", fontStyle: "italic", tag: t.comment }, + { color: "#22863a", tag: [t.typeName, t.className] }, + { color: "#e36209", tag: [t.variableName, t.labelName] }, + { color: "#d73a49", tag: [t.operator, t.operatorKeyword] }, + { color: "#6a737d", tag: [t.meta, t.documentMeta] }, + { color: "#22863a", tag: [t.tagName] }, + { color: "#6f42c1", tag: [t.attributeName] }, + { color: "#e36209", tag: [t.heading] }, + { color: "#032f62", tag: [t.link] }, + { fontWeight: "bold", tag: [t.strong] }, + { fontStyle: "italic", tag: [t.emphasis] }, + { tag: t.strikethrough, textDecoration: "line-through" } +]); + +export const lightTheme = [base, syntaxHighlighting(highlight)]; |