aboutsummaryrefslogtreecommitdiff
path: root/source/library
diff options
context:
space:
mode:
authornetop://ウィビ <paul@webb.page>2026-04-24 16:37:33 -0700
committernetop://ウィビ <paul@webb.page>2026-04-24 16:37:33 -0700
commit510fd8cbe53abb39cba2c7cbaaefcf2783dc0066 (patch)
tree8f753a33c475b285f2a297785d34cda3b0a8faed /source/library
parent261f3bdb77799009344aab4a60686b7186ebd3b0 (diff)
downloadgraphiql-510fd8cbe53abb39cba2c7cbaaefcf2783dc0066.tar.gz
graphiql-510fd8cbe53abb39cba2c7cbaaefcf2783dc0066.zip
Implement v0.6-1.0: shortcuts, format, export/import, splitter, timing, APQ
- v0.6: matchShortcut + format(); Cmd+Shift+Enter/W/F + Cmd+Alt+arrows - v0.7: SessionStore.exportAll/importTabs with version-1 validator - v0.8: Splitter component + four resize handles persisted under layout.* - v0.10: createApqFetcher (HTTP-only) wrapping shared http-body helpers - Drop .svelte re-exports from index.ts for multi-entry JSR/npm publishing
Diffstat (limited to 'source/library')
-rw-r--r--source/library/GraphiQL.svelte307
-rw-r--r--source/library/components/HistoryPanel.svelte106
-rw-r--r--source/library/components/ResultViewer.svelte51
-rw-r--r--source/library/components/Splitter.svelte123
-rw-r--r--source/library/components/Toolbar.svelte5
-rw-r--r--source/library/fetcher/apq.ts101
-rw-r--r--source/library/fetcher/http-body.ts31
-rw-r--r--source/library/fetcher/http.ts19
-rw-r--r--source/library/graphql/format.ts22
-rw-r--r--source/library/index.ts15
-rw-r--r--source/library/state/keyboard.ts50
-rw-r--r--source/library/state/session-io.ts127
-rw-r--r--source/library/state/session.svelte.ts142
13 files changed, 1031 insertions, 68 deletions
diff --git a/source/library/GraphiQL.svelte b/source/library/GraphiQL.svelte
index 329dd6f..4250fd3 100644
--- a/source/library/GraphiQL.svelte
+++ b/source/library/GraphiQL.svelte
@@ -10,14 +10,17 @@
import HeadersEditor from "./components/HeadersEditor.svelte";
import HistoryPanel from "./components/HistoryPanel.svelte";
import ResultViewer from "./components/ResultViewer.svelte";
+ import Splitter from "./components/Splitter.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 { matchShortcut } from "./state/keyboard.ts";
import { SchemaStore } from "./state/schema.svelte.ts";
import { SessionStore } from "./state/session.svelte.ts";
import type { SubscriptionMode, Tab } from "./state/session.svelte.ts";
+ import { validateSessionExport } from "./state/session-io.ts";
import { createLocalStorage, createMemoryStorage } from "./state/storage.ts";
import type { Storage } from "./state/storage.ts";
@@ -59,10 +62,28 @@
session.updateQuery(session.active.id, initialQuery);
let bottomPane = $state<"variables" | "headers">("variables");
+ let bottomHeight = $state(resolvedStorage.get<number>("layout.bottomHeight") ?? 35);
+ let centerEl = $state<HTMLDivElement | null>(null);
let docsOpen = $state(resolvedStorage.get<boolean>("docExplorer") ?? false);
+ let docsWidth = $state(resolvedStorage.get<number>("layout.docsWidth") ?? 320);
+ let historyNotice = $state<string | null>(null);
let historyOpen = $state(resolvedStorage.get<boolean>("historyPanel") ?? false);
+ let historyWidth = $state(resolvedStorage.get<number>("layout.historyWidth") ?? 260);
+ let leftEl = $state<HTMLDivElement | null>(null);
+ let leftWidth = $state(resolvedStorage.get<number>("layout.leftWidth") ?? 50);
let running = $state(false);
+ let dragStartHistoryWidth = 0;
+ let dragStartDocsWidth = 0;
+ let dragStartLeftWidth = 0;
+ let dragStartLeftWidthPx = 0;
+ let dragStartBottomHeight = 0;
+ let dragStartBottomHeightPx = 0;
+
+ function clamp(value: number, min: number, max: number): number {
+ return Math.min(Math.max(value, min), max);
+ }
+
$effect(() => {
void session.tabs;
void session.activeId;
@@ -92,6 +113,22 @@
resolvedStorage.set("historyPanel", historyOpen);
});
+ $effect(() => {
+ resolvedStorage.set("layout.bottomHeight", bottomHeight);
+ });
+
+ $effect(() => {
+ resolvedStorage.set("layout.docsWidth", docsWidth);
+ });
+
+ $effect(() => {
+ resolvedStorage.set("layout.historyWidth", historyWidth);
+ });
+
+ $effect(() => {
+ resolvedStorage.set("layout.leftWidth", leftWidth);
+ });
+
onMount(() => {
schema.introspect(fetcher);
});
@@ -149,6 +186,61 @@
session.overwriteActive(seed);
}
+ function onExportSession() {
+ if (typeof globalThis.URL === "undefined")
+ return;
+
+ if (typeof globalThis.document === "undefined")
+ return;
+
+ const data = session.exportAll();
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const a = globalThis.document.createElement("a");
+ a.download = `graphiql-session-${data.exportedAt}.json`;
+ a.href = url;
+ a.click();
+ URL.revokeObjectURL(url);
+ }
+
+ async function onImportSession(file: File) {
+ let text: string;
+
+ try {
+ text = await file.text();
+ } catch (err) {
+ historyNotice = `Import failed: ${String(err)}`;
+ return;
+ }
+
+ let parsed: unknown;
+
+ try {
+ parsed = JSON.parse(text);
+ } catch (err) {
+ historyNotice = `Import failed: invalid JSON (${String(err)})`;
+ return;
+ }
+
+ const valid = validateSessionExport(parsed);
+
+ if ("error" in valid) {
+ historyNotice = `Import failed: ${valid.error}`;
+ return;
+ }
+
+ const result = session.importTabs(valid, { mode: "append" });
+ const parts = [`Imported ${result.added} tab${result.added === 1 ? "" : "s"}`];
+
+ if (result.skipped > 0)
+ parts.push(`skipped ${result.skipped}`);
+
+ if (result.errors.length > 0)
+ parts.push(`${result.errors.length} error${result.errors.length === 1 ? "" : "s"}`);
+
+ historyNotice = parts.join(", ");
+ }
+
function onQueryChange(value: string) {
if (session.active)
session.updateQuery(session.active.id, value);
@@ -165,17 +257,96 @@
}
function onKeydown(event: KeyboardEvent) {
- const meta = event.metaKey || event.ctrlKey;
+ const action = matchShortcut(event);
- if (meta && event.key === "Enter") {
- event.preventDefault();
- run();
+ if (!action)
+ return;
+
+ event.preventDefault();
+
+ switch (action.type) {
+ case "closeTab":
+ session.closeTab(session.activeId);
+ break;
+ case "format":
+ session.formatActive();
+ break;
+ case "newTab":
+ session.addTab();
+ break;
+ case "nextTab":
+ session.nextTab();
+ break;
+ case "prevTab":
+ session.prevTab();
+ break;
+ case "run":
+ run();
+ break;
}
}
function onBeforeUnload() {
session.persist();
}
+
+ function onHistoryDragStart() {
+ dragStartHistoryWidth = historyWidth;
+ }
+
+ function onHistoryDrag(dx: number) {
+ historyWidth = clamp(dragStartHistoryWidth + dx, 200, 500);
+ }
+
+ function onHistoryKeyAdjust(delta: number) {
+ historyWidth = clamp(historyWidth + delta, 200, 500);
+ }
+
+ function onDocsDragStart() {
+ dragStartDocsWidth = docsWidth;
+ }
+
+ function onDocsDrag(dx: number) {
+ docsWidth = clamp(dragStartDocsWidth - dx, 240, 600);
+ }
+
+ function onDocsKeyAdjust(delta: number) {
+ docsWidth = clamp(docsWidth - delta, 240, 600);
+ }
+
+ function onLeftDragStart() {
+ dragStartLeftWidth = leftWidth;
+ dragStartLeftWidthPx = centerEl?.getBoundingClientRect().width ?? 0;
+ }
+
+ function onLeftDrag(dx: number) {
+ if (dragStartLeftWidthPx === 0)
+ return;
+
+ const percentDelta = (dx / dragStartLeftWidthPx) * 100;
+ leftWidth = clamp(dragStartLeftWidth + percentDelta, 20, 80);
+ }
+
+ function onLeftKeyAdjust(delta: number) {
+ leftWidth = clamp(leftWidth + delta, 20, 80);
+ }
+
+ function onBottomDragStart() {
+ dragStartBottomHeight = bottomHeight;
+ dragStartBottomHeightPx = leftEl?.getBoundingClientRect().height ?? 0;
+ }
+
+ function onBottomDrag(_dx: number, dy: number) {
+ if (dragStartBottomHeightPx === 0)
+ return;
+
+ const percentDelta = (dy / dragStartBottomHeightPx) * 100;
+ bottomHeight = clamp(dragStartBottomHeight - percentDelta, 15, 70);
+ }
+
+ function onBottomKeyAdjust(delta: number) {
+ bottomHeight = clamp(bottomHeight - delta, 15, 70);
+ }
</script>
<style lang="scss">
@@ -191,27 +362,34 @@
.panes {
display: grid;
- grid-template-columns: 1fr 1fr;
+ grid-template-columns: 1fr;
min-height: 0;
&.history-open {
- grid-template-columns: 260px 1fr 1fr;
+ grid-template-columns: var(--graphiql-history-width) 6px 1fr;
}
&.docs-open {
- grid-template-columns: 1fr 1fr 320px;
+ grid-template-columns: 1fr 6px var(--graphiql-docs-width);
}
&.history-open.docs-open {
- grid-template-columns: 260px 1fr 1fr 320px;
+ grid-template-columns: var(--graphiql-history-width) 6px 1fr 6px var(--graphiql-docs-width);
}
}
+ .center {
+ display: grid;
+ grid-template-columns: var(--graphiql-left-width) 6px calc(100% - var(--graphiql-left-width) - 6px);
+ min-height: 0;
+ min-width: 0;
+ }
+
.left {
- border-right: 1px solid var(--graphiql-border, #333);
display: grid;
- grid-template-rows: 2fr auto 1fr;
+ grid-template-rows: 1fr auto 6px var(--graphiql-bottom-height);
min-height: 0;
+ min-width: 0;
}
.query {
@@ -267,6 +445,7 @@
{docsOpen}
extras={toolbarExtras}
{historyOpen}
+ onFormat={() => session.formatActive()}
onRun={run}
onSelectOperation={(name) => {
if (session.active)
@@ -286,53 +465,93 @@
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}>
+ <div
+ class="panes"
+ class:docs-open={docsOpen && schema.schema}
+ class:history-open={historyOpen}
+ style="--graphiql-history-width: {historyWidth}px; --graphiql-docs-width: {docsWidth}px;"
+ >
{#if historyOpen}
<HistoryPanel
entries={history.entries}
+ notice={historyNotice}
onClear={() => history.clear()}
+ onDismissNotice={() => (historyNotice = null)}
+ onExport={onExportSession}
onFavorite={(id) => history.favorite(id)}
+ onImport={onImportSession}
onLoad={loadHistory}
onRemove={(id) => history.remove(id)}/>
+ <Splitter
+ onDrag={onHistoryDrag}
+ onDragStart={onHistoryDragStart}
+ onKeyAdjust={onHistoryKeyAdjust}
+ orientation="horizontal"/>
{/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"}
+ <div
+ bind:this={centerEl}
+ class="center"
+ style="--graphiql-left-width: {leftWidth}%;"
+ >
+ <div bind:this={leftEl} class="left" style="--graphiql-bottom-height: {bottomHeight}%;">
+ <div class="query">
<Editor
- language="json"
- onChange={onVariablesChange}
- {theme}
- value={session.active?.variables ?? "{}"}/>
- {:else}
- <HeadersEditor
- onChange={onHeadersChange}
+ language="graphql"
+ onChange={onQueryChange}
+ schema={schema.sdl}
{theme}
- value={session.active?.headers ?? "{}"}/>
- {/if}
+ 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>
+ <Splitter
+ onDrag={onBottomDrag}
+ onDragStart={onBottomDragStart}
+ onKeyAdjust={onBottomKeyAdjust}
+ orientation="vertical"/>
+ <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>
+ <Splitter
+ onDrag={onLeftDrag}
+ onDragStart={onLeftDragStart}
+ onKeyAdjust={onLeftKeyAdjust}
+ orientation="horizontal"/>
+ <div class="right">
+ <ResultViewer
+ footer={resultFooter}
+ streamIntervals={session.active?.streamIntervals ?? []}
+ {theme}
+ timing={session.active?.timing ?? null}
+ value={session.active?.result ?? ""}/>
</div>
- </div>
- <div class="right">
- <ResultViewer footer={resultFooter} {theme} value={session.active?.result ?? ""}/>
</div>
{#if docsOpen && schema.schema}
+ <Splitter
+ onDrag={onDocsDrag}
+ onDragStart={onDocsDragStart}
+ onKeyAdjust={onDocsKeyAdjust}
+ orientation="horizontal"/>
<DocExplorer schema={schema.schema}/>
{/if}
</div>
diff --git a/source/library/components/HistoryPanel.svelte b/source/library/components/HistoryPanel.svelte
index b7f5c4c..01f397a 100644
--- a/source/library/components/HistoryPanel.svelte
+++ b/source/library/components/HistoryPanel.svelte
@@ -3,13 +3,29 @@
type Props = {
entries: HistoryEntry[];
+ notice?: string | null;
onClear: () => void;
+ onDismissNotice?: () => void;
+ onExport?: () => void;
onFavorite: (id: string) => void;
+ onImport?: (file: File) => void;
onLoad: (id: string, inNewTab: boolean) => void;
onRemove: (id: string) => void;
};
- let { entries, onClear, onFavorite, onLoad, onRemove }: Props = $props();
+ let {
+ entries,
+ notice = null,
+ onClear,
+ onDismissNotice,
+ onExport,
+ onFavorite,
+ onImport,
+ onLoad,
+ onRemove
+ }: Props = $props();
+
+ let fileInput = $state<HTMLInputElement | null>(null);
const sorted = $derived([...entries].sort((a, b) => {
if (a.favorite !== b.favorite)
@@ -33,6 +49,20 @@
onLoad(id, event.shiftKey);
}
}
+
+ function onImportClick() {
+ fileInput?.click();
+ }
+
+ function onFileChange(event: Event) {
+ const input = event.currentTarget as HTMLInputElement;
+ const file = input.files?.[0];
+
+ if (file)
+ onImport?.(file);
+
+ input.value = "";
+ }
</script>
<style lang="scss">
@@ -50,6 +80,7 @@
align-items: center;
border-bottom: 1px solid var(--graphiql-border, #333);
display: flex;
+ gap: 0.5rem;
justify-content: space-between;
padding: 0.5rem 0.75rem;
}
@@ -59,7 +90,12 @@
font-weight: 600;
}
- .clear {
+ .actions {
+ display: flex;
+ gap: 0.5rem;
+ }
+
+ .action {
background: none;
border: none;
color: var(--graphiql-muted, #858585);
@@ -73,6 +109,36 @@
}
}
+ .hidden-input {
+ display: none;
+ }
+
+ .notice {
+ align-items: center;
+ background: var(--graphiql-bg, #1e1e1e);
+ border-bottom: 1px solid var(--graphiql-border, #333);
+ color: var(--graphiql-fg, #d4d4d4);
+ display: flex;
+ font-size: 0.75rem;
+ gap: 0.5rem;
+ justify-content: space-between;
+ padding: 0.375rem 0.75rem;
+ }
+
+ .notice-dismiss {
+ background: none;
+ border: none;
+ color: var(--graphiql-muted, #858585);
+ cursor: pointer;
+ font-size: 0.875rem;
+ line-height: 1;
+ padding: 0 0.25rem;
+
+ &:hover {
+ color: var(--graphiql-fg, #d4d4d4);
+ }
+ }
+
.list {
display: grid;
gap: 0.125rem;
@@ -147,10 +213,38 @@
</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>
+ <div>
+ <div class="header">
+ <span class="title">History</span>
+ <div class="actions">
+ {#if onExport}
+ <button class="action" onclick={onExport} type="button">Export</button>
+ {/if}
+ {#if onImport}
+ <button class="action" onclick={onImportClick} type="button">Import</button>
+ <input
+ accept="application/json"
+ bind:this={fileInput}
+ class="hidden-input"
+ onchange={onFileChange}
+ type="file"/>
+ {/if}
+ {#if entries.length > 0}
+ <button class="action" onclick={onClear} type="button">Clear</button>
+ {/if}
+ </div>
+ </div>
+ {#if notice}
+ <div class="notice">
+ <span>{notice}</span>
+ {#if onDismissNotice}
+ <button
+ aria-label="Dismiss notice"
+ class="notice-dismiss"
+ onclick={onDismissNotice}
+ type="button">×</button>
+ {/if}
+ </div>
{/if}
</div>
<div class="list">
diff --git a/source/library/components/ResultViewer.svelte b/source/library/components/ResultViewer.svelte
index 083828d..d277ec8 100644
--- a/source/library/components/ResultViewer.svelte
+++ b/source/library/components/ResultViewer.svelte
@@ -2,14 +2,50 @@
import Editor from "./Editor.svelte";
import type { Extension } from "@codemirror/state";
import type { Snippet } from "svelte";
+ import type { TabTiming } from "../state/session.svelte.ts";
type Props = {
footer?: Snippet<[{ result: string }]>;
+ streamIntervals?: number[];
theme?: Extension;
+ timing?: TabTiming | null;
value: string;
};
- let { footer, theme, value }: Props = $props();
+ let {
+ footer,
+ streamIntervals = [],
+ theme,
+ timing = null,
+ value
+ }: Props = $props();
+
+ function median(values: number[]): number {
+ if (values.length === 0)
+ return 0;
+
+ const sorted = [...values].sort((a, b) => a - b);
+ const mid = Math.floor(sorted.length / 2);
+
+ return sorted.length % 2 === 0 ?
+ (sorted[mid - 1] + sorted[mid]) / 2 :
+ sorted[mid];
+ }
+
+ let metadata = $derived.by(() => {
+ if (!timing)
+ return null;
+
+ if (streamIntervals.length > 0) {
+ const medianMs = Math.round(median(streamIntervals));
+ const messages = streamIntervals.length + 1;
+ return `→ ${messages} messages · median ${medianMs}ms`;
+ }
+
+ const totalMs = Math.round(timing.endMs - timing.startMs);
+ const firstByteMs = Math.round(timing.firstByteMs - timing.startMs);
+ return `→ ${totalMs}ms · first byte ${firstByteMs}ms`;
+ });
function noop(_v: string) {}
</script>
@@ -17,7 +53,7 @@
<style lang="scss">
.result {
display: grid;
- grid-template-rows: auto 1fr auto;
+ grid-template-rows: auto 1fr auto auto;
height: 100%;
min-height: 0;
}
@@ -30,6 +66,14 @@
text-transform: uppercase;
}
+ .metadata {
+ background: var(--graphiql-panel, #252526);
+ border-top: 1px solid var(--graphiql-border, #333);
+ color: var(--graphiql-muted, #858585);
+ font-size: 0.75rem;
+ padding: 0.25rem 0.75rem;
+ }
+
.footer {
background: var(--graphiql-panel, #252526);
border-top: 1px solid var(--graphiql-border, #333);
@@ -41,6 +85,9 @@
<div class="result">
<div class="label">Response</div>
<Editor language="json" onChange={noop} readOnly {theme} {value}/>
+ {#if metadata}
+ <div class="metadata">{metadata}</div>
+ {/if}
{#if footer}
<div class="footer">{@render footer({ result: value })}</div>
{/if}
diff --git a/source/library/components/Splitter.svelte b/source/library/components/Splitter.svelte
new file mode 100644
index 0000000..f4138f2
--- /dev/null
+++ b/source/library/components/Splitter.svelte
@@ -0,0 +1,123 @@
+<script lang="ts">
+ type Props = {
+ onDrag: (dx: number, dy: number) => void;
+ onDragEnd?: () => void;
+ onDragStart?: () => void;
+ onKeyAdjust?: (delta: number) => void;
+ orientation: "horizontal" | "vertical";
+ };
+
+ let { onDrag, onDragEnd, onDragStart, onKeyAdjust, orientation }: Props = $props();
+
+ let dragging = $state(false);
+ let startX = $state(0);
+ let startY = $state(0);
+
+ function onPointerDown(event: PointerEvent) {
+ event.preventDefault();
+ dragging = true;
+ startX = event.clientX;
+ startY = event.clientY;
+ event.currentTarget instanceof Element &&
+ event.currentTarget.setPointerCapture(event.pointerId);
+ onDragStart?.();
+ }
+
+ function onPointerMove(event: PointerEvent) {
+ if (!dragging)
+ return;
+
+ onDrag(event.clientX - startX, event.clientY - startY);
+ }
+
+ function onPointerUp(event: PointerEvent) {
+ if (!dragging)
+ return;
+
+ dragging = false;
+ event.currentTarget instanceof Element &&
+ event.currentTarget.releasePointerCapture(event.pointerId);
+ onDragEnd?.();
+ }
+
+ function onKeydown(event: KeyboardEvent) {
+ if (!onKeyAdjust)
+ return;
+
+ if (orientation === "horizontal") {
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ onKeyAdjust(-16);
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ onKeyAdjust(16);
+ }
+ } else {
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ onKeyAdjust(-16);
+ } else if (event.key === "ArrowDown") {
+ event.preventDefault();
+ onKeyAdjust(16);
+ }
+ }
+ }
+</script>
+
+<style lang="scss">
+ .splitter {
+ align-items: center;
+ background: transparent;
+ display: flex;
+ justify-content: center;
+ position: relative;
+ touch-action: none;
+ user-select: none;
+
+ &:hover::after,
+ &.dragging::after {
+ background: var(--graphiql-accent, #0e639c);
+ }
+
+ &::after {
+ background: var(--graphiql-border, #333);
+ content: "";
+ position: absolute;
+ transition: background 120ms ease;
+ }
+
+ &.horizontal {
+ cursor: col-resize;
+ height: 100%;
+ width: 6px;
+
+ &::after {
+ height: 100%;
+ width: 1px;
+ }
+ }
+
+ &.vertical {
+ cursor: row-resize;
+ height: 6px;
+ width: 100%;
+
+ &::after {
+ height: 1px;
+ width: 100%;
+ }
+ }
+ }
+</style>
+
+<div
+ aria-orientation={orientation}
+ class="splitter {orientation}"
+ class:dragging
+ onkeydown={onKeydown}
+ onpointerdown={onPointerDown}
+ onpointermove={onPointerMove}
+ onpointerup={onPointerUp}
+ role="separator"
+ tabindex="0"
+></div>
diff --git a/source/library/components/Toolbar.svelte b/source/library/components/Toolbar.svelte
index 8c75668..9882b7d 100644
--- a/source/library/components/Toolbar.svelte
+++ b/source/library/components/Toolbar.svelte
@@ -8,6 +8,7 @@
docsOpen?: boolean;
extras?: Snippet;
historyOpen?: boolean;
+ onFormat?: () => void;
onRun: () => void;
onSelectOperation?: (name: string | null) => void;
onToggleDocs?: () => void;
@@ -24,6 +25,7 @@
docsOpen = false,
extras,
historyOpen = false,
+ onFormat,
onRun,
onSelectOperation,
onToggleDocs,
@@ -128,6 +130,9 @@
{/each}
</select>
{/if}
+ {#if onFormat}
+ <button class="toggle" {disabled} onclick={onFormat} type="button">Format</button>
+ {/if}
{#if extras}{@render extras()}{/if}
<span class="hint">⌘/Ctrl + Enter</span>
{#if schemaLoading}
diff --git a/source/library/fetcher/apq.ts b/source/library/fetcher/apq.ts
new file mode 100644
index 0000000..7f9f3c1
--- /dev/null
+++ b/source/library/fetcher/apq.ts
@@ -0,0 +1,101 @@
+
+
+
+/*** UTILITY ------------------------------------------ ***/
+
+import { buildHeaders, postJson } from "./http-body.ts";
+import type { Fetcher, FetcherOptions } from "./types.ts";
+
+/*** EXPORT ------------------------------------------- ***/
+
+export type ApqOptions = FetcherOptions & {
+ disable?: boolean;
+};
+
+export function createApqFetcher(options: ApqOptions): Fetcher {
+ const cache = new Map<string, string>();
+ const fetchImpl = options.fetch ?? globalThis.fetch;
+
+ return async (req) => {
+ const headers = buildHeaders(options, req);
+ const sha256Hash = await getHash(cache, req.query);
+
+ const extensions = {
+ persistedQuery: {
+ sha256Hash,
+ version: 1
+ }
+ };
+
+ if (options.disable === true) {
+ return await postJson(fetchImpl, options.url, headers, {
+ extensions,
+ operationName: req.operationName,
+ query: req.query,
+ variables: req.variables
+ });
+ }
+
+ const firstResponse = await postJson(fetchImpl, options.url, headers, {
+ extensions,
+ operationName: req.operationName,
+ variables: req.variables
+ });
+
+ if (!isPersistedQueryNotFound(firstResponse))
+ return firstResponse;
+
+ return await postJson(fetchImpl, options.url, headers, {
+ extensions,
+ operationName: req.operationName,
+ query: req.query,
+ variables: req.variables
+ });
+ };
+}
+
+/*** INTERNAL ----------------------------------------- ***/
+
+async function getHash(cache: Map<string, string>, query: string): Promise<string> {
+ const cached = cache.get(query);
+
+ if (cached !== undefined)
+ return cached;
+
+ const hash = await sha256Hex(query);
+ cache.set(query, hash);
+ return hash;
+}
+
+function isPersistedQueryNotFound(response: Record<string, unknown>): boolean {
+ const errors = response.errors;
+
+ if (!Array.isArray(errors))
+ return false;
+
+ for (const entry of errors) {
+ if (entry === null || typeof entry !== "object")
+ continue;
+
+ const err = entry as Record<string, unknown>;
+
+ if (err.message === "PersistedQueryNotFound")
+ return true;
+
+ const ext = err.extensions;
+
+ if (ext !== null && typeof ext === "object") {
+ const code = (ext as Record<string, unknown>).code;
+
+ if (code === "PERSISTED_QUERY_NOT_FOUND")
+ return true;
+ }
+ }
+
+ return false;
+}
+
+async function sha256Hex(input: string): Promise<string> {
+ const buf = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input));
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
+}
diff --git a/source/library/fetcher/http-body.ts b/source/library/fetcher/http-body.ts
new file mode 100644
index 0000000..b079d47
--- /dev/null
+++ b/source/library/fetcher/http-body.ts
@@ -0,0 +1,31 @@
+
+
+
+/*** UTILITY ------------------------------------------ ***/
+
+import type { FetcherOptions, FetcherRequest } from "./types.ts";
+
+/*** EXPORT ------------------------------------------- ***/
+
+export function buildHeaders(options: FetcherOptions, req: FetcherRequest): Record<string, string> {
+ return {
+ "Content-Type": "application/json",
+ ...options.headers,
+ ...req.headers
+ };
+}
+
+export async function postJson(
+ fetchImpl: typeof globalThis.fetch,
+ url: string,
+ headers: Record<string, string>,
+ body: unknown
+): Promise<Record<string, unknown>> {
+ const response = await fetchImpl(url, {
+ body: JSON.stringify(body),
+ headers,
+ method: "POST"
+ });
+
+ return await response.json();
+}
diff --git a/source/library/fetcher/http.ts b/source/library/fetcher/http.ts
index 3138226..64322f2 100644
--- a/source/library/fetcher/http.ts
+++ b/source/library/fetcher/http.ts
@@ -3,6 +3,7 @@
/*** UTILITY ------------------------------------------ ***/
+import { buildHeaders, postJson } from "./http-body.ts";
import type { Fetcher, FetcherOptions } from "./types.ts";
/*** EXPORT ------------------------------------------- ***/
@@ -11,20 +12,10 @@ 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 postJson(fetchImpl, options.url, buildHeaders(options, req), {
+ operationName: req.operationName,
+ query: req.query,
+ variables: req.variables
});
-
- return await response.json();
};
}
diff --git a/source/library/graphql/format.ts b/source/library/graphql/format.ts
new file mode 100644
index 0000000..905e25b
--- /dev/null
+++ b/source/library/graphql/format.ts
@@ -0,0 +1,22 @@
+
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import { parse, print } from "graphql";
+
+/*** EXPORT ------------------------------------------- ***/
+
+export function format(query: string): string {
+ const trimmed = query.trim();
+
+ if (!trimmed)
+ return query;
+
+ try {
+ return print(parse(query));
+ } catch {
+ return query;
+ }
+}
diff --git a/source/library/index.ts b/source/library/index.ts
index 2bec6e4..d68955b 100644
--- a/source/library/index.ts
+++ b/source/library/index.ts
@@ -3,16 +3,20 @@
/*** EXPORT ------------------------------------------- ***/
+export { createApqFetcher } from "./fetcher/apq.ts";
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 { format } from "./graphql/format.ts";
export { HistoryStore } from "./state/history.svelte.ts";
export { lightTheme } from "./themes/light.ts";
+export { matchShortcut } from "./state/keyboard.ts";
export { SchemaStore } from "./state/schema.svelte.ts";
export { SessionStore } from "./state/session.svelte.ts";
+export { validateSessionExport } from "./state/session-io.ts";
+export type { ApqOptions } from "./fetcher/apq.ts";
export type { Extension } from "@codemirror/state";
export type {
@@ -23,13 +27,20 @@ export type {
} from "./fetcher/types.ts";
export type { HistoryEntry, HistoryInput } from "./state/history.svelte.ts";
+export type {
+ ImportResult,
+ SessionExport,
+ TabExport
+} from "./state/session-io.ts";
export type { OperationInfo } from "./graphql/operations.ts";
+export type { ShortcutAction } from "./state/keyboard.ts";
export type { SseFetcherOptions } from "./fetcher/sse.ts";
export type { Storage } from "./state/storage.ts";
export type {
RunOptions,
SubscriptionMode,
Tab,
- TabSeed
+ TabSeed,
+ TabTiming
} from "./state/session.svelte.ts";
export type { WsFetcherOptions } from "./fetcher/websocket.ts";
diff --git a/source/library/state/keyboard.ts b/source/library/state/keyboard.ts
new file mode 100644
index 0000000..5f07b2c
--- /dev/null
+++ b/source/library/state/keyboard.ts
@@ -0,0 +1,50 @@
+
+
+
+
+/*** EXPORT ------------------------------------------- ***/
+
+export type ShortcutAction =
+ | { type: "closeTab" }
+ | { type: "format" }
+ | { type: "newTab" }
+ | { type: "nextTab" }
+ | { type: "prevTab" }
+ | { type: "run" };
+
+export function matchShortcut(event: KeyboardEvent): ShortcutAction | null {
+ const meta = event.metaKey || event.ctrlKey;
+
+ if (!meta)
+ return null;
+
+ if (event.key === "Enter") {
+ if (event.shiftKey)
+ return { type: "newTab" };
+
+ if (!event.altKey)
+ return { type: "run" };
+
+ return null;
+ }
+
+ if (event.shiftKey && !event.altKey) {
+ const key = event.key.toLowerCase();
+
+ if (key === "w")
+ return { type: "closeTab" };
+
+ if (key === "f")
+ return { type: "format" };
+ }
+
+ if (event.altKey && !event.shiftKey) {
+ if (event.key === "ArrowRight")
+ return { type: "nextTab" };
+
+ if (event.key === "ArrowLeft")
+ return { type: "prevTab" };
+ }
+
+ return null;
+}
diff --git a/source/library/state/session-io.ts b/source/library/state/session-io.ts
new file mode 100644
index 0000000..a5e2ea9
--- /dev/null
+++ b/source/library/state/session-io.ts
@@ -0,0 +1,127 @@
+
+
+
+
+/*** UTILITY ------------------------------------------ ***/
+
+import type { Tab } from "./session.svelte.ts";
+
+const MAX_STRING_BYTES = 1024 * 1024;
+const MAX_TABS = 50;
+
+function isObject(value: unknown): value is Record<string, unknown> {
+ return typeof value === "object" && value !== null && !Array.isArray(value);
+}
+
+function stringTooLong(value: string): boolean {
+ return value.length > MAX_STRING_BYTES;
+}
+
+function validateTab(raw: unknown, index: number): TabExport | string {
+ if (!isObject(raw))
+ return `tabs[${index}]: not an object`;
+
+ if (typeof raw.headers !== "string")
+ return `tabs[${index}].headers: not a string`;
+
+ if (typeof raw.query !== "string")
+ return `tabs[${index}].query: not a string`;
+
+ if (typeof raw.title !== "string")
+ return `tabs[${index}].title: not a string`;
+
+ if (typeof raw.variables !== "string")
+ return `tabs[${index}].variables: not a string`;
+
+ if (raw.operationName !== null && typeof raw.operationName !== "string")
+ return `tabs[${index}].operationName: not a string or null`;
+
+ if (stringTooLong(raw.headers))
+ return `tabs[${index}].headers: exceeds 1 MB`;
+
+ if (stringTooLong(raw.query))
+ return `tabs[${index}].query: exceeds 1 MB`;
+
+ if (stringTooLong(raw.title))
+ return `tabs[${index}].title: exceeds 1 MB`;
+
+ if (stringTooLong(raw.variables))
+ return `tabs[${index}].variables: exceeds 1 MB`;
+
+ if (typeof raw.operationName === "string" && stringTooLong(raw.operationName))
+ return `tabs[${index}].operationName: exceeds 1 MB`;
+
+ return {
+ headers: raw.headers,
+ operationName: raw.operationName,
+ query: raw.query,
+ title: raw.title,
+ variables: raw.variables
+ };
+}
+
+/*** EXPORT ------------------------------------------- ***/
+
+export type TabExport = {
+ headers: string;
+ operationName: string | null;
+ query: string;
+ title: string;
+ variables: string;
+};
+
+export type SessionExport = {
+ exportedAt: string;
+ tabs: TabExport[];
+ version: 1;
+};
+
+export type ImportResult = {
+ added: number;
+ errors: string[];
+ skipped: number;
+};
+
+export function tabToExport(tab: Tab): TabExport {
+ return {
+ headers: tab.headers,
+ operationName: tab.operationName,
+ query: tab.query,
+ title: tab.title,
+ variables: tab.variables
+ };
+}
+
+export function validateSessionExport(data: unknown): SessionExport | { error: string } {
+ if (!isObject(data))
+ return { error: "not an object" };
+
+ if (data.version !== 1)
+ return { error: `unsupported version: ${String(data.version)}` };
+
+ if (typeof data.exportedAt !== "string")
+ return { error: "exportedAt: not a string" };
+
+ if (!Array.isArray(data.tabs))
+ return { error: "tabs: not an array" };
+
+ if (data.tabs.length > MAX_TABS)
+ return { error: `tabs: exceeds ${MAX_TABS}` };
+
+ const tabs: TabExport[] = [];
+
+ for (let i = 0; i < data.tabs.length; i++) {
+ const result = validateTab(data.tabs[i], i);
+
+ if (typeof result === "string")
+ return { error: result };
+
+ tabs.push(result);
+ }
+
+ return {
+ exportedAt: data.exportedAt,
+ tabs,
+ version: 1
+ };
+}
diff --git a/source/library/state/session.svelte.ts b/source/library/state/session.svelte.ts
index 9345d29..76777e5 100644
--- a/source/library/state/session.svelte.ts
+++ b/source/library/state/session.svelte.ts
@@ -4,11 +4,18 @@
/*** UTILITY ------------------------------------------ ***/
import type { Fetcher, FetcherResult } from "../fetcher/types.ts";
+import { format } from "../graphql/format.ts";
import {
deriveTitle,
parseOperations,
type OperationInfo
} from "../graphql/operations.ts";
+import {
+ tabToExport,
+ type ImportResult,
+ type SessionExport,
+ type TabExport
+} from "./session-io.ts";
import type { Storage } from "./storage.ts";
const STORAGE_KEY = "session";
@@ -26,6 +33,12 @@ type Snapshot = {
/*** EXPORT ------------------------------------------- ***/
+export type TabTiming = {
+ endMs: number;
+ firstByteMs: number;
+ startMs: number;
+};
+
export type Tab = {
headers: string;
id: string;
@@ -33,6 +46,8 @@ export type Tab = {
operations: OperationInfo[];
query: string;
result: string;
+ streamIntervals: number[];
+ timing: TabTiming | null;
title: string;
titleDirty: boolean;
variables: string;
@@ -103,6 +118,85 @@ export class SessionStore {
this.activeId = this.tabs[Math.max(0, idx - 1)].id;
}
+ exportAll(): SessionExport {
+ return {
+ exportedAt: new Date().toISOString(),
+ tabs: this.tabs.map(tabToExport),
+ version: 1
+ };
+ }
+
+ exportTab(id: string): TabExport | null {
+ const tab = this.tabs.find((t) => t.id === id);
+
+ if (!tab)
+ return null;
+
+ return tabToExport(tab);
+ }
+
+ formatActive() {
+ const tab = this.active;
+
+ if (!tab)
+ return;
+
+ const next = format(tab.query);
+
+ if (next === tab.query)
+ return;
+
+ tab.query = next;
+ tab.operations = parseOperations(next);
+
+ if (!tab.titleDirty)
+ tab.title = deriveTitle(next, tab.operations);
+ }
+
+ importTabs(data: SessionExport, opts: { mode: "append" | "replace" }): ImportResult {
+ const errors: string[] = [];
+ const capped = data.tabs.slice(0, 50);
+ const skipped = data.tabs.length - capped.length;
+
+ if (opts.mode === "replace") {
+ this.tabs = [];
+
+ for (const t of capped)
+ this.tabs.push(this.#seeded(t));
+
+ if (this.tabs.length === 0) {
+ const fresh = this.#blank();
+ this.tabs = [fresh];
+ this.activeId = fresh.id;
+ } else {
+ this.activeId = this.tabs[0].id;
+ }
+
+ return { added: capped.length, errors, skipped };
+ }
+
+ for (const t of capped)
+ this.tabs.push(this.#seeded(t));
+
+ if (capped.length > 0)
+ this.activeId = this.tabs[this.tabs.length - 1].id;
+
+ return { added: capped.length, errors, skipped };
+ }
+
+ nextTab() {
+ if (this.tabs.length <= 1)
+ return;
+
+ const idx = this.tabs.findIndex((t) => t.id === this.activeId);
+
+ if (idx === -1)
+ return;
+
+ const nextIdx = (idx + 1) % this.tabs.length;
+ this.activeId = this.tabs[nextIdx].id;
+ }
+
persist() {
this.#storage.set<Snapshot>(STORAGE_KEY, {
activeId: this.activeId,
@@ -110,6 +204,19 @@ export class SessionStore {
});
}
+ prevTab() {
+ if (this.tabs.length <= 1)
+ return;
+
+ const idx = this.tabs.findIndex((t) => t.id === this.activeId);
+
+ if (idx === -1)
+ return;
+
+ const prevIdx = (idx - 1 + this.tabs.length) % this.tabs.length;
+ this.activeId = this.tabs[prevIdx].id;
+ }
+
renameActive(title: string) {
if (!this.active)
return;
@@ -137,6 +244,10 @@ export class SessionStore {
const mode = options.subscriptionMode ?? "append";
const signal = options.signal;
+ tab.streamIntervals = [];
+ const startMs = performance.now();
+ tab.timing = { endMs: startMs, firstByteMs: startMs, startMs };
+
try {
const variables = tab.variables.trim() ? JSON.parse(tab.variables) : {};
const headers = tab.headers.trim() ? JSON.parse(tab.headers) : {};
@@ -151,6 +262,8 @@ export class SessionStore {
if (isAsyncIterable<FetcherResult>(result)) {
tab.result = "";
const iterator = result[Symbol.asyncIterator]();
+ let firstByteRecorded = false;
+ let previousMs = startMs;
try {
while (true) {
@@ -164,6 +277,23 @@ export class SessionStore {
if (step.done)
break;
+ const now = performance.now();
+
+ if (!firstByteRecorded) {
+ firstByteRecorded = true;
+ previousMs = now;
+ tab.timing = { ...tab.timing, endMs: now, firstByteMs: now };
+ } else {
+ const delta = now - previousMs;
+ previousMs = now;
+
+ if (tab.streamIntervals.length >= 500)
+ tab.streamIntervals.shift();
+
+ tab.streamIntervals = [...tab.streamIntervals, delta];
+ tab.timing = { ...tab.timing, endMs: now };
+ }
+
const payload = JSON.stringify(step.value, null, 2);
if (mode === "append") {
@@ -181,10 +311,14 @@ export class SessionStore {
return true;
}
+ const firstByteMs = performance.now();
+ tab.timing = { ...tab.timing, firstByteMs };
tab.result = JSON.stringify(result, null, 2);
+ tab.timing = { ...tab.timing, endMs: performance.now() };
return true;
} catch(err) {
tab.result = JSON.stringify({ error: String(err) }, null, 2);
+ tab.timing = { ...tab.timing, endMs: performance.now() };
return false;
}
}
@@ -201,6 +335,8 @@ export class SessionStore {
tab.operationName = seed.operationName ?? null;
tab.query = query;
tab.result = "";
+ tab.streamIntervals = [];
+ tab.timing = null;
tab.variables = seed.variables ?? "{}";
if (seed.title && !tab.titleDirty)
@@ -251,6 +387,8 @@ export class SessionStore {
operations: [],
query: "",
result: "",
+ streamIntervals: [],
+ timing: null,
title: "untitled",
titleDirty: false,
variables: "{}"
@@ -265,6 +403,8 @@ export class SessionStore {
operations: raw.operations ?? parseOperations(raw.query ?? ""),
query: raw.query ?? "",
result: raw.result ?? "",
+ streamIntervals: [],
+ timing: null,
title: raw.title ?? "untitled",
titleDirty: raw.titleDirty ?? raw.title !== "untitled",
variables: raw.variables ?? "{}"
@@ -283,6 +423,8 @@ export class SessionStore {
operations,
query,
result: "",
+ streamIntervals: [],
+ timing: null,
title,
titleDirty: Boolean(seed.title),
variables: seed.variables ?? "{}"