Clone in a terminal:
Commit c1551d2
Johan Mattsson - 2026-11-02T12:42:47+00:00
c1551d2072e907a085aa32bf84f7158c8acfbe65johan.mattsson.m@gmail.com

Fix setup and add save dialog

Changed files (6)

Diff

diff --git a/Cargo.lock b/Cargo.lockindex 761f624..f7c09b8 100644--- a/Cargo.lock+++ b/Cargo.lock@@ -681,6 +681,8 @@ dependencies = [  "js-sys",  "kaja-web",  "serde",+ "serde-wasm-bindgen 0.6.5",+ "serde_json",  "tauri-wasm",  "wasm-bindgen",  "wasm-bindgen-futures",diff --git a/Cargo.toml b/Cargo.tomlindex 2200d92..871b189 100644--- a/Cargo.toml+++ b/Cargo.toml@@ -10,6 +10,8 @@ crate-type = ["cdylib"] [dependencies] kaja-web = "1.0.7" serde = { version = "1", features = ["derive"] }+serde_json = "1"+serde-wasm-bindgen = "0.6" tauri-wasm = { version = "0.2", features = ["serde"] } wasm-bindgen = "0.2" wasm-bindgen-futures = "0.4"@@ -22,6 +24,7 @@ web-sys = { version = "0.3", features = [     "HtmlTextAreaElement",     "HtmlSelectElement",     "NodeList",+    "KeyboardEvent",     "CustomElementRegistry",     "ElementDefinitionOptions", ] }diff --git a/index.html b/index.htmlindex 270294f..aad9e4c 100644--- a/index.html+++ b/index.html@@ -12,6 +12,6 @@   </head>    <body>-      <rest-call-app></rest-call-app>+      <app-root></app-root>   </body> </html>diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rsindex bba41f4..40feaf6 100644--- a/src-tauri/src/lib.rs+++ b/src-tauri/src/lib.rs@@ -1,21 +1,128 @@+use std::{fs, io, path::PathBuf};+ use reqwest::{     Client, Method, Url,     header::{HeaderMap, HeaderName, HeaderValue}, }; use serde::{Deserialize, Serialize};+use tauri::{AppHandle, Manager}; -#[derive(Deserialize)]+#[derive(Clone, Deserialize, Serialize)] struct RequestHeader {     key: String,     value: String, } +#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase")]+struct StoredRequest {+    id: String,+    name: String,+    method: String,+    domain: String,+    endpoint: String,+    body: String,+    headers: Vec<RequestHeader>,+}++const REQUESTS_FILE_NAME: &str = "requests.json";+ #[derive(Serialize)] struct PostResponse {     status: u16,     body: String, } +fn requests_file_path(app: &AppHandle) -> Result<PathBuf, String> {+    let app_data_dir = app+        .path()+        .app_data_dir()+        .map_err(|error| format!("Could not find the application data directory: {error}"))?;++    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))+}++fn read_requests(app: &AppHandle) -> Result<Vec<StoredRequest>, String> {+    let path = requests_file_path(app)?;+    let contents = match fs::read_to_string(path) {+        Ok(contents) => contents,+        Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),+        Err(error) => return Err(format!("Could not read saved requests: {error}")),+    };++    serde_json::from_str(&contents)+        .map_err(|error| format!("Saved requests file is invalid JSON: {error}"))+}++fn write_requests(app: &AppHandle, requests: &[StoredRequest]) -> 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}"))+}++#[tauri::command]+fn load_requests(app: AppHandle) -> Result<Vec<StoredRequest>, String> {+    read_requests(&app)+}++#[tauri::command]+fn load_request(app: AppHandle, id: String) -> Result<StoredRequest, String> {+    read_requests(&app)?+        .into_iter()+        .find(|request| request.id == id)+        .ok_or_else(|| "The selected request no longer exists.".to_owned())+}++#[tauri::command]+fn save_request(+    app: AppHandle,+    id: String,+    name: String,+    method: String,+    domain: String,+    endpoint: String,+    body: String,+    headers: Vec<RequestHeader>,+) -> Result<StoredRequest, String> {+    let mut request = StoredRequest {+        id,+        name: name.trim().to_owned(),+        method,+        domain: domain.trim().to_owned(),+        endpoint: endpoint.trim().to_owned(),+        body,+        headers,+    };++    if request.name.is_empty() {+        return Err("A request name is required.".to_owned());+    }++    if request.id.is_empty() {+        request.id = format!(+            "request-{}",+            std::time::SystemTime::now()+                .duration_since(std::time::UNIX_EPOCH)+                .map_err(|error| format!("Could not generate a request ID: {error}"))?+                .as_millis()+        );+    }++    let mut requests = read_requests(&app)?;+    if let Some(position) = requests.iter().position(|saved| saved.id == request.id) {+        requests[position] = request.clone();+    } else {+        requests.push(request.clone());+    }++    write_requests(&app, &requests)?;+    Ok(request)+}+ #[tauri::command] async fn send_request(     method: String,@@ -78,7 +185,12 @@ fn parse_headers(headers: Vec<RequestHeader>) -> Result<HeaderMap, String> { pub fn run() {     tauri::Builder::default()         .plugin(tauri_plugin_opener::init())-        .invoke_handler(tauri::generate_handler![send_request])+        .invoke_handler(tauri::generate_handler![+            load_requests,+            load_request,+            save_request,+            send_request+        ])         .run(tauri::generate_context!())         .expect("error while running tauri application"); }diff --git a/src/lib.rs b/src/lib.rsindex cc89ce9..37f11cb 100644--- a/src/lib.rs+++ b/src/lib.rs@@ -1,6 +1,7 @@ use std::cell::Cell;  use kaja_web::prelude::*;+use serde::{Deserialize, Serialize}; use wasm_bindgen::{JsCast, prelude::wasm_bindgen}; use wasm_bindgen_futures::spawn_local; @@ -8,14 +9,17 @@ thread_local! {     static HEADER_ROW_COUNT: Cell<usize> = const { Cell::new(1) }; } -#[derive(serde::Serialize)]+#[derive(Clone, Deserialize, Serialize)] struct RequestHeader {     key: String,     value: String, } -#[derive(serde::Serialize)]-struct PostRequestArgs {+#[derive(Clone, Deserialize, Serialize)]+#[serde(rename_all = "camelCase")]+struct StoredRequest {+    id: String,+    name: String,     method: String,     domain: String,     endpoint: String,@@ -23,116 +27,525 @@ struct PostRequestArgs {     headers: Vec<RequestHeader>, } -#[component("rest-call-app")]-struct RestCallApp {}+#[derive(Serialize)]+struct LoadRequestArgs {+    id: String,+}++#[component("app-root")]+struct App {+    #[attribute]+    view: String,+    #[attribute]+    request_id: String,+    #[attribute]+    request_name: String,+    #[attribute]+    request_method: String,+    #[attribute]+    request_domain: String,+    #[attribute]+    request_endpoint: String,+    #[attribute]+    request_body: String,+    #[attribute]+    request_headers: String,+}++#[component("rest-call-setup")]+struct RestCallSetup {+    #[attribute]+    request_id: String,+    #[attribute]+    request_name: String,+    #[attribute]+    request_method: String,+    #[attribute]+    request_domain: String,+    #[attribute]+    request_endpoint: String,+    #[attribute]+    request_body: String,+    #[attribute]+    request_headers: String,+    #[attribute]+    dirty: bool,+} -impl Component for RestCallAppComponent {+impl Component for AppComponent {     fn connected(&self) {         self.render();     }++    fn attribute_changed(&self, name: &str, _old_value: &str, _new_value: &str) {+        if name == "view" {+            self.render();+        }+    } } -impl RestCallAppComponent {+impl AppComponent {     fn render(&self) {-        let id = self.get_id();+        if self.attribute("view") == "detail" {+            self.render_request_detail();+        } else {+            self.render_request_picker();+            load_requests_callback();+        }+    }++    fn render_request_picker(&self) {+        let content = r#"+            <main class="container">+                <header class="page-header page-header--split">+                    <div>+                        <h1>REST Call</h1>+                        <p>Choose a saved request or create a new one.</p>+                    </div>+                    <button type="button" onclick="newRequest()">New request</button>+                </header>+                <section class="request-picker" aria-label="Saved requests">+                    <div id="request-list" class="request-list" role="status" aria-live="polite">Loading saved requests…</div>+                </section>+            </main>+        "#;+        self.set_content(content);+    }++    fn render_request_detail(&self) {+        let request_id = self.attribute("request_id");+        let request_name = self.attribute("request_name");+        let request_method = self.attribute("request_method");+        let request_domain = self.attribute("request_domain");+        let request_endpoint = self.attribute("request_endpoint");+        let request_body = self.attribute("request_body");+        let request_headers = self.attribute("request_headers");+        let content = html! {{+            <rest-call-setup+                request_id="$request_id"+                request_name="$request_name"+                request_method="$request_method"+                request_domain="$request_domain"+                request_endpoint="$request_endpoint"+                request_body="$request_body"+                request_headers="$request_headers">+            </rest-call-setup>+        }};+        self.set_content(content.as_str());+    }++    fn attribute(&self, name: &str) -> String {+        self.get_attribute_string(name).unwrap_or_default()+    }++    fn set_content(&self, content: &str) {+        if let Some(element) = get_element_by_id(&self.get_id()) {+            element.set_inner_html(content);+        }+    }+}++impl Component for RestCallSetupComponent {+    fn connected(&self) {+        self.render();+    }+}++impl RestCallSetupComponent {+    fn render(&self) {+        let headers = self.headers();+        let row_count = headers.len();+        HEADER_ROW_COUNT.with(|count| count.set(row_count));++        let mut header_rows = String::new();+        for index in 0..row_count {+            let header = headers.get(index);+            let key = header.map(|header| header.key.as_str()).unwrap_or_default();+            let value = header+                .map(|header| header.value.as_str())+                .unwrap_or_default();+            header_rows.push_str(&header_row(index, key, value));+        }++        let request_id = self.attribute("request_id");+        let request_name = self.attribute("request_name");+        let request_name_headline = if request_name.trim().is_empty() {+            "None"+        } else {+            &request_name+        };+        let method = self.attribute("request_method");+        let domain = self.attribute("request_domain");+        let endpoint = self.attribute("request_endpoint");+        let body = self.attribute("request_body");+        let method = if method.is_empty() { "GET" } else { &method };+        let method_options = method_options(method);          let content = html! {{             <main class="container">-                <header class="page-header">-                    <h1>REST Call</h1>-                    <p>Send a REST request to any HTTP server.</p>+                <header class="page-header page-header--split">+                    <div>+                        <h1>$request_name_headline</h1>+                    </div>+                    <button class="button-secondary" type="button" onclick="showRequestPicker()">All requests</button>                 </header> -                <section class="request-form" aria-label="POST request">+                <section class="request-form" aria-label="Request details">+                    <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()" />+                    </div>                     <div class="field">                         <label for="method-input">Method</label>-                        <select id="method-input">-                            <option value="GET" selected>GET</option>-                            <option value="POST">POST</option>-                            <option value="PUT">PUT</option>-                            <option value="PATCH">PATCH</option>-                            <option value="DELETE">DELETE</option>-                            <option value="HEAD">HEAD</option>-                            <option value="OPTIONS">OPTIONS</option>-                        </select>+                        <select id="method-input" onchange="markRequestDirty()"></select>                     </div>-                     <div class="field">                         <label for="domain-input">Domain</label>-                        <input id="domain-input" type="url" placeholder="https://api.example.com" />+                        <input id="domain-input" type="url" value="$domain" placeholder="https://api.example.com" oninput="markRequestDirty()" />                     </div>-                     <div class="field">                         <label for="endpoint-input">Endpoint</label>-                        <input id="endpoint-input" type="text" placeholder="/v1/resources" />+                        <input id="endpoint-input" type="text" value="$endpoint" placeholder="/v1/resources" oninput="markRequestDirty()" />                     </div>-                     <div class="field">                         <label for="body-input">Request body</label>-                        <textarea id="body-input" spellcheck="false" placeholder="{&quot;name&quot;:&quot;Example&quot;}" onkeydown="if (event.key === 'Tab') { event.preventDefault(); this.setRangeText('    ', this.selectionStart, this.selectionEnd, 'end'); }"></textarea>-                        <small>Press Tab to insert four spaces.</small>+                        <textarea id="body-input" spellcheck="false" placeholder="{&quot;name&quot;:&quot;Example&quot;}" oninput="markRequestDirty()" onkeydown="handleBodyTab(event)">$body</textarea>                     </div>-                     <div class="field">                         <span class="field-label">Headers</span>-                        <div id="header-list" class="header-list">-                            <div id="header-row-0" class="header-row">-                                <div class="header-fields">-                                    <div>-                                        <label for="header-key-input-0">Key</label>-                                        <input id="header-key-input-0" type="text" placeholder="Content-Type" />-                                    </div>-                                    <div>-                                        <label for="header-value-input-0">Value</label>-                                        <input id="header-value-input-0" type="text" placeholder="application/json" />-                                    </div>-                                </div>-                                <button onclick="removeHeader(0)">Remove</button>-                            </div>-                        </div>-                        <button onclick="addHeader()">Add header</button>+                        <div id="header-list" class="header-list"></div>+                        <button class="button-secondary" type="button" onclick="addHeader()">Add header</button>+                    </div>+                    <div class="form-actions">+                        <button type="button" onclick="saveRequest()">Save request</button>+                        <button class="button-secondary" type="button" onclick="postRequest()">Send request</button>                     </div>--                    <button onclick="postRequest()">Send request</button>                 </section>-                 <p id="request-status" role="status" aria-live="polite"></p>                 <pre id="request-result"></pre>             </main>++            <div id="unsaved-changes-dialog" class="leave-dialog" role="dialog" aria-modal="true" aria-labelledby="unsaved-changes-title" hidden>+                <div class="leave-dialog-panel">+                    <p class="eyebrow">Unsaved changes</p>+                    <h2 id="unsaved-changes-title">Save this request before leaving?</h2>+                    <p>Your changes will be lost if you discard them.</p>+                    <div class="leave-dialog-actions">+                        <button type="button" onclick="saveAndShowRequestPicker()">Save</button>+                        <button class="button-danger" type="button" onclick="discardAndShowRequestPicker()">Discard changes</button>+                    </div>+                </div>+            </div>         }}; -        if let Some(element) = get_element_by_id(&id) {-            element.set_inner_html(content.as_str());+        self.set_content(content.as_str());++        if let Some(method_input) = get_element("#method-input") {+            method_input.set_inner_html(&method_options);+        }+        if let Some(header_list) = get_element("#header-list") {+            header_list.set_inner_html(&header_rows);+        }+        update_header_list_visibility();+    }++    fn headers(&self) -> Vec<RequestHeader> {+        serde_json::from_str(&self.attribute("request_headers")).unwrap_or_default()+    }++    fn attribute(&self, name: &str) -> String {+        self.get_attribute_string(name).unwrap_or_default()+    }++    fn set_content(&self, content: &str) {+        if let Some(element) = get_element_by_id(&self.get_id()) {+            element.set_inner_html(content);         }     } } -#[callback(addHeader)]-fn add_header_callback() {-    let added = HEADER_ROW_COUNT.with(|count| {-        let index = count.get();-        let row = html! {{-            <div id="header-row-$index" class="header-row">-                <div class="header-fields">-                    <div>-                        <label for="header-key-input-$index">Key</label>-                        <input id="header-key-input-$index" type="text">-                    </div>-                    <div>-                        <label for="header-value-input-$index">Value</label>-                        <input id="header-value-input-$index" type="text">-                    </div>+fn request_name_or_default(name: &str) -> &str {+    if name.trim().is_empty() {+        "New request"+    } else {+        name+    }+}++fn method_options(selected_method: &str) -> String {+    let mut options = String::new();+    for method in ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] {+        let selected = if method == selected_method {+            "selected"+        } else {+            ""+        };+        let option = html! {{+            <option value="$method" $selected>$method</option>+        }};+        options.push_str(option.as_str());+    }+    options+}++fn update_header_list_visibility() {+    let Some(header_list) = get_element("#header-list") else {+        return;+    };++    if get_element(".header-row").is_some() {+        let _ = header_list.remove_attribute("hidden");+    } else {+        let _ = header_list.set_attribute("hidden", "");+    }+}++fn header_row(index: usize, key: &str, value: &str) -> String {+    let row = html! {{+        <div id="header-row-$index" class="header-row">+            <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()" />+                </div>+                <div>+                    <label for="header-value-input-$index">Value</label>+                    <input id="header-value-input-$index" type="text" value="$value" oninput="markRequestDirty()" />                 </div>-                <button onclick="removeHeader($index)">Remove</button>             </div>+            <button class="button-danger" type="button" onclick="removeHeader($index)">Remove</button>+        </div>+    }};++    row.as_str().to_owned()+}++fn set_request_dirty(dirty: bool) {+    if let Some(request_app) = get_element("rest-call-setup") {+        let _ = request_app.set_attribute("dirty", if dirty { "true" } else { "false" });+    }+}++fn request_is_dirty() -> bool {+    get_element("rest-call-setup")+        .and_then(|request_app| request_app.get_attribute("dirty"))+        .as_deref()+        == Some("true")+}++fn show_request_picker() {+    if let Some(app) = get_element("app-root") {+        let _ = app.set_attribute("view", "picker");+    }+}++fn set_unsaved_changes_dialog_visible(visible: bool) {+    let Some(dialog) = get_element("#unsaved-changes-dialog") else {+        return;+    };++    if visible {+        let _ = dialog.remove_attribute("hidden");+    } else {+        let _ = dialog.set_attribute("hidden", "");+    }+}++fn set_request_attributes(request: &StoredRequest) {+    let Some(app) = get_element("app-root") else {+        return;+    };+    let headers = match serde_json::to_string(&request.headers) {+        Ok(headers) => headers,+        Err(_) => {+            try_inner_text("#request-status", "Could not prepare request headers.");+            return;+        }+    };++    let _ = app.set_attribute("request_id", &request.id);+    let _ = app.set_attribute("request_name", &request.name);+    let _ = app.set_attribute("request_method", &request.method);+    let _ = app.set_attribute("request_domain", &request.domain);+    let _ = app.set_attribute("request_endpoint", &request.endpoint);+    let _ = app.set_attribute("request_body", &request.body);+    let _ = app.set_attribute("request_headers", &headers);+}++#[callback(loadRequests)]+fn load_requests_callback() {+    spawn_local(async {+        match tauri_wasm::invoke("load_requests").await {+            Ok(value) => match serde_wasm_bindgen::from_value::<Vec<StoredRequest>>(value) {+                Ok(requests) => render_request_list(&requests),+                Err(error) => try_inner_text(+                    "#request-list",+                    &format!("Could not read saved requests: {error}"),+                ),+            },+            Err(error) => try_inner_text(+                "#request-list",+                &format!("Could not load saved requests: {error:?}"),+            ),+        }+    });+}++fn render_request_list(requests: &[StoredRequest]) {+    let Some(app) = get_element("app-root") else {+        return;+    };++    if app.get_attribute("view").as_deref() == Some("detail") {+        return;+    }++    if requests.is_empty() {+        try_inner_text(+            "#request-list",+            "No saved requests yet. Create one to get started.",+        );+        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>         }};+        cards.push_str(card.as_str());+    }++    if let Some(list) = get_element("#request-list") {+        list.set_inner_html(&cards);+    }+}++#[callback(newRequest)]+fn new_request_callback() {+    set_request_attributes(&StoredRequest {+        id: String::new(),+        name: String::new(),+        method: "GET".to_owned(),+        domain: String::new(),+        endpoint: String::new(),+        body: String::new(),+        headers: Vec::new(),+    });+    if let Some(app) = get_element("app-root") {+        let _ = app.set_attribute("view", "detail");+    }+}++#[callback(markRequestDirty)]+fn mark_request_dirty_callback() {+    set_request_dirty(true);+}++#[callback(handleBodyTab)]+fn handle_body_tab_callback(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 {+        return;+    };++    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() {+    if !request_is_dirty() {+        show_request_picker();+        return;+    }++    set_unsaved_changes_dialog_visible(true);+}++#[callback(saveAndShowRequestPicker)]+fn save_and_show_request_picker_callback() {+    save_request(true);+}++#[callback(discardAndShowRequestPicker)]+fn discard_and_show_request_picker_callback() {+    show_request_picker();+}++#[callback(openRequest)]+fn open_request_callback(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:?}"),+                );+                return;+            }+        };++        match tauri_wasm::invoke("load_request").with_args(args).await {+            Ok(value) => match serde_wasm_bindgen::from_value::<StoredRequest>(value) {+                Ok(request) => {+                    set_request_attributes(&request);+                    if let Some(app) = get_element("app-root") {+                        let _ = app.set_attribute("view", "detail");+                    }+                }+                Err(error) => try_inner_text(+                    "#request-list",+                    &format!("Could not read request details: {error}"),+                ),+            },+            Err(error) => try_inner_text(+                "#request-list",+                &format!("Could not open request: {error:?}"),+            ),+        }+    });+} +#[callback(addHeader)]+fn add_header_callback() {+    let added = HEADER_ROW_COUNT.with(|count| {+        let index = count.get();         let Some(header_list) = get_element("#header-list") else {             return false;         }; -        if header_list.insert_adjacent_html("beforeend", &row).is_ok() {+        if header_list+            .insert_adjacent_html("beforeend", &header_row(index, "", ""))+            .is_ok()+        {             count.set(index + 1);+            update_header_list_visibility();+            set_request_dirty(true);             true         } else {             false@@ -149,6 +562,8 @@ fn remove_header_callback(index: usize) {     let selector = format!("#header-row-{index}");     if let Some(row) = get_element(&selector) {         row.remove();+        update_header_list_visibility();+        set_request_dirty(true);     } } @@ -169,7 +584,6 @@ fn try_get_select_value(selector: &str) -> String { fn collect_headers() -> std::result::Result<Vec<RequestHeader>, String> {     HEADER_ROW_COUNT.with(|count| {         let mut headers = Vec::new();-         for index in 0..count.get() {             let key = try_get_value(&format!("#header-key-input-{index}"))                 .trim()@@ -181,54 +595,112 @@ fn collect_headers() -> std::result::Result<Vec<RequestHeader>, String> {             if key.is_empty() && value.is_empty() {                 continue;             }-             if key.is_empty() || value.is_empty() {                 return Err(format!("Header {} needs both a key and value.", index + 1));             }-             headers.push(RequestHeader { key, value });         }-         Ok(headers)     }) } -#[callback(postRequest)]-fn post_request_callback() {-    let headers = match collect_headers() {-        Ok(headers) => headers,+fn request_from_form() -> std::result::Result<StoredRequest, String> {+    Ok(StoredRequest {+        id: try_get_value("#request-id-input"),+        name: try_get_value("#request-name-input").trim().to_owned(),+        method: try_get_select_value("#method-input"),+        domain: try_get_value("#domain-input").trim().to_owned(),+        endpoint: try_get_value("#endpoint-input").trim().to_owned(),+        body: try_get_textarea_value("#body-input"),+        headers: collect_headers()?,+    })+}++#[callback(saveRequest)]+fn save_request_callback() {+    save_request(false);+}++fn save_request(return_to_picker: bool) {+    let request = match request_from_form() {+        Ok(request) => request,         Err(message) => {             try_inner_text("#request-status", &message);             return;         }     }; -    let payload = PostRequestArgs {-        method: try_get_select_value("#method-input"),-        domain: try_get_value("#domain-input").trim().to_owned(),-        endpoint: try_get_value("#endpoint-input").trim().to_owned(),-        body: try_get_textarea_value("#body-input"),-        headers,-    };+    if request.name.is_empty() {+        try_inner_text("#request-status", "Enter a request name before saving.");+        return;+    }++    try_inner_text("#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:?}"),+                );+                return;+            }+        };++        match tauri_wasm::invoke("save_request").with_args(args).await {+            Ok(value) => match serde_wasm_bindgen::from_value::<StoredRequest>(value) {+                Ok(saved_request) => {+                    set_request_attributes(&saved_request);+                    if let Some(id) = get_element("#request-id-input") {+                        let _ = id.set_attribute("value", &saved_request.id);+                    }+                    set_request_dirty(false);+                    try_inner_text("#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) => try_inner_text(+                "#request-status",+                &format!("Could not save request: {error:?}"),+            ),+        }+    });+}++#[callback(postRequest)]+fn post_request_callback() {+    let payload = match request_from_form() {+        Ok(request) => request,+        Err(message) => {+            try_inner_text("#request-status", &message);+            return;+        }+    };     if payload.domain.is_empty() {         try_inner_text("#request-status", "Enter a domain to send the request.");         return;     }      try_inner_text(-        "#request-status",+        &"#request-status",         &format!("Sending {} request…", payload.method),     );     try_inner_text("#request-result", "");-     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),+                    &format!("Could not prepare request: {error:?}"),                 );                 return;             }@@ -242,12 +714,10 @@ fn post_request_callback() {                 }                 Err(error) => try_inner_text(                     "#request-status",-                    &format!("Could not read response: {:?}", error),+                    &format!("Could not read response: {error:?}"),                 ),             },-            Err(error) => {-                try_inner_text("#request-status", &format!("Request failed: {:?}", error))-            }+            Err(error) => try_inner_text("#request-status", &format!("Request failed: {error:?}")),         }     }); }diff --git a/src/styles.css b/src/styles.cssindex f0874ed..68016b3 100644--- a/src/styles.css+++ b/src/styles.css@@ -14,15 +14,22 @@ body { }  .container {-  max-width: 680px;+  max-width: 760px;   margin: 0 auto;-  padding: 72px 24px;+  padding: 48px 30px; }  .page-header {   margin-bottom: 28px; } +.page-header--split {+  display: flex;+  align-items: flex-start;+  justify-content: space-between;+  gap: 20px;+}+ h1 {   margin: 0;   font-size: 2rem;@@ -35,16 +42,64 @@ h1 {   color: #64748b; } -.request-form {+.eyebrow {+  margin: 0 0 6px !important;+  color: #2563eb !important;+  font-size: 0.78rem;+  font-weight: 700;+  letter-spacing: 0.08em;+  text-transform: uppercase;+}++.request-form,+.request-picker {   display: grid;   gap: 20px;-  padding: 28px;   background: #ffffff;   border: 1px solid #e2e8f0;+  padding: 25px 25px 18px 26px;   border-radius: 12px;   box-shadow: 0 8px 24px rgba(15, 23, 42, 0.06); } +.request-list {+  display: grid;+  gap: 12px;+  padding-right: 15px;+}++.request-card {+  display: grid;+  justify-self: stretch;+  gap: 5px;+  width: 100%;+  padding: 16px;+  color: #1f2937;+  background: #f8fafc;+  border: 1px solid #e2e8f0;+  text-align: left;+}++.request-card:hover {+  background: #eff6ff;+  border-color: #93c5fd;+}++.request-card-name {+  font-weight: 700;+}++.request-card-details {+  color: #64748b;+  font-size: 0.9rem;+  overflow-wrap: anywhere;+}++.request-card-details strong {+  color: #2563eb;+  margin-right: 6px;+}+ .field {   display: grid;   gap: 7px;@@ -65,6 +120,11 @@ h1 {   border-radius: 8px; } +#header-list:empty,+#header-list[hidden] {+  display: none;+}+ .header-row {   display: grid;   grid-template-columns: minmax(0, 1fr) auto;@@ -94,10 +154,6 @@ h1 {   gap: 6px; } -.header-list + button {-  margin-top: 4px;-}- .field small {   color: #64748b;   font-size: 0.8rem;@@ -163,6 +219,77 @@ button:focus-visible {   outline-offset: 2px; } +.button-secondary {+  color: #1e3a8a;+  background: #ffffff;+  border-color: #93c5fd;+}++.button-secondary:hover {+  background: #eff6ff;+  border-color: #60a5fa;+}++.button-danger {+  color: #b91c1c;+  background: #ffffff;+  border-color: #fecaca;+}++.button-danger:hover {+  color: #ffffff;+  background: #dc2626;+  border-color: #dc2626;+}++.form-actions {+  display: flex;+  flex-wrap: wrap;+  gap: 12px;+}++.leave-dialog {+  position: fixed;+  z-index: 10;+  inset: 0;+  display: grid;+  place-items: center;+  padding: 24px;+  background: rgba(15, 23, 42, 0.55);+}++.leave-dialog[hidden] {+  display: none;+}++.leave-dialog-panel {+  width: min(100%, 440px);+  padding: 28px;+  color: #1f2937;+  background: #ffffff;+  border: 1px solid #e2e8f0;+  border-radius: 12px;+  box-shadow: 0 24px 48px rgba(15, 23, 42, 0.25);+}++.leave-dialog-panel h2 {+  margin: 0;+  font-size: 1.25rem;+}++.leave-dialog-panel > p:not(.eyebrow) {+  margin: 10px 0 0;+  color: #64748b;+  line-height: 1.5;+}++.leave-dialog-actions {+  display: flex;+  flex-wrap: wrap;+  gap: 12px;+  margin-top: 24px;+}+ #request-status {   min-height: 24px;   margin: 16px 0 0;@@ -183,10 +310,6 @@ button:focus-visible {   overflow-wrap: anywhere; } -#request-result:empty {-  display: none;-}- @media (prefers-color-scheme: dark) {   :root {     color: #e2e8f0;@@ -195,16 +318,33 @@ button:focus-visible {    .page-header p,   .field small,-  #request-status {+  #request-status,+  .request-card-details {     color: #94a3b8;   } -  .request-form {+  .request-form,+  .request-picker {     background: #1f2937;     border-color: #334155;     box-shadow: none;   } +  .request-card,+  .header-list {+    background: #182233;+    border-color: #334155;+  }++  .request-card {+    color: #e2e8f0;+  }++  .request-card:hover,+  .button-secondary:hover {+    background: #1e3a5f;+  }+   input,   select,   textarea {@@ -213,15 +353,26 @@ button:focus-visible {     border-color: #475569;   } -  .header-list {-    background: #182233;+  .header-row {     border-color: #334155;   } -  .header-row {+  .button-secondary {+    color: #bfdbfe;+    background: #1f2937;+    border-color: #3b82f6;+  }++  .leave-dialog-panel {+    color: #e2e8f0;+    background: #1f2937;     border-color: #334155;   } +  .leave-dialog-panel > p:not(.eyebrow) {+    color: #94a3b8;+  }+   #request-result {     color: #cbd5e1;     background: #111827;@@ -241,15 +392,19 @@ button:focus-visible {  @media (max-width: 520px) {   .container {-    padding: 40px 16px;+    padding: 32px 16px;+  }++  .page-header--split {+    display: grid;   }    .request-form {-    padding: 20px;+    padding: 0 20px;   } -  .header-row {-    grid-template-columns: minmax(0, 1fr) auto;+  .request-picker {+    padding-inline: 20px;   }    .header-fields {

Contact

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