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");