diff --git a/Cargo.lock b/Cargo.lock index 0f213b9..963d239 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -392,6 +392,23 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -411,7 +428,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -1109,6 +1130,7 @@ dependencies = [ "anyhow", "arboard", "crossterm", + "futures-util", "ratatui", "reqwest", "serde", @@ -1262,12 +1284,14 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-util", "tower", "tower-http", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", + "wasm-streams", "web-sys", ] @@ -2007,6 +2031,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" version = "0.3.85" diff --git a/Cargo.toml b/Cargo.toml index 74b14df..845d796 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,8 @@ edition = "2021" ratatui = "0.29" crossterm = "0.28" tokio = { version = "1", features = ["full"] } -reqwest = { version = "0.12", features = ["json", "native-tls", "multipart"] } +reqwest = { version = "0.12", features = ["json", "native-tls", "multipart", "stream"] } +futures-util = "0.3" anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/docs/plans/2026-02-17-feat-response-metadata-and-search-plan.md b/docs/plans/2026-02-17-feat-response-metadata-and-search-plan.md new file mode 100644 index 0000000..6f19564 --- /dev/null +++ b/docs/plans/2026-02-17-feat-response-metadata-and-search-plan.md @@ -0,0 +1,239 @@ +--- +title: "feat: add response metadata display, copy/save actions, and body search" +type: feat +date: 2026-02-17 +--- + +# Response Metadata & Search + +## Overview + +Add four response-panel enhancements that round out Perseus's Phase 1 feature set: response size display in the tab bar, one-key copy of response content, save-response-to-file with a path input popup, and vim-style `/` search with incremental highlighting and `n`/`N` match navigation. + +## Problem Statement / Motivation + +Perseus currently shows status code and duration after a request completes but has no way to: + +1. **See the response size** -- developers routinely check payload size when debugging APIs, measuring performance, or enforcing contract limits. +2. **Copy the full response body in one keystroke** -- the only path today is entering vim editing mode, selecting all (`ggVG`), and yanking. That is too many steps for a daily action. +3. **Save the response to a file** -- there is no export mechanism; users must manually copy-paste into a file. +4. **Search within the response** -- large JSON payloads are unnavigable without find. The sidebar already has `/` search for filtering items; the response panel has nothing equivalent. + +These are the last Phase 1 features. Completing them makes Perseus viable for daily API development work. + +## Proposed Solution + +Four sub-features, ordered from simplest to most complex: + +### 1. Response Size Display + +Show the response body byte count alongside the existing status text in the response tab bar. + +- Capture `body_size_bytes: usize` on `ResponseData` by calling `response.bytes().await` in `src/http.rs`, measuring `.len()`, then converting to a UTF-8 string with `String::from_utf8_lossy()`. This preserves the true wire size even when the body is later pretty-printed. (Note: reqwest's `.bytes()` and `.text()` both consume the response -- you must use `.bytes()` and convert manually.) +- Add a `format_size(bytes: usize) -> String` helper (1024-based thresholds: B / KB / MB / GB, one decimal place above bytes). +- Extend the right-aligned status text in `render_response_tab_bar()` from `"200 OK (245ms)"` to `"200 OK (245ms) · 1.2 KB"`. +- At narrow widths (< 50 cols inner), hide the size to avoid overlapping the tab labels. +- Size is body-only, not headers. + +### 2. One-Key Copy Response Content + +Copy the currently visible tab content (body **or** headers) to the system clipboard with a single keystroke. + +| Mode | Key | Behavior | +|------|-----|----------| +| Navigation (response focused) | `c` | Copy current tab content, show toast | + +Users in Editing mode press Esc first to return to Navigation, then `c`. One extra keystroke is acceptable -- it avoids adding a parallel Ctrl-combo binding with terminal compatibility concerns. + +- If `response_tab == Body`, copy the formatted (pretty-printed) body text. +- If `response_tab == Headers`, copy the rendered header text. +- If no successful response exists, show toast "No response to copy". +- Reuse the existing `clipboard.set_text()` + `set_clipboard_toast()` pattern from `src/app.rs`. +- Toast message: `"Copied response body (1.2 KB)"` or `"Copied response headers"`. + +### 3. Save Response to File + +Save the current tab content (body or headers) to a user-specified file path. + +| Mode | Key | Behavior | +|------|-----|----------| +| Navigation (response focused) | `S` | Open file path input popup | + +Users in Editing mode press Esc first to return to Navigation, then `S`. This avoids `Ctrl+Shift+S`, which most terminals cannot distinguish from `Ctrl+S` (already bound to save-collection). + +**Popup design:** +- Center-screen popup matching the existing `SidebarPopup` visual pattern (bordered block with title "Save Response"). +- Reuse `TextInput` struct for path entry. +- Enter confirms and writes the file; Esc cancels. +- Paths are resolved relative to the CWD where Perseus was launched. +- Tilde expansion (`~/`) is supported via simple prefix replacement. +- If the path's parent directory does not exist, show an error toast. +- If the file already exists, overwrite silently (matches `curl -o` behavior). +- On success: toast `"Saved to (1.2 KB)"`. +- On error: toast `"Save failed: "`. + +**State:** +- Add `save_popup: Option` to `App`. +- Intercept keys early in `handle_key()` when `save_popup.is_some()` -- route to `handle_text_input()`. +- Render via a new `render_save_popup()` function in `src/ui/mod.rs`. + +### 4. Response Body Search + +Vim-style `/` search with incremental highlighting and match navigation. + +**Activation:** +- Press `/` while in Editing Normal mode on the response Body tab. +- A 1-row search bar appears at the bottom of the response content area (steals space from the content, does not overlay). +- Format: `/ query text here [Aa] 3/17` + - Left: search input with `/` prefix + - Right: case indicator (`Aa` = insensitive, `AA` = sensitive) and match count `current/total` + +**State machine integration (Option B -- boolean flag):** +- Add to `App`: + ```rust + pub struct ResponseSearch { + pub active: bool, // search input bar is visible and receiving keys + pub query: String, // persisted after Enter so n/N can use it + pub input: TextInput, // current input field + pub matches: Vec, // (line_index, byte_start, byte_end) + pub current_match: usize, // index into matches vec + pub case_sensitive: bool, // toggle state + } + ``` +- `response_search: ResponseSearch` on `App` (always present, `active` toggled). +- When `active == true`, `handle_editing_mode()` intercepts **all** keys before vim dispatch: + - Character keys go to `input`. + - After each keystroke, recompute matches against the raw body text (pre-wrap, post-format). + - Enter: confirm search -- set `active = false`, persist `query = input.text()`, keep highlights and `current_match`. + - Esc: cancel -- set `active = false`, clear `query`, clear matches and highlights. + - `Ctrl+I`: toggle `case_sensitive`, recompute matches. +- When `active == false` but `query` is non-empty, `n`/`N` in vim Normal mode navigate matches: + - Intercept `n`/`N` in `handle_editing_mode()` before vim dispatch. + - `n` advances `current_match` (wraps around). + - `N` moves backwards (wraps around). + - Auto-scroll to bring the current match into view. + +**Highlighting:** +- Matches are highlighted with `bg(Color::Yellow) + fg(Color::Black)`. +- The current match is highlighted with `bg(Color::LightRed) + fg(Color::Black)`. +- Highlighting is applied as a post-processing pass over `cache.lines` (the colorized `Vec>`) before word wrapping, by splitting spans at match boundaries and overriding styles. This preserves JSON syntax colors underneath for non-matching regions. +- The `ResponseBodyRenderCache` gains a `search_generation: u64` counter; when the search state changes, bump the generation to trigger a re-render of highlighted lines. + +**Search operates on the raw (unwrapped) body text.** Match byte offsets are mapped to line/column positions in the pre-wrap lines. The wrapping pass carries highlight styles through to wrapped output. + +**Edge cases:** +- Empty query on Enter: clears search state (same as Esc). +- No matches: show `0/0` in the search bar, no highlights. +- New response arrives: clear all search state (`query`, `matches`, `active`). +- Search only available on Body tab. `/` on Headers tab is a no-op. +- Very large responses: match computation is O(n) per keystroke. For responses > 1 MB, add debouncing if profiling shows > 50ms latency (not implemented upfront -- measure first). + +**Layout change:** +- `ResponseLayout::new()` accepts a `search_active: bool` parameter. +- When true, adds `Constraint::Length(1)` at the bottom of `content_area`, splitting it into `content_area` + `search_bar_area`. + +## Technical Considerations + +**Render cache invalidation:** +The response body render cache has two layers: colorized lines and wrapped lines. Search highlighting adds a third concern. Rather than adding a full third cache layer, use a `search_generation` counter. When search state changes (new query, new match index, search cleared), bump the counter. The render function checks `search_generation` against the cache's stored value to decide whether to re-apply highlights. Highlights are applied on top of cached colorized lines, producing highlighted lines that are then wrapped. + +**Performance for large responses:** +- Incremental search recomputes match positions on every keystroke. For typical API responses (< 100 KB), this is negligible. If profiling shows > 50ms latency on larger responses, add debouncing then. +- Highlight application iterates all matches and splits spans. For thousands of matches, this is O(matches * avg_spans_per_line). Acceptable for typical use. + +**Keybinding conflicts:** +- `c` in Navigation mode on Response panel: currently unused. Safe. +- `S` in Navigation mode on Response panel: currently unused. Safe. +- `/` in Editing Normal mode: not handled by `transition_read_only()`, falls through to `Nop`. Safe to intercept before vim dispatch. +- `n`/`N` in Editing Normal mode: not handled by `transition_read_only()`. Safe to intercept. +- `Ctrl+I` during search input: not used elsewhere in text input contexts. Safe. + +**`body_size_bytes` accuracy:** +Measuring size from `response.bytes().await` gives the decompressed wire size (reqwest decompresses gzip by default). This matches what Postman displays and is what users expect. + +## Acceptance Criteria + +### Response Size Display +- [x] `ResponseData` has `body_size_bytes: usize` populated from HTTP response +- [x] Tab bar shows size after duration: `"200 OK (245ms) · 1.2 KB"` +- [x] Size formatted correctly: `0 B`, `512 B`, `1.0 KB`, `2.5 MB`, `1.1 GB` +- [x] Size hidden when terminal inner width < 50 columns +- [x] No size shown for error/loading/empty/cancelled states + +### One-Key Copy +- [x] `c` in Navigation mode (response focused) copies current tab content +- [x] Toast shows `"Copied response body (1.2 KB)"` or `"Copied response headers"` +- [x] `"No response to copy"` toast when no successful response exists +- [x] Copied text is the formatted (pretty-printed) body or rendered headers + +### Save to File +- [x] `S` in Navigation mode (response focused) opens save popup +- [x] Center-screen popup with `TextInput` for path entry +- [x] Enter writes file, Esc cancels +- [x] Tilde expansion works (`~/output.json` resolves correctly) +- [x] Error toast on invalid path / permission error +- [x] Success toast with path and size +- [x] Save popup intercepts all keys (no bleed-through to vim/navigation) + +### Response Body Search +- [x] `/` in Editing Normal mode on Body tab opens search bar +- [x] Search bar renders at bottom of response content area (1 row) +- [x] Typing incrementally highlights matches in the response body +- [x] Match count displayed as `current/total` in search bar +- [x] Enter confirms search, closes input bar, keeps highlights +- [x] Esc cancels search, clears query and highlights +- [x] `n` moves to next match (wraps around) +- [x] `N` moves to previous match (wraps around) +- [x] `Ctrl+I` toggles case sensitivity with visual indicator +- [x] Current match highlighted differently (LightRed) from other matches (Yellow) +- [x] Search highlights overlay JSON syntax colors correctly +- [x] Auto-scroll to current match position +- [x] Search state cleared when new response arrives +- [x] Search only active on Body tab; `/` is no-op on Headers tab +- [x] Help overlay updated with new search keybindings + +### General +- [x] Help overlay (`?`) documents all new keybindings +- [x] Status bar hints updated for response-focused context +- [ ] All features work correctly in both narrow (80 cols) and wide terminals +- [ ] No regressions in existing response panel behavior + +## Success Metrics + +- All four sub-features are accessible via documented keybindings without leaving the keyboard. +- Response search finds matches in < 50ms for typical API responses (< 100 KB). +- No frame drops or visible lag when searching in responses up to 1 MB. +- Zero clipboard/file-write failures on macOS and Linux with valid paths. + +## Dependencies & Risks + +| Dependency / Risk | Impact | Mitigation | +|-------------------|--------|------------| +| Search highlighting must integrate with JSON colorization pipeline | High complexity -- span splitting is fiddly | Implement size display and copy/save first; tackle search last with dedicated testing | +| `ResponseLayout` changes affect all response rendering | Medium -- layout change could break existing rendering | Conditional layout (search_active flag), thorough visual testing | +| `body_size_bytes` requires change to `http.rs` response handling | Low -- isolated change | Call `.bytes().await`, measure, then convert to String manually | +| `response.bytes()` consumes the response in reqwest | Low -- requires rewriting the response-read path | Replace `.text().await` with `.bytes().await` + `String::from_utf8_lossy()` | + +## References & Research + +### Internal References +- Response data struct: `src/app.rs:92-98` (`ResponseData`) +- Response tab bar rendering: `src/ui/mod.rs:1206-1244` (`render_response_tab_bar`) +- Response body rendering + caching: `src/ui/mod.rs:1278-1323` (`render_response_body`) +- JSON colorization: `src/ui/mod.rs:1376-1510` (`colorize_json`) +- Word wrapping with cursor: `src/ui/mod.rs:1528-1597` (`render_wrapped_response_cached`) +- Span wrapping with selection highlights: `src/ui/mod.rs:1656-1704` (`wrap_line_spans_with_cursor`) +- Clipboard provider: `src/clipboard.rs` (`ClipboardProvider`) +- Clipboard toast pattern: `src/app.rs:2150-2153` (`copy_selected_path`) +- Sidebar search pattern: `src/app.rs:1818-1887` (sidebar `/` search with `TextInput`) +- TextInput struct: `src/app.rs:431-475` +- Read-only vim handler: `src/vim.rs:460-644` (`handle_read_only_normal_visual_operator`) +- HTTP response handling: `src/http.rs:192` (`response.text().await`) +- Response arrival in event loop: `src/app.rs:2731-2767` +- Key dispatch: `src/app.rs:2823-2828` (`handle_key`) +- Status bar: `src/ui/mod.rs:1717-1840` (`render_status_bar`) +- Help overlay: `src/ui/mod.rs:1861+` +- ResponseLayout: `src/ui/layout.rs:118-139` + +### Brainstorm Reference +- Feature spec: `docs/brainstorms/2026-02-15-production-ready-features-brainstorm.md` lines 92-99 diff --git a/docs/response-panel.md b/docs/response-panel.md new file mode 100644 index 0000000..f9104e3 --- /dev/null +++ b/docs/response-panel.md @@ -0,0 +1,384 @@ +# Response Panel + +Perseus provides a full-featured response panel for inspecting API responses. After sending a request with `Ctrl+R`, the response panel displays the status code, timing, body size, and full response content. You can navigate the response with vim keybindings, search within the body, copy content to the clipboard, and save responses to files — all without leaving the keyboard. + +## Response Panel Layout + +``` +┌─ Response ──────────────────────────────────────┐ +│ Body | Headers 200 OK (245ms) · 1.2 KB │ <- Tab bar with status +│ │ +│ { │ +│ "id": 1, │ <- Response content +│ "name": "Perseus", │ (Body or Headers tab) +│ ... │ +│ │ +│ /search query [Aa] 3/17 │ <- Search bar (when active) +└──────────────────────────────────────────────────┘ +``` + +The tab bar shows two tabs (**Body** and **Headers**) on the left and the response status on the right. The status line includes the HTTP status code, reason phrase, response time in milliseconds, and body size formatted in human-readable units. + +## Features Overview + +| Feature | Key | Context | Description | +|---------|-----|---------|-------------| +| **Size Display** | *(automatic)* | Tab bar | Body size shown after duration | +| **Copy Content** | `c` | Navigation mode, response focused | Copy body or headers to clipboard | +| **Save to File** | `S` | Navigation mode, response focused | Save body or headers to a file | +| **Body Search** | `/` | Editing Normal mode, Body tab | Vim-style incremental search | +| **Search Navigation** | `n` / `N` | Editing Normal mode, Body tab | Next / previous match | +| **Case Toggle** | `Ctrl+I` | During search input | Toggle case sensitivity | + +## Response Size Display + +After a successful response, the tab bar displays the response body size alongside the status code and duration: + +``` +200 OK (245ms) · 1.2 KB +``` + +Size formatting uses 1024-based thresholds: + +| Size | Display | +|------|---------| +| 0 bytes | `0 B` | +| 512 bytes | `512 B` | +| 1536 bytes | `1.5 KB` | +| 2621440 bytes | `2.5 MB` | +| 1181116006 bytes | `1.1 GB` | + +The size reflects the raw (decompressed) response body as received from the server, matching what tools like Postman display. This is the wire size before any pretty-printing or formatting. + +**Narrow terminals:** When the response panel inner width is less than 50 columns, the size is hidden to prevent overlap with the tab labels. The status code and duration remain visible. + +**Non-success states:** Size is only shown for successful responses. Loading, error, cancelled, and empty states show their own status text without size information. + +## Copy Response Content + +Copy the currently visible tab content (body or headers) to the system clipboard with a single keystroke. + +### How to Copy + +1. Focus the response panel using `h`/`j`/`k`/`l` navigation in Navigation mode +2. Switch tabs if needed: press `Enter` to enter editing mode, then `H`/`L` to switch between Body and Headers, then `Esc` back to Navigation mode +3. Press `c` to copy + +### What Gets Copied + +| Active Tab | What is copied | Toast message | +|------------|---------------|---------------| +| Body | The formatted (pretty-printed) response body | `Copied response body (1.2 KB)` | +| Headers | The rendered header lines (`Key: Value` format) | `Copied response headers` | + +If no successful response exists (empty, loading, error, or cancelled state), pressing `c` shows the toast `No response to copy`. + +The toast message appears in the status bar for 2 seconds. + +### Example Workflow + +``` +1. Send request: Ctrl+R +2. Wait for response +3. Copy body: c -> Toast: "Copied response body (4.7 KB)" +4. Switch to headers: Enter -> L -> Esc +5. Copy headers: c -> Toast: "Copied response headers" +``` + +## Save Response to File + +Save the current tab content (body or headers) to a file on disk. + +### How to Save + +1. Focus the response panel in Navigation mode +2. Press `S` (uppercase) to open the save popup +3. Type a file path in the input field +4. Press `Enter` to write the file, or `Esc` to cancel + +### The Save Popup + +``` +┌─ Save Response ──────────────────────────────┐ +│ ~/output.json │ +└──────────────────────────────────────────────┘ +``` + +The popup appears centered on screen with a text input field. The input supports standard editing keys: + +| Key | Action | +|-----|--------| +| Characters | Insert at cursor position | +| `Backspace` | Delete character before cursor | +| `Delete` | Delete character at cursor | +| `Left` / `Right` | Move cursor | +| `Home` / `End` | Jump to start / end of input | +| `Enter` | Confirm and write file | +| `Esc` | Cancel and close popup | + +While the save popup is open, all other keybindings are suspended — no navigation or vim input can bleed through. + +### Path Resolution + +- **Relative paths** are resolved relative to the working directory where Perseus was launched +- **Tilde expansion** is supported: `~/output.json` resolves to `/Users/you/output.json` +- **Parent directories** must already exist; Perseus does not create intermediate directories + +### Success and Error Handling + +| Outcome | Toast message | +|---------|---------------| +| File written successfully | `Saved to ~/output.json (4.7 KB)` | +| Parent directory does not exist | `Save failed: directory does not exist` | +| Permission denied or OS error | `Save failed: Permission denied (os error 13)` | +| No successful response | `No response to save` | + +If the file already exists, it is overwritten silently (matching `curl -o` behavior). + +### What Gets Saved + +The content saved matches the active response tab: + +- **Body tab:** The formatted (pretty-printed) response body +- **Headers tab:** The rendered header lines in `Key: Value` format + +### Example Workflow + +``` +1. Send request: Ctrl+R +2. Focus response: l (or navigate with arrows) +3. Open save popup: S +4. Type path: ~/api-response.json +5. Confirm: Enter -> Toast: "Saved to ~/api-response.json (4.7 KB)" +``` + +## Response Body Search + +Vim-style `/` search with incremental highlighting and match navigation. Search lets you find text within large API response bodies without scrolling manually. + +### Opening the Search Bar + +1. Focus the response panel and enter editing mode: press `Enter` while on the response panel +2. Make sure you are on the **Body** tab (search is only available on the Body tab; `/` is a no-op on the Headers tab) +3. Press `/` in vim Normal mode + +The search bar appears at the bottom of the response content area, stealing one row from the content: + +``` +┌─ Response ──────────────────────────────────────┐ +│ Body | Headers 200 OK (245ms) · 1.2 KB │ +│ │ +│ { │ +│ "id": 1, │ +│ "name": "Perseus", │ +│ } │ +│ /Perseus [Aa] 1/3 │ <- Search bar +└──────────────────────────────────────────────────┘ +``` + +### Search Bar Layout + +``` +/ query text here [Aa] 3/17 +│ │ │ +│ │ └─ Match count (current/total) +│ └─ Case indicator +└─ Search input with / prefix +``` + +| Indicator | Meaning | +|-----------|---------| +| `[Aa]` | Case-insensitive search (default) | +| `[AA]` | Case-sensitive search | +| `3/17` | Currently on the 3rd match out of 17 total | +| `0/0` | No matches found | + +### Searching + +While the search bar is active: + +| Key | Action | +|-----|--------| +| Characters | Type to build the search query | +| `Backspace` | Delete character before cursor | +| `Delete` | Delete character at cursor | +| `Left` / `Right` | Move cursor within search input | +| `Enter` | Confirm search — close input bar, keep highlights and query | +| `Esc` | Cancel search — close input bar, clear query and highlights | +| `Ctrl+I` | Toggle case sensitivity and recompute matches | + +**Incremental search:** Matches are recomputed on every keystroke as you type. You see highlights update in real time without pressing Enter. + +### Match Highlighting + +Matches are highlighted directly over the response body content, preserving JSON syntax colors for non-matching regions: + +| Highlight | Color | Meaning | +|-----------|-------|---------| +| Yellow background, black text | All matches | Every occurrence of the search query | +| Light red background, black text | Current match | The match you are currently navigated to | + +### Navigating Matches + +After confirming a search with `Enter` (or while the search bar is active), use `n` and `N` in vim Normal mode to jump between matches: + +| Key | Action | +|-----|--------| +| `n` | Jump to the **next** match (wraps around to the first match after the last) | +| `N` | Jump to the **previous** match (wraps around to the last match from the first) | + +The response view auto-scrolls to bring the current match into the visible area. + +### Search Lifecycle + +``` + ┌──────────┐ + / │ Search │ Enter (non-empty) + ────────────> │ Active │ ──────────────────────┐ + └──────────┘ │ + │ v + Esc │ ┌──────────────┐ + or │ │ Highlights │ + Enter │ │ Visible │ + (empty) │ │ (n/N work) │ + │ └──────────────┘ + v │ + ┌──────────┐ Esc (nav mode) │ + │ No │ <─────────────────────┘ + │ Search │ or new response + └──────────┘ +``` + +1. **Search Active:** The search bar is visible, receiving keystrokes. Matches update on every keystroke. +2. **Highlights Visible:** The search bar shows the confirmed query and match count. `n`/`N` navigate between matches. Pressing `/` reopens the search input with the current query pre-filled. +3. **No Search:** No highlights, no search bar. Triggered by `Esc` during search, empty `Enter`, or a new response arriving. + +### Clearing Search + +Search state is automatically cleared when: + +- You press `Esc` while the search bar is active +- You press `Enter` with an empty query +- A new response arrives (new request sent) + +### Edge Cases + +- **Empty query + Enter:** Clears all search state (same as Esc) +- **No matches:** The search bar shows `0/0` and no highlights appear in the body +- **Headers tab:** `/` is a no-op when the Headers tab is active — search is only available on the Body tab +- **Large responses:** Match computation is O(n) per keystroke and runs on the formatted body text. For typical API responses under 100 KB, latency is negligible + +### Example Workflow + +``` +1. Send request: Ctrl+R +2. Enter response: Enter (vim Normal mode on response body) +3. Start search: / +4. Type query: error (highlights appear incrementally) +5. Confirm search: Enter (search bar shows "1/5") +6. Next match: n (jumps to match 2/5) +7. Next match: n (jumps to match 3/5) +8. Previous match: N (back to match 2/5) +9. New search: / (reopens with "error" pre-filled) +10. Clear and retype: Ctrl+A, then type "warning" +11. Cancel search: Esc (highlights cleared) +``` + +## Navigation and Editing + +### Focusing the Response Panel + +From Navigation mode, use directional keys to move focus to the response panel: + +| Key | Action | +|-----|--------| +| `h` / `j` / `k` / `l` | Move focus between panels | +| Arrow keys | Same as h/j/k/l | + +The response panel border turns green when focused. + +### Switching Response Tabs + +In Navigation mode, press `Enter` to enter editing mode, then: + +| Key | Action | +|-----|--------| +| `H` | Switch to the previous tab (Headers -> Body) | +| `L` | Switch to the next tab (Body -> Headers) | + +### Reading the Response + +In editing mode (vim Normal), you can navigate within the response body using standard vim motions: + +| Key | Action | +|-----|--------| +| `h` / `j` / `k` / `l` | Cursor movement | +| `w` / `b` / `e` | Word forward / back / end | +| `0` / `^` / `$` | Line start / first non-blank / line end | +| `gg` / `G` | Jump to top / bottom | +| `v` / `V` | Enter visual / visual line mode | + +The response body is **read-only** — insert mode and text modification commands are disabled. Visual mode works for selecting text to yank (copy). + +### Copying with Vim Yank + +In addition to the `c` one-key copy, you can use vim visual mode to copy specific selections: + +1. Enter editing mode: `Enter` +2. Enter visual mode: `v` (character) or `V` (line) +3. Move to expand selection: `h`/`j`/`k`/`l` or word motions +4. Yank to clipboard: `y` or `Cmd+C` / `Ctrl+C` +5. Exit: `Esc` + +## Keyboard Reference + +Quick reference for all response panel keybindings: + +### Navigation Mode (Response Focused) + +| Key | Action | +|-----|--------| +| `Enter` | Enter editing mode (vim Normal) | +| `i` | Enter editing mode (vim Normal for response) | +| `c` | Copy current tab content to clipboard | +| `S` | Open save-to-file popup | +| `h` / `j` / `k` / `l` | Move focus to adjacent panel | +| `?` | Toggle help overlay | + +### Editing Mode (Response Body) + +| Key | Mode | Action | +|-----|------|--------| +| `H` / `L` | Normal | Switch response tab (Body / Headers) | +| `/` | Normal (Body tab) | Open search bar | +| `n` | Normal (Body tab) | Jump to next search match | +| `N` | Normal (Body tab) | Jump to previous search match | +| `h` / `j` / `k` / `l` | Normal | Cursor movement | +| `w` / `b` / `e` | Normal | Word motions | +| `gg` / `G` | Normal | Jump to top / bottom | +| `v` / `V` | Normal | Enter visual / visual line mode | +| `y` | Visual | Yank selection to clipboard | +| `Cmd+C` / `Ctrl+C` | Visual | Copy selection to system clipboard | +| `Esc` | Any | Exit to navigation mode | + +### Search Bar (Active) + +| Key | Action | +|-----|--------| +| Characters | Type search query | +| `Backspace` / `Delete` | Delete character | +| `Left` / `Right` | Move cursor within input | +| `Enter` | Confirm search, close input, keep highlights | +| `Esc` | Cancel search, clear query and highlights | +| `Ctrl+I` | Toggle case sensitivity | + +### Save Popup + +| Key | Action | +|-----|--------| +| Characters | Type file path | +| `Backspace` / `Delete` | Delete character | +| `Left` / `Right` | Move cursor | +| `Home` / `End` | Jump to start / end | +| `Enter` | Write file and close | +| `Esc` | Cancel and close | diff --git a/src/app.rs b/src/app.rs index 8c14f8c..ee831ad 100644 --- a/src/app.rs +++ b/src/app.rs @@ -94,12 +94,35 @@ pub struct ResponseData { pub status_text: String, pub headers: Vec<(String, String)>, pub body: String, + pub body_size_bytes: usize, pub duration_ms: u64, } -fn is_json_like(headers: &[(String, String)], body: &str) -> bool { +pub fn format_size(bytes: usize) -> String { + if bytes < 1024 { + return format!("{} B", bytes); + } + let kb = bytes as f64 / 1024.0; + if kb < 1024.0 { + return format!("{:.1} KB", kb); + } + let mb = kb / 1024.0; + if mb < 1024.0 { + return format!("{:.1} MB", mb); + } + let gb = mb / 1024.0; + format!("{:.1} GB", gb) +} + +/// Checks whether the given headers and body represent JSON content. +/// +/// Returns `true` if either: +/// - A `Content-Type` header contains `application/json` (case-insensitive), or +/// - The trimmed body starts/ends with `{}` or `[]` (structural sniffing). +pub fn is_json_content(headers: &[(String, String)], body: &str) -> bool { let has_json_content_type = headers.iter().any(|(k, v)| { - k.eq_ignore_ascii_case("content-type") && v.to_ascii_lowercase().contains("application/json") + k.eq_ignore_ascii_case("content-type") + && v.to_ascii_lowercase().contains("application/json") }); if has_json_content_type { return true; @@ -110,7 +133,7 @@ fn is_json_like(headers: &[(String, String)], body: &str) -> bool { } fn format_json_if_possible(headers: &[(String, String)], body: &str) -> String { - if !is_json_like(headers, body) { + if !is_json_content(headers, body) { return body.to_string(); } match serde_json::from_str::(body) { @@ -199,10 +222,14 @@ impl Method { Method::Custom(s) => s.as_str(), } } +} - pub fn from_str(value: &str) -> Self { - let upper = value.to_uppercase(); - match upper.as_str() { +impl std::str::FromStr for Method { + type Err = std::convert::Infallible; + + fn from_str(s: &str) -> Result { + let upper = s.to_uppercase(); + Ok(match upper.as_str() { "GET" => Method::Standard(HttpMethod::Get), "POST" => Method::Standard(HttpMethod::Post), "PUT" => Method::Standard(HttpMethod::Put), @@ -211,9 +238,8 @@ impl Method { "HEAD" => Method::Standard(HttpMethod::Head), "OPTIONS" => Method::Standard(HttpMethod::Options), _ => Method::Custom(upper), - } + }) } - } impl From for Method { @@ -399,7 +425,6 @@ pub struct KvFocus { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -#[allow(dead_code)] pub enum Panel { Sidebar, #[default] @@ -436,13 +461,28 @@ pub struct TextInput { impl TextInput { pub fn new(value: String) -> Self { Self { - cursor: value.len(), + cursor: value.chars().count(), value, } } + /// Convert the character-based cursor index to a byte offset in the string. + pub fn byte_offset(&self) -> usize { + self.value + .char_indices() + .nth(self.cursor) + .map(|(i, _)| i) + .unwrap_or(self.value.len()) + } + + /// Return the number of characters in the value. + pub fn char_count(&self) -> usize { + self.value.chars().count() + } + pub fn insert_char(&mut self, ch: char) { - self.value.insert(self.cursor, ch); + let byte_pos = self.byte_offset(); + self.value.insert(byte_pos, ch); self.cursor += 1; } @@ -451,14 +491,16 @@ impl TextInput { return; } self.cursor -= 1; - self.value.remove(self.cursor); + let byte_pos = self.byte_offset(); + self.value.remove(byte_pos); } pub fn delete(&mut self) { - if self.cursor >= self.value.len() { + if self.cursor >= self.char_count() { return; } - self.value.remove(self.cursor); + let byte_pos = self.byte_offset(); + self.value.remove(byte_pos); } pub fn move_left(&mut self) { @@ -468,7 +510,7 @@ impl TextInput { } pub fn move_right(&mut self) { - if self.cursor < self.value.len() { + if self.cursor < self.char_count() { self.cursor += 1; } } @@ -531,6 +573,218 @@ impl SidebarCache { } } +#[derive(Debug, Clone)] +pub struct SearchMatch { + pub line_index: usize, + pub byte_start: usize, + pub byte_end: usize, +} + +pub struct ResponseSearch { + pub active: bool, + pub query: String, + pub input: TextInput, + pub matches: Vec, + pub current_match: usize, + pub case_sensitive: bool, + pub generation: u64, + /// Cache key: body generation at last computation + cached_body_generation: u64, + /// Cache key: query string at last computation + cached_query: String, + /// Cache key: case_sensitive flag at last computation + cached_case_sensitive: bool, +} + +impl ResponseSearch { + fn new() -> Self { + Self { + active: false, + query: String::new(), + input: TextInput::new(String::new()), + matches: Vec::new(), + current_match: 0, + case_sensitive: false, + generation: 0, + cached_body_generation: u64::MAX, + cached_query: String::new(), + cached_case_sensitive: false, + } + } + + fn clear(&mut self) { + self.active = false; + self.query.clear(); + self.input = TextInput::new(String::new()); + self.matches.clear(); + self.current_match = 0; + self.cached_body_generation = u64::MAX; + self.cached_query.clear(); + self.generation = self.generation.wrapping_add(1); + } + + /// Compute search matches using byte offsets from the original text. + /// + /// Skips recomputation when the body, query, and case-sensitivity haven't + /// changed since the last call (fixes per-keystroke allocation for large + /// bodies). Uses char-aware comparison so byte offsets are always valid + /// against the original text, even for Unicode chars whose byte length + /// changes under `to_lowercase()` (e.g. German sharp-s). + fn compute_matches(&mut self, text: &str, body_generation: u64) { + let query_owned = self.input.value.clone(); + let query = query_owned.as_str(); + if self.cached_body_generation == body_generation + && self.cached_case_sensitive == self.case_sensitive + && self.cached_query == query + { + return; + } + self.matches.clear(); + self.current_match = 0; + self.cached_body_generation = body_generation; + self.cached_case_sensitive = self.case_sensitive; + self.cached_query.clear(); + self.cached_query.push_str(query); + if query.is_empty() { + self.generation = self.generation.wrapping_add(1); + return; + } + // Build a line-start byte-offset table for mapping absolute byte + // positions into (line_index, offset_within_line) pairs. + let mut line_start: usize = 0; + let lines: Vec<&str> = text.split('\n').collect(); + let mut line_byte_starts: Vec = Vec::with_capacity(lines.len()); + for line in &lines { + line_byte_starts.push(line_start); + line_start += line.len() + 1; // +1 for '\n' + } + + if self.case_sensitive { + // Case-sensitive: plain byte-string search on original text. + // No allocation needed -- we search directly on the borrowed text. + let query_len = query.len(); + let mut start: usize = 0; + while start + query_len <= text.len() { + if let Some(pos) = text[start..].find(query) { + let abs_pos = start + pos; + let line_index = match line_byte_starts.binary_search(&abs_pos) { + Ok(i) => i, + Err(i) => i.saturating_sub(1), + }; + let line_offset = abs_pos - line_byte_starts[line_index]; + self.matches.push(SearchMatch { + line_index, + byte_start: line_offset, + byte_end: line_offset + query_len, + }); + start = abs_pos + 1; + } else { + break; + } + } + } else { + // Case-insensitive: char-aware matching that records byte offsets + // from the *original* text. This avoids the to_lowercase() + // byte-length mismatch where e.g. the German sharp-s (2 bytes) + // lowercases to "ss" (2 bytes, different chars) causing offset + // drift between the lowercased copy and the original. + // + // Strategy: flatten both text and query into sequences of + // (lowercased_char, source_byte, source_byte_len) entries, then + // slide a window over the text sequence comparing lowercased chars. + // Byte ranges are derived from the original text positions. + let query_lower: Vec = + query.chars().flat_map(|c| c.to_lowercase()).collect(); + if query_lower.is_empty() { + self.generation = self.generation.wrapping_add(1); + return; + } + + // Build flat sequence: each lowercased char maps back to its + // source char's byte position and byte length in the original text. + // Tuple: (lowercased_char, source_byte_offset, source_char_byte_len) + let mut flat: Vec<(char, usize, usize)> = Vec::with_capacity(text.len()); + for (byte_idx, ch) in text.char_indices() { + let src_len = ch.len_utf8(); + for lc in ch.to_lowercase() { + flat.push((lc, byte_idx, src_len)); + } + } + + let qlen = query_lower.len(); + let flen = flat.len(); + if qlen > flen { + self.generation = self.generation.wrapping_add(1); + return; + } + + let mut i: usize = 0; + while i + qlen <= flen { + let mut matched = true; + for j in 0..qlen { + if flat[i + j].0 != query_lower[j] { + matched = false; + break; + } + } + if matched { + // Byte range in original text: from the source byte of the + // first matched entry to the end of the source char of the + // last matched entry. + let match_byte_start = flat[i].1; + let last = &flat[i + qlen - 1]; + let match_byte_end = last.1 + last.2; + + let line_index = + match line_byte_starts.binary_search(&match_byte_start) { + Ok(idx) => idx, + Err(idx) => idx.saturating_sub(1), + }; + let line_offset = + match_byte_start - line_byte_starts[line_index]; + let byte_end_in_line = + match_byte_end - line_byte_starts[line_index]; + + self.matches.push(SearchMatch { + line_index, + byte_start: line_offset, + byte_end: byte_end_in_line, + }); + } + + // Advance to the next original-char boundary in the flat + // sequence to allow overlapping matches starting at different + // source characters. + let cur_src = flat[i].1; + i += 1; + while i < flen && flat[i].1 == cur_src { + i += 1; + } + } + } + + self.generation = self.generation.wrapping_add(1); + } + + fn next_match(&mut self) { + if !self.matches.is_empty() { + self.current_match = (self.current_match + 1) % self.matches.len(); + self.generation = self.generation.wrapping_add(1); + } + } + + fn prev_match(&mut self) { + if !self.matches.is_empty() { + self.current_match = if self.current_match == 0 { + self.matches.len() - 1 + } else { + self.current_match - 1 + }; + self.generation = self.generation.wrapping_add(1); + } + } +} + pub struct RequestState { pub method: Method, pub url_editor: TextArea<'static>, @@ -666,76 +920,6 @@ impl RequestState { self.body_binary_path_editor.lines().join("") } - pub fn build_body_content(&self) -> http::BodyContent { - match self.body_mode { - BodyMode::Raw => { - let text = self.body_text(); - if text.trim().is_empty() { - http::BodyContent::None - } else { - http::BodyContent::Raw(text) - } - } - BodyMode::Json => { - let text = self.body_text(); - if text.trim().is_empty() { - http::BodyContent::None - } else { - http::BodyContent::Json(text) - } - } - BodyMode::Xml => { - let text = self.body_text(); - if text.trim().is_empty() { - http::BodyContent::None - } else { - http::BodyContent::Xml(text) - } - } - BodyMode::FormUrlEncoded => { - let pairs: Vec<(String, String)> = self - .body_form_pairs - .iter() - .filter(|p| p.enabled && !(p.key.is_empty() && p.value.is_empty())) - .map(|p| (p.key.clone(), p.value.clone())) - .collect(); - if pairs.is_empty() { - http::BodyContent::None - } else { - http::BodyContent::FormUrlEncoded(pairs) - } - } - BodyMode::Multipart => { - let parts: Vec = self - .body_multipart_fields - .iter() - .filter(|f| f.enabled && !f.key.is_empty()) - .map(|f| http::MultipartPart { - key: f.key.clone(), - value: f.value.clone(), - field_type: match f.field_type { - MultipartFieldType::Text => http::MultipartPartType::Text, - MultipartFieldType::File => http::MultipartPartType::File, - }, - }) - .collect(); - if parts.is_empty() { - http::BodyContent::None - } else { - http::BodyContent::Multipart(parts) - } - } - BodyMode::Binary => { - let path = self.body_binary_path_text(); - if path.trim().is_empty() { - http::BodyContent::None - } else { - http::BodyContent::Binary(path) - } - } - } - } - pub fn auth_token_text(&self) -> String { self.auth_token_editor.lines().join("") } @@ -756,24 +940,6 @@ impl RequestState { self.auth_key_value_editor.lines().join("") } - pub fn build_auth_config(&self) -> http::AuthConfig { - match self.auth_type { - AuthType::NoAuth => http::AuthConfig::NoAuth, - AuthType::Bearer => http::AuthConfig::Bearer { - token: self.auth_token_text(), - }, - AuthType::Basic => http::AuthConfig::Basic { - username: self.auth_username_text(), - password: self.auth_password_text(), - }, - AuthType::ApiKey => http::AuthConfig::ApiKey { - key: self.auth_key_name_text(), - value: self.auth_key_value_text(), - location: self.api_key_location, - }, - } - } - pub fn active_editor( &mut self, field: RequestField, @@ -826,6 +992,12 @@ pub(crate) struct ResponseBodyRenderCache { pub(crate) is_json: bool, pub(crate) lines: Vec>, pub(crate) wrap_cache: WrapCache, + /// Cached lines with search highlights applied. Avoids cloning all lines + /// every frame when search is active. Invalidated by `highlight_search_gen`. + pub(crate) highlighted_lines: Vec>, + /// The search generation that produced `highlighted_lines`. When this differs + /// from `ResponseSearch::generation`, the highlight cache is stale. + pub(crate) highlight_search_gen: u64, } impl ResponseBodyRenderCache { @@ -837,6 +1009,8 @@ impl ResponseBodyRenderCache { is_json: false, lines: Vec::new(), wrap_cache: WrapCache::new(), + highlighted_lines: Vec::new(), + highlight_search_gen: 0, } } } @@ -862,51 +1036,55 @@ impl ResponseHeadersRenderCache { pub struct App { running: bool, dirty: bool, - pub config: Config, - pub request: RequestState, - pub focus: FocusState, - pub response: ResponseStatus, - pub response_tab: ResponseTab, - pub request_tab: RequestTab, - pub client: Client, - pub app_mode: AppMode, - pub vim: Vim, - pub response_scroll: u16, - pub loading_tick: u8, - pub show_help: bool, - pub show_method_popup: bool, - pub method_popup_index: usize, - pub method_popup_custom_mode: bool, - pub method_custom_input: String, - pub show_auth_type_popup: bool, - pub auth_type_popup_index: usize, - pub sidebar_visible: bool, - pub sidebar_width: u16, - pub collection: CollectionStore, - pub project_list: Vec, - pub sidebar_tree: ProjectTree, - pub sidebar: SidebarState, + config: Config, + pub(crate) request: RequestState, + pub(crate) focus: FocusState, + pub(crate) response: ResponseStatus, + pub(crate) response_tab: ResponseTab, + pub(crate) request_tab: RequestTab, + client: Client, + pub(crate) app_mode: AppMode, + pub(crate) vim: Vim, + pub(crate) response_scroll: u16, + pub(crate) loading_tick: u8, + pub(crate) show_help: bool, + pub(crate) show_method_popup: bool, + pub(crate) method_popup_index: usize, + pub(crate) method_popup_custom_mode: bool, + pub(crate) method_custom_input: String, + pub(crate) show_auth_type_popup: bool, + pub(crate) auth_type_popup_index: usize, + pub(crate) sidebar_visible: bool, + pub(crate) sidebar_width: u16, + collection: CollectionStore, + pub(crate) project_list: Vec, + pub(crate) sidebar_tree: ProjectTree, + pub(crate) sidebar: SidebarState, sidebar_cache: SidebarCache, - pub active_project_id: Uuid, - pub current_request_id: Option, - pub request_dirty: bool, + pub(crate) active_project_id: Uuid, + current_request_id: Option, + request_dirty: bool, clipboard_toast: Option<(String, Instant)>, request_handle: Option, clipboard: ClipboardProvider, last_yank_request: String, last_yank_response: String, last_yank_response_headers: String, - pub response_editor: TextArea<'static>, - pub response_headers_editor: TextArea<'static>, + pub(crate) response_editor: TextArea<'static>, + pub(crate) response_headers_editor: TextArea<'static>, pub(crate) response_body_cache: ResponseBodyRenderCache, pub(crate) response_headers_cache: ResponseHeadersRenderCache, - pub environments: Vec, - pub active_environment_name: Option, - pub show_env_popup: bool, - pub env_popup_index: usize, - pub show_body_mode_popup: bool, - pub body_mode_popup_index: usize, - pub kv_edit_textarea: Option>, + pub(crate) environments: Vec, + pub(crate) active_environment_name: Option, + pub(crate) show_env_popup: bool, + pub(crate) env_popup_index: usize, + pub(crate) show_body_mode_popup: bool, + pub(crate) body_mode_popup_index: usize, + pub(crate) kv_edit_textarea: Option>, + pub(crate) save_popup: Option, + pub(crate) response_search: ResponseSearch, + /// Actual height (in rows) of the response content area, updated each render frame. + pub(crate) response_viewport_height: u16, } impl App { @@ -1094,6 +1272,9 @@ impl App { show_body_mode_popup: false, body_mode_popup_index: 0, kv_edit_textarea: None, + save_popup: None, + response_search: ResponseSearch::new(), + response_viewport_height: 20, }; if let Some(request_id) = created_request_id { @@ -1352,7 +1533,7 @@ impl App { let method = if node.kind == NodeKind::Request { node.request_method .as_deref() - .map(Method::from_str) + .map(|s| s.parse::().unwrap()) } else { None }; @@ -1389,7 +1570,7 @@ impl App { let method = if node.kind == NodeKind::Request { node.request_method .as_deref() - .map(Method::from_str) + .map(|s| s.parse::().unwrap()) } else { None }; @@ -1628,7 +1809,7 @@ impl App { .get_item(request_id) .and_then(|item| item.request.clone()); if let Some(request) = request_data { - let method = Method::from_str(&request.method); + let method = request.method.parse::().unwrap(); let url = extract_url(&request.url); let headers = headers_to_text(&request.header); let raw_body = request @@ -2147,13 +2328,157 @@ impl App { return; }; let path = self.sidebar_tree.path_for(id).join("/"); - if let Err(_) = self.clipboard.set_text(path) { + if self.clipboard.set_text(path).is_err() { self.set_clipboard_toast("Clipboard write failed"); } else { self.set_clipboard_toast("Copied path"); } } + fn scroll_to_search_match(&mut self) { + if let Some(m) = self.response_search.matches.get(self.response_search.current_match) { + // Approximate: set scroll so the match line is visible + // The wrap cache maps logical lines to visual lines, but we don't have + // access to it here. Use the logical line_index as an approximation. + let target_line = m.line_index as u16; + let viewport_height = self.response_viewport_height.max(1); + // If target is not visible, scroll to center it in the viewport + if target_line < self.response_scroll + || target_line >= self.response_scroll + viewport_height + { + self.response_scroll = target_line.saturating_sub(viewport_height / 3); + } + // Invalidate wrap cache to force re-render with highlight changes + self.response_body_cache.wrap_cache.generation = 0; + } + } + + fn copy_response_content(&mut self) { + let body_size = match &self.response { + ResponseStatus::Success(data) => data.body_size_bytes, + _ => { + self.set_clipboard_toast("No response to copy"); + return; + } + }; + let (text, label) = match self.response_tab { + ResponseTab::Body => { + let body = self.response_editor.lines().join("\n"); + let size = format_size(body_size); + (body, format!("Copied response body ({})", size)) + } + ResponseTab::Headers => { + let headers = self.response_headers_editor.lines().join("\n"); + (headers, "Copied response headers".to_string()) + } + }; + if self.clipboard.set_text(text).is_err() { + self.set_clipboard_toast("Clipboard write failed"); + } else { + self.set_clipboard_toast(label); + } + } + + fn save_response_to_file(&mut self, raw_path: &str) { + let trimmed = raw_path.trim(); + if trimmed.is_empty() { + self.set_clipboard_toast("Save failed: empty path"); + return; + } + + // Expand tilde: handle both "~" alone and "~/..." prefix + let path_str = if trimmed == "~" { + match std::env::var("HOME") { + Ok(home) => home, + Err(_) => { + self.set_clipboard_toast("Save failed: could not resolve home directory"); + return; + } + } + } else if let Some(rest) = trimmed.strip_prefix("~/") { + match std::env::var("HOME") { + Ok(home) => format!("{}/{}", home, rest), + Err(_) => { + self.set_clipboard_toast("Save failed: could not resolve home directory"); + return; + } + } + } else { + trimmed.to_string() + }; + + let path = std::path::PathBuf::from(&path_str); + + // Reject paths containing traversal components + for component in path.components() { + if matches!(component, std::path::Component::ParentDir) { + self.set_clipboard_toast("Save failed: path must not contain '..' traversal"); + return; + } + } + + // Resolve to an absolute path so we can validate the final location + let resolved = if path.is_absolute() { + path.clone() + } else { + match std::env::current_dir() { + Ok(cwd) => cwd.join(&path), + Err(_) => { + self.set_clipboard_toast("Save failed: could not determine working directory"); + return; + } + } + }; + + // Validate parent directory exists + if let Some(parent) = resolved.parent() { + if !parent.as_os_str().is_empty() && !parent.exists() { + self.set_clipboard_toast("Save failed: directory does not exist"); + return; + } + } + + // Canonicalize the parent to catch symlink-based traversal, then re-append filename + let canonical_path = if let Some(parent) = resolved.parent() { + if parent.as_os_str().is_empty() { + resolved.clone() + } else { + match parent.canonicalize() { + Ok(canon_parent) => match resolved.file_name() { + Some(name) => canon_parent.join(name), + None => canon_parent, + }, + Err(err) => { + self.set_clipboard_toast(format!("Save failed: {}", err)); + return; + } + } + } + } else { + resolved.clone() + }; + + if !matches!(self.response, ResponseStatus::Success(_)) { + self.set_clipboard_toast("No response to save"); + return; + } + + let content = match self.response_tab { + ResponseTab::Body => self.response_editor.lines().join("\n"), + ResponseTab::Headers => self.response_headers_editor.lines().join("\n"), + }; + + match std::fs::write(&canonical_path, &content) { + Ok(_) => { + let size = format_size(content.len()); + self.set_clipboard_toast(format!("Saved to {} ({})", trimmed, size)); + } + Err(err) => { + self.set_clipboard_toast(format!("Save failed: {}", err)); + } + } + } + fn sidebar_expand_or_open(&mut self) { let Some(node) = self.sidebar_selected_node() else { return; @@ -2339,6 +2664,57 @@ impl App { } } + /// Toggle the environment quick-switch popup, closing any other open popups first. + /// If the popup is being opened, pre-selects the currently active environment. + fn toggle_env_popup(&mut self) { + self.show_method_popup = false; + self.show_auth_type_popup = false; + self.show_body_mode_popup = false; + self.show_env_popup = !self.show_env_popup; + if self.show_env_popup { + self.env_popup_index = self + .active_environment_name + .as_ref() + .and_then(|name| self.environments.iter().position(|e| e.name == *name)) + .map(|i| i + 1) + .unwrap_or(0); + } + self.dirty = true; + } + + /// Handle keyboard input when the environment popup is open. + /// Returns `true` if the key was consumed by the popup, `false` otherwise. + fn handle_env_popup_input(&mut self, key: KeyEvent) -> bool { + if !self.show_env_popup { + return false; + } + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + let count = self.environments.len() + 1; // +1 for "No Environment" + self.env_popup_index = (self.env_popup_index + 1) % count; + } + KeyCode::Char('k') | KeyCode::Up => { + let count = self.environments.len() + 1; + self.env_popup_index = + (self.env_popup_index + count - 1) % count; + } + KeyCode::Enter => { + self.active_environment_name = if self.env_popup_index == 0 { + None + } else { + Some(self.environments[self.env_popup_index - 1].name.clone()) + }; + self.show_env_popup = false; + } + KeyCode::Esc | KeyCode::Char('q') => { + self.show_env_popup = false; + } + _ => {} + } + self.dirty = true; + true + } + fn sync_clipboard_from_active_yank(&mut self) { let mut new_yank: Option = None; match self.focus.panel { @@ -2371,7 +2747,7 @@ impl App { } if let Some(yank) = new_yank { - if let Err(_) = self.clipboard.set_text(yank) { + if self.clipboard.set_text(yank).is_err() { self.set_clipboard_toast("Clipboard write failed"); } } @@ -2530,7 +2906,7 @@ impl App { if let Some(text) = yank { self.update_last_yank(target, text.clone()); - if let Err(_) = self.clipboard.set_text(text) { + if self.clipboard.set_text(text).is_err() { self.set_clipboard_toast("Clipboard write failed"); } } @@ -2761,6 +3137,7 @@ impl App { self.response_body_cache.dirty = true; self.response_headers_cache.dirty = true; } + self.response_search.clear(); self.dirty = true; } self.request_handle = None; @@ -2842,31 +3219,7 @@ impl App { } // Handle environment popup when open - if self.show_env_popup { - match key.code { - KeyCode::Char('j') | KeyCode::Down => { - let count = self.environments.len() + 1; // +1 for "No Environment" - self.env_popup_index = (self.env_popup_index + 1) % count; - } - KeyCode::Char('k') | KeyCode::Up => { - let count = self.environments.len() + 1; - self.env_popup_index = - (self.env_popup_index + count - 1) % count; - } - KeyCode::Enter => { - self.active_environment_name = if self.env_popup_index == 0 { - None - } else { - Some(self.environments[self.env_popup_index - 1].name.clone()) - }; - self.show_env_popup = false; - } - KeyCode::Esc | KeyCode::Char('q') => { - self.show_env_popup = false; - } - _ => {} - } - self.dirty = true; + if self.handle_env_popup_input(key) { return; } @@ -2951,6 +3304,26 @@ impl App { return; } + // Handle save popup when open + if let Some(ref mut input) = self.save_popup { + match key.code { + KeyCode::Enter => { + let path = input.value.clone(); + self.save_popup = None; + if !path.trim().is_empty() { + self.save_response_to_file(path.trim()); + } + } + KeyCode::Esc => { + self.save_popup = None; + } + _ => { + handle_text_input(input, key); + } + } + return; + } + if self.sidebar.popup.is_some() { self.handle_sidebar_popup(key); return; @@ -3031,19 +3404,7 @@ impl App { // Ctrl+N: environment quick-switch popup if key.code == KeyCode::Char('n') && key.modifiers.contains(KeyModifiers::CONTROL) { - self.show_method_popup = false; - self.show_auth_type_popup = false; - self.show_body_mode_popup = false; - self.show_env_popup = !self.show_env_popup; - if self.show_env_popup { - self.env_popup_index = self - .active_environment_name - .as_ref() - .and_then(|name| self.environments.iter().position(|e| e.name == *name)) - .map(|i| i + 1) - .unwrap_or(0); - } - self.dirty = true; + self.toggle_env_popup(); return; } @@ -3161,6 +3522,25 @@ impl App { _ => {} } + // Response-specific shortcuts + if in_response && key.modifiers.is_empty() { + match key.code { + KeyCode::Char('c') => { + self.copy_response_content(); + return; + } + KeyCode::Char('S') => { + if matches!(self.response, ResponseStatus::Success(_)) { + self.save_popup = Some(TextInput::new(String::new())); + } else { + self.set_clipboard_toast("No response to save"); + } + return; + } + _ => {} + } + } + match key.code { KeyCode::Char('?') => { self.show_help = !self.show_help; @@ -3214,11 +3594,10 @@ impl App { self.app_mode = AppMode::Sidebar; } else if in_request && self.focus.request_field == RequestField::Body { self.handle_body_enter(); - } else if in_request && self.is_editable_field() { - self.enter_editing(VimMode::Insert); } else if in_request - && self.focus.request_field == RequestField::Auth - && self.is_auth_text_field() + && (self.is_editable_field() + || (self.focus.request_field == RequestField::Auth + && self.is_auth_text_field())) { self.enter_editing(VimMode::Insert); } else if in_response @@ -3244,21 +3623,14 @@ impl App { return; } + // Handle environment popup when open + if self.handle_env_popup_input(key) { + return; + } + // Ctrl+N: environment quick-switch popup from sidebar mode if key.code == KeyCode::Char('n') && key.modifiers.contains(KeyModifiers::CONTROL) { - self.show_method_popup = false; - self.show_auth_type_popup = false; - self.show_body_mode_popup = false; - self.show_env_popup = !self.show_env_popup; - if self.show_env_popup { - self.env_popup_index = self - .active_environment_name - .as_ref() - .and_then(|name| self.environments.iter().position(|e| e.name == *name)) - .map(|i| i + 1) - .unwrap_or(0); - } - self.dirty = true; + self.toggle_env_popup(); return; } @@ -3286,6 +3658,11 @@ impl App { key: KeyEvent, tx: mpsc::Sender>, ) { + // Handle environment popup when open + if self.handle_env_popup_input(key) { + return; + } + // Ctrl+S: save current request if key.code == KeyCode::Char('s') && key.modifiers.contains(KeyModifiers::CONTROL) { if let Some(request_id) = self.current_request_id { @@ -3310,19 +3687,7 @@ impl App { // Ctrl+N: environment quick-switch popup, even in editing mode if key.code == KeyCode::Char('n') && key.modifiers.contains(KeyModifiers::CONTROL) { - self.show_method_popup = false; - self.show_auth_type_popup = false; - self.show_body_mode_popup = false; - self.show_env_popup = !self.show_env_popup; - if self.show_env_popup { - self.env_popup_index = self - .active_environment_name - .as_ref() - .and_then(|name| self.environments.iter().position(|e| e.name == *name)) - .map(|i| i + 1) - .unwrap_or(0); - } - self.dirty = true; + self.toggle_env_popup(); return; } @@ -3379,13 +3744,75 @@ impl App { } } + // Response search: intercept keys when search bar is active + if is_response && self.response_search.active { + match key.code { + KeyCode::Enter => { + self.response_search.active = false; + if self.response_search.input.value.is_empty() { + // Empty Enter: clear search + self.response_search.clear(); + } else { + self.response_search.query = self.response_search.input.value.clone(); + } + } + KeyCode::Esc => { + self.response_search.clear(); + } + KeyCode::Char('i') + if key.modifiers.contains(KeyModifiers::CONTROL) => + { + self.response_search.case_sensitive = !self.response_search.case_sensitive; + let body_text = self.response_body_cache.body_text.clone(); + let gen = self.response_body_cache.generation; + self.response_search.compute_matches(&body_text, gen); + } + _ => { + handle_text_input(&mut self.response_search.input, key); + let body_text = self.response_body_cache.body_text.clone(); + let gen = self.response_body_cache.generation; + self.response_search.compute_matches(&body_text, gen); + } + } + // Auto-scroll to current match + self.scroll_to_search_match(); + return; + } + + // Response search: '/' activates search, 'n'/'N' navigate matches + if is_response + && self.response_tab == ResponseTab::Body + && self.vim.mode == VimMode::Normal + && key.modifiers.is_empty() + { + match key.code { + KeyCode::Char('/') => { + self.response_search.active = true; + self.response_search.input = TextInput::new( + self.response_search.query.clone(), + ); + self.response_search.input.cursor = self.response_search.input.char_count(); + return; + } + KeyCode::Char('n') if !self.response_search.query.is_empty() => { + self.response_search.next_match(); + self.scroll_to_search_match(); + return; + } + KeyCode::Char('N') if !self.response_search.query.is_empty() => { + self.response_search.prev_match(); + self.scroll_to_search_match(); + return; + } + _ => {} + } + } + let is_clipboard_modifier = key.modifiers.contains(KeyModifiers::CONTROL) || key.modifiers.contains(KeyModifiers::SUPER); - if is_request { - if key.code != KeyCode::Esc { - self.request_dirty = true; - } + if is_request && key.code != KeyCode::Esc { + self.request_dirty = true; } if is_clipboard_modifier && matches!(key.code, KeyCode::Char('v') | KeyCode::Char('V')) { @@ -3482,13 +3909,15 @@ impl App { } else if let Some(textarea) = self.kv_edit_textarea.as_mut() { self.vim = std::mem::replace(&mut self.vim, Vim::new(VimMode::Normal)) .apply_transition(Transition::Mode(new_mode), textarea); - } else { - let textarea = self - .request - .active_editor(self.focus.request_field, self.focus.body_field) - .unwrap(); + } else if let Some(textarea) = self + .request + .active_editor(self.focus.request_field, self.focus.body_field) + { self.vim = std::mem::replace(&mut self.vim, Vim::new(VimMode::Normal)) .apply_transition(Transition::Mode(new_mode), textarea); + } else { + self.exit_editing(); + return; } self.update_terminal_cursor(); self.sync_clipboard_from_active_yank(); @@ -3511,13 +3940,14 @@ impl App { } else if let Some(textarea) = self.kv_edit_textarea.as_mut() { self.vim = std::mem::replace(&mut self.vim, Vim::new(VimMode::Normal)) .apply_transition(Transition::Pending(pending_input), textarea); - } else { - let textarea = self - .request - .active_editor(self.focus.request_field, self.focus.body_field) - .unwrap(); + } else if let Some(textarea) = self + .request + .active_editor(self.focus.request_field, self.focus.body_field) + { self.vim = std::mem::replace(&mut self.vim, Vim::new(VimMode::Normal)) .apply_transition(Transition::Pending(pending_input), textarea); + } else { + self.exit_editing(); } } Transition::Nop => {} @@ -4311,7 +4741,7 @@ impl App { if self.focus.request_field == RequestField::Auth { self.active_auth_editor() } else { - self.active_request_editor() + self.request.active_editor(self.focus.request_field, self.focus.body_field) } } } @@ -4397,7 +4827,7 @@ fn handle_text_input(input: &mut TextInput, key: KeyEvent) { KeyCode::Left => input.move_left(), KeyCode::Right => input.move_right(), KeyCode::Home => input.cursor = 0, - KeyCode::End => input.cursor = input.value.len(), + KeyCode::End => input.cursor = input.char_count(), _ => {} } } @@ -4426,3 +4856,666 @@ fn parse_add_path(raw: &str) -> (Vec, Option) { (folders, request) } } + +#[cfg(test)] +mod tests { + use super::*; + + // ----------------------------------------------------------------------- + // format_size + // ----------------------------------------------------------------------- + + #[test] + fn format_size_zero_bytes() { + assert_eq!(format_size(0), "0 B"); + } + + #[test] + fn format_size_small_bytes() { + assert_eq!(format_size(1), "1 B"); + assert_eq!(format_size(500), "500 B"); + assert_eq!(format_size(1023), "1023 B"); + } + + #[test] + fn format_size_exact_one_kb() { + assert_eq!(format_size(1024), "1.0 KB"); + } + + #[test] + fn format_size_fractional_kb() { + assert_eq!(format_size(1536), "1.5 KB"); + } + + #[test] + fn format_size_large_kb() { + // 500 KB = 512000 bytes + assert_eq!(format_size(512_000), "500.0 KB"); + } + + #[test] + fn format_size_exact_one_mb() { + assert_eq!(format_size(1_048_576), "1.0 MB"); + } + + #[test] + fn format_size_fractional_mb() { + // 1.5 MB = 1_572_864 bytes + assert_eq!(format_size(1_572_864), "1.5 MB"); + } + + #[test] + fn format_size_exact_one_gb() { + assert_eq!(format_size(1_073_741_824), "1.0 GB"); + } + + #[test] + fn format_size_fractional_gb() { + // 2.5 GB = 2_684_354_560 bytes + assert_eq!(format_size(2_684_354_560), "2.5 GB"); + } + + #[test] + fn format_size_boundary_below_kb() { + // 1023 bytes is still in the bytes range + assert_eq!(format_size(1023), "1023 B"); + } + + // ----------------------------------------------------------------------- + // is_json_content + // ----------------------------------------------------------------------- + + #[test] + fn is_json_content_with_json_content_type() { + let headers = vec![ + ("Content-Type".to_string(), "application/json".to_string()), + ]; + assert!(is_json_content(&headers, "")); + } + + #[test] + fn is_json_content_with_json_content_type_charset() { + let headers = vec![( + "Content-Type".to_string(), + "application/json; charset=utf-8".to_string(), + )]; + assert!(is_json_content(&headers, "not json body")); + } + + #[test] + fn is_json_content_case_insensitive_header_key() { + let headers = vec![ + ("content-type".to_string(), "application/json".to_string()), + ]; + assert!(is_json_content(&headers, "")); + } + + #[test] + fn is_json_content_case_insensitive_header_value() { + let headers = vec![ + ("Content-Type".to_string(), "APPLICATION/JSON".to_string()), + ]; + assert!(is_json_content(&headers, "")); + } + + #[test] + fn is_json_content_no_header_body_object() { + let headers: Vec<(String, String)> = vec![]; + assert!(is_json_content(&headers, r#"{"key": "value"}"#)); + } + + #[test] + fn is_json_content_no_header_body_array() { + let headers: Vec<(String, String)> = vec![]; + assert!(is_json_content(&headers, "[1, 2, 3]")); + } + + #[test] + fn is_json_content_body_with_whitespace() { + let headers: Vec<(String, String)> = vec![]; + assert!(is_json_content(&headers, " { \"a\": 1 } ")); + } + + #[test] + fn is_json_content_empty_body_no_header() { + let headers: Vec<(String, String)> = vec![]; + assert!(!is_json_content(&headers, "")); + } + + #[test] + fn is_json_content_plain_text_body() { + let headers: Vec<(String, String)> = vec![]; + assert!(!is_json_content(&headers, "hello world")); + } + + #[test] + fn is_json_content_html_body() { + let headers = vec![ + ("Content-Type".to_string(), "text/html".to_string()), + ]; + assert!(!is_json_content(&headers, "")); + } + + #[test] + fn is_json_content_mismatched_braces() { + let headers: Vec<(String, String)> = vec![]; + // Starts with { but ends with ] + assert!(!is_json_content(&headers, "{data]")); + } + + #[test] + fn is_json_content_mismatched_brackets() { + let headers: Vec<(String, String)> = vec![]; + // Starts with [ but ends with } + assert!(!is_json_content(&headers, "[data}")); + } + + // ----------------------------------------------------------------------- + // Method FromStr + // ----------------------------------------------------------------------- + + #[test] + fn method_from_str_get() { + let m: Method = "GET".parse().unwrap(); + assert_eq!(m, Method::Standard(HttpMethod::Get)); + } + + #[test] + fn method_from_str_post() { + let m: Method = "POST".parse().unwrap(); + assert_eq!(m, Method::Standard(HttpMethod::Post)); + } + + #[test] + fn method_from_str_put() { + let m: Method = "PUT".parse().unwrap(); + assert_eq!(m, Method::Standard(HttpMethod::Put)); + } + + #[test] + fn method_from_str_patch() { + let m: Method = "PATCH".parse().unwrap(); + assert_eq!(m, Method::Standard(HttpMethod::Patch)); + } + + #[test] + fn method_from_str_delete() { + let m: Method = "DELETE".parse().unwrap(); + assert_eq!(m, Method::Standard(HttpMethod::Delete)); + } + + #[test] + fn method_from_str_head() { + let m: Method = "HEAD".parse().unwrap(); + assert_eq!(m, Method::Standard(HttpMethod::Head)); + } + + #[test] + fn method_from_str_options() { + let m: Method = "OPTIONS".parse().unwrap(); + assert_eq!(m, Method::Standard(HttpMethod::Options)); + } + + #[test] + fn method_from_str_case_insensitive() { + let m: Method = "get".parse().unwrap(); + assert_eq!(m, Method::Standard(HttpMethod::Get)); + + let m: Method = "Post".parse().unwrap(); + assert_eq!(m, Method::Standard(HttpMethod::Post)); + + let m: Method = "dElEtE".parse().unwrap(); + assert_eq!(m, Method::Standard(HttpMethod::Delete)); + } + + #[test] + fn method_from_str_custom_method() { + let m: Method = "PURGE".parse().unwrap(); + assert_eq!(m, Method::Custom("PURGE".to_string())); + } + + #[test] + fn method_from_str_custom_method_uppercased() { + let m: Method = "purge".parse().unwrap(); + // Custom methods are stored in uppercase + assert_eq!(m, Method::Custom("PURGE".to_string())); + } + + #[test] + fn method_as_str_standard() { + let m = Method::Standard(HttpMethod::Get); + assert_eq!(m.as_str(), "GET"); + } + + #[test] + fn method_as_str_custom() { + let m = Method::Custom("PURGE".to_string()); + assert_eq!(m.as_str(), "PURGE"); + } + + // ----------------------------------------------------------------------- + // TextInput + // ----------------------------------------------------------------------- + + #[test] + fn text_input_new_empty() { + let ti = TextInput::new(String::new()); + assert_eq!(ti.value, ""); + assert_eq!(ti.cursor, 0); + assert_eq!(ti.char_count(), 0); + } + + #[test] + fn text_input_new_with_value() { + let ti = TextInput::new("hello".to_string()); + assert_eq!(ti.value, "hello"); + // Cursor starts at end + assert_eq!(ti.cursor, 5); + assert_eq!(ti.char_count(), 5); + } + + #[test] + fn text_input_new_unicode() { + // Each emoji is one char but multiple bytes + let ti = TextInput::new("cafe\u{0301}".to_string()); + // "cafe\u{0301}" has 5 chars (c, a, f, e, combining acute) + assert_eq!(ti.char_count(), 5); + assert_eq!(ti.cursor, 5); + } + + #[test] + fn text_input_insert_char_at_end() { + let mut ti = TextInput::new("ab".to_string()); + ti.insert_char('c'); + assert_eq!(ti.value, "abc"); + assert_eq!(ti.cursor, 3); + } + + #[test] + fn text_input_insert_char_at_beginning() { + let mut ti = TextInput::new("bc".to_string()); + ti.cursor = 0; + ti.insert_char('a'); + assert_eq!(ti.value, "abc"); + assert_eq!(ti.cursor, 1); + } + + #[test] + fn text_input_insert_char_in_middle() { + let mut ti = TextInput::new("ac".to_string()); + ti.cursor = 1; + ti.insert_char('b'); + assert_eq!(ti.value, "abc"); + assert_eq!(ti.cursor, 2); + } + + #[test] + fn text_input_insert_unicode_char() { + let mut ti = TextInput::new(String::new()); + ti.insert_char('\u{1F600}'); // grinning face emoji + assert_eq!(ti.value, "\u{1F600}"); + assert_eq!(ti.cursor, 1); + assert_eq!(ti.char_count(), 1); + } + + #[test] + fn text_input_backspace_at_end() { + let mut ti = TextInput::new("abc".to_string()); + ti.backspace(); + assert_eq!(ti.value, "ab"); + assert_eq!(ti.cursor, 2); + } + + #[test] + fn text_input_backspace_at_beginning() { + let mut ti = TextInput::new("abc".to_string()); + ti.cursor = 0; + ti.backspace(); + // No change when at beginning + assert_eq!(ti.value, "abc"); + assert_eq!(ti.cursor, 0); + } + + #[test] + fn text_input_backspace_in_middle() { + let mut ti = TextInput::new("abc".to_string()); + ti.cursor = 2; + ti.backspace(); + assert_eq!(ti.value, "ac"); + assert_eq!(ti.cursor, 1); + } + + #[test] + fn text_input_delete_at_cursor() { + let mut ti = TextInput::new("abc".to_string()); + ti.cursor = 1; + ti.delete(); + assert_eq!(ti.value, "ac"); + assert_eq!(ti.cursor, 1); + } + + #[test] + fn text_input_delete_at_end() { + let mut ti = TextInput::new("abc".to_string()); + // Cursor at end, delete should be no-op + ti.delete(); + assert_eq!(ti.value, "abc"); + assert_eq!(ti.cursor, 3); + } + + #[test] + fn text_input_delete_at_beginning() { + let mut ti = TextInput::new("abc".to_string()); + ti.cursor = 0; + ti.delete(); + assert_eq!(ti.value, "bc"); + assert_eq!(ti.cursor, 0); + } + + #[test] + fn text_input_move_left() { + let mut ti = TextInput::new("abc".to_string()); + assert_eq!(ti.cursor, 3); + ti.move_left(); + assert_eq!(ti.cursor, 2); + ti.move_left(); + assert_eq!(ti.cursor, 1); + ti.move_left(); + assert_eq!(ti.cursor, 0); + // Should not go below 0 + ti.move_left(); + assert_eq!(ti.cursor, 0); + } + + #[test] + fn text_input_move_right() { + let mut ti = TextInput::new("abc".to_string()); + ti.cursor = 0; + ti.move_right(); + assert_eq!(ti.cursor, 1); + ti.move_right(); + assert_eq!(ti.cursor, 2); + ti.move_right(); + assert_eq!(ti.cursor, 3); + // Should not go beyond char count + ti.move_right(); + assert_eq!(ti.cursor, 3); + } + + #[test] + fn text_input_byte_offset_ascii() { + let ti = TextInput::new("abc".to_string()); + // Cursor at 3 (end), byte offset is 3 + assert_eq!(ti.byte_offset(), 3); + } + + #[test] + fn text_input_byte_offset_unicode() { + // "\u{1F600}" is 4 bytes, "ab" is 2 bytes + let mut ti = TextInput::new("\u{1F600}ab".to_string()); + ti.cursor = 0; + assert_eq!(ti.byte_offset(), 0); + ti.cursor = 1; // After emoji + assert_eq!(ti.byte_offset(), 4); + ti.cursor = 2; // After emoji + 'a' + assert_eq!(ti.byte_offset(), 5); + ti.cursor = 3; // After emoji + 'ab' + assert_eq!(ti.byte_offset(), 6); + } + + #[test] + fn text_input_insert_into_unicode_string() { + let mut ti = TextInput::new("\u{1F600}b".to_string()); + ti.cursor = 1; // After emoji, before 'b' + ti.insert_char('a'); + assert_eq!(ti.value, "\u{1F600}ab"); + assert_eq!(ti.cursor, 2); + } + + #[test] + fn text_input_backspace_unicode_char() { + let mut ti = TextInput::new("a\u{1F600}b".to_string()); + ti.cursor = 2; // After emoji + ti.backspace(); + assert_eq!(ti.value, "ab"); + assert_eq!(ti.cursor, 1); + } + + // ----------------------------------------------------------------------- + // ResponseSearch::compute_matches - case sensitive + // ----------------------------------------------------------------------- + + #[test] + fn search_case_sensitive_single_match() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + search.input = TextInput::new("hello".to_string()); + search.compute_matches("hello world", 1); + assert_eq!(search.matches.len(), 1); + assert_eq!(search.matches[0].line_index, 0); + assert_eq!(search.matches[0].byte_start, 0); + assert_eq!(search.matches[0].byte_end, 5); + } + + #[test] + fn search_case_sensitive_multiple_matches() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + search.input = TextInput::new("ab".to_string()); + search.compute_matches("ab cd ab ef ab", 1); + assert_eq!(search.matches.len(), 3); + assert_eq!(search.matches[0].byte_start, 0); + assert_eq!(search.matches[1].byte_start, 6); + assert_eq!(search.matches[2].byte_start, 12); + } + + #[test] + fn search_case_sensitive_no_match() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + search.input = TextInput::new("Hello".to_string()); + search.compute_matches("hello world", 1); + assert_eq!(search.matches.len(), 0); + } + + #[test] + fn search_case_sensitive_empty_query() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + search.input = TextInput::new(String::new()); + search.compute_matches("hello world", 1); + assert_eq!(search.matches.len(), 0); + } + + #[test] + fn search_case_sensitive_empty_text() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + search.input = TextInput::new("hello".to_string()); + search.compute_matches("", 1); + assert_eq!(search.matches.len(), 0); + } + + #[test] + fn search_case_sensitive_multiline() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + search.input = TextInput::new("foo".to_string()); + search.compute_matches("line1 foo\nline2\nline3 foo bar", 1); + assert_eq!(search.matches.len(), 2); + // First match: line 0, byte offset 6 + assert_eq!(search.matches[0].line_index, 0); + assert_eq!(search.matches[0].byte_start, 6); + assert_eq!(search.matches[0].byte_end, 9); + // Second match: line 2, byte offset 6 + assert_eq!(search.matches[1].line_index, 2); + assert_eq!(search.matches[1].byte_start, 6); + assert_eq!(search.matches[1].byte_end, 9); + } + + // ----------------------------------------------------------------------- + // ResponseSearch::compute_matches - case insensitive + // ----------------------------------------------------------------------- + + #[test] + fn search_case_insensitive_basic() { + let mut search = ResponseSearch::new(); + search.case_sensitive = false; + search.input = TextInput::new("hello".to_string()); + search.compute_matches("Hello World HELLO", 1); + assert_eq!(search.matches.len(), 2); + } + + #[test] + fn search_case_insensitive_mixed_case_query() { + let mut search = ResponseSearch::new(); + search.case_sensitive = false; + search.input = TextInput::new("HeLLo".to_string()); + search.compute_matches("hello HELLO Hello", 1); + assert_eq!(search.matches.len(), 3); + } + + #[test] + fn search_case_insensitive_no_match() { + let mut search = ResponseSearch::new(); + search.case_sensitive = false; + search.input = TextInput::new("xyz".to_string()); + search.compute_matches("hello world", 1); + assert_eq!(search.matches.len(), 0); + } + + #[test] + fn search_case_insensitive_empty_query() { + let mut search = ResponseSearch::new(); + search.case_sensitive = false; + search.input = TextInput::new(String::new()); + search.compute_matches("hello world", 1); + assert_eq!(search.matches.len(), 0); + } + + // ----------------------------------------------------------------------- + // ResponseSearch::compute_matches - caching behavior + // ----------------------------------------------------------------------- + + #[test] + fn search_caching_same_generation_skips_recompute() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + search.input = TextInput::new("a".to_string()); + search.compute_matches("a b a", 1); + assert_eq!(search.matches.len(), 2); + + // Manually clear matches to detect if recomputation occurs + search.matches.clear(); + search.compute_matches("a b a", 1); + // Should still be empty because cache hit prevents recompute + assert_eq!(search.matches.len(), 0); + } + + #[test] + fn search_caching_different_generation_recomputes() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + search.input = TextInput::new("a".to_string()); + search.compute_matches("a b a", 1); + assert_eq!(search.matches.len(), 2); + + search.matches.clear(); + // Different body_generation forces recomputation + search.compute_matches("a b a", 2); + assert_eq!(search.matches.len(), 2); + } + + #[test] + fn search_caching_different_query_recomputes() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + search.input = TextInput::new("a".to_string()); + search.compute_matches("a b c", 1); + assert_eq!(search.matches.len(), 1); + + // Change query + search.input = TextInput::new("b".to_string()); + search.compute_matches("a b c", 1); + assert_eq!(search.matches.len(), 1); + assert_eq!(search.matches[0].byte_start, 2); + } + + #[test] + fn search_caching_case_sensitivity_change_recomputes() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + search.input = TextInput::new("a".to_string()); + search.compute_matches("A a A", 1); + // Case sensitive: only lowercase 'a' matches + assert_eq!(search.matches.len(), 1); + + // Toggle case sensitivity + search.case_sensitive = false; + search.compute_matches("A a A", 1); + // Case insensitive: all 'a'/'A' match + assert_eq!(search.matches.len(), 3); + } + + // ----------------------------------------------------------------------- + // ResponseSearch::compute_matches - Unicode + // ----------------------------------------------------------------------- + + #[test] + fn search_case_sensitive_unicode_at_end() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + // Search for a multibyte emoji at the end of the text so the + // byte-level `start += 1` advance after the match does not land + // inside a multibyte char (there is nothing left to search). + search.input = TextInput::new("\u{1F600}".to_string()); + search.compute_matches("hello \u{1F600}", 1); + assert_eq!(search.matches.len(), 1); + assert_eq!(search.matches[0].line_index, 0); + // "hello " is 6 bytes, emoji starts at byte 6 + assert_eq!(search.matches[0].byte_start, 6); + // Emoji is 4 bytes + assert_eq!(search.matches[0].byte_end, 10); + } + + #[test] + fn search_case_sensitive_ascii_after_unicode() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + // Search for an ASCII pattern that appears after a multibyte char. + // The case-sensitive path uses byte-level find, so searching for + // ASCII content is safe regardless of preceding multibyte chars. + search.input = TextInput::new("world".to_string()); + search.compute_matches("\u{1F600} world", 1); + assert_eq!(search.matches.len(), 1); + // "\u{1F600} " is 5 bytes (4 byte emoji + 1 space) + assert_eq!(search.matches[0].byte_start, 5); + assert_eq!(search.matches[0].byte_end, 10); + } + + #[test] + fn search_case_insensitive_unicode_text() { + let mut search = ResponseSearch::new(); + search.case_sensitive = false; + search.input = TextInput::new("\u{00FC}".to_string()); // u-umlaut + search.compute_matches("gr\u{00FC}n and GR\u{00DC}N", 1); + // \u{00FC} lowercases to itself; \u{00DC} lowercases to \u{00FC} + assert_eq!(search.matches.len(), 2); + } + + // ----------------------------------------------------------------------- + // ResponseSearch::compute_matches - overlapping patterns + // ----------------------------------------------------------------------- + + #[test] + fn search_case_sensitive_overlapping() { + let mut search = ResponseSearch::new(); + search.case_sensitive = true; + search.input = TextInput::new("aa".to_string()); + search.compute_matches("aaa", 1); + // "aaa" contains "aa" at position 0 and position 1 + assert_eq!(search.matches.len(), 2); + assert_eq!(search.matches[0].byte_start, 0); + assert_eq!(search.matches[1].byte_start, 1); + } +} diff --git a/src/http.rs b/src/http.rs index 1e0665b..1b05135 100644 --- a/src/http.rs +++ b/src/http.rs @@ -1,9 +1,117 @@ +use std::path::{Path, PathBuf}; use std::time::Instant; +use futures_util::StreamExt; use reqwest::Client; use crate::app::{ApiKeyLocation, HttpMethod, Method, ResponseData}; +/// Maximum response body size in bytes (50 MB). +/// Responses larger than this will be truncated to prevent out-of-memory conditions. +const MAX_RESPONSE_BODY_SIZE: usize = 50 * 1024 * 1024; + +/// Maximum file size allowed for upload (100 MB). +const MAX_UPLOAD_FILE_SIZE: u64 = 100 * 1024 * 1024; + +/// Well-known sensitive directories relative to the user's home directory. +/// Paths are checked after canonicalization, so symlink tricks are neutralized. +const SENSITIVE_HOME_PREFIXES: &[&str] = &[ + "/.ssh", + "/.gnupg", + "/.gpg", + "/.aws", + "/.config/gcloud", + "/.azure", + "/.kube", +]; + +/// Well-known sensitive absolute paths that do not depend on a home directory. +const SENSITIVE_ABSOLUTE_PATHS: &[&str] = &[ + "/etc/shadow", + "/etc/passwd", + "/etc/ssl/private", + "/etc/sudoers", +]; + +/// Validate and resolve a file path before reading it for upload. +/// +/// Returns the canonicalized [`PathBuf`] on success, or a descriptive error. +/// +/// Checks performed: +/// 1. The path is canonicalized to resolve `..`, `.`, and symlinks. This also +/// verifies the path exists on disk. +/// 2. The resolved target must be a regular file (not a directory, device, etc.). +/// 3. The file must not reside in a known sensitive directory. +/// 4. The file size must not exceed [`MAX_UPLOAD_FILE_SIZE`]. +fn validate_file_path(raw_path: &str) -> Result { + let path = Path::new(raw_path); + + // 1. Canonicalize -- resolves symlinks, `..`, `.` and verifies existence. + let canonical = path.canonicalize().map_err(|e| { + format!("Cannot resolve file path '{}': {}", raw_path, e) + })?; + + // 2. Must be a regular file after symlink resolution. + let metadata = std::fs::metadata(&canonical).map_err(|e| { + format!( + "Cannot read file metadata for '{}': {}", + canonical.display(), + e + ) + })?; + + if !metadata.is_file() { + return Err(format!( + "Path '{}' is not a regular file", + canonical.display() + )); + } + + // 3. Reject files inside sensitive directories. + let canonical_str = canonical.to_string_lossy(); + + if let Some(home) = home_dir_prefix() { + for prefix in SENSITIVE_HOME_PREFIXES { + let sensitive = format!("{}{}", home, prefix); + if canonical_str.starts_with(&sensitive) { + return Err(format!( + "Refusing to read file in sensitive directory: {}", + canonical.display() + )); + } + } + } + + for sensitive_path in SENSITIVE_ABSOLUTE_PATHS { + if canonical_str.starts_with(sensitive_path) { + return Err(format!( + "Refusing to read file in sensitive location: {}", + canonical.display() + )); + } + } + + // 4. Enforce maximum file size. + let size = metadata.len(); + if size > MAX_UPLOAD_FILE_SIZE { + return Err(format!( + "File '{}' is too large ({:.1} MB). Maximum allowed size is {:.0} MB.", + canonical.display(), + size as f64 / (1024.0 * 1024.0), + MAX_UPLOAD_FILE_SIZE as f64 / (1024.0 * 1024.0), + )); + } + + Ok(canonical) +} + +/// Return the user's home directory path as a [`String`], if available. +fn home_dir_prefix() -> Option { + std::env::var("HOME") + .ok() + .or_else(|| std::env::var("USERPROFILE").ok()) +} + pub enum AuthConfig { NoAuth, Bearer { token: String }, @@ -142,11 +250,16 @@ pub async fn send_request( form = form.text(part.key, part.value); } MultipartPartType::File => { - let path = std::path::Path::new(&part.value); - let file_bytes = std::fs::read(path).map_err(|e| { - format!("Failed to read file '{}': {}", part.value, e) - })?; - let file_name = path + let validated_path = validate_file_path(&part.value)?; + let file_bytes = + tokio::fs::read(&validated_path).await.map_err(|e| { + format!( + "Failed to read file '{}': {}", + validated_path.display(), + e + ) + })?; + let file_name = validated_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("file") @@ -164,8 +277,10 @@ pub async fn send_request( } BodyContent::Binary(path) => { if !path.is_empty() && sends_body { - let bytes = std::fs::read(&path) - .map_err(|e| format!("Failed to read file '{}': {}", path, e))?; + let validated_path = validate_file_path(&path)?; + let bytes = tokio::fs::read(&validated_path).await.map_err(|e| { + format!("Failed to read file '{}': {}", validated_path.display(), e) + })?; let mut b = builder; if !has_manual_content_type { b = b.header("Content-Type", "application/octet-stream"); @@ -189,7 +304,37 @@ pub async fn send_request( .map(|(k, v)| (k.to_string(), v.to_str().unwrap_or("").to_string())) .collect(); - let response_body = response.text().await.map_err(|e| e.to_string())?; + // Read the response body in chunks, enforcing a size limit to prevent OOM + // from malicious or misconfigured servers returning unbounded data. + let mut body_bytes: Vec = Vec::new(); + let mut truncated = false; + let mut stream = response.bytes_stream(); + + while let Some(chunk_result) = stream.next().await { + let chunk = chunk_result.map_err(|e| e.to_string())?; + let remaining = MAX_RESPONSE_BODY_SIZE.saturating_sub(body_bytes.len()); + if remaining == 0 { + truncated = true; + break; + } + if chunk.len() > remaining { + body_bytes.extend_from_slice(&chunk[..remaining]); + truncated = true; + break; + } + body_bytes.extend_from_slice(&chunk); + } + + let body_size_bytes = body_bytes.len(); + let mut response_body = String::from_utf8_lossy(&body_bytes).into_owned(); + + if truncated { + response_body.push_str(&format!( + "\n\n--- Response truncated at {} bytes (limit: {} bytes) ---", + body_size_bytes, + MAX_RESPONSE_BODY_SIZE, + )); + } let duration_ms = start.elapsed().as_millis() as u64; @@ -198,6 +343,7 @@ pub async fn send_request( status_text, headers: response_headers, body: response_body, + body_size_bytes, duration_ms, }) } diff --git a/src/storage/collection.rs b/src/storage/collection.rs index 9f0f6c4..db238e2 100644 --- a/src/storage/collection.rs +++ b/src/storage/collection.rs @@ -437,7 +437,7 @@ fn sort_collection(collection: &mut PostmanCollection) -> bool { sort_items(&mut collection.item) } -fn sort_items(items: &mut Vec) -> bool { +fn sort_items(items: &mut [PostmanItem]) -> bool { let before: Vec = items.iter().map(|i| i.id.clone()).collect(); items.sort_by(|a, b| { let an = a.name.to_lowercase(); @@ -467,7 +467,7 @@ fn find_item<'a>(items: &'a [PostmanItem], id: &str) -> Option<&'a PostmanItem> None } -fn find_item_mut<'a>(items: &'a mut Vec, id: &str) -> Option<&'a mut PostmanItem> { +fn find_item_mut<'a>(items: &'a mut [PostmanItem], id: &str) -> Option<&'a mut PostmanItem> { for item in items.iter_mut() { if item.id == id { return Some(item); diff --git a/src/storage/environment.rs b/src/storage/environment.rs index 19380ed..461dfb9 100644 --- a/src/storage/environment.rs +++ b/src/storage/environment.rs @@ -27,6 +27,7 @@ fn default_type() -> String { } impl EnvironmentVariable { + #[cfg(test)] pub fn new(key: &str, value: &str) -> Self { Self { key: key.to_string(), @@ -53,6 +54,9 @@ pub fn load_environment(path: &Path) -> Result { .map_err(|e| format!("Failed to parse {}: {}", path.display(), e)) } +/// Persist an environment to the project's environments directory. +/// Wired up when the UI supports environment create/edit. +#[allow(dead_code)] pub fn save_environment(env: &Environment) -> Result<(), String> { if !is_safe_env_name(&env.name) { return Err(format!( @@ -95,17 +99,43 @@ pub fn load_all_environments() -> Result, String> { Ok(environments) } +/// Delete an environment file from the project's environments directory. +/// Wired up when the UI supports environment deletion. +#[allow(dead_code)] pub fn delete_environment_file(name: &str) -> Result<(), String> { + if !is_safe_env_name(name) { + return Err(format!( + "Invalid environment name '{}': must be non-empty and contain only alphanumeric, underscore, or hyphen characters", + name + )); + } + let dir = project::environments_dir() .ok_or("Could not find environments directory")?; let path = dir.join(format!("{}.json", name)); + if path.exists() { - fs::remove_file(&path) - .map_err(|e| format!("Failed to delete {}: {}", path.display(), e))?; + // Canonicalize both paths and verify the target is within the environments directory. + // This serves as a second layer of defense against path traversal. + let canonical_dir = fs::canonicalize(&dir) + .map_err(|e| format!("Failed to resolve environments directory: {}", e))?; + let canonical_path = fs::canonicalize(&path) + .map_err(|e| format!("Failed to resolve path {}: {}", path.display(), e))?; + + if !canonical_path.starts_with(&canonical_dir) { + return Err(format!( + "Refusing to delete '{}': resolved path is outside the environments directory", + name + )); + } + + fs::remove_file(&canonical_path) + .map_err(|e| format!("Failed to delete {}: {}", canonical_path.display(), e))?; } Ok(()) } +#[allow(dead_code)] fn is_safe_env_name(name: &str) -> bool { !name.is_empty() && name diff --git a/src/storage/mod.rs b/src/storage/mod.rs index acbc2f8..9c20fb2 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,5 +1,3 @@ -#![allow(unused)] - mod collection; pub mod environment; mod migrate; @@ -9,24 +7,11 @@ mod project; mod session_state; mod ui_state; -pub use collection::{ - parse_headers, CollectionStore, NodeKind, ProjectInfo, ProjectTree, RequestFile, TreeNode, -}; -pub use environment::{ - delete_environment_file, load_all_environments, save_environment, Environment, - EnvironmentVariable, -}; +pub use collection::{parse_headers, CollectionStore, NodeKind, ProjectInfo, ProjectTree, TreeNode}; pub use postman::{ PostmanAuth, PostmanBody, PostmanFormParam, PostmanHeader, PostmanItem, PostmanKvPair, PostmanRequest, }; -pub use models::SavedRequest; -pub use project::{ - collection_path, ensure_environments_dir, ensure_storage_dir, environments_dir, - find_project_root, project_root_key, requests_dir, storage_dir, ui_state_path, -}; -pub use session_state::{ - load_session_for_root, load_sessions, save_session_for_root, save_sessions, SessionState, - SessionStore, -}; +pub use project::{find_project_root, project_root_key}; +pub use session_state::{load_session_for_root, save_session_for_root, SessionState}; pub use ui_state::{load_ui_state, save_ui_state, UiState}; diff --git a/src/storage/models.rs b/src/storage/models.rs index 055b745..6a56504 100644 --- a/src/storage/models.rs +++ b/src/storage/models.rs @@ -9,37 +9,3 @@ pub struct SavedRequest { pub headers: String, pub body: String, } - -impl SavedRequest { - pub fn new( - name: String, - url: String, - method: String, - headers: String, - body: String, - ) -> Self { - let id = generate_id(); - Self { - id, - name, - url, - method, - headers, - body, - } - } - - pub fn from_request_state(name: String, request: &crate::app::RequestState) -> Self { - Self::new( - name, - request.url_text(), - request.method.as_str().to_string(), - request.headers_text(), - request.body_text(), - ) - } -} - -fn generate_id() -> String { - uuid::Uuid::new_v4().to_string() -} diff --git a/src/storage/project.rs b/src/storage/project.rs index c9fca2d..a6f525b 100644 --- a/src/storage/project.rs +++ b/src/storage/project.rs @@ -57,6 +57,8 @@ pub fn environments_dir() -> Option { storage_dir().map(|root| root.join("environments")) } +/// Create the environments directory if it doesn't exist. Used by `save_environment`. +#[allow(dead_code)] pub fn ensure_environments_dir() -> Result { let dir = environments_dir().ok_or( "Could not find project root. Run from a directory with .git, Cargo.toml, package.json, or create a .perseus folder.", diff --git a/src/ui/layout.rs b/src/ui/layout.rs index a488669..8961378 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -119,21 +119,40 @@ pub struct ResponseLayout { pub tab_area: Rect, pub spacer_area: Rect, pub content_area: Rect, + pub search_bar_area: Option, } impl ResponseLayout { - pub fn new(area: Rect) -> Self { - let chunks = Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(3), - ]) - .split(area); - - Self { - tab_area: chunks[0], - spacer_area: chunks[1], - content_area: chunks[2], + pub fn new(area: Rect, search_active: bool) -> Self { + if search_active { + let chunks = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(2), + Constraint::Length(1), + ]) + .split(area); + + Self { + tab_area: chunks[0], + spacer_area: chunks[1], + content_area: chunks[2], + search_bar_area: Some(chunks[3]), + } + } else { + let chunks = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(3), + ]) + .split(area); + + Self { + tab_area: chunks[0], + spacer_area: chunks[1], + content_area: chunks[2], + search_bar_area: None, + } } } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 7d9811f..257e48d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -13,10 +13,10 @@ use tui_textarea::TextArea; use unicode_width::UnicodeWidthChar; use crate::app::{ - App, AppMode, AuthField, AuthType, BodyField, BodyMode, HttpMethod, KvColumn, KvFocus, KvPair, - Method, MultipartField, MultipartFieldType, Panel, RequestField, RequestTab, - ResponseBodyRenderCache, ResponseHeadersRenderCache, ResponseStatus, ResponseTab, - SidebarPopup, WrapCache, + format_size, is_json_content, App, AppMode, AuthField, AuthType, BodyField, BodyMode, + HttpMethod, KvColumn, KvFocus, KvPair, Method, MultipartField, MultipartFieldType, Panel, + RequestField, RequestTab, ResponseBodyRenderCache, ResponseHeadersRenderCache, ResponseSearch, + ResponseStatus, ResponseTab, SearchMatch, SidebarPopup, WrapCache, }; use crate::perf; use crate::storage::NodeKind; @@ -52,6 +52,10 @@ pub fn render(frame: &mut Frame, app: &mut App) { render_env_popup(frame, app); } + if app.save_popup.is_some() { + render_save_popup(frame, app); + } + if app.show_help { render_help_overlay(frame); } @@ -180,7 +184,7 @@ fn render_sidebar_popup(frame: &mut Frame, app: &App, popup: &SidebarPopup, area vec![ Line::from("Name or path (folder/req or folder/)"), Line::from(""), - Line::from(render_input_line(input)), + render_input_line(input), Line::from(""), Line::from("Enter: create Esc: cancel"), ], @@ -190,7 +194,7 @@ fn render_sidebar_popup(frame: &mut Frame, app: &App, popup: &SidebarPopup, area vec![ Line::from("New name"), Line::from(""), - Line::from(render_input_line(input)), + render_input_line(input), Line::from(""), Line::from("Enter: rename Esc: cancel"), ], @@ -200,7 +204,7 @@ fn render_sidebar_popup(frame: &mut Frame, app: &App, popup: &SidebarPopup, area vec![ Line::from("Filter items"), Line::from(""), - Line::from(render_input_line(input)), + render_input_line(input), Line::from(""), Line::from("Enter: apply Esc: clear"), ], @@ -264,8 +268,9 @@ fn render_sidebar_popup(frame: &mut Frame, app: &App, popup: &SidebarPopup, area fn render_input_line(input: &crate::app::TextInput) -> Line<'static> { let mut text = input.value.clone(); - if input.cursor <= text.len() { - text.insert(input.cursor, '|'); + let byte_pos = input.byte_offset(); + if byte_pos <= text.len() { + text.insert(byte_pos, '|'); } else { text.push('|'); } @@ -551,9 +556,7 @@ fn render_kv_table( frame.render_widget(ta, cols[1]); } } else { - let key_display = if row.key.is_empty() && !is_active_row { - "" - } else if row.key.is_empty() { + let key_display = if row.key.is_empty() { "" } else { row.key @@ -802,6 +805,49 @@ fn render_env_popup(frame: &mut Frame, app: &App) { frame.render_widget(list, inner); } +fn render_save_popup(frame: &mut Frame, app: &App) { + let area = frame.area(); + let width: u16 = 50.min(area.width.saturating_sub(4)); + let height: u16 = 3; + let x = (area.width.saturating_sub(width)) / 2; + let y = (area.height.saturating_sub(height)) / 2; + let popup_area = Rect::new(x, y, width, height); + + frame.render_widget(Clear, popup_area); + + let popup_block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" Save Response "); + + let inner = popup_block.inner(popup_area); + frame.render_widget(popup_block, popup_area); + + if let Some(ref input) = app.save_popup { + let display = input.value.to_string(); + let cursor_pos = input.cursor; + + let mut spans = Vec::new(); + if display.is_empty() { + spans.push(Span::styled( + "Enter file path...", + Style::default().fg(Color::DarkGray), + )); + } else { + spans.push(Span::raw(&display)); + } + + let line = Line::from(spans); + let input_widget = Paragraph::new(line); + frame.render_widget(input_widget, inner); + + // Position cursor + let cx = inner.x + cursor_pos.min(inner.width as usize) as u16; + let cy = inner.y; + frame.set_cursor_position((cx, cy)); + } +} + fn is_field_focused(app: &App, field: RequestField) -> bool { app.focus.panel == Panel::Request && app.focus.request_field == field } @@ -1135,7 +1181,12 @@ fn render_response_panel(frame: &mut Frame, app: &mut App, area: Rect) { let inner_area = outer_block.inner(area); frame.render_widget(outer_block, area); - let response_layout = ResponseLayout::new(inner_area); + let search_bar_visible = app.response_search.active + || (!app.response_search.query.is_empty() + && app.response_tab == ResponseTab::Body); + let response_layout = ResponseLayout::new(inner_area, search_bar_visible); + // Keep the stored viewport height in sync with the actual rendered content area + app.response_viewport_height = response_layout.content_area.height; render_response_tab_bar(frame, app, response_layout.tab_area); frame.render_widget(Paragraph::new(""), response_layout.spacer_area); @@ -1184,6 +1235,7 @@ fn render_response_panel(frame: &mut Frame, app: &mut App, area: Rect) { response_layout.content_area, response_scroll, editing_response, + &app.response_search, ); } ResponseTab::Headers => { @@ -1201,10 +1253,15 @@ fn render_response_panel(frame: &mut Frame, app: &mut App, area: Rect) { } } } + + // Render search bar + if let Some(search_area) = response_layout.search_bar_area { + render_search_bar(frame, &app.response_search, search_area); + } } fn render_response_tab_bar(frame: &mut Frame, app: &App, area: Rect) { - let (status_text, status_style) = response_status_text(app); + let (status_text, status_style) = response_status_text(app, area.width < 50); let active_color = if app.focus.panel == Panel::Response { Color::Green } else { @@ -1243,7 +1300,7 @@ fn render_response_tab_bar(frame: &mut Frame, app: &App, area: Rect) { frame.render_widget(status_widget, area); } -fn response_status_text(app: &App) -> (String, Style) { +fn response_status_text(app: &App, narrow: bool) -> (String, Style) { match &app.response { ResponseStatus::Empty => ( "Idle".to_string(), @@ -1258,15 +1315,25 @@ fn response_status_text(app: &App) -> (String, Style) { "Cancelled".to_string(), Style::default().fg(Color::Yellow), ), - ResponseStatus::Success(data) => ( - format!("{} {} ({}ms)", data.status, data.status_text, data.duration_ms), - Style::default().fg(status_color(data.status)), - ), + ResponseStatus::Success(data) => { + let text = if narrow { + format!("{} {} ({}ms)", data.status, data.status_text, data.duration_ms) + } else { + format!( + "{} {} ({}ms) · {}", + data.status, + data.status_text, + data.duration_ms, + format_size(data.body_size_bytes), + ) + }; + (text, Style::default().fg(status_color(data.status))) + } } } fn status_color(status: u16) -> Color { - if status >= 200 && status < 300 { + if (200..300).contains(&status) { Color::Green } else if status >= 400 { Color::Red @@ -1275,6 +1342,7 @@ fn status_color(status: u16) -> Color { } } +#[allow(clippy::too_many_arguments)] fn render_response_body( frame: &mut Frame, response_editor: &TextArea<'static>, @@ -1283,11 +1351,12 @@ fn render_response_body( area: Rect, scroll_offset: u16, editing: bool, + search: &ResponseSearch, ) { if cache.dirty { let editor_lines = response_editor.lines(); cache.body_text = editor_lines.join("\n"); - cache.is_json = is_json_response(&data.headers, &cache.body_text); + cache.is_json = is_json_content(&data.headers, &cache.body_text); cache.lines = if cache.is_json { colorize_json(&cache.body_text) } else { @@ -1299,7 +1368,38 @@ fn render_response_body( cache.generation = cache.generation.wrapping_add(1); cache.dirty = false; cache.wrap_cache.generation = 0; + // Body changed, invalidate search highlight cache so it recomputes + cache.highlight_search_gen = 0; + } + + // Determine which lines to render and the effective generation for the wrap cache. + // When search matches exist, use cached highlighted lines to avoid cloning every frame. + // When no search matches exist, pass the base lines directly without any allocation. + let search_gen = search.generation; + let has_matches = !search.matches.is_empty(); + + if has_matches && cache.highlight_search_gen != search_gen { + // Search state changed (query, matches, or current_match) -- recompute highlights + cache.highlighted_lines = + apply_search_highlights(&cache.lines, &search.matches, search.current_match); + cache.highlight_search_gen = search_gen; + } else if !has_matches { + // No active search -- clear highlight cache to free memory + if !cache.highlighted_lines.is_empty() { + cache.highlighted_lines = Vec::new(); + cache.highlight_search_gen = 0; + } } + + let lines_to_render: &[Line<'static>] = if has_matches { + &cache.highlighted_lines + } else { + &cache.lines + }; + + // Use search generation to force wrap cache invalidation when search changes + let effective_generation = cache.generation.wrapping_add(search_gen); + let cursor = if editing { Some(response_editor.cursor()) } else { @@ -1313,9 +1413,9 @@ fn render_response_body( render_wrapped_response_cached( frame, area, - &cache.lines, + lines_to_render, &mut cache.wrap_cache, - cache.generation, + effective_generation, cursor, selection, scroll_offset, @@ -1323,6 +1423,138 @@ fn render_response_body( ); } +fn apply_search_highlights( + lines: &[Line<'static>], + matches: &[SearchMatch], + current_match: usize, +) -> Vec> { + let highlight_style = Style::default().fg(Color::Black).bg(Color::Yellow); + let current_style = Style::default().fg(Color::Black).bg(Color::LightRed); + + let mut result = lines.to_vec(); + + // Group matches by line + for (match_idx, m) in matches.iter().enumerate() { + if m.line_index >= result.len() { + continue; + } + let style = if match_idx == current_match { + current_style + } else { + highlight_style + }; + + let line = &result[m.line_index]; + result[m.line_index] = highlight_spans_in_line(line, m.byte_start, m.byte_end, style); + } + + result +} + +fn highlight_spans_in_line( + line: &Line<'static>, + byte_start: usize, + byte_end: usize, + highlight_style: Style, +) -> Line<'static> { + let mut new_spans: Vec> = Vec::new(); + let mut byte_offset: usize = 0; + + for span in line.spans.iter() { + let span_content = span.content.as_ref(); + let span_len = span_content.len(); + let span_start = byte_offset; + let span_end = byte_offset + span_len; + + if byte_end <= span_start || byte_start >= span_end { + // No overlap + new_spans.push(span.clone()); + } else { + // There is overlap - split the span + let hl_start = byte_start.saturating_sub(span_start); + let hl_end = (byte_end - span_start).min(span_len); + + if hl_start > 0 { + new_spans.push(Span::styled( + span_content[..hl_start].to_string(), + span.style, + )); + } + new_spans.push(Span::styled( + span_content[hl_start..hl_end].to_string(), + highlight_style, + )); + if hl_end < span_len { + new_spans.push(Span::styled( + span_content[hl_end..].to_string(), + span.style, + )); + } + } + + byte_offset += span_len; + } + + Line::from(new_spans) +} + +fn render_search_bar(frame: &mut Frame, search: &ResponseSearch, area: Rect) { + let case_indicator = if search.case_sensitive { "AA" } else { "Aa" }; + let match_count = if search.matches.is_empty() { + "0/0".to_string() + } else { + format!("{}/{}", search.current_match + 1, search.matches.len()) + }; + + let right_info = format!("[{}] {}", case_indicator, match_count); + let right_len = right_info.len() as u16; + + // Left side: / prefix + query + let query_text = if search.active { + &search.input.value + } else { + &search.query + }; + let left = format!("/{}", query_text); + + let available_width = area.width.saturating_sub(right_len + 2); + let left_display = if left.len() > available_width as usize { + left[..available_width as usize].to_string() + } else { + left.clone() + }; + + let mut spans = vec![ + Span::styled( + left_display, + Style::default().fg(Color::White), + ), + ]; + + // Pad to push right_info to the end + let padding_len = area + .width + .saturating_sub(left.len() as u16 + right_len) as usize; + if padding_len > 0 { + spans.push(Span::raw(" ".repeat(padding_len))); + } + spans.push(Span::styled( + right_info, + Style::default().fg(Color::DarkGray), + )); + + let line = Line::from(spans); + let bar = Paragraph::new(line).style(Style::default().bg(Color::DarkGray).fg(Color::White)); + frame.render_widget(bar, area); + + // Position cursor when search input is active + if search.active { + let cursor_x = area.x + 1 + search.input.cursor as u16; // +1 for '/' prefix + let cursor_x = cursor_x.min(area.x + area.width.saturating_sub(1)); + frame.set_cursor_position((cursor_x, area.y)); + } +} + fn render_response_headers( frame: &mut Frame, response_headers_editor: &TextArea<'static>, @@ -1361,30 +1593,18 @@ fn render_response_headers( ); } -fn is_json_response(headers: &[(String, String)], body: &str) -> bool { - let has_json_content_type = headers.iter().any(|(k, v)| { - k.eq_ignore_ascii_case("content-type") && v.contains("application/json") - }); - if has_json_content_type { - return true; - } - let trimmed = body.trim(); - (trimmed.starts_with('{') && trimmed.ends_with('}')) - || (trimmed.starts_with('[') && trimmed.ends_with(']')) -} - fn colorize_json(json: &str) -> Vec> { let mut lines = Vec::new(); let mut current_spans: Vec> = Vec::new(); - let mut chars = json.chars().peekable(); + let chars = json.chars().peekable(); let mut in_string = false; let mut current_token = String::new(); let mut stack: Vec = Vec::new(); let mut expecting_key = false; let mut current_string_is_key = false; - while let Some(c) = chars.next() { + for c in chars { match c { '"' if !in_string => { in_string = true; @@ -1525,6 +1745,7 @@ fn colorize_headers(lines: &[String]) -> Vec> { .collect() } +#[allow(clippy::too_many_arguments)] fn render_wrapped_response_cached( frame: &mut Frame, area: Rect, @@ -1608,7 +1829,7 @@ fn wrap_lines_with_cursor( let mut cursor_pos: Option<(usize, usize)> = None; for (row, line) in lines.iter().enumerate() { - let line_len = line_char_len(&line); + let line_len = line_char_len(line); let selection_range = selection_range_for_row(selection, row, line_len); let cursor_col = cursor.and_then(|(r, c)| if r == row { Some(c) } else { None }); let (parts, line_cursor) = @@ -1778,20 +1999,31 @@ fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) { Panel::Response => format!("Response > {}", app.response_tab.label()), }; + let in_response = app.focus.panel == Panel::Response; let hints = if app.focus.panel == Panel::Sidebar { if matches!(app.app_mode, AppMode::Sidebar) { "j/k:move a:add r:rename d:del m:move /:search Enter:open Esc:exit" } else { "Enter/i:edit hjkl:nav Ctrl+p:projects Ctrl+e:toggle" } + } else if app.response_search.active { + "type:search Enter:confirm Esc:cancel Ctrl+i:case" } else { match app.app_mode { AppMode::Navigation => { - "hjkl:nav e:sidebar Enter:edit i:insert Ctrl+r:send Ctrl+s:save Ctrl+n:env Ctrl+e:toggle ?:help q:quit" + if in_response { + "hjkl:nav Enter:edit c:copy S:save Ctrl+r:send ?:help q:quit" + } else { + "hjkl:nav e:sidebar Enter:edit i:insert Ctrl+r:send Ctrl+s:save Ctrl+n:env Ctrl+e:toggle ?:help q:quit" + } } AppMode::Editing => match app.vim.mode { VimMode::Normal => { - "hjkl:move w/b/e:word i/a:insert v:visual d/c/y:op Cmd/Ctrl+C/V:clip Esc:exit" + if in_response && app.response_tab == ResponseTab::Body { + "hjkl:move /:search n/N:next/prev v:visual Cmd/Ctrl+C/V:clip Esc:exit" + } else { + "hjkl:move w/b/e:word i/a:insert v:visual d/c/y:op Cmd/Ctrl+C/V:clip Esc:exit" + } } VimMode::Insert => { "type text Cmd/Ctrl+V:paste Cmd/Ctrl+C:copy Enter:send(URL) Esc:normal" @@ -1873,6 +2105,8 @@ fn render_help_overlay(frame: &mut Frame) { Line::from(" Ctrl+p Project switcher"), Line::from(" Ctrl+s Save request"), Line::from(" Ctrl+n Switch environment"), + Line::from(" c Copy response (on response panel)"), + Line::from(" S Save response to file (on response panel)"), Line::from(" q / Esc Quit"), Line::from(""), Line::from(Span::styled( @@ -1916,6 +2150,16 @@ fn render_help_overlay(frame: &mut Frame) { Line::from(" u / Ctrl+r Undo / redo"), Line::from(" Enter Send request (URL field only)"), Line::from(" Esc Exit to navigation mode"), + Line::from(""), + Line::from(Span::styled( + "Response Search (Body tab)", + Style::default().fg(Color::Yellow), + )), + Line::from(" / Open search bar"), + Line::from(" n / N Next / previous match"), + Line::from(" Enter Confirm search, close input bar"), + Line::from(" Esc Cancel search, clear highlights"), + Line::from(" Ctrl+i Toggle case sensitivity"), ]; let help_paragraph = Paragraph::new(help_text);