aboutsummaryrefslogtreecommitdiff
path: root/source/library/GraphiQL.svelte
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/GraphiQL.svelte
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/GraphiQL.svelte')
-rw-r--r--source/library/GraphiQL.svelte307
1 files changed, 263 insertions, 44 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>