diff --git a/src-tauri/src/http_request.rs b/src-tauri/src/http_request.rs index 15cf6041..f4553e78 100644 --- a/src-tauri/src/http_request.rs +++ b/src-tauri/src/http_request.rs @@ -256,16 +256,24 @@ async fn execute_transaction( let transaction = HttpTransaction::new(sender); let start = Instant::now(); - // Capture request headers before sending (headers will be moved) + // Capture request headers before sending let request_headers: Vec = sendable_request .headers .iter() .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) .collect(); + { + // Update response with headers info and mark as connected + let mut r = response.lock().await; + r.url = sendable_request.url.clone(); + r.request_headers = request_headers.clone(); + app_handle.db().update_http_response_if_id(&r, &update_source)?; + } + // Execute the transaction with cancellation support // This returns the response with headers, but body is not yet consumed - let (http_response, _events) = + let (mut http_response, _events) = transaction.execute_with_cancellation(sendable_request, cancelled_rx.clone()).await?; // Prepare the response path before consuming the body @@ -280,37 +288,30 @@ async fn execute_transaction( }; // Extract metadata before consuming the body (headers are available immediately) - let status = http_response.status; - let status_reason = http_response.status_reason.clone(); - let url = http_response.url.clone(); - let remote_addr = http_response.remote_addr.clone(); - let version = http_response.version.clone(); - let content_length = http_response.content_length; + // Url might change, so update again let headers: Vec = http_response .headers .iter() .map(|(name, value)| HttpResponseHeader { name: name.clone(), value: value.clone() }) .collect(); - let headers_timing = http_response.timing.headers; - // Update response with headers info and mark as connected { + // Update response with headers info and mark as connected let mut r = response.lock().await; - r.body_path = Some( - body_path - .to_str() - .ok_or(GenericError(format!("Invalid path {body_path:?}")))? - .to_string(), - ); - r.elapsed_headers = headers_timing.as_millis() as i32; - r.elapsed = start.elapsed().as_millis() as i32; - r.status = status as i32; - r.status_reason = status_reason.clone(); - r.url = url.clone(); - r.remote_addr = remote_addr.clone(); - r.version = version.clone(); + r.body_path = Some(body_path.to_string_lossy().to_string()); + r.elapsed_headers = start.elapsed().as_millis() as i32; + r.status = http_response.status as i32; + r.status_reason = http_response.status_reason.clone().clone(); + r.url = http_response.url.clone().clone(); + r.remote_addr = http_response.remote_addr.clone(); + r.version = http_response.version.clone().clone(); r.headers = headers.clone(); - r.request_headers = request_headers.clone(); + r.content_length = http_response.content_length.map(|l| l as i32); + r.request_headers = http_response + .request_headers + .iter() + .map(|(n, v)| HttpResponseHeader { name: n.clone(), value: v.clone() }) + .collect(); r.state = HttpResponseState::Connected; app_handle.db().update_http_response_if_id(&r, &update_source)?; } @@ -332,7 +333,7 @@ async fn execute_transaction( let mut buf = [0u8; 8192]; loop { - // Check for cancellation - if we already have headers/body, just close cleanly + // Check for cancellation. If we already have headers/body, just close cleanly without error if *cancelled_rx.borrow() { break; } @@ -350,7 +351,7 @@ async fn execute_transaction( // Update response in DB with progress let mut r = response.lock().await; - r.elapsed = start.elapsed().as_millis() as i32; + r.elapsed = start.elapsed().as_millis() as i32; // Approx until the end r.content_length = Some(written_bytes as i32); app_handle.db().update_http_response_if_id(&r, &update_source)?; } @@ -362,20 +363,8 @@ async fn execute_transaction( // Final update with closed state let mut resp = response.lock().await.clone(); - resp.headers = headers; - resp.request_headers = request_headers; - resp.status = status as i32; - resp.status_reason = status_reason; - resp.url = url; - resp.remote_addr = remote_addr; - resp.version = version; - resp.state = HttpResponseState::Closed; - resp.content_length = match content_length { - Some(l) => Some(l as i32), - None => Some(written_bytes as i32), - }; resp.elapsed = start.elapsed().as_millis() as i32; - resp.elapsed_headers = headers_timing.as_millis() as i32; + resp.state = HttpResponseState::Closed; resp.body_path = Some( body_path.to_str().ok_or(GenericError(format!("Invalid path {body_path:?}",)))?.to_string(), ); diff --git a/src-tauri/yaak-http/src/sender.rs b/src-tauri/yaak-http/src/sender.rs index 236dcb45..54c9b390 100644 --- a/src-tauri/yaak-http/src/sender.rs +++ b/src-tauri/yaak-http/src/sender.rs @@ -7,16 +7,10 @@ use reqwest::{Client, Method, Version}; use std::collections::HashMap; use std::fmt::Display; use std::pin::Pin; -use std::time::{Duration, Instant}; +use std::time::Duration; use tokio::io::{AsyncRead, AsyncReadExt, BufReader}; use tokio_util::io::StreamReader; -#[derive(Debug, Default, Clone)] -pub struct HttpResponseTiming { - pub headers: Duration, - pub body: Duration, -} - #[derive(Debug)] pub enum HttpResponseEvent { Setting(String, String), @@ -68,6 +62,8 @@ pub struct HttpResponse { pub status_reason: Option, /// Response headers pub headers: HashMap, + /// Request headers + pub request_headers: HashMap, /// Content-Length from headers (may differ from actual body size) pub content_length: Option, /// Final URL (after redirects) @@ -76,15 +72,11 @@ pub struct HttpResponse { pub remote_addr: Option, /// HTTP version (e.g., "HTTP/1.1", "HTTP/2") pub version: Option, - /// Timing information - pub timing: HttpResponseTiming, /// The body stream (consumed when calling bytes(), text(), write_to_file(), or drain()) body_stream: Option, /// Content-Encoding for decompression encoding: ContentEncoding, - /// Start time for timing the body read - start_time: Instant, } impl std::fmt::Debug for HttpResponse { @@ -97,7 +89,6 @@ impl std::fmt::Debug for HttpResponse { .field("url", &self.url) .field("remote_addr", &self.remote_addr) .field("version", &self.version) - .field("timing", &self.timing) .field("body_stream", &"") .field("encoding", &self.encoding) .finish() @@ -111,33 +102,31 @@ impl HttpResponse { status: u16, status_reason: Option, headers: HashMap, + request_headers: HashMap, content_length: Option, url: String, remote_addr: Option, version: Option, - timing: HttpResponseTiming, body_stream: BodyStream, encoding: ContentEncoding, - start_time: Instant, ) -> Self { Self { status, status_reason, headers, + request_headers, content_length, url, remote_addr, version, - timing, body_stream: Some(body_stream), encoding, - start_time, } } /// Consume the body and return it as bytes (loads entire body into memory). /// Also decompresses the body if Content-Encoding is set. - pub async fn bytes(mut self) -> Result<(Vec, BodyStats, HttpResponseTiming)> { + pub async fn bytes(mut self) -> Result<(Vec, BodyStats)> { let stream = self.body_stream.take().ok_or_else(|| { Error::RequestError("Response body has already been consumed".to_string()) })?; @@ -163,9 +152,6 @@ impl HttpResponse { } } - let mut timing = self.timing.clone(); - timing.body = self.start_time.elapsed(); - let stats = BodyStats { // For now, we can't easily track compressed size when streaming through decoder // Use content_length as an approximation, or decompressed size if identity encoding @@ -173,21 +159,21 @@ impl HttpResponse { size_decompressed: decompressed.len() as u64, }; - Ok((decompressed, stats, timing)) + Ok((decompressed, stats)) } /// Consume the body and return it as a UTF-8 string. - pub async fn text(self) -> Result<(String, BodyStats, HttpResponseTiming)> { - let (bytes, stats, timing) = self.bytes().await?; + pub async fn text(self) -> Result<(String, BodyStats)> { + let (bytes, stats) = self.bytes().await?; let text = String::from_utf8(bytes) .map_err(|e| Error::RequestError(format!("Response is not valid UTF-8: {}", e)))?; - Ok((text, stats, timing)) + Ok((text, stats)) } /// Take the body stream for manual consumption. /// Returns an AsyncRead that decompresses on-the-fly if Content-Encoding is set. /// The caller is responsible for reading and processing the stream. - pub fn into_body_stream(mut self) -> Result> { + pub fn into_body_stream(&mut self) -> Result> { let stream = self.body_stream.take().ok_or_else(|| { Error::RequestError("Response body has already been consumed".to_string()) })?; @@ -199,7 +185,7 @@ impl HttpResponse { } /// Discard the body without reading it (useful for redirects). - pub async fn drain(mut self) -> Result { + pub async fn drain(mut self) -> Result<()> { let stream = self.body_stream.take().ok_or_else(|| { Error::RequestError("Response body has already been consumed".to_string()) })?; @@ -220,10 +206,7 @@ impl HttpResponse { } } - let mut timing = self.timing.clone(); - timing.body = self.start_time.elapsed(); - - Ok(timing) + Ok(()) } } @@ -297,9 +280,6 @@ impl HttpSender for ReqwestSender { } } - let start = Instant::now(); - let mut timing = HttpResponseTiming::default(); - // Send the request let sendable_req = req_builder.build()?; events.push(HttpResponseEvent::Setting( @@ -316,11 +296,11 @@ impl HttpSender for ReqwestSender { method: sendable_req.method().to_string(), }); + let mut request_headers = HashMap::new(); for (name, value) in sendable_req.headers() { - events.push(HttpResponseEvent::HeaderUp( - name.to_string(), - value.to_str().unwrap_or_default().to_string(), - )); + let v = value.to_str().unwrap_or_default().to_string(); + request_headers.insert(name.to_string(), v.clone()); + events.push(HttpResponseEvent::HeaderUp(name.to_string(), v)); } events.push(HttpResponseEvent::HeaderUpDone); events.push(HttpResponseEvent::Info("Sending request to server".to_string())); @@ -341,17 +321,13 @@ impl HttpSender for ReqwestSender { let url = response.url().to_string(); let remote_addr = response.remote_addr().map(|a| a.to_string()); let version = Some(version_to_str(&response.version())); + let content_length = response.content_length(); events.push(HttpResponseEvent::ReceiveUrl { version: response.version(), status: response.status().to_string(), }); - timing.headers = start.elapsed(); - - // Extract content length - let content_length = response.content_length(); - // Extract headers let mut headers = HashMap::new(); for (key, value) in response.headers() { @@ -385,14 +361,13 @@ impl HttpSender for ReqwestSender { status, status_reason, headers, + request_headers, content_length, url, remote_addr, version, - timing, body_stream, encoding, - start, )) } } diff --git a/src-tauri/yaak-http/src/transaction.rs b/src-tauri/yaak-http/src/transaction.rs index de4da72a..7c688138 100644 --- a/src-tauri/yaak-http/src/transaction.rs +++ b/src-tauri/yaak-http/src/transaction.rs @@ -201,12 +201,11 @@ impl HttpTransaction { mod tests { use super::*; use crate::decompress::ContentEncoding; - use crate::sender::{HttpResponseEvent, HttpResponseTiming, HttpSender}; + use crate::sender::{HttpResponseEvent, HttpSender}; use async_trait::async_trait; use std::collections::HashMap; use std::pin::Pin; use std::sync::Arc; - use std::time::Instant; use tokio::io::AsyncRead; use tokio::sync::Mutex; @@ -246,14 +245,13 @@ mod tests { mock.status, None, // status_reason mock.headers, + HashMap::new(), None, // content_length "https://example.com".to_string(), // url None, // remote_addr Some("HTTP/1.1".to_string()), // version - HttpResponseTiming::default(), body_stream, ContentEncoding::Identity, - Instant::now(), )) } } @@ -277,7 +275,7 @@ mod tests { assert_eq!(result.status, 200); // Consume the body to verify it - let (body, _, _) = result.bytes().await.unwrap(); + let (body, _) = result.bytes().await.unwrap(); assert_eq!(body, b"OK"); } @@ -308,7 +306,7 @@ mod tests { let (result, _) = transaction.execute_with_cancellation(request, rx).await.unwrap(); assert_eq!(result.status, 200); - let (body, _, _) = result.bytes().await.unwrap(); + let (body, _) = result.bytes().await.unwrap(); assert_eq!(body, b"Final"); } diff --git a/src-web/components/HttpResponsePane.tsx b/src-web/components/HttpResponsePane.tsx index 86c68ee4..6aea7918 100644 --- a/src-web/components/HttpResponsePane.tsx +++ b/src-web/components/HttpResponsePane.tsx @@ -62,6 +62,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { { value: TAB_BODY, label: 'Preview Mode', + hidden: (activeResponse?.contentLength || 0) === 0, options: { value: viewMode, onChange: setViewMode, @@ -92,6 +93,7 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { setViewMode, viewMode, activeResponse?.requestHeaders.length, + activeResponse?.contentLength, ], ); const activeTab = activeTabs?.[activeRequestId]; @@ -163,79 +165,66 @@ export function HttpResponsePane({ style, className, activeRequestId }: Props) { )} {/* Show tabs if we have any data (headers, body, etc.) even if there's an error */} - {(activeResponse?.headers.length > 0 || - activeResponse?.bodyPath || - !activeResponse?.error) && ( - - - - - - {activeResponse.state === 'initialized' ? ( - - - - - Sending Request - - - - - ) : activeResponse.state === 'closed' && - activeResponse.contentLength === 0 ? ( - Empty - ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( - - ) : mimeType?.match(/^image\/svg/) ? ( - - ) : mimeType?.match(/^image/i) ? ( - - ) : mimeType?.match(/^audio/i) ? ( - - ) : mimeType?.match(/^video/i) ? ( - - ) : mimeType?.match(/pdf/i) ? ( - - ) : mimeType?.match(/csv|tab-separated/i) ? ( - - ) : ( - - )} - - - - - - - - - - - - )} + + + + + + {activeResponse.state === 'initialized' ? ( + + + + + Sending Request + + + + + ) : activeResponse.state === 'closed' && + activeResponse.contentLength === 0 ? ( + Empty + ) : mimeType?.match(/^text\/event-stream/i) && viewMode === 'pretty' ? ( + + ) : mimeType?.match(/^image\/svg/) ? ( + + ) : mimeType?.match(/^image/i) ? ( + + ) : mimeType?.match(/^audio/i) ? ( + + ) : mimeType?.match(/^video/i) ? ( + + ) : mimeType?.match(/pdf/i) ? ( + + ) : mimeType?.match(/csv|tab-separated/i) ? ( + + ) : ( + + )} + + + + + + + + + + + )} diff --git a/src-web/components/ResponseHeaders.tsx b/src-web/components/ResponseHeaders.tsx index d4ef9310..e109c806 100644 --- a/src-web/components/ResponseHeaders.tsx +++ b/src-web/components/ResponseHeaders.tsx @@ -25,41 +25,53 @@ export function ResponseHeaders({ response }: Props) { ); return (
+ + Request + + } + > + {requestHeaders.length === 0 ? ( + + ) : ( + + {requestHeaders.map((h, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: none + + {h.value} + + ))} + + )} + - Response + Response } > - - {responseHeaders.map((h, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: none - - {h.value} - - ))} - - - - Request - - } - > - - {requestHeaders.map((h, i) => ( - // biome-ignore lint/suspicious/noArrayIndexKey: none - - {h.value} - - ))} - + {responseHeaders.length === 0 ? ( + + ) : ( + + {responseHeaders.map((h, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: none + + {h.value} + + ))} + + )}
); } + +function NoHeaders() { + return No Headers; +} diff --git a/src-web/components/ResponseInfo.tsx b/src-web/components/ResponseInfo.tsx index ea17ae74..dcb68816 100644 --- a/src-web/components/ResponseInfo.tsx +++ b/src-web/components/ResponseInfo.tsx @@ -12,10 +12,10 @@ export function ResponseInfo({ response }: Props) {
- {response.version} + {response.version ?? --} - {response.remoteAddr} + {response.remoteAddr ?? --} )} @@ -798,7 +798,13 @@ function FileActionsDropdown({ items={fileItems} itemsAfter={itemsAfter} > - + ); }