diff --git a/CHANGELOG.md b/CHANGELOG.md index c73265e..024376f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **v0.3 — Persistence:** JSON store at `$OS_CONFIG_DIR/hypr/store.json`; data survives restart. +- **Collections:** create named collections, save requests to them (name + target collection dialog), + load saved requests into any tab with one click, delete collections and individual requests. +- **History:** every sent request is recorded (method, URL, status, duration); shows in sidebar; + click an entry to restore the URL into the active tab; auto-capped at 200 entries; clear-all action. +- **Session restore:** open tabs (URL, method, headers, params, body, auth, settings) are persisted + on every meaningful action and restored on the next launch. +- **Sidebar:** collapsible left panel (toggle via `PanelLeftClose`/`PanelLeftOpen` icon) with + Collections and History sections; each section independently collapsible. +- Per-tab method and URL are now properly isolated — switching tabs restores each tab's method+URL + (previously method and URL were shared across all tabs). +- 5 new Go tests in `store_test.go` covering collection CRUD, request save/delete, + history append/clear/cap, and session save/restore (total: 34 tests). + +### Changed +- Backend: added `Store` type backed by JSON, 10 new bound methods + (`ListCollections`, `SaveCollection`, `DeleteCollection`, `SaveRequest`, `DeleteRequest`, + `AppendHistory`, `ListHistory`, `ClearHistory`, `LoadSession`, `SaveSession`). +- `App` struct gains a `store *Store` field initialized in `startup`; failure is non-fatal + (app runs without persistence rather than crashing). +- `KVPair`, `StoredAuth`, `TabState`, `SavedRequest`, `Collection`, `HistoryEntry`, `Session` + Go types added to `store.go`. +- Frontend layout changed from single-column scroll to sidebar + scrollable main area. +- ESLint: `@typescript-eslint/no-empty-function` turned off (fire-and-forget `.catch(() => {})` + is intentional for non-critical persistence calls). + ### Added - Complete UI redesign on Tailwind CSS + shadcn/ui (Radix), with IBM Plex typography and a reusable component kit under `frontend/src/components/ui/`. @@ -23,12 +50,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Git hooks (`.githooks/pre-commit`, `.githooks/pre-push`) that enforce formatting and mirror CI checks locally; enable with `make hooks`. - `Makefile` with `hooks`, `setup`, `lint`, `test`, and `build` targets. +- **v0.2 — Response insight (bundle A):** response status code + status text, request + duration, and body size shown next to the Response heading; status chip is colored by + HTTP class. +- **v0.2 — Body Pretty/Raw toggle** and **search-in-response** with match count, plus + **Copy** and **Save** buttons (the latter via a new `SaveTextFile` Go binding using a + native save dialog). +- **v0.2 — Request power (bundle B):** query-param builder synced two-way with the URL + field; **Auth** section (None / Bearer / Basic / API key with header-or-query target); + **Body** selector (None / JSON with a Format button / Form / Raw); per-request + **Settings** (timeout, follow-redirects, verify TLS); **Copy as cURL** action that + mirrors cURL import. ### Changed +- Backend: introduced `RequestSpec` and a new `Send(spec) RequestResult` bound method + that builds a per-request `http.Client` from settings (timeout, redirects, TLS verify). + `MakeRequest` is retained as a thin wrapper for backward compatibility. +- `RequestResult` now includes `Status`, `StatusText`, `DurationMs`, and `SizeBytes`. +- Non-JSON response bodies are returned verbatim instead of swallowed when JSON pretty- + printing fails. +- Request editor reorganized into tabbed sections (Headers / Params / Body / Auth / + Settings) within a single panel. - cURL import now populates the header rows and request body of the active tab. - The response view now follows the active request tab. - The "add header" action moved next to the Request Headers title. +### Deferred (planned for v0.3) +- Multipart / file-upload body type. +- Iframe HTML preview tab for the response body. + ### Fixed - `//go:embed all:frontend/dist` caused a compile failure on clean checkouts because `frontend/dist/` was gitignored; fixed by committing a `.gitkeep` and updating diff --git a/app.go b/app.go index 2c6848a..f22c587 100644 --- a/app.go +++ b/app.go @@ -3,6 +3,7 @@ package main import ( "bytes" "context" + "crypto/tls" "encoding/json" "io" "net/http" @@ -14,6 +15,7 @@ import ( type App struct { ctx context.Context client *http.Client + store *Store } // NewApp creates a new App application struct @@ -33,9 +35,16 @@ func (a *App) startup(ctx context.Context) { Transport: tr, } a.ctx = ctx + + var err error + a.store, err = newStore() + if err != nil { + // Non-fatal: app runs fine without persistence + a.store = &Store{} + } } -func makeRequest(c *http.Client, +func doRequest(c *http.Client, r *http.Request) ([]byte, *http.Response, error) { resp, err := c.Do(r) if err != nil { @@ -62,6 +71,29 @@ func HeadersToStr(h *http.Header) string { return result } +// BodySpec describes the request body type and content. +type BodySpec struct { + Type string `json:"type"` // "none" | "json" | "form" | "raw" + Raw string `json:"raw"` // raw/json text or pre-encoded form body +} + +// RequestSettings contains per-request client settings. +type RequestSettings struct { + TimeoutMs int `json:"timeoutMs"` // 0 = use default (50s) + FollowRedirects bool `json:"followRedirects"` // true = follow + VerifyTLS bool `json:"verifyTLS"` // true = verify +} + +// RequestSpec is the structured request used by Send(). +type RequestSpec struct { + Method string `json:"method"` + URL string `json:"url"` + Headers map[string]string `json:"headers"` + Body BodySpec `json:"body"` + Settings RequestSettings `json:"settings"` +} + +// RequestResult is the response shape returned to the UI. type RequestResult struct { Method string `json:"Method"` URL string `json:"URL"` @@ -70,6 +102,97 @@ type RequestResult struct { Body string `json:"Body"` HeadersStr string `json:"HeadersStr"` Error string `json:"Error"` + Status int `json:"Status"` + StatusText string `json:"StatusText"` + DurationMs int64 `json:"DurationMs"` + SizeBytes int `json:"SizeBytes"` +} + +// buildClient creates an http.Client configured from RequestSettings. +func buildClient(s RequestSettings) *http.Client { + timeout := 50 * time.Second + if s.TimeoutMs > 0 { + timeout = time.Duration(s.TimeoutMs) * time.Millisecond + } + + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: !s.VerifyTLS}, //nolint:gosec + } + + client := &http.Client{ + Timeout: timeout, + Transport: tr, + } + + if !s.FollowRedirects { + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + } + + return client +} + +// Send builds a per-request http.Client from Settings and executes the request. +func (a *App) Send(spec RequestSpec) RequestResult { + result := RequestResult{ + URL: spec.URL, + Method: spec.Method, + } + + var bodyReader io.Reader + if spec.Body.Type != "none" && spec.Body.Raw != "" { + bodyReader = strings.NewReader(spec.Body.Raw) + result.RequestBody = spec.Body.Raw + } + + r, err := http.NewRequest(spec.Method, spec.URL, bodyReader) + if err != nil { + result.Error = err.Error() + return result + } + + for key, value := range spec.Headers { + r.Header.Add(key, value) + } + + result.ReqHeaders = HeadersToStr(&r.Header) + + client := buildClient(spec.Settings) + + start := time.Now() + res, httpResp, err := doRequest(client, r) + result.DurationMs = time.Since(start).Milliseconds() + + if err != nil { + result.Error = err.Error() + // still capture status if response was partially received + if httpResp != nil { + result.Status = httpResp.StatusCode + result.StatusText = httpResp.Status + } + return result + } + + result.Status = httpResp.StatusCode + result.StatusText = httpResp.Status + result.HeadersStr = HeadersToStr(&httpResp.Header) + result.SizeBytes = len(res) + + // Pretty-print JSON bodies; on failure, return raw body without error + b := bytes.NewBuffer(make([]byte, 0, len(res))) + if jsonErr := json.Indent(b, res, "", " "); jsonErr == nil { + result.Body = b.String() + } else { + result.Body = string(res) + } + + return result +} + +// SaveTextFile opens a native save dialog and writes text content to the chosen file. +func (a *App) SaveTextFile(filename, contents string) error { + return saveTextFile(a.ctx, filename, contents) } func (a *App) RunCurl(curl string) RequestResult { @@ -88,50 +211,42 @@ func (a *App) RunCurl(curl string) RequestResult { return res } +// Header is a single key/value header pair (kept for Export compatibility). type Header struct { Key string Value string } -// Greet returns a greeting for the given name +// MakeRequest is kept for backward compatibility; it delegates to Send. func (a *App) MakeRequest( urlIn string, method string, body string, headers Headers, ) RequestResult { - result := RequestResult{ - URL: urlIn, - Method: method, - RequestBody: body, - } - rbody := bytes.NewBuffer([]byte(body)) - r, err := http.NewRequest(method, urlIn, rbody) - if err != nil { - result.Error = err.Error() - return result + hdrs := make(map[string]string, len(headers)) + for k, v := range headers { + hdrs[k] = v } - for key, value := range headers { - r.Header.Add(key, value) - } - - res, httpResp, err := makeRequest(a.client, r) - if err != nil { - result.Error = err.Error() - return result - } - - result.HeadersStr = HeadersToStr(&httpResp.Header) - b := bytes.NewBuffer(make([]byte, 0, len(res))) - err = json.Indent(b, res, "\n", " ") - if err != nil { - return RequestResult{ - Body: string(res), - Error: err.Error(), - } + spec := RequestSpec{ + Method: method, + URL: urlIn, + Headers: hdrs, + Body: BodySpec{ + Type: "raw", + Raw: body, + }, + Settings: RequestSettings{ + FollowRedirects: true, + VerifyTLS: true, + }, } - result.Body = b.String() + result := a.Send(spec) + // preserve old fields that Send populates differently + result.Method = method + result.URL = urlIn + result.RequestBody = body return result } diff --git a/export.go b/export.go index ce62700..5bc8fb1 100644 --- a/export.go +++ b/export.go @@ -1,6 +1,7 @@ package main import ( + "context" "encoding/json" "log" "os" @@ -56,3 +57,38 @@ func (a *App) Export(req Request, reqHeaders [][]Header, reqBodies []string, r R return nil } + +// saveTextFile opens a native save dialog and writes plain text to the chosen path. +func saveTextFile(ctx context.Context, defaultName, contents string) error { + if defaultName == "" { + defaultName = "response.txt" + } + + fp, err := runtime.SaveFileDialog(ctx, runtime.SaveDialogOptions{ + DefaultFilename: defaultName, + Title: "Save response body", + }) + if err != nil { + log.Println(err) + return err + } + + if fp == "" { + // user cancelled the dialog + return nil + } + + f, err := os.Create(fp) + if err != nil { + log.Println(err) + return err + } + defer f.Close() + + if _, err := f.WriteString(contents); err != nil { + log.Println(err) + return err + } + + return nil +} diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs index d58d819..36c7069 100644 --- a/frontend/.eslintrc.cjs +++ b/frontend/.eslintrc.cjs @@ -20,5 +20,7 @@ module.exports = { '@typescript-eslint/no-explicit-any': 'off', // The React root element is known to exist. '@typescript-eslint/no-non-null-assertion': 'off', + // Fire-and-forget .catch(() => {}) is intentional for non-critical persistence calls. + '@typescript-eslint/no-empty-function': 'off', }, } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ac6279b..73ac094 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,20 @@ -import {useState} from 'react' -import {MakeRequest, RunCurl, Export} from '../wailsjs/go/main/App' +import {useEffect, useRef, useState} from 'react' +import { + Send as SendBinding, + RunCurl, + Export, + SaveTextFile, + ListCollections, + SaveCollection, + DeleteCollection, + SaveRequest as SaveRequestBinding, + DeleteRequest as DeleteRequestBinding, + AppendHistory, + ListHistory, + ClearHistory, + LoadSession, + SaveSession, +} from '../wailsjs/go/main/App' import {main} from '../wailsjs/go/models' import { Send, @@ -13,15 +28,26 @@ import { AlertTriangle, Inbox, Loader2, + Copy, + Save, + Search, + KeyRound, + Settings as SettingsIcon, + Link2, + PanelLeftClose, + PanelLeftOpen, } from 'lucide-react' -import {Header} from './lib/header' -import {RequestHeader} from './components/headerform' +import {KVRow} from './components/kv-row' +import {AuthForm} from './components/auth-form' +import {SettingsForm} from './components/settings-form' +import {BodyEditor, type BodyType} from './components/body-editor' import {JsonView} from './components/json-view' +import {HighlightedText, countMatches} from './components/highlighted-text' +import {Sidebar} from './components/sidebar' +import {SaveRequestDialog} from './components/save-request-dialog' import {Button} from '@/components/ui/button' import {Input} from '@/components/ui/input' import {Textarea} from '@/components/ui/textarea' -import {Label} from '@/components/ui/label' -import {Badge} from '@/components/ui/badge' import {Separator} from '@/components/ui/separator' import { Select, @@ -39,23 +65,15 @@ import { DialogTitle, DialogDescription, } from '@/components/ui/dialog' +import {addRowAt, emptyKV, kvToRecord, removeRowAt, updateRowsAt, type KV} from './lib/kv' +import {applyAuth, emptyAuth, type AuthState} from './lib/auth' +import {defaultSettings, type ReqSettings} from './lib/settings' +import {encodeFormBody, parseUrlParams, rebuildUrlWithParams} from './lib/url-sync' +import {buildCurl} from './lib/curl-builder' +import {formatDuration, formatSize, statusChipClass} from './lib/formatters' +import type {Collection, HistoryEntry, SavedRequest, Session, TabState} from './lib/store-types' -class Request { - method: string - url: string - headers: {[key: string]: string} - body: string - - constructor(source: any = {}) { - if ('string' === typeof source) source = JSON.parse(source) - this.method = source['method'] || source['Method'] || 'GET' - this.url = source['url'] || '' - this.headers = source['headers'] || {} - this.body = source['body'] || '' - } -} - -const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] as const +const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] as const const METHOD_HSL: {[key: string]: string} = { GET: '152 60% 52%', @@ -63,14 +81,16 @@ const METHOD_HSL: {[key: string]: string} = { PUT: '212 90% 62%', DELETE: '0 84% 64%', PATCH: '265 85% 70%', + HEAD: '200 30% 60%', + OPTIONS: '200 30% 60%', } const methodColor = (m: string) => `hsl(${METHOD_HSL[m] ?? '84 78% 56%'})` const emptyResult = () => new main.RequestResult() -// "Key: Value\nKey: Value" -> Header[] -function parseHeaderLines(raw: string): Header[] { +// "Key: Value\nKey: Value" -> KV[] +function parseHeaderLines(raw: string): KV[] { const rows = raw .split('\n') .map((line) => line.trim()) @@ -80,58 +100,262 @@ function parseHeaderLines(raw: string): Header[] { if (idx === -1) return {Key: line, Value: ''} return {Key: line.slice(0, idx).trim(), Value: line.slice(idx + 1).trim()} }) - return rows.length ? rows : [{Key: '', Value: ''}] + return rows.length ? rows : [emptyKV()] } +const initialHeaderRows = (): KV[] => [emptyKV(), emptyKV(), emptyKV()] + function App() { - const [reqBodies, setReqBodies] = useState>(['']) - const [reqHeaders, setReqHeaders] = useState>>([ - [ - {Key: '', Value: ''}, - {Key: '', Value: ''}, - {Key: '', Value: ''}, - ], - ]) - const [responses, setResponses] = useState>([emptyResult()]) const [activeTab, setActiveTab] = useState(0) - const [request, setRequest] = useState(new Request({method: 'GET'})) - const [curlBody, setCurlBody] = useState('') - const [open, setOpen] = useState(false) - const [responseTab, setResponseTab] = useState('body') const [loading, setLoading] = useState(false) + const [sidebarOpen, setSidebarOpen] = useState(true) + + // Per-tab parallel arrays (all must stay in lockstep) + const [methods, setMethods] = useState(['GET']) + const [urls, setUrls] = useState(['']) + const [headers, setHeaders] = useState([initialHeaderRows()]) + const [params, setParams] = useState([[emptyKV()]]) + const [bodyTypes, setBodyTypes] = useState(['none']) + const [jsonBodies, setJsonBodies] = useState(['']) + const [rawBodies, setRawBodies] = useState(['']) + const [formRows, setFormRows] = useState([[emptyKV()]]) + const [auths, setAuths] = useState([emptyAuth()]) + const [settings, setSettings] = useState([defaultSettings()]) + const [responses, setResponses] = useState([emptyResult()]) + + // UI state + const [requestSection, setRequestSection] = useState('headers') + const [responseTab, setResponseTab] = useState('body') + const [bodyView, setBodyView] = useState<'pretty' | 'raw'>('pretty') + const [search, setSearch] = useState('') + const [curlOpen, setCurlOpen] = useState(false) + const [curlBody, setCurlBody] = useState('') + const [saveOpen, setSaveOpen] = useState(false) + + // Persistence state + const [collections, setCollections] = useState([]) + const [history, setHistory] = useState([]) + const sessionSaveTimer = useRef | null>(null) + // ── Derived values for active tab ───────────────────────────── + const method = methods[activeTab] ?? 'GET' + const url = urls[activeTab] ?? '' const result = responses[activeTab] ?? emptyResult() - const hasResponse = Boolean(result.Body || result.Error || result.HeadersStr) + const hasResponse = Boolean(result.Body || result.Error || result.HeadersStr || result.Status) + + // ── Session helpers ─────────────────────────────────────────── + function buildTabState(i: number): TabState { + return { + method: methods[i] ?? 'GET', + url: urls[i] ?? '', + headers: headers[i] ?? initialHeaderRows(), + params: params[i] ?? [emptyKV()], + auth: auths[i] ?? emptyAuth(), + bodyType: bodyTypes[i] ?? 'none', + jsonBody: jsonBodies[i] ?? '', + rawBody: rawBodies[i] ?? '', + formRows: formRows[i] ?? [emptyKV()], + settings: settings[i] ?? defaultSettings(), + } + } - const setMethod = (method: string) => setRequest((p) => ({...p, method})) - const setUrl = (url: string) => setRequest((p) => ({...p, url})) + function persistSession(overrideActiveTab?: number) { + if (sessionSaveTimer.current) clearTimeout(sessionSaveTimer.current) + sessionSaveTimer.current = setTimeout(() => { + const tabCount = methods.length + const openTabs = Array.from({length: tabCount}, (_, i) => buildTabState(i)) + const session: Session = {openTabs, activeTab: overrideActiveTab ?? activeTab} + SaveSession(session).catch(() => {}) + }, 800) + } + + function restoreSession(session: Session) { + const tabs = session.openTabs + if (!tabs?.length) return + setMethods(tabs.map((t) => t.method || 'GET')) + setUrls(tabs.map((t) => t.url || '')) + setHeaders(tabs.map((t) => (t.headers?.length ? t.headers : initialHeaderRows()))) + setParams(tabs.map((t) => (t.params?.length ? t.params : [emptyKV()]))) + setAuths(tabs.map((t) => t.auth || emptyAuth())) + setBodyTypes(tabs.map((t) => (t.bodyType as BodyType) || 'none')) + setJsonBodies(tabs.map((t) => t.jsonBody || '')) + setRawBodies(tabs.map((t) => t.rawBody || '')) + setFormRows(tabs.map((t) => (t.formRows?.length ? t.formRows : [emptyKV()]))) + setSettings(tabs.map((t) => t.settings || defaultSettings())) + setResponses(tabs.map(() => emptyResult())) + setActiveTab(Math.min(session.activeTab || 0, tabs.length - 1)) + } + // ── On mount: load session + collections + history ───────────── + useEffect(() => { + LoadSession() + .then((s) => { + if (s?.openTabs?.length) restoreSession(s) + }) + .catch(() => {}) + + ListCollections() + .then((c) => setCollections(c ?? [])) + .catch(() => {}) + + ListHistory(50) + .then((h) => setHistory(h ?? [])) + .catch(() => {}) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // ── Tab plumbing ────────────────────────────────────────────── const addNewTab = () => { - setReqBodies([...reqBodies, '']) - setReqHeaders([...reqHeaders, [{Key: '', Value: ''}]]) + const n = methods.length + setMethods([...methods, 'GET']) + setUrls([...urls, '']) + setHeaders([...headers, initialHeaderRows()]) + setParams([...params, [emptyKV()]]) + setBodyTypes([...bodyTypes, 'none']) + setJsonBodies([...jsonBodies, '']) + setRawBodies([...rawBodies, '']) + setFormRows([...formRows, [emptyKV()]]) + setAuths([...auths, emptyAuth()]) + setSettings([...settings, defaultSettings()]) setResponses([...responses, emptyResult()]) - setActiveTab(reqBodies.length) + setActiveTab(n) + persistSession(n) } const closeTab = (e: React.MouseEvent, index: number) => { e.stopPropagation() - if (reqBodies.length === 1) return - setReqBodies(reqBodies.filter((_, i) => i !== index)) - setReqHeaders(reqHeaders.filter((_, i) => i !== index)) - setResponses(responses.filter((_, i) => i !== index)) - if (activeTab >= index && activeTab > 0) setActiveTab(activeTab - 1) + if (methods.length === 1) return + const drop = (arr: T[]) => arr.filter((_, i) => i !== index) + setMethods(drop(methods)) + setUrls(drop(urls)) + setHeaders(drop(headers)) + setParams(drop(params)) + setBodyTypes(drop(bodyTypes)) + setJsonBodies(drop(jsonBodies)) + setRawBodies(drop(rawBodies)) + setFormRows(drop(formRows)) + setAuths(drop(auths)) + setSettings(drop(settings)) + setResponses(drop(responses)) + const next = activeTab >= index && activeTab > 0 ? activeTab - 1 : activeTab + if (activeTab >= index && activeTab > 0) setActiveTab(next) + persistSession(next) } - const updateReqBody = (e: React.ChangeEvent) => { - const newBodies = [...reqBodies] - newBodies[activeTab] = e.target.value - setReqBodies(newBodies) + const switchTab = (i: number) => { + setActiveTab(i) + persistSession(i) } - const addHeader = () => { - const newHeaders = [...reqHeaders] - newHeaders[activeTab] = [...newHeaders[activeTab], {Key: '', Value: ''}] - setReqHeaders(newHeaders) + // ── Method / URL handlers ───────────────────────────────────── + const setMethod = (m: string) => { + const n = [...methods] + n[activeTab] = m + setMethods(n) + } + + const onUrlChange = (newUrl: string) => { + const n = [...urls] + n[activeTab] = newUrl + setUrls(n) + const parsed = parseUrlParams(newUrl) + if (parsed) { + const np = [...params] + np[activeTab] = parsed + setParams(np) + } + } + + // ── Header handlers ─────────────────────────────────────────── + const onHeaderChange = (idx: number, field: 'Key' | 'Value', value: string) => + setHeaders(updateRowsAt(headers, activeTab, idx, field, value)) + const onHeaderRemove = (idx: number) => setHeaders(removeRowAt(headers, activeTab, idx)) + const onHeaderAdd = () => setHeaders(addRowAt(headers, activeTab)) + + // ── Params handlers (with URL sync) ─────────────────────────── + const onParamChange = (idx: number, field: 'Key' | 'Value', value: string) => { + const next = updateRowsAt(params, activeTab, idx, field, value) + setParams(next) + const nu = [...urls] + nu[activeTab] = rebuildUrlWithParams(url, next[activeTab]) + setUrls(nu) + } + const onParamRemove = (idx: number) => { + const next = removeRowAt(params, activeTab, idx) + setParams(next) + const nu = [...urls] + nu[activeTab] = rebuildUrlWithParams(url, next[activeTab]) + setUrls(nu) + } + const onParamAdd = () => setParams(addRowAt(params, activeTab)) + + // ── Body / auth / settings handlers ─────────────────────────── + const setBodyType = (t: BodyType) => { + const n = [...bodyTypes]; n[activeTab] = t; setBodyTypes(n) + } + const setJsonBody = (s: string) => { + const n = [...jsonBodies]; n[activeTab] = s; setJsonBodies(n) + } + const setRawBody = (s: string) => { + const n = [...rawBodies]; n[activeTab] = s; setRawBodies(n) + } + const setFormRowsForTab = (rows: KV[]) => { + const n = [...formRows]; n[activeTab] = rows; setFormRows(n) + } + const setAuth = (a: AuthState) => { + const n = [...auths]; n[activeTab] = a; setAuths(n) + } + const setSettingsForTab = (s: ReqSettings) => { + const n = [...settings]; n[activeTab] = s; setSettings(n) + } + + // ── Build the final RequestSpec for the active tab ──────────── + function buildSpec(): main.RequestSpec { + const userHeaders = kvToRecord(headers[activeTab]) + const auth = applyAuth(auths[activeTab]) + const mergedHeaders: Record = {...userHeaders, ...auth.headers} + + let finalUrl = rebuildUrlWithParams(url, params[activeTab]) + if (auth.query.length) { + try { + const u = new URL(finalUrl) + for (const [k, v] of auth.query) u.searchParams.append(k, v) + finalUrl = u.toString() + } catch { + const q = auth.query + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`) + .join('&') + finalUrl = finalUrl.includes('?') ? `${finalUrl}&${q}` : `${finalUrl}?${q}` + } + } + + const bodyType = bodyTypes[activeTab] + let body: main.BodySpec = main.BodySpec.createFrom({type: 'none', raw: ''}) + + if (bodyType === 'json') { + const raw = jsonBodies[activeTab] + if (raw && !mergedHeaders['Content-Type']) { + mergedHeaders['Content-Type'] = 'application/json' + } + body = main.BodySpec.createFrom({type: 'json', raw}) + } else if (bodyType === 'form') { + const raw = encodeFormBody(formRows[activeTab]) + if (raw && !mergedHeaders['Content-Type']) { + mergedHeaders['Content-Type'] = 'application/x-www-form-urlencoded' + } + body = main.BodySpec.createFrom({type: 'form', raw}) + } else if (bodyType === 'raw') { + body = main.BodySpec.createFrom({type: 'raw', raw: rawBodies[activeTab]}) + } + + const s = settings[activeTab] + return main.RequestSpec.createFrom({ + method, + url: finalUrl, + headers: mergedHeaders, + body, + settings: {timeoutMs: s.timeoutMs, followRedirects: s.followRedirects, verifyTLS: s.verifyTLS}, + }) } const storeResponse = (r: main.RequestResult) => { @@ -139,303 +363,585 @@ function App() { next[activeTab] = r setResponses(next) setResponseTab(r.Body ? 'body' : r.HeadersStr ? 'headers' : 'body') + setBodyView('pretty') + setSearch('') } function makeRequest() { - const headers: {[key: string]: string} = {} - for (const h of reqHeaders[activeTab]) { - if (h.Key && h.Value) headers[h.Key] = h.Value - } + const spec = buildSpec() setLoading(true) - MakeRequest(request.url, request.method, reqBodies[activeTab], headers) - .then((r) => storeResponse(r)) + SendBinding(spec) + .then((r) => { + storeResponse(r) + // Append to history (fire-and-forget) + AppendHistory({ + id: '', + method: spec.method, + url: spec.url, + status: r.Status, + statusText: r.StatusText, + durationMs: r.DurationMs, + sentAt: new Date().toISOString(), + }).then(() => { + ListHistory(50).then((h) => setHistory(h ?? [])).catch(() => {}) + }).catch(() => {}) + persistSession() + }) .finally(() => setLoading(false)) } function importCurl() { - RunCurl(curlBody).then((r: main.RequestResult) => { - setRequest(new Request({method: r.Method, url: r.URL})) - const newBodies = [...reqBodies] - newBodies[activeTab] = r.RequestBody || '' - setReqBodies(newBodies) - const newHeaders = [...reqHeaders] - newHeaders[activeTab] = parseHeaderLines(r.ReqHeaders || '') - setReqHeaders(newHeaders) + RunCurl(curlBody).then((r) => { + const nm = [...methods]; nm[activeTab] = r.Method || 'GET'; setMethods(nm) + const nu = [...urls]; nu[activeTab] = r.URL || ''; setUrls(nu) + const parsed = parseHeaderLines(r.ReqHeaders || '') + const nh = [...headers] + nh[activeTab] = parsed.length ? parsed : initialHeaderRows() + setHeaders(nh) + if (r.RequestBody) { + setBodyType('raw') + setRawBody(r.RequestBody) + } + const p = parseUrlParams(r.URL || '') + if (p) { + const np = [...params]; np[activeTab] = p; setParams(np) + } storeResponse(r) - setOpen(false) + setCurlOpen(false) setCurlBody('') }) } function handleExport() { - Export(request as any, reqHeaders, reqBodies, result) + Export( + {method, url, headers: kvToRecord(headers[activeTab]), body: rawBodies[activeTab] || jsonBodies[activeTab] || ''} as unknown as main.Request, + headers as unknown as Array, + rawBodies, + result + ) + } + + function copyAsCurl() { + const spec = buildSpec() + const bodyType = bodyTypes[activeTab] + const cmd = buildCurl({ + method: spec.method, + url: spec.url, + headers: spec.headers, + body: spec.body.raw || undefined, + bodyFlag: bodyType === 'json' ? '--data-raw' : '-d', + }) + navigator.clipboard.writeText(cmd) + } + + function copyBody() { + if (result.Body) navigator.clipboard.writeText(result.Body) + } + + function saveBody() { + if (result.Body) SaveTextFile('response.txt', result.Body) + } + + // ── Load a saved request into the active tab ────────────────── + function loadRequestToTab(req: SavedRequest) { + const i = activeTab + setMethods((p) => { const n = [...p]; n[i] = req.method || 'GET'; return n }) + setUrls((p) => { const n = [...p]; n[i] = req.url || ''; return n }) + setHeaders((p) => { const n = [...p]; n[i] = req.headers?.length ? req.headers : initialHeaderRows(); return n }) + setParams((p) => { const n = [...p]; n[i] = req.params?.length ? req.params : [emptyKV()]; return n }) + setAuths((p) => { const n = [...p]; n[i] = req.auth || emptyAuth(); return n }) + setBodyTypes((p) => { const n = [...p]; n[i] = (req.bodyType as BodyType) || 'none'; return n }) + setJsonBodies((p) => { const n = [...p]; n[i] = req.jsonBody || ''; return n }) + setRawBodies((p) => { const n = [...p]; n[i] = req.rawBody || ''; return n }) + setFormRows((p) => { const n = [...p]; n[i] = req.formRows?.length ? req.formRows : [emptyKV()]; return n }) + setSettings((p) => { const n = [...p]; n[i] = req.settings || defaultSettings(); return n }) + } + + function loadHistoryToTab(entry: HistoryEntry) { + setMethods((p) => { const n = [...p]; n[activeTab] = entry.method; return n }) + setUrls((p) => { const n = [...p]; n[activeTab] = entry.url; return n }) } + // ── Collection / save-request handlers ─────────────────────── + async function handleNewCollection(name: string): Promise { + const col: Collection = {id: '', name, requests: []} + await SaveCollection(col) + const updated = await ListCollections() + setCollections(updated ?? []) + return updated?.find((c) => c.name === name)?.id ?? '' + } + + async function handleDeleteCollection(id: string) { + await DeleteCollection(id) + setCollections((prev) => prev.filter((c) => c.id !== id)) + } + + async function handleDeleteRequest(collectionId: string, reqId: string) { + await DeleteRequestBinding(collectionId, reqId) + const updated = await ListCollections() + setCollections(updated ?? []) + } + + async function handleClearHistory() { + await ClearHistory() + setHistory([]) + } + + async function handleSaveRequest(name: string, collectionId: string) { + const tab = buildTabState(activeTab) + const req: SavedRequest = { + ...tab, + id: '', + name, + createdAt: '', + updatedAt: '', + } + await SaveRequestBinding(collectionId, req) + const updated = await ListCollections() + setCollections(updated ?? []) + } + + const matches = result.Body ? countMatches(result.Body, search) : 0 + return ( -
- {/* ── Top bar ─────────────────────────────────────────── */} -
-
-
- -
-
-
- HYPR -
-
- REST workbench +
+ {/* ── Sidebar ─────────────────────────────────────────── */} + {sidebarOpen && ( + setSaveOpen(true)} + onNewCollection={handleNewCollection} + onDeleteCollection={handleDeleteCollection} + onDeleteRequest={handleDeleteRequest} + onClearHistory={handleClearHistory} + /> + )} + + {/* ── Main area ────────────────────────────────────────── */} +
+ {/* ── Top bar ─────────────────────────────────────────── */} +
+
+ +
+
+ +
+
+
+ HYPR +
+
+ REST workbench +
+
-
-
- - + + +
+
+ + + + {/* ── Request bar ─────────────────────────────────────── */} +
+ + + onUrlChange(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && makeRequest()} + placeholder="https://api.example.com/v1/resource" + className="h-11 flex-1 font-mono text-sm" + /> + +
- - - - - {/* ── Request bar ─────────────────────────────────────── */} -
- - - setUrl(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && makeRequest()} - placeholder="https://api.example.com/v1/resource" - className="h-11 flex-1 font-mono text-sm" - /> - -
- - {/* ── Request tabs ────────────────────────────────────── */} -
-
- {reqBodies.map((_, index) => { - const active = index === activeTab - return ( - - ) - })} -
- -
- - {/* ── Request editor ──────────────────────────────────── */} -
- {/* Headers */} -
-
-
- - -
- + className={[ + 'h-1.5 w-1.5 rounded-full', + active ? 'bg-primary' : 'bg-muted-foreground/40', + ].join(' ')} + /> + REQ {String(index + 1).padStart(2, '0')} + {methods.length > 1 && ( + closeTab(e, index)} + className="-mr-1 grid h-5 w-5 place-items-center rounded text-muted-foreground/70 transition-colors hover:bg-destructive/20 hover:text-destructive" + > + + + )} + + ) + })}
-
- {reqHeaders[activeTab].map((header, index) => ( - + + +
+ + {/* ── Request editor (Headers / Params / Body / Auth / Settings) ── */} +
+ + + + + Headers + + + + Params + + + + Body + + + + Auth + + + + Settings + + + + +
+ +
+
+ {headers[activeTab].map((row, idx) => ( + + ))} +
+
+ + +
+ +
+
+ {params[activeTab].map((row, idx) => ( + + ))} +
+
+ + + - ))} -
- + - {/* Body */} -
-
- - -
-