From 7bd159b0b174f1ee58f8dc0abd4f08fabda99124 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Sat, 5 Sep 2026 21:20:20 -0700 Subject: [PATCH] feat(client): offer the request a response was sent from MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the selected response's version no longer matches the live request, the response header grows a "Request Changed" control with View Diff and Restore. It follows the GraphQL editor's shape — a state-labelled dropdown button rather than a new icon in the action row — because the label is the whole point: it has to say *that* something is different before it offers to do anything about it. Hidden when the two agree, and for responses recorded before versioning existed, which have no version and never will. The diff reuses the Git dialog's DiffViewer over YAML renderings of the two documents, so a request diff reads like a commit diff. Restore flushes pending edits first (the backend restores from the database, so a debounced keystroke would otherwise land on top of it) and calls wasUpdatedExternally afterwards, because the write came from this window and the store's echo suppression would leave open editors showing the old content. EditSessionTracker turns the boundaries only the frontend can see — switching requests, losing focus, closing, falling idle for 60s — into snapshot calls. It tracks no content of its own: content addressing already decides whether a boundary had anything behind it, which is what keeps this a timer and two assignments instead of a change-tracking system. --- .../components/HttpResponsePane.tsx | 6 +- .../components/RequestVersionDropdown.tsx | 79 +++++++++++++++++++ apps/yaak-client/hooks/useRequestVersion.ts | 26 ++++++ .../lib/editSessionTracker.test.ts | 75 ++++++++++++++++++ apps/yaak-client/lib/editSessionTracker.ts | 57 +++++++++++++ apps/yaak-client/lib/requestVersions.ts | 50 ++++++++++++ apps/yaak-client/lib/restoreRequestVersion.ts | 35 ++++++++ apps/yaak-client/main.tsx | 2 + 8 files changed, 328 insertions(+), 2 deletions(-) create mode 100644 apps/yaak-client/components/RequestVersionDropdown.tsx create mode 100644 apps/yaak-client/hooks/useRequestVersion.ts create mode 100644 apps/yaak-client/lib/editSessionTracker.test.ts create mode 100644 apps/yaak-client/lib/editSessionTracker.ts create mode 100644 apps/yaak-client/lib/requestVersions.ts create mode 100644 apps/yaak-client/lib/restoreRequestVersion.ts diff --git a/apps/yaak-client/components/HttpResponsePane.tsx b/apps/yaak-client/components/HttpResponsePane.tsx index 8d0697d7..e133ed78 100644 --- a/apps/yaak-client/components/HttpResponsePane.tsx +++ b/apps/yaak-client/components/HttpResponsePane.tsx @@ -30,6 +30,7 @@ import { EmptyStateText } from "./EmptyStateText"; import { ErrorBoundary } from "./ErrorBoundary"; import { HttpResponseTimeline } from "./HttpResponseTimeline"; import { RecentHttpResponsesDropdown } from "./RecentHttpResponsesDropdown"; +import { RequestVersionDropdown } from "./RequestVersionDropdown"; import { RequestBodyViewer } from "./RequestBodyViewer"; import { ResponseCookies } from "./ResponseCookies"; import { ResponseHeaders } from "./ResponseHeaders"; @@ -263,13 +264,14 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { ) : ( )} -
+ + -
+ )} diff --git a/apps/yaak-client/components/RequestVersionDropdown.tsx b/apps/yaak-client/components/RequestVersionDropdown.tsx new file mode 100644 index 00000000..57e04319 --- /dev/null +++ b/apps/yaak-client/components/RequestVersionDropdown.tsx @@ -0,0 +1,79 @@ +import type { HttpResponse, RequestVersionComparison } from "@yaakapp-internal/models"; +import { Icon } from "@yaakapp-internal/ui"; +import { stringify } from "yaml"; +import { useRequestVersion } from "../hooks/useRequestVersion"; +import { showDialog } from "../lib/dialog"; +import { restoreRequestVersion } from "../lib/restoreRequestVersion"; +import { Button } from "./core/Button"; +import { DiffViewer } from "./core/Editor/DiffViewer"; +import { Dropdown } from "./core/Dropdown"; + +interface Props { + response: Pick; +} + +/** + * Offers the request a response was sent from, when that is no longer the + * request you have. + * + * Hidden while the two agree, which is the overwhelmingly common case and the + * one where there is nothing to say. Responses recorded before versioning + * existed have no version and stay quiet forever. + */ +export function RequestVersionDropdown({ response }: Props) { + const comparison = useRequestVersion(response.versionId, response.requestId); + if (comparison.data == null || !comparison.data.differs) { + return null; + } + + return ( + , + onSelect: () => showRequestVersionDiff(comparison.data!), + }, + { + label: "Restore This Version", + leftSlot: , + onSelect: () => restoreRequestVersion(comparison.data!.version), + }, + ]} + > + + + ); +} + +function showRequestVersionDiff(comparison: RequestVersionComparison) { + showDialog({ + id: "request-version-diff", + title: "Request Changes Since This Response", + size: "full", + noPadding: true, + render: () => ( +
+ +
+ ), + }); +} + +/** Matches how the Git dialog renders a model for diffing. */ +function toYaml(document: unknown): string { + return stringify(document, { indent: 2, lineWidth: 0 }); +} diff --git a/apps/yaak-client/hooks/useRequestVersion.ts b/apps/yaak-client/hooks/useRequestVersion.ts new file mode 100644 index 00000000..b2d3fd23 --- /dev/null +++ b/apps/yaak-client/hooks/useRequestVersion.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import type { RequestVersionComparison } from "@yaakapp-internal/models"; +import { useAtomValue } from "jotai"; +import { allRequestsAtom } from "./useAllRequests"; +import { rpc } from "../lib/rpc"; + +/** + * The request version a response was sent from, alongside the request as it + * stands now. + * + * Refetches when the live request is written, which is what keeps the "has this + * changed?" answer honest while someone edits. The comparison itself is the + * backend's — the frontend never hashes anything. + */ +export function useRequestVersion(versionId: string | null | undefined, requestId: string | null) { + const requests = useAtomValue(allRequestsAtom); + const liveUpdatedAt = requests.find((r) => r.id === requestId)?.updatedAt; + + return useQuery({ + placeholderData: (prev) => prev, + queryKey: ["request_version", versionId, liveUpdatedAt], + enabled: versionId != null, + queryFn: () => + rpc("models_request_version", { versionId: versionId! }), + }); +} diff --git a/apps/yaak-client/lib/editSessionTracker.test.ts b/apps/yaak-client/lib/editSessionTracker.test.ts new file mode 100644 index 00000000..dc54c5a2 --- /dev/null +++ b/apps/yaak-client/lib/editSessionTracker.test.ts @@ -0,0 +1,75 @@ +import type { ModelVersionReason } from "@yaakapp-internal/models"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { EditSessionTracker } from "./editSessionTracker"; + +type Capture = [requestId: string, reason: ModelVersionReason]; + +function tracker(idleMs = 1000) { + const captured: Capture[] = []; + return { + captured, + tracker: new EditSessionTracker((id, reason) => captured.push([id, reason]), idleMs), + }; +} + +describe("EditSessionTracker", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + test("captures a request once it has been left alone", () => { + const { tracker: t, captured } = tracker(); + t.noteEdit("rq_1"); + + vi.advanceTimersByTime(999); + expect(captured).toEqual([]); + + vi.advanceTimersByTime(1); + expect(captured).toEqual([["rq_1", "idle"]]); + }); + + test("a burst of edits is one capture, not one per keystroke", () => { + const { tracker: t, captured } = tracker(); + for (let i = 0; i < 10; i++) { + t.noteEdit("rq_1"); + vi.advanceTimersByTime(500); + } + expect(captured).toEqual([]); + + vi.advanceTimersByTime(1000); + expect(captured).toEqual([["rq_1", "idle"]]); + }); + + test("captures the request being left, not the one being opened", () => { + const { tracker: t, captured } = tracker(); + t.noteActiveRequest("rq_1"); + expect(captured).toEqual([]); + + t.noteActiveRequest("rq_2"); + expect(captured).toEqual([["rq_1", "switch"]]); + }); + + test("re-selecting the same request is not a boundary", () => { + const { tracker: t, captured } = tracker(); + t.noteActiveRequest("rq_1"); + t.noteActiveRequest("rq_1"); + expect(captured).toEqual([]); + }); + + test("blur and close capture the request still on screen", () => { + const { tracker: t, captured } = tracker(); + t.noteActiveRequest("rq_1"); + t.noteBoundary(); + t.noteBoundary(); + expect(captured).toEqual([ + ["rq_1", "switch"], + ["rq_1", "switch"], + ]); + }); + + test("nothing is captured before a request is open", () => { + const { tracker: t, captured } = tracker(); + t.noteBoundary(); + t.noteActiveRequest(null); + expect(captured).toEqual([]); + }); +}); diff --git a/apps/yaak-client/lib/editSessionTracker.ts b/apps/yaak-client/lib/editSessionTracker.ts new file mode 100644 index 00000000..17e43440 --- /dev/null +++ b/apps/yaak-client/lib/editSessionTracker.ts @@ -0,0 +1,57 @@ +import type { ModelVersionReason } from "@yaakapp-internal/models"; + +/** + * How long a request has to sit untouched before its edits become a version. + * Long enough that typing a URL is one version rather than forty, short enough + * that walking away from a half-finished edit still records it. + */ +export const IDLE_MS = 60_000; + +type Snapshot = (requestId: string, reason: ModelVersionReason) => void; + +/** + * When a request's editing session ends. + * + * The backend versions a request on every send, which covers "what produced + * this response". This covers the rest: an edit someone made and then walked + * away from, which no send would ever have captured. + * + * It deliberately knows nothing about *what* changed. Versions are + * content-addressed, so a boundary that turns out to have nothing behind it + * costs one query and creates nothing — which is what lets this stay a timer + * and two assignments instead of a change-tracking system. + */ +export class EditSessionTracker { + private timer: ReturnType | null = null; + private idleRequestId: string | null = null; + private activeRequestId: string | null = null; + + constructor( + private readonly snapshot: Snapshot, + private readonly idleMs: number = IDLE_MS, + ) {} + + /** A request was written. Restarts its idle countdown. */ + noteEdit(requestId: string) { + if (this.timer != null) clearTimeout(this.timer); + this.idleRequestId = requestId; + this.timer = setTimeout(() => { + this.timer = null; + const requestId = this.idleRequestId; + if (requestId != null) this.snapshot(requestId, "idle"); + }, this.idleMs); + } + + /** The user moved to a different request, so the one they left is finished. */ + noteActiveRequest(requestId: string | null) { + if (requestId === this.activeRequestId) return; + const left = this.activeRequestId; + this.activeRequestId = requestId; + if (left != null) this.snapshot(left, "switch"); + } + + /** The window lost focus or is closing. */ + noteBoundary() { + if (this.activeRequestId != null) this.snapshot(this.activeRequestId, "switch"); + } +} diff --git a/apps/yaak-client/lib/requestVersions.ts b/apps/yaak-client/lib/requestVersions.ts new file mode 100644 index 00000000..690529f6 --- /dev/null +++ b/apps/yaak-client/lib/requestVersions.ts @@ -0,0 +1,50 @@ +import { flushAllPendingPatches } from "@yaakapp-internal/models"; +import type { ModelPayload, ModelVersion, ModelVersionReason } from "@yaakapp-internal/models"; +import { platform } from "@yaakapp-internal/platform"; +import { EditSessionTracker } from "./editSessionTracker"; +import { activeRequestIdAtom } from "../hooks/useActiveRequestId"; +import { jotaiStore } from "./jotai"; +import { rpc } from "./rpc"; + +const REQUEST_MODELS = ["http_request", "grpc_request", "websocket_request"]; + +/** + * Ask the backend to capture a request's current content. + * + * Quiet by design: a version that fails to write is not worth a toast, because + * every caller below is reacting to the user leaving rather than asking for + * anything. + */ +export function snapshotRequestVersion(requestId: string, reason: ModelVersionReason) { + // Edits reach the database on a debounce, so flush before asking for a + // version of what is in it + flushAllPendingPatches(); + rpc("models_snapshot_request", { requestId, reason }).catch((err: unknown) => { + console.warn("Failed to snapshot request version", err); + }); +} + +export function initRequestVersionSnapshots() { + const tracker = new EditSessionTracker(snapshotRequestVersion); + + platform.listen("model_writes", (payloads) => { + for (const payload of payloads) { + if (payload.change.type !== "upsert") continue; + if (!REQUEST_MODELS.includes(payload.model.model)) continue; + tracker.noteEdit(payload.model.id); + } + }); + + tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom)); + jotaiStore.sub(activeRequestIdAtom, () => { + tracker.noteActiveRequest(jotaiStore.get(activeRequestIdAtom)); + }); + + platform.window.onFocusChanged((focused) => { + if (!focused) tracker.noteBoundary(); + }); + + // Closing is the last boundary there is. Nothing can be awaited here, but the + // write is already on its way and the backend outlives the window. + window.addEventListener("beforeunload", () => tracker.noteBoundary()); +} diff --git a/apps/yaak-client/lib/restoreRequestVersion.ts b/apps/yaak-client/lib/restoreRequestVersion.ts new file mode 100644 index 00000000..2daff40c --- /dev/null +++ b/apps/yaak-client/lib/restoreRequestVersion.ts @@ -0,0 +1,35 @@ +import { flushAllModelWrites } from "@yaakapp-internal/models"; +import type { ModelVersion } from "@yaakapp-internal/models"; +import { wasUpdatedExternally } from "../hooks/useRequestUpdateKey"; +import { fireAndForget } from "./fireAndForget"; +import { rpc } from "./rpc"; +import { showToast } from "./toast"; + +/** + * Put an old version's content back into the live request. + * + * The backend captures whatever the request currently holds before + * overwriting it, so this is not a destructive action even when the last edit + * was never versioned — but the request is still rewritten under the user's + * cursor, so it is announced. + */ +export function restoreRequestVersion(version: ModelVersion) { + fireAndForget( + (async () => { + // The backend restores from the database, so anything still sitting in a + // debounce has to land first — otherwise it would overwrite the restore + await flushAllModelWrites(); + const requestId = await rpc("models_restore_request_version", { + versionId: version.id, + }); + // The write came from this window, so the store's echo suppression would + // otherwise leave open editors showing what was there before + wasUpdatedExternally(requestId); + showToast({ + id: "request-version-restored", + color: "success", + message: "Restored the request that produced this response", + }); + })(), + ); +} diff --git a/apps/yaak-client/main.tsx b/apps/yaak-client/main.tsx index 8c23f777..cfcbf55e 100644 --- a/apps/yaak-client/main.tsx +++ b/apps/yaak-client/main.tsx @@ -8,6 +8,7 @@ import { createRoot } from "react-dom/client"; import { initGit } from "./init/git"; import { initSync } from "./init/sync"; import { initGlobalListeners } from "./lib/initGlobalListeners"; +import { initRequestVersionSnapshots } from "./lib/requestVersions"; import { jotaiStore } from "./lib/jotai"; import { router } from "./lib/router"; @@ -36,6 +37,7 @@ initGit(); initSync(); initModelStore(jotaiStore); initGlobalListeners(); +initRequestVersionSnapshots(); await changeModelStoreWorkspace(null); // Load global models console.log("Creating React root");