diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d358b2..90edf6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ All notable changes to the REST Lab extension will be documented in this file. ## Future Roadmap -- [ ] Request history +- [x] Request history - [ ] Pre-request scripts - [ ] Test scripts / assertions - [ ] Code generation (JS fetch, Python requests, curl, etc.) diff --git a/docs/superpowers/plans/2026-07-16-history-editor-panel.md b/docs/superpowers/plans/2026-07-16-history-editor-panel.md new file mode 100644 index 0000000..7b6a1b0 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-history-editor-panel.md @@ -0,0 +1,1143 @@ +# History Editor Panel Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Move the global History view out of the cramped sidebar and into a full-width singleton editor panel, matching how request/folder editors already open — while keeping the feature fully working after every task via an incremental cutover. + +**Architecture:** A new `HistoryEditorProvider` (mirroring `FolderEditorProvider`/`RequestEditorProvider`) opens one singleton `WebviewPanel` in the main editor area, backed by a new 4th Vite bundle (`src/webview/history/`) that reuses the already-shared `HistoryEntryList` component. The four history operations currently inlined in `SidebarProvider`'s message switch get extracted into public methods first, so both the (eventually removed) sidebar path and the new panel call the exact same logic — no duplication. The sidebar itself reverts to just the collection tree plus a small button that opens the new panel. + +**Tech Stack:** TypeScript (strict), VS Code Extension API, React 18 (classic JSX runtime), Vite (existing `scripts/build.mts` pattern). + +## Global Constraints + +- Strict TypeScript — never add `@ts-nocheck`, `@ts-ignore`, or eslint-disable comments; fix root causes. +- No test suite/lint script in this repo — every task's verification is `npx tsc --noEmit` (run it yourself) plus a **manual verification** description for the developer (via `npm run watch` + Extension Development Host) — do not run `npm run build`/`npm run watch` yourself, and do not claim the manual step passed. +- `HistoryEntry.request.*` raw-vs-resolved correctness (established in the original history feature) is unaffected by this plan — none of these tasks touch `HistoryManager`, `HistoryEntry`, or the restore data shape. +- Each task must independently type-check. Where a symbol is removed and a caller updated, both edits happen in the SAME task (never leave a dangling reference for tsc to fail on between tasks). +- Spec: `docs/superpowers/specs/2026-07-16-history-editor-panel-design.md`. + +--- + +### Task 1: Extract history operations onto `SidebarProvider` as public methods + +**Files:** +- Modify: `src/providers/SidebarProvider.ts` + +**Interfaces:** +- Produces: `SidebarProvider.getHistoryEntries(): HistoryEntry[]`, `SidebarProvider.deleteHistoryEntryById(entryId: string): Promise`, `SidebarProvider.clearAllHistoryEntries(): Promise` (returns whether the user confirmed and it actually cleared), `SidebarProvider.restoreHistoryEntryById(entryId: string): Promise` — all consumed by Task 2's `HistoryEditorProvider`. +- This task is a pure behavior-preserving refactor: the sidebar's existing in-place History section (still present until Task 4) keeps working exactly as before, just by delegating to these new methods. + +- [ ] **Step 1: Import `HistoryEntry`** + +Change the type import near the top of `src/providers/SidebarProvider.ts` from: + +```ts +import { AuthConfig } from "../webview/types/internal.types"; +``` + +to: + +```ts +import { AuthConfig, HistoryEntry } from "../webview/types/internal.types"; +``` + +- [ ] **Step 2: Add the four public methods** + +Add these methods to the `SidebarProvider` class, directly before the existing `public notifyHistoryChanged(): void { ... }` method: + +```ts + // ── History operations (shared by the sidebar's own messages and HistoryEditorProvider) ── + + public getHistoryEntries(): HistoryEntry[] { + return this._historyManager.getAll(); + } + + public async deleteHistoryEntryById(entryId: string): Promise { + const entryBeingDeleted = this._historyManager + .getAll() + .find((e) => e.id === entryId); + await this._historyManager.deleteEntry(entryId); + if (entryBeingDeleted) { + RequestEditorProvider.refreshPanelHistory( + entryBeingDeleted.requestId, + this._historyManager, + ); + } + } + + /** Returns true if the user confirmed and history was actually cleared. */ + public async clearAllHistoryEntries(): Promise { + const confirm = await vscode.window.showWarningMessage( + "Clear all request history? This cannot be undone.", + { modal: true }, + "Clear All", + ); + if (confirm !== "Clear All") return false; + + const affectedRequestIds = [ + ...new Set(this._historyManager.getAll().map((e) => e.requestId)), + ]; + await this._historyManager.clearAll(); + for (const requestId of affectedRequestIds) { + RequestEditorProvider.refreshPanelHistory(requestId, this._historyManager); + } + return true; + } + + public async restoreHistoryEntryById(entryId: string): Promise { + const entry = this._historyManager.getAll().find((e) => e.id === entryId); + if (!entry) return; + + const folder = this._findFolder(entry.folderId); + const requestExists = folder?.requests?.some( + (r) => r.id === entry.requestId, + ); + if (!requestExists) { + vscode.window.showWarningMessage( + `Cannot restore "${entry.requestName}" — the original request no longer exists.`, + ); + return; + } + + const existingConfig = + this._context.globalState.get( + `restlab.request.${entry.requestId}`, + ) || {}; + const restoredConfig = { + ...existingConfig, + method: entry.request.method, + url: entry.request.url, + headers: entry.request.headers, + params: entry.request.params, + body: entry.request.body, + contentType: entry.request.contentType, + formData: entry.request.formData, + cookies: entry.request.cookies, + }; + await this._context.globalState.update( + `restlab.request.${entry.requestId}`, + restoredConfig, + ); + RequestEditorProvider.refreshPanelConfig( + this._context, + entry.requestId, + entry.folderId, + this, + this._historyManager, + ); + vscode.window.showInformationMessage( + `Restored "${entry.requestName}" from history`, + ); + } +``` + +- [ ] **Step 3: Rewrite the four existing switch cases to call the new methods** + +Replace the entire `case "deleteHistoryEntry": { ... }`, `case "clearAllHistory": { ... }`, and `case "restoreHistoryEntry": { ... }` blocks (the `case "getHistory":` block is unchanged) with: + +```ts + case "getHistory": + this._sendHistoryToWebview(); + break; + case "deleteHistoryEntry": + await this.deleteHistoryEntryById(message.entryId); + this._sendHistoryToWebview(); + break; + case "clearAllHistory": { + const cleared = await this.clearAllHistoryEntries(); + if (cleared) { + this._sendHistoryToWebview(); + } + break; + } + case "restoreHistoryEntry": + await this.restoreHistoryEntryById(message.entryId); + break; +``` + +- [ ] **Step 4: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 5: Manual verification (developer runs this)** + +Run `npm run watch`, launch the Extension Development Host. The sidebar's existing Collections/History toggle and in-place history list should work exactly as before this change (list, expand, restore, delete, clear all) — this task changes nothing observable, it only relocates the logic. + +- [ ] **Step 6: Commit** + +```bash +git add src/providers/SidebarProvider.ts +git commit -m "ref: extract history operations onto SidebarProvider as public methods" +``` + +--- + +### Task 2: `HistoryEditorProvider` + new `history` webview bundle + +**Files:** +- Create: `src/providers/HistoryEditorProvider.ts` +- Create: `src/webview/history/index.tsx` +- Create: `src/webview/history/HistoryView.tsx` +- Create: `src/webview/history/styles.css` +- Modify: `scripts/build.mts` + +**Interfaces:** +- Consumes: `SidebarProvider.getHistoryEntries()` / `.deleteHistoryEntryById()` / `.clearAllHistoryEntries()` / `.restoreHistoryEntryById()` (Task 1); the shared `HistoryEntryList` component from `src/webview/components/HistoryEntryList.tsx` (already exists). +- Produces: `HistoryEditorProvider.openHistoryPanel(context: vscode.ExtensionContext, sidebarProvider: SidebarProvider): void` and `HistoryEditorProvider.refreshIfOpen(sidebarProvider?: SidebarProvider): void` — both consumed by Task 3. + +- [ ] **Step 1: Create the host provider** + +```ts +import * as vscode from "vscode"; +import { getNonce } from "../utils/getNonce"; +import { SidebarProvider } from "./SidebarProvider"; + +export class HistoryEditorProvider { + private static panel: vscode.WebviewPanel | undefined; + + /** Push a fresh historyUpdated payload to the History panel, if it's open. */ + public static refreshIfOpen(sidebarProvider?: SidebarProvider): void { + if (!HistoryEditorProvider.panel || !sidebarProvider) return; + HistoryEditorProvider.panel.webview.postMessage({ + type: "historyUpdated", + entries: sidebarProvider.getHistoryEntries(), + }); + } + + public static openHistoryPanel( + context: vscode.ExtensionContext, + sidebarProvider: SidebarProvider, + ): void { + if (HistoryEditorProvider.panel) { + HistoryEditorProvider.panel.reveal(vscode.ViewColumn.One); + return; + } + + const panel = vscode.window.createWebviewPanel( + "restlab.historyEditor", + "History", + vscode.ViewColumn.One, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [context.extensionUri], + }, + ); + + HistoryEditorProvider.panel = panel; + + panel.onDidDispose(() => { + HistoryEditorProvider.panel = undefined; + }); + + panel.webview.html = HistoryEditorProvider._getHtmlForWebview( + panel.webview, + context, + ); + + panel.webview.onDidReceiveMessage(async (message) => { + switch (message.type) { + case "getHistory": + panel.webview.postMessage({ + type: "historyUpdated", + entries: sidebarProvider.getHistoryEntries(), + }); + break; + case "deleteHistoryEntry": + await sidebarProvider.deleteHistoryEntryById(message.entryId); + panel.webview.postMessage({ + type: "historyUpdated", + entries: sidebarProvider.getHistoryEntries(), + }); + break; + case "clearAllHistory": { + const cleared = await sidebarProvider.clearAllHistoryEntries(); + if (cleared) { + panel.webview.postMessage({ + type: "historyUpdated", + entries: sidebarProvider.getHistoryEntries(), + }); + } + break; + } + case "restoreHistoryEntry": + await sidebarProvider.restoreHistoryEntryById(message.entryId); + panel.webview.postMessage({ + type: "historyUpdated", + entries: sidebarProvider.getHistoryEntries(), + }); + break; + } + }); + } + + private static _getHtmlForWebview( + webview: vscode.Webview, + context: vscode.ExtensionContext, + ): string { + const scriptUri = webview.asWebviewUri( + vscode.Uri.joinPath(context.extensionUri, "dist", "history", "index.js"), + ); + const styleUri = webview.asWebviewUri( + vscode.Uri.joinPath(context.extensionUri, "dist", "history", "index.css"), + ); + + const nonce = getNonce(); + + return ` + + + + + + + History + + +
+ + + `; + } +} +``` + +Save as `src/providers/HistoryEditorProvider.ts`. + +- [ ] **Step 2: Create the bundle's root component** + +```tsx +import React, { useEffect, useState } from "react"; +import HistoryEntryList from "../components/HistoryEntryList"; +import { HistoryEntry } from "../types/internal.types"; + +declare function acquireVsCodeApi(): { + postMessage: (message: unknown) => void; + getState: () => unknown; + setState: (state: unknown) => void; +}; + +const vscode = acquireVsCodeApi(); + +export const HistoryView: React.FC = () => { + const [entries, setEntries] = useState([]); + + useEffect(() => { + vscode.postMessage({ type: "getHistory" }); + + const handleMessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === "historyUpdated") { + setEntries(message.entries || []); + } + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, []); + + const handleRestore = (entryId: string) => { + vscode.postMessage({ type: "restoreHistoryEntry", entryId }); + }; + + const handleDelete = (entryId: string) => { + vscode.postMessage({ type: "deleteHistoryEntry", entryId }); + }; + + const handleClearAll = () => { + vscode.postMessage({ type: "clearAllHistory" }); + }; + + return ( +
+
+

Request History

+ {entries.length > 0 && ( + + )} +
+
+ +
+
+ ); +}; +``` + +Save as `src/webview/history/HistoryView.tsx`. + +- [ ] **Step 3: Create the bundle entry point** + +```tsx +import React from "react"; +import { createRoot } from "react-dom/client"; +import "./styles.css"; +import { HistoryView } from "./HistoryView"; + +const container = document.getElementById("root"); +if (container) { + const root = createRoot(container); + root.render(); +} +``` + +Save as `src/webview/history/index.tsx`. + +- [ ] **Step 4: Create the bundle's stylesheet** + +```css +/* ============================================================ + REST-Lab History Panel — Custom CSS + ============================================================ */ + +/* ---- Reset ---- */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ---- Base ---- */ +body { + font-family: var( + --vscode-font-family, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif + ); + font-size: var(--vscode-font-size, 13px); + color: var(--vscode-foreground); + background-color: var(--vscode-editor-background); + line-height: 1.45; +} + +/* ---- Brand & VS Code tokens ---- */ +:root { + --restlab-gradient: linear-gradient(90deg, #38bdf8 0%, #6366f1 100%); + --restlab-accent: #38bdf8; + --restlab-accent-hover: #0ea5e9; + --restlab-accent-subtle: rgba(56, 189, 248, 0.1); + --restlab-danger: #ef4444; + --restlab-danger-subtle: rgba(239, 68, 68, 0.1); + --glass-bg: rgba(255, 255, 255, 0.03); + --glass-border: rgba(255, 255, 255, 0.08); + --method-get: #22c55e; + --method-post: #3b82f6; + --method-put: #f59e0b; + --method-patch: #a855f7; + --method-delete: #ef4444; + --rl-sp1: 0.30em; + --rl-sp2: 0.46em; + --rl-sp3: 0.62em; + --rl-sp4: 0.92em; + --rl-sp5: 1.23em; + --rl-ctrl: 2.35em; + --rl-icon: 1.25em; + --rl-r1: 0.35em; + --rl-r2: 0.50em; + --rl-r3: 0.65em; +} + +/* ============================================================ + PAGE SHELL + ============================================================ */ +.history-page { + display: flex; + flex-direction: column; + height: 100vh; + max-width: 960px; + margin: 0 auto; + padding: var(--rl-sp5); + overflow: hidden; +} + +.history-page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: var(--rl-sp4); + border-bottom: 1px solid var(--glass-border); + margin-bottom: var(--rl-sp4); + flex-shrink: 0; +} + +.history-page-header h1 { + font-size: 1.15em; + font-weight: 800; + background: var(--restlab-gradient); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.history-page-body { + flex: 1; + overflow-y: auto; +} + +/* ============================================================ + METHOD BADGE + ============================================================ */ +.method-badge { + flex-shrink: 0; + font-size: 0.62em; + font-weight: 800; + padding: 0.32em 0.55em; + border-radius: 0.5em; + text-transform: uppercase; + letter-spacing: 0.06em; + border: 1px solid transparent; + line-height: 1; + min-width: 3.6em; + text-align: center; +} +.method-get { + color: var(--method-get); + background: rgba(34, 197, 94, 0.14); + border-color: rgba(34, 197, 94, 0.32); +} +.method-post { + color: var(--method-post); + background: rgba(59, 130, 246, 0.14); + border-color: rgba(59, 130, 246, 0.32); +} +.method-put { + color: var(--method-put); + background: rgba(245, 158, 11, 0.14); + border-color: rgba(245, 158, 11, 0.32); +} +.method-patch { + color: var(--method-patch); + background: rgba(168, 85, 247, 0.14); + border-color: rgba(168, 85, 247, 0.32); +} +.method-delete { + color: var(--method-delete); + background: rgba(239, 68, 68, 0.14); + border-color: rgba(239, 68, 68, 0.32); +} + +/* ============================================================ + STATUS & RESPONSE BADGES + ============================================================ */ +.status-badge { + padding: 0.18em 0.55em; + border-radius: 1.4em; + font-size: 0.7em; + font-weight: 700; + letter-spacing: 0.3px; +} +.status-success { + background: linear-gradient(90deg, rgba(34, 197, 94, 0.2) 0%, rgba(74, 222, 128, 0.2) 100%); + color: #22c55e; +} +.status-redirect { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(99, 102, 241, 0.2) 100%); + color: #3b82f6; +} +.status-client-error { + background: linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(251, 191, 36, 0.2) 100%); + color: #f59e0b; +} +.status-server-error { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(249, 115, 22, 0.2) 100%); + color: #ef4444; +} +.status-error { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.time-badge { + padding: 0.18em 0.55em; + border-radius: 1.4em; + font-size: 0.7em; + font-weight: 600; + background: var(--glass-bg); + color: var(--vscode-foreground); +} + +.empty-hint { + color: var(--vscode-descriptionForeground); + font-size: 12px; + font-style: italic; +} + +.add-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border: 1px dashed var(--vscode-panel-border); + border-radius: 4px; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; +} +.add-btn:hover { + border-color: var(--restlab-accent); + color: var(--restlab-accent); +} + +.remove-btn { + display: flex; + align-items: center; + justify-content: center; + width: var(--rl-ctrl); + height: var(--rl-ctrl); + border: none; + background: transparent; + color: var(--vscode-descriptionForeground); + border-radius: var(--rl-r2); + cursor: pointer; +} +.remove-btn:hover { + background: var(--restlab-danger-subtle); + color: var(--restlab-danger); +} + +.response-header-row { + display: flex; + padding: var(--rl-sp2) var(--rl-sp3); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 6px; + font-size: 12px; +} +.response-header-row .header-name { + font-weight: 700; + min-width: 11em; + background: var(--restlab-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.response-header-row .header-value { + flex: 1; + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + word-break: break-all; + opacity: 0.9; +} + +.response-headers { + display: flex; + flex-direction: column; + gap: 4px; + overflow: auto; + flex: 1; + min-height: 0; +} + +/* ============================================================ + HISTORY + ============================================================ */ +.history-list { + display: flex; + flex-direction: column; + gap: var(--rl-sp2); +} + +.history-entry { + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + background: var(--glass-bg); + overflow: hidden; +} + +.history-entry-row { + display: flex; + align-items: center; + gap: var(--rl-sp3); + padding: var(--rl-sp3); + cursor: pointer; +} +.history-entry-row:hover { + background: rgba(56, 189, 248, 0.05); +} + +.history-request-name { + font-weight: 600; + font-size: 12px; +} + +.history-url { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + font-size: 12px; + opacity: 0.85; +} + +.history-timestamp { + font-size: 11px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +.history-entry-details { + border-top: 1px solid var(--glass-border); + padding: var(--rl-sp3); + display: flex; + flex-direction: column; + gap: var(--rl-sp3); +} + +.history-detail-section h4 { + margin: 0 0 var(--rl-sp2) 0; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.7; +} + +.history-detail-line { + font-size: 12px; + margin: 0 0 var(--rl-sp2) 0; + word-break: break-all; +} + +.history-body { + margin: var(--rl-sp2) 0 0 0; + padding: var(--rl-sp3); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + max-height: 240px; + overflow: auto; +} + +.history-entry-actions { + display: flex; + align-items: center; + gap: var(--rl-sp2); +} +``` + +Save as `src/webview/history/styles.css`. + +- [ ] **Step 5: Add the 4th parallel Vite build** + +In `scripts/build.mts`, change: + +```ts + await Promise.all([ + build(createWebviewConfig("sidebar")), + build(createWebviewConfig("editor")), + build(createWebviewConfig("request")), + ]); +``` + +to: + +```ts + await Promise.all([ + build(createWebviewConfig("sidebar")), + build(createWebviewConfig("editor")), + build(createWebviewConfig("request")), + build(createWebviewConfig("history")), + ]); +``` + +- [ ] **Step 6: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. (`HistoryEditorProvider` is not yet called from anywhere — that's Task 3 — so this compiles standalone.) + +- [ ] **Step 7: Manual verification (developer runs this)** + +Nothing is wired to open this panel yet (Task 3 registers the command) — this step is compile-only. Note in your report that functional verification is deferred to Task 3. + +- [ ] **Step 8: Commit** + +```bash +git add src/providers/HistoryEditorProvider.ts src/webview/history/ scripts/build.mts +git commit -m "feat: add HistoryEditorProvider and a new history webview bundle" +``` + +--- + +### Task 3: Wire the `restlab.openHistory` command and switch history-change notifications to the new panel + +**Files:** +- Modify: `src/extension.ts` +- Modify: `src/providers/RequestEditorProvider.ts` +- Modify: `src/providers/SidebarProvider.ts` + +**Interfaces:** +- Consumes: `HistoryEditorProvider.openHistoryPanel`/`.refreshIfOpen` (Task 2). +- Produces: command `restlab.openHistory`, registered but not yet triggered by any UI (Task 4 adds the sidebar button). `SidebarProvider.notifyHistoryChanged()` is removed — nothing outside this file called it except the three sites being updated in the same commit. + +- [ ] **Step 1: Register the command in `extension.ts`** + +Add an import: + +```ts +import { HistoryEditorProvider } from "./providers/HistoryEditorProvider"; +``` + +Add a new command registration, directly after the existing `restlab.openRequest` registration: + +```ts + // Register command to open the global history panel + context.subscriptions.push( + vscode.commands.registerCommand("restlab.openHistory", () => { + HistoryEditorProvider.openHistoryPanel(context, sidebarProvider); + }), + ); +``` + +- [ ] **Step 2: Swap `RequestEditorProvider`'s notification calls** + +Add an import to `src/providers/RequestEditorProvider.ts`: + +```ts +import { HistoryEditorProvider } from "./HistoryEditorProvider"; +``` + +Replace all three occurrences of: + +```ts + sidebarProvider?.notifyHistoryChanged(); +``` + +(inside the `sendRequest` case's `recordHistory` closure) and: + +```ts + sidebarProvider?.notifyHistoryChanged(); +``` + +(inside the `deleteHistoryEntry` case, and inside the `clearRequestHistory` case) + +with, respectively: + +```ts + HistoryEditorProvider.refreshIfOpen(sidebarProvider); +``` + +and (the two per-panel cases): + +```ts + HistoryEditorProvider.refreshIfOpen(sidebarProvider); +``` + +(Same replacement text in all three places — only the surrounding indentation differs, matching each call site's existing indentation exactly.) + +- [ ] **Step 3: Remove the now-unused `notifyHistoryChanged` from `SidebarProvider`** + +Remove this method entirely from `src/providers/SidebarProvider.ts` (its only callers were the three sites just updated in Step 2): + +```ts + public notifyHistoryChanged(): void { + this._sendHistoryToWebview(); + } + +``` + +Leave `_sendHistoryToWebview` (private) in place — it's still called by this file's own `getHistory`/`deleteHistoryEntry`/`clearAllHistory` switch cases until Task 4 removes those cases. + +- [ ] **Step 4: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 5: Manual verification (developer runs this)** + +Run `npm run watch`, launch the Extension Development Host. The sidebar's existing in-place History section should still work exactly as before (unaffected by this task). There's no visible way to open the new panel yet (no button — Task 4 adds it), but you can confirm the command exists via the Command Palette: run "Preferences: Open Keyboard Shortcuts" or just trust `tsc`; there's no user-facing surface for `restlab.openHistory` until Task 4. Skip to Task 4 for the first visibly testable result. + +- [ ] **Step 6: Commit** + +```bash +git add src/extension.ts src/providers/RequestEditorProvider.ts src/providers/SidebarProvider.ts +git commit -m "feat: wire restlab.openHistory command and notify the History panel on changes" +``` + +--- + +### Task 4: Cut the sidebar over to a History button, remove the in-place list + +**Files:** +- Modify: `src/webview/sidebar/Sidebar.tsx` +- Modify: `src/providers/SidebarProvider.ts` +- Modify: `src/webview/sidebar/sidebar.css` +- Delete: `src/webview/sidebar/HistoryPanel.tsx` + +**Interfaces:** +- Consumes: the `restlab.openHistory` command (Task 3). +- Produces: nothing new — this is the final cutover; after this task, the sidebar's message switch no longer handles `getHistory`/`deleteHistoryEntry`/`clearAllHistory`/`restoreHistoryEntry` at all (only `HistoryEditorProvider` does, via the methods from Task 1). + +- [ ] **Step 1: Remove the in-place history cases from `SidebarProvider`, add `openHistory`** + +Replace the entire block (from `case "getHistory":` through the end of the `case "restoreHistoryEntry":` case) in `src/providers/SidebarProvider.ts`'s message switch: + +```ts + case "getHistory": + this._sendHistoryToWebview(); + break; + case "deleteHistoryEntry": + await this.deleteHistoryEntryById(message.entryId); + this._sendHistoryToWebview(); + break; + case "clearAllHistory": { + const cleared = await this.clearAllHistoryEntries(); + if (cleared) { + this._sendHistoryToWebview(); + } + break; + } + case "restoreHistoryEntry": + await this.restoreHistoryEntryById(message.entryId); + break; +``` + +with: + +```ts + case "openHistory": + vscode.commands.executeCommand("restlab.openHistory"); + break; +``` + +Then remove the now-fully-unused private method: + +```ts + private _sendHistoryToWebview() { + if (this._view) { + this._view.webview.postMessage({ + type: "historyUpdated", + entries: this._historyManager.getAll(), + }); + } + } + +``` + +(Keep `getHistoryEntries`/`deleteHistoryEntryById`/`clearAllHistoryEntries`/`restoreHistoryEntryById` from Task 1 — those are still used by `HistoryEditorProvider`.) + +- [ ] **Step 2: Revert `Sidebar.tsx` to tree-only, add the History button** + +Remove the `activeView` state: + +```ts + const [activeView, setActiveView] = useState<"collections" | "history">( + "collections", + ); +``` + +Remove the `handleClearAllHistory` handler: + +```ts + const handleClearAllHistory = () => { + vscode.postMessage({ type: "clearAllHistory" }); + }; +``` + +Add a new handler in its place: + +```ts + const handleOpenHistory = () => { + vscode.postMessage({ type: "openHistory" }); + }; +``` + +Remove the `HistoryPanel` import: + +```ts +import HistoryPanel from "./HistoryPanel"; +``` + +Keep the `HistoryIcon` import — it's reused below. + +Replace the returned JSX's header block: + +```tsx +
+ + +
+
+ {activeView === "collections" ? ( + <> + + + + ) : ( + + )} +
+``` + +with: + +```tsx +
+ + + + + +
+``` + +Then replace the body's tree/history conditional: + +```tsx + {activeView === "collections" ? ( +
{ + if (e.dataTransfer.types.includes(DRAG_TYPE_FOLDER)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + } + }} + onDrop={handleDropOnRoot} + > +``` + +...(everything through the matching closing tags)... + +```tsx +
+ ) : ( + + )} + + ); +}; +``` + +with the tree rendered unconditionally (drop the ternary, keep the tree's own JSX exactly as-is): + +```tsx +
{ + if (e.dataTransfer.types.includes(DRAG_TYPE_FOLDER)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + } + }} + onDrop={handleDropOnRoot} + > +``` + +...(everything through the matching closing tags, unchanged)... + +```tsx +
+ + ); +}; +``` + +`Tooltip` is already imported at the top of this file (used elsewhere) — no new import needed for it. + +- [ ] **Step 3: Delete the now-unused sidebar `HistoryPanel.tsx`** + +```bash +rm src/webview/sidebar/HistoryPanel.tsx +``` + +- [ ] **Step 4: Strip the now-dead history/toggle CSS from `sidebar.css`** + +`src/webview/sidebar/sidebar.css` is currently 813 lines. Everything from the `/* STATUS & RESPONSE BADGES (ported from request editor) */` comment (line 581) through the end of the file (line 813) exists solely to support the sidebar's in-place history list and toggle, both removed in Steps 1–3 — confirm via `grep -rn "remove-btn\|add-btn\|response-header-row\|status-badge\|time-badge\|history-\|sb-view-\|sb-history" src/webview/sidebar/*.tsx` that no `.tsx` file in this bundle references any of these classes anymore, then truncate the file to its first 580 lines: + +```bash +head -n 580 src/webview/sidebar/sidebar.css > /tmp/sidebar.css.trimmed +mv /tmp/sidebar.css.trimmed src/webview/sidebar/sidebar.css +``` + +Verify the file now ends cleanly (no trailing dangling rule) by checking the last few lines: + +```bash +tail -n 15 src/webview/sidebar/sidebar.css +``` + +Expected: the `@keyframes sbTooltipFadeIn { ... }` block, with nothing after it. + +- [ ] **Step 5: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 6: Manual verification (developer runs this)** + +Run `npm run watch`, launch the Extension Development Host. Confirm: +- The sidebar shows only the collection tree — no Collections/History toggle. +- A small History icon button sits next to New Collection/Import, same height as both. +- Clicking it opens a full-width "History" tab in the main editor area (or refocuses it if already open) listing every request ever sent, newest first, with the same expand/Restore/Delete/Clear-All behavior as before. +- Sending a request from any open request editor updates the History panel's list live if it's open. +- Restoring/deleting/clearing from the History panel correctly updates the affected request's own per-request History tab if that request's panel is also open. +- Opening the History panel a second time (via the button) reveals the same tab rather than creating a duplicate. + +- [ ] **Step 7: Commit** + +```bash +git add src/webview/sidebar/Sidebar.tsx src/providers/SidebarProvider.ts src/webview/sidebar/sidebar.css +git rm src/webview/sidebar/HistoryPanel.tsx +git commit -m "feat: replace in-sidebar history list with a button that opens the History panel" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** singleton editor-panel provider (Task 2), new 4th bundle + build wiring (Task 2), shared-method extraction to avoid duplicating restore/delete/clear logic (Task 1), command registration + live-refresh notification swap (Task 3), sidebar cutover to button + tree-only (Task 4), dead-CSS cleanup (Task 4) — all covered. +- **Placeholder scan:** no TBD/TODO; every step shows exact code. +- **Type consistency:** `HistoryEditorProvider.openHistoryPanel`/`.refreshIfOpen`, `SidebarProvider.getHistoryEntries`/`.deleteHistoryEntryById`/`.clearAllHistoryEntries`/`.restoreHistoryEntryById` are spelled identically everywhere they're produced and consumed. +- **Incremental testability:** Tasks 1–3 keep the existing in-sidebar history UI fully functional throughout (pure refactor + additive-only), so the feature never regresses mid-rollout; Task 4 is the single cutover point, and it's also the first point the new panel becomes visible — matching how the original 8-task plan sequenced per-request-tab-first, global-view-second. diff --git a/docs/superpowers/plans/2026-07-16-request-history.md b/docs/superpowers/plans/2026-07-16-request-history.md new file mode 100644 index 0000000..e9f1d48 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-request-history.md @@ -0,0 +1,2007 @@ +# Request History Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users view, restore, and delete past sends of an HTTP request, both scoped to a single saved request (a new History tab in the request editor) and across the whole workspace (a new History section in the sidebar). + +**Architecture:** A new `HistoryManager` class owns a single `globalState` array (`restlab.history`) of `HistoryEntry` records, capped per-request and globally. `RequestEditorProvider` records one entry every time a request is sent (success or network failure) and serves per-request reads/writes to its own webview panel. `SidebarProvider` serves the unfiltered global list to the sidebar webview and handles restoring into requests that may not have an open panel. Both webviews render history through one shared React component, `HistoryEntryList`. + +**Tech Stack:** TypeScript (strict), VS Code Extension API, React 18 (classic JSX runtime), esbuild-based `tsx` for disposable verification scripts (no test framework exists in this repo). + +## Global Constraints + +- Strict TypeScript — never add `@ts-nocheck`, `@ts-ignore`, or eslint-disable comments; fix root causes. +- This repo has **no test suite and no lint script**. Every task's own verification step is `npx tsc --noEmit` (run it yourself) plus a **manual verification** description (for the developer to run via `npm run watch` + the Extension Development Host — do not run `npm run build` or `npm run watch` yourself, and do not claim the manual step passed). +- Keep components under 500 lines; extract a sub-component rather than growing an existing file past that. +- `HistoryEntry.request.*` fields must stay **raw/pre-interpolation** (as configured, relative to the folder's `baseUrl`, may contain `{{variables}}`) — never the fully-resolved values used to execute the HTTP call. Only `resolvedUrl` carries the resolved value, for display only. This is load-bearing for Restore correctness (see spec's "Why raw, not resolved" note). +- Constants (not user-configurable): `MAX_PER_REQUEST = 20`, `MAX_GLOBAL = 200`, `MAX_BODY_BYTES = 200_000`. +- Spec: `docs/superpowers/specs/2026-07-16-request-history-design.md`. + +--- + +### Task 1: History data model & relative-time helper + +**Files:** +- Modify: `src/webview/types/internal.types.ts` +- Modify: `src/webview/helpers/helper.ts` + +**Interfaces:** +- Produces: `HistoryEntry` interface (consumed by every later task), `formatRelativeTime(timestamp: number): string` helper (consumed by `HistoryEntryList` in Task 4). + +- [ ] **Step 1: Add the `HistoryEntry` interface** + +Open `src/webview/types/internal.types.ts` and insert this new interface directly after the existing `ResponseData` interface (after line 104, before `RequestEditorProps`): + +```ts +export interface HistoryEntry { + id: string; + requestId: string; + requestName: string; + folderId: string; + timestamp: number; + request: { + method: string; + url: string; + resolvedUrl: string; + headers: Header[]; + params: Header[]; + body?: string; + contentType?: string; + formData?: FormDataItem[]; + cookies?: Cookie[]; + }; + response: ResponseData; + truncated?: boolean; +} +``` + +- [ ] **Step 2: Add the relative-time helper** + +Open `src/webview/helpers/helper.ts` and append this function at the end of the file: + +```ts +// Format a timestamp as a short relative-time string (e.g. "5m ago") +export const formatRelativeTime = (timestamp: number): string => { + const diffSec = Math.floor((Date.now() - timestamp) / 1000); + if (diffSec < 5) return "Just now"; + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) return `${diffHour}h ago`; + const diffDay = Math.floor(diffHour / 24); + if (diffDay < 7) return `${diffDay}d ago`; + return new Date(timestamp).toLocaleDateString(); +}; +``` + +- [ ] **Step 3: Verify** + +Run: `npx tsc --noEmit` +Expected: no errors (the new interface and function are unused so far — this project's `tsconfig.json` has no `noUnusedLocals`/`noUnusedParameters`, so that alone does not error). + +- [ ] **Step 4: Commit** + +```bash +git add src/webview/types/internal.types.ts src/webview/helpers/helper.ts +git commit -m "feat: add HistoryEntry type and relative-time helper" +``` + +--- + +### Task 2: `HistoryManager` data layer + +**Files:** +- Create: `src/providers/HistoryManager.ts` + +**Interfaces:** +- Consumes: `HistoryEntry` from `src/webview/types/internal.types.ts` (Task 1). +- Produces: `class HistoryManager` with `constructor(context: vscode.ExtensionContext)` and methods `getAll(): HistoryEntry[]`, `getForRequest(requestId: string): HistoryEntry[]`, `addEntry(input: Omit): Promise`, `deleteEntry(entryId: string): Promise`, `clearForRequest(requestId: string): Promise`, `clearAll(): Promise` — all consumed by Task 3, 6. + +- [ ] **Step 1: Create `HistoryManager.ts`** + +```ts +import type * as vscode from "vscode"; +import { HistoryEntry } from "../webview/types/internal.types"; + +const STORAGE_KEY = "restlab.history"; +const MAX_PER_REQUEST = 20; +const MAX_GLOBAL = 200; +const MAX_BODY_BYTES = 200_000; + +function truncateIfNeeded(value: string | undefined): { + value: string | undefined; + truncated: boolean; +} { + if (!value) return { value, truncated: false }; + const byteLength = Buffer.byteLength(value, "utf8"); + if (byteLength <= MAX_BODY_BYTES) return { value, truncated: false }; + const sliced = value.slice(0, MAX_BODY_BYTES); + return { + value: `${sliced}\n...[truncated for storage, original size ${byteLength} bytes]`, + truncated: true, + }; +} + +export class HistoryManager { + constructor(private readonly context: vscode.ExtensionContext) {} + + private _getAll(): HistoryEntry[] { + return this.context.globalState.get(STORAGE_KEY, []); + } + + private async _setAll(entries: HistoryEntry[]): Promise { + await this.context.globalState.update(STORAGE_KEY, entries); + } + + public getAll(): HistoryEntry[] { + return this._getAll(); + } + + public getForRequest(requestId: string): HistoryEntry[] { + return this._getAll().filter((e) => e.requestId === requestId); + } + + public async addEntry( + input: Omit, + ): Promise { + const bodyResult = truncateIfNeeded(input.request.body); + const responseDataResult = truncateIfNeeded(input.response.data); + + const entry: HistoryEntry = { + ...input, + id: `history-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: Date.now(), + request: { ...input.request, body: bodyResult.value }, + response: { ...input.response, data: responseDataResult.value ?? "" }, + truncated: bodyResult.truncated || responseDataResult.truncated, + }; + + let entries = [entry, ...this._getAll()]; + + // Cap entries for this specific request first (newest-first order preserved) + let keptForRequest = 0; + entries = entries.filter((e) => { + if (e.requestId !== entry.requestId) return true; + keptForRequest += 1; + return keptForRequest <= MAX_PER_REQUEST; + }); + + // Then cap the global list + if (entries.length > MAX_GLOBAL) { + entries = entries.slice(0, MAX_GLOBAL); + } + + await this._setAll(entries); + return entry; + } + + public async deleteEntry(entryId: string): Promise { + await this._setAll(this._getAll().filter((e) => e.id !== entryId)); + } + + public async clearForRequest(requestId: string): Promise { + await this._setAll(this._getAll().filter((e) => e.requestId !== requestId)); + } + + public async clearAll(): Promise { + await this._setAll([]); + } +} +``` + +Note the `import type * as vscode` (not a plain `import`): `vscode.ExtensionContext` is used only as a type here, never as a runtime value, so marking it `import type` makes esbuild/tsx elide the import entirely. This matters for Step 3 below, where the class is exercised outside the extension host (`vscode` module doesn't exist there) — and it's simply the correct way to import a type-only dependency either way. + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Write and run a disposable verification script** + +There is no test framework in this repo (per `CLAUDE.md`); verify the pruning/truncation logic with a throwaway script run via the already-installed `tsx` runner, then delete it — it must not be committed. + +Create `verify-history-manager.ts` at the repository root: + +```ts +import { HistoryManager } from "./src/providers/HistoryManager"; + +class FakeMemento { + private store = new Map(); + get(key: string, defaultValue?: T): T { + return (this.store.has(key) ? this.store.get(key) : defaultValue) as T; + } + async update(key: string, value: unknown): Promise { + this.store.set(key, value); + } +} + +async function main() { + const context = { globalState: new FakeMemento() } as any; + const manager = new HistoryManager(context); + + for (let i = 0; i < 25; i++) { + await manager.addEntry({ + requestId: "r1", + requestName: "Test", + folderId: "f1", + request: { + method: "GET", + url: `/posts/${i}`, + resolvedUrl: `https://example.com/posts/${i}`, + headers: [], + params: [], + }, + response: { status: 200, statusText: "OK", headers: {}, data: "{}", time: 10, size: 2 }, + }); + } + + const forR1 = manager.getForRequest("r1"); + console.log("count for r1:", forR1.length, "expected 20"); + if (forR1.length !== 20) throw new Error("FAIL: per-request cap not enforced"); + if (forR1[0].request.url !== "/posts/24") { + throw new Error(`FAIL: newest entry not first, got ${forR1[0].request.url}`); + } + + const bigBody = "x".repeat(300_000); + const entry = await manager.addEntry({ + requestId: "r2", + requestName: "Big", + folderId: "f1", + request: { method: "GET", url: "/big", resolvedUrl: "https://example.com/big", headers: [], params: [] }, + response: { status: 200, statusText: "OK", headers: {}, data: bigBody, time: 10, size: bigBody.length }, + }); + console.log("truncated:", entry.truncated, "expected true"); + if (entry.truncated !== true) throw new Error("FAIL: large response should be truncated"); + + await manager.deleteEntry(entry.id); + console.log("after delete, r2 count:", manager.getForRequest("r2").length, "expected 0"); + if (manager.getForRequest("r2").length !== 0) throw new Error("FAIL: deleteEntry did not remove entry"); + + console.log("ALL CHECKS PASSED"); +} + +main(); +``` + +Run: `npx tsx verify-history-manager.ts` +Expected output ends with `ALL CHECKS PASSED` and no thrown error. + +- [ ] **Step 4: Delete the disposable script** + +```bash +rm verify-history-manager.ts +``` + +- [ ] **Step 5: Commit** + +```bash +git add src/providers/HistoryManager.ts +git commit -m "feat: add HistoryManager for capped, truncated request history storage" +``` + +--- + +### Task 3: Record history from `RequestEditorProvider`, wire `HistoryManager` through the extension host + +**Files:** +- Modify: `src/providers/RequestEditorProvider.ts` +- Modify: `src/providers/SidebarProvider.ts` +- Modify: `src/extension.ts` +- Modify: `src/webview/request/RequestContext.tsx` + +**Interfaces:** +- Consumes: `HistoryManager` (Task 2), `HistoryEntry` (Task 1). +- Produces: `RequestEditorProvider.openRequestEditor(context, requestId, requestName, folderId, historyManager, sidebarProvider?)` (new signature — the `historyManager` param is inserted before `sidebarProvider`), `RequestEditorProvider.refreshPanelConfig(context, requestId, folderId, sidebarProvider)` static method (consumed by Task 6), `SidebarProvider` constructor now takes `historyManager` as a third argument, `SidebarProvider.notifyHistoryChanged(): void` (consumed by this task and available to future callers), webview messages `historyUpdated` (`{type, entries}`) and `historyRestored` (`{type, request}`) posted to the request-editor panel, and a new `historySnapshot` field on the outgoing `sendRequest` webview message. + +- [ ] **Step 1: Send a raw config snapshot alongside every `sendRequest` message** + +In `src/webview/request/RequestContext.tsx`, find the `vscode.postMessage` call inside `handleSendRequest` (near the end of that callback): + +```ts + vscode.postMessage({ + type: "sendRequest", + method: config.method, + url: fullUrl, + headers: interpolatedHeaders, + body: requestBody, + formData: formDataWithFiles, + cookies: enabledCookies, + }); +``` + +Replace it with: + +```ts + vscode.postMessage({ + type: "sendRequest", + method: config.method, + url: fullUrl, + headers: interpolatedHeaders, + body: requestBody, + formData: formDataWithFiles, + cookies: enabledCookies, + historySnapshot: { + method: config.method, + url: config.url, + headers: config.headers || [], + params: config.params || [], + body: config.body, + contentType: config.contentType, + formData: config.formData, + cookies: config.cookies, + }, + }); +``` + +`historySnapshot` carries the raw, pre-interpolation, request-level-only config — exactly what Restore will later write back — separate from the interpolated `headers`/`body`/`url` used to actually execute the call. + +- [ ] **Step 2: Update `RequestEditorProvider.ts` imports and signature** + +Replace the import block at the top of `src/providers/RequestEditorProvider.ts`: + +```ts +import axios, { AxiosRequestConfig } from "axios"; +import FormData from "form-data"; +import * as vscode from "vscode"; +import { getNonce } from "../utils/getNonce"; +import { RequestConfig, ResponseCookie } from "../webview/types/internal.types"; +import { SidebarProvider } from "./SidebarProvider"; +``` + +with: + +```ts +import axios, { AxiosRequestConfig } from "axios"; +import FormData from "form-data"; +import * as vscode from "vscode"; +import { getNonce } from "../utils/getNonce"; +import { + FormDataItem, + RequestConfig, + ResponseCookie, + ResponseData, +} from "../webview/types/internal.types"; +import { HistoryManager } from "./HistoryManager"; +import { SidebarProvider } from "./SidebarProvider"; +``` + +Then change the `openRequestEditor` signature from: + +```ts + public static openRequestEditor( + context: vscode.ExtensionContext, + requestId: string, + requestName: string, + folderId: string, + sidebarProvider?: SidebarProvider, + ) { +``` + +to: + +```ts + public static openRequestEditor( + context: vscode.ExtensionContext, + requestId: string, + requestName: string, + folderId: string, + historyManager: HistoryManager, + sidebarProvider?: SidebarProvider, + ) { +``` + +- [ ] **Step 3: Include history in the `getConfig` response** + +In the `case "getConfig":` handler, the `panel.webview.postMessage({ type: "configLoaded", config: {...}, folderConfig: ..., ... })` call currently ends with: + +```ts + folderConfig: folderConfig, + envVariables: envVariables, + collectionId: collectionId, + environments: collectionData.environments, + activeEnvironmentId: collectionData.activeEnvironmentId, + }); + break; +``` + +Add a `history` field: + +```ts + folderConfig: folderConfig, + envVariables: envVariables, + collectionId: collectionId, + environments: collectionData.environments, + activeEnvironmentId: collectionData.activeEnvironmentId, + history: historyManager.getForRequest(requestId), + }); + break; +``` + +- [ ] **Step 4: Record a history entry on every send, and add per-panel history messages** + +Replace the entire `case "sendRequest":` block: + +```ts + case "sendRequest": + try { + const response = await provider._sendHttpRequest( + message.method, + message.url, + message.headers, + message.body, + message.formData, + message.cookies, + ); + panel.webview.postMessage({ + type: "responseReceived", + response, + }); + } catch (error: any) { + panel.webview.postMessage({ + type: "responseReceived", + response: { + status: 0, + statusText: "Error", + headers: {}, + data: error.message || "Request failed", + time: 0, + }, + }); + } + break; +``` + +with: + +```ts + case "sendRequest": { + const recordHistory = async (response: ResponseData) => { + const snapshot = message.historySnapshot || {}; + const strippedFormData: FormDataItem[] = (snapshot.formData || []).map( + (field: FormDataItem) => + field.type === "file" + ? { key: field.key, type: field.type, fileName: field.fileName } + : field, + ); + await historyManager.addEntry({ + requestId, + requestName, + folderId, + request: { + method: snapshot.method || message.method, + url: snapshot.url || "", + resolvedUrl: message.url, + headers: snapshot.headers || [], + params: snapshot.params || [], + body: snapshot.body, + contentType: snapshot.contentType, + formData: strippedFormData, + cookies: snapshot.cookies, + }, + response, + }); + panel.webview.postMessage({ + type: "historyUpdated", + entries: historyManager.getForRequest(requestId), + }); + sidebarProvider?.notifyHistoryChanged(); + }; + + try { + const response = await provider._sendHttpRequest( + message.method, + message.url, + message.headers, + message.body, + message.formData, + message.cookies, + ); + panel.webview.postMessage({ + type: "responseReceived", + response, + }); + await recordHistory(response); + } catch (error: any) { + const errorResponse: ResponseData = { + status: 0, + statusText: "Error", + headers: {}, + data: error.message || "Request failed", + time: 0, + size: 0, + }; + panel.webview.postMessage({ + type: "responseReceived", + response: errorResponse, + }); + await recordHistory(errorResponse); + } + break; + } +``` + +Now add four new cases. Insert them directly after the closing `break;` of the `sendRequest` block above (still inside the same `switch (message.type)`, before `case "showInfo":`): + +```ts + case "getRequestHistory": + panel.webview.postMessage({ + type: "historyUpdated", + entries: historyManager.getForRequest(requestId), + }); + break; + case "restoreHistoryEntry": { + const entry = historyManager + .getForRequest(requestId) + .find((e) => e.id === message.entryId); + if (entry) { + panel.webview.postMessage({ + type: "historyRestored", + request: entry.request, + }); + } + break; + } + case "deleteHistoryEntry": + await historyManager.deleteEntry(message.entryId); + panel.webview.postMessage({ + type: "historyUpdated", + entries: historyManager.getForRequest(requestId), + }); + break; + case "clearRequestHistory": + await historyManager.clearForRequest(requestId); + panel.webview.postMessage({ + type: "historyUpdated", + entries: historyManager.getForRequest(requestId), + }); + break; +``` + +- [ ] **Step 5: Add the `refreshPanelConfig` static method** + +Add this new static method to the `RequestEditorProvider` class, directly after the existing `broadcastToAllPanels` static method: + +```ts + /** Push a fresh configLoaded payload to a single open panel, if it exists. Used after a global-history restore, which has no open editor form of its own to update. */ + public static refreshPanelConfig( + context: vscode.ExtensionContext, + requestId: string, + folderId: string, + sidebarProvider: SidebarProvider, + ): void { + const panel = RequestEditorProvider.openPanels.get(requestId); + if (!panel) return; + + const savedRequest = context.globalState.get( + `restlab.request.${requestId}`, + ); + if (!savedRequest) return; + + const folderConfig = sidebarProvider.getInheritedConfig(folderId); + const envVariables = sidebarProvider.getActiveEnvVariables(folderId); + const collectionId = sidebarProvider.getRootCollectionId(folderId); + const collectionData = sidebarProvider.getCollectionData(folderId); + + panel.webview.postMessage({ + type: "configLoaded", + config: { + id: requestId, + name: savedRequest.name, + folderId, + method: savedRequest.method || "GET", + url: savedRequest.url || "", + headers: savedRequest.headers || [], + params: savedRequest.params || [], + body: savedRequest.body || "", + contentType: savedRequest.contentType || "", + formData: savedRequest.formData || [], + auth: savedRequest.auth, + cookies: savedRequest.cookies || [], + }, + folderConfig, + envVariables, + collectionId, + environments: collectionData.environments, + activeEnvironmentId: collectionData.activeEnvironmentId, + }); + } +``` + +- [ ] **Step 6: Wire `HistoryManager` into `SidebarProvider`'s constructor** + +In `src/providers/SidebarProvider.ts`, add an import (keep alphabetical with the existing `./FolderEditorProvider` / `./RequestEditorProvider` imports): + +```ts +import { HistoryManager } from "./HistoryManager"; +``` + +Change the constructor from: + +```ts + constructor( + private readonly _extensionUri: vscode.Uri, + private readonly _context: vscode.ExtensionContext, + ) { +``` + +to: + +```ts + constructor( + private readonly _extensionUri: vscode.Uri, + private readonly _context: vscode.ExtensionContext, + private readonly _historyManager: HistoryManager, + ) { +``` + +Then add these two methods directly after the existing `notifyActiveRequest` method: + +```ts + public notifyHistoryChanged(): void { + this._sendHistoryToWebview(); + } + + private _sendHistoryToWebview() { + if (this._view) { + this._view.webview.postMessage({ + type: "historyUpdated", + entries: this._historyManager.getAll(), + }); + } + } +``` + +(`_sendHistoryToWebview` is deliberately private and reused by the message-handling cases added in Task 6, so both paths push the exact same payload shape.) + +- [ ] **Step 7: Wire everything together in `extension.ts`** + +Add an import: + +```ts +import { HistoryManager } from "./providers/HistoryManager"; +``` + +Change the top of `activate()` from: + +```ts +export async function activate(context: vscode.ExtensionContext) { + console.log("REST Lab extension is now active!"); + await seedDefaultData(context); + + // Initialize the sidebar provider + const sidebarProvider = new SidebarProvider(context.extensionUri, context); +``` + +to: + +```ts +export async function activate(context: vscode.ExtensionContext) { + console.log("REST Lab extension is now active!"); + await seedDefaultData(context); + + // Initialize the history manager and sidebar provider + const historyManager = new HistoryManager(context); + const sidebarProvider = new SidebarProvider( + context.extensionUri, + context, + historyManager, + ); +``` + +Then update the `restlab.openRequest` command registration from: + +```ts + context.subscriptions.push( + vscode.commands.registerCommand( + "restlab.openRequest", + (requestId: string, requestName: string, folderId: string) => { + RequestEditorProvider.openRequestEditor( + context, + requestId, + requestName, + folderId, + sidebarProvider, + ); + }, + ), + ); +``` + +to: + +```ts + context.subscriptions.push( + vscode.commands.registerCommand( + "restlab.openRequest", + (requestId: string, requestName: string, folderId: string) => { + RequestEditorProvider.openRequestEditor( + context, + requestId, + requestName, + folderId, + historyManager, + sidebarProvider, + ); + }, + ), + ); +``` + +- [ ] **Step 8: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. (`SidebarProvider`'s `notifyHistoryChanged`/`_sendHistoryToWebview` compile even though nothing calls `notifyHistoryChanged` externally yet besides `RequestEditorProvider` — that call already exists from Step 4.) + +- [ ] **Step 9: Manual verification (developer runs this)** + +This step has no visible UI yet (that's Task 5), so it's a regression check only: run `npm run watch`, launch the Extension Development Host, confirm the sidebar still loads its collections, opening a request still loads its config, and sending a request still shows a response exactly as before. No new UI should appear yet. + +- [ ] **Step 10: Commit** + +```bash +git add src/providers/RequestEditorProvider.ts src/providers/SidebarProvider.ts src/extension.ts src/webview/request/RequestContext.tsx +git commit -m "feat: record request history on every send and wire HistoryManager through the extension host" +``` + +--- + +### Task 4: Shared history UI components + +**Files:** +- Create: `src/webview/components/icons/HistoryIcon.tsx` +- Create: `src/webview/components/HistoryEntryList.tsx` +- Modify: `src/webview/request/styles.css` +- Modify: `src/webview/sidebar/sidebar.css` + +**Interfaces:** +- Consumes: `HistoryEntry` (Task 1), `formatJson`/`formatSize`/`getStatusColor`/`formatRelativeTime` from `../helpers/helper` (Task 1 adds the last one; the others already exist). +- Produces: `HistoryEntryList` component with props `{ entries: HistoryEntry[]; showRequestName?: boolean; onRestore: (entryId: string) => void; onDelete: (entryId: string) => void; }`, consumed by Task 5 (`HistoryTab.tsx`) and Task 7 (`HistoryPanel.tsx`). `HistoryIcon` consumed by Task 7 (`Sidebar.tsx`). + +- [ ] **Step 1: Create the history icon** + +```tsx +import React from "react"; + +type HistoryIconProps = { + className?: string; +}; + +const HistoryIcon = ({ className }: HistoryIconProps) => ( + + + + +); + +export default HistoryIcon; +``` + +Save as `src/webview/components/icons/HistoryIcon.tsx`. + +- [ ] **Step 2: Create the shared `HistoryEntryList` component** + +```tsx +import React, { useState } from "react"; +import { + formatJson, + formatRelativeTime, + formatSize, + getStatusColor, +} from "../helpers/helper"; +import { HistoryEntry } from "../types/internal.types"; +import Tooltip from "./Tooltip"; +import TrashIcon from "./icons/TrashIcon"; + +interface HistoryEntryListProps { + entries: HistoryEntry[]; + showRequestName?: boolean; + onRestore: (entryId: string) => void; + onDelete: (entryId: string) => void; +} + +const renderBody = (body: string | undefined, contentType?: string): string => { + if (!body) return ""; + return contentType?.includes("json") ? formatJson(body) : body; +}; + +const HistoryEntryList: React.FC = ({ + entries, + showRequestName = false, + onRestore, + onDelete, +}) => { + const [expandedId, setExpandedId] = useState(null); + + if (entries.length === 0) { + return

No history yet

; + } + + return ( +
+ {entries.map((entry) => { + const isExpanded = expandedId === entry.id; + return ( +
+
+ setExpandedId((prev) => (prev === entry.id ? null : entry.id)) + } + role="button" + tabIndex={0} + > + + {entry.request.method} + + {showRequestName && ( + {entry.requestName} + )} + + {entry.request.url || entry.request.resolvedUrl} + + + {entry.response.status === 0 ? "Network Error" : entry.response.status} + + {entry.response.time}ms + + {formatRelativeTime(entry.timestamp)} + +
+ + {isExpanded && ( +
+ {entry.truncated && ( +

Some content was truncated for storage.

+ )} + +
+

Request

+

+ {entry.request.method} {entry.request.resolvedUrl} +

+ {entry.request.headers.length > 0 && ( +
+ {entry.request.headers.map((h, i) => ( +
+ {h.key} + {h.value} +
+ ))} +
+ )} + {entry.request.body && ( +
+                      {renderBody(entry.request.body, entry.request.contentType)}
+                    
+ )} +
+ +
+

Response

+

+ {entry.response.status} {entry.response.statusText} ·{" "} + {formatSize(entry.response.size)} +

+
+ {Object.entries(entry.response.headers).map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+                    {renderBody(entry.response.data, entry.response.headers["content-type"])}
+                  
+
+ +
+ + + + +
+
+ )} +
+ ); + })} +
+ ); +}; + +export default HistoryEntryList; +``` + +Save as `src/webview/components/HistoryEntryList.tsx`. + +- [ ] **Step 3: Add history-specific CSS to `request/styles.css`** + +Append to the end of `src/webview/request/styles.css` (this bundle already has `.status-badge`, `.time-badge`, `.response-header-row`, `.empty-hint`, `.add-btn`, `.remove-btn` — it's only missing the standalone method-badge pill and the history-specific classes): + +```css +/* ============================================================ + METHOD BADGE (standalone pill, distinct from .method-select) + ============================================================ */ +.method-badge { + flex-shrink: 0; + font-size: 0.62em; + font-weight: 800; + padding: 0.32em 0.55em; + border-radius: 0.5em; + text-transform: uppercase; + letter-spacing: 0.06em; + border: 1px solid transparent; + line-height: 1; + min-width: 3.6em; + text-align: center; +} +.method-get { + color: var(--method-get); + background: rgba(34, 197, 94, 0.14); + border-color: rgba(34, 197, 94, 0.32); +} +.method-post { + color: var(--method-post); + background: rgba(59, 130, 246, 0.14); + border-color: rgba(59, 130, 246, 0.32); +} +.method-put { + color: var(--method-put); + background: rgba(245, 158, 11, 0.14); + border-color: rgba(245, 158, 11, 0.32); +} +.method-patch { + color: var(--method-patch); + background: rgba(168, 85, 247, 0.14); + border-color: rgba(168, 85, 247, 0.32); +} +.method-delete { + color: var(--method-delete); + background: rgba(239, 68, 68, 0.14); + border-color: rgba(239, 68, 68, 0.32); +} + +/* ============================================================ + HISTORY + ============================================================ */ +.history-list { + display: flex; + flex-direction: column; + gap: var(--rl-sp2); + padding: var(--rl-sp3); +} + +.history-entry { + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + background: var(--glass-bg); + overflow: hidden; +} + +.history-entry-row { + display: flex; + align-items: center; + gap: var(--rl-sp3); + padding: var(--rl-sp3); + cursor: pointer; +} + +.history-entry-row:hover { + background: rgba(56, 189, 248, 0.05); +} + +.history-request-name { + font-weight: 600; + font-size: 12px; +} + +.history-url { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + font-size: 12px; + opacity: 0.85; +} + +.history-timestamp { + font-size: 11px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +.history-entry-details { + border-top: 1px solid var(--glass-border); + padding: var(--rl-sp3); + display: flex; + flex-direction: column; + gap: var(--rl-sp3); +} + +.history-detail-section h4 { + margin: 0 0 var(--rl-sp2) 0; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.7; +} + +.history-detail-line { + font-size: 12px; + margin: 0 0 var(--rl-sp2) 0; + word-break: break-all; +} + +.history-body { + margin: var(--rl-sp2) 0 0 0; + padding: var(--rl-sp3); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + max-height: 240px; + overflow: auto; +} + +.history-entry-actions { + display: flex; + align-items: center; + gap: var(--rl-sp2); +} +``` + +- [ ] **Step 4: Add the equivalent CSS to `sidebar/sidebar.css`** + +This bundle already has `.method-badge` + `.method-get/post/put/patch/delete` (used by the request tree). It's missing `.status-badge`, `.time-badge`, `.response-header-row`, `.empty-hint`, `.add-btn`, `.remove-btn`, plus the history-specific classes. Append to the end of `src/webview/sidebar/sidebar.css`: + +```css +/* ============================================================ + STATUS & RESPONSE BADGES (ported from request editor) + ============================================================ */ +.status-badge { + padding: 0.18em 0.55em; + border-radius: 1.4em; + font-size: 0.7em; + font-weight: 700; + letter-spacing: 0.3px; +} +.status-success { + background: linear-gradient(90deg, rgba(34, 197, 94, 0.2) 0%, rgba(74, 222, 128, 0.2) 100%); + color: #22c55e; +} +.status-redirect { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(99, 102, 241, 0.2) 100%); + color: #3b82f6; +} +.status-client-error { + background: linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(251, 191, 36, 0.2) 100%); + color: #f59e0b; +} +.status-server-error { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(249, 115, 22, 0.2) 100%); + color: #ef4444; +} +.status-error { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.time-badge { + padding: 0.18em 0.55em; + border-radius: 1.4em; + font-size: 0.7em; + font-weight: 600; + background: var(--glass-bg); + color: var(--vscode-foreground); +} + +.empty-hint { + color: var(--vscode-descriptionForeground); + font-size: 12px; + font-style: italic; +} + +.add-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border: 1px dashed var(--vscode-panel-border); + border-radius: 4px; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; +} +.add-btn:hover { + border-color: var(--restlab-accent); + color: var(--restlab-accent); +} + +.remove-btn { + display: flex; + align-items: center; + justify-content: center; + width: var(--rl-ctrl); + height: var(--rl-ctrl); + border: none; + background: transparent; + color: var(--vscode-descriptionForeground); + border-radius: var(--rl-r2); + cursor: pointer; +} +.remove-btn:hover { + background: var(--restlab-danger-subtle); + color: var(--restlab-danger); +} + +.response-header-row { + display: flex; + padding: var(--rl-sp2) var(--rl-sp3); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 6px; + font-size: 12px; +} +.response-header-row .header-name { + font-weight: 700; + min-width: 11em; + background: var(--restlab-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.response-header-row .header-value { + flex: 1; + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + word-break: break-all; + opacity: 0.9; +} + +/* ============================================================ + HISTORY + ============================================================ */ +.history-list { + display: flex; + flex-direction: column; + gap: var(--rl-sp2); + padding: var(--rl-sp3); +} + +.history-entry { + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + background: var(--glass-bg); + overflow: hidden; +} + +.history-entry-row { + display: flex; + align-items: center; + gap: var(--rl-sp3); + padding: var(--rl-sp3); + cursor: pointer; +} +.history-entry-row:hover { + background: rgba(56, 189, 248, 0.05); +} + +.history-request-name { + font-weight: 600; + font-size: 12px; +} + +.history-url { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + font-size: 12px; + opacity: 0.85; +} + +.history-timestamp { + font-size: 11px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +.history-entry-details { + border-top: 1px solid var(--glass-border); + padding: var(--rl-sp3); + display: flex; + flex-direction: column; + gap: var(--rl-sp3); +} + +.history-detail-section h4 { + margin: 0 0 var(--rl-sp2) 0; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.7; +} + +.history-detail-line { + font-size: 12px; + margin: 0 0 var(--rl-sp2) 0; + word-break: break-all; +} + +.history-body { + margin: var(--rl-sp2) 0 0 0; + padding: var(--rl-sp3); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + max-height: 240px; + overflow: auto; +} + +.history-entry-actions { + display: flex; + align-items: center; + gap: var(--rl-sp2); +} +``` + +- [ ] **Step 5: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. (`HistoryEntryList` is unused until Task 5/7 import it — fine, same reasoning as Task 1.) + +- [ ] **Step 6: Commit** + +```bash +git add src/webview/components/icons/HistoryIcon.tsx src/webview/components/HistoryEntryList.tsx src/webview/request/styles.css src/webview/sidebar/sidebar.css +git commit -m "feat: add shared HistoryEntryList component and history styles" +``` + +--- + +### Task 5: Per-request History tab + +**Files:** +- Modify: `src/webview/request/RequestContext.tsx` +- Modify: `src/webview/request/RequestEditor.tsx` +- Create: `src/webview/request/HistoryTab.tsx` + +**Interfaces:** +- Consumes: `HistoryEntryList` (Task 4), `HistoryEntry` (Task 1), the `historyUpdated`/`historyRestored`/`configLoaded` messages produced by Task 3. +- Produces: `useRequestContext().historyEntries: HistoryEntry[]`, `.handleRestoreHistoryEntry(entryId)`, `.handleDeleteHistoryEntry(entryId)`, `.handleClearRequestHistory()` — consumed only within this task's own `HistoryTab.tsx`, but exposed on the shared context for consistency with every other tab. + +- [ ] **Step 1: Add `historyEntries` state and the `"history"` tab to `RequestContext.tsx`** + +Change the type import to include `HistoryEntry`: + +```ts +import { + AuthConfig, + Cookie, + FolderConfig, + FormDataItem, + HistoryEntry, + RequestConfig, + RequestEditorProps, + ResponseData, +} from "../types/internal.types"; +``` + +Change the `ActiveTab` type from: + +```ts +type ActiveTab = "headers" | "body" | "params" | "auth" | "cookies"; +``` + +to: + +```ts +type ActiveTab = "headers" | "body" | "params" | "auth" | "cookies" | "history"; +``` + +Add `historyEntries` to the `RequestContextValue` interface, under the existing `// State` section (near `isSaved`): + +```ts + isSaved: boolean; + historyEntries: HistoryEntry[]; +``` + +Add three handler signatures to the interface (near `handleSetActiveEnvironment`): + +```ts + handleSetActiveEnvironment: (envId: string | null) => void; + handleRestoreHistoryEntry: (entryId: string) => void; + handleDeleteHistoryEntry: (entryId: string) => void; + handleClearRequestHistory: () => void; +``` + +Add the state declaration next to `isSaved`'s `useState`: + +```ts + const [isSaved, setIsSaved] = useState(true); + const [historyEntries, setHistoryEntries] = useState([]); +``` + +- [ ] **Step 2: Handle the new incoming messages** + +In the `handleMessage` switch inside the message-handling `useEffect`, add `history` to the `configLoaded` case: + +```ts + case "configLoaded": + setConfig(message.config); + setFolderConfig(message.folderConfig || {}); + setEnvVariables(message.envVariables || {}); + setEnvironments(message.environments || []); + setActiveEnvironmentId(message.activeEnvironmentId ?? null); + setCollectionId(message.collectionId ?? null); + collectionIdRef.current = message.collectionId ?? null; + setHistoryEntries(message.history || []); + setIsSaved(true); +``` + +(this only adds the `setHistoryEntries(message.history || []);` line — everything else in that case is unchanged). + +Then add two new cases right after the existing `case "responseReceived":` block: + +```ts + case "historyUpdated": + setHistoryEntries(message.entries || []); + break; + case "historyRestored": + setConfig((prev) => ({ + ...prev, + method: message.request.method, + url: message.request.url, + headers: message.request.headers, + params: message.request.params, + body: message.request.body, + contentType: message.request.contentType, + formData: message.request.formData, + cookies: message.request.cookies, + })); + setIsSaved(false); + break; +``` + +- [ ] **Step 3: Add the three handlers** + +Add these next to `handleSetActiveEnvironment`: + +```ts + const handleRestoreHistoryEntry = useCallback((entryId: string) => { + vscode.postMessage({ type: "restoreHistoryEntry", entryId }); + }, []); + + const handleDeleteHistoryEntry = useCallback((entryId: string) => { + vscode.postMessage({ type: "deleteHistoryEntry", entryId }); + }, []); + + const handleClearRequestHistory = useCallback(() => { + vscode.postMessage({ type: "clearRequestHistory" }); + }, []); +``` + +- [ ] **Step 4: Expose the new state and handlers in the context value** + +In the `value: RequestContextValue = { ... }` object at the bottom of the file, add `historyEntries` under `// State`: + +```ts + isSaved, + historyEntries, +``` + +and the three handlers under `// Handlers`, near `handleSetActiveEnvironment`: + +```ts + handleSetActiveEnvironment, + handleRestoreHistoryEntry, + handleDeleteHistoryEntry, + handleClearRequestHistory, +``` + +- [ ] **Step 5: Create `HistoryTab.tsx`** + +```tsx +import React from "react"; +import HistoryEntryList from "../components/HistoryEntryList"; +import { useRequestContext } from "./RequestContext"; + +const HistoryTab: React.FC = () => { + const { + historyEntries, + handleRestoreHistoryEntry, + handleDeleteHistoryEntry, + handleClearRequestHistory, + } = useRequestContext(); + + return ( +
+
+
+

Request History

+ {historyEntries.length > 0 && ( + + )} +
+ +
+
+ ); +}; + +export default HistoryTab; +``` + +Save as `src/webview/request/HistoryTab.tsx`. + +- [ ] **Step 6: Wire the tab button and content into `RequestEditor.tsx`** + +Add the import: + +```ts +import HistoryTab from "./HistoryTab"; +``` + +Add `historyEntries` to the destructuring from `useRequestContext()` (near `isSaved`): + +```ts + isSaved, + historyEntries, +``` + +Add a new tab button directly after the existing Cookies tab button: + +```tsx + + +``` + +Add the tab content directly after the existing `{activeTab === "cookies" && }` line: + +```tsx + {activeTab === "cookies" && } + {activeTab === "history" && } +``` + +- [ ] **Step 7: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 8: Manual verification (developer runs this)** + +Run `npm run watch`, launch the Extension Development Host, open any request, send it 2-3 times (mix a couple of successful calls and, e.g., a request to an unreachable host to produce a network-error entry). Open the **History** tab: confirm entries appear newest-first with correct method/status/time/relative-timestamp badges. Click an entry to expand it and confirm request headers/body and response headers/body render. Click **Restore** on an older entry and confirm the form's method/URL/headers/body update and the Save button switches to "unsaved" — without any request being sent. Click **Delete** on one entry and confirm only that entry disappears. Click **Clear** and confirm the list empties. + +- [ ] **Step 9: Commit** + +```bash +git add src/webview/request/RequestContext.tsx src/webview/request/RequestEditor.tsx src/webview/request/HistoryTab.tsx +git commit -m "feat: add per-request History tab to the request editor" +``` + +--- + +### Task 6: Global history message handling in `SidebarProvider` + +**Files:** +- Modify: `src/providers/SidebarProvider.ts` + +**Interfaces:** +- Consumes: `HistoryManager` (Task 2, already wired into the constructor in Task 3), `RequestEditorProvider.refreshPanelConfig` (Task 3). +- Produces: handles incoming webview messages `getHistory`, `deleteHistoryEntry`, `clearAllHistory`, `restoreHistoryEntry` — consumed by Task 7's `HistoryPanel.tsx`. + +- [ ] **Step 1: Add the four new message cases** + +In `resolveWebviewView`'s `onDidReceiveMessage` switch, add these cases directly after the existing `case "saveExpandedFolders":` block (still before the switch's closing brace): + +```ts + case "getHistory": + this._sendHistoryToWebview(); + break; + case "deleteHistoryEntry": + await this._historyManager.deleteEntry(message.entryId); + this._sendHistoryToWebview(); + break; + case "clearAllHistory": { + const confirm = await vscode.window.showWarningMessage( + "Clear all request history? This cannot be undone.", + { modal: true }, + "Clear All", + ); + if (confirm === "Clear All") { + await this._historyManager.clearAll(); + this._sendHistoryToWebview(); + } + break; + } + case "restoreHistoryEntry": { + const entry = this._historyManager + .getAll() + .find((e) => e.id === message.entryId); + if (!entry) break; + + const folder = this._findFolder(entry.folderId); + const requestExists = folder?.requests?.some( + (r) => r.id === entry.requestId, + ); + if (!requestExists) { + vscode.window.showWarningMessage( + `Cannot restore "${entry.requestName}" — the original request no longer exists.`, + ); + break; + } + + const existingConfig = + this._context.globalState.get( + `restlab.request.${entry.requestId}`, + ) || {}; + const restoredConfig = { + ...existingConfig, + method: entry.request.method, + url: entry.request.url, + headers: entry.request.headers, + params: entry.request.params, + body: entry.request.body, + contentType: entry.request.contentType, + formData: entry.request.formData, + cookies: entry.request.cookies, + }; + await this._context.globalState.update( + `restlab.request.${entry.requestId}`, + restoredConfig, + ); + RequestEditorProvider.refreshPanelConfig( + this._context, + entry.requestId, + entry.folderId, + this, + ); + vscode.window.showInformationMessage( + `Restored "${entry.requestName}" from history`, + ); + break; + } +``` + +`RequestEditorProvider` is already imported in this file (used elsewhere for `closePanel`/`updatePanelTitle`), so no new import is needed. + +- [ ] **Step 2: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 3: Manual verification (developer runs this)** + +There's no sidebar UI to trigger these messages yet (that's Task 7) — this step is a type-check-only task. Defer functional verification to Task 7. + +- [ ] **Step 4: Commit** + +```bash +git add src/providers/SidebarProvider.ts +git commit -m "feat: handle global history messages (list, delete, clear, restore) in SidebarProvider" +``` + +--- + +### Task 7: Global History section in the sidebar + +**Files:** +- Modify: `src/webview/sidebar/Sidebar.tsx` +- Create: `src/webview/sidebar/HistoryPanel.tsx` +- Modify: `src/webview/sidebar/sidebar.css` + +**Interfaces:** +- Consumes: `HistoryEntryList` (Task 4), the `historyUpdated` message and `getHistory`/`deleteHistoryEntry`/`clearAllHistory`/`restoreHistoryEntry` message handling (Task 6). +- Produces: the sidebar's `vscode` singleton is now exported from `Sidebar.tsx` for `HistoryPanel.tsx` to reuse (VS Code only allows `acquireVsCodeApi()` to be called once per webview). + +- [ ] **Step 1: Export the existing `vscode` singleton from `Sidebar.tsx`** + +`Sidebar.tsx` already calls `acquireVsCodeApi()` once at module scope — `HistoryPanel.tsx` must reuse that same instance rather than calling `acquireVsCodeApi()` again (VS Code throws if it's called twice in the same webview). Change: + +```ts +const vscode = acquireVsCodeApi(); +``` + +to: + +```ts +export const vscode = acquireVsCodeApi(); +``` + +- [ ] **Step 2: Create `HistoryPanel.tsx`** + +```tsx +import React, { useEffect, useState } from "react"; +import HistoryEntryList from "../components/HistoryEntryList"; +import { HistoryEntry } from "../types/internal.types"; +import { vscode } from "./Sidebar"; + +const HistoryPanel: React.FC = () => { + const [entries, setEntries] = useState([]); + + useEffect(() => { + vscode.postMessage({ type: "getHistory" }); + + const handleMessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === "historyUpdated") { + setEntries(message.entries || []); + } + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, []); + + const handleRestore = (entryId: string) => { + vscode.postMessage({ type: "restoreHistoryEntry", entryId }); + }; + + const handleDelete = (entryId: string) => { + vscode.postMessage({ type: "deleteHistoryEntry", entryId }); + }; + + return ( +
+ +
+ ); +}; + +export default HistoryPanel; +``` + +Save as `src/webview/sidebar/HistoryPanel.tsx`. + +- [ ] **Step 3: Add the Collections/History toggle to `Sidebar.tsx`** + +Add imports: + +```ts +import HistoryIcon from "../components/icons/HistoryIcon"; +import HistoryPanel from "./HistoryPanel"; +``` + +Add state, next to the existing `isDragging` state declaration: + +```ts + const [isDragging, setIsDragging] = useState(false); + const [activeView, setActiveView] = useState<"collections" | "history">( + "collections", + ); +``` + +Add a handler next to `handleImportCollection`: + +```ts + const handleClearAllHistory = () => { + vscode.postMessage({ type: "clearAllHistory" }); + }; +``` + +Replace the returned JSX's `
...
` block: + +```tsx +
+

+ REST Lab +

+
+ + +
+
+``` + +with: + +```tsx +
+

+ REST Lab +

+
+ + +
+
+ {activeView === "collections" ? ( + <> + + + + ) : ( + + )} +
+
+``` + +Then wrap the existing `
...
` tree block (everything from `
`) in a conditional, and add the `HistoryPanel` alternative. The block currently reads: + +```tsx +
{ + if (e.dataTransfer.types.includes(DRAG_TYPE_FOLDER)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + } + }} + onDrop={handleDropOnRoot} + > + {folders.length === 0 ? ( + ... + ) : ( + ... + )} +
+
+ ); +}; +``` + +Change the opening to add the conditional, and add the `else` branch just before the component's own closing ``: + +```tsx + {activeView === "collections" ? ( +
{ + if (e.dataTransfer.types.includes(DRAG_TYPE_FOLDER)) { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + } + }} + onDrop={handleDropOnRoot} + > + {folders.length === 0 ? ( +
+ +

No collections yet

+

+ Create your first collection to get started +

+
+ ) : ( + <> + {folders.map((folder) => ( + + ))} + {isDragging && ( +
+ Drop here to move to root level +
+ )} + + )} +
+ ) : ( + + )} + + ); +}; +``` + +- [ ] **Step 4: Add toggle CSS** + +Append to `src/webview/sidebar/sidebar.css`: + +```css +.sb-view-toggle { + display: flex; + gap: 4px; + padding: 0 var(--rl-sp4); + margin-top: var(--rl-sp2); +} + +.sb-view-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid transparent; + border-radius: var(--rl-r2); + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: 12px; + cursor: pointer; +} + +.sb-view-btn.active { + background: var(--glass-bg); + border-color: var(--glass-border); + color: var(--vscode-foreground); +} + +.sb-history { + flex: 1; + overflow-y: auto; +} +``` + +- [ ] **Step 5: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 6: Manual verification (developer runs this)** + +Run `npm run watch`, launch the Extension Development Host. Send a couple of requests from two different saved requests. In the sidebar, click **History**: confirm entries from both requests appear, newest-first, each showing its request name. Expand one and click **Restore** — confirm an info toast appears, and if that request's panel is open, its form updates; reopen the request if the panel was closed and confirm the saved config reflects the restored values. Click **Delete** on an entry and confirm it disappears from both the sidebar list and that request's own History tab (if open). Click **Clear All**, confirm the modal warning, confirm accepting empties the list (and confirm cancelling leaves it untouched). Finally, delete the underlying request itself (via the tree), go back to History, and confirm attempting to Restore its orphaned entry shows the "no longer exists" warning instead of restoring anything. + +- [ ] **Step 7: Commit** + +```bash +git add src/webview/sidebar/Sidebar.tsx src/webview/sidebar/HistoryPanel.tsx src/webview/sidebar/sidebar.css +git commit -m "feat: add global History section to the sidebar" +``` + +--- + +### Task 8: Update CHANGELOG roadmap + +**Files:** +- Modify: `CHANGELOG.md` + +**Interfaces:** None — this is a documentation-only change. + +- [ ] **Step 1: Mark the roadmap item done** + +In `CHANGELOG.md`'s `## Future Roadmap` section, change: + +```markdown +- [ ] Request history +``` + +to: + +```markdown +- [x] Request history +``` + +Do not add a hand-authored `## x.y.z` release section — every existing one in this file (e.g. `## 1.6.0`, `## 1.6.1`) was generated by `semantic-release` from conventional commit messages at release time (see the `chore(release)` commits in `git log`), not hand-written in a feature branch. + +- [ ] **Step 2: Commit** + +```bash +git add CHANGELOG.md +git commit -m "docs: mark request history roadmap item done" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** data model + storage/pruning (Task 1-2), extension host recording + per-panel messages + `refreshPanelConfig` (Task 3), shared list UI (Task 4), per-request History tab (Task 5), global history message handling (Task 6) and sidebar UI (Task 7), restore-writes-raw-not-resolved correctness (Task 3 Step 1 + Global Constraints), truncation safeguard (Task 2), orphaned-request restore warning (Task 6/7), CHANGELOG (Task 8) — all covered. +- **Placeholder scan:** no TBD/TODO, no "add appropriate handling" — every step shows the exact code to write. +- **Type consistency:** `HistoryEntry`, `HistoryManager`, `historySnapshot`, `historyUpdated`, `historyRestored`, `refreshPanelConfig`, `notifyHistoryChanged` are spelled identically everywhere they're produced and consumed across tasks. diff --git a/docs/superpowers/plans/2026-07-17-history-opt-out.md b/docs/superpowers/plans/2026-07-17-history-opt-out.md new file mode 100644 index 0000000..d9de58c --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-history-opt-out.md @@ -0,0 +1,452 @@ +# History Opt-Out Toggle Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users pause request-history recording from a toggle in the History panel, without deleting anything already recorded. + +**Architecture:** `HistoryManager` owns a second `globalState` flag (`restlab.history.enabled`, default `true`) and gates `addEntry` on it internally — no caller needs to know the flag exists. `SidebarProvider` exposes thin wrapper methods matching its existing delegation pattern. `HistoryEditorProvider` piggybacks the flag onto every existing `historyUpdated` push (via one new shared payload helper) and adds one new incoming message, `setHistoryEnabled`. The History panel's webview gets a checkbox toggle plus a "paused" hint when recording is off. + +**Tech Stack:** TypeScript (strict), VS Code Extension API, React 18. + +## Global Constraints + +- Strict TypeScript — never `@ts-nocheck`, `@ts-ignore`, eslint-disable. +- No test suite/lint script in this repo — verification is `npx tsc --noEmit` (run it yourself) plus a manual step for the developer (do not run `npm run build`/`npm run watch` yourself). +- Turning the toggle off must never delete existing entries — only `addEntry` is gated; `deleteEntry`/`clearForRequest`/`clearAll` are untouched and still callable regardless of the flag. +- Spec: `docs/superpowers/specs/2026-07-17-history-opt-out-design.md`. + +--- + +### Task 1: Enabled flag on `HistoryManager`, wrapper methods on `SidebarProvider` + +**Files:** +- Modify: `src/providers/HistoryManager.ts` +- Modify: `src/providers/SidebarProvider.ts` + +**Interfaces:** +- Produces: `HistoryManager.isEnabled(): boolean`, `HistoryManager.setEnabled(enabled: boolean): Promise`, `HistoryManager.addEntry(...): Promise` (return type changed from non-nullable — its only caller, `RequestEditorProvider`'s `recordHistory`, already discards the return value, so this is a safe signature widening). `SidebarProvider.isHistoryEnabled(): boolean`, `SidebarProvider.setHistoryEnabled(enabled: boolean): Promise` — both consumed by Task 2's `HistoryEditorProvider`. + +- [ ] **Step 1: Add the enabled-flag storage key and methods to `HistoryManager`** + +Change the constants block at the top of `src/providers/HistoryManager.ts` from: + +```ts +const STORAGE_KEY = "restlab.history"; +const MAX_PER_REQUEST = 20; +``` + +to: + +```ts +const STORAGE_KEY = "restlab.history"; +const ENABLED_KEY = "restlab.history.enabled"; +const MAX_PER_REQUEST = 20; +``` + +Add two new public methods, directly after the constructor: + +```ts + public isEnabled(): boolean { + return this.context.globalState.get(ENABLED_KEY, true); + } + + public async setEnabled(enabled: boolean): Promise { + await this.context.globalState.update(ENABLED_KEY, enabled); + } +``` + +- [ ] **Step 2: Gate `addEntry` on the flag** + +Change the `addEntry` signature and add an early return. From: + +```ts + public async addEntry( + input: Omit, + ): Promise { + const bodyResult = truncateIfNeeded(input.request.body); +``` + +to: + +```ts + public async addEntry( + input: Omit, + ): Promise { + if (!this.isEnabled()) return null; + + const bodyResult = truncateIfNeeded(input.request.body); +``` + +- [ ] **Step 3: Add wrapper methods to `SidebarProvider`** + +In `src/providers/SidebarProvider.ts`, add two methods directly after the existing `getHistoryEntries()` method: + +```ts + public isHistoryEnabled(): boolean { + return this._historyManager.isEnabled(); + } + + public async setHistoryEnabled(enabled: boolean): Promise { + await this._historyManager.setEnabled(enabled); + } +``` + +- [ ] **Step 4: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 5: Write and run a disposable verification script** + +No test framework exists in this repo — verify with a throwaway `tsx` script, deleted afterward and never committed. + +Create `verify-history-enabled.ts` at the repository root: + +```ts +import { HistoryManager } from "./src/providers/HistoryManager"; + +class FakeMemento { + private store = new Map(); + get(key: string, defaultValue?: T): T { + return (this.store.has(key) ? this.store.get(key) : defaultValue) as T; + } + async update(key: string, value: unknown): Promise { + this.store.set(key, value); + } +} + +async function main() { + const context = { globalState: new FakeMemento() } as any; + const manager = new HistoryManager(context); + + console.log("default enabled:", manager.isEnabled(), "expected true"); + if (manager.isEnabled() !== true) throw new Error("FAIL: default should be enabled"); + + const entryInput = { + requestId: "r1", + requestName: "Test", + folderId: "f1", + request: { method: "GET", url: "/x", resolvedUrl: "https://example.com/x", headers: [], params: [] }, + response: { status: 200, statusText: "OK", headers: {}, data: "{}", time: 5, size: 2 }, + }; + + const entry1 = await manager.addEntry(entryInput); + console.log("entry recorded while enabled:", entry1 !== null, "expected true"); + if (entry1 === null) throw new Error("FAIL: addEntry should record while enabled"); + if (manager.getAll().length !== 1) throw new Error("FAIL: expected 1 stored entry"); + + await manager.setEnabled(false); + console.log("isEnabled after disable:", manager.isEnabled(), "expected false"); + if (manager.isEnabled() !== false) throw new Error("FAIL: setEnabled(false) should persist"); + + const entry2 = await manager.addEntry(entryInput); + console.log("entry recorded while disabled:", entry2, "expected null"); + if (entry2 !== null) throw new Error("FAIL: addEntry should no-op while disabled"); + if (manager.getAll().length !== 1) throw new Error("FAIL: disabled addEntry must not add a second entry"); + + await manager.deleteEntry(manager.getAll()[0].id); + console.log("delete still works while disabled, count:", manager.getAll().length, "expected 0"); + if (manager.getAll().length !== 0) throw new Error("FAIL: deleteEntry must work regardless of the flag"); + + console.log("ALL CHECKS PASSED"); +} + +main(); +``` + +Run: `npx tsx verify-history-enabled.ts` +Expected output ends with `ALL CHECKS PASSED` and no thrown error. + +- [ ] **Step 6: Delete the disposable script** + +```bash +rm verify-history-enabled.ts +``` + +- [ ] **Step 7: Commit** + +```bash +git add src/providers/HistoryManager.ts src/providers/SidebarProvider.ts +git commit -m "feat: add a globalState-backed enable/disable flag for request history" +``` + +--- + +### Task 2: Surface the toggle in the History panel + +**Files:** +- Modify: `src/providers/HistoryEditorProvider.ts` +- Modify: `src/webview/history/HistoryView.tsx` +- Modify: `src/webview/history/styles.css` + +**Interfaces:** +- Consumes: `SidebarProvider.isHistoryEnabled()`/`.setHistoryEnabled()` (Task 1). +- Produces: every `historyUpdated` message now carries `{ type: "historyUpdated", entries: HistoryEntry[], enabled: boolean }`; a new incoming message `{ type: "setHistoryEnabled", enabled: boolean }`. + +- [ ] **Step 1: Add a shared payload helper and the `setHistoryEnabled` case to `HistoryEditorProvider`** + +Replace the entire `refreshIfOpen` method: + +```ts + /** Push a fresh historyUpdated payload to the History panel, if it's open. */ + public static refreshIfOpen(sidebarProvider?: SidebarProvider): void { + if (!HistoryEditorProvider.panel || !sidebarProvider) return; + HistoryEditorProvider.panel.webview.postMessage({ + type: "historyUpdated", + entries: sidebarProvider.getHistoryEntries(), + }); + } +``` + +with: + +```ts + /** Push a fresh historyUpdated payload to the History panel, if it's open. */ + public static refreshIfOpen(sidebarProvider?: SidebarProvider): void { + if (!HistoryEditorProvider.panel || !sidebarProvider) return; + HistoryEditorProvider.panel.webview.postMessage( + HistoryEditorProvider._buildHistoryPayload(sidebarProvider), + ); + } + + private static _buildHistoryPayload(sidebarProvider: SidebarProvider) { + return { + type: "historyUpdated", + entries: sidebarProvider.getHistoryEntries(), + enabled: sidebarProvider.isHistoryEnabled(), + }; + } +``` + +Then replace the entire `onDidReceiveMessage` switch body: + +```ts + panel.webview.onDidReceiveMessage(async (message) => { + switch (message.type) { + case "getHistory": + panel.webview.postMessage({ + type: "historyUpdated", + entries: sidebarProvider.getHistoryEntries(), + }); + break; + case "deleteHistoryEntry": + await sidebarProvider.deleteHistoryEntryById(message.entryId); + panel.webview.postMessage({ + type: "historyUpdated", + entries: sidebarProvider.getHistoryEntries(), + }); + break; + case "clearAllHistory": { + const cleared = await sidebarProvider.clearAllHistoryEntries(); + if (cleared) { + panel.webview.postMessage({ + type: "historyUpdated", + entries: sidebarProvider.getHistoryEntries(), + }); + } + break; + } + case "restoreHistoryEntry": + await sidebarProvider.restoreHistoryEntryById(message.entryId); + panel.webview.postMessage({ + type: "historyUpdated", + entries: sidebarProvider.getHistoryEntries(), + }); + break; + } + }); +``` + +with: + +```ts + panel.webview.onDidReceiveMessage(async (message) => { + switch (message.type) { + case "getHistory": + panel.webview.postMessage( + HistoryEditorProvider._buildHistoryPayload(sidebarProvider), + ); + break; + case "deleteHistoryEntry": + await sidebarProvider.deleteHistoryEntryById(message.entryId); + panel.webview.postMessage( + HistoryEditorProvider._buildHistoryPayload(sidebarProvider), + ); + break; + case "clearAllHistory": { + const cleared = await sidebarProvider.clearAllHistoryEntries(); + if (cleared) { + panel.webview.postMessage( + HistoryEditorProvider._buildHistoryPayload(sidebarProvider), + ); + } + break; + } + case "restoreHistoryEntry": + await sidebarProvider.restoreHistoryEntryById(message.entryId); + panel.webview.postMessage( + HistoryEditorProvider._buildHistoryPayload(sidebarProvider), + ); + break; + case "setHistoryEnabled": + await sidebarProvider.setHistoryEnabled(message.enabled); + panel.webview.postMessage( + HistoryEditorProvider._buildHistoryPayload(sidebarProvider), + ); + break; + } + }); +``` + +- [ ] **Step 2: Add `enabled` state and the toggle to `HistoryView.tsx`** + +Replace the entire file: + +```tsx +import React, { useEffect, useState } from "react"; +import HistoryEntryList from "../components/HistoryEntryList"; +import { HistoryEntry } from "../types/internal.types"; + +declare function acquireVsCodeApi(): { + postMessage: (message: unknown) => void; + getState: () => unknown; + setState: (state: unknown) => void; +}; + +const vscode = acquireVsCodeApi(); + +export const HistoryView: React.FC = () => { + const [entries, setEntries] = useState([]); + const [enabled, setEnabled] = useState(true); + + useEffect(() => { + vscode.postMessage({ type: "getHistory" }); + + const handleMessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === "historyUpdated") { + setEntries(message.entries || []); + setEnabled(message.enabled ?? true); + } + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, []); + + const handleRestore = (entryId: string) => { + vscode.postMessage({ type: "restoreHistoryEntry", entryId }); + }; + + const handleDelete = (entryId: string) => { + vscode.postMessage({ type: "deleteHistoryEntry", entryId }); + }; + + const handleClearAll = () => { + vscode.postMessage({ type: "clearAllHistory" }); + }; + + const handleToggleEnabled = () => { + const next = !enabled; + setEnabled(next); + vscode.postMessage({ type: "setHistoryEnabled", enabled: next }); + }; + + return ( +
+
+

Request History

+
+ + {entries.length > 0 && ( + + )} +
+
+ {!enabled && ( +

+ Recording is paused — new requests won't be added to history. +

+ )} +
+ +
+
+ ); +}; +``` + +- [ ] **Step 3: Add CSS for the header actions group and the toggle** + +Append to `src/webview/history/styles.css`: + +```css +.history-page-header-actions { + display: flex; + align-items: center; + gap: var(--rl-sp4); +} + +.history-toggle { + display: flex; + align-items: center; + gap: var(--rl-sp2); + font-size: 12px; + color: var(--vscode-descriptionForeground); + cursor: pointer; + user-select: none; +} + +.history-toggle input[type="checkbox"] { + accent-color: var(--restlab-accent); + cursor: pointer; +} + +.history-paused-hint { + margin-bottom: var(--rl-sp3); +} +``` + +- [ ] **Step 4: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 5: Manual verification (developer runs this)** + +Run `npm run watch`, launch the Extension Development Host, open the History panel. Confirm: +- The "Record new requests" checkbox is checked by default. +- Sending a request from any request editor still adds a new entry while checked. +- Unchecking it shows the "Recording is paused" hint; sending another request afterward does NOT add a new entry to the list (existing entries stay). +- Re-checking it makes new sends start appearing again. +- Close and reopen the History panel (or restart the Extension Development Host) — the checkbox stays in whatever state you last left it (persisted via `globalState`). +- Clear All and individual Delete/Restore still work regardless of the toggle's state. + +- [ ] **Step 6: Commit** + +```bash +git add src/providers/HistoryEditorProvider.ts src/webview/history/HistoryView.tsx src/webview/history/styles.css +git commit -m "feat: add a toggle in the History panel to pause/resume request recording" +``` + +--- + +## Self-Review Notes + +- **Spec coverage:** flag storage + `addEntry` gate (Task 1), wrapper methods (Task 1), payload helper + new message + UI toggle + paused hint (Task 2) — all covered. +- **Placeholder scan:** no TBD/TODO; every step shows exact code. +- **Type consistency:** `isEnabled`/`setEnabled`/`isHistoryEnabled`/`setHistoryEnabled`/`_buildHistoryPayload`/`setHistoryEnabled` message type are spelled identically everywhere produced and consumed. diff --git a/docs/superpowers/specs/2026-07-16-history-editor-panel-design.md b/docs/superpowers/specs/2026-07-16-history-editor-panel-design.md new file mode 100644 index 0000000..a2c655c --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-history-editor-panel-design.md @@ -0,0 +1,61 @@ +# Global History as an Editor Panel Design + +**Date:** 2026-07-16 +**Branch:** feat/request-history +**Status:** Approved + +## Goal + +Replace the in-sidebar global History section (added earlier on this same branch) with a full-width editor-panel view, matching how request/folder editors already open. The sidebar is too narrow to comfortably show method/URL/status/headers/body for a list of history entries — moving it to the main editor area gives it the same real estate a request tab has. + +## What Changes vs. the Original Design + +The original design (`2026-07-16-request-history-design.md`) put global history in a sidebar section with a Collections/History toggle. That shipped, but the in-sidebar list "is not perfect" (cramped). This design supersedes only the *global* history surface — the per-request History tab inside the request editor is unaffected and stays exactly as it is. + +## Architecture + +- **New provider:** `src/providers/HistoryEditorProvider.ts`, following the same shape as `FolderEditorProvider`/`RequestEditorProvider`: + - `static openHistoryPanel(context, historyManager, sidebarProvider)` — creates a singleton `vscode.WebviewPanel` in `ViewColumn.One` if none is open, or reveals the existing one (a single module-level `panel: vscode.WebviewPanel | undefined`, not a `Map` — there's only ever one global history, unlike per-request panels). + - `static refreshIfOpen(historyManager)` — pushes a fresh `historyUpdated` message to the panel if it's currently open. This replaces `SidebarProvider.notifyHistoryChanged()`, which is removed. + - Its `onDidReceiveMessage` handles `getHistory`, `deleteHistoryEntry`, `clearAllHistory`, `restoreHistoryEntry` — the same four message types the sidebar used to handle — by delegating to new public methods on `SidebarProvider` (see below), so the restore/existence-check logic exists in exactly one place. + +- **`SidebarProvider.ts` refactor:** extract the four history operations currently inlined in its message switch into public methods, unchanged in behavior: + - `getHistoryEntries(): HistoryEntry[]` + - `async deleteHistoryEntryById(entryId: string): Promise` — keeps looking up the entry's `requestId` before deleting, then calling `RequestEditorProvider.refreshPanelHistory(requestId, historyManager)` for that request's open panel, exactly as the current sidebar case does. + - `async clearAllHistoryEntries(): Promise` — keeps the modal confirmation (`vscode.window.showWarningMessage`) inside this method, since it's host-side and works identically regardless of which webview triggered it; keeps computing the affected `requestId`s before clearing and calling `RequestEditorProvider.refreshPanelHistory` for each afterward. + - `async restoreHistoryEntryById(entryId: string): Promise` — keeps the existence check, the "no longer exists" warning, the direct `globalState` write, and the call to `RequestEditorProvider.refreshPanelConfig` for an open request panel. + - Remove `notifyHistoryChanged()` and `_sendHistoryToWebview()` — no longer needed once the sidebar stops displaying history. + - Remove the `getHistory`/`deleteHistoryEntry`/`clearAllHistory`/`restoreHistoryEntry` cases from the sidebar's own message switch; add one new case, `openHistory`, which calls `vscode.commands.executeCommand("restlab.openHistory")`. + - `RequestEditorProvider`'s `recordHistory` (in the `sendRequest` case) and the extracted methods above call `HistoryEditorProvider.refreshIfOpen(historyManager)` directly, instead of `sidebarProvider?.notifyHistoryChanged()`. + +- **`extension.ts`:** register `restlab.openHistory` → `HistoryEditorProvider.openHistoryPanel(context, historyManager, sidebarProvider)`. Not added to `package.json`'s command palette contributions — same as `restlab.openRequest`/`restlab.openFolderConfig`, it's triggered only from the sidebar button, not the command palette. + +- **`Sidebar.tsx` / `HistoryPanel.tsx`:** + - Remove `activeView` state, the `.sb-view-toggle` toggle, the tree/HistoryPanel ternary, and the `HistoryPanel` import — the sidebar body goes back to always showing the collection tree. + - Remove `handleClearAllHistory` (moves into the new bundle). + - Add a small, always-visible History icon button in `.sb-head-actions` (next to New Collection / Import), posting `{ type: "openHistory" }`. + - Delete `src/webview/sidebar/HistoryPanel.tsx` — its logic moves into the new bundle's root component. + +- **New webview bundle `src/webview/history/`:** + - `index.tsx` — bundle entry; calls `acquireVsCodeApi()` once (a separate webview panel gets its own separate API instance, so this is unrelated to — and doesn't conflict with — the sidebar's or request editor's own single call). + - `HistoryView.tsx` — root component: a header (title + "Clear All" button) and a body rendering the shared `HistoryEntryList` (`showRequestName`). On mount, posts `getHistory`; listens for `historyUpdated`; wires Restore/Delete/Clear to `restoreHistoryEntry`/`deleteHistoryEntry`/`clearAllHistory` messages — same message shapes the old `HistoryPanel.tsx` already used. + - `styles.css` — this bundle's own stylesheet (each Vite-built webview bundle ships its own CSS, per the existing three-bundle pattern), containing the design-token variables plus the `.history-*`/`.status-badge`/`.method-badge`/etc. classes already written twice (once in `request/styles.css`, once in `sidebar/sidebar.css`) — a third copy here, plus new layout rules for a full-page header/toolbar. + +- **`scripts/build.mts`:** add a 4th parallel Vite build for the `history` bundle, following the exact pattern already used for `sidebar`/`editor`/`request`. + +- **`RequestEditorProvider.ts`:** the `sidebarProvider?.notifyHistoryChanged()` call inside `recordHistory` is replaced with `HistoryEditorProvider.refreshIfOpen(historyManager)`. No signature changes needed elsewhere in this file. + +## Data Model / Storage + +No changes. `HistoryManager`, the `restlab.history` `globalState` key, and the `HistoryEntry` shape are all unchanged — this is purely a UI-surface relocation plus the DRY refactor it requires. + +## What Is Not Changed + +- The per-request History tab in the request editor (`HistoryTab.tsx`, `RequestContext.tsx` history state/handlers) — unaffected. +- `HistoryManager`'s pruning/truncation/storage behavior — unaffected. +- The raw-vs-resolved restore correctness property — unaffected; `restoreHistoryEntryById` keeps writing the same raw fields it always did. + +## User Experience + +- Sidebar: collection tree only, with a small History button in the header actions row. +- Clicking it opens (or refocuses) a full-width "History" tab in the main editor area, showing every request ever sent, newest first, with the same expand/Restore/Delete/Clear-All interactions as before — just with room to breathe. diff --git a/docs/superpowers/specs/2026-07-16-request-history-design.md b/docs/superpowers/specs/2026-07-16-request-history-design.md new file mode 100644 index 0000000..4e98285 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-request-history-design.md @@ -0,0 +1,128 @@ +# Request History Design + +**Date:** 2026-07-16 +**Branch:** feat/request-history (to be created) +**Status:** Approved + +## Goal + +Let users see and reuse past HTTP requests they've sent — both scoped to a single saved request ("did this endpoint 500 last time I hit it?") and across the whole workspace ("what did I send in the last hour?"). Closes the "Request history" item on the `CHANGELOG.md` Future Roadmap. + +## Scope + +- Per-request history: a new **History** tab in the request editor, alongside Body/Params/Headers/Auth/Cookies, showing only runs of that specific saved request. +- Global history: a new **History** section in the sidebar (a toggle alongside the existing collection tree) listing every request sent across the whole workspace, newest first. +- Both views share the same underlying store — global history is not a separate cap, it's an unfiltered view over the same list, filtered by `requestId` for the per-request tab. +- Out of scope: configurable retention (no settings UI — caps are constants), a "re-send directly from history" action (restore-then-send covers that), history for FolderEditor or bulk/collection-runner requests (there is no such feature yet). + +## Data Model & Storage + +New type in `src/webview/types/internal.types.ts`: + +```ts +export interface HistoryEntry { + id: string; + requestId: string; + requestName: string; // snapshot of the name at send time, in case later renamed/deleted + folderId: string; + timestamp: number; + request: { + method: string; + url: string; // AS CONFIGURED — relative to folder baseUrl, may contain {{vars}}. This is what Restore writes back. + resolvedUrl: string; // fully resolved URL actually sent, post-interpolation — DISPLAY ONLY, never restored + headers: Header[]; // request-level headers as configured (not merged with inherited folder headers) + params: Header[]; // request-level params as configured + body?: string; // as configured, pre-interpolation + contentType?: string; + formData?: FormDataItem[]; // file entries kept as { key, type, fileName } only; fileData stripped + cookies?: Cookie[]; + }; + response: ResponseData; // reuses existing type: status, statusText, headers, data, time, size, cookies — this reflects the actual interpolated call + truncated?: boolean; // true if request.body or response.data was capped before storing +} +``` + +**Why raw, not resolved, for `request.*`:** `RequestConfig.url`/`headers`/`body` are stored relative to the folder's `baseUrl` and may contain `{{variable}}` placeholders — that's the form the editor and `globalState` both expect. If history stored the fully-interpolated values that were actually put on the wire, restoring them would double-prepend `baseUrl` and freeze variable placeholders into literal values. So `request.*` captures the same pre-interpolation, request-level-only snapshot the editor already holds in its `config` state (excluding inherited folder headers/params, which don't belong on the request), and only `resolvedUrl` — a single extra string — captures what was truly sent, for display purposes. + +Stored under a new `globalState` key, `restlab.history`, as `HistoryEntry[]`, newest-first. + +### `HistoryManager` (`src/providers/HistoryManager.ts`) + +A plain class wrapping `context.globalState`, with no webview of its own: + +- `MAX_PER_REQUEST = 20`, `MAX_GLOBAL = 200`, `MAX_BODY_BYTES = 200_000` (constants, not user-configurable). +- `addEntry(input: Omit): Promise` + - Assigns `id` (nonce-based) and `timestamp` (`Date.now()`). + - Truncates `request.body` and `response.data` independently if either exceeds `MAX_BODY_BYTES`, setting `truncated: true` and appending a marker so the UI can show "response truncated for storage". + - Prepends to the list, then prunes: first drop the oldest entries sharing this `requestId` beyond `MAX_PER_REQUEST`, then drop the oldest entries overall beyond `MAX_GLOBAL`. + - Persists via `globalState.update` and returns the stored entry. +- `getAll(): HistoryEntry[]` +- `getForRequest(requestId: string): HistoryEntry[]` +- `deleteEntry(entryId: string): Promise` +- `clearForRequest(requestId: string): Promise` +- `clearAll(): Promise` + +## Extension Host Wiring + +- `extension.ts` creates one `HistoryManager` instance in `activate()`, passed to `SidebarProvider` (constructor) and to every `RequestEditorProvider.openRequestEditor(...)` call (new required parameter). +- The `sendRequest` message posted from the webview gains one extra field, `historySnapshot`, carrying the raw pre-interpolation `{ method, url, headers, params, body, contentType, formData, cookies }` straight from `config` (the same values the form already holds) — distinct from the interpolated `method`/`url`/`headers`/`body`/`formData`/`cookies` fields used to actually execute the axios call. +- `RequestEditorProvider`'s `sendRequest` handler: after producing `response` (both the success path and the existing catch-block error-response path), call `historyManager.addEntry(...)` with `historySnapshot` as `request.*` (stripping file `fileData` from any `formData` entries), `message.url` as `resolvedUrl`, and the response. Then: + - `panel.webview.postMessage({ type: 'historyUpdated', entries: historyManager.getForRequest(requestId) })` so that panel's History tab updates live. + - `sidebarProvider.notifyHistoryChanged()` — pushes `historyManager.getAll()` to the sidebar webview if it is currently resolved/visible. +- New per-panel messages handled in `RequestEditorProvider`: + - `getRequestHistory` → replies `historyUpdated` with `getForRequest(requestId)`. + - `restoreHistoryEntry` (`entryId`) → looks up the entry and replies with a `historyRestored` message carrying `entry.request.*`; the client merges those fields into the in-memory `RequestConfig` and marks it unsaved. Nothing is written to `globalState` until the user hits Save — no auto-send. + - `deleteHistoryEntry` (`entryId`) → `historyManager.deleteEntry`, replies with refreshed `historyUpdated`. + - `clearRequestHistory` → `historyManager.clearForRequest(requestId)`, replies with refreshed (empty) `historyUpdated`. +- New messages handled in `SidebarProvider` for the global view: + - `getHistory` → replies `historyUpdated` with `historyManager.getAll()`. + - `deleteHistoryEntry` (`entryId`) → deletes, re-pushes `historyUpdated`. + - `clearAllHistory` → `historyManager.clearAll()`, re-pushes `historyUpdated`. + - `restoreHistoryEntry` (`entryId`) → checks whether `requestId` still exists in the folder tree. If yes, writes the merged config to `restlab.request.` and, if that request's panel is currently open, calls a new `RequestEditorProvider.refreshPanelConfig(requestId)` static method (looks up the single matching panel in `openPanels` and posts a `configLoaded`-shaped refresh to it only — unlike `broadcastToAllPanels`, this never touches unrelated panels). If the request no longer exists, shows `vscode.window.showWarningMessage(...)` instead of restoring. + +## Webview UI + +### Shared list rendering + +A new `HistoryEntryList.tsx` component (used by both the per-request tab and the global panel) renders entries newest-first as expandable rows: + +- Collapsed row: method badge, status badge (colored green/yellow/red by 2xx/4xx/5xx, gray for the `status: 0` network-error case), response time, size, relative timestamp. The global variant also shows the request name. +- Expanded row: full read-only request (method, URL, headers, body) and response (status line, headers, body). Bodies are rendered as plain `
` text, JSON-pretty-printed via the existing `formatJson` helper when the content type is JSON — no Monaco instance per row, to keep a 200-row list cheap.
+- Row actions: **Restore** and **Delete**.
+
+### Per-request History tab
+
+- `ActiveTab` in `RequestContext.tsx` gains `"history"`.
+- `RequestEditor.tsx` gets a new tab button next to Cookies, with a count badge (`historyEntries.length`).
+- `RequestContext` gains `historyEntries: HistoryEntry[]` state: populated from the `configLoaded` payload (host includes `getForRequest(requestId)` alongside the config) and refreshed on `historyUpdated` push messages.
+- New `HistoryTab.tsx` renders `HistoryEntryList` scoped to the current request, plus a "Clear history for this request" button that posts `clearRequestHistory`.
+- Restore/Delete post `restoreHistoryEntry` / `deleteHistoryEntry` directly to this panel's own message handler.
+
+### Global History (sidebar)
+
+- `Sidebar.tsx` gains a small "Collections / History" toggle at the top of `sb-head`. Switching to History swaps the tree body for a new `HistoryPanel.tsx` and swaps the header actions (New Collection / Import) for a **Clear All** button (confirms via `vscode.window.showWarningMessage` Yes/No on the host side before clearing).
+- `HistoryPanel.tsx` posts `getHistory` on mount, listens for `historyUpdated`, and renders `HistoryEntryList` unscoped (all requests). Restore/Delete post to the sidebar's message handler.
+
+### Restore semantics
+
+The two entry points restore differently, because only one of them has a live editor form to leave "unsaved":
+
+- **From the per-request History tab** (an open editor panel): merges the historic `method`/`url`/`headers`/`params`/`body`/`contentType`/`formData`/`cookies` into the in-memory `RequestConfig` and marks it unsaved (`isSaved: false`) — the user must hit Save to persist, matching how every other in-place edit in the editor already behaves. Never auto-sends.
+- **From the global History sidebar** (no editor form in scope): writes the same fields directly into `restlab.request.` in `globalState` immediately, then refreshes that request's panel if it's open (via `RequestEditorProvider.refreshPanelConfig`). There is no "unsaved" intermediate state here since the sidebar has nothing to leave pending.
+
+## Edge Cases
+
+- **Request deleted after being sent**: history entries persist (they carry their own `requestName`/`folderId` snapshot). The per-request History tab simply won't exist anymore for a deleted request; those entries remain visible and restorable-if-recreated-elsewhere only through the global view, where Restore is disabled with a warning since there's no live request to write into.
+- **Network-failure sends** (DNS/timeout/refused): `RequestEditorProvider`'s existing catch-block already builds a `{status: 0, statusText: "Error", ...}` response object — these are recorded like any other entry so failed attempts show up in history too.
+- **Large bodies**: capped at `MAX_BODY_BYTES` per field with a `truncated` flag surfaced in the UI, so a handful of huge responses can't blow up `globalState`.
+- **Sidebar not visible when a send happens**: `notifyHistoryChanged()` is a no-op if the webview view isn't currently resolved; the global list is re-fetched via `getHistory` next time the sidebar (or its History section) becomes visible, following the existing `foldersUpdated` pattern.
+
+## What Is Not Changed
+
+- `FolderEditorProvider` — untouched.
+- Existing `saveConfig`/`getConfig` flows — untouched; history is purely additive.
+- Import/export flows — untouched; history is not included in collection export.
+
+## CHANGELOG
+
+On completion, mark `- [ ] Request history` as `- [x] Request history` in the Future Roadmap section. The dated `## x.y.z` release sections in this file are generated by `semantic-release` from conventional commit messages at release time (see the `chore(release)` commits in git history) — no hand-authored release entry is added here.
diff --git a/docs/superpowers/specs/2026-07-17-history-opt-out-design.md b/docs/superpowers/specs/2026-07-17-history-opt-out-design.md
new file mode 100644
index 0000000..41446d0
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-17-history-opt-out-design.md
@@ -0,0 +1,37 @@
+# History Enable/Disable Toggle Design
+
+**Date:** 2026-07-17
+**Branch:** feat/request-history
+**Status:** Approved
+
+## Goal
+
+Let users pause request-history recording without leaving the extension. Turning it off stops new entries from being recorded; it never deletes what's already there (Clear All remains the separate, explicit way to do that).
+
+## Storage
+
+`HistoryManager` (`src/providers/HistoryManager.ts`) gains a second `globalState` key, `ENABLED_KEY = "restlab.history.enabled"`, alongside the existing `restlab.history` entries key:
+
+- `public isEnabled(): boolean` — `this.context.globalState.get(ENABLED_KEY, true)` (on by default — this is an opt-out, not opt-in).
+- `public async setEnabled(enabled: boolean): Promise` — writes the flag.
+- `addEntry(...)` gains an early guard: if `!this.isEnabled()`, return `null` immediately without touching storage. Its return type changes from `Promise` to `Promise`. Its only caller (`RequestEditorProvider`'s `recordHistory`) already discards the return value, so no caller-side change is needed — the entire "should we record" decision is encapsulated inside `HistoryManager`, invisible to every caller.
+
+This is deliberately NOT a check callers make themselves (e.g. `RequestEditorProvider` asking "is history enabled?" before calling `addEntry`) — `HistoryManager` is the single owner of both the entries store and the enabled flag, so it's the only place that needs to know about the relationship between them.
+
+## Surface
+
+A toggle switch in the History panel's header (`src/webview/history/HistoryView.tsx`), next to Clear All. No toggle is added to the per-request History tab — the flag is global, and the user chose to keep the control in one place (the global panel).
+
+**Wire format:** every existing `historyUpdated` message (`{type, entries}`) gains an `enabled: boolean` field, so the panel's toggle state is always in sync with the same push/pull messages it already uses for entries — no extra round-trip message type for reading state. A new message, `setHistoryEnabled` (`{type: "setHistoryEnabled", enabled: boolean}`), is the only new wire message, sent webview → host when the user flips the switch.
+
+**Host wiring:**
+- `SidebarProvider` gains two thin wrapper methods, matching the existing delegation pattern (`getHistoryEntries` etc.): `isHistoryEnabled(): boolean` and `async setHistoryEnabled(enabled: boolean): Promise`, both wrapping the same-named `HistoryManager` methods.
+- `HistoryEditorProvider`'s message switch gains a `setHistoryEnabled` case, and every existing `historyUpdated` post (there are 4 in the switch, plus `refreshIfOpen`) includes `enabled: sidebarProvider.isHistoryEnabled()` alongside `entries`. Since this field is now added to every post, factor the payload construction into one small private helper inside `HistoryEditorProvider` to avoid repeating `{ type: "historyUpdated", entries: ..., enabled: ... }` five times.
+
+**Webview UI:** `HistoryView.tsx` gains `enabled` state (from the `historyUpdated` message), a toggle control in `.history-page-header`, and — when `enabled` is `false` — a small inline note in `.history-page-body` ("Recording is paused — new requests won't be added to history") so it's clear why the list has stopped growing, without needing to check the toggle's visual state to understand why.
+
+## What Is Not Changed
+
+- Entry pruning/truncation/restore logic in `HistoryManager` — untouched.
+- The per-request History tab (`HistoryTab.tsx`) — no toggle added there; it still shows whatever entries exist, growing only while recording is enabled.
+- Clear All — still a fully separate, explicit action; the toggle never triggers it.
diff --git a/scripts/build.mts b/scripts/build.mts
index 107cdf8..9311615 100644
--- a/scripts/build.mts
+++ b/scripts/build.mts
@@ -138,6 +138,7 @@ async function buildAll() {
     build(createWebviewConfig("sidebar")),
     build(createWebviewConfig("editor")),
     build(createWebviewConfig("request")),
+    build(createWebviewConfig("history")),
   ]);
 
   const elapsed = ((Date.now() - startTime) / 1000).toFixed(2);
diff --git a/src/extension.ts b/src/extension.ts
index 35c7a53..d9944b4 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -1,7 +1,9 @@
 import * as vscode from "vscode";
 import { Folder, Request, SidebarProvider } from "./providers/SidebarProvider";
 import { FolderEditorProvider } from "./providers/FolderEditorProvider";
+import { HistoryManager } from "./providers/HistoryManager";
 import { RequestEditorProvider } from "./providers/RequestEditorProvider";
+import { HistoryEditorProvider } from "./providers/HistoryEditorProvider";
 import {
   FolderConfig,
   RequestConfig,
@@ -155,8 +157,13 @@ export async function activate(context: vscode.ExtensionContext) {
   console.log("REST Lab extension is now active!");
   await seedDefaultData(context);
 
-  // Initialize the sidebar provider
-  const sidebarProvider = new SidebarProvider(context.extensionUri, context);
+  // Initialize the history manager and sidebar provider
+  const historyManager = new HistoryManager(context);
+  const sidebarProvider = new SidebarProvider(
+    context.extensionUri,
+    context,
+    historyManager,
+  );
 
   // Register the sidebar webview provider
   context.subscriptions.push(
@@ -222,12 +229,20 @@ export async function activate(context: vscode.ExtensionContext) {
           requestId,
           requestName,
           folderId,
+          historyManager,
           sidebarProvider,
         );
       },
     ),
   );
 
+  // Register command to open the global history panel
+  context.subscriptions.push(
+    vscode.commands.registerCommand("restlab.openHistory", () => {
+      HistoryEditorProvider.openHistoryPanel(context, sidebarProvider);
+    }),
+  );
+
   // Register command to import collection
   context.subscriptions.push(
     vscode.commands.registerCommand("restlab.importCollection", async () => {
diff --git a/src/providers/HistoryEditorProvider.ts b/src/providers/HistoryEditorProvider.ts
new file mode 100644
index 0000000..57013a8
--- /dev/null
+++ b/src/providers/HistoryEditorProvider.ts
@@ -0,0 +1,121 @@
+import * as vscode from "vscode";
+import { getNonce } from "../utils/getNonce";
+import { SidebarProvider } from "./SidebarProvider";
+
+export class HistoryEditorProvider {
+  private static panel: vscode.WebviewPanel | undefined;
+
+  /** Push a fresh historyUpdated payload to the History panel, if it's open. */
+  public static refreshIfOpen(sidebarProvider?: SidebarProvider): void {
+    if (!HistoryEditorProvider.panel || !sidebarProvider) return;
+    HistoryEditorProvider.panel.webview.postMessage(
+      HistoryEditorProvider._buildHistoryPayload(sidebarProvider),
+    );
+  }
+
+  private static _buildHistoryPayload(sidebarProvider: SidebarProvider) {
+    return {
+      type: "historyUpdated",
+      entries: sidebarProvider.getHistoryEntries(),
+      enabled: sidebarProvider.isHistoryEnabled(),
+    };
+  }
+
+  public static openHistoryPanel(
+    context: vscode.ExtensionContext,
+    sidebarProvider: SidebarProvider,
+  ): void {
+    if (HistoryEditorProvider.panel) {
+      HistoryEditorProvider.panel.reveal(vscode.ViewColumn.One);
+      return;
+    }
+
+    const panel = vscode.window.createWebviewPanel(
+      "restlab.historyEditor",
+      "History",
+      vscode.ViewColumn.One,
+      {
+        enableScripts: true,
+        retainContextWhenHidden: true,
+        localResourceRoots: [context.extensionUri],
+      },
+    );
+
+    HistoryEditorProvider.panel = panel;
+
+    panel.onDidDispose(() => {
+      HistoryEditorProvider.panel = undefined;
+    });
+
+    panel.webview.html = HistoryEditorProvider._getHtmlForWebview(
+      panel.webview,
+      context,
+    );
+
+    panel.webview.onDidReceiveMessage(async (message) => {
+      switch (message.type) {
+        case "getHistory":
+          panel.webview.postMessage(
+            HistoryEditorProvider._buildHistoryPayload(sidebarProvider),
+          );
+          break;
+        case "deleteHistoryEntry":
+          await sidebarProvider.deleteHistoryEntryById(message.entryId);
+          panel.webview.postMessage(
+            HistoryEditorProvider._buildHistoryPayload(sidebarProvider),
+          );
+          break;
+        case "clearAllHistory": {
+          const cleared = await sidebarProvider.clearAllHistoryEntries();
+          if (cleared) {
+            panel.webview.postMessage(
+              HistoryEditorProvider._buildHistoryPayload(sidebarProvider),
+            );
+          }
+          break;
+        }
+        case "restoreHistoryEntry":
+          await sidebarProvider.restoreHistoryEntryById(message.entryId);
+          panel.webview.postMessage(
+            HistoryEditorProvider._buildHistoryPayload(sidebarProvider),
+          );
+          break;
+        case "setHistoryEnabled":
+          await sidebarProvider.setHistoryEnabled(message.enabled);
+          panel.webview.postMessage(
+            HistoryEditorProvider._buildHistoryPayload(sidebarProvider),
+          );
+          break;
+      }
+    });
+  }
+
+  private static _getHtmlForWebview(
+    webview: vscode.Webview,
+    context: vscode.ExtensionContext,
+  ): string {
+    const scriptUri = webview.asWebviewUri(
+      vscode.Uri.joinPath(context.extensionUri, "dist", "history", "index.js"),
+    );
+    const styleUri = webview.asWebviewUri(
+      vscode.Uri.joinPath(context.extensionUri, "dist", "history", "index.css"),
+    );
+
+    const nonce = getNonce();
+
+    return `
+      
+      
+        
+        
+        
+        
+        History
+      
+      
+        
+ + + `; + } +} diff --git a/src/providers/HistoryManager.ts b/src/providers/HistoryManager.ts new file mode 100644 index 0000000..44fe083 --- /dev/null +++ b/src/providers/HistoryManager.ts @@ -0,0 +1,98 @@ +import type * as vscode from "vscode"; +import { HistoryEntry } from "../webview/types/internal.types"; + +const STORAGE_KEY = "restlab.history"; +const ENABLED_KEY = "restlab.history.enabled"; +const MAX_PER_REQUEST = 20; +const MAX_GLOBAL = 200; +const MAX_BODY_BYTES = 200_000; + +function truncateIfNeeded(value: string | undefined): { + value: string | undefined; + truncated: boolean; +} { + if (!value) return { value, truncated: false }; + const buffer = Buffer.from(value, "utf8"); + if (buffer.byteLength <= MAX_BODY_BYTES) return { value, truncated: false }; + const sliced = buffer.subarray(0, MAX_BODY_BYTES).toString("utf8"); + return { + value: `${sliced}\n...[truncated for storage, original size ${buffer.byteLength} bytes]`, + truncated: true, + }; +} + +export class HistoryManager { + constructor(private readonly context: vscode.ExtensionContext) {} + + public isEnabled(): boolean { + return this.context.globalState.get(ENABLED_KEY, true); + } + + public async setEnabled(enabled: boolean): Promise { + await this.context.globalState.update(ENABLED_KEY, enabled); + } + + private _getAll(): HistoryEntry[] { + return this.context.globalState.get(STORAGE_KEY, []); + } + + private async _setAll(entries: HistoryEntry[]): Promise { + await this.context.globalState.update(STORAGE_KEY, entries); + } + + public getAll(): HistoryEntry[] { + return this._getAll(); + } + + public getForRequest(requestId: string): HistoryEntry[] { + return this._getAll().filter((e) => e.requestId === requestId); + } + + public async addEntry( + input: Omit, + ): Promise { + if (!this.isEnabled()) return null; + + const bodyResult = truncateIfNeeded(input.request.body); + const responseDataResult = truncateIfNeeded(input.response.data); + + const entry: HistoryEntry = { + ...input, + id: `history-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: Date.now(), + request: { ...input.request, body: bodyResult.value }, + response: { ...input.response, data: responseDataResult.value ?? "" }, + truncated: bodyResult.truncated || responseDataResult.truncated, + }; + + let entries = [entry, ...this._getAll()]; + + // Cap entries for this specific request first (newest-first order preserved) + let keptForRequest = 0; + entries = entries.filter((e) => { + if (e.requestId !== entry.requestId) return true; + keptForRequest += 1; + return keptForRequest <= MAX_PER_REQUEST; + }); + + // Then cap the global list + if (entries.length > MAX_GLOBAL) { + entries = entries.slice(0, MAX_GLOBAL); + } + + await this._setAll(entries); + return entry; + } + + public async deleteEntry(entryId: string): Promise { + await this._setAll(this._getAll().filter((e) => e.id !== entryId)); + } + + public async clearForRequest(requestId: string): Promise { + await this._setAll(this._getAll().filter((e) => e.requestId !== requestId)); + } + + public async clearAll(): Promise { + await this._setAll([]); + } +} diff --git a/src/providers/RequestEditorProvider.ts b/src/providers/RequestEditorProvider.ts index 7845f68..39088ae 100644 --- a/src/providers/RequestEditorProvider.ts +++ b/src/providers/RequestEditorProvider.ts @@ -2,7 +2,14 @@ import axios, { AxiosRequestConfig } from "axios"; import FormData from "form-data"; import * as vscode from "vscode"; import { getNonce } from "../utils/getNonce"; -import { RequestConfig, ResponseCookie } from "../webview/types/internal.types"; +import { + FormDataItem, + RequestConfig, + ResponseCookie, + ResponseData, +} from "../webview/types/internal.types"; +import { HistoryEditorProvider } from "./HistoryEditorProvider"; +import { HistoryManager } from "./HistoryManager"; import { SidebarProvider } from "./SidebarProvider"; function parseSetCookie(raw: string): ResponseCookie { @@ -60,11 +67,68 @@ export class RequestEditorProvider { }); } + /** Push a fresh configLoaded payload to a single open panel, if it exists. Used after a global-history restore, which has no open editor form of its own to update. */ + /** Push a fresh historyUpdated payload to a single open panel, if it exists. Used after a global-history delete/clear affecting that request. */ + public static refreshPanelHistory(requestId: string, historyManager: HistoryManager): void { + const panel = RequestEditorProvider.openPanels.get(requestId); + if (!panel) return; + panel.webview.postMessage({ + type: "historyUpdated", + entries: historyManager.getForRequest(requestId), + }); + } + + public static refreshPanelConfig( + context: vscode.ExtensionContext, + requestId: string, + folderId: string, + sidebarProvider: SidebarProvider, + historyManager: HistoryManager, + ): void { + const panel = RequestEditorProvider.openPanels.get(requestId); + if (!panel) return; + + const savedRequest = context.globalState.get( + `restlab.request.${requestId}`, + ); + if (!savedRequest) return; + + const folderConfig = sidebarProvider.getInheritedConfig(folderId); + const envVariables = sidebarProvider.getActiveEnvVariables(folderId); + const collectionId = sidebarProvider.getRootCollectionId(folderId); + const collectionData = sidebarProvider.getCollectionData(folderId); + + panel.webview.postMessage({ + type: "configLoaded", + config: { + id: requestId, + name: savedRequest.name, + folderId, + method: savedRequest.method || "GET", + url: savedRequest.url || "", + headers: savedRequest.headers || [], + params: savedRequest.params || [], + body: savedRequest.body || "", + contentType: savedRequest.contentType || "", + formData: savedRequest.formData || [], + auth: savedRequest.auth, + cookies: savedRequest.cookies || [], + }, + folderConfig, + envVariables, + collectionId, + environments: collectionData.environments, + activeEnvironmentId: collectionData.activeEnvironmentId, + history: historyManager.getForRequest(requestId), + }); + } + public static openRequestEditor( context: vscode.ExtensionContext, requestId: string, requestName: string, folderId: string, + historyManager: HistoryManager, sidebarProvider?: SidebarProvider, ) { // Check if panel already exists for this request @@ -204,6 +268,7 @@ export class RequestEditorProvider { collectionId: collectionId, environments: collectionData.environments, activeEnvironmentId: collectionData.activeEnvironmentId, + history: historyManager.getForRequest(requestId), }); break; case "saveConfig": @@ -248,7 +313,39 @@ export class RequestEditorProvider { }); } break; - case "sendRequest": + case "sendRequest": { + const recordHistory = async (response: ResponseData) => { + const snapshot = message.historySnapshot || {}; + const strippedFormData: FormDataItem[] = (snapshot.formData || []).map( + (field: FormDataItem) => + field.type === "file" + ? { key: field.key, type: field.type, value: "", fileName: field.fileName } + : field, + ); + await historyManager.addEntry({ + requestId, + requestName, + folderId, + request: { + method: snapshot.method || message.method, + url: snapshot.url || "", + resolvedUrl: message.url, + headers: snapshot.headers || [], + params: snapshot.params || [], + body: snapshot.body, + contentType: snapshot.contentType, + formData: strippedFormData, + cookies: snapshot.cookies, + }, + response, + }); + panel.webview.postMessage({ + type: "historyUpdated", + entries: historyManager.getForRequest(requestId), + }); + HistoryEditorProvider.refreshIfOpen(sidebarProvider); + }; + try { const response = await provider._sendHttpRequest( message.method, @@ -262,19 +359,58 @@ export class RequestEditorProvider { type: "responseReceived", response, }); + await recordHistory(response); } catch (error: any) { + const errorResponse: ResponseData = { + status: 0, + statusText: "Error", + headers: {}, + data: error.message || "Request failed", + time: 0, + size: 0, + }; panel.webview.postMessage({ type: "responseReceived", - response: { - status: 0, - statusText: "Error", - headers: {}, - data: error.message || "Request failed", - time: 0, - }, + response: errorResponse, }); + await recordHistory(errorResponse); } break; + } + case "getRequestHistory": + panel.webview.postMessage({ + type: "historyUpdated", + entries: historyManager.getForRequest(requestId), + }); + break; + case "restoreHistoryEntry": { + const entry = historyManager + .getForRequest(requestId) + .find((e) => e.id === message.entryId); + if (entry) { + panel.webview.postMessage({ + type: "historyRestored", + request: entry.request, + }); + } + break; + } + case "deleteHistoryEntry": + await historyManager.deleteEntry(message.entryId); + panel.webview.postMessage({ + type: "historyUpdated", + entries: historyManager.getForRequest(requestId), + }); + HistoryEditorProvider.refreshIfOpen(sidebarProvider); + break; + case "clearRequestHistory": + await historyManager.clearForRequest(requestId); + panel.webview.postMessage({ + type: "historyUpdated", + entries: historyManager.getForRequest(requestId), + }); + HistoryEditorProvider.refreshIfOpen(sidebarProvider); + break; case "showInfo": vscode.window.showInformationMessage(message.message); break; diff --git a/src/providers/SidebarProvider.ts b/src/providers/SidebarProvider.ts index 2fa417a..6e637b3 100644 --- a/src/providers/SidebarProvider.ts +++ b/src/providers/SidebarProvider.ts @@ -7,8 +7,9 @@ import { } from "../utils/exportParser"; import { getNonce } from "../utils/getNonce"; import { ImportResult, parseImportedFile } from "../utils/importParser"; -import { AuthConfig } from "../webview/types/internal.types"; +import { AuthConfig, HistoryEntry } from "../webview/types/internal.types"; import { FolderEditorProvider } from "./FolderEditorProvider"; +import { HistoryManager } from "./HistoryManager"; import { RequestEditorProvider } from "./RequestEditorProvider"; export interface Request { @@ -53,6 +54,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { constructor( private readonly _extensionUri: vscode.Uri, private readonly _context: vscode.ExtensionContext, + private readonly _historyManager: HistoryManager, ) { // Load saved folders from global state this._folders = this._context.globalState.get( @@ -166,6 +168,9 @@ export class SidebarProvider implements vscode.WebviewViewProvider { message.expandedFolderIds, ); break; + case "openHistory": + vscode.commands.executeCommand("restlab.openHistory"); + break; } }); @@ -1126,6 +1131,98 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } } + // ── History operations (shared by the sidebar's own messages and HistoryEditorProvider) ── + + public getHistoryEntries(): HistoryEntry[] { + return this._historyManager.getAll(); + } + + public isHistoryEnabled(): boolean { + return this._historyManager.isEnabled(); + } + + public async setHistoryEnabled(enabled: boolean): Promise { + await this._historyManager.setEnabled(enabled); + } + + public async deleteHistoryEntryById(entryId: string): Promise { + const entryBeingDeleted = this._historyManager + .getAll() + .find((e) => e.id === entryId); + await this._historyManager.deleteEntry(entryId); + if (entryBeingDeleted) { + RequestEditorProvider.refreshPanelHistory( + entryBeingDeleted.requestId, + this._historyManager, + ); + } + } + + /** Returns true if the user confirmed and history was actually cleared. */ + public async clearAllHistoryEntries(): Promise { + const confirm = await vscode.window.showWarningMessage( + "Clear all request history? This cannot be undone.", + { modal: true }, + "Clear All", + ); + if (confirm !== "Clear All") return false; + + const affectedRequestIds = [ + ...new Set(this._historyManager.getAll().map((e) => e.requestId)), + ]; + await this._historyManager.clearAll(); + for (const requestId of affectedRequestIds) { + RequestEditorProvider.refreshPanelHistory(requestId, this._historyManager); + } + return true; + } + + public async restoreHistoryEntryById(entryId: string): Promise { + const entry = this._historyManager.getAll().find((e) => e.id === entryId); + if (!entry) return; + + const folder = this._findFolder(entry.folderId); + const requestExists = folder?.requests?.some( + (r) => r.id === entry.requestId, + ); + if (!requestExists) { + vscode.window.showWarningMessage( + `Cannot restore "${entry.requestName}" — the original request no longer exists.`, + ); + return; + } + + const existingConfig = + this._context.globalState.get( + `restlab.request.${entry.requestId}`, + ) || {}; + const restoredConfig = { + ...existingConfig, + method: entry.request.method, + url: entry.request.url, + headers: entry.request.headers, + params: entry.request.params, + body: entry.request.body, + contentType: entry.request.contentType, + formData: entry.request.formData, + cookies: entry.request.cookies, + }; + await this._context.globalState.update( + `restlab.request.${entry.requestId}`, + restoredConfig, + ); + RequestEditorProvider.refreshPanelConfig( + this._context, + entry.requestId, + entry.folderId, + this, + this._historyManager, + ); + vscode.window.showInformationMessage( + `Restored "${entry.requestName}" from history`, + ); + } + private _sendFoldersToWebview() { if (this._view) { const expandedFolderIds = this._context.globalState.get( diff --git a/src/webview/components/HistoryEntryList.tsx b/src/webview/components/HistoryEntryList.tsx new file mode 100644 index 0000000..540c9d7 --- /dev/null +++ b/src/webview/components/HistoryEntryList.tsx @@ -0,0 +1,148 @@ +import React, { useState } from "react"; +import { + formatJson, + formatRelativeTime, + formatSize, + getStatusColor, +} from "../helpers/helper"; +import { HistoryEntry } from "../types/internal.types"; +import Tooltip from "./Tooltip"; +import TrashIcon from "./icons/TrashIcon"; + +interface HistoryEntryListProps { + entries: HistoryEntry[]; + showRequestName?: boolean; + onRestore: (entryId: string) => void; + onDelete: (entryId: string) => void; +} + +const renderBody = (body: string | undefined, contentType?: string): string => { + if (!body) return ""; + return contentType?.includes("json") ? formatJson(body) : body; +}; + +const HistoryEntryList: React.FC = ({ + entries, + showRequestName = false, + onRestore, + onDelete, +}) => { + const [expandedId, setExpandedId] = useState(null); + + if (entries.length === 0) { + return

No history yet

; + } + + return ( +
+ {entries.map((entry) => { + const isExpanded = expandedId === entry.id; + return ( +
+
+ setExpandedId((prev) => (prev === entry.id ? null : entry.id)) + } + role="button" + tabIndex={0} + > + + {entry.request.method} + + {showRequestName && ( + {entry.requestName} + )} + + {entry.request.url || entry.request.resolvedUrl} + + + {entry.response.status === 0 ? "Network Error" : entry.response.status} + + {entry.response.time}ms + + {formatRelativeTime(entry.timestamp)} + +
+ + {isExpanded && ( +
+ {entry.truncated && ( +

Some content was truncated for storage.

+ )} + +
+

Request

+

+ {entry.request.method} {entry.request.resolvedUrl} +

+ {entry.request.headers.length > 0 && ( +
+ {entry.request.headers.map((h, i) => ( +
+ {h.key} + {h.value} +
+ ))} +
+ )} + {entry.request.body && ( +
+                      {renderBody(entry.request.body, entry.request.contentType)}
+                    
+ )} +
+ +
+

Response

+

+ {entry.response.status} {entry.response.statusText} ·{" "} + {formatSize(entry.response.size)} +

+
+ {Object.entries(entry.response.headers).map(([k, v]) => ( +
+ {k} + {v} +
+ ))} +
+
+                    {renderBody(entry.response.data, entry.response.headers["content-type"])}
+                  
+
+ +
+ + + + +
+
+ )} +
+ ); + })} +
+ ); +}; + +export default HistoryEntryList; diff --git a/src/webview/components/icons/HistoryIcon.tsx b/src/webview/components/icons/HistoryIcon.tsx new file mode 100644 index 0000000..26a2959 --- /dev/null +++ b/src/webview/components/icons/HistoryIcon.tsx @@ -0,0 +1,24 @@ +import React from "react"; + +type HistoryIconProps = { + className?: string; +}; + +const HistoryIcon = ({ className }: HistoryIconProps) => ( + + + + +); + +export default HistoryIcon; diff --git a/src/webview/helpers/helper.ts b/src/webview/helpers/helper.ts index ec5d796..1204de1 100644 --- a/src/webview/helpers/helper.ts +++ b/src/webview/helpers/helper.ts @@ -196,3 +196,17 @@ export function resolveAuth( return { headers: [], params: [] }; } + +// Format a timestamp as a short relative-time string (e.g. "5m ago") +export const formatRelativeTime = (timestamp: number): string => { + const diffSec = Math.floor((Date.now() - timestamp) / 1000); + if (diffSec < 5) return "Just now"; + if (diffSec < 60) return `${diffSec}s ago`; + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) return `${diffMin}m ago`; + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) return `${diffHour}h ago`; + const diffDay = Math.floor(diffHour / 24); + if (diffDay < 7) return `${diffDay}d ago`; + return new Date(timestamp).toLocaleDateString(); +}; diff --git a/src/webview/history/HistoryView.tsx b/src/webview/history/HistoryView.tsx new file mode 100644 index 0000000..d317977 --- /dev/null +++ b/src/webview/history/HistoryView.tsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from "react"; +import HistoryEntryList from "../components/HistoryEntryList"; +import { HistoryEntry } from "../types/internal.types"; + +declare function acquireVsCodeApi(): { + postMessage: (message: unknown) => void; + getState: () => unknown; + setState: (state: unknown) => void; +}; + +const vscode = acquireVsCodeApi(); + +export const HistoryView: React.FC = () => { + const [entries, setEntries] = useState([]); + const [enabled, setEnabled] = useState(true); + + useEffect(() => { + vscode.postMessage({ type: "getHistory" }); + + const handleMessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === "historyUpdated") { + setEntries(message.entries || []); + setEnabled(message.enabled ?? true); + } + }; + + window.addEventListener("message", handleMessage); + return () => window.removeEventListener("message", handleMessage); + }, []); + + const handleRestore = (entryId: string) => { + vscode.postMessage({ type: "restoreHistoryEntry", entryId }); + }; + + const handleDelete = (entryId: string) => { + vscode.postMessage({ type: "deleteHistoryEntry", entryId }); + }; + + const handleClearAll = () => { + vscode.postMessage({ type: "clearAllHistory" }); + }; + + const handleToggleEnabled = () => { + const next = !enabled; + setEnabled(next); + vscode.postMessage({ type: "setHistoryEnabled", enabled: next }); + }; + + return ( +
+
+

Request History

+
+ + {entries.length > 0 && ( + + )} +
+
+ {!enabled && ( +

+ History is paused — new requests won't be added to history. +

+ )} +
+ +
+
+ ); +}; diff --git a/src/webview/history/index.tsx b/src/webview/history/index.tsx new file mode 100644 index 0000000..afe9ddd --- /dev/null +++ b/src/webview/history/index.tsx @@ -0,0 +1,10 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import "./styles.css"; +import { HistoryView } from "./HistoryView"; + +const container = document.getElementById("root"); +if (container) { + const root = createRoot(container); + root.render(); +} diff --git a/src/webview/history/styles.css b/src/webview/history/styles.css new file mode 100644 index 0000000..20703db --- /dev/null +++ b/src/webview/history/styles.css @@ -0,0 +1,361 @@ +/* ============================================================ + REST-Lab History Panel — Custom CSS + ============================================================ */ + +/* ---- Reset ---- */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ---- Base ---- */ +body { + font-family: var( + --vscode-font-family, + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + Roboto, + sans-serif + ); + font-size: var(--vscode-font-size, 13px); + color: var(--vscode-foreground); + background-color: var(--vscode-editor-background); + line-height: 1.45; +} + +/* ---- Brand & VS Code tokens ---- */ +:root { + --restlab-gradient: linear-gradient(90deg, #38bdf8 0%, #6366f1 100%); + --restlab-accent: #38bdf8; + --restlab-accent-hover: #0ea5e9; + --restlab-accent-subtle: rgba(56, 189, 248, 0.1); + --restlab-danger: #ef4444; + --restlab-danger-subtle: rgba(239, 68, 68, 0.1); + --glass-bg: rgba(255, 255, 255, 0.03); + --glass-border: rgba(255, 255, 255, 0.08); + --method-get: #22c55e; + --method-post: #3b82f6; + --method-put: #f59e0b; + --method-patch: #a855f7; + --method-delete: #ef4444; + --rl-sp1: 0.30em; + --rl-sp2: 0.46em; + --rl-sp3: 0.62em; + --rl-sp4: 0.92em; + --rl-sp5: 1.23em; + --rl-ctrl: 2.35em; + --rl-icon: 1.25em; + --rl-r1: 0.35em; + --rl-r2: 0.50em; + --rl-r3: 0.65em; +} + +/* ============================================================ + PAGE SHELL + ============================================================ */ +.history-page { + display: flex; + flex-direction: column; + height: 100vh; + max-width: 960px; + margin: 0 auto; + padding: var(--rl-sp5); + overflow: hidden; +} + +.history-page-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: var(--rl-sp4); + border-bottom: 1px solid var(--glass-border); + margin-bottom: var(--rl-sp4); + flex-shrink: 0; +} + +.history-page-header h1 { + font-size: 1.15em; + font-weight: 800; + background: var(--restlab-gradient); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +.history-page-body { + flex: 1; + overflow-y: auto; +} + +/* ============================================================ + METHOD BADGE + ============================================================ */ +.method-badge { + flex-shrink: 0; + font-size: 0.62em; + font-weight: 800; + padding: 0.32em 0.55em; + border-radius: 0.5em; + text-transform: uppercase; + letter-spacing: 0.06em; + border: 1px solid transparent; + line-height: 1; + min-width: 3.6em; + text-align: center; +} +.method-get { + color: var(--method-get); + background: rgba(34, 197, 94, 0.14); + border-color: rgba(34, 197, 94, 0.32); +} +.method-post { + color: var(--method-post); + background: rgba(59, 130, 246, 0.14); + border-color: rgba(59, 130, 246, 0.32); +} +.method-put { + color: var(--method-put); + background: rgba(245, 158, 11, 0.14); + border-color: rgba(245, 158, 11, 0.32); +} +.method-patch { + color: var(--method-patch); + background: rgba(168, 85, 247, 0.14); + border-color: rgba(168, 85, 247, 0.32); +} +.method-delete { + color: var(--method-delete); + background: rgba(239, 68, 68, 0.14); + border-color: rgba(239, 68, 68, 0.32); +} + +/* ============================================================ + STATUS & RESPONSE BADGES + ============================================================ */ +.status-badge { + padding: 0.18em 0.55em; + border-radius: 1.4em; + font-size: 0.7em; + font-weight: 700; + letter-spacing: 0.3px; +} +.status-success { + background: linear-gradient(90deg, rgba(34, 197, 94, 0.2) 0%, rgba(74, 222, 128, 0.2) 100%); + color: #22c55e; +} +.status-redirect { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.2) 0%, rgba(99, 102, 241, 0.2) 100%); + color: #3b82f6; +} +.status-client-error { + background: linear-gradient(135deg, rgba(245, 158, 11, 0.2) 0%, rgba(251, 191, 36, 0.2) 100%); + color: #f59e0b; +} +.status-server-error { + background: linear-gradient(135deg, rgba(239, 68, 68, 0.2) 0%, rgba(249, 115, 22, 0.2) 100%); + color: #ef4444; +} +.status-error { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; +} + +.time-badge { + padding: 0.18em 0.55em; + border-radius: 1.4em; + font-size: 0.7em; + font-weight: 600; + background: var(--glass-bg); + color: var(--vscode-foreground); +} + +.empty-hint { + color: var(--vscode-descriptionForeground); + font-size: 12px; + font-style: italic; +} + +.add-btn { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + border: 1px dashed var(--vscode-panel-border); + border-radius: 4px; + background: transparent; + color: var(--vscode-foreground); + cursor: pointer; +} +.add-btn:hover { + border-color: var(--restlab-accent); + color: var(--restlab-accent); +} + +.remove-btn { + display: flex; + align-items: center; + justify-content: center; + width: var(--rl-ctrl); + height: var(--rl-ctrl); + border: none; + background: transparent; + color: var(--vscode-descriptionForeground); + border-radius: var(--rl-r2); + cursor: pointer; +} +.remove-btn:hover { + background: var(--restlab-danger-subtle); + color: var(--restlab-danger); +} + +.response-header-row { + display: flex; + padding: var(--rl-sp2) var(--rl-sp3); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: 6px; + font-size: 12px; +} +.response-header-row .header-name { + font-weight: 700; + min-width: 11em; + background: var(--restlab-gradient); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.response-header-row .header-value { + flex: 1; + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + word-break: break-all; + opacity: 0.9; +} + +.response-headers { + display: flex; + flex-direction: column; + gap: 4px; + overflow: auto; + flex: 1; + min-height: 0; +} + +/* ============================================================ + HISTORY + ============================================================ */ +.history-list { + display: flex; + flex-direction: column; + gap: var(--rl-sp2); +} + +.history-entry { + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + background: var(--glass-bg); + overflow: hidden; +} + +.history-entry-row { + display: flex; + align-items: center; + gap: var(--rl-sp3); + padding: var(--rl-sp3); + cursor: pointer; +} +.history-entry-row:hover { + background: rgba(56, 189, 248, 0.05); +} + +.history-request-name { + font-weight: 600; + font-size: 12px; +} + +.history-url { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + font-size: 12px; + opacity: 0.85; +} + +.history-timestamp { + font-size: 11px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +.history-entry-details { + border-top: 1px solid var(--glass-border); + padding: var(--rl-sp3); + display: flex; + flex-direction: column; + gap: var(--rl-sp3); +} + +.history-detail-section h4 { + margin: 0 0 var(--rl-sp2) 0; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.7; +} + +.history-detail-line { + font-size: 12px; + margin: 0 0 var(--rl-sp2) 0; + word-break: break-all; +} + +.history-body { + margin: var(--rl-sp2) 0 0 0; + padding: var(--rl-sp3); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + max-height: 240px; + overflow: auto; +} + +.history-entry-actions { + display: flex; + align-items: center; + gap: var(--rl-sp2); +} + +.history-page-header-actions { + display: flex; + align-items: center; + gap: var(--rl-sp4); +} + +.history-toggle { + display: flex; + align-items: center; + gap: var(--rl-sp2); + font-size: 12px; + color: var(--vscode-descriptionForeground); + cursor: pointer; + user-select: none; +} + +.history-toggle input[type="checkbox"] { + accent-color: var(--restlab-accent); + cursor: pointer; +} + +.history-paused-hint { + margin-bottom: var(--rl-sp3); +} diff --git a/src/webview/request/HistoryTab.tsx b/src/webview/request/HistoryTab.tsx new file mode 100644 index 0000000..530faf9 --- /dev/null +++ b/src/webview/request/HistoryTab.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import HistoryEntryList from "../components/HistoryEntryList"; +import { useRequestContext } from "./RequestContext"; + +const HistoryTab: React.FC = () => { + const { + historyEntries, + handleRestoreHistoryEntry, + handleDeleteHistoryEntry, + handleClearRequestHistory, + } = useRequestContext(); + + return ( +
+
+
+

Request History

+ {historyEntries.length > 0 && ( + + )} +
+ +
+
+ ); +}; + +export default HistoryTab; diff --git a/src/webview/request/RequestContext.tsx b/src/webview/request/RequestContext.tsx index 46817d8..134c48d 100644 --- a/src/webview/request/RequestContext.tsx +++ b/src/webview/request/RequestContext.tsx @@ -26,6 +26,7 @@ import { Cookie, FolderConfig, FormDataItem, + HistoryEntry, RequestConfig, RequestEditorProps, ResponseData, @@ -40,7 +41,7 @@ declare function acquireVsCodeApi(): { const vscode = acquireVsCodeApi(); type SplitLayout = "horizontal" | "vertical"; -type ActiveTab = "headers" | "body" | "params" | "auth" | "cookies"; +type ActiveTab = "headers" | "body" | "params" | "auth" | "cookies" | "history"; type ResponseTab = "body" | "headers" | "cookies"; interface RequestContextValue { @@ -55,6 +56,7 @@ interface RequestContextValue { activeTab: ActiveTab; responseTab: ResponseTab; isSaved: boolean; + historyEntries: HistoryEntry[]; splitLayout: SplitLayout; requestSize: number; isResizing: boolean; @@ -86,6 +88,9 @@ interface RequestContextValue { toggleLayout: () => void; handleResizeStart: (e: React.MouseEvent) => void; handleSetActiveEnvironment: (envId: string | null) => void; + handleRestoreHistoryEntry: (entryId: string) => void; + handleDeleteHistoryEntry: (entryId: string) => void; + handleClearRequestHistory: () => void; // Header handlers handleAddHeader: () => void; @@ -181,6 +186,7 @@ export const RequestContextProvider: React.FC = ({ const [activeTab, setActiveTab] = useState("headers"); const [responseTab, setResponseTab] = useState("body"); const [isSaved, setIsSaved] = useState(true); + const [historyEntries, setHistoryEntries] = useState([]); // Layout state const [splitLayout, setSplitLayout] = useState("vertical"); @@ -295,6 +301,7 @@ export const RequestContextProvider: React.FC = ({ setActiveEnvironmentId(message.activeEnvironmentId ?? null); setCollectionId(message.collectionId ?? null); collectionIdRef.current = message.collectionId ?? null; + setHistoryEntries(message.history || []); setIsSaved(true); if (METHODS_WITH_BODY.includes(message.config.method)) { setActiveTab("body"); @@ -330,6 +337,23 @@ export const RequestContextProvider: React.FC = ({ setResponse(message.response); setIsLoading(false); break; + case "historyUpdated": + setHistoryEntries(message.entries || []); + break; + case "historyRestored": + setConfig((prev) => ({ + ...prev, + method: message.request.method, + url: message.request.url, + headers: message.request.headers, + params: message.request.params, + body: message.request.body, + contentType: message.request.contentType, + formData: message.request.formData, + cookies: message.request.cookies, + })); + setIsSaved(false); + break; } }; @@ -505,6 +529,16 @@ export const RequestContextProvider: React.FC = ({ body: requestBody, formData: formDataWithFiles, cookies: enabledCookies, + historySnapshot: { + method: config.method, + url: config.url, + headers: config.headers || [], + params: config.params || [], + body: config.body, + contentType: config.contentType, + formData: config.formData, + cookies: config.cookies, + }, }); vscode.postMessage({ type: "saveConfig", config }); @@ -524,6 +558,18 @@ export const RequestContextProvider: React.FC = ({ vscode.postMessage({ type: "setActiveEnvironment", envId }); }, []); + const handleRestoreHistoryEntry = useCallback((entryId: string) => { + vscode.postMessage({ type: "restoreHistoryEntry", entryId }); + }, []); + + const handleDeleteHistoryEntry = useCallback((entryId: string) => { + vscode.postMessage({ type: "deleteHistoryEntry", entryId }); + }, []); + + const handleClearRequestHistory = useCallback(() => { + vscode.postMessage({ type: "clearRequestHistory" }); + }, []); + const handleAuthChange = useCallback((auth: AuthConfig | undefined) => { setConfig((prev) => ({ ...prev, auth })); setIsSaved(false); @@ -851,6 +897,7 @@ export const RequestContextProvider: React.FC = ({ activeTab, responseTab, isSaved, + historyEntries, splitLayout, requestSize, isResizing, @@ -882,6 +929,9 @@ export const RequestContextProvider: React.FC = ({ toggleLayout, handleResizeStart, handleSetActiveEnvironment, + handleRestoreHistoryEntry, + handleDeleteHistoryEntry, + handleClearRequestHistory, // Header handlers handleAddHeader, diff --git a/src/webview/request/RequestEditor.tsx b/src/webview/request/RequestEditor.tsx index 73926f4..e316806 100644 --- a/src/webview/request/RequestEditor.tsx +++ b/src/webview/request/RequestEditor.tsx @@ -11,6 +11,7 @@ import AuthTab from "./AuthTab"; import BodyTab from "./BodyTab"; import CookieTab from "./CookieTab"; import HeaderTab from "./HeaderTab"; +import HistoryTab from "./HistoryTab"; import ParamsTab from "./ParamsTab"; import { RequestContextProvider, useRequestContext } from "./RequestContext"; import ResponsePanel from "./ResponsePanel"; @@ -53,6 +54,7 @@ const RequestEditorContent: React.FC = () => { isLoading, activeTab, isSaved, + historyEntries, splitLayout, requestSize, isResponseHidden, @@ -269,6 +271,15 @@ const RequestEditorContent: React.FC = () => { )} + @@ -293,6 +304,7 @@ const RequestEditorContent: React.FC = () => { /> )} {activeTab === "cookies" && } + {activeTab === "history" && } diff --git a/src/webview/request/styles.css b/src/webview/request/styles.css index 7811191..9e52a9e 100644 --- a/src/webview/request/styles.css +++ b/src/webview/request/styles.css @@ -1907,3 +1907,138 @@ fieldset.form-section { display: none; } } + +/* ============================================================ + METHOD BADGE (standalone pill, distinct from .method-select) + ============================================================ */ +.method-badge { + flex-shrink: 0; + font-size: 0.62em; + font-weight: 800; + padding: 0.32em 0.55em; + border-radius: 0.5em; + text-transform: uppercase; + letter-spacing: 0.06em; + border: 1px solid transparent; + line-height: 1; + min-width: 3.6em; + text-align: center; +} +.method-get { + color: var(--method-get); + background: rgba(34, 197, 94, 0.14); + border-color: rgba(34, 197, 94, 0.32); +} +.method-post { + color: var(--method-post); + background: rgba(59, 130, 246, 0.14); + border-color: rgba(59, 130, 246, 0.32); +} +.method-put { + color: var(--method-put); + background: rgba(245, 158, 11, 0.14); + border-color: rgba(245, 158, 11, 0.32); +} +.method-patch { + color: var(--method-patch); + background: rgba(168, 85, 247, 0.14); + border-color: rgba(168, 85, 247, 0.32); +} +.method-delete { + color: var(--method-delete); + background: rgba(239, 68, 68, 0.14); + border-color: rgba(239, 68, 68, 0.32); +} + +/* ============================================================ + HISTORY + ============================================================ */ +.history-list { + display: flex; + flex-direction: column; + gap: var(--rl-sp2); + padding: var(--rl-sp3); +} + +.history-entry { + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + background: var(--glass-bg); + overflow: hidden; +} + +.history-entry-row { + display: flex; + align-items: center; + gap: var(--rl-sp3); + padding: var(--rl-sp3); + cursor: pointer; +} + +.history-entry-row:hover { + background: rgba(56, 189, 248, 0.05); +} + +.history-request-name { + font-weight: 600; + font-size: 12px; +} + +.history-url { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + font-size: 12px; + opacity: 0.85; +} + +.history-timestamp { + font-size: 11px; + color: var(--vscode-descriptionForeground); + white-space: nowrap; +} + +.history-entry-details { + border-top: 1px solid var(--glass-border); + padding: var(--rl-sp3); + display: flex; + flex-direction: column; + gap: var(--rl-sp3); +} + +.history-detail-section h4 { + margin: 0 0 var(--rl-sp2) 0; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + opacity: 0.7; +} + +.history-detail-line { + font-size: 12px; + margin: 0 0 var(--rl-sp2) 0; + word-break: break-all; +} + +.history-body { + margin: var(--rl-sp2) 0 0 0; + padding: var(--rl-sp3); + background: var(--glass-bg); + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + font-size: 12px; + white-space: pre-wrap; + word-break: break-all; + max-height: 240px; + overflow: auto; +} + +.history-entry-actions { + display: flex; + align-items: center; + gap: var(--rl-sp2); +} diff --git a/src/webview/sidebar/Sidebar.tsx b/src/webview/sidebar/Sidebar.tsx index 527f339..b8ee97d 100644 --- a/src/webview/sidebar/Sidebar.tsx +++ b/src/webview/sidebar/Sidebar.tsx @@ -4,6 +4,7 @@ import ChevronIcon from "../components/icons/ChevronIcon"; import CollectionAddIcon from "../components/icons/CollectionAddIcon"; import CollectionIcon from "../components/icons/CollectionIcon"; import FolderIcon from "../components/icons/FolderIcon"; +import HistoryIcon from "../components/icons/HistoryIcon"; import NoItemsIcon from "../components/icons/NoItemsIcon"; import PlusIcon from "../components/icons/PlusIcon"; import { Folder, Request } from "../types/internal.types"; @@ -17,7 +18,7 @@ declare function acquireVsCodeApi(): { setState: (state: unknown) => void; }; -const vscode = acquireVsCodeApi(); +export const vscode = acquireVsCodeApi(); // Drag data type constants const DRAG_TYPE_REQUEST = "application/x-restlab-request"; @@ -338,6 +339,10 @@ export const Sidebar: React.FC = () => { vscode.postMessage({ type: "importCollection", provider: providerId }); }; + const handleOpenHistory = () => { + vscode.postMessage({ type: "openHistory" }); + }; + const handleToggleFolder = (folderId: string) => { setExpandedFolders((prev) => { const next = new Set(prev); @@ -602,6 +607,11 @@ export const Sidebar: React.FC = () => { New Collection + + + @@ -615,53 +625,52 @@ export const Sidebar: React.FC = () => { }} onDrop={handleDropOnRoot} > - {folders.length === 0 ? ( -
- -

No collections yet

-

- Create your first collection to get started -

-
- ) : ( - <> - {folders.map((folder) => ( - - ))} - {/* Drop zone indicator at root level */} - {isDragging && ( -
- Drop here to move to root level -
- )} - - )} + {folders.length === 0 ? ( +
+ +

No collections yet

+

+ Create your first collection to get started +

+
+ ) : ( + <> + {folders.map((folder) => ( + + ))} + {isDragging && ( +
+ Drop here to move to root level +
+ )} + + )} ); diff --git a/src/webview/sidebar/sidebar.css b/src/webview/sidebar/sidebar.css index bde7dda..2eee817 100644 --- a/src/webview/sidebar/sidebar.css +++ b/src/webview/sidebar/sidebar.css @@ -122,6 +122,7 @@ body { display: flex; align-items: center; gap: var(--rl-sp2); + margin-top: var(--rl-sp3); } /* ---- Tree ---- */ @@ -364,7 +365,7 @@ body { justify-content: center; gap: var(--rl-sp3); flex: 1; - min-height: var(--rl-ctrl); + height: var(--rl-ctrl); padding: 0 var(--rl-sp4); border: none; border-radius: var(--rl-r3); @@ -576,3 +577,4 @@ body { from { opacity: 0; } to { opacity: 1; } } + diff --git a/src/webview/types/internal.types.ts b/src/webview/types/internal.types.ts index 2161580..f3eeec4 100644 --- a/src/webview/types/internal.types.ts +++ b/src/webview/types/internal.types.ts @@ -103,6 +103,27 @@ export interface ResponseData { cookies?: ResponseCookie[]; } +export interface HistoryEntry { + id: string; + requestId: string; + requestName: string; + folderId: string; + timestamp: number; + request: { + method: string; + url: string; + resolvedUrl: string; + headers: Header[]; + params: Header[]; + body?: string; + contentType?: string; + formData?: FormDataItem[]; + cookies?: Cookie[]; + }; + response: ResponseData; + truncated?: boolean; +} + export interface RequestEditorProps { requestId: string; requestName: string;