aboutsummaryrefslogtreecommitdiff
path: root/source/library/components
diff options
context:
space:
mode:
Diffstat (limited to 'source/library/components')
-rw-r--r--source/library/components/HistoryPanel.svelte106
-rw-r--r--source/library/components/ResultViewer.svelte51
-rw-r--r--source/library/components/Splitter.svelte123
-rw-r--r--source/library/components/Toolbar.svelte5
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}