diff options
31 files changed, 2934 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3b26ea2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +Thumbs.db + +.claude @@ -0,0 +1,138 @@ +# PLAN.md — @eol/graphiql + +A Svelte 5 GraphiQL alternative. JSR-published under the EOL scope. + +## Current state (v0.2 complete) + +Shipped: + +- HTTP fetcher with injectable `fetch` impl +- Schema introspection and SDL caching +- Session store with tabs, persistence, and rename +- TabBar with double-click inline rename (Enter commits, Escape cancels, blur commits) +- Toolbar with Cmd/Ctrl+Enter run shortcut +- Variables + Headers panes (switchable in bottom-left) +- Debounced localStorage persistence (300ms) with `beforeunload` flush +- Memory storage fallback for SSR +- CodeMirror 6 editor wrapper via `cm6-graphql` with dynamic import (SSR-safe) +- `readOnly` prop on Editor, used by ResultViewer +- Reactive schema updates via `$effect` calling `updateSchema()` +- Public API surface in `src/lib/index.ts` + +Stub files needing implementation: `src/lib/fetcher/sse.ts`, `src/lib/fetcher/websocket.ts`. + +## Coding conventions + +Non-negotiable. Match existing files exactly: + +- Double quotes, semicolons, two-space indent +- No trailing spaces, no unnecessary whitespace +- Svelte component structure: `<script lang="ts">`, then `<style lang="scss">`, then markup +- Object literal keys alphabetical unless semantic ordering matters +- TypeScript strict mode, no `any` +- Svelte 5 runes only (`$state`, `$derived`, `$effect`, `$props`) — no stores, no `writable` +- No direct `window` or `document` access — use `$app/environment`'s `browser`, or `typeof globalThis.X !== "undefined"` guards +- No React, no Node-specific APIs (Deno/Bun first) +- Fix mistakes in place; don't re-scaffold + +## Stages + +### v0.2.1 — Polish (small, worth doing before v0.3) + +**Tab auto-titling.** When typing in a brand-new tab, auto-generate a title from the first meaningful token of the query (operation name if present, else "query"/"mutation"/"subscription", else first 20 chars). Only auto-rename if the user hasn't manually renamed. Track with a `titleDirty: boolean` field on `Tab`. Set it to `true` in `renameTab()` and `renameActive()`. + +**Operation detection.** Parse the query with `graphql`'s `parse()` and set `tab.operationName` automatically when there's a single named operation. If multiple operations exist and none selected, surface a picker next to the Run button. Keep parse errors silent — the CM6 linter already shows them. + +**Theme prop.** Currently hardcoded to `oneDark`. Extract to an optional `theme?: Extension` prop on the root component, defaulting to `oneDark`. Consumers can pass any CM6 theme extension. A light theme preset (`import("@codemirror/theme")` basics or a small hand-rolled one) should ship alongside so people can toggle without adding a dependency. + +### v0.3 — Doc explorer and history + +**Doc explorer (`components/DocExplorer.svelte`).** Right-hand collapsible pane. Walks `schema.schema` (the built `GraphQLSchema` object). Breadcrumb navigation: Root → Type → Field → Arg. Clicking a field pushes onto a navigation stack. Clicking a breadcrumb crumb pops to that level. + +Structure: + +- `DocExplorer.svelte` — container, manages nav stack via `$state` +- `DocExplorer/TypeView.svelte` — renders a `GraphQLObjectType`, `GraphQLInterfaceType`, `GraphQLUnionType`, `GraphQLEnumType`, `GraphQLInputObjectType`, or `GraphQLScalarType` +- `DocExplorer/FieldView.svelte` — renders a single field with args and return type +- `DocExplorer/TypeLink.svelte` — clickable type reference (unwraps `NonNull`/`List`) + +Use the `graphql` package's `isObjectType`, `isInterfaceType`, etc. guards — don't duck-type. + +Toggle visibility with a toolbar button. Width around 320px. Collapsed state persisted via the same storage adapter under a `"docExplorer"` key. + +**History panel (`components/HistoryPanel.svelte`).** Drawer from the left. Logs every successful query execution (query + variables + headers + timestamp + operationName). New history store: `state/history.svelte.ts` with `HistoryStore` class, methods `add(entry)`, `remove(id)`, `clear()`, `favorite(id)`. Cap at 100 entries; favorites never evict. Clicking an entry loads it into the active tab (overwriting). Shift-click opens in a new tab. + +Persist under `"history"` key. Same debounce pattern as session. + +### v0.4 — Subscriptions (SSE + WebSocket) + +Replace the stubs in `fetcher/sse.ts` and `fetcher/websocket.ts`. + +**SSE implementation.** Use `graphql-sse` protocol. The fetcher signature already allows `AsyncIterable<FetcherResult>` as a return type — use it. Stream each `next` event as an iteration. Close the `EventSource` on `complete` or consumer abort. + +**WebSocket implementation.** Use the `graphql-ws` library (JSR: `npm:graphql-ws`). Return an `AsyncIterable` that yields each `next` message. Handle `connection_init`, `ping`/`pong`, subscription IDs, and clean teardown. + +**Root component update.** `GraphiQL.svelte`'s `run()` needs to detect `AsyncIterable` return values and stream them into `tab.result`. Append each payload with a timestamp header, or replace on each — make it a prop choice (`subscriptionMode: "replace" | "append"`, default `"append"`). + +**Protocol detection.** Add a prop to pick transport per-operation: if the query contains a subscription, prefer WS; otherwise HTTP. Let consumers override by passing a custom fetcher that dispatches internally. + +### v0.5 — Theming and plugin slots + +**Theming.** Two layers: the editor theme (a CM6 `Extension` passed as a prop per v0.2.1) and the surrounding chrome theme (CSS custom properties like `--graphiql-bg`, `--graphiql-fg`). Extract the chrome variables into `src/lib/styles/theme.scss` with light/dark mode variables gated by `@media (prefers-color-scheme)` and an override class. Ship at least two CM6 theme presets (dark via `oneDark`, light via a small hand-rolled `EditorView.theme({...})`). Document the full variable list and theme prop API in README. + +**Plugin slots.** Use Svelte snippets (the `{@render}` / `Snippet` type from Svelte 5). Add optional props to `GraphiQL.svelte`: + +- `toolbarExtras?: Snippet` — rendered after the Run button +- `resultFooter?: Snippet<[{ result: string }]>` — rendered below the result pane +- `tabExtras?: Snippet<[{ tab: Tab }]>` — rendered inside each tab button + +This keeps the plugin story type-safe without a bespoke registry. + +### v0.6+ — Nice to have + +- Export/import session state as JSON (for sharing) +- Query formatting button (uses `graphql`'s `print()`) +- Keyboard shortcut for new tab (Cmd+T), close tab (Cmd+W), next/prev tab (Cmd+Opt+Right/Left) +- Split-pane resize handles (use CSS `resize: horizontal` or a lightweight splitter) +- Request/response timing display +- Persisted query support (APQ) + +## Publishing + +The project publishes to JSR as `@eol/graphiql`. Current `jsr.json` and `deno.json` are set up for v0.2.0. Bump versions with ChronVer per NetOpWibby's convention. The `exports` points at `src/lib/index.ts`; JSR ships `.svelte` files as-is and the consumer's SvelteKit build compiles them. **Do not pre-compile Svelte components** — that breaks SSR and prevents consumers from using their own Svelte version. + +Run `deno publish --dry-run` before tagging. + +## Known gotchas + +**CM6 extension composition.** CodeMirror 6 is extension-based — every feature is an opt-in `Extension`. The current `Editor.svelte` bundles a reasonable default set (line numbers, history, bracket matching, close brackets, indent-on-input, default + history keymaps, syntax highlighting, oneDark theme). If consumers want a leaner editor (e.g., for the result viewer), the cleanest path is to make the extension array a prop. Not done yet — v0.2.1 `theme` prop is the entry point for this refactor. + +**Schema reactivity.** Schema changes are handled by an `$effect` that calls `cm6-graphql`'s `updateSchema(view, newSchema)`. This is the documented CM6 pattern — do not recreate the editor on schema change, as that loses cursor position, history, and undo stack. If `schema` is invalid SDL, the catch block silently skips and the editor keeps working without schema awareness. + +**`crypto.randomUUID()`** works in Deno, modern browsers, and Bun. It does not work in Node.js before 14.17. Since we're Deno/Bun first this is fine; flag in docs if someone reports Node issues. + +**Nested button in TabBar.** The close `×` button is nested inside the tab `<button>`. Technically invalid HTML but works everywhere. If a11y linters complain, refactor the outer `<button>` to `<div role="tab" tabindex="0">` with explicit keyboard handlers. + +**Schema introspection failures.** Some GraphQL servers disable introspection in production. Surface the error clearly (already done in `.status` bar) but make sure queries still run without schema — the CM6 editor falls back to plain GraphQL syntax highlighting when `sdl` is empty. + +## Testing + +No test harness yet. For v0.3 add Deno's built-in test runner targeting the state stores (pure logic, no DOM): + +- `tests/session.test.ts` — tab add/close/rename/persist round-trip +- `tests/storage.test.ts` — memory storage + namespace isolation +- `tests/history.test.ts` — eviction and favorite pinning + +Component tests can wait until there's demand; Svelte 5 + runes + Vitest is still settling. + +## Architecture notes for future me + +The separation between **state stores** (`*.svelte.ts` classes) and **components** is deliberate. Stores own all mutation logic and can be unit tested without a DOM. Components own rendering and side effects (persistence timers, keyboard handlers). + +The `$effect` in `GraphiQL.svelte` intentionally reads `session.tabs` and `session.activeId` via `void` to establish dependencies without using the values. This is idiomatic runes and cleaner than `$effect.root` with explicit subscriptions. + +The fetcher abstraction is intentionally minimal — one function type that returns a Promise or AsyncIterable. This covers HTTP, SSE, WS, and any future transport without an interface explosion. Transport-specific options live in the factory function, not the fetcher itself. + +**Why CodeMirror 6 over Monaco.** Bundle size, build simplicity, and philosophical fit. Monaco is multi-megabyte with required web worker setup in Vite — a real friction point for consumers. CM6 is tens of kilobytes, composed of opt-in extensions, and works with zero build config. `cm6-graphql` is maintained inside the graphql/graphiql monorepo itself and uses the same `graphql-language-service` that powers `monaco-graphql`, so we get schema-aware autocomplete and linting without the bloat. The tradeoff is slightly less polished hover docs and no signature-help popups — acceptable for v1, addressable later via custom CM6 extensions if needed. + +When in doubt about API shape, look at `@graphiql/toolkit` for reference — the GraphiQL maintainers have already factored out the transport concerns nicely, we're just giving it a Svelte + CM6 skin. diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..61efce7 --- /dev/null +++ b/deno.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "lib": ["deno.window", "dom", "dom.iterable"], + "strict": true + }, + "exports": "./source/library/index.ts", + "imports": { + "@codemirror/autocomplete": "npm:@codemirror/autocomplete@^6.18.0", + "@codemirror/commands": "npm:@codemirror/commands@^6.7.0", + "@codemirror/lang-json": "npm:@codemirror/lang-json@^6.0.1", + "@codemirror/language": "npm:@codemirror/language@^6.10.0", + "@codemirror/state": "npm:@codemirror/state@^6.4.0", + "@codemirror/theme-one-dark": "npm:@codemirror/theme-one-dark@^6.1.0", + "@codemirror/view": "npm:@codemirror/view@^6.34.0", + "@lezer/highlight": "npm:@lezer/highlight@^1.2.0", + "cm6-graphql": "npm:cm6-graphql@^0.2.1", + "codemirror": "npm:codemirror@^6.0.1", + "graphql": "npm:graphql@^16.8.0" + }, + "name": "@eol/graphiql", + "publish": { + "include": ["source/library/**/*", "README.md", "LICENSE"] + }, + "tasks": { + "check": "deno check source/library/**/*.ts", + "test": "deno test --allow-env --location=http://localhost/ tests/" + }, + "version": "0.1.0" +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..8774cf1 --- /dev/null +++ b/deno.lock @@ -0,0 +1,204 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.13", + "npm:@codemirror/autocomplete@^6.18.0": "6.20.1", + "npm:@codemirror/commands@^6.7.0": "6.10.3", + "npm:@codemirror/lang-json@^6.0.1": "6.0.2", + "npm:@codemirror/language@^6.10.0": "6.12.3", + "npm:@codemirror/state@^6.4.0": "6.6.0", + "npm:@codemirror/theme-one-dark@^6.1.0": "6.1.3", + "npm:@codemirror/view@^6.34.0": "6.41.1", + "npm:@lezer/highlight@^1.2.0": "1.2.3", + "npm:cm6-graphql@~0.2.1": "0.2.1_@codemirror+autocomplete@6.20.1_@codemirror+language@6.12.3_@codemirror+lint@6.9.5_@codemirror+state@6.6.0_@codemirror+view@6.41.1_@lezer+highlight@1.2.3_graphql@16.13.2", + "npm:codemirror@^6.0.1": "6.0.2", + "npm:graphql@^16.8.0": "16.13.2" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.13": { + "integrity": "2f9546691d4ac2d32859c82dff284aaeac980ddeca38430d07941e7e288725c0" + } + }, + "npm": { + "@codemirror/autocomplete@6.20.1": { + "integrity": "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A==", + "dependencies": [ + "@codemirror/language", + "@codemirror/state", + "@codemirror/view", + "@lezer/common" + ] + }, + "@codemirror/commands@6.10.3": { + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "dependencies": [ + "@codemirror/language", + "@codemirror/state", + "@codemirror/view", + "@lezer/common" + ] + }, + "@codemirror/lang-json@6.0.2": { + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "dependencies": [ + "@codemirror/language", + "@lezer/json" + ] + }, + "@codemirror/language@6.12.3": { + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "dependencies": [ + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + "style-mod" + ] + }, + "@codemirror/lint@6.9.5": { + "integrity": "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA==", + "dependencies": [ + "@codemirror/state", + "@codemirror/view", + "crelt" + ] + }, + "@codemirror/search@6.7.0": { + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "dependencies": [ + "@codemirror/state", + "@codemirror/view", + "crelt" + ] + }, + "@codemirror/state@6.6.0": { + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "dependencies": [ + "@marijn/find-cluster-break" + ] + }, + "@codemirror/theme-one-dark@6.1.3": { + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "dependencies": [ + "@codemirror/language", + "@codemirror/state", + "@codemirror/view", + "@lezer/highlight" + ] + }, + "@codemirror/view@6.41.1": { + "integrity": "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg==", + "dependencies": [ + "@codemirror/state", + "crelt", + "style-mod", + "w3c-keyname" + ] + }, + "@lezer/common@1.5.2": { + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==" + }, + "@lezer/highlight@1.2.3": { + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "dependencies": [ + "@lezer/common" + ] + }, + "@lezer/json@1.0.3": { + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "dependencies": [ + "@lezer/common", + "@lezer/highlight", + "@lezer/lr" + ] + }, + "@lezer/lr@1.4.10": { + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "dependencies": [ + "@lezer/common" + ] + }, + "@marijn/find-cluster-break@1.0.2": { + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==" + }, + "cm6-graphql@0.2.1_@codemirror+autocomplete@6.20.1_@codemirror+language@6.12.3_@codemirror+lint@6.9.5_@codemirror+state@6.6.0_@codemirror+view@6.41.1_@lezer+highlight@1.2.3_graphql@16.13.2": { + "integrity": "sha512-FIAFHn6qyiXChTz3Pml0NgTM8LyyXs8QfP2iPG7MLA8Xi83WuVlkGG5PDs+DDeEVabHkLIZmcyNngQlxLXKk6A==", + "dependencies": [ + "@codemirror/autocomplete", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/state", + "@codemirror/view", + "@lezer/highlight", + "graphql", + "graphql-language-service" + ] + }, + "codemirror@6.0.2": { + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "dependencies": [ + "@codemirror/autocomplete", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view" + ] + }, + "crelt@1.0.6": { + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==" + }, + "debounce-promise@3.1.2": { + "integrity": "sha512-rZHcgBkbYavBeD9ej6sP56XfG53d51CD4dnaw989YX/nZ/ZJfgRx/9ePKmTNiUiyQvh4mtrMoS3OAWW+yoYtpg==" + }, + "graphql-language-service@5.5.0_graphql@16.13.2": { + "integrity": "sha512-9EvWrLLkF6Y5e29/2cmFoAO6hBPPAZlCyjznmpR11iFtRydfkss+9m6x+htA8h7YznGam+TtJwS6JuwoWWgb2Q==", + "dependencies": [ + "debounce-promise", + "graphql", + "nullthrows", + "vscode-languageserver-types" + ], + "bin": true + }, + "graphql@16.13.2": { + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==" + }, + "nullthrows@1.1.1": { + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==" + }, + "style-mod@4.1.3": { + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==" + }, + "vscode-languageserver-types@3.17.5": { + "integrity": "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==" + }, + "w3c-keyname@2.2.8": { + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + } + }, + "workspace": { + "dependencies": [ + "npm:@codemirror/autocomplete@^6.18.0", + "npm:@codemirror/commands@^6.7.0", + "npm:@codemirror/lang-json@^6.0.1", + "npm:@codemirror/language@^6.10.0", + "npm:@codemirror/state@^6.4.0", + "npm:@codemirror/theme-one-dark@^6.1.0", + "npm:@codemirror/view@^6.34.0", + "npm:@lezer/highlight@^1.2.0", + "npm:cm6-graphql@~0.2.1", + "npm:codemirror@^6.0.1", + "npm:graphql@^16.8.0" + ] + } +} 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 @@ +<script lang="ts"> + /*** IMPORT ------------------------------------------- ***/ + + import { onMount } from "svelte"; + + /*** UTILITY ------------------------------------------ ***/ + + import DocExplorer from "./components/DocExplorer.svelte"; + import Editor from "./components/Editor.svelte"; + import HeadersEditor from "./components/HeadersEditor.svelte"; + import HistoryPanel from "./components/HistoryPanel.svelte"; + import ResultViewer from "./components/ResultViewer.svelte"; + import TabBar from "./components/TabBar.svelte"; + import Toolbar from "./components/Toolbar.svelte"; + import type { Extension } from "@codemirror/state"; + import type { Fetcher } from "./fetcher/types.ts"; + import { HistoryStore } from "./state/history.svelte.ts"; + import { SchemaStore } from "./state/schema.svelte.ts"; + import { SessionStore } from "./state/session.svelte.ts"; + import { createLocalStorage, createMemoryStorage } from "./state/storage.ts"; + import type { Storage } from "./state/storage.ts"; + + type Props = { + fetcher: Fetcher; + initialQuery?: string; + namespace?: string; + storage?: Storage; + theme?: Extension; + }; + + let { + fetcher, + initialQuery = "", + namespace = "eol-graphiql", + storage, + theme + }: Props = $props(); + + const resolvedStorage = storage ?? + (typeof globalThis.localStorage !== "undefined" ? + createLocalStorage(namespace) : + createMemoryStorage()); + + const PERSIST_DEBOUNCE_MS = 300; + const history = new HistoryStore(resolvedStorage); + const schema = new SchemaStore(); + const session = new SessionStore(resolvedStorage); + + if (initialQuery && session.active && session.active.query === "") + session.updateQuery(session.active.id, initialQuery); + + let bottomPane = $state<"variables" | "headers">("variables"); + let docsOpen = $state(resolvedStorage.get<boolean>("docExplorer") ?? false); + let historyOpen = $state(resolvedStorage.get<boolean>("historyPanel") ?? false); + let running = $state(false); + + $effect(() => { + void session.tabs; + void session.activeId; + + const timer = setTimeout(() => { + session.persist(); + }, PERSIST_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }); + + $effect(() => { + void history.entries; + + const timer = setTimeout(() => { + history.persist(); + }, PERSIST_DEBOUNCE_MS); + + return () => clearTimeout(timer); + }); + + $effect(() => { + resolvedStorage.set("docExplorer", docsOpen); + }); + + $effect(() => { + resolvedStorage.set("historyPanel", historyOpen); + }); + + onMount(() => { + schema.introspect(fetcher); + }); + + async function run() { + if (running) + return; + + running = true; + + try { + const tab = session.active; + const ok = await session.run(fetcher); + + if (ok && tab) { + history.add({ + headers: tab.headers, + operationName: tab.operationName, + query: tab.query, + title: tab.title, + variables: tab.variables + }); + } + } finally { + running = false; + } + } + + function loadHistory(id: string, inNewTab: boolean) { + const entry = history.entries.find((e) => e.id === id); + + if (!entry) + return; + + const seed = { + headers: entry.headers, + operationName: entry.operationName, + query: entry.query, + title: entry.title, + variables: entry.variables + }; + + if (inNewTab) + session.addTab(seed); + else + session.overwriteActive(seed); + } + + function onQueryChange(value: string) { + if (session.active) + session.updateQuery(session.active.id, value); + } + + function onVariablesChange(value: string) { + if (session.active) + session.active.variables = value; + } + + function onHeadersChange(value: string) { + if (session.active) + session.active.headers = value; + } + + function onKeydown(event: KeyboardEvent) { + const meta = event.metaKey || event.ctrlKey; + + if (meta && event.key === "Enter") { + event.preventDefault(); + run(); + } + } + + function onBeforeUnload() { + session.persist(); + } +</script> + +<style lang="scss"> + .graphiql { + width: 100%; height: 100%; + + background-color: var(--graphiql-bg, #1e1e1e); + color: var(--graphiql-fg, #d4d4d4); + display: grid; + font-family: var(--graphiql-font, ui-monospace, SFMono-Regular, monospace); + grid-template-rows: auto auto 1fr; + } + + .panes { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 0; + + &.history-open { + grid-template-columns: 260px 1fr 1fr; + } + + &.docs-open { + grid-template-columns: 1fr 1fr 320px; + } + + &.history-open.docs-open { + grid-template-columns: 260px 1fr 1fr 320px; + } + } + + .left { + border-right: 1px solid var(--graphiql-border, #333); + display: grid; + grid-template-rows: 2fr auto 1fr; + min-height: 0; + } + + .query { + min-height: 0; + } + + .switcher { + background-color: var(--graphiql-panel, #252526); + border-top: 1px solid var(--graphiql-border, #333); + display: flex; + } + + .switch { + background: none; + border: none; + cursor: pointer; + font-size: 0.75rem; + letter-spacing: 0.05em; + padding: 0.375rem 0.75rem; + text-transform: uppercase; + + &:not(.active) { + color: var(--graphiql-muted, #858585); + } + + &.active { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .bottom { + min-height: 0; + } + + .right { + min-height: 0; + } + + .status { + background-color: var(--graphiql-panel, #252526); + border-top: 1px solid var(--graphiql-border, #333); + font-size: 0.75rem; + padding: 0.25rem 0.75rem; + } +</style> + +<svelte:window onbeforeunload={onBeforeUnload} onkeydown={onKeydown}/> + +<div class="graphiql"> + <Toolbar + disabled={running || !session.active} + docsAvailable={schema.schema !== null} + {docsOpen} + {historyOpen} + onRun={run} + onSelectOperation={(name) => { + 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}/> + <TabBar + activeId={session.activeId} + onAdd={() => session.addTab()} + onClose={(id) => session.closeTab(id)} + onRename={(id, title) => session.renameTab(id, title)} + onSelect={(id) => session.selectTab(id)} + tabs={session.tabs}/> + <div class="panes" class:docs-open={docsOpen && schema.schema} class:history-open={historyOpen}> + {#if historyOpen} + <HistoryPanel + entries={history.entries} + onClear={() => history.clear()} + onFavorite={(id) => history.favorite(id)} + onLoad={loadHistory} + onRemove={(id) => history.remove(id)}/> + {/if} + <div class="left"> + <div class="query"> + <Editor + language="graphql" + onChange={onQueryChange} + schema={schema.sdl} + {theme} + value={session.active?.query ?? ""}/> + </div> + <div class="switcher"> + <button + class="switch" + class:active={bottomPane === "variables"} + onclick={() => (bottomPane = "variables")}>Variables</button> + <button + class="switch" + class:active={bottomPane === "headers"} + onclick={() => (bottomPane = "headers")}>Headers</button> + </div> + <div class="bottom"> + {#if bottomPane === "variables"} + <Editor + language="json" + onChange={onVariablesChange} + {theme} + value={session.active?.variables ?? "{}"}/> + {:else} + <HeadersEditor + onChange={onHeadersChange} + {theme} + value={session.active?.headers ?? "{}"}/> + {/if} + </div> + </div> + <div class="right"> + <ResultViewer {theme} value={session.active?.result ?? ""}/> + </div> + {#if docsOpen && schema.schema} + <DocExplorer schema={schema.schema}/> + {/if} + </div> + {#if schema.error} + <div class="status">Schema error: {schema.error}</div> + {/if} +</div> 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 @@ +<script lang="ts"> + import FieldView from "./DocExplorer/FieldView.svelte"; + import TypeView from "./DocExplorer/TypeView.svelte"; + import { + isInputObjectType, + isInterfaceType, + isObjectType + } from "graphql"; + import type { + GraphQLField, + GraphQLInputField, + GraphQLNamedType, + GraphQLSchema + } from "graphql"; + + type NavEntry = + | { kind: "field"; fieldName: string; typeName: string } + | { kind: "type"; name: string }; + + type Props = { + schema: GraphQLSchema; + }; + + let { schema }: Props = $props(); + + let stack = $state<NavEntry[]>([]); + + const current = $derived<NavEntry | null>(stack.length > 0 ? stack[stack.length - 1] : null); + + const rootTypes = $derived.by(() => { + const out: { label: string; type: GraphQLNamedType }[] = []; + const q = schema.getQueryType(); + const m = schema.getMutationType(); + const s = schema.getSubscriptionType(); + + if (q) + out.push({ label: "Query", type: q }); + + if (m) + out.push({ label: "Mutation", type: m }); + + if (s) + out.push({ label: "Subscription", type: s }); + + return out; + }); + + const currentType = $derived.by<GraphQLNamedType | null>(() => { + if (!current) + return null; + + const name = current.kind === "type" ? current.name : current.typeName; + return schema.getType(name) ?? null; + }); + + const currentField = $derived.by<GraphQLField<unknown, unknown> | GraphQLInputField | null>(() => { + if (!current || current.kind !== "field" || !currentType) + return null; + + if (isObjectType(currentType) || isInterfaceType(currentType) || isInputObjectType(currentType)) + return currentType.getFields()[current.fieldName] ?? null; + + return null; + }); + + function crumbLabel(entry: NavEntry): string { + return entry.kind === "type" ? entry.name : entry.fieldName; + } + + function gotoRoot() { + stack = []; + } + + function gotoIndex(index: number) { + stack = stack.slice(0, index + 1); + } + + function pushType(name: string) { + if (!schema.getType(name)) + return; + + stack = [...stack, { kind: "type", name }]; + } + + function pushField(fieldName: string) { + const typeName = current?.kind === "type" ? current.name : null; + + if (!typeName) + return; + + stack = [...stack, { fieldName, kind: "field", typeName }]; + } +</script> + +<style lang="scss"> + .explorer { + background: var(--graphiql-panel, #252526); + border-left: 1px solid var(--graphiql-border, #333); + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + overflow: hidden; + } + + .breadcrumbs { + align-items: center; + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + flex-wrap: wrap; + font-size: 0.8125rem; + gap: 0.25rem; + padding: 0.5rem 0.75rem; + } + + .crumb { + background: none; + border: none; + color: var(--graphiql-link, #79b8ff); + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + + &:hover { + text-decoration: underline; + } + + &.current { + color: var(--graphiql-fg, #d4d4d4); + cursor: default; + + &:hover { + text-decoration: none; + } + } + } + + .separator { + color: var(--graphiql-muted, #858585); + } + + .body { + min-height: 0; + overflow-y: auto; + } + + .root { + display: grid; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .section-label { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; + text-transform: uppercase; + } + + .root-list { + display: grid; + gap: 0.375rem; + } + + .root-link { + background: none; + border: none; + color: var(--graphiql-link, #79b8ff); + cursor: pointer; + font-family: inherit; + font-size: 0.875rem; + padding: 0; + text-align: left; + + &:hover { + text-decoration: underline; + } + } + + .empty { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + padding: 0.75rem 1rem; + } +</style> + +<div class="explorer"> + <div class="breadcrumbs"> + <button + class="crumb" + class:current={stack.length === 0} + onclick={gotoRoot}>Docs</button> + {#each stack as entry, i} + <span class="separator">/</span> + <button + class="crumb" + class:current={i === stack.length - 1} + onclick={() => gotoIndex(i)}>{crumbLabel(entry)}</button> + {/each} + </div> + <div class="body"> + {#if stack.length === 0} + <div class="root"> + <div class="section-label">Root Types</div> + <div class="root-list"> + {#each rootTypes as entry} + <button class="root-link" onclick={() => pushType(entry.type.name)}> + {entry.label}: {entry.type.name} + </button> + {/each} + </div> + </div> + {:else if currentField} + <FieldView field={currentField} onNavigate={pushType}/> + {:else if currentType} + <TypeView + onNavigateField={pushField} + onNavigateType={pushType} + type={currentType}/> + {:else} + <div class="empty">Type not found in schema.</div> + {/if} + </div> +</div> 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 @@ +<script lang="ts"> + import TypeLink from "./TypeLink.svelte"; + import type { GraphQLField, GraphQLInputField } from "graphql"; + + type Props = { + field: GraphQLField<unknown, unknown> | GraphQLInputField; + onNavigate: (typeName: string) => void; + }; + + let { field, onNavigate }: Props = $props(); + + const args = $derived("args" in field ? field.args : []); +</script> + +<style lang="scss"> + .field { + display: grid; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .heading { + font-size: 0.95rem; + font-weight: 600; + } + + .section-label { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; + text-transform: uppercase; + } + + .description { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + line-height: 1.4; + } + + .args { + display: grid; + gap: 0.375rem; + } + + .arg { + font-size: 0.8125rem; + } + + .arg-name { + color: var(--graphiql-fg, #d4d4d4); + } + + .arg-description { + color: var(--graphiql-muted, #858585); + font-size: 0.75rem; + margin-left: 1rem; + margin-top: 0.125rem; + } +</style> + +<div class="field"> + <div class="heading">{field.name}</div> + {#if field.description} + <div class="description">{field.description}</div> + {/if} + <div> + <div class="section-label">Type</div> + <TypeLink {onNavigate} type={field.type}/> + </div> + {#if args.length > 0} + <div> + <div class="section-label">Arguments</div> + <div class="args"> + {#each args as arg} + <div class="arg"> + <span class="arg-name">{arg.name}</span>: + <TypeLink {onNavigate} type={arg.type}/> + {#if arg.description} + <div class="arg-description">{arg.description}</div> + {/if} + </div> + {/each} + </div> + </div> + {/if} +</div> 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 @@ +<script lang="ts"> + import { getNamedType, isListType, isNonNullType } from "graphql"; + import type { GraphQLType } from "graphql"; + + type Props = { + onNavigate: (typeName: string) => void; + type: GraphQLType; + }; + + let { onNavigate, type }: Props = $props(); + + function label(t: GraphQLType): string { + if (isNonNullType(t)) + return `${label(t.ofType)}!`; + + if (isListType(t)) + return `[${label(t.ofType)}]`; + + return t.name; + } + + const named = $derived(getNamedType(type)); + const text = $derived(label(type)); +</script> + +<style lang="scss"> + .link { + background: none; + border: none; + color: var(--graphiql-link, #79b8ff); + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + + &:hover { + text-decoration: underline; + } + } +</style> + +<button class="link" onclick={() => onNavigate(named.name)}>{text}</button> 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 @@ +<script lang="ts"> + import TypeLink from "./TypeLink.svelte"; + import { + isEnumType, + isInputObjectType, + isInterfaceType, + isObjectType, + isScalarType, + isUnionType + } from "graphql"; + import type { GraphQLNamedType } from "graphql"; + + type Props = { + onNavigateField: (fieldName: string) => void; + onNavigateType: (typeName: string) => void; + type: GraphQLNamedType; + }; + + let { onNavigateField, onNavigateType, type }: Props = $props(); + + const fields = $derived.by(() => { + if (isObjectType(type) || isInterfaceType(type) || isInputObjectType(type)) + return Object.values(type.getFields()); + + return []; + }); + + const interfaces = $derived.by(() => { + if (isObjectType(type) || isInterfaceType(type)) + return type.getInterfaces(); + + return []; + }); + + const unionMembers = $derived.by(() => { + if (isUnionType(type)) + return type.getTypes(); + + return []; + }); + + const enumValues = $derived.by(() => { + if (isEnumType(type)) + return type.getValues(); + + return []; + }); + + const kindLabel = $derived.by(() => { + if (isObjectType(type)) + return "type"; + + if (isInterfaceType(type)) + return "interface"; + + if (isUnionType(type)) + return "union"; + + if (isEnumType(type)) + return "enum"; + + if (isInputObjectType(type)) + return "input"; + + if (isScalarType(type)) + return "scalar"; + + return ""; + }); +</script> + +<style lang="scss"> + .type { + display: grid; + gap: 0.75rem; + padding: 0.75rem 1rem; + } + + .heading { + font-size: 0.95rem; + font-weight: 600; + } + + .kind { + color: var(--graphiql-muted, #858585); + font-weight: normal; + margin-right: 0.375rem; + } + + .description { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + line-height: 1.4; + } + + .section-label { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + letter-spacing: 0.05em; + margin-bottom: 0.25rem; + text-transform: uppercase; + } + + .list { + display: grid; + gap: 0.375rem; + } + + .entry { + font-size: 0.8125rem; + } + + .field-button { + background: none; + border: none; + color: var(--graphiql-fg, #d4d4d4); + cursor: pointer; + font-family: inherit; + font-size: inherit; + padding: 0; + + &:hover { + text-decoration: underline; + } + } + + .entry-description { + color: var(--graphiql-muted, #858585); + font-size: 0.75rem; + margin-left: 1rem; + margin-top: 0.125rem; + } +</style> + +<div class="type"> + <div class="heading"> + {#if kindLabel}<span class="kind">{kindLabel}</span>{/if}{type.name} + </div> + {#if type.description} + <div class="description">{type.description}</div> + {/if} + {#if interfaces.length > 0} + <div> + <div class="section-label">Implements</div> + <div class="list"> + {#each interfaces as iface} + <div class="entry"> + <TypeLink onNavigate={onNavigateType} type={iface}/> + </div> + {/each} + </div> + </div> + {/if} + {#if fields.length > 0} + <div> + <div class="section-label">Fields</div> + <div class="list"> + {#each fields as field} + <div class="entry"> + <button + class="field-button" + onclick={() => onNavigateField(field.name)}>{field.name}</button>: + <TypeLink onNavigate={onNavigateType} type={field.type}/> + {#if field.description} + <div class="entry-description">{field.description}</div> + {/if} + </div> + {/each} + </div> + </div> + {/if} + {#if unionMembers.length > 0} + <div> + <div class="section-label">Members</div> + <div class="list"> + {#each unionMembers as member} + <div class="entry"> + <TypeLink onNavigate={onNavigateType} type={member}/> + </div> + {/each} + </div> + </div> + {/if} + {#if enumValues.length > 0} + <div> + <div class="section-label">Values</div> + <div class="list"> + {#each enumValues as value} + <div class="entry"> + <span>{value.name}</span> + {#if value.description} + <div class="entry-description">{value.description}</div> + {/if} + </div> + {/each} + </div> + </div> + {/if} +</div> 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 @@ +<script lang="ts"> + /*** IMPORT ------------------------------------------- ***/ + + import { browser } from "$app/environment"; + import { onMount } from "svelte"; + import type { Extension } from "@codemirror/state"; + import type { EditorView } from "@codemirror/view"; + import type { GraphQLSchema } from "graphql"; + + /*** UTILITY ------------------------------------------ ***/ + + type Props = { + language?: "graphql" | "json"; + onChange: (value: string) => void; + readOnly?: boolean; + schema?: string; + theme?: Extension; + value: string; + }; + + let { + language = "graphql", + onChange, + readOnly = false, + schema, + theme, + value + }: Props = $props(); + + let buildSchemaFn = $state<((sdl: string) => GraphQLSchema) | null>(null); + let container: HTMLDivElement; + let updateSchemaFn = $state<((v: EditorView, s: GraphQLSchema) => void) | null>(null); + let view = $state<EditorView | null>(null); + + onMount(() => { + if (!browser) + return; + + let disposed = false; + + (async () => { + const [ + { EditorView: EV, keymap, lineNumbers, highlightActiveLine }, + { EditorState }, + { defaultKeymap, history, historyKeymap }, + { syntaxHighlighting, defaultHighlightStyle, bracketMatching, indentOnInput }, + { closeBrackets, closeBracketsKeymap }, + { graphql, updateSchema }, + { json }, + { buildSchema } + ] = await Promise.all([ + import("@codemirror/view"), + import("@codemirror/state"), + import("@codemirror/commands"), + import("@codemirror/language"), + import("@codemirror/autocomplete"), + import("cm6-graphql"), + import("@codemirror/lang-json"), + import("graphql") + ]); + + if (disposed) + return; + + const themeExt: Extension = theme ?? (await import("@codemirror/theme-one-dark")).oneDark; + + const languageExt = language === "graphql" ? + graphql(schema ? buildSchema(schema) : undefined) : + json(); + + const instance = new EV({ + parent: container, + state: EditorState.create({ + doc: value, + extensions: [ + lineNumbers(), + highlightActiveLine(), + history(), + bracketMatching(), + closeBrackets(), + indentOnInput(), + syntaxHighlighting(defaultHighlightStyle, { fallback: true }), + keymap.of([...closeBracketsKeymap, ...defaultKeymap, ...historyKeymap]), + languageExt, + themeExt, + EV.editable.of(!readOnly), + EV.updateListener.of((u) => { + if (u.docChanged) + onChange(u.state.doc.toString()); + }) + ] + }) + }); + + view = instance; + updateSchemaFn = updateSchema; + buildSchemaFn = buildSchema; + })(); + + return () => { + disposed = true; + view?.destroy(); + }; + }); + + $effect(() => { + if (!view || !updateSchemaFn || !buildSchemaFn) + return; + + if (language !== "graphql" || !schema) + return; + + try { + updateSchemaFn(view, buildSchemaFn(schema)); + } catch { + // Invalid SDL — silently skip; editor keeps working without schema awareness + } + }); +</script> + +<style lang="scss"> + .editor { + height: 100%; + width: 100%; + + :global(.cm-editor) { + height: 100%; + } + + :global(.cm-scroller) { + font-family: inherit; + } + } +</style> + +<div bind:this={container} class="editor"></div> 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 @@ +<script lang="ts"> + import Editor from "./Editor.svelte"; + import type { Extension } from "@codemirror/state"; + + type Props = { + onChange: (value: string) => void; + theme?: Extension; + value: string; + }; + + let { onChange, theme, value }: Props = $props(); +</script> + +<style lang="scss"> + .headers { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + } + + .label { + background-color: var(--graphiql-panel, #252526); + font-size: 0.75rem; + letter-spacing: 0.05em; + padding: 0.25rem 0.75rem; + text-transform: uppercase; + } +</style> + +<div class="headers"> + <div class="label">Headers</div> + <Editor language="json" {onChange} {theme} {value}/> +</div> 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 @@ +<script lang="ts"> + import type { HistoryEntry } from "../state/history.svelte.ts"; + + type Props = { + entries: HistoryEntry[]; + onClear: () => void; + onFavorite: (id: string) => void; + onLoad: (id: string, inNewTab: boolean) => void; + onRemove: (id: string) => void; + }; + + let { entries, onClear, onFavorite, onLoad, onRemove }: Props = $props(); + + const sorted = $derived([...entries].sort((a, b) => { + if (a.favorite !== b.favorite) + return a.favorite ? -1 : 1; + + return b.timestamp - a.timestamp; + })); + + function formatTimestamp(ms: number): string { + const d = new Date(ms); + return d.toLocaleString(); + } + + function onEntryClick(event: MouseEvent, id: string) { + onLoad(id, event.shiftKey); + } + + function onEntryKey(event: KeyboardEvent, id: string) { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onLoad(id, event.shiftKey); + } + } +</script> + +<style lang="scss"> + .panel { + background: var(--graphiql-panel, #252526); + border-right: 1px solid var(--graphiql-border, #333); + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + overflow: hidden; + } + + .header { + align-items: center; + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + justify-content: space-between; + padding: 0.5rem 0.75rem; + } + + .title { + font-size: 0.8125rem; + font-weight: 600; + } + + .clear { + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-family: inherit; + font-size: 0.75rem; + padding: 0; + + &:hover { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .list { + display: grid; + gap: 0.125rem; + min-height: 0; + overflow-y: auto; + padding: 0.375rem 0; + } + + .entry { + align-items: flex-start; + cursor: pointer; + display: grid; + gap: 0.125rem; + grid-template-columns: auto 1fr auto; + padding: 0.375rem 0.75rem; + + &:hover { + background: var(--graphiql-bg, #1e1e1e); + } + } + + .star { + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-size: 0.875rem; + padding: 0 0.375rem 0 0; + + &.active { + color: var(--graphiql-accent, #e3b341); + } + } + + .meta { + align-self: center; + min-width: 0; + } + + .entry-title { + font-size: 0.8125rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .entry-time { + color: var(--graphiql-muted, #858585); + font-size: 0.7rem; + } + + .remove { + align-self: center; + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0 0.25rem; + + &:hover { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .empty { + color: var(--graphiql-muted, #858585); + font-size: 0.8125rem; + padding: 0.75rem; + } +</style> + +<div class="panel"> + <div class="header"> + <span class="title">History</span> + {#if entries.length > 0} + <button class="clear" onclick={onClear} type="button">Clear</button> + {/if} + </div> + <div class="list"> + {#if sorted.length === 0} + <div class="empty">No history yet.</div> + {:else} + {#each sorted as entry (entry.id)} + <div + aria-label="Load history entry" + class="entry" + onclick={(e) => onEntryClick(e, entry.id)} + onkeydown={(e) => onEntryKey(e, entry.id)} + role="button" + tabindex="0"> + <button + aria-label={entry.favorite ? "Unfavorite" : "Favorite"} + class="star" + class:active={entry.favorite} + onclick={(e) => { e.stopPropagation(); onFavorite(entry.id); }} + type="button">{entry.favorite ? "★" : "☆"}</button> + <div class="meta"> + <div class="entry-title">{entry.title}</div> + <div class="entry-time">{formatTimestamp(entry.timestamp)}</div> + </div> + <button + aria-label="Remove entry" + class="remove" + onclick={(e) => { e.stopPropagation(); onRemove(entry.id); }} + type="button">×</button> + </div> + {/each} + {/if} + </div> +</div> 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 @@ +<script lang="ts"> + import Editor from "./Editor.svelte"; + import type { Extension } from "@codemirror/state"; + + type Props = { + theme?: Extension; + value: string; + }; + + let { theme, value }: Props = $props(); + + function noop(_v: string) {} +</script> + +<style lang="scss"> + .result { + display: grid; + grid-template-rows: auto 1fr; + height: 100%; + min-height: 0; + } + + .label { + background: var(--graphiql-panel, #252526); + font-size: 0.75rem; + letter-spacing: 0.05em; + padding: 0.25rem 0.75rem; + text-transform: uppercase; + } +</style> + +<div class="result"> + <div class="label">Response</div> + <Editor language="json" onChange={noop} readOnly {theme} {value}/> +</div> 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 @@ +<script lang="ts"> + import { tick } from "svelte"; + import type { Tab } from "../state/session.svelte.ts"; + + type Props = { + activeId: string; + onAdd: () => void; + onClose: (id: string) => void; + onRename: (id: string, title: string) => void; + onSelect: (id: string) => void; + tabs: Tab[]; + }; + + let { activeId, onAdd, onClose, onRename, onSelect, tabs }: Props = $props(); + + let editingId = $state<string | null>(null); + let draft = $state<string>(""); + let inputEl = $state<HTMLInputElement | null>(null); + + async function startEditing(tab: Tab) { + editingId = tab.id; + draft = tab.title; + await tick(); + inputEl?.select(); + } + + function commit() { + if (editingId === null) return; + onRename(editingId, draft); + editingId = null; + draft = ""; + } + + function cancel() { + editingId = null; + draft = ""; + } + + function onKeydown(event: KeyboardEvent) { + if (event.key === "Enter") { + event.preventDefault(); + commit(); + } else if (event.key === "Escape") { + event.preventDefault(); + cancel(); + } + } + + function handleClose(event: MouseEvent, id: string) { + event.stopPropagation(); + onClose(id); + } +</script> + +<style lang="scss"> + .tabbar { + align-items: stretch; + background: var(--graphiql-panel, #252526); + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + font-size: 0.8125rem; + min-height: 2rem; + overflow-x: auto; + } + + .tab { + align-items: center; + background: transparent; + border: none; + border-right: 1px solid var(--graphiql-border, #333); + color: var(--graphiql-muted, #858585); + cursor: pointer; + display: flex; + gap: 0.5rem; + padding: 0 0.75rem; + + &.active { + background: var(--graphiql-bg, #1e1e1e); + color: var(--graphiql-fg, #d4d4d4); + } + + &:hover:not(.active) { + color: var(--graphiql-fg, #d4d4d4); + } + } + + .title { + white-space: nowrap; + } + + .edit { + background: var(--graphiql-bg, #1e1e1e); + border: 1px solid var(--graphiql-accent, #0e639c); + border-radius: 2px; + color: var(--graphiql-fg, #d4d4d4); + font-family: inherit; + font-size: inherit; + min-width: 6rem; + outline: none; + padding: 0.125rem 0.25rem; + } + + .close { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + line-height: 1; + opacity: 0.6; + padding: 0; + + &:hover { + opacity: 1; + } + } + + .add { + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-size: 1rem; + padding: 0 0.75rem; + + &:hover { + color: var(--graphiql-fg, #d4d4d4); + } + } +</style> + +<div class="tabbar"> + {#each tabs as tab (tab.id)} + <button + class="tab" + class:active={tab.id === activeId} + ondblclick={() => startEditing(tab)} + onclick={() => onSelect(tab.id)} + > + {#if editingId === tab.id} + <input + bind:this={inputEl} + bind:value={draft} + class="edit" + onblur={commit} + onclick={(e) => e.stopPropagation()} + onkeydown={onKeydown} + type="text" + /> + {:else} + <span class="title">{tab.title}</span> + {/if} + <button + aria-label="Close tab" + class="close" + onclick={(e) => handleClose(e, tab.id)} + >×</button> + </button> + {/each} + <button aria-label="New tab" class="add" onclick={onAdd}>+</button> +</div> 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 @@ +<script lang="ts"> + import type { OperationInfo } from "../graphql/operations.ts"; + + type Props = { + disabled: boolean; + docsAvailable?: boolean; + docsOpen?: boolean; + historyOpen?: boolean; + onRun: () => void; + onSelectOperation?: (name: string | null) => void; + onToggleDocs?: () => void; + onToggleHistory?: () => void; + operationName?: string | null; + operations?: OperationInfo[]; + running: boolean; + schemaLoading: boolean; + }; + + let { + disabled, + docsAvailable = false, + docsOpen = false, + historyOpen = false, + onRun, + onSelectOperation, + onToggleDocs, + onToggleHistory, + operationName = null, + operations = [], + running, + schemaLoading + }: Props = $props(); + + const namedOperations = $derived(operations.filter((o) => o.name !== null)); + + function onPick(event: Event) { + const value = (event.currentTarget as HTMLSelectElement).value; + onSelectOperation?.(value || null); + } +</script> + +<style lang="scss"> + .toolbar { + align-items: center; + background: var(--graphiql-panel, #252526); + border-bottom: 1px solid var(--graphiql-border, #333); + display: flex; + gap: 0.75rem; + padding: 0.5rem 0.75rem; + } + + .run { + background: var(--graphiql-accent, #0e639c); + border: none; + border-radius: 3px; + color: #fff; + cursor: pointer; + font-size: 0.875rem; + padding: 0.375rem 1rem; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + } + + .hint { + color: var(--graphiql-muted, #858585); + font-size: 0.75rem; + } + + .picker { + background: var(--graphiql-bg, #1e1e1e); + border: 1px solid var(--graphiql-border, #333); + border-radius: 3px; + color: var(--graphiql-fg, #d4d4d4); + font-family: inherit; + font-size: 0.8125rem; + padding: 0.25rem 0.5rem; + } + + .spacer { + flex: 1; + } + + .toggle { + background: none; + border: 1px solid var(--graphiql-border, #333); + border-radius: 3px; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-family: inherit; + font-size: 0.75rem; + padding: 0.25rem 0.625rem; + + &.active { + background: var(--graphiql-bg, #1e1e1e); + color: var(--graphiql-fg, #d4d4d4); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } + + &:hover:not(:disabled) { + color: var(--graphiql-fg, #d4d4d4); + } + } +</style> + +<div class="toolbar"> + <button class="run" {disabled} onclick={onRun}> + {running ? "Running…" : "Run"} + </button> + {#if namedOperations.length > 1} + <select + aria-label="Operation" + class="picker" + onchange={onPick} + value={operationName ?? ""}> + <option value="">Select operation…</option> + {#each namedOperations as op} + <option value={op.name}>{op.type} {op.name}</option> + {/each} + </select> + {/if} + <span class="hint">⌘/Ctrl + Enter</span> + {#if schemaLoading} + <span class="hint">Loading schema…</span> + {/if} + <span class="spacer"></span> + {#if onToggleHistory} + <button + aria-pressed={historyOpen} + class="toggle" + class:active={historyOpen} + onclick={onToggleHistory} + type="button">History</button> + {/if} + {#if onToggleDocs} + <button + aria-pressed={docsOpen} + class="toggle" + class:active={docsOpen} + disabled={!docsAvailable} + onclick={onToggleDocs} + type="button">Docs</button> + {/if} +</div> 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<string, string>; + operationName?: string | null; + query: string; + variables?: Record<string, unknown>; +}; + +export type FetcherResult = Record<string, unknown>; +export type Fetcher = (req: FetcherRequest) => Promise<FetcherResult> | AsyncIterable<FetcherResult>; + +export type FetcherOptions = { + fetch?: typeof globalThis.fetch; + headers?: Record<string, string>; + 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<T>(initial: T): T; +declare function $state<T>(): T | undefined; + +declare namespace $state { + function raw<T>(initial: T): T; + function raw<T>(): T | undefined; + function snapshot<T>(value: T): T; +} + +declare function $derived<T>(expression: T): T; + +declare namespace $derived { + function by<T>(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 = Record<string, unknown>>(): T; +declare function $bindable<T>(fallback?: T): T; +declare function $inspect<T>(...values: T[]): { with: (fn: (type: "init" | "update", ...values: T[]) => void) => void }; +declare function $host<T extends HTMLElement = HTMLElement>(): 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<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)); + } + }; +} 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)]; diff --git a/tests/history.test.ts b/tests/history.test.ts new file mode 100644 index 0000000..ecd7785 --- /dev/null +++ b/tests/history.test.ts @@ -0,0 +1,77 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { assertEquals } from "jsr:@std/assert@^1.0.0"; + +/*** UTILITY ------------------------------------------ ***/ + +import { evict } from "../source/library/state/history-logic.ts"; + +type Entry = { + favorite: boolean; + id: string; + timestamp: number; +}; + +function entry(id: string, timestamp: number, favorite = false): Entry { + return { favorite, id, timestamp }; +} + +/*** TESTS -------------------------------------------- ***/ + +Deno.test("evict keeps everything when under cap", () => { + const entries = [entry("a", 3), entry("b", 2), entry("c", 1)]; + assertEquals(evict(entries, 5), entries); +}); + +Deno.test("evict drops the oldest non-favorites above cap", () => { + const entries = [ + entry("a", 5), + entry("b", 4), + entry("c", 3), + entry("d", 2), + entry("e", 1) + ]; + const kept = evict(entries, 3); + assertEquals(kept.map((e) => e.id), ["a", "b", "c"]); +}); + +Deno.test("evict never drops favorites", () => { + const entries = [ + entry("a", 10), + entry("b", 9), + entry("fav-old", 1, true), + entry("c", 8), + entry("d", 7) + ]; + const kept = evict(entries, 3); + + assertEquals(kept.some((e) => e.id === "fav-old"), true); + assertEquals(kept.length, 3); +}); + +Deno.test("evict can exceed cap when favorites alone do so", () => { + const entries = [ + entry("fav-1", 5, true), + entry("fav-2", 4, true), + entry("fav-3", 3, true), + entry("regular", 2) + ]; + const kept = evict(entries, 2); + + assertEquals(kept.length, 3); + assertEquals(kept.every((e) => e.favorite), true); +}); + +Deno.test("evict sorts by timestamp descending", () => { + const entries = [ + entry("c", 1), + entry("a", 3), + entry("b", 2), + entry("d", 0) + ]; + const kept = evict(entries, 3); + assertEquals(kept.map((e) => e.id), ["a", "b", "c"]); +}); diff --git a/tests/operations.test.ts b/tests/operations.test.ts new file mode 100644 index 0000000..99357ea --- /dev/null +++ b/tests/operations.test.ts @@ -0,0 +1,64 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { assertEquals } from "jsr:@std/assert@^1.0.0"; + +/*** UTILITY ------------------------------------------ ***/ + +import { deriveTitle, parseOperations } from "../source/library/graphql/operations.ts"; + +/*** TESTS -------------------------------------------- ***/ + +Deno.test("parseOperations returns empty for blank query", () => { + assertEquals(parseOperations(""), []); + assertEquals(parseOperations(" "), []); +}); + +Deno.test("parseOperations returns empty on syntax error", () => { + assertEquals(parseOperations("query { ..."), []); +}); + +Deno.test("parseOperations captures a single named query", () => { + const ops = parseOperations("query Foo { viewer { id } }"); + assertEquals(ops, [{ name: "Foo", type: "query" }]); +}); + +Deno.test("parseOperations returns null name for anonymous ops", () => { + const ops = parseOperations("{ viewer { id } }"); + assertEquals(ops, [{ name: null, type: "query" }]); +}); + +Deno.test("parseOperations captures multiple operations", () => { + const ops = parseOperations(` + query Foo { a } + mutation Bar { b } + subscription Baz { c } + `); + assertEquals(ops, [ + { name: "Foo", type: "query" }, + { name: "Bar", type: "mutation" }, + { name: "Baz", type: "subscription" } + ]); +}); + +Deno.test("deriveTitle prefers the first operation name", () => { + const ops = parseOperations("query Foo { a }"); + assertEquals(deriveTitle("query Foo { a }", ops), "Foo"); +}); + +Deno.test("deriveTitle falls back to operation type", () => { + const ops = parseOperations("mutation { a }"); + assertEquals(deriveTitle("mutation { a }", ops), "mutation"); +}); + +Deno.test("deriveTitle falls back to the first 20 chars when unparsable", () => { + const query = "this is not valid graphql at all"; + assertEquals(deriveTitle(query, parseOperations(query)), "this is not valid gr"); +}); + +Deno.test("deriveTitle returns 'untitled' for empty input", () => { + assertEquals(deriveTitle("", []), "untitled"); + assertEquals(deriveTitle(" ", []), "untitled"); +}); diff --git a/tests/storage.test.ts b/tests/storage.test.ts new file mode 100644 index 0000000..7d6ba73 --- /dev/null +++ b/tests/storage.test.ts @@ -0,0 +1,83 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { assertEquals } from "jsr:@std/assert@^1.0.0"; + +/*** UTILITY ------------------------------------------ ***/ + +import { + createLocalStorage, + createMemoryStorage +} from "../source/library/state/storage.ts"; + +/*** TESTS -------------------------------------------- ***/ + +Deno.test("memory storage round-trips objects", () => { + const storage = createMemoryStorage(); + storage.set("k", { hello: "world" }); + assertEquals(storage.get<{ hello: string }>("k"), { hello: "world" }); +}); + +Deno.test("memory storage returns null for missing keys", () => { + const storage = createMemoryStorage(); + assertEquals(storage.get("missing"), null); +}); + +Deno.test("memory storage remove clears a key", () => { + const storage = createMemoryStorage(); + storage.set("k", 42); + storage.remove("k"); + assertEquals(storage.get("k"), null); +}); + +Deno.test("memory storage instances are isolated", () => { + const a = createMemoryStorage(); + const b = createMemoryStorage(); + a.set("shared", 1); + assertEquals(b.get("shared"), null); +}); + +Deno.test("local storage namespaces keys", () => { + globalThis.localStorage.clear(); + + const alpha = createLocalStorage("alpha"); + const beta = createLocalStorage("beta"); + + alpha.set("shared", { tag: "a" }); + beta.set("shared", { tag: "b" }); + + assertEquals(alpha.get<{ tag: string }>("shared"), { tag: "a" }); + assertEquals(beta.get<{ tag: string }>("shared"), { tag: "b" }); + assertEquals(globalThis.localStorage.getItem("alpha:shared"), JSON.stringify({ tag: "a" })); + assertEquals(globalThis.localStorage.getItem("beta:shared"), JSON.stringify({ tag: "b" })); + + globalThis.localStorage.clear(); +}); + +Deno.test("local storage remove respects the namespace", () => { + globalThis.localStorage.clear(); + + const alpha = createLocalStorage("alpha"); + const beta = createLocalStorage("beta"); + + alpha.set("k", 1); + beta.set("k", 2); + + alpha.remove("k"); + assertEquals(alpha.get("k"), null); + assertEquals(beta.get<number>("k"), 2); + + globalThis.localStorage.clear(); +}); + +Deno.test("local storage returns null on malformed JSON", () => { + globalThis.localStorage.clear(); + globalThis.localStorage.setItem("alpha:bad", "not-json"); + + const alpha = createLocalStorage("alpha"); + assertEquals(alpha.get("bad"), null); + + globalThis.localStorage.clear(); +}); |