From c87a1511e113af61a08095cf9e047a8b63e429bd Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Mon, 22 Jun 2026 22:15:20 +0530 Subject: [PATCH 01/19] UI --- .gitignore | 3 +- pitchdeck.md | 307 --------------------------------------------------- 2 files changed, 2 insertions(+), 308 deletions(-) delete mode 100644 pitchdeck.md diff --git a/.gitignore b/.gitignore index 45ecfaf8..6f1f542d 100644 --- a/.gitignore +++ b/.gitignore @@ -69,5 +69,6 @@ apps/web/.codex apps/desktop/src-tauri/target/debug/* apps/desktop/src-tauri/target/release/ .claude +/docs - +.superpowers \ No newline at end of file diff --git a/pitchdeck.md b/pitchdeck.md deleted file mode 100644 index 8e653e68..00000000 --- a/pitchdeck.md +++ /dev/null @@ -1,307 +0,0 @@ -# MyDevTools — Pitch Deck - -> Working draft. Each `## Slide` maps to one deck slide. Speaker notes in blockquotes. -> **Numbers are filled with modeled/illustrative values for a 10K-paying-user Year 1.** Items marked **⚠️ VERIFY** (traction, founder bio, final raise terms) are placeholders only you can confirm — replace with live data before sending. See the Appendix checklist. - ---- - -## Slide 1 — Title - -# MyDevTools.tech -### The all-in-one developer toolkit — fast, private, beautifully crafted. - -- **The only tool that unifies SQL, NoSQL, and Redis in one workspace** — alongside 60+ developer utilities and productivity apps. -- Client-side first. Privacy by design. Open source (GPLv3). -> One-liner: "One client for every database, plus the 60+ tools developers keep in 20 browser tabs — unified, private, and beautiful." - ---- - -## Slide 2 — The Problem - -**Developers waste time and trust on fragmented tooling.** - -- **Scattered.** A typical workflow touches 10–20 single-purpose sites (JSON formatter, JWT decoder, regex tester, cron builder…) — and 2–3 separate apps just for SQL, NoSQL, and Redis. -- **Ad-riddled & slow.** Most free dev-tool sites monetize with intrusive ads and SEO spam. -- **Privacy risk.** Pasting tokens, secrets, certs, or production data into unknown servers is a real security exposure. -- **No continuity.** No saved history, no shared environments, no team state. Every session starts from zero. -- **Context-switching tax.** Constant tab- and app-hopping breaks flow and kills productivity. - -> Pain is daily, universal, and currently "solved" by a junk drawer of bookmarks and three open database GUIs. - ---- - -## Slide 3 — The Solution - -**One workspace. Every tool. Your data stays yours.** - -- **One client for every database** — SQL, NoSQL (MongoDB), and Redis, side by side. No more juggling three separate apps. -- **60+ tools unified** — utilities, an API client, database clients, and productivity apps in one home. -- **Client-side first** — data processed in-browser wherever possible; no server round-trips for sensitive input. -- **Persistent & personal** — saved snippets, notes, environments, vault, bookmarks — synced across sessions. -- **Premium UX** — dark/light mode, fluid animations, fully responsive, command palette, i18n. -- **Open source & self-hostable** — trust through transparency. - -> We replace 20 sketchy tabs and 3 database apps with one tool developers want open all day. - ---- - -## Slide 4 — Product & USPs - -### Unique Selling Points -1. **The only all-in-one database client** — SQL + NoSQL (MongoDB) + Redis in one workspace. Tools force devs into 3 separate apps; we're one. This is the wedge. -2. **Breadth + depth in one app** — 60+ tools; no competitor bundles this range with this polish. -3. **Privacy-first architecture** — client-side processing; secrets never leave the browser. -4. **Open source (GPLv3) + self-host** — developer trust moat. -5. **Persistence & sync** — tools remember your work; productivity apps live alongside utilities. -6. **Beautiful, fast, ad-free** — premium UX vs. the ad-spam incumbents. - -### The Toolbox (60+) - -**Database & Connectivity (the wedge):** SQL Client · NoSQL Explorer (MongoDB) · Redis Commander · S3 Drive · API Client (Postman-like) - -**Developer Utilities:** Base64 · Certificate/PEM Decoder · Color Picker · Contrast Checker · Cron Builder · CSV/Excel/JSON · Diff Checker · Docker Compose Generator · Email Validator · Encryption Playground · Environment Manager · Format Converter (YAML/TOML/JSON/XML) · GraphQL Formatter · Hash Generator · HMAC Generator · HTTP Status Codes · Image Compressor · Image→Base64 · IP Subnet Calculator · JSON Formatter (Monaco) · JSON Schema Generator · .gitignore Generator · JWT Decoder · Lorem Ipsum · Markdown Preview · MIME Lookup · Mock Data Generator · Number Base Converter · Regex Tester · QR Code Generator · Secret/API Key Generator · Snippet Manager · SQL Formatter · SVG Optimizer · Timestamp Converter · TOTP Generator · Unit Converter · URL Encode/Decode · URL Parser · User Agent Parser · UUID Generator · CSS Gradient Builder - -**Productivity Apps:** Bookmarks · Break Room · Notes (Tiptap) · Password Manager (client-side encrypted) · Task Manager - -**Built-in distribution:** Public developer profile at `mydevtools.tech/` — shareable, GitHub stats, social links. - -> Demo flow: connect a Postgres DB + a Redis instance side by side → run queries → save to snippets → share profile. Show the wedge + breadth + persistence in 90 seconds. - ---- - -## Slide 5 — Security & Trust - -**We connect to your databases. Earning trust is the product.** - -- **Why this slide exists:** the unified DB client touches users' SQL/NoSQL/Redis — potentially production credentials. This is both our biggest responsibility and our strongest differentiator vs. ad-funded web tools. -- **Architecture commitments:** - - Credentials encrypted at rest; **no plaintext secret storage**; client-side encryption for the vault. - - Minimal-trust connection handling; transparent, open-source codebase users (and acquirers) can audit. - - Self-host option for teams that won't put DB access in any SaaS. -- **Compliance roadmap:** SOC 2 Type II path post-raise; GDPR-aligned data handling; clear data-residency story for Team/Enterprise. - -> Turn the scariest objection into a moat: "We're the database tool you can actually trust — open source, encrypted, self-hostable." ⚠️ VERIFY exact current architecture before claiming specifics on stage. - ---- - -## Slide 6 — Why Now - -- **Developer population exploding** — ~30M+ developers worldwide today, projected toward ~45M by 2030. -- **PLG is the proven motion** — bottoms-up adoption (Postman, Vercel, Linear) wins developer markets. -- **Privacy backlash** — rising distrust of where pasted data and credentials go; client-side + open source is a selling point. -- **Tool sprawl is worsening** — AI-era workflows mean devs juggle more tools, data, and databases than ever; a unified, trusted hub matters more. -- **Open-source distribution compounds cheaply** — GitHub, Product Hunt, and SEO drive near-zero-CAC growth. - ---- - -## Slide 7 — Market - -- **TAM:** 30M+ professional developers globally. At $96/yr that's a ~$2.9B addressable spend; broaden to "developer productivity SaaS" and the category is tens of billions. -- **SAM:** Developers who work with databases (SQL/NoSQL/Redis) and want unified tooling — ~8–10M reachable via PLG/SEO. -- **SOM (5-yr target):** ~160K paying users — well under 2% of SAM. - -> We don't need to win the market. Capturing <2% of SAM as paid yields a $15M+ ARR business. - ---- - -## Slide 8 — Business Model - -**Freemium → Pro subscription.** - -| Tier | Price | Who | What | -|---|---|---|---| -| **Free** | $0 | Top-of-funnel | Limited tool/usage access; drives discovery & SEO (scope TBD) | -| **Pro** | **$8/mo** ($80/yr annual) | Power users | All 60+ tools, the unified DB client (SQL/NoSQL/Redis), unlimited persistence, sync, vault | -| **Team** | $8/seat/mo (future) | Small teams | Shared environments, collections, RBAC | -| **Enterprise / Self-host** | Custom (future) | Orgs | SSO, on-prem, support, compliance | - -- **Headline unit:** $8/mo = **$96/yr per paid user**. ARR = paid users × $96. -- **Free tier (live)** is the low-CAC growth engine; usage/tool limits nudge upgrade — the unified DB client is the "aha" that converts. -- **Year 1 target: 10,000 paying users**, drawn from a larger free base. -- **Expansion revenue (future):** team seats and enterprise/self-host licenses lift ARPU well above $96. - -> Free tier widens the funnel cheaply via SEO/OSS; Pro monetizes daily-active power users at $96/yr. - ---- - -## Slide 9 — Traction - -> ⚠️ VERIFY — numbers below are illustrative launch-stage figures. Replace with your live metrics; this is the most scrutinized slide. - -- Launched on Product Hunt (featured). -- Open source on GitHub — **10 stars**, **3 contributors**. -- **50 registered users**, **10 weekly active**, **250 paying** to date. -- Month-over-month growth: **25%**. Monthly churn: **5%**. -- 60+ tools shipped; weekly release cadence. - -> If revenue is still early, lead with usage + growth rate + the wedge's pull. Honesty beats inflated metrics with VCs — swap every number here for real data. - ---- - -## Slide 10 — Go-to-Market - -**Product-Led Growth — compounding and low-CAC.** - -1. **SEO moat** — 60+ tool pages ranking for high-intent queries ("redis gui", "jwt decoder", "cron builder"). Organic = near-zero marginal CAC. -2. **The DB wedge** — lead acquisition with the one thing no one else does: SQL + NoSQL + Redis in one place. -3. **Open-source flywheel** — GitHub stars, contributors, self-host advocates → credibility + inbound. -4. **Built-in virality** — public developer profiles shared publicly, branded with MyDevTools. -5. **Community** — Product Hunt, Hacker News, Reddit (r/webdev, r/database), dev Twitter/X, Dev.to. -6. **Conversion** — free usage → $8/mo Pro at the DB-client / persistence wall. Target ~7%+ free→paid. - -> Channel mix is cheap and compounding — the foundation of the unit economics two slides down. - ---- - -## Slide 11 — Competition - -| | MyDevTools | DBeaver / TablePlus | Single-tool sites | Postman / niche SaaS | -|---|---|---|---|---| -| **SQL + NoSQL + Redis in one** | ✅ | ⚠️ SQL-first, partial | ❌ | ❌ | -| Breadth (60+ tools) | ✅ | ❌ | ❌ | ❌ | -| Privacy / client-side | ✅ | ✅ desktop | ❌ ads/trackers | ⚠️ | -| Web + persistence + sync | ✅ | ❌ desktop only | ❌ | ✅ (one domain) | -| Open source / self-host | ✅ | ⚠️ | ❌ | ❌ | -| Productivity apps + profile | ✅ | ❌ | ❌ | ❌ | - -**Moat (defensibility):** features are copyable — the durable moat is the **compound**: the DB-trifecta wedge × 60+-tool breadth × privacy/trust brand × open-source distribution × switching costs from saved snippets/environments/vault × the viral profile network. Each tool added and each saved workspace deepens lock-in. - ---- - -## Slide 12 — 5-Year Financial Forecast - -**Model:** Freemium. Pro = $8/mo = $96/yr. ARR = paying users × $96. Conversion improves with product depth & retention. - -| Year | Registered Users | Free→Paid Conv | Paying Users | ARPU/yr | **ARR** | -|---|---|---|---|---|---| -| **1** | ~143,000 | 7% | 10,000 | $96 | **$0.96M** | -| **2** | ~375,000 | 8% | 30,000 | $96 | **$2.88M** | -| **3** | ~667,000 | 9% | 60,000 | $96 | **$5.76M** | -| **4** | ~1,000,000 | 10% | 100,000 | $96 | **$9.60M** | -| **5** | ~1,450,000 | 11% | 160,000 | $96 | **$15.36M** | - -**Sensitivity (Year 1 paying users):** -- Conservative: 7,000 paid → $672K ARR -- Base: 10,000 paid → $960K ARR -- Aggressive: 15,000 paid → $1.44M ARR - -> Year 1 goal: **10K paying users → ~$1M ARR**, from a ~140K free base at ~7% conversion. ARPU is held flat at $96 (conservative) — Team/Enterprise seats are upside not yet modeled. - ---- - -## Slide 13 — Unit Economics & CAC - -- **Price:** $8/mo · **ARPU:** ~$96/yr · **Avg. paid lifetime:** ~24 mo → **LTV ≈ $192 gross** (~$165 contribution) -- **Gross margin:** target ~85–90% (validate vs. DB-connection infra cost) - -### Can ₹10.5L marketing deliver 10K paying users? - -**The funnel (Year 1):** ~143K registered → **10K paying** (7% conversion). - -| Channel | Share of signups | New signups | Cost | Note | -|---|---|---|---|---| -| **Organic** (SEO on 60+ tool pages, OSS, Product Hunt, viral profiles) | ~75% | ~107K | ~₹0 marginal | The engine — compounds | -| **Paid** (dev newsletters, content/social ads, sponsorships) | ~25% | ~36K | ₹10.5L (~$12.6K) | The accelerant | - -- **Paid cost per signup:** ₹10.5L ÷ 36K ≈ **₹29 (~$0.35)** -- **Paid-channel CAC per paying user:** ₹29 ÷ 7% ≈ **₹417 (~$5)** -- **Blended CAC** (all marketing ÷ all 10K paid): ₹10.5L ÷ 10K ≈ **₹105 (~$1.27)** - -### Why this is safe -- **LTV:CAC** — blended ~150x; paid-channel ~38x. Even at a pessimistic **$30 cold-paid CAC, still ~6x** and payback < 4 months. -- **Break-even headroom:** could pay up to **~$64/paying user** (3:1 threshold) and stay healthy — we budget ~$5. Huge margin of safety. - -> The math only works because organic carries ~75% of the funnel. ⚠️ The real risk isn't CAC — it's hitting **143K registered** and **7% conversion**. Prove both with early cohort data; that's what an angel should underwrite. - ---- - -## Slide 14 — Milestones & Roadmap - -**₹15L funds ~12 months of marketing + infra to reach 10K paying users (~$1M ARR).** - -| Quarter | Product | Growth / GTM | Target | -|---|---|---|---| -| **Q1** | Free tier limits live; DB client hardening | SEO content sprint, Product Hunt relaunch | 25K registered | -| **Q2** | Team tier (shared envs/collections) | Community + OSS push | First 3K paying | -| **Q3** | Security: SOC 2 path kickoff; vault hardening | Paid-channel CAC tests | 6K paying, churn <4% | -| **Q4** | Enterprise/self-host packaging | Partnerships / integrations | **10K paying · ~$1M ARR** | -| **Y2** | SSO, RBAC, audit logs | Outbound to teams | 30K paying · ~$2.9M ARR | - -> Tie spend to gates. Show you know exactly what each rupee unlocks. ⚠️ VERIFY quarterly targets against your real ramp. - ---- - -## Slide 15 — Team - -- **Akhil** — Founder / Lead Engineer. Full-stack developer who designed, built, and shipped 60+ production tools solo at a weekly release cadence. ⚠️ VERIFY — add prior role/company, notable wins, and why you'll win this market. -- Open-source contributor community — 18 contributors and growing. -- **Lean & capital-efficient:** product fully built solo; this round funds marketing + infra, not headcount. Part-time/contract help for content & design as needed. -- **Single-founder risk (addressed head-on):** actively seeking a technical co-founder and onboarding 2 advisors (a devtools GTM operator + a security/compliance lead) to de-risk execution. FTE hiring deferred to the next round once ARR justifies it. - -> Investors back people. Proof of execution: 60+ tools shipped solo at weekly cadence. Pre-empt the solo-founder objection — name it before they do. - ---- - -## Slide 16 — The Ask - -- **Raising:** ₹10–20 lakh (~$12K–24K) angel / pre-seed round. ⚠️ VERIFY final amount & terms. -- **Use of funds (₹15 lakh midpoint):** - - **70% marketing (~₹10.5 lakh)** — SEO content, paid-channel tests, community, Product Hunt, partnerships → drive the free funnel to 10K paying users - - **30% infrastructure (~₹4.5 lakh)** — hosting, database-connection infra, reliability, security hardening -- **What it buys:** runway to scale the free funnel toward **10K paying users (~$1M ARR)** in Year 1, founder-led and capital-efficient. -- **Why raise vs. self-fund:** the product is built and shipping — we want to validate the growth engine on investor capital first, de-risking before we commit our own. Founder is prepared to invest personally in later stages once the funnel is proven. - -> Lean raise, focused spend. The product is built (60+ tools shipped solo) — this money buys distribution, not R&D. - ---- - -## Slide 17 — Vision - -**The default workspace developers keep open all day.** - -Start as the unified database client + utility belt. Become the trusted, private, collaborative hub for individual developers and teams — data tools, utilities, productivity, and identity in one open platform. - -> Closing line: "One tool for every database, and every utility. The workspace developers keep open all day." - ---- - -## Slide 18 — Exit & Investor Upside - -**Your early check buys equity now. Returns come via the next round, or a strategic acquisition.** - -- **Nearest upside — the next round:** ₹10–20L today funds the growth that unlocks a priced seed/Series-A at a higher valuation. Early angel equity marks up as ARR climbs ($0.96M → $2.88M Y1→Y2). -- **Why we're acquirable later:** a sticky, daily-active developer base + a unified SQL/NoSQL/Redis client is a natural bolt-on for devtool, cloud, and database platforms (GitLab, Atlassian, DigitalOcean, MongoDB, Redis…). -- **Comparable M&A:** developer SaaS exits at **8–15x ARR** strategically — meaningful upside on an early, small check as the company scales. -- **Founder committed:** building through scale, no early bail-out. Any founder liquidity happens alongside investors, never ahead. - -> Angel-stage framing: lead with the **path to the next round** (concrete, 12–24 months out), not a Year-5 acquisition fantasy. The acquisition story is the ceiling, not the pitch. ⚠️ Set entry valuation with your angel. - ---- - -## Appendix — Assumptions & Pre-Pitch Checklist - -**Key assumptions (validate before pitching):** -- Freemium: live limited free tier + $8/mo Pro; annual discount to $80/yr. Free-tier scope (tool/usage limits) TBD. -- Year 1 target = 10,000 paying users, from ~140K free base at ~7% conversion. -- Paid growth 10K → 160K over 5 years; conversion ramp 7%→11% (assumed; validate with cohort data). **Note: 7% free→paid is at the optimistic end (typical SaaS 2–5%) — be ready to defend it with the wedge's stickiness.** -- Avg paid lifetime 24 months; CAC $15–30 via PLG/SEO/OSS. -- Developer TAM ~30M, growing ~45M by 2030 (cite source: e.g. SlashData/Evans Data). - -**⚠️ VERIFY — filled with illustrative values; replace with real before sending (only you have these):** -- [ ] Real traction numbers (Slide 9) — currently placeholders: 400 stars, 18 contributors, 12K registered, 3.5K WAU, 250 paying, 25% MoM, 5% churn. -- [ ] Founder bio + prior wins (Slide 15) — generic placeholder text in place. -- [ ] Raise terms (Slide 16) — modeled as ₹10–20 lakh angel round (70% marketing / 30% infra); set exact amount, valuation & instrument. -- [ ] Milestone targets (Slide 14) — modeled quarterly ramp; check against your real plan. -- [ ] Security architecture specifics (Slide 5) — confirm what's actually implemented today. -- [ ] Exit / next-round upside (Slide 18) — set entry valuation with your angel; keep return framing consistent with the ₹10–20L raise. - -**Defend-with-data (investors will probe):** -- [ ] **Churn / retention** — the 24-mo lifetime and LTV rest on it. -- [ ] **CAC proof** — show real cost per paid user, not just "SEO is cheap." -- [ ] **Margin** — if any DB proxying is server-side, infra scales with usage; confirm 85–90% holds. -- [ ] **Conversion rate** — prove the 7% with early cohort data. - -**Polish for the designed deck:** -- [ ] Visual charts: ARR curve, funnel, market-size pyramid. -- [ ] Verify market-size citation with a real source. -- [ ] One-line demo GIF/video of SQL + Redis side by side. From c9ee1e3b22d1510585fb7f8d498d28fbdcd398fa Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Mon, 22 Jun 2026 22:39:00 +0530 Subject: [PATCH 02/19] refactor(api-client): drop dead destructures in ApiClientInner after T7 split Only addHistoryItem and substituteVariables are used; remove clearHistory, deleteHistoryItem, setActiveEnvId, addEnvironment, updateEnvironment, deleteEnvironment. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/components/api-client/api-client.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/api-client/api-client.tsx b/apps/web/src/components/api-client/api-client.tsx index ed3c835a..97b2f435 100644 --- a/apps/web/src/components/api-client/api-client.tsx +++ b/apps/web/src/components/api-client/api-client.tsx @@ -61,9 +61,9 @@ function ApiClientInner() { const { collections } = useCollectionsState() const { saveRequest } = useCollectionsActions() const { history } = useHistoryState() - const { addHistoryItem, clearHistory, deleteHistoryItem } = useHistoryActions() + const { addHistoryItem } = useHistoryActions() const { environments, activeEnvId, activeEnvironmentVariables } = useEnvironmentsState() - const { setActiveEnvId, addEnvironment, updateEnvironment, deleteEnvironment, substituteVariables } = useEnvironmentsActions() + const { substituteVariables } = useEnvironmentsActions() const isMobile = useIsMobile() const [collectionsOpen, setCollectionsOpen] = React.useState(false) From 4e8910e9821f2a2a47d6f026fa79f06e5bf85bd2 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Mon, 22 Jun 2026 22:41:02 +0530 Subject: [PATCH 03/19] refactor(api-client): compose context providers at page entry Co-Authored-By: Claude Sonnet 4.6 --- apps/web/src/app/app/api-client/page.tsx | 10 +++++++--- .../src/components/api-client/api-client.tsx | 20 +++++-------------- .../api-client/context/api-client-context.tsx | 18 +++++++++++++++++ 3 files changed, 30 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/components/api-client/context/api-client-context.tsx diff --git a/apps/web/src/app/app/api-client/page.tsx b/apps/web/src/app/app/api-client/page.tsx index 6e4d5406..ada5fdd8 100644 --- a/apps/web/src/app/app/api-client/page.tsx +++ b/apps/web/src/app/app/api-client/page.tsx @@ -1,8 +1,12 @@ import { ApiClientLazy } from '@/components/app-tools/client-only-tool-loaders' +import { ApiClientProviders } from '@/components/api-client/context/api-client-context' + export default function ApiClientPage() { return ( -
- -
+ +
+ +
+
) } diff --git a/apps/web/src/components/api-client/api-client.tsx b/apps/web/src/components/api-client/api-client.tsx index 97b2f435..67fe4e61 100644 --- a/apps/web/src/components/api-client/api-client.tsx +++ b/apps/web/src/components/api-client/api-client.tsx @@ -29,10 +29,10 @@ import { FolderOpen, PanelRight } from "lucide-react" import { cn } from "@/lib/utils" import { ensureHttpScheme } from "@/lib/url-normalize" import { useJsonFormatter } from "./workers/use-json-formatter" -import { TabsProvider, useTabs, useTabsActions, createNewTab } from "./context/tabs-context" -import { CollectionsProvider, useCollectionsState, useCollectionsActions } from "./context/collections-context" -import { EnvironmentsProvider, useEnvironmentsState, useEnvironmentsActions } from "./context/environments-context" -import { HistoryProvider, useHistoryState, useHistoryActions } from "./context/history-context" +import { useTabs, useTabsActions, createNewTab } from "./context/tabs-context" +import { useCollectionsState, useCollectionsActions } from "./context/collections-context" +import { useEnvironmentsState, useEnvironmentsActions } from "./context/environments-context" +import { useHistoryState, useHistoryActions } from "./context/history-context" /** `new URL()` requires a scheme; host-only URLs (e.g. `api.example.com/v1`) are common in API clients. */ function buildRequestUrl(raw: string): URL { @@ -602,15 +602,5 @@ function ApiClientInner() { } export function ApiClient() { - return ( - - - - - - - - - - ) + return } diff --git a/apps/web/src/components/api-client/context/api-client-context.tsx b/apps/web/src/components/api-client/context/api-client-context.tsx new file mode 100644 index 00000000..503cc3ab --- /dev/null +++ b/apps/web/src/components/api-client/context/api-client-context.tsx @@ -0,0 +1,18 @@ +"use client" +import * as React from "react" +import { TabsProvider } from "./tabs-context" +import { CollectionsProvider } from "./collections-context" +import { EnvironmentsProvider } from "./environments-context" +import { HistoryProvider } from "./history-context" + +export function ApiClientProviders({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + + ) +} From 41b1563fbd2c468f5f5aa0f18ff7e94742a080c9 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Mon, 22 Jun 2026 23:01:43 +0530 Subject: [PATCH 04/19] perf(api-client): virtualize history list with react-window Replace infinite-scroll + slice pattern with react-window v2 List component so only visible rows are rendered regardless of history size. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/package.json | 2 + .../__tests__/virtual-history-list.test.tsx | 87 +++++++++ .../collections/collections-sidebar.tsx | 174 +++++++++--------- .../collections/virtual-history-list.tsx | 53 ++++++ 4 files changed, 232 insertions(+), 84 deletions(-) create mode 100644 apps/web/src/components/api-client/__tests__/virtual-history-list.test.tsx create mode 100644 apps/web/src/components/api-client/collections/virtual-history-list.tsx diff --git a/apps/web/package.json b/apps/web/package.json index f3ebfd0f..17ebe599 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -101,6 +101,7 @@ "react-firebase-hooks": "^5.1.1", "react-github-calendar": "^5.0.6", "react-resizable-panels": "^3.0.6", + "react-window": "^2.2.7", "reflect-metadata": "^0.2.2", "smol-toml": "^1.6.1", "sonner": "^2.0.7", @@ -133,6 +134,7 @@ "@types/qrcode": "^1.5.6", "@types/react": "^19", "@types/react-dom": "^19", + "@types/react-window": "^2.0.0", "@types/turndown": "^5.0.6", "@vitalets/google-translate-api": "^9.2.1", "autoprefixer": "^10.4.21", diff --git a/apps/web/src/components/api-client/__tests__/virtual-history-list.test.tsx b/apps/web/src/components/api-client/__tests__/virtual-history-list.test.tsx new file mode 100644 index 00000000..e5f91b82 --- /dev/null +++ b/apps/web/src/components/api-client/__tests__/virtual-history-list.test.tsx @@ -0,0 +1,87 @@ +// Approach: mock react-window so List calls rowComponent once with index:0, style:{}. +// This lets us assert that renderRow is invoked without @testing-library/react (not installed). +// The virtualization cap (renders << total items) is validated at the integration level. + +jest.mock("react-window", () => { + const React = require("react") + return { + List: ({ rowComponent: RowComponent, rowProps, rowCount }: { + rowComponent: React.ComponentType<{ ariaAttributes: object; index: number; style: React.CSSProperties } & typeof rowProps> + rowProps: Record + rowCount: number + rowHeight: number + style?: React.CSSProperties + defaultHeight?: number + }) => { + if (rowCount === 0) return null + return React.createElement(RowComponent, { + ariaAttributes: { "aria-posinset": 1, "aria-setsize": rowCount, role: "listitem" }, + index: 0, + style: {}, + ...rowProps, + }) + }, + } +}) + +describe("VirtualHistoryList", () => { + it("is a React.memo component", () => { + const { VirtualHistoryList } = require("../collections/virtual-history-list") + expect(VirtualHistoryList.$$typeof).toBe(Symbol.for("react.memo")) + }) + + it("calls renderRow with the first item and a style object", () => { + const React = require("react") + const ReactDOM = require("react-dom/server") + const { VirtualHistoryList } = require("../collections/virtual-history-list") + + const item: import("../types").HistoryRequest = { + id: "1", + name: "Test", + method: "GET", + url: "https://example.com", + params: [], + headers: [], + body: { type: "none", content: "" }, + auth: { type: "none" }, + timestamp: 1000, + } + + const renderRow = jest.fn((_item: unknown, style: React.CSSProperties) => + React.createElement("div", { style }, "row") + ) + + ReactDOM.renderToStaticMarkup( + React.createElement(VirtualHistoryList, { + items: [item], + height: 400, + onSelect: () => {}, + onDelete: () => {}, + renderRow, + }) + ) + + expect(renderRow).toHaveBeenCalledTimes(1) + expect(renderRow).toHaveBeenCalledWith(item, {}) + }) + + it("renders nothing when items array is empty", () => { + const React = require("react") + const ReactDOM = require("react-dom/server") + const { VirtualHistoryList } = require("../collections/virtual-history-list") + + const renderRow = jest.fn() + + ReactDOM.renderToStaticMarkup( + React.createElement(VirtualHistoryList, { + items: [], + height: 400, + onSelect: () => {}, + onDelete: () => {}, + renderRow, + }) + ) + + expect(renderRow).not.toHaveBeenCalled() + }) +}) diff --git a/apps/web/src/components/api-client/collections/collections-sidebar.tsx b/apps/web/src/components/api-client/collections/collections-sidebar.tsx index 6739e853..31fdf362 100644 --- a/apps/web/src/components/api-client/collections/collections-sidebar.tsx +++ b/apps/web/src/components/api-client/collections/collections-sidebar.tsx @@ -7,8 +7,8 @@ import { ScrollArea } from "@/components/ui/scroll-area" import { Collection, CollectionRequest } from "../types" import { CollectionItem } from "./collection-item" import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X, Loader2 } from "lucide-react" -import { useInfiniteScroll } from "@/hooks/use-infinite-scroll" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { VirtualHistoryList } from "./virtual-history-list" import { cn } from "@/lib/utils" import { Dialog, @@ -68,18 +68,24 @@ export function CollectionsSidebar({ ) }, [history, debouncedSearch]) - const historyScrollRef = React.useRef(null) - const { displayCount: historyDisplayCount, sentinelRef: historySentinelRef, hasMore: historyHasMore } = useInfiniteScroll({ - totalCount: filteredHistory.length, - resetKey: debouncedSearch, - pageSize: 30, - scrollContainerRef: historyScrollRef, - }) + // FixedSizeList virtualizes overflow; all filtered items are passed directly. + const visibleHistory = filteredHistory + + // Measure the flex container height so FixedSizeList fills it exactly. + const historyListContainerRef = React.useRef(null) + const [historyListHeight, setHistoryListHeight] = React.useState(400) + React.useEffect(() => { + const el = historyListContainerRef.current + if (!el) return + const obs = new ResizeObserver(([entry]) => { + if (entry) setHistoryListHeight(entry.contentRect.height) + }) + obs.observe(el) + // Set initial height + setHistoryListHeight(el.clientHeight || 400) + return () => obs.disconnect() + }, []) - const visibleHistory = React.useMemo( - () => filteredHistory.slice(0, historyDisplayCount), - [filteredHistory, historyDisplayCount] - ) const [newFolderDialogOpen, setNewFolderDialogOpen] = React.useState(false) const [newCollectionDialogOpen, setNewCollectionDialogOpen] = React.useState(false) const [renameCollectionDialogOpen, setRenameCollectionDialogOpen] = React.useState(false) @@ -307,81 +313,81 @@ export function CollectionsSidebar({ )} -
-
- {visibleHistory.map((item) => ( -
onLoadRequest(item)} - > -
-
- - {item.method} - - - {item.name ? getApiClientRequestDisplayName(item.name, tRoot) : item.url} - -
- -
-
- {item.url} -
- {new Date(item.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} - {item.status && ( - = 200 && item.status < 300 ? "text-green-500" - : item.status >= 300 && item.status < 400 ? "text-amber-500" - : "text-destructive" - )}> - {item.status} - - )} +
+
+ + {item.method} + + + {item.name ? getApiClientRequestDisplayName(item.name, tRoot) : item.url} + +
+ +
+
+ {item.url} +
+ {new Date(item.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})} + {item.status && ( + = 200 && item.status < 300 ? "text-green-500" + : item.status >= 300 && item.status < 400 ? "text-amber-500" + : "text-destructive" + )}> + {item.status} + + )} +
+
-
- ))} - {historyHasMore && ( -
- -
- )} - {!history.length && ( -
-
- -
-

{t("noHistoryTitle")}

-

{t("noHistoryHint")}

-
- )} - {!!history.length && historySearch && !filteredHistory.length && ( -
No results for "{historySearch}"
- )} -
+ )} + /> + )}
diff --git a/apps/web/src/components/api-client/collections/virtual-history-list.tsx b/apps/web/src/components/api-client/collections/virtual-history-list.tsx new file mode 100644 index 00000000..2ee91cd0 --- /dev/null +++ b/apps/web/src/components/api-client/collections/virtual-history-list.tsx @@ -0,0 +1,53 @@ +"use client" +import * as React from "react" +import { List } from "react-window" +import type { HistoryRequest } from "../types" + +// Custom data passed through rowProps (must not include ariaAttributes, index, or style) +type HistoryRowCustomProps = { + items: HistoryRequest[] + renderRow: (item: HistoryRequest, style: React.CSSProperties) => React.ReactNode +} + +// Full props received by the row component (custom data + injected by react-window) +type HistoryRowProps = HistoryRowCustomProps & { + ariaAttributes: { + "aria-posinset": number + "aria-setsize": number + role: "listitem" + } + index: number + style: React.CSSProperties +} + +function HistoryRow({ index, style, items, renderRow }: HistoryRowProps): React.ReactElement | null { + const item = items[index] + if (!item) return null + return <>{renderRow(item, style)} +} + +interface VirtualHistoryListProps { + items: HistoryRequest[] + height: number + onSelect: (item: HistoryRequest) => void + onDelete: (id: string) => void + renderRow: (item: HistoryRequest, style: React.CSSProperties) => React.ReactNode +} + +export const VirtualHistoryList = React.memo(function VirtualHistoryList({ + items, + height, + renderRow, +}: VirtualHistoryListProps) { + const rowProps: HistoryRowCustomProps = { items, renderRow } + return ( + + rowComponent={HistoryRow} + rowProps={rowProps} + rowCount={items.length} + rowHeight={64} + style={{ height }} + defaultHeight={height} + /> + ) +}) From a950aafc59000b44a0e148c510f8d46f986f2a59 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Mon, 22 Jun 2026 23:11:05 +0530 Subject: [PATCH 05/19] perf(api-client): truncate large response bodies and gate download Cap Monaco input at 2 MB; show amber banner with download-full-body button for oversized responses. Extract truncateBody helper + test. Co-Authored-By: Claude Sonnet 4.6 --- apps/web/messages/af.json | 4 +- apps/web/messages/ar.json | 4 +- apps/web/messages/ca.json | 4 +- apps/web/messages/cs.json | 4 +- apps/web/messages/da.json | 4 +- apps/web/messages/de.json | 4 +- apps/web/messages/el.json | 4 +- apps/web/messages/en.json | 4 +- apps/web/messages/es.json | 4 +- apps/web/messages/fa.json | 4 +- apps/web/messages/fr.json | 4 +- apps/web/messages/id.json | 4 +- apps/web/messages/it.json | 4 +- apps/web/messages/ja.json | 4 +- apps/web/messages/ko.json | 4 +- apps/web/messages/ms.json | 4 +- apps/web/messages/nb.json | 4 +- apps/web/messages/nl.json | 4 +- apps/web/messages/pl.json | 4 +- apps/web/messages/pt-BR.json | 4 +- apps/web/messages/pt.json | 4 +- apps/web/messages/ru.json | 4 +- apps/web/messages/sv.json | 4 +- apps/web/messages/tr.json | 4 +- apps/web/messages/uk.json | 4 +- apps/web/messages/vi.json | 4 +- apps/web/messages/zh.json | 4 +- .../response-panel.large-body.test.tsx | 71 +++++++++++++++++++ .../components/api-client/response-panel.tsx | 38 ++++++++-- .../components/api-client/truncate-body.ts | 14 ++++ 30 files changed, 197 insertions(+), 34 deletions(-) create mode 100644 apps/web/src/components/api-client/__tests__/response-panel.large-body.test.tsx create mode 100644 apps/web/src/components/api-client/truncate-body.ts diff --git a/apps/web/messages/af.json b/apps/web/messages/af.json index 9309f407..b79b7817 100644 --- a/apps/web/messages/af.json +++ b/apps/web/messages/af.json @@ -1528,7 +1528,9 @@ "previewTab": "Voorskou", "headersTab": "Koptekste", "binaryRawView": "Binêre data kan nie in rouaansig vertoon word nie.", - "errorStatusLabel": "Fout" + "errorStatusLabel": "Fout", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Nuwe oortjie" diff --git a/apps/web/messages/ar.json b/apps/web/messages/ar.json index 2d52c645..ef99a135 100644 --- a/apps/web/messages/ar.json +++ b/apps/web/messages/ar.json @@ -1528,7 +1528,9 @@ "previewTab": "معاينة", "headersTab": "الرؤوس", "binaryRawView": "لا يمكن عرض البيانات الثنائية في العرض الخام.", - "errorStatusLabel": "خطأ" + "errorStatusLabel": "خطأ", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "تبويب جديد" diff --git a/apps/web/messages/ca.json b/apps/web/messages/ca.json index 7610b53b..1384c92a 100644 --- a/apps/web/messages/ca.json +++ b/apps/web/messages/ca.json @@ -1528,7 +1528,9 @@ "previewTab": "Vista prèvia", "headersTab": "Capçaleres", "binaryRawView": "Les dades binàries no es poden mostrar en vista en brut.", - "errorStatusLabel": "Error" + "errorStatusLabel": "Error", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Pestanya nova" diff --git a/apps/web/messages/cs.json b/apps/web/messages/cs.json index fae1d684..9f8a3d29 100644 --- a/apps/web/messages/cs.json +++ b/apps/web/messages/cs.json @@ -1528,7 +1528,9 @@ "previewTab": "Náhled", "headersTab": "Hlavičky", "binaryRawView": "Binární data nelze zobrazit v surovém zobrazení.", - "errorStatusLabel": "Chyba" + "errorStatusLabel": "Chyba", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Nová karta" diff --git a/apps/web/messages/da.json b/apps/web/messages/da.json index 6f585d63..e62e9836 100644 --- a/apps/web/messages/da.json +++ b/apps/web/messages/da.json @@ -1528,7 +1528,9 @@ "previewTab": "Forhåndsvisning", "headersTab": "Overskrifter", "binaryRawView": "Binære data kan ikke vises i rå visning.", - "errorStatusLabel": "Fejl" + "errorStatusLabel": "Fejl", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Ny fane" diff --git a/apps/web/messages/de.json b/apps/web/messages/de.json index d2c3a8e4..b40f760b 100644 --- a/apps/web/messages/de.json +++ b/apps/web/messages/de.json @@ -1528,7 +1528,9 @@ "previewTab": "Vorschau", "headersTab": "Header", "binaryRawView": "Binärdaten können in der Rohansicht nicht angezeigt werden.", - "errorStatusLabel": "Fehler" + "errorStatusLabel": "Fehler", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Neue Registerkarte" diff --git a/apps/web/messages/el.json b/apps/web/messages/el.json index 3344caa8..4ab78d22 100644 --- a/apps/web/messages/el.json +++ b/apps/web/messages/el.json @@ -1528,7 +1528,9 @@ "previewTab": "Πρεμιέρα", "headersTab": "Κεφαλίδες", "binaryRawView": "Τα δυαδικά δεδομένα δεν μπορούν να εμφανιστούν σε μη επεξεργασμένη προβολή.", - "errorStatusLabel": "Σφάλμα" + "errorStatusLabel": "Σφάλμα", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Νέα καρτέλα" diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 73bec8a4..ef933134 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1549,7 +1549,9 @@ "previewTab": "Preview", "headersTab": "Headers", "binaryRawView": "Binary data cannot be displayed in raw view.", - "errorStatusLabel": "Error" + "errorStatusLabel": "Error", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "New tab" diff --git a/apps/web/messages/es.json b/apps/web/messages/es.json index 4c9257d9..83b013c4 100644 --- a/apps/web/messages/es.json +++ b/apps/web/messages/es.json @@ -1528,7 +1528,9 @@ "previewTab": "Vista previa", "headersTab": "Cabeceras", "binaryRawView": "Los datos binarios no se pueden mostrar en vista en bruto.", - "errorStatusLabel": "Error" + "errorStatusLabel": "Error", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Nueva pestaña" diff --git a/apps/web/messages/fa.json b/apps/web/messages/fa.json index e2b52967..57b40901 100644 --- a/apps/web/messages/fa.json +++ b/apps/web/messages/fa.json @@ -1528,7 +1528,9 @@ "previewTab": "پیش نمایش", "headersTab": "سرصفحه ها", "binaryRawView": "داده های باینری را نمی توان در نمای خام نمایش داد.", - "errorStatusLabel": "خطا" + "errorStatusLabel": "خطا", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "برگه جدید" diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index 694642da..417db47b 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -1528,7 +1528,9 @@ "previewTab": "Aperçu", "headersTab": "En-têtes", "binaryRawView": "Les données binaires ne peuvent pas s’afficher en brut.", - "errorStatusLabel": "Erreur" + "errorStatusLabel": "Erreur", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Nouvel onglet" diff --git a/apps/web/messages/id.json b/apps/web/messages/id.json index d6ea2183..a813b03f 100644 --- a/apps/web/messages/id.json +++ b/apps/web/messages/id.json @@ -1528,7 +1528,9 @@ "previewTab": "Preview", "headersTab": "Headers", "binaryRawView": "Binary data cannot be displayed in raw view.", - "errorStatusLabel": "Error" + "errorStatusLabel": "Error", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "New tab" diff --git a/apps/web/messages/it.json b/apps/web/messages/it.json index 5d893705..4eb741d3 100644 --- a/apps/web/messages/it.json +++ b/apps/web/messages/it.json @@ -1528,7 +1528,9 @@ "previewTab": "Anteprima", "headersTab": "Intestazioni", "binaryRawView": "I dati binari non possono essere visualizzati nella vista grezza.", - "errorStatusLabel": "Errore" + "errorStatusLabel": "Errore", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Nuova scheda" diff --git a/apps/web/messages/ja.json b/apps/web/messages/ja.json index 2651fe1e..e7f4d789 100644 --- a/apps/web/messages/ja.json +++ b/apps/web/messages/ja.json @@ -1528,7 +1528,9 @@ "previewTab": "プレビュー", "headersTab": "ヘッダー", "binaryRawView": "バイナリデータはraw表示できません。", - "errorStatusLabel": "エラー" + "errorStatusLabel": "エラー", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "新しいタブ" diff --git a/apps/web/messages/ko.json b/apps/web/messages/ko.json index f28edd5b..4ed9f1c0 100644 --- a/apps/web/messages/ko.json +++ b/apps/web/messages/ko.json @@ -1528,7 +1528,9 @@ "previewTab": "미리보기", "headersTab": "헤더", "binaryRawView": "이진 데이터는 원시 보기에서 표시할 수 없습니다.", - "errorStatusLabel": "오류" + "errorStatusLabel": "오류", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "새 탭" diff --git a/apps/web/messages/ms.json b/apps/web/messages/ms.json index 3c59c6b6..e0815bb2 100644 --- a/apps/web/messages/ms.json +++ b/apps/web/messages/ms.json @@ -1528,7 +1528,9 @@ "previewTab": "Pratonton", "headersTab": "Pengepala", "binaryRawView": "Data binari tidak boleh dipaparkan dalam paparan mentah.", - "errorStatusLabel": "Ralat" + "errorStatusLabel": "Ralat", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Tab baharu" diff --git a/apps/web/messages/nb.json b/apps/web/messages/nb.json index 5e324a41..4b0c1a01 100644 --- a/apps/web/messages/nb.json +++ b/apps/web/messages/nb.json @@ -1528,7 +1528,9 @@ "previewTab": "Forhåndsvisning", "headersTab": "Overskrifter", "binaryRawView": "Binære data kan ikke vises i råvisning.", - "errorStatusLabel": "Feil" + "errorStatusLabel": "Feil", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Ny fane" diff --git a/apps/web/messages/nl.json b/apps/web/messages/nl.json index 7d812b18..422ba7a2 100644 --- a/apps/web/messages/nl.json +++ b/apps/web/messages/nl.json @@ -1528,7 +1528,9 @@ "previewTab": "Voorbeeld", "headersTab": "Headers", "binaryRawView": "Binaire gegevens kunnen niet worden weergegeven in ruwe weergave.", - "errorStatusLabel": "Fout" + "errorStatusLabel": "Fout", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Nieuw tabblad" diff --git a/apps/web/messages/pl.json b/apps/web/messages/pl.json index 5022e838..46b20f7c 100644 --- a/apps/web/messages/pl.json +++ b/apps/web/messages/pl.json @@ -1528,7 +1528,9 @@ "previewTab": "Preview", "headersTab": "Headers", "binaryRawView": "Binary data cannot be displayed in raw view.", - "errorStatusLabel": "Error" + "errorStatusLabel": "Error", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "New tab" diff --git a/apps/web/messages/pt-BR.json b/apps/web/messages/pt-BR.json index df10541a..61af4888 100644 --- a/apps/web/messages/pt-BR.json +++ b/apps/web/messages/pt-BR.json @@ -1528,7 +1528,9 @@ "previewTab": "Pré-visualização", "headersTab": "Cabeçalhos", "binaryRawView": "Dados binários não podem ser exibidos na visualização bruta.", - "errorStatusLabel": "Erro" + "errorStatusLabel": "Erro", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Nova aba" diff --git a/apps/web/messages/pt.json b/apps/web/messages/pt.json index 11d09140..a052319e 100644 --- a/apps/web/messages/pt.json +++ b/apps/web/messages/pt.json @@ -1528,7 +1528,9 @@ "previewTab": "Pré-visualização", "headersTab": "Cabeçalhos", "binaryRawView": "Dados binários não podem ser apresentados na vista em bruto.", - "errorStatusLabel": "Erro" + "errorStatusLabel": "Erro", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Novo separador" diff --git a/apps/web/messages/ru.json b/apps/web/messages/ru.json index 28439ca6..28d3de46 100644 --- a/apps/web/messages/ru.json +++ b/apps/web/messages/ru.json @@ -1528,7 +1528,9 @@ "previewTab": "Предпросмотр", "headersTab": "Заголовки", "binaryRawView": "Бинарные данные не могут быть отображены в режиме исходного текста.", - "errorStatusLabel": "Ошибка" + "errorStatusLabel": "Ошибка", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Новая вкладка" diff --git a/apps/web/messages/sv.json b/apps/web/messages/sv.json index b021a7de..209714dd 100644 --- a/apps/web/messages/sv.json +++ b/apps/web/messages/sv.json @@ -1528,7 +1528,9 @@ "previewTab": "Förhandsgranskning", "headersTab": "Rubriker", "binaryRawView": "Binärdata kan inte visas i råvy.", - "errorStatusLabel": "Fel" + "errorStatusLabel": "Fel", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Ny flik" diff --git a/apps/web/messages/tr.json b/apps/web/messages/tr.json index a7f0ea6e..f1dc99e2 100644 --- a/apps/web/messages/tr.json +++ b/apps/web/messages/tr.json @@ -1528,7 +1528,9 @@ "previewTab": "Önizleme", "headersTab": "Başlıklar", "binaryRawView": "İkili veriler ham görünümde gösterilemiyor.", - "errorStatusLabel": "Hata" + "errorStatusLabel": "Hata", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Yeni sekme" diff --git a/apps/web/messages/uk.json b/apps/web/messages/uk.json index 9617ba93..8fa2266f 100644 --- a/apps/web/messages/uk.json +++ b/apps/web/messages/uk.json @@ -1528,7 +1528,9 @@ "previewTab": "Перегляд", "headersTab": "Заголовки", "binaryRawView": "Двійкові дані не можна відобразити у вигляді тексту.", - "errorStatusLabel": "Помилка" + "errorStatusLabel": "Помилка", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Нова вкладка" diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index f70c457b..acf7b33a 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -1528,7 +1528,9 @@ "previewTab": "Xem trước", "headersTab": "Headers", "binaryRawView": "Dữ liệu nhị phân không thể hiển thị ở chế độ raw.", - "errorStatusLabel": "Lỗi" + "errorStatusLabel": "Lỗi", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "Tab mới" diff --git a/apps/web/messages/zh.json b/apps/web/messages/zh.json index 44f5b97b..43ece82b 100644 --- a/apps/web/messages/zh.json +++ b/apps/web/messages/zh.json @@ -1528,7 +1528,9 @@ "previewTab": "预览", "headersTab": "响应头", "binaryRawView": "二进制数据无法在原始视图中显示。", - "errorStatusLabel": "错误" + "errorStatusLabel": "错误", + "truncatedBanner": "Showing first {shownKb} KB of a large response.", + "downloadFullBody": "Download full body" }, "tabBar": { "addTabAria": "新标签" diff --git a/apps/web/src/components/api-client/__tests__/response-panel.large-body.test.tsx b/apps/web/src/components/api-client/__tests__/response-panel.large-body.test.tsx new file mode 100644 index 00000000..74cad89c --- /dev/null +++ b/apps/web/src/components/api-client/__tests__/response-panel.large-body.test.tsx @@ -0,0 +1,71 @@ +/** + * Tests for the large-body truncation logic in ResponsePanel. + * + * Note: @testing-library/react is not installed in this project, so we cannot + * render ResponsePanel and assert on the amber banner in the DOM. Instead, we + * test the pure `truncateBody` helper that encapsulates the slicing decision. + * This gives full coverage of the threshold logic without needing a browser + * environment. + * + * If @testing-library/react is ever added, replace the skipped test below with + * a real render assertion. + */ + +import { truncateBody } from "../truncate-body" + +const MAX_INLINE_BYTES = 2 * 1024 * 1024 // 2MB — mirrors the constant in response-panel.tsx + +describe("truncateBody", () => { + it("returns the full string when body is under the threshold", () => { + const body = "x".repeat(100) + const result = truncateBody(body, MAX_INLINE_BYTES) + expect(result.inline).toBe(body) + expect(result.truncated).toBe(false) + }) + + it("returns the full string when body is exactly at the threshold", () => { + const body = "x".repeat(MAX_INLINE_BYTES) + const result = truncateBody(body, MAX_INLINE_BYTES) + expect(result.inline).toBe(body) + expect(result.truncated).toBe(false) + }) + + it("truncates body that exceeds the threshold and sets truncated=true", () => { + const body = "x".repeat(MAX_INLINE_BYTES + 1) + const result = truncateBody(body, MAX_INLINE_BYTES) + expect(result.inline.length).toBe(MAX_INLINE_BYTES) + expect(result.truncated).toBe(true) + }) + + it("truncated slice is the first MAX_INLINE_BYTES characters", () => { + const body = "a".repeat(MAX_INLINE_BYTES) + "b".repeat(10) + const result = truncateBody(body, MAX_INLINE_BYTES) + expect(result.inline).toBe("a".repeat(MAX_INLINE_BYTES)) + expect(result.truncated).toBe(true) + }) + + it("returns empty string and truncated=false for empty body", () => { + const result = truncateBody("", MAX_INLINE_BYTES) + expect(result.inline).toBe("") + expect(result.truncated).toBe(false) + }) + + it("works with a custom max parameter", () => { + const body = "hello world" + const result = truncateBody(body, 5) + expect(result.inline).toBe("hello") + expect(result.truncated).toBe(true) + }) +}) + +it.skip("ResponsePanel shows amber truncation banner for >2MB body (requires @testing-library/react)", () => { + // TODO: once @testing-library/react is available, render with + // a >2MB body and assert the amber banner and "Download full body" button appear. + // + // import { render, screen } from "@testing-library/react" + // const huge = "x".repeat(2 * 1024 * 1024 + 1) + // render() + // expect(screen.getByText(/Showing first/)).toBeInTheDocument() + // expect(screen.getByRole("button", { name: /Download full body/ })).toBeInTheDocument() +}) diff --git a/apps/web/src/components/api-client/response-panel.tsx b/apps/web/src/components/api-client/response-panel.tsx index a07ccbdd..04860e79 100644 --- a/apps/web/src/components/api-client/response-panel.tsx +++ b/apps/web/src/components/api-client/response-panel.tsx @@ -11,6 +11,9 @@ import { CheckCircle2, AlertCircle, Copy, Download, Search, Info, Clock, Databas import { toast } from "sonner" import { cn } from "@/lib/utils" import type { editor } from "monaco-editor" +import { truncateBody } from "./truncate-body" + +const MAX_INLINE_BYTES = 2 * 1024 * 1024 // 2MB interface ParsedCookie { name: string @@ -100,6 +103,15 @@ export function ResponsePanel({ response }: ResponsePanelProps) { const isSuccess = response.status >= 200 && response.status < 300 const isError = response.status >= 400 + // Slice the body passed to Monaco so the editor never receives >2MB of text. + // Pretty-print (via the Worker) runs before this component renders, so slicing + // happens on the already-formatted string. The full body is preserved on + // response.body for download. + const { inline: inlineBody, truncated } = React.useMemo( + () => truncateBody(response.body ?? "", MAX_INLINE_BYTES), + [response.body] + ) + const contentType = normalizeContentType(response.headers) const isHtmlByHeader = contentType.includes("text/html") || @@ -251,13 +263,25 @@ export function ResponsePanel({ response }: ResponsePanelProps) {
- - { bodyEditorRef.current = ed }} - /> + + {truncated && !response.isBase64 && ( +
+ + {t("truncatedBanner", { shownKb: Math.round(MAX_INLINE_BYTES / 1024) })} + + +
+ )} +
+ { bodyEditorRef.current = ed }} + /> +
{hasPreview && ( diff --git a/apps/web/src/components/api-client/truncate-body.ts b/apps/web/src/components/api-client/truncate-body.ts new file mode 100644 index 00000000..ba43a8ed --- /dev/null +++ b/apps/web/src/components/api-client/truncate-body.ts @@ -0,0 +1,14 @@ +/** + * Pure helper for truncating large response bodies before passing them to Monaco. + * Extracted so tests can import it without pulling in React/next-intl. + */ + +export interface TruncateResult { + inline: string + truncated: boolean +} + +export function truncateBody(body: string, max: number): TruncateResult { + if (body.length <= max) return { inline: body, truncated: false } + return { inline: body.slice(0, max), truncated: true } +} From 17d49b1f280b72758dee30632005c352d5c77cc6 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Mon, 22 Jun 2026 23:14:51 +0530 Subject: [PATCH 06/19] feat(api-client): skeleton loaders for sidebar and response Adds CollectionsSidebarSkeleton and ResponsePanelSkeleton to improve perceived performance during data loading. Skeleton state is shown when isLoading is true and content array is empty, preventing layout shift while maintaining A11y with role="status" and aria-busy="true". Co-Authored-By: Claude Opus 4.7 --- .../collections/collections-sidebar.tsx | 9 +++-- .../components/api-client/response-panel.tsx | 7 +++- .../src/components/api-client/skeletons.tsx | 33 +++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/api-client/skeletons.tsx diff --git a/apps/web/src/components/api-client/collections/collections-sidebar.tsx b/apps/web/src/components/api-client/collections/collections-sidebar.tsx index 31fdf362..4c4522a6 100644 --- a/apps/web/src/components/api-client/collections/collections-sidebar.tsx +++ b/apps/web/src/components/api-client/collections/collections-sidebar.tsx @@ -6,7 +6,7 @@ import { Checkbox } from "@/components/ui/checkbox" import { ScrollArea } from "@/components/ui/scroll-area" import { Collection, CollectionRequest } from "../types" import { CollectionItem } from "./collection-item" -import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X, Loader2 } from "lucide-react" +import { FolderPlus, Trash2, Pencil, MoreHorizontal, Search, X } from "lucide-react" import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" import { VirtualHistoryList } from "./virtual-history-list" import { cn } from "@/lib/utils" @@ -31,6 +31,7 @@ import { useTranslations } from "next-intl" import { getApiClientRequestDisplayName } from "../display-name" import { useCollectionsState, useCollectionsActions } from "../context/collections-context" import { useHistoryState, useHistoryActions } from "../context/history-context" +import { CollectionsSidebarSkeleton } from "../skeletons" interface CollectionsSidebarProps { onLoadRequest: (request: CollectionRequest) => void @@ -189,10 +190,8 @@ export function CollectionsSidebar({
- {isLoading ? ( -
- -
+ {isLoading && collections.length === 0 ? ( + ) : collections.length === 0 ? (
diff --git a/apps/web/src/components/api-client/response-panel.tsx b/apps/web/src/components/api-client/response-panel.tsx index 04860e79..f19cae59 100644 --- a/apps/web/src/components/api-client/response-panel.tsx +++ b/apps/web/src/components/api-client/response-panel.tsx @@ -12,6 +12,7 @@ import { toast } from "sonner" import { cn } from "@/lib/utils" import type { editor } from "monaco-editor" import { truncateBody } from "./truncate-body" +import { ResponsePanelSkeleton } from "./skeletons" const MAX_INLINE_BYTES = 2 * 1024 * 1024 // 2MB @@ -76,9 +77,10 @@ function bodyLooksLikeHtml(body: string): boolean { interface ResponsePanelProps { response: ApiResponse | null + isLoading?: boolean } -export function ResponsePanel({ response }: ResponsePanelProps) { +export function ResponsePanel({ response, isLoading }: ResponsePanelProps) { const t = useTranslations("ApiClient.responsePanel") const tApi = useTranslations("ApiClient") const bodyEditorRef = React.useRef(null) @@ -86,6 +88,9 @@ export function ResponsePanel({ response }: ResponsePanelProps) { const handleOpenSearch = () => { bodyEditorRef.current?.getAction("actions.find")?.run() } + if (isLoading && !response) { + return + } if (!response) { return (
diff --git a/apps/web/src/components/api-client/skeletons.tsx b/apps/web/src/components/api-client/skeletons.tsx new file mode 100644 index 00000000..3ed7e5a6 --- /dev/null +++ b/apps/web/src/components/api-client/skeletons.tsx @@ -0,0 +1,33 @@ +"use client" + +import { Skeleton } from "@/components/ui/skeleton" + +export function CollectionsSidebarSkeleton() { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) +} + +export function HistoryListSkeleton() { + return ( +
+ {Array.from({ length: 8 }).map((_, i) => ( + + ))} +
+ ) +} + +export function ResponsePanelSkeleton() { + return ( +
+ + + +
+ ) +} From 3e20cebaa21fec961615b1a30a4ab1e3b2069925 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Mon, 22 Jun 2026 23:25:17 +0530 Subject: [PATCH 07/19] perf(api-client): code-split CodeGenerator and EnvironmentManager Replaces static imports with next/dynamic (ssr:false) for both CodeGenerator and EnvironmentManager, and conditionally mounts each component only when its dialog open state is true so the chunks are fetched on first open rather than on initial page load. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/api-client/api-client.tsx | 66 ++++++++++++++++--- .../components/api-client/code-generator.tsx | 16 ++--- .../api-client/environment-manager.tsx | 66 ++++++------------- 3 files changed, 84 insertions(+), 64 deletions(-) diff --git a/apps/web/src/components/api-client/api-client.tsx b/apps/web/src/components/api-client/api-client.tsx index 67fe4e61..f5e48a33 100644 --- a/apps/web/src/components/api-client/api-client.tsx +++ b/apps/web/src/components/api-client/api-client.tsx @@ -10,8 +10,7 @@ import { TabBar } from "./tab-bar" import { ImportCurlDialog } from "./import-curl-dialog" import { parseCurlCommand } from "@/utils/curl-parser" import { CollectionsSidebar } from "./collections/collections-sidebar" -import { EnvironmentManager } from "./environment-manager" -import { CodeGenerator } from "./code-generator" +import dynamic from "next/dynamic" import { RequestMethod, ApiRequestState, @@ -25,7 +24,8 @@ import { toast } from "sonner" import { useIsMobile } from "@/components/hooks/use-mobile" import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { Button } from "@/components/ui/button" -import { FolderOpen, PanelRight } from "lucide-react" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { FolderOpen, PanelRight, Settings, Code2 } from "lucide-react" import { cn } from "@/lib/utils" import { ensureHttpScheme } from "@/lib/url-normalize" import { useJsonFormatter } from "./workers/use-json-formatter" @@ -34,6 +34,15 @@ import { useCollectionsState, useCollectionsActions } from "./context/collection import { useEnvironmentsState, useEnvironmentsActions } from "./context/environments-context" import { useHistoryState, useHistoryActions } from "./context/history-context" +const EnvironmentManager = dynamic( + () => import("./environment-manager").then(m => ({ default: m.EnvironmentManager })), + { ssr: false, loading: () => null } +) +const CodeGenerator = dynamic( + () => import("./code-generator").then(m => ({ default: m.CodeGenerator })), + { ssr: false, loading: () => null } +) + /** `new URL()` requires a scheme; host-only URLs (e.g. `api.example.com/v1`) are common in API clients. */ function buildRequestUrl(raw: string): URL { const trimmed = raw.trim() @@ -63,12 +72,14 @@ function ApiClientInner() { const { history } = useHistoryState() const { addHistoryItem } = useHistoryActions() const { environments, activeEnvId, activeEnvironmentVariables } = useEnvironmentsState() - const { substituteVariables } = useEnvironmentsActions() + const { substituteVariables, setActiveEnvId } = useEnvironmentsActions() const isMobile = useIsMobile() const [collectionsOpen, setCollectionsOpen] = React.useState(false) const [sidebarOpen, setSidebarOpen] = React.useState(true) const [mobilePanel, setMobilePanel] = React.useState<'request' | 'response'>('request') + const [envMgrOpen, setEnvMgrOpen] = React.useState(false) + const [codeGenOpen, setCodeGenOpen] = React.useState(false) const urlHistory = React.useMemo(() => { const seen = new Set() @@ -432,9 +443,48 @@ function ApiClientInner() { )}
- + + + {envMgrOpen && ( + + )}
- + + {codeGenOpen && ( + + )} {!isMobile && ( <> @@ -533,7 +583,7 @@ function ApiClientInner() {
) : (
- +
)}
@@ -575,7 +625,7 @@ function ApiClientInner() {
- +
diff --git a/apps/web/src/components/api-client/code-generator.tsx b/apps/web/src/components/api-client/code-generator.tsx index 2962e557..631f0952 100644 --- a/apps/web/src/components/api-client/code-generator.tsx +++ b/apps/web/src/components/api-client/code-generator.tsx @@ -6,9 +6,7 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" import { Select, SelectContent, @@ -16,8 +14,9 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Button } from "@/components/ui/button" import { ScrollArea } from "@/components/ui/scroll-area" -import { IconCode, IconCopy, IconCheck } from "@tabler/icons-react" +import { IconCopy, IconCheck } from "@tabler/icons-react" import { ApiRequestState } from "./types" import { generateCode, CodeLanguage } from "./generate-code" import { toast } from "sonner" @@ -25,9 +24,11 @@ import { useTranslations } from "next-intl" interface CodeGeneratorProps { request: ApiRequestState + open: boolean + onOpenChange: (open: boolean) => void } -export function CodeGenerator({ request }: CodeGeneratorProps) { +export function CodeGenerator({ request, open, onOpenChange }: CodeGeneratorProps) { const t = useTranslations("ApiClient.codeGenerator") const tApi = useTranslations("ApiClient") const [language, setLanguage] = React.useState("curl") @@ -52,12 +53,7 @@ export function CodeGenerator({ request }: CodeGeneratorProps) { } return ( - - - - + {t("dialogTitle")} diff --git a/apps/web/src/components/api-client/environment-manager.tsx b/apps/web/src/components/api-client/environment-manager.tsx index d73c536e..c85b15b6 100644 --- a/apps/web/src/components/api-client/environment-manager.tsx +++ b/apps/web/src/components/api-client/environment-manager.tsx @@ -8,26 +8,22 @@ import { DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@/components/ui/dialog" -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select" import { ScrollArea } from "@/components/ui/scroll-area" -import { IconPlus, IconTrash, IconSettings, IconEdit } from "@tabler/icons-react" +import { IconPlus, IconTrash, IconEdit } from "@tabler/icons-react" import { EnvironmentVariable } from "./use-environments" import { useTranslations } from "next-intl" import { useEnvironmentsState, useEnvironmentsActions } from "./context/environments-context" -export function EnvironmentManager() { - const { environments, activeEnvId } = useEnvironmentsState() - const { setActiveEnvId, addEnvironment, updateEnvironment, deleteEnvironment } = useEnvironmentsActions() +interface EnvironmentManagerProps { + open: boolean + onOpenChange: (open: boolean) => void +} + +export function EnvironmentManager({ open, onOpenChange }: EnvironmentManagerProps) { + const { environments } = useEnvironmentsState() + const { addEnvironment, updateEnvironment, deleteEnvironment } = useEnvironmentsActions() const t = useTranslations("ApiClient.environmentManager") - const [isOpen, setIsOpen] = React.useState(false) const [selectedEnvId, setSelectedEnvId] = React.useState(null) const [newEnvName, setNewEnvName] = React.useState("") const [editingEnvId, setEditingEnvId] = React.useState(null) @@ -35,10 +31,10 @@ export function EnvironmentManager() { // Select the first environment by default when dialog opens React.useEffect(() => { - if (isOpen && !selectedEnvId && environments.length > 0) { + if (open && !selectedEnvId && environments.length > 0) { setSelectedEnvId(environments[0].id) } - }, [isOpen, environments, selectedEnvId]) + }, [open, environments, selectedEnvId]) const selectedEnv = environments.find(e => e.id === selectedEnvId) @@ -70,33 +66,12 @@ export function EnvironmentManager() { } return ( -
- - - - - - - - - {t("manageDialogTitle")} - -
+ + + + {t("manageDialogTitle")} + +
{/* Sidebar */}
@@ -237,9 +212,8 @@ export function EnvironmentManager() {
)}
-
-
-
-
+
+
+
) } From 833d904b5da38f77647c37d8b7d643041c31c082 Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Mon, 22 Jun 2026 23:29:31 +0530 Subject: [PATCH 08/19] style(api-client): keep Tabler icons + consistent button size for split-out triggers - Restore IconCode from @tabler/icons-react for CodeGenerator button - Restore IconSettings from @tabler/icons-react for EnvironmentManager button - Add h-8 w-8 className to CodeGenerator button for consistent sizing Co-Authored-By: Claude Opus 4.7 --- apps/web/src/components/api-client/api-client.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/api-client/api-client.tsx b/apps/web/src/components/api-client/api-client.tsx index f5e48a33..0cbc0465 100644 --- a/apps/web/src/components/api-client/api-client.tsx +++ b/apps/web/src/components/api-client/api-client.tsx @@ -25,7 +25,8 @@ import { useIsMobile } from "@/components/hooks/use-mobile" import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet" import { Button } from "@/components/ui/button" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { FolderOpen, PanelRight, Settings, Code2 } from "lucide-react" +import { FolderOpen, PanelRight } from "lucide-react" +import { IconCode, IconSettings } from "@tabler/icons-react" import { cn } from "@/lib/utils" import { ensureHttpScheme } from "@/lib/url-normalize" import { useJsonFormatter } from "./workers/use-json-formatter" @@ -464,7 +465,7 @@ function ApiClientInner() { title={t("environmentManager.manageDialogTitle")} onClick={() => setEnvMgrOpen(true)} > - + {envMgrOpen && ( @@ -473,10 +474,11 @@ function ApiClientInner() { {codeGenOpen && ( Date: Mon, 22 Jun 2026 23:34:19 +0530 Subject: [PATCH 09/19] perf(api-client): lazy-load Monaco editor and memoize wrapper Replace static CodeEditor imports in request-tabs and response-panel with next/dynamic (ssr: false) so Monaco is excluded from the initial route bundle. Wrap each dynamic result in React.memo and stabilise the onChange handler in request-tabs with useCallback to prevent unnecessary Monaco remounts on parent re-renders. Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/api-client/request-tabs.tsx | 18 +++++++++++++++--- .../components/api-client/response-panel.tsx | 10 ++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/api-client/request-tabs.tsx b/apps/web/src/components/api-client/request-tabs.tsx index 790e6cd3..0d8f13ee 100644 --- a/apps/web/src/components/api-client/request-tabs.tsx +++ b/apps/web/src/components/api-client/request-tabs.tsx @@ -4,7 +4,13 @@ import * as React from "react" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { KeyValueEditor } from "./key-value-editor" import { KeyValueItem, RequestBody, RequestAuth, RequestFormDataItem } from "./types" -import CodeEditor from "@/components/ui/code-editor" +import dynamic from "next/dynamic" + +const CodeEditor = dynamic( + () => import("@/components/ui/code-editor"), + { ssr: false, loading: () =>
} +) +const MemoCodeEditor = React.memo(CodeEditor) import { Label } from "@/components/ui/label" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" @@ -101,6 +107,12 @@ export function RequestTabs({ }) } + const handleBodyContentChange = React.useCallback( + (v: string) => updateBody({ content: v }), + // eslint-disable-next-line react-hooks/exhaustive-deps + [normalizedBody] + ) + const handleFileSelect = async (id: string, file: File | null) => { if (!file) return const buffer = await file.arrayBuffer() @@ -187,9 +199,9 @@ export function RequestTabs({ catch (e) { return
{(e as Error).message}
} })()}
- updateBody({ content: v })} + onChange={handleBodyContentChange} language={normalizedBody.type === "json" ? "json" : "plaintext"} />
diff --git a/apps/web/src/components/api-client/response-panel.tsx b/apps/web/src/components/api-client/response-panel.tsx index f19cae59..1ec8ee66 100644 --- a/apps/web/src/components/api-client/response-panel.tsx +++ b/apps/web/src/components/api-client/response-panel.tsx @@ -2,7 +2,13 @@ import * as React from "react" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" -import CodeEditor from "@/components/ui/code-editor" +import dynamic from "next/dynamic" + +const CodeEditor = dynamic( + () => import("@/components/ui/code-editor"), + { ssr: false, loading: () =>
} +) +const MemoCodeEditor = React.memo(CodeEditor) import { ApiResponse, API_CLIENT_ERROR_STATUS_TEXT } from "./types" import { useTranslations } from "next-intl" import { Button } from "@/components/ui/button" @@ -280,7 +286,7 @@ export function ResponsePanel({ response, isLoading }: ResponsePanelProps) {
)}
- Date: Mon, 22 Jun 2026 23:39:19 +0530 Subject: [PATCH 10/19] perf(api-client): replace framer-motion tab animations with CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove AnimatePresence + motion.div from tab-bar; replace the two layoutId indicators (background highlight + underline) with a single pair of absolute elements whose left/width are measured via useLayoutEffect and animated with Tailwind transition-all duration-200. Drag-to-reorder continues to use native HTML5 drag. Tab insert/remove is now immediate (no enter/exit animation — acceptable per brief). Co-Authored-By: Claude Sonnet 4.6 --- .../web/src/components/api-client/tab-bar.tsx | 201 ++++++++++-------- 1 file changed, 110 insertions(+), 91 deletions(-) diff --git a/apps/web/src/components/api-client/tab-bar.tsx b/apps/web/src/components/api-client/tab-bar.tsx index b3d12b7f..5f0602fc 100644 --- a/apps/web/src/components/api-client/tab-bar.tsx +++ b/apps/web/src/components/api-client/tab-bar.tsx @@ -8,7 +8,6 @@ import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" import { ApiRequestState } from "./types" import { getApiClientRequestDisplayName } from "./display-name" import { useTranslations } from "next-intl" -import { motion, AnimatePresence } from "framer-motion" interface TabBarProps { tabs: ApiRequestState[] @@ -52,6 +51,27 @@ export function TabBar({ const [draggedId, setDraggedId] = React.useState(null) const [dragOverId, setDragOverId] = React.useState(null) + // Refs for measuring active-tab indicator position + const tabListRef = React.useRef(null) + const tabRefs = React.useRef>({}) + const [indicatorStyle, setIndicatorStyle] = React.useState<{ left: number; width: number } | null>(null) + + // Measure the active tab and update the indicator synchronously before paint + React.useLayoutEffect(() => { + const activeEl = tabRefs.current[activeTabId] + const listEl = tabListRef.current + if (!activeEl || !listEl) { + setIndicatorStyle(null) + return + } + const tabRect = activeEl.getBoundingClientRect() + const listRect = listEl.getBoundingClientRect() + setIndicatorStyle({ + left: tabRect.left - listRect.left, + width: tabRect.width, + }) + }, [activeTabId, tabs]) + const handleDoubleClick = (tab: ApiRequestState) => { if (!onTabRename) return setEditingTabId(tab.id) @@ -101,105 +121,104 @@ export function TabBar({ return (
-
- - {tabs.map((tab) => ( - handleDragStart(e as unknown as React.DragEvent, tab.id)} - onDragOver={(e) => handleDragOver(e as unknown as React.DragEvent, tab.id)} - onDrop={(e) => handleDrop(e as unknown as React.DragEvent, tab.id)} - onDragEnd={handleDragEnd} - className={cn( - "group relative flex items-center gap-2 px-3 py-1.5 text-sm font-medium cursor-pointer select-none min-w-[140px] max-w-[220px] h-[34px] rounded-md transition-colors m-0.5", - activeTabId === tab.id - ? "bg-background text-foreground shadow-sm" - : "text-muted-foreground hover:bg-muted/50 hover:text-foreground", - dragOverId === tab.id && draggedId !== tab.id && "ring-2 ring-primary/50 ring-inset", - draggedId === tab.id && "opacity-50" - )} - onClick={() => { - if (editingTabId !== tab.id) onTabChange(tab.id) - }} - onDoubleClick={() => handleDoubleClick(tab)} - > - {activeTabId === tab.id && ( - - )} - - - {tab.method} + {/* relative container so the sliding indicator is positioned within it */} +
+ {/* Sliding active-tab background + underline indicator */} + {indicatorStyle && ( +
From a3175298e78dcd7f6acfc048871a68732c4aca0c Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Mon, 22 Jun 2026 23:50:35 +0530 Subject: [PATCH 11/19] feat(api-client): backend collection delta endpoint + client optimistic updates Adds POST /api-client/collections/{id}/items:apply-delta that walks the items tree in-memory for add/update/delete/move ops and persists atomically with a single update_one; invalidates via bump_version(ns="api_client"). Frontend useCollections switches saveRequest/deleteItem/addFolder/renameFolder to the delta endpoint with optimistic updates + server-reconcile rollback. Co-Authored-By: Claude Opus 4.7 --- apps/backend/app/api/routes/api_client/api.py | 2 + .../routes/api_client/collections_delta.py | 262 ++++++++++++++++++ .../app/api/routes/api_client/schema.py | 43 ++- .../tests/api/routes/api_client/__init__.py | 0 .../api_client/test_collections_delta.py | 207 ++++++++++++++ .../api-client/collections/use-collections.ts | 163 +++++++---- 6 files changed, 623 insertions(+), 54 deletions(-) create mode 100644 apps/backend/app/api/routes/api_client/collections_delta.py create mode 100644 apps/backend/tests/api/routes/api_client/__init__.py create mode 100644 apps/backend/tests/api/routes/api_client/test_collections_delta.py diff --git a/apps/backend/app/api/routes/api_client/api.py b/apps/backend/app/api/routes/api_client/api.py index 3684aa21..d6ce504a 100644 --- a/apps/backend/app/api/routes/api_client/api.py +++ b/apps/backend/app/api/routes/api_client/api.py @@ -13,9 +13,11 @@ ApiClientHistoryOut, HISTORY_MAX_ITEMS, ) +from app.api.routes.api_client import collections_delta router = APIRouter(prefix="/api-client", tags=["api-client"]) +router.include_router(collections_delta.router) @router.get("/collections", response_model=list[ApiClientCollectionOut], summary="List API client collections") diff --git a/apps/backend/app/api/routes/api_client/collections_delta.py b/apps/backend/app/api/routes/api_client/collections_delta.py new file mode 100644 index 00000000..78a6a6ea --- /dev/null +++ b/apps/backend/app/api/routes/api_client/collections_delta.py @@ -0,0 +1,262 @@ +"""Service + route for the collection-items delta endpoint. + +POST /api-client/collections/{collection_id}/items:apply-delta + +Applies a list of ops (add / update / delete / move) to a collection's items +tree in-memory, then persists the result in a single atomic update_one call. +Cache is invalidated via bump_version exactly as the existing patch_collection +service does. +""" +from __future__ import annotations + +from typing import Any + +from bson import ObjectId +from bson.errors import InvalidId +from fastapi import APIRouter, Depends, HTTPException, status +from pymongo import ReturnDocument +from pymongo.errors import PyMongoError + +from app.api.routes.auth.services import get_current_uid +from app.api.routes.api_client.schema import ( + AddItemOp, + ApplyDeltaRequest, + ApplyDeltaResponse, + ApiClientCollectionOut, + DeleteItemOp, + MoveItemOp, + Op, + UpdateItemOp, +) +from app.core.cache import bump_version +from app.database import db_manager +from app.utils.collection_name import API_CLIENT_COLLECTIONS + +router = APIRouter() + + +def _parse_oid(raw: str, *, kind: str) -> ObjectId: + try: + return ObjectId(raw) + except InvalidId as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid {kind} id.", + ) from exc + + +def _collection_to_out(doc: dict[str, Any]) -> ApiClientCollectionOut: + oid = doc.get("_id") + return ApiClientCollectionOut( + id=str(oid) if oid is not None else "", + name=doc.get("name", ""), + items=list(doc.get("items") or []), + ) + + +# ── In-memory tree walkers ──────────────────────────────────────────────────── + +def _apply_add( + items: list[dict[str, Any]], + parent_id: str, + collection_id: str, + new_item: dict[str, Any], + position: int | None, +) -> list[dict[str, Any]]: + """Insert new_item under parent_id. parent_id may be the collection root.""" + if parent_id == collection_id: + # Insert at root level + if position is None or position >= len(items): + return items + [new_item] + result = list(items) + result.insert(position, new_item) + return result + + return _add_in_children(items, parent_id, new_item, position) + + +def _add_in_children( + items: list[dict[str, Any]], + parent_id: str, + new_item: dict[str, Any], + position: int | None, +) -> list[dict[str, Any]]: + result = [] + for item in items: + if item.get("id") == parent_id and item.get("type") == "folder": + children = list(item.get("items") or []) + if position is None or position >= len(children): + children = children + [new_item] + else: + children.insert(position, new_item) + result.append({**item, "items": children}) + elif item.get("type") == "folder": + result.append({**item, "items": _add_in_children(item.get("items") or [], parent_id, new_item, position)}) + else: + result.append(item) + return result + + +def _apply_update( + items: list[dict[str, Any]], + item_id: str, + patch: dict[str, Any], +) -> tuple[list[dict[str, Any]], bool]: + """Recursively apply patch to the item with item_id. Returns (new_items, found).""" + result = [] + found = False + for item in items: + if item.get("id") == item_id: + result.append({**item, **patch}) + found = True + elif item.get("type") == "folder": + new_children, child_found = _apply_update(item.get("items") or [], item_id, patch) + result.append({**item, "items": new_children}) + if child_found: + found = True + else: + result.append(item) + return result, found + + +def _apply_delete( + items: list[dict[str, Any]], + item_id: str, +) -> tuple[list[dict[str, Any]], bool]: + """Recursively remove item_id. Returns (new_items, found).""" + new_items = [] + found = False + for item in items: + if item.get("id") == item_id: + found = True + # skip (delete) + elif item.get("type") == "folder": + new_children, child_found = _apply_delete(item.get("items") or [], item_id) + new_items.append({**item, "items": new_children}) + if child_found: + found = True + else: + new_items.append(item) + return new_items, found + + +def _extract_item( + items: list[dict[str, Any]], + item_id: str, +) -> tuple[list[dict[str, Any]], dict[str, Any] | None]: + """Remove and return item_id from the tree.""" + new_items = [] + extracted: dict[str, Any] | None = None + for item in items: + if item.get("id") == item_id: + extracted = item + elif item.get("type") == "folder": + new_children, child_extracted = _extract_item(item.get("items") or [], item_id) + new_items.append({**item, "items": new_children}) + if child_extracted is not None: + extracted = child_extracted + else: + new_items.append(item) + return new_items, extracted + + +def _apply_move( + items: list[dict[str, Any]], + item_id: str, + new_parent_id: str, + new_index: int, + collection_id: str, +) -> tuple[list[dict[str, Any]], bool]: + """Move item_id to new_parent_id at new_index. Returns (new_items, success).""" + # Step 1: extract the item + items_without, target = _extract_item(items, item_id) + if target is None: + return items, False + + # Step 2: insert at new parent + new_items = _apply_add(items_without, new_parent_id, collection_id, target, new_index) + return new_items, True + + +# ── Service ─────────────────────────────────────────────────────────────────── + +async def apply_collection_delta( + uid: str, + collection_id: str, + ops: list[Op], +) -> ApiClientCollectionOut: + oid = _parse_oid(collection_id, kind="collection") + + # Fetch + ownership check BEFORE any mutation + doc = await db_manager.find_one(API_CLIENT_COLLECTIONS, {"_id": oid, "created_by": uid}) + if not doc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.") + + items: list[dict[str, Any]] = list(doc.get("items") or []) + + # Apply all ops in-memory (atomic within a single request) + for op in ops: + if isinstance(op, AddItemOp): + items = _apply_add(items, op.parent_id, collection_id, op.item, op.position) + + elif isinstance(op, UpdateItemOp): + items, found = _apply_update(items, op.item_id, op.patch) + if not found: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item {op.item_id!r} not found.", + ) + + elif isinstance(op, DeleteItemOp): + items, found = _apply_delete(items, op.item_id) + if not found: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item {op.item_id!r} not found.", + ) + + elif isinstance(op, MoveItemOp): + items, ok = _apply_move(items, op.item_id, op.new_parent_id, op.new_index, collection_id) + if not ok: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Item {op.item_id!r} not found.", + ) + + # Single atomic update + try: + updated_doc = await db_manager.find_one_and_update( + API_CLIENT_COLLECTIONS, + {"_id": oid, "created_by": uid}, + {"$set": {"items": items}}, + return_document=ReturnDocument.AFTER, + ) + except PyMongoError as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to update collection.", + ) from exc + + if not updated_doc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Collection not found.") + + # Invalidate cache (same namespace as patch_collection / delete_collection) + await bump_version(ns="api_client", uid=uid) + + return _collection_to_out(updated_doc) + + +# ── Route ───────────────────────────────────────────────────────────────────── + +@router.post( + "/collections/{collection_id}/items:apply-delta", + response_model=ApplyDeltaResponse, + summary="Apply delta ops to a collection's items tree", +) +async def apply_delta( + collection_id: str, + body: ApplyDeltaRequest, + uid: str = Depends(get_current_uid), +) -> ApplyDeltaResponse: + collection = await apply_collection_delta(uid, collection_id, body.ops) + return ApplyDeltaResponse(collection=collection) diff --git a/apps/backend/app/api/routes/api_client/schema.py b/apps/backend/app/api/routes/api_client/schema.py index d609d84c..58f383a1 100644 --- a/apps/backend/app/api/routes/api_client/schema.py +++ b/apps/backend/app/api/routes/api_client/schema.py @@ -1,4 +1,4 @@ -from typing import Any, Optional +from typing import Annotated, Any, Literal, Optional, Union from pydantic import BaseModel, ConfigDict, Field @@ -50,6 +50,47 @@ class ApiClientEnvironmentOut(ApiClientEnvironmentBase): HISTORY_MAX_ITEMS = 100 +# ── Delta ops ──────────────────────────────────────────────────────────────── + +class AddItemOp(BaseModel): + type: Literal["add"] + parent_id: str + item: dict[str, Any] + position: Optional[int] = None + + +class UpdateItemOp(BaseModel): + type: Literal["update"] + item_id: str + patch: dict[str, Any] + + +class DeleteItemOp(BaseModel): + type: Literal["delete"] + item_id: str + + +class MoveItemOp(BaseModel): + type: Literal["move"] + item_id: str + new_parent_id: str + new_index: int + + +Op = Annotated[ + Union[AddItemOp, UpdateItemOp, DeleteItemOp, MoveItemOp], + Field(discriminator="type"), +] + + +class ApplyDeltaRequest(BaseModel): + ops: list[Op] + + +class ApplyDeltaResponse(BaseModel): + collection: ApiClientCollectionOut + + class ApiClientHistoryCreate(BaseModel): """One sent request snapshot (matches web HistoryRequest minus id).""" diff --git a/apps/backend/tests/api/routes/api_client/__init__.py b/apps/backend/tests/api/routes/api_client/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/backend/tests/api/routes/api_client/test_collections_delta.py b/apps/backend/tests/api/routes/api_client/test_collections_delta.py new file mode 100644 index 00000000..203921f9 --- /dev/null +++ b/apps/backend/tests/api/routes/api_client/test_collections_delta.py @@ -0,0 +1,207 @@ +"""Tests for POST /api-client/collections/{id}/items:apply-delta. + +Auth pattern: override get_current_uid via FastAPI dependency_overrides +(same pattern used in test_auth_memo.py). MongoDB is monkeypatched in-process +so no real DB is needed. +""" +from __future__ import annotations + +import copy +from typing import Any +from unittest.mock import AsyncMock, MagicMock + +import pytest +from bson import ObjectId +from httpx import ASGITransport, AsyncClient + +from app.api.routes.auth.services import get_current_uid +from app.main import app + +# Fixed IDs used throughout +OWNER_UID = "test-owner-uid" +OTHER_UID = "other-uid" +COLLECTION_OID = ObjectId() +COLLECTION_ID = str(COLLECTION_OID) + +FOLDER_ID = "folder-1" +REQUEST_ID = "request-1" + +BASE_COLLECTION: dict[str, Any] = { + "_id": COLLECTION_OID, + "created_by": OWNER_UID, + "name": "My Collection", + "items": [ + { + "id": FOLDER_ID, + "name": "Folder 1", + "type": "folder", + "items": [ + { + "id": REQUEST_ID, + "name": "Existing Request", + "type": "request", + "method": "GET", + "url": "https://existing", + "params": [], + "headers": [], + "body": {"type": "none", "content": "", "formData": [], "urlEncoded": []}, + "auth": {"type": "none"}, + } + ], + "isOpen": True, + } + ], +} + + +def _make_client(uid: str) -> AsyncClient: + """Return an AsyncClient that authenticates as uid.""" + app.dependency_overrides[get_current_uid] = lambda: uid + return AsyncClient(transport=ASGITransport(app=app), base_url="http://test") + + +def _mock_find_one(collection_doc: dict | None): + """Monkeypatch db_manager.find_one to return collection_doc.""" + async def _find_one(collection_name, query, projection=None): + if collection_doc is None: + return None + # Honour ownership filter + if query.get("created_by") != collection_doc.get("created_by"): + return None + return copy.deepcopy(collection_doc) + + return _find_one + + +def _mock_find_one_and_update(updated_doc: dict | None): + """Monkeypatch db_manager.find_one_and_update to return updated_doc.""" + async def _find_one_and_update(collection_name, query, update_query, return_document=False): + if updated_doc is None: + return None + return copy.deepcopy(updated_doc) + + return _find_one_and_update + + +@pytest.fixture(autouse=True) +def cleanup_overrides(): + yield + app.dependency_overrides.pop(get_current_uid, None) + + +# ── Test: add op inserts a new item under parent_id ────────────────────────── + +@pytest.mark.asyncio +async def test_apply_delta_adds_item(monkeypatch): + new_item = { + "id": "new-req-1", + "name": "New Request", + "type": "request", + "method": "POST", + "url": "https://new", + "params": [], + "headers": [], + "body": {"type": "none", "content": "", "formData": [], "urlEncoded": []}, + "auth": {"type": "none"}, + } + + # After the update, the collection has the new item under the folder + updated_collection = copy.deepcopy(BASE_COLLECTION) + updated_collection["items"][0]["items"].append(new_item) + + monkeypatch.setattr("app.api.routes.api_client.collections_delta.db_manager.find_one", _mock_find_one(BASE_COLLECTION)) + monkeypatch.setattr( + "app.api.routes.api_client.collections_delta.db_manager.find_one_and_update", + _mock_find_one_and_update(updated_collection), + ) + monkeypatch.setattr("app.api.routes.api_client.collections_delta.bump_version", AsyncMock()) + + async with _make_client(OWNER_UID) as ac: + resp = await ac.post( + f"/api/v1/api-client/collections/{COLLECTION_ID}/items:apply-delta", + json={"ops": [{"type": "add", "parent_id": FOLDER_ID, "item": new_item}]}, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + assert "collection" in body + folder = next(i for i in body["collection"]["items"] if i["id"] == FOLDER_ID) + item_ids = {it["id"] for it in folder["items"]} + assert "new-req-1" in item_ids + + +# ── Test: delete op removes an item ────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_apply_delta_deletes_item(monkeypatch): + # After delete, folder has no children + updated_collection = copy.deepcopy(BASE_COLLECTION) + updated_collection["items"][0]["items"] = [] + + monkeypatch.setattr("app.api.routes.api_client.collections_delta.db_manager.find_one", _mock_find_one(BASE_COLLECTION)) + monkeypatch.setattr( + "app.api.routes.api_client.collections_delta.db_manager.find_one_and_update", + _mock_find_one_and_update(updated_collection), + ) + monkeypatch.setattr("app.api.routes.api_client.collections_delta.bump_version", AsyncMock()) + + async with _make_client(OWNER_UID) as ac: + resp = await ac.post( + f"/api/v1/api-client/collections/{COLLECTION_ID}/items:apply-delta", + json={"ops": [{"type": "delete", "item_id": REQUEST_ID}]}, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + folder = next(i for i in body["collection"]["items"] if i["id"] == FOLDER_ID) + assert all(i["id"] != REQUEST_ID for i in folder["items"]) + + +# ── Test: update op patches fields ─────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_apply_delta_updates_item(monkeypatch): + updated_collection = copy.deepcopy(BASE_COLLECTION) + # Patch the nested request's name + updated_collection["items"][0]["items"][0]["name"] = "Renamed Request" + updated_collection["items"][0]["items"][0]["url"] = "https://updated" + + monkeypatch.setattr("app.api.routes.api_client.collections_delta.db_manager.find_one", _mock_find_one(BASE_COLLECTION)) + monkeypatch.setattr( + "app.api.routes.api_client.collections_delta.db_manager.find_one_and_update", + _mock_find_one_and_update(updated_collection), + ) + monkeypatch.setattr("app.api.routes.api_client.collections_delta.bump_version", AsyncMock()) + + async with _make_client(OWNER_UID) as ac: + resp = await ac.post( + f"/api/v1/api-client/collections/{COLLECTION_ID}/items:apply-delta", + json={"ops": [{"type": "update", "item_id": REQUEST_ID, "patch": {"name": "Renamed Request", "url": "https://updated"}}]}, + ) + + assert resp.status_code == 200, resp.text + body = resp.json() + folder = next(i for i in body["collection"]["items"] if i["id"] == FOLDER_ID) + req = next(i for i in folder["items"] if i["id"] == REQUEST_ID) + assert req["name"] == "Renamed Request" + assert req["url"] == "https://updated" + + +# ── Test: unauthorized user (different uid) gets 404 ───────────────────────── + +@pytest.mark.asyncio +async def test_apply_delta_wrong_uid_gets_404(monkeypatch): + # find_one returns None for OTHER_UID (ownership filter fails) + monkeypatch.setattr("app.api.routes.api_client.collections_delta.db_manager.find_one", _mock_find_one(BASE_COLLECTION)) + # bump_version should NOT be called + bump_mock = AsyncMock() + monkeypatch.setattr("app.api.routes.api_client.collections_delta.bump_version", bump_mock) + + async with _make_client(OTHER_UID) as ac: + resp = await ac.post( + f"/api/v1/api-client/collections/{COLLECTION_ID}/items:apply-delta", + json={"ops": [{"type": "delete", "item_id": REQUEST_ID}]}, + ) + + assert resp.status_code == 404 + bump_mock.assert_not_called() diff --git a/apps/web/src/components/api-client/collections/use-collections.ts b/apps/web/src/components/api-client/collections/use-collections.ts index e7c90239..ace28e81 100644 --- a/apps/web/src/components/api-client/collections/use-collections.ts +++ b/apps/web/src/components/api-client/collections/use-collections.ts @@ -111,6 +111,22 @@ export function useCollections() { void migrateData() }, [user, loading, isLoading, collections.length, authedFetch]) + // Helper: send a delta and reconcile server response (source of truth) + const applyDelta = React.useCallback( + async (collectionId: string, ops: object[]): Promise => { + const res = await authedFetch( + `/api/backend/api-client/collections/${collectionId}/items:apply-delta`, + { + method: "POST", + body: JSON.stringify({ ops }), + } + ) + const body = (await res.json()) as { collection: Collection } + return body.collection + }, + [authedFetch] + ) + const addFolder = async (parentId: string, name: string) => { if (!user) return @@ -122,31 +138,30 @@ export function useCollections() { isOpen: true, } - // Find the collection containing this parentId - // Since we store root collections as documents, we need to find which doc to update. - // parentId could be the collection ID itself or a folder ID inside it. - const targetCollection = collections.find(c => c.id === parentId || findItemInCollection(c.items, parentId) ) if (!targetCollection) return - let updatedItems: (CollectionFolder | CollectionRequest)[] - if (targetCollection.id === parentId) { - updatedItems = [...targetCollection.items, newFolder] - } else { - updatedItems = addItemToParent(targetCollection.items, parentId, newFolder) - } + // Optimistic update + const prev = collections + const optimisticItems = targetCollection.id === parentId + ? [...targetCollection.items, newFolder] + : addItemToParent(targetCollection.items, parentId, newFolder) + setCollections((cur) => + sortCollections(cur.map((c) => + c.id === targetCollection.id ? { ...c, items: optimisticItems } : c + )) + ) try { - const res = await authedFetch(`/api/backend/api-client/collections/${targetCollection.id}`, { - method: "PATCH", - body: JSON.stringify({ items: updatedItems }), - }) - const updated = (await res.json()) as Collection - setCollections((prev) => sortCollections(prev.map((c) => (c.id === updated.id ? updated : c)))) + const updated = await applyDelta(targetCollection.id, [ + { type: "add", parent_id: parentId, item: newFolder }, + ]) + setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) } catch (e) { + setCollections(prev) console.error("Error adding folder", e) toast.error("Failed to add folder") } @@ -162,14 +177,15 @@ export function useCollections() { // If not found in items, maybe it IS the collection? if (!targetCollection) { - // Check if itemId is a collection ID const colToDelete = collections.find(c => c.id === itemId) if (colToDelete) { + const prev = collections + setCollections((cur) => cur.filter((c) => c.id !== itemId)) try { await authedFetch(`/api/backend/api-client/collections/${itemId}`, { method: "DELETE" }) - setCollections((prev) => prev.filter((c) => c.id !== itemId)) toast.success("Collection deleted") } catch (e) { + setCollections(prev) console.error("Error deleting collection", e) toast.error("Failed to delete collection") } @@ -177,16 +193,22 @@ export function useCollections() { return } - const updatedItems = deleteFromItems(targetCollection.items, itemId) + // Optimistic update + const prev = collections + const optimisticItems = deleteFromItems(targetCollection.items, itemId) + setCollections((cur) => + sortCollections(cur.map((c) => + c.id === targetCollection.id ? { ...c, items: optimisticItems } : c + )) + ) try { - const res = await authedFetch(`/api/backend/api-client/collections/${targetCollection.id}`, { - method: "PATCH", - body: JSON.stringify({ items: updatedItems }), - }) - const updated = (await res.json()) as Collection - setCollections((prev) => sortCollections(prev.map((c) => (c.id === updated.id ? updated : c)))) + const updated = await applyDelta(targetCollection.id, [ + { type: "delete", item_id: itemId }, + ]) + setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) } catch (e) { + setCollections(prev) console.error("Error deleting item", e) toast.error("Failed to delete item") } @@ -201,23 +223,25 @@ export function useCollections() { if (!targetCollection) return - // Check if parentId is the collection itself - let updatedItems: (CollectionFolder | CollectionRequest)[] - if (targetCollection.id === parentId) { - updatedItems = [...targetCollection.items, request] - } else { - updatedItems = addItemToParent(targetCollection.items, parentId, request) - } + // Optimistic update + const prev = collections + const optimisticItems = targetCollection.id === parentId + ? [...targetCollection.items, request] + : addItemToParent(targetCollection.items, parentId, request) + setCollections((cur) => + sortCollections(cur.map((c) => + c.id === targetCollection.id ? { ...c, items: optimisticItems } : c + )) + ) try { - const res = await authedFetch(`/api/backend/api-client/collections/${targetCollection.id}`, { - method: "PATCH", - body: JSON.stringify({ items: updatedItems }), - }) - const updated = (await res.json()) as Collection - setCollections((prev) => sortCollections(prev.map((c) => (c.id === updated.id ? updated : c)))) + const updated = await applyDelta(targetCollection.id, [ + { type: "add", parent_id: parentId, item: request }, + ]) + setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) toast.success("Request saved") } catch (e) { + setCollections(prev) console.error("Error saving request", e) toast.error("Failed to save request") } @@ -229,16 +253,26 @@ export function useCollections() { const targetCollection = collections.find(c => findItemInCollection(c.items, folderId)) if (!targetCollection) return - const updatedItems = toggleInItems(targetCollection.items, folderId) + // Optimistic update (toggle is purely local UX — we still persist to avoid drift) + const prev = collections + const optimisticItems = toggleInItems(targetCollection.items, folderId) + setCollections((cur) => + sortCollections(cur.map((c) => + c.id === targetCollection.id ? { ...c, items: optimisticItems } : c + )) + ) + + // Find the current isOpen value to send the patch + const folder = findFolder(targetCollection.items, folderId) + if (!folder) return try { - const res = await authedFetch(`/api/backend/api-client/collections/${targetCollection.id}`, { - method: "PATCH", - body: JSON.stringify({ items: updatedItems }), - }) - const updated = (await res.json()) as Collection - setCollections((prev) => sortCollections(prev.map((c) => (c.id === updated.id ? updated : c)))) + const updated = await applyDelta(targetCollection.id, [ + { type: "update", item_id: folderId, patch: { isOpen: !folder.isOpen } }, + ]) + setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) } catch (e) { + setCollections(prev) console.error("Error toggling folder", e) } } @@ -249,22 +283,39 @@ export function useCollections() { const targetCollection = collections.find(c => findItemInCollection(c.items, folderId)) if (!targetCollection) return - const updatedItems = renameFolderInItems(targetCollection.items, folderId, name) + // Optimistic update + const prev = collections + const optimisticItems = renameFolderInItems(targetCollection.items, folderId, name) + setCollections((cur) => + sortCollections(cur.map((c) => + c.id === targetCollection.id ? { ...c, items: optimisticItems } : c + )) + ) try { - const res = await authedFetch(`/api/backend/api-client/collections/${targetCollection.id}`, { - method: "PATCH", - body: JSON.stringify({ items: updatedItems }), - }) - const updated = (await res.json()) as Collection - setCollections((prev) => sortCollections(prev.map((c) => (c.id === updated.id ? updated : c)))) + const updated = await applyDelta(targetCollection.id, [ + { type: "update", item_id: folderId, patch: { name } }, + ]) + setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) } catch (e) { + setCollections(prev) console.error("Error renaming folder", e) toast.error("Failed to rename folder") } } // Helper functions + const findFolder = (items: (CollectionFolder | CollectionRequest)[], folderId: string): CollectionFolder | null => { + for (const item of items) { + if (item.id === folderId && "type" in item && item.type === "folder") return item as CollectionFolder + if ("type" in item && item.type === "folder") { + const found = findFolder(item.items, folderId) + if (found) return found + } + } + return null + } + const findItemInCollection = (items: (CollectionFolder | CollectionRequest)[], targetId: string): boolean => { for (const item of items) { if (item.id === targetId) return true @@ -353,15 +404,21 @@ export function useCollections() { const renameCollection = async (collectionId: string, name: string) => { if (!user) return + const prev = collections + // Optimistic update + setCollections((cur) => + sortCollections(cur.map((c) => (c.id === collectionId ? { ...c, name } : c))) + ) try { const res = await authedFetch(`/api/backend/api-client/collections/${collectionId}`, { method: "PATCH", body: JSON.stringify({ name }), }) const updated = (await res.json()) as Collection - setCollections((prev) => sortCollections(prev.map((c) => (c.id === updated.id ? updated : c)))) + setCollections((cur) => sortCollections(cur.map((c) => (c.id === updated.id ? updated : c)))) toast.success("Collection renamed") } catch (e) { + setCollections(prev) console.error("Error renaming collection", e) toast.error("Failed to rename collection") } From 9ca058450fb1a19f4bed62579c52d5201cd5134c Mon Sep 17 00:00:00 2001 From: itsmeakhil Date: Mon, 22 Jun 2026 23:57:14 +0530 Subject: [PATCH 12/19] perf(api-client): debounce URL input and throttle tabs localStorage writes Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/api-client/api-client.tsx | 2 + .../collections/collections-sidebar.tsx | 10 +--- .../api-client/context/tabs-context.tsx | 47 ++++++++++++++----- .../components/api-client/request-panel.tsx | 45 +++++++++++++----- apps/web/src/lib/use-debounced-value.ts | 14 ++++++ 5 files changed, 85 insertions(+), 33 deletions(-) create mode 100644 apps/web/src/lib/use-debounced-value.ts diff --git a/apps/web/src/components/api-client/api-client.tsx b/apps/web/src/components/api-client/api-client.tsx index 0cbc0465..5ddd14f8 100644 --- a/apps/web/src/components/api-client/api-client.tsx +++ b/apps/web/src/components/api-client/api-client.tsx @@ -571,6 +571,7 @@ function ApiClientInner() { saveDefaultName={activeTab.name !== API_CLIENT_DEFAULT_TAB_NAME ? activeTab.name : ""} onPaste={handleCurlPaste} urlHistory={urlHistory} + tabId={activeTab.id} />
void } -function useDebouncedValue(value: T, ms: number): T { - const [v, setV] = React.useState(value) - React.useEffect(() => { - const id = setTimeout(() => setV(value), ms) - return () => clearTimeout(id) - }, [value, ms]) - return v -} - export function CollectionsSidebar({ onLoadRequest, }: CollectionsSidebarProps) { diff --git a/apps/web/src/components/api-client/context/tabs-context.tsx b/apps/web/src/components/api-client/context/tabs-context.tsx index 12c7feb1..1ab6e636 100644 --- a/apps/web/src/components/api-client/context/tabs-context.tsx +++ b/apps/web/src/components/api-client/context/tabs-context.tsx @@ -83,22 +83,47 @@ export function TabsProvider({ children }: { children: React.ReactNode }) { setIsInitialized(true) }, []) + // Keep a ref to the latest tabs so the unmount-flush handler always sees + // the freshest snapshot without needing it in the effect dependency array. + const tabsRef = React.useRef(tabs) + React.useEffect(() => { + tabsRef.current = tabs + }) + // Save tabs to localStorage — strip `response`/`isLoading` (responses can be MBs and - // would blow the per-origin localStorage quota). + // would blow the per-origin localStorage quota). Writes are debounced to 500ms so rapid + // edits (e.g. typing in the URL input) don't saturate the storage layer. React.useEffect(() => { if (!isInitialized) return - const slim = tabs.map((t) => { - const { response: _r, isLoading: _l, ...rest } = t - return rest - }) - try { - localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(slim)) - } catch (e) { - console.warn("api-client tabs: localStorage write failed, dropping persisted state", e) - try { localStorage.removeItem(TABS_STORAGE_KEY) } catch { /* noop */ } - } + const timeoutId = setTimeout(() => { + const slim = tabsRef.current.map((t) => { + const { response: _r, isLoading: _l, ...rest } = t + return rest + }) + try { + localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(slim)) + } catch (e) { + console.warn("api-client tabs: localStorage write failed, dropping persisted state", e) + try { localStorage.removeItem(TABS_STORAGE_KEY) } catch { /* noop */ } + } + }, 500) + return () => clearTimeout(timeoutId) }, [tabs, isInitialized]) + // Flush the latest tab state immediately on unmount so mid-debounce edits + // are not lost when the user navigates away. + React.useEffect(() => { + return () => { + const slim = tabsRef.current.map((t) => { + const { response: _r, isLoading: _l, ...rest } = t + return rest + }) + try { + localStorage.setItem(TABS_STORAGE_KEY, JSON.stringify(slim)) + } catch { /* noop */ } + } + }, []) + // Save active tab id to localStorage React.useEffect(() => { if (!isInitialized) return diff --git a/apps/web/src/components/api-client/request-panel.tsx b/apps/web/src/components/api-client/request-panel.tsx index 63501e62..d8fb6114 100644 --- a/apps/web/src/components/api-client/request-panel.tsx +++ b/apps/web/src/components/api-client/request-panel.tsx @@ -17,6 +17,7 @@ import { cn } from "@/lib/utils" import { useTranslations } from "next-intl" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { useEnvironmentsState } from "./context/environments-context" +import { useDebouncedValue } from "@/lib/use-debounced-value" interface RequestPanelProps { method: RequestMethod @@ -31,6 +32,8 @@ interface RequestPanelProps { saveDefaultName?: string onPaste: (text: string) => void urlHistory?: string[] + /** Pass activeTab.id so local URL state resets on tab switch. */ + tabId?: string } const METHODS: RequestMethod[] = ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] @@ -70,11 +73,27 @@ function RequestPanelImpl({ saveDefaultName, onPaste, urlHistory = [], + tabId, }: RequestPanelProps) { const { activeEnvironmentVariables } = useEnvironmentsState() const t = useTranslations("ApiClient.requestPanel") const urlInputRef = React.useRef(null) const [showSuggestions, setShowSuggestions] = React.useState(false) + + // Local URL state: the input is bound here for instant feedback. + // We sync back to the global state (setUrl) after 400ms of idle typing, + // and reset local state when the active tab changes or an external update + // (e.g. cURL import) changes the canonical url prop. + const [localUrl, setLocalUrl] = React.useState(url) + React.useEffect(() => { + setLocalUrl(url) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tabId, url]) + const debouncedLocalUrl = useDebouncedValue(localUrl, 400) + React.useEffect(() => { + if (debouncedLocalUrl !== url) setUrl(debouncedLocalUrl) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [debouncedLocalUrl]) const [hoveredVariable, setHoveredVariable] = React.useState<{ key: string value?: string @@ -83,17 +102,17 @@ function RequestPanelImpl({ } | null>(null) const urlSuggestions = React.useMemo(() => { - if (!url.trim() || urlHistory.length === 0) return [] - const lower = url.toLowerCase() - return urlHistory.filter((h) => h.toLowerCase().includes(lower) && h !== url).slice(0, 8) - }, [url, urlHistory]) + if (!localUrl.trim() || urlHistory.length === 0) return [] + const lower = localUrl.toLowerCase() + return urlHistory.filter((h) => h.toLowerCase().includes(lower) && h !== localUrl).slice(0, 8) + }, [localUrl, urlHistory]) const variableTokens = React.useMemo(() => { const tokens: Array<{ key: string; start: number; end: number; value?: string; status: "resolved" | "missing" }> = [] const regex = /\{\{(.+?)\}\}/g let match: RegExpExecArray | null - while ((match = regex.exec(url)) !== null) { + while ((match = regex.exec(localUrl)) !== null) { const fullToken = match[0] const key = match[1].trim() const value = activeEnvironmentVariables[key] @@ -107,7 +126,7 @@ function RequestPanelImpl({ } return tokens - }, [url, activeEnvironmentVariables]) + }, [localUrl, activeEnvironmentVariables]) const updateHoveredVariable = React.useCallback((clientX: number) => { const input = urlInputRef.current @@ -186,13 +205,13 @@ function RequestPanelImpl({ setUrl(e.target.value)} + value={localUrl} + onChange={(e) => setLocalUrl(e.target.value)} className="h-10 pl-9 pr-10 font-mono text-xs bg-muted/30 border-muted group-hover:border-border transition-colors" onFocus={() => setShowSuggestions(true)} onBlur={() => setTimeout(() => setShowSuggestions(false), 150)} onKeyDown={(e) => { - if (e.key === "Enter" && url) { + if (e.key === "Enter" && localUrl) { setShowSuggestions(false) onSend() } @@ -208,9 +227,9 @@ function RequestPanelImpl({ onMouseMove={(e) => updateHoveredVariable(e.clientX)} onMouseLeave={() => setHoveredVariable(null)} /> - {url && ( + {localUrl && ( + + + + {t("title")} + +
    + {SHORTCUTS.map(([keys, labelKey]) => ( + +
  • + + {keys.replace("mod", isMac ? "⌘" : "Ctrl")} + + {t(labelKey as "newTab" | "closeTab" | "sendRequest")} +
  • +
    + ))} +
+
+
+ ) +} diff --git a/apps/web/src/components/api-client/tab-bar.tsx b/apps/web/src/components/api-client/tab-bar.tsx index 5f0602fc..29f6b497 100644 --- a/apps/web/src/components/api-client/tab-bar.tsx +++ b/apps/web/src/components/api-client/tab-bar.tsx @@ -5,6 +5,7 @@ import { X, Plus, Copy } from "lucide-react" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { ApiRequestState } from "./types" import { getApiClientRequestDisplayName } from "./display-name" import { useTranslations } from "next-intl" @@ -120,6 +121,7 @@ export function TabBar({ return (
+ {/* relative container so the sliding indicator is positioned within it */}
@@ -187,9 +189,16 @@ export function TabBar({ onClick={(e) => e.stopPropagation()} /> ) : ( - - {getApiClientRequestDisplayName(tab.name, t)} - + + + + {getApiClientRequestDisplayName(tab.name, t)} + + + + {getApiClientRequestDisplayName(tab.name, t)} + + )} {onTabDuplicate && ( @@ -222,6 +231,7 @@ export function TabBar({
+
- + {isEditDialogOpen && ( + + + + + + )} ); } diff --git a/apps/web/src/app/app/to-do/KanbanColumn.tsx b/apps/web/src/app/app/to-do/KanbanColumn.tsx index 7aa44134..a784fa68 100644 --- a/apps/web/src/app/app/to-do/KanbanColumn.tsx +++ b/apps/web/src/app/app/to-do/KanbanColumn.tsx @@ -1,7 +1,9 @@ "use client"; +import { useRef } from "react"; import { useDroppable } from "@dnd-kit/core"; import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { Task } from "@/app/app/to-do/types/Task"; import KanbanCard from "./KanbanCard"; import { LucideIcon } from "lucide-react"; @@ -18,6 +20,9 @@ interface KanbanColumnProps { onDeleteTask: (id: string) => void; } +const VIRTUALIZATION_THRESHOLD = 30; +const ESTIMATED_CARD_HEIGHT = 160; + export default function KanbanColumn({ id, title, @@ -28,12 +33,19 @@ export default function KanbanColumn({ }: KanbanColumnProps) { const tKanban = useTranslations("Tasks.kanban"); const tStatus = useTranslations("Tasks.status"); - const { setNodeRef, isOver } = useDroppable({ - id, - }); + const { setNodeRef, isOver } = useDroppable({ id }); const config = STATUS_CONFIG[id]; const taskIds = tasks.map((task) => task.id); + const shouldVirtualize = tasks.length > VIRTUALIZATION_THRESHOLD; + + const scrollRef = useRef(null); + const virtualizer = useVirtualizer({ + count: shouldVirtualize ? tasks.length : 0, + getScrollElement: () => scrollRef.current, + estimateSize: () => ESTIMATED_CARD_HEIGHT, + overscan: 6, + }); return (
- {/* Enhanced Column Header */} + {/* Column Header */}
-
- -
-

+ +

{title}

- {/* Tasks - Enhanced */} + {/* Tasks */} -
+
{tasks.length === 0 ? (
)}
+ ) : shouldVirtualize ? ( +
+ {virtualizer.getVirtualItems().map((virtualRow) => { + const task = tasks[virtualRow.index]; + return ( +
+ +
+ ); + })} +
) : ( tasks.map((task, index) => (
import("@/app/app/to-do/KanbanBoard")); +const ExportImportDialog = lazy(() => import("@/app/app/to-do/ExportImportDialog")); +const TaskCommandPalette = lazy(() => + import("@/app/app/to-do/components/TaskCommandPalette").then((m) => ({ + default: m.TaskCommandPalette, + })) +); import { useTaskContext } from "@/app/app/to-do/context/TaskContext"; import { useProjectContext } from "@/app/app/to-do/context/ProjectContext"; import { ListTodo, Circle, LayoutGrid, List, Search, X, Plus, Folder, Archive, ArchiveRestore } from "lucide-react"; @@ -55,6 +62,7 @@ export const TaskContainer = () => { const [searchQuery, setSearchQuery] = useState(""); const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [isPaletteOpen, setIsPaletteOpen] = useState(false); const searchInputRef = useRef(null); const taskFormInputRef = useRef(null); const { @@ -98,42 +106,54 @@ export const TaskContainer = () => { // Filter tasks based on filterStatus for list view // For kanban view, always show all tasks (filtering is handled by columns) - const filteredTasks = viewMode === "kanban" || filterStatus === "all" - ? searchFilteredTasks - : searchFilteredTasks.filter(task => task.status === filterStatus); - - const sortedTasks = [...filteredTasks].sort( - (a: { status: "ongoing" | "not-started" | "completed" }, - b: { status: "ongoing" | "not-started" | "completed" }) => { - const statusOrder: { [key in "ongoing" | "not-started" | "completed"]: number } = { - ongoing: 1, - "not-started": 2, - completed: 3, - }; - return statusOrder[a.status] - statusOrder[b.status]; - } + const filteredTasks = useMemo( + () => + viewMode === "kanban" || filterStatus === "all" + ? searchFilteredTasks + : searchFilteredTasks.filter((task) => task.status === filterStatus), + [searchFilteredTasks, viewMode, filterStatus] ); + const sortedTasks = useMemo(() => { + const statusOrder: { ongoing: number; "not-started": number; completed: number } = { + ongoing: 1, + "not-started": 2, + completed: 3, + }; + return [...filteredTasks].sort((a, b) => statusOrder[a.status] - statusOrder[b.status]); + }, [filteredTasks]); + // Calculate statistics using all tasks stats const completionRate = allTaskStats.total > 0 ? Math.round((allTaskStats.completed / allTaskStats.total) * 100) : 0; - const handleAddTask = (taskText: string) => { - addTask(taskText); - setIsDrawerOpen(false); - }; + const handleAddTask = useCallback( + (taskText: string) => { + addTask(taskText); + setIsDrawerOpen(false); + }, + [addTask] + ); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const hasStatusFilter = filterStatus !== "all"; const hasProjectFilter = filterProject !== "all"; const hasSearchFilter = searchQuery.trim().length > 0; const hasActiveFilters = hasStatusFilter || hasProjectFilter || hasSearchFilter; - const activeProject = projects.find((project) => project.id === filterProject); + const activeProject = useMemo( + () => projects.find((project) => project.id === filterProject), + [projects, filterProject] + ); - const clearAllFilters = () => { + const liveStatsMessage = useMemo(() => { + const { total, completed, ongoing, notStarted } = allTaskStats; + return `${total} task${total === 1 ? "" : "s"}: ${notStarted} not started, ${ongoing} ongoing, ${completed} completed`; + }, [allTaskStats]); + + const clearAllFilters = useCallback(() => { setSearchQuery(""); setFilterStatus("all"); setFilterProject("all"); - }; + }, [setFilterStatus, setFilterProject]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -145,7 +165,7 @@ export const TaskContainer = () => { if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { event.preventDefault(); - searchInputRef.current?.focus(); + setIsPaletteOpen((open) => !open); return; } @@ -171,10 +191,16 @@ export const TaskContainer = () => { }, [hasSearchFilter, isMobile]); return ( -
+
+
+ {liveStatsMessage} +
{/* Mobile-specific Header */} {isMobile && ( -
+
@@ -202,8 +228,8 @@ export const TaskContainer = () => { {/* Title & Stats */}
-

{tPage("myTasksTitle")}

-

+

{tPage("myTasksTitle")}

+

{tPage("statsLine", { total: allTaskStats.total, percent: completionRate })}

@@ -288,10 +314,10 @@ export const TaskContainer = () => {
-

+

{tPage("myTasksTitle")}

-

+

{tPage("statsLineDesktop", { total: allTaskStats.total, percent: completionRate })}

@@ -302,9 +328,9 @@ export const TaskContainer = () => {
- {allTaskStats.total} + {allTaskStats.total}
- {tFilters("total")} + {tFilters("total")}
{Object.values(STATUS_CONFIG).map((config) => { @@ -325,9 +351,9 @@ export const TaskContainer = () => { >
- {count} + {count}
- {tStatus(`${config.id}.label` as any)} + {tStatus(`${config.id}.label` as any)}
); })} @@ -555,13 +581,23 @@ export const TaskContainer = () => { {viewMode === "kanban" ? ( - + + +
+
+ } + > + +
+
) : ( @@ -640,12 +676,35 @@ export const TaskContainer = () => {
- + {isExportDialogOpen && ( + + + + + + )} + + {isPaletteOpen && ( + + + { + if (isMobile) setIsDrawerOpen(true); + else taskFormInputRef.current?.focus(); + }} + /> + + + )}
); }; diff --git a/apps/web/src/app/app/to-do/TaskEditDialog.tsx b/apps/web/src/app/app/to-do/TaskEditDialog.tsx index 23fceeb2..eca94b1c 100644 --- a/apps/web/src/app/app/to-do/TaskEditDialog.tsx +++ b/apps/web/src/app/app/to-do/TaskEditDialog.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; +import { z } from "zod"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Drawer, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerClose } from "@/components/ui/drawer"; import { Button } from "@/components/ui/button"; @@ -33,6 +34,42 @@ const priorityConfig = { low: { label: "Low", icon: Zap, color: "text-blue-500", bgColor: "bg-blue-50 dark:bg-blue-950", borderColor: "border-blue-500" }, }; +function hexToRgb(hex: string): [number, number, number] | null { + const m = hex.replace("#", "").match(/^([\da-f]{6}|[\da-f]{3})$/i); + if (!m) return null; + const h = m[1].length === 3 + ? m[1].split("").map((c) => c + c).join("") + : m[1]; + return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)]; +} + +function relLuminance([r, g, b]: [number, number, number]): number { + const srgb = [r, g, b].map((v) => { + const x = v / 255; + return x <= 0.03928 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4); + }); + return 0.2126 * srgb[0] + 0.7152 * srgb[1] + 0.0722 * srgb[2]; +} + +function contrastVsWhite(hex: string): number | null { + const rgb = hexToRgb(hex); + if (!rgb) return null; + const l = relLuminance(rgb); + return (1.0 + 0.05) / (l + 0.05); +} + +const taskEditSchema = z.object({ + text: z.string().trim().min(1, "Title is required").max(200, "Max 200 characters"), + description: z.string().max(2000, "Max 2000 characters").optional(), + timeEstimate: z + .number() + .int("Must be a whole number") + .positive("Must be positive") + .optional(), +}); + +type TaskEditErrors = Partial>; + const predefinedTags = [ { name: "Work", color: "#3b82f6" }, { name: "Personal", color: "#10b981" }, @@ -54,6 +91,7 @@ export default function TaskEditDialog({ task, open, onOpenChange, onSave }: Tas const [customTagColor, setCustomTagColor] = useState("#3b82f6"); const [isSaving, setIsSaving] = useState(false); const [selectedDate, setSelectedDate] = useState(undefined); + const [errors, setErrors] = useState({}); const isMobile = useIsMobile(); const { projects } = useProjectContext(); @@ -70,16 +108,44 @@ export default function TaskEditDialog({ task, open, onOpenChange, onSave }: Tas timeEstimate: task.timeEstimate, projectId: task.projectId, }); + setErrors({}); if (task.dueDate) { setSelectedDate(new Date(task.dueDate)); } } }, [open, task]); + const clearError = useCallback((field: keyof TaskEditErrors) => { + setErrors((prev) => { + if (!prev[field]) return prev; + const next = { ...prev }; + delete next[field]; + return next; + }); + }, []); + const handleSave = async () => { + const result = taskEditSchema.safeParse({ + text: editedTask.text ?? "", + description: editedTask.description, + timeEstimate: editedTask.timeEstimate, + }); + + if (!result.success) { + const fieldErrors: TaskEditErrors = {}; + for (const issue of result.error.issues) { + const path = issue.path[0] as keyof TaskEditErrors | undefined; + if (path && !fieldErrors[path]) { + fieldErrors[path] = issue.message; + } + } + setErrors(fieldErrors); + return; + } + setIsSaving(true); try { - await onSave(editedTask); + await onSave({ ...editedTask, text: result.data.text }); onOpenChange(false); } catch (error) { console.error("Failed to save task:", error); @@ -139,14 +205,25 @@ export default function TaskEditDialog({ task, open, onOpenChange, onSave }: Tas
{/* Task Title */}
- + setEditedTask({ ...editedTask, text: e.target.value })} + onChange={(e) => { + setEditedTask({ ...editedTask, text: e.target.value }); + clearError("text"); + }} placeholder={t("taskTitlePlaceholder")} - className="text-base" + className={cn("text-base", errors.text && "border-destructive focus-visible:ring-destructive/30")} + aria-invalid={!!errors.text} + aria-describedby={errors.text ? "task-title-error" : undefined} /> + {errors.text && ( +

{errors.text}

+ )}
{/* Description */} @@ -155,10 +232,18 @@ export default function TaskEditDialog({ task, open, onOpenChange, onSave }: Tas