diff options
Diffstat (limited to 'source/library/state')
| -rw-r--r-- | source/library/state/keyboard.ts | 50 | ||||
| -rw-r--r-- | source/library/state/session-io.ts | 127 | ||||
| -rw-r--r-- | source/library/state/session.svelte.ts | 142 |
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 ?? "{}" |