diff --git a/docs/superpowers/plans/2026-07-29-history-response-viewer.md b/docs/superpowers/plans/2026-07-29-history-response-viewer.md new file mode 100644 index 0000000..5182ad8 --- /dev/null +++ b/docs/superpowers/plans/2026-07-29-history-response-viewer.md @@ -0,0 +1,1087 @@ +# History Response Viewer & Restore UX 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:** Give History entries (both the standalone History panel and the per-request History tab) a tabbed, full-size response viewer with Copy/Download/Open-in-Editor actions matching the live Response panel, and replace the unexplained one-click "Restore" button with a tooltip + confirmation dialog. + +**Architecture:** Extract the extension-host download/open-in-editor logic into a shared helper so both `RequestEditorProvider` and `HistoryEditorProvider` can serve those messages. On the webview side, add two new shared components — `HistoryResponseViewer` (tabs + actions, reusing the existing `BodyEditor` Monaco component) and a generic `ConfirmDialog` — and wire them into the existing shared `HistoryEntryList`, which both the standalone History panel and the request editor's History tab already render. + +**Tech Stack:** TypeScript (strict), React (classic JSX runtime), `@monaco-editor/react` (already a dependency), VS Code Webview API, no test framework. + +## Global Constraints + +- Strict TypeScript — no `@ts-nocheck`, `@ts-ignore`, or eslint-disable suppression comments (from project CLAUDE.md). +- Keep components under 500 lines; split into a sub-component if a file would grow beyond that (from project CLAUDE.md). +- **Do not run `npm run build` or `npm run watch`** — the project's CLAUDE.md says this is slow and is the developer's job. After each task, verify only with `npx tsc --noEmit`. Each task also lists a manual QA checklist for the developer to run later — do not attempt to launch the Extension Development Host yourself. +- Follow the existing custom-CSS convention already used in `request/styles.css` and `history/styles.css` (plain class names, CSS custom properties like `--rl-sp3`, `--glass-bg`, `--restlab-accent` — **not** Tailwind; Tailwind is only used in the sidebar bundle). +- Work happens on the already-created branch `feat/history-response-viewer` — do not create a new branch. +- Design spec: `docs/superpowers/specs/2026-07-29-history-response-viewer-design.md`. + +--- + +### Task 1: Extract shared response file-action helpers + +**Files:** +- Create: `src/utils/responseFileActions.ts` +- Modify: `src/providers/RequestEditorProvider.ts:1-13` (imports), `:420-464` (switch cases) + +**Interfaces:** +- Produces: `handleDownloadResponse(message: { content: string; filename: string }): Promise` and `handleOpenResponseInEditor(message: { content: string; extension?: string; mimeType?: string }): Promise`, exported from `src/utils/responseFileActions.ts`. Later tasks (Task 2) import these two functions by name. + +This is a pure refactor — behavior must be identical to today. `RequestEditorProvider.ts` currently has this logic inlined (read it first to confirm the exact current text before editing): + +```ts + case "downloadResponse": + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(message.filename), + filters: { + "All Files": ["*"], + JSON: ["json"], + XML: ["xml"], + Text: ["txt"], + HTML: ["html"], + }, + }); + if (uri) { + await vscode.workspace.fs.writeFile( + uri, + Buffer.from(message.content, "utf-8"), + ); + vscode.window.showInformationMessage( + `Response saved to ${uri.fsPath}`, + ); + } + break; + case "openResponseInEditor": + // Determine language ID based on extension or mime type + let languageId = "plaintext"; + if (message.extension === "json") { + languageId = "json"; + } else if (message.extension === "xml") { + languageId = "xml"; + } else if (message.extension === "html") { + languageId = "html"; + } else if (message.mimeType?.includes("json")) { + languageId = "json"; + } else if (message.mimeType?.includes("xml")) { + languageId = "xml"; + } else if (message.mimeType?.includes("html")) { + languageId = "html"; + } + + // Open a new untitled document with the response content + const doc = await vscode.workspace.openTextDocument({ + content: message.content, + language: languageId, + }); + await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside); + break; +``` + +- [ ] **Step 1: Create `src/utils/responseFileActions.ts`** + +```ts +import * as vscode from "vscode"; + +export async function handleDownloadResponse(message: { + content: string; + filename: string; +}): Promise { + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(message.filename), + filters: { + "All Files": ["*"], + JSON: ["json"], + XML: ["xml"], + Text: ["txt"], + HTML: ["html"], + }, + }); + if (uri) { + await vscode.workspace.fs.writeFile( + uri, + Buffer.from(message.content, "utf-8"), + ); + vscode.window.showInformationMessage(`Response saved to ${uri.fsPath}`); + } +} + +export async function handleOpenResponseInEditor(message: { + content: string; + extension?: string; + mimeType?: string; +}): Promise { + let languageId = "plaintext"; + if (message.extension === "json") { + languageId = "json"; + } else if (message.extension === "xml") { + languageId = "xml"; + } else if (message.extension === "html") { + languageId = "html"; + } else if (message.mimeType?.includes("json")) { + languageId = "json"; + } else if (message.mimeType?.includes("xml")) { + languageId = "xml"; + } else if (message.mimeType?.includes("html")) { + languageId = "html"; + } + + const doc = await vscode.workspace.openTextDocument({ + content: message.content, + language: languageId, + }); + await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside); +} +``` + +- [ ] **Step 2: Update `src/providers/RequestEditorProvider.ts` imports** + +Add this import near the other relative imports at the top of the file (after the `HistoryManager`/`SidebarProvider` imports around line 11-13): + +```ts +import { + handleDownloadResponse, + handleOpenResponseInEditor, +} from "../utils/responseFileActions"; +``` + +- [ ] **Step 3: Replace the inlined switch cases** + +Replace the `case "downloadResponse":` and `case "openResponseInEditor":` blocks shown above with: + +```ts + case "downloadResponse": + await handleDownloadResponse(message); + break; + case "openResponseInEditor": + await handleOpenResponseInEditor(message); + break; +``` + +- [ ] **Step 4: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/utils/responseFileActions.ts src/providers/RequestEditorProvider.ts +git commit -m "refactor: extract response download/open-in-editor logic into a shared helper" +``` + +**Manual QA (developer, later):** Open a request, send it, and confirm Download and Open-in-Editor in the live Response panel still work exactly as before (this task is a pure refactor of existing, already-working behavior). + +--- + +### Task 2: Wire Download/Open-in-Editor/Copy support into the standalone History panel + +**Files:** +- Modify: `src/providers/HistoryEditorProvider.ts:1-4` (imports), `:55-90` (message switch) + +**Interfaces:** +- Consumes: `handleDownloadResponse`, `handleOpenResponseInEditor` from `src/utils/responseFileActions.ts` (Task 1). + +- [ ] **Step 1: Add imports** + +At the top of `src/providers/HistoryEditorProvider.ts`, add: + +```ts +import { + handleDownloadResponse, + handleOpenResponseInEditor, +} from "../utils/responseFileActions"; +``` + +- [ ] **Step 2: Add three new cases to the message switch** + +In the `panel.webview.onDidReceiveMessage(async (message) => { switch (message.type) { ... } })` block, add these cases (placement doesn't matter — add them after the existing `setHistoryEnabled` case, before the closing `}`): + +```ts + case "showInfo": + vscode.window.showInformationMessage(message.message); + break; + case "downloadResponse": + await handleDownloadResponse(message); + break; + case "openResponseInEditor": + await handleOpenResponseInEditor(message); + break; +``` + +This requires `vscode` to be imported in this file — it already is (`import * as vscode from "vscode";` at the top). + +- [ ] **Step 3: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/providers/HistoryEditorProvider.ts +git commit -m "feat: support downloading and opening response bodies from the standalone History panel" +``` + +**Manual QA (developer, later):** Nothing is wired up on the frontend yet (Task 3 does that) — this task alone has nothing to click. Verified by `tsc` only. + +--- + +### Task 3: Tabbed response viewer with Copy/Download/Open-in-Editor in History + +**Files:** +- Create: `src/webview/components/HistoryResponseViewer.tsx` +- Modify: `src/webview/components/HistoryEntryList.tsx` (full file — response section + new prop) +- Modify: `src/webview/history/HistoryView.tsx` (pass `vscode` prop down) +- Modify: `src/webview/request/HistoryTab.tsx` (pass `vscode` prop down) +- Modify: `src/webview/history/styles.css` (add tab/action-button classes, new viewer sizing) +- Modify: `src/webview/request/styles.css` (add viewer sizing override only — tab/action classes already exist there) + +**Interfaces:** +- Consumes: `BodyEditor` from `src/webview/request/BodyEditor.tsx` (existing, props: `value: string`, `language: string`, `readOnly?: boolean`, `className?: string`, `showHint?: string`); `formatJson`, `getEditorLanguageFromContentType`, `getFileExtension` from `src/webview/helpers/helper.ts` (existing); `Tooltip` from `src/webview/components/Tooltip.tsx` (existing, props `text: string`, `position?: TooltipPosition`); `CopyIcon`/`DownloadIcon`/`PencilIcon` from `src/webview/components/icons/*` (existing, no props); `ResponseData` type from `src/webview/types/internal.types.ts` (existing). +- Produces: `HistoryResponseViewer` component, default export, props `{ response: ResponseData; truncated?: boolean; vscode: { postMessage: (message: unknown) => void } }`. `HistoryEntryList` gains a new required prop `vscode: { postMessage: (message: unknown) => void }`, consumed by Task 4 as well. + +#### Step 1: Create `src/webview/components/HistoryResponseViewer.tsx` + +- [ ] Write the file: + +```tsx +import React, { useState } from "react"; +import { + formatJson, + getEditorLanguageFromContentType, + getFileExtension, +} from "../helpers/helper"; +import BodyEditor from "../request/BodyEditor"; +import { ResponseData } from "../types/internal.types"; +import CopyIcon from "./icons/CopyIcon"; +import DownloadIcon from "./icons/DownloadIcon"; +import PencilIcon from "./icons/PencilIcon"; +import Tooltip from "./Tooltip"; + +type ResponseTab = "body" | "headers" | "cookies"; + +interface HistoryResponseViewerProps { + response: ResponseData; + truncated?: boolean; + vscode: { postMessage: (message: unknown) => void }; +} + +const HistoryResponseViewer: React.FC = ({ + response, + truncated, + vscode, +}) => { + const [tab, setTab] = useState("body"); + + const contentType = response.headers["content-type"]; + + const getResponseContent = () => + tab === "body" + ? formatJson(response.data) + : Object.entries(response.headers) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); + + const getResponseFileInfo = () => ({ + extension: tab === "body" ? getFileExtension(response.headers) : "txt", + mimeType: tab === "body" ? contentType || "text/plain" : "text/plain", + }); + + return ( +
+
+
+ + + {(response.cookies?.length || 0) > 0 && ( + + )} +
+
+ + + + + + + + + +
+
+ + {truncated && ( +

+ Response content was truncated for storage — actions above use the + stored (possibly partial) data. +

+ )} + +
+ {tab === "body" && ( + + )} + {tab === "headers" && ( +
+ {Object.keys(response.headers).length === 0 ? ( +

No headers available

+ ) : ( + Object.entries(response.headers).map(([key, value]) => ( +
+ {key} + {value} +
+ )) + )} +
+ )} + {tab === "cookies" && ( +
+ {(response.cookies || []).map((cookie, i) => ( +
+ {cookie.name} + + {cookie.value} + {cookie.path && ( + + Path: {cookie.path} + + )} + {cookie.httpOnly && ( + + HttpOnly + + )} + {cookie.secure && ( + + Secure + + )} + +
+ ))} +
+ )} +
+
+ ); +}; + +export default HistoryResponseViewer; +``` + +#### Step 2: Update `src/webview/components/HistoryEntryList.tsx` + +- [ ] Replace the whole file with: + +```tsx +import React, { useState } from "react"; +import { formatRelativeTime, getStatusColor } from "../helpers/helper"; +import { formatJson } from "../helpers/helper"; +import { HistoryEntry } from "../types/internal.types"; +import HistoryResponseViewer from "./HistoryResponseViewer"; +import Tooltip from "./Tooltip"; +import TrashIcon from "./icons/TrashIcon"; + +interface HistoryEntryListProps { + entries: HistoryEntry[]; + showRequestName?: boolean; + vscode: { postMessage: (message: unknown) => void }; + 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, + vscode, + 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 && ( +
+
+

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

+ +
+ + +
+ )} +
+ ); + })} +
+ ); +}; + +// Placeholder for Task 4 — Task 4 replaces this with the real +// restore-confirmation-aware implementation and removes this comment. +const HistoryEntryActions: React.FC<{ + entry: HistoryEntry; + onRestore: (entryId: string) => void; + onDelete: (entryId: string) => void; +}> = ({ entry, onRestore, onDelete }) => ( +
+ + + + +
+); + +export default HistoryEntryList; +``` + +Note: this step intentionally leaves the Restore button behavior unchanged (extracted into a small `HistoryEntryActions` sub-component so Task 4 can replace just that piece without re-touching the rest of the file). The `entry.truncated` banner that used to sit above the Request section is now handled inside `HistoryResponseViewer` (next to the response actions, per the design spec), so it is not duplicated here. + +#### Step 3: Thread the `vscode` prop through both callers + +- [ ] In `src/webview/history/HistoryView.tsx`, find the `` usage and add `vscode={vscode}` (the module-level `vscode` from `acquireVsCodeApi()` already declared at the top of that file): + +```tsx + +``` + +- [ ] In `src/webview/request/HistoryTab.tsx`, destructure `vscode` from `useRequestContext()` and pass it down: + +```tsx +const HistoryTab: React.FC = () => { + const { + historyEntries, + vscode, + handleRestoreHistoryEntry, + handleDeleteHistoryEntry, + handleClearRequestHistory, + } = useRequestContext(); + + return ( +
+
+
+

Request History

+ {historyEntries.length > 0 && ( + + )} +
+ +
+
+ ); +}; +``` + +#### Step 4: Add CSS + +- [ ] In `src/webview/history/styles.css`, append these new rules at the end of the file (they duplicate the tab/action-button styling that already exists in `request/styles.css`, following this codebase's existing per-bundle CSS duplication convention): + +```css +/* ---- Response tabs & actions (duplicated from request/styles.css) ---- */ +.tabs { + display: flex; + gap: var(--rl-sp1); + overflow-x: auto; + scrollbar-width: none; + position: relative; +} +.tabs::-webkit-scrollbar { + height: 0; +} + +.tab { + display: flex; + align-items: center; + gap: var(--rl-sp2); + padding: var(--rl-sp2) var(--rl-sp2); + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: 0.92em; + font-weight: 500; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + flex-shrink: 0; + white-space: nowrap; +} + +.tab::before { + content: ""; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + height: 2px; + background: var(--restlab-gradient); + transform: scaleX(0); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 1px; +} + +.tab:hover { + color: var(--vscode-foreground); + background: var(--glass-bg); +} + +.tab.active { + color: var(--restlab-accent); +} + +.tab.active::before { + transform: scaleX(1); +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.4em; + height: 1.4em; + padding: 0 0.42em; + font-size: 0.72em; + font-weight: 700; + background: var(--restlab-gradient); + color: #ffffff; + border-radius: 0.8em; +} + +.response-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--rl-sp2); + flex-wrap: wrap; + gap: var(--rl-sp3); +} + +.response-actions { + display: flex; + gap: 8px; +} + +.response-actions .action-btn { + display: flex; + align-items: center; + gap: var(--rl-sp2); + height: var(--rl-ctrl); + padding: 0 var(--rl-sp4); + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + background: var(--glass-bg); + color: var(--vscode-foreground); + font-size: 0.8em; + font-weight: 500; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.response-actions .action-btn:hover { + background: var(--restlab-gradient); + border-color: transparent; + color: #ffffff; + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--restlab-accent-glow); +} + +.response-actions .action-btn svg { + flex-shrink: 0; +} + +.response-content { + flex: 1; + min-height: 0; + overflow: visible; + display: flex; + flex-direction: column; +} + +/* ---- History-specific response viewer sizing ---- */ +.history-response-viewer { + display: flex; + flex-direction: column; + gap: var(--rl-sp2); +} + +.history-response-viewer .response-editor { + height: 360px; + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + overflow: hidden; +} + +.history-response-truncated-hint { + margin: 0; +} +``` + +- [ ] In `src/webview/request/styles.css`, `.tabs`, `.tab`, `.badge`, `.response-toolbar`, `.response-actions`, and `.response-content` already exist (used by `ResponsePanel.tsx`) — only append the History-specific sizing override, right after the existing `.response-editor` rule (around line 1734): + +```css +.history-response-viewer { + display: flex; + flex-direction: column; + gap: var(--rl-sp2); +} + +.history-response-viewer .response-editor { + height: 360px; + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + overflow: hidden; +} + +.history-response-truncated-hint { + margin: 0; +} +``` + +Check `--restlab-accent-glow` is defined in `history/styles.css` before relying on it in the copied `.response-actions .action-btn:hover` rule — if it's missing, add `--restlab-accent-glow: rgba(56, 189, 248, 0.35);` (matching `request/styles.css`'s definition) to the `:root` block near the other `--restlab-*` variables at the top of `history/styles.css`. + +- [ ] **Step 5: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 6: Commit** + +```bash +git add src/webview/components/HistoryResponseViewer.tsx \ + src/webview/components/HistoryEntryList.tsx \ + src/webview/history/HistoryView.tsx \ + src/webview/request/HistoryTab.tsx \ + src/webview/history/styles.css \ + src/webview/request/styles.css +git commit -m "feat: tabbed response viewer with copy/download/open-in-editor for History entries" +``` + +**Manual QA (developer, later):** +- In the standalone History panel: expand an entry, confirm Body/Headers/Cookies tabs appear, the body area is noticeably larger than before (360px, was 240px) and scrolls internally, and Copy/Download/Open-in-Editor all work. +- Repeat inside a request's History tab. +- Expand an entry that has `truncated: true` (or force one by sending a request with a very large response, if the pruning logic truncates it) and confirm the truncation note appears next to the actions and the actions remain clickable. + +--- + +### Task 4: Restore button — tooltip and confirmation dialog + +**Files:** +- Create: `src/webview/components/ConfirmDialog.tsx` +- Modify: `src/webview/components/HistoryEntryList.tsx` (replace the `HistoryEntryActions` sub-component from Task 3 with the confirmation-aware version) +- Modify: `src/webview/history/styles.css` (append confirm-dialog CSS) +- Modify: `src/webview/request/styles.css` (append confirm-dialog CSS) + +**Interfaces:** +- Consumes: nothing from earlier tasks besides the `HistoryEntryActions` sub-component location established in Task 3. +- Produces: `ConfirmDialog` component, default export, props `{ title: string; message: string; confirmLabel?: string; cancelLabel?: string; danger?: boolean; onConfirm: () => void; onCancel: () => void }`. Generic — not History-specific — for reuse elsewhere later. + +#### Step 1: Create `src/webview/components/ConfirmDialog.tsx` + +- [ ] Write the file: + +```tsx +import React from "react"; +import { createPortal } from "react-dom"; + +interface ConfirmDialogProps { + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; + onConfirm: () => void; + onCancel: () => void; +} + +const ConfirmDialog: React.FC = ({ + title, + message, + confirmLabel = "Confirm", + cancelLabel = "Cancel", + danger = false, + onConfirm, + onCancel, +}) => { + return createPortal( +
+
e.stopPropagation()} + > +

{title}

+

{message}

+
+ + +
+
+
, + document.body, + ); +}; + +export default ConfirmDialog; +``` + +#### Step 2: Replace the `HistoryEntryActions` sub-component in `HistoryEntryList.tsx` + +- [ ] In `src/webview/components/HistoryEntryList.tsx`, add these imports at the top (alongside the existing ones): + +```tsx +import ConfirmDialog from "./ConfirmDialog"; +``` + +- [ ] Replace the `HistoryEntryActions` component (added in Task 3) with: + +```tsx +const HistoryEntryActions: React.FC<{ + entry: HistoryEntry; + onRestore: (entryId: string) => void; + onDelete: (entryId: string) => void; +}> = ({ entry, onRestore, onDelete }) => { + const [confirmingRestore, setConfirmingRestore] = useState(false); + + return ( +
+ + + + + + + {confirmingRestore && ( + { + setConfirmingRestore(false); + onRestore(entry.id); + }} + onCancel={() => setConfirmingRestore(false)} + /> + )} +
+ ); +}; +``` + +This adds a second `useState` import usage in the same file — `useState` is already imported at the top of `HistoryEntryList.tsx` from Task 3's version, so no import change is needed there. + +#### Step 3: Add confirm-dialog CSS + +- [ ] Append to `src/webview/history/styles.css`: + +```css +/* ---- Confirm dialog ---- */ +.confirm-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.confirm-dialog { + width: min(360px, calc(100vw - 32px)); + background: var(--vscode-editor-background); + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + padding: var(--rl-sp4); + display: flex; + flex-direction: column; + gap: var(--rl-sp3); +} + +.confirm-dialog-title { + font-size: 14px; + font-weight: 700; + margin: 0; +} + +.confirm-dialog-message { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin: 0; + line-height: 1.5; +} + +.confirm-dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--rl-sp2); + margin-top: var(--rl-sp2); +} + +.confirm-dialog-cancel, +.confirm-dialog-confirm { + height: var(--rl-ctrl); + padding: 0 var(--rl-sp4); + border-radius: var(--rl-r2); + font-size: 0.85em; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.confirm-dialog-cancel { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + color: var(--vscode-foreground); +} + +.confirm-dialog-cancel:hover { + background: var(--glass-border); +} + +.confirm-dialog-confirm { + background: var(--restlab-gradient); + color: #ffffff; +} + +.confirm-dialog-confirm:hover { + filter: brightness(1.1); +} + +.confirm-dialog-confirm.danger { + background: var(--restlab-danger); +} + +.confirm-dialog-confirm.danger:hover { + background: #dc2626; +} +``` + +- [ ] Append the identical block to `src/webview/request/styles.css` (the History tab lives in this bundle too, so it needs its own copy per the existing per-bundle CSS convention). + +- [ ] **Step 4: Type-check** + +Run: `npx tsc --noEmit` +Expected: no errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/webview/components/ConfirmDialog.tsx \ + src/webview/components/HistoryEntryList.tsx \ + src/webview/history/styles.css \ + src/webview/request/styles.css +git commit -m "feat: add tooltip and confirmation dialog to History's Restore button" +``` + +**Manual QA (developer, later):** +- Hover the Restore button — confirm the tooltip explains what it does. +- Click Restore — confirm a dialog appears naming the request and explaining the overwrite, with Cancel and Restore buttons. +- Click Cancel — confirm nothing changes (no overwrite, dialog closes). +- Click the overlay outside the dialog — confirm it also cancels (same as Cancel). +- Click Restore (confirm) — confirm the request's saved form state is overwritten exactly as it was before this change (same underlying `restoreHistoryEntryById` logic, untouched). + +--- + +## Self-Review Notes + +- **Spec coverage:** Bigger/tabbed response area → Task 3. Copy/Download/Open-in-Editor → Tasks 1-3. Restore tooltip + confirmation → Task 4. Truncated-entry handling → Task 3. Shared backend logic (no duplication) → Task 1-2. CSS/component size discipline → noted per task. +- **Deviation from spec, called out explicitly:** the spec said to "remove the `240px` cap on `.history-body`." In practice `.history-body` is also used by the still-unchanged Request-body `
` block, so this plan leaves that class untouched and instead gives the response viewer its own `.history-response-viewer .response-editor { height: 360px }` rule — same effective outcome (response is no longer capped at 240px) without touching the Request section's styling, which the spec says must stay as-is.
+- **Type consistency:** `vscode: { postMessage: (message: unknown) => void }` is used identically in `HistoryEntryList`, `HistoryResponseViewer` — matches the shape already used by `EnvironmentModal.tsx` elsewhere in the codebase. `ResponseData` and `HistoryEntry` types are consumed as already defined in `internal.types.ts`, no changes needed there.
diff --git a/docs/superpowers/specs/2026-07-29-history-response-viewer-design.md b/docs/superpowers/specs/2026-07-29-history-response-viewer-design.md
new file mode 100644
index 0000000..7b2fb5d
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-29-history-response-viewer-design.md
@@ -0,0 +1,85 @@
+# History Response Viewer & Restore UX Design
+
+**Date:** 2026-07-29
+**Branch:** feat/history-response-viewer
+**Status:** Approved
+
+## Goal
+
+Fix three related UX problems in the History section (both the standalone History editor panel and the per-request History tab, which share `HistoryEntryList.tsx`):
+
+1. The saved response body area is too small (`
` capped at `max-height: 240px`) and has no Body/Headers/Cookies tabs, unlike the live Response panel.
+2. History entries have no Copy / Download / Open-in-Editor actions for the response, unlike the live Response panel.
+3. The "Restore" button has no explanation of what it does and no confirmation, even though it silently overwrites the request's saved form state with no undo.
+
+## Architecture
+
+### New component: `src/webview/components/HistoryResponseViewer.tsx`
+
+Replaces the response `
` block in `HistoryEntryList.tsx` (current lines ~98-115) for a single `HistoryEntry`. Mirrors `ResponsePanel.tsx`'s pattern, scoped to that entry's already-persisted `response: ResponseData`:
+
+- Local `tab` state: `"body" | "headers" | "cookies"`.
+- Tab bar identical in structure to `ResponsePanel.tsx:137-169` (Body, Headers with count badge, Cookies with count badge — only rendered if `response.cookies?.length`).
+- Body tab renders a read-only Monaco `BodyEditor` (replacing the `
`), sized to fill available space instead of the current 240px cap — same `getEditorLanguageFromContentType`/`formatJson` helpers already used elsewhere for language detection and pretty-printing.
+- Headers/Cookies tabs reuse the existing `.response-headers` / `.response-header-row` row markup already used both in `ResponsePanel.tsx` and in the current History detail view.
+- Action toolbar (`.response-actions`): Copy, Download, Open in Editor — same three buttons, same `Tooltip` wrapping, same message shapes (`showInfo`, `downloadResponse`, `openResponseInEditor`) as `ResponsePanel.tsx:170-214`. Content/extension/mimeType derivation reuses `getFileExtension`/`getEditorLanguageFromContentType` against `entry.response.headers`, mirroring `getResponseContent`/`getResponseFileInfo` in `ResponsePanel.tsx:44-60` but reading from the entry's data instead of live context state.
+- If `entry.truncated`, the existing "Some content was truncated for storage." hint is kept, and rendered adjacent to the action toolbar (not just above the Request section) so it's visible without scrolling up. Buttons remain enabled — the entry's stored data is still valid, just possibly incomplete.
+
+The Request section of `HistoryEntryList.tsx` (method/URL/headers/body) is unchanged — out of scope per the original complaint, which was specifically about the response body.
+
+### `HistoryEntryList.tsx` changes
+
+- Add a required prop `vscode: { postMessage: (message: unknown) => void }` (same minimal shape already used by `EnvironmentModal.tsx`), needed by `HistoryResponseViewer` for Copy/Download/Open-in-Editor and by the new confirm dialog flow below.
+- Replace the response `
` block with ``.
+- Restore button:
+  - Wrap in ``.
+  - `onClick` no longer calls `onRestore` directly; it sets local state (`confirmRestoreId`) to open a `ConfirmDialog` instead.
+  - On confirm, calls `onRestore(entry.id)` and closes the dialog; on cancel, just closes it.
+
+### New component: `src/webview/components/ConfirmDialog.tsx`
+
+Generic, reusable confirm modal — not History-specific — since VS Code webviews can't use the native `window.confirm()`. Props: `title: string`, `message: string`, `confirmLabel?: string` (default "Confirm"), `cancelLabel?: string` (default "Cancel"), `danger?: boolean` (styles the confirm button as destructive), `onConfirm: () => void`, `onCancel: () => void`. Renders a fixed-position overlay + centered panel, following the existing custom-CSS (non-Tailwind) convention used throughout `request/`, `history/`, and the shared `components/` directory (`EnvironmentModal.tsx`'s Tailwind styling is specific to the sidebar bundle and not reused here).
+
+For the Restore flow specifically: title "Restore this request?", message naming what will be overwritten (method/URL/headers/params/body) and that it can't be undone, `danger` styling, confirm label "Restore".
+
+### Callers: `HistoryView.tsx` and `HistoryTab.tsx`
+
+- `HistoryView.tsx`: pass the module-level `vscode` (already `acquireVsCodeApi()`'d there) down as the new `vscode` prop to `HistoryEntryList`.
+- `HistoryTab.tsx`: pull `vscode` from `useRequestContext()` (already exposed there per `ResponsePanel.tsx:33`) and pass it down the same way.
+
+## Backend / Message Wiring
+
+- `RequestEditorProvider.ts` already handles `downloadResponse` / `openResponseInEditor` (lines 420-464) and `showInfo` (line 417-419) — the request-panel History tab reuses these unchanged since it shares that panel's message channel.
+- `HistoryEditorProvider.ts` (the standalone History panel) has no such handlers today. Rather than duplicating the ~45 lines of `vscode.window.showSaveDialog` / `vscode.workspace.fs.writeFile` / `vscode.workspace.openTextDocument` logic into a second provider, extract it into a shared helper module:
+  - **New file `src/utils/responseFileActions.ts`** exporting `handleDownloadResponse(message: { content: string; filename: string }): Promise` and `handleOpenResponseInEditor(message: { content: string; extension?: string; mimeType?: string }): Promise`, containing the exact logic currently inline in `RequestEditorProvider.ts:420-464`.
+  - `RequestEditorProvider.ts`'s `downloadResponse`/`openResponseInEditor` cases call these helpers instead of inlining the logic (behavior unchanged).
+  - `HistoryEditorProvider.ts` adds three new cases to its `onDidReceiveMessage` switch: `showInfo` (→ `vscode.window.showInformationMessage`), `downloadResponse` (→ `handleDownloadResponse`), `openResponseInEditor` (→ `handleOpenResponseInEditor`).
+
+## Data Model
+
+No changes. `HistoryEntry.response` (`src/webview/types/internal.types.ts:106-125`) already carries the full `ResponseData` shape (`status`, `statusText`, `headers`, `data`, `size`, `time`, `cookies`) that `ResponsePanel` consumes, and `entry.truncated` already exists and is reused as-is.
+
+## CSS
+
+- Remove the `240px` cap on `.history-body` (`history/styles.css:328`) — no longer used once the Body tab moves to `BodyEditor`.
+- Add response-viewer sizing rules (min-height, scroll behavior) modeled on `.response-content`/`.response-editor` in `request/styles.css`, added to both `history/styles.css` (standalone panel) and `request/styles.css` (History tab reuses the same component inside that bundle, and each Vite bundle ships its own CSS independently).
+- Add `.confirm-dialog-overlay` / `.confirm-dialog` styles to whichever stylesheet is shared by both bundles that mount `ConfirmDialog` (`history/styles.css` and `request/styles.css`, mirroring the existing pattern of some shared component classes being duplicated per bundle, e.g. `.history-*`/`.status-badge`).
+
+## Component Size
+
+`HistoryEntryList.tsx` (currently 148 lines) stays small by delegating to `HistoryResponseViewer.tsx` and `ConfirmDialog.tsx` rather than growing inline, consistent with the CLAUDE.md 500-line guideline. `HistoryEditorProvider.ts` (currently 121 lines) stays small by delegating file-action logic to `responseFileActions.ts` rather than inlining it.
+
+## What Is Not Changed
+
+- `HistoryManager`, `SidebarProvider.restoreHistoryEntryById` (the actual overwrite logic) — unaffected; only the UI trigger path gains a confirmation step.
+- The Request section of the history detail view — stays as plain text, not tabbed.
+- Any persistence/truncation behavior for how much response data is saved to history.
+
+## Testing
+
+No test suite in this repo. Verification: `npx tsc --noEmit`, plus manual testing in both surfaces (standalone History panel and the request editor's History tab):
+
+- Expand an entry, confirm Body/Headers/Cookies tabs render and the body area is no longer cramped.
+- Copy, Download, and Open in Editor all work and match the live Response panel's behavior.
+- A truncated entry shows the warning near the actions and buttons remain usable.
+- Clicking Restore opens the confirmation dialog with a clear explanation; Cancel does nothing; Confirm performs the restore exactly as before.
diff --git a/package.json b/package.json
index 907b014..2701ef6 100644
--- a/package.json
+++ b/package.json
@@ -87,7 +87,7 @@
     "commands": [
       {
         "command": "restlab.createFolder",
-        "title": "Create Folder",
+        "title": "Create Collection",
         "category": "REST Lab"
       },
       {
diff --git a/src/extension.ts b/src/extension.ts
index d9944b4..faf41eb 100644
--- a/src/extension.ts
+++ b/src/extension.ts
@@ -194,8 +194,8 @@ export async function activate(context: vscode.ExtensionContext) {
   context.subscriptions.push(
     vscode.commands.registerCommand("restlab.createFolder", async () => {
       const folderName = await vscode.window.showInputBox({
-        prompt: "Enter folder name",
-        placeHolder: "New Folder",
+        prompt: "Enter Collection name",
+        placeHolder: "New Collection",
       });
 
       if (folderName) {
diff --git a/src/providers/HistoryEditorProvider.ts b/src/providers/HistoryEditorProvider.ts
index 57013a8..3c53f3f 100644
--- a/src/providers/HistoryEditorProvider.ts
+++ b/src/providers/HistoryEditorProvider.ts
@@ -1,6 +1,10 @@
 import * as vscode from "vscode";
 import { getNonce } from "../utils/getNonce";
 import { SidebarProvider } from "./SidebarProvider";
+import {
+  handleDownloadResponse,
+  handleOpenResponseInEditor,
+} from "../utils/responseFileActions";
 
 export class HistoryEditorProvider {
   private static panel: vscode.WebviewPanel | undefined;
@@ -86,6 +90,15 @@ export class HistoryEditorProvider {
             HistoryEditorProvider._buildHistoryPayload(sidebarProvider),
           );
           break;
+        case "showInfo":
+          vscode.window.showInformationMessage(message.message);
+          break;
+        case "downloadResponse":
+          await handleDownloadResponse(message);
+          break;
+        case "openResponseInEditor":
+          await handleOpenResponseInEditor(message);
+          break;
       }
     });
   }
@@ -108,7 +121,7 @@ export class HistoryEditorProvider {
       
         
         
-        
+        
         
         History
       
diff --git a/src/providers/RequestEditorProvider.ts b/src/providers/RequestEditorProvider.ts
index 556b2db..13366f1 100644
--- a/src/providers/RequestEditorProvider.ts
+++ b/src/providers/RequestEditorProvider.ts
@@ -11,6 +11,10 @@ import {
 import { HistoryEditorProvider } from "./HistoryEditorProvider";
 import { HistoryManager } from "./HistoryManager";
 import { SidebarProvider } from "./SidebarProvider";
+import {
+  handleDownloadResponse,
+  handleOpenResponseInEditor,
+} from "../utils/responseFileActions";
 
 function parseSetCookie(raw: string): ResponseCookie {
   const parts = raw.split(';').map((p) => p.trim());
@@ -418,49 +422,10 @@ export class RequestEditorProvider {
           vscode.window.showInformationMessage(message.message);
           break;
         case "downloadResponse":
-          const uri = await vscode.window.showSaveDialog({
-            defaultUri: vscode.Uri.file(message.filename),
-            filters: {
-              "All Files": ["*"],
-              JSON: ["json"],
-              XML: ["xml"],
-              Text: ["txt"],
-              HTML: ["html"],
-            },
-          });
-          if (uri) {
-            await vscode.workspace.fs.writeFile(
-              uri,
-              Buffer.from(message.content, "utf-8"),
-            );
-            vscode.window.showInformationMessage(
-              `Response saved to ${uri.fsPath}`,
-            );
-          }
+          await handleDownloadResponse(message);
           break;
         case "openResponseInEditor":
-          // Determine language ID based on extension or mime type
-          let languageId = "plaintext";
-          if (message.extension === "json") {
-            languageId = "json";
-          } else if (message.extension === "xml") {
-            languageId = "xml";
-          } else if (message.extension === "html") {
-            languageId = "html";
-          } else if (message.mimeType?.includes("json")) {
-            languageId = "json";
-          } else if (message.mimeType?.includes("xml")) {
-            languageId = "xml";
-          } else if (message.mimeType?.includes("html")) {
-            languageId = "html";
-          }
-
-          // Open a new untitled document with the response content
-          const doc = await vscode.workspace.openTextDocument({
-            content: message.content,
-            language: languageId,
-          });
-          await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside);
+          await handleOpenResponseInEditor(message);
           break;
       }
     });
diff --git a/src/providers/SidebarProvider.ts b/src/providers/SidebarProvider.ts
index 6e637b3..07ab1b5 100644
--- a/src/providers/SidebarProvider.ts
+++ b/src/providers/SidebarProvider.ts
@@ -82,8 +82,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider {
       switch (message.type) {
         case "createFolder":
           const folderName = await vscode.window.showInputBox({
-            prompt: "Enter folder name",
-            placeHolder: "New Folder",
+            prompt: "Enter Collection name",
+            placeHolder: "New Collection",
           });
           if (folderName) {
             this.addFolder(folderName);
diff --git a/src/utils/responseFileActions.ts b/src/utils/responseFileActions.ts
new file mode 100644
index 0000000..2de225f
--- /dev/null
+++ b/src/utils/responseFileActions.ts
@@ -0,0 +1,51 @@
+import * as vscode from "vscode";
+
+export async function handleDownloadResponse(message: {
+  content: string;
+  filename: string;
+}): Promise {
+  const uri = await vscode.window.showSaveDialog({
+    defaultUri: vscode.Uri.file(message.filename),
+    filters: {
+      "All Files": ["*"],
+      JSON: ["json"],
+      XML: ["xml"],
+      Text: ["txt"],
+      HTML: ["html"],
+    },
+  });
+  if (uri) {
+    await vscode.workspace.fs.writeFile(
+      uri,
+      Buffer.from(message.content, "utf-8"),
+    );
+    vscode.window.showInformationMessage(`Response saved to ${uri.fsPath}`);
+  }
+}
+
+export async function handleOpenResponseInEditor(message: {
+  content: string;
+  extension?: string;
+  mimeType?: string;
+}): Promise {
+  let languageId = "plaintext";
+  if (message.extension === "json") {
+    languageId = "json";
+  } else if (message.extension === "xml") {
+    languageId = "xml";
+  } else if (message.extension === "html") {
+    languageId = "html";
+  } else if (message.mimeType?.includes("json")) {
+    languageId = "json";
+  } else if (message.mimeType?.includes("xml")) {
+    languageId = "xml";
+  } else if (message.mimeType?.includes("html")) {
+    languageId = "html";
+  }
+
+  const doc = await vscode.workspace.openTextDocument({
+    content: message.content,
+    language: languageId,
+  });
+  await vscode.window.showTextDocument(doc, vscode.ViewColumn.Beside);
+}
diff --git a/src/webview/components/ConfirmDialog.tsx b/src/webview/components/ConfirmDialog.tsx
new file mode 100644
index 0000000..f0143d8
--- /dev/null
+++ b/src/webview/components/ConfirmDialog.tsx
@@ -0,0 +1,50 @@
+import React from "react";
+import { createPortal } from "react-dom";
+
+interface ConfirmDialogProps {
+  title: string;
+  message: string;
+  confirmLabel?: string;
+  cancelLabel?: string;
+  danger?: boolean;
+  onConfirm: () => void;
+  onCancel: () => void;
+}
+
+const ConfirmDialog: React.FC = ({
+  title,
+  message,
+  confirmLabel = "Confirm",
+  cancelLabel = "Cancel",
+  danger = false,
+  onConfirm,
+  onCancel,
+}) => {
+  return createPortal(
+    
+
e.stopPropagation()} + > +

{title}

+

{message}

+
+ + +
+
+
, + document.body, + ); +}; + +export default ConfirmDialog; diff --git a/src/webview/components/HistoryEntryList.tsx b/src/webview/components/HistoryEntryList.tsx index 0fbb238..0742ec1 100644 --- a/src/webview/components/HistoryEntryList.tsx +++ b/src/webview/components/HistoryEntryList.tsx @@ -1,17 +1,15 @@ import React, { useState } from "react"; -import { - formatJson, - formatRelativeTime, - formatSize, - getStatusColor, -} from "../helpers/helper"; +import { formatJson, formatRelativeTime, getStatusColor } from "../helpers/helper"; import { HistoryEntry } from "../types/internal.types"; +import ConfirmDialog from "./ConfirmDialog"; +import HistoryResponseViewer from "./HistoryResponseViewer"; import Tooltip from "./Tooltip"; import TrashIcon from "./icons/TrashIcon"; interface HistoryEntryListProps { entries: HistoryEntry[]; showRequestName?: boolean; + vscode: { postMessage: (message: unknown) => void }; onRestore: (entryId: string) => void; onDelete: (entryId: string) => void; } @@ -24,6 +22,7 @@ const renderBody = (body: string | undefined, contentType?: string): string => { const HistoryEntryList: React.FC = ({ entries, showRequestName = false, + vscode, onRestore, onDelete, }) => { @@ -69,10 +68,6 @@ const HistoryEntryList: React.FC = ({ {isExpanded && (
- {entry.truncated && ( -

Some content was truncated for storage.

- )} -

Request

@@ -97,45 +92,19 @@ const HistoryEntryList: React.FC = ({

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"])}
-                  
+
-
- - - - -
+
)}
@@ -145,4 +114,55 @@ const HistoryEntryList: React.FC = ({ ); }; +const HistoryEntryActions: React.FC<{ + entry: HistoryEntry; + onRestore: (entryId: string) => void; + onDelete: (entryId: string) => void; +}> = ({ entry, onRestore, onDelete }) => { + const [confirmingRestore, setConfirmingRestore] = useState(false); + + return ( +
+ + + + + + + {confirmingRestore && ( + { + setConfirmingRestore(false); + onRestore(entry.id); + }} + onCancel={() => setConfirmingRestore(false)} + /> + )} +
+ ); +}; + export default HistoryEntryList; diff --git a/src/webview/components/HistoryResponseViewer.tsx b/src/webview/components/HistoryResponseViewer.tsx new file mode 100644 index 0000000..b4f1374 --- /dev/null +++ b/src/webview/components/HistoryResponseViewer.tsx @@ -0,0 +1,224 @@ +import React, { useState } from "react"; +import { + formatJson, + formatSize, + getEditorLanguageFromContentType, + getFileExtension, + parseTruncationOriginalSize, +} from "../helpers/helper"; +import BodyEditor from "../request/BodyEditor"; +import { ResponseData } from "../types/internal.types"; +import CopyIcon from "./icons/CopyIcon"; +import DownloadIcon from "./icons/DownloadIcon"; +import PencilIcon from "./icons/PencilIcon"; +import Tooltip from "./Tooltip"; + +type ResponseTab = "body" | "headers" | "cookies"; + +interface HistoryResponseViewerProps { + response: ResponseData; + truncated?: boolean; + requestBody?: string; + vscode: { postMessage: (message: unknown) => void }; +} + +const byteLength = (text: string) => new TextEncoder().encode(text).length; + +// Builds a precise "storing X of Y" message from the truncation marker +// HistoryManager embeds in whichever field(s) it actually truncated, rather +// than a generic notice with no numbers. +const buildTruncationMessage = ( + response: ResponseData, + requestBody?: string, +): string => { + const parts: string[] = []; + + const responseOriginal = parseTruncationOriginalSize(response.data); + if (responseOriginal !== null) { + parts.push( + `response: storing ${formatSize(byteLength(response.data))} of ${formatSize(responseOriginal)}`, + ); + } + + const requestOriginal = parseTruncationOriginalSize(requestBody); + if (requestOriginal !== null && requestBody) { + parts.push( + `request body: storing ${formatSize(byteLength(requestBody))} of ${formatSize(requestOriginal)}`, + ); + } + + return parts.length > 0 + ? `Truncated for storage (${parts.join("; ")}) — actions above use the stored, partial data.` + : "Some content was truncated for storage — actions above use the stored (possibly partial) data."; +}; + +const HistoryResponseViewer: React.FC = ({ + response, + truncated, + requestBody, + vscode, +}) => { + const [tab, setTab] = useState("body"); + + const contentType = response.headers["content-type"]; + + const getResponseContent = () => + tab === "body" + ? formatJson(response.data) + : Object.entries(response.headers) + .map(([k, v]) => `${k}: ${v}`) + .join("\n"); + + const getResponseFileInfo = () => ({ + extension: tab === "body" ? getFileExtension(response.headers) : "txt", + mimeType: tab === "body" ? contentType || "text/plain" : "text/plain", + }); + + return ( +
+
+ {response.statusText} · {formatSize(response.size)} +
+
+
+ + + {(response.cookies?.length || 0) > 0 && ( + + )} +
+
+ + + + + + + + + +
+
+ + {truncated && ( +

+ {buildTruncationMessage(response, requestBody)} +

+ )} + +
+ {tab === "body" && ( + + )} + {tab === "headers" && ( +
+ {Object.keys(response.headers).length === 0 ? ( +

No headers available

+ ) : ( + Object.entries(response.headers).map(([key, value]) => ( +
+ {key} + {value} +
+ )) + )} +
+ )} + {tab === "cookies" && ( +
+ {(response.cookies || []).map((cookie, i) => ( +
+ {cookie.name} + + {cookie.value} + {cookie.path && ( + + Path: {cookie.path} + + )} + {cookie.httpOnly && ( + + HttpOnly + + )} + {cookie.secure && ( + + Secure + + )} + +
+ ))} +
+ )} +
+
+ ); +}; + +export default HistoryResponseViewer; diff --git a/src/webview/helpers/helper.ts b/src/webview/helpers/helper.ts index 1204de1..eff0c1d 100644 --- a/src/webview/helpers/helper.ts +++ b/src/webview/helpers/helper.ts @@ -56,6 +56,18 @@ export const formatSize = (bytes: number): string => { return `${size} ${units[i]}`; }; +// Extract the pre-truncation byte size from the marker HistoryManager +// appends to a body/response field it truncated for storage +// (`...[truncated for storage, original size N bytes]`). Returns null when +// the text carries no such marker (i.e. this field wasn't the one truncated). +export const parseTruncationOriginalSize = ( + text: string | undefined, +): number | null => { + if (!text) return null; + const match = text.match(/\[truncated for storage, original size (\d+) bytes\]\s*$/); + return match ? parseInt(match[1], 10) : null; +}; + // Get placeholder text for body editor based on content type export const getBodyPlaceholder = (contentType?: string): string => { switch (contentType) { diff --git a/src/webview/history/HistoryView.tsx b/src/webview/history/HistoryView.tsx index d317977..4a55e81 100644 --- a/src/webview/history/HistoryView.tsx +++ b/src/webview/history/HistoryView.tsx @@ -76,6 +76,7 @@ export const HistoryView: React.FC = () => { diff --git a/src/webview/history/index.tsx b/src/webview/history/index.tsx index afe9ddd..1eed5e8 100644 --- a/src/webview/history/index.tsx +++ b/src/webview/history/index.tsx @@ -1,5 +1,7 @@ import React from "react"; import { createRoot } from "react-dom/client"; +// Configure Monaco to use local bundle (must be before any Monaco usage) +import "../config/monaco"; import "./styles.css"; import { HistoryView } from "./HistoryView"; diff --git a/src/webview/history/styles.css b/src/webview/history/styles.css index 20703db..e90f927 100644 --- a/src/webview/history/styles.css +++ b/src/webview/history/styles.css @@ -33,6 +33,7 @@ body { --restlab-accent: #38bdf8; --restlab-accent-hover: #0ea5e9; --restlab-accent-subtle: rgba(56, 189, 248, 0.1); + --restlab-accent-glow: rgba(56, 189, 248, 0.4); --restlab-danger: #ef4444; --restlab-danger-subtle: rgba(239, 68, 68, 0.1); --glass-bg: rgba(255, 255, 255, 0.03); @@ -359,3 +360,318 @@ body { .history-paused-hint { margin-bottom: var(--rl-sp3); } + +/* ---- Response tabs & actions (duplicated from request/styles.css) ---- */ +.tabs { + display: flex; + gap: var(--rl-sp1); + overflow-x: auto; + scrollbar-width: none; + position: relative; +} +.tabs::-webkit-scrollbar { + height: 0; +} + +.tab { + display: flex; + align-items: center; + gap: var(--rl-sp2); + padding: var(--rl-sp2) var(--rl-sp2); + border: none; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--vscode-descriptionForeground); + font-size: 0.92em; + font-weight: 500; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); + position: relative; + flex-shrink: 0; + white-space: nowrap; +} + +.tab::before { + content: ""; + position: absolute; + bottom: -2px; + left: 0; + right: 0; + height: 2px; + background: var(--restlab-gradient); + transform: scaleX(0); + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1); + border-radius: 1px; +} + +.tab:hover { + color: var(--vscode-foreground); + background: var(--glass-bg); +} + +.tab.active { + color: var(--restlab-accent); +} + +.tab.active::before { + transform: scaleX(1); +} + +.badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.4em; + height: 1.4em; + padding: 0 0.42em; + font-size: 0.72em; + font-weight: 700; + background: var(--restlab-gradient); + color: #ffffff; + border-radius: 0.8em; +} + +.response-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: var(--rl-sp2); + flex-wrap: wrap; + gap: var(--rl-sp3); +} + +.response-actions { + display: flex; + gap: 8px; +} + +.response-actions .action-btn { + display: flex; + align-items: center; + gap: var(--rl-sp2); + height: var(--rl-ctrl); + padding: 0 var(--rl-sp4); + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + background: var(--glass-bg); + color: var(--vscode-foreground); + font-size: 0.8em; + font-weight: 500; + cursor: pointer; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.response-actions .action-btn:hover { + background: var(--restlab-gradient); + border-color: transparent; + color: #ffffff; + transform: translateY(-1px); + box-shadow: 0 4px 12px var(--restlab-accent-glow); +} + +.response-actions .action-btn svg { + flex-shrink: 0; +} + +.response-content { + flex: 1; + min-height: 0; + overflow: visible; + display: flex; + flex-direction: column; +} + +/* ---- History-specific response viewer sizing ---- */ +.history-response-viewer { + display: flex; + flex-direction: column; + gap: var(--rl-sp2); +} + +.history-response-viewer .response-content { + /* This component has no bounded-height ancestor here (unlike the live + Response panel's .response-panel, which gets a JS-set pixel height) — + cancel the inherited flex-grow/flex-basis:0 behavior so children size + themselves deterministically instead of collapsing or overflowing. */ + flex: none; + min-height: 0; +} + +.history-response-viewer .response-editor { + flex: none; + height: 360px; + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + overflow: hidden; +} + +.history-response-viewer .response-content .response-headers { + flex: none; + max-height: 360px; + overflow: auto; +} + +.history-response-viewer .response-header-row { + /* .header-name only has a min-width floor (11em), no ceiling — long + names (access-control-allow-origin, access-control-allow-credentials, + etc.) overflow past their column and run into the value with no gap. + Stacking name above value sidesteps the column-width fight entirely, + the same way most REST clients/DevTools handle long header names. */ + flex-direction: column; + align-items: stretch; + gap: 2px; +} + +.history-response-viewer .response-header-row .header-name { + min-width: 0; +} + +.history-response-viewer .response-header-row .header-value { + /* flex: 1 items default to min-width: auto, refusing to shrink below + their content's natural width — the same class of bug fixed for the + sidebar's New Collection button, here for long unbroken header values. */ + min-width: 0; + overflow-wrap: anywhere; +} + +.history-response-truncated-hint { + margin: 0; +} + +.history-response-meta { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-bottom: var(--rl-sp2); +} + +/* ---- Confirm dialog ---- */ +.confirm-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.confirm-dialog { + width: min(360px, calc(100vw - 32px)); + background: var(--vscode-editor-background); + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + padding: var(--rl-sp4); + display: flex; + flex-direction: column; + gap: var(--rl-sp3); +} + +.confirm-dialog-title { + font-size: 14px; + font-weight: 700; + margin: 0; +} + +.confirm-dialog-message { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin: 0; + line-height: 1.5; +} + +.confirm-dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--rl-sp2); + margin-top: var(--rl-sp2); +} + +.confirm-dialog-cancel, +.confirm-dialog-confirm { + height: var(--rl-ctrl); + padding: 0 var(--rl-sp4); + border-radius: var(--rl-r2); + font-size: 0.85em; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.confirm-dialog-cancel { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + color: var(--vscode-foreground); +} + +.confirm-dialog-cancel:hover { + background: var(--glass-border); +} + +.confirm-dialog-confirm { + background: var(--restlab-gradient); + color: #ffffff; +} + +.confirm-dialog-confirm:hover { + filter: brightness(1.1); +} + +.confirm-dialog-confirm.danger { + background: var(--restlab-danger); +} + +.confirm-dialog-confirm.danger:hover { + background: #dc2626; +} + +/* JSON Editor with Monaco (duplicated from request/styles.css) */ +.json-editor-container { + position: relative; + width: 100%; + flex: 1; + min-height: 150px; + border: 1px solid var(--vscode-panel-border); + border-radius: 8px; + background-color: var(--vscode-input-background); + overflow: hidden; + display: flex; + flex-direction: column; +} + +.json-editor-container:focus-within { + border-color: var(--restlab-accent); +} + +.editor-placeholder { + position: absolute; + top: 8px; + left: 12px; + right: 12px; + color: var(--vscode-descriptionForeground); + opacity: 0.6; + pointer-events: none; + font-family: "SF Mono", "Fira Code", "Consolas", monospace; + white-space: pre-wrap; +} + +.json-editor-hint { + position: absolute; + bottom: 0; + left: 0; + right: 0; + padding: 4px 12px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + background: var(--vscode-input-background); + border-top: 1px solid var(--glass-border); + opacity: 0.7; + z-index: 10; +} + +.json-editor-hint span { + display: inline-flex; + align-items: center; + gap: 4px; +} diff --git a/src/webview/request/HistoryTab.tsx b/src/webview/request/HistoryTab.tsx index 530faf9..fc071af 100644 --- a/src/webview/request/HistoryTab.tsx +++ b/src/webview/request/HistoryTab.tsx @@ -5,6 +5,7 @@ import { useRequestContext } from "./RequestContext"; const HistoryTab: React.FC = () => { const { historyEntries, + vscode, handleRestoreHistoryEntry, handleDeleteHistoryEntry, handleClearRequestHistory, @@ -23,6 +24,7 @@ const HistoryTab: React.FC = () => { diff --git a/src/webview/request/styles.css b/src/webview/request/styles.css index a5ce8a8..d66795e 100644 --- a/src/webview/request/styles.css +++ b/src/webview/request/styles.css @@ -1553,6 +1553,12 @@ fieldset.form-section { gap: var(--rl-sp3); } +.history-response-meta { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-bottom: var(--rl-sp2); +} + .response-actions { display: flex; gap: 8px; @@ -1733,6 +1739,62 @@ fieldset.form-section { min-height: 0; } +.history-response-viewer { + display: flex; + flex-direction: column; + gap: var(--rl-sp2); +} + +.history-response-viewer .response-content { + /* This component has no bounded-height ancestor here (unlike the live + Response panel's .response-panel, which gets a JS-set pixel height) — + cancel the inherited flex-grow/flex-basis:0 behavior so children size + themselves deterministically instead of collapsing or overflowing. */ + flex: none; + min-height: 0; +} + +.history-response-viewer .response-editor { + flex: none; + height: 360px; + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + overflow: hidden; +} + +.history-response-viewer .response-content .response-headers { + flex: none; + max-height: 360px; + overflow: auto; +} + +.history-response-viewer .response-header-row { + /* .header-name only has a min-width floor (11em), no ceiling — long + names (access-control-allow-origin, access-control-allow-credentials, + etc.) overflow past their column and run into the value with no gap. + Stacking name above value sidesteps the column-width fight entirely, + the same way most REST clients/DevTools handle long header names. */ + flex-direction: column; + align-items: stretch; + gap: 2px; +} + +.history-response-viewer .response-header-row .header-name { + min-width: 0; +} + +.history-response-viewer .response-header-row .header-value { + /* flex: 1 items default to min-width: auto, refusing to shrink below + their content's natural width — the same class of bug fixed for the + sidebar's New Collection button, here for long unbroken header values. */ + min-width: 0; + overflow-wrap: anywhere; +} + +.history-response-truncated-hint { + margin: 0; +} + .response-body { padding: 16px; background: linear-gradient( @@ -2041,3 +2103,85 @@ fieldset.form-section { align-items: center; gap: var(--rl-sp2); } + +/* ---- Confirm dialog ---- */ +.confirm-dialog-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; +} + +.confirm-dialog { + width: min(360px, calc(100vw - 32px)); + background: var(--vscode-editor-background); + border: 1px solid var(--glass-border); + border-radius: var(--rl-r2); + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); + padding: var(--rl-sp4); + display: flex; + flex-direction: column; + gap: var(--rl-sp3); +} + +.confirm-dialog-title { + font-size: 14px; + font-weight: 700; + margin: 0; +} + +.confirm-dialog-message { + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin: 0; + line-height: 1.5; +} + +.confirm-dialog-actions { + display: flex; + justify-content: flex-end; + gap: var(--rl-sp2); + margin-top: var(--rl-sp2); +} + +.confirm-dialog-cancel, +.confirm-dialog-confirm { + height: var(--rl-ctrl); + padding: 0 var(--rl-sp4); + border-radius: var(--rl-r2); + font-size: 0.85em; + font-weight: 600; + cursor: pointer; + border: none; + transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.confirm-dialog-cancel { + background: var(--glass-bg); + border: 1px solid var(--glass-border); + color: var(--vscode-foreground); +} + +.confirm-dialog-cancel:hover { + background: var(--glass-border); +} + +.confirm-dialog-confirm { + background: var(--restlab-gradient); + color: #ffffff; +} + +.confirm-dialog-confirm:hover { + filter: brightness(1.1); +} + +.confirm-dialog-confirm.danger { + background: var(--restlab-danger); +} + +.confirm-dialog-confirm.danger:hover { + background: #dc2626; +} diff --git a/src/webview/sidebar/FolderItem.tsx b/src/webview/sidebar/FolderItem.tsx index c077fa2..0437fcb 100644 --- a/src/webview/sidebar/FolderItem.tsx +++ b/src/webview/sidebar/FolderItem.tsx @@ -129,7 +129,7 @@ const FolderItem: React.FC = ({ ) : ( )} - + {folder.name}
@@ -235,7 +235,7 @@ const FolderItem: React.FC = ({ > {request.method} - + {request.name} { REST Lab
- + + +