aboutsummaryrefslogtreecommitdiff
path: root/source/library/components
diff options
context:
space:
mode:
Diffstat (limited to 'source/library/components')
-rw-r--r--source/library/components/DocExplorer.svelte226
-rw-r--r--source/library/components/DocExplorer/FieldView.svelte87
-rw-r--r--source/library/components/DocExplorer/TypeLink.svelte42
-rw-r--r--source/library/components/DocExplorer/TypeView.svelte199
-rw-r--r--source/library/components/Editor.svelte136
-rw-r--r--source/library/components/HeadersEditor.svelte34
-rw-r--r--source/library/components/HistoryPanel.svelte187
-rw-r--r--source/library/components/ResultViewer.svelte35
-rw-r--r--source/library/components/TabBar.svelte161
-rw-r--r--source/library/components/Toolbar.svelte150
10 files changed, 1257 insertions, 0 deletions
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>