aboutsummaryrefslogtreecommitdiff
path: root/source/library/state
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/state
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/state')
-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
3 files changed, 319 insertions, 0 deletions
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 ?? "{}"