From 841bd932c85ad021a7af5a46d55a68082905a1c1 Mon Sep 17 00:00:00 2001 From: Naheel Muhammed Date: Sat, 8 Aug 2026 23:35:02 +0530 Subject: [PATCH 1/5] feat: save recent generations in local history --- src/__tmp__/generationHistory.test.ts | 187 ++++++++++++++++++ src/app/generate/GeneratePageClient.tsx | 78 +++++++- .../Generator/GenerationHistory.tsx | 153 ++++++++++++++ src/components/Generator/SearchInput.tsx | 4 +- src/lib/generationHistory.ts | 139 +++++++++++++ 5 files changed, 557 insertions(+), 4 deletions(-) create mode 100644 src/__tmp__/generationHistory.test.ts create mode 100644 src/components/Generator/GenerationHistory.tsx create mode 100644 src/lib/generationHistory.ts diff --git a/src/__tmp__/generationHistory.test.ts b/src/__tmp__/generationHistory.test.ts new file mode 100644 index 0000000..9e3bd4f --- /dev/null +++ b/src/__tmp__/generationHistory.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect } from "vitest"; +import { + appendGeneration, + clearHistory, + GENERATION_HISTORY_KEY, + loadHistory, + MAX_HISTORY_ENTRIES, + MAX_MARKDOWN_CHARS, + removeHistoryEntry, + saveHistory, + type GenerationHistoryEntry, +} from "@/lib/generationHistory"; + +type FakeStorage = { + getItem: (key: string) => string | null; + setItem: (key: string, value: string) => void; + removeItem: (key: string) => void; + dump: () => Record; +}; + +function createFakeStorage(initial?: Record): FakeStorage { + const store = new Map(Object.entries(initial ?? {})); + return { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + removeItem: (key) => { + store.delete(key); + }, + dump: () => Object.fromEntries(store), + }; +} + +function makeEntry(url: string, language = "English", markdown = "# Readme"): GenerationHistoryEntry { + return { + id: `id-${url}-${language}`, + url, + language, + markdown, + createdAt: 1_000_000, + }; +} + +describe("appendGeneration", () => { + it("adds a new entry at the front and normalizes the URL", () => { + const next = appendGeneration([], " https://github.com/Owner/Repo/ ", "English", "# Hi"); + expect(next).toHaveLength(1); + expect(next[0].url).toBe("https://github.com/Owner/Repo"); + expect(next[0].language).toBe("English"); + expect(next[0].markdown).toBe("# Hi"); + }); + + it("dedupes by URL + language, moving the existing entry to the front", () => { + const first = [makeEntry("https://github.com/a/b", "English")]; + const oldDate = 100; + const firstWithDate = [{ ...first[0], createdAt: oldDate }]; + const next = appendGeneration(firstWithDate, "https://github.com/A/B", "English", "# v2", 500); + expect(next).toHaveLength(1); + expect(next[0].markdown).toBe("# v2"); + expect(next[0].createdAt).toBe(500); + }); + + it("keeps separate entries for different languages", () => { + const next = appendGeneration( + [makeEntry("https://github.com/a/b", "English")], + "https://github.com/a/b", + "Spanish", + "# v2", + ); + expect(next).toHaveLength(2); + }); + + it("evicts the oldest entries beyond the maximum", () => { + let entries: GenerationHistoryEntry[] = []; + for (let i = 0; i < MAX_HISTORY_ENTRIES + 5; i++) { + entries = appendGeneration(entries, `https://github.com/owner/repo${i}`, "English", "# x"); + } + expect(entries).toHaveLength(MAX_HISTORY_ENTRIES); + expect(entries[0].url).toBe(`https://github.com/owner/repo${MAX_HISTORY_ENTRIES + 4}`); + }); + + it("truncates oversized markdown", () => { + const hugeMarkdown = "x".repeat(MAX_MARKDOWN_CHARS + 500); + const next = appendGeneration([], "https://github.com/a/b", "English", hugeMarkdown); + expect(next[0].markdown.length).toBe(MAX_MARKDOWN_CHARS); + }); +}); + +describe("loadHistory", () => { + it("returns [] when no key exists", () => { + expect(loadHistory(createFakeStorage())).toEqual([]); + }); + + it("returns [] for corrupt JSON", () => { + const storage = createFakeStorage({ [GENERATION_HISTORY_KEY]: "{not json" }); + expect(loadHistory(storage)).toEqual([]); + }); + + it("returns [] when the payload is not an array", () => { + const storage = createFakeStorage({ [GENERATION_HISTORY_KEY]: JSON.stringify({ url: "x" }) }); + expect(loadHistory(storage)).toEqual([]); + }); + + it("drops malformed entries and keeps valid ones", () => { + const payload = [ + makeEntry("https://github.com/good/repo"), + { url: "https://github.com/broken/repo" }, + "junk", + null, + ]; + const storage = createFakeStorage({ + [GENERATION_HISTORY_KEY]: JSON.stringify(payload), + }); + const loaded = loadHistory(storage); + expect(loaded).toHaveLength(1); + expect(loaded[0].url).toBe("https://github.com/good/repo"); + }); + + it("enforces the entry cap and markdown cap on load", () => { + const many = Array.from({ length: MAX_HISTORY_ENTRIES + 5 }, (_, i) => + makeEntry(`https://github.com/o/r${i}`), + ).map((entry) => ({ ...entry, markdown: entry.markdown.repeat(MAX_MARKDOWN_CHARS) })); + const storage = createFakeStorage({ + [GENERATION_HISTORY_KEY]: JSON.stringify(many), + }); + const loaded = loadHistory(storage); + expect(loaded).toHaveLength(MAX_HISTORY_ENTRIES); + expect(loaded.every((entry) => entry.markdown.length <= MAX_MARKDOWN_CHARS)).toBe(true); + }); +}); + +describe("saveHistory", () => { + it("round-trips entries through localStorage", () => { + const storage = createFakeStorage(); + const entries = [makeEntry("https://github.com/a/b", "English")]; + saveHistory(entries, storage); + expect(loadHistory(storage)).toEqual(entries); + }); + + it("shrinks the payload when the quota is exceeded", () => { + const store = new Map(); + const storage: FakeStorage = { + getItem: (key) => store.get(key) ?? null, + setItem: (key, value) => { + const parsedLength = (JSON.parse(value) as unknown[]).length; + if (parsedLength >= 4) { + throw new Error("QuotaExceededError"); + } + store.set(key, value); + }, + removeItem: (key) => { + store.delete(key); + }, + dump: () => Object.fromEntries(store), + }; + + let entries: GenerationHistoryEntry[] = []; + for (let i = 0; i < 6; i++) { + entries = appendGeneration(entries, `https://github.com/owner/repo${i}`, "English", "# x"); + } + expect(entries).toHaveLength(6); + + expect(() => saveHistory(entries, storage)).not.toThrow(); + expect(loadHistory(storage).length).toBeLessThan(6); + }); +}); + +describe("removeHistoryEntry", () => { + it("removes the requested entry and keeps the rest", () => { + const entries = [ + makeEntry("https://github.com/a/b", "English"), + makeEntry("https://github.com/c/d", "English"), + ]; + const next = removeHistoryEntry(entries, entries[0].id); + expect(next).toHaveLength(1); + expect(next[0].url).toBe("https://github.com/c/d"); + }); +}); + +describe("clearHistory", () => { + it("removes the stored key", () => { + const storage = createFakeStorage({ [GENERATION_HISTORY_KEY]: JSON.stringify([makeEntry("https://github.com/a/b")]) }); + clearHistory(storage); + expect(storage.dump()).toEqual({}); + }); +}); \ No newline at end of file diff --git a/src/app/generate/GeneratePageClient.tsx b/src/app/generate/GeneratePageClient.tsx index f8476fd..29aa5c2 100644 --- a/src/app/generate/GeneratePageClient.tsx +++ b/src/app/generate/GeneratePageClient.tsx @@ -1,12 +1,21 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useRef } from "react"; import { Navbar } from "@/components/layout/Navbar"; import { Footer } from "@/components/layout/Footer"; import { SearchInput } from "@/components/Generator/SearchInput"; import { MarkdownPreview } from "@/components/Generator/MarkdownPreview"; +import { GenerationHistory } from "@/components/Generator/GenerationHistory"; import LoadingOverlay from "@/components/Generator/LoadingOverlay"; import { navLinks } from "@/constants/navLinks"; import { TerminalMockup } from "@/components/sections/TerminalMockup"; +import { + appendGeneration, + clearHistory, + loadHistory, + saveHistory, + GENERATION_HISTORY_KEY, + type GenerationHistoryEntry, +} from "@/lib/generationHistory"; interface GeneratePageProps { repoSlug?: string; @@ -20,6 +29,13 @@ export default function GeneratePageClient({ repoSlug }: GeneratePageProps) { const [authRequired, setAuthRequired] = useState(false); const [privateRepoConsentRequired, setPrivateRepoConsentRequired] = useState(false); + const [history, setHistory] = useState([]); + const [restoredForm, setRestoredForm] = useState<{ + url: string; + language: string; + } | null>(null); + const [restoreKey, setRestoreKey] = useState(0); + const previewRef = useRef(null); // Optional: Update document title for SPA navigation useEffect(() => { @@ -31,6 +47,18 @@ export default function GeneratePageClient({ repoSlug }: GeneratePageProps) { } }, [repoSlug]); + useEffect(() => { + setHistory(loadHistory()); + + const handleStorage = (event: StorageEvent) => { + if (event.key === GENERATION_HISTORY_KEY) { + setHistory(loadHistory()); + } + }; + window.addEventListener("storage", handleStorage); + return () => window.removeEventListener("storage", handleStorage); + }, []); + const handleGenerate = async ( githubUrl: string, language: string = "English", @@ -83,6 +111,16 @@ export default function GeneratePageClient({ repoSlug }: GeneratePageProps) { const data = await response.json(); if (data && typeof data.markdown === "string") { setMarkdown(data.markdown); + setHistory((prev) => { + const next = appendGeneration( + prev, + githubUrl, + language, + data.markdown, + ); + saveHistory(next); + return next; + }); } else { setMarkdown(""); throw new Error( @@ -106,6 +144,25 @@ export default function GeneratePageClient({ repoSlug }: GeneratePageProps) { setAuthRequired(false); }; + const handleRestoreGeneration = (entry: GenerationHistoryEntry) => { + setRestoredForm({ url: entry.url, language: entry.language }); + setRestoreKey((key) => key + 1); + setMarkdown(entry.markdown); + setErrorMessage(null); + setErrorCode(null); + setAuthRequired(false); + setPrivateRepoConsentRequired(false); + previewRef.current?.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + }; + + const handleClearHistory = () => { + setHistory([]); + clearHistory(); + }; + return (
{/* UI LOADING OVERLAY @@ -120,9 +177,14 @@ export default function GeneratePageClient({ repoSlug }: GeneratePageProps) { Generate Your AI-Powered README - +
+ +
+
+ +