From 8a59f92d031963e23ecc84b75feecf43eb4dd146 Mon Sep 17 00:00:00 2001 From: "netop://ウィビ" Date: Fri, 24 Apr 2026 11:33:25 -0700 Subject: 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 --- source/library/GraphiQL.svelte | 322 +++++++++++++++++++++ source/library/components/DocExplorer.svelte | 226 +++++++++++++++ .../components/DocExplorer/FieldView.svelte | 87 ++++++ .../library/components/DocExplorer/TypeLink.svelte | 42 +++ .../library/components/DocExplorer/TypeView.svelte | 199 +++++++++++++ source/library/components/Editor.svelte | 136 +++++++++ source/library/components/HeadersEditor.svelte | 34 +++ source/library/components/HistoryPanel.svelte | 187 ++++++++++++ source/library/components/ResultViewer.svelte | 35 +++ source/library/components/TabBar.svelte | 161 +++++++++++ source/library/components/Toolbar.svelte | 150 ++++++++++ source/library/fetcher/http.ts | 30 ++ source/library/fetcher/sse.ts | 18 ++ source/library/fetcher/types.ts | 20 ++ source/library/fetcher/websocket.ts | 18 ++ source/library/graphql/operations.ts | 56 ++++ source/library/index.ts | 28 ++ source/library/runes.d.ts | 37 +++ source/library/state/history-logic.ts | 20 ++ source/library/state/history.svelte.ts | 94 ++++++ source/library/state/schema.svelte.ts | 41 +++ source/library/state/session.svelte.ts | 242 ++++++++++++++++ source/library/state/storage.ts | 77 +++++ source/library/themes/light.ts | 75 +++++ 24 files changed, 2335 insertions(+) create mode 100644 source/library/GraphiQL.svelte create mode 100644 source/library/components/DocExplorer.svelte create mode 100644 source/library/components/DocExplorer/FieldView.svelte create mode 100644 source/library/components/DocExplorer/TypeLink.svelte create mode 100644 source/library/components/DocExplorer/TypeView.svelte create mode 100644 source/library/components/Editor.svelte create mode 100644 source/library/components/HeadersEditor.svelte create mode 100644 source/library/components/HistoryPanel.svelte create mode 100644 source/library/components/ResultViewer.svelte create mode 100644 source/library/components/TabBar.svelte create mode 100644 source/library/components/Toolbar.svelte create mode 100644 source/library/fetcher/http.ts create mode 100644 source/library/fetcher/sse.ts create mode 100644 source/library/fetcher/types.ts create mode 100644 source/library/fetcher/websocket.ts create mode 100644 source/library/graphql/operations.ts create mode 100644 source/library/index.ts create mode 100644 source/library/runes.d.ts create mode 100644 source/library/state/history-logic.ts create mode 100644 source/library/state/history.svelte.ts create mode 100644 source/library/state/schema.svelte.ts create mode 100644 source/library/state/session.svelte.ts create mode 100644 source/library/state/storage.ts create mode 100644 source/library/themes/light.ts (limited to 'source') diff --git a/source/library/GraphiQL.svelte b/source/library/GraphiQL.svelte new file mode 100644 index 0000000..0a8f01a --- /dev/null +++ b/source/library/GraphiQL.svelte @@ -0,0 +1,322 @@ + + + + + + +
+ { + if (session.active) + session.selectOperation(session.active.id, name); + }} + onToggleDocs={() => (docsOpen = !docsOpen)} + onToggleHistory={() => (historyOpen = !historyOpen)} + operationName={session.active?.operationName ?? null} + operations={session.active?.operations ?? []} + {running} + schemaLoading={schema.loading}/> + session.addTab()} + onClose={(id) => session.closeTab(id)} + onRename={(id, title) => session.renameTab(id, title)} + onSelect={(id) => session.selectTab(id)} + tabs={session.tabs}/> +
+ {#if historyOpen} + history.clear()} + onFavorite={(id) => history.favorite(id)} + onLoad={loadHistory} + onRemove={(id) => history.remove(id)}/> + {/if} +
+
+ +
+
+ + +
+
+ {#if bottomPane === "variables"} + + {:else} + + {/if} +
+
+
+ +
+ {#if docsOpen && schema.schema} + + {/if} +
+ {#if schema.error} +
Schema error: {schema.error}
+ {/if} +
diff --git a/source/library/components/DocExplorer.svelte b/source/library/components/DocExplorer.svelte new file mode 100644 index 0000000..536cb2a --- /dev/null +++ b/source/library/components/DocExplorer.svelte @@ -0,0 +1,226 @@ + + + + +
+ +
+ {#if stack.length === 0} +
+ +
+ {#each rootTypes as entry} + + {/each} +
+
+ {:else if currentField} + + {:else if currentType} + + {:else} +
Type not found in schema.
+ {/if} +
+
diff --git a/source/library/components/DocExplorer/FieldView.svelte b/source/library/components/DocExplorer/FieldView.svelte new file mode 100644 index 0000000..71d215c --- /dev/null +++ b/source/library/components/DocExplorer/FieldView.svelte @@ -0,0 +1,87 @@ + + + + +
+
{field.name}
+ {#if field.description} +
{field.description}
+ {/if} +
+ + +
+ {#if args.length > 0} +
+ +
+ {#each args as arg} +
+ {arg.name}: + + {#if arg.description} +
{arg.description}
+ {/if} +
+ {/each} +
+
+ {/if} +
diff --git a/source/library/components/DocExplorer/TypeLink.svelte b/source/library/components/DocExplorer/TypeLink.svelte new file mode 100644 index 0000000..253d16e --- /dev/null +++ b/source/library/components/DocExplorer/TypeLink.svelte @@ -0,0 +1,42 @@ + + + + + diff --git a/source/library/components/DocExplorer/TypeView.svelte b/source/library/components/DocExplorer/TypeView.svelte new file mode 100644 index 0000000..31a1ca3 --- /dev/null +++ b/source/library/components/DocExplorer/TypeView.svelte @@ -0,0 +1,199 @@ + + + + +
+
+ {#if kindLabel}{kindLabel}{/if}{type.name} +
+ {#if type.description} +
{type.description}
+ {/if} + {#if interfaces.length > 0} +
+ +
+ {#each interfaces as iface} +
+ +
+ {/each} +
+
+ {/if} + {#if fields.length > 0} +
+ +
+ {#each fields as field} +
+ : + + {#if field.description} +
{field.description}
+ {/if} +
+ {/each} +
+
+ {/if} + {#if unionMembers.length > 0} +
+ +
+ {#each unionMembers as member} +
+ +
+ {/each} +
+
+ {/if} + {#if enumValues.length > 0} +
+ +
+ {#each enumValues as value} +
+ {value.name} + {#if value.description} +
{value.description}
+ {/if} +
+ {/each} +
+
+ {/if} +
diff --git a/source/library/components/Editor.svelte b/source/library/components/Editor.svelte new file mode 100644 index 0000000..f2bf82d --- /dev/null +++ b/source/library/components/Editor.svelte @@ -0,0 +1,136 @@ + + + + +
diff --git a/source/library/components/HeadersEditor.svelte b/source/library/components/HeadersEditor.svelte new file mode 100644 index 0000000..fc3a193 --- /dev/null +++ b/source/library/components/HeadersEditor.svelte @@ -0,0 +1,34 @@ + + + + +
+
Headers
+ +
diff --git a/source/library/components/HistoryPanel.svelte b/source/library/components/HistoryPanel.svelte new file mode 100644 index 0000000..b7f5c4c --- /dev/null +++ b/source/library/components/HistoryPanel.svelte @@ -0,0 +1,187 @@ + + + + +
+
+ History + {#if entries.length > 0} + + {/if} +
+
+ {#if sorted.length === 0} +
No history yet.
+ {:else} + {#each sorted as entry (entry.id)} +
onEntryClick(e, entry.id)} + onkeydown={(e) => onEntryKey(e, entry.id)} + role="button" + tabindex="0"> + +
+
{entry.title}
+
{formatTimestamp(entry.timestamp)}
+
+ +
+ {/each} + {/if} +
+
diff --git a/source/library/components/ResultViewer.svelte b/source/library/components/ResultViewer.svelte new file mode 100644 index 0000000..e2c74fe --- /dev/null +++ b/source/library/components/ResultViewer.svelte @@ -0,0 +1,35 @@ + + + + +
+
Response
+ +
diff --git a/source/library/components/TabBar.svelte b/source/library/components/TabBar.svelte new file mode 100644 index 0000000..d87449d --- /dev/null +++ b/source/library/components/TabBar.svelte @@ -0,0 +1,161 @@ + + + + +
+ {#each tabs as tab (tab.id)} + + + {/each} + +
diff --git a/source/library/components/Toolbar.svelte b/source/library/components/Toolbar.svelte new file mode 100644 index 0000000..a17191c --- /dev/null +++ b/source/library/components/Toolbar.svelte @@ -0,0 +1,150 @@ + + + + +
+ + {#if namedOperations.length > 1} + + {/if} + ⌘/Ctrl + Enter + {#if schemaLoading} + Loading schema… + {/if} + + {#if onToggleHistory} + + {/if} + {#if onToggleDocs} + + {/if} +
diff --git a/source/library/fetcher/http.ts b/source/library/fetcher/http.ts new file mode 100644 index 0000000..3138226 --- /dev/null +++ b/source/library/fetcher/http.ts @@ -0,0 +1,30 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { Fetcher, FetcherOptions } from "./types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +export function createHttpFetcher(options: FetcherOptions): Fetcher { + const fetchImpl = options.fetch ?? globalThis.fetch; + + return async (req) => { + const response = await fetchImpl(options.url, { + body: JSON.stringify({ + operationName: req.operationName, + query: req.query, + variables: req.variables + }), + headers: { + "Content-Type": "application/json", + ...options.headers, + ...req.headers + }, + method: "POST" + }); + + return await response.json(); + }; +} diff --git a/source/library/fetcher/sse.ts b/source/library/fetcher/sse.ts new file mode 100644 index 0000000..b6805dc --- /dev/null +++ b/source/library/fetcher/sse.ts @@ -0,0 +1,18 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { Fetcher, FetcherOptions } from "./types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +/** + * Server-Sent Events fetcher for graphql-sse protocol. + * Stub implementation — see PLAN.md stage v0.4 for full implementation. + */ +export function createSseFetcher(_options: FetcherOptions): Fetcher { + return () => { + throw new Error("SSE fetcher not yet implemented — see PLAN.md v0.4"); + }; +} diff --git a/source/library/fetcher/types.ts b/source/library/fetcher/types.ts new file mode 100644 index 0000000..af849a9 --- /dev/null +++ b/source/library/fetcher/types.ts @@ -0,0 +1,20 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export type FetcherRequest = { + headers?: Record; + operationName?: string | null; + query: string; + variables?: Record; +}; + +export type FetcherResult = Record; +export type Fetcher = (req: FetcherRequest) => Promise | AsyncIterable; + +export type FetcherOptions = { + fetch?: typeof globalThis.fetch; + headers?: Record; + url: string; +}; diff --git a/source/library/fetcher/websocket.ts b/source/library/fetcher/websocket.ts new file mode 100644 index 0000000..6376e76 --- /dev/null +++ b/source/library/fetcher/websocket.ts @@ -0,0 +1,18 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { Fetcher, FetcherOptions } from "./types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +/** + * WebSocket fetcher for graphql-ws protocol. + * Stub implementation — see PLAN.md stage v0.4 for full implementation. + */ +export function createWsFetcher(_options: FetcherOptions): Fetcher { + return () => { + throw new Error("WebSocket fetcher not yet implemented — see PLAN.md v0.4"); + }; +} diff --git a/source/library/graphql/operations.ts b/source/library/graphql/operations.ts new file mode 100644 index 0000000..b34aeee --- /dev/null +++ b/source/library/graphql/operations.ts @@ -0,0 +1,56 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { parse } from "graphql"; + +/*** EXPORT ------------------------------------------- ***/ + +export type OperationInfo = { + name: string | null; + type: "mutation" | "query" | "subscription"; +}; + +export function deriveTitle(query: string, ops: OperationInfo[]): string { + const first = ops[0]; + + if (first && first.name) + return first.name; + + if (first) + return first.type; + + const trimmed = query.trim(); + + if (!trimmed) + return "untitled"; + + return trimmed.slice(0, 20); +} + +export function parseOperations(query: string): OperationInfo[] { + const trimmed = query.trim(); + + if (!trimmed) + return []; + + try { + const doc = parse(trimmed); + const ops: OperationInfo[] = []; + + for (const def of doc.definitions) { + if (def.kind !== "OperationDefinition") + continue; + + ops.push({ + name: def.name?.value ?? null, + type: def.operation + }); + } + + return ops; + } catch { + return []; + } +} diff --git a/source/library/index.ts b/source/library/index.ts new file mode 100644 index 0000000..5aecff1 --- /dev/null +++ b/source/library/index.ts @@ -0,0 +1,28 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export { createHttpFetcher } from "./fetcher/http.ts"; +export { createLocalStorage, createMemoryStorage } from "./state/storage.ts"; +export { createSseFetcher } from "./fetcher/sse.ts"; +export { createWsFetcher } from "./fetcher/websocket.ts"; +export { default as GraphiQL } from "./GraphiQL.svelte"; +export { HistoryStore } from "./state/history.svelte.ts"; +export { lightTheme } from "./themes/light.ts"; +export { SchemaStore } from "./state/schema.svelte.ts"; +export { SessionStore } from "./state/session.svelte.ts"; + +export type { Extension } from "@codemirror/state"; + +export type { + Fetcher, + FetcherOptions, + FetcherRequest, + FetcherResult +} from "./fetcher/types.ts"; + +export type { HistoryEntry, HistoryInput } from "./state/history.svelte.ts"; +export type { OperationInfo } from "./graphql/operations.ts"; +export type { Storage } from "./state/storage.ts"; +export type { Tab, TabSeed } from "./state/session.svelte.ts"; diff --git a/source/library/runes.d.ts b/source/library/runes.d.ts new file mode 100644 index 0000000..4b73482 --- /dev/null +++ b/source/library/runes.d.ts @@ -0,0 +1,37 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +/** + * Ambient declarations for Svelte 5 runes so `deno check` can type-check + * `.svelte.ts` files. The runtime forms are injected by the Svelte compiler. + */ + +declare function $state(initial: T): T; +declare function $state(): T | undefined; + +declare namespace $state { + function raw(initial: T): T; + function raw(): T | undefined; + function snapshot(value: T): T; +} + +declare function $derived(expression: T): T; + +declare namespace $derived { + function by(fn: () => T): T; +} + +declare function $effect(fn: () => void | (() => void)): void; + +declare namespace $effect { + function pre(fn: () => void | (() => void)): void; + function root(fn: () => void | (() => void)): () => void; + function tracking(): boolean; +} + +declare function $props>(): T; +declare function $bindable(fallback?: T): T; +declare function $inspect(...values: T[]): { with: (fn: (type: "init" | "update", ...values: T[]) => void) => void }; +declare function $host(): T; 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(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([]); + + #storage: Storage; + + constructor(storage: Storage) { + this.#storage = storage; + + const restored = storage.get(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(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(null); + loading = $state(false); + schema = $state(null); + sdl = $state(""); + + 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(""); + 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): Promise { + 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(key: string): T | null; + remove(key: string): void; + set(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(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(key: string, value: T): void { + if (!available()) + return; + + globalThis.localStorage.setItem(prefix + key, JSON.stringify(value)); + } + }; +} + +export function createMemoryStorage(): Storage { + const store = new Map(); + + return { + get(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(key: string, value: T): void { + store.set(key, JSON.stringify(value)); + } + }; +} diff --git a/source/library/themes/light.ts b/source/library/themes/light.ts new file mode 100644 index 0000000..daaede2 --- /dev/null +++ b/source/library/themes/light.ts @@ -0,0 +1,75 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language"; +import { EditorView } from "@codemirror/view"; +import { tags as t } from "@lezer/highlight"; + +/*** EXPORT ------------------------------------------- ***/ + +const BG = "#fafafa"; +const BORDER = "#e0e0e0"; +const FG = "#24292e"; +const GUTTER_BG = "#f3f3f3"; +const GUTTER_FG = "#9ca3af"; +const SELECTION = "#b3d4fc"; + +const base = EditorView.theme({ + "&": { + backgroundColor: BG, + color: FG + }, + "&.cm-focused": { + outline: "none" + }, + ".cm-activeLine": { + backgroundColor: "#f0f0f0" + }, + ".cm-activeLineGutter": { + backgroundColor: "transparent", + color: FG + }, + ".cm-content": { + caretColor: "#1f6feb" + }, + ".cm-cursor, .cm-dropCursor": { + borderLeftColor: "#1f6feb" + }, + ".cm-gutters": { + backgroundColor: GUTTER_BG, + border: "none", + borderRight: `1px solid ${BORDER}`, + color: GUTTER_FG + }, + ".cm-matchingBracket": { + backgroundColor: "#dbeafe", + outline: "1px solid #93c5fd" + }, + ".cm-selectionBackground, &.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground": { + backgroundColor: SELECTION + } +}, { dark: false }); + +const highlight = HighlightStyle.define([ + { color: "#d73a49", tag: t.keyword }, + { color: "#6f42c1", tag: [t.name, t.deleted, t.character, t.macroName] }, + { color: "#6f42c1", tag: [t.propertyName] }, + { color: "#032f62", tag: [t.string, t.special(t.string)] }, + { color: "#005cc5", tag: [t.number, t.bool, t.null, t.atom] }, + { color: "#6a737d", fontStyle: "italic", tag: t.comment }, + { color: "#22863a", tag: [t.typeName, t.className] }, + { color: "#e36209", tag: [t.variableName, t.labelName] }, + { color: "#d73a49", tag: [t.operator, t.operatorKeyword] }, + { color: "#6a737d", tag: [t.meta, t.documentMeta] }, + { color: "#22863a", tag: [t.tagName] }, + { color: "#6f42c1", tag: [t.attributeName] }, + { color: "#e36209", tag: [t.heading] }, + { color: "#032f62", tag: [t.link] }, + { fontWeight: "bold", tag: [t.strong] }, + { fontStyle: "italic", tag: [t.emphasis] }, + { tag: t.strikethrough, textDecoration: "line-through" } +]); + +export const lightTheme = [base, syntaxHighlighting(highlight)]; -- cgit v1.2.3