Commit 69cc03f
Add support for projects
Changed files (6)
modifiedsrc-tauri/Cargo.lockmodifiedsrc-tauri/Cargo.tomlmodifiedsrc-tauri/capabilities/default.jsonmodifiedsrc-tauri/src/lib.rsmodifiedsrc/lib.rsmodifiedsrc/styles.css
Diff
@@ -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"@@ -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"] }@@ -5,6 +5,7 @@ "windows": ["main"], "permissions": [ "core:default",- "opener:default"+ "opener:default",+ "dialog:default" ] }@@ -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,@@ -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}"));@@ -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; }