Clone in a terminal:
Commit 836c4fc
Johan Mattsson - 2026-11-02T14:05:05+00:00
836c4fc749bf3be0869af8aa2bb7238c972037bbjohan.mattsson.m@gmail.com

Update save and load code

Changed files (5)

Diff

diff --git a/Cargo.toml b/Cargo.tomlindex 871b189..c437f13 100644--- a/Cargo.toml+++ b/Cargo.toml@@ -23,8 +23,11 @@ web-sys = { version = "0.3", features = [     "HtmlElement",     "HtmlTextAreaElement",     "HtmlSelectElement",+    "HtmlInputElement",     "NodeList",     "KeyboardEvent",+    "Window",+    "EventTarget",     "CustomElementRegistry",     "ElementDefinitionOptions", ] }diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rsindex 40feaf6..7f95f3e 100644--- a/src-tauri/src/lib.rs+++ b/src-tauri/src/lib.rs@@ -1,4 +1,4 @@-use std::{fs, io, path::PathBuf};+use std::{collections::BTreeMap, fs, io, path::PathBuf};  use reqwest::{     Client, Method, Url,@@ -11,6 +11,18 @@ use tauri::{AppHandle, Manager}; struct RequestHeader {     key: String,     value: String,+    enabled: bool,+}++#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase")]+struct RequestData {+    id: String,+    name: String,+    method: String,+    domain: String,+    endpoint: String,+    body: String, }  #[derive(Clone, Deserialize, Serialize)]@@ -25,15 +37,22 @@ struct StoredRequest {     headers: Vec<RequestHeader>, } +type HeadersByRequestName = BTreeMap<String, Vec<RequestHeader>>;+ const REQUESTS_FILE_NAME: &str = "requests.json";+const HEADERS_FILE_NAME: &str = "headers.json";  #[derive(Serialize)]+#[serde(rename_all = "camelCase")] struct PostResponse {+    method: String,     status: u16,+    status_text: String,+    url: String,     body: String, } -fn requests_file_path(app: &AppHandle) -> Result<PathBuf, String> {+fn storage_file_path(app: &AppHandle, file_name: &str) -> Result<PathBuf, String> {     let app_data_dir = app         .path()         .app_data_dir()@@ -42,10 +61,18 @@ fn requests_file_path(app: &AppHandle) -> Result<PathBuf, String> {     fs::create_dir_all(&app_data_dir)         .map_err(|error| format!("Could not create the application data directory: {error}"))?; -    Ok(app_data_dir.join(REQUESTS_FILE_NAME))+    Ok(app_data_dir.join(file_name))+}++fn requests_file_path(app: &AppHandle) -> Result<PathBuf, String> {+    storage_file_path(app, REQUESTS_FILE_NAME) } -fn read_requests(app: &AppHandle) -> Result<Vec<StoredRequest>, String> {+fn headers_file_path(app: &AppHandle) -> Result<PathBuf, String> {+    storage_file_path(app, HEADERS_FILE_NAME)+}++fn load_requests(app: &AppHandle) -> Result<Vec<RequestData>, String> {     let path = requests_file_path(app)?;     let contents = match fs::read_to_string(path) {         Ok(contents) => contents,@@ -57,24 +84,66 @@ fn read_requests(app: &AppHandle) -> Result<Vec<StoredRequest>, String> {         .map_err(|error| format!("Saved requests file is invalid JSON: {error}")) } -fn write_requests(app: &AppHandle, requests: &[StoredRequest]) -> Result<(), String> {+fn write_requests(app: &AppHandle, requests: &[RequestData]) -> Result<(), String> {     let contents = serde_json::to_string_pretty(requests)         .map_err(|error| format!("Could not serialize saved requests: {error}"))?;     fs::write(requests_file_path(app)?, contents)         .map_err(|error| format!("Could not write saved requests: {error}")) } +fn read_headers(app: &AppHandle) -> Result<HeadersByRequestName, String> {+    let path = headers_file_path(app)?;+    let contents = match fs::read_to_string(path) {+        Ok(contents) => contents,+        Err(error) if error.kind() == io::ErrorKind::NotFound => {+            return Ok(HeadersByRequestName::new());+        }+        Err(error) => return Err(format!("Could not read saved request headers: {error}")),+    };++    serde_json::from_str(&contents)+        .map_err(|error| format!("Saved request headers file is invalid JSON: {error}"))+}++fn write_headers(app: &AppHandle, headers: &HeadersByRequestName) -> Result<(), String> {+    let contents = serde_json::to_string_pretty(headers)+        .map_err(|error| format!("Could not serialize saved request headers: {error}"))?;+    fs::write(headers_file_path(app)?, contents)+        .map_err(|error| format!("Could not write saved request headers: {error}"))+}++fn read_headers_for_request(+    app: &AppHandle,+    request_name: &str,+) -> Result<Vec<RequestHeader>, String> {+    Ok(read_headers(app)?+        .get(request_name)+        .cloned()+        .unwrap_or_default())+}+ #[tauri::command]-fn load_requests(app: AppHandle) -> Result<Vec<StoredRequest>, String> {-    read_requests(&app)+fn load_request_list(app: AppHandle) -> Result<Vec<RequestData>, String> {+    load_requests(&app) }  #[tauri::command] fn load_request(app: AppHandle, id: String) -> Result<StoredRequest, String> {-    read_requests(&app)?+    let request = load_requests(&app)?         .into_iter()         .find(|request| request.id == id)-        .ok_or_else(|| "The selected request no longer exists.".to_owned())+        .ok_or_else(|| "The selected request no longer exists.".to_owned())?;+    let headers = read_headers_for_request(&app, &request.name)?;++    Ok(StoredRequest {+        id: request.id,+        name: request.name,+        method: request.method,+        domain: request.domain,+        endpoint: request.endpoint,+        body: request.body,+        headers,+    }) }  #[tauri::command]@@ -88,14 +157,13 @@ fn save_request(     body: String,     headers: Vec<RequestHeader>, ) -> Result<StoredRequest, String> {-    let mut request = StoredRequest {+    let mut request = RequestData {         id,         name: name.trim().to_owned(),         method,         domain: domain.trim().to_owned(),         endpoint: endpoint.trim().to_owned(),         body,-        headers,     };      if request.name.is_empty() {@@ -112,15 +180,46 @@ fn save_request(         );     } -    let mut requests = read_requests(&app)?;+    let mut requests = load_requests(&app)?;+    if requests+        .iter()+        .any(|saved| saved.id != request.id && saved.name == request.name)+    {+        return Err("A request with this name already exists.".to_owned());+    }++    let previous_name = requests+        .iter()+        .find(|saved| saved.id == request.id)+        .map(|saved| saved.name.clone());     if let Some(position) = requests.iter().position(|saved| saved.id == request.id) {         requests[position] = request.clone();     } else {         requests.push(request.clone());     } +    let mut headers_by_name = read_headers(&app)?;+    headers_by_name.insert(request.name.clone(), headers.clone());++    if let Some(previous_name) =+        previous_name.filter(|previous_name| previous_name != &request.name)+    {+        if !requests.iter().any(|saved| saved.name == previous_name) {+            headers_by_name.remove(&previous_name);+        }+    }++    write_headers(&app, &headers_by_name)?;     write_requests(&app, &requests)?;-    Ok(request)+    Ok(StoredRequest {+        id: request.id,+        name: request.name,+        method: request.method,+        domain: request.domain,+        endpoint: request.endpoint,+        body: request.body,+        headers,+    }) }  #[tauri::command]@@ -134,23 +233,39 @@ async fn send_request(     let method = Method::from_bytes(method.as_bytes())         .map_err(|error| format!("Invalid HTTP method: {error}"))?;     let url = build_url(&domain, &endpoint)?;+    let response_url = url.to_string();     let headers = parse_headers(headers)?; -    let response = Client::new()-        .request(method, url)-        .headers(headers)-        .body(body)+    let response_method = method.to_string();+    let method_allows_body = !matches!(method, Method::GET | Method::HEAD);+    let request = Client::new().request(method, url).headers(headers);+    let request = if method_allows_body {+        request.body(body)+    } else {+        request+    };+    let response = request         .send()         .await         .map_err(|error| format!("HTTP request failed: {error}"))?; -    let status = response.status().as_u16();+    let status = response.status();+    let status_text = status+        .canonical_reason()+        .unwrap_or("Unknown status")+        .to_owned();     let body = response         .text()         .await         .map_err(|error| format!("Could not read response body: {error}"))?; -    Ok(PostResponse { status, body })+    Ok(PostResponse {+        method: response_method,+        status: status.as_u16(),+        status_text,+        url: response_url,+        body,+    }) }  fn build_url(domain: &str, endpoint: &str) -> Result<Url, String> {@@ -159,7 +274,8 @@ fn build_url(domain: &str, endpoint: &str) -> Result<Url, String> {         return Err("Domain is required.".to_owned());     } -    let base = Url::parse(domain).map_err(|error| format!("Invalid domain: {error}"))?;+    let base =+        Url::parse(domain).map_err(|_| "Domain must start with https:// or http://.".to_owned())?;     if !matches!(base.scheme(), "http" | "https") || !base.has_host() {         return Err("Domain must be an absolute http or https URL.".to_owned());     }@@ -171,6 +287,10 @@ fn build_url(domain: &str, endpoint: &str) -> Result<Url, String> { fn parse_headers(headers: Vec<RequestHeader>) -> Result<HeaderMap, String> {     let mut header_map = HeaderMap::new();     for header in headers {+        if !header.enabled {+            continue;+        }+         let name = HeaderName::from_bytes(header.key.as_bytes())             .map_err(|error| format!("Invalid header name: {error}"))?;         let value = HeaderValue::from_str(&header.value)@@ -186,7 +306,7 @@ pub fn run() {     tauri::Builder::default()         .plugin(tauri_plugin_opener::init())         .invoke_handler(tauri::generate_handler![-            load_requests,+            load_request_list,             load_request,             save_request,             send_requestdiff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.jsonindex a049d5e..88b1b78 100644--- a/src-tauri/tauri.conf.json+++ b/src-tauri/tauri.conf.json@@ -2,7 +2,7 @@   "$schema": "https://schema.tauri.app/config/2",   "productName": "tauri-app",   "version": "0.1.0",-  "identifier": "tauri-app",+  "identifier": "restingchair",   "build": {     "beforeDevCommand": "npm run build:wasm && npm run dev",     "devUrl": "http://localhost:1420",diff --git a/src/lib.rs b/src/lib.rsindex 37f11cb..8708474 100644--- a/src/lib.rs+++ b/src/lib.rs@@ -13,6 +13,7 @@ thread_local! { struct RequestHeader {     key: String,     value: String,+    enabled: bool, }  #[derive(Clone, Deserialize, Serialize)]@@ -24,9 +25,30 @@ struct StoredRequest {     domain: String,     endpoint: String,     body: String,+    #[serde(default)]     headers: Vec<RequestHeader>, } +#[derive(Deserialize)]+#[serde(rename_all = "camelCase")]+struct RequestSummary {+    id: String,+    name: String,+    method: String,+    domain: String,+    endpoint: String,+}++#[derive(Deserialize)]+#[serde(rename_all = "camelCase")]+struct PostResponse {+    method: String,+    status: u16,+    status_text: String,+    url: String,+    body: String,+}+ #[derive(Serialize)] struct LoadRequestArgs {     id: String,@@ -90,7 +112,7 @@ impl AppComponent {             self.render_request_detail();         } else {             self.render_request_picker();-            load_requests_callback();+            load_requests();         }     } @@ -164,7 +186,8 @@ impl RestCallSetupComponent {             let value = header                 .map(|header| header.value.as_str())                 .unwrap_or_default();-            header_rows.push_str(&header_row(index, key, value));+            let enabled = header.map(|header| header.enabled).unwrap_or(true);+            header_rows.push_str(&header_row(index, key, value, enabled));         }          let request_id = self.attribute("request_id");@@ -185,7 +208,7 @@ impl RestCallSetupComponent {             <main class="container">                 <header class="page-header page-header--split">                     <div>-                        <h1>$request_name_headline</h1>+                        <h1 id="request-title">$request_name_headline</h1>                     </div>                     <button class="button-secondary" type="button" onclick="showRequestPicker()">All requests</button>                 </header>@@ -194,23 +217,23 @@ impl RestCallSetupComponent {                     <input id="request-id-input" type="hidden" value="$request_id" />                     <div class="field">                         <label for="request-name-input">Name</label>-                        <input id="request-name-input" type="text" value="$request_name" placeholder="List users" oninput="markRequestDirty()" />+                        <input id="request-name-input" type="text" value="$request_name" oninput="updateRequest()" />                     </div>                     <div class="field">                         <label for="method-input">Method</label>-                        <select id="method-input" onchange="markRequestDirty()"></select>+                        <select id="method-input" onchange="updateRequest()"></select>                     </div>                     <div class="field">                         <label for="domain-input">Domain</label>-                        <input id="domain-input" type="url" value="$domain" placeholder="https://api.example.com" oninput="markRequestDirty()" />+                        <input id="domain-input" type="url" value="$domain" placeholder="https://api.example.com" oninput="updateRequest()" />                     </div>                     <div class="field">                         <label for="endpoint-input">Endpoint</label>-                        <input id="endpoint-input" type="text" value="$endpoint" placeholder="/v1/resources" oninput="markRequestDirty()" />+                        <input id="endpoint-input" type="text" value="$endpoint" placeholder="/v1/resources" oninput="updateRequest()" />                     </div>-                    <div class="field">+                    <div id="body-field" class="field">                         <label for="body-input">Request body</label>-                        <textarea id="body-input" spellcheck="false" placeholder="{&quot;name&quot;:&quot;Example&quot;}" oninput="markRequestDirty()" onkeydown="handleBodyTab(event)">$body</textarea>+                        <textarea id="body-input" spellcheck="false" placeholder="{&quot;name&quot;:&quot;Example&quot;}" oninput="updateRequest()" onkeydown="handleBodyTab(event)">$body</textarea>                     </div>                     <div class="field">                         <span class="field-label">Headers</span>@@ -222,8 +245,11 @@ impl RestCallSetupComponent {                         <button class="button-secondary" type="button" onclick="postRequest()">Send request</button>                     </div>                 </section>-                <p id="request-status" role="status" aria-live="polite"></p>-                <pre id="request-result"></pre>+                <p id="request-status" class="request-status" role="status" aria-live="polite"></p>+                <p id="request-url" class="request-url"></p>+                <section id="request-response" class="request-response" aria-label="Response" hidden>+                    <pre id="request-result"></pre>+                </section>             </main>              <div id="unsaved-changes-dialog" class="leave-dialog" role="dialog" aria-modal="true" aria-labelledby="unsaved-changes-title" hidden>@@ -248,6 +274,8 @@ impl RestCallSetupComponent {             header_list.set_inner_html(&header_rows);         }         update_header_list_visibility();+        update_request_url();+        update_request_body_visibility();     }      fn headers(&self) -> Vec<RequestHeader> {@@ -275,7 +303,9 @@ fn request_name_or_default(name: &str) -> &str {  fn method_options(selected_method: &str) -> String {     let mut options = String::new();-    for method in ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] {+    for method in [+        "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "QUERY",+    ] {         let selected = if method == selected_method {             "selected"         } else {@@ -301,17 +331,19 @@ fn update_header_list_visibility() {     } } -fn header_row(index: usize, key: &str, value: &str) -> String {+fn header_row(index: usize, key: &str, value: &str, enabled: bool) -> String {+    let checked = if enabled { "checked" } else { "" };     let row = html! {{         <div id="header-row-$index" class="header-row">+            <input id="header-enabled-input-$index" class="header-enabled" type="checkbox" aria-label="Enable header" $checked onchange="updateRequest()" />             <div class="header-fields">                 <div>                     <label for="header-key-input-$index">Key</label>-                    <input id="header-key-input-$index" type="text" value="$key" oninput="markRequestDirty()" />+                    <input id="header-key-input-$index" type="text" value="$key" oninput="updateRequest()" />                 </div>                 <div>                     <label for="header-value-input-$index">Value</label>-                    <input id="header-value-input-$index" type="text" value="$value" oninput="markRequestDirty()" />+                    <input id="header-value-input-$index" type="text" value="$value" oninput="updateRequest()" />                 </div>             </div>             <button class="button-danger" type="button" onclick="removeHeader($index)">Remove</button>@@ -334,6 +366,76 @@ fn request_is_dirty() -> bool {         == Some("true") } +fn set_request_status(message: &str) {+    if let Some(status) = get_element("#request-status") {+        let _ = status.set_attribute("class", "request-status");+    }+    try_inner_text("#request-status", message);+}++fn set_request_error(message: &str) {+    if let Some(status) = get_element("#request-status") {+        let _ = status.set_attribute("class", "request-status request-status-failed");+    }+    try_inner_text("#request-status", message);+}++fn set_request_status_with_code(status_code: u16, message: &str) {+    if let Some(status) = get_element("#request-status") {+        let class = if status_code == 200 {+            "request-status request-status-success"+        } else {+            "request-status request-status-failed"+        };+        let _ = status.set_attribute("class", class);+    }+    try_inner_text("#request-status", &format!("{status_code} {message}"));+}++fn display_request_url(method: &str, domain: &str, endpoint: &str) {+    let domain = domain.trim();+    if domain.is_empty() {+        try_inner_text("#request-url", "");+        return;+    }++    try_inner_text(+        "#request-url",+        &format!("{method} {domain}{}", endpoint.trim()),+    );+}++fn update_request_url() {+    display_request_url(+        &try_get_select_value("#method-input"),+        &try_get_value("#domain-input"),+        &try_get_value("#endpoint-input"),+    );+}++fn update_request_title() {+    let name = try_get_value("#request-name-input");+    let title = if name.trim().is_empty() {+        "None"+    } else {+        name.trim()+    };+    try_inner_text("#request-title", title);+}++fn update_request_body_visibility() {+    let Some(body_field) = get_element("#body-field") else {+        return;+    };+    let method = try_get_select_value("#method-input");++    if matches!(method.as_str(), "GET" | "HEAD") {+        let _ = body_field.set_attribute("hidden", "");+    } else {+        let _ = body_field.remove_attribute("hidden");+    }+}+ fn show_request_picker() {     if let Some(app) = get_element("app-root") {         let _ = app.set_attribute("view", "picker");@@ -359,7 +461,7 @@ fn set_request_attributes(request: &StoredRequest) {     let headers = match serde_json::to_string(&request.headers) {         Ok(headers) => headers,         Err(_) => {-            try_inner_text("#request-status", "Could not prepare request headers.");+            set_request_error("Could not prepare request headers.");             return;         }     };@@ -374,10 +476,10 @@ fn set_request_attributes(request: &StoredRequest) { }  #[callback(loadRequests)]-fn load_requests_callback() {+fn load_requests() {     spawn_local(async {-        match tauri_wasm::invoke("load_requests").await {-            Ok(value) => match serde_wasm_bindgen::from_value::<Vec<StoredRequest>>(value) {+        match tauri_wasm::invoke("load_request_list").await {+            Ok(value) => match serde_wasm_bindgen::from_value::<Vec<RequestSummary>>(value) {                 Ok(requests) => render_request_list(&requests),                 Err(error) => try_inner_text(                     "#request-list",@@ -386,13 +488,13 @@ fn load_requests_callback() {             },             Err(error) => try_inner_text(                 "#request-list",-                &format!("Could not load saved requests: {error:?}"),+                &format!("Could not load saved requests: {error}"),             ),         }     }); } -fn render_request_list(requests: &[StoredRequest]) {+fn render_request_list(requests: &[RequestSummary]) {     let Some(app) = get_element("app-root") else {         return;     };@@ -402,25 +504,17 @@ fn render_request_list(requests: &[StoredRequest]) {     }      if requests.is_empty() {-        try_inner_text(-            "#request-list",-            "No saved requests yet. Create one to get started.",-        );+        try_inner_text("#request-list", "No requests yet.");         return;     }      let mut cards = String::new();      for request in requests {-        let request_id = &request.id;-        let request_name = request_name_or_default(&request.name);-        let request_method = &request.method;-        let request_domain = &request.domain;-        let request_endpoint = &request.endpoint;         let card = html! {{-            <button class="request-card" type="button" data-request-id="$request_id" onclick="openRequest(this.dataset.requestId)">-                <span class="request-card-name">$request_name</span>-                <span class="request-card-details"><strong>$request_method</strong> $request_domain$request_endpoint</span>+            <button class="request-card" type="button" data-request-id="$(request.id)" onclick="openRequest(this.dataset.requestId)">+                <span class="request-card-name">$(request_name_or_default(&request.name))</span>+                <span class="request-card-details"><strong>$(request.method)</strong> $(request.domain)$(request.endpoint)</span>             </button>         }};         cards.push_str(card.as_str());@@ -432,7 +526,7 @@ fn render_request_list(requests: &[StoredRequest]) { }  #[callback(newRequest)]-fn new_request_callback() {+fn new_request() {     set_request_attributes(&StoredRequest {         id: String::new(),         name: String::new(),@@ -447,36 +541,48 @@ fn new_request_callback() {     } } -#[callback(markRequestDirty)]-fn mark_request_dirty_callback() {+#[callback(updateRequest)]+fn update_request() {     set_request_dirty(true);+    update_request_url();+    update_request_title();+    update_request_body_visibility(); }  #[callback(handleBodyTab)]-fn handle_body_tab_callback(event: web_sys::KeyboardEvent) {+fn handle_body_tab(event: web_sys::KeyboardEvent) {     if event.key() != "Tab" {         return;     }      event.prevent_default(); -    let Some(textarea) = get_element("#body-input")-        .and_then(|element| element.dyn_into::<web_sys::HtmlTextAreaElement>().ok())-    else {+    let element = get_element("#body-input");++    if element.is_none() {         return;-    };+    }++    let textarea = element.unwrap().dyn_into::<web_sys::HtmlTextAreaElement>();++    if textarea.is_err() {+        return;+    }++    let textarea = textarea.unwrap();      let start = textarea         .selection_start()         .ok()         .flatten()         .unwrap_or_default();+     let end = textarea.selection_end().ok().flatten().unwrap_or(start);     let _ = textarea.set_range_text_with_start_and_end_and_mode("    ", start, end, "end"); }  #[callback(showRequestPicker)]-fn show_request_picker_callback() {+fn show_request_picker_action() {     if !request_is_dirty() {         show_request_picker();         return;@@ -486,26 +592,23 @@ fn show_request_picker_callback() { }  #[callback(saveAndShowRequestPicker)]-fn save_and_show_request_picker_callback() {+fn save_and_show_request_picker() {     save_request(true); }  #[callback(discardAndShowRequestPicker)]-fn discard_and_show_request_picker_callback() {+fn discard_and_show_request_picker() {     show_request_picker(); }  #[callback(openRequest)]-fn open_request_callback(id: String) {+fn open_request(id: String) {     spawn_local(async move {         let request_args = LoadRequestArgs { id };         let args = match tauri_wasm::args(&request_args) {             Ok(args) => args,             Err(error) => {-                try_inner_text(-                    "#request-list",-                    &format!("Could not open request: {error:?}"),-                );+                try_inner_text("#request-list", &format!("Could not open request: {error}"));                 return;             }         };@@ -523,16 +626,15 @@ fn open_request_callback(id: String) {                     &format!("Could not read request details: {error}"),                 ),             },-            Err(error) => try_inner_text(-                "#request-list",-                &format!("Could not open request: {error:?}"),-            ),+            Err(error) => {+                try_inner_text("#request-list", &format!("Could not open request: {error}"))+            }         }     }); }  #[callback(addHeader)]-fn add_header_callback() {+fn add_header() {     let added = HEADER_ROW_COUNT.with(|count| {         let index = count.get();         let Some(header_list) = get_element("#header-list") else {@@ -540,7 +642,7 @@ fn add_header_callback() {         };          if header_list-            .insert_adjacent_html("beforeend", &header_row(index, "", ""))+            .insert_adjacent_html("beforeend", &header_row(index, "", "", true))             .is_ok()         {             count.set(index + 1);@@ -553,12 +655,12 @@ fn add_header_callback() {     });      if !added {-        try_inner_text("#request-status", "Could not add a header field.");+        set_request_error("Could not add a header field.");     } }  #[callback(removeHeader)]-fn remove_header_callback(index: usize) {+fn remove_header(index: usize) {     let selector = format!("#header-row-{index}");     if let Some(row) = get_element(&selector) {         row.remove();@@ -581,6 +683,13 @@ fn try_get_select_value(selector: &str) -> String {         .unwrap_or_default() } +fn try_get_checkbox_checked(selector: &str) -> bool {+    get_element(selector)+        .and_then(|element| element.dyn_into::<web_sys::HtmlInputElement>().ok())+        .map(|input| input.checked())+        .unwrap_or(true)+}+ fn collect_headers() -> std::result::Result<Vec<RequestHeader>, String> {     HEADER_ROW_COUNT.with(|count| {         let mut headers = Vec::new();@@ -591,14 +700,19 @@ fn collect_headers() -> std::result::Result<Vec<RequestHeader>, String> {             let value = try_get_value(&format!("#header-value-input-{index}"))                 .trim()                 .to_owned();+            let enabled = try_get_checkbox_checked(&format!("#header-enabled-input-{index}"));              if key.is_empty() && value.is_empty() {                 continue;             }-            if key.is_empty() || value.is_empty() {+            if enabled && (key.is_empty() || value.is_empty()) {                 return Err(format!("Header {} needs both a key and value.", index + 1));             }-            headers.push(RequestHeader { key, value });+            headers.push(RequestHeader {+                key,+                value,+                enabled,+            });         }         Ok(headers)     })@@ -616,35 +730,88 @@ fn request_from_form() -> std::result::Result<StoredRequest, String> {     }) } +fn clear_request_result() {+    if let Some(result) = get_element("#request-result") {+        let height = result.client_height();+        if height > 0 {+            let _ = result.set_attribute("style", &format!("min-height: {height}px"));+        }+    }+    if let Some(response) = get_element("#request-response") {+        let _ = response.set_attribute("class", "request-response request-response-empty");+        let _ = response.set_attribute("aria-hidden", "true");+    }+    try_inner_text("#request-result", "");+}++fn format_response_body(body: &str) -> String {+    serde_json::from_str::<serde_json::Value>(body)+        .and_then(|value| serde_json::to_string_pretty(&value))+        .unwrap_or_else(|_| body.to_owned())+}++fn render_request_response(response: PostResponse) {+    try_inner_text(+        "#request-url",+        &format!("{} {}", response.method, response.url),+    );++    if response.body.trim().is_empty() {+        clear_request_result();+        return;+    }++    if let Some(response_container) = get_element("#request-response") {+        let _ = response_container.remove_attribute("hidden");+        let _ = response_container.set_attribute("class", "request-response");+        let _ = response_container.remove_attribute("aria-hidden");+    }++    try_inner_text("#request-result", &format_response_body(&response.body));+    if let Some(result) = get_element("#request-result") {+        let _ = result.remove_attribute("style");+    }+}+ #[callback(saveRequest)]-fn save_request_callback() {+fn save_request_action() {+    save_request(false);+}++#[callback(saveRequestShortcut)]+fn save_request_shortcut(event: web_sys::KeyboardEvent) {+    if !(event.ctrl_key() || event.meta_key())+        || !event.key().eq_ignore_ascii_case("s")+        || get_element("#request-name-input").is_none()+    {+        return;+    }++    event.prevent_default();     save_request(false); }  fn save_request(return_to_picker: bool) {+    if try_get_value("#request-name-input").trim().is_empty() {+        set_request_error("Enter a request name before saving.");+        return;+    }+     let request = match request_from_form() {         Ok(request) => request,         Err(message) => {-            try_inner_text("#request-status", &message);+            set_request_error(&message);             return;         }     }; -    if request.name.is_empty() {-        try_inner_text("#request-status", "Enter a request name before saving.");-        return;-    }--    try_inner_text("#request-status", "Saving request");+    set_request_status("Saving request");      spawn_local(async move {         let args = match tauri_wasm::args(&request) {             Ok(args) => args,             Err(error) => {-                try_inner_text(-                    "#request-status",-                    &format!("Could not prepare request: {error:?}"),-                );+                set_request_error(&format!("Could not prepare request: {error}"));                 return;             }         };@@ -657,67 +824,54 @@ fn save_request(return_to_picker: bool) {                         let _ = id.set_attribute("value", &saved_request.id);                     }                     set_request_dirty(false);-                    try_inner_text("#request-status", "Request saved.");+                    clear_request_result();+                    set_request_status("Request saved.");                     if return_to_picker {                         show_request_picker();                     }                 }-                Err(error) => try_inner_text(-                    "#request-status",-                    &format!("Could not read saved request: {error}"),-                ),+                Err(error) => set_request_error(&format!("Could not read saved request: {error}")),             },-            Err(error) => try_inner_text(-                "#request-status",-                &format!("Could not save request: {error:?}"),-            ),+            Err(error) => set_request_error(&format!("Could not save request: {error}")),         }     }); }  #[callback(postRequest)]-fn post_request_callback() {+fn post_request() {     let payload = match request_from_form() {         Ok(request) => request,         Err(message) => {-            try_inner_text("#request-status", &message);+            set_request_error(&message);             return;         }     };+    display_request_url(&payload.method, &payload.domain, &payload.endpoint);+     if payload.domain.is_empty() {-        try_inner_text("#request-status", "Enter a domain to send the request.");+        set_request_error("Enter a domain to send the request.");         return;     } -    try_inner_text(-        &"#request-status",-        &format!("Sending {} request…", payload.method),-    );-    try_inner_text("#request-result", "");+    set_request_status(&format!("Sending {} request…", payload.method));     spawn_local(async move {         let args = match tauri_wasm::args(&payload) {             Ok(args) => args,             Err(error) => {-                try_inner_text(-                    "#request-status",-                    &format!("Could not prepare request: {error:?}"),-                );+                set_request_error(&format!("Could not prepare request: {error}"));                 return;             }         };          match tauri_wasm::invoke("send_request").with_args(args).await {-            Ok(value) => match js_sys::JSON::stringify(&value) {+            Ok(value) => match serde_wasm_bindgen::from_value::<PostResponse>(value) {                 Ok(response) => {-                    try_inner_text("#request-status", "Request completed.");-                    try_inner_text("#request-result", &response.as_string().unwrap_or_default());+                    set_request_status_with_code(response.status, &response.status_text);+                    render_request_response(response);                 }-                Err(error) => try_inner_text(-                    "#request-status",-                    &format!("Could not read response: {error:?}"),-                ),+                Err(error) => set_request_error(&format!("Could not read response: {error}")),             },-            Err(error) => try_inner_text("#request-status", &format!("Request failed: {error:?}")),+            Err(error) => set_request_error(&error.to_string()),         }     }); }@@ -725,4 +879,12 @@ fn post_request_callback() { #[wasm_bindgen(start)] pub fn init() {     init_callbacks();++    let Some(window) = web_sys::window() else {+        return;+    };+    let Some(callback) = get_callback("saveRequestShortcut") else {+        return;+    };+    let _ = window.add_event_listener_with_callback("keydown", &callback); }diff --git a/src/styles.css b/src/styles.cssindex 68016b3..790e60e 100644--- a/src/styles.css+++ b/src/styles.css@@ -105,6 +105,10 @@ h1 {   gap: 7px; } +#body-field[hidden] {+  display: none;+}+ .field label, .field-label {   font-size: 0.9rem;@@ -127,7 +131,7 @@ h1 {  .header-row {   display: grid;-  grid-template-columns: minmax(0, 1fr) auto;+  grid-template-columns: auto minmax(0, 1fr) auto;   align-items: end;   gap: 16px;   padding-bottom: 16px;@@ -143,6 +147,45 @@ h1 {   margin: 0 0 1px; } +.header-enabled {+  appearance: none;+  display: grid;+  width: 18px;+  height: 18px;+  margin: 0 0 11px;+  padding: 0;+  cursor: pointer;+  place-content: center;+  background: #ffffff;+  border: 1px solid #93c5fd;+  border-radius: 4px;+}++.header-enabled:checked {+  background: #2563eb;+  border-color: #2563eb;+}++.header-enabled:checked::after {+  width: 4px;+  height: 8px;+  content: "";+  border: solid #ffffff;+  border-width: 0 2px 2px 0;+  transform: rotate(45deg) translateY(-1px);+}++.header-enabled:focus-visible {+  outline: 3px solid rgba(37, 99, 235, 0.35);+  outline-offset: 2px;+}++.header-row:has(.header-enabled:not(:checked)) .header-fields input {+  color: #64748b;+  background: #e2e8f0;+  border-color: #cbd5e1;+}+ .header-fields {   display: grid;   grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);@@ -290,21 +333,69 @@ button:focus-visible {   margin-top: 24px; } -#request-status {-  min-height: 24px;+.request-status {+  display: flex;+  align-items: center;+  min-height: 20px;   margin: 16px 0 0;+  padding: 10px 14px;   color: #475569;+  background: #f8fafc;+  border: 1px solid #e2e8f0;+  border-radius: 8px;+}++.request-status:empty {+  display: none;+}++.request-status-success {+  color: #ffffff;+  background: #16a34a;+  font-weight: 700;+  border-color: #16a34a;+}++.request-url {+  margin: 8px 0 0;+  color: #64748b;+  font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;+  font-size: 0.875rem;+  overflow-wrap: anywhere;+}++.request-url:empty {+  display: none;+}++.request-status-failed {+  color: #ffffff;+  background: #dc2626;+  font-weight: 700;+  border-color: #dc2626;+}++.request-response {+  margin-top: 12px;+  background: #eef2f7;+  border: 1px solid #e2e8f0;+  border-radius: 8px;+}++.request-response[hidden] {+  display: none;+}++.request-response-empty {+  visibility: hidden; }  #request-result {-  max-height: 280px;-  margin: 12px 0 0;+  box-sizing: border-box;+  margin: 0;   padding: 14px;-  overflow: auto;   color: #334155;   background: #eef2f7;-  border: 1px solid #e2e8f0;-  border-radius: 8px;   text-align: left;   white-space: pre-wrap;   overflow-wrap: anywhere;@@ -318,11 +409,32 @@ button:focus-visible {    .page-header p,   .field small,-  #request-status,   .request-card-details {     color: #94a3b8;   } +  .request-status {+    color: #94a3b8;+    background: #182233;+    border-color: #334155;+  }++  .request-status-success {+    color: #ffffff;+    background: #16a34a;+    border-color: #16a34a;+  }++  .request-url {+    color: #94a3b8;+  }++  .request-status-failed {+    color: #ffffff;+    background: #dc2626;+    border-color: #dc2626;+  }+   .request-form,   .request-picker {     background: #1f2937;@@ -357,6 +469,17 @@ button:focus-visible {     border-color: #334155;   } +  .header-enabled {+    background: #1f2937;+    border-color: #3b82f6;+  }++  .header-row:has(.header-enabled:not(:checked)) .header-fields input {+    color: #94a3b8;+    background: #263445;+    border-color: #475569;+  }+   .button-secondary {     color: #bfdbfe;     background: #1f2937;@@ -373,6 +496,7 @@ button:focus-visible {     color: #94a3b8;   } +  .request-response,   #request-result {     color: #cbd5e1;     background: #111827;

Contact

Email: johan.mattsson.m@gmail.com
Mastodon: typo.social/@birdfont
GitHub: johanmattssonm
LinkedIn: johan-mattsson-2ab324112