feat: add Tauri state persistence and BvCli trait

- Add Tauri storage adapter for Zustand (tauri-storage.ts)
- Add read_state, write_state, clear_state Tauri commands
- Wire focus-store and nav-store to use Tauri persistence
- Add BvCli trait for bv CLI mocking with response types
- Add BvError and McError conversion for bv errors
- Add cleanup_tmp_files tests for bridge
- Fix linter-introduced tauri_specta::command issues

Closes bd-2x6, bd-gil, bd-3px

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
teernisse
2026-02-26 10:05:53 -05:00
parent 443db24fb3
commit 087b588d71
14 changed files with 877 additions and 20 deletions

109
src/lib/tauri-storage.ts Normal file
View File

@@ -0,0 +1,109 @@
/**
* Tauri storage adapter for Zustand persist middleware.
*
* Stores Zustand state in `~/.local/share/mc/state.json` via Tauri backend
* instead of browser localStorage. Falls back to localStorage in browser context.
*/
import { invoke } from "@tauri-apps/api/core";
import type { StateStorage } from "zustand/middleware";
/**
* Create a storage adapter that persists to Tauri backend.
*
* Uses the `read_state`, `write_state`, and `clear_state` Tauri commands.
*/
export function createTauriStorage(): StateStorage {
return {
getItem: async (_name: string): Promise<string | null> => {
try {
const state = await invoke<Record<string, unknown> | null>("read_state");
if (state === null) {
return null;
}
return JSON.stringify(state);
} catch (error) {
console.warn("[tauri-storage] Failed to read state:", error);
return null;
}
},
setItem: async (_name: string, value: string): Promise<void> => {
try {
const state = JSON.parse(value) as Record<string, unknown>;
await invoke("write_state", { state });
} catch (error) {
console.warn("[tauri-storage] Failed to write state:", error);
}
},
removeItem: async (_name: string): Promise<void> => {
try {
await invoke("clear_state");
} catch (error) {
console.warn("[tauri-storage] Failed to clear state:", error);
}
},
};
}
/**
* Check if running in Tauri context.
*/
function isTauriContext(): boolean {
return typeof window !== "undefined" && "__TAURI__" in window;
}
/**
* Create a localStorage-based storage adapter for browser context.
*/
function createLocalStorageAdapter(): StateStorage {
return {
getItem: (name: string): string | null => {
if (typeof window === "undefined") return null;
return localStorage.getItem(name);
},
setItem: (name: string, value: string): void => {
if (typeof window === "undefined") return;
localStorage.setItem(name, value);
},
removeItem: (name: string): void => {
if (typeof window === "undefined") return;
localStorage.removeItem(name);
},
};
}
/**
* Get the appropriate storage adapter for the current context.
*
* - In Tauri: Uses backend persistence to ~/.local/share/mc/state.json
* - In browser: Falls back to localStorage
*/
export async function initializeStorage(): Promise<StateStorage> {
if (isTauriContext()) {
return createTauriStorage();
}
return createLocalStorageAdapter();
}
/**
* Singleton storage instance.
* Use this in store definitions to avoid recreating the adapter.
*/
let _storage: StateStorage | null = null;
/**
* Get the storage adapter (lazy initialization).
*
* In Tauri context, returns Tauri storage.
* In browser context, returns localStorage wrapper.
*/
export function getStorage(): StateStorage {
if (_storage === null) {
_storage = isTauriContext() ? createTauriStorage() : createLocalStorageAdapter();
}
return _storage;
}

View File

@@ -6,7 +6,8 @@
*/
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { persist, createJSONStorage } from "zustand/middleware";
import { getStorage } from "@/lib/tauri-storage";
import type { FocusAction, FocusItem } from "@/lib/types";
export interface FocusState {
@@ -108,6 +109,7 @@ export const useFocusStore = create<FocusState>()(
}),
{
name: "mc-focus-store",
storage: createJSONStorage(() => getStorage()),
partialize: (state) => ({
current: state.current,
queue: state.queue,

View File

@@ -6,7 +6,8 @@
*/
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { persist, createJSONStorage } from "zustand/middleware";
import { getStorage } from "@/lib/tauri-storage";
export type ViewId = "focus" | "queue" | "inbox";
@@ -23,6 +24,7 @@ export const useNavStore = create<NavState>()(
}),
{
name: "mc-nav-store",
storage: createJSONStorage(() => getStorage()),
}
)
);