feat(stage4): Rust SQLite 存储层与文件管理命令

This commit is contained in:
Muse
2026-11-13 22:43:29 +08:00
parent ac2c29df8f
commit 0774b70d29
5 changed files with 500 additions and 0 deletions

120
src-tauri/Cargo.lock generated
View File

@@ -8,6 +8,18 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "ahash"
version = "0.8.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
dependencies = [
"cfg-if",
"once_cell",
"version_check",
"zerocopy",
]
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -757,6 +769,18 @@ dependencies = [
"typeid",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1204,12 +1228,30 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1"
dependencies = [
"ahash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "hashlink"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af"
dependencies = [
"hashbrown 0.14.5",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -1739,6 +1781,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.30.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "litemap"
version = "0.8.3"
@@ -1896,11 +1949,13 @@ name = "new-markdown"
version = "0.1.0"
dependencies = [
"encoding_rs",
"rusqlite",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"trash",
]
[[package]]
@@ -2615,6 +2670,20 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "rusqlite"
version = "0.32.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3774,6 +3843,25 @@ dependencies = [
"once_cell",
]
[[package]]
name = "trash"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e13ed7a76ae8b9053f33493745ebb4cd2e5ee1cccd9823bb96ec97b78ec4844"
dependencies = [
"chrono",
"libc",
"log",
"objc2",
"objc2-foundation",
"once_cell",
"percent-encoding",
"scopeguard",
"urlencoding",
"windows",
"windows-core",
]
[[package]]
name = "tray-icon"
version = "0.25.1"
@@ -3839,6 +3927,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.6.0"
@@ -3878,6 +3972,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4502,6 +4602,26 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zerocopy"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "zerofrom"
version = "0.1.8"

View File

@@ -17,3 +17,5 @@ tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
encoding_rs = "0.8"
rusqlite = { version = "0.32", features = ["bundled"] }
trash = "5"

View File

@@ -217,3 +217,157 @@ pub fn list_dir(path: String) -> Result<Vec<DirEntry>, String> {
});
Ok(entries)
}
// ————————————————— 阶段 4:文件状态与管理工作区命令 —————————————————
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FileStat {
pub exists: bool,
/// 修改时间(毫秒,自 Unix 纪元);文件不存在时为 0
pub mtime_ms: i64,
pub size: u64,
}
/// 文件状态探针:外部修改检测的基线与轮询都用它(只读元数据,不读内容)。
#[tauri::command]
pub fn stat_file(path: String) -> FileStat {
match fs::metadata(&path) {
Ok(meta) => {
let mtime_ms = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
FileStat { exists: true, mtime_ms, size: meta.len() }
}
Err(_) => FileStat { exists: false, mtime_ms: 0, size: 0 },
}
}
/// 新建空文本文件;目标已存在时报错(不覆盖)。
#[tauri::command]
pub fn create_text_file(path: String) -> Result<(), String> {
let p = Path::new(&path);
if p.exists() {
return Err("同名文件已存在".to_string());
}
if let Some(parent) = p.parent() {
fs::create_dir_all(parent).map_err(|e| format!("创建目录失败:{e}"))?;
}
fs::write(p, "").map_err(|e| format!("创建文件失败:{e}"))
}
#[tauri::command]
pub fn create_dir(path: String) -> Result<(), String> {
fs::create_dir_all(&path).map_err(|e| format!("创建文件夹失败:{e}"))
}
/// 重命名/移动文件或文件夹;目标已存在时报错。
#[tauri::command]
pub fn rename_path(from: String, to: String) -> Result<(), String> {
if Path::new(&to).exists() {
return Err("目标名称已存在".to_string());
}
fs::rename(&from, &to).map_err(|e| format!("重命名失败:{e}"))
}
/// 删除到系统回收站(失败时退回永久删除仅限文件)。
#[tauri::command]
pub fn delete_to_trash(path: String) -> Result<(), String> {
match trash::delete(&path) {
Ok(()) => Ok(()),
Err(e) => {
let p = Path::new(&path);
if p.is_file() {
fs::remove_file(p).map_err(|e2| format!("删除失败:{e};备用删除也失败:{e2}"))
} else {
Err(format!("删除失败:{e}"))
}
}
}
}
// ————————————————— 阶段 4:SQLite 存储命令(逻辑在 storage.rs) —————————————————
use crate::storage;
use crate::AppDb;
use tauri::State;
fn lock_db<'a>(db: &'a State<'a, AppDb>) -> Result<std::sync::MutexGuard<'a, rusqlite::Connection>, String> {
db.0.lock().map_err(|_| "数据库锁损坏".to_string())
}
#[tauri::command]
pub fn db_recent_list(db: State<'_, AppDb>) -> Result<Vec<storage::RecentEntry>, String> {
let conn = lock_db(&db)?;
storage::recent_list(&conn, 30)
}
#[tauri::command]
pub fn db_recent_add(db: State<'_, AppDb>, path: String, name: String, opened_at: i64) -> Result<(), String> {
let conn = lock_db(&db)?;
storage::recent_add(&conn, &path, &name, opened_at, 30)
}
#[tauri::command]
pub fn db_recent_remove(db: State<'_, AppDb>, path: String) -> Result<(), String> {
let conn = lock_db(&db)?;
storage::recent_remove(&conn, &path)
}
#[tauri::command]
pub fn db_recent_clear(db: State<'_, AppDb>) -> Result<(), String> {
let conn = lock_db(&db)?;
storage::recent_clear(&conn)
}
#[tauri::command]
pub fn db_workspace_list(db: State<'_, AppDb>) -> Result<Vec<String>, String> {
let conn = lock_db(&db)?;
storage::workspace_list(&conn)
}
#[tauri::command]
pub fn db_workspace_add(db: State<'_, AppDb>, path: String, opened_at: i64) -> Result<(), String> {
let conn = lock_db(&db)?;
storage::workspace_add(&conn, &path, opened_at)
}
#[tauri::command]
pub fn db_pref_get(db: State<'_, AppDb>, key: String) -> Result<Option<String>, String> {
let conn = lock_db(&db)?;
storage::pref_get(&conn, &key)
}
#[tauri::command]
pub fn db_pref_set(db: State<'_, AppDb>, key: String, value: String) -> Result<(), String> {
let conn = lock_db(&db)?;
storage::pref_set(&conn, &key, &value)
}
#[tauri::command]
pub fn db_recovery_list(db: State<'_, AppDb>) -> Result<Vec<storage::RecoveryEntry>, String> {
let conn = lock_db(&db)?;
storage::recovery_list(&conn)
}
#[tauri::command]
pub fn db_recovery_save(
db: State<'_, AppDb>,
doc_key: String,
path: Option<String>,
title: String,
content: String,
updated_at: i64,
) -> Result<(), String> {
let conn = lock_db(&db)?;
storage::recovery_save(&conn, &doc_key, path.as_deref(), &title, &content, updated_at)
}
#[tauri::command]
pub fn db_recovery_delete(db: State<'_, AppDb>, doc_key: String) -> Result<(), String> {
let conn = lock_db(&db)?;
storage::recovery_delete(&conn, &doc_key)
}

View File

@@ -1,15 +1,47 @@
mod commands;
mod storage;
use std::sync::Mutex;
use tauri::Manager;
/// 应用数据库句柄(阶段 4):连接在启动时打开并建表,命令层经 State 访问。
pub struct AppDb(pub Mutex<rusqlite::Connection>);
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.setup(|app| {
let data_dir = app.path().app_data_dir().expect("app data dir");
std::fs::create_dir_all(&data_dir).expect("create app data dir");
let conn = rusqlite::Connection::open(data_dir.join("new-markdown.db"))
.expect("open app database");
storage::init_schema(&conn).expect("init app database schema");
app.manage(AppDb(Mutex::new(conn)));
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::read_text_file,
commands::write_text_file,
commands::list_dir,
commands::copy_to_assets,
commands::save_bytes_to_assets,
commands::stat_file,
commands::create_text_file,
commands::create_dir,
commands::rename_path,
commands::delete_to_trash,
commands::db_recent_list,
commands::db_recent_add,
commands::db_recent_remove,
commands::db_recent_clear,
commands::db_workspace_list,
commands::db_workspace_add,
commands::db_pref_get,
commands::db_pref_set,
commands::db_recovery_list,
commands::db_recovery_save,
commands::db_recovery_delete,
])
.run(tauri::generate_context!())
.expect("error while running new-markdown");

192
src-tauri/src/storage.rs Normal file
View File

@@ -0,0 +1,192 @@
//! SQLite 存储层(阶段 4):最近文件、工作区、偏好、崩溃恢复记录。
//! 红线(docs/02):这里永远不存文档正文的唯一副本——正文只在 .md 文件里;
//! recovery 表是未保存草稿的副本,仅用于崩溃/误关后恢复,保存或确认丢弃即删。
//! 本文件不依赖 Tauri 类型,命令层(commands.rs)只做参数转发,便于独立校验。
use rusqlite::{params, Connection};
use serde::Serialize;
pub fn init_schema(conn: &Connection) -> Result<(), String> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS recent_files (
path TEXT PRIMARY KEY,
name TEXT NOT NULL,
opened_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS workspaces (
path TEXT PRIMARY KEY,
opened_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS prefs (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS recovery (
doc_key TEXT PRIMARY KEY,
path TEXT,
title TEXT NOT NULL,
content TEXT NOT NULL,
updated_at INTEGER NOT NULL
);",
)
.map_err(|e| format!("初始化数据库失败:{e}"))
}
fn err<E: std::fmt::Display>(e: E) -> String {
format!("数据库操作失败:{e}")
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RecentEntry {
pub path: String,
pub name: String,
pub opened_at: i64,
}
pub fn recent_list(conn: &Connection, limit: i64) -> Result<Vec<RecentEntry>, String> {
let mut stmt = conn
.prepare("SELECT path, name, opened_at FROM recent_files ORDER BY opened_at DESC LIMIT ?1")
.map_err(err)?;
let rows = stmt
.query_map(params![limit], |row| {
Ok(RecentEntry {
path: row.get(0)?,
name: row.get(1)?,
opened_at: row.get(2)?,
})
})
.map_err(err)?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(err)?);
}
Ok(out)
}
pub fn recent_add(conn: &Connection, path: &str, name: &str, opened_at: i64, keep: i64) -> Result<(), String> {
conn.execute(
"INSERT INTO recent_files (path, name, opened_at) VALUES (?1, ?2, ?3)
ON CONFLICT(path) DO UPDATE SET name = excluded.name, opened_at = excluded.opened_at",
params![path, name, opened_at],
)
.map_err(err)?;
conn.execute(
"DELETE FROM recent_files WHERE path NOT IN (SELECT path FROM recent_files ORDER BY opened_at DESC LIMIT ?1)",
params![keep],
)
.map_err(err)?;
Ok(())
}
pub fn recent_remove(conn: &Connection, path: &str) -> Result<(), String> {
conn.execute("DELETE FROM recent_files WHERE path = ?1", params![path])
.map_err(err)?;
Ok(())
}
pub fn recent_clear(conn: &Connection) -> Result<(), String> {
conn.execute("DELETE FROM recent_files", []).map_err(err)?;
Ok(())
}
pub fn workspace_list(conn: &Connection) -> Result<Vec<String>, String> {
let mut stmt = conn
.prepare("SELECT path FROM workspaces ORDER BY opened_at DESC")
.map_err(err)?;
let rows = stmt
.query_map([], |row| row.get::<_, String>(0))
.map_err(err)?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(err)?);
}
Ok(out)
}
pub fn workspace_add(conn: &Connection, path: &str, opened_at: i64) -> Result<(), String> {
conn.execute(
"INSERT INTO workspaces (path, opened_at) VALUES (?1, ?2)
ON CONFLICT(path) DO UPDATE SET opened_at = excluded.opened_at",
params![path, opened_at],
)
.map_err(err)?;
Ok(())
}
pub fn pref_get(conn: &Connection, key: &str) -> Result<Option<String>, String> {
let mut stmt = conn
.prepare("SELECT value FROM prefs WHERE key = ?1")
.map_err(err)?;
let mut rows = stmt.query(params![key]).map_err(err)?;
match rows.next().map_err(err)? {
Some(row) => Ok(Some(row.get(0).map_err(err)?)),
None => Ok(None),
}
}
pub fn pref_set(conn: &Connection, key: &str, value: &str) -> Result<(), String> {
conn.execute(
"INSERT INTO prefs (key, value) VALUES (?1, ?2)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
params![key, value],
)
.map_err(err)?;
Ok(())
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct RecoveryEntry {
pub doc_key: String,
pub path: Option<String>,
pub title: String,
pub content: String,
pub updated_at: i64,
}
pub fn recovery_list(conn: &Connection) -> Result<Vec<RecoveryEntry>, String> {
let mut stmt = conn
.prepare("SELECT doc_key, path, title, content, updated_at FROM recovery ORDER BY updated_at DESC")
.map_err(err)?;
let rows = stmt
.query_map([], |row| {
Ok(RecoveryEntry {
doc_key: row.get(0)?,
path: row.get(1)?,
title: row.get(2)?,
content: row.get(3)?,
updated_at: row.get(4)?,
})
})
.map_err(err)?;
let mut out = Vec::new();
for r in rows {
out.push(r.map_err(err)?);
}
Ok(out)
}
pub fn recovery_save(
conn: &Connection,
doc_key: &str,
path: Option<&str>,
title: &str,
content: &str,
updated_at: i64,
) -> Result<(), String> {
conn.execute(
"INSERT INTO recovery (doc_key, path, title, content, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(doc_key) DO UPDATE SET path = excluded.path, title = excluded.title,
content = excluded.content, updated_at = excluded.updated_at",
params![doc_key, path, title, content, updated_at],
)
.map_err(err)?;
Ok(())
}
pub fn recovery_delete(conn: &Connection, doc_key: &str) -> Result<(), String> {
conn.execute("DELETE FROM recovery WHERE doc_key = ?1", params![doc_key])
.map_err(err)?;
Ok(())
}