aboutsummaryrefslogtreecommitdiff
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
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 '')
-rw-r--r--PLAN.md96
-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
-rw-r--r--tests/apq.test.ts151
-rw-r--r--tests/format.test.ts28
-rw-r--r--tests/keyboard.test.ts111
-rw-r--r--tests/session-io.test.ts201
-rw-r--r--tests/timing.test.ts100
19 files changed, 1710 insertions, 76 deletions
diff --git a/PLAN.md b/PLAN.md
index 25f806e..f97e7ac 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -88,14 +88,94 @@ Replace the stubs in `fetcher/sse.ts` and `fetcher/websocket.ts`.
This keeps the plugin story type-safe without a bespoke registry.
-### v0.6+ — Nice to have
-
-- Export/import session state as JSON (for sharing)
-- Query formatting button (uses `graphql`'s `print()`)
-- Keyboard shortcut for new tab (Cmd+T), close tab (Cmd+W), next/prev tab (Cmd+Opt+Right/Left)
-- Split-pane resize handles (use CSS `resize: horizontal` or a lightweight splitter)
-- Request/response timing display
-- Persisted query support (APQ)
+### v0.6 — Ergonomics (keyboard + format)
+
+**Keyboard shortcuts.** Extend the existing `onKeydown` handler in `GraphiQL.svelte` (already bound via `<svelte:window>`). Add, in priority order:
+
+- `Cmd/Ctrl + Shift + Enter` — new tab (`session.addTab()`). We do **not** use `Cmd+T`; browsers intercept it and `preventDefault` is unreliable across hosts. Document this in README — if an embedder is running in Tauri/Electron they can remap to `Cmd+T` themselves.
+- `Cmd/Ctrl + Shift + W` — close active tab (`session.closeTab(session.activeId)`). Same reasoning — `Cmd+W` is browser-owned.
+- `Cmd/Ctrl + Alt + Right/Left` — next/prev tab. Wraps around end-to-start. No-op on a single tab.
+- `Cmd/Ctrl + Shift + F` — format active query (see below).
+
+Extract the handler into `state/keyboard.ts` as a pure function `matchShortcut(event) → Action | null` so `tests/keyboard.test.ts` can cover matrix cases without a DOM.
+
+**Query formatting.** New file `graphql/format.ts` exporting `format(query: string): string` that calls `print(parse(query))` from `graphql`. On parse failure, return the original string unchanged — don't throw. Wire into:
+
+- A `Format` button in `Toolbar.svelte`, placed between the operation picker and `extras`. Disabled when the active tab query is empty or unparseable (check via `operations.length === 0 && query.trim().length > 0`? No — cheaper to attempt `format()` and compare; if equal to input *and* parse would have failed, disable. Actually simplest: always enable, no-op on unparseable).
+- `Cmd/Ctrl + Shift + F` shortcut above. Route through `session.formatActive()` which sets `tab.query = format(tab.query)` and re-derives operations.
+
+Gotcha: `print()` strips comments. Document this — we are not going to keep a CST round-trip for v0.6.
+
+### v0.7 — Session portability
+
+**Export/import session JSON.** Extend `SessionStore` with:
+
+- `exportTab(id: string): TabExport | null` — strips `id`, `result`, `operations` (derivable), keeps `headers`, `operationName`, `query`, `title`, `variables`, and a version tag.
+- `exportAll(): SessionExport` — `{ version: 1, exportedAt: ISO, tabs: TabExport[] }`.
+- `importTabs(data: SessionExport, opts: { mode: "replace" | "append" }): ImportResult` — validates the `version` field and tab shape (lightweight hand-rolled check, no zod); rejects unknown versions with a descriptive error; returns `{ added: number, skipped: number, errors: string[] }`.
+
+Shape validation lives in `state/session-io.ts` so it can be unit tested without the store. Treat imported JSON as untrusted — reject if any string field exceeds 1 MB (covers accidental mega-pastes), cap tab count at 50 per import.
+
+**UI surface.** Two buttons in the History panel header (already has `Clear`) — `Export` and `Import`. Export downloads via `Blob` + object URL, filename `graphiql-session-{ISO}.json`. Import uses a hidden `<input type="file" accept="application/json">` triggered by the button. Show an `ImportResult` toast-style row inline in the panel on completion; no toast library.
+
+### v0.8 — Layout resize
+
+**Splitter component.** `components/Splitter.svelte`, no external deps. Props: `orientation: "horizontal" | "vertical"`, `min: number`, `max: number`, `value: number`, `onChange: (v: number) => void`. Internal: `pointerdown` captures the pointer, `pointermove` computes delta against the parent element's bounding rect, `pointerup` releases capture. Writes to `value` via `onChange` on every move (consumer is expected to throttle via `$effect` if needed — store writes are already debounced).
+
+**Wire into `GraphiQL.svelte`.** Replace the fixed `grid-template-columns` / `grid-template-rows` with CSS custom properties driven by `$state`:
+
+- `--graphiql-left-width` (default `1fr`, resized via middle splitter between query column and result)
+- `--graphiql-bottom-height` (default split between editor and variables/headers pane — currently `2fr auto 1fr`, change the `1fr` to `var(--graphiql-bottom-height, 1fr)`)
+- `--graphiql-docs-width` (default `320px`, only present when docs open)
+- `--graphiql-history-width` (default `260px`, only present when history open)
+
+Persist each to storage under `layout.{key}` keys, hydrated in the constructor. Storage accepts numbers; we re-stringify as `{n}px` or fractions depending on which axis.
+
+Three splitters total: between history/left, between left/right, between right/docs, and a horizontal one inside `.left` between the query editor and the variables/headers pane. Keep total splitter DOM cost at four elements.
+
+### v0.9 — Timing display
+
+**Instrumentation at the run boundary.** Modify `SessionStore.run()` only — don't touch the `Fetcher` signature. Record:
+
+- `tab.timing.startMs = performance.now()` before the fetcher call.
+- `tab.timing.firstByteMs = performance.now()` on first payload (either the awaited object or the first iterator step).
+- `tab.timing.endMs = performance.now()` on completion.
+- For subscriptions: `tab.streamIntervals: number[]` — delta between each successive payload, capped at 500 entries per run.
+
+Add `timing` and `streamIntervals` fields to `Tab`. Render a small row under `ResultViewer` (not a footer snippet — that's for consumers) showing `→ 42ms · first byte 18ms` for one-shot, or `→ 12 messages · median 430ms` for streams. Compute the median inline; no helpers dir.
+
+Subscription streams don't have a single end; treat `endMs` as the time of the last payload and update it on each step so consumers see a rolling duration while streaming.
+
+**Do not** attempt to surface server-side trace extensions (Apollo Tracing, `response.extensions.tracing`) in v0.9 — that's a separate feature with its own UI. Document as future work.
+
+### v0.10 — Persisted queries (APQ)
+
+**Wrapper fetcher.** New file `fetcher/apq.ts`, exported as `createApqFetcher(inner: Fetcher, options?: ApqOptions): Fetcher`. Implements the Apollo Automatic Persisted Queries protocol:
+
+1. Compute `sha256Hash` of the query string with `globalThis.crypto.subtle.digest("SHA-256", ...)`. Cache per-query in a `Map<string, string>` to avoid rehashing.
+2. First attempt — send `{ operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash } } }` without the `query`. Wrap the inner fetcher by passing a synthetic `FetcherRequest` with `query: ""` and stash the hash in `headers["x-apq-hash"]`? No — cleaner: don't reuse `inner`, build the body ourselves. This means APQ needs its own HTTP fetcher variant or needs to extend the inner fetcher contract.
+
+ **Decision:** APQ is HTTP-specific. Implement `createApqFetcher(options: FetcherOptions): Fetcher` that builds the body directly, bypassing `createHttpFetcher`. Share the header-merging logic via a small helper `fetcher/http-body.ts`. SSE/WS get no APQ support in v0.10 — document as out of scope.
+
+3. If response contains `{ errors: [{ message: "PersistedQueryNotFound", ... }] }`, retry with `{ query, operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash } } }`.
+4. Cache the hash → query mapping in memory for the fetcher's lifetime; no disk persistence in v0.10 (servers already cache).
+
+`ApqOptions`:
+
+- Everything from `FetcherOptions`
+- `disable?: boolean` — escape hatch for debugging, forces full query on every request.
+
+**Export.** Add `createApqFetcher` and `ApqOptions` to `source/library/index.ts`. No UI changes — this is consumer-facing plumbing.
+
+### v1.0 — Stabilization
+
+Not a feature stage. Before tagging v1.0:
+
+- README audit: every exported name has an example.
+- `deno publish --dry-run` clean, no type errors.
+- Every `*.svelte.ts` store has a companion `tests/*.test.ts`.
+- Theme variables documented in one table in the README.
+- One known-good end-to-end story: run against a real GraphQL endpoint (e.g., the Countries API) via an example app in `examples/` (optional, can skip if the README reproducer is enough).
## Publishing
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 ?? "{}"
diff --git a/tests/apq.test.ts b/tests/apq.test.ts
new file mode 100644
index 0000000..66607cd
--- /dev/null
+++ b/tests/apq.test.ts
@@ -0,0 +1,151 @@
+
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import { expect, test } from "vitest";
+
+/*** UTILITY ------------------------------------------ ***/
+
+import { createApqFetcher } from "../source/library/fetcher/apq.ts";
+
+/*** HELPERS ------------------------------------------ ***/
+
+type Call = { body: Record<string, unknown>; url: string };
+
+function createStub(queue: Array<Record<string, unknown>>): {
+ calls: Call[];
+ stub: typeof fetch;
+} {
+ const calls: Call[] = [];
+
+ const stub: typeof fetch = (input, init) => {
+ const body = JSON.parse((init?.body as string) ?? "null") as Record<string, unknown>;
+ calls.push({ body, url: String(input) });
+
+ const next = queue.shift();
+
+ if (next === undefined)
+ throw new Error("stub fetch exhausted");
+
+ return Promise.resolve(new Response(JSON.stringify(next), { status: 200 }));
+ };
+
+ return { calls, stub };
+}
+
+async function expectedHash(query: string): Promise<string> {
+ const buf = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(query));
+ return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join("");
+}
+
+function readPersistedHash(body: Record<string, unknown>): string {
+ const extensions = body.extensions as Record<string, unknown>;
+ const persistedQuery = extensions.persistedQuery as Record<string, unknown>;
+ return persistedQuery.sha256Hash as string;
+}
+
+/*** TESTS -------------------------------------------- ***/
+
+test("apq cache miss retries with the full query", async () => {
+ const { calls, stub } = createStub([
+ { errors: [{ message: "PersistedQueryNotFound" }] },
+ { data: { hello: "world" } }
+ ]);
+
+ const fetcher = createApqFetcher({
+ fetch: stub,
+ url: "https://example.test/graphql"
+ });
+
+ const result = await fetcher({ query: "{ hello }" });
+
+ expect(result).toEqual({ data: { hello: "world" } });
+ expect(calls.length).toEqual(2);
+ expect(calls[0].body.query).toEqual(undefined);
+ expect(calls[1].body.query).toEqual("{ hello }");
+});
+
+test("apq cache hit sends a single request without the query", async () => {
+ const { calls, stub } = createStub([{ data: { hello: "world" } }]);
+
+ const fetcher = createApqFetcher({
+ fetch: stub,
+ url: "https://example.test/graphql"
+ });
+
+ const result = await fetcher({ query: "{ hello }" });
+
+ expect(result).toEqual({ data: { hello: "world" } });
+ expect(calls.length).toEqual(1);
+ expect(calls[0].body.query).toEqual(undefined);
+ expect(calls[0].body.extensions !== undefined).toBe(true);
+});
+
+test("apq hashes are stable across calls with the same query", async () => {
+ const { calls, stub } = createStub([
+ { data: { hello: "world" } },
+ { data: { hello: "world" } }
+ ]);
+
+ const fetcher = createApqFetcher({
+ fetch: stub,
+ url: "https://example.test/graphql"
+ });
+
+ await fetcher({ query: "{ hello }" });
+ await fetcher({ query: "{ hello }" });
+
+ expect(calls.length).toEqual(2);
+ expect(readPersistedHash(calls[0].body)).toEqual(readPersistedHash(calls[1].body));
+ expect(readPersistedHash(calls[0].body)).toEqual(await expectedHash("{ hello }"));
+});
+
+test("apq with disable sends the full query and the extension in one request", async () => {
+ const { calls, stub } = createStub([{ data: { hello: "world" } }]);
+
+ const fetcher = createApqFetcher({
+ disable: true,
+ fetch: stub,
+ url: "https://example.test/graphql"
+ });
+
+ const result = await fetcher({ query: "{ hello }" });
+
+ expect(result).toEqual({ data: { hello: "world" } });
+ expect(calls.length).toEqual(1);
+ expect(calls[0].body.query).toEqual("{ hello }");
+ expect(readPersistedHash(calls[0].body)).toEqual(await expectedHash("{ hello }"));
+});
+
+test("apq hash is lowercase hex 64 chars", async () => {
+ const { calls, stub } = createStub([{ data: { hello: "world" } }]);
+
+ const fetcher = createApqFetcher({
+ fetch: stub,
+ url: "https://example.test/graphql"
+ });
+
+ await fetcher({ query: "{ hello }" });
+
+ expect(readPersistedHash(calls[0].body)).toMatch(/^[0-9a-f]{64}$/);
+});
+
+test("apq accepts PERSISTED_QUERY_NOT_FOUND extension code", async () => {
+ const { calls, stub } = createStub([
+ { errors: [{ extensions: { code: "PERSISTED_QUERY_NOT_FOUND" }, message: "nope" }] },
+ { data: { hello: "world" } }
+ ]);
+
+ const fetcher = createApqFetcher({
+ fetch: stub,
+ url: "https://example.test/graphql"
+ });
+
+ const result = await fetcher({ query: "{ hello }" });
+
+ expect(result).toEqual({ data: { hello: "world" } });
+ expect(calls.length).toEqual(2);
+ expect(calls[1].body.query).toEqual("{ hello }");
+});
diff --git a/tests/format.test.ts b/tests/format.test.ts
new file mode 100644
index 0000000..ae5d40c
--- /dev/null
+++ b/tests/format.test.ts
@@ -0,0 +1,28 @@
+
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import { expect, test } from "vitest";
+
+/*** UTILITY ------------------------------------------ ***/
+
+import { format } from "../source/library/graphql/format.ts";
+
+/*** TESTS -------------------------------------------- ***/
+
+test("format pretty-prints a valid query", () => {
+ const input = "query Foo{viewer{id name}}";
+ const expected = "query Foo {\n viewer {\n id\n name\n }\n}";
+ expect(format(input)).toEqual(expected);
+});
+
+test("format returns the input unchanged on parse failure", () => {
+ const input = "query { ...";
+ expect(format(input)).toEqual(input);
+});
+
+test("format returns empty string for empty input", () => {
+ expect(format("")).toEqual("");
+});
diff --git a/tests/keyboard.test.ts b/tests/keyboard.test.ts
new file mode 100644
index 0000000..3a7f3cc
--- /dev/null
+++ b/tests/keyboard.test.ts
@@ -0,0 +1,111 @@
+
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import { expect, test } from "vitest";
+
+/*** UTILITY ------------------------------------------ ***/
+
+import { matchShortcut } from "../source/library/state/keyboard.ts";
+
+type EventInit = {
+ altKey?: boolean;
+ ctrlKey?: boolean;
+ key: string;
+ metaKey?: boolean;
+ shiftKey?: boolean;
+};
+
+function makeEvent(init: EventInit): KeyboardEvent {
+ return {
+ altKey: init.altKey ?? false,
+ ctrlKey: init.ctrlKey ?? false,
+ key: init.key,
+ metaKey: init.metaKey ?? false,
+ shiftKey: init.shiftKey ?? false
+ } as KeyboardEvent;
+}
+
+/*** TESTS -------------------------------------------- ***/
+
+test("matchShortcut returns null for plain Enter", () => {
+ expect(matchShortcut(makeEvent({ key: "Enter" }))).toEqual(null);
+});
+
+test("matchShortcut returns null when no modifier is pressed", () => {
+ expect(matchShortcut(makeEvent({ key: "f", shiftKey: true }))).toEqual(null);
+});
+
+test("matchShortcut maps Cmd+Enter to run", () => {
+ expect(
+ matchShortcut(makeEvent({ key: "Enter", metaKey: true }))
+ ).toEqual({ type: "run" });
+});
+
+test("matchShortcut maps Ctrl+Enter to run", () => {
+ expect(
+ matchShortcut(makeEvent({ ctrlKey: true, key: "Enter" }))
+ ).toEqual({ type: "run" });
+});
+
+test("matchShortcut maps Cmd+Shift+Enter to newTab", () => {
+ expect(
+ matchShortcut(makeEvent({ key: "Enter", metaKey: true, shiftKey: true }))
+ ).toEqual({ type: "newTab" });
+});
+
+test("matchShortcut maps Ctrl+Shift+Enter to newTab", () => {
+ expect(
+ matchShortcut(makeEvent({ ctrlKey: true, key: "Enter", shiftKey: true }))
+ ).toEqual({ type: "newTab" });
+});
+
+test("matchShortcut maps Cmd+Shift+W to closeTab", () => {
+ expect(
+ matchShortcut(makeEvent({ key: "w", metaKey: true, shiftKey: true }))
+ ).toEqual({ type: "closeTab" });
+});
+
+test("matchShortcut maps Ctrl+Shift+W to closeTab", () => {
+ expect(
+ matchShortcut(makeEvent({ ctrlKey: true, key: "W", shiftKey: true }))
+ ).toEqual({ type: "closeTab" });
+});
+
+test("matchShortcut maps Cmd+Shift+F to format on mac", () => {
+ expect(
+ matchShortcut(makeEvent({ key: "f", metaKey: true, shiftKey: true }))
+ ).toEqual({ type: "format" });
+});
+
+test("matchShortcut maps Ctrl+Shift+F to format on non-mac", () => {
+ expect(
+ matchShortcut(makeEvent({ ctrlKey: true, key: "F", shiftKey: true }))
+ ).toEqual({ type: "format" });
+});
+
+test("matchShortcut maps Cmd+Alt+ArrowRight to nextTab", () => {
+ expect(
+ matchShortcut(makeEvent({ altKey: true, key: "ArrowRight", metaKey: true }))
+ ).toEqual({ type: "nextTab" });
+});
+
+test("matchShortcut maps Ctrl+Alt+ArrowLeft to prevTab", () => {
+ expect(
+ matchShortcut(makeEvent({ altKey: true, ctrlKey: true, key: "ArrowLeft" }))
+ ).toEqual({ type: "prevTab" });
+});
+
+test("matchShortcut ignores unrelated Cmd+key combos", () => {
+ expect(
+ matchShortcut(makeEvent({ key: "s", metaKey: true }))
+ ).toEqual(null);
+});
+
+test("matchShortcut ignores Cmd+Alt+Enter", () => {
+ expect(
+ matchShortcut(makeEvent({ altKey: true, key: "Enter", metaKey: true }))
+ ).toEqual(null);
+});
diff --git a/tests/session-io.test.ts b/tests/session-io.test.ts
new file mode 100644
index 0000000..6de919b
--- /dev/null
+++ b/tests/session-io.test.ts
@@ -0,0 +1,201 @@
+
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import { expect, test } from "vitest";
+
+/*** UTILITY ------------------------------------------ ***/
+
+import {
+ tabToExport,
+ validateSessionExport,
+ type SessionExport
+} from "../source/library/state/session-io.ts";
+
+type TabInput = Parameters<typeof tabToExport>[0];
+
+function validExport(): SessionExport {
+ return {
+ exportedAt: "2026-04-24T00:00:00.000Z",
+ tabs: [
+ {
+ headers: "{}",
+ operationName: "MyOp",
+ query: "query MyOp { hello }",
+ title: "MyOp",
+ variables: "{}"
+ }
+ ],
+ version: 1
+ };
+}
+
+function isError(result: unknown): result is { error: string } {
+ return typeof result === "object" &&
+ result !== null &&
+ "error" in result;
+}
+
+/*** TESTS -------------------------------------------- ***/
+
+test("validateSessionExport round-trips a valid payload unchanged", () => {
+ const data = validExport();
+ const result = validateSessionExport(data);
+
+ expect(isError(result)).toBe(false);
+ expect(result).toEqual(data);
+});
+
+test("validateSessionExport rejects non-object input", () => {
+ expect(isError(validateSessionExport(null))).toBe(true);
+ expect(isError(validateSessionExport("nope"))).toBe(true);
+ expect(isError(validateSessionExport(42))).toBe(true);
+ expect(isError(validateSessionExport([]))).toBe(true);
+});
+
+test("validateSessionExport rejects wrong version", () => {
+ const zero = validateSessionExport({ ...validExport(), version: 0 });
+ const two = validateSessionExport({ ...validExport(), version: 2 });
+ const missing = validateSessionExport({
+ exportedAt: "2026-04-24T00:00:00.000Z",
+ tabs: []
+ });
+
+ expect(isError(zero)).toBe(true);
+ expect(isError(two)).toBe(true);
+ expect(isError(missing)).toBe(true);
+});
+
+test("validateSessionExport rejects missing or non-string exportedAt", () => {
+ const missing = validateSessionExport({ tabs: [], version: 1 });
+ const wrongType = validateSessionExport({ exportedAt: 0, tabs: [], version: 1 });
+
+ expect(isError(missing)).toBe(true);
+ expect(isError(wrongType)).toBe(true);
+});
+
+test("validateSessionExport rejects non-array tabs", () => {
+ const result = validateSessionExport({
+ exportedAt: "2026-04-24T00:00:00.000Z",
+ tabs: "not-an-array",
+ version: 1
+ });
+
+ expect(isError(result)).toBe(true);
+});
+
+test("validateSessionExport rejects tab with non-string query", () => {
+ const result = validateSessionExport({
+ exportedAt: "2026-04-24T00:00:00.000Z",
+ tabs: [
+ {
+ headers: "{}",
+ operationName: null,
+ query: 42,
+ title: "t",
+ variables: "{}"
+ }
+ ],
+ version: 1
+ });
+
+ expect(isError(result)).toBe(true);
+});
+
+test("validateSessionExport rejects tab with wrong-typed operationName", () => {
+ const result = validateSessionExport({
+ exportedAt: "2026-04-24T00:00:00.000Z",
+ tabs: [
+ {
+ headers: "{}",
+ operationName: 42,
+ query: "",
+ title: "t",
+ variables: "{}"
+ }
+ ],
+ version: 1
+ });
+
+ expect(isError(result)).toBe(true);
+});
+
+test("validateSessionExport accepts null operationName", () => {
+ const result = validateSessionExport({
+ exportedAt: "2026-04-24T00:00:00.000Z",
+ tabs: [
+ {
+ headers: "{}",
+ operationName: null,
+ query: "",
+ title: "t",
+ variables: "{}"
+ }
+ ],
+ version: 1
+ });
+
+ expect(isError(result)).toBe(false);
+});
+
+test("validateSessionExport rejects string field > 1 MB", () => {
+ const big = "x".repeat(1024 * 1024 + 1);
+ const result = validateSessionExport({
+ exportedAt: "2026-04-24T00:00:00.000Z",
+ tabs: [
+ {
+ headers: "{}",
+ operationName: null,
+ query: big,
+ title: "t",
+ variables: "{}"
+ }
+ ],
+ version: 1
+ });
+
+ expect(isError(result)).toBe(true);
+});
+
+test("validateSessionExport rejects > 50 tabs", () => {
+ const tabs = Array.from({ length: 51 }, () => ({
+ headers: "{}",
+ operationName: null,
+ query: "",
+ title: "t",
+ variables: "{}"
+ }));
+ const result = validateSessionExport({
+ exportedAt: "2026-04-24T00:00:00.000Z",
+ tabs,
+ version: 1
+ });
+
+ expect(isError(result)).toBe(true);
+});
+
+test("tabToExport strips id, result, operations, titleDirty", () => {
+ const tab: TabInput = {
+ headers: "{\"x\":1}",
+ id: "abc-123",
+ operationName: "MyOp",
+ operations: [{ name: "MyOp", type: "query" }],
+ query: "query MyOp { hello }",
+ result: "{\"data\":{}}",
+ streamIntervals: [],
+ timing: null,
+ title: "MyOp",
+ titleDirty: true,
+ variables: "{}"
+ };
+
+ expect(tabToExport(tab)).toEqual({
+ headers: "{\"x\":1}",
+ operationName: "MyOp",
+ query: "query MyOp { hello }",
+ title: "MyOp",
+ variables: "{}"
+ });
+});
diff --git a/tests/timing.test.ts b/tests/timing.test.ts
new file mode 100644
index 0000000..e2a0a06
--- /dev/null
+++ b/tests/timing.test.ts
@@ -0,0 +1,100 @@
+
+
+
+
+/*** IMPORT ------------------------------------------- ***/
+
+import { expect, test } from "vitest";
+
+/*** UTILITY ------------------------------------------ ***/
+
+import type { Fetcher, FetcherResult } from "../source/library/fetcher/types.ts";
+import { SessionStore } from "../source/library/state/session.svelte.ts";
+import { createMemoryStorage } from "../source/library/state/storage.ts";
+
+function delay(ms: number): Promise<void> {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/*** TESTS -------------------------------------------- ***/
+
+test("run() populates timing for a one-shot fetcher", async () => {
+ const store = new SessionStore(createMemoryStorage());
+ const tab = store.active;
+
+ expect(tab).toBeDefined();
+
+ if (!tab)
+ return;
+
+ const fetcher: Fetcher = () => Promise.resolve({ data: { hello: "world" } });
+ const ok = await store.run(fetcher);
+
+ expect(ok).toBe(true);
+ expect(tab.timing).not.toBeNull();
+
+ if (tab.timing === null)
+ return;
+
+ expect(tab.timing.startMs <= tab.timing.firstByteMs).toBe(true);
+ expect(tab.timing.firstByteMs <= tab.timing.endMs).toBe(true);
+ expect(tab.timing.endMs - tab.timing.startMs >= 0).toBe(true);
+ expect(tab.streamIntervals.length).toEqual(0);
+});
+
+test("run() records intervals between async iterable payloads", async () => {
+ const store = new SessionStore(createMemoryStorage());
+ const tab = store.active;
+
+ expect(tab).toBeDefined();
+
+ if (!tab)
+ return;
+
+ async function* stream(): AsyncGenerator<FetcherResult> {
+ yield { data: { n: 1 } };
+ await delay(5);
+ yield { data: { n: 2 } };
+ await delay(5);
+ yield { data: { n: 3 } };
+ }
+
+ const fetcher: Fetcher = () => stream();
+ const ok = await store.run(fetcher);
+
+ expect(ok).toBe(true);
+ expect(tab.streamIntervals.length).toEqual(2);
+ expect(tab.timing).not.toBeNull();
+
+ if (tab.timing === null)
+ return;
+
+ expect(tab.timing.firstByteMs >= tab.timing.startMs).toBe(true);
+ expect(tab.timing.endMs >= tab.timing.firstByteMs).toBe(true);
+
+ for (const delta of tab.streamIntervals)
+ expect(delta >= 0).toBe(true);
+});
+
+test("run() resets timing and streamIntervals on each invocation", async () => {
+ const store = new SessionStore(createMemoryStorage());
+ const tab = store.active;
+
+ expect(tab).toBeDefined();
+
+ if (!tab)
+ return;
+
+ async function* stream(): AsyncGenerator<FetcherResult> {
+ yield { data: { n: 1 } };
+ await delay(5);
+ yield { data: { n: 2 } };
+ }
+
+ await store.run(() => stream());
+ expect(tab.streamIntervals.length).toEqual(1);
+
+ await store.run(() => Promise.resolve({ data: {} }));
+ expect(tab.streamIntervals.length).toEqual(0);
+ expect(tab.timing).not.toBeNull();
+});