Skip to content

Commit 8cd0888

Browse files
committed
feat(storage): 添加自定义文档根目录功能,支持更改和重置目录 (close #31)
- 新增 `init_documents_root`、`set_documents_root`、`clear_documents_root_override` 等函数以管理文档根目录 - 更新 `documents_root` 函数以支持自定义路径 - 在 `StorageSpaceManager` 组件中实现更改和重置目录的功能 - 添加多语言支持的相关文本
1 parent 24bed86 commit 8cd0888

19 files changed

Lines changed: 620 additions & 31 deletions

File tree

src-tauri/crates/core/src/path_vars.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ pub fn encode_path(absolute_path: &str) -> String {
2121
None => return absolute_path.to_string(),
2222
};
2323
let aqbot_home = home.join(".aqbot");
24-
let documents_root = dirs::document_dir()
25-
.map(|d| d.join("aqbot"))
26-
.unwrap_or_else(|| home.join("Documents").join("aqbot"));
24+
let documents_root = crate::storage_paths::documents_root();
2725

2826
// Try the most specific prefix first so that e.g. ~/.aqbot/… is not
2927
// encoded as {{HOME}}/.aqbot/… when {{AQBOT_HOME}}/… is more precise.
@@ -54,9 +52,7 @@ pub fn decode_path(encoded_path: &str) -> String {
5452
None => return encoded_path.to_string(),
5553
};
5654
let aqbot_home = home.join(".aqbot");
57-
let documents_root = dirs::document_dir()
58-
.map(|d| d.join("aqbot"))
59-
.unwrap_or_else(|| home.join("Documents").join("aqbot"));
55+
let documents_root = crate::storage_paths::documents_root();
6056

6157
if let Some(rest) = encoded_path.strip_prefix(VAR_AQBOT_HOME) {
6258
return format!(

src-tauri/crates/core/src/storage_paths.rs

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,47 @@
11
use std::path::{Path, PathBuf};
2+
use std::sync::RwLock;
23

3-
/// Returns the documents root: ~/Documents/aqbot/
4+
static DOCUMENTS_ROOT_OVERRIDE: RwLock<Option<PathBuf>> = RwLock::new(None);
5+
6+
/// Initialise the custom documents root from a stored setting.
7+
/// Call once during app startup; ignored if `custom` is `None`.
8+
pub fn init_documents_root(custom: Option<PathBuf>) {
9+
if let Some(path) = custom {
10+
if let Ok(mut guard) = DOCUMENTS_ROOT_OVERRIDE.write() {
11+
*guard = Some(path);
12+
}
13+
}
14+
}
15+
16+
/// Replace the documents root at runtime (e.g. after the user picks a new
17+
/// directory). Takes effect immediately for all subsequent `documents_root()`
18+
/// calls.
19+
pub fn set_documents_root(path: PathBuf) {
20+
if let Ok(mut guard) = DOCUMENTS_ROOT_OVERRIDE.write() {
21+
*guard = Some(path);
22+
}
23+
}
24+
25+
/// Clear any custom override so `documents_root()` falls back to the default.
26+
pub fn clear_documents_root_override() {
27+
if let Ok(mut guard) = DOCUMENTS_ROOT_OVERRIDE.write() {
28+
*guard = None;
29+
}
30+
}
31+
32+
/// Returns the active documents root — custom override if set, otherwise the
33+
/// platform default (`~/Documents/aqbot/`).
434
pub fn documents_root() -> PathBuf {
35+
if let Ok(guard) = DOCUMENTS_ROOT_OVERRIDE.read() {
36+
if let Some(ref custom) = *guard {
37+
return custom.clone();
38+
}
39+
}
40+
default_documents_root()
41+
}
42+
43+
/// The platform default documents root: `~/Documents/aqbot/`.
44+
pub fn default_documents_root() -> PathBuf {
545
dirs::document_dir()
646
.expect("Could not determine Documents directory")
747
.join("aqbot")
@@ -95,7 +135,7 @@ mod tests {
95135

96136
#[test]
97137
fn documents_root_ends_with_aqbot() {
98-
let root = documents_root();
138+
let root = default_documents_root();
99139
assert!(
100140
root.ends_with("aqbot"),
101141
"Expected path ending with 'aqbot', got {:?}",
@@ -113,7 +153,7 @@ mod tests {
113153

114154
#[test]
115155
fn documents_root_is_absolute() {
116-
let root = documents_root();
156+
let root = default_documents_root();
117157
assert!(root.is_absolute(), "Expected absolute path, got {:?}", root);
118158
}
119159

src-tauri/crates/core/src/types.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,8 @@ pub struct AppSettings {
600600
pub webdav_max_remote_backups: u32,
601601
pub webdav_include_documents: bool,
602602
pub last_selected_conversation_id: Option<String>,
603+
/// Custom documents root directory (overrides ~/Documents/aqbot/).
604+
pub documents_root_override: Option<String>,
603605
/// Auto update check interval in minutes (default 60, min 1).
604606
pub update_check_interval: u32,
605607
/// Global system prompt fallback — used when a conversation has no custom system prompt.
@@ -699,6 +701,7 @@ impl Default for AppSettings {
699701
webdav_max_remote_backups: 10,
700702
webdav_include_documents: false,
701703
last_selected_conversation_id: None,
704+
documents_root_override: None,
702705
update_check_interval: 60,
703706
default_system_prompt: None,
704707
chat_minimap_enabled: false,

src-tauri/src/commands/storage.rs

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
use aqbot_core::storage_inventory::{self, StorageInventory};
2+
use aqbot_core::storage_paths;
3+
use crate::AppState;
4+
use serde::Serialize;
5+
use std::path::PathBuf;
6+
use tauri::State;
27

38
#[tauri::command]
49
pub async fn get_storage_inventory() -> Result<StorageInventory, String> {
@@ -7,9 +12,182 @@ pub async fn get_storage_inventory() -> Result<StorageInventory, String> {
712

813
#[tauri::command]
914
pub async fn open_storage_directory(app: tauri::AppHandle) -> Result<(), String> {
10-
let root = aqbot_core::storage_paths::documents_root();
15+
let root = storage_paths::documents_root();
1116
use tauri_plugin_opener::OpenerExt;
1217
app.opener()
1318
.reveal_item_in_dir(&root)
1419
.map_err(|e| e.to_string())
1520
}
21+
22+
// -- Change documents root --
23+
24+
#[derive(Debug, Serialize)]
25+
pub struct ChangeDocumentsRootResult {
26+
pub files_moved: usize,
27+
pub files_failed: usize,
28+
}
29+
30+
/// Validate a candidate documents root directory.
31+
/// Returns (is_empty, exists, writable).
32+
#[tauri::command]
33+
pub async fn validate_documents_root(path: String) -> Result<ValidateResult, String> {
34+
let target = PathBuf::from(&path);
35+
36+
if !target.is_absolute() {
37+
return Err("路径必须是绝对路径".into());
38+
}
39+
40+
let exists = target.exists();
41+
42+
// Create if missing (to test writability), then remove if we created it.
43+
let created_now = if !exists {
44+
std::fs::create_dir_all(&target).map_err(|e| format!("无法创建目录: {e}"))?;
45+
true
46+
} else {
47+
false
48+
};
49+
50+
// Test writability
51+
let probe = target.join(".aqbot_write_probe");
52+
let writable = std::fs::write(&probe, b"ok").is_ok();
53+
let _ = std::fs::remove_file(&probe);
54+
55+
// Check emptiness
56+
let is_empty = match std::fs::read_dir(&target) {
57+
Ok(mut entries) => entries.next().is_none(),
58+
Err(_) => true,
59+
};
60+
61+
// Clean up if we created the dir
62+
if created_now && is_empty {
63+
let _ = std::fs::remove_dir(&target);
64+
}
65+
66+
Ok(ValidateResult {
67+
exists,
68+
is_empty,
69+
writable,
70+
})
71+
}
72+
73+
#[derive(Debug, Serialize)]
74+
pub struct ValidateResult {
75+
pub exists: bool,
76+
pub is_empty: bool,
77+
pub writable: bool,
78+
}
79+
80+
/// Change the documents root to `new_path`.
81+
/// If `migrate` is true, copies all files from the current root.
82+
#[tauri::command]
83+
pub async fn change_documents_root(
84+
state: State<'_, AppState>,
85+
new_path: String,
86+
migrate: bool,
87+
) -> Result<ChangeDocumentsRootResult, String> {
88+
let new_root = PathBuf::from(&new_path);
89+
let old_root = storage_paths::documents_root();
90+
91+
if new_root == old_root {
92+
return Err("新目录与当前目录相同".into());
93+
}
94+
95+
if !new_root.is_absolute() {
96+
return Err("路径必须是绝对路径".into());
97+
}
98+
99+
// Ensure the target directory and subdirs exist
100+
for sub in &["images", "files", "backups"] {
101+
std::fs::create_dir_all(new_root.join(sub))
102+
.map_err(|e| format!("无法创建目录 {sub}: {e}"))?;
103+
}
104+
105+
let mut result = ChangeDocumentsRootResult {
106+
files_moved: 0,
107+
files_failed: 0,
108+
};
109+
110+
// Migrate files if requested — move (rename or copy+delete)
111+
if migrate {
112+
for sub in &["images", "files", "backups"] {
113+
let src_dir = old_root.join(sub);
114+
let dst_dir = new_root.join(sub);
115+
if !src_dir.exists() {
116+
continue;
117+
}
118+
if let Ok(entries) = std::fs::read_dir(&src_dir) {
119+
for entry in entries.flatten() {
120+
let meta = match entry.metadata() {
121+
Ok(m) => m,
122+
Err(_) => { result.files_failed += 1; continue; }
123+
};
124+
if !meta.is_file() {
125+
continue;
126+
}
127+
let src = entry.path();
128+
let dst = dst_dir.join(entry.file_name());
129+
if dst.exists() {
130+
// Already at destination — delete source and count as moved
131+
let _ = std::fs::remove_file(&src);
132+
result.files_moved += 1;
133+
continue;
134+
}
135+
// Try rename first (atomic, same filesystem)
136+
if std::fs::rename(&src, &dst).is_ok() {
137+
result.files_moved += 1;
138+
continue;
139+
}
140+
// Cross-filesystem: copy then delete source
141+
match std::fs::copy(&src, &dst) {
142+
Ok(_) => {
143+
let _ = std::fs::remove_file(&src);
144+
result.files_moved += 1;
145+
}
146+
Err(e) => {
147+
tracing::warn!(
148+
src = %src.display(),
149+
dst = %dst.display(),
150+
error = %e,
151+
"failed to move file during documents root change"
152+
);
153+
result.files_failed += 1;
154+
}
155+
}
156+
}
157+
}
158+
}
159+
}
160+
161+
// Persist the setting
162+
let db = &state.sea_db;
163+
let mut settings = aqbot_core::repo::settings::get_settings(db)
164+
.await
165+
.map_err(|e| e.to_string())?;
166+
settings.documents_root_override = Some(new_path);
167+
aqbot_core::repo::settings::save_settings(db, &settings)
168+
.await
169+
.map_err(|e| e.to_string())?;
170+
171+
// Update the in-process global so subsequent calls see the new root
172+
storage_paths::set_documents_root(new_root);
173+
174+
Ok(result)
175+
}
176+
177+
/// Reset documents root back to the platform default.
178+
#[tauri::command]
179+
pub async fn reset_documents_root(
180+
state: State<'_, AppState>,
181+
) -> Result<(), String> {
182+
let db = &state.sea_db;
183+
let mut settings = aqbot_core::repo::settings::get_settings(db)
184+
.await
185+
.map_err(|e| e.to_string())?;
186+
settings.documents_root_override = None;
187+
aqbot_core::repo::settings::save_settings(db, &settings)
188+
.await
189+
.map_err(|e| e.to_string())?;
190+
191+
storage_paths::clear_documents_root_override();
192+
Ok(())
193+
}

src-tauri/src/lib.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,9 @@ pub fn run() {
287287
// storage
288288
commands::storage::get_storage_inventory,
289289
commands::storage::open_storage_directory,
290+
commands::storage::validate_documents_root,
291+
commands::storage::change_documents_root,
292+
commands::storage::reset_documents_root,
290293
// agent
291294
commands::agent::agent_query,
292295
commands::agent::agent_cancel,
@@ -438,10 +441,21 @@ pub fn run() {
438441
// Migrate any hardcoded absolute paths in settings to dynamic variables
439442
rt.block_on(aqbot_core::path_vars::migrate_hardcoded_paths(&db_handle.conn));
440443

441-
let tray_language = rt
444+
let app_settings = rt
442445
.block_on(aqbot_core::repo::settings::get_settings(&db_handle.conn))
443-
.map(|settings| settings.language)
444-
.unwrap_or_else(|_| "zh-CN".to_string());
446+
.unwrap_or_default();
447+
448+
// Apply custom documents root (if configured) before anything
449+
// that reads documents_root().
450+
aqbot_core::storage_paths::init_documents_root(
451+
app_settings.documents_root_override.as_ref().map(PathBuf::from),
452+
);
453+
454+
// Re-ensure documents dirs under the (possibly custom) root
455+
aqbot_core::storage_paths::ensure_documents_dirs()
456+
.expect("failed to create documents storage dirs (custom root)");
457+
458+
let tray_language = app_settings.language.clone();
445459

446460
app.manage(AppState {
447461
sea_db: db_handle.conn,

0 commit comments

Comments
 (0)