diff --git a/web/src/components/repeater/repeater-editor.tsx b/web/src/components/repeater/repeater-editor.tsx index 4cab00a..b3a0e17 100644 --- a/web/src/components/repeater/repeater-editor.tsx +++ b/web/src/components/repeater/repeater-editor.tsx @@ -1,7 +1,5 @@ "use client"; -import * as React from "react"; - import { SendIcon } from "lucide-react"; import { toast } from "sonner"; @@ -24,35 +22,23 @@ const METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { const { method, url, headersText, body, useProxy, insecure, response, history } = tab; const updateTab = useRepeaterStore((state) => state.updateTab); - const recordSend = useRepeaterStore((state) => state.recordSend); + const busy = useRepeaterStore((state) => state.inflight[tab.id] !== undefined); const setMethod = (method: string) => updateTab(tab.id, { method }); const setURL = (url: string) => updateTab(tab.id, { url }); const setHeadersText = (headersText: string) => updateTab(tab.id, { headersText }); const setBody = (body: string) => updateTab(tab.id, { body }); const setUseProxy = (useProxy: boolean) => updateTab(tab.id, { useProxy }); const setInsecure = (insecure: boolean) => updateTab(tab.id, { insecure }); - const [busy, setBusy] = React.useState(false); - const generation = React.useRef(0); - const sending = React.useRef(false); - React.useEffect( - () => () => { - generation.current += 1; - sending.current = false; - }, - [], - ); - async function send() { - if (sending.current) return; + const store = useRepeaterStore.getState(); + if (store.inflight[tab.id] !== undefined) return; const target = url.trim(); if (!target) { toast.error("请填写 URL"); return; } - const requestGeneration = ++generation.current; - sending.current = true; - setBusy(true); - updateTab(tab.id, { response: null }); + const requestGeneration = store.beginSend(tab.id); + if (requestGeneration === undefined) return; try { const result = await api.repeaterSend({ method, @@ -62,8 +48,7 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { use_recording_proxy: useProxy, insecure_skip_verify: insecure, }); - if (requestGeneration !== generation.current) return; - recordSend(tab.id, { + store.completeSend(tab.id, requestGeneration, { id: newId(), method, url: target, @@ -75,12 +60,9 @@ export function RepeaterEditor({ tab }: { tab: RepeaterTab }) { at: new Date().toISOString(), }); } catch (error) { - if (requestGeneration === generation.current) toast.error((error as Error).message); + if (store.isCurrentSend(tab.id, requestGeneration)) toast.error((error as Error).message); } finally { - if (requestGeneration === generation.current) { - sending.current = false; - setBusy(false); - } + store.clearInflight(tab.id, requestGeneration); } } diff --git a/web/src/components/repeater/repeater-state.ts b/web/src/components/repeater/repeater-state.ts index 5b94f86..8279147 100644 --- a/web/src/components/repeater/repeater-state.ts +++ b/web/src/components/repeater/repeater-state.ts @@ -5,7 +5,9 @@ import type { RepeaterInitial } from "./repeater-http"; export const STORAGE_KEY = "artex_repeater_workspace_v1"; export const MAX_HISTORY = 20; export const MAX_RESPONSE_CHARS = 256 * 1024; -export const MAX_STORAGE_BYTES = 2.5 * 1024 * 1024; +// Count UTF-16 storage bytes (two per code unit), reserving headroom below 5 MiB +// for the storage key and other browser preferences. +export const MAX_STORAGE_BYTES = 4 * 1024 * 1024; export const DEFAULT_GROUP_ID = "default"; export type RepeaterHistoryEntry = { @@ -119,7 +121,7 @@ export function serializeWorkspace(workspace: RepeaterWorkspace, maxBytes = MAX_ })), }; let json = JSON.stringify(saved); - const size = () => new TextEncoder().encode(json).length; + const size = () => json.length * 2; // Remove the oldest send globally, including its request snapshot, first. const oldest = saved.tabs .flatMap((tab) => tab.history.map((entry) => ({ tab, entry }))) diff --git a/web/src/components/repeater/repeater-store.test.mjs b/web/src/components/repeater/repeater-store.test.mjs index 40d44c1..a0f47c9 100644 --- a/web/src/components/repeater/repeater-store.test.mjs +++ b/web/src/components/repeater/repeater-store.test.mjs @@ -26,6 +26,8 @@ const { parseHeadersText, setHostHeader, } = require("./repeater-http.ts"); +const { flush, useRepeaterStore } = require("./repeater-store.ts"); +const { toast } = require("sonner"); const response = (body = "ok") => ({ status: 200, @@ -134,7 +136,7 @@ test("budget removes globally oldest history first then strips responses, preser const workspace = addTab(defaultWorkspace()); workspace.tabs[0].history = [entry(3), entry(1)]; workspace.tabs[1].history = [entry(4), entry(2)]; - const limit = Buffer.byteLength(serializeWorkspace(workspace)) - Buffer.byteLength(JSON.stringify(entry(1))) - 1; + const limit = (serializeWorkspace(workspace).length - JSON.stringify(entry(1)).length - 1) * 2; const saved = JSON.parse(serializeWorkspace(workspace, limit)); assert.deepEqual( saved.tabs.map((tab) => tab.history.map((item) => item.id)), @@ -154,7 +156,189 @@ test("budget removes globally oldest history first then strips responses, preser tab.response = response("中".repeat(MAX_RESPONSE_CHARS)); tab.history = [entry(1, "中".repeat(MAX_RESPONSE_CHARS))]; } - assert.ok(Buffer.byteLength(serializeWorkspace(large)) <= MAX_STORAGE_BYTES); + assert.ok(serializeWorkspace(large).length * 2 <= MAX_STORAGE_BYTES); +}); + +test("storage cap counts UTF-16 bytes for ASCII, Chinese and surrogate pairs", () => { + for (const text of ["x", "中", "😀"]) { + let workspace = defaultWorkspace(); + for (let index = 0; index < 5; index++) workspace = addTab(workspace); + for (const tab of workspace.tabs) { + const body = text.repeat(MAX_RESPONSE_CHARS).slice(0, MAX_RESPONSE_CHARS); + tab.response = response(body); + tab.history = [entry(1, body)]; + tab.body = `保留草稿 ${text}`; + } + assert.ok(JSON.stringify(workspace).length * 2 > MAX_STORAGE_BYTES); + const json = serializeWorkspace(workspace); + assert.ok(json.length * 2 <= MAX_STORAGE_BYTES); + assert.deepEqual( + JSON.parse(json).tabs.map((tab) => tab.body), + workspace.tabs.map((tab) => tab.body), + ); + } +}); + +test("drafts exceeding the soft UTF-16 budget are never discarded", () => { + const workspace = defaultWorkspace(); + workspace.tabs[0].body = "x".repeat(MAX_STORAGE_BYTES / 2); + workspace.tabs[0].response = response("previous"); + workspace.tabs[0].history = [entry(1)]; + const json = serializeWorkspace(workspace); + const saved = JSON.parse(json); + assert.ok(json.length * 2 > MAX_STORAGE_BYTES); + assert.equal(saved.tabs[0].body, workspace.tabs[0].body); + assert.deepEqual(saved.tabs[0].history, []); + assert.equal(saved.tabs[0].response.body, ""); +}); + +function setupStore(t) { + const previousWindow = globalThis.window; + const writes = []; + globalThis.window = { + localStorage: { + setItem: (key, value) => writes.push({ key, value }), + }, + }; + useRepeaterStore.setState({ workspace: addTab(defaultWorkspace()), inflight: {} }); + t.after(() => { + useRepeaterStore.setState({ workspace: null, inflight: {} }); + flush(); // Cancel pending persistence without writing to another test's storage. + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + }); + return { store: useRepeaterStore.getState(), writes }; +} + +test("a second send invalidates only that tab's first send and stale cleanup cannot clear it", (t) => { + const { store } = setupStore(t); + const [first, other] = store.workspace.tabs; + store.recordSend(first.id, entry(1, "previous")); + const generation1 = store.beginSend(first.id); + const otherGeneration = store.beginSend(other.id); + const generation2 = store.beginSend(first.id); + assert.notEqual(generation1, generation2); + assert.equal(store.isCurrentSend(first.id, generation1), false); + assert.equal(store.isCurrentSend(first.id, generation2), true); + assert.equal(store.isCurrentSend(other.id, otherGeneration), true); + store.completeSend(first.id, generation1, entry(2, "stale")); + store.clearInflight(first.id, generation1); + assert.equal(useRepeaterStore.getState().inflight[first.id], generation2); + assert.equal(useRepeaterStore.getState().workspace.tabs[0].response.body, "previous"); + store.completeSend(first.id, generation2, entry(3, "newest")); + const state = useRepeaterStore.getState(); + assert.equal(state.workspace.tabs[0].response.body, "newest"); + assert.deepEqual( + state.workspace.tabs[0].history.map((item) => item.id), + ["3", "1"], + ); + assert.equal(state.inflight[first.id], undefined); + assert.equal(state.inflight[other.id], otherGeneration); +}); + +test("closing a tab or clearing the workspace drops pending results and history", (t) => { + const { store } = setupStore(t); + const [closed, other] = store.workspace.tabs; + const generation = store.beginSend(closed.id); + const otherGeneration = store.beginSend(other.id); + store.closeTab(closed.id); + assert.equal(store.isCurrentSend(closed.id, generation), false); + assert.equal(store.beginSend(closed.id), undefined); + store.completeSend(closed.id, generation, entry(1)); + assert.equal(useRepeaterStore.getState().inflight[closed.id], undefined); + assert.equal(useRepeaterStore.getState().workspace.tabs.length, 1); + assert.deepEqual(useRepeaterStore.getState().workspace.tabs[0].history, []); + assert.equal(store.isCurrentSend(other.id, otherGeneration), true); + store.clearWorkspace(); + store.completeSend(other.id, otherGeneration, entry(2)); + assert.deepEqual(useRepeaterStore.getState().inflight, {}); + assert.deepEqual(useRepeaterStore.getState().workspace.tabs[0].history, []); +}); + +test("busy state survives remount and successful completion records without an editor mounted", async (t) => { + const { store, writes } = setupStore(t); + const [tab, other] = store.workspace.tabs; + store.recordSend(tab.id, entry(1, "previous")); + const observedBusy = []; + const unmount = useRepeaterStore.subscribe((state) => observedBusy.push(state.inflight[tab.id] !== undefined)); + const generation = store.beginSend(tab.id); + let resolveResponse; + const pending = new Promise((resolve) => { + resolveResponse = resolve; + }).then((result) => store.completeSend(tab.id, generation, result)); + unmount(); + store.setActiveTab(other.id); + store.setActiveTab(tab.id); + // A remounted editor reads the same store selector to disable duplicate sends. + assert.equal(useRepeaterStore.getState().inflight[tab.id] !== undefined, true); + assert.equal(useRepeaterStore.getState().workspace.tabs[0].response.body, "previous"); + store.setActiveTab(other.id); + resolveResponse(entry(2, "completed offscreen")); + await pending; + const state = useRepeaterStore.getState(); + assert.deepEqual(observedBusy, [true]); + assert.equal(state.inflight[tab.id], undefined); + assert.equal(state.workspace.activeTabId, other.id); + assert.equal(state.workspace.tabs[0].response.body, "completed offscreen"); + assert.deepEqual( + state.workspace.tabs[0].history.map((item) => item.id), + ["2", "1"], + ); + assert.equal(JSON.parse(writes.at(-1).value).tabs[0].history[0].id, "2"); +}); + +test("failed send cleanup preserves the previous response and history", (t) => { + const { store } = setupStore(t); + const tab = store.workspace.tabs[0]; + store.recordSend(tab.id, entry(1, "previous")); + const generation = store.beginSend(tab.id); + store.clearInflight(tab.id, generation); + const state = useRepeaterStore.getState(); + assert.equal(state.inflight[tab.id], undefined); + assert.equal(state.workspace.tabs[0].response.body, "previous"); + assert.deepEqual( + state.workspace.tabs[0].history.map((item) => item.id), + ["1"], + ); +}); + +test("failed storage write retries with histories and response bodies removed, preserving live data and drafts", (t) => { + const { store } = setupStore(t); + const tab = store.workspace.tabs[0]; + store.updateTab(tab.id, { body: "keep this draft" }); + store.recordSend(tab.id, entry(1, "previous response")); + const attempts = []; + t.mock.method(globalThis.window.localStorage, "setItem", (_key, value) => { + attempts.push(value); + if (attempts.length === 1) throw new DOMException("full", "QuotaExceededError"); + }); + const error = t.mock.method(toast, "error", () => "test-toast"); + flush(); + assert.equal(attempts.length, 2); + assert.ok(attempts[1].length < attempts[0].length); + const saved = JSON.parse(attempts[1]).tabs[0]; + assert.equal(saved.body, "keep this draft"); + assert.deepEqual(saved.history, []); + assert.equal(saved.response.body, ""); + assert.equal(saved.response.body_truncated, true); + assert.equal(useRepeaterStore.getState().workspace.tabs[0].response.body, "previous response"); + assert.equal(useRepeaterStore.getState().workspace.tabs[0].history.length, 1); + assert.equal(error.mock.callCount(), 0); +}); + +test("two failed storage writes report save failure through a Chinese error toast", (t) => { + const { store } = setupStore(t); + const tab = store.workspace.tabs[0]; + store.updateTab(tab.id, { body: "unsaved draft" }); + const setItem = t.mock.method(globalThis.window.localStorage, "setItem", () => { + throw new DOMException("full", "QuotaExceededError"); + }); + const error = t.mock.method(toast, "error", () => "test-toast"); + flush(); + assert.equal(setItem.mock.callCount(), 2); + assert.equal(error.mock.callCount(), 1); + assert.match(error.mock.calls[0].arguments[0], /工作区保存失败/); + assert.equal(useRepeaterStore.getState().workspace.tabs[0].body, "unsaved draft"); }); test("Host shortcut replaces case-insensitive duplicates and preserves other header lines", () => { diff --git a/web/src/components/repeater/repeater-store.ts b/web/src/components/repeater/repeater-store.ts index 912e52c..a6001f0 100644 --- a/web/src/components/repeater/repeater-store.ts +++ b/web/src/components/repeater/repeater-store.ts @@ -1,9 +1,9 @@ "use client"; +import { toast } from "sonner"; import { create } from "zustand"; -import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; - +import { getLocalStorageValue } from "../../lib/local-storage.client"; import type { RepeaterInitial } from "./repeater-http"; import { addTab, @@ -28,6 +28,11 @@ export type TabPatch = Partial< >; type Store = { workspace: RepeaterWorkspace | null; + inflight: Record; + beginSend: (id: string) => number | undefined; + isCurrentSend: (id: string, generation: number) => boolean; + clearInflight: (id: string, generation: number) => void; + completeSend: (id: string, generation: number, entry: RepeaterHistoryEntry) => void; hydrate: () => void; createTab: (seed?: TabSeed, opts?: TabOptions) => string; closeTab: (id: string) => void; @@ -44,11 +49,23 @@ type Store = { }; let timer: ReturnType | undefined; -function flush() { +let nextGeneration = 0; +export function flush() { if (timer) clearTimeout(timer); timer = undefined; const workspace = useRepeaterStore.getState().workspace; - if (workspace) setLocalStorageValue(STORAGE_KEY, serializeWorkspace(workspace)); + if (!workspace) return; + try { + window.localStorage.setItem(STORAGE_KEY, serializeWorkspace(workspace)); + } catch { + try { + // Retry with only drafts and response metadata: evict all history, then + // response bodies. Never shrink the request drafts, even if they exceed quota. + window.localStorage.setItem(STORAGE_KEY, serializeWorkspace(workspace, 0)); + } catch { + toast.error("工作区保存失败,浏览器存储空间不足或不可用,请备份请求草稿"); + } + } } export const useRepeaterStore = create((set, get) => { @@ -73,6 +90,26 @@ export const useRepeaterStore = create((set, get) => { } return { workspace: null, + inflight: {}, + beginSend: (id) => { + if (!current().tabs.some((tab) => tab.id === id)) return; + const generation = ++nextGeneration; + set({ inflight: { ...get().inflight, [id]: generation } }); + return generation; + }, + isCurrentSend: (id, generation) => + get().inflight[id] === generation && !!get().workspace?.tabs.some((tab) => tab.id === id), + clearInflight: (id, generation) => { + if (get().inflight[id] !== generation) return; + const inflight = { ...get().inflight }; + delete inflight[id]; + set({ inflight }); + }, + completeSend: (id, generation, entry) => { + if (!get().isCurrentSend(id, generation)) return; + get().recordSend(id, entry); + get().clearInflight(id, generation); + }, hydrate: () => { if (get().workspace || typeof window === "undefined") return; set({ workspace: deserializeWorkspace(getLocalStorageValue(STORAGE_KEY)) }); @@ -90,6 +127,8 @@ export const useRepeaterStore = create((set, get) => { const workspace = current(); const index = workspace.tabs.findIndex((tab) => tab.id === id); if (index < 0) return; + const generation = get().inflight[id]; + if (generation !== undefined) get().clearInflight(id, generation); const tabs = workspace.tabs.filter((tab) => tab.id !== id); if (!tabs.length) tabs.push(emptyTab()); change({ @@ -120,7 +159,10 @@ export const useRepeaterStore = create((set, get) => { updateTab: editTab, recordSend: (id, entry) => { const tab = current().tabs.find((item) => item.id === id); - if (tab) editTab(id, { response: entry.response, history: [entry, ...tab.history].slice(0, MAX_HISTORY) }); + if (tab) { + editTab(id, { response: entry.response, history: [entry, ...tab.history].slice(0, MAX_HISTORY) }); + flush(); + } }, createGroup: (name) => { const workspace = current(); @@ -151,6 +193,7 @@ export const useRepeaterStore = create((set, get) => { }); }, clearWorkspace: () => { + set({ inflight: {} }); change(defaultWorkspace()); flush(); },