Commit c1551d2
Fix setup and add save dialog
Changed files (6)
modifiedCargo.lockmodifiedCargo.tomlmodifiedindex.htmlmodifiedsrc-tauri/src/lib.rsmodifiedsrc/lib.rsmodifiedsrc/styles.css
Diff
@@ -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",@@ -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", ] }@@ -12,6 +12,6 @@ </head> <body>- <rest-call-app></rest-call-app>+ <app-root></app-root> </body> </html>@@ -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"); }@@ -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="{"name":"Example"}" 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="{"name":"Example"}" 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:?}")), } }); }@@ -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 {