//! HTTP send + `.http` / `.curl` / `.rest` file + request pane.
//!
//! Extracted from `app/mod.rs` in the file-split refactor
//!. Pure non-destructive move: no API
//! change. Owns the `http.*` palette commands, the background HTTP
//! worker thread, request-pane multi-block writeback, and the
//! `splice_http_block` free fn.
use super::*;
/// Result of a backgrounded `:ws.send` worker.
pub struct WsSendReply {
pub url: String,
pub message: String,
pub result: Result<WsSendOutput, String>,
}
pub struct WsSendOutput {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub elapsed_ms: u128,
}
/// Select which `.curl` block the cursor is over.
///
/// Returns `(block_start, block_end)` — inclusive file-line
/// bounds — or `(0, lines.len() - 1)` when the file has no `###`
/// separators (i.e. a single-block .curl).
///
/// Bug fixed 2026-07-06: cursor BEFORE the first `###` was
/// dispatching the first NAMED block, not the leading unnamed
/// content. The leading region now maps to (0, starts[0] - 1).
///
/// Public-in-module for unit-testing the bounds-picking logic
/// in isolation from IO / `App`.
/// Percent-encode `s` for use as a URL query component (RFC 3986
/// `application/x-www-form-urlencoded` semantics with `+` for space).
/// Preserves the unreserved set (`A-Z`, `a-z`, `0-9`, `-`, `_`, `.`,
/// `~`); everything else becomes `%XX`. api-round-10 SEV-2
/// 2026-07-12 — was raw-splicing values into the URL.
fn percent_encode_component(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
b' ' => out.push('+'),
_ => {
use std::fmt::Write;
let _ = write!(out, "%{b:02X}");
}
}
}
out
}
fn curl_block_bounds(lines: &[&str], cursor_row: usize) -> (usize, usize) {
let starts: Vec<usize> = lines
.iter()
.enumerate()
.filter_map(|(i, l)| l.trim_start().starts_with("###").then_some(i))
.collect();
if starts.is_empty() {
return (0, lines.len().saturating_sub(1));
}
match starts.iter().rev().find(|&&s| s <= cursor_row).copied() {
Some(s) => {
let end = starts
.iter()
.find(|&&n| n > s)
.map(|&n| n - 1)
.unwrap_or(lines.len().saturating_sub(1));
(s, end)
}
None => (0, starts[0].saturating_sub(1)),
}
}
/// #polish 2026-07-06 — env-name resolver used by every write path.
/// Returns `(name, is_fallback)`. `is_fallback = true` when nothing
/// (env override, config default, `.rqst/config`) picked a name and
/// we defaulted to `"dev"`. Callers use the flag to surface a one-
/// shot toast so the user sees WHY their var landed in `dev.env`
/// instead of the file they were expecting.
fn resolve_env_name_with_fallback(
workspace: &std::path::Path,
override_: Option<&str>,
config_default: Option<&str>,
) -> (String, bool) {
match crate::http::template::EnvSet::select_with_config_default(
workspace,
override_,
config_default,
)
.name()
{
Some(n) => (n.to_string(), false),
None => ("dev".to_string(), true),
}
}
/// Run `websocat --exit-on-eof -n1 <url>` with `message` written to
/// stdin. Polls for child exit up to `timeout_ms`; kills + reports
/// "timeout" on overrun. Called from a worker thread.
fn run_websocat_send(
url: &str,
message: &str,
timeout_ms: u64,
headers: &[(String, String)],
) -> Result<WsSendOutput, String> {
let mut cmd = std::process::Command::new("websocat");
cmd.arg("--exit-on-eof").arg("-n1").arg(url);
for (k, v) in headers {
cmd.arg("-H").arg(format!("{k}: {v}"));
}
cmd.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| format!("spawn websocat: {e} (is it on PATH?)"))?;
if let Some(mut stdin) = child.stdin.take() {
use std::io::Write;
let _ = writeln!(stdin, "{message}");
drop(stdin);
}
let started = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
let out = child
.wait_with_output()
.map_err(|e| format!("websocat wait: {e}"))?;
return Ok(WsSendOutput {
stdout: out.stdout,
stderr: out.stderr,
elapsed_ms: started.elapsed().as_millis(),
});
}
Ok(None) => {
if started.elapsed().as_millis() as u64 > timeout_ms {
let _ = child.kill();
return Err(format!("timeout after {timeout_ms}ms"));
}
std::thread::sleep(std::time::Duration::from_millis(50));
}
Err(e) => return Err(format!("websocat: {e}")),
}
}
}
/// Replace the named block inside an `.http` / `.rest` source with the
/// pre-rendered `new_block` text, leaving every other block untouched.
/// `name` is what `RequestPane.source_block_name` stored — `Some(s)` means
/// the matched block had `### s` (or `### ` alone when `s.is_empty()`); the
/// only `None` case here is a single-block file, which the caller handles
/// separately. Returns `None` when the file no longer parses as multi-block,
/// or no block matches — caller falls back to whole-file overwrite.
fn splice_http_block(existing: &str, name: Option<&str>, new_block: &str) -> Option<String> {
let blocks = crate::http::file::parse_all(existing).ok()?;
if blocks.len() < 2 {
return None;
}
let lines: Vec<&str> = existing.split('\n').collect();
// Resolve the `### name` separator on each block (`Block.name` is the text
// after `###`; we also need to know whether the block had a separator at
// all, since the leading block in a multi-block file doesn't).
let block_separator_name = |b: &crate::http::file::Block| -> Option<String> {
lines
.get(b.start_line)
.and_then(|l| l.trim_start().strip_prefix("###"))
.map(|rest| rest.trim().to_string())
};
let target_idx = blocks.iter().position(|b| match name {
// Match both "had a `###` separator" and the right name.
Some(want) => block_separator_name(b).is_some_and(|n| n == want),
// We only call this with `Some(name)` from the caller, but stay safe.
None => block_separator_name(b).is_none(),
})?;
let target = &blocks[target_idx];
let last_idx = lines.len().saturating_sub(1);
let end = target.end_line.min(last_idx);
// The replacement carries its own trailing newline (from `as_http_block`).
// Trim it before splicing so the file's existing line structure isn't
// double-newlined when we re-join.
let replacement = new_block.trim_end_matches('\n');
let mut out: Vec<String> = Vec::with_capacity(lines.len());
out.extend(lines[..target.start_line].iter().map(|s| s.to_string()));
for line in replacement.split('\n') {
out.push(line.to_string());
}
// api-workflow-user 3rd 2026-06-29 SEV-3: preserve the blank
// separator between the unnamed leading block and the first
// `###` block. The leading block's `end_line` absorbs the
// trailing blank line; `as_http_block(None)` doesn't emit a
// replacement, so the splice removed the blank silently.
// Restore it by checking whether the line we're about to
// splice over (lines[end]) was blank AND there's a following
// `###` block in the suffix — that's the leading-block
// signature.
let removed_blank = lines.get(end).is_some_and(|l| l.trim().is_empty());
let next_starts_with_separator = lines
.get(end + 1)
.is_some_and(|l| l.trim_start().starts_with("###"));
if removed_blank && next_starts_with_separator {
out.push(String::new());
}
if end < last_idx {
out.extend(lines[end + 1..].iter().map(|s| s.to_string()));
}
let mut joined = out.join("\n");
// Preserve the original file's trailing-newline policy.
if existing.ends_with('\n') && !joined.ends_with('\n') {
joined.push('\n');
}
Some(joined)
}
/// Read the most-distinctive `# ...` comment from a `.curl` /
/// `.http` file's leading comment block for the bufferline tab
/// label.
///
/// Priority (2026-07-09):
/// 1. **`# example: <name>`** — named-example expansions from
/// `discover` (`POST /admin/event` with 200+ event variants
/// share the SAME operation summary — "Trigger an event" —
/// but the example name IS the distinctive info). When
/// present, `<name>` wins.
/// 2. **First plain `# <text>` / `// <text>`** — the operation
/// summary from the swagger. Skips empty lines and the
/// `# METHOD /path` routing marker discover writes.
/// 3. `None` if the leading block has no matching comment.
///
/// Only lines at the top before the first non-comment line count.
/// Both `#` and `//` markers accepted.
fn extract_summary(text: &str) -> Option<String> {
// First pass: named-example wins if present.
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let body = if trimmed.starts_with('#') {
// Strip ALL leading `#`s so `### block-name` becomes
// `block-name`, not `## block-name`. api-workflow round-9
// SEV-2 2026-07-11 — was leaving one `#` in the summary.
trimmed.trim_start_matches('#').trim()
} else if let Some(rest) = trimmed.strip_prefix("//") {
rest.trim()
} else {
break;
};
if let Some(rest) = body.strip_prefix("example:") {
let name = rest.trim();
if !name.is_empty() {
return Some(name.to_string());
}
}
}
// Second pass: first non-empty, non-METHOD-path, non-example
// comment wins.
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
// api-workflow round-9 SEV-2 2026-07-12 — was `strip_prefix('#')`
// (one `#` only), so `### block-name` in a multi-block .http
// file returned `## block-name` as the summary. Strip all
// leading `#`s so `### get` → `get`.
let body = if trimmed.starts_with('#') {
trimmed.trim_start_matches('#').trim()
} else if let Some(rest) = trimmed.strip_prefix("//") {
rest.trim()
} else {
break;
};
if body.is_empty() || body.starts_with("example:") {
continue;
}
// Skip `# METHOD /path` markers (discover adds them right
// before the curl line).
let head = body.split_whitespace().next().unwrap_or("");
if matches!(
head,
"GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS"
) && body.contains('/')
{
continue;
}
return Some(body.to_string());
}
None
}
/// Does the `.env` file at `path` contain a (non-comment) line
/// for `key`? Used by `write_env_var` to decide which file gets
/// the write when both `.mnml/env/` and `.rqst/env/` exist.
/// True when `pat` is `.mnml` OR starts with `.mnml/` — i.e. the
/// path-first-segment is the mnml config dir, not something merely
/// prefixed with those five characters like `.mnml-backup/` or
/// `.mnmlrc`. Used by the auto-gitignore negation check so that
/// unrelated dotfiles/dirs don't false-trigger skip-and-warn.
fn starts_with_mnml_segment(pat: &str) -> bool {
pat == ".mnml" || pat.starts_with(".mnml/")
}
/// #861 — on the first `.mnml/env/*.env` write in a git-tracked
/// workspace, make sure `.gitignore` at the workspace root contains
/// a `.mnml/env/` line so freshly-written API tokens can't
/// accidentally end up in a commit.
///
/// Guardrails:
/// - **Git repo only.** Non-git workspaces have no commit risk to
/// guard against, and creating a `.gitignore` in a tempdir
/// scratch or non-git folder is presumptuous. Detects via
/// `<workspace>/.git` existing (dir OR file — worktrees stamp a
/// `.git` FILE that points at the real one, still counts).
/// - **Idempotent.** If any existing gitignore line already covers
/// `.mnml/env/` (with or without trailing slash, or the broader
/// `.mnml/**` pattern), no-op. Only appends when a genuinely
/// new pattern is missing.
/// - **Append-only.** Never rewrites existing gitignore lines or
/// reorders — just adds one line if needed.
///
/// Returns `Some(toast)` when a modification was made so the caller
/// can surface it (rare enough that users benefit from seeing what
/// happened). `None` for skip / no-op cases.
fn ensure_mnml_env_gitignored(workspace: &std::path::Path) -> Option<String> {
// .git can be a dir (normal repo) or a file (git worktree
// stub pointing at the real dir). Both count as "this is
// tracked by git" for our purposes.
let git_marker = workspace.join(".git");
if !git_marker.exists() {
return None;
}
let gitignore = workspace.join(".gitignore");
let existing = std::fs::read_to_string(&gitignore).unwrap_or_default();
// First-pass: refuse to append if the user has explicitly
// WHITELISTED any `.mnml/…` path via a `!…` negation — either
// targeting `env/` directly OR a broader `.mnml/**` / `.mnml/`
// that would include env. Our append would land AFTER their
// negation and silently override it (gitignore is order-
// dependent — a later broad ignore wins over an earlier
// negation). Path-anchored `/.mnml/…` counts too. Non-`.mnml`
// negations (`!node_modules/`, `!vendor/.mnml/env-old/`) don't
// match — they can't collide with our `.mnml/env/` append.
//
// Reviewer 2026-08-03. NOTE: single-process-per-workspace
// today so the read-modify-write is safe; add a lock file if
// we ever share workspaces across processes.
let has_mnml_negation = existing.lines().any(|line| {
let trimmed = line.trim();
if !trimmed.starts_with('!') {
return false;
}
let pat = trimmed[1..].trim_start();
// Path-segment boundary — not a raw prefix. `.mnml-backup/`
// and `.mnmlrc` shouldn't false-positive; only patterns
// that ARE `.mnml` or start with `.mnml/` count. Ditto for
// the leading-slash variant. Reviewer 2026-08-03.
starts_with_mnml_segment(pat)
|| starts_with_mnml_segment(pat.strip_prefix('/').unwrap_or(""))
});
if has_mnml_negation {
return Some(
".gitignore has an explicit `!.mnml/…` negation; \
leaving it alone. Verify tokens aren't committable manually."
.to_string(),
);
}
// Coarse but effective — any line that mentions `.mnml/env`
// (with or without trailing slash / glob) is treated as
// already covering us. Comments starting with `#` skipped.
let already_covered = existing.lines().any(|line| {
let trimmed = line.trim();
if trimmed.starts_with('#') || trimmed.is_empty() {
return false;
}
trimmed == ".mnml/env"
|| trimmed == ".mnml/env/"
|| trimmed == ".mnml/env/*"
|| trimmed == ".mnml/env/**"
|| trimmed == ".mnml/"
|| trimmed == ".mnml"
|| trimmed == ".mnml/**"
});
if already_covered {
return None;
}
// Preserve trailing newline hygiene: if the file exists and
// doesn't end in `\n`, add one before our append so we don't
// glue our line onto the last existing line.
let mut new_body = existing.clone();
if !new_body.is_empty() && !new_body.ends_with('\n') {
new_body.push('\n');
}
new_body.push_str(".mnml/env/\n");
std::fs::write(&gitignore, new_body).ok()?;
Some(".gitignore: appended .mnml/env/ (keeps API tokens out of commits)".to_string())
}
fn file_contains_env_key(path: &std::path::Path, key: &str) -> bool {
let Ok(text) = std::fs::read_to_string(path) else {
return false;
};
text.lines().any(|line| {
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
return false;
}
trimmed
.split_once('=')
.is_some_and(|(k, _)| k.trim() == key)
})
}
/// Insert-or-replace a `KEY=VALUE` line in an `.env` file body.
/// Preserves comments + ordering of other keys. If `var` isn't
/// present, appends a new line. Used by the lookup picker's
/// final stage to write picked items to the active env file.
/// Errs only when a malformed value would corrupt the file.
fn upsert_env_var(existing: &str, var: &str, value: &str) -> Result<String, String> {
if value.contains('\n') {
return Err("lookup: value can't contain newline".into());
}
let mut replaced = false;
let mut out = String::with_capacity(existing.len() + var.len() + value.len() + 8);
for line in existing.lines() {
let trimmed = line.trim_start();
if !replaced
&& !trimmed.starts_with('#')
&& let Some((k, _)) = trimmed.split_once('=')
&& k.trim() == var
{
out.push_str(&format!("{var}={value}\n"));
replaced = true;
continue;
}
out.push_str(line);
out.push('\n');
}
if !replaced {
if !out.ends_with('\n') && !out.is_empty() {
out.push('\n');
}
out.push_str(&format!("{var}={value}\n"));
}
Ok(out)
}
impl App {
/// The env set every HTTP-side read/write/send should agree on.
///
/// api-round-11 SEV-1 (edit surface wiping Vars-cell values) +
/// api-round-12 SEV-1 (send surface failing "unresolved vars"
/// on a green Vars tab) both fell out of the read/write/send
/// paths disagreeing about which env is active in a
/// `.mnml`-only workspace. Route everything through
/// [`crate::http::template::EnvSet::select_with_full_fallback`]
/// so the fallback (explicit → $MNML_ENV → `[http] default_env`
/// → `.rqst/config` → literal "dev") is the same for every
/// surface — Vars tab render, edit-seed, write, delete,
/// send, refire, bench, extract, chain, CLI `run`, CLI
/// `chain run`.
pub(crate) fn active_envset(&self) -> crate::http::template::EnvSet {
crate::http::template::EnvSet::select_with_full_fallback(
&self.workspace,
self.http_env_override.as_deref(),
self.config.http.default_env.as_deref(),
)
}
/// `http.insert_header` — opens a picker over common HTTP
/// header names. Enter inserts `Name: ` at the active Request
/// pane's Headers cursor (or appends if no Headers field
/// focus). Saves the user typing `Content-Type`/`Accept`/etc
/// from memory.
pub fn http_insert_header_picker(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
const COMMON_HEADERS: &[(&str, &str)] = &[
// Content negotiation
("Accept", "Acceptable media types for the response"),
(
"Accept-Encoding",
"Acceptable content encodings (gzip, br, …)",
),
("Accept-Language", "Preferred natural languages"),
("Accept-Charset", "Preferred character sets"),
("Content-Type", "Media type of the request body"),
("Content-Length", "Size of the request body in bytes"),
(
"Content-Encoding",
"Encoding applied to the body (gzip, br, …)",
),
("Content-Disposition", "Attachment / inline indicator"),
// Auth + identity
(
"Authorization",
"Credentials for authentication (Bearer, Basic, …)",
),
("Cookie", "HTTP cookies"),
("X-Api-Key", "API key (convention)"),
("X-Auth-Token", "Auth token (convention)"),
// Caching / conditionals
("Cache-Control", "Caching directives (no-cache, max-age=…)"),
("Pragma", "Implementation-specific cache directives"),
("If-Match", "Conditional request — match this ETag"),
(
"If-None-Match",
"Conditional request — NOT this ETag (caching)",
),
(
"If-Modified-Since",
"Conditional request — modified after this date",
),
(
"If-Unmodified-Since",
"Conditional request — not modified since",
),
// Routing / origin
("Host", "Target hostname (usually auto-set by clients)"),
("Origin", "Origin of the request (CORS)"),
("Referer", "URL of the referring page"),
("User-Agent", "Client identification string"),
// CORS preflight (request side)
(
"Access-Control-Request-Method",
"CORS preflight — intended method",
),
(
"Access-Control-Request-Headers",
"CORS preflight — intended headers",
),
// Proxy / forwarding
("X-Forwarded-For", "Original client IP (proxy chain)"),
(
"X-Forwarded-Proto",
"Original scheme (http/https) through proxy",
),
("X-Forwarded-Host", "Original Host header through proxy"),
("X-Real-IP", "Original client IP (nginx convention)"),
// Tracing / debugging
("X-Trace-Id", "Distributed-trace correlation id"),
("X-Request-Id", "Request correlation id"),
("X-Correlation-Id", "Correlation id (convention)"),
// GraphQL / RPC
("X-GraphQL-Operation", "GraphQL operation name"),
// Misc
("X-Requested-With", "XMLHttpRequest / fetch indicator"),
("DNT", "Do Not Track preference (1 = opt-out)"),
("Upgrade-Insecure-Requests", "1 = prefer HTTPS (CSP)"),
];
let items: Vec<PickerItem> = COMMON_HEADERS
.iter()
.map(|(name, hint)| {
PickerItem::new(name.to_string(), name.to_string(), hint.to_string())
})
.collect();
self.open_picker(Picker::new(
PickerKind::HttpHeader,
"Insert HTTP header",
items,
));
}
/// `http.generate_code` — open a picker over supported
/// languages (curl / Python requests / JS fetch / Go / wget /
/// HTTPie). On accept, render the active Request pane as
/// source code in that language, copy to the system clipboard,
/// and toast. Bruno-style Generate Code affordance.
pub fn http_generate_code_prompt(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let has_req = matches!(
self.active.and_then(|i| self.panes.get(i)),
Some(Pane::Request(_))
);
if !has_req {
self.toast("generate: no active Request pane");
return;
}
let items: Vec<PickerItem> = [
("curl", "cURL", "shell one-liner"),
("python", "Python", "requests library"),
("js", "JavaScript", "fetch API"),
("go", "Go", "net/http"),
("wget", "wget", "shell one-liner"),
("httpie", "HTTPie", "shell one-liner"),
]
.iter()
.map(|(id, name, hint)| PickerItem::new(id.to_string(), name.to_string(), hint.to_string()))
.collect();
self.open_picker(Picker::new(PickerKind::HttpGenerateCode, "Copy as:", items));
}
/// Copy the active Request pane's Done response body to the
/// system clipboard. No-op + toast when there's no response.
/// Same shape as `http.copy_curl` but for the response side.
pub fn http_copy_response_body(&mut self) {
let Some(cur) = self.active else { return };
let body = match self.panes.get(cur) {
Some(Pane::Request(rp)) => match &rp.state {
crate::request_pane::RunState::Done(r) => r.body.clone(),
crate::request_pane::RunState::Streaming(r) => r.body.clone(),
_ => {
self.toast("copy: no response body yet");
return;
}
},
_ => return,
};
self.clipboard.set(body, false);
self.toast("response body copied");
}
/// Copy the active Request pane's response headers to the
/// clipboard, one per line as `Name: value`. Same shape as
/// `http_copy_response_body` but for the header pane.
pub fn http_copy_response_headers(&mut self) {
let Some(cur) = self.active else { return };
let headers = match self.panes.get(cur) {
Some(Pane::Request(rp)) => match &rp.state {
crate::request_pane::RunState::Done(r) => r.headers.clone(),
crate::request_pane::RunState::Streaming(r) => r.headers.clone(),
_ => {
self.toast("copy: no response yet");
return;
}
},
_ => return,
};
let text: String = headers
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect::<Vec<_>>()
.join("\n");
self.clipboard.set(text, false);
self.toast(format!("{} headers copied", headers.len()));
}
/// Toggle the Response body's wrap mode. Same as the `w` chord
/// over a Request pane in Response view; exposed as a chip on
/// the Response tab strip so mouse users can find it.
pub fn http_toggle_response_wrap(&mut self) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.body_wrap = !rp.body_wrap;
}
}
/// Open a picker for the Response body's render format.
pub fn http_response_format_prompt(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let has_req = matches!(
self.active.and_then(|i| self.panes.get(i)),
Some(Pane::Request(_))
);
if !has_req {
return;
}
let items: Vec<PickerItem> = [
("auto", "Auto", "detect from content-type + body shape"),
("json", "JSON", "force syntax highlight"),
("xml", "XML", "plain text (no highlight yet)"),
("html", "HTML", "plain text (no highlight yet)"),
("text", "Text", "plain text (no highlight)"),
]
.iter()
.map(|(id, name, hint)| PickerItem::new(id.to_string(), name.to_string(), hint.to_string()))
.collect();
self.open_picker(Picker::new(
PickerKind::HttpResponseFormat,
"Render response as:",
items,
));
}
/// Accept handler for `PickerKind::HttpResponseFormat`. Stores
/// the choice on `RequestPane::response_body_format`.
pub fn accept_http_response_format(&mut self, format_id: &str) {
use crate::request_pane::ResponseBodyFormat;
let Some(cur) = self.active else { return };
let format = match format_id {
"auto" => ResponseBodyFormat::Auto,
"json" => ResponseBodyFormat::Json,
"xml" => ResponseBodyFormat::Xml,
"html" => ResponseBodyFormat::Html,
"text" => ResponseBodyFormat::Text,
_ => return,
};
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.response_body_format = format;
}
}
/// Accept handler for `PickerKind::HttpGenerateCode` — renders
/// the active Request pane in the picked language and copies to
/// the clipboard.
pub fn accept_http_generate_code(&mut self, lang_id: &str) {
let Some(cur) = self.active else { return };
let snippet = match self.panes.get(cur) {
Some(Pane::Request(rp)) => match lang_id {
"curl" => rp.as_curl(),
"python" => rp.as_python(),
"js" => rp.as_js_fetch(),
"go" => rp.as_go(),
"wget" => rp.as_wget(),
"httpie" => rp.as_httpie(),
_ => {
self.toast(format!("generate: unknown language `{lang_id}`"));
return;
}
},
_ => return,
};
self.clipboard.set(snippet, false);
self.toast(format!("copied as {lang_id}"));
}
/// Accept handler for `PickerKind::HttpHeader`. Inserts
/// `<name>: ` at the Headers cursor (or appends as a new
/// line if there's existing content).
pub fn accept_http_header(&mut self, name: &str) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let to_insert = if rp.headers_buffer.is_empty() || rp.headers_buffer.ends_with('\n') {
format!("{name}: ")
} else {
format!("\n{name}: ")
};
let cursor = rp.headers_buffer.len();
rp.headers_buffer.push_str(&to_insert);
rp.headers_cursor = rp.headers_buffer.len();
rp.view = crate::request_pane::ViewMode::Edit;
rp.focus = crate::request_pane::EditField::Headers;
rp.edit_tab = crate::request_pane::EditTab::Headers;
self.toast(format!("header: inserted {name}"));
let _ = cursor;
}
}
/// Open a picker of every `.env` file the workspace knows about
/// (both `.mnml/env/*.env` and `.rqst/env/*.env`). Accepting a
/// row sets `App::http_env_override` so subsequent
/// `EnvSet::select*` calls resolve against the picked env. (#11)
pub fn open_http_env_picker(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let mut seen: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for sub in [".mnml", ".rqst"] {
let dir = self.workspace.join(sub).join("env");
if let Ok(rd) = std::fs::read_dir(&dir) {
for entry in rd.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("env")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
seen.insert(stem.to_string());
}
}
}
}
if seen.is_empty() {
self.toast("http: no `.env` files under `.mnml/env/` or `.rqst/env/`");
return;
}
let current = self.http_env_override.clone().or_else(|| {
std::env::var("MNML_ENV")
.ok()
.filter(|s| !s.trim().is_empty())
});
let items: Vec<PickerItem> = seen
.into_iter()
.map(|name| {
let hint = if Some(&name) == current.as_ref() {
"current".to_string()
} else {
String::new()
};
PickerItem::new(name.clone(), name, hint)
})
.collect();
self.open_picker(Picker::new(PickerKind::HttpEnv, "Pick env", items));
}
/// Accept handler for `PickerKind::HttpEnv`. Stores the picked
/// env name on `App::http_env_override`.
pub fn accept_http_env(&mut self, name: &str) {
self.http_env_override = Some(name.to_string());
self.toast(format!("http env: {name}"));
}
/// `+ New env` chip in the sidebar → prompt for a name.
pub fn http_new_env_prompt(&mut self) {
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::HttpNewEnv,
"New env name (creates .mnml/env/<name>.env):".to_string(),
));
}
/// Accept handler — creates `.mnml/env/<name>.env`, sets it as
/// the active env, refreshes the sidebar cache, and opens the
/// file in an editor pane so the user can add vars.
pub fn http_new_env_create(&mut self, name: &str) {
let name = name.trim();
if name.is_empty() {
self.toast("env: name can't be empty");
return;
}
if name.contains(['/', '\\']) {
self.toast("env: name can't contain path separators");
return;
}
let dir = self.workspace.join(".mnml").join("env");
if let Err(e) = std::fs::create_dir_all(&dir) {
self.toast(format!("env: create dir failed: {e}"));
return;
}
let path = dir.join(format!("{name}.env"));
if path.exists() {
// Don't clobber — just switch to it.
self.http_env_override = Some(name.to_string());
self.http_panel_refresh();
self.toast(format!("env: switched to existing {name}"));
return;
}
let stub = format!("# {name} env — one KEY=VALUE per line\n");
if let Err(e) = std::fs::write(&path, stub) {
self.toast(format!("env: write failed: {e}"));
return;
}
self.http_env_override = Some(name.to_string());
self.http_panel_refresh();
self.open_path(&path);
self.toast(format!("env: created + switched to {name}"));
}
/// #polish 2026-07-06 — open a `.http`/`.curl`/`.rest` file
/// as a Request pane (parses the file, populates the pane's
/// form fields, wires \`source_path\` so Ctrl+S writes back).
/// Falls back to a plain text editor pane if the file doesn't
/// parse — that way a corrupt/half-written file is still
/// reachable.
pub fn open_request_pane_from_file(&mut self, path: &std::path::Path) {
use crate::pane::Pane;
use crate::request_pane::{EditField, RequestPane, RunState, ViewMode};
// Already open as a Request pane? Reveal it.
if let Some(i) = self
.panes
.iter()
.position(|p| matches!(p, Pane::Request(rp) if rp.source_path.as_deref() == Some(path)))
{
self.reveal_pane(i);
return;
}
let text = match std::fs::read_to_string(path) {
Ok(t) => t,
Err(e) => {
self.toast(format!("open: {}: {e}", path.display()));
return;
}
};
// api-workflow SEV-1 fix 2026-07-10 — parse_all so we can
// capture the FIRST block's name into `source_block_name`.
// Without this, `file.save` on a multi-block .http file
// falls through the named-block splice and CLOBBERS the
// whole file with a single curl line — silently deleting
// blocks 2, 3, N.
//
// api-workflow SEV-1 round-7 2026-07-11 — .curl files must
// route to the curl parser FIRST. `parse_all` is a naive
// `.http`-file line splitter; on `curl {{BASE_URL}}/echo …`
// it happily accepts "CURL" as an HTTP method because
// `{{BASE_URL}}/echo` matches its `looks_like_url` check,
// silently corrupting every flag on the line. Check the
// extension to pick the right primary parser.
let is_curl_ext = path
.extension()
.and_then(|s| s.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("curl"));
// api-workflow round-8 SEV-2 2026-07-11 — pass the .curl file's
// own dir as the multipart base_dir so `-F name=@relpath` finds
// sibling files without depending on the process's CWD.
let source_dir = path.parent();
let blocks = if is_curl_ext {
match crate::http::parse_with_base(&text, source_dir) {
Ok(r) => {
let end = text.matches('\n').count();
vec![crate::http::file::Block {
name: None,
start_line: 0,
end_line: end,
request: r,
}]
}
Err(_) => match crate::http::file::parse_all(&text) {
Ok(bs) => bs,
Err(_) => {
self.toast(format!(
"http: parse failed for {}, opened as text",
path.display()
));
self.open_path_as_editor(path);
return;
}
},
}
} else {
match crate::http::file::parse_all(&text) {
Ok(bs) => bs,
Err(_) => match crate::http::parse(&text) {
Ok(_) => Vec::new(),
Err(_) => {
self.toast(format!(
"http: parse failed for {}, opened as text",
path.display()
));
self.open_path_as_editor(path);
return;
}
},
}
};
let (request, source_block_name) = if let Some(first) = blocks.first() {
(first.request.clone(), first.name.clone())
} else {
// Fallback single-parse path (parse_all returned Empty
// but the standalone `parse` succeeded above).
match crate::http::parse(&text) {
Ok(r) => (r, None),
Err(_) => {
self.toast(format!(
"http: parse failed for {}, opened as text",
path.display()
));
self.open_path_as_editor(path);
return;
}
}
};
let script = crate::http::script::parse(&text);
let mut pane = RequestPane::new(Some(path.to_path_buf()), request, script, 0);
pane.source_block_name = source_block_name;
// Pull the tab summary from the file's leading `# ...`
// comment. Discover-generated stubs put the swagger
// operation's `summary` on the first line; matching format
// works for hand-authored `.curl`/`.http` files too. Skip
// the `# example: <name>` line (that's the named-example
// marker, not the operation title) — first non-`example`
// comment wins.
pane.summary = extract_summary(&text);
// Land in Edit view on the URL field — the user just clicked
// a request, so they're likely about to fire or tweak it.
pane.view = ViewMode::Edit;
pane.focus = EditField::Url;
pane.state = RunState::Failed("not sent yet · press `r` to fire".to_string());
// File-backed requests open in PREVIEW mode too — arrowing
// through the tree / HTTP-panel COLLECTIONS shouldn't pile
// up tabs for each request the user glances at. The first
// edit promotes; a subsequent preview-open replaces this
// pane. 2026-07-08.
pane.is_preview = true;
// Preview-replace path: if any existing Request pane is
// still in preview, REPLACE its contents instead of
// spawning a new pane. Keeps the "one browsing tab as I
// flip through requests" idiom.
if let Some(preview_pid) = self
.panes
.iter()
.position(|p| matches!(p, Pane::Request(rp) if rp.is_preview))
{
self.panes[preview_pid] = Pane::Request(pane);
self.active = Some(preview_pid);
self.focus = crate::focus::Focus::Pane;
self.maybe_auto_format_active_body();
self.note_recent_file(path);
return;
}
self.panes.push(Pane::Request(pane));
let new_id = self.panes.len() - 1;
if self.active.is_some() {
self.reveal_pane(new_id);
} else {
*self.layout_mut() = crate::layout::Layout::leaf(new_id);
self.active = Some(new_id);
}
self.focus = crate::focus::Focus::Pane;
// Format the just-loaded body when auto-format is on.
// Files saved in prior sessions might have compressed bodies;
// this keeps the "always pretty" invariant.
self.maybe_auto_format_active_body();
self.note_recent_file(path);
}
/// #polish 2026-07-06 — companion to `open_request_pane_from_file`.
/// Force-open the file as a plain text Editor pane, bypassing
/// the extension-based routing in `open_path`. Used by right-
/// click "Open as text" on HTTP-panel rows and the "raw" chip
/// on the Request pane top bar.
pub fn open_path_as_editor(&mut self, path: &std::path::Path) {
use crate::pane::Pane;
// Reuse existing editor pane for this path if one's open.
if let Some(i) = self
.panes
.iter()
.position(|p| matches!(p, Pane::Editor(b) if b.is_at(path)))
{
self.reveal_pane(i);
return;
}
match crate::buffer::Buffer::open_or_new_empty(path, &self.config) {
Ok(mut buf) => {
buf.apply_editorconfig(&self.workspace);
buf.input.set_ex_history(self.ex_history.clone());
self.panes.push(Pane::Editor(buf));
let new_id = self.panes.len() - 1;
if self.active.is_some() {
self.reveal_pane(new_id);
} else {
*self.layout_mut() = crate::layout::Layout::leaf(new_id);
self.active = Some(new_id);
}
self.focus = crate::focus::Focus::Pane;
self.note_recent_file(path);
}
Err(e) => self.toast(format!("open: {}: {e}", path.display())),
}
}
/// #polish 2026-07-06 — per-collection `+` chip → open a new
/// in-memory Request pane whose `source_path` is pre-seeded to
/// the next unused `req-N.http` inside the given collection
/// folder. Ctrl+S writes it to disk without prompting; the user
/// can rename via Save-As if they want a real name.
pub fn http_new_request_in_collection(&mut self, collection: &std::path::Path) {
use crate::pane::Pane;
use crate::request_pane::{EditField, RequestPane, RunState, ViewMode};
let request = crate::http::Request {
method: "GET".to_string(),
url: String::new(),
headers: Vec::new(),
body: None,
insecure: false,
};
// Pick the next unused req-N.http slot in the collection.
let mut n = 1usize;
let source = loop {
let candidate = collection.join(format!("req-{n}.http"));
if !candidate.exists() {
break candidate;
}
n += 1;
if n > 999 {
self.toast("collection: too many req-N.http files (999+)");
return;
}
};
let mut pane = RequestPane::new(
Some(source.clone()),
request,
crate::http::script::Script::default(),
0,
);
pane.view = ViewMode::Edit;
pane.focus = EditField::Url;
pane.state = RunState::Failed("not sent yet · press `r` to fire".to_string());
self.panes.push(Pane::Request(pane));
let new_id = self.panes.len() - 1;
if self.active.is_some() {
self.reveal_pane(new_id);
} else {
*self.layout_mut() = crate::layout::Layout::leaf(new_id);
self.active = Some(new_id);
}
self.focus = crate::focus::Focus::Pane;
let rel = crate::app::rel_path(&self.workspace, &source);
self.toast(format!("new request → {rel} (Ctrl+S to save)"));
}
/// `+ New chain` chip in the sidebar → prompt for a name.
pub fn http_new_chain_prompt(&mut self) {
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::HttpNewChain,
"New chain name (creates .mnml/chains/<name>.chain.json):".to_string(),
));
}
/// Accept handler — creates `.mnml/chains/<name>.chain.json` with a
/// starter template (see `crate::http::chain` for the schema),
/// refreshes the sidebar cache, and opens it in an editor pane so
/// the user can fill in steps.
pub fn http_new_chain_create(&mut self, name: &str) {
let name = name.trim();
if name.is_empty() {
self.toast("chain: name can't be empty");
return;
}
if name.contains(['/', '\\']) {
self.toast("chain: name can't contain path separators");
return;
}
let dir = self.workspace.join(".mnml").join("chains");
if let Err(e) = std::fs::create_dir_all(&dir) {
self.toast(format!("chain: create dir failed: {e}"));
return;
}
let path = dir.join(format!("{name}.chain.json"));
if path.exists() {
self.toast(format!("chain: {name}.chain.json already exists"));
self.open_path(&path);
return;
}
// Two-step template: fire one GET, capture something, fire a
// POST with the captured value. Users can adapt.
let stub = format!(
"{{\n \"name\": \"{name}\",\n \"steps\": [\n {{\n \"name\": \"login\",\n \"request\": {{\n \"method\": \"POST\",\n \"url\": \"https://example.test/login\",\n \"headers\": {{ \"Content-Type\": \"application/json\" }},\n \"body\": \"{{\\\"user\\\":\\\"alice\\\",\\\"pass\\\":\\\"...\\\"}}\"\n }},\n \"capture\": {{ \"token\": \"$.access_token\" }}\n }},\n {{\n \"name\": \"whoami\",\n \"request\": {{\n \"method\": \"GET\",\n \"url\": \"https://example.test/me\",\n \"headers\": {{ \"Authorization\": \"Bearer {{{{token}}}}\" }}\n }}\n }}\n ]\n}}\n"
);
if let Err(e) = std::fs::write(&path, stub) {
self.toast(format!("chain: write failed: {e}"));
return;
}
self.http_panel_refresh();
self.open_path(&path);
self.toast(format!("chain: created {name}.chain.json"));
}
/// `+ New collection` chip in the sidebar → prompt for a name.
pub fn http_new_collection_prompt(&mut self) {
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::HttpNewCollection,
"New collection name (creates .mnml/collections/<name>/):".to_string(),
));
}
/// Accept handler — creates `.mnml/collections/<name>/` with a
/// starter `.http` file, refreshes the sidebar cache, and opens
/// the starter file so the user can populate it.
pub fn http_new_collection_create(&mut self, name: &str) {
let name = name.trim();
if name.is_empty() {
self.toast("collection: name can't be empty");
return;
}
if name.contains(['/', '\\']) {
self.toast("collection: name can't contain path separators");
return;
}
// Guard path-traversal — `.` / `..` join into the parent dir
// (writing the starter file to `.mnml/` alongside `ipc/`,
// `chains/`, etc.). code-reviewer 2026-07-06.
if name == "." || name == ".." {
self.toast("collection: name can't be '.' or '..'");
return;
}
// #polish 2026-07-06 — write location follows
// `[http] collection_root`. Default: `.mnml/collections/`
// (hidden per-user). Workspace mode drops the collection
// straight at the workspace root, Bruno-flavor.
let dir = match self.config.http.collection_root {
crate::config::HttpCollectionRoot::Hidden => {
self.workspace.join(".mnml").join("collections").join(name)
}
crate::config::HttpCollectionRoot::Workspace => self.workspace.join(name),
};
if dir.exists() {
self.toast(format!("collection: {name}/ already exists"));
return;
}
if let Err(e) = std::fs::create_dir_all(&dir) {
self.toast(format!("collection: create dir failed: {e}"));
return;
}
let starter = dir.join("requests.http");
let stub = "### list\nGET https://example.test/items\n\n### create\nPOST https://example.test/items\nContent-Type: application/json\n\n{\"name\": \"new\"}\n";
if let Err(e) = std::fs::write(&starter, stub) {
self.toast(format!("collection: write failed: {e}"));
return;
}
self.http_panel_refresh();
self.open_path(&starter);
self.toast(format!("collection: created {name}/"));
}
/// Clear the runtime env override so `EnvSet::select` falls back
/// to `MNML_ENV` / config default again.
pub fn http_reset_env(&mut self) {
if self.http_env_override.take().is_some() {
self.toast("http env: reset to default");
}
}
/// Dispatcher for Auth-tab row clicks. `id` matches the
/// row's stable id stored in App.rects.request_auth_rows.
pub fn http_auth_row_clicked(&mut self, id: &str) {
match id {
"set_bearer" => {
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::HttpAuthBearer,
"Bearer token:".to_string(),
));
}
"set_basic" => {
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::HttpAuthBasic,
"Basic auth — user:password:".to_string(),
));
}
"set_api_key" => {
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::HttpAuthApiKey,
"X-Api-Key value:".to_string(),
));
}
"apply_preset" => self.auth_apply_preset_picker(),
"save_preset" => self.auth_save_preset_prompt(),
"clear" => self.http_auth_clear(),
_ => {}
}
}
/// Replace (or insert) a header on the active Request pane.
/// Used by the Auth tab to set Authorization / X-Api-Key.
pub fn http_auth_set(&mut self, name: &str, value: &str) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let pos = rp
.request
.headers
.iter()
.position(|(k, _)| k.eq_ignore_ascii_case(name));
if let Some(i) = pos {
rp.request.headers[i].1 = value.to_string();
} else {
rp.request
.headers
.push((name.to_string(), value.to_string()));
}
rp.headers_buffer = crate::request_pane::headers_to_text(&rp.request.headers);
rp.headers_cursor = rp.headers_buffer.len();
self.toast(format!("auth: set {name}"));
}
}
/// Remove the Authorization header from the active Request.
pub fn http_auth_clear(&mut self) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let pre = rp.request.headers.len();
rp.request
.headers
.retain(|(k, _)| !k.eq_ignore_ascii_case("authorization"));
if rp.request.headers.len() < pre {
rp.headers_buffer = crate::request_pane::headers_to_text(&rp.request.headers);
rp.headers_cursor = rp.headers_buffer.len();
self.toast("auth: cleared Authorization");
} else {
self.toast("auth: no Authorization header to clear");
}
}
}
/// Save the active Request pane. If `source_path` is set, write
/// in place (`save_request_to_source`). Otherwise open a
/// Save-As prompt for the destination `.http` path. Bound to
/// the Save button in the Request pane's top row.
pub fn http_save_or_prompt_save_as(&mut self) {
let Some(cur) = self.active else { return };
let has_source = matches!(
self.panes.get(cur),
Some(Pane::Request(rp)) if rp.source_path.is_some()
);
if has_source {
self.save_request_to_source();
} else {
self.http_save_request_as_prompt();
}
}
/// Open a Save-As prompt for the active Request pane. The typed
/// path is passed to `http_save_request_as` on Enter.
pub fn http_save_request_as_prompt(&mut self) {
let has_request = matches!(
self.active.and_then(|i| self.panes.get(i)),
Some(Pane::Request(_))
);
if !has_request {
self.toast("save: no active Request pane");
return;
}
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::HttpSaveRequestAs,
"Save request to file:".to_string(),
));
}
/// Accept handler for `PromptKind::HttpSaveRequestAs`. Assigns
/// the typed path to the active Request pane's `source_path`
/// (workspace-relative unless absolute) and writes the file via
/// `save_request_to_source`.
pub fn http_save_request_as(&mut self, path: &str) {
let path = path.trim();
if path.is_empty() {
self.toast("save: path can't be empty");
return;
}
let mut p = if path.starts_with('/') {
std::path::PathBuf::from(path)
} else {
self.workspace.join(path)
};
// Default extension to `.http` when the user typed a bare
// name — matches the sidebar convention.
if p.extension().is_none() {
p.set_extension("http");
}
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.source_path = Some(p.clone());
}
self.save_request_to_source();
// Refresh the sidebar file list so the new file appears.
self.http_panel_refresh();
}
/// `http.save_response` — open a prompt for the destination
/// path; on Enter, write the active Done response body there.
pub fn http_save_response_prompt(&mut self) {
use crate::request_pane::RunState;
let has_done = matches!(
self.active.and_then(|i| self.panes.get(i)),
Some(Pane::Request(rp)) if matches!(rp.state, RunState::Done(_))
);
if !has_done {
self.toast("http.save_response: no Done response on active pane");
return;
}
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::HttpSaveResponse,
"Save response to file:".to_string(),
));
}
/// Accept handler for `PromptKind::HttpSaveResponse`. Writes
/// the active pane's Done response body to `path`.
pub fn http_save_response_to(&mut self, path: &str) {
use crate::request_pane::RunState;
let path = path.trim();
if path.is_empty() {
self.toast("save: path can't be empty");
return;
}
let Some(cur) = self.active else { return };
// api-workflow SEV-1 2026-07-11: use body_bytes (raw payload)
// instead of body (UTF-8-lossy display view). Saving a PNG /
// PDF / zip used to write the U+FFFD-replaced view to disk,
// corrupting the file byte-for-byte.
let body_bytes = match self.panes.get(cur) {
Some(Pane::Request(rp)) => match &rp.state {
RunState::Done(r) => r.body_bytes.clone(),
_ => return,
},
_ => return,
};
let p = if path.starts_with('/') {
std::path::PathBuf::from(path)
} else {
self.workspace.join(path)
};
if let Some(parent) = p.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
self.toast(format!("save: mkdir {}: {e}", parent.display()));
return;
}
match std::fs::write(&p, &body_bytes) {
Ok(()) => self.toast(format!(
"save: wrote {} bytes → {}",
body_bytes.len(),
p.display()
)),
Err(e) => self.toast(format!("save: write {}: {e}", p.display())),
}
}
/// `:http.run_chain` — picker over `.mnml/chains/*.chain.json`.
/// Accept fires the chain in a worker thread; the step-by-step
/// trace lands in a `[chain-trace]` scratch when done. Postman
/// runner arc — Postman collections are imported into mnml's
/// chain format via `:http.import_postman` then run with this.
pub fn open_http_chain_picker(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let chains_dir = self.workspace.join(".mnml").join("chains");
let mut entries: Vec<std::path::PathBuf> = match std::fs::read_dir(&chains_dir) {
Ok(rd) => rd
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| {
p.file_name()
.and_then(|s| s.to_str())
.is_some_and(|n| n.ends_with(".chain.json"))
})
.collect(),
Err(_) => Vec::new(),
};
if entries.is_empty() {
self.toast(format!(
"http.run_chain: no chains at {}",
chains_dir.display()
));
return;
}
entries.sort();
let items: Vec<PickerItem> = entries
.iter()
.map(|p| {
let name = p
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.trim_end_matches(".chain.json")
.to_string();
let n_steps = std::fs::read_to_string(p)
.ok()
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|v| v.as_array().map(|a| a.len()))
.unwrap_or(0);
PickerItem::new(
p.to_string_lossy().to_string(),
name,
format!("{n_steps} step(s)"),
)
})
.collect();
self.open_picker(Picker::new(PickerKind::HttpChains, "HTTP chains", items));
}
/// Backing for the `HttpChains` picker's accept handler — spawn
/// a worker that runs the chain and replies via
/// `http_chain_chan`.
pub fn http_chain_run_path(&mut self, chain_file: std::path::PathBuf) {
if self.http_chain_in_flight {
self.toast("http.run_chain: a chain is already running");
return;
}
let tx = self
.http_chain_chan
.get_or_insert_with(std::sync::mpsc::channel)
.0
.clone();
let workspace = self.workspace.clone();
// qa-7th api SEV-2 2026-06-30 — chain runner ignored the
// `[http] default_env` TOML config. Other call sites use
// EnvSet::select_with_config_default; chain went straight
// through `std::env::var`. Fall back to the config default
// when MNML_ENV isn't set.
let env_name = std::env::var("MNML_ENV")
.ok()
.or_else(|| self.config.http.default_env.clone());
// 2026-06-21 — pass the cookie jar to the chain runner so
// multi-step authenticated flows (login → use session
// cookie) actually work.
let cookie_jar = self.cookie_jar.clone();
self.http_chain_in_flight = true;
self.toast(format!(
"chain: running {}…",
chain_file
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
));
std::thread::Builder::new()
.name("mnml-chain-run".into())
.spawn(move || {
let mut trace = String::new();
let result = crate::http::chain::run(
&chain_file,
&workspace,
env_name.as_deref(),
&mut trace,
Some(cookie_jar),
);
let _ = tx.send((trace, result));
})
.ok();
}
/// `tick` hook — drain `:http.run_chain` worker replies.
pub fn drain_http_chain(&mut self) {
let replies: Vec<(String, Result<(), String>)> = match &self.http_chain_chan {
Some((_, rx)) => rx.try_iter().collect(),
None => return,
};
for (trace, result) in replies {
self.http_chain_in_flight = false;
let mut body = trace;
let summary = match &result {
Ok(()) => "✓ chain completed successfully".to_string(),
Err(e) => format!("✗ chain failed: {e}"),
};
body.push_str("\n────\n");
body.push_str(&summary);
body.push('\n');
self.open_scratch_with_text("[chain-trace]".to_string(), body);
self.toast(summary);
}
}
/// `:http.ai_build` — open a prompt asking for a natural-language
/// request description, then spawn a worker that calls Claude
/// (`api_client::nl_to_curl`). The reply lands as a curl command;
/// `drain_http_ai_build` parses it, opens a new Request pane,
/// switches it to the Source tab so the user can see what came
/// back. Requires `$ANTHROPIC_API_KEY`.
pub fn http_ai_build_prompt(&mut self) {
// Task #973 (2026-08-17) — was gated on $ANTHROPIC_API_KEY
// (direct-API path). Now spawns `claude -p` via
// `nl_to_curl`, so the only requirement is that `claude` is
// on PATH. Silent path preferred here since a `command not
// found` from the spawn surfaces a clear stderr in the toast.
if self.http_ai_build_in_flight {
self.toast("http.ai_build: a build is already in flight");
return;
}
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::HttpAiBuild,
"Describe the request (NL → curl):".to_string(),
));
}
/// Accept handler for the `HttpAiBuild` prompt. Spawns a worker
/// thread calling `api_client::nl_to_curl`.
pub fn http_ai_build_accept(&mut self, description: String) {
if description.trim().is_empty() {
self.toast("http.ai_build: empty description");
return;
}
let tx = self
.http_ai_build_chan
.get_or_insert_with(std::sync::mpsc::channel)
.0
.clone();
let model = self.ai_model();
self.http_ai_build_in_flight = true;
self.toast("http.ai_build: calling Claude…");
std::thread::Builder::new()
.name("mnml-http-ai-build".into())
.spawn(move || {
let result = crate::ai::api_client::nl_to_curl(&description, model.as_deref());
let _ = tx.send(result);
})
.ok();
}
/// `tick` hook — drain replies from the `:http.ai_build` worker.
/// Parses the curl reply + opens a new Request pane with the
/// parsed request loaded. Single-shot per call.
pub fn drain_http_ai_build(&mut self) {
let replies: Vec<Result<String, String>> = match &self.http_ai_build_chan {
Some((_, rx)) => rx.try_iter().collect(),
None => return,
};
for result in replies {
self.http_ai_build_in_flight = false;
match result {
Ok(curl) => match crate::http::parse(&curl) {
Ok(parsed) => {
self.open_new_request_pane();
// 2026-06-21 api-workflow SEV-2: was
// `let Some(cur) = self.active else { continue };`
// which silently dropped the AI-built curl
// if open_new_request_pane somehow didn't
// set self.active. Now: toast a clear
// error and skip; the user knows Claude's
// reply was lost.
let Some(cur) = self.active else {
self.toast(
"http.ai_build: couldn't open a Request pane — reply dropped",
);
continue;
};
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.headers_buffer =
crate::request_pane::headers_to_text(&parsed.headers);
rp.headers_cursor = rp.headers_buffer.len();
rp.url_cursor = parsed.url.len();
rp.body_cursor = parsed.body.as_deref().map(str::len).unwrap_or(0);
rp.source_buffer = curl.clone();
rp.source_cursor = curl.len();
rp.request = parsed;
rp.view = crate::request_pane::ViewMode::Edit;
// Land on the Source tab so the user
// immediately sees the curl Claude
// produced (auditable before re-firing).
rp.edit_tab = crate::request_pane::EditTab::Source;
} else {
self.toast(
"http.ai_build: opened pane wasn't a Request pane — reply dropped",
);
continue;
}
self.toast("http.ai_build: ✓ ready (Source tab)");
}
Err(e) => {
self.toast(format!("http.ai_build: parse failed: {e}"));
}
},
Err(e) => {
self.toast(format!("http.ai_build: {e}"));
}
}
}
}
/// `:ws.connect` — open a Prompt for a wss:// URL. Each
/// connection opens its own `Pane::Websocket`; multiple
/// connections can run side by side.
pub fn ws_connect_prompt(&mut self) {
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::WsConnect,
"WebSocket URL (wss://…):".to_string(),
));
}
/// `:ws.send_message` — open a Prompt for the message to send.
/// The message goes to the focused `Pane::Websocket`.
pub fn ws_send_message_prompt(&mut self) {
let Some(i) = self.active else {
self.toast("ws: focus a ws pane first");
return;
};
if !matches!(self.panes.get(i), Some(Pane::Websocket(_))) {
self.toast("ws: focus a ws pane first");
return;
}
// 2026-06-21 api-workflow SEV-3: stash the WS pane index
// at prompt-open time so the accept handler sends to the
// right pane even if the user switched focus mid-prompt.
// Was: accept handler re-checked `self.active`; switching
// panes silently dropped the typed message.
self.pending_ws_send_pane = Some(i);
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::WsSendMessage,
"Message to send:".to_string(),
));
}
/// `:ws.disconnect` — close the focused WS pane's connection.
/// The pane stays open showing the final log; user closes it
/// like any other pane (`:close` / `Ctrl+W`).
pub fn ws_disconnect(&mut self) {
let Some(i) = self.active else {
self.toast("ws: no focused pane");
return;
};
if let Some(Pane::Websocket(p)) = self.panes.get_mut(i) {
p.close();
self.toast("ws: closing…");
} else {
self.toast("ws: focus a ws pane first");
}
}
/// Accept handler — actually connects. Opens a new pane split
/// off the active leaf.
pub fn ws_connect_to(&mut self, url: &str) {
let url = url.trim().to_string();
if url.is_empty() {
self.toast("ws: URL can't be empty");
return;
}
// 2026-06-21 power-user-ws-git SEV-3: reject obviously
// non-WS schemes up front so the user doesn't end up
// staring at a zombie `· closed` tab while wondering
// what happened. http:// / https:// / file:// / etc. are
// dropped here; ws:// + wss:// pass through, and bare
// host:port goes through (tungstenite accepts the
// protocol-less form).
let lower = url.to_lowercase();
if !lower.starts_with("ws://") && !lower.starts_with("wss://") {
// Allow bare host:port (no scheme at all) but reject
// anything with a scheme that ISN'T ws/wss.
if lower.contains("://") {
self.toast(format!(
"ws: only ws:// and wss:// URLs are supported (got {url})"
));
return;
}
}
let opts = crate::websocket::WsConnectOpts {
subprotocols: self.config.ws.subprotocols.clone(),
ping_interval_secs: self.config.ws.ping_interval_secs,
reconnect_max_attempts: self.config.ws.reconnect_max_attempts,
};
let pane = Pane::Websocket(crate::websocket::WebsocketPane::connect(url.clone(), opts));
match self.active {
Some(cur) => {
let new_id = self.split_leaf_with(cur, crate::layout::SplitDir::Horizontal, pane);
self.active = Some(new_id);
}
None => {
self.panes.push(pane);
let id = self.panes.len() - 1;
*self.layout_mut() = crate::layout::Layout::leaf(id);
self.active = Some(id);
}
}
self.focus = Focus::Pane;
self.toast(format!("ws: connecting to {url}"));
}
/// Accept handler — sends the typed message on the focused WS pane.
pub fn ws_send_on_active(&mut self, message: &str) {
// Prefer the pane we were focused on at prompt-open time
// (stashed in `pending_ws_send_pane`). Fall back to current
// focus for backward compat / direct callers.
let target = self.pending_ws_send_pane.take().or(self.active);
let Some(i) = target else {
self.toast("ws: no focused pane");
return;
};
let Some(Pane::Websocket(p)) = self.panes.get_mut(i) else {
self.toast("ws: focus a ws pane first (was the WS pane closed?)");
return;
};
p.input = message.to_string();
p.input_cursor = message.len();
p.send_input();
}
/// Drain incoming WebSocket events for every `Pane::Websocket`.
/// Called from `App.tick`.
pub fn drain_websocket(&mut self) {
for i in 0..self.panes.len() {
if let Some(Pane::Websocket(p)) = self.panes.get_mut(i) {
p.drain();
}
}
}
/// `ws.send` — one-shot WebSocket fire-and-receive via the
/// system `websocat` binary. Sends `message`, waits for a
/// single response, closes. Multi-round-trip or persistent
/// streams are v2 (would need a Pane::Websocket).
///
/// Active editor JSON shape:
/// { "url": "wss://…",
/// "message": "string payload",
/// "timeout_ms": 5000, // optional
/// "headers": { "name": "value" } } // optional
pub fn ws_send_active(&mut self) {
let buf_text = match self.active.and_then(|i| self.panes.get(i)) {
Some(Pane::Editor(b)) => b.editor.text().to_string(),
_ => {
self.toast("ws.send: no active editor");
return;
}
};
let cfg: serde_json::Value = match serde_json::from_str(&buf_text) {
Ok(v) => v,
Err(e) => {
self.toast(format!("ws.send: not valid JSON: {e}"));
return;
}
};
let url = cfg.get("url").and_then(|v| v.as_str()).map(str::to_string);
let Some(url) = url else {
self.toast("ws.send: missing 'url' field");
return;
};
let message = cfg
.get("message")
.map(|v| match v {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
})
.unwrap_or_default();
let timeout_ms = cfg
.get("timeout_ms")
.and_then(|v| v.as_u64())
.unwrap_or(5000);
let headers: Vec<(String, String)> = cfg
.get("headers")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
let tx = self
.ws_send_chan
.get_or_insert_with(std::sync::mpsc::channel)
.0
.clone();
let worker_url = url.clone();
let worker_msg = message.clone();
self.toast(format!("ws: connecting {url}…"));
// 2026-06-21 — was: busy-polled child.wait_with_output() on
// the main app thread for up to timeout_ms ms, freezing
// every render tick (the api-workflow SEV-1 finding). Now:
// spawn a worker that does the websocat call + sends back
// the result via `ws_send_chan`; drain_ws_send opens the
// scratch when it arrives.
std::thread::Builder::new()
.name("mnml-ws-send".into())
.spawn(move || {
let result = run_websocat_send(&worker_url, &worker_msg, timeout_ms, &headers);
let _ = tx.send(WsSendReply {
url: worker_url,
message: worker_msg,
result,
});
})
.ok();
}
/// Tick hook — render any completed `:ws.send` worker replies
/// into a `[ws-response]` scratch.
pub fn drain_ws_send(&mut self) {
let replies: Vec<WsSendReply> = match &self.ws_send_chan {
Some((_, rx)) => rx.try_iter().collect(),
None => return,
};
for reply in replies {
let mut body = format!("# ws {}\n\n## sent\n\n{}\n\n", reply.url, reply.message);
match reply.result {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
if !stdout.is_empty() {
body.push_str("## received\n\n");
body.push_str(&stdout);
}
if !stderr.is_empty() {
body.push_str("\n## stderr\n\n");
body.push_str(&stderr);
}
self.toast(format!("ws: ok ({}ms) → [ws-response]", out.elapsed_ms));
}
Err(e) => {
body.push_str(&format!("\n## error\n\n{e}\n"));
self.toast(format!("ws.send: {e}"));
}
}
self.open_scratch_with_text("[ws-response]".to_string(), body);
}
}
/// 2026-06-21 — `:ws.history` opens a picker over past
/// connections. Reads `~/.mnml/ws-history/*/history.jsonl`,
/// sorts by last activity desc, shows URL + msg count.
/// Accept opens a connection to that URL and a `[ws-history-
/// <host>]` scratch with the last 200 lines of the history
/// for context.
pub fn ws_history_picker(&mut self) {
let rows = crate::websocket::read_ws_history();
if rows.is_empty() {
self.toast("ws.history: empty (no past connections persisted)");
return;
}
use crate::picker::{Picker, PickerItem, PickerKind};
let items: Vec<PickerItem> = rows
.into_iter()
.map(|(url, _ts, count)| {
let detail = format!("{count} msgs");
PickerItem::new(url.clone(), url, detail)
})
.collect();
self.open_picker(Picker::new(
PickerKind::WsHistory,
"ws history (past connections)",
items,
));
}
/// Accept handler for `:ws.history` picker — open a scratch
/// with the last 200 history lines and start a fresh
/// connection to the same URL.
pub fn ws_history_open(&mut self, url: String) {
// 1) Seed a scratch with the last ~200 lines of history
// so the user can see what they've sent / received.
if let Some(home) = std::env::var_os("HOME") {
let host = url
.strip_prefix("wss://")
.or_else(|| url.strip_prefix("ws://"))
.unwrap_or(&url)
.split('/')
.next()
.unwrap_or("?");
let slug: String = host
.replace(':', "_")
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-' {
c
} else {
'_'
}
})
.collect();
let path = std::path::PathBuf::from(home)
.join(".mnml/ws-history")
.join(&slug)
.join("history.jsonl");
if let Ok(text) = std::fs::read_to_string(&path) {
let lines: Vec<&str> = text.lines().collect();
let start = lines.len().saturating_sub(200);
let mut body = format!("# ws history — {host}\n\n");
for l in &lines[start..] {
body.push_str(l);
body.push('\n');
}
self.open_scratch_with_text(format!("[ws-history-{host}]"), body);
}
}
// 2) Start a fresh connection to the URL.
self.ws_connect_to(&url);
}
/// Auto-format hook: fires the same logic as
/// `http_format_body` when `[http] auto_format_body = true` AND
/// the active pane's body parses as JSON. Silent on failure —
/// leaves the user's typed body untouched. Called at key
/// touchpoints (paste, load-from-file, send) so bodies stay
/// pretty without any user action.
///
/// 2026-07-08 user request: "an auto setting that autoformats
/// so it's always pretty".
pub fn maybe_auto_format_active_body(&mut self) {
if !self.config.http.auto_format_body {
return;
}
let Some(cur) = self.active else {
return;
};
let Some(Pane::Request(rp)) = self.panes.get_mut(cur) else {
return;
};
let body = match rp.request.body.as_deref() {
Some(b) if !b.trim().is_empty() => b.to_string(),
_ => return,
};
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&body)
&& let Ok(pretty) = serde_json::to_string_pretty(&v)
&& pretty != body
{
rp.body_cursor = pretty.len();
rp.request.body = Some(pretty);
}
}
/// `http.regenerate_body` — refresh every dynamic value in the
/// active Request pane's body. Walks the body, finds ISO 8601
/// timestamps and lowercase UUIDs (whether concrete or already
/// `{{$dynamic}}` templates), and replaces each with a fresh
/// value. Reroll gesture for repeated sends: fire an order,
/// click ↻, fire another with new customer + order id + timestamp.
///
/// 2026-07-09 Tier 1 companion — user request: "if i sent want
/// to send an order and then send another order its just a click
/// away to make new customer info and order id".
pub fn http_regenerate_body(&mut self) {
let Some(cur) = self.active else {
self.toast("http.regenerate_body: no active Request pane");
return;
};
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let body = match rp.request.body.as_deref() {
Some(b) if !b.trim().is_empty() => b.to_string(),
_ => {
self.toast("http.regenerate_body: Body is empty");
return;
}
};
// Two-step: (1) normalize concrete timestamp/UUID values
// back into `{{$dynamic}}` templates, (2) expand the
// templates to fresh concrete values via the runtime env.
// Net: every dynamic value in the body is refreshed
// regardless of whether the user last saw it as concrete
// or as a template.
let normalized = crate::http::discover::normalize_dynamic_values_public(&body);
let env = crate::http::template::EnvSet::empty();
let refreshed = crate::http::template::expand(&normalized, &env);
rp.body_cursor = refreshed.len();
rp.request.body = Some(refreshed);
// 2026-07-21 — regenerate commits the preview state.
rp.is_preview = false;
self.toast("body: regenerated (fresh timestamps + UUIDs)");
}
}
/// `http.copy_ai_prompt` — when the active Request pane has a
/// failed response, build a structured markdown prompt (method
/// / URL / status / headers / body / env context / schema
/// errors, with obvious sensitive-value redaction) and copy it
/// to the system clipboard. Toast confirms; user pastes into
/// Claude / Codex / etc.
///
/// 2026-07-09 user request.
pub fn http_copy_ai_prompt(&mut self) {
let Some(cur) = self.active else {
self.toast("http.copy_ai_prompt: no active Request pane");
return;
};
// Resolve the workspace env via the same path every other
// send-time consumer uses (explicit override → MNML_ENV →
// config default → `.rqst/config` default). Prior
// implementation only read `http_env_override` and passed
// the name as a string, so the AI prompt reported every
// `.mnml/env`-defined var as "undefined" — api-workflow
// SEV-2 2026-07-09.
// code-reviewer 2026-07-09: pass the `[http] default_env`
// config value in the third arg so users on a plain config
// (no explicit override, no $MNML_ENV) still resolve the
// same env the actual send-time path would use. Prior
// version hardcoded `None`, leaving a narrower slice of
// the original SEV-2 unfixed.
// api-round-12 SEV-1 2026-07-14 — same alignment as the
// send / bench / extract paths.
let env = self.active_envset();
let Some(Pane::Request(rp)) = self.panes.get(cur) else {
self.toast("http.copy_ai_prompt: active pane isn't a Request");
return;
};
let Some(prompt) = crate::http::ai_prompt::build_prompt(rp, &env) else {
self.toast("http.copy_ai_prompt: no failure to explain (response is 2xx)");
return;
};
let mut clip = crate::clipboard::Clipboard::new();
clip.set(prompt, false);
self.toast("AI prompt copied — paste into Claude / Codex");
}
/// `http.format_body` — parse the active Request pane's Body
/// as JSON and rewrite with 2-space indent. No-op if Body
/// isn't valid JSON (toasts the parse error).
pub fn http_format_body(&mut self) {
let Some(cur) = self.active else {
self.toast("http.format_body: no active Request pane");
return;
};
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let body = match rp.request.body.as_deref() {
Some(b) if !b.trim().is_empty() => b.to_string(),
_ => {
self.toast("http.format_body: Body is empty");
return;
}
};
match serde_json::from_str::<serde_json::Value>(&body) {
Ok(v) => match serde_json::to_string_pretty(&v) {
Ok(pretty) => {
rp.body_cursor = pretty.len();
rp.request.body = Some(pretty);
self.toast("body: formatted as JSON");
}
Err(e) => self.toast(format!("body: format failed: {e}")),
},
Err(e) => self.toast(format!("body: not valid JSON: {e}")),
}
}
}
/// `http.show_schema_errors` — opens a `[schema-errors]` scratch
/// with the full validator-error list for the active Request
/// pane's last response. Falls back to a toast when there's no
/// validation result on the response (no sidecar, or response
/// already validated cleanly).
pub fn http_show_schema_errors(&mut self) {
let Some(cur) = self.active else {
self.toast("http.show_schema_errors: no active Request pane");
return;
};
use crate::request_pane::RunState;
let (status, errors, schema_path) = match self.panes.get(cur) {
Some(Pane::Request(rp)) => match &rp.state {
RunState::Done(rv) | RunState::Streaming(rv) => {
// 2026-06-21 api-workflow SEV-2 — distinguish
// "no sidecar" from "validation not yet run".
// For streaming responses, schema_result is None
// until Close; the old toast falsely blamed the
// sidecar.
let Some(sr) = rv.schema_result.as_ref() else {
if matches!(rp.state, RunState::Streaming(_)) {
self.toast(
"schema: stream still open — wait for close before validating",
);
} else {
self.toast("schema: no sidecar (.schema.json) for this request");
}
return;
};
(sr.status.clone(), sr.errors.clone(), sr.schema_path.clone())
}
_ => {
self.toast("schema: no completed response");
return;
}
},
_ => {
self.toast("http.show_schema_errors: not a Request pane");
return;
}
};
use crate::http::schema::SchemaStatus;
let sidecar = schema_path
.as_ref()
.and_then(|p| p.to_str())
.unwrap_or("<unknown>");
let body = match status {
SchemaStatus::Valid => {
self.toast(format!("✓ schema valid ({sidecar})"));
return;
}
SchemaStatus::NoSidecar => {
self.toast("schema: no sidecar (.schema.json) for this request");
return;
}
SchemaStatus::NotJson => format!("Body isn't JSON — schema ({sidecar}) skipped.\n"),
SchemaStatus::ReadError(e) => {
format!("Schema read error ({sidecar}):\n {e}\n")
}
SchemaStatus::SchemaParseError(e) => {
format!("Schema parse error ({sidecar}):\n {e}\n")
}
SchemaStatus::Invalid => {
let mut out = format!("✗ Schema validation failed ({sidecar})\n");
out.push_str(&format!(" {} error(s):\n\n", errors.len()));
for (i, e) in errors.iter().enumerate() {
out.push_str(&format!(" {:>3}. {e}\n", i + 1));
}
out
}
};
self.open_scratch_with_text("[schema-errors]".to_string(), body);
}
/// `http.revalidate_schema` — re-run schema validation against
/// the existing response body. Useful after editing the
/// sidecar `.schema.json` without re-firing the request.
pub fn http_revalidate_schema(&mut self) {
let Some(cur) = self.active else {
self.toast("http.revalidate_schema: no active Request pane");
return;
};
use crate::request_pane::RunState;
let (source_path, body) = match self.panes.get(cur) {
Some(Pane::Request(rp)) => match &rp.state {
RunState::Done(rv) => (rp.source_path.clone(), rv.body.clone()),
RunState::Streaming(_) => {
self.toast("schema: stream still open — wait for close before revalidating");
return;
}
_ => {
self.toast("schema: no completed response");
return;
}
},
_ => {
self.toast("http.revalidate_schema: not a Request pane");
return;
}
};
let result = crate::http::schema::validate_for(source_path.as_deref(), &body);
let summary = match &result.status {
crate::http::schema::SchemaStatus::Valid => "✓ schema re-validated: valid".to_string(),
crate::http::schema::SchemaStatus::Invalid => {
format!("✗ schema re-validated: {} error(s)", result.errors.len())
}
crate::http::schema::SchemaStatus::NoSidecar => {
"schema: no sidecar (.schema.json) for this request".to_string()
}
crate::http::schema::SchemaStatus::NotJson => {
"schema: response body isn't JSON".to_string()
}
crate::http::schema::SchemaStatus::ReadError(e) => {
format!("schema: read error — {e}")
}
crate::http::schema::SchemaStatus::SchemaParseError(e) => {
format!("schema: parse error — {e}")
}
};
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur)
&& let RunState::Done(rv) = &mut rp.state
{
rv.schema_result = Some(result);
}
self.toast(summary);
}
/// Click on the Method chip opens this dropdown — one entry
/// per HTTP verb. Each entry calls `:http.set_method:<VERB>`
/// which sets that exact verb on the active Request pane.
/// Postman-style verb picker.
pub fn open_method_dropdown(&mut self, anchor: (u16, u16)) {
use crate::context_menu::{ContextMenu, MenuAction, MenuItem};
let items = vec![
MenuItem::new("GET", MenuAction::Command("http.set_method.get")),
MenuItem::new("POST", MenuAction::Command("http.set_method.post")),
MenuItem::new("PUT", MenuAction::Command("http.set_method.put")),
MenuItem::new("PATCH", MenuAction::Command("http.set_method.patch")),
MenuItem::new("DELETE", MenuAction::Command("http.set_method.delete")),
MenuItem::new("HEAD", MenuAction::Command("http.set_method.head")),
MenuItem::new("OPTIONS", MenuAction::Command("http.set_method.options")),
];
self.context_menu = Some(ContextMenu::new(Some("Method".into()), anchor, items));
}
/// Backing for the 7 `:http.set_method.<verb>` palette
/// commands. Sets the method on the active Request pane.
pub fn http_set_method(&mut self, verb: &str) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.request.method = verb.to_string();
self.toast(format!("method: {verb}"));
}
}
// ── Field-aware clipboard (URL / Method / Headers / Body) ──
//
// 2026-07-21 SEV-1 fix. The Request-field right-click menu
// previously wired Copy/Paste/Cut/Select-all to `editor.*`
// commands, which route through `active_editor_mut()` — that
// ONLY matches `Pane::Editor`, so on a Request pane the items
// were silent no-ops. These four `http.field_*` variants
// operate on `RequestPane` fields directly.
fn field_text_and_cursor(rp: &crate::request_pane::RequestPane) -> (&str, usize) {
use crate::request_pane::EditField;
match rp.focus {
EditField::Url => (rp.request.url.as_str(), rp.url_cursor),
EditField::Method => (rp.request.method.as_str(), 0),
EditField::Headers => (rp.headers_buffer.as_str(), rp.headers_cursor),
EditField::Body => (rp.request.body.as_deref().unwrap_or(""), rp.body_cursor),
EditField::Source => (rp.source_buffer.as_str(), rp.source_cursor),
}
}
fn field_text_mut(rp: &mut crate::request_pane::RequestPane) -> (&mut String, &mut usize) {
use crate::request_pane::EditField;
match rp.focus {
EditField::Url => (&mut rp.request.url, &mut rp.url_cursor),
EditField::Method => {
// Method has no cursor of its own; return the method
// string and a dummy cursor kept in a scratch field.
rp.method_cursor_scratch = rp.request.method.len();
(&mut rp.request.method, &mut rp.method_cursor_scratch)
}
EditField::Headers => (&mut rp.headers_buffer, &mut rp.headers_cursor),
EditField::Body => {
let body = rp.request.body.get_or_insert_with(String::new);
(body, &mut rp.body_cursor)
}
EditField::Source => (&mut rp.source_buffer, &mut rp.source_cursor),
}
}
/// `http.field_copy` — copy the focused Request-field's text
/// to the clipboard. No selection model yet; copies the whole
/// field.
pub fn http_field_copy(&mut self) {
let Some(cur) = self.active else { return };
let Some(Pane::Request(rp)) = self.panes.get(cur) else {
self.toast("field_copy: no active Request pane");
return;
};
let (text, _) = Self::field_text_and_cursor(rp);
let text = text.to_string();
if text.is_empty() {
self.toast("nothing to copy — field is empty");
return;
}
self.clipboard.set(text, false);
self.toast("copied field");
}
/// `http.field_paste` — insert clipboard text at the focused
/// Request field's cursor.
pub fn http_field_paste(&mut self) {
let Some(cur) = self.active else { return };
let Some(Pane::Request(_)) = self.panes.get(cur) else {
self.toast("field_paste: no active Request pane");
return;
};
let clip = self.clipboard.text();
if clip.is_empty() {
self.toast("clipboard empty");
return;
}
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let (buf, cursor) = Self::field_text_mut(rp);
let insert_at = (*cursor).min(buf.len());
buf.insert_str(insert_at, &clip);
*cursor = insert_at + clip.len();
rp.is_preview = false;
}
self.toast("pasted");
}
/// `http.field_cut` — copy the focused field's text then clear
/// the field. Same "whole field" semantics as copy.
pub fn http_field_cut(&mut self) {
let Some(cur) = self.active else { return };
let Some(Pane::Request(rp)) = self.panes.get(cur) else {
self.toast("field_cut: no active Request pane");
return;
};
let (text, _) = Self::field_text_and_cursor(rp);
let text = text.to_string();
if text.is_empty() {
self.toast("nothing to cut — field is empty");
return;
}
self.clipboard.set(text, false);
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let (buf, cursor) = Self::field_text_mut(rp);
buf.clear();
*cursor = 0;
rp.is_preview = false;
}
self.toast("cut field");
}
/// `http.field_select_all` — no selection model on Request
/// fields yet; snap the cursor to the END of the field so the
/// next Ctrl+Backspace / Delete gesture at least reaches
/// everything. Also copies the full text to clipboard so
/// select-all-then-copy is a two-tap noop → single-tap noop.
pub fn http_field_select_all(&mut self) {
let Some(cur) = self.active else { return };
let Some(Pane::Request(rp)) = self.panes.get(cur) else {
self.toast("field_select_all: no active Request pane");
return;
};
let (text, _) = Self::field_text_and_cursor(rp);
let text = text.to_string();
if !text.is_empty() {
self.clipboard.set(text.clone(), false);
}
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let (buf, cursor) = Self::field_text_mut(rp);
*cursor = buf.len();
}
self.toast("field: cursor to end + copied to clipboard");
}
/// Right-click on any Request pane Edit-mode field row →
/// field-aware context menu. Common actions (Send / Copy as
/// curl / Switch to Response) appear for every field; the
/// Method row adds "Cycle method" so users can change the
/// verb without keyboard. v2 ideas: "Format JSON" on Body,
/// "Paste cookies" on Headers.
pub fn open_request_field_context_menu(
&mut self,
field: crate::request_pane::EditField,
anchor: (u16, u16),
) {
use crate::context_menu::{ContextMenu, MenuAction, MenuItem};
use crate::request_pane::EditField;
// 2026-07-21 — Send/Copy on top, then editing operations,
// then curl paste at the bottom (still discoverable but not
// the ONLY paste option). User: "right click only provides
// paste curl, where are other paste options".
let mut items = vec![
MenuItem::new("Send", MenuAction::Command("http.send")),
// 2026-07-21 — was `editor.copy` / `editor.paste` /
// `editor.cut` / `editor.select_all`, which noop'd on
// Request panes (they only match Pane::Editor).
// Wired to the new field-aware variants.
MenuItem::new("Copy", MenuAction::Command("http.field_copy")),
MenuItem::new("Paste", MenuAction::Command("http.field_paste")),
MenuItem::new("Cut", MenuAction::Command("http.field_cut")),
MenuItem::new("Select all", MenuAction::Command("http.field_select_all")),
];
// 2026-07-21 — Body-tab-specific: expose Format alongside
// the general edit ops, since it lives on the Body chip
// strip and users might miss it.
if matches!(field, EditField::Body) {
items.push(MenuItem::new(
"Format body (JSON)",
MenuAction::Command("http.format_body"),
));
}
items.extend([
MenuItem::new("Copy as curl", MenuAction::Command("http.copy_curl")),
MenuItem::new(
"Paste curl from clipboard",
MenuAction::Command("http.paste_curl"),
),
MenuItem::new(
"Switch to Response",
MenuAction::Command("http.toggle_view"),
),
]);
if matches!(field, EditField::Method) {
items.insert(
0,
MenuItem::new("Cycle method", MenuAction::Command("http.cycle_method")),
);
}
let title = match field {
EditField::Url => "Request · URL",
EditField::Method => "Request · Method",
EditField::Headers => "Request · Headers",
EditField::Body => "Request · Body",
EditField::Source => "Request · Source",
};
self.context_menu = Some(ContextMenu::new(Some(title.into()), anchor, items));
}
/// `y` in the browser pane's network panel — copy the selected request as a
/// curl command to the clipboard.
pub fn copy_net_entry_curl(&mut self) {
let curl = match self.active.and_then(|i| self.panes.get(i)) {
Some(Pane::Browser(b)) => b.selected_net().map(crate::browser_pane::NetEntry::as_curl),
_ => None,
};
match curl {
Some(c) => {
self.clipboard.set(c, false);
self.toast("copied request as curl");
}
None => self.toast("no network request selected"),
}
}
/// `Enter` in the browser pane's network panel — open the selected request in a
/// `Pane::Request` (split below the browser) and re-send it.
pub fn open_net_entry_as_request(&mut self) {
let Some(cur) = self.active else { return };
let request = match self.panes.get(cur) {
Some(Pane::Browser(b)) => b
.selected_net()
.map(crate::browser_pane::NetEntry::to_request),
_ => None,
};
let Some(request) = request else {
self.toast("no network request selected");
return;
};
let script = crate::http::script::Script::default();
let job_id = self.spawn_http_job(request.clone(), script.clone(), None);
let pane = Pane::Request(crate::request_pane::RequestPane::new(
None, request, script, job_id,
));
let new_id = self.split_leaf_with(cur, crate::layout::SplitDir::Horizontal, pane);
self.active = Some(new_id);
self.focus = Focus::Pane;
}
/// `http.edit_env` — structured env-file editor. Opens a
/// picker listing every `KEY=VALUE` pair in the active env
/// file plus a synthetic `+ Add new variable…` row at the top.
/// Phase 3 polish of the rqst→mnml port-back.
pub fn http_edit_env_open(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
// Read-path: no toast on the fallback — it'd fire on every
// picker open. The write paths (write_env_var /
// http_delete_env_key) do toast so the user sees where their
// change landed.
let (env_name, _) = resolve_env_name_with_fallback(
&self.workspace,
self.http_env_override.as_deref(),
self.config.http.default_env.as_deref(),
);
// 2026-06-19 — api-workflow-user SEV-3: read BOTH .rqst/
// and .mnml/ env files so keys exclusive to .mnml/ surface
// in the picker. `.mnml/` wins same-key (matches EnvSet::
// load precedence).
let mut by_key: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
for sub in [".rqst", ".mnml"] {
let env_path = self
.workspace
.join(sub)
.join("env")
.join(format!("{env_name}.env"));
let text = std::fs::read_to_string(&env_path).unwrap_or_default();
for line in text.lines() {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if let Some((k, v)) = trimmed.split_once('=') {
by_key.insert(k.trim().to_string(), v.trim().to_string());
}
}
}
let mut items: Vec<PickerItem> = Vec::new();
items.push(PickerItem::new(
"+add".to_string(),
"+ Add new variable…".to_string(),
String::new(),
));
for (key, val) in by_key {
let preview = if val.len() > 48 {
format!("{}…", &val[..46])
} else {
val.clone()
};
items.push(PickerItem::new(key.clone(), key, preview));
}
self.open_picker(Picker::new(
PickerKind::EnvVars,
format!("Env vars · {env_name}.env"),
items,
));
}
/// Accept handler for `PickerKind::EnvVars`. The `+add`
/// synthetic id opens the add-key prompt; any other id is an
/// existing key — stash it + open the edit-value prompt seeded
/// with the current value.
pub fn accept_env_vars(&mut self, id: &str) {
if id == "+add" {
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::EnvAddKey,
"KEY=VALUE for new env var:".to_string(),
));
return;
}
// Read-path (seed the edit-value prompt): silence the
// fallback toast to avoid firing on every prompt-open.
let (env_name, _) = resolve_env_name_with_fallback(
&self.workspace,
self.http_env_override.as_deref(),
self.config.http.default_env.as_deref(),
);
// 2026-06-19 — api-workflow third hunt SEV-2: previously
// seeded the prompt from a hardcoded `.rqst/env/` path, so
// a key whose `.mnml/` value was shown in the picker would
// pre-fill with the stale `.rqst/` baseline. Now read in
// the same .rqst→.mnml order as `http_edit_env_open` and
// pick the last value seen — matches the picker display.
let current_val = ["", ".rqst", ".mnml"]
.iter()
.filter(|s| !s.is_empty())
.filter_map(|sub| {
let p = self
.workspace
.join(sub)
.join("env")
.join(format!("{env_name}.env"));
std::fs::read_to_string(p).ok()
})
.flat_map(|text| {
text.lines()
.filter_map(|l| {
let t = l.trim_start();
if t.starts_with('#') {
return None;
}
let (k, v) = t.split_once('=')?;
(k.trim() == id).then(|| v.to_string())
})
.collect::<Vec<_>>()
})
.last()
.unwrap_or_default();
self.pending_env_edit_key = Some(id.to_string());
let mut prompt = crate::prompt::Prompt::new(
crate::prompt::PromptKind::EnvEditValue,
format!("Value for {id}:"),
);
let cursor = current_val.len();
prompt.input = current_val;
prompt.cursor = cursor;
self.prompt = Some(prompt);
}
/// Accept handler for `PromptKind::EnvEditValue`. Upserts
/// `<pending_env_edit_key>=<typed>` into the active env file.
///
/// 2026-06-19 — api-workflow-user SEV-3: earlier impl trimmed
/// the value, silently dropping intentional leading/trailing
/// whitespace (`API_KEY= Bearer xyz` → `API_KEY=Bearer xyz`).
/// Now preserves the typed value verbatim. Newlines are still
/// rejected by `upsert_env_var` (would corrupt the file).
pub fn accept_env_edit_value(&mut self, value: &str) {
let Some(key) = self.pending_env_edit_key.take() else {
return;
};
self.write_env_var(&key, value);
}
/// Accept handler for `PromptKind::EnvAddKey`. Splits the
/// typed `KEY=VALUE` and upserts. Toasts an error for
/// malformed input (no `=`, empty key).
pub fn accept_env_add_key(&mut self, input: &str) {
let Some((key, value)) = input.split_once('=') else {
self.toast("env: input must be KEY=VALUE");
return;
};
let key = key.trim();
if key.is_empty() {
self.toast("env: key can't be empty");
return;
}
self.write_env_var(key, value.trim());
}
/// Shared write-back path for `EnvEditValue` + `EnvAddKey`
/// + `LookupVarName`. Resolves the active env file, upserts,
/// toasts the result. Creates the parent dir if missing.
///
/// 2026-06-19 — api-workflow-user SEV-3: when both `.mnml/`
/// and `.rqst/` env files exist and the key lives in `.mnml/`,
/// writing to `.rqst/` is overshadowed on next request (same-
/// key precedence). Now writes to WHICHEVER existing file
/// contains the key; new keys go to `.mnml/` (the preferred
/// mnml-native location).
fn write_env_var(&mut self, key: &str, value: &str) {
let (env_name, is_fallback) = resolve_env_name_with_fallback(
&self.workspace,
self.http_env_override.as_deref(),
self.config.http.default_env.as_deref(),
);
if is_fallback {
self.toast("env: no active env — using dev.env (set `[http] default_env` or MNML_ENV)");
}
let mnml_path = self
.workspace
.join(".mnml")
.join("env")
.join(format!("{env_name}.env"));
let rqst_path = self
.workspace
.join(".rqst")
.join("env")
.join(format!("{env_name}.env"));
// Decide target: .mnml takes precedence (the authoritative
// EnvSet::load reader), so a key that lives there gets the
// write. Otherwise a key already in .rqst gets the write
// there. New keys default to .mnml (preferred location).
let mnml_has = file_contains_env_key(&mnml_path, key);
let rqst_has = file_contains_env_key(&rqst_path, key);
let env_path = if mnml_has || (!rqst_has) {
mnml_path
} else {
rqst_path
};
let existing = std::fs::read_to_string(&env_path).unwrap_or_default();
let updated = match upsert_env_var(&existing, key, value) {
Ok(s) => s,
Err(e) => {
self.toast(format!("env: {e}"));
return;
}
};
if let Some(parent) = env_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
self.toast(format!("env: mkdir {}: {e}", parent.display()));
return;
}
match std::fs::write(&env_path, updated) {
Ok(()) => {
self.toast(format!("wrote {key}={value} → {}", env_path.display()));
// #861 — first .env write in this workspace? Make sure
// `.mnml/env/` is in `.gitignore` so the API tokens we
// just wrote don't accidentally end up in a commit.
// Only touches gitignore when this workspace is
// actually a git repo (a `.git/` dir sits at the
// root) — non-git tempdir workspaces have no commit
// risk to guard against. Called ONLY on .mnml/ writes
// (skipped for .rqst/ ones — that's a legacy path).
if env_path.starts_with(self.workspace.join(".mnml"))
&& let Some(msg) = ensure_mnml_env_gitignored(&self.workspace)
{
self.toast(msg);
}
}
Err(e) => self.toast(format!("env: write {}: {e}", env_path.display())),
}
}
/// #23 v2 — delete a var from the active env file. Same
/// precedence rules as `write_env_var`: mnml/env wins when
/// both files exist. Silent no-op when the key isn't
/// present in either file.
pub fn http_delete_env_key(&mut self, key: &str) {
let (env_name, is_fallback) = resolve_env_name_with_fallback(
&self.workspace,
self.http_env_override.as_deref(),
self.config.http.default_env.as_deref(),
);
if is_fallback {
self.toast("env: no active env — using dev.env (set `[http] default_env` or MNML_ENV)");
}
let mnml_path = self
.workspace
.join(".mnml")
.join("env")
.join(format!("{env_name}.env"));
let rqst_path = self
.workspace
.join(".rqst")
.join("env")
.join(format!("{env_name}.env"));
let mut hit = None;
for candidate in [&mnml_path, &rqst_path] {
if file_contains_env_key(candidate, key) {
hit = Some(candidate.clone());
break;
}
}
let Some(env_path) = hit else {
self.toast(format!("env: {key} not found"));
return;
};
let existing = std::fs::read_to_string(&env_path).unwrap_or_default();
let updated: String = existing
.lines()
.filter(|line| {
let trimmed = line.trim_start();
if trimmed.is_empty() || trimmed.starts_with('#') {
return true;
}
trimmed
.split_once('=')
.map(|(k, _)| k.trim() != key)
.unwrap_or(true)
})
.collect::<Vec<_>>()
.join("\n");
let mut updated = updated;
if !updated.ends_with('\n') {
updated.push('\n');
}
match std::fs::write(&env_path, updated) {
Ok(()) => self.toast(format!("deleted {key} from {}", env_path.display())),
Err(e) => self.toast(format!("env: write {}: {e}", env_path.display())),
}
}
/// `http.next_block` — move the cursor to the `###` line of
/// the next block in a multi-block `.http` / `.rest` file. If
/// the cursor is at/past the last block, wrap to the first.
/// http-2nd 2026-06-28 SEV-3b — was no chord/command path.
pub fn http_next_block(&mut self) {
self.move_to_http_block(true);
}
/// `http.prev_block` — mirror of `next_block` for the
/// previous-block direction.
pub fn http_prev_block(&mut self) {
self.move_to_http_block(false);
}
fn move_to_http_block(&mut self, forward: bool) {
// Request-pane path first — .http/.curl/.rest files auto-open
// as Pane::Request (2026-07-06), so the old active_editor()
// gate made ]/[ a silent no-op for the standard flow.
// api-workflow SEV-1 2026-07-10 fix.
if self.move_request_pane_to_next_block(forward) {
return;
}
let Some(b) = self.active_editor() else {
self.toast("http.next/prev_block: no active editor");
return;
};
let ext = b
.path
.as_ref()
.and_then(|p| p.extension())
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
// qa-5th 2026-06-29 SEV-2: was `"http" | "rest"` — silently
// rejected .curl files. The integration guards at lines 2328
// and 2919 (the send-request paths) include "curl" too.
// For consistency, accept all three; the empty-blocks toast
// below handles the single-block .curl case gracefully.
if !matches!(ext.as_str(), "http" | "rest" | "curl") {
self.toast("http.next/prev_block: needs an open .http/.rest/.curl file");
return;
}
let text = b.editor.text().to_string();
let cur_row = b.editor.row_col().0;
// qa-6th nvchad SEV-2: was using parse_all, which requires
// every block's body to parse cleanly as an HTTP request.
// For .curl files the bodies are `curl -X POST ...` invocations
// that parse_block rejects — parse_all returned Err, the
// outer toast fired with "parse error" (which the agent
// didn't see because of run-command toast timing), and
// cursor didn't move. Block nav only needs the `###`
// separator positions; scan for them directly.
let blocks: Vec<usize> = text
.lines()
.enumerate()
.filter_map(|(i, l)| l.trim_start().starts_with("###").then_some(i))
.collect();
if blocks.is_empty() {
self.toast("http.next/prev_block: no ### blocks in file");
return;
}
// For files where the FIRST block has no `###` separator
// (leading unnamed block in .http/.rest), treat line 0 as
// an implicit block start so prev from anywhere in the
// leading block can wrap to "start of leading block".
let mut starts: Vec<usize> = blocks.clone();
if starts.first().copied() != Some(0) {
starts.insert(0, 0);
}
let target_row = if forward {
starts
.iter()
.find(|&&l| l > cur_row)
.copied()
.unwrap_or(starts[0])
} else {
starts
.iter()
.rev()
.find(|&&l| l < cur_row)
.copied()
.unwrap_or_else(|| *starts.last().unwrap())
};
if let Some(b) = self.active_editor_mut() {
b.editor.place_cursor(target_row, 0);
}
// input-handler-reviewer W-2 2026-06-28: programmatic
// cursor jumps need to scroll the viewport — without
// reveal_pane, jumping to a block above/below the
// current viewport leaves the cursor offscreen.
if let Some(id) = self.active {
self.reveal_pane(id);
}
}
/// Move an active `Pane::Request` to the next/prev `###` block in its
/// source file, in place (does NOT spawn a new pane). Returns `true`
/// when the active pane is a Request pane and navigation was
/// attempted (even if it failed / toasted); `false` when there's no
/// Request-pane path, so the caller can fall through to the editor
/// path. `.http`/`.curl`/`.rest` files auto-open as `Pane::Request`
/// since 2026-07-06, so this is the standard-flow path — the
/// editor branch only runs when the user forced "Open as text".
///
/// api-workflow SEV-1 fix 2026-07-10 — was previously a silent
/// no-op through `active_editor()`, making `]`/`[` unreachable in
/// the default open flow.
fn move_request_pane_to_next_block(&mut self, forward: bool) -> bool {
use crate::pane::Pane;
use crate::request_pane::{EditField, RunState, ViewMode};
let Some(active) = self.active else {
return false;
};
let Some(Pane::Request(rp)) = self.panes.get(active) else {
return false;
};
let Some(path) = rp.source_path.clone() else {
self.toast("http.next/prev_block: request has no source file");
return true;
};
let current_block_name = rp.source_block_name.clone();
let text = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
self.toast(format!("http.next/prev_block: {}: {e}", path.display()));
return true;
}
};
let blocks = match crate::http::file::parse_all(&text) {
Ok(bs) => bs,
Err(_) => {
self.toast("http.next/prev_block: no ### blocks in file");
return true;
}
};
if blocks.len() < 2 {
self.toast("http.next/prev_block: only one block in file");
return true;
}
// Locate current block index. Match on `source_block_name` first
// (Some("foo") ↔ block.name == Some("foo"), None ↔ block.name
// is None for the leading-unnamed block). Fall back to 0 if no
// match — e.g. the pane was opened before the block was
// renamed/removed.
let cur_idx = blocks
.iter()
.position(|b| b.name == current_block_name)
.unwrap_or(0);
let n = blocks.len();
let next_idx = if forward {
(cur_idx + 1) % n
} else {
(cur_idx + n - 1) % n
};
let next = &blocks[next_idx];
let request = next.request.clone();
let block_name = next.name.clone();
let script = crate::http::script::parse(&text);
if let Some(Pane::Request(rp)) = self.panes.get_mut(active) {
rp.request = request;
rp.source_block_name = block_name.clone();
rp.script = script;
rp.view = ViewMode::Edit;
rp.focus = EditField::Url;
rp.state = RunState::Failed("not sent yet · press `r` to fire".to_string());
rp.url_cursor = rp.request.url.len();
rp.scroll = 0;
// Rebuild the headers text buffer from the freshly-loaded request.
rp.headers_buffer = rp
.request
.headers
.iter()
.map(|(k, v)| format!("{k}: {v}"))
.collect::<Vec<_>>()
.join("\n");
// api-workflow-user 2026-07-30 SEV-2 — cursor was reset to
// 0 for both body + headers, but every OTHER code path in
// this file sets each to end-of-buffer. Consequence: after
// `http.next_block`, typing in Headers PREPENDED the new
// header onto the existing line with no separator (e.g.
// `X-Injected: yesContent-Type: application/json`) —
// corruption gets sent on the wire on the next `r`. Match
// the pattern the other 9 call sites use.
rp.headers_cursor = rp.headers_buffer.len();
rp.body_cursor = rp.request.body.as_deref().unwrap_or_default().len();
// api-workflow round-9 SEV-2 2026-07-11 — refresh the
// tab title's summary from the newly-active block's own
// leading `# ...` comment. Was stale on the tab strip
// after `http.next_block`.
let block_source_start = next.start_line;
let block_source_end = next.end_line.min(text.lines().count().saturating_sub(1));
let block_source: String = text
.lines()
.skip(block_source_start)
.take(block_source_end - block_source_start + 1)
.collect::<Vec<_>>()
.join("\n");
rp.summary = extract_summary(&block_source);
}
self.maybe_auto_format_active_body();
self.reveal_pane(active);
let label = block_name.unwrap_or_else(|| format!("#{}", next_idx + 1));
self.toast(format!("block: {label} ({}/{})", next_idx + 1, n));
true
}
/// `http.lookup` — open the lookup picker (stage 1: pick a
/// `.curl` file under `<workspace>/.rqst/lookups/`). Subsequent
/// stages — fire-request → pick-item → enter-var-name → write-
/// to-env — are chained by the picker/prompt accept handlers.
/// Phase 7 of the rqst→mnml port-back.
pub fn http_lookup_open(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
// http-2nd 2026-06-28 SEV-3a: use the recursive walker
// (crate::http::lookup::scan_lookups). Was a flat read_dir
// that silently missed `requests/auth/login.curl` nested
// under a subdirectory.
let workspace = self.workspace.clone();
let mut items: Vec<PickerItem> = crate::http::lookup::scan_lookups(&workspace)
.into_iter()
.map(|path| {
let label = crate::http::lookup::relative_label(&path, &workspace);
PickerItem::new(path.to_string_lossy().into_owned(), label, String::new())
})
.collect();
if items.is_empty() {
let dir = workspace.join(".rqst").join("lookups");
self.toast(format!(
"no lookups in {} — add a `.curl` file under that dir",
dir.display()
));
return;
}
items.sort_by(|a, b| a.label.cmp(&b.label));
self.open_picker(Picker::new(PickerKind::LookupFile, "Lookup file", items));
}
/// Accept handler for `PickerKind::LookupFile`. Spawns a
/// background thread that fires the chosen `.curl` file as an
/// HTTP request; on response, `App::tick`'s drain opens the
/// `LookupItem` picker with parsed list rows.
pub fn accept_lookup_file(&mut self, file_path: &std::path::Path) {
use crate::http;
// #polish 2026-07-06 — double-fire guard. Was: a second
// `:http.lookup` accept while the first was still in-flight
// overwrote `lookup_fire_rx` and dropped the first result
// silently. Matches the guard shape used by `http.bench` /
// `http.sync`.
if self.lookup_fire_rx.is_some() {
self.toast("lookup: another lookup is still in-flight");
return;
}
let text = match std::fs::read_to_string(file_path) {
Ok(t) => t,
Err(e) => {
self.toast(format!("lookup: read {}: {e}", file_path.display()));
return;
}
};
let mut request = match http::parse(&text) {
Ok(r) => r,
Err(e) => {
self.toast(format!("lookup: parse {}: {e}", file_path.display()));
return;
}
};
let script = http::script::parse(&text);
// api-round-12 SEV-1 2026-07-14 — was
// `EnvSet::select_with_config_default` (4-tier: explicit /
// $MNML_ENV / config / .rqst-config). In a `.mnml`-only
// workspace with none of those set, it returned empty and
// every `{{VAR}}` reference in the request template stayed
// literal on the wire — Send failed with "unresolved vars"
// even though the Vars tab correctly showed the resolved
// values. Round-11 fix aligned the EDIT surface with the
// write path's "dev" fallback via `active_envset()` but
// left the SEND surface behind, splitting the resolver in
// half. Route through the shared helper so read/edit/write/
// send all agree.
let mut env = self.active_envset();
http::script::apply_pre(&script, &mut request, &mut env);
request.url = http::template::expand(&request.url, &env);
for (_, v) in request.headers.iter_mut() {
*v = http::template::expand(v, &env);
}
if let Some(body) = request.body.as_mut() {
*body = http::template::expand(body, &env);
}
let file_label = crate::http::lookup::relative_label(file_path, &self.workspace);
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = http::send(&request)
.map(|r| (r.body, file_label.clone()))
.map_err(|e| format!("lookup fire: {e}"));
let _ = tx.send(result);
});
self.lookup_fire_rx = Some(rx);
self.lookup_fire_started = Some(std::time::Instant::now());
self.toast("lookup: firing request…");
}
/// Drain the in-flight lookup-fire result. On success, parses
/// the response body for list items and opens the
/// `PickerKind::LookupItem` picker. Called from `App::tick`.
pub fn drain_lookup_fire_result(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let Some(rx) = self.lookup_fire_rx.as_ref() else {
return;
};
match rx.try_recv() {
Ok(Ok((body, label))) => {
self.lookup_fire_rx = None;
let Some(parsed) = crate::http::lookup::parse_items(&body) else {
self.toast(format!(
"lookup: {label} response wasn't a recognized list shape"
));
return;
};
if parsed.is_empty() {
self.toast(format!("lookup: {label} returned 0 items"));
return;
}
let items: Vec<PickerItem> = parsed
.iter()
.enumerate()
.map(|(i, item)| {
PickerItem::new(i.to_string(), item.label.clone(), item.id.clone())
})
.collect();
self.pending_lookup_items = parsed;
self.open_picker(Picker::new(
PickerKind::LookupItem,
format!("Lookup item · {label}"),
items,
));
}
Ok(Err(e)) => {
self.lookup_fire_rx = None;
self.toast(e);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.lookup_fire_rx = None;
self.toast("lookup: worker dropped");
}
}
}
/// Accept handler for `PickerKind::LookupItem`. Stashes the
/// picked item's id into `pending_lookup_picked_id` and opens
/// the var-name prompt.
pub fn accept_lookup_item(&mut self, idx: usize) {
let Some(item) = self.pending_lookup_items.get(idx).cloned() else {
return;
};
self.pending_lookup_picked_id = Some(item.id.clone());
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::LookupVarName,
format!("Env var name for {} ({}):", item.label, item.id),
));
}
/// Accept handler for `PromptKind::LookupVarName`. Writes
/// `<var>=<id>` to `<workspace>/.rqst/env/<current>.env`
/// (appending or replacing in place if the var exists), toasts
/// the write.
pub fn accept_lookup_var_name(&mut self, var: &str) {
let var = var.trim();
if var.is_empty() {
self.toast("lookup: var name can't be empty");
return;
}
let Some(id) = self.pending_lookup_picked_id.take() else {
return;
};
// 2026-06-19 — unified with `write_env_var` so the lookup
// write respects the same `.mnml/` vs `.rqst/` precedence
// the env editor uses: existing key → its file; new key →
// `.mnml/env/` (preferred).
self.write_env_var(var, &id);
}
/// `http.capture_now` — append every NetEntry from the active
/// browser pane into `<workspace>/.rqst/captured/log.jsonl`.
/// The captured log persists across browser sessions so the
/// user can review or re-fire entries later. Phase 4 of the
/// rqst→mnml port-back.
pub fn http_capture_browser_net_to_log(&mut self) {
let Some(cur) = self.active else {
self.toast("http.capture_now: no active pane");
return;
};
let entries: Vec<crate::http::captured::CapturedRow> = match self.panes.get(cur) {
Some(Pane::Browser(b)) => {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
b.net
.iter()
.map(|n| crate::http::captured::CapturedRow {
at: now,
request_id: n.request_id.clone(),
method: n.method.clone(),
url: n.url.clone(),
headers: n.headers.clone(),
body: n.post_data.clone(),
paused: false,
})
.collect()
}
_ => {
self.toast("http.capture_now: needs an active browser pane");
return;
}
};
if entries.is_empty() {
self.toast("http.capture_now: browser pane has no network entries yet");
return;
}
let log_path = self
.workspace
.join(".rqst")
.join("captured")
.join("log.jsonl");
if let Some(parent) = log_path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
self.toast(format!("http.capture_now: mkdir {}: {e}", parent.display()));
return;
}
let count = entries.len();
let mut written = 0;
match std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
Ok(mut f) => {
use std::io::Write;
for row in &entries {
if let Ok(line) = serde_json::to_string(row)
&& f.write_all(line.as_bytes()).is_ok()
&& f.write_all(b"\n").is_ok()
{
written += 1;
}
}
self.toast(format!(
"http.capture_now: wrote {written}/{count} entries to {}",
log_path.display()
));
// #polish 2026-07-07 — user reported CAPTURED still
// read `(0)` after clicking the chip because the
// panel's captured cache was loaded lazily and
// never re-read after writes. Refresh so freshly-
// dumped entries land in the sidebar immediately.
self.http_panel_refresh();
}
Err(e) => self.toast(format!(
"http.capture_now: open {}: {e}",
log_path.display()
)),
}
}
/// `http.view_captured` — load `.rqst/captured/log.jsonl` and
/// open a picker over the entries. Enter opens the chosen row
/// as a fresh `.curl` editor buffer (via `CapturedRow::to_curl`)
/// so the user can fire it again. Phase 4 of the rqst→mnml
/// port-back — replaces the v1 stub that just opened the JSONL
/// file in an editor.
pub fn open_http_captured_log(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let path = self
.workspace
.join(".rqst")
.join("captured")
.join("log.jsonl");
let rows = crate::http::captured::load(&path);
if rows.is_empty() {
self.toast(format!(
"http.view_captured: no entries at {} — run http.capture_now first",
path.display()
));
return;
}
let items: Vec<PickerItem> = rows
.iter()
.enumerate()
.map(|(i, r)| {
// Display: "METHOD short_url" (matching browser pane's
// short_url convention — host + path, no scheme/query).
let short = r
.url
.strip_prefix("https://")
.or_else(|| r.url.strip_prefix("http://"))
.unwrap_or(&r.url);
let short = short.split(['?', '#']).next().unwrap_or(short);
let detail = if r.body.as_deref().unwrap_or("").is_empty() {
String::new()
} else {
format!("(body: {} bytes)", r.body.as_deref().unwrap().len())
};
PickerItem::new(i.to_string(), format!("{} {short}", r.method), detail)
})
.collect();
self.pending_captured_rows = rows;
self.open_picker(Picker::new(
PickerKind::CapturedRows,
"Captured requests",
items,
));
}
/// `http.history_global` — load `~/.config/mnml/history-global.jsonl`
/// and open a picker over the most recent 100 entries across
/// ALL workspaces. Detail line shows the workspace name + status.
/// Useful when you remember firing a request but not which
/// project you were in. Enter opens a `.curl` scratch so you
/// can re-fire it from the current workspace.
pub fn open_http_history_global(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let rows = crate::http::history::tail_global(100);
if rows.is_empty() {
let path = crate::http::history::global_history_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "(HOME unset)".to_string());
self.toast(format!("http.history_global: no entries yet at {path}"));
return;
}
let items: Vec<PickerItem> = rows
.iter()
.enumerate()
.rev()
.map(|(i, v)| {
let method = v
.get("method")
.and_then(|s| s.as_str())
.unwrap_or("?")
.to_string();
let url = v
.get("url")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let workspace = v
.get("workspace")
.and_then(|s| s.as_str())
.unwrap_or("?")
.to_string();
let status = v.get("status").and_then(|s| s.as_u64());
let dur = v.get("duration_ms").and_then(|d| d.as_u64());
let detail = match (status, dur) {
(Some(s), Some(d)) => format!("{workspace} · {s} · {d}ms"),
(Some(s), None) => format!("{workspace} · {s}"),
(None, Some(d)) => format!("{workspace} · FAILED · {d}ms"),
(None, None) => format!("{workspace} · FAILED"),
};
let short = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(&url)
.split(['?', '#'])
.next()
.unwrap_or(&url)
.to_string();
PickerItem::new(i.to_string(), format!("{method} {short}"), detail)
})
.collect();
self.pending_history_rows = rows;
self.open_picker(Picker::new(
PickerKind::HistoryRows,
"HTTP history · all workspaces",
items,
));
}
/// `http.history` — load `.rqst/history.jsonl` and open a
/// picker over the most recent 100 entries. Enter opens the
/// chosen entry's method/URL as a `.curl` scratch buffer so
/// the user can re-fire it. Phase 9 of the rqst→mnml
/// port-back — replaces the v1 stub that just opened the file
/// in an editor.
pub fn open_http_history(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let workspace = self.workspace.clone();
let rows = crate::http::history::tail(&workspace, 100);
if rows.is_empty() {
self.toast(format!(
"http.history: no history yet at {}",
workspace.join(".rqst").join("history.jsonl").display()
));
return;
}
let items: Vec<PickerItem> = rows
.iter()
.enumerate()
.rev()
.map(|(i, v)| {
let method = v
.get("method")
.and_then(|s| s.as_str())
.unwrap_or("?")
.to_string();
let url = v
.get("url")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let status = v.get("status").and_then(|s| s.as_u64());
let dur = v.get("duration_ms").and_then(|d| d.as_u64());
let detail = match (status, dur) {
(Some(s), Some(d)) => format!("{s} · {d}ms"),
(Some(s), None) => format!("{s}"),
(None, Some(d)) => format!("FAILED · {d}ms"),
(None, None) => "FAILED".to_string(),
};
let short = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
.unwrap_or(&url)
.split(['?', '#'])
.next()
.unwrap_or(&url)
.to_string();
PickerItem::new(i.to_string(), format!("{method} {short}"), detail)
})
.collect();
self.pending_history_rows = rows;
self.open_picker(Picker::new(PickerKind::HistoryRows, "HTTP history", items));
}
/// `http.save_mock` — freeze the active Request pane's response
/// to disk as a `<source>.curl.mock.json` sidecar. The mock
/// captures status + status_text + headers + body so it can be
/// re-served by `http.replay_mock` for offline review or
/// canned-data testing. Phase 6 of the rqst→mnml port-back.
pub fn http_save_active_response_as_mock(&mut self) {
let Some(cur) = self.active else {
self.toast("http.save_mock: no active pane");
return;
};
let (source_path, source_block_name, mock) = match self.panes.get(cur) {
Some(Pane::Request(rp)) => {
let Some(rp_path) = rp.source_path.as_ref() else {
self.toast("http.save_mock: pane has no source file path");
return;
};
let crate::request_pane::RunState::Done(rv) = &rp.state else {
self.toast("http.save_mock: response not ready yet");
return;
};
(
rp_path.clone(),
rp.source_block_name.clone(),
crate::http::mock::Mock {
status: rv.status,
status_text: rv.status_text.clone(),
headers: rv.headers.clone(),
body: rv.body.clone(),
},
)
}
_ => {
self.toast("http.save_mock: needs an active Request pane");
return;
}
};
// http-2nd SEV-2: multi-block .http files share the integration
// path so block A's mock overwrote block B's. Use per-block
// path when a named block is the source.
let mock_path =
crate::http::mock::sibling_path_for_block(&source_path, source_block_name.as_deref());
match crate::http::mock::save(&mock_path, &mock) {
Ok(()) => self.toast(format!("saved mock → {}", mock_path.display())),
Err(e) => self.toast(format!("http.save_mock: {e}")),
}
}
/// `http.replay_mock` — load the active Request pane's sibling
/// `.mock.json` and serve it as if it had been the live
/// response. The pane's state flips to `Done` with the mock's
/// status / headers / body — no network call. Phase 6 of the
/// rqst→mnml port-back.
pub fn http_replay_active_request_from_mock(&mut self) {
let Some(cur) = self.active else {
self.toast("http.replay_mock: no active pane");
return;
};
let mock_path = match self.panes.get(cur) {
Some(Pane::Request(rp)) => {
let Some(p) = rp.source_path.as_ref() else {
self.toast("http.replay_mock: pane has no source file path");
return;
};
// http-2nd SEV-2: prefer the per-block path when
// the source has a named block; fall back to the
// bare sibling for unnamed leading blocks.
crate::http::mock::sibling_path_for_block(p, rp.source_block_name.as_deref())
}
_ => {
self.toast("http.replay_mock: needs an active Request pane");
return;
}
};
let mock = match crate::http::mock::load(&mock_path) {
Ok(m) => m,
Err(e) => {
self.toast(format!("http.replay_mock: {e}"));
return;
}
};
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.state =
crate::request_pane::RunState::Done(Box::new(crate::request_pane::ResponseView {
status: mock.status,
status_text: mock.status_text,
headers: mock.headers,
body_bytes: mock.body.as_bytes().to_vec(),
body: mock.body,
elapsed: std::time::Duration::ZERO,
timing: crate::http::Timing::default(),
assertions: Vec::new(),
captures: Vec::new(),
schema_result: None,
sse_event_count: 0,
}));
rp.view = crate::request_pane::ViewMode::Response;
}
self.toast(format!("replayed mock ({})", mock_path.display()));
}
/// Sidebar-triggered mock replay — replay `path` directly (skips
/// the integration-path lookup that `http_replay_active_request_from_mock`
/// does). Opens a fresh Request pane if none is active.
pub fn http_replay_mock_from_path(&mut self, path: &std::path::Path) {
let mock = match crate::http::mock::load(path) {
Ok(m) => m,
Err(e) => {
self.toast(format!("replay_mock: {e}"));
return;
}
};
let has_request = matches!(
self.active.and_then(|i| self.panes.get(i)),
Some(Pane::Request(_))
);
if !has_request {
self.open_new_request_pane();
}
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.state =
crate::request_pane::RunState::Done(Box::new(crate::request_pane::ResponseView {
status: mock.status,
status_text: mock.status_text,
headers: mock.headers,
body_bytes: mock.body.as_bytes().to_vec(),
body: mock.body,
elapsed: std::time::Duration::ZERO,
timing: crate::http::Timing::default(),
assertions: Vec::new(),
captures: Vec::new(),
schema_result: None,
sse_event_count: 0,
}));
rp.view = crate::request_pane::ViewMode::Response;
}
self.toast(format!(
"replayed mock: {}",
path.file_name()
.and_then(|s| s.to_str())
.unwrap_or("(mock)")
));
}
/// `↓ Import…` sidebar chip → picker over supported import
/// formats. Accept fires the matching import path.
pub fn http_import_prompt(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let items = vec![
PickerItem::new(
"postman".to_string(),
"Postman collection".to_string(),
"from clipboard (JSON)".to_string(),
),
PickerItem::new(
"har".to_string(),
"HAR file".to_string(),
"from clipboard (Chrome/Firefox network export)".to_string(),
),
];
self.open_picker(Picker::new(PickerKind::HttpImport, "Import from:", items));
}
/// Accept handler for `PickerKind::HttpImport`.
pub fn accept_http_import(&mut self, kind_id: &str) {
match kind_id {
"postman" => self.http_import_postman_from_clipboard(),
"har" => self.http_import_har_from_clipboard(),
_ => {}
}
}
/// Parse the active editor as an HTTP request, expanding env
/// vars from `.mnml/env/$MNML_ENV` (or `.rqst/env/`). Returns
/// `None` when there's no active editor, it isn't a recognized
/// HTTP file, or parsing/template expansion fails. Used by
/// `http.bench` and similar one-off-request commands; the
/// richer `send_request_from_active` path does full multi-block
/// block-aware parsing for `.http` / `.rest`.
fn parse_active_as_request(&mut self) -> Option<crate::http::Request> {
use crate::http;
let cur = self.active?;
// From a Request pane, clone the in-flight request AND run the
// same pre-script + template::expand triplet every other send
// path runs. api-workflow SEV-1 2026-07-11: was previously a
// bare clone → http.bench fired the pane's literal
// `{{BASE_URL}}/…` templates to reqwest N times, guaranteeing
// "bad request: builder error" and a degenerate all-zero
// percentile histogram. Request-pane sends work because they
// route through spawn_http_job which does the same expansion;
// bench went straight through this helper without it.
if let Some(Pane::Request(rp)) = self.panes.get(cur) {
let mut request = rp.request.clone();
let script = rp.script.clone();
// api-round-12 SEV-1 2026-07-14 — bench went through
// the 4-tier resolver too; same story as send_active.
let mut env = self.active_envset();
http::script::apply_pre(&script, &mut request, &mut env);
request.url = http::template::expand(&request.url, &env);
for (_, v) in request.headers.iter_mut() {
*v = http::template::expand(v, &env);
}
if let Some(body) = request.body.as_mut() {
*body = http::template::expand(body, &env);
}
return Some(request);
}
let (ext, text, cursor_row, source_path) = match self.panes.get(cur) {
Some(Pane::Editor(b)) => (
b.language_ext.clone().unwrap_or_default(),
b.editor.text().to_string(),
b.editor.row_col().0,
b.path.clone(),
),
_ => return None,
};
if !matches!(ext.as_str(), "http" | "rest" | "curl") {
return None;
}
// qa-7th api SEV-2 2026-06-30 — was matches!("http" | "rest"),
// so .curl files always fell to the whole-file parse and
// ignored cursor position on multi-block .curl. Extended
// to .curl via the same line-scan strategy as
// move_to_http_block: find ### separators directly, slice
// out the cursor's block, parse JUST that block.
let lines: Vec<&str> = text.split('\n').collect();
let block_src = if matches!(ext.as_str(), "http" | "rest")
&& let Ok(blocks) = http::file::parse_all(&text)
{
// .http/.rest still use parse_all (rich block metadata).
let b = blocks
.iter()
.find(|b| cursor_row >= b.start_line && cursor_row <= b.end_line)
.unwrap_or(&blocks[0]);
Some(lines[b.start_line..=b.end_line.min(lines.len().saturating_sub(1))].join("\n"))
} else {
// .curl (and the catch-all): scan ### markers directly
// since parse_all rejects curl-syntax block bodies.
let starts: Vec<usize> = lines
.iter()
.enumerate()
.filter_map(|(i, l)| l.trim_start().starts_with("###").then_some(i))
.collect();
if starts.is_empty() {
None
} else {
let block_start = starts
.iter()
.rev()
.find(|&&s| s <= cursor_row)
.copied()
.unwrap_or(starts[0]);
let block_end = starts
.iter()
.find(|&&s| s > block_start)
.map(|&n| n - 1)
.unwrap_or(lines.len().saturating_sub(1));
Some(lines[block_start..=block_end].join("\n"))
}
};
// api-workflow round-8 SEV-2 2026-07-12 — resolve `-F @relpath`
// against the source file's parent so bench-style helpers
// don't hit the process-CWD bug either.
let base_dir = source_path.as_deref().and_then(|p| p.parent());
let (mut request, script_src) = match block_src {
Some(src) => match http::parse_with_base(&src, base_dir) {
Ok(r) => (r, src),
Err(_) => return None,
},
None => match http::parse_with_base(&text, base_dir) {
Ok(r) => (r, text.clone()),
Err(_) => return None,
},
};
let script = http::script::parse(&script_src);
// api-round-12 SEV-1 2026-07-14 — was
// `EnvSet::select_with_config_default` (4-tier: explicit /
// $MNML_ENV / config / .rqst-config). In a `.mnml`-only
// workspace with none of those set, it returned empty and
// every `{{VAR}}` reference in the request template stayed
// literal on the wire — Send failed with "unresolved vars"
// even though the Vars tab correctly showed the resolved
// values. Round-11 fix aligned the EDIT surface with the
// write path's "dev" fallback via `active_envset()` but
// left the SEND surface behind, splitting the resolver in
// half. Route through the shared helper so read/edit/write/
// send all agree.
let mut env = self.active_envset();
http::script::apply_pre(&script, &mut request, &mut env);
request.url = http::template::expand(&request.url, &env);
for (_, v) in request.headers.iter_mut() {
*v = http::template::expand(v, &env);
}
if let Some(body) = request.body.as_mut() {
*body = http::template::expand(body, &env);
}
Some(request)
}
/// `http.bench` — fire the active editor's request `n` times
/// across `concurrency` worker threads, then write the summary
/// trace to the clipboard and toast a one-liner. The full
/// trace has the p50/p95/p99/max + status-class breakdown so
/// the user can paste it into a buffer for inspection. Phase 5
/// of the rqst→mnml port-back; 2026-06-19.
///
/// Runs on a background thread (10 sequential 30-second
/// reqwest calls = up to 5 minutes of frozen UI without
/// this). `App::tick` drains the result channel.
pub fn http_bench_active(&mut self, n: u32, concurrency: u32) {
if self.http_bench_rx.is_some() {
self.toast("http.bench already running");
return;
}
let Some(req) = self.parse_active_as_request() else {
self.toast("http.bench: no active .http/.curl/.rest editor");
return;
};
let (tx, rx) = std::sync::mpsc::channel();
let progress = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0));
let progress_worker = progress.clone();
std::thread::spawn(move || {
let trace =
crate::http::bench::run_with_progress(&req, n, concurrency, Some(progress_worker));
let _ = tx.send(trace);
});
self.http_bench_rx = Some(rx);
self.http_bench_started = Some(std::time::Instant::now());
self.http_bench_progress = Some((progress, n));
self.toast(format!(
"http.bench: firing {n}× ({concurrency} concurrent)…"
));
}
/// Drain the in-flight `http.bench` result and surface it via
/// toast + clipboard. Called from `App::tick`.
pub fn drain_http_bench_result(&mut self) {
let Some(rx) = self.http_bench_rx.as_ref() else {
return;
};
match rx.try_recv() {
Ok(trace) => {
self.http_bench_rx = None;
// Pull the "bench summary" headline out for the
// toast; the FULL trace also opens as a scratch
// buffer so the user can read / share / save it
// directly. Earlier impl only put the trace on
// the clipboard (mouse hunt SEV-3: invisible,
// and the toast's "trace → clipboard" hint
// wasn't clickable). Clipboard still gets a copy
// for paste-into-elsewhere workflows.
let headline = trace
.lines()
.find(|l| l.trim_start().starts_with("bench summary"))
.unwrap_or("bench: complete")
.trim()
.to_string();
self.clipboard.set(trace.clone(), false);
self.open_scratch_with_text("[bench-trace]".to_string(), trace);
self.toast(format!(
"{headline} · full trace → [bench-trace] + clipboard"
));
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.http_bench_rx = None;
self.toast("http.bench: worker dropped");
}
}
}
/// `jwt.decode` — decode the JWT currently on the clipboard
/// (claims segment only — signature isn't verified, this is
/// purely a display tool for tokens you already have). Toasts
/// the headline claims (`sub`, `email`, `exp`) so a user can
/// quickly check who/when a token is for. Phase 8 of the
/// rqst→mnml port-back; 2026-06-19.
pub fn jwt_decode_clipboard(&mut self) {
let token = self.clipboard.text();
if token.trim().is_empty() {
self.toast("jwt.decode: clipboard is empty");
return;
}
let Some(claims) = crate::jwt::decode(&token) else {
self.toast("jwt.decode: not a valid JWT (3 dot-separated segments)");
return;
};
let mut parts: Vec<String> = Vec::new();
if let Some(sub) = claims.sub.as_deref() {
parts.push(format!("sub={sub}"));
}
if let Some(email) = claims.email.as_deref() {
parts.push(format!("email={email}"));
}
if let Some(exp) = claims.exp_display() {
parts.push(format!("exp={exp}"));
}
if claims.is_expired() {
parts.push("EXPIRED".into());
}
let msg = if parts.is_empty() {
"jwt.decode: (token has no standard claims)".to_string()
} else {
format!("jwt: {}", parts.join(" · "))
};
self.toast(msg);
}
/// `sse.parse_active_response` — parse the active Request
/// pane's Done response body as Server-Sent Events and toast
/// the event count + first event's name/data preview. Useful
/// when an endpoint streams `data: <json>` lines and you want
/// to confirm the SSE shape without reading raw text. The full
/// progressive streaming-send display (per-event response pane
/// updates) is a v2 follow-up. Phase 8 follow-up of the
/// rqst→mnml port-back.
pub fn sse_parse_active_response(&mut self) {
let body = self
.active
.and_then(|i| self.panes.get(i))
.and_then(|p| match p {
Pane::Request(rp) => match &rp.state {
crate::request_pane::RunState::Done(rv) => Some(rv.body.clone()),
_ => None,
},
_ => None,
});
let Some(body) = body else {
self.toast("sse.parse: no active Request pane with a Done response");
return;
};
let mut reader = crate::sse::Reader::new(body.as_bytes());
let mut events: Vec<crate::sse::Event> = Vec::new();
while let Ok(Some(evt)) = reader.next_event() {
events.push(evt);
}
if events.is_empty() {
self.toast("sse.parse: body has no SSE events (no blank-line-delimited data blocks)");
return;
}
let first = &events[0];
let preview = if first.data.len() > 40 {
format!("{}…", &first.data[..38])
} else {
first.data.clone()
};
let label = if first.name.is_empty() {
String::new()
} else {
format!(" [{}]", first.name)
};
self.toast(format!(
"sse: {} event(s){label} · first: {preview}",
events.len()
));
}
/// `auth.save_preset` — read the active Request pane's
/// Authorization header, prompt for a preset name, write to
/// `.mnml/auth/<name>.txt`. Useful when a long-lived token is
/// the only thing distinguishing several environments — store
/// once, apply later via `:auth.apply_preset`.
pub fn auth_save_preset_prompt(&mut self) {
let Some(cur) = self.active else {
self.toast("auth: no active Request pane");
return;
};
let has = match self.panes.get(cur) {
Some(Pane::Request(rp)) => rp
.request
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("authorization")),
_ => false,
};
if !has {
self.toast("auth: active Request has no Authorization header");
return;
}
self.prompt = Some(crate::prompt::Prompt::new(
crate::prompt::PromptKind::AuthSavePreset,
"Preset name (filename stem):".to_string(),
));
}
/// Accept handler for `PromptKind::AuthSavePreset`.
pub fn accept_auth_save_preset(&mut self, name: &str) {
let name = name.trim();
if name.is_empty() {
self.toast("auth: preset name can't be empty");
return;
}
let safe_name: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
let Some(cur) = self.active else { return };
let header_value = match self.panes.get(cur) {
Some(Pane::Request(rp)) => rp
.request
.headers
.iter()
.find(|(k, _)| k.eq_ignore_ascii_case("authorization"))
.map(|(_, v)| v.clone()),
_ => None,
};
let Some(value) = header_value else { return };
let path = self
.workspace
.join(".mnml")
.join("auth")
.join(format!("{safe_name}.txt"));
if let Some(parent) = path.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
self.toast(format!("auth: mkdir: {e}"));
return;
}
match std::fs::write(&path, &value) {
Ok(()) => self.toast(format!("auth: saved → {}", path.display())),
Err(e) => self.toast(format!("auth: write failed: {e}")),
}
}
/// `auth.apply_preset` — picker over `.mnml/auth/*.txt`. Enter
/// reads the preset and sets the active Request pane's
/// Authorization header to its content.
pub fn auth_apply_preset_picker(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let auth_dir = self.workspace.join(".mnml").join("auth");
let entries: Vec<PickerItem> = match std::fs::read_dir(&auth_dir) {
Ok(rd) => rd
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "txt"))
.filter_map(|e| {
let stem = e.path().file_stem()?.to_string_lossy().into_owned();
let preview = std::fs::read_to_string(e.path())
.ok()
.map(|s| {
let line = s.lines().next().unwrap_or("").to_string();
if line.len() > 48 {
format!("{}…", &line[..46])
} else {
line
}
})
.unwrap_or_default();
Some(PickerItem::new(stem.clone(), stem, preview))
})
.collect(),
Err(_) => Vec::new(),
};
if entries.is_empty() {
self.toast(format!(
"auth: no presets in {} (save with :auth.save_preset)",
auth_dir.display()
));
return;
}
self.open_picker(Picker::new(
PickerKind::AuthPresets,
"Auth presets",
entries,
));
}
/// Accept handler for `PickerKind::AuthPresets`.
pub fn accept_auth_preset(&mut self, name: &str) {
let path = self
.workspace
.join(".mnml")
.join("auth")
.join(format!("{name}.txt"));
let value = match std::fs::read_to_string(&path) {
Ok(s) => s.trim_end().to_string(),
Err(e) => {
self.toast(format!("auth: read {}: {e}", path.display()));
return;
}
};
let Some(cur) = self.active else {
self.toast("auth: no active Request pane");
return;
};
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
// Replace existing Authorization header in-place, or
// append a new one. Also reflect into headers_buffer
// (the editable textarea source of truth) so the user
// sees the change in the Headers tab immediately.
let existing = rp
.request
.headers
.iter()
.position(|(k, _)| k.eq_ignore_ascii_case("authorization"));
if let Some(i) = existing {
rp.request.headers[i].1 = value.clone();
} else {
rp.request
.headers
.push(("Authorization".to_string(), value.clone()));
}
rp.headers_buffer = crate::request_pane::headers_to_text(&rp.request.headers);
rp.headers_cursor = rp.headers_buffer.len();
self.toast(format!("auth: applied {name}"));
}
}
/// `cookies.delete` — picker over jar entries; Enter removes
/// the selected cookie + persists. Companion to `cookies.show`
/// (which copies on Enter).
pub fn cookies_delete_picker(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let Ok(jar) = self.cookie_jar.lock() else {
self.toast("cookies: jar lock poisoned");
return;
};
let items: Vec<PickerItem> = jar
.iter()
.map(|(host, name, value)| {
let preview = if value.len() > 32 {
format!("{}…", &value[..30])
} else {
value.to_string()
};
let id = format!("{host}\t{name}");
let label = format!("{host} · {name} · {preview}");
PickerItem::new(id, label, String::new())
})
.collect();
let total = items.len();
drop(jar);
if items.is_empty() {
self.toast("cookies: jar is empty");
return;
}
self.open_picker(Picker::new(
PickerKind::CookiesDelete,
format!("Delete cookie ({total} total)"),
items,
));
}
/// `cookies.show` — picker over every entry in the persistent
/// cookie jar. Rows: `<host> · <name> · <preview>`. Enter
/// copies `<name>=<value>` to clipboard.
pub fn cookies_show_picker(&mut self) {
use crate::picker::{Picker, PickerItem, PickerKind};
let Ok(jar) = self.cookie_jar.lock() else {
self.toast("cookies: jar lock poisoned");
return;
};
let mut items: Vec<PickerItem> = jar
.iter()
.map(|(host, name, value)| {
let preview = if value.len() > 32 {
format!("{}…", &value[..30])
} else {
value.to_string()
};
let id = format!("{host}\t{name}");
let label = format!("{host} · {name} · {preview}");
PickerItem::new(id, label, String::new())
})
.collect();
let total = items.len();
drop(jar);
if items.is_empty() {
items.push(PickerItem::new(
"_empty".to_string(),
"(jar is empty — :http.send accumulates from Set-Cookie)".to_string(),
String::new(),
));
}
self.open_picker(Picker::new(
PickerKind::Cookies,
format!("Cookies ({total} total)"),
items,
));
}
/// `cookies.clear` — drop every cookie from the jar (in-memory
/// + persisted file). Useful when login state on a domain has
/// gone bad and you want a fresh start.
pub fn cookies_clear_jar(&mut self) {
let prev = {
let Ok(mut jar) = self.cookie_jar.lock() else {
self.toast("cookies: jar lock poisoned");
return;
};
let prev = jar.total();
jar.clear();
let _ = jar.save(&self.workspace);
prev
};
self.toast(format!("cookies: cleared {prev} entries"));
}
/// `cookies.persist` — write the in-memory jar to
/// `.mnml/cookies.json` immediately. The jar auto-flushes on
/// some mutations but this is the explicit "flush now" path.
pub fn cookies_persist(&mut self) {
let outcome = {
let Ok(jar) = self.cookie_jar.lock() else {
self.toast("cookies: jar lock poisoned");
return;
};
let total = jar.total();
match jar.save(&self.workspace) {
Ok(p) => Ok((total, p)),
Err(e) => Err(e),
}
};
match outcome {
Ok((n, p)) => self.toast(format!("cookies: wrote {n} entries → {}", p.display())),
Err(e) => self.toast(format!("cookies: write failed: {e}")),
}
}
/// `cookies.normalize_clipboard` — read the clipboard, run it
/// through `crate::cookies::normalize_cookie_value` to collapse
/// any of the three DevTools paste shapes into the canonical
/// `name=value; name=value; …` form, and write the result back
/// to the clipboard. Lets a user paste cookies copied from
/// Chrome's Network or Application tab, run this, then paste
/// the result into a `Cookie:` header value without hand-
/// editing. Phase 8 follow-up of the rqst→mnml port-back.
pub fn cookies_normalize_clipboard(&mut self) {
let raw = self.clipboard.text();
if raw.trim().is_empty() {
self.toast("cookies.normalize: clipboard is empty");
return;
}
let normalized = crate::cookies::normalize_cookie_value(&raw);
if normalized.is_empty() {
self.toast("cookies.normalize: no cookie pairs found");
return;
}
let preview = if normalized.len() > 64 {
format!("{}…", &normalized[..62])
} else {
normalized.clone()
};
self.clipboard.set(normalized, false);
self.toast(format!("cookies: {preview} (copied)"));
}
/// `auth.extract_bearer` — pull a bearer token out of arbitrary
/// clipboard text (a paste of `Authorization: Bearer eyJ…` or
/// just `Bearer eyJ…`, or the bare JWT itself). Writes the
/// extracted token back to the clipboard so the user can paste
/// it into an env file. Phase 8 of the rqst→mnml port-back.
pub fn auth_extract_bearer_from_clipboard(&mut self) {
let raw = self.clipboard.text();
match crate::auth::extract_bearer_from_clipboard(&raw) {
Some(token) => {
let preview = if token.len() > 18 {
format!("{}…{}", &token[..6], &token[token.len() - 6..])
} else {
token.clone()
};
self.clipboard.set(token, false);
self.toast(format!("bearer: {preview} (copied)"));
}
None => {
self.toast("auth.extract_bearer: no bearer token found");
}
}
}
/// `http.sync` — read `<workspace>/.mnml/sources.json` (or
/// `<workspace>/.rqst/sources.json` for legacy workspaces) and
/// regenerate `.curl` stub files for every `kind: "swagger"`
/// source. Runs on a background thread (reqwest's blocking
/// client has a 30-second per-request timeout; 6 sources ×
/// 30s = potentially 3 minutes of frozen UI without this).
/// `App::tick` drains the result channel + toasts. Reviewer-
/// flagged 2026-06-19 — phase 2 of the rqst→mnml port-back.
pub fn http_sync_sources(&mut self) {
if self.http_sync_rx.is_some() {
self.toast("http.sync already running");
return;
}
let workspace = self.workspace.clone();
let normalize = self.config.http.sync_normalize;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = crate::http::sources::run_sync_with_normalize(&workspace, normalize);
let _ = tx.send(result);
});
self.http_sync_rx = Some(rx);
self.http_sync_started = Some(std::time::Instant::now());
self.toast(if normalize {
"http.sync: fetching swagger sources… (normalize on)"
} else {
"http.sync: fetching swagger sources…"
});
}
/// `http.sync_check` — dry-run drift check. Fetches every
/// swagger source (same as `http.sync`) but generates into a
/// temp dir + diffs against the on-disk stub tree. Opens a
/// scratch pane with the added/removed/changed report; NO
/// writes to the real `.rqst/requests/` tree. Users who want
/// to know what changed upstream without touching their
/// current stubs run this first, then decide whether to
/// follow up with `http.sync`.
/// 2026-07-08 user request.
pub fn http_sync_check(&mut self) {
if self.http_sync_check_rx.is_some() {
self.toast("http.sync_check already running");
return;
}
let workspace = self.workspace.clone();
let normalize = self.config.http.sync_normalize;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let result = crate::http::sources::check_sync_with_normalize(&workspace, normalize);
let _ = tx.send(result);
});
self.http_sync_check_rx = Some(rx);
self.toast(if normalize {
"http.sync_check: checking for drift… (normalize on)"
} else {
"http.sync_check: checking for drift…"
});
}
/// Drain the in-flight `http.sync_check` result. Called from
/// `App::tick`; opens the drift trace as a `[sync-check]`
/// scratch pane and toasts a summary.
pub fn drain_http_sync_check_result(&mut self) {
let Some(rx) = self.http_sync_check_rx.as_ref() else {
return;
};
match rx.try_recv() {
Ok(Ok((trace, drift))) => {
self.http_sync_check_rx = None;
if drift == 0 {
self.toast("http.sync_check: clean — no drift");
} else {
self.toast(format!("http.sync_check: {drift} file(s) differ"));
}
self.open_scratch_with_text("[sync-check]".into(), trace);
}
Ok(Err(e)) => {
self.http_sync_check_rx = None;
self.toast(format!("http.sync_check failed: {e}"));
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.http_sync_check_rx = None;
self.toast("http.sync_check: worker dropped");
}
}
}
/// Drain the in-flight `http.sync` result. Called from
/// `App::tick`; no-op when nothing is pending or the worker
/// hasn't responded yet.
pub fn drain_http_sync_result(&mut self) {
let Some(rx) = self.http_sync_rx.as_ref() else {
return;
};
match rx.try_recv() {
Ok(Ok((_trace, total))) => {
self.http_sync_rx = None;
self.toast(format!(
"http.sync: wrote {total} request stub(s) — tree refreshed"
));
self.tree.refresh();
}
Ok(Err(e)) => {
self.http_sync_rx = None;
self.toast(format!("http.sync failed: {e}"));
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
self.http_sync_rx = None;
self.toast("http.sync: worker dropped");
}
}
}
/// `http.send` — parse the active `.http`/`.rest`/`.curl` editor (the block
/// under the cursor for multi-block `.http` files), expand `{{vars}}` against
/// `.mnml/env/$MNML_ENV`, open a `Pane::Request` split, and fire the request
/// on a background thread. `tick` delivers the response.
pub fn send_request_from_active(&mut self) {
use crate::http;
let Some(cur) = self.active else {
self.toast("no active editor");
return;
};
// From an existing request pane, `http.send` just re-fires it.
if matches!(self.panes.get(cur), Some(Pane::Request(_))) {
// Auto-format the body before send so what gets fired
// matches what the user just saw pretty-printed.
self.maybe_auto_format_active_body();
// 2026-07-21 — sending commits the preview state.
// Prevents the pane from being silently force-closed
// when the user later switches activity sections.
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.is_preview = false;
}
self.refire_request(cur);
return;
}
let (path, ext, text, cursor_row) = match self.panes.get(cur) {
Some(Pane::Editor(b)) => (
b.path.clone(),
b.language_ext.clone().unwrap_or_default(),
b.editor.text().to_string(),
b.editor.row_col().0,
),
_ => {
self.toast("not an editor");
return;
}
};
if !matches!(ext.as_str(), "http" | "rest" | "curl") {
self.toast("http.send needs a .http / .rest / .curl file");
return;
}
// Pick the request + the directive text. For `.http`/`.rest`, use the
// block under the cursor; otherwise treat the whole buffer as one request.
// `source_block_name` is captured iff the file is genuinely multi-block
// (>1 parsed block) — single-block files round-trip through the simple
// overwrite path on save.
// qa-7th api SEV-2 2026-06-30 — extended to .curl via
// direct ### scan; parse_all rejects curl-syntax bodies
// so it can't dispatch .curl on its own.
let lines: Vec<&str> = text.split('\n').collect();
let (request, script_src, source_block_name): (http::Request, String, Option<String>) = {
// .http/.rest still use parse_all for rich metadata.
if matches!(ext.as_str(), "http" | "rest")
&& let Ok(blocks) = http::file::parse_all(&text)
{
let b = blocks
.iter()
.find(|b| cursor_row >= b.start_line && cursor_row <= b.end_line)
.unwrap_or(&blocks[0]);
let src =
lines[b.start_line..=b.end_line.min(lines.len().saturating_sub(1))].join("\n");
let block_name = if blocks.len() > 1 {
if lines
.get(b.start_line)
.is_some_and(|l| l.trim_start().starts_with("###"))
{
Some(b.name.clone().unwrap_or_default())
} else {
None
}
} else {
None
};
(b.request.clone(), src, block_name)
} else {
// .curl (and other): scan ### directly.
let has_separators = lines.iter().any(|l| l.trim_start().starts_with("###"));
let (slice, block_name) = if !has_separators {
(text.clone(), None)
} else {
let (block_start, block_end) = curl_block_bounds(&lines, cursor_row);
let name = if lines
.get(block_start)
.is_some_and(|l| l.trim_start().starts_with("###"))
{
let after_hashes = lines[block_start]
.trim_start()
.trim_start_matches('#')
.trim()
.to_string();
Some(after_hashes)
} else {
None
};
(lines[block_start..=block_end].join("\n"), name)
};
// api-workflow round-8 SEV-2 2026-07-12 — pass the
// .curl file's own dir so `-F name=@relpath` uploads
// resolve against the workspace layout, not the mnml
// process's CWD.
let base_dir = path.as_deref().and_then(|p| p.parent());
match http::parse_with_base(&slice, base_dir) {
Ok(r) => (r, slice, block_name),
Err(e) => {
self.toast(format!("can't parse request: {e}"));
return;
}
}
}
};
let script = http::script::parse(&script_src);
// api-round-12 SEV-1 2026-07-14 — was
// `EnvSet::select_with_config_default` (4-tier: explicit /
// $MNML_ENV / config / .rqst-config). In a `.mnml`-only
// workspace with none of those set, it returned empty and
// every `{{VAR}}` reference in the request template stayed
// literal on the wire — Send failed with "unresolved vars"
// even though the Vars tab correctly showed the resolved
// values. Round-11 fix aligned the EDIT surface with the
// write path's "dev" fallback via `active_envset()` but
// left the SEND surface behind, splitting the resolver in
// half. Route through the shared helper so read/edit/write/
// send all agree.
let mut env = self.active_envset();
// Merge the file's running env (@capture-populated) so a
// login → orders flow inside one file resolves `{{TOKEN}}`.
// Later entries win over base env values.
self.merge_http_running_env(path.as_deref(), &mut env);
// api-workflow SEV-1 fix 2026-07-10 — expand `{{VAR}}` on a
// CLONE and send that; the pane keeps the templated version.
// Prior code mutated `request` in place then stored it on
// the pane, so a later `file.save` wrote resolved values
// (`Bearer devtoken123`) back to disk where the source had
// `Bearer {{TOKEN}}` — leaking secrets to git. `refire_request`
// already does this correctly; matched its pattern here.
let mut resolved = request.clone();
http::script::apply_pre(&script, &mut resolved, &mut env);
resolved.url = http::template::expand(&resolved.url, &env);
for (_, v) in &mut resolved.headers {
*v = http::template::expand(v, &env);
}
if let Some(b) = &mut resolved.body {
*b = http::template::expand(b, &env);
}
let job_id = self.spawn_http_job(resolved, script.clone(), path.clone());
let mut rp = crate::request_pane::RequestPane::new(path, request, script, job_id);
rp.source_block_name = source_block_name;
let new_id =
self.split_leaf_with(cur, crate::layout::SplitDir::Horizontal, Pane::Request(rp));
self.active = Some(new_id);
self.focus = Focus::Pane;
}
/// Re-send the request a `Pane::Request` already holds (its `r` key / re-`http.send`).
fn refire_request(&mut self, pane_id: PaneId) {
// Apply edits from the Headers field (the editable buffer is the
// source of truth in Edit mode — parse it back before sending).
if let Some(Pane::Request(rp)) = self.panes.get_mut(pane_id) {
rp.commit_headers();
}
let (mut request, script, source_path) = match self.panes.get(pane_id) {
Some(Pane::Request(rp)) => (
rp.request.clone(),
rp.script.clone(),
rp.source_path.clone(),
),
_ => return,
};
// #polish 2026-07-07 (multilang-dev SEV-1) — resolve `{{VAR}}`
// templates before spawning the job. Was: refire_request
// (opened when clicking a `.curl`/`.http` file or pressing `r`
// on a Request pane) skipped `template::expand`, so vars
// stayed literal on the wire — breaking the headline var/auth-
// token flow that the sidebar UI heavily depends on. Other
// send paths (`send_active`, `send_file`) already do this;
// refire_request was the outlier.
// api-round-12 SEV-1 2026-07-14 — final send-path holdout;
// route through `active_envset()` so read/edit/write/send
// all agree on the effective env.
let mut env = self.active_envset();
self.merge_http_running_env(source_path.as_deref(), &mut env);
crate::http::script::apply_pre(&script, &mut request, &mut env);
request.url = crate::http::template::expand(&request.url, &env);
for (_, v) in &mut request.headers {
*v = crate::http::template::expand(v, &env);
}
if let Some(body) = &mut request.body {
*body = crate::http::template::expand(body, &env);
}
let job_id = self.spawn_http_job(request, script, source_path);
if let Some(Pane::Request(rp)) = self.panes.get_mut(pane_id) {
rp.job_id = job_id;
rp.state = crate::request_pane::RunState::Sending;
rp.scroll = 0;
}
}
/// Allocate a job id, ensure the result channel exists, spawn the worker.
/// `source_path` (the request's `.curl` / `.http` source file, if any)
/// is threaded through so the worker can resolve an integration
/// `*.schema.json` and validate the response body.
fn spawn_http_job(
&mut self,
mut request: crate::http::Request,
script: crate::http::script::Script,
source_path: Option<std::path::PathBuf>,
) -> u64 {
use crate::request_pane::ResponseView;
let job_id = self.next_job_id;
self.next_job_id += 1;
let tx = self
.http_chan
.get_or_insert_with(std::sync::mpsc::channel)
.0
.clone();
// 2026-06-19 — cookie jar v1: if the request URL's host
// has cookies stored, inject a Cookie header (only when
// the caller didn't already set one). The header value
// is the on-the-wire `name=v; name=v` form via
// CookieJar::cookie_header_for.
let jar = self.cookie_jar.clone();
if let Some(host) = crate::cookie_jar::CookieJar::host_of(&request.url)
&& !request
.headers
.iter()
.any(|(k, _)| k.eq_ignore_ascii_case("cookie"))
&& let Ok(j) = jar.lock()
&& let Some(cookie) = j.cookie_header_for(&host)
{
request.headers.push(("Cookie".to_string(), cookie));
}
let host_for_record = crate::cookie_jar::CookieJar::host_of(&request.url);
std::thread::spawn(move || {
let result: Result<ResponseView, String> = (|| {
let resp = crate::http::send(&request)?;
// Record any Set-Cookie headers from the response.
if let Some(host) = &host_for_record
&& let Ok(mut j) = jar.lock()
{
for (k, v) in &resp.headers {
if k.eq_ignore_ascii_case("set-cookie") {
j.record_set_cookie(host, v);
}
}
}
let assertions = crate::http::script::run_assertions(
&script,
resp.status,
&resp.headers,
&resp.body,
);
let mut env = crate::http::template::EnvSet::empty();
let captures = crate::http::script::apply_captures(
&script,
&resp.headers,
&resp.body,
&mut env,
);
let schema_result = source_path
.as_deref()
.map(|p| crate::http::schema::validate_for(Some(p), &resp.body));
Ok(ResponseView {
status: resp.status,
status_text: resp.status_text,
headers: resp.headers,
body: resp.body,
body_bytes: resp.body_bytes,
elapsed: resp.elapsed,
timing: resp.timing,
assertions,
captures,
schema_result,
sse_event_count: 0,
})
})();
let _ = tx.send((job_id, result));
});
job_id
}
/// `http.paste_curl` — read the clipboard, parse as curl /
/// `.http` / `.rest`, overwrite the active Request pane's
/// Method / URL / Headers / Body. Postman-style "paste a curl
/// from Chrome DevTools" workflow. If no active Request pane,
/// opens a blank one first (`:http.new` + `:http.paste_curl`
/// chain works seamlessly).
pub fn http_paste_curl_to_active(&mut self) {
let raw = self.clipboard.text();
if raw.trim().is_empty() {
self.toast("http.paste_curl: clipboard is empty");
return;
}
// #20 Pattern B — if the active Request pane has non-empty
// URL / body / headers, pop the confirm modal before we
// clobber user work. Simple guard: any non-blank field
// means "not fresh".
let dirty = self
.active
.and_then(|i| self.panes.get(i))
.and_then(|p| match p {
Pane::Request(rp) => Some(rp),
_ => None,
})
.is_some_and(|rp| {
!rp.request.url.trim().is_empty()
|| !rp.request.body.as_deref().unwrap_or("").trim().is_empty()
|| !rp.request.headers.is_empty()
});
if dirty {
self.pending_confirm = Some(crate::app::PendingConfirm {
title: "Overwrite request?".to_string(),
message: "The active request has unsaved edits. Pasting will replace them."
.to_string(),
confirm_label: "Overwrite".to_string(),
focused: 0,
action: crate::app::ConfirmAction::OverwriteRequestPane { raw },
});
return;
}
self.http_paste_curl_from_text(&raw);
}
/// Core impl behind `http.paste_curl` — parses `raw` as curl /
/// `.http` / `.rest` and populates the active Request pane's
/// fields. Opens a new Request pane first if none is active
/// (matches paste_curl's "just make it work" idiom). Shared
/// with the bracketed-paste handler so pasting a curl into a
/// blank Request pane populates the form directly.
pub fn http_paste_curl_from_text(&mut self, raw: &str) {
if raw.trim().is_empty() {
return;
}
let parsed = match crate::http::parse(raw) {
Ok(r) => r,
Err(e) => {
self.toast(format!("http.paste_curl: parse failed: {e}"));
return;
}
};
let has_request = matches!(
self.active.and_then(|i| self.panes.get(i)),
Some(Pane::Request(_))
);
if !has_request {
self.open_new_request_pane();
}
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.headers_buffer = crate::request_pane::headers_to_text(&parsed.headers);
rp.headers_cursor = rp.headers_buffer.len();
rp.url_cursor = parsed.url.len();
rp.body_cursor = parsed.body.as_deref().map(str::len).unwrap_or(0);
rp.request = parsed;
rp.view = crate::request_pane::ViewMode::Edit;
rp.focus = crate::request_pane::EditField::Url;
rp.edit_tab = crate::request_pane::EditTab::Body;
// 2026-07-21 — a paste_curl populates the pane, which
// counts as a commit. Without this, switching activity
// sections would silently force-close the pane because
// `is_preview` is only cleared by literal KeyCode
// char events. SEV-1 from api-workflow-user.
rp.is_preview = false;
}
// Auto-format the just-pasted body when the config is on —
// curl paste often dumps a compressed one-line JSON blob.
self.maybe_auto_format_active_body();
let preview = if raw.trim().len() > 56 {
format!("{}…", &raw.trim()[..54])
} else {
raw.trim().to_string()
};
self.toast(format!("paste_curl: populated from {preview}"));
}
/// Cheap "does this look like a curl / http-file paste?" check.
/// Used by the bracketed-paste handler to decide whether to
/// route a paste into the Request pane's field-population path
/// or fall through to the default (text-insert into focused
/// field). Handles the "curl -X POST ..." shape plus the
/// bare-URL + method-verb-prefix shapes that the http/rest
/// parsers accept.
pub fn text_looks_like_curl(raw: &str) -> bool {
let trimmed = raw.trim_start();
if trimmed.starts_with("curl ") || trimmed.starts_with("curl\t") {
return true;
}
// "GET https://..." / "POST http://..." shape.
for verb in [
"GET ", "POST ", "PUT ", "PATCH ", "DELETE ", "HEAD ", "OPTIONS ",
] {
if let Some(rest) = trimmed.strip_prefix(verb)
&& (rest.starts_with("http://") || rest.starts_with("https://"))
{
return true;
}
}
false
}
/// `http.paste_source` — parse the active Request pane's
/// `source_buffer` (Source tab) into the structured Method /
/// URL / Headers / Body fields, clear the buffer, switch to
/// Body tab. Same parse pipeline as `:http.paste_curl` (just
/// reads from the pane field instead of the clipboard).
pub fn http_parse_source_buffer(&mut self) {
let Some(cur) = self.active else {
self.toast("paste_source: no active Request pane");
return;
};
let src = match self.panes.get(cur) {
Some(Pane::Request(rp)) => rp.source_buffer.clone(),
_ => {
self.toast("paste_source: active pane is not a Request");
return;
}
};
if src.trim().is_empty() {
self.toast("paste_source: Source buffer is empty");
return;
}
let parsed = match crate::http::parse(&src) {
Ok(r) => r,
Err(e) => {
self.toast(format!("paste_source: parse failed: {e}"));
return;
}
};
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.headers_buffer = crate::request_pane::headers_to_text(&parsed.headers);
rp.headers_cursor = rp.headers_buffer.len();
rp.url_cursor = parsed.url.len();
rp.body_cursor = parsed.body.as_deref().map(str::len).unwrap_or(0);
rp.request = parsed;
rp.source_buffer.clear();
rp.source_cursor = 0;
rp.view = crate::request_pane::ViewMode::Edit;
rp.edit_tab = crate::request_pane::EditTab::Body;
rp.focus = crate::request_pane::EditField::Url;
self.toast("paste_source: populated from Source buffer");
}
}
/// Pick a `{{VAR}}` name for the palette-driven `set_value` /
/// `jump_to_definition` commands to act on: first tries the URL
/// caret location; falls back to the first UNDEFINED var anywhere
/// in URL / body / headers (so a keyboard user can define a
/// missing token without needing to place the caret exactly).
/// Returns "" when no vars are present. 2026-07-07.
pub fn pending_var_at_cursor_name(&self) -> String {
let Some(cur) = self.active else {
return String::new();
};
let Some(crate::pane::Pane::Request(rp)) = self.panes.get(cur) else {
return String::new();
};
// api-round-11 SEV-1 2026-07-14 — use the shared helper so
// this reader agrees with the writer at http_kv_edit_commit.
let envset = self.active_envset();
// 1. URL caret first — most specific.
let url = &rp.request.url;
let caret = rp.url_cursor.min(url.len());
let bytes = url.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'{'
&& bytes[i + 1] == b'{'
&& let Some(end_off) = url[i + 2..].find("}}")
{
let end = i + 2 + end_off + 2;
if caret >= i && caret <= end {
let name = url[i + 2..i + 2 + end_off].trim();
if !name.is_empty() {
return name.to_string();
}
}
i = end;
continue;
}
i += 1;
}
// 2. First undefined var anywhere.
let hay = format!(
"{} {} {}",
url,
rp.request.body.as_deref().unwrap_or(""),
rp.headers_buffer
);
let bytes = hay.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'{'
&& bytes[i + 1] == b'{'
&& let Some(end_off) = hay[i + 2..].find("}}")
{
let name = hay[i + 2..i + 2 + end_off].trim().to_string();
if !name.is_empty() && !name.starts_with('$') && envset.lookup(&name).is_none() {
return name;
}
i = i + 2 + end_off + 2;
continue;
}
i += 1;
}
// 3. Any var at all (fallback).
let bytes = url.as_bytes();
let mut i = 0;
while i + 1 < bytes.len() {
if bytes[i] == b'{'
&& bytes[i + 1] == b'{'
&& let Some(end_off) = url[i + 2..].find("}}")
{
let name = url[i + 2..i + 2 + end_off].trim().to_string();
if !name.is_empty() {
return name;
}
i = i + 2 + end_off + 2;
continue;
}
i += 1;
}
String::new()
}
/// Click on a `{{VAR}}` token in a Request pane → open the active
/// env file at the line where `<name>=…` lives. Falls back to
/// opening the env file at end-of-file (so the user can add the
/// var) when the name isn't defined yet. `.mnml/env/<n>.env`
/// wins over `.rqst/env/<n>.env` when both exist. 2026-07-07.
pub fn open_env_var_definition(&mut self, name: &str) {
// Dynamic vars (`{{$uuid}}`, `{{$timestamp}}`, `{{$epoch}}`,
// etc.) resolve through `dynamic_var()`, not the env file —
// there's no env entry to jump to. Toast the built-in's
// behavior instead so a click on a resolved dynamic var
// doesn't send the user into a "not defined — jump to end"
// dead-end. Unknown `$foo` names get a clear "unknown
// dynamic" message. SEV-3 fix 2026-07-07.
if let Some(dyn_name) = name.strip_prefix('$') {
match crate::http::template::dynamic_var(dyn_name) {
Some(val) => {
let clipped: String = val.chars().take(60).collect();
self.toast(format!(
"{{{{{name}}}}} is a built-in dynamic var (current: {clipped})"
));
}
None => {
self.toast(format!(
"{{{{{name}}}}} — unknown dynamic var (try $uuid / $timestamp / $epoch / $randomInt / $randomHex / $randomString / $randomBool)"
));
}
}
return;
}
// api-round-11 SEV-1 2026-07-14 — shared helper so this
// "set value" flow can't disagree with the reader at
// pending_var_at_cursor_name or the writer.
let envset = self.active_envset();
// Resolve the target env name. If there's an active env, use it.
// Otherwise (vscode-mouse SEV-2 #6 2026-07-10) fall back to the
// sole env file when there's exactly one — a click on a var in
// a workspace with just `dev.env` should Just Work without
// forcing the user to select an active env first.
let env_name = match envset.name() {
Some(n) => n.to_string(),
None => {
let mut env_files = Vec::new();
for dir in [
self.workspace.join(".mnml").join("env"),
self.workspace.join(".rqst").join("env"),
] {
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
let p = e.path();
if p.extension().and_then(|s| s.to_str()) == Some("env")
&& let Some(stem) = p.file_stem().and_then(|s| s.to_str())
&& !env_files.iter().any(|s: &String| s == stem)
{
env_files.push(stem.to_string());
}
}
}
}
match env_files.as_slice() {
[only] => only.clone(),
[] => {
self.toast(format!(
"no env files under .mnml/env or .rqst/env — create one to define {name}"
));
return;
}
_ => {
self.toast(format!(
"no active env selected ({} envs) — click env chip to pick one, then click {name} again",
env_files.len()
));
return;
}
}
}
};
// Candidate files in preference order — .mnml/env wins on new-var
// creation (higher-priority overlay), .rqst/env is the legacy
// fallback. For jump-to-def, prefer the file that ACTUALLY
// defines the var, not just the first that exists — api-workflow
// SEV-2 2026-07-10: a var defined only in .rqst/ was silently
// reported "not defined" because `.mnml/env/<n>.env` existed
// (empty or with other vars).
let candidates = [
self.workspace
.join(".mnml")
.join("env")
.join(format!("{env_name}.env")),
self.workspace
.join(".rqst")
.join("env")
.join(format!("{env_name}.env")),
];
let find_definition = |path: &std::path::Path| -> Option<usize> {
let text = std::fs::read_to_string(path).ok()?;
for (idx, line) in text.lines().enumerate() {
let stripped = line.trim_start();
let stripped = stripped.strip_prefix("export ").unwrap_or(stripped);
if let Some(rest) = stripped.strip_prefix(name)
&& rest.trim_start().starts_with('=')
{
return Some(idx);
}
}
None
};
// First pass: prefer a candidate that defines the var.
let mut chosen: Option<(std::path::PathBuf, Option<usize>)> = None;
for c in &candidates {
if let Some(line) = find_definition(c) {
chosen = Some((c.clone(), Some(line)));
break;
}
}
// Second pass: fall back to the first existing candidate so
// "jump to end so I can add it" still works.
if chosen.is_none() {
for c in &candidates {
if c.exists() {
chosen = Some((c.clone(), None));
break;
}
}
}
let Some((env_file, target_line)) = chosen else {
self.toast(format!(
"env file {env_name}.env not found in .mnml/env or .rqst/env"
));
return;
};
self.open_path(&env_file);
if let Some(row) = target_line {
if let Some(b) = self.active_editor_mut() {
b.editor.place_cursor(row, 0);
}
self.toast(format!("{name} \u{2192} {env_name}.env line {}", row + 1));
} else {
if let Some(b) = self.active_editor_mut() {
let last_row = b.editor.text().lines().count().saturating_sub(1);
b.editor.place_cursor(last_row, 0);
}
self.toast(format!(
"{name} not defined in {env_name}.env \u{2014} jump to end so you can add it"
));
}
}
/// HTTP panel keyboard nav helpers — the tuple's `.0` is the
/// section id (1=RECENT, 2=CAPTURED, 4=CHAINS, 5=MOCKS,
/// 6=COLLECTIONS); `.1` is the row within that section. Skips
/// FILES (0) and ENVS (3) since those don't have arrow-key nav
/// today (envs are 1-click set-active, files are stragglers).
/// Counts respect the active `/` filter so the cursor never lands
/// on a hidden row (design-critic #1 2026-07-07).
/// 2026-07-07.
fn http_panel_navigable_sections(&self) -> Vec<(u8, usize)> {
vec![
(6, self.http_panel_collection_flat_rows().len()),
(1, self.http_panel_filtered_recent().len()),
(2, self.http_panel_filtered_captured().len()),
(4, self.http_panel_filtered_chains().len()),
(5, self.http_panel_filtered_mocks().len()),
]
}
/// COLLECTIONS as a flat list of navigable rows, matching the
/// order the renderer produces. Each entry is either a folder
/// header (`member = None`) or a member file inside its
/// currently-expanded folder. Respects:
/// - the collapsed-set (`http_panel_collections_collapsed_dirs`)
/// - the `/` filter (folders whose name doesn't hit are shown
/// only when some member path matches)
///
/// The renderer force-expands filter-matched folders even when
/// their collapsed-set entry is present; we mirror that here so
/// arrow-key nav lands on the same rows the user sees.
/// 2026-07-07 — closes the design-critic #3 stub.
pub(crate) fn http_panel_collection_flat_rows(
&self,
) -> Vec<(std::path::PathBuf, Option<std::path::PathBuf>)> {
let mut out = Vec::new();
let mut order: Vec<(std::path::PathBuf, crate::app::HttpCollectionKind)> =
self.http_panel_collection_roots.clone();
order.sort_by(|a, b| match (a.1, b.1) {
(crate::app::HttpCollectionKind::InTree, crate::app::HttpCollectionKind::Hidden) => {
std::cmp::Ordering::Less
}
(crate::app::HttpCollectionKind::Hidden, crate::app::HttpCollectionKind::InTree) => {
std::cmp::Ordering::Greater
}
_ => a.0.cmp(&b.0),
});
let files = &self.http_panel_files_cache;
let filter_lc = self.http_panel_filter.to_ascii_lowercase();
for (root, _kind) in &order {
let name = root
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.to_string();
let name_hits = filter_lc.is_empty() || name.to_ascii_lowercase().contains(&filter_lc);
let all_members: Vec<&std::path::PathBuf> =
files.iter().filter(|p| p.starts_with(root)).collect();
let members: Vec<&std::path::PathBuf> = if name_hits {
all_members.clone()
} else {
all_members
.iter()
.copied()
.filter(|p| {
p.strip_prefix(root)
.map(|r| r.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default()
.contains(&filter_lc)
})
.collect()
};
if !filter_lc.is_empty() && !name_hits && members.is_empty() {
continue;
}
let force_expanded = !filter_lc.is_empty();
let collapsed =
!force_expanded && self.http_panel_collections_collapsed_dirs.contains(root);
out.push((root.clone(), None));
if !collapsed {
for m in members {
out.push((root.clone(), Some(m.clone())));
}
}
}
out
}
/// RECENT entries in display order (newest-first) after the `/`
/// filter, returned as raw-cache indices. Mirrors the render
/// loop in `ui/http_panel::draw_recent`.
fn http_panel_filtered_recent(&self) -> Vec<usize> {
let filter_lc = self.http_panel_filter.to_ascii_lowercase();
self.http_panel_recent_cache
.iter()
.enumerate()
.rev()
.filter(|(_, entry)| {
if filter_lc.is_empty() {
return true;
}
let method = entry
.get("method")
.and_then(|s| s.as_str())
.unwrap_or("GET");
let url = entry.get("url").and_then(|s| s.as_str()).unwrap_or("");
format!("{method} {url}")
.to_ascii_lowercase()
.contains(&filter_lc)
})
.map(|(i, _)| i)
.collect()
}
/// CAPTURED entries in display order (newest-first) after filter.
fn http_panel_filtered_captured(&self) -> Vec<usize> {
let filter_lc = self.http_panel_filter.to_ascii_lowercase();
self.http_panel_captured_cache
.iter()
.enumerate()
.rev()
.filter(|(_, row)| {
if filter_lc.is_empty() {
return true;
}
format!("{} {}", row.method, row.url)
.to_ascii_lowercase()
.contains(&filter_lc)
})
.map(|(i, _)| i)
.collect()
}
/// CHAINS paths in display order after filter — matches
/// `draw_chains`' name-based filter (`.chain.json` trimmed off).
fn http_panel_filtered_chains(&self) -> Vec<usize> {
let filter_lc = self.http_panel_filter.to_ascii_lowercase();
self.http_panel_chains_cache
.iter()
.enumerate()
.filter(|(_, path)| {
if filter_lc.is_empty() {
return true;
}
let name = path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("?")
.trim_end_matches(".chain.json");
name.to_ascii_lowercase().contains(&filter_lc)
})
.map(|(i, _)| i)
.collect()
}
/// MOCKS paths in display order after filter — matches
/// `draw_mocks`' filter on the workspace-relative short path
/// (`.mock.json` trimmed off).
fn http_panel_filtered_mocks(&self) -> Vec<usize> {
let filter_lc = self.http_panel_filter.to_ascii_lowercase();
self.http_panel_mocks_cache
.iter()
.enumerate()
.filter(|(_, path)| {
if filter_lc.is_empty() {
return true;
}
let rel = path
.strip_prefix(&self.workspace)
.unwrap_or(path)
.to_string_lossy();
rel.trim_end_matches(".mock.json")
.to_ascii_lowercase()
.contains(&filter_lc)
})
.map(|(i, _)| i)
.collect()
}
/// Snap `http_panel_cursor` back to the first populated navigable
/// section (row 0). Called whenever the filter text changes so
/// the `▸` marker can't be left pointing past the visible set.
pub fn http_panel_cursor_reset(&mut self) {
let sections = self.http_panel_navigable_sections();
for (s, count) in sections {
if count > 0 {
self.http_panel_cursor = (s, 0);
return;
}
}
// Nothing populated — leave cursor as-is (both down and up
// fall through when every count is 0).
self.http_panel_cursor = (1, 0);
}
/// Move the HTTP-panel cursor one row down. Wraps to the first
/// populated section when the last row of the last populated
/// section is under the cursor (design-critic #4 2026-07-07).
pub fn http_panel_cursor_down(&mut self) {
let sections = self.http_panel_navigable_sections();
let (cur_sec, cur_row) = self.http_panel_cursor;
let cur_idx = sections
.iter()
.position(|(s, _)| *s == cur_sec)
.unwrap_or(0);
if let Some((_, count)) = sections.get(cur_idx)
&& *count > 0
&& cur_row + 1 < *count
{
self.http_panel_cursor = (cur_sec, cur_row + 1);
return;
}
// Advance to the next section with entries.
for (s, count) in sections.iter().skip(cur_idx + 1) {
if *count > 0 {
self.http_panel_cursor = (*s, 0);
return;
}
}
// Wrap — walk from the start looking for the first populated
// section (skipping the current one so `j` at the very end
// moves visibly instead of no-op'ing).
for (s, count) in sections.iter() {
if *count > 0 {
self.http_panel_cursor = (*s, 0);
return;
}
}
}
/// Move up — reverse of `http_panel_cursor_down`, with wrap to
/// the last row of the last populated section.
pub fn http_panel_cursor_up(&mut self) {
let sections = self.http_panel_navigable_sections();
let (cur_sec, cur_row) = self.http_panel_cursor;
let cur_idx = sections
.iter()
.position(|(s, _)| *s == cur_sec)
.unwrap_or(0);
// Cursor might be sitting on an empty section (init default
// is COLLECTIONS at count=0) — treat that as "before any row"
// so up walks the same wrap path as down.
let on_empty = sections.get(cur_idx).is_some_and(|(_, count)| *count == 0);
if !on_empty && cur_row > 0 {
self.http_panel_cursor = (cur_sec, cur_row - 1);
return;
}
// Retreat to the previous populated section's last row.
for i in (0..cur_idx).rev() {
let (s, count) = sections[i];
if count > 0 {
self.http_panel_cursor = (s, count - 1);
return;
}
}
// Wrap — last populated section's last row (design-critic #2).
for (s, count) in sections.iter().rev() {
if *count > 0 {
self.http_panel_cursor = (*s, *count - 1);
return;
}
}
}
/// Enter on the cursor row — activate whichever row's under it.
/// Walks the filtered display order so the row we open matches
/// what the `▸` marker shows (design-critic #1 2026-07-07).
pub fn http_panel_cursor_activate(&mut self) {
let (sec, row) = self.http_panel_cursor;
match sec {
1 => {
let indices = self.http_panel_filtered_recent();
let recent = self.http_panel_recent_cache.clone();
let Some(entry) = indices.get(row).and_then(|&i| recent.get(i)) else {
return;
};
let (curl, method, url) = crate::http::history::entry_to_curl(entry);
self.open_curl_scratch(&curl, &method, &url);
}
2 => {
let indices = self.http_panel_filtered_captured();
let captured = self.http_panel_captured_cache.clone();
let Some(row_data) = indices.get(row).and_then(|&i| captured.get(i)) else {
return;
};
self.open_curl_scratch(&row_data.to_curl(), &row_data.method, &row_data.url);
}
4 => {
let indices = self.http_panel_filtered_chains();
if let Some(path) = indices
.get(row)
.and_then(|&i| self.http_panel_chains_cache.get(i))
.cloned()
{
self.http_chain_run_path(path);
}
}
5 => {
let indices = self.http_panel_filtered_mocks();
if let Some(path) = indices
.get(row)
.and_then(|&i| self.http_panel_mocks_cache.get(i))
.cloned()
{
self.open_path_as_editor(&path);
}
}
6 => {
let rows = self.http_panel_collection_flat_rows();
let Some((root, member)) = rows.get(row).cloned() else {
return;
};
match member {
None => {
// Folder row — toggle collapse.
if self.http_panel_collections_collapsed_dirs.contains(&root) {
self.http_panel_collections_collapsed_dirs.remove(&root);
} else {
self.http_panel_collections_collapsed_dirs.insert(root);
}
}
Some(path) => {
// Member row — open the request as an editor.
self.open_path_as_editor(&path);
}
}
}
_ => {
self.toast("nothing to activate at cursor");
}
}
}
/// `http.toggle_edit_split` — flip the Request pane's edit
/// area between single-tab and side-by-side (Body|Vars default,
/// or whichever the user picked via the right-side tab strip).
pub fn http_toggle_edit_split(&mut self) {
let Some(cur) = self.active else {
self.toast("http.toggle_edit_split: no active Request pane");
return;
};
let Some(Pane::Request(rp)) = self.panes.get_mut(cur) else {
self.toast("http.toggle_edit_split: active pane isn't a Request");
return;
};
rp.view = crate::request_pane::ViewMode::Edit;
rp.toggle_edit_split();
}
/// `http.diff_last_two` — open a scratch buffer with a
/// textual diff between the active Request pane's previous
/// Done response and the current one. Lines starting with
/// `-` came only from previous, `+` came only from current,
/// ` ` were shared.
pub fn http_diff_last_two(&mut self) {
let Some(cur) = self.active else {
self.toast("http.diff: no active Request pane");
return;
};
let (prev, current) = match self.panes.get(cur) {
Some(Pane::Request(rp)) => {
let cur_rv = match &rp.state {
crate::request_pane::RunState::Done(rv) => Some(rv.clone()),
_ => None,
};
(rp.prev_response.clone(), cur_rv)
}
_ => return,
};
let (Some(prev), Some(current)) = (prev, current) else {
self.toast("http.diff: need at least 2 successful sends to diff");
return;
};
let mut out = String::new();
out.push_str("# HTTP diff — last two responses\n\n");
out.push_str(&format!(
"status: {} {} → {} {}\n",
prev.status, prev.status_text, current.status, current.status_text
));
out.push_str(&format!(
"elapsed: {}ms → {}ms\n\n",
prev.elapsed.as_millis(),
current.elapsed.as_millis()
));
// Headers (set comparison). Render unchanged / removed / added.
out.push_str("## headers\n\n");
let prev_set: std::collections::HashSet<(String, String)> =
prev.headers.iter().cloned().collect();
let curr_set: std::collections::HashSet<(String, String)> =
current.headers.iter().cloned().collect();
for (k, v) in &prev.headers {
if curr_set.contains(&(k.clone(), v.clone())) {
out.push_str(&format!(" {k}: {v}\n"));
} else {
out.push_str(&format!("- {k}: {v}\n"));
}
}
for (k, v) in ¤t.headers {
if !prev_set.contains(&(k.clone(), v.clone())) {
out.push_str(&format!("+ {k}: {v}\n"));
}
}
out.push_str("\n## body\n\n");
// Simple line-by-line diff (no LCS — fast + readable for
// most API responses).
let p_lines: Vec<&str> = prev.body.lines().collect();
let c_lines: Vec<&str> = current.body.lines().collect();
let max = p_lines.len().max(c_lines.len());
for i in 0..max {
let pl = p_lines.get(i).copied().unwrap_or("");
let cl = c_lines.get(i).copied().unwrap_or("");
if pl == cl {
out.push_str(&format!(" {pl}\n"));
} else {
if !pl.is_empty() {
out.push_str(&format!("- {pl}\n"));
}
if !cl.is_empty() {
out.push_str(&format!("+ {cl}\n"));
}
}
}
self.open_scratch_with_text("[http-diff]".to_string(), out);
}
/// `http.fan_envs` — fan the active Request out against every
/// env file in the workspace (one fire per env, concurrent),
/// collect the (env, status, ms, error) tuples, render a
/// table summary to clipboard + a one-line toast headline.
/// The fastest way to verify "does this work against dev,
/// staging, AND prod?" without manually swapping envs.
pub fn http_fan_envs(&mut self) {
let Some(request) = self.parse_active_as_request() else {
self.toast("http.fan_envs: no active .http/.curl/.rest editor");
return;
};
let mut env_names: Vec<String> = Vec::new();
for sub in [".mnml", ".rqst"] {
let dir = self.workspace.join(sub).join("env");
if let Ok(rd) = std::fs::read_dir(&dir) {
for e in rd.flatten() {
let p = e.path();
if p.extension().is_some_and(|x| x == "env")
&& let Some(stem) = p.file_stem().and_then(|s| s.to_str())
{
let s = stem.to_string();
if !env_names.contains(&s) {
env_names.push(s);
}
}
}
}
}
if env_names.is_empty() {
self.toast("http.fan_envs: no env files found in .mnml/env or .rqst/env");
return;
}
let workspace = self.workspace.clone();
let raw_request = request.clone();
let started = std::time::Instant::now();
// Concurrent fan-out: one thread per env. Each thread
// reads its own EnvSet, expands the request URL/headers/
// body, fires via crate::http::send, returns the tuple.
let (tx, rx) = std::sync::mpsc::channel();
for env_name in env_names.iter() {
let tx = tx.clone();
let env_name = env_name.clone();
let ws = workspace.clone();
let req_template = raw_request.clone();
std::thread::spawn(move || {
let env = crate::http::template::EnvSet::load(&ws, &env_name);
let mut req = req_template.clone();
req.url = crate::http::template::expand(&req.url, &env);
for (_, v) in req.headers.iter_mut() {
*v = crate::http::template::expand(v, &env);
}
if let Some(b) = req.body.as_mut() {
*b = crate::http::template::expand(b, &env);
}
let started = std::time::Instant::now();
let result = match crate::http::send(&req) {
Ok(resp) => Ok((resp.status, started.elapsed())),
Err(e) => Err(e),
};
let _ = tx.send((env_name, result));
});
}
drop(tx);
// Collect all results (blocking — fan_envs is short-lived).
let mut rows: Vec<(String, String)> = Vec::new();
let mut clipboard_text = String::from("env\tstatus\tms\n");
let mut ok_count = 0usize;
while let Ok((env_name, result)) = rx.recv() {
let line = match result {
Ok((status, elapsed)) => {
let ms = elapsed.as_millis();
if (200..300).contains(&status) {
ok_count += 1;
}
clipboard_text.push_str(&format!("{env_name}\t{status}\t{ms}\n"));
format!("{env_name}: {status} ({ms}ms)")
}
Err(e) => {
clipboard_text.push_str(&format!("{env_name}\tERR\t{e}\n"));
format!("{env_name}: ERR ({e})")
}
};
rows.push((env_name, line));
}
let elapsed = started.elapsed().as_millis();
let total = rows.len();
let summary = rows
.iter()
.map(|(_, l)| l.as_str())
.collect::<Vec<_>>()
.join(" · ");
self.clipboard.set(clipboard_text, false);
self.toast(format!(
"fan_envs: {ok_count}/{total} OK in {elapsed}ms · {summary} · (full table → clipboard)"
));
}
/// `http.import_postman` — read a Postman Collection v2.1
/// JSON from clipboard and explode it into N `.curl` files
/// under `<workspace>/.rqst/captured/postman-<collection-name>/`.
/// Folder hierarchy is flattened into filenames so the
/// collection's grouping survives (`<group>__<request>.curl`).
/// Postman variables (`{{token}}`) are preserved verbatim —
/// they match mnml's existing template syntax so they round-
/// trip through `:http.send` naturally.
pub fn http_import_postman_from_clipboard(&mut self) {
let raw = self.clipboard.text();
if raw.trim().is_empty() {
self.toast("http.import_postman: clipboard is empty");
return;
}
let parsed: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
self.toast(format!("postman: not valid JSON: {e}"));
return;
}
};
// Postman collection top-level shape: { info: { name }, item: [...] }
let coll_name = parsed
.get("info")
.and_then(|i| i.get("name"))
.and_then(|n| n.as_str())
.unwrap_or("collection")
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>();
let Some(items) = parsed.get("item").and_then(|i| i.as_array()) else {
self.toast("postman: missing `item` array (not a Collection?)");
return;
};
let out_dir = self
.workspace
.join(".rqst")
.join("captured")
.join(format!("postman-{coll_name}"));
if let Err(e) = std::fs::create_dir_all(&out_dir) {
self.toast(format!("postman: mkdir {}: {e}", out_dir.display()));
return;
}
// Walk the (potentially nested) item tree. Each leaf has a
// `request` field; each folder has its own `item` array.
fn walk(
items: &[serde_json::Value],
prefix: &str,
out_dir: &std::path::Path,
counter: &mut usize,
written: &mut usize,
) {
for item in items {
let name = item
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("unnamed")
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>();
if let Some(sub) = item.get("item").and_then(|i| i.as_array()) {
let new_prefix = if prefix.is_empty() {
name.clone()
} else {
format!("{prefix}__{name}")
};
walk(sub, &new_prefix, out_dir, counter, written);
continue;
}
let Some(req) = item.get("request") else {
continue;
};
let method = req
.get("method")
.and_then(|m| m.as_str())
.unwrap_or("GET")
.to_uppercase();
let url = req
.get("url")
.and_then(|u| match u {
serde_json::Value::String(s) => Some(s.clone()),
serde_json::Value::Object(_) => {
u.get("raw").and_then(|r| r.as_str()).map(str::to_string)
}
_ => None,
})
.unwrap_or_default();
if url.is_empty() {
continue;
}
let mut curl = format!("curl -X {method} '{url}'");
if let Some(headers) = req.get("header").and_then(|h| h.as_array()) {
for h in headers {
let (Some(name), Some(value)) = (
h.get("key").and_then(|n| n.as_str()),
h.get("value").and_then(|v| v.as_str()),
) else {
continue;
};
if h.get("disabled").and_then(|d| d.as_bool()).unwrap_or(false) {
continue;
}
curl.push_str(&format!(" \\\n -H '{name}: {value}'"));
}
}
if let Some(body) = req.get("body")
&& let Some(raw) = body.get("raw").and_then(|r| r.as_str())
&& !raw.is_empty()
{
let escaped = raw.replace('\'', "'\\''");
curl.push_str(&format!(" \\\n --data '{escaped}'"));
}
let stem = if prefix.is_empty() {
format!("{counter:03}_{name}")
} else {
format!("{counter:03}_{prefix}__{name}")
};
*counter += 1;
let path = out_dir.join(format!("{stem}.curl"));
if std::fs::write(&path, curl).is_ok() {
*written += 1;
}
}
}
let mut counter = 0usize;
let mut written = 0usize;
walk(items, "", &out_dir, &mut counter, &mut written);
self.toast(format!(
"postman: wrote {written} curls → {}",
out_dir.display()
));
}
/// `http.import_har` — read a HAR (HTTP Archive) from the
/// clipboard, write one `.curl` file per HAR entry into
/// `<workspace>/.rqst/captured/har-<ts>/`. The natural follow-
/// up to `:http.paste_curl` for users with many requests:
/// "save all as HAR" in DevTools, paste here, get N fireable
/// curls. Spec: <http://www.softwareishard.com/blog/har-12-spec/>.
pub fn http_import_har_from_clipboard(&mut self) {
let raw = self.clipboard.text();
if raw.trim().is_empty() {
self.toast("http.import_har: clipboard is empty");
return;
}
let parsed: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(e) => {
self.toast(format!("har: not valid JSON: {e}"));
return;
}
};
let entries = parsed
.get("log")
.and_then(|l| l.get("entries"))
.and_then(|e| e.as_array());
let Some(entries) = entries else {
self.toast("har: missing log.entries (not a HAR file?)");
return;
};
// Stable directory name: timestamp from the first entry's
// startedDateTime, falling back to a counter, so the path
// is deterministic across re-imports.
let stem = entries
.first()
.and_then(|e| e.get("startedDateTime"))
.and_then(|s| s.as_str())
.map(|s| s.replace(':', "-").chars().take(19).collect::<String>())
.unwrap_or_else(|| "import".to_string());
let out_dir = self
.workspace
.join(".rqst")
.join("captured")
.join(format!("har-{stem}"));
if let Err(e) = std::fs::create_dir_all(&out_dir) {
self.toast(format!("har: mkdir {}: {e}", out_dir.display()));
return;
}
let mut written = 0usize;
for (i, entry) in entries.iter().enumerate() {
let Some(req) = entry.get("request") else {
continue;
};
let method = req
.get("method")
.and_then(|m| m.as_str())
.unwrap_or("GET")
.to_uppercase();
let Some(url) = req.get("url").and_then(|u| u.as_str()) else {
continue;
};
let mut curl = format!("curl -X {method} '{url}'");
if let Some(headers) = req.get("headers").and_then(|h| h.as_array()) {
for h in headers {
let (Some(name), Some(value)) = (
h.get("name").and_then(|n| n.as_str()),
h.get("value").and_then(|v| v.as_str()),
) else {
continue;
};
// Skip pseudo-headers; Chrome HAR emits them
// (`:method`, `:authority`) but they're not
// usable as curl `-H` args.
if name.starts_with(':') {
continue;
}
curl.push_str(&format!(" \\\n -H '{name}: {value}'"));
}
}
if let Some(post) = req
.get("postData")
.and_then(|p| p.get("text"))
.and_then(|t| t.as_str())
&& !post.is_empty()
{
let escaped = post.replace('\'', "'\\''");
curl.push_str(&format!(" \\\n --data '{escaped}'"));
}
// Filename: derived from host + path so users can grep.
// Plain parse — strip query string, sanitize each
// component to ASCII alphanum/underscore.
let host_path = {
let stripped = url.split('?').next().unwrap_or(url);
let after_scheme = stripped
.split_once("://")
.map(|(_, r)| r)
.unwrap_or(stripped);
after_scheme
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '_' })
.collect::<String>()
.chars()
.take(80)
.collect::<String>()
};
let stem = if host_path.is_empty() {
format!("entry_{i:03}")
} else {
format!("{i:03}_{host_path}")
};
let path = out_dir.join(format!("{stem}.curl"));
if std::fs::write(&path, curl).is_ok() {
written += 1;
}
}
self.toast(format!(
"har: wrote {written} curls → {}",
out_dir.display()
));
}
/// `http.params_add` — start the inline params editor on the
/// active Request pane's Params tab. A draft row is appended
/// at the bottom of the Params list with focus on the key
/// field; Tab cycles to value; Enter commits (appends to URL);
/// Esc cancels. Replaces the earlier "modal prompt in the
/// middle of the screen" flow which felt out of place.
pub fn http_params_add(&mut self) {
let Some(cur) = self.active else {
self.toast("http.params_add: no active Request pane");
return;
};
let Some(Pane::Request(rp)) = self.panes.get_mut(cur) else {
self.toast("http.params_add: no active Request pane");
return;
};
rp.edit_tab = crate::request_pane::EditTab::Params;
rp.params_add = Some(crate::request_pane::ParamsAddDraft::default());
}
/// Commit the inline params-add draft: parse key + value, append
/// to the active URL, clear the draft. Called on Enter from the
/// draft-row key handler.
/// Commit the current draft. `continue_drafting = true` starts
/// a fresh empty draft row after committing so the user can
/// keep adding rows without touching the mouse (spreadsheet-
/// style Enter → new row). `false` closes the draft.
pub fn http_params_add_commit(&mut self, continue_drafting: bool) {
let Some(cur) = self.active else { return };
// Take the draft in a short scope so the pane borrow drops
// before we call `self.toast` (which needs `&mut self`).
let draft = {
let Some(Pane::Request(rp)) = self.panes.get_mut(cur) else {
return;
};
rp.params_add.take()
};
let Some(draft) = draft else { return };
let key = draft.key.trim();
if key.is_empty() {
// Empty key + empty value + Enter → "I'm done", silent
// close. Non-empty value with empty key → toast + put
// the draft back so the user can fix it.
if draft.value.trim().is_empty() {
return;
}
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.params_add = Some(draft);
}
self.toast("params: key can't be empty");
return;
}
let value = draft.value.trim();
let key_owned = key.to_string();
let value_owned = value.to_string();
// api-round-10 SEV-2 2026-07-12 — percent-encode the value
// so a param that contains `?`, `&`, `=`, `#`, space, or
// any other reserved query char doesn't corrupt the URL.
// Was splicing raw. Encode the key too so `x y=1` doesn't
// produce `?x y=1`.
let key_encoded = percent_encode_component(&key_owned);
let value_encoded = percent_encode_component(&value_owned);
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let sep = if rp.request.url.contains('?') {
'&'
} else {
'?'
};
rp.request.url.push(sep);
rp.request.url.push_str(&key_encoded);
rp.request.url.push('=');
rp.request.url.push_str(&value_encoded);
rp.url_cursor = rp.request.url.len();
if continue_drafting {
rp.params_add = Some(crate::request_pane::ParamsAddDraft::default());
}
}
self.toast(format!("params: added {key_owned}={value_owned}"));
}
/// Start the inline headers-add draft — same shape as
/// `http_params_add` but writes to the Headers tab's editor.
pub fn http_headers_add(&mut self) {
let Some(cur) = self.active else {
self.toast("no active Request pane");
return;
};
let Some(Pane::Request(rp)) = self.panes.get_mut(cur) else {
self.toast("no active Request pane");
return;
};
rp.edit_tab = crate::request_pane::EditTab::Headers;
rp.headers_add = Some(crate::request_pane::InlineKvDraft::default());
}
/// Commit the current headers-add draft — appends
/// `Name: value\n` to `headers_buffer` and refreshes the
/// parsed `request.headers`. `continue_drafting` opens a new
/// blank draft after committing (same spreadsheet flow as
/// Params).
pub fn http_headers_add_commit(&mut self, continue_drafting: bool) {
let Some(cur) = self.active else { return };
let draft = {
let Some(Pane::Request(rp)) = self.panes.get_mut(cur) else {
return;
};
rp.headers_add.take()
};
let Some(draft) = draft else { return };
let key = draft.key.trim();
if key.is_empty() {
if draft.value.trim().is_empty() {
return;
}
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.headers_add = Some(draft);
}
self.toast("headers: name can't be empty");
return;
}
let value = draft.value.trim();
let key_owned = key.to_string();
let value_owned = value.to_string();
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
if !rp.headers_buffer.is_empty() && !rp.headers_buffer.ends_with('\n') {
rp.headers_buffer.push('\n');
}
rp.headers_buffer
.push_str(&format!("{key_owned}: {value_owned}\n"));
rp.headers_cursor = rp.headers_buffer.len();
rp.commit_headers();
if continue_drafting {
rp.headers_add = Some(crate::request_pane::InlineKvDraft::default());
}
}
self.toast(format!("headers: added {key_owned}: {value_owned}"));
}
/// Cancel the inline headers-add draft.
pub fn http_headers_add_cancel(&mut self) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.headers_add = None;
}
}
/// Start an in-place value-cell edit on a KV row. Sets
/// `rp.kv_edit` with the row's current value pre-loaded so
/// typing appends. Enter commits, Esc cancels.
pub fn http_kv_edit_begin(&mut self, kind: crate::request_pane::KvEditKind, key: String) {
self.http_kv_edit_begin_cell(kind, key, false);
}
/// Start an in-place NAME-cell edit on a KV row — same idea as
/// `http_kv_edit_begin` but commits rename the key (preserving
/// the row's value + position). The `editing_name` flag on
/// `KvValueEdit` routes the commit path.
pub fn http_kv_edit_begin_name(&mut self, kind: crate::request_pane::KvEditKind, key: String) {
self.http_kv_edit_begin_cell(kind, key, true);
}
fn http_kv_edit_begin_cell(
&mut self,
kind: crate::request_pane::KvEditKind,
key: String,
editing_name: bool,
) {
let Some(cur) = self.active else { return };
// api-round-11 SEV-1 2026-07-14 — resolve the Vars seed via
// the shared active-env helper BEFORE the `rp` mut-borrow so
// read/write agree on the effective env in a `.mnml`-only
// workspace (was: broken `EnvSet::select(no config_default)`
// returned empty here and Tab committed the empty back to disk).
let vars_seed = if !editing_name && matches!(kind, crate::request_pane::KvEditKind::Vars) {
self.active_envset().lookup(&key).unwrap_or_default()
} else {
String::new()
};
let Some(Pane::Request(rp)) = self.panes.get_mut(cur) else {
return;
};
let seed = if editing_name {
// Pre-load with the current name so the user can edit
// it in place.
key.clone()
} else {
match kind {
crate::request_pane::KvEditKind::Params => {
let url = &rp.request.url;
let q = url.find('?').map(|i| &url[i + 1..]).unwrap_or("");
q.split('&')
.find_map(|kv| {
kv.split_once('=')
.and_then(|(k, v)| (k == key).then(|| v.to_string()))
})
.unwrap_or_default()
}
crate::request_pane::KvEditKind::Headers => rp
.headers_buffer
.lines()
.find_map(|l| {
let (k, v) = crate::request_pane::split_header_line(l)?;
(k.trim().eq_ignore_ascii_case(&key)).then(|| v.trim().to_string())
})
.unwrap_or_default(),
crate::request_pane::KvEditKind::Vars => vars_seed,
}
};
rp.kv_edit = Some(crate::request_pane::KvValueEdit {
kind,
original_key: key,
buffer: seed.clone(),
cursor: seed.len(),
editing_name,
});
}
/// Commit an in-place value-cell edit — replaces the row's
/// value with `kv_edit.buffer`. Clears `kv_edit`.
pub fn http_kv_edit_commit(&mut self) {
let Some(cur) = self.active else { return };
let edit = {
let Some(Pane::Request(rp)) = self.panes.get_mut(cur) else {
return;
};
rp.kv_edit.take()
};
let Some(edit) = edit else { return };
let new_buffer = edit.buffer.trim().to_string();
if edit.editing_name && new_buffer.is_empty() {
self.toast("kv: name can't be empty");
return;
}
match edit.kind {
crate::request_pane::KvEditKind::Params => {
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let url = rp.request.url.clone();
let (base, query_opt) = match url.find('?') {
Some(i) => (&url[..i], Some(&url[i + 1..])),
None => (url.as_str(), None),
};
let mut rewritten = base.to_string();
let mut sep = '?';
if let Some(q) = query_opt {
for kv in q.split('&').filter(|s| !s.is_empty()) {
let (k, v) = match kv.split_once('=') {
Some(kv) => kv,
None => (kv, ""),
};
let (out_k, out_v) = if k == edit.original_key {
if edit.editing_name {
(new_buffer.as_str(), v)
} else {
(k, new_buffer.as_str())
}
} else {
(k, v)
};
rewritten.push(sep);
rewritten.push_str(out_k);
rewritten.push('=');
rewritten.push_str(out_v);
sep = '&';
}
}
rp.request.url = rewritten;
rp.url_cursor = rp.request.url.len();
}
}
crate::request_pane::KvEditKind::Headers => {
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let rewritten: Vec<String> = rp
.headers_buffer
.lines()
.map(|l| match crate::request_pane::split_header_line(l) {
Some((k, v)) if k.trim().eq_ignore_ascii_case(&edit.original_key) => {
if edit.editing_name {
format!("{}: {}", new_buffer, v.trim())
} else {
format!("{}: {}", k.trim(), new_buffer)
}
}
_ => l.to_string(),
})
.collect();
rp.headers_buffer = rewritten.join("\n");
if !rp.headers_buffer.is_empty() && !rp.headers_buffer.ends_with('\n') {
rp.headers_buffer.push('\n');
}
rp.headers_cursor = rp.headers_buffer.len();
rp.commit_headers();
}
}
crate::request_pane::KvEditKind::Vars => {
// #23 v3 — env var commit. Name-cell edit means
// rename: delete old key, upsert new key with the
// original value. Value-cell edit: upsert the key
// with the new value.
if edit.editing_name {
// Look up current value first so we can
// preserve it under the new name.
// api-round-11 SEV-1 2026-07-14 — was
// `EnvSet::select(no config_default)` which
// returned empty on `.mnml`-only workspaces, so
// renames replaced the old key with an EMPTY-
// valued new key. `active_envset` uses the same
// fallback as the write path.
let current_val = self
.active_envset()
.lookup(&edit.original_key)
.unwrap_or_default();
self.http_delete_env_key(&edit.original_key);
self.write_env_var(&new_buffer, ¤t_val);
} else {
self.write_env_var(&edit.original_key, &new_buffer);
}
}
}
self.toast(format!(
"{}: updated",
match edit.kind {
crate::request_pane::KvEditKind::Params => "params",
crate::request_pane::KvEditKind::Headers => "headers",
crate::request_pane::KvEditKind::Vars => "vars",
}
));
}
/// Cancel an in-place value-cell edit — drops the buffer.
pub fn http_kv_edit_cancel(&mut self) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.kv_edit = None;
}
}
/// Delete a header row by name from the buffer. Mirrors
/// `http_params_delete` — used by row-click on the Headers
/// table (whole-row = delete for v1).
pub fn http_headers_delete(&mut self, name: &str) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let filtered: Vec<String> = rp
.headers_buffer
.lines()
.filter(|l| {
let k = l.split_once(':').map(|(k, _)| k.trim()).unwrap_or("");
!k.eq_ignore_ascii_case(name)
})
.map(str::to_string)
.collect();
rp.headers_buffer = filtered.join("\n");
if !rp.headers_buffer.is_empty() && !rp.headers_buffer.ends_with('\n') {
rp.headers_buffer.push('\n');
}
rp.headers_cursor = rp.headers_buffer.len();
rp.commit_headers();
}
}
/// Cancel the inline params-add draft (Esc from the draft row).
pub fn http_params_add_cancel(&mut self) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.params_add = None;
}
}
/// Accept handler for `PromptKind::HttpParamAdd`. Appends the
/// param to the active URL with the correct separator.
pub fn accept_http_param_add(&mut self, input: &str) {
let Some((key, value)) = input.split_once('=') else {
self.toast("params: input must be KEY=VALUE");
return;
};
let key = key.trim();
if key.is_empty() {
self.toast("params: key can't be empty");
return;
}
let value = value.trim();
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let sep = if rp.request.url.contains('?') {
'&'
} else {
'?'
};
rp.request.url.push(sep);
rp.request.url.push_str(key);
rp.request.url.push('=');
rp.request.url.push_str(value);
rp.url_cursor = rp.request.url.len();
// Auto-switch to Params tab so user sees the addition.
rp.edit_tab = crate::request_pane::EditTab::Params;
self.toast(format!("params: added {key}={value}"));
}
}
/// Delete a single query param `key` from the active URL.
/// Used by the Params-tab row click. No-op when the param
/// isn't present.
pub fn http_params_delete(&mut self, key: &str) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let url = &rp.request.url;
let Some(qi) = url.find('?') else { return };
let (base, query) = url.split_at(qi);
let query = &query[1..]; // strip the leading `?`
let remaining: Vec<&str> = query
.split('&')
.filter(|kv| {
let k = kv.split_once('=').map(|(k, _)| k).unwrap_or(*kv);
k != key
})
.collect();
let new_url = if remaining.is_empty() {
base.to_string()
} else {
format!("{base}?{}", remaining.join("&"))
};
rp.request.url = new_url;
rp.url_cursor = rp.request.url.len();
self.toast(format!("params: deleted {key}"));
}
}
/// `http.params_clear` — strip the entire `?…` portion from
/// the active Request URL.
pub fn http_params_clear(&mut self) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
if let Some(i) = rp.request.url.find('?') {
let removed = rp.request.url[i..].to_string();
rp.request.url.truncate(i);
rp.url_cursor = rp.request.url.len();
self.toast(format!("params: cleared {removed}"));
} else {
self.toast("params: no query string on URL");
}
}
}
/// `http.abort` — release the UI-side tracking for any
/// in-flight HTTP work (bench / sync / lookup fire). The
/// worker thread keeps running until it naturally completes
/// (~seconds for bench / sync, possibly minutes for SSE), but
/// the user gets immediate UI feedback that they've moved on.
/// Late results from the orphaned thread land on a dropped
/// receiver and are silently discarded.
///
/// True cancellation (interrupting a worker mid-network-call)
/// is a v3 follow-up that would need cooperative cancel tokens
/// threaded through reqwest::blocking — or a switch to async
/// reqwest with proper drop semantics. The simpler "drop the
/// rx" path covers the user-visible case (toast clears, "next
/// thing please") without rearchitecting the worker shape.
pub fn http_abort_all(&mut self) {
// 2026-06-21 api-workflow SEV-2 — was leaving
// http_chain_in_flight + http_ai_build_in_flight set, so a
// stalled chain or AI build was unrecoverable. Now resets
// both flags. The chain / ai-build workers themselves can't
// be killed mid-flight (std HTTP / Anthropic API are
// blocking), but the user can retry instead of waiting.
let was_active = self.http_bench_rx.is_some()
|| self.http_sync_rx.is_some()
|| self.lookup_fire_rx.is_some()
|| self.http_chain_in_flight
|| self.http_ai_build_in_flight;
self.http_bench_rx = None;
self.http_sync_rx = None;
self.lookup_fire_rx = None;
self.http_chain_in_flight = false;
self.http_ai_build_in_flight = false;
if was_active {
self.toast("http: released UI tracking (worker finishes in background)");
} else {
self.toast("http: nothing in flight");
}
}
/// `http.cycle_method` — cycle the active Request pane's
/// method through the standard verbs. Same gesture as Space
/// when the Method field is focused, but reachable from the
/// palette / Method-row context menu without keyboard focus.
pub fn http_cycle_method(&mut self) {
let Some(cur) = self.active else { return };
// 2026-06-19 — api-workflow third hunt SEV-3: this used an
// inline verb list that swapped PATCH and DELETE vs
// `STANDARD_METHODS`, so the palette command's cycle order
// diverged from Space-key cycling in the Method field. Use
// the canonical list.
let new_method = if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
let cycled = crate::request_pane::cycle_method(&rp.request.method);
rp.request.method = cycled.clone();
Some(cycled)
} else {
None
};
if let Some(m) = new_method {
self.toast(format!("method: {m}"));
}
}
/// `http.new` — open a blank Request pane in Edit mode for
/// the "I want to start a request without thinking about files
/// first" Postman-style workflow. The pane has:
/// * Method = GET, URL = empty, headers = none, body = none
/// * view = Edit (the form is visible immediately)
/// * focus = URL (typing populates URL)
/// * state = Failed("(not sent — press `r` to fire)") so
/// the response panel shows a useful hint instead of an
/// empty Sending… spinner
/// * source_path = None (Ctrl+S toasts "no source file";
/// save-as is a v2 follow-up)
/// User-requested 2026-06-19 — closing the "where's the new-
/// request button" gap.
pub fn open_new_request_pane(&mut self) {
use crate::request_pane::{EditField, RequestPane, RunState, ViewMode};
let request = crate::http::Request {
method: "GET".to_string(),
url: String::new(),
headers: Vec::new(),
body: None,
insecure: false,
};
let mut pane = RequestPane::new(None, request, crate::http::script::Script::default(), 0);
pane.view = ViewMode::Edit;
pane.focus = EditField::Url;
pane.state = RunState::Failed("not sent yet · press `r` to fire".to_string());
// #polish 2026-07-06 — was calling \`split_leaf_with\` which
// opened the new request as a vertical split BELOW the
// existing pane. Users expected a new TAB in the same
// strip (VS Code / browser convention). Now:
// * push the pane into `self.panes`
// * route through `reveal_pane`, which adds it to the
// active leaf's tabs and makes it the active tab
// * fall back to seeding `Layout::leaf` when the layout
// was Empty (fresh workspace)
self.panes.push(Pane::Request(pane));
let new_id = self.panes.len() - 1;
if self.active.is_some() {
self.reveal_pane(new_id);
} else {
*self.layout_mut() = crate::layout::Layout::leaf(new_id);
self.active = Some(new_id);
}
self.focus = Focus::Pane;
self.toast("new request — Tab cycles fields, `r` fires");
}
/// `http.send_streaming` — like `http.send`, but the response
/// is read as Server-Sent Events. The worker keeps the
/// connection open (no client timeout), pulls events through
/// `crate::sse::Reader`, and renders the buffered event list
/// into the Response pane body when the stream closes. Use for
/// Anthropic / OpenAI / SSE-style `text/event-stream` endpoints
/// where the server holds the socket and pushes events over
/// time.
///
/// Buffered (not progressive): events are collected server-side
/// then displayed at end. Progressive in-pane display as events
/// arrive is a v2 follow-up. Phase 8 polish — 2026-06-19.
pub fn send_streaming_from_active(&mut self) {
let Some(request) = self.parse_active_as_request() else {
self.toast("http.send_streaming: no active .http/.curl/.rest editor");
return;
};
let script = crate::http::script::Script::default();
let job_id = self.spawn_sse_streaming_job(request.clone(), script.clone());
let Some(cur) = self.active else {
return;
};
let pane = Pane::Request(crate::request_pane::RequestPane::new(
None, request, script, job_id,
));
let new_id = self.split_leaf_with(cur, crate::layout::SplitDir::Vertical, pane);
self.active = Some(new_id);
self.focus = Focus::Pane;
self.toast("http.send_streaming: opening SSE stream…");
}
/// Background worker for SSE streaming. Builds a reqwest client
/// with NO timeout (servers keep SSE connections open
/// indefinitely; a 30s default would close us first), fires the
/// request, wraps the response in `crate::sse::Reader`, drains
/// every event, and posts a synthetic `ResponseView` whose body
/// is the formatted event list (`[event_name] data` per
/// block) over the existing `http_chan`. Status / headers /
/// elapsed pulled from the underlying response.
fn spawn_sse_streaming_job(
&mut self,
request: crate::http::Request,
_script: crate::http::script::Script,
) -> u64 {
use crate::request_pane::SseStreamMsg;
let job_id = self.next_job_id;
self.next_job_id += 1;
let tx = self
.sse_chan
.get_or_insert_with(std::sync::mpsc::channel)
.0
.clone();
std::thread::spawn(move || {
// 2026-06-20 — progressive display. Worker now sends
// Open → Event* → Close (was: buffered all events,
// sent one synthetic ResponseView). App.tick mutates
// the matching pane's Streaming state in real time.
let send_err = |error: String| {
let _ = tx.send(SseStreamMsg::Error { job_id, error });
};
let _result: Result<(), String> = (|| {
// 2026-06-19 — api-workflow-user agent flagged
// that `timeout(None)` leaks the worker thread for
// any endpoint that holds the socket without
// sending events (long-poll, badly-configured
// SSE, hung server). A per-read timeout of 60s
// exits the loop on quiet sockets without
// blocking SSE streams that actually emit events
// (every event resets the timer in `read_line`).
// Generous overall timeout so a slow SSE server
// can stream for many minutes; quiet sockets exit
// via the natural timeout. Full cancellation
// (Esc to abort an in-flight stream) is a v2
// follow-up that would need a channel back to
// the worker.
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(600))
.build()
.map_err(|e| format!("client build failed: {e}"))?;
let method = reqwest::Method::from_bytes(request.method.to_uppercase().as_bytes())
.map_err(|_| format!("invalid HTTP method {:?}", request.method))?;
let mut req = client.request(method, &request.url);
for (k, v) in &request.headers {
req = req.header(k, v);
}
if let Some(body) = &request.body {
req = req.body(body.clone());
}
let started = std::time::Instant::now();
let resp = req.send().map_err(|e| format!("send: {e}"))?;
let status = resp.status().as_u16();
let status_text = resp.status().canonical_reason().unwrap_or("").to_string();
let headers: Vec<(String, String)> = resp
.headers()
.iter()
.map(|(k, v)| (k.as_str().to_string(), v.to_str().unwrap_or("").to_string()))
.collect();
// Open message → App allocates Streaming state.
if tx
.send(SseStreamMsg::Open {
job_id,
status,
status_text,
headers,
started,
})
.is_err()
{
return Ok(()); // receiver dropped → abort
}
let mut reader = crate::sse::Reader::new(resp);
loop {
match reader.next_event() {
Ok(Some(evt)) => {
if tx
.send(SseStreamMsg::Event {
job_id,
name: evt.name,
data: evt.data,
})
.is_err()
{
return Ok(());
}
}
Ok(None) => break,
Err(e) => {
let _ = tx.send(SseStreamMsg::Error {
job_id,
error: e.to_string(),
});
return Ok(());
}
}
}
let _ = tx.send(SseStreamMsg::Close { job_id });
Ok(())
})();
if let Err(e) = _result {
send_err(e);
}
});
job_id
}
/// `http.copy_curl` — copy the active request (in an editor: parse the buffer;
/// in a request pane: the request it holds) to the clipboard as a curl command.
pub fn copy_active_curl(&mut self) {
let curl = match self.active.and_then(|i| self.panes.get(i)) {
Some(Pane::Request(rp)) => Some(rp.as_curl()),
Some(Pane::Editor(b))
if matches!(b.language_ext.as_deref(), Some("http" | "rest" | "curl")) =>
{
crate::http::parse(b.editor.text()).ok().map(|r| {
crate::request_pane::RequestPane::new(None, r, Default::default(), 0).as_curl()
})
}
_ => None,
};
match curl {
Some(c) => {
self.clipboard.set(c, false);
self.toast("copied request as curl");
}
None => self.toast("no request here to copy"),
}
}
/// Deliver any completed background HTTP sends to their request panes.
/// 2026-06-20 — drain progressive SSE stream messages and
/// mutate the matching Request pane's Streaming state.
pub(super) fn drain_sse_jobs(&mut self) {
use crate::request_pane::{ResponseView, RunState, SseStreamMsg};
let Some((_, rx)) = &self.sse_chan else {
return;
};
let msgs: Vec<SseStreamMsg> = rx.try_iter().collect();
for msg in msgs {
match msg {
SseStreamMsg::Open {
job_id,
status,
status_text,
headers,
started,
} => {
// Find pane with matching job_id.
if let Some((pid, _)) = self
.panes
.iter()
.enumerate()
.find(|(_, p)| matches!(p, Pane::Request(r) if r.job_id == job_id))
&& let Some(Pane::Request(rp)) = self.panes.get_mut(pid)
{
// 2026-06-21 SEV-3 fix: capture any
// prior Done into prev_response BEFORE
// overwriting state with Streaming.
if let RunState::Done(prev) =
std::mem::replace(&mut rp.state, RunState::Sending)
{
rp.prev_response = Some(prev);
}
rp.state = RunState::Streaming(Box::new(ResponseView {
status,
status_text,
headers,
body: String::new(),
body_bytes: Vec::new(),
elapsed: started.elapsed(),
timing: crate::http::Timing::default(),
assertions: Vec::new(),
captures: Vec::new(),
schema_result: None,
sse_event_count: 0,
}));
}
}
SseStreamMsg::Event { job_id, name, data } => {
if let Some((pid, _)) = self
.panes
.iter()
.enumerate()
.find(|(_, p)| matches!(p, Pane::Request(r) if r.job_id == job_id))
&& let Some(Pane::Request(rp)) = self.panes.get_mut(pid)
&& let RunState::Streaming(rv) = &mut rp.state
{
if !name.is_empty() {
rv.body.push_str(&format!("[{name}]\n"));
}
rv.body.push_str(&data);
rv.body.push_str("\n\n");
// 2026-06-21 api-workflow SEV-2: proper
// per-pane SSE event counter. Was
// pushing empty ("", "") into captures
// — abused as a counter, then clobbered
// any real @capture results on Close.
rv.sse_event_count = rv.sse_event_count.saturating_add(1);
}
}
SseStreamMsg::Close { job_id } => {
// prev_response was already captured at the
// start of the stream (when we replaced any
// prior Done with the new Streaming). Here we
// just promote the in-flight Streaming → Done.
if let Some((pid, _)) = self
.panes
.iter()
.enumerate()
.find(|(_, p)| matches!(p, Pane::Request(r) if r.job_id == job_id))
&& let Some(Pane::Request(rp)) = self.panes.get_mut(pid)
{
let source_path = rp.source_path.clone();
if let RunState::Streaming(rv) =
std::mem::replace(&mut rp.state, RunState::Sending)
{
let mut rv = *rv;
// captures stays untouched — was
// being cleared as part of the
// event-counter hack.
rv.schema_result = source_path
.as_deref()
.map(|p| crate::http::schema::validate_for(Some(p), &rv.body));
rp.state = RunState::Done(Box::new(rv));
}
}
}
SseStreamMsg::Error { job_id, error } => {
if let Some((pid, _)) = self
.panes
.iter()
.enumerate()
.find(|(_, p)| matches!(p, Pane::Request(r) if r.job_id == job_id))
&& let Some(Pane::Request(rp)) = self.panes.get_mut(pid)
{
rp.state = RunState::Failed(error);
}
}
}
}
}
/// Copy `http_running_env[key]` (if any) into `env.vars`. The
/// key is `source_path` when present, else an empty PathBuf (so
/// paneless flows still get carry-over within a session).
/// Running-env values WIN over base-env values on the same key —
/// captures are the freshest snapshot.
pub(super) fn merge_http_running_env(
&self,
source_path: Option<&std::path::Path>,
env: &mut crate::http::template::EnvSet,
) {
let key = source_path.map(|p| p.to_path_buf()).unwrap_or_default();
if let Some(entries) = self.http_running_env.get(&key) {
for (k, v) in entries {
env.vars.insert(k.clone(), v.clone());
}
}
}
/// Persist captures from a successful send into `http_running_env`
/// under `source_path` (empty PathBuf when the request came from
/// a paneless flow). Called from `drain_http_jobs` after each Ok
/// result.
pub(super) fn persist_http_captures(
&mut self,
source_path: Option<&std::path::Path>,
captures: &[(String, String)],
) {
if captures.is_empty() {
return;
}
let key = source_path.map(|p| p.to_path_buf()).unwrap_or_default();
let bucket = self.http_running_env.entry(key).or_default();
for (k, v) in captures {
bucket.insert(k.clone(), v.clone());
}
}
pub(super) fn drain_http_jobs(&mut self) {
use crate::request_pane::RunState;
let Some((_, rx)) = &self.http_chan else {
return;
};
let done: Vec<HttpJobDone> = rx.try_iter().collect();
let mut toasts: Vec<String> = Vec::new();
let workspace = self.workspace.clone();
// Base envset snapshot for the batch — per-job code below
// clones this and layers on the running-env values keyed by
// the job's source_path. Doing the base pull once (immutable
// self borrow) is fine; the merge_http_running_env call
// per-job also uses immutable self, so both stay clean of
// the mut borrow on self.panes below. (Fixed 2026-08-05
// reviewer flag: sharing a single un-merged snapshot missed
// @capture-d vars — the exact case SEV-2 exists to solve.)
let hist_env_base = self.active_envset();
// Per-job source_path lookup — sniff before the mut-borrow
// loop so the merge step can be a plain hashmap read.
let job_source_paths: std::collections::HashMap<u64, Option<std::path::PathBuf>> = done
.iter()
.filter_map(|(job_id, _)| {
self.panes.iter().find_map(|p| {
if let Pane::Request(rp) = p
&& rp.job_id == *job_id
{
Some((*job_id, rp.source_path.clone()))
} else {
None
}
})
})
.collect();
// Deferred captures to persist after the mut-borrow loop.
let mut carry_forward: Vec<(Option<std::path::PathBuf>, Vec<(String, String)>)> =
Vec::new();
// Reviewer 2026-08-05 — same-tick capture visibility. If two
// jobs in the SAME drain tick are dependent (A captures
// TOKEN, B references {{TOKEN}}), B's history-log expansion
// must see A's fresh capture. `carry_forward` is applied to
// `self.http_running_env` only AFTER this loop, so we keep a
// per-batch overlay here and layer it on top of the per-job
// env in the expand step. The live wire request is already
// safe (sends are sequenced pre-drain); only history.jsonl
// was affected.
let mut batch_captures: std::collections::HashMap<
std::path::PathBuf,
std::collections::HashMap<String, String>,
> = std::collections::HashMap::new();
for (job_id, result) in done {
let Some(Pane::Request(rp)) = self.panes.iter_mut().find(
|p| matches!(p, Pane::Request(rp) if rp.job_id == job_id && matches!(rp.state, RunState::Sending)),
) else {
continue;
};
match result {
Ok(rv) => {
// Carry-forward: any @capture-d values persist to
// the file's running env so the next request in
// the same file resolves `{{TOKEN}}` etc. (docs:
// `manual/http.md:126`). api-workflow round-7
// SEV-1 fix — previously chain-only.
if !rv.captures.is_empty() {
carry_forward.push((rp.source_path.clone(), rv.captures.clone()));
// Also fold into the per-batch overlay so
// any subsequent job in this same drain tick
// sees these captures when expanding history.
let key = rp.source_path.clone().unwrap_or_default();
let overlay = batch_captures.entry(key).or_default();
for (k, v) in &rv.captures {
overlay.insert(k.clone(), v.clone());
}
}
let failed = rv.assertions.iter().filter(|a| !a.passed).count();
let total = rv.assertions.len();
toasts.push(if total > 0 {
format!(
"← {} · {}/{} asserts passed",
rv.status,
total - failed,
total
)
} else {
format!("← {} {}", rv.status, rv.status_text)
});
// Phase 9 — append to .rqst/history.jsonl so
// grep/jq workflows AND the in-app `http.history`
// viewer see the request.
//
// api-workflow SEV-2 2026-08-05 — expand `{{VAR}}`
// templates before writing. `rp.request` is
// deliberately kept templated on the pane (so
// `file.save` doesn't bake secrets into the
// source file), but the history log needs the
// resolved values to be useful for jq/grep audit
// workflows.
//
// Reviewer 2026-08-05 follow-up — clone the base
// envset per-job + layer on the running-env
// values keyed by THIS job's source_path so
// @capture-d vars resolve correctly (matches
// the live send path at ~4382/4436). Inline the
// merge instead of calling merge_http_running_env
// because we hold a mut borrow on self.panes.
let mut hist_env = hist_env_base.clone();
let src = job_source_paths.get(&job_id).and_then(|p| p.as_deref());
let key = src.map(|p| p.to_path_buf()).unwrap_or_default();
if let Some(entries) = self.http_running_env.get(&key) {
for (k, v) in entries {
hist_env.vars.insert(k.clone(), v.clone());
}
}
// Layer this batch's not-yet-persisted captures
// on top so a dependent job draining in the same
// tick sees the fresh values.
if let Some(overlay) = batch_captures.get(&key) {
for (k, v) in overlay {
hist_env.vars.insert(k.clone(), v.clone());
}
}
let hist_url = crate::http::template::expand(&rp.request.url, &hist_env);
let hist_headers: Vec<(String, String)> = rp
.request
.headers
.iter()
.map(|(k, v)| (k.clone(), crate::http::template::expand(v, &hist_env)))
.collect();
let hist_body = rp
.request
.body
.as_deref()
.map(|b| crate::http::template::expand(b, &hist_env));
crate::http::history::append_with_global_mirror(
&workspace,
&crate::http::history::Entry {
method: &rp.request.method,
url: &hist_url,
status: Some(rv.status),
duration_ms: Some(rv.elapsed.as_millis()),
// api-round-14 SEV-2 2026-07-16 — was
// `rv.body.len()` which inflated
// non-UTF8 payloads via U+FFFD (3-byte)
// replacement chars. Prefer raw bytes
// when captured; fall back to `body`
// length for the text-only shape.
body_bytes: Some(if !rv.body_bytes.is_empty() {
rv.body_bytes.len()
} else {
rv.body.len()
}),
error: None,
headers: Some(&hist_headers),
request_body: hist_body.as_deref(),
},
);
// 2026-06-19 — diff support: shift the
// previous Done into prev_response so
// :http.diff_last_two can compare. Done →
// prev_response; new rv → state.
if let RunState::Done(prev) =
std::mem::replace(&mut rp.state, RunState::Done(Box::new(rv)))
{
rp.prev_response = Some(prev);
}
}
Err(e) => {
toasts.push(format!("request failed: {e}"));
// Failed sends still get a history entry so
// forensic queries can find them.
crate::http::history::append_with_global_mirror(
&workspace,
&crate::http::history::Entry {
method: &rp.request.method,
url: &rp.request.url,
status: None,
duration_ms: None,
body_bytes: None,
error: Some(&e),
headers: Some(&rp.request.headers),
request_body: rp.request.body.as_deref(),
},
);
rp.state = RunState::Failed(e);
}
}
}
// Persist captures after the mut-borrow loop so the running-env
// update doesn't fight `self.panes.iter_mut()`.
for (path, caps) in carry_forward {
self.persist_http_captures(path.as_deref(), &caps);
}
for t in toasts {
self.toast(t);
}
}
/// `Ctrl+S` over the active `Pane::Request` — write the current request
/// (with the in-pane edits applied) back to its source file as a curl
/// command. Pane has no `source_path` ⇒ toast and bail.
pub fn save_request_to_source(&mut self) {
let Some(cur) = self.active else { return };
if let Some(Pane::Request(rp)) = self.panes.get_mut(cur) {
rp.commit_headers();
}
// Snapshot the pane state in one pass so we can let go of the borrow
// before any disk I/O.
let (path, ext, source_block_name, curl_text, http_block) = match self.panes.get(cur) {
Some(Pane::Request(rp)) => {
let Some(p) = rp.source_path.clone() else {
self.toast("no source file to save to (re-fire is in-memory only)");
return;
};
let ext = p
.extension()
.and_then(|s| s.to_str())
.unwrap_or_default()
.to_ascii_lowercase();
(
p,
ext,
rp.source_block_name.clone(),
rp.as_curl(),
rp.as_http_block(rp.source_block_name.as_deref()),
)
}
_ => return,
};
// Multi-block `.http` / `.rest` source: splice just that block in
// place so the other blocks survive. If the splice can't find a
// home for the edit (file was edited externally and the block we
// sent from is gone) we refuse rather than overwrite — losing the
// other blocks would be the worst possible outcome.
// http-2nd 2026-06-28 SEV-1: was guarded on
// `source_block_name.is_some()` so unnamed LEADING blocks
// (no `###` separator) fell through to the whole-file
// overwrite — destroying every subsequent `### named` block.
// splice_http_block correctly handles `None` (matches the
// leading block by the no-separator-name predicate), so the
// only fix needed is to enter the splice path for all .http
// sources, not just named-block ones.
if matches!(ext.as_str(), "http" | "rest") {
let existing = match std::fs::read_to_string(&path) {
Ok(t) => t,
Err(e) => {
self.toast(format!("save failed: {e}"));
return;
}
};
// http-2nd 2026-06-28 SEV-2: splice_http_block returns
// None when blocks.len() < 2 (single-block file). The
// old gate `source_block_name.is_some()` skipped the
// splice for single-block sources; removing it (5020def)
// for leading-block correctness made single-block .http
// saves error-toast instead of falling through. If the
// file is multi-block, splice returns Some; if it's
// single-block, splice returns None and we fall through
// to the whole-file overwrite below.
if let Some(new_text) =
splice_http_block(&existing, source_block_name.as_deref(), &http_block)
{
match std::fs::write(&path, &new_text) {
Ok(()) => {
let rel = rel_path(&self.workspace, &path);
self.toast(format!("saved block → {rel}"));
self.git.refresh();
}
Err(e) => self.toast(format!("save failed: {e}")),
}
return;
}
// Single-block .http/.rest — splice returned None
// because blocks.len() < 2. Fall through to overwrite.
}
// Single-block source (`.curl`, or `.http` whose only block is the
// one we're saving): overwrite with the curl one-liner. Same as the
// pre-multi-block behavior.
match std::fs::write(&path, format!("{curl_text}\n")) {
Ok(()) => {
let rel = rel_path(&self.workspace, &path);
self.toast(format!("saved request → {rel}"));
self.git.refresh();
}
Err(e) => self.toast(format!("save failed: {e}")),
}
}
}
#[cfg(test)]
mod http_tests {
use super::*;
// ── #861 auto-gitignore ─────────────────────────────────────
/// Non-git workspace → no modification, no toast, no
/// `.gitignore` created out of thin air.
#[test]
fn ensure_mnml_env_gitignored_noop_when_not_a_git_repo() {
let ws = tempfile::tempdir().unwrap();
let out = ensure_mnml_env_gitignored(ws.path());
assert!(out.is_none(), "non-git workspace should be a no-op");
assert!(
!ws.path().join(".gitignore").exists(),
"must not create a .gitignore in a non-git workspace"
);
}
/// Git repo with no `.gitignore` yet → creates one containing
/// exactly `.mnml/env/`, toasts.
#[test]
fn ensure_mnml_env_gitignored_creates_when_git_repo_has_no_gitignore() {
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join(".git")).unwrap();
let toast = ensure_mnml_env_gitignored(ws.path()).expect("should have toasted");
assert!(toast.contains(".mnml/env/"));
let body = std::fs::read_to_string(ws.path().join(".gitignore")).unwrap();
assert!(body.contains(".mnml/env/"));
}
/// Existing `.gitignore` that already covers `.mnml/env/` → no
/// modification (idempotent).
#[test]
fn ensure_mnml_env_gitignored_idempotent_when_pattern_present() {
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join(".git")).unwrap();
let gi = ws.path().join(".gitignore");
std::fs::write(&gi, "target/\n.mnml/env/\nnode_modules/\n").unwrap();
let before = std::fs::read_to_string(&gi).unwrap();
let out = ensure_mnml_env_gitignored(ws.path());
assert!(out.is_none());
assert_eq!(std::fs::read_to_string(&gi).unwrap(), before);
}
/// Existing gitignore covers `.mnml/env/` via a broader `.mnml`
/// pattern → also treated as covered, no duplicate append.
#[test]
fn ensure_mnml_env_gitignored_respects_broader_mnml_pattern() {
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join(".git")).unwrap();
let gi = ws.path().join(".gitignore");
std::fs::write(&gi, "target/\n.mnml/\n").unwrap();
let out = ensure_mnml_env_gitignored(ws.path());
assert!(out.is_none());
}
/// User has explicitly whitelisted a specific env file via
/// `!.mnml/env/dev.env`. Our append would silently override it
/// (gitignore order-dependent). Skip + toast a warning instead.
#[test]
fn ensure_mnml_env_gitignored_respects_negation() {
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join(".git")).unwrap();
let gi = ws.path().join(".gitignore");
let body = "target/\n.env\n!.mnml/env/dev.env\n";
std::fs::write(&gi, body).unwrap();
let out = ensure_mnml_env_gitignored(ws.path());
assert!(out.is_some(), "should toast to explain the skip");
let toast = out.unwrap();
assert!(
toast.contains("negation") || toast.contains("!.mnml"),
"toast should explain the negation was respected: {toast}"
);
assert_eq!(
std::fs::read_to_string(&gi).unwrap(),
body,
"gitignore body must be unchanged"
);
}
/// Broader-scope negation `!.mnml/**` (or `!.mnml/`) also
/// covers env, so an append after would silently re-override
/// it. Same skip-and-warn semantics as the narrow variant.
#[test]
fn ensure_mnml_env_gitignored_respects_broader_negation() {
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join(".git")).unwrap();
let gi = ws.path().join(".gitignore");
let body = "target/\n.mnml/\n!.mnml/**\n";
std::fs::write(&gi, body).unwrap();
let out = ensure_mnml_env_gitignored(ws.path());
assert!(out.is_some());
assert_eq!(
std::fs::read_to_string(&gi).unwrap(),
body,
"gitignore body must be unchanged"
);
}
/// Path-segment-boundary check — `!.mnml-backup/`, `!.mnmlrc`
/// merely share the raw prefix `.mnml`, but they're not the
/// mnml config dir. Must NOT false-trigger skip-and-warn.
/// Reviewer 2026-08-03 finding on c1424996.
#[test]
fn ensure_mnml_env_gitignored_dot_mnml_prefix_is_segment_bounded() {
for negation in &[
"!.mnml-backup/",
"!.mnmlrc",
"!.mnml-old",
"!/.mnml-backup/",
] {
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join(".git")).unwrap();
let gi = ws.path().join(".gitignore");
std::fs::write(&gi, format!("target/\n{negation}\n")).unwrap();
let out = ensure_mnml_env_gitignored(ws.path())
.unwrap_or_else(|| panic!("should have appended for {negation}"));
assert!(
out.contains(".mnml/env/"),
"toast should confirm append for {negation}: {out}"
);
let body = std::fs::read_to_string(&gi).unwrap();
assert!(
body.ends_with(".mnml/env/\n"),
"append should have landed for {negation}"
);
}
}
/// Non-mnml negation `!vendor/.mnml/env-old/` shouldn't
/// trigger skip — it's a completely unrelated path that
/// happens to have `.mnml/env` as a substring. Our append
/// wouldn't collide with it either way.
#[test]
fn ensure_mnml_env_gitignored_ignores_non_mnml_prefixed_negation() {
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join(".git")).unwrap();
let gi = ws.path().join(".gitignore");
let body = "target/\n!vendor/.mnml/env-old/\n";
std::fs::write(&gi, body).unwrap();
let out = ensure_mnml_env_gitignored(ws.path()).expect("should append");
assert!(out.contains(".mnml/env/"));
let after = std::fs::read_to_string(&gi).unwrap();
assert!(after.ends_with(".mnml/env/\n"));
}
/// Existing gitignore doesn't cover us and doesn't end in
/// `\n` → append adds a newline first so lines don't glue.
#[test]
fn ensure_mnml_env_gitignored_prepends_newline_when_needed() {
let ws = tempfile::tempdir().unwrap();
std::fs::create_dir(ws.path().join(".git")).unwrap();
let gi = ws.path().join(".gitignore");
std::fs::write(&gi, "target/").unwrap(); // no trailing newline
let _ = ensure_mnml_env_gitignored(ws.path()).unwrap();
let body = std::fs::read_to_string(&gi).unwrap();
assert_eq!(body, "target/\n.mnml/env/\n");
}
// ── extract_summary — drives the bufferline tab label ──
#[test]
fn http_next_block_navigates_request_pane_in_place() {
// api-workflow SEV-1 2026-07-10: `.http`/`.curl`/`.rest` files
// auto-open as Pane::Request, but `]`/`[` (http_next_block /
// http_prev_block) previously went through `active_editor()`,
// which is None for Request panes → silent no-op. This
// regression test drives the same path: open a multi-block
// .http as a Request pane, call http_next_block, assert the
// SAME pane is now showing block 2.
let d = tempfile::tempdir().unwrap();
let mut app = App::new(d.path().to_path_buf(), crate::config::Config::default()).unwrap();
let file = d.path().join("multi.http");
std::fs::write(
&file,
"### one\nGET https://example.com/one\n\n### two\nGET https://example.com/two\n\n### three\nGET https://example.com/three\n",
)
.unwrap();
app.open_request_pane_from_file(&file);
let pane_count_before = app.panes.len();
let active_before = app.active;
let assert_block = |app: &App, expected_name: &str, expected_url_suffix: &str| {
let idx = app.active.expect("active pane");
match app.panes.get(idx).expect("pane exists") {
crate::pane::Pane::Request(rp) => {
assert_eq!(
rp.source_block_name.as_deref(),
Some(expected_name),
"block name for expected {expected_name}"
);
assert!(
rp.request.url.ends_with(expected_url_suffix),
"url {} ends with {expected_url_suffix}",
rp.request.url
);
}
_ => panic!("expected Request pane"),
}
};
assert_block(&app, "one", "/one");
app.http_next_block();
assert_eq!(app.panes.len(), pane_count_before, "no new pane spawned");
assert_eq!(app.active, active_before, "same pane");
assert_block(&app, "two", "/two");
app.http_next_block();
assert_block(&app, "three", "/three");
// Wrap forward.
app.http_next_block();
assert_block(&app, "one", "/one");
// Wrap backward.
app.http_prev_block();
assert_block(&app, "three", "/three");
}
#[test]
fn regenerate_body_rerolls_concrete_timestamps_and_uuids() {
// Body has a stale timestamp + UUID. After regenerate, they
// should be different values (fresh from the runtime).
let d = tempfile::tempdir().unwrap();
let mut app = App::new(d.path().to_path_buf(), crate::config::Config::default()).unwrap();
app.open_new_request_pane();
let stale = r#"{"orderId":"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee","asOfDate":"2020-01-01T00:00:00.0000000Z","note":"keep"}"#.to_string();
if let Some(cur) = app.active
&& let Some(crate::pane::Pane::Request(rp)) = app.panes.get_mut(cur)
{
rp.request.body = Some(stale.clone());
}
app.http_regenerate_body();
let out = app
.active
.and_then(|i| app.panes.get(i))
.and_then(|p| match p {
crate::pane::Pane::Request(rp) => rp.request.body.clone(),
_ => None,
})
.unwrap();
assert_ne!(out, stale, "body should change");
// Static text is preserved.
assert!(out.contains(r#""note":"keep""#), "note preserved: {out}");
// The stale UUID / date should NOT be in the output (fresh
// values replaced them).
assert!(!out.contains("aaaaaaaa-bbbb"), "stale uuid gone: {out}");
assert!(!out.contains("2020-01-01"), "stale date gone: {out}");
}
#[test]
fn extract_summary_picks_first_useful_comment() {
let text = "# Trigger a Playwright build\n# POST /v3/api/test-executions/playwright/builds\ncurl 'https://x' \\\n -X POST\n";
assert_eq!(
extract_summary(text).as_deref(),
Some("Trigger a Playwright build")
);
}
#[test]
fn extract_summary_skips_method_path_marker() {
// Bare "# POST /path" isn't a summary — it's discover-added
// routing metadata.
let text = "# POST /admin/event\ncurl 'https://x' \\\n";
assert_eq!(extract_summary(text), None);
}
#[test]
fn extract_summary_prefers_example_name_over_operation_summary() {
// 2026-07-09 flip: when a `# example: <name>` line is
// present, the example name is the DISTINCTIVE info (the
// operation summary is shared across 200+ TriggerEvent
// files). Example name wins.
let text =
"# Trigger an event\n# example: ChatmeterDeleteReviewCommand\ncurl 'https://x'\n";
assert_eq!(
extract_summary(text).as_deref(),
Some("ChatmeterDeleteReviewCommand")
);
}
#[test]
fn extract_summary_falls_through_to_summary_when_no_example() {
let text = "# Trigger an event\ncurl 'https://x'\n";
assert_eq!(extract_summary(text).as_deref(), Some("Trigger an event"));
}
#[test]
fn extract_summary_handles_slash_slash_comments() {
let text = "// Get a user\ncurl 'https://x'\n";
assert_eq!(extract_summary(text).as_deref(), Some("Get a user"));
}
#[test]
fn extract_summary_empty_when_no_leading_comments() {
let text = "curl 'https://x'\n";
assert_eq!(extract_summary(text), None);
}
#[test]
fn curl_block_bounds_no_separators_returns_whole_file() {
// Single-block .curl — no `###` at all. Any cursor row →
// (0, last).
let lines = vec!["curl 'https://x/a'", " -H 'X: 1'", ""];
assert_eq!(curl_block_bounds(&lines, 0), (0, 2));
assert_eq!(curl_block_bounds(&lines, 1), (0, 2));
assert_eq!(curl_block_bounds(&lines, 99), (0, 2));
}
#[test]
fn curl_block_bounds_cursor_on_a_named_block() {
// Cursor on line 4 (inside second block) → (3, 5).
let lines = vec![
"### first", // 0
"curl 'https://x/1'", // 1
"", // 2
"### second", // 3
"curl 'https://x/2'", // 4
" -H 'X: 1'", // 5
];
assert_eq!(curl_block_bounds(&lines, 4), (3, 5));
// Cursor on the header line itself — same block.
assert_eq!(curl_block_bounds(&lines, 3), (3, 5));
// First block — cursor on line 1 → (0, 2).
assert_eq!(curl_block_bounds(&lines, 1), (0, 2));
}
#[test]
fn curl_block_bounds_cursor_before_first_separator_hits_leading_block() {
// Regression for #polish 2026-07-06 — leading unnamed
// block was silently firing the FIRST NAMED block. Now
// the leading region (lines 0..=2) is its own block
// when the cursor sits in it.
let lines = vec![
"curl 'https://leading/'", // 0 ← leading unnamed block
" -H 'X: 1'", // 1
"", // 2
"### named-first", // 3
"curl 'https://x/1'", // 4
];
// Cursor on the leading content — MUST land on (0, 2), not (3, 4).
assert_eq!(curl_block_bounds(&lines, 0), (0, 2));
assert_eq!(curl_block_bounds(&lines, 1), (0, 2));
assert_eq!(curl_block_bounds(&lines, 2), (0, 2));
// Cursor on the named block header — that block wins.
assert_eq!(curl_block_bounds(&lines, 3), (3, 4));
}
#[test]
fn request_pane_save_writes_curl_back_to_source() {
let d = tempfile::tempdir().unwrap();
let src = d.path().join("hello.curl");
std::fs::write(&src, "curl 'https://x/'\n").unwrap();
let mut app = App::new(d.path().to_path_buf(), Config::default()).unwrap();
// Build a Request pane manually (no real HTTP send — we just want to
// exercise the save-back path).
let (cmd_tx, _cmd_rx) = std::sync::mpsc::channel::<crate::cdp::CdpCommand>();
let _ = cmd_tx; // silence unused; we don't have a worker
let req = crate::http::Request {
method: "POST".into(),
url: "https://example.test/v1".into(),
headers: vec![("Accept".into(), "application/json".into())],
body: Some(r#"{"q":1}"#.into()),
insecure: false,
};
let pane = Pane::Request(crate::request_pane::RequestPane::new(
Some(src.clone()),
req,
crate::http::script::Script::default(),
1,
));
app.panes.push(pane);
app.active = Some(app.panes.len() - 1);
app.save_request_to_source();
let on_disk = std::fs::read_to_string(&src).unwrap();
assert!(on_disk.contains("curl 'https://example.test/v1'"));
// POST + --data-raw lets curl infer POST, so `-X POST` is omitted.
assert!(on_disk.contains("Accept: application/json"));
assert!(on_disk.contains(r#"--data-raw '{"q":1}'"#));
}
#[test]
fn auto_format_body_preserves_bigint_literals() {
// api-workflow SEV-2 2026-07-11: auto-format used to parse JSON
// through serde_json's default number handling, which stores
// any integer larger than u64 as f64 (lossy). `99999999999999999999`
// → `1e+20`. The `arbitrary_precision` feature routes numbers
// through a Number type that round-trips exactly.
let d = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.http.auto_format_body = true;
let mut app = App::new(d.path().to_path_buf(), config).unwrap();
app.open_new_request_pane();
let big = r#"{"orderId":"XYZ","amount":99999999999999999999,"pi":3.14159265358979}"#;
if let Some(cur) = app.active
&& let Some(crate::pane::Pane::Request(rp)) = app.panes.get_mut(cur)
{
rp.request.body = Some(big.to_string());
}
app.maybe_auto_format_active_body();
let out = app
.active
.and_then(|i| app.panes.get(i))
.and_then(|p| match p {
crate::pane::Pane::Request(rp) => rp.request.body.clone(),
_ => None,
})
.unwrap();
assert!(
out.contains("99999999999999999999"),
"bigint preserved: {out}"
);
assert!(
!out.contains("1e+20") && !out.contains("1e20"),
"no lossy float: {out}"
);
}
#[test]
fn splice_http_block_preserves_other_blocks() {
let src = "\
### one
GET https://example.com/one
### two
POST https://example.com/two
Content-Type: application/json
{\"a\": 1}
### three
GET https://example.com/three
";
let new_two = "### two\nPUT https://example.com/two-EDITED\n";
let out = splice_http_block(src, Some("two"), new_two).unwrap();
// The other blocks survive verbatim.
assert!(out.contains("### one\nGET https://example.com/one"));
assert!(out.contains("### three\nGET https://example.com/three"));
// The target block is the edited one, not the original.
assert!(out.contains("PUT https://example.com/two-EDITED"));
assert!(!out.contains("POST https://example.com/two"));
// Trailing-newline policy preserved.
assert!(out.ends_with('\n'));
}
#[test]
fn splice_http_block_returns_none_for_single_block() {
let src = "GET https://example.com\n";
let new_text = "### x\nPUT https://example.com\n";
// Single-block file ⇒ caller falls back to whole-file overwrite.
assert!(splice_http_block(src, Some("x"), new_text).is_none());
}
#[test]
fn splice_http_block_returns_none_when_name_missing() {
let src = "\
### a
GET https://example.com/a
### b
GET https://example.com/b
";
// No block named "missing" ⇒ caller falls back to overwrite (which the
// user would notice is destructive — better than silently editing the
// wrong block).
assert!(splice_http_block(src, Some("missing"), "### missing\nGET x\n").is_none());
}
#[test]
fn splice_http_block_handles_unnamed_leading_block() {
// The leading block in a multi-block .http file may not have a `###`
// separator. Editing it shouldn't disturb the named blocks below.
let src = "\
GET https://example.com/leading
### second
GET https://example.com/second
";
let new_text = "PUT https://example.com/leading-EDITED\n";
let out = splice_http_block(src, None, new_text).unwrap();
assert!(out.contains("PUT https://example.com/leading-EDITED"));
assert!(out.contains("### second\nGET https://example.com/second"));
assert!(!out.contains("GET https://example.com/leading\n"));
}
#[test]
fn splice_http_block_preserves_blank_separator_before_first_named_block() {
// api-workflow-user 3rd 2026-06-29 SEV-3: editing the unnamed
// leading block used to strip the blank line between it and
// the first `### name` block (the leading block's end_line
// absorbs the trailing blank, and as_http_block(None)
// doesn't emit a replacement blank). Lock the fix.
let src = "\
GET https://example.com/leading
### second
GET https://example.com/second
";
let new_text = "PUT https://example.com/leading-EDITED\n";
let out = splice_http_block(src, None, new_text).unwrap();
// Blank line must survive between the replaced leading block
// and the `### second` separator.
assert!(
out.contains("EDITED\n\n### second"),
"expected blank line between leading-block replacement and `### second`, got:\n{out}"
);
}
}