From 2ed5e3d792ea2848c739a0b39eacd46c1124db2b Mon Sep 17 00:00:00 2001 From: stephanbuettig Date: Mon, 6 Apr 2026 09:13:17 +0200 Subject: [PATCH] feat: Add ZIP export and batch selection (#76, #867) Implement two features requested by @pimterry: - Batch Export (#76): Multi-select exchanges via Ctrl+Click, Shift+Click, Ctrl+A with toolbar for batch HAR and ZIP export - ZIP Export (#867): Export code snippets in 37 formats as a single ZIP archive, with format picker UI and persistent selection Technical highlights: - 37 HTTPSnippet formats organized by language category - All snippet generation runs in the existing Web Worker (fflate compression, level 6) - Progress reporting every 5% with spinner UI - Shared format selection via MobX UiStore (persisted across sessions) - ZIP option integrated into Export card dropdown per @pimterry guidance - Safe filename convention: {index}_{METHOD}_{STATUS}_{hostname}.{ext} - Full HAR with complete traffic (requests + responses) included in every ZIP - Error resilience: failed snippets logged in _errors.json, export continues Closes httptoolkit/httptoolkit#76 Closes httptoolkit/httptoolkit#867 Related: httptoolkit/httptoolkit#866 --- .gitignore | 9 +- FEATURES.md | 152 ++++++ automation/webpack.common.ts | 1 + automation/webpack.fast.ts | 146 ++++++ automation/webpack.test.ts | 54 +++ package-lock.json | 27 +- package.json | 1 + src/components/editor/base-editor.tsx | 5 + src/components/view/http/http-export-card.tsx | 69 ++- src/components/view/selection-toolbar.tsx | 199 ++++++++ .../view/view-event-list-buttons.tsx | 62 ++- .../view/view-event-list-footer.tsx | 3 +- src/components/view/view-event-list.tsx | 88 +++- src/components/view/view-page.tsx | 23 +- src/components/view/zip-download-panel.tsx | 443 ++++++++++++++++++ src/model/events/events-store.ts | 115 ++++- src/model/ui/snippet-formats.ts | 318 +++++++++++++ src/model/ui/ui-store.ts | 20 + src/model/ui/zip-metadata.ts | 41 ++ src/services/ui-worker-api.ts | 112 ++++- src/services/ui-worker.ts | 201 +++++++- src/util/download.ts | 25 + src/util/export-filenames.ts | 111 +++++ src/util/ui.ts | 2 +- test/unit/model/ui/snippet-formats.spec.ts | 89 ++++ test/unit/util/export-filenames.spec.ts | 111 +++++ 26 files changed, 2399 insertions(+), 28 deletions(-) create mode 100644 FEATURES.md create mode 100644 automation/webpack.fast.ts create mode 100644 automation/webpack.test.ts create mode 100644 src/components/view/selection-toolbar.tsx create mode 100644 src/components/view/zip-download-panel.tsx create mode 100644 src/model/ui/snippet-formats.ts create mode 100644 src/model/ui/zip-metadata.ts create mode 100644 src/util/download.ts create mode 100644 src/util/export-filenames.ts create mode 100644 test/unit/model/ui/snippet-formats.spec.ts create mode 100644 test/unit/util/export-filenames.spec.ts diff --git a/.gitignore b/.gitignore index 26893b490..cff4763aa 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,11 @@ dist/ .env meta/ *.tsbuildinfo -.httptoolkit-server/ \ No newline at end of file +.httptoolkit-server/ + +# Local dev/test files (not part of the PR) +test-features.cmd +push-fork.cmd +PR-DESCRIPTION.md +ISSUE-COMMENTS.md +export/ \ No newline at end of file diff --git a/FEATURES.md b/FEATURES.md new file mode 100644 index 000000000..245f2d461 --- /dev/null +++ b/FEATURES.md @@ -0,0 +1,152 @@ +# ZIP Export & Batch Selection — Feature Documentation + +## Overview + +This contribution implements two features requested by @pimterry: + +- **Batch Export / Multi-Select** ([#76](https://github.com/httptoolkit/httptoolkit/issues/76)) — Select multiple HTTP exchanges via Ctrl+Click, Shift+Click, Ctrl+A and export them together +- **ZIP Export** ([#867](https://github.com/httptoolkit/httptoolkit/issues/867)) — Export code snippets in up to 37 formats as a ZIP archive + +Both features originated from the [Ghost Collector discussion](https://github.com/httptoolkit/httptoolkit/issues/866#issuecomment-4060468086), where @pimterry suggested integrating bulk export directly into the app using `@httptoolkit/httpsnippet` and `fflate`, with heavy processing offloaded to a Web Worker. + +## Architecture + +``` +User clicks "Export ZIP" + │ + ▼ +┌─────────────────────┐ +│ UI Component │ ZipDownloadPanel / SelectionToolbar / ExportAsZipButton +│ (Main Thread) │ ── Format selection from UiStore (shared, persisted) +│ │ ── Converts exchanges to HAR via generateHar() +│ │ ── Calls generateZipInWorker() +└──────────┬──────────┘ + │ postMessage (HAR entries + format definitions) + ▼ +┌─────────────────────┐ +│ Web Worker │ ui-worker.ts → 'generateZip' case +│ (Background Thread) │ ── Iterates formats × entries +│ │ ── Generates snippets via HTTPSnippet +│ │ ── Reports progress every 5% +│ │ ── Compresses with fflate (level 6) +│ │ ── Transfers ArrayBuffer back (zero-copy) +└──────────┬──────────┘ + │ postMessage (ArrayBuffer + error counts) + ▼ +┌─────────────────────┐ +│ Browser Download │ downloadBlob() triggers save dialog +└─────────────────────┘ +``` + +## Files Added + +| File | Purpose | +|------|---------| +| `src/model/ui/snippet-formats.ts` | Central registry of all 37 HTTPSnippet formats with categories, extensions, labels | +| `src/model/ui/zip-metadata.ts` | Metadata builder for `_metadata.json` inside ZIP archives | +| `src/util/export-filenames.ts` | Safe filename generation following HTTPToolkit naming conventions | +| `src/util/download.ts` | Browser download utility (Blob → save dialog) | +| `src/components/view/zip-download-panel.tsx` | Format picker UI with checkboxes, category grouping, quick actions | +| `src/components/view/selection-toolbar.tsx` | Multi-select batch toolbar with HAR + ZIP export | +| `test/unit/util/export-filenames.spec.ts` | Unit tests for filename generation | +| `test/unit/model/ui/snippet-formats.spec.ts` | Unit tests for snippet format definitions | +| `automation/webpack.fast.ts` | Lean dev build config (no Monaco, no type-checking, ~60s) | +| `automation/webpack.test.ts` | Test-specific webpack config | +| `FEATURES.md` | This file — architecture documentation | + +## Files Modified + +| File | Changes | +|------|---------| +| `src/model/ui/ui-store.ts` | Added `_zipFormatIds` (persisted), `zipFormatIds` getter, `setZipFormatIds()` | +| `src/services/ui-worker.ts` | Added `generateZip` message handler with snippet generation + fflate compression | +| `src/services/ui-worker-api.ts` | Added `generateZipInWorker()` with 5-min timeout, progress callbacks, cleanup | +| `src/components/view/http/http-export-card.tsx` | Added "ZIP (Selected Formats)" option to export dropdown | +| `src/components/view/view-event-list-buttons.tsx` | Added `ExportAsZipButton` to footer | +| `src/components/view/view-event-list-footer.tsx` | Added `ExportAsZipButton` to footer bar | +| `src/components/view/view-event-list.tsx` | Multi-select highlighting, Ctrl+Click/Shift+Click handling, aria-selected | +| `src/components/view/view-page.tsx` | Integrated `SelectionToolbar` | +| `src/model/events/events-store.ts` | Added selection state (`selectedExchangeIds`, `selectExchange`, etc.) | +| `src/components/editor/base-editor.tsx` | Guard for `jsonDefaults` when Monaco JSON support not loaded | +| `src/util/ui.ts` | Added `isCmdCtrlPressed` utility | +| `automation/webpack.common.ts` | Added `vm: false` polyfill fallback | +| `package.json` | Added `fflate` dependency | +| `package-lock.json` | Lock file updated for `fflate` | +| `.gitignore` | Excluded local dev/test files | + +## ZIP Archive Structure + +``` +HTTPToolkit_2026-04-06_14-30_180-requests.zip +├── shell-curl/ +│ ├── 001_GET_200_api.github.com.sh +│ ├── 002_POST_201_httpbin.org.sh +│ └── ... +├── python-requests/ +│ ├── 001_GET_200_api.github.com.py +│ └── ... +├── ... (37 format folders) +├── HTTPToolkit_180-requests_full-traffic.har ← Complete traffic (requests + responses) +├── _metadata.json ← Export info, format list, content guide +└── _errors.json ← Only if any snippets failed +``` + +## Snippet Filename Convention + +``` +{index}_{METHOD}_{STATUS}_{hostname}.{ext} + 001 GET 200 api.github.com .sh +``` + +Follows HTTPToolkit's existing HAR export pattern (`{METHOD} {hostname}.har`), extended with index and status for sortability. + +## Supported Formats (37) + +**Shell**: cURL, HTTPie, Wget +**JavaScript**: Fetch API, XMLHttpRequest, jQuery, Axios +**Node.js**: node-fetch, Axios, HTTP module, Request, Unirest +**Python**: Requests, http.client +**Java**: OkHttp, Unirest, AsyncHttp, HttpClient +**Kotlin**: OkHttp +**C#**: RestSharp, HttpClient +**Go**: net/http +**PHP**: ext-cURL, HTTP v1, HTTP v2 +**Ruby**: Net::HTTP, Faraday +**Rust**: reqwest +**Swift**: URLSession +**Objective-C**: NSURLSession +**C**: libcurl +**R**: httr +**OCaml**: CoHTTP +**Clojure**: clj-http +**PowerShell**: Invoke-WebRequest, Invoke-RestMethod +**HTTP**: Raw HTTP/1.1 + +## Key Design Decisions + +1. **fflate over JSZip** — As recommended by @pimterry. Faster and smaller bundle. + +2. **Web Worker for all ZIP generation** — All snippet generation and compression runs off-thread. The UI shows a progress bar and never freezes, even with thousands of requests. + +3. **Shared format selection via UiStore** — The user's format choices persist across sessions and are shared between the Export Card (single exchange), the batch SelectionToolbar, and the footer ExportAsZipButton. + +4. **ZIP in dropdown, not in context menu** — Per @pimterry's guidance: "it's awkward UX to have a submenu where most items copy, but one item downloads an entire zip." + +5. **Error resilience** — If a snippet fails (e.g., Clojure's clj_http with certain JSON arrays), the export continues. Failed snippets are logged in `_errors.json` with full context. + +## Testing + +Run unit tests via Karma (the project's existing test runner): +```bash +npm run test:unit +``` + +For fast development builds (no Monaco, no type-checking, ~60s): +```bash +npx env-cmd -f ./automation/ts-node.env.js npx webpack --config ./automation/webpack.fast.ts +``` + +## Known Limitations + +- **Clojure clj_http**: Crashes on JSON array bodies (`[{...}]`). This is an upstream bug in `@httptoolkit/httpsnippet`, not in this code. The error is caught and documented in `_errors.json`. +- **Crystal**: Commented out — target not available in the current httpsnippet version. diff --git a/automation/webpack.common.ts b/automation/webpack.common.ts index 60724b271..1322ddd16 100644 --- a/automation/webpack.common.ts +++ b/automation/webpack.common.ts @@ -30,6 +30,7 @@ export default { net: false, tls: false, http: false, + vm: false, // Used by asn1.js via crypto-browserify; not needed in browser assert: require.resolve('assert/'), crypto: require.resolve('crypto-browserify'), diff --git a/automation/webpack.fast.ts b/automation/webpack.fast.ts new file mode 100644 index 000000000..1c63e9b47 --- /dev/null +++ b/automation/webpack.fast.ts @@ -0,0 +1,146 @@ +/** + * Ultra-lean webpack config for rapid iteration. + * + * Compared to webpack.test.ts this saves ~2 GB RAM and ~8 minutes by: + * - mode: 'development' → no minification, no tree-shaking + * - devtool: false → no source maps + * - output.clean: false → reuse existing assets in dist/ + * - No CopyPlugin → API JSONs already in dist/ from prior build + * - No ForkTsCheckerPlugin → run `npx tsc --noEmit` separately if needed + * - No ForkTsCheckerNotifier + * - thread-loader: 1 worker → minimal memory overhead + * - No MonacoWebpackPlugin → reuse existing monaco chunks in dist/ + * + * Usage (from project root): + * npx env-cmd -f ./automation/ts-node.env.js \ + * npx webpack --config ./automation/webpack.fast.ts + * + * Prerequisites: + * dist/ must already contain a full build (from webpack.test.ts or CI). + * This config only recompiles TS/TSX source → JS chunks. + */ +import * as path from 'path'; +import * as Webpack from 'webpack'; +import HtmlWebpackPlugin from 'html-webpack-plugin'; + +const SRC_DIR = path.resolve(__dirname, '..', 'src'); +const OUTPUT_DIR = path.resolve(__dirname, '..', 'dist'); + +const config: Webpack.Configuration = { + mode: 'development', + devtool: false, + + entry: path.join(SRC_DIR, 'index.tsx'), + + output: { + path: OUTPUT_DIR, + filename: '[name].js', + chunkFilename: '[name].bundle.js', + // CRITICAL: do NOT clean dist — we want to keep existing assets + // (API JSONs, monaco chunks, fonts, wasm) from the full build. + clean: false + }, + + resolve: { + extensions: ['.mjs', '.ts', '.tsx', '...'], + fallback: { + fs: false, + net: false, + tls: false, + http: false, + vm: false, + assert: require.resolve('assert/'), + crypto: require.resolve('crypto-browserify'), + path: require.resolve('path-browserify'), + process: require.resolve('process/browser'), + querystring: require.resolve('querystring-es3'), + stream: require.resolve('stream-browserify'), + buffer: require.resolve('buffer/'), + url: require.resolve('url/'), + util: require.resolve('util/'), + zlib: require.resolve('browserify-zlib') + }, + alias: { + mockrtc$: path.resolve(__dirname, '../node_modules/mockrtc/dist/main-browser.js') + } + }, + + stats: { + assets: false, + children: false, + chunks: false, + entrypoints: false, + modules: false + }, + + performance: { hints: false }, + + module: { + rules: [{ + test: /\.tsx?$/, + use: [{ + loader: 'ts-loader', + options: { + // Skip type checking entirely — we do that with tsc --noEmit + transpileOnly: true + } + }], + exclude: /node_modules/ + }, { + test: /\.(png|svg)$/, + type: 'asset/resource' + }, { + test: /\.mjs$/, + include: /node_modules/, + type: "javascript/auto" + }, { + test: /\.css$/, + use: ['style-loader', 'css-loader'] + }, { + test: /amiusing.html$/, + type: 'asset/source' + }, { + test: /node_modules[\\|/]typesafe-get/, + use: { loader: 'umd-compat-loader' } + }] + }, + + experiments: { + asyncWebAssembly: true + }, + + optimization: { + // Minimal splitting — keeps memory low + splitChunks: false, + runtimeChunk: false, + minimize: false + }, + + plugins: [ + new Webpack.IgnorePlugin({ + resourceRegExp: /\/zstd-codec-binding.js$/, + contextRegExp: /zstd-codec/ + }), + new HtmlWebpackPlugin({ + template: path.join(SRC_DIR, 'index.html') + }), + // No CopyPlugin — API JSONs persist in dist/ from previous full build + // No MonacoWebpackPlugin — reuses existing monaco chunks in dist/ + // No ForkTsCheckerWebpackPlugin — use `npx tsc --noEmit` separately + new Webpack.ProvidePlugin({ + 'process': 'process/browser.js', + 'Buffer': ['buffer', 'Buffer'] + }), + new Webpack.EnvironmentPlugin({ + 'SENTRY_DSN': null, + 'POSTHOG_KEY': null, + 'UI_VERSION': null, + 'ACCOUNTS_API': null, + }), + new Webpack.DefinePlugin({ + 'process.env.DISABLE_UPDATES': 'true' + }) + ] +}; + +export default config; diff --git a/automation/webpack.test.ts b/automation/webpack.test.ts new file mode 100644 index 000000000..d3f00e877 --- /dev/null +++ b/automation/webpack.test.ts @@ -0,0 +1,54 @@ +/** + * Lightweight production-like webpack config for local testing. + * + * This produces a real production bundle (mode: 'production') but skips + * all the heavy plugins that require external services or tokens: + * - No Sentry source map upload + * - No Workbox service worker injection + * - No CSP Caddyfile generation + * - No bundle analyzer + * + * Usage: + * npx env-cmd -f ./automation/ts-node.env.js ^ + * node -r ts-node/register --max_old_space_size=4096 ^ + * ./node_modules/.bin/webpack --config ./automation/webpack.test.ts + */ +import * as Webpack from 'webpack'; +import merge from 'webpack-merge'; +import common from './webpack.common'; + +export default merge(common, { + mode: 'production', + devtool: 'source-map', + + optimization: { + chunkIds: 'named', + splitChunks: { + chunks: 'all', + cacheGroups: { + zstd: { + test: /[\\/]node_modules[\\/]zstd-codec[\\/]/, + name: 'zstd' + }, + monaco: { + test: /[\\/]node_modules[\\/](monaco-editor|react-monaco-editor)[\\/]/, + name: 'monaco' + }, + apis: { + test: /[\\/]node_modules[\\/]openapi-directory[\\/]/, + name: 'apis' + }, + mockttp: { + test: /[\\/]node_modules[\\/]mockttp[\\/]/, + name: 'mockttp' + } + } + } + }, + + plugins: [ + new Webpack.DefinePlugin({ + 'process.env.DISABLE_UPDATES': 'true' + }) + ] +}); diff --git a/package-lock.json b/package-lock.json index 1b8d47aae..2dabbe251 100644 --- a/package-lock.json +++ b/package-lock.json @@ -66,6 +66,7 @@ "dompurify": "^3.3.3", "fast-json-patch": "^3.1.1", "fast-xml-parser": "^5.5.7", + "fflate": "^0.8.2", "graphql": "^15.8.0", "har-validator": "^5.1.3", "http-encoding": "^2.0.1", @@ -9631,9 +9632,10 @@ } }, "node_modules/fflate": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", - "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==" + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" }, "node_modules/file-size": { "version": "0.0.5", @@ -16072,6 +16074,12 @@ "rrweb-snapshot": "^1.1.14" } }, + "node_modules/posthog-js/node_modules/fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==", + "license": "MIT" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -29771,9 +29779,9 @@ } }, "fflate": { - "version": "0.4.8", - "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", - "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==" + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" }, "file-size": { "version": "0.0.5", @@ -34443,6 +34451,13 @@ "requires": { "fflate": "^0.4.1", "rrweb-snapshot": "^1.1.14" + }, + "dependencies": { + "fflate": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.8.tgz", + "integrity": "sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==" + } } }, "prebuild-install": { diff --git a/package.json b/package.json index a5ad9ee8c..0321228ca 100644 --- a/package.json +++ b/package.json @@ -94,6 +94,7 @@ "dompurify": "^3.3.3", "fast-json-patch": "^3.1.1", "fast-xml-parser": "^5.5.7", + "fflate": "^0.8.2", "graphql": "^15.8.0", "har-validator": "^5.1.3", "http-encoding": "^2.0.1", diff --git a/src/components/editor/base-editor.tsx b/src/components/editor/base-editor.tsx index 3c995ef8f..22e0bb0b5 100644 --- a/src/components/editor/base-editor.tsx +++ b/src/components/editor/base-editor.tsx @@ -334,6 +334,10 @@ class BaseEditor extends React.Component { // Update the set of JSON schemas recognized by Monaco, to potentially include this file's // schema (from props.newSchema) linked to its model URI, or remove our stale schemas. + // Guard: Monaco's JSON language support may not be fully loaded yet + // (e.g. when MonacoWebpackPlugin is omitted in fast builds). + if (!this.monaco.languages.json?.jsonDefaults) return; + const existingOptions = this.monaco.languages.json.jsonDefaults.diagnosticsOptions; let newSchemaMappings: SchemaMapping[] = existingOptions.schemas || []; @@ -381,6 +385,7 @@ class BaseEditor extends React.Component { componentWillUnmount() { if (this.editor && this.monaco && this.registeredSchemaUri) { // When we unmount, clear our registered schema, if we have one. + if (!this.monaco.languages.json?.jsonDefaults) return; const existingOptions = this.monaco.languages.json.jsonDefaults.diagnosticsOptions; const newSchemaMappings = (existingOptions.schemas || []) diff --git a/src/components/view/http/http-export-card.tsx b/src/components/view/http/http-export-card.tsx index 8ae3c4f08..290bf5091 100644 --- a/src/components/view/http/http-export-card.tsx +++ b/src/components/view/http/http-export-card.tsx @@ -1,3 +1,4 @@ +import * as _ from 'lodash'; import React from "react"; import { action, computed } from "mobx"; import { inject, observer } from "mobx-react"; @@ -20,6 +21,8 @@ import { snippetExportOptions, SnippetOption } from '../../../model/ui/export'; +import { ZIP_ALL_FORMAT_KEY } from '../../../model/ui/snippet-formats'; +import { ZipDownloadPanel } from '../zip-download-panel'; import { ProHeaderPill, CardSalesPitch } from '../../account/pro-placeholders'; import { @@ -135,6 +138,32 @@ const ExportHarPill = styled(observer((p: { margin-right: auto; `; +// Virtual SnippetOption used as the PillSelector value when ZIP is selected. +// This is never passed to httpsnippet — it's only used for dropdown rendering. +const ZIP_SNIPPET_OPTION: SnippetOption = { + target: ZIP_ALL_FORMAT_KEY as any, + client: '' as any, + name: 'ZIP (Selected Formats)', + description: 'Download selected code snippet formats in a single ZIP archive', + link: '' +}; + +// Build extended optGroups with ZIP at the top +const exportOptionsWithZip: _.Dictionary = { + 'Archive': [ZIP_SNIPPET_OPTION], + ...snippetExportOptions +}; + +const getExportFormatKey = (option: SnippetOption): string => { + if (option === ZIP_SNIPPET_OPTION) return ZIP_ALL_FORMAT_KEY; + return getCodeSnippetFormatKey(option); +}; + +const getExportFormatName = (option: SnippetOption): string => { + if (option === ZIP_SNIPPET_OPTION) return ZIP_SNIPPET_OPTION.name; + return getCodeSnippetFormatName(option); +}; + @inject('accountStore') @inject('uiStore') @observer @@ -143,6 +172,7 @@ export class HttpExportCard extends React.Component { render() { const { exchange, accountStore } = this.props; const isPaidUser = accountStore!.user.isPaidUser(); + const isZipSelected = this.isZipSelected; return
@@ -153,10 +183,10 @@ export class HttpExportCard extends React.Component { onChange={this.setSnippetOption} - value={this.snippetOption} - optGroups={snippetExportOptions} - keyFormatter={getCodeSnippetFormatKey} - nameFormatter={getCodeSnippetFormatName} + value={this.currentDropdownValue} + optGroups={exportOptionsWithZip} + keyFormatter={getExportFormatKey} + nameFormatter={getExportFormatName} /> @@ -166,10 +196,13 @@ export class HttpExportCard extends React.Component { { isPaidUser ?
- + { isZipSelected + ? + : + }
: @@ -188,11 +221,29 @@ export class HttpExportCard extends React.Component { ; } + @computed + private get isZipSelected(): boolean { + return (this.props.uiStore!.exportSnippetFormat || '') === ZIP_ALL_FORMAT_KEY; + } + + @computed + private get currentDropdownValue(): SnippetOption { + if (this.isZipSelected) return ZIP_SNIPPET_OPTION; + return this.snippetOption; + } + @computed private get snippetOption(): SnippetOption { let exportSnippetFormat = this.props.uiStore!.exportSnippetFormat || DEFAULT_SNIPPET_FORMAT_KEY; - return getCodeSnippetOptionFromKey(exportSnippetFormat); + // If ZIP is selected, fall back to default for the snippet option + if (exportSnippetFormat === ZIP_ALL_FORMAT_KEY) { + exportSnippetFormat = DEFAULT_SNIPPET_FORMAT_KEY; + } + // Guard: if the format key doesn't resolve (e.g. deleted/invalid key), + // fall back to the default cURL option + return getCodeSnippetOptionFromKey(exportSnippetFormat) + ?? getCodeSnippetOptionFromKey(DEFAULT_SNIPPET_FORMAT_KEY); } @action.bound diff --git a/src/components/view/selection-toolbar.tsx b/src/components/view/selection-toolbar.tsx new file mode 100644 index 000000000..d85bcac74 --- /dev/null +++ b/src/components/view/selection-toolbar.tsx @@ -0,0 +1,199 @@ +import * as React from 'react'; +import { inject } from 'mobx-react'; +import { observer } from 'mobx-react-lite'; +import { toJS } from 'mobx'; +import * as dateFns from 'date-fns'; + +import { styled } from '../../styles'; +import { Icon } from '../../icons'; + +import { AccountStore } from '../../model/account/account-store'; +import { EventsStore } from '../../model/events/events-store'; +import { UiStore } from '../../model/ui/ui-store'; +import { generateHar } from '../../model/http/har'; +import { buildZipMetadata } from '../../model/ui/zip-metadata'; +import { resolveFormats } from '../../model/ui/snippet-formats'; +import { generateZipInWorker, ZipProgressInfo } from '../../services/ui-worker-api'; +import { downloadBlob } from '../../util/download'; +import { buildZipArchiveName } from '../../util/export-filenames'; +import { saveFile } from '../../util/ui'; +import { logError } from '../../errors'; + +const ToolbarContainer = styled.div` + display: flex; + align-items: center; + gap: 10px; + padding: 6px 12px; + background-color: ${p => p.theme.popColor}; + color: ${p => p.theme.mainBackground}; + font-size: ${p => p.theme.textSize}; + font-weight: 600; + flex-shrink: 0; +`; + +const ToolbarButton = styled.button` + padding: 4px 12px; + background: ${p => p.theme.mainBackground}; + color: ${p => p.theme.popColor}; + border: 1px solid ${p => p.theme.popColor}; + border-radius: 3px; + font-size: ${p => p.theme.textSize}; + cursor: pointer; + display: flex; + align-items: center; + gap: 6px; + + &:hover { opacity: 0.85; } + &:disabled { opacity: 0.5; cursor: not-allowed; } +`; + +const ToolbarSpacer = styled.div` + flex: 1; +`; + +const ErrorLabel = styled.span` + color: ${p => p.theme.warningColor}; + font-weight: normal; + font-size: ${p => p.theme.textSize}; + cursor: help; +`; + +const ClearButton = styled(ToolbarButton)` + border-color: transparent; + background: transparent; + color: ${p => p.theme.mainBackground}; + + &:hover { opacity: 0.7; } +`; + +interface SelectionToolbarProps { + eventsStore: EventsStore; + accountStore?: AccountStore; + uiStore?: UiStore; +} + +export const SelectionToolbar = inject('accountStore', 'uiStore')(observer((props: SelectionToolbarProps) => { + const { eventsStore, accountStore, uiStore } = props; + const count = eventsStore.selectedExchangeCount; + const isPaidUser = accountStore!.user.isPaidUser(); + + const [isExporting, setIsExporting] = React.useState(false); + const [exportError, setExportError] = React.useState(null); + const [zipProgress, setZipProgress] = React.useState(null); + + // Guard against setState on unmounted component during async operations + const mountedRef = React.useRef(true); + React.useEffect(() => { + return () => { mountedRef.current = false; }; + }, []); + + // ALL hooks must be called unconditionally (React rules of hooks). + // The early return below only controls rendering, not hook execution. + const handleExportHar = React.useCallback(async () => { + setIsExporting(true); + setExportError(null); + try { + const exchanges = eventsStore.selectedExchanges.slice(); + if (exchanges.length === 0) return; + + const harContent = JSON.stringify( + await generateHar(exchanges) + ); + if (!mountedRef.current) return; + + const filename = `HTTPToolkit_${ + dateFns.format(Date.now(), 'YYYY-MM-DD_HH-mm') + }_${exchanges.length}-requests.har`; + saveFile(filename, 'application/har+json;charset=utf-8', harContent); + } catch (err) { + logError(err); + if (!mountedRef.current) return; + setExportError(err instanceof Error ? err.message : 'HAR export failed'); + } finally { + if (mountedRef.current) setIsExporting(false); + } + }, [eventsStore]); + + const handleExportZip = React.useCallback(async () => { + setIsExporting(true); + setExportError(null); + setZipProgress(null); + try { + const exchanges = eventsStore.selectedExchanges.slice(); + if (exchanges.length === 0) return; + + const har = await generateHar(exchanges); + const harEntries = toJS(har.log.entries); + // Use the same format selection as the Export card's ZIP picker + const formats = resolveFormats(uiStore!.zipFormatIds); + const metadata = buildZipMetadata(exchanges.length, formats); + + const result = await generateZipInWorker( + harEntries, formats, metadata, + (info) => { if (mountedRef.current) setZipProgress(info); } + ); + + if (!mountedRef.current) return; + const blob = new Blob([result.buffer], { type: 'application/zip' }); + downloadBlob(blob, buildZipArchiveName(exchanges.length)); + + if (result.snippetErrors > 0) { + setExportError( + `${result.snippetErrors} of ${result.totalSnippets} snippets failed (see _errors.json)` + ); + } + } catch (err) { + logError(err); + if (!mountedRef.current) return; + setExportError(err instanceof Error ? err.message : 'ZIP export failed'); + } finally { + if (mountedRef.current) { + setIsExporting(false); + setZipProgress(null); + } + } + }, [eventsStore, uiStore]); + + const handleClearSelection = React.useCallback(() => { + eventsStore.clearSelection(); + }, [eventsStore]); + + if (count <= 1) return null; // Only show for multi-selection + + return + {count} exchanges selected + {exportError && + {exportError.includes('snippets failed') ? 'Partial export' : 'Export failed'} + } + + + + Export HAR + + + + + {isExporting + ? (zipProgress ? `Exporting ${zipProgress.percent}%` : 'Exporting...') + : 'Export ZIP' + } + + + + + + Clear selection + + ; +})); diff --git a/src/components/view/view-event-list-buttons.tsx b/src/components/view/view-event-list-buttons.tsx index 010284388..3afc1cc32 100644 --- a/src/components/view/view-event-list-buttons.tsx +++ b/src/components/view/view-event-list-buttons.tsx @@ -1,5 +1,7 @@ import * as React from 'react'; -import { observer, inject } from 'mobx-react'; +import { inject } from 'mobx-react'; +import { observer } from 'mobx-react-lite'; +import { toJS } from 'mobx'; import * as dateFns from 'date-fns'; import * as dedent from 'dedent'; import * as Ajv from 'ajv'; @@ -9,7 +11,13 @@ import { saveFile, uploadFile, Ctrl } from '../../util/ui'; import { AccountStore } from '../../model/account/account-store'; import { EventsStore } from '../../model/events/events-store'; +import { UiStore } from '../../model/ui/ui-store'; import { generateHar } from '../../model/http/har'; +import { buildZipMetadata } from '../../model/ui/zip-metadata'; +import { resolveFormats } from '../../model/ui/snippet-formats'; +import { generateZipInWorker } from '../../services/ui-worker-api'; +import { downloadBlob } from '../../util/download'; +import { buildZipArchiveName } from '../../util/export-filenames'; import { logError } from '../../errors'; import { formatAjvError } from '../../util/json-schema'; @@ -57,6 +65,58 @@ export const ExportAsHarButton = inject('accountStore')(observer((props: { /> })); +export const ExportAsZipButton = inject('accountStore', 'uiStore')(observer((props: { + className?: string, + accountStore?: AccountStore, + uiStore?: UiStore, + events: ReadonlyArray +}) => { + const isPaidUser = props.accountStore!.user.isPaidUser(); + const [isExporting, setIsExporting] = React.useState(false); + + // Guard against setState on unmounted component + const mountedRef = React.useRef(true); + React.useEffect(() => { + return () => { mountedRef.current = false; }; + }, []); + + return { + setIsExporting(true); + try { + // Snapshot to avoid mid-export changes + const events = props.events.slice(); + if (events.length === 0) return; + + const har = await generateHar(events); + const harEntries = toJS(har.log.entries); + const formats = resolveFormats(props.uiStore!.zipFormatIds); + const metadata = buildZipMetadata(events.length, formats); + + const result = await generateZipInWorker(harEntries, formats, metadata); + if (!mountedRef.current) return; + + const blob = new Blob([result.buffer], { type: 'application/zip' }); + downloadBlob(blob, buildZipArchiveName(events.length)); + } catch (err) { + logError(err); + } finally { + if (mountedRef.current) setIsExporting(false); + } + }} + /> +})); + export const ImportHarButton = inject('eventsStore', 'accountStore')( observer((props: { accountStore?: AccountStore, diff --git a/src/components/view/view-event-list-footer.tsx b/src/components/view/view-event-list-footer.tsx index 60993bb31..36bd2dcef 100644 --- a/src/components/view/view-event-list-footer.tsx +++ b/src/components/view/view-event-list-footer.tsx @@ -10,7 +10,7 @@ import { SelectableSearchFilterClasses } from '../../model/filters/search-filters'; -import { ClearAllButton, ExportAsHarButton, ImportHarButton, PlayPauseButton, ScrollToEndButton } from './view-event-list-buttons'; +import { ClearAllButton, ExportAsHarButton, ExportAsZipButton, ImportHarButton, PlayPauseButton, ScrollToEndButton } from './view-event-list-buttons'; import { SearchFilter } from './filters/search-filter'; export const HEADER_FOOTER_HEIGHT = 38; @@ -91,6 +91,7 @@ export const ViewEventListFooter = styled(observer((props: { + void; onSelected: (event: CollectedEvent | undefined) => void; @@ -336,15 +339,17 @@ interface EventRowProps extends ListChildComponentProps { selectedEvent: CollectedEvent | undefined; events: ReadonlyArray; contextMenuBuilder: ViewEventContextMenuBuilder; + selectedExchangeIds?: Set; } } const EventRow = observer((props: EventRowProps) => { const { index, style } = props; - const { events, selectedEvent, contextMenuBuilder } = props.data; + const { events, selectedEvent, contextMenuBuilder, selectedExchangeIds } = props.data; const event = events[index]; - const isSelected = (selectedEvent === event); + const isSelected = (selectedEvent === event) || + (selectedExchangeIds ? selectedExchangeIds.has(event.id) : false); if (event.isTlsFailure() || event.isTlsTunnel()) { return { return { selectedEvent: this.props.selectedEvent, events: this.props.filteredEvents, - contextMenuBuilder: this.props.contextMenuBuilder + contextMenuBuilder: this.props.contextMenuBuilder, + selectedExchangeIds: this.props.eventsStore?.selectedExchangeIds }; } @@ -1041,6 +1053,29 @@ export class ViewEventList extends React.Component { const eventIndex = parseInt(ariaRowIndex, 10) - 1; const event = this.props.filteredEvents[eventIndex]; + const { eventsStore } = this.props; + + // Multi-select: if Ctrl/Cmd or Shift is held, delegate to the eventsStore + if (eventsStore && (isCmdCtrlPressed(mouseEvent) || mouseEvent.shiftKey)) { + const visibleIds = this.props.filteredEvents.map(e => e.id); + eventsStore.selectExchange( + event.id, + isCmdCtrlPressed(mouseEvent), + mouseEvent.shiftKey, + visibleIds + ); + // Also set as the active/focused event + this.onEventSelected(eventIndex); + return; + } + + // Clear multi-selection on plain click, but keep the anchor + // so that a subsequent Shift+Click creates a range from here. + if (eventsStore) { + eventsStore.clearSelection(); + eventsStore.setSelectionAnchor(event.id); + } + if (event !== this.props.selectedEvent) { this.onEventSelected(eventIndex); } else { @@ -1061,7 +1096,52 @@ export class ViewEventList extends React.Component { @action.bound onKeyDown(event: React.KeyboardEvent) { - const { moveSelection } = this.props; + const { moveSelection, eventsStore } = this.props; + + // Ctrl/Cmd+A: Select all visible events + if (event.key === 'a' && isCmdCtrlPressed(event) && eventsStore) { + const target = event.target as HTMLElement; + // Only if focus is NOT in an editable field + if (target.tagName !== 'INPUT' && target.tagName !== 'TEXTAREA' && !target.isContentEditable) { + const visibleIds = this.props.filteredEvents.map(e => e.id); + eventsStore.selectAllExchanges(visibleIds); + event.preventDefault(); + return; + } + } + + // Escape: Clear selection + if (event.key === 'Escape' && eventsStore && eventsStore.selectedExchangeCount > 0) { + eventsStore.clearSelection(); + event.preventDefault(); + return; + } + + // Shift+Arrow: Extend selection by one row in that direction + if (event.shiftKey && (event.key === 'ArrowDown' || event.key === 'ArrowUp') && eventsStore) { + const distance = event.key === 'ArrowDown' ? 1 : -1; + const { filteredEvents, selectedEvent } = this.props; + const visibleIds = filteredEvents.map(e => e.id); + + // Compute target index before calling moveSelection (which is async in effect) + const currentIndex = selectedEvent + ? filteredEvents.findIndex(e => e.id === selectedEvent.id) + : -1; + const targetIndex = currentIndex === -1 + ? (distance >= 0 ? 0 : filteredEvents.length - 1) + : Math.max(0, Math.min(filteredEvents.length - 1, currentIndex + distance)); + + // Move focus to the target row + moveSelection(distance); + + // Extend selection to include the new row + const targetId = filteredEvents[targetIndex]?.id; + if (targetId) { + eventsStore.selectExchange(targetId, false, true, visibleIds); + } + event.preventDefault(); + return; + } switch (event.key) { case 'ArrowDown': diff --git a/src/components/view/view-page.tsx b/src/components/view/view-page.tsx index 403a6e4ed..457373399 100644 --- a/src/components/view/view-page.tsx +++ b/src/components/view/view-page.tsx @@ -8,7 +8,8 @@ import { runInAction, when, comparer, - observe + observe, + reaction } from 'mobx'; import { observer, disposeOnUnmount, inject } from 'mobx-react'; import * as portals from 'react-reverse-portal'; @@ -37,6 +38,7 @@ import { SelfSizedEditor } from '../editor/base-editor'; import { ViewEventList } from './view-event-list'; import { ViewEventListFooter } from './view-event-list-footer'; +import { SelectionToolbar } from './selection-toolbar'; import { ViewEventContextMenuBuilder } from './view-context-menu-builder'; import { PaneOuterContainer } from './view-details-pane'; import { HttpDetailsPane } from './http/http-details-pane'; @@ -305,6 +307,21 @@ class ViewPage extends React.Component { } })); + // Clear multi-select when filters change, so users don't have invisible selections. + // We track the filter configuration itself (not filteredEvents.length) because new + // events arriving should NOT clear the selection — only actual filter changes should. + disposeOnUnmount(this, + reaction( + () => this.currentSearchFilters, + () => { + const { eventsStore } = this.props; + if (eventsStore.selectedExchangeCount > 0) { + eventsStore.clearSelection(); + } + } + ) + ); + // Due to https://github.com/facebook/react/issues/16087 in React, which is fundamentally caused by // https://bugs.chromium.org/p/chromium/issues/detail?id=1218275 in Chrome, we can leak filtered event // list references, which means that HTTP exchanges persist in memory even after they're cleared. @@ -451,6 +468,9 @@ class ViewPage extends React.Component { onClear={this.onClear} onScrollToEnd={this.onScrollToEnd} /> + { onSelected={this.onSelected} contextMenuBuilder={this.contextMenuBuilder} uiStore={this.props.uiStore} + eventsStore={this.props.eventsStore} ref={this.listRef} /> diff --git a/src/components/view/zip-download-panel.tsx b/src/components/view/zip-download-panel.tsx new file mode 100644 index 000000000..20c6794d7 --- /dev/null +++ b/src/components/view/zip-download-panel.tsx @@ -0,0 +1,443 @@ +import * as React from 'react'; +import { inject } from 'mobx-react'; +import { observer } from 'mobx-react-lite'; +import { toJS } from 'mobx'; + +import { styled, css } from '../../styles'; +import { Icon } from '../../icons'; + +import { HttpExchangeView } from '../../types'; +import { UiStore } from '../../model/ui/ui-store'; +import { generateHar } from '../../model/http/har'; +import { buildZipMetadata } from '../../model/ui/zip-metadata'; +import { + ALL_SNIPPET_FORMATS, + FORMAT_CATEGORIES, + FORMATS_BY_CATEGORY, + DEFAULT_SELECTED_FORMAT_IDS, + ALL_FORMAT_IDS, + resolveFormats +} from '../../model/ui/snippet-formats'; +import { generateZipInWorker, ZipProgressInfo } from '../../services/ui-worker-api'; +import { downloadBlob } from '../../util/download'; +import { buildZipArchiveName } from '../../util/export-filenames'; +import { logError } from '../../errors'; + +type ZipPanelState = 'idle' | 'generating' | 'error'; + +interface ZipDownloadPanelProps { + exchanges: HttpExchangeView[]; + uiStore?: UiStore; +} + +// ── Styled components ──────────────────────────────────────────────────────── + +const PanelContainer = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + padding: 16px 20px; +`; + +const FormatPickerContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; + max-height: 320px; + overflow-y: auto; + border: 1px solid ${p => p.theme.containerBorder}; + border-radius: 4px; + padding: 10px 12px; + background: ${p => p.theme.mainLowlightBackground}; +`; + +const PickerHeader = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 0 8px 0; + margin: 0 -12px 4px -12px; + padding-left: 12px; + padding-right: 12px; + border-bottom: 1px solid ${p => p.theme.containerBorder}; + position: sticky; + top: -10px; + z-index: 1; + background: ${p => p.theme.mainLowlightBackground}; + padding-top: 10px; +`; + +const PickerTitle = styled.span` + font-weight: 600; + font-size: ${p => p.theme.textSize}; + color: ${p => p.theme.mainColor}; +`; + +const PickerActions = styled.div` + display: flex; + gap: 8px; +`; + +const PickerActionLink = styled.button` + background: none; + border: none; + color: ${p => p.theme.popColor}; + font-size: 12px; + cursor: pointer; + padding: 0; + text-decoration: underline; + opacity: 0.85; + &:hover { opacity: 1; } +`; + +const CategoryGroup = styled.div` + margin-bottom: 4px; +`; + +const CategoryHeader = styled.div.attrs({ role: 'button', tabIndex: 0 })` + display: flex; + align-items: center; + gap: 6px; + padding: 4px 0; + cursor: pointer; + user-select: none; + + &:hover { opacity: 0.8; } + &:focus-visible { outline: 2px solid ${p => p.theme.popColor}; outline-offset: 1px; border-radius: 2px; } +`; + +const CategoryLabel = styled.span` + font-weight: 600; + font-size: 12px; + color: ${p => p.theme.mainColor}; + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +const CategoryCount = styled.span` + font-size: 11px; + color: ${p => p.theme.mainColor}; + opacity: 0.4; +`; + +const FormatList = styled.div` + display: flex; + flex-wrap: wrap; + gap: 4px 12px; + padding-left: 4px; + margin-bottom: 4px; +`; + +const FormatCheckbox = styled.label<{ isChecked: boolean }>` + display: flex; + align-items: center; + gap: 5px; + padding: 2px 4px; + border-radius: 3px; + cursor: pointer; + font-size: ${p => p.theme.textSize}; + color: ${p => p.theme.mainColor}; + min-width: 130px; + transition: background-color 0.1s; + + ${p => p.isChecked && css` + color: ${p.theme.popColor}; + `} + + &:hover { + background: ${p => p.theme.containerBackground}; + } + + input { + accent-color: ${p => p.theme.popColor}; + cursor: pointer; + } +`; + +const BottomBar = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +`; + +const SelectionSummary = styled.span` + font-size: ${p => p.theme.textSize}; + color: ${p => p.theme.mainColor}; + opacity: 0.6; +`; + +const DownloadButton = styled.button` + padding: 10px 20px; + background: ${p => p.theme.popColor}; + color: ${p => p.theme.mainBackground}; + border: none; + border-radius: 4px; + font-size: ${p => p.theme.textSize}; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; + + &:hover { opacity: 0.9; } + &:disabled { opacity: 0.5; cursor: not-allowed; } +`; + +const ProgressContainer = styled.div` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 30px 20px; + gap: 12px; +`; + +const ProgressBar = styled.div` + width: 100%; + max-width: 300px; + height: 6px; + background: ${p => p.theme.containerBorder}; + border-radius: 3px; + overflow: hidden; +`; + +const ProgressFill = styled.div<{ percent: number }>` + height: 100%; + width: ${p => p.percent}%; + background: ${p => p.theme.popColor}; + border-radius: 3px; + transition: width 0.2s ease; +`; + +const StatusText = styled.p` + color: ${p => p.theme.mainColor}; + font-size: ${p => p.theme.textSize}; + opacity: 0.7; + text-align: center; + margin: 0; +`; + +const ErrorText = styled(StatusText)` + color: ${p => p.theme.warningColor}; + opacity: 1; +`; + +const WarningText = styled.span` + font-size: 12px; + color: ${p => p.theme.warningColor}; + opacity: 0.85; +`; + +const RetryButton = styled(DownloadButton)` + padding: 8px 16px; +`; + +// ── Component ──────────────────────────────────────────────────────────────── + +export const ZipDownloadPanel = inject('uiStore')(observer((props: ZipDownloadPanelProps) => { + const { exchanges, uiStore } = props; + const [state, setState] = React.useState('idle'); + const [errorMsg, setErrorMsg] = React.useState(null); + const [progress, setProgress] = React.useState(null); + + // Format selection lives in UiStore — shared with batch toolbar + const selectedIds = uiStore!.zipFormatIds; + + // Guard against setState on unmounted component + const mountedRef = React.useRef(true); + React.useEffect(() => { + return () => { mountedRef.current = false; }; + }, []); + + // ── Selection helpers (mutate UiStore directly) ───────────────────── + + const toggleFormat = React.useCallback((id: string) => { + const next = new Set(selectedIds); + if (next.has(id)) next.delete(id); else next.add(id); + uiStore!.setZipFormatIds(next); + }, [selectedIds, uiStore]); + + const toggleCategory = React.useCallback((category: string) => { + const categoryFormats = FORMATS_BY_CATEGORY[category] || []; + const next = new Set(selectedIds); + const allSelected = categoryFormats.every(f => selectedIds.has(f.id)); + categoryFormats.forEach(f => { + if (allSelected) next.delete(f.id); else next.add(f.id); + }); + uiStore!.setZipFormatIds(next); + }, [selectedIds, uiStore]); + + const selectAll = React.useCallback(() => { + uiStore!.setZipFormatIds(ALL_FORMAT_IDS); + }, [uiStore]); + + const selectPopular = React.useCallback(() => { + uiStore!.setZipFormatIds(DEFAULT_SELECTED_FORMAT_IDS); + }, [uiStore]); + + const selectNone = React.useCallback(() => { + uiStore!.setZipFormatIds([]); + }, [uiStore]); + + // ── Download handler ───────────────────────────────────────────────── + + const handleDownload = React.useCallback(async () => { + setState('generating'); + setErrorMsg(null); + setProgress(null); + try { + const snapshotExchanges = exchanges.slice(); + if (snapshotExchanges.length === 0) { + setState('idle'); + return; + } + + const formats = resolveFormats(selectedIds); + if (formats.length === 0) { + throw new Error('No formats selected'); + } + + const har = await generateHar(snapshotExchanges); + const harEntries = toJS(har.log.entries); + const metadata = buildZipMetadata(snapshotExchanges.length, formats); + + const result = await generateZipInWorker( + harEntries, + formats, + metadata, + (info) => { if (mountedRef.current) setProgress(info); } + ); + + if (!mountedRef.current) return; + + const blob = new Blob([result.buffer], { type: 'application/zip' }); + downloadBlob(blob, buildZipArchiveName(snapshotExchanges.length)); + + if (result.snippetErrors > 0) { + setErrorMsg( + `ZIP saved. ${result.snippetErrors} of ${result.totalSnippets} snippets failed (see _errors.json).` + ); + } + setState('idle'); + setProgress(null); + } catch (err) { + logError(err); + if (!mountedRef.current) return; + setErrorMsg(err instanceof Error ? err.message : 'ZIP generation failed'); + setState('error'); + setProgress(null); + } + }, [exchanges, selectedIds]); + + // ── Render: Generating state ───────────────────────────────────────── + + if (state === 'generating') { + const percent = progress?.percent ?? 0; + const formatCount = selectedIds.size; + + return + + + Generating {formatCount} format{formatCount !== 1 ? 's' : ''} for{' '} + {exchanges.length} exchange{exchanges.length !== 1 ? 's' : ''} + {percent > 0 ? ` — ${percent}%` : '...'} + + {percent > 0 && ( + + + + )} + ; + } + + // ── Render: Error state ────────────────────────────────────────────── + + if (state === 'error') { + return + {errorMsg} + setState('idle')}> + Retry + + ; + } + + // ── Render: Idle state (format picker + download) ──────────────────── + + const totalFormats = ALL_SNIPPET_FORMATS.length; + const selectedCount = selectedIds.size; + + return + + + + Snippet Formats + + + All ({totalFormats}) + Popular + None + + + + {FORMAT_CATEGORIES.map(category => { + const formats = FORMATS_BY_CATEGORY[category]; + const catSelected = formats.filter(f => selectedIds.has(f.id)).length; + return + toggleCategory(category)} + onKeyDown={(e: React.KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggleCategory(category); + } + }} + aria-label={`Toggle all ${category} formats (${catSelected}/${formats.length} selected)`} + > + {category} + + {catSelected}/{formats.length} + + + + {formats.map(fmt => ( + + toggleFormat(fmt.id)} + /> + {fmt.label} + + ))} + + ; + })} + + + +
+ + {selectedCount} of {totalFormats} formats selected + + {errorMsg && <>
{errorMsg}} +
+ + + Download ZIP ({exchanges.length} exchange{exchanges.length !== 1 ? 's' : ''}) + +
+
; +})); diff --git a/src/model/events/events-store.ts b/src/model/events/events-store.ts index 09c69fa3f..817bb074c 100644 --- a/src/model/events/events-store.ts +++ b/src/model/events/events-store.ts @@ -1,7 +1,8 @@ import * as _ from 'lodash'; import { observable, - action + action, + computed } from 'mobx'; import { HarParseError } from 'har-validator'; @@ -331,6 +332,115 @@ export class EventsStore { this.isPaused = !this.isPaused; } + // ── Multi-Select State (Feature 1: Batch Export / Issue #76) ───────────── + + // Use observable.set() for guaranteed MobX 5 reactivity on Set operations + readonly selectedExchangeIds = observable.set(); + + private lastSelectedExchangeId: string | null = null; + + @computed + get selectedExchangeCount(): number { + return this.selectedExchangeIds.size; + } + + @computed + get hasMultiSelection(): boolean { + return this.selectedExchangeIds.size > 1; + } + + /** + * Returns the HttpExchangeView[] for all currently selected exchange IDs. + * Non-HTTP events and IDs no longer in the event list are silently skipped. + */ + @computed + get selectedExchanges(): import('../../types').HttpExchangeView[] { + const result: import('../../types').HttpExchangeView[] = []; + for (const id of this.selectedExchangeIds) { + const event = this.eventsList.getById(id); + if (event && event.isHttp()) { + result.push(event as import('../../types').HttpExchangeView); + } + } + return result; + } + + /** + * Handle a row click for selection. Supports single click, Ctrl/Cmd+click + * (toggle), and Shift+click (range). + * + * @param id - The exchange ID that was clicked + * @param metaKey - Whether Ctrl/Cmd was held + * @param shiftKey - Whether Shift was held + * @param visibleIds - Ordered array of currently visible event IDs (for range selection) + */ + @action.bound + selectExchange(id: string, metaKey: boolean, shiftKey: boolean, visibleIds: string[]) { + if (shiftKey) { + if (this.lastSelectedExchangeId) { + // Range selection: select everything between lastSelectedExchangeId and id + const startIdx = visibleIds.indexOf(this.lastSelectedExchangeId); + const endIdx = visibleIds.indexOf(id); + if (startIdx !== -1 && endIdx !== -1) { + const [lo, hi] = startIdx < endIdx + ? [startIdx, endIdx] + : [endIdx, startIdx]; + + if (!metaKey) { + this.selectedExchangeIds.clear(); + } + for (let i = lo; i <= hi; i++) { + this.selectedExchangeIds.add(visibleIds[i]); + } + } + } else { + // Shift+Click with no prior anchor: treat as first anchor + this.selectedExchangeIds.clear(); + this.selectedExchangeIds.add(id); + this.lastSelectedExchangeId = id; + } + } else if (metaKey) { + // Toggle selection + if (this.selectedExchangeIds.has(id)) { + this.selectedExchangeIds.delete(id); + } else { + this.selectedExchangeIds.add(id); + } + this.lastSelectedExchangeId = id; + } else { + // Single selection — replace all + this.selectedExchangeIds.clear(); + this.selectedExchangeIds.add(id); + this.lastSelectedExchangeId = id; + } + } + + @action.bound + selectAllExchanges(visibleIds: string[]) { + this.selectedExchangeIds.clear(); + for (const id of visibleIds) { + this.selectedExchangeIds.add(id); + } + } + + @action.bound + clearSelection() { + this.selectedExchangeIds.clear(); + this.lastSelectedExchangeId = null; + } + + /** + * Set the anchor point for Shift+Click range selection without adding + * the ID to the selection set itself. This is called on plain click so + * that a subsequent Shift+Click produces a range from this row. + */ + @action.bound + setSelectionAnchor(id: string) { + this.lastSelectedExchangeId = id; + } + + // ── End Multi-Select State ─────────────────────────────────────────────── + @action private addInitiatedRequest(request: InputInitiatedRequest) { // Due to race conditions, it's possible this request already exists. If so, @@ -702,6 +812,9 @@ export class EventsStore { @action.bound clearInterceptedData(clearPinned: boolean) { + // Clear multi-select state when exchanges are cleared + this.clearSelection(); + const [pinnedEvents, unpinnedEvents] = _.partition( this.events, clearPinned ? () => false : (ex) => ex.pinned diff --git a/src/model/ui/snippet-formats.ts b/src/model/ui/snippet-formats.ts new file mode 100644 index 000000000..67179fd18 --- /dev/null +++ b/src/model/ui/snippet-formats.ts @@ -0,0 +1,318 @@ +/** + * Snippet format registry — single source of truth for all export formats. + * + * Contains ALL available HTTPSnippet targets/clients organized by language + * category. The ZIP export pipeline, format picker UI, and batch toolbar + * all consume this registry. + */ +import * as HTTPSnippet from '@httptoolkit/httpsnippet'; + +// ── Sentinel key for the "ZIP (Selected Formats)" meta-option ─────────────── +export const ZIP_ALL_FORMAT_KEY = '__zip_all__' as const; + +// ── Format definition used by the ZIP generation pipeline ──────────────────── +export interface SnippetFormatDefinition { + /** Unique ID, e.g. 'shell_curl' */ + id: string; + /** Language category for grouping in the format picker */ + category: string; + /** Folder name inside the ZIP archive */ + folderName: string; + /** File extension for generated snippets */ + extension: string; + /** httpsnippet target identifier */ + target: HTTPSnippet.Target; + /** httpsnippet client identifier */ + client: HTTPSnippet.Client; + /** Human-readable label */ + label: string; + /** Whether this is a "popular" format (pre-checked in format picker) */ + popular: boolean; +} + +/** + * Complete registry of all HTTPSnippet-supported formats. + * Organized by language category for clean grouping in the UI. + */ +export const ALL_SNIPPET_FORMATS: SnippetFormatDefinition[] = [ + // ── Shell ──────────────────────────────────────────────────────────── + { + id: 'shell_curl', category: 'Shell', folderName: 'shell-curl', + extension: 'sh', target: 'shell', client: 'curl', + label: 'cURL', popular: true + }, + { + id: 'shell_httpie', category: 'Shell', folderName: 'shell-httpie', + extension: 'sh', target: 'shell', client: 'httpie', + label: 'HTTPie', popular: true + }, + { + id: 'shell_wget', category: 'Shell', folderName: 'shell-wget', + extension: 'sh', target: 'shell', client: 'wget', + label: 'Wget', popular: false + }, + + // ── JavaScript (Browser) ───────────────────────────────────────────── + { + id: 'javascript_fetch', category: 'JavaScript', folderName: 'js-fetch', + extension: 'js', target: 'javascript', client: 'fetch', + label: 'Fetch API', popular: true + }, + { + id: 'javascript_xhr', category: 'JavaScript', folderName: 'js-xhr', + extension: 'js', target: 'javascript', client: 'xhr', + label: 'XMLHttpRequest', popular: false + }, + { + id: 'javascript_jquery', category: 'JavaScript', folderName: 'js-jquery', + extension: 'js', target: 'javascript', client: 'jquery', + label: 'jQuery', popular: false + }, + { + id: 'javascript_axios', category: 'JavaScript', folderName: 'js-axios', + extension: 'js', target: 'javascript', client: 'axios', + label: 'Axios', popular: false + }, + + // ── Node.js ────────────────────────────────────────────────────────── + { + id: 'node_fetch', category: 'Node.js', folderName: 'node-fetch', + extension: 'js', target: 'node', client: 'fetch', + label: 'node-fetch', popular: false + }, + { + id: 'node_axios', category: 'Node.js', folderName: 'node-axios', + extension: 'js', target: 'node', client: 'axios', + label: 'Axios', popular: true + }, + { + id: 'node_native', category: 'Node.js', folderName: 'node-http', + extension: 'js', target: 'node', client: 'native', + label: 'HTTP module', popular: false + }, + { + id: 'node_request', category: 'Node.js', folderName: 'node-request', + extension: 'js', target: 'node', client: 'request', + label: 'Request', popular: false + }, + { + id: 'node_unirest', category: 'Node.js', folderName: 'node-unirest', + extension: 'js', target: 'node', client: 'unirest', + label: 'Unirest', popular: false + }, + + // ── Python ─────────────────────────────────────────────────────────── + { + id: 'python_requests', category: 'Python', folderName: 'python-requests', + extension: 'py', target: 'python', client: 'requests', + label: 'Requests', popular: true + }, + { + id: 'python_python3', category: 'Python', folderName: 'python-http', + extension: 'py', target: 'python', client: 'python3', + label: 'http.client', popular: false + }, + + // ── Java ───────────────────────────────────────────────────────────── + { + id: 'java_okhttp', category: 'Java', folderName: 'java-okhttp', + extension: 'java', target: 'java', client: 'okhttp', + label: 'OkHttp', popular: true + }, + { + id: 'java_unirest', category: 'Java', folderName: 'java-unirest', + extension: 'java', target: 'java', client: 'unirest', + label: 'Unirest', popular: false + }, + { + id: 'java_asynchttp', category: 'Java', folderName: 'java-asynchttp', + extension: 'java', target: 'java', client: 'asynchttp', + label: 'AsyncHttp', popular: false + }, + { + id: 'java_nethttp', category: 'Java', folderName: 'java-nethttp', + extension: 'java', target: 'java', client: 'nethttp', + label: 'HttpClient', popular: false + }, + + // ── Kotlin ─────────────────────────────────────────────────────────── + { + id: 'kotlin_okhttp', category: 'Kotlin', folderName: 'kotlin-okhttp', + extension: 'kt', target: 'kotlin' as HTTPSnippet.Target, client: 'okhttp', + label: 'OkHttp', popular: false + }, + + // ── C# ─────────────────────────────────────────────────────────────── + { + id: 'csharp_restsharp', category: 'C#', folderName: 'csharp-restsharp', + extension: 'cs', target: 'csharp', client: 'restsharp', + label: 'RestSharp', popular: false + }, + { + id: 'csharp_httpclient', category: 'C#', folderName: 'csharp-httpclient', + extension: 'cs', target: 'csharp', client: 'httpclient', + label: 'HttpClient', popular: false + }, + + // ── Go ─────────────────────────────────────────────────────────────── + { + id: 'go_native', category: 'Go', folderName: 'go-native', + extension: 'go', target: 'go', client: 'native', + label: 'net/http', popular: false + }, + + // ── PHP ────────────────────────────────────────────────────────────── + { + id: 'php_curl', category: 'PHP', folderName: 'php-curl', + extension: 'php', target: 'php', client: 'curl', + label: 'ext-cURL', popular: false + }, + { + id: 'php_http1', category: 'PHP', folderName: 'php-http1', + extension: 'php', target: 'php', client: 'http1', + label: 'HTTP v1', popular: false + }, + { + id: 'php_http2', category: 'PHP', folderName: 'php-http2', + extension: 'php', target: 'php', client: 'http2', + label: 'HTTP v2', popular: false + }, + + // ── Ruby ───────────────────────────────────────────────────────────── + { + id: 'ruby_native', category: 'Ruby', folderName: 'ruby-native', + extension: 'rb', target: 'ruby', client: 'native', + label: 'Net::HTTP', popular: false + }, + { + id: 'ruby_faraday', category: 'Ruby', folderName: 'ruby-faraday', + extension: 'rb', target: 'ruby', client: 'faraday', + label: 'Faraday', popular: false + }, + + // ── Rust ───────────────────────────────────────────────────────────── + { + id: 'rust_reqwest', category: 'Rust', folderName: 'rust-reqwest', + extension: 'rs', target: 'rust' as HTTPSnippet.Target, client: 'reqwest', + label: 'reqwest', popular: false + }, + + // ── Swift ──────────────────────────────────────────────────────────── + { + id: 'swift_nsurlsession', category: 'Swift', folderName: 'swift-nsurlsession', + extension: 'swift', target: 'swift', client: 'nsurlsession', + label: 'URLSession', popular: false + }, + + // ── Objective-C ────────────────────────────────────────────────────── + { + id: 'objc_nsurlsession', category: 'Objective-C', folderName: 'objc-nsurlsession', + extension: 'm', target: 'objc', client: 'nsurlsession', + label: 'NSURLSession', popular: false + }, + + // ── C ──────────────────────────────────────────────────────────────── + { + id: 'c_libcurl', category: 'C', folderName: 'c-libcurl', + extension: 'c', target: 'c', client: 'libcurl', + label: 'libcurl', popular: false + }, + + // ── R ──────────────────────────────────────────────────────────────── + { + id: 'r_httr', category: 'R', folderName: 'r-httr', + extension: 'r', target: 'r' as HTTPSnippet.Target, client: 'httr', + label: 'httr', popular: false + }, + + // ── OCaml ──────────────────────────────────────────────────────────── + { + id: 'ocaml_cohttp', category: 'OCaml', folderName: 'ocaml-cohttp', + extension: 'ml', target: 'ocaml', client: 'cohttp', + label: 'CoHTTP', popular: false + }, + + // ── Clojure ────────────────────────────────────────────────────────── + { + id: 'clojure_clj_http', category: 'Clojure', folderName: 'clojure-clj_http', + extension: 'clj', target: 'clojure', client: 'clj_http', + label: 'clj-http', popular: false + }, + + // ── Crystal ────────────────────────────────────────────────────────── + // Note: Crystal target may not be available in all httpsnippet versions + // { + // id: 'crystal_native', category: 'Crystal', folderName: 'crystal-native', + // extension: 'cr', target: 'crystal' as any, client: 'native' as any, + // label: 'HTTP::Client', popular: false + // }, + + // ── PowerShell ─────────────────────────────────────────────────────── + { + id: 'powershell_webrequest', category: 'PowerShell', folderName: 'powershell-webrequest', + extension: 'ps1', target: 'powershell', client: 'webrequest', + label: 'Invoke-WebRequest', popular: true + }, + { + id: 'powershell_restmethod', category: 'PowerShell', folderName: 'powershell-restmethod', + extension: 'ps1', target: 'powershell', client: 'restmethod', + label: 'Invoke-RestMethod', popular: false + }, + + // ── HTTP ───────────────────────────────────────────────────────────── + { + id: 'http_1.1', category: 'HTTP', folderName: 'http-raw', + extension: 'txt', target: 'http' as HTTPSnippet.Target, client: '1.1', + label: 'Raw HTTP/1.1', popular: false + }, + + // ── Java (RestAssured) ─────────────────────────────────────────────── + // Available in some httpsnippet forks/versions: + // { + // id: 'java_restclient', category: 'Java', folderName: 'java-restclient', + // extension: 'java', target: 'java', client: 'restclient' as any, + // label: 'RestClient', popular: false + // }, +]; + +/** + * Extract unique categories in their original insertion order. + */ +export const FORMAT_CATEGORIES: string[] = [ + ...new Set(ALL_SNIPPET_FORMATS.map(f => f.category)) +]; + +/** + * Formats grouped by category for UI rendering. + */ +export const FORMATS_BY_CATEGORY: Record = + ALL_SNIPPET_FORMATS.reduce((acc, fmt) => { + (acc[fmt.category] ??= []).push(fmt); + return acc; + }, {} as Record); + +/** + * Default set of format IDs pre-selected in the format picker. + * These are the "popular" formats that most developers use. + */ +export const DEFAULT_SELECTED_FORMAT_IDS: ReadonlySet = new Set( + ALL_SNIPPET_FORMATS.filter(f => f.popular).map(f => f.id) +); + +/** All format IDs as a set (for "select all") */ +export const ALL_FORMAT_IDS: ReadonlySet = new Set( + ALL_SNIPPET_FORMATS.map(f => f.id) +); + +/** Quick lookup by format ID */ +export const FORMAT_BY_ID: ReadonlyMap = new Map( + ALL_SNIPPET_FORMATS.map(f => [f.id, f]) +); + +/** + * Resolve a set of format IDs to their full definitions. + * Silently skips unknown IDs. + */ +export function resolveFormats(ids: ReadonlySet): SnippetFormatDefinition[] { + return ALL_SNIPPET_FORMATS.filter(f => ids.has(f.id)); +} diff --git a/src/model/ui/ui-store.ts b/src/model/ui/ui-store.ts index 143dd33eb..d267d9bdc 100644 --- a/src/model/ui/ui-store.ts +++ b/src/model/ui/ui-store.ts @@ -8,6 +8,7 @@ import { persist, hydrate } from '../../util/mobx-persist/persist'; import { unreachableCheck, UnreachableCheck } from '../../util/error'; import { AccountStore } from '../account/account-store'; +import { DEFAULT_SELECTED_FORMAT_IDS } from './snippet-formats'; import { emptyFilterSet, FilterSet } from '../filters/search-filters'; import { DesktopApi } from '../../services/desktop-api'; import { @@ -461,6 +462,25 @@ export class UiStore { @persist @observable exportSnippetFormat: string | undefined; + /** + * Persisted list of snippet format IDs selected for ZIP export. + * Shared between the Export card (single exchange) and the batch toolbar + * (multi-select), so the user's choice is consistent everywhere. + * Initialized with popular defaults; updated via setZipFormatIds(). + */ + @persist('list') @observable + _zipFormatIds: string[] = [...DEFAULT_SELECTED_FORMAT_IDS]; + + @computed + get zipFormatIds(): ReadonlySet { + return new Set(this._zipFormatIds); + } + + @action.bound + setZipFormatIds(ids: ReadonlySet | string[]) { + this._zipFormatIds = Array.isArray(ids) ? [...ids] : [...ids]; + } + // Actions for persisting view state when switching tabs @action.bound setViewScrollPosition(position: number | 'end') { diff --git a/src/model/ui/zip-metadata.ts b/src/model/ui/zip-metadata.ts new file mode 100644 index 000000000..2f73c3477 --- /dev/null +++ b/src/model/ui/zip-metadata.ts @@ -0,0 +1,41 @@ +/** + * Metadata schema and builder for _metadata.json inside ZIP exports. + */ +import { UI_VERSION } from '../../services/service-versions'; +import type { SnippetFormatDefinition } from './snippet-formats'; + +export interface ZipMetadata { + /** ISO 8601 timestamp of the export */ + exportedAt: string; + /** Number of HTTP exchanges included */ + exchangeCount: number; + /** HTTP Toolkit UI version string */ + httptoolkitVersion: string; + /** List of format folder names included in the archive */ + formats: string[]; + /** Explains the archive structure to users who open _metadata.json */ + contents: { + snippetFolders: string; + harFile: string; + }; +} + +/** + * Builds the metadata object for the ZIP archive. + * This is serialized as `_metadata.json` at the root of the archive. + */ +export function buildZipMetadata( + exchangeCount: number, + formats: SnippetFormatDefinition[] +): ZipMetadata { + return { + exportedAt: new Date().toISOString(), + exchangeCount, + httptoolkitVersion: UI_VERSION, + formats: formats.map(f => f.folderName), + contents: { + snippetFolders: 'Each folder contains code snippets to reproduce the requests (request only)', + harFile: 'The .har file contains the full network traffic: requests AND responses with headers, bodies, and timings' + } + }; +} diff --git a/src/services/ui-worker-api.ts b/src/services/ui-worker-api.ts index 7185032bd..5c04e7704 100644 --- a/src/services/ui-worker-api.ts +++ b/src/services/ui-worker-api.ts @@ -18,11 +18,15 @@ import type { FormatRequest, FormatResponse, ParseCertRequest, - ParseCertResponse + ParseCertResponse, + GenerateZipRequest, + GenerateZipResponse } from './ui-worker'; import { Headers, Omit } from '../types'; import type { ApiMetadata, ApiSpec } from '../model/api/api-interfaces'; +import type { SnippetFormatDefinition } from '../model/ui/snippet-formats'; +import type { ZipMetadata } from '../model/ui/zip-metadata'; import { WorkerFormatterKey } from './ui-worker-formatters'; import { decodingRequired } from '../model/events/bodies'; @@ -153,4 +157,110 @@ export async function formatBufferAsync(buffer: Buffer, format: WorkerFormatterK format, headers, })).formatted; +} + +export interface ZipProgressInfo { + phase: string; + completed: number; + total: number; + percent: number; +} + +export interface ZipResult { + buffer: ArrayBuffer; + /** Number of snippet generations that failed (see _errors.json in the archive) */ + snippetErrors: number; + /** Total number of snippet generations attempted */ + totalSnippets: number; +} + +/** + * Generates a ZIP archive containing code snippets in all formats plus + * the HAR data and metadata. All CPU-intensive work runs in the Web Worker. + * + * @param harEntries - Plain (non-MobX-proxy) HAR entry objects. Use toJS() before calling. + * @param formats - Which snippet formats to include. + * @param metadata - The ZipMetadata object for _metadata.json. + * @param onProgress - Optional callback invoked with progress updates (~every 5%). + * @returns ZipResult with the compressed archive buffer and snippet error counts. + */ +export function generateZipInWorker( + harEntries: any[], + formats: SnippetFormatDefinition[], + metadata: ZipMetadata, + onProgress?: (info: ZipProgressInfo) => void +): Promise { + if (harEntries.length === 0) { + return Promise.reject(new Error('No entries to export')); + } + if (formats.length === 0) { + return Promise.reject(new Error('No formats selected')); + } + + const id = getId(); + + return new Promise((resolve, reject) => { + let settled = false; + const cleanup = () => { + worker.removeEventListener('message', handler); + clearTimeout(timeoutId); + }; + + // Safety timeout: if the worker doesn't respond within 5 minutes, + // clean up the listener to prevent memory leaks. + const timeoutId = setTimeout(() => { + if (!settled) { + settled = true; + cleanup(); + reject(new Error('ZIP generation timed out after 5 minutes')); + } + }, 5 * 60 * 1000); + + const handler = (event: MessageEvent) => { + const data = event.data; + if (data.id !== id) return; + + // Progress messages have a 'type' field; final responses don't + if (data.type === 'generateZipProgress') { + onProgress?.({ + phase: data.phase, + completed: data.completed, + total: data.total, + percent: data.percent + }); + return; // Keep listening for the final result + } + + // Final result — always remove listener before resolving/rejecting + if (settled) return; + settled = true; + cleanup(); + + if (data.error) { + reject(deserializeError(data.error)); + } else { + resolve({ + buffer: data.buffer, + snippetErrors: data.snippetErrors || 0, + totalSnippets: data.totalSnippets || 0 + }); + } + }; + + worker.addEventListener('message', handler); + + try { + worker.postMessage(Object.assign({ id }, { + type: 'generateZip', + harEntries, + formats, + metadata + } as Omit)); + } catch (err) { + // postMessage can throw for unserializable data (MobX proxies, etc.) + settled = true; + cleanup(); + reject(err); + } + }); } \ No newline at end of file diff --git a/src/services/ui-worker.ts b/src/services/ui-worker.ts index c25f86b72..277e08809 100644 --- a/src/services/ui-worker.ts +++ b/src/services/ui-worker.ts @@ -12,12 +12,17 @@ import { SUPPORTED_ENCODING } from 'http-encoding'; import { OpenAPIObject } from 'openapi-directory'; +import * as HTTPSnippet from '@httptoolkit/httpsnippet'; +import { zip } from 'fflate'; import { Headers } from '../types'; import { ApiMetadata, ApiSpec } from '../model/api/api-interfaces'; import { buildOpenApiMetadata, buildOpenRpcMetadata } from '../model/api/build-api-metadata'; import { parseCert, ParsedCertificate, validatePKCS12, ValidationResult } from '../model/crypto'; import { WorkerFormatterKey, formatBuffer } from './ui-worker-formatters'; +import { buildZipFileName } from '../util/export-filenames'; +import type { SnippetFormatDefinition } from '../model/ui/snippet-formats'; +import type { ZipMetadata } from '../model/ui/zip-metadata'; interface Message { id: number; @@ -100,6 +105,18 @@ export interface FormatResponse extends Message { formatted: string; } +export interface GenerateZipRequest extends Message { + type: 'generateZip'; + harEntries: any[]; + formats: SnippetFormatDefinition[]; + metadata: ZipMetadata; +} + +export interface GenerateZipResponse extends Message { + error?: Error; + buffer: ArrayBuffer; +} + export type BackgroundRequest = | DecodeRequest | EncodeRequest @@ -107,7 +124,8 @@ export type BackgroundRequest = | BuildApiRequest | ValidatePKCSRequest | ParseCertRequest - | FormatRequest; + | FormatRequest + | GenerateZipRequest; export type BackgroundResponse = | DecodeResponse @@ -116,7 +134,8 @@ export type BackgroundResponse = | BuildApiResponse | ValidatePKCSResponse | ParseCertResponse - | FormatResponse; + | FormatResponse + | GenerateZipResponse; const bufferToArrayBuffer = (buffer: Buffer): ArrayBuffer => // Have to remember to slice: this can be a view into part of a much larger buffer! @@ -228,6 +247,184 @@ ctx.addEventListener('message', async (event: { data: BackgroundRequest }) => { ctx.postMessage({ id: event.data.id, formatted }); break; + case 'generateZip': { + const { id, harEntries, formats, metadata } = event.data as GenerateZipRequest; + + try { + if (!harEntries || harEntries.length === 0) { + throw new Error('No HAR entries provided for ZIP export'); + } + if (!formats || formats.length === 0) { + throw new Error('No snippet formats selected for ZIP export'); + } + + const encoder = new TextEncoder(); + const files: Record = {}; + const totalSteps = formats.length * harEntries.length; + let completedSteps = 0; + let lastReportedPercent = 0; + + // Track snippet generation errors for transparency + const snippetErrors: Array<{ + format: string; + entryIndex: number; + method: string; + url: string; + error: string; + }> = []; + + // 1. Generate snippet files for each format × each entry + for (const format of formats) { + for (let i = 0; i < harEntries.length; i++) { + const entry = harEntries[i]; + try { + // HTTPSnippet expects a HAR *request* object, not a full + // HAR entry. Extract and simplify the request, matching + // the pattern used by generateCodeSnippet() on the main + // thread (see model/ui/export.ts). + const harRequest = entry.request; + + // Sanitize postData: httpsnippet's HAR validator rejects + // null values in postData.text (e.g. CDN beacon POSTs). + // Also strip empty queryString entries that fail validation. + const postData = harRequest.postData + ? { + ...harRequest.postData, + text: harRequest.postData.text ?? '' + } + : harRequest.postData; + + const snippetInput = { + ...harRequest, + headers: (harRequest.headers || []).filter((h: any) => + h.name.toLowerCase() !== 'content-length' && + h.name.toLowerCase() !== 'content-encoding' && + !h.name.startsWith(':') + ), + queryString: (harRequest.queryString || []).filter( + (q: any) => q.name !== '' || q.value !== '' + ), + cookies: [], // Included in headers already + ...(postData !== undefined ? { postData } : {}) + }; + const snippet = new HTTPSnippet(snippetInput); + const code = snippet.convert(format.target, format.client); + if (code) { + const filename = buildZipFileName( + i + 1, + entry.request?.method ?? 'UNKNOWN', + entry.response?.status ?? null, + format.extension, + entry.request?.url + ); + const content = Array.isArray(code) ? code[0] : code; + files[`${format.folderName}/${filename}`] = encoder.encode( + typeof content === 'string' ? content : String(content) + ); + } + } catch (snippetErr) { + // Skip this format for this entry but continue + console.warn( + `Snippet generation failed for ${format.folderName}, entry ${i}:`, + snippetErr + ); + snippetErrors.push({ + format: format.folderName, + entryIndex: i + 1, + method: entry.request?.method ?? 'UNKNOWN', + url: entry.request?.url ?? 'unknown', + error: snippetErr instanceof Error ? snippetErr.message : String(snippetErr) + }); + } + + // Report progress every 5% (avoids flooding main thread) + completedSteps++; + if (totalSteps > 0) { + const currentPercent = Math.floor((completedSteps / totalSteps) * 100); + if (currentPercent >= lastReportedPercent + 5) { + lastReportedPercent = currentPercent; + ctx.postMessage({ + id, + type: 'generateZipProgress', + phase: 'snippets', + completed: completedSteps, + total: totalSteps, + percent: currentPercent + }); + } + } + } + } + + // 2. Add full traffic capture as HAR + // Contains the complete network traffic (requests + responses + // with headers, bodies, timings, cookies) for every exchange. + // The snippets in the format folders only reproduce the request; + // this file is the authoritative record of what actually happened. + const harDocument = { + log: { + version: '1.2', + creator: { + name: 'HTTP Toolkit', + version: metadata.httptoolkitVersion + }, + entries: harEntries + } + }; + const harFileName = `HTTPToolkit_${harEntries.length}-requests_full-traffic.har`; + files[harFileName] = encoder.encode( + JSON.stringify(harDocument, null, 2) + ); + + // 3. Add _metadata.json (include error summary if any) + const metadataWithErrors = snippetErrors.length > 0 + ? { ...metadata, snippetErrors: snippetErrors.length, totalSnippets: totalSteps } + : metadata; + files['_metadata.json'] = encoder.encode( + JSON.stringify(metadataWithErrors, null, 2) + ); + + // 3b. If any snippets failed, include a detailed error log + if (snippetErrors.length > 0) { + files['_errors.json'] = encoder.encode( + JSON.stringify({ + summary: `${snippetErrors.length} of ${totalSteps} snippet generations failed`, + errors: snippetErrors + }, null, 2) + ); + } + + // 4. Compress with fflate (async callback API) + zip(files, { level: 6 }, (err, data) => { + if (err) { + ctx.postMessage({ + id, + error: serializeError(err) + }); + return; + } + // Transfer the ArrayBuffer for zero-copy. + // Include snippet error count so the UI can warn if needed. + ctx.postMessage( + { + id, + buffer: data.buffer, + snippetErrors: snippetErrors.length, + totalSnippets: totalSteps + }, + [data.buffer] + ); + }); + } catch (err) { + ctx.postMessage({ + id, + error: serializeError(err) + }); + } + // Note: response is sent asynchronously from the zip() callback above + return; + } + default: console.error('Unknown worker event', event); } diff --git a/src/util/download.ts b/src/util/download.ts new file mode 100644 index 000000000..8a5bcd60b --- /dev/null +++ b/src/util/download.ts @@ -0,0 +1,25 @@ +/** + * Download utility — triggers a browser file download from a Blob. + * + * The object URL is revoked after a generous 10-second delay to ensure + * the browser has started the download even for very large files. + * This prevents premature revocation that could abort downloads on + * slower machines or when the browser needs extra time to begin writing. + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + try { + anchor.click(); + } finally { + document.body.removeChild(anchor); + // 10 seconds is generous enough even for very large ZIPs (>500MB) + // where the browser may need extra time to begin the write. + // Once the browser has begun writing to disk, revoking only frees + // the in-memory blob reference — it does not abort the download. + setTimeout(() => URL.revokeObjectURL(url), 10000); + } +} diff --git a/src/util/export-filenames.ts b/src/util/export-filenames.ts new file mode 100644 index 000000000..292930a47 --- /dev/null +++ b/src/util/export-filenames.ts @@ -0,0 +1,111 @@ +/** + * Utilities for generating safe, consistent filenames for exports. + * + * Naming conventions follow HTTPToolkit's established patterns: + * - HAR single: "{METHOD} {hostname}.har" + * - HAR batch: "HTTPToolkit_export_{date}_{count}-requests.har" + * - ZIP archive: "HTTPToolkit_{date}_{count}-requests.zip" + * - Snippet: "{index}_{METHOD}_{STATUS}_{hostname}.{ext}" + */ + +const MAX_FILENAME_LENGTH = 100; + +/** + * Sanitizes a string for safe use in filenames. + * Strips protocol, query strings, and invalid characters. + */ +function sanitize(raw: string, maxLen: number = 40): string { + return raw + .replace(/^https?:\/\//, '') // Strip protocol + .replace(/[?#].*$/, '') // Strip query/fragment + .replace(/\/+$/, '') // Strip trailing slashes + .replace(/[<>:"/\\|?*\x00-\x1F]/g, '_') // Replace invalid FS chars + .replace(/_+/g, '_') // Collapse multiple underscores + .slice(0, maxLen); +} + +/** + * Extracts a short, readable hostname from a URL. + * Falls back to the first path segment if the hostname is generic. + * + * Examples: + * "https://api.example.com/v2/users?page=1" → "api.example.com" + * "https://10.0.0.1:8080/health" → "10.0.0.1" + */ +function extractHost(url: string | undefined | null): string { + if (!url) return ''; + try { + const parsed = new URL(url); + // Use hostname (without port) for readability + return parsed.hostname || ''; + } catch { + // Fallback: extract something meaningful from the raw string + const match = url.match(/^https?:\/\/([^/:?#]+)/); + return match ? match[1] : ''; + } +} + +/** + * Builds a filename for a single snippet inside the ZIP archive. + * + * Convention: `{index}_{METHOD}_{STATUS}_{hostname}.{ext}` + * Examples: + * "001_GET_200_api.github.com.sh" + * "023_POST_201_httpbin.org.py" + * "007_DELETE_pending_localhost.js" + * + * The hostname gives the user immediate context about which request + * each file represents, matching HTTPToolkit's existing HAR export + * pattern of "{METHOD} {hostname}.har". + */ +export function buildZipFileName( + index: number, + method: string, + status: number | null, + extension: string, + url?: string +): string { + const padWidth = 3; + const safeIndex = String(Math.max(1, Math.floor(index))).padStart(padWidth, '0'); + const safeMethod = (method || 'UNKNOWN').toUpperCase().replace(/[^A-Z]/g, '') || 'UNKNOWN'; + const safeStatus = status != null ? String(status) : 'pending'; + const safeExt = extension.replace(/[^a-zA-Z0-9]/g, '') || 'txt'; + + const host = extractHost(url); + const safeHost = host ? '_' + sanitize(host, 30) : ''; + + const name = `${safeIndex}_${safeMethod}_${safeStatus}${safeHost}.${safeExt}`; + + // Ensure we don't exceed filesystem limits + return name.length > MAX_FILENAME_LENGTH + ? `${safeIndex}_${safeMethod}_${safeStatus}.${safeExt}` + : name; +} + +/** + * Builds the archive filename for the downloaded ZIP. + * + * Convention: `HTTPToolkit_{date}_{count}-requests.zip` + * Example: "HTTPToolkit_2026-04-04_14-30_180-requests.zip" + * + * Follows HTTPToolkit's established batch HAR naming pattern: + * "HTTPToolkit_export_{date}_{count}-requests.har" + */ +export function buildZipArchiveName(exchangeCount?: number): string { + const now = new Date(); + const date = [ + now.getFullYear(), + String(now.getMonth() + 1).padStart(2, '0'), + String(now.getDate()).padStart(2, '0') + ].join('-'); + const time = [ + String(now.getHours()).padStart(2, '0'), + String(now.getMinutes()).padStart(2, '0') + ].join('-'); + + const countPart = exchangeCount != null + ? `_${exchangeCount}-requests` + : ''; + + return `HTTPToolkit_${date}_${time}${countPart}.zip`; +} diff --git a/src/util/ui.ts b/src/util/ui.ts index 93f8faf4e..3b647d454 100644 --- a/src/util/ui.ts +++ b/src/util/ui.ts @@ -32,7 +32,7 @@ export const AriaCtrlCmd = isMac ? 'meta' : 'control'; -export function isCmdCtrlPressed(event: React.KeyboardEvent) { +export function isCmdCtrlPressed(event: React.KeyboardEvent | React.MouseEvent) { return isMac ? event.metaKey : event.ctrlKey; diff --git a/test/unit/model/ui/snippet-formats.spec.ts b/test/unit/model/ui/snippet-formats.spec.ts new file mode 100644 index 000000000..5024e0b52 --- /dev/null +++ b/test/unit/model/ui/snippet-formats.spec.ts @@ -0,0 +1,89 @@ +import { expect } from 'chai'; +import { + ZIP_ALL_FORMAT_KEY, + SNIPPET_FORMATS, + SNIPPET_FORMATS_FOR_ZIP, + SNIPPET_FORMATS_FOR_CONTEXT_MENU, + SnippetFormatDefinition +} from '../../../../src/model/ui/snippet-formats'; + +describe('snippet-formats', () => { + + describe('ZIP_ALL_FORMAT_KEY', () => { + it('is a non-empty string', () => { + expect(ZIP_ALL_FORMAT_KEY).to.be.a('string'); + expect(ZIP_ALL_FORMAT_KEY.length).to.be.greaterThan(0); + }); + + it('is not a valid httpsnippet target (sentinel)', () => { + // Sentinel keys should not collide with real target_client IDs + expect(ZIP_ALL_FORMAT_KEY).to.include('__'); + }); + }); + + describe('SNIPPET_FORMATS', () => { + it('contains at least 4 formats', () => { + expect(SNIPPET_FORMATS.length).to.be.greaterThanOrEqual(4); + }); + + it('has unique IDs', () => { + const ids = SNIPPET_FORMATS.map(f => f.id); + expect(new Set(ids).size).to.equal(ids.length); + }); + + it('has unique folder names', () => { + const folders = SNIPPET_FORMATS.map(f => f.folderName); + expect(new Set(folders).size).to.equal(folders.length); + }); + + it('each format has all required fields', () => { + SNIPPET_FORMATS.forEach((f: SnippetFormatDefinition) => { + expect(f.id).to.be.a('string').and.not.empty; + expect(f.folderName).to.be.a('string').and.not.empty; + expect(f.extension).to.be.a('string').and.not.empty; + expect(f.target).to.be.a('string').and.not.empty; + expect(f.client).to.be.a('string').and.not.empty; + expect(f.label).to.be.a('string').and.not.empty; + expect(f.includeInContextMenu).to.be.a('boolean'); + expect(f.includeInZip).to.be.a('boolean'); + }); + }); + + it('includes cURL format', () => { + const curl = SNIPPET_FORMATS.find(f => f.id === 'shell_curl'); + expect(curl).to.exist; + expect(curl!.target).to.equal('shell'); + expect(curl!.client).to.equal('curl'); + }); + + it('includes Python Requests format', () => { + const python = SNIPPET_FORMATS.find(f => f.id === 'python_requests'); + expect(python).to.exist; + expect(python!.target).to.equal('python'); + }); + }); + + describe('SNIPPET_FORMATS_FOR_ZIP', () => { + it('only contains formats with includeInZip=true', () => { + SNIPPET_FORMATS_FOR_ZIP.forEach(f => { + expect(f.includeInZip).to.be.true; + }); + }); + + it('is a subset of SNIPPET_FORMATS', () => { + const allIds = new Set(SNIPPET_FORMATS.map(f => f.id)); + SNIPPET_FORMATS_FOR_ZIP.forEach(f => { + expect(allIds.has(f.id)).to.be.true; + }); + }); + }); + + describe('SNIPPET_FORMATS_FOR_CONTEXT_MENU', () => { + it('only contains formats with includeInContextMenu=true', () => { + SNIPPET_FORMATS_FOR_CONTEXT_MENU.forEach(f => { + expect(f.includeInContextMenu).to.be.true; + }); + }); + }); + +}); diff --git a/test/unit/util/export-filenames.spec.ts b/test/unit/util/export-filenames.spec.ts new file mode 100644 index 000000000..2c403fd7d --- /dev/null +++ b/test/unit/util/export-filenames.spec.ts @@ -0,0 +1,111 @@ +import { expect } from 'chai'; +import { + buildZipFileName, + buildZipArchiveName +} from '../../../src/util/export-filenames'; + +describe('export-filenames', () => { + + describe('buildZipFileName', () => { + + it('builds a standard filename with hostname', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh', 'https://api.github.com/repos')) + .to.equal('001_GET_200_api.github.com.sh'); + }); + + it('builds a filename without URL', () => { + expect(buildZipFileName(3, 'POST', 201, 'py')) + .to.equal('003_POST_201.py'); + }); + + it('zero-pads the index to 3 digits', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh')) + .to.equal('001_GET_200.sh'); + expect(buildZipFileName(42, 'POST', 201, 'py')) + .to.equal('042_POST_201.py'); + }); + + it('uppercases the method', () => { + expect(buildZipFileName(1, 'get', 200, 'sh')) + .to.equal('001_GET_200.sh'); + }); + + it('strips non-alpha characters from method', () => { + expect(buildZipFileName(1, 'M-SEARCH', 200, 'sh')) + .to.equal('001_MSEARCH_200.sh'); + }); + + it('uses "pending" for null status', () => { + expect(buildZipFileName(7, 'DELETE', null, 'js')) + .to.equal('007_DELETE_pending.js'); + }); + + it('handles zero status code', () => { + expect(buildZipFileName(1, 'GET', 0, 'sh')) + .to.equal('001_GET_0.sh'); + }); + + it('handles large index numbers beyond padding', () => { + expect(buildZipFileName(9999, 'PATCH', 204, 'ps1')) + .to.equal('9999_PATCH_204.ps1'); + }); + + it('uses "UNKNOWN" for empty method string', () => { + expect(buildZipFileName(1, '', 200, 'sh')) + .to.equal('001_UNKNOWN_200.sh'); + }); + + it('extracts hostname from URL and appends it', () => { + expect(buildZipFileName(5, 'POST', 201, 'py', 'https://httpbin.org/post?q=1')) + .to.equal('005_POST_201_httpbin.org.py'); + }); + + it('handles URL with port by dropping port', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh', 'http://localhost:8080/api')) + .to.equal('001_GET_200_localhost.sh'); + }); + + it('handles IP address URLs', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh', 'http://192.168.1.1/test')) + .to.equal('001_GET_200_192.168.1.1.sh'); + }); + + it('omits hostname when URL is undefined', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh', undefined)) + .to.equal('001_GET_200.sh'); + }); + + it('omits hostname when URL is invalid', () => { + expect(buildZipFileName(1, 'GET', 200, 'sh', 'not-a-url')) + .to.equal('001_GET_200.sh'); + }); + }); + + describe('buildZipArchiveName', () => { + + it('starts with "HTTPToolkit_"', () => { + expect(buildZipArchiveName()).to.match(/^HTTPToolkit_/); + }); + + it('ends with ".zip"', () => { + expect(buildZipArchiveName()).to.match(/\.zip$/); + }); + + it('contains date and time', () => { + const name = buildZipArchiveName(); + // Should match: HTTPToolkit_YYYY-MM-DD_HH-MM.zip + expect(name).to.match(/^HTTPToolkit_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}\.zip$/); + }); + + it('includes exchange count when provided', () => { + const name = buildZipArchiveName(42); + expect(name).to.match(/^HTTPToolkit_\d{4}-\d{2}-\d{2}_\d{2}-\d{2}_42-requests\.zip$/); + }); + + it('works without exchange count', () => { + const name = buildZipArchiveName(); + expect(name).to.not.include('requests'); + }); + }); + +});