diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index ff1102f0..9f00b3bc 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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 { Some(dir) } - #[derive(Serialize, Deserialize)] struct TerminalConfig { #[serde(default)] @@ -342,17 +343,16 @@ impl TerminalConfig { fn terminal_config_path(app: &AppHandle) -> Option { // 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) { - use tauri::Manager; // 确保作用域内引入 Manager - +fn set_app_mode( + app: tauri::AppHandle, + mode: String, + portable_dir: Option, +) -> 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 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 { // 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 { - 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 { @@ -1656,12 +1717,7 @@ fn start_adapters_sidecars(app: &AppHandle) -> Result, 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, diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index 35aeb8d6..3c15fea5 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -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 { 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 { #[cfg(target_os = "windows")] let system_config: Option = std::env::var("APPDATA").ok().map(PathBuf::from); #[cfg(target_os = "macos")] - let system_config: Option = std::env::var("HOME").ok().map(|h| PathBuf::from(h).join("Library").join("Application Support")); + let system_config: Option = std::env::var("HOME") + .ok() + .map(|h| PathBuf::from(h).join("Library").join("Application Support")); #[cfg(target_os = "linux")] let system_config: Option = 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 diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 2cba509e..977ba0bc 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() + + 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() diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index ff647b3b..3fea5a09 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 43f770bb..5e792648 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -772,19 +772,32 @@ export const zh: Record = { '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': '配色主题', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 1bdbbef6..db7d2c93 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -1398,12 +1398,19 @@ function GeneralSettings() { const [notificationActionRunning, setNotificationActionRunning] = useState(false) const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false) const [pendingMode, setPendingMode] = useState(null) + const [pendingPortableDir, setPendingPortableDir] = useState(null) + const [portableDirDraft, setPortableDirDraft] = useState('') + const [modeActionRunning, setModeActionRunning] = useState(false) + const [modeError, setModeError] = useState(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 = { 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 (
- {/* Mode Section */} - {isTauriRuntime() && ( -
-

{t('settings.general.modeTitle')}

-

{t('settings.general.modeDescription')}

-
- - -
- {appMode.mode === 'portable' && appMode.portableDir && ( -
- {t('settings.general.modePortableDir')}: {appMode.portableDir} -
- )} - {appMode.mode === 'default' && ( -
- {t('settings.general.modeDefaultHint')} -
- )} -
- )} - - {/* Restart Required Banner */} - {appModeRequiresRestart && ( -
- - warning - -
-
- {t('settings.general.modeRestartTitle')} -
-
- {t('settings.general.modeRestartHint')} -
-
-
- )} - {/* Appearance selector */}

{t('settings.general.appearanceTitle')}

{t('settings.general.appearanceDescription')}

@@ -1990,21 +1994,159 @@ function GeneralSettings() {
+ + {isTauriRuntime() && ( +
+

{t('settings.general.storageTitle')}

+

{t('settings.general.storageDescription')}

+ +
+
+ + +
+
+ drive_file_move +
+
{t('settings.general.storagePortableTitle')}
+
{t('settings.general.storagePortableDescription')}
+
+
+ +
+
+ { + setPortableDirDraft(event.target.value) + setModeError(null) + }} + className="w-full font-mono text-xs" + /> +
+ +
+ +
+ + +
+
+
+ + {activeConfigDir && ( +
+
{t('settings.general.storageActiveDir')}
+
{activeConfigDir}
+
+ )} + + {isEnvironmentConfigDir && ( +
+ {t('settings.general.storageEnvironmentHint')} +
+ )} + + {appModeRequiresRestart && ( +
+ {t('settings.general.storageRestartHint')} +
+ )} + +
+ {t('settings.general.storageMoveHint')} +
+ + {modeError && ( +
+ {modeError} +
+ )} +
+
+ )} + {/* Confirm dialog for mode switch */} { 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={( +
+

+ {pendingMode === 'portable' + ? t('settings.general.storageSwitchPortableBody') + : t('settings.general.storageSwitchDefaultBody')} +

+ {pendingMode === 'portable' && pendingPortableDir && ( +
+ {pendingPortableDir} +
+ )} +

{t('settings.general.storageSwitchRestartBody')}

+
+ )} confirmLabel={t('settings.general.modeSwitchConfirm')} cancelLabel={t('common.cancel')} + confirmVariant="primary" + loading={modeActionRunning} /> ) diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 5fc9ccbf..ceb776f1 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -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) }) diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 8ada2b8a..420b18d7 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -134,7 +134,13 @@ export const useSettingsStore = create((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((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 { diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 721af985..96ffb604 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -73,4 +73,6 @@ export type AppModeConfig = { mode: AppMode portableDir: string | null defaultPortableDir: string | null + activeConfigDir?: string | null + configDirSource?: 'system' | 'environment' | 'portable' }