feat: add config directory override support for WSL

- Add persistent app settings with custom Claude Code and Codex config directories
- Add config directory override UI in settings modal with manual input, browse, and reset options
- Integrate tauri-plugin-dialog for native directory picker
- Support WSL and other special environments where config paths need manual specification

Changes:
- settings.rs: Implement settings load/save and directory override logic
- SettingsModal: Add config directory override UI components
- API: Add get_config_dir and pick_directory commands
This commit is contained in:
Jason
2025-09-20 21:20:07 +08:00
parent b8d2daccde
commit 54f1357bcc
14 changed files with 789 additions and 39 deletions

View File

@@ -10,6 +10,10 @@ use std::path::Path;
/// 获取 Codex 配置目录路径
pub fn get_codex_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_codex_override_dir() {
return custom;
}
dirs::home_dir().expect("无法获取用户主目录").join(".codex")
}

View File

@@ -3,14 +3,14 @@
use std::collections::HashMap;
use tauri::State;
use tauri_plugin_opener::OpenerExt;
use tauri_plugin_dialog::DialogExt;
use crate::app_config::AppType;
use crate::codex_config;
use crate::config::{get_claude_settings_path, ConfigStatus};
use crate::vscode;
use crate::config;
use crate::config::{self, get_claude_settings_path, ConfigStatus};
use crate::provider::Provider;
use crate::store::AppState;
use crate::vscode;
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> {
match app_type {
@@ -520,6 +520,26 @@ pub async fn get_claude_code_config_path() -> Result<String, String> {
Ok(get_claude_settings_path().to_string_lossy().to_string())
}
/// 获取当前生效的配置目录
#[tauri::command]
pub async fn get_config_dir(
app_type: Option<AppType>,
app: Option<String>,
appType: Option<String>,
) -> Result<String, String> {
let app = app_type
.or_else(|| app.as_deref().map(|s| s.into()))
.or_else(|| appType.as_deref().map(|s| s.into()))
.unwrap_or(AppType::Claude);
let dir = match app {
AppType::Claude => config::get_claude_config_dir(),
AppType::Codex => codex_config::get_codex_config_dir(),
};
Ok(dir.to_string_lossy().to_string())
}
/// 打开配置文件夹
/// 兼容两种参数:`app_type`(推荐)或 `app`(字符串)
#[tauri::command]
@@ -553,6 +573,38 @@ pub async fn open_config_folder(
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn pick_directory(
app: tauri::AppHandle,
default_path: Option<String>,
) -> Result<Option<String>, String> {
let initial = default_path
.map(|p| p.trim().to_string())
.filter(|p| !p.is_empty());
let result = tauri::async_runtime::spawn_blocking(move || {
let mut builder = app.dialog().file();
if let Some(path) = initial {
builder = builder.set_directory(path);
}
builder.blocking_pick_folder()
})
.await
.map_err(|e| format!("弹出目录选择器失败: {}", e))?;
match result {
Some(file_path) => {
let resolved = file_path
.simplified()
.into_path()
.map_err(|e| format!("解析选择的目录失败: {}", e))?;
Ok(Some(resolved.to_string_lossy().to_string()))
}
None => Ok(None),
}
}
/// 打开外部链接
#[tauri::command]
pub async fn open_external(app: tauri::AppHandle, url: String) -> Result<bool, String> {
@@ -603,21 +655,15 @@ pub async fn open_app_config_folder(handle: tauri::AppHandle) -> Result<bool, St
/// 获取设置
#[tauri::command]
pub async fn get_settings(_state: State<'_, AppState>) -> Result<serde_json::Value, String> {
// 暂时返回默认设置:系统托盘(菜单栏)显示开关
Ok(serde_json::json!({
"showInTray": true
}))
pub async fn get_settings() -> Result<serde_json::Value, String> {
serde_json::to_value(crate::settings::get_settings())
.map_err(|e| format!("序列化设置失败: {}", e))
}
/// 保存设置
#[tauri::command]
pub async fn save_settings(
_state: State<'_, AppState>,
settings: serde_json::Value,
) -> Result<bool, String> {
// TODO: 实现系统托盘显示开关的保存与应用(显示/隐藏菜单栏托盘图标)
log::info!("保存设置: {:?}", settings);
pub async fn save_settings(settings: crate::settings::AppSettings) -> Result<bool, String> {
crate::settings::update_settings(settings)?;
Ok(true)
}

View File

@@ -6,6 +6,10 @@ use std::path::{Path, PathBuf};
/// 获取 Claude Code 配置目录路径
pub fn get_claude_config_dir() -> PathBuf {
if let Some(custom) = crate::settings::get_claude_override_dir() {
return custom;
}
dirs::home_dir()
.expect("无法获取用户主目录")
.join(".claude")

View File

@@ -2,10 +2,11 @@ mod app_config;
mod codex_config;
mod commands;
mod config;
mod vscode;
mod migration;
mod provider;
mod settings;
mod store;
mod vscode;
use store::AppState;
#[cfg(target_os = "macos")]
@@ -246,6 +247,7 @@ pub fn run() {
_ => {}
})
.plugin(tauri_plugin_process::init())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.setup(|app| {
// 注册 Updater 插件(桌面端)
@@ -351,7 +353,9 @@ pub fn run() {
commands::get_claude_config_status,
commands::get_config_status,
commands::get_claude_code_config_path,
commands::get_config_dir,
commands::open_config_folder,
commands::pick_directory,
commands::open_external,
commands::get_app_config_path,
commands::open_app_config_folder,

147
src-tauri/src/settings.rs Normal file
View File

@@ -0,0 +1,147 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
use std::sync::{OnceLock, RwLock};
/// 应用设置结构,允许覆盖默认配置目录
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AppSettings {
#[serde(default = "default_show_in_tray")]
pub show_in_tray: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub claude_config_dir: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub codex_config_dir: Option<String>,
}
fn default_show_in_tray() -> bool {
true
}
impl Default for AppSettings {
fn default() -> Self {
Self {
show_in_tray: true,
claude_config_dir: None,
codex_config_dir: None,
}
}
}
impl AppSettings {
fn settings_path() -> PathBuf {
crate::config::get_app_config_dir().join("settings.json")
}
fn normalize_paths(&mut self) {
self.claude_config_dir = self
.claude_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
self.codex_config_dir = self
.codex_config_dir
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
}
pub fn load() -> Self {
let path = Self::settings_path();
if let Ok(content) = fs::read_to_string(&path) {
match serde_json::from_str::<AppSettings>(&content) {
Ok(mut settings) => {
settings.normalize_paths();
settings
}
Err(err) => {
log::warn!(
"解析设置文件失败,将使用默认设置。路径: {}, 错误: {}",
path.display(),
err
);
Self::default()
}
}
} else {
Self::default()
}
}
pub fn save(&self) -> Result<(), String> {
let mut normalized = self.clone();
normalized.normalize_paths();
let path = Self::settings_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|e| format!("创建设置目录失败: {}", e))?;
}
let json = serde_json::to_string_pretty(&normalized)
.map_err(|e| format!("序列化设置失败: {}", e))?;
fs::write(&path, json).map_err(|e| format!("写入设置失败: {}", e))?;
Ok(())
}
}
fn settings_store() -> &'static RwLock<AppSettings> {
static STORE: OnceLock<RwLock<AppSettings>> = OnceLock::new();
STORE.get_or_init(|| RwLock::new(AppSettings::load()))
}
fn resolve_override_path(raw: &str) -> PathBuf {
if raw == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
} else if let Some(stripped) = raw.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
} else if let Some(stripped) = raw.strip_prefix("~\\") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
}
PathBuf::from(raw)
}
pub fn get_settings() -> AppSettings {
settings_store()
.read()
.expect("读取设置锁失败")
.clone()
}
pub fn update_settings(mut new_settings: AppSettings) -> Result<(), String> {
new_settings.normalize_paths();
new_settings.save()?;
let mut guard = settings_store()
.write()
.expect("写入设置锁失败");
*guard = new_settings;
Ok(())
}
pub fn get_claude_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.claude_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}
pub fn get_codex_override_dir() -> Option<PathBuf> {
let settings = settings_store().read().ok()?;
settings
.codex_config_dir
.as_ref()
.map(|p| resolve_override_path(p))
}