aboutsummaryrefslogtreecommitdiff
path: root/source/library/state
diff options
context:
space:
mode:
authornetop://ウィビ <paul@webb.page>2026-04-24 11:33:25 -0700
committernetop://ウィビ <paul@webb.page>2026-04-24 11:33:25 -0700
commit8a59f92d031963e23ecc84b75feecf43eb4dd146 (patch)
tree75de5768885583897061a3b1795e4c987ce90039 /source/library/state
downloadgraphiql-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.ts20
-rw-r--r--source/library/state/history.svelte.ts94
-rw-r--r--source/library/state/schema.svelte.ts41
-rw-r--r--source/library/state/session.svelte.ts242
-rw-r--r--source/library/state/storage.ts77
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));
+ }
+ };
+}