Clone in a terminal:
Commit 69cc03f
Johan Mattsson - 2026-11-02T14:56:51+00:00
69cc03f7b9268ebe2d7ff00c3e0cf1b1a81ade72johan.mattsson.m@gmail.com

Add support for projects

Changed files (6)

Diff

diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lockindex d6b534e..7452d87 100644--- a/src-tauri/Cargo.lock+++ b/src-tauri/Cargo.lock@@ -2324,6 +2324,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [  "bitflags 2.13.2",  "block2",+ "libc",  "objc2",  "objc2-core-foundation", ]@@ -2962,6 +2963,32 @@ dependencies = [ ]  [[package]]+name = "rfd"+version = "0.17.2"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220"+dependencies = [+ "block2",+ "dispatch2",+ "glib-sys",+ "gobject-sys",+ "gtk-sys",+ "js-sys",+ "libc",+ "log",+ "objc2",+ "objc2-app-kit",+ "objc2-core-foundation",+ "objc2-foundation",+ "percent-encoding",+ "raw-window-handle",+ "wasm-bindgen",+ "wasm-bindgen-futures",+ "web-sys",+ "windows-sys 0.61.2",+]++[[package]] name = "ring" version = "0.17.14" source = "registry+https://github.com/rust-lang/crates.io-index"@@ -3666,6 +3693,7 @@ dependencies = [  "serde_json",  "tauri",  "tauri-build",+ "tauri-plugin-dialog",  "tauri-plugin-opener", ] @@ -3750,6 +3778,48 @@ dependencies = [ ]  [[package]]+name = "tauri-plugin-dialog"+version = "2.8.4"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "ce0fb9c2b6d34a9720627ed930ebc8af26f61e648695aecaeb03d5c33c5896f5"+dependencies = [+ "log",+ "raw-window-handle",+ "rfd",+ "serde",+ "serde_json",+ "tauri",+ "tauri-plugin",+ "tauri-plugin-fs",+ "thiserror 2.0.21",+ "url",+]++[[package]]+name = "tauri-plugin-fs"+version = "2.7.1"+source = "registry+https://github.com/rust-lang/crates.io-index"+checksum = "5d58eb1d4984e535f47a642ffec09a2da6f5c18a12b5902d12126cb7626d628b"+dependencies = [+ "anyhow",+ "dunce",+ "glob",+ "log",+ "objc2-foundation",+ "percent-encoding",+ "schemars 0.8.22",+ "serde",+ "serde_json",+ "serde_repr",+ "tauri",+ "tauri-plugin",+ "tauri-utils",+ "thiserror 2.0.21",+ "toml 1.1.8+spec-1.1.0",+ "url",+]++[[package]] name = "tauri-plugin-opener" version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index"diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.tomlindex d2e1863..b02bfe1 100644--- a/src-tauri/Cargo.toml+++ b/src-tauri/Cargo.toml@@ -20,6 +20,7 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2"+tauri-plugin-dialog = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.jsonindex 4cdbf49..778bfb5 100644--- a/src-tauri/capabilities/default.json+++ b/src-tauri/capabilities/default.json@@ -5,6 +5,7 @@   "windows": ["main"],   "permissions": [     "core:default",-    "opener:default"+    "opener:default",+    "dialog:default"   ] }diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rsindex 7f95f3e..3e894d5 100644--- a/src-tauri/src/lib.rs+++ b/src-tauri/src/lib.rs@@ -6,6 +6,7 @@ use reqwest::{ }; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager};+use tauri_plugin_dialog::DialogExt;  #[derive(Clone, Deserialize, Serialize)] struct RequestHeader {@@ -37,8 +38,21 @@ struct StoredRequest {     headers: Vec<RequestHeader>, } +#[derive(Clone, Deserialize, Serialize)]+struct Project {+    path: String,+}++#[derive(Clone, Serialize)]+#[serde(rename_all = "camelCase")]+struct ProjectSummary {+    path: String,+    name: String,+}+ type HeadersByRequestName = BTreeMap<String, Vec<RequestHeader>>; +const PROJECTS_FILE_NAME: &str = "projects.json"; const REQUESTS_FILE_NAME: &str = "requests.json"; const HEADERS_FILE_NAME: &str = "headers.json"; @@ -52,28 +66,71 @@ struct PostResponse {     body: String, } -fn storage_file_path(app: &AppHandle, file_name: &str) -> Result<PathBuf, String> {-    let app_data_dir = app-        .path()-        .app_data_dir()-        .map_err(|error| format!("Could not find the application data directory: {error}"))?;+fn projects_file_path(app: &AppHandle) -> Result<PathBuf, String> {+    let config_dir = app.path().app_config_dir().map_err(|error| {+        format!("Could not find the application configuration directory: {error}")+    })?;++    fs::create_dir_all(&config_dir).map_err(|error| {+        format!("Could not create the application configuration directory: {error}")+    })?; -    fs::create_dir_all(&app_data_dir)-        .map_err(|error| format!("Could not create the application data directory: {error}"))?;+    Ok(config_dir.join(PROJECTS_FILE_NAME))+}++fn project_file_path(project_path: &str, file_name: &str) -> Result<PathBuf, String> {+    let path = PathBuf::from(project_path);+    if !path.is_dir() {+        return Err("The selected project folder no longer exists.".to_owned());+    }++    Ok(path.join(file_name))+}++fn requests_file_path(project_path: &str) -> Result<PathBuf, String> {+    project_file_path(project_path, REQUESTS_FILE_NAME)+}++fn headers_file_path(project_path: &str) -> Result<PathBuf, String> {+    project_file_path(project_path, HEADERS_FILE_NAME)+}++fn load_projects(app: &AppHandle) -> Result<Vec<Project>, String> {+    let path = projects_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 projects: {error}")),+    }; -    Ok(app_data_dir.join(file_name))+    serde_json::from_str(&contents)+        .map_err(|error| format!("Projects file is invalid JSON: {error}")) } -fn requests_file_path(app: &AppHandle) -> Result<PathBuf, String> {-    storage_file_path(app, REQUESTS_FILE_NAME)+fn write_projects(app: &AppHandle, projects: &[Project]) -> Result<(), String> {+    let contents = serde_json::to_string_pretty(projects)+        .map_err(|error| format!("Could not serialize projects: {error}"))?;+    fs::write(projects_file_path(app)?, contents)+        .map_err(|error| format!("Could not write projects: {error}")) } -fn headers_file_path(app: &AppHandle) -> Result<PathBuf, String> {-    storage_file_path(app, HEADERS_FILE_NAME)+fn project_summary(project: &Project) -> ProjectSummary {+    let path = PathBuf::from(&project.path);+    let name = path+        .file_name()+        .and_then(|name| name.to_str())+        .filter(|name| !name.is_empty())+        .unwrap_or(&project.path)+        .to_owned();++    ProjectSummary {+        path: project.path.clone(),+        name,+    } } -fn load_requests(app: &AppHandle) -> Result<Vec<RequestData>, String> {-    let path = requests_file_path(app)?;+fn load_requests(project_path: &str) -> Result<Vec<RequestData>, String> {+    let path = requests_file_path(project_path)?;     let contents = match fs::read_to_string(path) {         Ok(contents) => contents,         Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),@@ -84,15 +141,15 @@ fn load_requests(app: &AppHandle) -> Result<Vec<RequestData>, String> {         .map_err(|error| format!("Saved requests file is invalid JSON: {error}")) } -fn write_requests(app: &AppHandle, requests: &[RequestData]) -> Result<(), String> {+fn write_requests(project_path: &str, 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)+    fs::write(requests_file_path(project_path)?, 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)?;+fn read_headers(project_path: &str) -> Result<HeadersByRequestName, String> {+    let path = headers_file_path(project_path)?;     let contents = match fs::read_to_string(path) {         Ok(contents) => contents,         Err(error) if error.kind() == io::ErrorKind::NotFound => {@@ -105,35 +162,79 @@ fn read_headers(app: &AppHandle) -> Result<HeadersByRequestName, String> {         .map_err(|error| format!("Saved request headers file is invalid JSON: {error}")) } -fn write_headers(app: &AppHandle, headers: &HeadersByRequestName) -> Result<(), String> {+fn write_headers(project_path: &str, 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)+    fs::write(headers_file_path(project_path)?, contents)         .map_err(|error| format!("Could not write saved request headers: {error}")) }  fn read_headers_for_request(-    app: &AppHandle,+    project_path: &str,     request_name: &str, ) -> Result<Vec<RequestHeader>, String> {-    Ok(read_headers(app)?+    Ok(read_headers(project_path)?         .get(request_name)         .cloned()         .unwrap_or_default()) }  #[tauri::command]-fn load_request_list(app: AppHandle) -> Result<Vec<RequestData>, String> {-    load_requests(&app)+fn load_project_list(app: AppHandle) -> Result<Vec<ProjectSummary>, String> {+    let mut projects = load_projects(&app)?;+    let original_count = projects.len();+    projects.retain(|project| PathBuf::from(&project.path).is_dir());+    projects.sort_by(|left, right| left.path.cmp(&right.path));+    projects.dedup_by(|left, right| left.path == right.path);++    if projects.len() != original_count {+        write_projects(&app, &projects)?;+    }++    Ok(projects.iter().map(project_summary).collect())+}++#[tauri::command]+async fn pick_project_folder(app: AppHandle) -> Option<String> {+    app.dialog()+        .file()+        .set_title("Choose a project folder")+        .blocking_pick_folder()+        .map(|path| path.to_string())+}++#[tauri::command]+fn add_project(app: AppHandle, path: String) -> Result<ProjectSummary, String> {+    let canonical_path = fs::canonicalize(path.trim())+        .map_err(|error| format!("Could not access the project folder: {error}"))?;+    if !canonical_path.is_dir() {+        return Err("Choose an existing folder for the project.".to_owned());+    }++    let project = Project {+        path: canonical_path.to_string_lossy().into_owned(),+    };+    let mut projects = load_projects(&app)?;+    if !projects.iter().any(|saved| saved.path == project.path) {+        projects.push(project.clone());+        write_projects(&app, &projects)?;+    }++    Ok(project_summary(&project))+}++#[tauri::command]+fn load_request_list(project_path: String) -> Result<Vec<RequestData>, String> {+    load_requests(&project_path) }  #[tauri::command]-fn load_request(app: AppHandle, id: String) -> Result<StoredRequest, String> {-    let request = load_requests(&app)?+fn load_request(project_path: String, id: String) -> Result<StoredRequest, String> {+    let request = load_requests(&project_path)?         .into_iter()         .find(|request| request.id == id)         .ok_or_else(|| "The selected request no longer exists.".to_owned())?;-    let headers = read_headers_for_request(&app, &request.name)?;+    let headers = read_headers_for_request(&project_path, &request.name)?;      Ok(StoredRequest {         id: request.id,@@ -148,7 +249,7 @@ fn load_request(app: AppHandle, id: String) -> Result<StoredRequest, String> {  #[tauri::command] fn save_request(-    app: AppHandle,+    project_path: String,     id: String,     name: String,     method: String,@@ -180,7 +281,7 @@ fn save_request(         );     } -    let mut requests = load_requests(&app)?;+    let mut requests = load_requests(&project_path)?;     if requests         .iter()         .any(|saved| saved.id != request.id && saved.name == request.name)@@ -198,7 +299,7 @@ fn save_request(         requests.push(request.clone());     } -    let mut headers_by_name = read_headers(&app)?;+    let mut headers_by_name = read_headers(&project_path)?;     headers_by_name.insert(request.name.clone(), headers.clone());      if let Some(previous_name) =@@ -209,8 +310,8 @@ fn save_request(         }     } -    write_headers(&app, &headers_by_name)?;-    write_requests(&app, &requests)?;+    write_headers(&project_path, &headers_by_name)?;+    write_requests(&project_path, &requests)?;     Ok(StoredRequest {         id: request.id,         name: request.name,@@ -305,7 +406,11 @@ fn parse_headers(headers: Vec<RequestHeader>) -> Result<HeaderMap, String> { pub fn run() {     tauri::Builder::default()         .plugin(tauri_plugin_opener::init())+        .plugin(tauri_plugin_dialog::init())         .invoke_handler(tauri::generate_handler![+            load_project_list,+            pick_project_folder,+            add_project,             load_request_list,             load_request,             save_request,diff --git a/src/lib.rs b/src/lib.rsindex 8708474..aaeed3d 100644--- a/src/lib.rs+++ b/src/lib.rs@@ -5,6 +5,10 @@ use serde::{Deserialize, Serialize}; use wasm_bindgen::{JsCast, prelude::wasm_bindgen}; use wasm_bindgen_futures::spawn_local; +mod tauri;++use tauri::{AddProjectArgs, LoadRequestArgs, LoadRequestListArgs, SaveRequestArgs};+ thread_local! {     static HEADER_ROW_COUNT: Cell<usize> = const { Cell::new(1) }; }@@ -39,6 +43,13 @@ struct RequestSummary {     endpoint: String, } +#[derive(Clone, Deserialize)]+#[serde(rename_all = "camelCase")]+struct ProjectSummary {+    path: String,+    name: String,+}+ #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct PostResponse {@@ -49,11 +60,6 @@ struct PostResponse {     body: String, } -#[derive(Serialize)]-struct LoadRequestArgs {-    id: String,-}- #[component("app-root")] struct App {     #[attribute]@@ -72,6 +78,10 @@ struct App {     request_body: String,     #[attribute]     request_headers: String,+    #[attribute]+    project_path: String,+    #[attribute]+    project_name: String, }  #[component("rest-call-setup")]@@ -108,30 +118,79 @@ impl Component for AppComponent {  impl AppComponent {     fn render(&self) {-        if self.attribute("view") == "detail" {-            self.render_request_detail();-        } else {-            self.render_request_picker();-            load_requests();+        match self.attribute("view").as_str() {+            "detail" => self.render_request_detail(),+            "picker" => {+                self.render_request_picker();+                load_requests();+            }+            _ => {+                self.render_project_picker();+                load_projects();+            }         }     } +    fn render_project_picker(&self) {+        let content = html! {{+            <main class="container">+                <header class="page-header page-header-split">+                    <div>+                        <h1>Projects</h1>+                        <p>Choose a folder that contains your saved requests.</p>+                    </div>+                    <div class="header-actions">+                        <button type="button" onclick="showProjectFolderDialog()">Add project</button>+                    </div>+                </header>+                <section class="request-picker" aria-label="Projects">+                    <div id="project-list" class="request-list" role="status" aria-live="polite">Loading projects...</div>+                </section>+            </main>++            <div id="project-folder-dialog" class="leave-dialog" role="dialog" aria-modal="true" aria-labelledby="project-folder-title" hidden>+                <div class="leave-dialog-panel">+                    <p class="eyebrow">Project folder</p>+                    <h2 id="project-folder-title">Choose a project folder</h2>+                    <p>Requests and headers will be saved in this folder.</p>+                    <div class="field project-folder-field">+                        <label for="project-folder-input">Folder path</label>+                        <div class="project-folder-input">+                            <input id="project-folder-input" type="text" placeholder="/path/to/project" />+                            <button class="button-secondary" type="button" onclick="browseProjectFolder()">Choose folder</button>+                        </div>+                    </div>+                    <p id="project-folder-error" class="request-status request-status-failed" role="alert" hidden></p>+                    <div class="leave-dialog-actions">+                        <button type="button" onclick="addProject()">Add project</button>+                        <button class="button-secondary" type="button" onclick="hideProjectFolderDialog()">Cancel</button>+                    </div>+                </div>+            </div>+        }};+        self.set_content(content.as_str());+    }+     fn render_request_picker(&self) {-        let content = r#"+        let project_name = self.attribute("project_name");+        let content = html! {{             <main class="container">-                <header class="page-header page-header--split">+                <header class="page-header page-header-split">                     <div>-                        <h1>REST Call</h1>+                        <h1>$project_name</h1>                         <p>Choose a saved request or create a new one.</p>                     </div>-                    <button type="button" onclick="newRequest()">New request</button>+                    <div class="header-actions">+                        <button class="button-secondary" type="button" onclick="showProjectPicker()">Back to projects</button>+                        <button type="button" onclick="newRequest()">New request</button>+                    </div>                 </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>+                    <div id="request-list" class="request-list" role="status" aria-live="polite">Loading saved requests...</div>                 </section>             </main>-        "#;-        self.set_content(content);+        }};+        self.set_content(content.as_str());     }      fn render_request_detail(&self) {@@ -206,11 +265,11 @@ impl RestCallSetupComponent {          let content = html! {{             <main class="container">-                <header class="page-header page-header--split">+                <header class="page-header page-header-split">                     <div>                         <h1 id="request-title">$request_name_headline</h1>                     </div>-                    <button class="button-secondary" type="button" onclick="showRequestPicker()">All requests</button>+                    <button class="button-secondary" type="button" onclick="showRequestPicker()">Back to requests</button>                 </header>                  <section class="request-form" aria-label="Request details">@@ -436,12 +495,24 @@ fn update_request_body_visibility() {     } } +fn selected_project_path() -> String {+    get_element("app-root")+        .and_then(|app| app.get_attribute("project_path"))+        .unwrap_or_default()+}+ fn show_request_picker() {     if let Some(app) = get_element("app-root") {         let _ = app.set_attribute("view", "picker");     } } +fn show_project_picker() {+    if let Some(app) = get_element("app-root") {+        let _ = app.set_attribute("view", "projects");+    }+}+ fn set_unsaved_changes_dialog_visible(visible: bool) {     let Some(dialog) = get_element("#unsaved-changes-dialog") else {         return;@@ -475,10 +546,84 @@ fn set_request_attributes(request: &StoredRequest) {     let _ = app.set_attribute("request_headers", &headers); } +#[callback(loadProjects)]+fn load_projects() {+    spawn_local(async {+        match tauri_wasm::invoke("load_project_list").await {+            Ok(value) => match serde_wasm_bindgen::from_value::<Vec<ProjectSummary>>(value) {+                Ok(projects) => render_project_list(&projects),+                Err(error) => try_inner_text(+                    "#project-list",+                    &format!("Could not read projects: {error}"),+                ),+            },+            Err(error) => try_inner_text(+                "#project-list",+                &format!("Could not load projects: {error}"),+            ),+        }+    });+}++fn render_project_list(projects: &[ProjectSummary]) {+    let Some(app) = get_element("app-root") else {+        return;+    };+    if app.get_attribute("view").as_deref() == Some("picker")+        || app.get_attribute("view").as_deref() == Some("detail")+    {+        return;+    }++    if projects.is_empty() {+        try_inner_text(+            "#project-list",+            "No projects yet. Add a folder to get started.",+        );+        return;+    }++    let mut cards = String::new();+    for project in projects {+        let card = html! {{+            <button class="request-card" type="button" data-project-path="$(project.path)" data-project-name="$(project.name)" onclick="openProject(this.dataset.projectPath, this.dataset.projectName)">+                <span class="request-card-name">$(project.name)</span>+                <span class="request-card-details">$(project.path)</span>+            </button>+        }};+        cards.push_str(card.as_str());+    }++    if let Some(list) = get_element("#project-list") {+        list.set_inner_html(&cards);+    }+}+ #[callback(loadRequests)] fn load_requests() {-    spawn_local(async {-        match tauri_wasm::invoke("load_request_list").await {+    let project_path = selected_project_path();+    if project_path.is_empty() {+        show_project_picker();+        return;+    }++    spawn_local(async move {+        let project_args = LoadRequestListArgs { project_path };+        let args = match tauri_wasm::args(&project_args) {+            Ok(args) => args,+            Err(error) => {+                try_inner_text(+                    "#request-list",+                    &format!("Could not load saved requests: {error}"),+                );+                return;+            }+        };++        match tauri_wasm::invoke("load_request_list")+            .with_args(args)+            .await+        {             Ok(value) => match serde_wasm_bindgen::from_value::<Vec<RequestSummary>>(value) {                 Ok(requests) => render_request_list(&requests),                 Err(error) => try_inner_text(@@ -499,7 +644,7 @@ fn render_request_list(requests: &[RequestSummary]) {         return;     }; -    if app.get_attribute("view").as_deref() == Some("detail") {+    if app.get_attribute("view").as_deref() != Some("picker") {         return;     } @@ -525,8 +670,134 @@ fn render_request_list(requests: &[RequestSummary]) {     } } +fn show_project_folder_dialog(start_new_request: bool) {+    let Some(dialog) = get_element("#project-folder-dialog") else {+        return;+    };++    let _ = dialog.set_attribute(+        "data-start-new-request",+        if start_new_request { "true" } else { "false" },+    );+    let _ = dialog.remove_attribute("hidden");+    clear_project_folder_error();+}++fn clear_project_folder_error() {+    try_inner_text("#project-folder-error", "");+    if let Some(error) = get_element("#project-folder-error") {+        let _ = error.set_attribute("hidden", "");+    }+}++fn hide_project_folder_dialog() {+    if let Some(dialog) = get_element("#project-folder-dialog") {+        let _ = dialog.set_attribute("hidden", "");+    }+}++fn set_project_folder_error(message: &str) {+    try_inner_text("#project-folder-error", message);+    if let Some(error) = get_element("#project-folder-error") {+        let _ = error.remove_attribute("hidden");+    }+}++fn set_project_folder_path(path: &str) {+    if let Some(input) = get_element("#project-folder-input")+        .and_then(|element| element.dyn_into::<web_sys::HtmlInputElement>().ok())+    {+        input.set_value(path);+    }+}++fn select_project(project: &ProjectSummary) {+    if let Some(app) = get_element("app-root") {+        let _ = app.set_attribute("project_path", &project.path);+        let _ = app.set_attribute("project_name", &project.name);+    }+}++#[callback(showProjectFolderDialog)]+fn show_project_folder_dialog_action() {+    show_project_folder_dialog(false);+}++#[callback(hideProjectFolderDialog)]+fn hide_project_folder_dialog_action() {+    hide_project_folder_dialog();+}++#[callback(browseProjectFolder)]+fn browse_project_folder() {+    spawn_local(async {+        match tauri_wasm::invoke("pick_project_folder").await {+            Ok(value) => match serde_wasm_bindgen::from_value::<Option<String>>(value) {+                Ok(Some(path)) => {+                    set_project_folder_path(&path);+                    clear_project_folder_error();+                }+                Ok(None) => {}+                Err(error) => {+                    set_project_folder_error(&format!("Could not read selected folder: {error}"))+                }+            },+            Err(error) => set_project_folder_error(&format!("Could not choose a folder: {error}")),+        }+    });+}++#[callback(addProject)]+fn add_project() {+    let path = try_get_value("#project-folder-input").trim().to_owned();+    if path.is_empty() {+        set_project_folder_error("Enter a folder path.");+        return;+    }++    let Some(dialog) = get_element("#project-folder-dialog") else {+        return;+    };++    let start_new_request =+        dialog.get_attribute("data-start-new-request").as_deref() == Some("true");++    let project_args = AddProjectArgs { path };++    spawn_local(async move {+        let args = match tauri_wasm::args(&project_args) {+            Ok(args) => args,+            Err(error) => {+                set_project_folder_error(&format!("Could not prepare project folder: {error}"));+                return;+            }+        };++        match tauri_wasm::invoke("add_project").with_args(args).await {+            Ok(value) => match serde_wasm_bindgen::from_value::<ProjectSummary>(value) {+                Ok(project) => {+                    select_project(&project);+                    hide_project_folder_dialog();+                    if start_new_request {+                        new_request();+                    } else {+                        load_projects();+                    }+                }+                Err(error) => set_project_folder_error(&format!("Could not read project: {error}")),+            },+            Err(error) => set_project_folder_error(&format!("{error}")),+        }+    });+}+ #[callback(newRequest)] fn new_request() {+    if selected_project_path().is_empty() {+        show_project_folder_dialog(true);+        return;+    }+     set_request_attributes(&StoredRequest {         id: String::new(),         name: String::new(),@@ -601,10 +872,35 @@ fn discard_and_show_request_picker() {     show_request_picker(); } +#[callback(showProjectPicker)]+fn show_project_picker_action() {+    if !request_is_dirty() {+        show_project_picker();+        return;+    }++    set_unsaved_changes_dialog_visible(true);+}++#[callback(openProject)]+fn open_project(path: String, name: String) {+    if let Some(app) = get_element("app-root") {+        let _ = app.set_attribute("project_path", &path);+        let _ = app.set_attribute("project_name", &name);+        let _ = app.set_attribute("view", "picker");+    }+}+ #[callback(openRequest)] fn open_request(id: String) {+    let project_path = selected_project_path();+    if project_path.is_empty() {+        show_project_picker();+        return;+    }+     spawn_local(async move {-        let request_args = LoadRequestArgs { id };+        let request_args = LoadRequestArgs { project_path, id };         let args = match tauri_wasm::args(&request_args) {             Ok(args) => args,             Err(error) => {@@ -792,6 +1088,12 @@ fn save_request_shortcut(event: web_sys::KeyboardEvent) { }  fn save_request(return_to_picker: bool) {+    let project_path = selected_project_path();+    if project_path.is_empty() {+        show_project_folder_dialog(true);+        return;+    }+     if try_get_value("#request-name-input").trim().is_empty() {         set_request_error("Enter a request name before saving.");         return;@@ -806,9 +1108,13 @@ fn save_request(return_to_picker: bool) {     };      set_request_status("Saving request");+    let request_args = SaveRequestArgs {+        project_path,+        request,+    };      spawn_local(async move {-        let args = match tauri_wasm::args(&request) {+        let args = match tauri_wasm::args(&request_args) {             Ok(args) => args,             Err(error) => {                 set_request_error(&format!("Could not prepare request: {error}"));diff --git a/src/styles.css b/src/styles.cssindex 790e60e..d83a047 100644--- a/src/styles.css+++ b/src/styles.css@@ -23,13 +23,20 @@ body {   margin-bottom: 28px; } -.page-header--split {+.page-header-split {   display: flex;   align-items: flex-start;   justify-content: space-between;   gap: 20px; } +.header-actions {+  display: flex;+  flex-wrap: wrap;+  justify-content: flex-end;+  gap: 12px;+}+ h1 {   margin: 0;   font-size: 2rem;@@ -326,6 +333,33 @@ button:focus-visible {   line-height: 1.5; } +.project-folder-field {+  margin-top: 20px;+}++.project-folder-input {+  display: flex;+  gap: 12px;+}++.project-folder-input input {+  min-width: 0;+}++.project-folder-input button {+  flex: 0 0 auto;+  white-space: nowrap;+}++#project-folder-error {+  margin: 16px 0 0;+  color: #ffffff;+}++#project-folder-error[hidden] {+  display: none;+}+ .leave-dialog-actions {   display: flex;   flex-wrap: wrap;@@ -519,10 +553,23 @@ button:focus-visible {     padding: 32px 16px;   } -  .page-header--split {+  .page-header-split {     display: grid;   } +  .header-actions {+    justify-content: flex-start;+  }++  .project-folder-input {+    align-items: stretch;+    flex-direction: column;+  }++  .project-folder-input button {+    width: 100%;+  }+   .request-form {     padding: 0 20px;   }

Contact

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