diff options
| author | netop://ウィビ <paul@webb.page> | 2026-04-24 11:33:25 -0700 |
|---|---|---|
| committer | netop://ウィビ <paul@webb.page> | 2026-04-24 11:33:25 -0700 |
| commit | 8a59f92d031963e23ecc84b75feecf43eb4dd146 (patch) | |
| tree | 75de5768885583897061a3b1795e4c987ce90039 /source/library/state | |
| download | graphiql-8a59f92d031963e23ecc84b75feecf43eb4dd146.tar.gz graphiql-8a59f92d031963e23ecc84b75feecf43eb4dd146.zip | |
Initial commit: @eol/graphiql v0.3
Svelte 5 GraphiQL alternative for JSR. Covers:
- HTTP fetcher with injectable fetch; SSE/WS stubs
- Session store with tabs, auto-titling, persistence, rename
- Operation detection via graphql parse(); Toolbar picker
- CodeMirror 6 editor via cm6-graphql with theme prop
- Light theme preset (hand-rolled EditorView.theme)
- Doc explorer with breadcrumb nav and type guards
- History panel with 100-entry cap, favorite pinning
- Deno tests for operations, storage, and history eviction
Diffstat (limited to 'source/library/state')
| -rw-r--r-- | source/library/state/history-logic.ts | 20 | ||||
| -rw-r--r-- | source/library/state/history.svelte.ts | 94 | ||||
| -rw-r--r-- | source/library/state/schema.svelte.ts | 41 | ||||
| -rw-r--r-- | source/library/state/session.svelte.ts | 242 | ||||
| -rw-r--r-- | source/library/state/storage.ts | 77 |
5 files changed, 474 insertions, 0 deletions
diff --git a/source/library/state/history-logic.ts b/source/library/state/history-logic.ts new file mode 100644 index 0000000..5fce766 --- /dev/null +++ b/source/library/state/history-logic.ts @@ -0,0 +1,20 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export type HistoryEvictable = { + favorite: boolean; + timestamp: number; +}; + +export function evict<T extends HistoryEvictable>(entries: T[], max: number): T[] { + if (entries.length <= max) + return entries; + + const favorites = entries.filter((e) => e.favorite); + const regular = entries.filter((e) => !e.favorite); + const keepRegular = regular.slice(0, Math.max(0, max - favorites.length)); + + return [...favorites, ...keepRegular].sort((a, b) => b.timestamp - a.timestamp); +} diff --git a/source/library/state/history.svelte.ts b/source/library/state/history.svelte.ts new file mode 100644 index 0000000..2726283 --- /dev/null +++ b/source/library/state/history.svelte.ts @@ -0,0 +1,94 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { evict } from "./history-logic.ts"; +import type { Storage } from "./storage.ts"; + +const MAX_ENTRIES = 100; +const STORAGE_KEY = "history"; + +/*** EXPORT ------------------------------------------- ***/ + +export type HistoryEntry = { + favorite: boolean; + headers: string; + id: string; + operationName: string | null; + query: string; + timestamp: number; + title: string; + variables: string; +}; + +export type HistoryInput = { + headers: string; + operationName: string | null; + query: string; + title: string; + variables: string; +}; + +export class HistoryStore { + entries = $state<HistoryEntry[]>([]); + + #storage: Storage; + + constructor(storage: Storage) { + this.#storage = storage; + + const restored = storage.get<HistoryEntry[]>(STORAGE_KEY); + + if (Array.isArray(restored)) + this.entries = restored.map((e) => ({ + favorite: Boolean(e.favorite), + headers: e.headers ?? "{}", + id: e.id, + operationName: e.operationName ?? null, + query: e.query ?? "", + timestamp: e.timestamp ?? Date.now(), + title: e.title ?? "untitled", + variables: e.variables ?? "{}" + })); + } + + add(input: HistoryInput) { + const entry: HistoryEntry = { + favorite: false, + headers: input.headers, + id: crypto.randomUUID(), + operationName: input.operationName, + query: input.query, + timestamp: Date.now(), + title: input.title, + variables: input.variables + }; + + this.entries = [entry, ...this.entries]; + this.#evict(); + } + + clear() { + this.entries = this.entries.filter((e) => e.favorite); + } + + favorite(id: string) { + const entry = this.entries.find((e) => e.id === id); + + if (entry) + entry.favorite = !entry.favorite; + } + + persist() { + this.#storage.set<HistoryEntry[]>(STORAGE_KEY, this.entries); + } + + remove(id: string) { + this.entries = this.entries.filter((e) => e.id !== id); + } + + #evict() { + this.entries = evict(this.entries, MAX_ENTRIES); + } +} diff --git a/source/library/state/schema.svelte.ts b/source/library/state/schema.svelte.ts new file mode 100644 index 0000000..c5f148f --- /dev/null +++ b/source/library/state/schema.svelte.ts @@ -0,0 +1,41 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { + buildClientSchema, + getIntrospectionQuery, + printSchema, + type GraphQLSchema, + type IntrospectionQuery +} from "graphql"; + +/*** UTILITY ------------------------------------------ ***/ + +import type { Fetcher } from "../fetcher/types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +export class SchemaStore { + error = $state<string | null>(null); + loading = $state(false); + schema = $state<GraphQLSchema | null>(null); + sdl = $state<string>(""); + + async introspect(fetcher: Fetcher) { + this.loading = true; + this.error = null; + + try { + const result = await fetcher({ query: getIntrospectionQuery() }); + const data = (result as { data: IntrospectionQuery }).data; + this.schema = buildClientSchema(data); + this.sdl = printSchema(this.schema); + } catch(err) { + this.error = String(err); + } finally { + this.loading = false; + } + } +} diff --git a/source/library/state/session.svelte.ts b/source/library/state/session.svelte.ts new file mode 100644 index 0000000..d9f52ff --- /dev/null +++ b/source/library/state/session.svelte.ts @@ -0,0 +1,242 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { Fetcher } from "../fetcher/types.ts"; +import { + deriveTitle, + parseOperations, + type OperationInfo +} from "../graphql/operations.ts"; +import type { Storage } from "./storage.ts"; + +const STORAGE_KEY = "session"; + +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 class SessionStore { + activeId = $state<string>(""); + tabs = $state<Tab[]>([]); + active = $derived(this.tabs.find((t) => t.id === this.activeId)); + + #storage: Storage; + + constructor(storage: Storage) { + this.#storage = storage; + const restored = storage.get<Snapshot>(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<Snapshot>(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): Promise<boolean> { + const tab = this.active; + + if (!tab) + return false; + + 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 + }); + + 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 ?? "{}" + }; + } +} diff --git a/source/library/state/storage.ts b/source/library/state/storage.ts new file mode 100644 index 0000000..a24096a --- /dev/null +++ b/source/library/state/storage.ts @@ -0,0 +1,77 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export type Storage = { + get<T>(key: string): T | null; + remove(key: string): void; + set<T>(key: string, value: T): void; +}; + +export function createLocalStorage(namespace: string): Storage { + const prefix = `${namespace}:`; + + function available(): boolean { + try { + return typeof globalThis.localStorage !== "undefined"; + } catch { + return false; + } + } + + return { + get<T>(key: string): T | null { + if (!available()) + return null; + + const raw = globalThis.localStorage.getItem(prefix + key); + + if (raw === null) + return null; + + try { + return JSON.parse(raw) as T; + } catch { + return null; + } + }, + remove(key: string): void { + if (!available()) + return; + + globalThis.localStorage.removeItem(prefix + key); + }, + set<T>(key: string, value: T): void { + if (!available()) + return; + + globalThis.localStorage.setItem(prefix + key, JSON.stringify(value)); + } + }; +} + +export function createMemoryStorage(): Storage { + const store = new Map<string, string>(); + + return { + get<T>(key: string): T | null { + const raw = store.get(key); + + if (raw === undefined) + return null; + + try { + return JSON.parse(raw) as T; + } catch { + return null; + } + }, + remove(key: string): void { + store.delete(key); + }, + set<T>(key: string, value: T): void { + store.set(key, JSON.stringify(value)); + } + }; +} |