diff --git a/Cargo.lock b/Cargo.lock index 8787864..39f2ebf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -652,15 +652,6 @@ dependencies = [ "crypto-common", ] -[[package]] -name = "directories" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" -dependencies = [ - "dirs-sys", -] - [[package]] name = "dirs" version = "6.0.0" @@ -2220,7 +2211,6 @@ version = "0.1.0" dependencies = [ "anyhow", "chrono", - "directories", "env_logger", "log", "nomina-core", diff --git a/crates/nomina-app/Cargo.toml b/crates/nomina-app/Cargo.toml index f893735..6e49cdc 100644 --- a/crates/nomina-app/Cargo.toml +++ b/crates/nomina-app/Cargo.toml @@ -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"] } diff --git a/crates/nomina-app/src/commands/context_menu.rs b/crates/nomina-app/src/commands/context_menu.rs index 3de9f31..38ef8c5 100644 --- a/crates/nomina-app/src/commands/context_menu.rs +++ b/crates/nomina-app/src/commands/context_menu.rs @@ -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 { + #[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] diff --git a/crates/nomina-app/src/commands/mod.rs b/crates/nomina-app/src/commands/mod.rs index 3bb814b..320b6d8 100644 --- a/crates/nomina-app/src/commands/mod.rs +++ b/crates/nomina-app/src/commands/mod.rs @@ -4,3 +4,4 @@ pub mod presets; pub mod undo; pub mod context_menu; pub mod updates; +pub mod state; diff --git a/crates/nomina-app/src/commands/presets.rs b/crates/nomina-app/src/commands/presets.rs index 415646e..d11eaa6 100644 --- a/crates/nomina-app/src/commands/presets.rs +++ b/crates/nomina-app/src/commands/presets.rs @@ -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 { diff --git a/crates/nomina-app/src/commands/rename.rs b/crates/nomina-app/src/commands/rename.rs index 946047a..99f0ace 100644 --- a/crates/nomina-app/src/commands/rename.rs +++ b/crates/nomina-app/src/commands/rename.rs @@ -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") } diff --git a/crates/nomina-app/src/commands/state.rs b/crates/nomina-app/src/commands/state.rs new file mode 100644 index 0000000..25c41f9 --- /dev/null +++ b/crates/nomina-app/src/commands/state.rs @@ -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 { + 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()) +} diff --git a/crates/nomina-app/src/commands/undo.rs b/crates/nomina-app/src/commands/undo.rs index 91bacd1..67e694f 100644 --- a/crates/nomina-app/src/commands/undo.rs +++ b/crates/nomina-app/src/commands/undo.rs @@ -78,7 +78,5 @@ fn execute_undo(batch: &UndoBatch) -> Result { } 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") } diff --git a/crates/nomina-app/src/main.rs b/crates/nomina-app/src/main.rs index 2b22b6f..fd0494b 100644 --- a/crates/nomina-app/src/main.rs +++ b/crates/nomina-app/src/main.rs @@ -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"); diff --git a/crates/nomina-app/src/portable.rs b/crates/nomina-app/src/portable.rs new file mode 100644 index 0000000..346fd1b --- /dev/null +++ b/crates/nomina-app/src/portable.rs @@ -0,0 +1,14 @@ +use std::path::PathBuf; +use std::sync::OnceLock; + +static DATA_DIR: OnceLock = 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") + }) +} diff --git a/crates/nomina-core/src/bru.rs b/crates/nomina-core/src/bru.rs index 5cd8edf..8027183 100644 --- a/crates/nomina-core/src/bru.rs +++ b/crates/nomina-core/src/bru.rs @@ -24,7 +24,7 @@ pub fn parse_bru_file(path: &Path) -> crate::Result { rules.push(make_rule("regex", serde_json::json!({ "pattern": pattern, "replacement": replace, - "case_sensitive": case, + "case_insensitive": !case, "global": true, "target": "Name", }))); diff --git a/ui/src/components/browser/FileList.tsx b/ui/src/components/browser/FileList.tsx index a9d9c1e..396598e 100644 --- a/ui/src/components/browser/FileList.tsx +++ b/ui/src/components/browser/FileList.tsx @@ -550,10 +550,7 @@ export function FileList() { const hasError = preview?.has_error; const hasConflict = preview?.has_conflict; - // entry index relative to allDisplayEntries for shift-select - const entryIndex = isDir - ? virtualRow.index - parentRow - : virtualRow.index - parentRow; + const entryIndex = virtualRow.index - parentRow; const isOdd = virtualRow.index % 2 === 1; diff --git a/ui/src/components/layout/AppShell.tsx b/ui/src/components/layout/AppShell.tsx index 5f7a11a..7da3bda 100644 --- a/ui/src/components/layout/AppShell.tsx +++ b/ui/src/components/layout/AppShell.tsx @@ -78,6 +78,20 @@ export function AppShell() { .catch(() => {}); }, []); + // check if Explorer context menu points to the wrong exe (app was moved) + useEffect(() => { + invoke("check_context_menu_path") + .then((status) => { + if (status === "fixed") { + toast.info("Explorer context menu updated", { + description: "The app was moved since the context menu was registered. The registry entries have been updated to point to the new location.", + duration: Infinity, + }); + } + }) + .catch(() => {}); + }, []); + const onDividerPointerDown = useCallback((e: React.PointerEvent) => { e.preventDefault(); dragging.current = true; diff --git a/ui/src/components/layout/Sidebar.tsx b/ui/src/components/layout/Sidebar.tsx index 3b3ec5a..b13d470 100644 --- a/ui/src/components/layout/Sidebar.tsx +++ b/ui/src/components/layout/Sidebar.tsx @@ -104,7 +104,7 @@ export function Sidebar() { }, }, ); - if (entries.length >= 0) { + if (entries.length > 0) { drives.push({ path, name: path, children: [], loaded: false, expanded: false }); } } catch { diff --git a/ui/src/components/presets/PresetsDialog.tsx b/ui/src/components/presets/PresetsDialog.tsx index 0d36a13..77a39af 100644 --- a/ui/src/components/presets/PresetsDialog.tsx +++ b/ui/src/components/presets/PresetsDialog.tsx @@ -131,7 +131,7 @@ export function PresetsDialog({ open, onOpenChange }: PresetsDialogProps) { ], }); if (!selected) return; - const path = typeof selected === "string" ? selected : selected; + const path = typeof selected === "string" ? selected : selected[0]; const preset = await invoke("import_preset", { path }); loadPipeline(preset.rules); if (preset.filters) { diff --git a/ui/src/components/settings/SettingsDialog.tsx b/ui/src/components/settings/SettingsDialog.tsx index 9e4ffb0..f7c6b02 100644 --- a/ui/src/components/settings/SettingsDialog.tsx +++ b/ui/src/components/settings/SettingsDialog.tsx @@ -655,8 +655,8 @@ function StartupSection() { setChecking(true); setUpdateStatus(null); try { - const result = await invoke<{ has_update: boolean; latest_version: string; current_version: string }>("check_for_updates"); - if (result.has_update) { + const result = await invoke<{ available: boolean; latest_version: string; current_version: string }>("check_for_updates"); + if (result.available) { setUpdateStatus(`Update available: v${result.latest_version} (current: v${result.current_version})`); } else { setUpdateStatus(`You're up to date (v${result.current_version})`); diff --git a/ui/src/hooks/useKeyboardShortcuts.ts b/ui/src/hooks/useKeyboardShortcuts.ts index 64752bf..07d1f4d 100644 --- a/ui/src/hooks/useKeyboardShortcuts.ts +++ b/ui/src/hooks/useKeyboardShortcuts.ts @@ -1,11 +1,9 @@ import { useEffect } from "react"; import { useFileStore } from "@/stores/fileStore"; -import { useRuleStore } from "@/stores/ruleStore"; export function useKeyboardShortcuts() { const selectAll = useFileStore((s) => s.selectAll); const deselectAll = useFileStore((s) => s.deselectAll); - const resetAllRules = useRuleStore((s) => s.resetAllRules); useEffect(() => { function handler(e: KeyboardEvent) { @@ -18,11 +16,17 @@ export function useKeyboardShortcuts() { deselectAll(); } if (e.key === "Escape") { - resetAllRules(); + const target = e.target as HTMLElement; + const inOverlay = target.closest( + "[data-slot='dialog-content'], [data-slot='popover-content'], [data-slot='context-menu-content'], [data-slot='dropdown-menu-content']" + ); + if (!inOverlay) { + deselectAll(); + } } } window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [selectAll, deselectAll, resetAllRules]); + }, [selectAll, deselectAll]); } diff --git a/ui/src/hooks/useWindowState.ts b/ui/src/hooks/useWindowState.ts index d4613eb..5c49451 100644 --- a/ui/src/hooks/useWindowState.ts +++ b/ui/src/hooks/useWindowState.ts @@ -1,6 +1,7 @@ import { useEffect } from "react"; import { getCurrentWindow, availableMonitors } from "@tauri-apps/api/window"; import { PhysicalPosition, PhysicalSize } from "@tauri-apps/api/dpi"; +import { getStorageItem, setStorageItem } from "@/lib/storage"; const KEY = "nomina-window-state"; const MIN_WIDTH = 400; @@ -52,7 +53,7 @@ export function useWindowState() { // restore (async () => { try { - const raw = localStorage.getItem(KEY); + const raw = getStorageItem(KEY); if (raw) { const s: WindowState = JSON.parse(raw); if (s.maximized) { @@ -90,13 +91,13 @@ export function useWindowState() { try { const maximized = await win.isMaximized(); if (maximized) { - const prev = localStorage.getItem(KEY); + const prev = getStorageItem(KEY); if (prev) { const s: WindowState = JSON.parse(prev); s.maximized = true; - localStorage.setItem(KEY, JSON.stringify(s)); + setStorageItem(KEY, JSON.stringify(s)); } else { - localStorage.setItem(KEY, JSON.stringify({ x: 100, y: 100, width: 1200, height: 800, maximized: true })); + setStorageItem(KEY, JSON.stringify({ x: 100, y: 100, width: 1200, height: 800, maximized: true })); } return; } @@ -109,7 +110,7 @@ export function useWindowState() { height: size.height, maximized: false, }; - localStorage.setItem(KEY, JSON.stringify(state)); + setStorageItem(KEY, JSON.stringify(state)); } catch {} } diff --git a/ui/src/lib/storage.ts b/ui/src/lib/storage.ts new file mode 100644 index 0000000..5c3fccc --- /dev/null +++ b/ui/src/lib/storage.ts @@ -0,0 +1,56 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { StateStorage } from "zustand/middleware"; + +let cache: Record = {}; +let loaded = false; + +async function loadAll(): Promise> { + if (loaded) return cache; + try { + const raw = await invoke("load_app_state"); + cache = JSON.parse(raw) || {}; + } catch { + cache = {}; + } + loaded = true; + return cache; +} + +let saveTimer: ReturnType | null = null; + +function scheduleSave() { + if (saveTimer) clearTimeout(saveTimer); + saveTimer = setTimeout(async () => { + try { + await invoke("save_app_state", { json: JSON.stringify(cache) }); + } catch {} + }, 500); +} + +export async function initStorage() { + await loadAll(); +} + +export const portableStorage: StateStorage = { + getItem(name: string): string | null { + return cache[name] ?? null; + }, + setItem(name: string, value: string): void { + cache[name] = value; + scheduleSave(); + }, + removeItem(name: string): void { + delete cache[name]; + scheduleSave(); + }, +}; + +// direct read/write for non-Zustand stores +export function getStorageItem(key: string): string | null { + return cache[key] ?? null; +} + +export function setStorageItem(key: string, value: string): void { + cache[key] = value; + scheduleSave(); +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 27481e0..2c9f790 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -1,10 +1,13 @@ import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App"; +import { initStorage } from "@/lib/storage"; import "./index.css"; -ReactDOM.createRoot(document.getElementById("root")!).render( - - - , -); +initStorage().then(() => { + ReactDOM.createRoot(document.getElementById("root")!).render( + + + , + ); +}); diff --git a/ui/src/stores/ruleStore.ts b/ui/src/stores/ruleStore.ts index 4ee184b..0976189 100644 --- a/ui/src/stores/ruleStore.ts +++ b/ui/src/stores/ruleStore.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { invoke } from "@tauri-apps/api/core"; import type { PreviewResult } from "@/types/files"; import type { RuleConfig } from "@/types/rules"; +import { getStorageItem, setStorageItem } from "@/lib/storage"; import { useFileStore } from "./fileStore"; import { useSettingsStore } from "./settingsStore"; import { @@ -54,13 +55,13 @@ function nextId(): string { function savePipeline(pipeline: PipelineRule[]) { try { - localStorage.setItem(PIPELINE_KEY, JSON.stringify(pipeline.map((r) => r.config))); + setStorageItem(PIPELINE_KEY, JSON.stringify(pipeline.map((r) => r.config))); } catch {} } function loadSavedPipeline(): RuleConfig[] | null { try { - const raw = localStorage.getItem(PIPELINE_KEY); + const raw = getStorageItem(PIPELINE_KEY); if (raw) return JSON.parse(raw); } catch {} return null; @@ -80,7 +81,7 @@ function createDefault(type: string): RuleConfig { const caseSensitive = useSettingsStore.getState().caseSensitiveMatch; switch (type) { case "replace": return { ...defaultReplace(), match_case: caseSensitive } as RuleConfig; - case "regex": return { ...defaultRegex(), case_sensitive: caseSensitive } as RuleConfig; + case "regex": return { ...defaultRegex(), case_insensitive: !caseSensitive } as RuleConfig; case "remove": return defaultRemove(); case "add": return defaultAdd(); case "case": return defaultCase(); @@ -138,7 +139,7 @@ export const useRuleStore = create((set, get) => ({ const current = get().pipeline; const idx = current.findIndex((r) => r.id === id); if (idx === -1) return; - const clone: PipelineRule = { id: nextId(), config: { ...current[idx].config } }; + const clone: PipelineRule = { id: nextId(), config: JSON.parse(JSON.stringify(current[idx].config)) }; const pipeline = [...current.slice(0, idx + 1), clone, ...current.slice(idx + 1)]; set({ pipeline }); savePipeline(pipeline); diff --git a/ui/src/stores/settingsStore.ts b/ui/src/stores/settingsStore.ts index be8398d..9c8bdc7 100644 --- a/ui/src/stores/settingsStore.ts +++ b/ui/src/stores/settingsStore.ts @@ -1,5 +1,6 @@ import { create } from "zustand"; -import { persist } from "zustand/middleware"; +import { persist, createJSONStorage } from "zustand/middleware"; +import { portableStorage } from "@/lib/storage"; type Theme = "light" | "dark" | "system"; type SortOrder = "name" | "date" | "size" | "type"; @@ -247,6 +248,7 @@ export const useSettingsStore = create()( }), { name: "nomina-settings", + storage: createJSONStorage(() => portableStorage), } ) );