/*** UTILITY ------------------------------------------ ***/ import type { Fetcher, FetcherResult } from "../fetcher/types.ts"; import { deriveTitle, parseOperations, type OperationInfo } from "../graphql/operations.ts"; import type { Storage } from "./storage.ts"; const STORAGE_KEY = "session"; function isAsyncIterable(value: unknown): value is AsyncIterable { return typeof value === "object" && value !== null && typeof (value as AsyncIterable)[Symbol.asyncIterator] === "function"; } type Snapshot = { activeId: string; tabs: Tab[]; }; /*** EXPORT ------------------------------------------- ***/ export type Tab = { headers: string; id: string; operationName: string | null; operations: OperationInfo[]; query: string; result: string; title: string; titleDirty: boolean; variables: string; }; export type TabSeed = { headers?: string; operationName?: string | null; query?: string; title?: string; variables?: string; }; export type SubscriptionMode = "append" | "replace"; export type RunOptions = { signal?: AbortSignal; subscriptionMode?: SubscriptionMode; }; export class SessionStore { activeId = $state(""); tabs = $state([]); active = $derived(this.tabs.find((t) => t.id === this.activeId)); #storage: Storage; constructor(storage: Storage) { this.#storage = storage; const restored = storage.get(STORAGE_KEY); if (restored && restored.tabs.length > 0) { this.tabs = restored.tabs.map((t) => this.#hydrate(t)); this.activeId = this.tabs.some((t) => t.id === restored.activeId) ? restored.activeId : this.tabs[0].id; } else { const tab = this.#blank(); this.tabs = [tab]; this.activeId = tab.id; } } addTab(seed?: TabSeed) { const tab = seed ? this.#seeded(seed) : this.#blank(); this.tabs.push(tab); this.activeId = tab.id; } closeTab(id: string) { const idx = this.tabs.findIndex((t) => t.id === id); if (idx === -1) return; if (this.tabs.length === 1) { const fresh = this.#blank(); this.tabs = [fresh]; this.activeId = fresh.id; return; } this.tabs.splice(idx, 1); if (this.activeId === id) this.activeId = this.tabs[Math.max(0, idx - 1)].id; } persist() { this.#storage.set(STORAGE_KEY, { activeId: this.activeId, tabs: this.tabs }); } renameActive(title: string) { if (!this.active) return; this.active.title = title.trim() || "untitled"; this.active.titleDirty = true; } renameTab(id: string, title: string) { const tab = this.tabs.find((t) => t.id === id); if (!tab) return; tab.title = title.trim() || "untitled"; tab.titleDirty = true; } async run(fetcher: Fetcher, options: RunOptions = {}): Promise { const tab = this.active; if (!tab) return false; const mode = options.subscriptionMode ?? "append"; const signal = options.signal; try { const variables = tab.variables.trim() ? JSON.parse(tab.variables) : {}; const headers = tab.headers.trim() ? JSON.parse(tab.headers) : {}; const result = await fetcher({ headers, operationName: tab.operationName, query: tab.query, variables }); if (isAsyncIterable(result)) { tab.result = ""; const iterator = result[Symbol.asyncIterator](); try { while (true) { if (signal?.aborted) { await iterator.return?.(); break; } const step = await iterator.next(); if (step.done) break; const payload = JSON.stringify(step.value, null, 2); if (mode === "append") { const stamp = new Date().toISOString(); const chunk = `// ${stamp}\n${payload}\n`; tab.result = tab.result ? `${tab.result}\n${chunk}` : chunk; } else { tab.result = payload; } } } finally { await iterator.return?.(); } return true; } tab.result = JSON.stringify(result, null, 2); return true; } catch(err) { tab.result = JSON.stringify({ error: String(err) }, null, 2); return false; } } overwriteActive(seed: TabSeed) { const tab = this.active; if (!tab) return; const query = seed.query ?? ""; tab.headers = seed.headers ?? "{}"; tab.operations = parseOperations(query); tab.operationName = seed.operationName ?? null; tab.query = query; tab.result = ""; tab.variables = seed.variables ?? "{}"; if (seed.title && !tab.titleDirty) tab.title = seed.title; else if (!tab.titleDirty) tab.title = deriveTitle(query, tab.operations); } selectOperation(id: string, name: string | null) { const tab = this.tabs.find((t) => t.id === id); if (tab) tab.operationName = name; } selectTab(id: string) { if (this.tabs.some((t) => t.id === id)) this.activeId = id; } updateQuery(id: string, query: string) { const tab = this.tabs.find((t) => t.id === id); if (!tab) return; tab.query = query; const ops = parseOperations(query); tab.operations = ops; if (ops.length === 0) tab.operationName = null; else if (ops.length === 1) tab.operationName = ops[0].name; else if (tab.operationName && !ops.some((o) => o.name === tab.operationName)) tab.operationName = null; if (!tab.titleDirty) tab.title = deriveTitle(query, ops); } #blank(): Tab { return { headers: "{}", id: crypto.randomUUID(), operationName: null, operations: [], query: "", result: "", title: "untitled", titleDirty: false, variables: "{}" }; } #hydrate(raw: Tab): Tab { return { headers: raw.headers ?? "{}", id: raw.id, operationName: raw.operationName ?? null, operations: raw.operations ?? parseOperations(raw.query ?? ""), query: raw.query ?? "", result: raw.result ?? "", title: raw.title ?? "untitled", titleDirty: raw.titleDirty ?? raw.title !== "untitled", variables: raw.variables ?? "{}" }; } #seeded(seed: TabSeed): Tab { const query = seed.query ?? ""; const operations = parseOperations(query); const title = seed.title ?? deriveTitle(query, operations); return { headers: seed.headers ?? "{}", id: crypto.randomUUID(), operationName: seed.operationName ?? null, operations, query, result: "", title, titleDirty: Boolean(seed.title), variables: seed.variables ?? "{}" }; } }