From e229f2efa8e58d5e747cac3f1b5e043b4992fe61 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 24 Dec 2025 17:46:27 +0000 Subject: [PATCH] feat: persist project selection --- .living_spec/specs/functional/PERSISTENCE.md | 37 ++++++++++ .living_spec/specs/tech/STACK.md | 2 + package.json | 1 + pnpm-lock.yaml | 10 +++ src-tauri/Cargo.lock | 29 ++++++++ src-tauri/Cargo.toml | 1 + src-tauri/capabilities/default.json | 7 +- src-tauri/src/commands/fs.rs | 77 +++++++++++++++++++- src-tauri/src/lib.rs | 3 + src/App.tsx | 32 +++++++- 10 files changed, 192 insertions(+), 7 deletions(-) create mode 100644 .living_spec/specs/functional/PERSISTENCE.md diff --git a/.living_spec/specs/functional/PERSISTENCE.md b/.living_spec/specs/functional/PERSISTENCE.md new file mode 100644 index 0000000..c329b1b --- /dev/null +++ b/.living_spec/specs/functional/PERSISTENCE.md @@ -0,0 +1,37 @@ +# Functional Spec: Persistence + +## 1. Scope +The application needs to persist user preferences and session state across restarts. +The primary use case is remembering the **Last Opened Project**. + +## 2. Storage Mechanism +* **Library:** `tauri-plugin-store` +* **File:** `store.json` (located in the App Data directory). +* **Keys:** + * `last_project_path`: String (Absolute path). + * (Future) `theme`: String. + * (Future) `recent_projects`: Array. + +## 3. Startup Logic +1. **Backend Init:** + * Load `store.json`. + * Read `last_project_path`. + * Verify path exists and is a directory. + * If valid: + * Update `SessionState`. + * Return "Project Loaded" status to Frontend on init. + * If invalid/missing: + * Clear key. + * Remain in `Idle` state. + +## 4. Frontend Logic +* **On Mount:** + * Call `get_current_project()` command. + * If returns path -> Show Workspace. + * If returns null -> Show Selection Screen. +* **On "Open Project":** + * After successful open, save path to store. +* **On "Close Project":** + * Clear `SessionState`. + * Remove `last_project_path` from store. + * Show Selection Screen. diff --git a/.living_spec/specs/tech/STACK.md b/.living_spec/specs/tech/STACK.md index fa1aae8..543ab34 100644 --- a/.living_spec/specs/tech/STACK.md +++ b/.living_spec/specs/tech/STACK.md @@ -81,9 +81,11 @@ To support both Remote and Local models, the system implements a `ModelProvider` * `uuid`: For unique message IDs. * `chrono`: For timestamps. * `tauri-plugin-dialog`: Native system dialogs. + * `tauri-plugin-store`: Persistent key-value storage. * **JavaScript:** * `@tauri-apps/api`: Tauri Bridge. * `@tauri-apps/plugin-dialog`: Dialog API. + * `@tauri-apps/plugin-store`: Store API. * `react-markdown`: For rendering chat responses. ## Safety & Sandbox diff --git a/package.json b/package.json index 1b54b9b..139d3f6 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2.4.2", "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-store": "^2.4.1", "react": "^19.1.0", "react-dom": "^19.1.0", "react-markdown": "^10.1.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bdc81d0..0dbe220 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@tauri-apps/plugin-opener': specifier: ^2 version: 2.5.2 + '@tauri-apps/plugin-store': + specifier: ^2.4.1 + version: 2.4.1 react: specifier: ^19.1.0 version: 19.2.3 @@ -496,6 +499,9 @@ packages: '@tauri-apps/plugin-opener@2.5.2': resolution: {integrity: sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew==} + '@tauri-apps/plugin-store@2.4.1': + resolution: {integrity: sha512-ckGSEzZ5Ii4Hf2D5x25Oqnm2Zf9MfDWAzR+volY0z/OOBz6aucPKEY0F649JvQ0Vupku6UJo7ugpGRDOFOunkA==} + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1285,6 +1291,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-store@2.4.1': + dependencies: + '@tauri-apps/api': 2.9.1 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 17ca361..6ab83f4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -2066,6 +2066,7 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-store", "uuid", "walkdir", ] @@ -4121,6 +4122,22 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-store" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59a77036340a97eb5bbe1b3209c31e5f27f75e6f92a52fd9dd4b211ef08bf310" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "tauri-runtime" version = "2.9.2" @@ -4339,10 +4356,22 @@ dependencies = [ "pin-project-lite", "signal-hook-registry", "socket2", + "tokio-macros", "tracing", "windows-sys 0.61.2", ] +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "tokio-native-tls" version = "0.3.1" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e1b1073..e9855b6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -29,4 +29,5 @@ reqwest = { version = "0.12.28", features = ["json", "blocking"] } uuid = { version = "1.19.0", features = ["v4", "serde"] } chrono = { version = "0.4.42", features = ["serde"] } async-trait = "0.1.89" +tauri-plugin-store = "2.4.1" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 803b0a6..e7f9d8b 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -3,5 +3,10 @@ "identifier": "default", "description": "Capability for the main window", "windows": ["main"], - "permissions": ["core:default", "opener:default", "dialog:default"] + "permissions": [ + "core:default", + "opener:default", + "dialog:default", + "store:default" + ] } diff --git a/src-tauri/src/commands/fs.rs b/src-tauri/src/commands/fs.rs index fe90ab8..3469943 100644 --- a/src-tauri/src/commands/fs.rs +++ b/src-tauri/src/commands/fs.rs @@ -1,8 +1,13 @@ use crate::state::SessionState; use serde::Serialize; +use serde_json::json; use std::fs; use std::path::PathBuf; -use tauri::State; +use tauri::{AppHandle, State}; +use tauri_plugin_store::StoreExt; + +const STORE_PATH: &str = "store.json"; +const KEY_LAST_PROJECT: &str = "last_project_path"; // ----------------------------------------------------------------------------- // Helper Functions @@ -32,7 +37,11 @@ fn resolve_path(state: &State<'_, SessionState>, relative_path: &str) -> Result< // ----------------------------------------------------------------------------- #[tauri::command] -pub async fn open_project(path: String, state: State<'_, SessionState>) -> Result { +pub async fn open_project( + app: AppHandle, + path: String, + state: State<'_, SessionState>, +) -> Result { let p = PathBuf::from(&path); // Validate path existence in blocking thread @@ -49,13 +58,73 @@ pub async fn open_project(path: String, state: State<'_, SessionState>) -> Resul .await .map_err(|e| format!("Task failed: {}", e))??; - let mut root = state.project_root.lock().map_err(|e| e.to_string())?; - *root = Some(p.clone()); + { + let mut root = state.project_root.lock().map_err(|e| e.to_string())?; + *root = Some(p.clone()); + } + + // Persist to store + let store = app + .store(STORE_PATH) + .map_err(|e| format!("Failed to access store: {}", e))?; + store.set(KEY_LAST_PROJECT, json!(path)); + let _ = store.save(); println!("Project opened: {:?}", p); Ok(path) } +#[tauri::command] +pub async fn close_project(app: AppHandle, state: State<'_, SessionState>) -> Result<(), String> { + // Clear session state + { + let mut root = state.project_root.lock().map_err(|e| e.to_string())?; + *root = None; + } + + // Clear from store + let store = app + .store(STORE_PATH) + .map_err(|e| format!("Failed to access store: {}", e))?; + store.delete(KEY_LAST_PROJECT); + let _ = store.save(); + + Ok(()) +} + +#[tauri::command] +pub async fn get_current_project( + app: AppHandle, + state: State<'_, SessionState>, +) -> Result, String> { + // 1. Check in-memory state + { + let root = state.project_root.lock().map_err(|e| e.to_string())?; + if let Some(path) = &*root { + return Ok(Some(path.to_string_lossy().to_string())); + } + } + + // 2. Check store + let store = app + .store(STORE_PATH) + .map_err(|e| format!("Failed to access store: {}", e))?; + + if let Some(val) = store.get(KEY_LAST_PROJECT) { + if let Some(path_str) = val.as_str() { + let p = PathBuf::from(path_str); + if p.exists() && p.is_dir() { + // Update session state + let mut root = state.project_root.lock().map_err(|e| e.to_string())?; + *root = Some(p.clone()); + return Ok(Some(path_str.to_string())); + } + } + } + + Ok(None) +} + #[tauri::command] pub async fn read_file(path: String, state: State<'_, SessionState>) -> Result { let full_path = resolve_path(&state, &path)?; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d4ecf33..ffd5666 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,9 +9,12 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_store::Builder::default().build()) .manage(SessionState::default()) .invoke_handler(tauri::generate_handler![ commands::fs::open_project, + commands::fs::close_project, + commands::fs::get_current_project, commands::fs::read_file, commands::fs::write_file, commands::fs::list_directory, diff --git a/src/App.tsx b/src/App.tsx index 622350f..ccd30fa 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { invoke } from "@tauri-apps/api/core"; import { open } from "@tauri-apps/plugin-dialog"; import { Chat } from "./components/Chat"; @@ -8,6 +8,23 @@ function App() { const [projectPath, setProjectPath] = useState(null); const [errorMsg, setErrorMsg] = useState(null); + useEffect(() => { + invoke("get_current_project") + .then((path) => { + if (path) setProjectPath(path); + }) + .catch((e) => console.error(e)); + }, []); + + async function closeProject() { + try { + await invoke("close_project"); + setProjectPath(null); + } catch (e) { + console.error(e); + } + } + async function selectProject() { try { setErrorMsg(null); @@ -57,9 +74,20 @@ function App() { background: "#f0f0f0", borderRadius: "4px", color: "#333", + display: "flex", + justifyContent: "space-between", + alignItems: "center", }} > - Active Project: {projectPath} + + Active Project: {projectPath} + +