diff options
Diffstat (limited to 'source/library/components')
| -rw-r--r-- | source/library/components/HistoryPanel.svelte | 106 | ||||
| -rw-r--r-- | source/library/components/ResultViewer.svelte | 51 | ||||
| -rw-r--r-- | source/library/components/Splitter.svelte | 123 | ||||
| -rw-r--r-- | source/library/components/Toolbar.svelte | 5 |
4 files changed, 277 insertions, 8 deletions
diff --git a/source/library/components/HistoryPanel.svelte b/source/library/components/HistoryPanel.svelte index b7f5c4c..01f397a 100644 --- a/source/library/components/HistoryPanel.svelte +++ b/source/library/components/HistoryPanel.svelte @@ -3,13 +3,29 @@ type Props = { entries: HistoryEntry[]; + notice?: string | null; onClear: () => void; + onDismissNotice?: () => void; + onExport?: () => void; onFavorite: (id: string) => void; + onImport?: (file: File) => void; onLoad: (id: string, inNewTab: boolean) => void; onRemove: (id: string) => void; }; - let { entries, onClear, onFavorite, onLoad, onRemove }: Props = $props(); + let { + entries, + notice = null, + onClear, + onDismissNotice, + onExport, + onFavorite, + onImport, + onLoad, + onRemove + }: Props = $props(); + + let fileInput = $state<HTMLInputElement | null>(null); const sorted = $derived([...entries].sort((a, b) => { if (a.favorite !== b.favorite) @@ -33,6 +49,20 @@ onLoad(id, event.shiftKey); } } + + function onImportClick() { + fileInput?.click(); + } + + function onFileChange(event: Event) { + const input = event.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + + if (file) + onImport?.(file); + + input.value = ""; + } </script> <style lang="scss"> @@ -50,6 +80,7 @@ align-items: center; border-bottom: 1px solid var(--graphiql-border, #333); display: flex; + gap: 0.5rem; justify-content: space-between; padding: 0.5rem 0.75rem; } @@ -59,7 +90,12 @@ font-weight: 600; } - .clear { + .actions { + display: flex; + gap: 0.5rem; + } + + .action { background: none; border: none; color: var(--graphiql-muted, #858585); @@ -73,6 +109,36 @@ } } + .hidden-input { + display: none; + } + + .notice { + align-items: center; + background: var(--graphiql-bg, #1e1e1e); + border-bottom: 1px solid var(--graphiql-border, #333); + color: var(--graphiql-fg, #d4d4d4); + display: flex; + font-size: 0.75rem; + gap: 0.5rem; + justify-content: space-between; + padding: 0.375rem 0.75rem; + } + + .notice-dismiss { + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-size: 0.875rem; + line-height: 1; + padding: 0 0.25rem; + + &:hover { + color: var(--graphiql-fg, #d4d4d4); + } + } + .list { display: grid; gap: 0.125rem; @@ -147,10 +213,38 @@ </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> + <div> + <div class="header"> + <span class="title">History</span> + <div class="actions"> + {#if onExport} + <button class="action" onclick={onExport} type="button">Export</button> + {/if} + {#if onImport} + <button class="action" onclick={onImportClick} type="button">Import</button> + <input + accept="application/json" + bind:this={fileInput} + class="hidden-input" + onchange={onFileChange} + type="file"/> + {/if} + {#if entries.length > 0} + <button class="action" onclick={onClear} type="button">Clear</button> + {/if} + </div> + </div> + {#if notice} + <div class="notice"> + <span>{notice}</span> + {#if onDismissNotice} + <button + aria-label="Dismiss notice" + class="notice-dismiss" + onclick={onDismissNotice} + type="button">×</button> + {/if} + </div> {/if} </div> <div class="list"> diff --git a/source/library/components/ResultViewer.svelte b/source/library/components/ResultViewer.svelte index 083828d..d277ec8 100644 --- a/source/library/components/ResultViewer.svelte +++ b/source/library/components/ResultViewer.svelte @@ -2,14 +2,50 @@ import Editor from "./Editor.svelte"; import type { Extension } from "@codemirror/state"; import type { Snippet } from "svelte"; + import type { TabTiming } from "../state/session.svelte.ts"; type Props = { footer?: Snippet<[{ result: string }]>; + streamIntervals?: number[]; theme?: Extension; + timing?: TabTiming | null; value: string; }; - let { footer, theme, value }: Props = $props(); + let { + footer, + streamIntervals = [], + theme, + timing = null, + value + }: Props = $props(); + + function median(values: number[]): number { + if (values.length === 0) + return 0; + + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + + return sorted.length % 2 === 0 ? + (sorted[mid - 1] + sorted[mid]) / 2 : + sorted[mid]; + } + + let metadata = $derived.by(() => { + if (!timing) + return null; + + if (streamIntervals.length > 0) { + const medianMs = Math.round(median(streamIntervals)); + const messages = streamIntervals.length + 1; + return `→ ${messages} messages · median ${medianMs}ms`; + } + + const totalMs = Math.round(timing.endMs - timing.startMs); + const firstByteMs = Math.round(timing.firstByteMs - timing.startMs); + return `→ ${totalMs}ms · first byte ${firstByteMs}ms`; + }); function noop(_v: string) {} </script> @@ -17,7 +53,7 @@ <style lang="scss"> .result { display: grid; - grid-template-rows: auto 1fr auto; + grid-template-rows: auto 1fr auto auto; height: 100%; min-height: 0; } @@ -30,6 +66,14 @@ text-transform: uppercase; } + .metadata { + background: var(--graphiql-panel, #252526); + border-top: 1px solid var(--graphiql-border, #333); + color: var(--graphiql-muted, #858585); + font-size: 0.75rem; + padding: 0.25rem 0.75rem; + } + .footer { background: var(--graphiql-panel, #252526); border-top: 1px solid var(--graphiql-border, #333); @@ -41,6 +85,9 @@ <div class="result"> <div class="label">Response</div> <Editor language="json" onChange={noop} readOnly {theme} {value}/> + {#if metadata} + <div class="metadata">{metadata}</div> + {/if} {#if footer} <div class="footer">{@render footer({ result: value })}</div> {/if} diff --git a/source/library/components/Splitter.svelte b/source/library/components/Splitter.svelte new file mode 100644 index 0000000..f4138f2 --- /dev/null +++ b/source/library/components/Splitter.svelte @@ -0,0 +1,123 @@ +<script lang="ts"> + type Props = { + onDrag: (dx: number, dy: number) => void; + onDragEnd?: () => void; + onDragStart?: () => void; + onKeyAdjust?: (delta: number) => void; + orientation: "horizontal" | "vertical"; + }; + + let { onDrag, onDragEnd, onDragStart, onKeyAdjust, orientation }: Props = $props(); + + let dragging = $state(false); + let startX = $state(0); + let startY = $state(0); + + function onPointerDown(event: PointerEvent) { + event.preventDefault(); + dragging = true; + startX = event.clientX; + startY = event.clientY; + event.currentTarget instanceof Element && + event.currentTarget.setPointerCapture(event.pointerId); + onDragStart?.(); + } + + function onPointerMove(event: PointerEvent) { + if (!dragging) + return; + + onDrag(event.clientX - startX, event.clientY - startY); + } + + function onPointerUp(event: PointerEvent) { + if (!dragging) + return; + + dragging = false; + event.currentTarget instanceof Element && + event.currentTarget.releasePointerCapture(event.pointerId); + onDragEnd?.(); + } + + function onKeydown(event: KeyboardEvent) { + if (!onKeyAdjust) + return; + + if (orientation === "horizontal") { + if (event.key === "ArrowLeft") { + event.preventDefault(); + onKeyAdjust(-16); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + onKeyAdjust(16); + } + } else { + if (event.key === "ArrowUp") { + event.preventDefault(); + onKeyAdjust(-16); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + onKeyAdjust(16); + } + } + } +</script> + +<style lang="scss"> + .splitter { + align-items: center; + background: transparent; + display: flex; + justify-content: center; + position: relative; + touch-action: none; + user-select: none; + + &:hover::after, + &.dragging::after { + background: var(--graphiql-accent, #0e639c); + } + + &::after { + background: var(--graphiql-border, #333); + content: ""; + position: absolute; + transition: background 120ms ease; + } + + &.horizontal { + cursor: col-resize; + height: 100%; + width: 6px; + + &::after { + height: 100%; + width: 1px; + } + } + + &.vertical { + cursor: row-resize; + height: 6px; + width: 100%; + + &::after { + height: 1px; + width: 100%; + } + } + } +</style> + +<div + aria-orientation={orientation} + class="splitter {orientation}" + class:dragging + onkeydown={onKeydown} + onpointerdown={onPointerDown} + onpointermove={onPointerMove} + onpointerup={onPointerUp} + role="separator" + tabindex="0" +></div> diff --git a/source/library/components/Toolbar.svelte b/source/library/components/Toolbar.svelte index 8c75668..9882b7d 100644 --- a/source/library/components/Toolbar.svelte +++ b/source/library/components/Toolbar.svelte @@ -8,6 +8,7 @@ docsOpen?: boolean; extras?: Snippet; historyOpen?: boolean; + onFormat?: () => void; onRun: () => void; onSelectOperation?: (name: string | null) => void; onToggleDocs?: () => void; @@ -24,6 +25,7 @@ docsOpen = false, extras, historyOpen = false, + onFormat, onRun, onSelectOperation, onToggleDocs, @@ -128,6 +130,9 @@ {/each} </select> {/if} + {#if onFormat} + <button class="toggle" {disabled} onclick={onFormat} type="button">Format</button> + {/if} {#if extras}{@render extras()}{/if} <span class="hint">⌘/Ctrl + Enter</span> {#if schemaLoading} |