portable storage, bug fixes, tooltips

all app data now lives next to the exe (presets, undo, settings,
window state, webview cache). dropped the directories crate.
auto-detects and fixes stale Explorer context menu entries when
the exe is moved. fixed regex case_insensitive field mismatch,
update check type mismatch, drive detection condition, rename
panic on root paths. added tooltips to zoom and browse buttons.
escape key now deselects files instead of wiping the pipeline
This commit is contained in:
2026-03-14 19:31:02 +02:00
parent 6f5b862234
commit 1fed289704
22 changed files with 202 additions and 51 deletions

View File

@@ -15,7 +15,6 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "serde"] }
log = "0.4"
env_logger = "0.11"
directories = "6"
anyhow = "1"
winreg = "0.55"
tokio = { version = "1", features = ["full"] }

View File

@@ -93,6 +93,42 @@ pub fn unregister_context_menu() -> Result<(), String> {
Err("Context menu registration is only supported on Windows".to_string())
}
/// Check if Explorer context menu entries exist and whether they point to the current exe.
/// Returns: "ok" if registered and correct, "stale" if registered but wrong path,
/// "none" if not registered.
#[tauri::command]
pub fn check_context_menu_path() -> Result<String, String> {
#[cfg(target_os = "windows")]
{
let exe = std::env::current_exe()
.map_err(|e| format!("Failed to get exe path: {}", e))?;
let exe_str = exe.to_string_lossy().to_string();
let expected_cmd = format!("\"{}\"", exe_str);
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let cmd_path = r"Software\Classes\*\shell\Nomina\command";
match hkcu.open_subkey(cmd_path) {
Ok(key) => {
let val: String = key.get_value("").unwrap_or_default();
if val.starts_with(&expected_cmd) {
Ok("ok".to_string())
} else {
// auto-fix: re-register with current exe path
drop(key);
let _ = crate::commands::context_menu::unregister_context_menu();
crate::commands::context_menu::register_context_menu()?;
Ok("fixed".to_string())
}
}
Err(_) => Ok("none".to_string()),
}
}
#[cfg(not(target_os = "windows"))]
Ok("none".to_string())
}
/// Given CLI args (paths), figure out what folder to open and which files to select.
/// Returns (folder_path, selected_file_paths).
#[tauri::command]

View File

@@ -4,3 +4,4 @@ pub mod presets;
pub mod undo;
pub mod context_menu;
pub mod updates;
pub mod state;

View File

@@ -63,9 +63,7 @@ pub struct PresetInfo {
}
fn get_presets_dir() -> PathBuf {
let dirs = directories::ProjectDirs::from("com", "nomina", "Nomina")
.expect("failed to get app data directory");
dirs.data_dir().join("presets")
crate::portable::data_dir().join("presets")
}
fn sanitize_filename(name: &str) -> String {

View File

@@ -178,11 +178,13 @@ pub async fn execute_rename(
if !bp.is_empty() {
PathBuf::from(bp)
} else {
let first_parent = valid[0].original_path.parent().unwrap();
let first_parent = valid[0].original_path.parent()
.ok_or("Cannot determine backup directory")?;
first_parent.join("_nomina_backup")
}
} else {
let first_parent = valid[0].original_path.parent().unwrap();
let first_parent = valid[0].original_path.parent()
.ok_or("Cannot determine backup directory")?;
first_parent.join("_nomina_backup")
};
std::fs::create_dir_all(&backup_dir)
@@ -203,7 +205,13 @@ pub async fn execute_rename(
let mut undo_entries = Vec::new();
for op in &valid {
let parent = op.original_path.parent().unwrap();
let parent = match op.original_path.parent() {
Some(p) => p,
None => {
failed.push(format!("{}: cannot determine parent directory", op.original_name));
continue;
}
};
let new_path = parent.join(&op.new_name);
// if target exists and isn't another file we're renaming
@@ -261,7 +269,5 @@ pub async fn execute_rename(
}
fn get_undo_log_path() -> PathBuf {
let dirs = directories::ProjectDirs::from("com", "nomina", "Nomina")
.expect("failed to get app data directory");
dirs.data_dir().join("undo.json")
crate::portable::data_dir().join("undo.json")
}

View File

@@ -0,0 +1,23 @@
use std::path::PathBuf;
fn state_file() -> PathBuf {
crate::portable::data_dir().join("state.json")
}
#[tauri::command]
pub async fn load_app_state() -> Result<String, String> {
let path = state_file();
if !path.exists() {
return Ok("{}".to_string());
}
std::fs::read_to_string(&path).map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn save_app_state(json: String) -> Result<(), String> {
let path = state_file();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
std::fs::write(&path, json).map_err(|e| e.to_string())
}

View File

@@ -78,7 +78,5 @@ fn execute_undo(batch: &UndoBatch) -> Result<UndoReport, String> {
}
fn get_undo_log_path() -> PathBuf {
let dirs = directories::ProjectDirs::from("com", "nomina", "Nomina")
.expect("failed to get app data directory");
dirs.data_dir().join("undo.json")
crate::portable::data_dir().join("undo.json")
}

View File

@@ -1,8 +1,13 @@
#![windows_subsystem = "windows"]
mod commands;
mod portable;
fn main() {
// redirect WebView2 data to portable location next to the exe
let webview_data = portable::data_dir().join("webview");
std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &webview_data);
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_shell::init())
@@ -25,8 +30,11 @@ fn main() {
commands::context_menu::get_launch_args,
commands::context_menu::register_context_menu,
commands::context_menu::unregister_context_menu,
commands::context_menu::check_context_menu_path,
commands::context_menu::resolve_launch_paths,
commands::updates::check_for_updates,
commands::state::load_app_state,
commands::state::save_app_state,
])
.run(tauri::generate_context!())
.expect("error running nomina");

View File

@@ -0,0 +1,14 @@
use std::path::PathBuf;
use std::sync::OnceLock;
static DATA_DIR: OnceLock<PathBuf> = OnceLock::new();
/// Returns the portable data directory next to the executable.
/// All app data (presets, undo history, state) is stored here.
pub fn data_dir() -> &'static PathBuf {
DATA_DIR.get_or_init(|| {
let exe = std::env::current_exe().expect("cannot determine executable path");
let exe_dir = exe.parent().expect("executable has no parent directory");
exe_dir.join("data")
})
}

View File

@@ -24,7 +24,7 @@ pub fn parse_bru_file(path: &Path) -> crate::Result<NominaPreset> {
rules.push(make_rule("regex", serde_json::json!({
"pattern": pattern,
"replacement": replace,
"case_sensitive": case,
"case_insensitive": !case,
"global": true,
"target": "Name",
})));