From 51e6286e01965845454eacefb16a69b739f65827 Mon Sep 17 00:00:00 2001 From: Rahul Tyagi Date: Sun, 14 Jun 2026 21:56:49 +0530 Subject: [PATCH] feat: restore request payload model with persisted responses --- CHANGELOG.md | 16 +- CLAUDE.md | 10 +- frontend/package.json.md5 | 2 +- frontend/src/App.tsx | 669 +++++++++++------- .../src/components/save-request-dialog.tsx | 24 +- frontend/src/components/sidebar.tsx | 36 +- frontend/src/lib/autosave.ts | 39 + frontend/src/lib/kv.ts | 28 +- frontend/src/lib/model.ts | 94 +++ frontend/wailsjs/go/main/App.d.ts | 31 +- frontend/wailsjs/go/main/App.js | 56 +- frontend/wailsjs/go/models.ts | 347 ++++++++- store.go | 86 ++- store_test.go | 56 +- 14 files changed, 1059 insertions(+), 435 deletions(-) create mode 100644 frontend/src/lib/autosave.ts create mode 100644 frontend/src/lib/model.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 024376f..85e44d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Autosave to localStorage:** the working session (all open requests + payloads) is + mirrored to browser `localStorage` on every change, in addition to the durable Go store. + On launch the local draft is restored instantly (falling back to the Go store), and an + "Autosaved HH:MM:SS" indicator in the top bar shows the last save time. +- **Two-level request/payload model:** the UI now has top-level **request tabs** (each with + its own URL, method, auth, and settings) and, within each, inner **payload tabs** that share + the request's URL/method/auth/settings but each carry their own headers, params, body, and + response. This restores Hypr's core premise — comparing multiple payloads against one URL — + while letting several independent requests stay open side by side. "New Request" (sidebar + + top "+") adds a top-level request; the "+" on the Payloads strip adds a payload. - **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. @@ -17,10 +27,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 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). +- Go tests in `store_test.go` covering collection CRUD, request save/delete (incl. payload + round-trip), history append/clear/cap, and session save/restore. ### Changed - Backend: added `Store` type backed by JSON, 10 new bound methods diff --git a/CLAUDE.md b/CLAUDE.md index 7a5366f..9d7ae65 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,7 +33,15 @@ The frontend↔backend boundary is the only non-obvious part. Wails binds the Go **Curl parsing** (`parse_curl.go`): a hand-written state machine over `go-shellwords` tokens. Supports `-X/--request`, `-H/--header`, `-d/--data[-ascii|-raw]`, `-A/--user-agent`, `-u/--user` (→ Basic auth), `-I/--head`, `-b/--cookie`. Does **not** handle `--data-binary`, `--data-urlencode`, or multipart. Input must start with `curl `. -**Frontend state** (`frontend/src/App.tsx`): the UI is multi-tab ("Request 1", "Request 2", …). State is held as **parallel arrays indexed by tab** — `reqBodies[]`, `reqHeaders[][]`, `responses[]` — all kept in sync in `addNewTab`/`closeTab`. When editing tab logic, update all three arrays together or they desync. Headers in the UI are `{Key, Value}` objects (`src/lib/header.ts`); they're converted to a plain `Record` before being passed to Go (which expects `main.Headers`). +**Core premise:** Hypr's reason to exist vs. Postman/Insomnia is testing **multiple payloads against a single request URL** on one screen. This is encoded in a **two-level tab model** — don't collapse it. + +**Frontend state** (`frontend/src/App.tsx`, types in `src/lib/model.ts`): a single nested structure, **not** parallel arrays. +- Top level: `requests: RequestTab[]` + `activeRequest` index. Each `RequestTab` owns the **shared** `method`, `url`, `auth`, `settings`, plus `payloads: Payload[]` and `activePayload`. +- Inner level: each `Payload` owns its own `headers`, `params`, `bodyType`/`jsonBody`/`rawBody`/`formRows`, and `response`. All payloads of a request share that request's url/method/auth/settings. +- All edits go through the immutable updaters `updateRequest(requests, ri, patch)` and `updatePayload(requests, ri, pi, patch)` in `model.ts`. Per-payload KV-row edits use `setRow`/`dropRow`/`appendRow` from `src/lib/kv.ts`. +- **URL ↔ params is one-way at send time only**: the URL bar is the shared base URL; the Params tab edits the active payload; `buildSpec()` merges `req.url` + payload params + auth query when sending. Editing one does not rewrite the other. +- `response` is **never persisted**; `toStoredPayload()` strips it before saving. Session/collections persist via the Go store (`store.go`): `Session.openRequests`/`activeRequest`, `SavedRequest` carries all payloads. +- Headers in the UI are `{Key, Value}` objects (`src/lib/kv.ts`); converted to a plain `Record` before being passed to Go. **UI stack**: the frontend uses **Tailwind CSS + shadcn/ui** (Radix primitives in `frontend/src/components/ui/`, `cn` helper in `src/lib/utils.ts`, design tokens as CSS variables in `src/index.css`, theme in `tailwind.config.js`). Icons are `lucide-react`. JSON responses are highlighted by the in-house `src/components/json-view.tsx` (no `react-json-pretty`). The `@/*` import alias maps to `src/`. diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index b993a2e..b836398 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d3c70ad3166b11f53dac87fb2b41f3c2 \ No newline at end of file +cec421cd25fd35fe8f5916df0826d3d0 \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73ac094..257945d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,8 @@ import { Link2, PanelLeftClose, PanelLeftOpen, + Layers, + Check, } from 'lucide-react' import {KVRow} from './components/kv-row' import {AuthForm} from './components/auth-form' @@ -65,13 +67,24 @@ import { DialogTitle, DialogDescription, } from '@/components/ui/dialog' -import {addRowAt, emptyKV, kvToRecord, removeRowAt, updateRowsAt, type KV} from './lib/kv' +import {appendRow, dropRow, emptyKV, kvToRecord, setRow, 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' +import {readDraft, writeDraft} from './lib/autosave' +import { + emptyPayload, + emptyRequest, + emptyResult, + initialHeaderRows, + toStoredPayload, + updatePayload, + updateRequest, + type Payload, + type RequestTab, +} from './lib/model' const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS'] as const @@ -87,7 +100,13 @@ const METHOD_HSL: {[key: string]: string} = { const methodColor = (m: string) => `hsl(${METHOD_HSL[m] ?? '84 78% 56%'})` -const emptyResult = () => new main.RequestResult() +const genId = (): string => + typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}` + +const formatClock = (ms: number): string => + new Date(ms).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', second: '2-digit'}) // "Key: Value\nKey: Value" -> KV[] function parseHeaderLines(raw: string): KV[] { @@ -103,26 +122,14 @@ function parseHeaderLines(raw: string): KV[] { return rows.length ? rows : [emptyKV()] } -const initialHeaderRows = (): KV[] => [emptyKV(), emptyKV(), emptyKV()] - function App() { - const [activeTab, setActiveTab] = useState(0) + // ── Two-level nested state ──────────────────────────────────── + const [requests, setRequests] = useState([emptyRequest()]) + const [activeRequest, setActiveRequest] = useState(0) + 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') @@ -133,189 +140,240 @@ function App() { const [saveOpen, setSaveOpen] = useState(false) // Persistence state - const [collections, setCollections] = useState([]) - const [history, setHistory] = useState([]) + const [collections, setCollections] = useState([]) + const [history, setHistory] = useState([]) + const [focusCollectionId, setFocusCollectionId] = useState() const sessionSaveTimer = useRef | null>(null) + const draftSaveTimer = useRef | null>(null) + // Gate session writes until the initial load completes, so a slow load + // can't be clobbered by an early persist of the empty default state. + const hydratedRef = useRef(false) + const [lastSaved, setLastSaved] = useState(null) + + // ── Derived accessors for active request / payload ──────────── + const req = requests[activeRequest] ?? emptyRequest() + const pIdx = req.activePayload + const payload = req.payloads[pIdx] ?? emptyPayload() + const method = req.method + const url = req.url + const result = payload.response ?? emptyResult() + const hasResponse = Boolean( + result.Body || result.Error || result.HeadersStr || result.Status + ) - // ── 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 || result.Status) - - // ── Session helpers ─────────────────────────────────────────── - function buildTabState(i: number): TabState { + // ── Session persistence ─────────────────────────────────────── + function buildRequestState(r: RequestTab) { 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(), + savedId: r.savedId ?? '', + method: r.method, + url: r.url, + auth: r.auth, + settings: r.settings, + payloads: r.payloads.map(toStoredPayload), + activePayload: r.activePayload, } } - function persistSession(overrideActiveTab?: number) { + function persistSession(nextRequests?: RequestTab[], nextActive?: number) { + const rs = nextRequests ?? requests + const active = nextActive ?? activeRequest 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(() => {}) + SaveSession({ + openRequests: rs.map(buildRequestState), + activeRequest: active, + } as any).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)) + // Autosave whenever requests / activeRequest change (after initial hydration): + // an instant localStorage draft + the durable Go store (debounced). + useEffect(() => { + if (!hydratedRef.current) return + if (draftSaveTimer.current) clearTimeout(draftSaveTimer.current) + draftSaveTimer.current = setTimeout(() => { + const ts = writeDraft({ + openRequests: requests.map(buildRequestState), + activeRequest, + }) + if (ts) setLastSaved(ts) + }, 400) + persistSession() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [requests, activeRequest]) + + function payloadFromStored(p: any): Payload { + return { + headers: p?.headers?.length ? p.headers : initialHeaderRows(), + params: p?.params?.length ? p.params : [emptyKV()], + bodyType: (p?.bodyType as BodyType) || 'none', + jsonBody: p?.jsonBody || '', + rawBody: p?.rawBody || '', + formRows: p?.formRows?.length ? p.formRows : [emptyKV()], + response: p?.response ? main.RequestResult.createFrom(p.response) : emptyResult(), + } } - // ── On mount: load session + collections + history ───────────── + function requestFromStored(t: any): RequestTab { + const payloads: Payload[] = t?.payloads?.length + ? t.payloads.map(payloadFromStored) + : [emptyPayload()] + return { + savedId: t?.savedId || undefined, + method: t?.method || 'GET', + url: t?.url || '', + auth: (t?.auth as AuthState) || emptyAuth(), + settings: (t?.settings as ReqSettings) || defaultSettings(), + payloads, + activePayload: Math.min(t?.activePayload || 0, payloads.length - 1), + } + } + + // ── On mount: restore session (localStorage draft first, then Go store) ── useEffect(() => { - LoadSession() - .then((s) => { - if (s?.openTabs?.length) restoreSession(s) - }) - .catch(() => {}) + const draft = readDraft() + if (draft?.openRequests?.length) { + // Fast path: hydrate instantly from the local draft. + const rs = draft.openRequests.map(requestFromStored) + setRequests(rs) + setActiveRequest(Math.min(draft.activeRequest || 0, rs.length - 1)) + setLastSaved(draft.savedAt) + hydratedRef.current = true + } else { + LoadSession() + .then((s: any) => { + if (s?.openRequests?.length) { + const rs = s.openRequests.map(requestFromStored) + setRequests(rs) + setActiveRequest(Math.min(s.activeRequest || 0, rs.length - 1)) + } + }) + .catch(() => {}) + .finally(() => { + hydratedRef.current = true + }) + } ListCollections() - .then((c) => setCollections(c ?? [])) + .then((c) => setCollections((c as main.Collection[]) ?? [])) .catch(() => {}) ListHistory(50) - .then((h) => setHistory(h ?? [])) + .then((h) => setHistory((h as main.HistoryEntry[]) ?? [])) .catch(() => {}) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // ── Tab plumbing ────────────────────────────────────────────── - const addNewTab = () => { - 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(n) - persistSession(n) + // ── Request-level tab plumbing ──────────────────────────────── + const addRequest = () => { + setRequests((prev) => [...prev, emptyRequest()]) + setActiveRequest(requests.length) } - const closeTab = (e: React.MouseEvent, index: number) => { + const closeRequest = (e: React.MouseEvent, index: number) => { e.stopPropagation() - 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) + if (requests.length === 1) return + const next = requests.filter((_, i) => i !== index) + setRequests(next) + setActiveRequest( + activeRequest > index ? activeRequest - 1 : Math.min(activeRequest, next.length - 1) + ) } - const switchTab = (i: number) => { - setActiveTab(i) - persistSession(i) + const switchRequest = (index: number) => setActiveRequest(index) + + // ── Payload-level tab plumbing (within active request) ──────── + const addPayload = () => { + setRequests((prev) => { + const r = prev[activeRequest] + const cur = r.payloads[r.activePayload] + const copy: Payload = { + ...cur, + response: emptyResult(), + headers: cur.headers.map((x) => ({...x})), + params: cur.params.map((x) => ({...x})), + formRows: cur.formRows.map((x) => ({...x})), + } + return updateRequest(prev, activeRequest, { + payloads: [...r.payloads, copy], + activePayload: r.payloads.length, + }) + }) } - // ── Method / URL handlers ───────────────────────────────────── - const setMethod = (m: string) => { - const n = [...methods] - n[activeTab] = m - setMethods(n) + const closePayload = (e: React.MouseEvent, index: number) => { + e.stopPropagation() + setRequests((prev) => { + const r = prev[activeRequest] + if (r.payloads.length === 1) return prev + const nextPayloads = r.payloads.filter((_, i) => i !== index) + const nextActive = + r.activePayload > index + ? r.activePayload - 1 + : Math.min(r.activePayload, nextPayloads.length - 1) + return updateRequest(prev, activeRequest, { + payloads: nextPayloads, + activePayload: nextActive, + }) + }) } - 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) - } - } + const switchPayload = (index: number) => + setRequests((prev) => updateRequest(prev, activeRequest, {activePayload: index})) - // ── Header handlers ─────────────────────────────────────────── + // ── Request-level field handlers (shared across payloads) ───── + const setMethod = (m: string) => + setRequests((prev) => updateRequest(prev, activeRequest, {method: m})) + const onUrlChange = (newUrl: string) => + setRequests((prev) => updateRequest(prev, activeRequest, {url: newUrl})) + const setAuth = (a: AuthState) => + setRequests((prev) => updateRequest(prev, activeRequest, {auth: a})) + const setRequestSettings = (s: ReqSettings) => + setRequests((prev) => updateRequest(prev, activeRequest, {settings: s})) + + // ── Payload-level field 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)) + setRequests((prev) => + updatePayload(prev, activeRequest, pIdx, {headers: setRow(payload.headers, idx, field, value)}) + ) + const onHeaderRemove = (idx: number) => + setRequests((prev) => + updatePayload(prev, activeRequest, pIdx, {headers: dropRow(payload.headers, idx)}) + ) + const onHeaderAdd = () => + setRequests((prev) => + updatePayload(prev, activeRequest, pIdx, {headers: appendRow(payload.headers)}) + ) - // ── 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) - } + const onParamChange = (idx: number, field: 'Key' | 'Value', value: string) => + setRequests((prev) => + updatePayload(prev, activeRequest, pIdx, {params: setRow(payload.params, idx, field, value)}) + ) + const onParamRemove = (idx: number) => + setRequests((prev) => + updatePayload(prev, activeRequest, pIdx, {params: dropRow(payload.params, idx)}) + ) + const onParamAdd = () => + setRequests((prev) => + updatePayload(prev, activeRequest, pIdx, {params: appendRow(payload.params)}) + ) - // ── Build the final RequestSpec for the active tab ──────────── + const setBodyType = (t: BodyType) => + setRequests((prev) => updatePayload(prev, activeRequest, pIdx, {bodyType: t})) + const setJsonBody = (s: string) => + setRequests((prev) => updatePayload(prev, activeRequest, pIdx, {jsonBody: s})) + const setRawBody = (s: string) => + setRequests((prev) => updatePayload(prev, activeRequest, pIdx, {rawBody: s})) + const setFormRowsForPayload = (rows: KV[]) => + setRequests((prev) => updatePayload(prev, activeRequest, pIdx, {formRows: rows})) + + // ── Build the final RequestSpec for the active payload ──────── function buildSpec(): main.RequestSpec { - const userHeaders = kvToRecord(headers[activeTab]) - const auth = applyAuth(auths[activeTab]) + const userHeaders = kvToRecord(payload.headers) + const auth = applyAuth(req.auth) const mergedHeaders: Record = {...userHeaders, ...auth.headers} - let finalUrl = rebuildUrlWithParams(url, params[activeTab]) + let finalUrl = rebuildUrlWithParams(req.url, payload.params) if (auth.query.length) { try { const u = new URL(finalUrl) @@ -329,28 +387,26 @@ function App() { } } - const bodyType = bodyTypes[activeTab] let body: main.BodySpec = main.BodySpec.createFrom({type: 'none', raw: ''}) - - if (bodyType === 'json') { - const raw = jsonBodies[activeTab] + if (payload.bodyType === 'json') { + const raw = payload.jsonBody 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]) + } else if (payload.bodyType === 'form') { + const raw = encodeFormBody(payload.formRows) 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]}) + } else if (payload.bodyType === 'raw') { + body = main.BodySpec.createFrom({type: 'raw', raw: payload.rawBody}) } - const s = settings[activeTab] + const s = req.settings return main.RequestSpec.createFrom({ - method, + method: req.method, url: finalUrl, headers: mergedHeaders, body, @@ -359,9 +415,7 @@ function App() { } const storeResponse = (r: main.RequestResult) => { - const next = [...responses] - next[activeTab] = r - setResponses(next) + setRequests((prev) => updatePayload(prev, activeRequest, pIdx, {response: r})) setResponseTab(r.Body ? 'body' : r.HeadersStr ? 'headers' : 'body') setBodyView('pretty') setSearch('') @@ -373,7 +427,6 @@ function App() { SendBinding(spec) .then((r) => { storeResponse(r) - // Append to history (fire-and-forget) AppendHistory({ id: '', method: spec.method, @@ -382,31 +435,45 @@ function App() { statusText: r.StatusText, durationMs: r.DurationMs, sentAt: new Date().toISOString(), - }).then(() => { - ListHistory(50).then((h) => setHistory(h ?? [])).catch(() => {}) - }).catch(() => {}) - persistSession() + } as any) + .then(() => { + ListHistory(50).then((h) => setHistory((h as main.HistoryEntry[]) ?? [])).catch(() => {}) + }) + .catch(() => {}) }) .finally(() => setLoading(false)) } function importCurl() { 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) + setRequests((prev) => + prev.map((reqTab, i) => { + if (i !== activeRequest) return reqTab + const updatedPayload: Payload = { + ...reqTab.payloads[reqTab.activePayload], + headers: parsed.length ? parsed : initialHeaderRows(), + params: p ?? [emptyKV()], + response: r, + } + if (r.RequestBody) { + updatedPayload.bodyType = 'raw' + updatedPayload.rawBody = r.RequestBody + } + return { + ...reqTab, + method: r.Method || 'GET', + url: r.URL || '', + payloads: reqTab.payloads.map((pl, j) => + j === reqTab.activePayload ? updatedPayload : pl + ), + } + }) + ) + setResponseTab(r.Body ? 'body' : 'headers') + setBodyView('pretty') + setSearch('') setCurlOpen(false) setCurlBody('') }) @@ -414,22 +481,26 @@ function App() { function handleExport() { Export( - {method, url, headers: kvToRecord(headers[activeTab]), body: rawBodies[activeTab] || jsonBodies[activeTab] || ''} as unknown as main.Request, - headers as unknown as Array, - rawBodies, + { + method: req.method, + url: req.url, + headers: kvToRecord(payload.headers), + body: payload.rawBody || payload.jsonBody || '', + } as unknown as main.Request, + payload.headers as unknown as Array, + [payload.rawBody || payload.jsonBody || ''], 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', + bodyFlag: payload.bodyType === 'json' ? '--data-raw' : '-d', }) navigator.clipboard.writeText(cmd) } @@ -442,33 +513,30 @@ function App() { 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 }) + // ── Load a saved request: switch to it if already open, else open a tab ── + function loadRequestToTab(saved: main.SavedRequest) { + const existing = requests.findIndex((r) => r.savedId && r.savedId === saved.id) + if (existing !== -1) { + setActiveRequest(existing) + return + } + const newReq: RequestTab = {...requestFromStored(saved), savedId: saved.id} + setRequests((prev) => [...prev, newReq]) + setActiveRequest(requests.length) } - 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 }) + function loadHistoryToTab(entry: main.HistoryEntry) { + setRequests((prev) => + updateRequest(prev, activeRequest, {method: entry.method, url: entry.url}) + ) } // ── Collection / save-request handlers ─────────────────────── async function handleNewCollection(name: string): Promise { - const col: Collection = {id: '', name, requests: []} - await SaveCollection(col) + await SaveCollection({id: '', name, requests: []} as any) const updated = await ListCollections() - setCollections(updated ?? []) - return updated?.find((c) => c.name === name)?.id ?? '' + setCollections((updated as main.Collection[]) ?? []) + return (updated as main.Collection[])?.find((c) => c.name === name)?.id ?? '' } async function handleDeleteCollection(id: string) { @@ -479,7 +547,7 @@ function App() { async function handleDeleteRequest(collectionId: string, reqId: string) { await DeleteRequestBinding(collectionId, reqId) const updated = await ListCollections() - setCollections(updated ?? []) + setCollections((updated as main.Collection[]) ?? []) } async function handleClearHistory() { @@ -488,17 +556,22 @@ function App() { } async function handleSaveRequest(name: string, collectionId: string) { - const tab = buildTabState(activeTab) - const req: SavedRequest = { - ...tab, - id: '', + // Reuse the tab's existing id so re-saving updates in place; otherwise mint one + // up front so we can link the open tab to it (and avoid duplicates on re-save). + const id = req.savedId || genId() + const reqState = buildRequestState(req) + await SaveRequestBinding(collectionId, { + ...reqState, + id, name, createdAt: '', updatedAt: '', - } - await SaveRequestBinding(collectionId, req) + } as any) + // Link the active tab to the saved request. + setRequests((prev) => updateRequest(prev, activeRequest, {savedId: id})) const updated = await ListCollections() - setCollections(updated ?? []) + setCollections((updated as main.Collection[]) ?? []) + setFocusCollectionId(collectionId) } const matches = result.Body ? countMatches(result.Body, search) : 0 @@ -510,9 +583,11 @@ function App() { setSaveOpen(true)} + onNewTab={addRequest} onNewCollection={handleNewCollection} onDeleteCollection={handleDeleteCollection} onDeleteRequest={handleDeleteRequest} @@ -553,6 +628,15 @@ function App() {
+ {lastSaved && ( + + + Autosaved {formatClock(lastSaved)} + + )} + ) + })} +
+ + + + {/* ── Request bar (shared URL + method) ───────────────── */}