feat: persist project selection
This commit is contained in:
37
.living_spec/specs/functional/PERSISTENCE.md
Normal file
37
.living_spec/specs/functional/PERSISTENCE.md
Normal file
@@ -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<String>.
|
||||||
|
|
||||||
|
## 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.
|
||||||
@@ -81,9 +81,11 @@ To support both Remote and Local models, the system implements a `ModelProvider`
|
|||||||
* `uuid`: For unique message IDs.
|
* `uuid`: For unique message IDs.
|
||||||
* `chrono`: For timestamps.
|
* `chrono`: For timestamps.
|
||||||
* `tauri-plugin-dialog`: Native system dialogs.
|
* `tauri-plugin-dialog`: Native system dialogs.
|
||||||
|
* `tauri-plugin-store`: Persistent key-value storage.
|
||||||
* **JavaScript:**
|
* **JavaScript:**
|
||||||
* `@tauri-apps/api`: Tauri Bridge.
|
* `@tauri-apps/api`: Tauri Bridge.
|
||||||
* `@tauri-apps/plugin-dialog`: Dialog API.
|
* `@tauri-apps/plugin-dialog`: Dialog API.
|
||||||
|
* `@tauri-apps/plugin-store`: Store API.
|
||||||
* `react-markdown`: For rendering chat responses.
|
* `react-markdown`: For rendering chat responses.
|
||||||
|
|
||||||
## Safety & Sandbox
|
## Safety & Sandbox
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.4.2",
|
"@tauri-apps/plugin-dialog": "^2.4.2",
|
||||||
"@tauri-apps/plugin-opener": "^2",
|
"@tauri-apps/plugin-opener": "^2",
|
||||||
|
"@tauri-apps/plugin-store": "^2.4.1",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-markdown": "^10.1.0"
|
"react-markdown": "^10.1.0"
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
|||||||
'@tauri-apps/plugin-opener':
|
'@tauri-apps/plugin-opener':
|
||||||
specifier: ^2
|
specifier: ^2
|
||||||
version: 2.5.2
|
version: 2.5.2
|
||||||
|
'@tauri-apps/plugin-store':
|
||||||
|
specifier: ^2.4.1
|
||||||
|
version: 2.4.1
|
||||||
react:
|
react:
|
||||||
specifier: ^19.1.0
|
specifier: ^19.1.0
|
||||||
version: 19.2.3
|
version: 19.2.3
|
||||||
@@ -496,6 +499,9 @@ packages:
|
|||||||
'@tauri-apps/plugin-opener@2.5.2':
|
'@tauri-apps/plugin-opener@2.5.2':
|
||||||
resolution: {integrity: sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew==}
|
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':
|
'@types/babel__core@7.20.5':
|
||||||
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
|
||||||
|
|
||||||
@@ -1285,6 +1291,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tauri-apps/api': 2.9.1
|
'@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':
|
'@types/babel__core@7.20.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/parser': 7.28.5
|
'@babel/parser': 7.28.5
|
||||||
|
|||||||
29
src-tauri/Cargo.lock
generated
29
src-tauri/Cargo.lock
generated
@@ -2066,6 +2066,7 @@ dependencies = [
|
|||||||
"tauri-build",
|
"tauri-build",
|
||||||
"tauri-plugin-dialog",
|
"tauri-plugin-dialog",
|
||||||
"tauri-plugin-opener",
|
"tauri-plugin-opener",
|
||||||
|
"tauri-plugin-store",
|
||||||
"uuid",
|
"uuid",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
]
|
]
|
||||||
@@ -4121,6 +4122,22 @@ dependencies = [
|
|||||||
"zbus",
|
"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]]
|
[[package]]
|
||||||
name = "tauri-runtime"
|
name = "tauri-runtime"
|
||||||
version = "2.9.2"
|
version = "2.9.2"
|
||||||
@@ -4339,10 +4356,22 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"signal-hook-registry",
|
"signal-hook-registry",
|
||||||
"socket2",
|
"socket2",
|
||||||
|
"tokio-macros",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "tokio-native-tls"
|
name = "tokio-native-tls"
|
||||||
version = "0.3.1"
|
version = "0.3.1"
|
||||||
|
|||||||
@@ -29,4 +29,5 @@ reqwest = { version = "0.12.28", features = ["json", "blocking"] }
|
|||||||
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
uuid = { version = "1.19.0", features = ["v4", "serde"] }
|
||||||
chrono = { version = "0.4.42", features = ["serde"] }
|
chrono = { version = "0.4.42", features = ["serde"] }
|
||||||
async-trait = "0.1.89"
|
async-trait = "0.1.89"
|
||||||
|
tauri-plugin-store = "2.4.1"
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,10 @@
|
|||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"permissions": ["core:default", "opener:default", "dialog:default"]
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"opener:default",
|
||||||
|
"dialog:default",
|
||||||
|
"store:default"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
use crate::state::SessionState;
|
use crate::state::SessionState;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
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
|
// Helper Functions
|
||||||
@@ -32,7 +37,11 @@ fn resolve_path(state: &State<'_, SessionState>, relative_path: &str) -> Result<
|
|||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_project(path: String, state: State<'_, SessionState>) -> Result<String, String> {
|
pub async fn open_project(
|
||||||
|
app: AppHandle,
|
||||||
|
path: String,
|
||||||
|
state: State<'_, SessionState>,
|
||||||
|
) -> Result<String, String> {
|
||||||
let p = PathBuf::from(&path);
|
let p = PathBuf::from(&path);
|
||||||
|
|
||||||
// Validate path existence in blocking thread
|
// Validate path existence in blocking thread
|
||||||
@@ -49,13 +58,73 @@ pub async fn open_project(path: String, state: State<'_, SessionState>) -> Resul
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Task failed: {}", e))??;
|
.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);
|
println!("Project opened: {:?}", p);
|
||||||
Ok(path)
|
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<Option<String>, 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]
|
#[tauri::command]
|
||||||
pub async fn read_file(path: String, state: State<'_, SessionState>) -> Result<String, String> {
|
pub async fn read_file(path: String, state: State<'_, SessionState>) -> Result<String, String> {
|
||||||
let full_path = resolve_path(&state, &path)?;
|
let full_path = resolve_path(&state, &path)?;
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ pub fn run() {
|
|||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.manage(SessionState::default())
|
.manage(SessionState::default())
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::fs::open_project,
|
commands::fs::open_project,
|
||||||
|
commands::fs::close_project,
|
||||||
|
commands::fs::get_current_project,
|
||||||
commands::fs::read_file,
|
commands::fs::read_file,
|
||||||
commands::fs::write_file,
|
commands::fs::write_file,
|
||||||
commands::fs::list_directory,
|
commands::fs::list_directory,
|
||||||
|
|||||||
32
src/App.tsx
32
src/App.tsx
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import { Chat } from "./components/Chat";
|
import { Chat } from "./components/Chat";
|
||||||
@@ -8,6 +8,23 @@ function App() {
|
|||||||
const [projectPath, setProjectPath] = useState<string | null>(null);
|
const [projectPath, setProjectPath] = useState<string | null>(null);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
invoke<string | null>("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() {
|
async function selectProject() {
|
||||||
try {
|
try {
|
||||||
setErrorMsg(null);
|
setErrorMsg(null);
|
||||||
@@ -57,9 +74,20 @@ function App() {
|
|||||||
background: "#f0f0f0",
|
background: "#f0f0f0",
|
||||||
borderRadius: "4px",
|
borderRadius: "4px",
|
||||||
color: "#333",
|
color: "#333",
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
alignItems: "center",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<strong>Active Project:</strong> {projectPath}
|
<span>
|
||||||
|
<strong>Active Project:</strong> {projectPath}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={closeProject}
|
||||||
|
style={{ padding: "5px 10px", fontSize: "0.9em" }}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<hr style={{ margin: "20px 0" }} />
|
<hr style={{ margin: "20px 0" }} />
|
||||||
<Chat />
|
<Chat />
|
||||||
|
|||||||
Reference in New Issue
Block a user