diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bae66e14..e6ea741e 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -23,6 +23,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tauri-plugin-window-state", "tokio", "url", "urlencoding", @@ -4840,6 +4841,21 @@ dependencies = [ "zip", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.11.0", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.10.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f3f5c5b6..0cf4b8cd 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -32,4 +32,5 @@ open = "5" regex = "1" walkdir = "2" tauri-plugin-single-instance = "2" +tauri-plugin-window-state = "2" chrono = "0.4" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 715b6b03..a4f8581c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -16,6 +16,7 @@ "dialog:allow-open", "core:menu:default", "clipboard-manager:allow-write-text", - "updater:default" + "updater:default", + "window-state:default" ] } diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 0d7a78e9..a9810521 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -26,7 +26,7 @@ pub struct GitStatus { pub has_upstream: bool, // Whether the current branch tracks an upstream pub remote_url: Option, // URL of the 'origin' remote pub changed_count: usize, - pub ahead_count: i32, // -1 if no upstream tracking + pub ahead_count: i32, // -1 if no upstream tracking pub behind_count: i32, // -1 if no upstream tracking pub current_branch: Option, pub error: Option, @@ -97,13 +97,9 @@ pub fn get_status(path: &Path) -> GitStatus { } // Check for remote - if let Ok(output) = git_cmd() - .args(["remote"]) - .current_dir(path) - .output() - { - status.has_remote = output.status.success() - && !String::from_utf8_lossy(&output.stdout).trim().is_empty(); + if let Ok(output) = git_cmd().args(["remote"]).current_dir(path).output() { + status.has_remote = + output.status.success() && !String::from_utf8_lossy(&output.stdout).trim().is_empty(); // Get remote URL if remote exists if status.has_remote { @@ -164,11 +160,7 @@ pub fn get_status(path: &Path) -> GitStatus { /// Stage all changes and commit pub fn commit_all(path: &Path, message: &str) -> GitResult { // Stage all changes - let stage_output = match git_cmd() - .args(["add", "-A"]) - .current_dir(path) - .output() - { + let stage_output = match git_cmd().args(["add", "-A"]).current_dir(path).output() { Ok(output) => output, Err(e) => { return GitResult { @@ -189,7 +181,11 @@ pub fn commit_all(path: &Path, message: &str) -> GitResult { error: Some(format!( "Failed to stage changes: {}{}", stderr, - if stdout.is_empty() { String::new() } else { format!("\n{}", stdout) } + if stdout.is_empty() { + String::new() + } else { + format!("\n{}", stdout) + } )), }; } @@ -237,7 +233,13 @@ pub fn commit_all(path: &Path, message: &str) -> GitResult { /// Push to remote pub fn push(path: &Path) -> GitResult { let output = git_cmd() - .args(["-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=10", "push"]) + .args([ + "-c", + "http.lowSpeedLimit=1000", + "-c", + "http.lowSpeedTime=10", + "push", + ]) .env("GIT_SSH_COMMAND", "ssh -o ConnectTimeout=10") .current_dir(path) .output(); @@ -269,7 +271,14 @@ pub fn push(path: &Path) -> GitResult { /// Fetch from remote to update tracking refs pub fn fetch(path: &Path) -> GitResult { let output = git_cmd() - .args(["-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=10", "fetch", "--quiet"]) + .args([ + "-c", + "http.lowSpeedLimit=1000", + "-c", + "http.lowSpeedTime=10", + "fetch", + "--quiet", + ]) .env("GIT_SSH_COMMAND", "ssh -o ConnectTimeout=10") .current_dir(path) .output(); @@ -301,7 +310,15 @@ pub fn fetch(path: &Path) -> GitResult { /// Pull from remote pub fn pull(path: &Path) -> GitResult { let output = git_cmd() - .args(["-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=10", "-c", "pull.rebase=false", "pull"]) + .args([ + "-c", + "http.lowSpeedLimit=1000", + "-c", + "http.lowSpeedTime=10", + "-c", + "pull.rebase=false", + "pull", + ]) .env("GIT_SSH_COMMAND", "ssh -o ConnectTimeout=10") .current_dir(path) .output(); @@ -360,7 +377,10 @@ pub fn add_remote(path: &Path, url: &str) -> GitResult { return GitResult { success: false, message: None, - error: Some("Invalid remote URL format. URL must start with https://, http://, or git@".to_string()), + error: Some( + "Invalid remote URL format. URL must start with https://, http://, or git@" + .to_string(), + ), }; } @@ -406,7 +426,16 @@ pub fn add_remote(path: &Path, url: &str) -> GitResult { /// Push to remote and set upstream tracking (git push -u origin ) pub fn push_with_upstream(path: &Path, branch: &str) -> GitResult { let output = git_cmd() - .args(["-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=10", "push", "-u", "origin", branch]) + .args([ + "-c", + "http.lowSpeedLimit=1000", + "-c", + "http.lowSpeedTime=10", + "push", + "-u", + "origin", + branch, + ]) .env("GIT_SSH_COMMAND", "ssh -o ConnectTimeout=10") .current_dir(path) .output(); @@ -464,7 +493,8 @@ fn parse_pull_error(stderr: &str) -> String { } else if stderr.contains("CONFLICT") || stderr.contains("Merge conflict") { "Pull failed due to merge conflicts. Resolve conflicts manually.".to_string() } else if stderr.contains("not possible to fast-forward") { - "Pull failed: local and remote have diverged. Try pulling with rebase or merging manually.".to_string() + "Pull failed: local and remote have diverged. Try pulling with rebase or merging manually." + .to_string() } else if stderr.contains("unrelated histories") { "Pull failed: repositories have unrelated histories. Merge them manually or re-run with --allow-unrelated-histories.".to_string() } else { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a9935908..1cc2e134 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,14 +4,17 @@ use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, RwLock}; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, Mutex, RwLock, +}; use std::time::{Duration, Instant}; use tantivy::collector::TopDocs; use tantivy::query::QueryParser; use tantivy::schema::*; use tantivy::{doc, Index, IndexReader, IndexWriter, ReloadPolicy}; -use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl}; use tauri::webview::WebviewWindowBuilder; +use tauri::{AppHandle, Emitter, Manager, State, WebviewUrl}; use tauri_plugin_clipboard_manager::ClipboardExt; use tokio::fs; use tokio::io::AsyncWriteExt; @@ -96,10 +99,17 @@ pub enum TextDirection { Rtl, } -// App config (stored in app data directory - just the notes folder path) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MainWindowSize { + pub width: f64, + pub height: f64, +} + +// App config (stored in app data directory) #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct AppConfig { pub notes_folder: Option, + pub main_window_size: Option, } // Per-folder settings (stored in .scratch/settings.json within notes folder) @@ -325,12 +335,13 @@ impl SearchIndex { // App state with improved structure pub struct AppState { - pub app_config: RwLock, // notes_folder path (stored in app data) - pub settings: RwLock, // per-folder settings (stored in .scratch/) + pub app_config: RwLock, // app-level config (stored in app data) + pub settings: RwLock, // per-folder settings (stored in .scratch/) pub notes_cache: RwLock>, pub file_watcher: Mutex>, pub search_index: Mutex>, pub debounce_map: Arc>>, + pub main_window_size_dirty: AtomicBool, } impl Default for AppState { @@ -342,6 +353,7 @@ impl Default for AppState { file_watcher: Mutex::new(None), search_index: Mutex::new(None), debounce_map: Arc::new(Mutex::new(HashMap::new())), + main_window_size_dirty: AtomicBool::new(false), } } } @@ -486,7 +498,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("~~") { if let Some(end) = result[start + 2..].find("~~") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -496,7 +513,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("**") { if let Some(end) = result[start + 2..].find("**") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -504,7 +526,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find("__") { if let Some(end) = result[start + 2..].find("__") { let inner = &result[start + 2..start + 2 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 4 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 4 + end..] + ); } else { break; } @@ -514,7 +541,12 @@ fn strip_markdown(text: &str) -> String { while let Some(start) = result.find('`') { if let Some(end) = result[start + 1..].find('`') { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -534,7 +566,12 @@ fn strip_markdown(text: &str) -> String { if let Some(end) = result[start + 1..].find('*') { if end > 0 { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -547,7 +584,12 @@ fn strip_markdown(text: &str) -> String { if let Some(end) = result[start + 1..].find('_') { if end > 0 { let inner = &result[start + 1..start + 1 + end]; - result = format!("{}{}{}", &result[..start], inner, &result[start + 2 + end..]); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[start + 2 + end..] + ); } else { break; } @@ -606,7 +648,9 @@ fn id_from_abs_path(notes_root: &Path, file_path: &Path) -> Option { // Strip .md by converting to string and trimming (avoids with_extension // which breaks on stems containing dots like "meeting.2024-01-15.md"). let rel_str = rel.to_str()?; - let id = rel_str.strip_suffix(".md")?.replace(std::path::MAIN_SEPARATOR, "/"); + let id = rel_str + .strip_suffix(".md")? + .replace(std::path::MAIN_SEPARATOR, "/"); if id.is_empty() { None @@ -698,6 +742,107 @@ fn save_app_config(app: &AppHandle, config: &AppConfig) -> Result<()> { Ok(()) } +fn main_window_size_from_physical( + size: tauri::PhysicalSize, + scale_factor: f64, +) -> Option { + if !scale_factor.is_finite() || scale_factor <= 0.0 { + return None; + } + + // Round to avoid floating noise from physical/logical conversions. + let width = ((size.width as f64 / scale_factor) * 100.0).round() / 100.0; + let height = ((size.height as f64 / scale_factor) * 100.0).round() / 100.0; + + if !width.is_finite() || !height.is_finite() || width <= 0.0 || height <= 0.0 { + return None; + } + + Some(MainWindowSize { width, height }) +} + +fn update_main_window_size_cache(state: &AppState, size: MainWindowSize) { + let mut app_config = state.app_config.write().expect("app_config write lock"); + let changed = app_config.main_window_size.as_ref().is_none_or(|saved| { + (saved.width - size.width).abs() > 0.1 || (saved.height - size.height).abs() > 0.1 + }); + + if changed { + app_config.main_window_size = Some(size); + state.main_window_size_dirty.store(true, Ordering::Relaxed); + } +} + +fn capture_main_window_size(window: &tauri::Window, state: &AppState) { + if window.is_maximized().unwrap_or_default() + || window.is_minimized().unwrap_or_default() + || window.is_fullscreen().unwrap_or_default() + { + return; + } + + if let (Ok(size), Ok(scale_factor)) = (window.inner_size(), window.scale_factor()) { + if let Some(logical_size) = main_window_size_from_physical(size, scale_factor) { + update_main_window_size_cache(state, logical_size); + } + } +} + +fn capture_main_window_size_with_scale( + window: &tauri::Window, + state: &AppState, + size: tauri::PhysicalSize, + scale_factor: f64, +) { + if window.is_maximized().unwrap_or_default() + || window.is_minimized().unwrap_or_default() + || window.is_fullscreen().unwrap_or_default() + { + return; + } + + if let Some(logical_size) = main_window_size_from_physical(size, scale_factor) { + update_main_window_size_cache(state, logical_size); + } +} + +fn apply_saved_main_window_size(app: &AppHandle, config: &AppConfig) { + let Some(saved_size) = config.main_window_size.as_ref() else { + return; + }; + + if !saved_size.width.is_finite() + || !saved_size.height.is_finite() + || saved_size.width <= 0.0 + || saved_size.height <= 0.0 + { + return; + } + + if let Some(main_window) = app.get_webview_window("main") { + let _ = main_window + .as_ref() + .window() + .set_size(tauri::LogicalSize::new(saved_size.width, saved_size.height)); + } +} + +fn persist_app_config_if_dirty(app: &AppHandle, state: &AppState) { + if !state.main_window_size_dirty.load(Ordering::Relaxed) { + return; + } + + let app_config = state + .app_config + .read() + .expect("app_config read lock") + .clone(); + + if save_app_config(app, &app_config).is_ok() { + state.main_window_size_dirty.store(false, Ordering::Relaxed); + } +} + // Load per-folder settings from disk fn load_settings(notes_folder: &str) -> Settings { let path = get_settings_path(notes_folder); @@ -748,7 +893,11 @@ fn normalize_notes_folder_path(path: &str) -> Result { /// Shared initialization logic for setting a notes folder. /// Creates required directories, verifies write access, updates config/settings, /// adds asset protocol scope, and rebuilds the search index. -fn initialize_notes_folder(app: &AppHandle, path_buf: &PathBuf, state: &AppState) -> Result { +fn initialize_notes_folder( + app: &AppHandle, + path_buf: &PathBuf, + state: &AppState, +) -> Result { let normalized_path = path_buf.to_string_lossy().into_owned(); // Verify it's a valid directory @@ -900,9 +1049,9 @@ async fn list_notes(state: State<'_, AppState>) -> Result, Str let b_pinned = pinned_ids.contains(&b.id); match (a_pinned, b_pinned) { - (true, false) => std::cmp::Ordering::Less, // a pinned, b not -> a first + (true, false) => std::cmp::Ordering::Less, // a pinned, b not -> a first (false, true) => std::cmp::Ordering::Greater, // b pinned, a not -> b first - _ => b.modified.cmp(&a.modified), // both same status -> sort by date (newest first) + _ => b.modified.cmp(&a.modified), // both same status -> sort by date (newest first) } }); @@ -937,9 +1086,7 @@ async fn read_note(id: String, state: State<'_, AppState>) -> Result) -> Result<(), Strin } #[tauri::command] -async fn create_note(target_folder: Option, state: State<'_, AppState>) -> Result { +async fn create_note( + target_folder: Option, + state: State<'_, AppState>, +) -> Result { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); app_config @@ -1500,7 +1651,9 @@ async fn move_note( // Ensure target directory exists if let Some(parent) = dest_path.parent() { - fs::create_dir_all(parent).await.map_err(|e| e.to_string())?; + fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; } // Handle collision @@ -1597,7 +1750,9 @@ async fn move_folder( // Ensure target parent exists if let Some(parent) = dest.parent() { - fs::create_dir_all(parent).await.map_err(|e| e.to_string())?; + fs::create_dir_all(parent) + .await + .map_err(|e| e.to_string())?; } // Compute old and new path prefixes for updating IDs @@ -1662,13 +1817,13 @@ fn get_settings(state: State) -> Settings { } #[tauri::command] -fn update_settings( - new_settings: Settings, - state: State, -) -> Result<(), String> { +fn update_settings(new_settings: Settings, state: State) -> Result<(), String> { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? }; { @@ -1690,7 +1845,10 @@ fn update_git_enabled( ) -> Result<(), String> { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); - let folder = app_config.notes_folder.clone().ok_or("Notes folder not set")?; + let folder = app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")?; if folder != expected_folder { return Err("Notes folder changed".to_string()); @@ -1888,7 +2046,7 @@ async fn import_file_to_folder( } Err(_) => return Err("Failed to create file".to_string()), } - }; + } let modified = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1934,7 +2092,10 @@ async fn import_file_to_folder( } #[tauri::command] -async fn search_notes(query: String, state: State<'_, AppState>) -> Result, String> { +async fn search_notes( + query: String, + state: State<'_, AppState>, +) -> Result, String> { let trimmed_query = query.trim().to_string(); if trimmed_query.is_empty() { return Ok(vec![]); @@ -1944,7 +2105,9 @@ async fn search_notes(query: String, state: State<'_, AppState>) -> Result) -> Result { - eprintln!("Tantivy search error, falling back to substring search: {}", e); + eprintln!( + "Tantivy search error, falling back to substring search: {}", + e + ); fallback_search(&trimmed_query, &state).await } None => { @@ -1966,7 +2132,10 @@ async fn search_notes(query: String, state: State<'_, AppState>) -> Result) -> Result, String> { +async fn fallback_search( + query: &str, + state: &State<'_, AppState>, +) -> Result, String> { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); app_config.notes_folder.clone() @@ -2033,7 +2202,11 @@ async fn fallback_search(query: &str, state: &State<'_, AppState>) -> Result 100 { - map.retain(|_, last| now.duration_since(*last) < Duration::from_secs(5)); + map.retain(|_, last| { + now.duration_since(*last) < Duration::from_secs(5) + }); } if let Some(last) = map.get(path) { @@ -2103,10 +2278,13 @@ fn setup_file_watcher( let modified = std::fs::metadata(path) .ok() .and_then(|m| m.modified().ok()) - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) + .and_then(|t| { + t.duration_since(std::time::UNIX_EPOCH).ok() + }) .map(|d| d.as_secs() as i64) .unwrap_or(0); - let _ = search_index.index_note(¬e_id, &title, &content, modified); + let _ = search_index + .index_note(¬e_id, &title, &content, modified); } Err(_) => { // File gone between event and read — treat as deletion @@ -2170,11 +2348,7 @@ fn start_file_watcher(app: AppHandle, state: State) -> Result<(), Stri // Clean up debounce map before starting cleanup_debounce_map(&state.debounce_map); - let watcher_state = setup_file_watcher( - app, - &folder, - Arc::clone(&state.debounce_map), - )?; + let watcher_state = setup_file_watcher(app, &folder, Arc::clone(&state.debounce_map))?; let mut file_watcher = state.file_watcher.lock().expect("file watcher mutex"); *file_watcher = Some(watcher_state); @@ -2439,11 +2613,9 @@ async fn git_get_status(state: State<'_, AppState>) -> Result { - tauri::async_runtime::spawn_blocking(move || { - git::get_status(&PathBuf::from(path)) - }) - .await - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || git::get_status(&PathBuf::from(path))) + .await + .map_err(|e| e.to_string()) } None => Ok(git::GitStatus::default()), } @@ -2453,14 +2625,15 @@ async fn git_get_status(state: State<'_, AppState>) -> Result) -> Result<(), String> { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? }; - tauri::async_runtime::spawn_blocking(move || { - git::git_init(&PathBuf::from(folder)) - }) - .await - .map_err(|e| e.to_string())? + tauri::async_runtime::spawn_blocking(move || git::git_init(&PathBuf::from(folder))) + .await + .map_err(|e| e.to_string())? } #[tauri::command] @@ -2471,13 +2644,11 @@ async fn git_commit(message: String, state: State<'_, AppState>) -> Result { - tauri::async_runtime::spawn_blocking(move || { - git::commit_all(&PathBuf::from(path), &message) - }) - .await - .map_err(|e| e.to_string()) - } + Some(path) => tauri::async_runtime::spawn_blocking(move || { + git::commit_all(&PathBuf::from(path), &message) + }) + .await + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -2494,13 +2665,9 @@ async fn git_push(state: State<'_, AppState>) -> Result }; match folder { - Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::push(&PathBuf::from(path)) - }) + Some(path) => tauri::async_runtime::spawn_blocking(move || git::push(&PathBuf::from(path))) .await - .map_err(|e| e.to_string()) - } + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -2518,11 +2685,9 @@ async fn git_fetch(state: State<'_, AppState>) -> Result match folder { Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::fetch(&PathBuf::from(path)) - }) - .await - .map_err(|e| e.to_string()) + tauri::async_runtime::spawn_blocking(move || git::fetch(&PathBuf::from(path))) + .await + .map_err(|e| e.to_string()) } None => Ok(git::GitResult { success: false, @@ -2540,13 +2705,9 @@ async fn git_pull(state: State<'_, AppState>) -> Result }; match folder { - Some(path) => { - tauri::async_runtime::spawn_blocking(move || { - git::pull(&PathBuf::from(path)) - }) + Some(path) => tauri::async_runtime::spawn_blocking(move || git::pull(&PathBuf::from(path))) .await - .map_err(|e| e.to_string()) - } + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -2563,13 +2724,11 @@ async fn git_add_remote(url: String, state: State<'_, AppState>) -> Result { - tauri::async_runtime::spawn_blocking(move || { - git::add_remote(&PathBuf::from(path), &url) - }) - .await - .map_err(|e| e.to_string()) - } + Some(path) => tauri::async_runtime::spawn_blocking(move || { + git::add_remote(&PathBuf::from(path), &url) + }) + .await + .map_err(|e| e.to_string()), None => Ok(git::GitResult { success: false, message: None, @@ -2592,10 +2751,9 @@ async fn git_push_with_upstream(state: State<'_, AppState>) -> Result { - if !branch - .chars() - .all(|c| c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.')) - { + if !branch.chars().all(|c| { + c.is_ascii_alphanumeric() || matches!(c, '/' | '-' | '_' | '.') + }) { return git::GitResult { success: false, message: None, @@ -2727,26 +2885,42 @@ fn cli_target_path() -> PathBuf { #[tauri::command] fn get_cli_status() -> Result { #[cfg(not(target_os = "macos"))] - return Ok(CliStatus { supported: false, installed: false, path: None }); + return Ok(CliStatus { + supported: false, + installed: false, + path: None, + }); #[cfg(target_os = "macos")] { let target = cli_target_path(); if !target.exists() && target.symlink_metadata().is_err() { - return Ok(CliStatus { supported: true, installed: false, path: None }); + return Ok(CliStatus { + supported: true, + installed: false, + path: None, + }); } // Verify this is our wrapper (has marker) and points to the current binary let content = std::fs::read_to_string(&target).unwrap_or_default(); if !content.contains(SCRATCH_CLI_MARKER) { // Foreign binary at this path — don't claim it as ours - return Ok(CliStatus { supported: true, installed: false, path: None }); + return Ok(CliStatus { + supported: true, + installed: false, + path: None, + }); } let current_exe = std::env::current_exe() .map(|p| p.to_string_lossy().into_owned()) .unwrap_or_default(); if !current_exe.is_empty() && !content.contains(¤t_exe) { // Our wrapper but points to a moved/deleted binary — needs reinstall - return Ok(CliStatus { supported: true, installed: false, path: None }); + return Ok(CliStatus { + supported: true, + installed: false, + path: None, + }); } Ok(CliStatus { supported: true, @@ -2785,8 +2959,8 @@ fn install_cli() -> Result { .map_err(|e| format!("Failed to remove existing file: {}", e))?; } - let exe_path = std::env::current_exe() - .map_err(|e| format!("Cannot find exe path: {}", e))?; + let exe_path = + std::env::current_exe().map_err(|e| format!("Cannot find exe path: {}", e))?; // Shell-escape the exe path using single quotes to prevent // interpretation of $, `, ", and other metacharacters. @@ -2797,8 +2971,7 @@ fn install_cli() -> Result { // the terminal is not blocked waiting for the GUI app to exit. let script = format!( "#!/bin/sh\n{}\nnohup {} \"$@\" >/dev/null 2>&1 &\n", - SCRATCH_CLI_MARKER, - escaped_exe + SCRATCH_CLI_MARKER, escaped_exe ); std::fs::write(&target, script.as_bytes()) .map_err(|e| format!("Failed to write CLI script: {}", e))?; @@ -3086,7 +3259,10 @@ async fn ai_execute_claude( ) -> Result { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? }; let path = PathBuf::from(&file_path); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); @@ -3154,7 +3330,10 @@ async fn ai_execute_opencode( ) -> Result { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? }; let path = PathBuf::from(&file_path); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); @@ -3221,7 +3400,10 @@ async fn ai_execute_ollama( ) -> Result { let folder = { let app_config = state.app_config.read().expect("app_config read lock"); - app_config.notes_folder.clone().ok_or("Notes folder not set")? + app_config + .notes_folder + .clone() + .ok_or("Notes folder not set")? }; let path = PathBuf::from(&file_path); let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); @@ -3323,7 +3505,10 @@ async fn ai_execute_ollama( return Ok(AiExecutionResult { success: false, output: String::new(), - error: Some("Authentication required. Run `ollama login` in your terminal to sign in.".to_string()), + error: Some( + "Authentication required. Run `ollama login` in your terminal to sign in." + .to_string(), + ), }); } } @@ -3544,8 +3729,18 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_updater::Builder::new().build()) + .plugin( + tauri_plugin_window_state::Builder::default() + .with_state_flags( + tauri_plugin_window_state::StateFlags::POSITION + | tauri_plugin_window_state::StateFlags::MAXIMIZED + | tauri_plugin_window_state::StateFlags::VISIBLE + | tauri_plugin_window_state::StateFlags::FULLSCREEN, + ) + .build(), + ) .setup(|app| { - // Load app config on startup (contains notes folder path) + // Load app config on startup. let mut app_config = load_app_config(app.handle()); // Normalize legacy/invalid saved paths (e.g. file:// URI from older builds) @@ -3561,7 +3756,10 @@ pub fn run() { Ok(normalized) => { // Path is structurally valid but not currently a directory // (e.g., unmounted drive). Preserve the user's preference. - eprintln!("Notes folder not found (may be temporarily unavailable): {:?}", normalized); + eprintln!( + "Notes folder not found (may be temporarily unavailable): {:?}", + normalized + ); } Err(_) => { app_config.notes_folder = None; @@ -3590,6 +3788,8 @@ pub fn run() { None }; + apply_saved_main_window_size(app.handle(), &app_config); + let state = AppState { app_config: RwLock::new(app_config), settings: RwLock::new(settings), @@ -3597,11 +3797,19 @@ pub fn run() { file_watcher: Mutex::new(None), search_index: Mutex::new(search_index), debounce_map: Arc::new(Mutex::new(HashMap::new())), + main_window_size_dirty: AtomicBool::new(false), }; app.manage(state); // Add notes folder to asset protocol scope so images can be served - if let Some(ref folder) = app.state::().app_config.read().expect("app_config read lock").notes_folder.clone() { + if let Some(ref folder) = app + .state::() + .app_config + .read() + .expect("app_config read lock") + .notes_folder + .clone() + { let _ = app.asset_protocol_scope().allow_directory(folder, true); } @@ -3658,6 +3866,45 @@ pub fn run() { } } } + + if window.label() != "main" { + return; + } + + let app_handle = window.app_handle(); + let Some(state) = app_handle.try_state::() else { + return; + }; + + match event { + tauri::WindowEvent::Resized(size) => { + if let Ok(scale_factor) = window.scale_factor() { + capture_main_window_size_with_scale( + window, + &state, + *size, + scale_factor, + ); + } + } + tauri::WindowEvent::ScaleFactorChanged { + scale_factor, + new_inner_size, + .. + } => { + capture_main_window_size_with_scale( + window, + &state, + *new_inner_size, + *scale_factor, + ); + } + tauri::WindowEvent::CloseRequested { .. } => { + capture_main_window_size(window, &state); + persist_app_config_if_dirty(app_handle, &state); + } + _ => {} + } }) .invoke_handler(tauri::generate_handler![ get_notes_folder, @@ -3717,19 +3964,28 @@ pub fn run() { // Use .run() callback to handle macOS "Open With" file events // RunEvent::Opened is macOS-only in Tauri v2 - app.run(|_app_handle, _event| { + app.run(|app_handle, event| { #[cfg(target_os = "macos")] - if let tauri::RunEvent::Opened { urls } = _event { + if let tauri::RunEvent::Opened { urls } = &event { for url in urls { if let Ok(path) = url.to_file_path() { if is_markdown_extension(&path) && path.is_file() - && !try_select_in_notes_folder(_app_handle, &path) + && !try_select_in_notes_folder(app_handle, &path) { - let _ = create_preview_window(_app_handle, &path.to_string_lossy()); + let _ = create_preview_window(app_handle, &path.to_string_lossy()); } } } } + + if matches!(event, tauri::RunEvent::Exit) { + if let Some(state) = app_handle.try_state::() { + if let Some(main_window) = app_handle.get_webview_window("main") { + capture_main_window_size(&main_window.as_ref().window(), &state); + } + persist_app_config_if_dirty(app_handle, &state); + } + } }); }