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:
@@ -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;
|
||||
|
||||
|
||||
@@ -78,6 +78,20 @@ export function AppShell() {
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// check if Explorer context menu points to the wrong exe (app was moved)
|
||||
useEffect(() => {
|
||||
invoke<string>("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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<NominaPreset>("import_preset", { path });
|
||||
loadPipeline(preset.rules);
|
||||
if (preset.filters) {
|
||||
|
||||
@@ -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})`);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
|
||||
|
||||
56
ui/src/lib/storage.ts
Normal file
56
ui/src/lib/storage.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { StateStorage } from "zustand/middleware";
|
||||
|
||||
let cache: Record<string, string> = {};
|
||||
let loaded = false;
|
||||
|
||||
async function loadAll(): Promise<Record<string, string>> {
|
||||
if (loaded) return cache;
|
||||
try {
|
||||
const raw = await invoke<string>("load_app_state");
|
||||
cache = JSON.parse(raw) || {};
|
||||
} catch {
|
||||
cache = {};
|
||||
}
|
||||
loaded = true;
|
||||
return cache;
|
||||
}
|
||||
|
||||
let saveTimer: ReturnType<typeof setTimeout> | 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();
|
||||
}
|
||||
@@ -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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
initStorage().then(() => {
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<RuleState>((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);
|
||||
|
||||
@@ -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<SettingsState>()(
|
||||
}),
|
||||
{
|
||||
name: "nomina-settings",
|
||||
storage: createJSONStorage(() => portableStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user