From 510fd8cbe53abb39cba2c7cbaaefcf2783dc0066 Mon Sep 17 00:00:00 2001
From: "netop://ウィビ" <paul@webb.page>
Date: Fri, 24 Apr 2026 16:37:33 -0700
Subject: 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
---
 source/library/GraphiQL.svelte | 307 +++++++++++++++++++++++++++++++++++------
 1 file changed, 263 insertions(+), 44 deletions(-)

(limited to 'source/library/GraphiQL.svelte')

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>
-- 
cgit v1.2.3

