+
{lastSaved && (
- Autosaved {formatClock(lastSaved)}
+ {formatClock(lastSaved)}
)}
-
-
+ >
+ ) : req.kind === 'ws' ? (
+
+ setConn({wsUrl: s})}
+ lines={req.conn.wsLog}
+ onAppend={appendWsLine}
+ onClear={clearWsLog}
+ />
+
+ ) : (
+
+ setConn({grpcTarget: s})}
+ plaintext={req.conn.grpcPlaintext}
+ onPlaintextChange={(b) => setConn({grpcPlaintext: b})}
+ method={req.conn.grpcMethod}
+ onMethodChange={(s) => setConn({grpcMethod: s})}
+ body={req.conn.grpcBody}
+ onBodyChange={(s) => setConn({grpcBody: s})}
+ response={req.conn.grpcResponse}
+ onResponseChange={(s) => setConn({grpcResponse: s})}
+ />
+
+ )}
{/* ββ cURL import dialog ββββββββββββββββββββββββββββββββ */}
@@ -1113,12 +1466,112 @@ function App() {
+ {/* ββ Environment editor dialog βββββββββββββββββββββββββ */}
+
+
{/* ββ Save request dialog βββββββββββββββββββββββββββββββ */}
diff --git a/frontend/src/components/body-editor.tsx b/frontend/src/components/body-editor.tsx
index aeb2aae..22ac857 100644
--- a/frontend/src/components/body-editor.tsx
+++ b/frontend/src/components/body-editor.tsx
@@ -5,7 +5,7 @@ import {Tabs, TabsList, TabsTrigger} from '@/components/ui/tabs'
import {KVRow} from '@/components/kv-row'
import type {KV} from '@/lib/kv'
-export type BodyType = 'none' | 'json' | 'form' | 'raw'
+export type BodyType = 'none' | 'json' | 'form' | 'raw' | 'graphql'
interface BodyEditorProps {
bodyType: BodyType
@@ -16,6 +16,10 @@ interface BodyEditorProps {
onRawChange: (s: string) => void
formRows: KV[]
onFormRowsChange: (rows: KV[]) => void
+ gqlQuery: string
+ onGqlQueryChange: (s: string) => void
+ gqlVars: string
+ onGqlVarsChange: (s: string) => void
}
export function BodyEditor(props: BodyEditorProps) {
@@ -71,6 +75,12 @@ export function BodyEditor(props: BodyEditorProps) {
>
Raw
+
+ GraphQL
+
@@ -137,6 +147,35 @@ export function BodyEditor(props: BodyEditorProps) {
className="min-h-[260px] resize-none font-mono text-[0.8125rem] leading-relaxed"
/>
)}
+
+ {props.bodyType === 'graphql' && (
+
+
+
+ Query
+
+
+
+
+ Variables
+
+
+
+ )}
)
}
diff --git a/frontend/src/components/grpc-panel.tsx b/frontend/src/components/grpc-panel.tsx
new file mode 100644
index 0000000..d809d79
--- /dev/null
+++ b/frontend/src/components/grpc-panel.tsx
@@ -0,0 +1,188 @@
+import {useRef, useState} from 'react'
+import {GRPCListServices, GRPCInvoke, GRPCMethodTemplate} from '../../wailsjs/go/main/App'
+import {Button} from '@/components/ui/button'
+import {Input} from '@/components/ui/input'
+import {Textarea} from '@/components/ui/textarea'
+import {JsonView} from '@/components/json-view'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {Loader2, List as ListIcon, Play, Braces, AlertTriangle} from 'lucide-react'
+
+interface GrpcPanelProps {
+ target: string
+ onTargetChange: (s: string) => void
+ plaintext: boolean
+ onPlaintextChange: (b: boolean) => void
+ method: string
+ onMethodChange: (s: string) => void
+ body: string
+ onBodyChange: (s: string) => void
+ // Last invoke response is lifted into the persisted tab state (like a REST
+ // response); the method list / busy flags stay local and transient.
+ response: string
+ onResponseChange: (s: string) => void
+}
+
+export function GrpcPanel({
+ target,
+ onTargetChange,
+ plaintext,
+ onPlaintextChange,
+ method,
+ onMethodChange,
+ body,
+ onBodyChange,
+ response,
+ onResponseChange,
+}: GrpcPanelProps) {
+ const [methods, setMethods] = useState([])
+ const [error, setError] = useState('')
+ const [busy, setBusy] = useState(false)
+ // Latest method a fill was requested for, so a slow fetch can't overwrite a
+ // newer selection.
+ const pending = useRef('')
+
+ async function list() {
+ setError('')
+ setBusy(true)
+ try {
+ const m = await GRPCListServices(target, plaintext)
+ setMethods(m)
+ if (m.length && !m.includes(method)) pickMethod(m[0])
+ } catch (e) {
+ setError(String(e))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ // Selecting a method loads that method's request template into the body.
+ async function pickMethod(m: string) {
+ onMethodChange(m)
+ pending.current = m
+ try {
+ const tmpl = await GRPCMethodTemplate(target, m, plaintext)
+ if (pending.current !== m) return // superseded by a newer selection
+ onBodyChange(tmpl)
+ } catch {
+ // leave the body as-is if the template can't be fetched
+ }
+ }
+
+ async function invoke() {
+ setError('')
+ onResponseChange('')
+ setBusy(true)
+ try {
+ const out = await GRPCInvoke(target, method, body, {}, plaintext)
+ onResponseChange(out)
+ } catch (e) {
+ setError(String(e))
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ {/* Target bar */}
+
+ onTargetChange(e.target.value)}
+ placeholder="host:port"
+ className="h-9 flex-1 border-0 bg-transparent font-mono text-sm focus-visible:ring-0"
+ />
+ onPlaintextChange(!plaintext)}
+ className={[
+ 'h-8 shrink-0 rounded-md border px-2.5 font-mono text-xs font-semibold transition-colors',
+ plaintext
+ ? 'border-primary/40 bg-primary/15 text-primary'
+ : 'border-border/70 bg-card/40 text-muted-foreground hover:text-foreground',
+ ].join(' ')}
+ title="Toggle plaintext (no TLS)"
+ >
+ plaintext
+
+
+ {busy ? (
+
+ ) : (
+
+ )}
+ List
+
+
+
+ {/* Method + request */}
+
+
+
+
+ Method
+
+ {methods.length > 0 ? (
+
+ ) : (
+
+ List services to choose a method.
+
+ )}
+
+
+
+
+ {error && (
+
+ )}
+
+ {response && (
+
+ )}
+
+ )
+}
diff --git a/frontend/src/components/new-request-menu.tsx b/frontend/src/components/new-request-menu.tsx
new file mode 100644
index 0000000..23752e5
--- /dev/null
+++ b/frontend/src/components/new-request-menu.tsx
@@ -0,0 +1,57 @@
+import {useState, type ReactNode} from 'react'
+import {Globe, Radio, Network} from 'lucide-react'
+import {cn} from '@/lib/utils'
+import type {RequestKind} from '@/lib/model'
+
+// Shared metadata for the three request kinds β label/icon reused by the menu,
+// request tabs, and sidebar badges.
+export const KINDS: {kind: RequestKind; label: string; icon: typeof Globe}[] = [
+ {kind: 'rest', label: 'REST', icon: Globe},
+ {kind: 'ws', label: 'WebSocket', icon: Radio},
+ {kind: 'grpc', label: 'gRPC', icon: Network},
+]
+
+export const kindMeta = (k: RequestKind) => KINDS.find((x) => x.kind === k) ?? KINDS[0]
+
+interface NewRequestMenuProps {
+ onPick: (kind: RequestKind) => void
+ children: ReactNode
+ align?: 'left' | 'right'
+}
+
+export function NewRequestMenu({onPick, children, align = 'right'}: NewRequestMenuProps) {
+ const [open, setOpen] = useState(false)
+ return (
+
+
setOpen((p) => !p)}>{children}
+ {open && (
+ <>
+
setOpen(false)} />
+
+
+ New request
+
+ {KINDS.map(({kind, label, icon: Icon}) => (
+
{
+ onPick(kind)
+ setOpen(false)
+ }}
+ className="flex w-full items-center gap-2 rounded-sm px-2.5 py-1.5 text-left text-sm text-foreground hover:bg-primary/15 hover:text-primary"
+ >
+
+ {label}
+
+ ))}
+
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/components/payload-diff.tsx b/frontend/src/components/payload-diff.tsx
new file mode 100644
index 0000000..1a73407
--- /dev/null
+++ b/frontend/src/components/payload-diff.tsx
@@ -0,0 +1,51 @@
+import {type Payload} from '@/lib/model'
+import {formatDuration, formatSize, statusChipClass} from '@/lib/formatters'
+
+// Line-level diff vs the first payload's body: a line present in the baseline
+// set is "same", otherwise "added". ponytail: set-based, not positional LCS β
+// enough to eyeball which payloads diverge. Upgrade to a real diff if ordering
+// matters.
+function diffLines(baseline: string, body: string) {
+ const baseSet = new Set(baseline.split('\n'))
+ return body.split('\n').map((line) => ({line, same: baseSet.has(line)}))
+}
+
+export function PayloadDiff({payloads}: {payloads: Payload[]}) {
+ const baseline = payloads[0]?.response?.Body ?? ''
+ return (
+
+ {payloads.map((p, i) => {
+ const r = p.response
+ const lines = diffLines(baseline, r?.Body ?? '')
+ return (
+
+
+ P{i + 1}
+ {r?.Status ? (
+ {r.Status}
+ ) : (
+ β
+ )}
+ {r?.DurationMs ? (
+ {formatDuration(r.DurationMs)}
+ ) : null}
+ {r?.SizeBytes ? (
+ {formatSize(r.SizeBytes)}
+ ) : null}
+
+
+ {lines.map((l, j) => (
+
+ {l.line || 'Β '}
+
+ ))}
+
+
+ )
+ })}
+
+ )
+}
diff --git a/frontend/src/components/sidebar.tsx b/frontend/src/components/sidebar.tsx
index e7c0bbf..c5b4fe1 100644
--- a/frontend/src/components/sidebar.tsx
+++ b/frontend/src/components/sidebar.tsx
@@ -7,7 +7,6 @@ import {
Clock,
Plus,
Trash2,
- BookMarked,
FileText,
} from 'lucide-react'
import {Button} from '@/components/ui/button'
@@ -15,6 +14,8 @@ import {Separator} from '@/components/ui/separator'
import {cn} from '@/lib/utils'
import {main} from '../../wailsjs/go/models'
import {formatDuration, statusChipClass} from '@/lib/formatters'
+import {NewRequestMenu} from '@/components/new-request-menu'
+import type {RequestKind} from '@/lib/model'
interface SidebarProps {
collections: main.Collection[]
@@ -26,8 +27,23 @@ interface SidebarProps {
onDeleteCollection: (id: string) => void
onDeleteRequest: (collectionId: string, reqId: string) => void
onClearHistory: () => void
- onSaveRequest: () => void
- onNewTab: () => void
+ onNewTab: (kind: RequestKind) => void
+ onAddRequestToCollection: (collectionId: string, kind: RequestKind) => void
+}
+
+const KIND_LABEL: Record
= {ws: 'WS', grpc: 'gRPC'}
+
+// Saved requests show their protocol: a short kind tag for ws/grpc, the HTTP method otherwise.
+function RequestBadge({req}: {req: main.SavedRequest}) {
+ const kind = (req as unknown as {kind?: string}).kind
+ if (kind && KIND_LABEL[kind]) {
+ return (
+
+ {KIND_LABEL[kind]}
+
+ )
+ }
+ return
}
const METHOD_COLOR: Record = {
@@ -58,8 +74,8 @@ export function Sidebar({
onDeleteCollection,
onDeleteRequest,
onClearHistory,
- onSaveRequest,
onNewTab,
+ onAddRequestToCollection,
}: SidebarProps) {
const [collectionsOpen, setCollectionsOpen] = useState(true)
const [historyOpen, setHistoryOpen] = useState(true)
@@ -95,24 +111,13 @@ export function Sidebar({
return (