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:
程序员阿江(Relakkes) 2026-05-18 22:59:20 +08:00
parent 71ce980f1c
commit 47cc63c402
9 changed files with 700 additions and 180 deletions

View File

@ -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,

View File

@ -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

View File

@ -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 />)

View File

@ -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',

View File

@ -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': '配色主题',

View File

@ -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>
)

View File

@ -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)
})

View File

@ -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 {

View File

@ -73,4 +73,6 @@ export type AppModeConfig = {
mode: AppMode
portableDir: string | null
defaultPortableDir: string | null
activeConfigDir?: string | null
configDirSource?: 'system' | 'environment' | 'portable'
}