mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
fix: make portable storage mode understandable and selectable
The previous portable-mode settings were hard to discover and mostly explained through environment variables. Users could not pick a target folder from the desktop UI or clearly understand which config directory would become active after switching modes. This moves the control to a lower-priority storage section, makes the active directory source explicit, supports choosing a portable data folder, and prepares the desktop app for a controlled restart so the new storage location takes effect. Constraint: Directory picking and relaunch require the Tauri desktop runtime Rejected: Keep environment-variable-only setup | users could not discover or validate the target path from the app Confidence: high Scope-risk: moderate Tested: cd desktop && bun run test -- generalSettings settingsStore Tested: bun run check:desktop Tested: bun run check:native Tested: bun run check:coverage Tested: bun run verify Tested: Maintainer manual desktop UI test Not-tested: Windows relaunch path beyond existing native coverage
This commit is contained in:
parent
71ce980f1c
commit
47cc63c402
@ -233,7 +233,9 @@ const MIN_VISIBLE_PIXELS: i64 = 64;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum AppMode {
|
||||
#[serde(alias = "Default")]
|
||||
Default,
|
||||
#[serde(alias = "Portable")]
|
||||
Portable,
|
||||
}
|
||||
|
||||
@ -299,7 +301,6 @@ fn get_default_portable_dir() -> Option<PathBuf> {
|
||||
Some(dir)
|
||||
}
|
||||
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct TerminalConfig {
|
||||
#[serde(default)]
|
||||
@ -342,17 +343,16 @@ impl TerminalConfig {
|
||||
|
||||
fn terminal_config_path(app: &AppHandle) -> Option<PathBuf> {
|
||||
// honour CLAUDE_CONFIG_DIR for portable installs
|
||||
std::env::var("CLAUDE_CONFIG_DIR").ok().map(|dir| {
|
||||
PathBuf::from(&dir).join(TERMINAL_CONFIG_FILE)
|
||||
}).or_else(|| {
|
||||
match app.path().app_config_dir() {
|
||||
std::env::var("CLAUDE_CONFIG_DIR")
|
||||
.ok()
|
||||
.map(|dir| PathBuf::from(&dir).join(TERMINAL_CONFIG_FILE))
|
||||
.or_else(|| match app.path().app_config_dir() {
|
||||
Ok(dir) => Some(dir.join(TERMINAL_CONFIG_FILE)),
|
||||
Err(err) => {
|
||||
eprintln!("[desktop] failed to resolve app config dir: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
impl Default for TerminalConfig {
|
||||
@ -499,6 +499,21 @@ fn prepare_for_update_install(app: AppHandle) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn prepare_for_app_mode_restart(app: AppHandle) -> Result<(), String> {
|
||||
mark_app_quitting(&app);
|
||||
stop_server_sidecar(&app);
|
||||
stop_adapters_sidecar(&app);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
kill_windows_sidecars();
|
||||
}
|
||||
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cancel_update_install(app: AppHandle) -> Result<(), String> {
|
||||
clear_app_quitting(&app);
|
||||
@ -507,53 +522,96 @@ fn cancel_update_install(app: AppHandle) -> Result<(), String> {
|
||||
|
||||
/// Returns the current app mode and portable directory info.
|
||||
#[tauri::command]
|
||||
fn get_app_mode() -> serde_json::Value {
|
||||
let config_dir = if let Ok(cd) = std::env::var("CLAUDE_CONFIG_DIR") {
|
||||
Some(PathBuf::from(&cd))
|
||||
fn get_app_mode(app: AppHandle) -> serde_json::Value {
|
||||
let env_config_dir = std::env::var("CLAUDE_CONFIG_DIR").ok().map(PathBuf::from);
|
||||
let active_config_dir = env_config_dir
|
||||
.clone()
|
||||
.or_else(|| app.path().app_config_dir().ok());
|
||||
let config_dir_source = if env_config_dir.is_some() {
|
||||
if std::env::var_os("CC_HAHA_APP_PORTABLE_DIR").is_some() {
|
||||
"portable"
|
||||
} else {
|
||||
"environment"
|
||||
}
|
||||
} else {
|
||||
get_default_portable_dir()
|
||||
"system"
|
||||
};
|
||||
let config_dir = env_config_dir.clone().or_else(get_default_portable_dir);
|
||||
|
||||
serde_json::json!({
|
||||
"mode": if std::env::var("CLAUDE_CONFIG_DIR").is_ok() { "portable" } else { "default" },
|
||||
"mode": if env_config_dir.is_some() { "portable" } else { "default" },
|
||||
"portableDir": config_dir.as_ref().and_then(|p| p.to_str()),
|
||||
"defaultPortableDir": get_default_portable_dir().as_ref().and_then(|p| p.to_str()),
|
||||
"activeConfigDir": active_config_dir.as_ref().and_then(|p| p.to_str()),
|
||||
"configDirSource": config_dir_source,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sets the app mode. Persists to app-mode.json in the current active config dir.
|
||||
/// Requires restart to take effect.
|
||||
#[tauri::command]
|
||||
fn set_app_mode(app: tauri::AppHandle, mode: String, portable_dir: Option<String>) {
|
||||
use tauri::Manager; // 确保作用域内引入 Manager
|
||||
|
||||
fn set_app_mode(
|
||||
app: tauri::AppHandle,
|
||||
mode: String,
|
||||
portable_dir: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
// 确定当前正在使用的配置目录
|
||||
let active_config_dir = if let Ok(cd) = std::env::var("CLAUDE_CONFIG_DIR") {
|
||||
std::path::PathBuf::from(&cd)
|
||||
} else {
|
||||
match app.path().app_config_dir() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
eprintln!("[desktop] set_app_mode: failed to resolve config dir: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
app.path()
|
||||
.app_config_dir()
|
||||
.map_err(|e| format!("resolve app config dir: {e}"))?
|
||||
};
|
||||
|
||||
let app_mode = if mode == "portable" {
|
||||
AppMode::Portable
|
||||
let (app_mode, portable_dir, target_portable_dir) = if mode == "portable" {
|
||||
let selected_dir = portable_dir
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.or_else(get_default_portable_dir)
|
||||
.ok_or_else(|| "portable config directory is unavailable".to_string())?;
|
||||
|
||||
if selected_dir.exists() && !selected_dir.is_dir() {
|
||||
return Err(format!(
|
||||
"portable config path is not a directory: {}",
|
||||
selected_dir.display()
|
||||
));
|
||||
}
|
||||
|
||||
fs::create_dir_all(&selected_dir)
|
||||
.map_err(|e| format!("create portable config directory: {e}"))?;
|
||||
|
||||
let persisted_portable_dir = if get_default_portable_dir().as_ref() == Some(&selected_dir) {
|
||||
None
|
||||
} else {
|
||||
Some(selected_dir.to_string_lossy().to_string())
|
||||
};
|
||||
|
||||
(
|
||||
AppMode::Portable,
|
||||
persisted_portable_dir,
|
||||
Some(selected_dir),
|
||||
)
|
||||
} else {
|
||||
AppMode::Default
|
||||
(AppMode::Default, None, None)
|
||||
};
|
||||
|
||||
let config = AppModeConfig {
|
||||
mode: app_mode,
|
||||
portable_dir,
|
||||
portable_dir: portable_dir.clone(),
|
||||
};
|
||||
|
||||
// 写入当前活跃的配置目录
|
||||
write_app_mode_config(&active_config_dir, &config);
|
||||
|
||||
if let Some(dir) = target_portable_dir.as_ref() {
|
||||
if dir != &active_config_dir {
|
||||
write_app_mode_config(dir, &config);
|
||||
}
|
||||
}
|
||||
|
||||
// 修复:同时始终将模式状态写入系统默认配置目录,
|
||||
// 以防止应用层切换模式后,main.rs在下一次启动时读取到旧的系统全局状态
|
||||
if let Ok(sys_dir) = app.path().app_config_dir() {
|
||||
@ -561,13 +619,18 @@ fn set_app_mode(app: tauri::AppHandle, mode: String, portable_dir: Option<String
|
||||
write_app_mode_config(&sys_dir, &config);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks if the default portable directory has existing data files.
|
||||
#[tauri::command]
|
||||
fn detect_portable_dir() -> serde_json::Value {
|
||||
let default_portable = get_default_portable_dir();
|
||||
let has_data = default_portable.as_ref().map(|d| dir_has_portable_data(d)).unwrap_or(false);
|
||||
let has_data = default_portable
|
||||
.as_ref()
|
||||
.map(|d| dir_has_portable_data(d))
|
||||
.unwrap_or(false);
|
||||
serde_json::json!({
|
||||
"defaultPortableDir": default_portable.as_ref().and_then(|p| p.to_str()),
|
||||
"hasData": has_data,
|
||||
@ -646,21 +709,19 @@ fn window_state_path(app: &AppHandle) -> Option<PathBuf> {
|
||||
// honour CLAUDE_CONFIG_DIR so portable installs keep window-state.json
|
||||
// and terminal-config.json alongside the config dir instead of
|
||||
// %APPDATA%\com.claude-code-haha.desktop\.
|
||||
resolve_portable_state_path().or_else(|| {
|
||||
match app.path().app_config_dir() {
|
||||
Ok(dir) => Some(dir.join(WINDOW_STATE_FILE)),
|
||||
Err(err) => {
|
||||
eprintln!("[desktop] failed to resolve app config dir: {err}");
|
||||
None
|
||||
}
|
||||
resolve_portable_state_path().or_else(|| match app.path().app_config_dir() {
|
||||
Ok(dir) => Some(dir.join(WINDOW_STATE_FILE)),
|
||||
Err(err) => {
|
||||
eprintln!("[desktop] failed to resolve app config dir: {err}");
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_portable_state_path() -> Option<PathBuf> {
|
||||
std::env::var("CLAUDE_CONFIG_DIR").ok().map(|dir| {
|
||||
PathBuf::from(&dir).join(WINDOW_STATE_FILE)
|
||||
})
|
||||
std::env::var("CLAUDE_CONFIG_DIR")
|
||||
.ok()
|
||||
.map(|dir| PathBuf::from(&dir).join(WINDOW_STATE_FILE))
|
||||
}
|
||||
|
||||
fn read_stored_window_state(app: &AppHandle) -> Option<StoredWindowState> {
|
||||
@ -1656,12 +1717,7 @@ fn start_adapters_sidecars(app: &AppHandle) -> Result<Vec<CommandChild>, String>
|
||||
.env("CLAUDE_CONFIG_DIR", &config_dir)
|
||||
.env("XDG_CACHE_HOME", cache_dir.to_string_lossy().to_string());
|
||||
}
|
||||
let sidecar = sidecar_final.args([
|
||||
"adapters",
|
||||
"--app-root",
|
||||
&app_root_arg,
|
||||
flag,
|
||||
]);
|
||||
let sidecar = sidecar_final.args(["adapters", "--app-root", &app_root_arg, flag]);
|
||||
|
||||
let (mut rx, child) = sidecar
|
||||
.spawn()
|
||||
@ -1783,9 +1839,9 @@ mod tests {
|
||||
use super::{
|
||||
decode_terminal_output, default_utf8_locale, ensure_utf8_locale,
|
||||
has_meaningful_intersection, is_persistable_window_state, normalize_terminal_bash_path,
|
||||
parse_env_block, resolve_desktop_terminal_shell, run_notification_bridge, select_h5_dist_dir,
|
||||
DesktopTerminalConfig, StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST,
|
||||
SERVER_CONTROL_HOST,
|
||||
parse_env_block, resolve_desktop_terminal_shell, run_notification_bridge,
|
||||
select_h5_dist_dir, DesktopTerminalConfig, StoredWindowState, TerminalHostPlatform,
|
||||
SERVER_BIND_HOST, SERVER_CONTROL_HOST,
|
||||
};
|
||||
use std::{collections::HashMap, fs};
|
||||
|
||||
@ -1899,10 +1955,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn terminal_bash_path_normalizer_rejects_missing_files() {
|
||||
let missing = std::env::temp_dir().join(format!(
|
||||
"cchh-missing-bash-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let missing =
|
||||
std::env::temp_dir().join(format!("cchh-missing-bash-{}", std::process::id()));
|
||||
|
||||
let error = normalize_terminal_bash_path(Some(missing.to_string_lossy().to_string()))
|
||||
.expect_err("missing path should be rejected");
|
||||
@ -1912,10 +1966,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn terminal_bash_path_normalizer_accepts_existing_files() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"cchh-bash-path-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let path = std::env::temp_dir().join(format!("cchh-bash-path-test-{}", std::process::id()));
|
||||
fs::write(&path, "").expect("write bash path fixture");
|
||||
|
||||
assert_eq!(
|
||||
@ -1969,12 +2020,8 @@ mod tests {
|
||||
#[test]
|
||||
fn desktop_terminal_shell_resolution_keeps_system_default_without_preference() {
|
||||
assert_eq!(
|
||||
resolve_desktop_terminal_shell(
|
||||
TerminalHostPlatform::Windows,
|
||||
None,
|
||||
"powershell.exe",
|
||||
)
|
||||
.expect("resolution should succeed"),
|
||||
resolve_desktop_terminal_shell(TerminalHostPlatform::Windows, None, "powershell.exe",)
|
||||
.expect("resolution should succeed"),
|
||||
None
|
||||
);
|
||||
}
|
||||
@ -2018,10 +2065,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn h5_dist_dir_prefers_tauri_parent_resource_mapping() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"cchh-h5-dist-test-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let root = std::env::temp_dir().join(format!("cchh-h5-dist-test-{}", std::process::id()));
|
||||
let resource_dir = root.join("Contents").join("Resources");
|
||||
let app_root = root.join("Contents").join("MacOS");
|
||||
let mapped_dist = resource_dir.join("_up_").join("dist");
|
||||
@ -2070,6 +2114,7 @@ pub fn run() {
|
||||
get_server_url,
|
||||
restart_adapters_sidecar,
|
||||
prepare_for_update_install,
|
||||
prepare_for_app_mode_restart,
|
||||
cancel_update_install,
|
||||
terminal_spawn,
|
||||
terminal_write,
|
||||
|
||||
@ -18,7 +18,11 @@ fn main() {
|
||||
// All existing std::env::var("CLAUDE_CONFIG_DIR") checks in lib.rs handle this.
|
||||
|
||||
if let Some(portable_dir) = determine_startup_portable_dir() {
|
||||
std::env::set_var("CLAUDE_CONFIG_DIR", portable_dir.to_string_lossy().to_string());
|
||||
std::env::set_var(
|
||||
"CLAUDE_CONFIG_DIR",
|
||||
portable_dir.to_string_lossy().to_string(),
|
||||
);
|
||||
std::env::set_var("CC_HAHA_APP_PORTABLE_DIR", "1");
|
||||
}
|
||||
|
||||
// If CLAUDE_CONFIG_DIR is set (either from env or from our startup logic above),
|
||||
@ -52,8 +56,15 @@ fn determine_startup_portable_dir() -> Option<PathBuf> {
|
||||
let path = dir.join("app-mode.json");
|
||||
let data = std::fs::read_to_string(&path).ok()?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&data).ok()?;
|
||||
let mode = parsed.get("mode").and_then(|m| m.as_str()).unwrap_or("default").to_string();
|
||||
let portable_dir = parsed.get("portable_dir").and_then(|v| v.as_str()).map(PathBuf::from);
|
||||
let mode = parsed
|
||||
.get("mode")
|
||||
.and_then(|m| m.as_str())
|
||||
.unwrap_or("default")
|
||||
.to_ascii_lowercase();
|
||||
let portable_dir = parsed
|
||||
.get("portable_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(PathBuf::from);
|
||||
Some((mode, portable_dir))
|
||||
}
|
||||
|
||||
@ -70,11 +81,18 @@ fn determine_startup_portable_dir() -> Option<PathBuf> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let system_config: Option<PathBuf> = std::env::var("APPDATA").ok().map(PathBuf::from);
|
||||
#[cfg(target_os = "macos")]
|
||||
let system_config: Option<PathBuf> = std::env::var("HOME").ok().map(|h| PathBuf::from(h).join("Library").join("Application Support"));
|
||||
let system_config: Option<PathBuf> = std::env::var("HOME")
|
||||
.ok()
|
||||
.map(|h| PathBuf::from(h).join("Library").join("Application Support"));
|
||||
#[cfg(target_os = "linux")]
|
||||
let system_config: Option<PathBuf> = std::env::var("XDG_CONFIG_HOME")
|
||||
.ok().map(PathBuf::from)
|
||||
.or_else(|| std::env::var("HOME").ok().map(|h| PathBuf::from(h).join(".config")));
|
||||
.ok()
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(|h| PathBuf::from(h).join(".config"))
|
||||
});
|
||||
|
||||
if let Some(ref sys_cfg) = system_config {
|
||||
// 修复:必须使用 Tauri 默认的 bundle identifier
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen, within } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
import { Settings } from '../pages/Settings'
|
||||
@ -8,7 +8,7 @@ import { useUIStore } from '../stores/uiStore'
|
||||
import { useUpdateStore } from '../stores/updateStore'
|
||||
import type { SavedProvider } from '../types/provider'
|
||||
import type { ProviderPreset } from '../types/providerPreset'
|
||||
import type { ThemeMode, UpdateProxySettings } from '../types/settings'
|
||||
import type { AppMode, ThemeMode, UpdateProxySettings } from '../types/settings'
|
||||
|
||||
const MOCK_DELETE_PROVIDER = vi.fn()
|
||||
const MOCK_GET_SETTINGS = vi.fn()
|
||||
@ -22,6 +22,15 @@ const desktopNotificationsMock = vi.hoisted(() => ({
|
||||
const clipboardMock = vi.hoisted(() => ({
|
||||
copyTextToClipboard: vi.fn(),
|
||||
}))
|
||||
const tauriCoreMock = vi.hoisted(() => ({
|
||||
invoke: vi.fn(),
|
||||
}))
|
||||
const tauriDialogMock = vi.hoisted(() => ({
|
||||
open: vi.fn(),
|
||||
}))
|
||||
const tauriProcessMock = vi.hoisted(() => ({
|
||||
relaunch: vi.fn(),
|
||||
}))
|
||||
const providerStoreState = {
|
||||
providers: [] as SavedProvider[],
|
||||
activeId: null as string | null,
|
||||
@ -59,6 +68,9 @@ vi.mock('../api/providers', () => ({
|
||||
|
||||
vi.mock('../lib/desktopNotifications', () => desktopNotificationsMock)
|
||||
vi.mock('../components/chat/clipboard', () => clipboardMock)
|
||||
vi.mock('@tauri-apps/api/core', () => tauriCoreMock)
|
||||
vi.mock('@tauri-apps/plugin-dialog', () => tauriDialogMock)
|
||||
vi.mock('@tauri-apps/plugin-process', () => tauriProcessMock)
|
||||
vi.mock('qrcode', () => ({
|
||||
default: {
|
||||
toDataURL: vi.fn().mockResolvedValue('data:image/png;base64,h5qr'),
|
||||
@ -120,6 +132,13 @@ describe('Settings > General tab', () => {
|
||||
desktopNotificationsMock.openDesktopNotificationSettings.mockResolvedValue(true)
|
||||
clipboardMock.copyTextToClipboard.mockReset()
|
||||
clipboardMock.copyTextToClipboard.mockResolvedValue(true)
|
||||
tauriCoreMock.invoke.mockReset()
|
||||
tauriCoreMock.invoke.mockResolvedValue(undefined)
|
||||
tauriDialogMock.open.mockReset()
|
||||
tauriDialogMock.open.mockResolvedValue('/Users/test/cc-haha-data')
|
||||
tauriProcessMock.relaunch.mockReset()
|
||||
tauriProcessMock.relaunch.mockResolvedValue(undefined)
|
||||
delete (window as unknown as { __TAURI_INTERNALS__?: object }).__TAURI_INTERNALS__
|
||||
MOCK_GET_SETTINGS.mockResolvedValue({})
|
||||
MOCK_UPDATE_SETTINGS.mockResolvedValue({})
|
||||
providerStoreState.providers = []
|
||||
@ -174,6 +193,27 @@ describe('Settings > General tab', () => {
|
||||
setWebSearch: vi.fn().mockImplementation(async (webSearch) => {
|
||||
useSettingsStore.setState({ webSearch })
|
||||
}),
|
||||
appMode: {
|
||||
mode: 'default',
|
||||
portableDir: null,
|
||||
defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR',
|
||||
activeConfigDir: null,
|
||||
configDirSource: 'system',
|
||||
},
|
||||
appModeRequiresRestart: false,
|
||||
fetchAppMode: vi.fn().mockResolvedValue(undefined),
|
||||
setAppMode: vi.fn().mockImplementation(async (mode: AppMode, portableDir?: string | null) => {
|
||||
useSettingsStore.setState({
|
||||
appMode: {
|
||||
mode,
|
||||
portableDir: mode === 'portable' ? portableDir ?? '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR' : null,
|
||||
defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR',
|
||||
activeConfigDir: mode === 'portable' ? portableDir ?? '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR' : null,
|
||||
configDirSource: mode === 'portable' ? 'portable' : 'system',
|
||||
},
|
||||
appModeRequiresRestart: true,
|
||||
})
|
||||
}),
|
||||
enableH5Access: vi.fn().mockImplementation(async () => {
|
||||
const current = useSettingsStore.getState().h5Access
|
||||
useSettingsStore.setState({
|
||||
@ -274,6 +314,176 @@ describe('Settings > General tab', () => {
|
||||
expect((uiZoomHeading.compareDocumentPosition(webFetchHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps data storage at the bottom of General settings', () => {
|
||||
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
||||
tauriWindow.__TAURI_INTERNALS__ = {}
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
const webSearchHeading = screen.getByRole('heading', { name: 'WebSearch' })
|
||||
const storageHeading = screen.getByRole('heading', { name: 'Data Storage Location' })
|
||||
|
||||
expect((webSearchHeading.compareDocumentPosition(storageHeading) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true)
|
||||
expect(screen.getByText(/Switching directories does not migrate existing data/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets desktop users choose a portable data directory and relaunch immediately', async () => {
|
||||
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
||||
tauriWindow.__TAURI_INTERNALS__ = {}
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose Folder' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('Portable data directory')).toHaveValue('/Users/test/cc-haha-data')
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' }))
|
||||
expect(screen.getByText('Switch data storage location?')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save and Restart' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useSettingsStore.getState().setAppMode).toHaveBeenCalledWith('portable', '/Users/test/cc-haha-data')
|
||||
expect(tauriCoreMock.invoke).toHaveBeenCalledWith('prepare_for_app_mode_restart')
|
||||
expect(tauriProcessMock.relaunch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('switches back to the system directory without deleting portable data', async () => {
|
||||
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
||||
tauriWindow.__TAURI_INTERNALS__ = {}
|
||||
useSettingsStore.setState({
|
||||
appMode: {
|
||||
mode: 'portable',
|
||||
portableDir: '/Users/test/cc-haha-data',
|
||||
defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR',
|
||||
activeConfigDir: '/Users/test/cc-haha-data',
|
||||
configDirSource: 'portable',
|
||||
},
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
fireEvent.click(screen.getByRole('button', { name: /Use system directory/ }))
|
||||
|
||||
expect(screen.getByText(/Data in the portable directory is not deleted/)).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save and Restart' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(useSettingsStore.getState().setAppMode).toHaveBeenCalledWith('default', null)
|
||||
expect(tauriCoreMock.invoke).toHaveBeenCalledWith('prepare_for_app_mode_restart')
|
||||
expect(tauriProcessMock.relaunch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
it('validates portable directory input and lets users reset to the app-side folder', async () => {
|
||||
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
||||
tauriWindow.__TAURI_INTERNALS__ = {}
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
const input = screen.getByLabelText('Portable data directory')
|
||||
|
||||
fireEvent.change(input, { target: { value: '' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' }))
|
||||
expect(screen.getByText('Choose or enter a portable data directory first.')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use the default portable folder beside the app' }))
|
||||
expect(input).toHaveValue('/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR')
|
||||
expect(screen.queryByText('Choose or enter a portable data directory first.')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows folder picker failures as an inline storage error', async () => {
|
||||
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
||||
tauriWindow.__TAURI_INTERNALS__ = {}
|
||||
tauriDialogMock.open.mockRejectedValueOnce(new Error('dialog unavailable'))
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Choose Folder' }))
|
||||
|
||||
expect(await screen.findByText('Could not open the folder picker. Paste the folder path manually.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('treats external CLAUDE_CONFIG_DIR as the controlling data source', async () => {
|
||||
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
||||
tauriWindow.__TAURI_INTERNALS__ = {}
|
||||
useSettingsStore.setState({
|
||||
appMode: {
|
||||
mode: 'portable',
|
||||
portableDir: '/env/claude-data',
|
||||
defaultPortableDir: '/Applications/Claude Code Haha/CLAUDE_CONFIG_DIR',
|
||||
activeConfigDir: '/env/claude-data',
|
||||
configDirSource: 'environment',
|
||||
},
|
||||
})
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
expect(screen.getByText(/The current directory is controlled by the CLAUDE_CONFIG_DIR environment variable/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Use system directory/ }))
|
||||
expect(screen.getByText(/Remove it from the launch environment before switching back/)).toBeInTheDocument()
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Portable data directory'), { target: { value: '/other/data' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' }))
|
||||
expect(screen.queryByText('Switch data storage location?')).not.toBeInTheDocument()
|
||||
expect(screen.getByText(/Remove it from the launch environment before switching back/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('keeps mode switch confirmation cancelable before restart starts', async () => {
|
||||
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
||||
tauriWindow.__TAURI_INTERNALS__ = {}
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' }))
|
||||
expect(screen.getByText('Switch data storage location?')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Switch data storage location?')).not.toBeInTheDocument()
|
||||
})
|
||||
expect(useSettingsStore.getState().setAppMode).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows restart preparation failures without relaunching', async () => {
|
||||
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
||||
tauriWindow.__TAURI_INTERNALS__ = {}
|
||||
tauriCoreMock.invoke.mockRejectedValueOnce(new Error('restart preparation failed'))
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use This Folder and Restart' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save and Restart' }))
|
||||
|
||||
expect(await screen.findByText('restart preparation failed')).toBeInTheDocument()
|
||||
expect(tauriProcessMock.relaunch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the saved restart-required state inside the storage section', () => {
|
||||
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
||||
tauriWindow.__TAURI_INTERNALS__ = {}
|
||||
useSettingsStore.setState({ appModeRequiresRestart: true })
|
||||
|
||||
render(<Settings />)
|
||||
|
||||
fireEvent.click(screen.getByText('General'))
|
||||
|
||||
expect(screen.getByText('The storage change has been saved. Restart the app for the new data directory to take effect.')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('previews UI zoom while dragging and applies it once on release', async () => {
|
||||
render(<Settings />)
|
||||
|
||||
|
||||
@ -770,18 +770,32 @@ export const en = {
|
||||
'settings.computerUse.flagClipboard': 'Clipboard Access',
|
||||
'settings.computerUse.flagSystemKeys': 'System Key Combos',
|
||||
|
||||
// Settings > General - Mode
|
||||
'settings.general.modeTitle': 'Mode',
|
||||
'settings.general.modeDescription': 'Choose where app data is stored. Default mode respects the CLAUDE_CONFIG_DIR env var or uses system directories. Portable mode stores data in a configurable directory.',
|
||||
'settings.general.modeDefault': 'Default',
|
||||
'settings.general.modePortable': 'Portable',
|
||||
'settings.general.modeDefaultHint': 'Uses CLAUDE_CONFIG_DIR env var if set, otherwise system directory (%APPDATA% on Windows).',
|
||||
'settings.general.modePortableDir': 'Portable config dir',
|
||||
'settings.general.modeSwitchTitle': 'Switch Mode?',
|
||||
'settings.general.modeSwitchBody': 'Switch to {mode}? This requires a restart to take effect.',
|
||||
'settings.general.modeSwitchConfirm': 'Switch and Restart Later',
|
||||
'settings.general.modeRestartTitle': 'Restart Required',
|
||||
'settings.general.modeRestartHint': 'Restart the app for the mode change to take effect.',
|
||||
// Settings > General - Storage
|
||||
'settings.general.modeSwitchTitle': 'Switch data storage location?',
|
||||
'settings.general.modeSwitchConfirm': 'Save and Restart',
|
||||
'settings.general.storageTitle': 'Data Storage Location',
|
||||
'settings.general.storageDescription': 'Advanced, low-frequency setting. After switching, sessions, Skills, MCP, plugins, provider settings, tasks, and caches are read from the new directory.',
|
||||
'settings.general.storageSystemTitle': 'Use system directory',
|
||||
'settings.general.storageSystemDescription': 'Return to the default data source. If CLAUDE_CONFIG_DIR is set in the launch environment, that environment variable still takes priority.',
|
||||
'settings.general.storagePortableTitle': 'Use portable directory',
|
||||
'settings.general.storagePortableDescription': 'Store desktop data in a folder you choose. Use this for external drives or app bundles you want to move together.',
|
||||
'settings.general.storagePortableDirLabel': 'Portable data directory',
|
||||
'settings.general.storagePortableDirPlaceholder': 'Choose a folder for cc-haha data',
|
||||
'settings.general.storageChooseDir': 'Choose Folder',
|
||||
'settings.general.storageChooseDirTitle': 'Choose portable data directory',
|
||||
'settings.general.storageUseDefaultPortableDir': 'Use the default portable folder beside the app',
|
||||
'settings.general.storageApplyPortable': 'Use This Folder and Restart',
|
||||
'settings.general.storageActiveDir': 'Current active data directory',
|
||||
'settings.general.storageEnvironmentHint': 'The current directory is controlled by the CLAUDE_CONFIG_DIR environment variable. In-app switching cannot override it; remove CLAUDE_CONFIG_DIR from the launch environment to return to the system directory.',
|
||||
'settings.general.storageEnvironmentSwitchBlocked': 'CLAUDE_CONFIG_DIR currently controls the data directory. Remove it from the launch environment before switching back to the system directory.',
|
||||
'settings.general.storageRestartHint': 'The storage change has been saved. Restart the app for the new data directory to take effect.',
|
||||
'settings.general.storageMoveHint': 'Switching directories does not migrate existing data. Copy projects, skills, plugins, cc-haha, and related folders from the old directory if you want old sessions to keep appearing. For a portable bundle, keep this folder beside the app and zip them together.',
|
||||
'settings.general.storageNoDirError': 'Choose or enter a portable data directory first.',
|
||||
'settings.general.storagePickerError': 'Could not open the folder picker. Paste the folder path manually.',
|
||||
'settings.general.storageRestartError': 'The change was saved, but automatic restart failed. Restart the app manually.',
|
||||
'settings.general.storageSwitchPortableBody': 'After switching, the desktop app will read and write sessions, settings, Skills, MCP, plugins, tasks, and caches in this directory.',
|
||||
'settings.general.storageSwitchDefaultBody': 'After switching back, the desktop app will use the system data source again. Data in the portable directory is not deleted or moved back automatically.',
|
||||
'settings.general.storageSwitchRestartBody': 'The app will stop the local server and adapter processes, then relaunch. The new directory takes effect after restart.',
|
||||
|
||||
// Settings > General
|
||||
'settings.general.appearanceTitle': 'Appearance',
|
||||
|
||||
@ -772,19 +772,32 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.computerUse.flagClipboard': '剪贴板访问',
|
||||
'settings.computerUse.flagSystemKeys': '系统快捷键',
|
||||
|
||||
// Settings > General
|
||||
// Settings > General - Mode
|
||||
'settings.general.modeTitle': '运行模式',
|
||||
'settings.general.modeDescription': '选择应用程序数据的存储位置。默认模式下,如果设置了 CLAUDE_CONFIG_DIR 环境变量则使用该路径,否则使用系统目录。便携模式将数据存储在自定义目录中。',
|
||||
'settings.general.modeDefault': '默认模式',
|
||||
'settings.general.modePortable': '便携模式',
|
||||
'settings.general.modeDefaultHint': 'CLAUDE_CONFIG_DIR 环境变量指定的路径,或系统目录(Windows 上为 %APPDATA%)。',
|
||||
'settings.general.modePortableDir': '便携配置目录',
|
||||
'settings.general.modeSwitchTitle': '切换模式?',
|
||||
'settings.general.modeSwitchBody': '切换到{mode}?此更改需要重启后生效。',
|
||||
'settings.general.modeSwitchConfirm': '切换,稍后重启',
|
||||
'settings.general.modeRestartTitle': '需要重启',
|
||||
'settings.general.modeRestartHint': '重启应用使模式切换生效。下次启动时将更新配置目录。',
|
||||
// Settings > General - Storage
|
||||
'settings.general.modeSwitchTitle': '切换数据存储位置?',
|
||||
'settings.general.modeSwitchConfirm': '保存并重启',
|
||||
'settings.general.storageTitle': '数据存储位置',
|
||||
'settings.general.storageDescription': '低频高级设置。切换后,会话记录、Skills、MCP、插件、Provider 配置、任务和缓存都会从新的目录读取。',
|
||||
'settings.general.storageSystemTitle': '使用系统目录',
|
||||
'settings.general.storageSystemDescription': '回到默认数据源。若启动环境设置了 CLAUDE_CONFIG_DIR,则仍会优先使用该环境变量指定的目录。',
|
||||
'settings.general.storagePortableTitle': '使用便携目录',
|
||||
'settings.general.storagePortableDescription': '把桌面端数据写入你选择的文件夹,适合放在移动硬盘或和应用一起打包迁移。',
|
||||
'settings.general.storagePortableDirLabel': '便携数据目录',
|
||||
'settings.general.storagePortableDirPlaceholder': '选择一个用于保存 cc-haha 数据的文件夹',
|
||||
'settings.general.storageChooseDir': '选择目录',
|
||||
'settings.general.storageChooseDirTitle': '选择便携数据目录',
|
||||
'settings.general.storageUseDefaultPortableDir': '使用应用旁边的默认便携目录',
|
||||
'settings.general.storageApplyPortable': '使用这个目录并重启',
|
||||
'settings.general.storageActiveDir': '当前实际读取目录',
|
||||
'settings.general.storageEnvironmentHint': '当前目录由 CLAUDE_CONFIG_DIR 环境变量控制。应用内切换不会覆盖这个环境变量;如需回到系统目录,请先移除启动环境里的 CLAUDE_CONFIG_DIR。',
|
||||
'settings.general.storageEnvironmentSwitchBlocked': '当前由 CLAUDE_CONFIG_DIR 环境变量控制。请先移除启动环境里的 CLAUDE_CONFIG_DIR,再切回系统目录。',
|
||||
'settings.general.storageRestartHint': '已保存切换请求。请重启应用,让新的数据目录生效。',
|
||||
'settings.general.storageMoveHint': '切换目录不会自动搬迁旧数据。如果希望旧会话继续出现,请把原目录下的 projects、skills、plugins、cc-haha 等数据复制到新目录。要做便携包,建议把目录放在应用旁边并一起压缩。',
|
||||
'settings.general.storageNoDirError': '请先选择或填写一个便携数据目录。',
|
||||
'settings.general.storagePickerError': '无法打开目录选择器,请手动粘贴目录路径。',
|
||||
'settings.general.storageRestartError': '切换已保存,但自动重启失败,请手动重启应用。',
|
||||
'settings.general.storageSwitchPortableBody': '切换后,桌面端会从下面这个目录读取和写入会话、配置、Skills、MCP、插件、任务和缓存。',
|
||||
'settings.general.storageSwitchDefaultBody': '切回系统目录后,桌面端会回到系统默认数据源。当前便携目录中的数据不会被删除,也不会自动迁回。',
|
||||
'settings.general.storageSwitchRestartBody': '应用将先关闭本地服务和适配器进程,然后自动重启。重启后新目录才会生效。',
|
||||
|
||||
// Settings > General
|
||||
'settings.general.appearanceTitle': '配色主题',
|
||||
|
||||
@ -1398,12 +1398,19 @@ function GeneralSettings() {
|
||||
const [notificationActionRunning, setNotificationActionRunning] = useState(false)
|
||||
const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false)
|
||||
const [pendingMode, setPendingMode] = useState<AppMode | null>(null)
|
||||
const [pendingPortableDir, setPendingPortableDir] = useState<string | null>(null)
|
||||
const [portableDirDraft, setPortableDirDraft] = useState('')
|
||||
const [modeActionRunning, setModeActionRunning] = useState(false)
|
||||
const [modeError, setModeError] = useState<string | null>(null)
|
||||
const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom)
|
||||
const [isUiZoomDragging, setIsUiZoomDragging] = useState(false)
|
||||
const isUiZoomDraggingRef = useRef(false)
|
||||
const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch)
|
||||
const uiZoomPercent = Math.round(uiZoomDraft * 100)
|
||||
const uiZoomRangeProgress = `${Math.round(((uiZoomDraft - UI_ZOOM_MIN) / (UI_ZOOM_MAX - UI_ZOOM_MIN)) * 1000) / 10}%`
|
||||
const activeConfigDir = appMode.activeConfigDir ?? (appMode.mode === 'portable' ? appMode.portableDir : null)
|
||||
const configDirSource = appMode.configDirSource ?? (appMode.mode === 'portable' ? 'portable' : 'system')
|
||||
const isEnvironmentConfigDir = configDirSource === 'environment'
|
||||
|
||||
useEffect(() => {
|
||||
setWebSearchDraft(webSearch)
|
||||
@ -1430,6 +1437,10 @@ function GeneralSettings() {
|
||||
void fetchAppMode()
|
||||
}, [fetchAppMode])
|
||||
|
||||
useEffect(() => {
|
||||
setPortableDirDraft(appMode.portableDir ?? appMode.defaultPortableDir ?? '')
|
||||
}, [appMode.defaultPortableDir, appMode.portableDir])
|
||||
|
||||
const EFFORT_LABELS: Record<EffortLevel, string> = {
|
||||
low: t('settings.general.effort.low'),
|
||||
medium: t('settings.general.effort.medium'),
|
||||
@ -1535,6 +1546,72 @@ function GeneralSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
const openPortableDirPicker = async () => {
|
||||
setModeError(null)
|
||||
try {
|
||||
const { open } = await import('@tauri-apps/plugin-dialog')
|
||||
const selected = await open({
|
||||
directory: true,
|
||||
multiple: false,
|
||||
title: t('settings.general.storageChooseDirTitle'),
|
||||
})
|
||||
if (typeof selected === 'string') {
|
||||
setPortableDirDraft(selected)
|
||||
}
|
||||
} catch {
|
||||
setModeError(t('settings.general.storagePickerError'))
|
||||
}
|
||||
}
|
||||
|
||||
const openModeSwitchConfirm = (mode: AppMode) => {
|
||||
if (isEnvironmentConfigDir) {
|
||||
setModeError(t('settings.general.storageEnvironmentSwitchBlocked'))
|
||||
return
|
||||
}
|
||||
|
||||
const portableDir = portableDirDraft.trim()
|
||||
if (mode === 'portable' && !portableDir) {
|
||||
setModeError(t('settings.general.storageNoDirError'))
|
||||
return
|
||||
}
|
||||
|
||||
setModeError(null)
|
||||
setPendingMode(mode)
|
||||
setPendingPortableDir(mode === 'portable' ? portableDir : null)
|
||||
setModeSwitchConfirmOpen(true)
|
||||
}
|
||||
|
||||
const closeModeSwitchConfirm = () => {
|
||||
if (modeActionRunning) return
|
||||
setModeSwitchConfirmOpen(false)
|
||||
setPendingMode(null)
|
||||
setPendingPortableDir(null)
|
||||
}
|
||||
|
||||
const confirmModeSwitch = async () => {
|
||||
if (!pendingMode) return
|
||||
|
||||
setModeActionRunning(true)
|
||||
setModeError(null)
|
||||
try {
|
||||
await setAppModeAction(pendingMode, pendingPortableDir)
|
||||
const { invoke } = await import('@tauri-apps/api/core')
|
||||
await invoke('prepare_for_app_mode_restart')
|
||||
const { relaunch } = await import('@tauri-apps/plugin-process')
|
||||
await relaunch()
|
||||
} catch (error) {
|
||||
setModeError(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t('settings.general.storageRestartError'),
|
||||
)
|
||||
setModeSwitchConfirmOpen(false)
|
||||
setPendingMode(null)
|
||||
setPendingPortableDir(null)
|
||||
setModeActionRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const setUiZoomDraggingState = (dragging: boolean) => {
|
||||
isUiZoomDraggingRef.current = dragging
|
||||
setIsUiZoomDragging(dragging)
|
||||
@ -1650,79 +1727,6 @@ function GeneralSettings() {
|
||||
|
||||
return (
|
||||
<div className="max-w-xl">
|
||||
{/* Mode Section */}
|
||||
{isTauriRuntime() && (
|
||||
<div className="mb-8">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.modeTitle')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.modeDescription')}</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (appMode.mode === 'default') return
|
||||
setPendingMode('default')
|
||||
setModeSwitchConfirmOpen(true)
|
||||
}}
|
||||
aria-pressed={appMode.mode === 'default'}
|
||||
className={`flex-1 py-3 text-sm font-semibold rounded-lg border transition-all ${
|
||||
appMode.mode === 'default'
|
||||
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[22px]">settings_applications</span>
|
||||
<span>{t('settings.general.modeDefault')}</span>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (appMode.mode === 'portable') return
|
||||
setPendingMode('portable')
|
||||
setModeSwitchConfirmOpen(true)
|
||||
}}
|
||||
aria-pressed={appMode.mode === 'portable'}
|
||||
className={`flex-1 py-3 text-sm font-semibold rounded-lg border transition-all ${
|
||||
appMode.mode === 'portable'
|
||||
? 'bg-[image:var(--gradient-btn-primary)] text-[var(--color-btn-primary-fg)] border-transparent shadow-[var(--shadow-button-primary)]'
|
||||
: 'border-[var(--color-border)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[22px]">drive_file_move</span>
|
||||
<span>{t('settings.general.modePortable')}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
{appMode.mode === 'portable' && appMode.portableDir && (
|
||||
<div className="mt-2 text-xs text-[var(--color-text-tertiary)] font-mono break-all">
|
||||
{t('settings.general.modePortableDir')}: {appMode.portableDir}
|
||||
</div>
|
||||
)}
|
||||
{appMode.mode === 'default' && (
|
||||
<div className="mt-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.modeDefaultHint')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Restart Required Banner */}
|
||||
{appModeRequiresRestart && (
|
||||
<div className="mb-6 rounded-xl border border-[var(--color-warning)] bg-[var(--color-warning)]/10 px-4 py-3 flex items-center gap-3">
|
||||
<span className="material-symbols-outlined text-[20px] text-[var(--color-warning)]" style={{ fontVariationSettings: "'FILL' 1" }}>
|
||||
warning
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('settings.general.modeRestartTitle')}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] mt-0.5">
|
||||
{t('settings.general.modeRestartHint')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Appearance selector */}
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.appearanceTitle')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.appearanceDescription')}</p>
|
||||
@ -1990,21 +1994,159 @@ function GeneralSettings() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isTauriRuntime() && (
|
||||
<div className="mt-8 border-t border-[var(--color-border)] pt-8">
|
||||
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.storageTitle')}</h2>
|
||||
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.storageDescription')}</p>
|
||||
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
|
||||
<div className="flex flex-col gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isEnvironmentConfigDir) {
|
||||
setModeError(t('settings.general.storageEnvironmentSwitchBlocked'))
|
||||
return
|
||||
}
|
||||
if (appMode.mode !== 'default') {
|
||||
openModeSwitchConfirm('default')
|
||||
}
|
||||
}}
|
||||
aria-pressed={appMode.mode === 'default' && !isEnvironmentConfigDir}
|
||||
className={`flex items-start gap-3 rounded-lg border px-3 py-3 text-left transition-all ${
|
||||
appMode.mode === 'default' && !isEnvironmentConfigDir
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-surface)] shadow-[var(--shadow-focus-ring)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)] hover:border-[var(--color-border-focus)]'
|
||||
}`}
|
||||
>
|
||||
<span className="material-symbols-outlined mt-0.5 text-[20px] text-[var(--color-text-secondary)]">settings_applications</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.general.storageSystemTitle')}</span>
|
||||
<span className="mt-1 block text-xs leading-5 text-[var(--color-text-tertiary)]">{t('settings.general.storageSystemDescription')}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
className={`rounded-lg border px-3 py-3 transition-all ${
|
||||
appMode.mode === 'portable' && !isEnvironmentConfigDir
|
||||
? 'border-[var(--color-brand)] bg-[var(--color-surface)] shadow-[var(--shadow-focus-ring)]'
|
||||
: 'border-[var(--color-border)] bg-[var(--color-surface)]'
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-start gap-3">
|
||||
<span className="material-symbols-outlined mt-0.5 text-[20px] text-[var(--color-text-secondary)]">drive_file_move</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-[var(--color-text-primary)]">{t('settings.general.storagePortableTitle')}</div>
|
||||
<div className="mt-1 text-xs leading-5 text-[var(--color-text-tertiary)]">{t('settings.general.storagePortableDescription')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Input
|
||||
id="portable-data-dir"
|
||||
label={t('settings.general.storagePortableDirLabel')}
|
||||
value={portableDirDraft}
|
||||
placeholder={t('settings.general.storagePortableDirPlaceholder')}
|
||||
onChange={(event) => {
|
||||
setPortableDirDraft(event.target.value)
|
||||
setModeError(null)
|
||||
}}
|
||||
className="w-full font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="h-10 flex-shrink-0 px-3 whitespace-nowrap"
|
||||
onClick={() => void openPortableDirPicker()}
|
||||
>
|
||||
{t('settings.general.storageChooseDir')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs font-medium text-[var(--color-brand)] hover:underline"
|
||||
onClick={() => {
|
||||
setPortableDirDraft(appMode.defaultPortableDir ?? '')
|
||||
setModeError(null)
|
||||
}}
|
||||
>
|
||||
{t('settings.general.storageUseDefaultPortableDir')}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={modeActionRunning || (appMode.mode === 'portable' && portableDirDraft.trim() === (appMode.portableDir ?? ''))}
|
||||
onClick={() => openModeSwitchConfirm('portable')}
|
||||
>
|
||||
{t('settings.general.storageApplyPortable')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeConfigDir && (
|
||||
<div className="mt-3 rounded-lg border border-[var(--color-border)]/70 bg-[var(--color-surface)] px-3 py-2">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wide text-[var(--color-text-tertiary)]">{t('settings.general.storageActiveDir')}</div>
|
||||
<div className="mt-1 break-all font-mono text-xs text-[var(--color-text-secondary)]">{activeConfigDir}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEnvironmentConfigDir && (
|
||||
<div className="mt-3 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning)]/10 px-3 py-2 text-xs leading-5 text-[var(--color-text-secondary)]">
|
||||
{t('settings.general.storageEnvironmentHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{appModeRequiresRestart && (
|
||||
<div className="mt-3 rounded-lg border border-[var(--color-warning)] bg-[var(--color-warning)]/10 px-3 py-2 text-xs leading-5 text-[var(--color-text-secondary)]">
|
||||
{t('settings.general.storageRestartHint')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-xs leading-5 text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.storageMoveHint')}
|
||||
</div>
|
||||
|
||||
{modeError && (
|
||||
<div className="mt-3 text-xs text-[var(--color-error)]">
|
||||
{modeError}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Confirm dialog for mode switch */}
|
||||
<ConfirmDialog
|
||||
open={modeSwitchConfirmOpen}
|
||||
onClose={() => { setModeSwitchConfirmOpen(false); setPendingMode(null); }}
|
||||
onConfirm={() => {
|
||||
if (pendingMode) { void setAppModeAction(pendingMode); }
|
||||
setModeSwitchConfirmOpen(false);
|
||||
setPendingMode(null);
|
||||
}}
|
||||
onClose={closeModeSwitchConfirm}
|
||||
onConfirm={() => void confirmModeSwitch()}
|
||||
title={t('settings.general.modeSwitchTitle')}
|
||||
body={t('settings.general.modeSwitchBody', {
|
||||
mode: pendingMode === 'portable' ? t('settings.general.modePortable') : t('settings.general.modeDefault'),
|
||||
})}
|
||||
body={(
|
||||
<div className="space-y-3 text-sm leading-6 text-[var(--color-text-secondary)]">
|
||||
<p>
|
||||
{pendingMode === 'portable'
|
||||
? t('settings.general.storageSwitchPortableBody')
|
||||
: t('settings.general.storageSwitchDefaultBody')}
|
||||
</p>
|
||||
{pendingMode === 'portable' && pendingPortableDir && (
|
||||
<div className="rounded-lg bg-[var(--color-surface-container-low)] px-3 py-2 font-mono text-xs break-all text-[var(--color-text-secondary)]">
|
||||
{pendingPortableDir}
|
||||
</div>
|
||||
)}
|
||||
<p>{t('settings.general.storageSwitchRestartBody')}</p>
|
||||
</div>
|
||||
)}
|
||||
confirmLabel={t('settings.general.modeSwitchConfirm')}
|
||||
cancelLabel={t('common.cancel')}
|
||||
confirmVariant="primary"
|
||||
loading={modeActionRunning}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -221,6 +221,72 @@ describe('settingsStore app mode', () => {
|
||||
mode: 'portable',
|
||||
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
||||
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
||||
activeConfigDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
||||
configDirSource: 'portable',
|
||||
})
|
||||
expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true)
|
||||
})
|
||||
|
||||
it('persists a user-selected portable directory', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(undefined)
|
||||
vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
|
||||
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
||||
tauriWindow.__TAURI_INTERNALS__ = {}
|
||||
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
useSettingsStore.setState({
|
||||
appMode: {
|
||||
mode: 'default',
|
||||
portableDir: null,
|
||||
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
||||
},
|
||||
appModeRequiresRestart: false,
|
||||
})
|
||||
|
||||
await useSettingsStore.getState().setAppMode('portable', 'D:\\portable-data')
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('set_app_mode', {
|
||||
mode: 'portable',
|
||||
portableDir: 'D:\\portable-data',
|
||||
})
|
||||
expect(useSettingsStore.getState().appMode).toMatchObject({
|
||||
mode: 'portable',
|
||||
portableDir: 'D:\\portable-data',
|
||||
activeConfigDir: 'D:\\portable-data',
|
||||
configDirSource: 'portable',
|
||||
})
|
||||
})
|
||||
|
||||
it('switches app mode back to the system data source', async () => {
|
||||
const invoke = vi.fn().mockResolvedValue(undefined)
|
||||
vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
|
||||
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
|
||||
tauriWindow.__TAURI_INTERNALS__ = {}
|
||||
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
useSettingsStore.setState({
|
||||
appMode: {
|
||||
mode: 'portable',
|
||||
portableDir: 'D:\\portable-data',
|
||||
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
||||
activeConfigDir: 'D:\\portable-data',
|
||||
configDirSource: 'portable',
|
||||
},
|
||||
appModeRequiresRestart: false,
|
||||
})
|
||||
|
||||
await useSettingsStore.getState().setAppMode('default', null)
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('set_app_mode', {
|
||||
mode: 'default',
|
||||
portableDir: null,
|
||||
})
|
||||
expect(useSettingsStore.getState().appMode).toEqual({
|
||||
mode: 'default',
|
||||
portableDir: null,
|
||||
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
|
||||
activeConfigDir: null,
|
||||
configDirSource: 'system',
|
||||
})
|
||||
expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true)
|
||||
})
|
||||
|
||||
@ -134,7 +134,13 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
appMode: { mode: 'default', portableDir: null, defaultPortableDir: null },
|
||||
appMode: {
|
||||
mode: 'default',
|
||||
portableDir: null,
|
||||
defaultPortableDir: null,
|
||||
activeConfigDir: null,
|
||||
configDirSource: 'system',
|
||||
},
|
||||
appModeRequiresRestart: false,
|
||||
setUiZoom: (zoom: number) => {
|
||||
const level = normalizeAppZoomLevel(zoom)
|
||||
@ -393,6 +399,10 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
portableDir: mode === 'portable'
|
||||
? portableDir ?? prev.defaultPortableDir ?? prev.portableDir
|
||||
: null,
|
||||
activeConfigDir: mode === 'portable'
|
||||
? portableDir ?? prev.defaultPortableDir ?? prev.portableDir
|
||||
: null,
|
||||
configDirSource: mode === 'portable' ? 'portable' : 'system',
|
||||
}
|
||||
set({ appMode: newMode, appModeRequiresRestart: true })
|
||||
try {
|
||||
|
||||
@ -73,4 +73,6 @@ export type AppModeConfig = {
|
||||
mode: AppMode
|
||||
portableDir: string | null
|
||||
defaultPortableDir: string | null
|
||||
activeConfigDir?: string | null
|
||||
configDirSource?: 'system' | 'environment' | 'portable'
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user