Merge origin/main into local post-0.2.7 main

Remote main carries portable-mode, legacy Windows workdir recovery, and Feishu path-safety fixes while local main carries terminal shell, update proxy, slash-command, prompt-draft, AskUserQuestion, background-work, and shell-env changes. This merge keeps both lines by layering portable Bash-path defaults underneath the desktop terminal shell preference and preserving both update-proxy and app-mode settings state.

Constraint: Local main and origin/main diverged after v0.2.7 and both lines contain release-relevant desktop/runtime fixes

Rejected: Prefer either side's terminal settings wholesale | would drop either Windows portable Bash support or explicit desktop startup-shell support

Confidence: medium

Scope-risk: moderate

Directive: Keep portable Bash path as the system-default terminal fallback; explicit desktop startup-shell settings should continue to override it

Tested: cd desktop && bun run test -- --run src/pages/TerminalSettings.test.tsx src/stores/settingsStore.test.ts

Tested: cd desktop/src-tauri && cargo test terminal -- --nocapture

Tested: bun test src/server/__tests__/sessions.test.ts -t stale worktree

Tested: bun run check:desktop

Tested: bun run check:server

Tested: bun run check:native

Not-tested: Manual Windows packaged-app terminal/portable smoke
This commit is contained in:
程序员阿江(Relakkes) 2026-05-18 20:05:24 +08:00
commit 8a5b3467c5
25 changed files with 1080 additions and 136 deletions

View File

@ -5,7 +5,7 @@
*/
import { describe, it, expect } from 'bun:test'
import * as path from 'node:path'
import { isOutsideWorkDir } from '../path-safety.js'
// ---------- helpers extracted from feishu/index.ts for testability ----------
@ -209,14 +209,6 @@ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
}
}
function isOutsideWorkDir(filePath: string, workDir: string): boolean {
const abs = path.isAbsolute(filePath)
? path.normalize(filePath)
: path.resolve(workDir, filePath)
const normWork = path.normalize(workDir).replace(/\/+$/, '')
return abs !== normWork && !abs.startsWith(normWork + path.sep)
}
function truncateTarget(s: string, maxLen = 160): string {
if (s.length <= maxLen) return s
return s.slice(0, maxLen - 1) + '…'

View File

@ -37,6 +37,7 @@ import { AttachmentStore } from '../common/attachment/attachment-store.js'
import { checkAttachmentLimit } from '../common/attachment/attachment-limits.js'
import { ImageBlockWatcher } from '../common/attachment/image-block-watcher.js'
import type { PendingUpload } from '../common/attachment/attachment-types.js'
import { isOutsideWorkDir } from './path-safety.js'
// ---------- init ----------
@ -502,16 +503,6 @@ function summarizeToolCall(toolName: string, input: unknown): ToolCallSummary {
}
}
/** True if `filePath` resolves to a location outside of `workDir`.
* Relative paths are resolved against workDir first. */
function isOutsideWorkDir(filePath: string, workDir: string): boolean {
const abs = path.isAbsolute(filePath)
? path.normalize(filePath)
: path.resolve(workDir, filePath)
const normWork = path.normalize(workDir).replace(/\/+$/, '')
return abs !== normWork && !abs.startsWith(normWork + path.sep)
}
/** Truncate a single-line target preview (e.g. shell command) to maxLen. */
function truncateTarget(s: string, maxLen = 160): string {
if (s.length <= maxLen) return s

View File

@ -0,0 +1,23 @@
import * as path from 'node:path'
/** True if `filePath` resolves to a location outside of `workDir`.
* Relative paths are resolved against workDir first. */
export function isOutsideWorkDir(filePath: string, workDir: string): boolean {
const pathApi = usesWindowsPath(filePath) || usesWindowsPath(workDir) ? path.win32 : path.posix
const abs = pathApi.isAbsolute(filePath)
? pathApi.normalize(filePath)
: pathApi.resolve(workDir, filePath)
const normWork = stripTrailingSeparators(pathApi.normalize(workDir), pathApi)
const relative = pathApi.relative(normWork, abs)
return relative !== '' && (relative.startsWith('..') || pathApi.isAbsolute(relative))
}
function usesWindowsPath(value: string): boolean {
return /^[a-zA-Z]:[\\/]/.test(value) || value.startsWith('\\\\')
}
function stripTrailingSeparators(value: string, pathApi: typeof path.posix | typeof path.win32): string {
const root = pathApi.parse(value).root
if (value === root) return value
return value.replace(/[\\/]+$/, '')
}

View File

@ -224,10 +224,143 @@ const MAIN_WINDOW_LABEL: &str = "main";
const TRAY_SHOW_ID: &str = "tray_show";
const TRAY_QUIT_ID: &str = "tray_quit";
const WINDOW_STATE_FILE: &str = "window-state.json";
const TERMINAL_CONFIG_FILE: &str = "terminal-config.json";
const APP_MODE_FILE: &str = "app-mode.json";
const MIN_WINDOW_WIDTH: u32 = 960;
const MIN_WINDOW_HEIGHT: u32 = 640;
const MIN_VISIBLE_PIXELS: i64 = 64;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum AppMode {
Default,
Portable,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct AppModeConfig {
#[serde(default = "default_app_mode")]
mode: AppMode,
#[serde(default)]
portable_dir: Option<String>,
}
fn default_app_mode() -> AppMode {
AppMode::Default
}
impl Default for AppModeConfig {
fn default() -> Self {
Self {
mode: AppMode::Default,
portable_dir: None,
}
}
}
/// Write the persisted app-mode.json to the given config directory.
fn write_app_mode_config(config_dir: &Path, config: &AppModeConfig) {
let path = config_dir.join(APP_MODE_FILE);
if let Some(parent) = path.parent() {
if let Err(e) = fs::create_dir_all(parent) {
eprintln!("[desktop] failed to create dir for app-mode.json: {e}");
return;
}
}
let data = match serde_json::to_string_pretty(config) {
Ok(data) => data,
Err(e) => {
eprintln!("[desktop] failed to serialize app-mode.json: {e}");
return;
}
};
if let Err(e) = fs::write(&path, data) {
eprintln!("[desktop] failed to write app-mode.json: {e}");
}
}
/// Check if a directory contains portable config/data files.
fn dir_has_portable_data(dir: &Path) -> bool {
if !dir.is_dir() {
return false;
}
[WINDOW_STATE_FILE, TERMINAL_CONFIG_FILE]
.iter()
.any(|f| dir.join(f).is_file())
|| dir.join("Cache").is_dir()
|| dir.join("EBWebView").is_dir()
}
/// Resolve the default portable config directory: exe_dir/CLAUDE_CONFIG_DIR.
fn get_default_portable_dir() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let mut dir = exe.parent()?.to_path_buf();
dir.push("CLAUDE_CONFIG_DIR");
Some(dir)
}
#[derive(Serialize, Deserialize)]
struct TerminalConfig {
#[serde(default)]
bash_path: Option<String>,
}
impl TerminalConfig {
fn load(app: &AppHandle) -> Self {
let path = match terminal_config_path(app) {
Some(p) => p,
None => return Self::default(),
};
fs::read_to_string(&path)
.ok()
.and_then(|data| serde_json::from_str(&data).ok())
.unwrap_or_default()
}
fn save(&self, app: &AppHandle) -> Result<(), String> {
let Some(path) = terminal_config_path(app) else {
return Err("terminal config path is unavailable".to_string());
};
if let Some(parent) = path.parent() {
if let Err(err) = fs::create_dir_all(parent) {
return Err(format!("create terminal config directory: {err}"));
}
}
let data = match serde_json::to_string_pretty(self) {
Ok(data) => data,
Err(err) => {
return Err(format!("serialize terminal config: {err}"));
}
};
if let Err(err) = fs::write(&path, data) {
return Err(format!("write terminal config: {err}"));
}
Ok(())
}
}
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() {
Ok(dir) => Some(dir.join(TERMINAL_CONFIG_FILE)),
Err(err) => {
eprintln!("[desktop] failed to resolve app config dir: {err}");
None
}
}
})
}
impl Default for TerminalConfig {
fn default() -> Self {
Self { bash_path: None }
}
}
#[derive(Default)]
struct ServerState(Mutex<ServerStatus>);
@ -372,6 +505,75 @@ fn cancel_update_install(app: AppHandle) -> Result<(), String> {
Ok(())
}
/// 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))
} else {
get_default_portable_dir()
};
serde_json::json!({
"mode": if std::env::var("CLAUDE_CONFIG_DIR").is_ok() { "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()),
})
}
/// 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
// 确定当前正在使用的配置目录
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;
}
}
};
let app_mode = if mode == "portable" {
AppMode::Portable
} else {
AppMode::Default
};
let config = AppModeConfig {
mode: app_mode,
portable_dir,
};
// 写入当前活跃的配置目录
write_app_mode_config(&active_config_dir, &config);
// 修复:同时始终将模式状态写入系统默认配置目录,
// 以防止应用层切换模式后main.rs在下一次启动时读取到旧的系统全局状态
if let Ok(sys_dir) = app.path().app_config_dir() {
if sys_dir != active_config_dir {
write_app_mode_config(&sys_dir, &config);
}
}
}
/// 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);
serde_json::json!({
"defaultPortableDir": default_portable.as_ref().and_then(|p| p.to_str()),
"hasData": has_data,
})
}
fn set_app_quitting(app: &AppHandle, next: bool) {
if let Some(state) = app.try_state::<AppExitState>() {
if let Ok(mut is_quitting) = state.is_quitting.lock() {
@ -441,13 +643,24 @@ fn is_window_state_visible_on_any_monitor(
}
fn window_state_path(app: &AppHandle) -> Option<PathBuf> {
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
// 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
}
}
}
})
}
fn resolve_portable_state_path() -> Option<PathBuf> {
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> {
@ -631,7 +844,7 @@ fn terminal_spawn(
cwd: Option<String>,
) -> Result<TerminalSpawnResult, String> {
let cwd_path = resolve_terminal_cwd(cwd)?;
let shell = resolved_terminal_shell()?;
let shell = resolved_terminal_shell(&app)?;
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize {
@ -831,6 +1044,19 @@ fn terminal_kill(state: State<'_, TerminalState>, session_id: u32) -> Result<(),
Ok(())
}
#[tauri::command]
fn get_terminal_bash_path(app: AppHandle) -> Option<String> {
let config = TerminalConfig::load(&app);
config.bash_path
}
#[tauri::command]
fn set_terminal_bash_path(app: AppHandle, path: Option<String>) -> Result<(), String> {
let mut config = TerminalConfig::load(&app);
config.bash_path = normalize_terminal_bash_path(path)?;
config.save(&app)
}
#[tauri::command]
async fn macos_notification_permission_state() -> Result<String, String> {
run_notification_bridge(macos_notifications::permission_state).await
@ -1069,8 +1295,9 @@ fn read_desktop_terminal_config() -> Option<DesktopTerminalConfig> {
settings.desktop_terminal
}
fn resolved_terminal_shell() -> Result<String, String> {
let system_default = default_shell();
fn resolved_terminal_shell(app: &AppHandle) -> Result<String, String> {
let terminal_config = TerminalConfig::load(app);
let system_default = default_shell(terminal_config.bash_path.as_deref());
let platform = current_terminal_host_platform();
let configured = read_desktop_terminal_config();
let override_shell =
@ -1124,7 +1351,31 @@ fn resolve_desktop_terminal_shell(
}
}
fn default_shell() -> String {
fn normalize_terminal_bash_path(path: Option<String>) -> Result<Option<String>, String> {
let Some(path) = path else {
return Ok(None);
};
let trimmed = path.trim();
if trimmed.is_empty() {
return Ok(None);
}
let bash_path = PathBuf::from(trimmed);
if !bash_path.is_file() {
return Err(format!("terminal bash path does not exist: {trimmed}"));
}
Ok(Some(trimmed.to_string()))
}
fn default_shell(_custom_bash: Option<&str>) -> String {
// On Windows, use configured bash path if set and valid
#[cfg(target_os = "windows")]
if let Some(bash_path) = _custom_bash {
let trimmed = bash_path.trim();
if !trimmed.is_empty() && PathBuf::from(trimmed).is_file() {
return trimmed.to_string();
}
}
#[cfg(target_os = "windows")]
{
std::env::var("COMSPEC").unwrap_or_else(|_| "powershell.exe".to_string())
@ -1258,12 +1509,28 @@ fn start_server_sidecar(app: &AppHandle) -> Result<ServerRuntime, String> {
.shell()
.sidecar("claude-sidecar")
.map_err(|err| format!("resolve sidecar: {err}"))?;
for (key, value) in terminal_environment(&default_shell()) {
for (key, value) in terminal_environment(&default_shell(None)) {
sidecar = sidecar.env(key, value);
}
sidecar = sidecar
.env("CLAUDE_H5_AUTO_PUBLIC_URL", "1")
.env("CLAUDE_H5_DIST_DIR", h5_dist_dir);
// Pass through CLAUDE_CONFIG_DIR so the sidecar (Node.js) uses the same
// portable config directory. Also set XDG_CACHE_HOME to redirect the
// env-paths cache from %LOCALAPPDATA%\claude-cli-nodejs\ to alongside
// the portable config dir.
if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
let cache_dir = PathBuf::from(&config_dir).join("Cache");
if let Err(e) = fs::create_dir_all(&cache_dir) {
eprintln!("[desktop] failed to create Cache dir: {e}");
}
sidecar = sidecar
.env("CLAUDE_CONFIG_DIR", &config_dir)
.env("XDG_CACHE_HOME", cache_dir.to_string_lossy().to_string())
.env("CLAUDE_H5_AUTO_PUBLIC_URL", "1")
.env("CLAUDE_H5_DIST_DIR", h5_dist_dir);
} else {
sidecar = sidecar
.env("CLAUDE_H5_AUTO_PUBLIC_URL", "1")
.env("CLAUDE_H5_DIST_DIR", h5_dist_dir);
}
let sidecar = sidecar.args([
"server",
"--app-root",
@ -1378,10 +1645,18 @@ fn start_adapters_sidecars(app: &AppHandle) -> Result<Vec<CommandChild>, String>
.shell()
.sidecar("claude-sidecar")
.map_err(|err| format!("resolve {label} adapter sidecar: {err}"))?;
for (key, value) in terminal_environment(&default_shell()) {
for (key, value) in terminal_environment(&default_shell(None)) {
sidecar = sidecar.env(key, value);
}
let sidecar = sidecar.env("ADAPTER_SERVER_URL", &server_ws_url).args([
// Pass through CLAUDE_CONFIG_DIR for portable installs
let mut sidecar_final = sidecar.env("ADAPTER_SERVER_URL", &server_ws_url);
if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
let cache_dir = PathBuf::from(&config_dir).join("Cache");
sidecar_final = sidecar_final
.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,
@ -1507,8 +1782,8 @@ fn kill_windows_sidecars() {
mod tests {
use super::{
decode_terminal_output, default_utf8_locale, ensure_utf8_locale,
has_meaningful_intersection, is_persistable_window_state, parse_env_block,
resolve_desktop_terminal_shell, run_notification_bridge, select_h5_dist_dir,
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,
};
@ -1610,6 +1885,48 @@ mod tests {
assert_eq!(env.get("EMPTY").map(String::as_str), Some(""));
}
#[test]
fn terminal_bash_path_normalizer_clears_blank_values() {
assert_eq!(
normalize_terminal_bash_path(Some(" ".to_string())).expect("blank path clears"),
None
);
assert_eq!(
normalize_terminal_bash_path(None).expect("missing path clears"),
None
);
}
#[test]
fn terminal_bash_path_normalizer_rejects_missing_files() {
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");
assert!(error.contains("terminal bash path does not exist"));
}
#[test]
fn terminal_bash_path_normalizer_accepts_existing_files() {
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!(
normalize_terminal_bash_path(Some(format!(" {} ", path.display())))
.expect("existing file is accepted"),
Some(path.to_string_lossy().to_string())
);
fs::remove_file(path).expect("remove bash path fixture");
}
#[test]
fn terminal_environment_forces_utf8_locale_when_shell_uses_c_locale() {
let mut env = HashMap::from([
@ -1758,10 +2075,15 @@ pub fn run() {
terminal_write,
terminal_resize,
terminal_kill,
get_terminal_bash_path,
set_terminal_bash_path,
macos_notification_permission_state,
macos_request_notification_permission,
macos_send_notification,
open_windows_notification_settings,
get_app_mode,
set_app_mode,
detect_portable_dir,
set_app_zoom
]);

View File

@ -1,6 +1,108 @@
// Prevents additional console window on Windows in release
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use std::fs;
use std::path::PathBuf;
fn main() {
// Determine if we should start in portable mode and set CLAUDE_CONFIG_DIR
// before any Tauri/WebView2 initialization.
//
// Mode resolution order:
// 1. External CLAUDE_CONFIG_DIR env var (batch script etc.) — always respected
// 2. Persisted app-mode.json saying "portable"
// 3. Auto-detect: default portable dir already has data files
//
// In "default" mode the app does NOT set CLAUDE_CONFIG_DIR itself.
// It relies on the env var (if set externally) or falls back to system dirs.
// 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());
}
// If CLAUDE_CONFIG_DIR is set (either from env or from our startup logic above),
// redirect WebView2 user data folder so EBWebView cache lives alongside it.
if let Ok(config_dir) = std::env::var("CLAUDE_CONFIG_DIR") {
let webview_data = PathBuf::from(&config_dir).join("EBWebView");
if let Err(e) = fs::create_dir_all(&webview_data) {
eprintln!("[desktop] failed to create EBWebView dir: {e}");
}
std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", &webview_data);
}
claude_code_desktop_lib::run()
}
/// Determine if we should start in portable mode.
/// Returns the portable config directory path if yes, None for default mode.
fn determine_startup_portable_dir() -> Option<PathBuf> {
// 1. 如果外部已经设置了 CLAUDE_CONFIG_DIR 环境变量,我们不应该覆盖它,直接返回 None 让 main 保持原样
if std::env::var("CLAUDE_CONFIG_DIR").is_ok() {
return None;
}
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
let mut default_portable = exe_dir.to_path_buf();
default_portable.push("CLAUDE_CONFIG_DIR");
// 辅助函数:读取 app-mode.json 获取模式和自定义便携路径
fn get_mode_from_config(dir: &std::path::Path) -> Option<(String, 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);
Some((mode, portable_dir))
}
// 2. 优先检查便携目录本地的配置文件(保证移动便携版到新电脑依然生效,并能正确处理切回默认模式)
if let Some((mode, portable_dir)) = get_mode_from_config(&default_portable) {
if mode == "portable" {
return Some(portable_dir.unwrap_or(default_portable.clone()));
} else {
return None; // 明确设置了 default直接使用系统默认
}
}
// 3. 检查系统全局配置
#[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"));
#[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")));
if let Some(ref sys_cfg) = system_config {
// 修复:必须使用 Tauri 默认的 bundle identifier
let app_subdir = sys_cfg.join("com.claude-code-haha.desktop");
if let Some((mode, portable_dir)) = get_mode_from_config(&app_subdir) {
if mode == "portable" {
return Some(portable_dir.unwrap_or(default_portable.clone()));
} else {
return None; // 明确设置了 default
}
}
}
// 4. 自动检测:如果默认便携目录中已经存在数据文件,则自动进入便携模式
fn dir_has_portable_data(dir: &std::path::Path) -> bool {
if !dir.is_dir() {
return false;
}
["window-state.json", "terminal-config.json"]
.iter()
.any(|f| dir.join(f).is_file())
|| dir.join("Cache").is_dir()
|| dir.join("EBWebView").is_dir()
}
if dir_has_portable_data(&default_portable) {
return Some(default_portable);
}
None
}

View File

@ -55,4 +55,12 @@ export const terminalApi = {
const events = await import('@tauri-apps/api/event')
return events.listen<TerminalExitPayload>('terminal-exit', (event) => handler(event.payload))
},
getBashPath() {
return invoke<string | null>('get_terminal_bash_path', undefined)
},
setBashPath(path: string | null) {
return invoke<void>('set_terminal_bash_path', { path })
},
}

View File

@ -206,6 +206,12 @@ export const en = {
'settings.terminal.status.exited': 'Exited',
'settings.terminal.status.error': 'Error',
'settings.terminal.status.unavailable': 'Unavailable',
'settings.terminal.bashPathLabel': 'Bash Path',
'settings.terminal.bashPathDescription': 'Windows defaults to CMD as the terminal shell. If tool calls use Unix commands (grep, sed, etc.), configure the path to a Bash executable here (e.g., Git Bash).',
'settings.terminal.bashPathSave': 'Save',
'settings.terminal.bashPathReset': 'Reset to default',
'settings.terminal.bashPathSaved': 'Saved',
'settings.terminal.bashPathInvalid': 'Path does not exist. Select a valid Bash executable.',
'terminal.newTab': 'New Terminal',
'terminal.openInTab': 'Open in Tab',
'terminal.closePanel': 'Close terminal panel',
@ -763,6 +769,19 @@ 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
'settings.general.appearanceTitle': 'Appearance',
'settings.general.appearanceDescription': 'Switch between the warm classic workspace, dark workspace, and a pure white workspace.',

View File

@ -208,6 +208,12 @@ export const zh: Record<TranslationKey, string> = {
'settings.terminal.status.exited': '已退出',
'settings.terminal.status.error': '错误',
'settings.terminal.status.unavailable': '不可用',
'settings.terminal.bashPathLabel': 'Bash 路径',
'settings.terminal.bashPathDescription': 'Windows 下默认使用 CMD 作为终端。如果工具调用 grep、sed 等 Unix 命令,请在此配置 Bash 可执行文件路径(如 Git Bash。',
'settings.terminal.bashPathSave': '保存',
'settings.terminal.bashPathReset': '恢复默认',
'settings.terminal.bashPathSaved': '已保存',
'settings.terminal.bashPathInvalid': '路径不存在,请选择有效的 Bash 可执行文件',
'terminal.newTab': '新建终端',
'terminal.openInTab': '在 Tab 中打开',
'terminal.closePanel': '关闭终端面板',
@ -765,6 +771,20 @@ 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
'settings.general.appearanceTitle': '配色主题',
'settings.general.appearanceDescription': '在经典暖色、暗色与纯白工作区之间切换。',

View File

@ -9,7 +9,7 @@ import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { Dropdown } from '../components/shared/Dropdown'
import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, WebSearchMode } from '../types/settings'
import type { PermissionMode, EffortLevel, ThemeMode, UpdateProxyMode, WebSearchMode, AppMode } from '../types/settings'
import type { Locale } from '../i18n'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
@ -1385,6 +1385,10 @@ function GeneralSettings() {
setWebSearch,
responseLanguage,
setResponseLanguage,
appMode,
appModeRequiresRestart,
fetchAppMode,
setAppMode: setAppModeAction,
uiZoom,
setUiZoom,
} = useSettingsStore()
@ -1392,6 +1396,8 @@ function GeneralSettings() {
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('default')
const [notificationActionRunning, setNotificationActionRunning] = useState(false)
const [modeSwitchConfirmOpen, setModeSwitchConfirmOpen] = useState(false)
const [pendingMode, setPendingMode] = useState<AppMode | null>(null)
const [uiZoomDraft, setUiZoomDraft] = useState(uiZoom)
const [isUiZoomDragging, setIsUiZoomDragging] = useState(false)
const isUiZoomDraggingRef = useRef(false)
@ -1419,6 +1425,11 @@ function GeneralSettings() {
}
}, [])
useEffect(() => {
if (!isTauriRuntime()) return
void fetchAppMode()
}, [fetchAppMode])
const EFFORT_LABELS: Record<EffortLevel, string> = {
low: t('settings.general.effort.low'),
medium: t('settings.general.effort.medium'),
@ -1639,6 +1650,79 @@ 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>
@ -1906,6 +1990,22 @@ function GeneralSettings() {
</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);
}}
title={t('settings.general.modeSwitchTitle')}
body={t('settings.general.modeSwitchBody', {
mode: pendingMode === 'portable' ? t('settings.general.modePortable') : t('settings.general.modeDefault'),
})}
confirmLabel={t('settings.general.modeSwitchConfirm')}
cancelLabel={t('common.cancel')}
/>
</div>
)
}

View File

@ -1,4 +1,4 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useSettingsStore } from '../stores/settingsStore'
@ -28,6 +28,8 @@ const terminalMocks = vi.hoisted(() => {
kill: vi.fn(),
onOutput: vi.fn(),
onExit: vi.fn(),
getBashPath: vi.fn(),
setBashPath: vi.fn(),
}
})
@ -48,6 +50,8 @@ vi.mock('../api/terminal', () => ({
kill: terminalMocks.kill,
onOutput: terminalMocks.onOutput,
onExit: terminalMocks.onExit,
getBashPath: terminalMocks.getBashPath,
setBashPath: terminalMocks.setBashPath,
},
}))
@ -55,6 +59,7 @@ import { TerminalSettings } from './TerminalSettings'
describe('TerminalSettings', () => {
beforeEach(() => {
vi.restoreAllMocks()
useSettingsStore.setState({ locale: 'en' })
useSettingsStore.setState({
desktopTerminal: {
@ -70,6 +75,8 @@ describe('TerminalSettings', () => {
terminalMocks.kill.mockReset()
terminalMocks.onOutput.mockReset()
terminalMocks.onExit.mockReset()
terminalMocks.getBashPath.mockReset()
terminalMocks.setBashPath.mockReset()
terminalMocks.terminalInstance.loadAddon.mockClear()
terminalMocks.terminalInstance.open.mockClear()
terminalMocks.terminalInstance.dispose.mockClear()
@ -80,6 +87,8 @@ describe('TerminalSettings', () => {
terminalMocks.fitInstance.fit.mockClear()
terminalMocks.onOutput.mockResolvedValue(vi.fn())
terminalMocks.onExit.mockResolvedValue(vi.fn())
terminalMocks.getBashPath.mockResolvedValue(null)
terminalMocks.setBashPath.mockResolvedValue(undefined)
terminalMocks.write.mockResolvedValue(undefined)
terminalMocks.resize.mockResolvedValue(undefined)
terminalMocks.kill.mockResolvedValue(undefined)
@ -92,6 +101,7 @@ describe('TerminalSettings', () => {
observe = vi.fn()
disconnect = vi.fn()
})
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('MacIntel')
})
it('shows a desktop-runtime empty state outside Tauri', () => {
@ -162,4 +172,35 @@ describe('TerminalSettings', () => {
expect(screen.getAllByText('Startup shell')).toHaveLength(2)
expect(screen.getByText('Use for new terminal sessions and after restart.')).toBeInTheDocument()
})
it('saves a custom Windows bash path from the terminal settings panel', async () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32')
terminalMocks.available = true
terminalMocks.getBashPath.mockResolvedValue('C:\\Program Files\\Git\\bin\\bash.exe')
render(<TerminalSettings showPreferences />)
const input = await screen.findByDisplayValue('C:\\Program Files\\Git\\bin\\bash.exe')
fireEvent.change(input, { target: { value: ' C:\\Tools\\Git\\bin\\bash.exe ' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() => {
expect(terminalMocks.setBashPath).toHaveBeenCalledWith('C:\\Tools\\Git\\bin\\bash.exe')
})
expect(await screen.findByRole('button', { name: 'Saved' })).toBeInTheDocument()
})
it('shows an invalid path message when native bash path validation fails', async () => {
vi.spyOn(navigator, 'platform', 'get').mockReturnValue('Win32')
terminalMocks.available = true
terminalMocks.setBashPath.mockRejectedValue(new Error('terminal bash path does not exist'))
render(<TerminalSettings showPreferences />)
const input = await screen.findByPlaceholderText('Bash Path')
fireEvent.change(input, { target: { value: 'C:\\missing\\bash.exe' } })
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Path does not exist. Select a valid Bash executable.')).toBeInTheDocument()
})
})

View File

@ -374,77 +374,80 @@ export function TerminalSettings({
)}
{showPreferences && isWindows && (
<div className="mb-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
<div className="flex flex-col gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.terminal.preferencesTitle')}
</h3>
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
{t('settings.terminal.preferencesBody')}
</p>
</div>
<>
<div className="mb-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
<div className="flex flex-col gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.terminal.preferencesTitle')}
</h3>
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
{t('settings.terminal.preferencesBody')}
</p>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.terminal.startupShell')}
</span>
<Dropdown<DesktopTerminalStartupShell>
items={shellItems}
value={startupShell}
onChange={(value) => {
setStartupShell(value)
setPreferencesError(null)
setPreferencesSaved(false)
}}
width="100%"
trigger={
<button
type="button"
className="flex h-10 w-full items-center justify-between rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)]"
>
<span>{shellItems.find((item) => item.value === startupShell)?.label ?? startupShell}</span>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">expand_more</span>
</button>
}
/>
</div>
{startupShell === 'custom' && (
<Input
label={t('settings.terminal.customPath')}
placeholder={t('settings.terminal.customPathPlaceholder')}
value={customShellPath}
onChange={(event) => {
setCustomShellPath(event.target.value)
setPreferencesError(null)
setPreferencesSaved(false)
}}
error={preferencesError ?? undefined}
/>
)}
{preferencesError && startupShell !== 'custom' && (
<p className="text-xs text-[var(--color-error)]">{preferencesError}</p>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
size="sm"
loading={preferencesSaving}
onClick={() => void savePreferences()}
>
{t('settings.terminal.saveShell')}
</Button>
{preferencesSaved && (
<span className="text-xs text-[var(--color-text-secondary)]">
{t('settings.terminal.saveShellSuccess')}
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.terminal.startupShell')}
</span>
<Dropdown<DesktopTerminalStartupShell>
items={shellItems}
value={startupShell}
onChange={(value) => {
setStartupShell(value)
setPreferencesError(null)
setPreferencesSaved(false)
}}
width="100%"
trigger={
<button
type="button"
className="flex h-10 w-full items-center justify-between rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)]"
>
<span>{shellItems.find((item) => item.value === startupShell)?.label ?? startupShell}</span>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">expand_more</span>
</button>
}
/>
</div>
{startupShell === 'custom' && (
<Input
label={t('settings.terminal.customPath')}
placeholder={t('settings.terminal.customPathPlaceholder')}
value={customShellPath}
onChange={(event) => {
setCustomShellPath(event.target.value)
setPreferencesError(null)
setPreferencesSaved(false)
}}
error={preferencesError ?? undefined}
/>
)}
{preferencesError && startupShell !== 'custom' && (
<p className="text-xs text-[var(--color-error)]">{preferencesError}</p>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
size="sm"
loading={preferencesSaving}
onClick={() => void savePreferences()}
>
{t('settings.terminal.saveShell')}
</Button>
{preferencesSaved && (
<span className="text-xs text-[var(--color-text-secondary)]">
{t('settings.terminal.saveShellSuccess')}
</span>
)}
</div>
</div>
</div>
</div>
<BashPathSettings isTauri={terminalApi.isAvailable()} />
</>
)}
{status === 'unavailable' ? (
@ -499,3 +502,120 @@ function StatusPill({ status, label }: { status: TerminalStatus; label: string }
</span>
)
}
function BashPathSettings({ isTauri }: { isTauri: boolean }) {
const t = useTranslation()
const [bashPath, setBashPath] = useState<string | null>(null)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [invalid, setInvalid] = useState(false)
useEffect(() => {
if (!isTauri) return
void terminalApi.getBashPath().then((path) => setBashPath(path)).catch(() => {})
}, [isTauri])
const handleSave = async () => {
const trimmed = bashPath?.trim() || null
setSaving(true)
setInvalid(false)
setSaved(false)
try {
await terminalApi.setBashPath(trimmed)
setBashPath(trimmed)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch {
setInvalid(true)
} finally {
setSaving(false)
}
}
const handleReset = async () => {
setSaving(true)
setSaved(false)
setInvalid(false)
try {
await terminalApi.setBashPath(null)
setBashPath(null)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch {
// ignore
} finally {
setSaving(false)
}
}
const handleBrowse = async () => {
if (!isTauri) return
try {
const { open } = await import('@tauri-apps/plugin-dialog')
const selected = await open({
title: t('settings.terminal.bashPathLabel'),
multiple: false,
filters: [{
name: 'Bash Executable',
extensions: ['exe', '', 'bat', 'cmd', 'ps1'],
}],
})
if (selected && typeof selected === 'string') {
setBashPath(selected)
setInvalid(false)
}
} catch {
// user cancelled
}
}
if (!isTauri) return null
return (
<div className="mb-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-3">
<label className="mb-1.5 block text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.terminal.bashPathLabel')}
</label>
<p className="mb-2 text-xs text-[var(--color-text-tertiary)]">
{t('settings.terminal.bashPathDescription')}
</p>
<div className="flex gap-2">
<input
type="text"
value={bashPath || ''}
onChange={(e) => { setBashPath(e.target.value); setInvalid(false); setSaved(false) }}
placeholder={t('settings.terminal.bashPathLabel')}
className="flex-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm font-mono text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)]"
/>
<button
type="button"
onClick={handleBrowse}
className="inline-flex h-8 items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)]"
>
<span className="material-symbols-outlined text-[16px]">folder_open</span>
</button>
<button
type="button"
onClick={handleSave}
disabled={saving}
className="inline-flex h-8 items-center gap-1 rounded-[var(--radius-sm)] bg-[var(--color-text-primary)] px-3 text-xs font-medium text-[var(--color-surface)] transition-colors hover:opacity-90 disabled:opacity-50"
>
{saved ? t('settings.terminal.bashPathSaved') : t('settings.terminal.bashPathSave')}
</button>
<button
type="button"
onClick={handleReset}
disabled={saving || bashPath === null}
className="inline-flex h-8 items-center gap-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] px-3 text-xs font-medium text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-hover)] disabled:opacity-50"
>
{t('settings.terminal.bashPathReset')}
</button>
</div>
{invalid && (
<p className="mt-1.5 text-xs text-[var(--color-error)]">
{t('settings.terminal.bashPathInvalid')}
</p>
)}
</div>
)
}

View File

@ -166,6 +166,66 @@ describe('settingsStore update proxy persistence', () => {
})
})
describe('settingsStore app mode', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
delete (window as unknown as { __TAURI_INTERNALS__?: object }).__TAURI_INTERNALS__
})
it('hydrates app mode from the native desktop command', async () => {
const invoke = vi.fn().mockResolvedValue({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
})
vi.doMock('@tauri-apps/api/core', () => ({ invoke }))
const tauriWindow = window as unknown as { __TAURI_INTERNALS__?: object }
tauriWindow.__TAURI_INTERNALS__ = {}
const { useSettingsStore } = await import('./settingsStore')
await useSettingsStore.getState().fetchAppMode()
expect(invoke).toHaveBeenCalledWith('get_app_mode')
expect(useSettingsStore.getState().appMode).toEqual({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
})
})
it('persists app mode through the native desktop command and marks restart required', 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')
expect(invoke).toHaveBeenCalledWith('set_app_mode', {
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
})
expect(useSettingsStore.getState().appMode).toEqual({
mode: 'portable',
portableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
defaultPortableDir: 'C:\\cc-haha\\CLAUDE_CONFIG_DIR',
})
expect(useSettingsStore.getState().appModeRequiresRestart).toBe(true)
})
})
describe('settingsStore desktop notification persistence', () => {
beforeEach(() => {
vi.resetModules()

View File

@ -5,6 +5,8 @@ import { modelsApi } from '../api/models'
import { h5AccessApi } from '../api/h5Access'
import {
isThemeMode,
type AppMode,
type AppModeConfig,
type DesktopTerminalSettings,
type DesktopTerminalStartupShell,
type H5AccessSettings,
@ -16,6 +18,7 @@ import {
type UpdateProxySettings,
type WebSearchSettings,
} from '../types/settings'
import { isTauriRuntime } from '../lib/desktopRuntime'
import type { Locale } from '../i18n'
import {
APP_ZOOM_CONTROL_STEP,
@ -64,6 +67,9 @@ type SettingsStore = {
isLoading: boolean
error: string | null
appMode: AppModeConfig
appModeRequiresRestart: boolean
fetchAll: () => Promise<void>
fetchH5Access: () => Promise<void>
setPermissionMode: (mode: PermissionMode) => Promise<void>
@ -85,6 +91,8 @@ type SettingsStore = {
publicBaseUrl?: string | null
}) => Promise<void>
setResponseLanguage: (language: string) => Promise<void>
fetchAppMode: () => Promise<void>
setAppMode: (mode: AppMode, portableDir?: string | null) => Promise<void>
setUiZoom: (zoom: number) => void
}
@ -126,6 +134,8 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
isLoading: false,
error: null,
appMode: { mode: 'default', portableDir: null, defaultPortableDir: null },
appModeRequiresRestart: false,
setUiZoom: (zoom: number) => {
const level = normalizeAppZoomLevel(zoom)
set({ uiZoom: level })
@ -364,6 +374,37 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
set({ responseLanguage: prev })
}
},
fetchAppMode: async () => {
if (!isTauriRuntime()) return
try {
const { invoke } = await import('@tauri-apps/api/core')
const result: AppModeConfig = await invoke('get_app_mode')
set({ appMode: result })
} catch { /* silently ignore - not in Tauri or command unavailable */ }
},
setAppMode: async (mode, portableDir) => {
if (!isTauriRuntime()) return
const prev = get().appMode
const newMode: AppModeConfig = {
...prev,
mode,
portableDir: mode === 'portable'
? portableDir ?? prev.defaultPortableDir ?? prev.portableDir
: null,
}
set({ appMode: newMode, appModeRequiresRestart: true })
try {
const { invoke } = await import('@tauri-apps/api/core')
await invoke('set_app_mode', {
mode,
portableDir: newMode.portableDir || null,
})
} catch {
set({ appMode: prev, appModeRequiresRestart: false })
}
},
}))
function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): WebSearchSettings {

View File

@ -2,19 +2,21 @@ import { describe, expect, it } from 'vitest'
import css from './globals.css?raw'
const normalizedCss = css.replace(/\r\n/g, '\n')
function getThemeBlock(selector: ':root,\n[data-theme="light"]' | '[data-theme="white"]' | '[data-theme="dark"]') {
const start = css.indexOf(`${selector} {`)
const start = normalizedCss.indexOf(`${selector} {`)
expect(start).toBeGreaterThanOrEqual(0)
const bodyStart = css.indexOf('{', start)
const bodyStart = normalizedCss.indexOf('{', start)
let depth = 0
for (let index = bodyStart; index < css.length; index += 1) {
const char = css[index]
for (let index = bodyStart; index < normalizedCss.length; index += 1) {
const char = normalizedCss[index]
if (char === '{') depth += 1
if (char === '}') {
depth -= 1
if (depth === 0) {
return css.slice(bodyStart + 1, index)
return normalizedCss.slice(bodyStart + 1, index)
}
}
}
@ -23,11 +25,11 @@ function getThemeBlock(selector: ':root,\n[data-theme="light"]' | '[data-theme="
}
function getCssBetween(startMarker: string, endMarker: string) {
const start = css.indexOf(startMarker)
const start = normalizedCss.indexOf(startMarker)
expect(start).toBeGreaterThanOrEqual(0)
const end = css.indexOf(endMarker, start)
const end = normalizedCss.indexOf(endMarker, start)
expect(end).toBeGreaterThan(start)
return css.slice(start, end)
return normalizedCss.slice(start, end)
}
describe('desktop theme tokens', () => {

View File

@ -66,3 +66,11 @@ export type UserSettings = {
desktopTerminal?: Partial<DesktopTerminalSettings>
[key: string]: unknown
}
export type AppMode = 'default' | 'portable'
export type AppModeConfig = {
mode: AppMode
portableDir: string | null
defaultPortableDir: string | null
}

View File

@ -35,6 +35,7 @@ describe('installPrePushHook', () => {
expect(result.hookPath).toBe(hookPath)
expect(result.liveConfigured).toBe(false)
expect(readFileSync(hookPath, 'utf8')).toContain('echo quality')
if (process.platform === 'win32') return
expect(statSync(hookPath).mode & 0o111).toBeGreaterThan(0)
} finally {
rmSync(tempDir, { recursive: true, force: true })

View File

@ -67,6 +67,10 @@ describe('Adapters API', () => {
const configPath = path.join(tmpDir, 'adapters.json')
const stat = await fs.stat(configPath)
if (process.platform === 'win32') {
expect(stat.isFile()).toBe(true)
return
}
expect(stat.mode & 0o777).toBe(0o600)
})

View File

@ -763,9 +763,9 @@ describe('WebSocket Chat Integration', () => {
})
afterAll(async () => {
server?.stop()
server?.stop(true)
if (tmpDir) {
await fs.rm(tmpDir, { recursive: true, force: true })
await rmWithRetry(tmpDir)
}
if (originalCliPath) {
process.env.CLAUDE_CLI_PATH = originalCliPath
@ -906,6 +906,7 @@ describe('WebSocket Chat Integration', () => {
workDir: worktreePath!,
repository: launchInfo!.repository,
})
await sessionService.deletePlaceholderSessionFiles(sessionId, worktreePath!)
const messages = await runTurn(sessionId, 'Continue in the existing worktree')
const statusVerbs = messages

View File

@ -1240,6 +1240,21 @@ describe('SessionService', () => {
expect(context.branches.some((branch) => branch.name.startsWith('worktree-desktop-'))).toBe(false)
})
it('should keep stale worktree records when their paths cannot be resolved', async () => {
const workDir = await createCleanGitRepo(tmpDir)
const staleWorktreeName = `stale-worktree-${Date.now()}`
const staleWorktree = path.join(tmpDir, staleWorktreeName)
git(workDir, 'worktree', 'add', '-b', 'stale-worktree', staleWorktree, 'feature/rail')
await fs.rm(staleWorktree, { recursive: true, force: true })
const context = await getRepositoryContext(workDir)
const expectedPath = path.join(await fs.realpath(tmpDir), staleWorktreeName).normalize('NFC')
expect(context.state).toBe('ok')
expect(context.worktrees.some((worktree) => (
worktree.path === expectedPath && worktree.branch === 'stale-worktree' && !worktree.current
))).toBe(true)
})
it('should let git carry compatible dirty changes during direct branch launch', async () => {
const workDir = await createCleanGitRepo(tmpDir)
await fs.writeFile(path.join(workDir, 'README.md'), 'main\nlocal-pricing-edit\n')
@ -1529,6 +1544,26 @@ describe('SessionService', () => {
expect(launchInfo!.transcriptMessageCount).toBe(2)
expect(launchInfo!.customTitle).toBe('Saved chat')
})
it('should recover Windows drive paths from sanitized project dirs for old transcripts without metadata', async () => {
const sessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff'
const userUuid = crypto.randomUUID()
const userEntry = makeUserEntry('Resume this Windows session', userUuid)
delete userEntry.cwd
await writeSessionFile('g--AI-NTos-NT-deepseek-nano-core', sessionId, [
makeSnapshotEntry(),
userEntry,
makeAssistantEntry('Welcome back', userUuid),
])
const expectedWorkDir = 'g:\\AI\\NTos\\NT\\deepseek\\nano\\core'
expect(await service.getSessionWorkDir(sessionId)).toBe(expectedWorkDir)
const launchInfo = await service.getSessionLaunchInfo(sessionId)
expect(launchInfo).not.toBeNull()
expect(launchInfo!.workDir).toBe(expectedWorkDir)
expect(launchInfo!.transcriptMessageCount).toBe(2)
})
})
// ============================================================================
@ -1547,11 +1582,8 @@ describe('Sessions API', () => {
const { handleSessionsApi } = await import('../api/sessions.js')
const { handleConversationsApi } = await import('../api/conversations.js')
const port = 30000 + Math.floor(Math.random() * 10000)
baseUrl = `http://127.0.0.1:${port}`
server = Bun.serve({
port,
port: 0,
hostname: '127.0.0.1',
async fetch(req) {
@ -1569,6 +1601,7 @@ describe('Sessions API', () => {
return new Response('Not Found', { status: 404 })
},
})
baseUrl = `http://127.0.0.1:${server.port}`
})
afterEach(async () => {

View File

@ -461,11 +461,8 @@ describe('Teams API', () => {
const { handleTeamsApi } = await import('../api/teams.js')
const port = 40000 + Math.floor(Math.random() * 10000)
baseUrl = `http://127.0.0.1:${port}`
server = Bun.serve({
port,
port: 0,
hostname: '127.0.0.1',
async fetch(req) {
@ -479,6 +476,7 @@ describe('Teams API', () => {
return new Response('Not Found', { status: 404 })
},
})
baseUrl = `http://127.0.0.1:${server.port}`
})
afterEach(async () => {

View File

@ -778,9 +778,10 @@ const RECENT_PROJECTS_CACHE_TTL = 30_000
const DESKTOP_WORKTREE_MARKER = '/.claude/worktrees/'
function projectNameForRecentPath(realPath: string, fallback: string): string {
const displayRoot = realPath.includes(DESKTOP_WORKTREE_MARKER)
? realPath.slice(0, realPath.indexOf(DESKTOP_WORKTREE_MARKER))
: realPath
const normalizedRealPath = realPath.replace(/\\/g, '/')
const displayRoot = normalizedRealPath.includes(DESKTOP_WORKTREE_MARKER)
? normalizedRealPath.slice(0, normalizedRealPath.indexOf(DESKTOP_WORKTREE_MARKER))
: normalizedRealPath
return displayRoot.split('/').filter(Boolean).pop() || fallback
}

View File

@ -189,6 +189,19 @@ async function resolveDirectory(workDir: string): Promise<string> {
return realPath
}
async function canonicalizeKnownPath(candidate: string): Promise<string> {
try {
return (await fs.realpath(candidate)).normalize('NFC')
} catch {
return path.resolve(candidate).normalize('NFC')
}
}
function isSameOrInsidePath(root: string, candidate: string): boolean {
const relative = path.relative(root, candidate)
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative))
}
function normalizeRemoteBranch(ref: string): { name: string; remoteRef: string } | null {
if (!ref || ref.endsWith('/HEAD')) return null
const slash = ref.indexOf('/')
@ -353,11 +366,17 @@ export async function getRepositoryContext(workDir: string): Promise<RepositoryC
])
const currentBranch = branchResult.stdout.trim() || null
const worktreeRecords = worktreeResult.code === 0 ? parseWorktreeList(worktreeResult.stdout) : []
const rawWorktreeRecords = worktreeResult.code === 0 ? parseWorktreeList(worktreeResult.stdout) : []
const worktreeRecords = await Promise.all(
rawWorktreeRecords.map(async (worktree) => ({
...worktree,
path: await canonicalizeKnownPath(worktree.path),
})),
)
const worktrees = worktreeRecords.map((worktree) => ({
path: worktree.path,
branch: worktree.branch,
current: absWorkDir === worktree.path || absWorkDir.startsWith(`${worktree.path}${path.sep}`),
current: isSameOrInsidePath(worktree.path, absWorkDir),
}))
return {

View File

@ -950,10 +950,16 @@ export class SessionService {
* Reverses sanitizePath(): `-Users-nanmi-workspace` `/Users/nanmi/workspace`.
*/
desanitizePath(sanitized: string): string {
// The sanitized form replaces all '/' (and '\') with '-'.
// The sanitized form replaces all non-alphanumeric characters with '-'.
// This fallback is necessarily lossy, but old Windows transcripts without
// session-meta still need the drive separator restored well enough to resume.
const windowsDrivePath = sanitized.match(/^([a-zA-Z])--(.+)$/)
if (windowsDrivePath) {
return `${windowsDrivePath[1]}:${path.win32.sep}${windowsDrivePath[2].replace(/-/g, path.win32.sep)}`
}
// On POSIX the original path starts with '/', so the sanitized form starts with '-'.
// We restore by replacing every '-' with '/' (the platform separator).
// On Windows the leading character would be a drive letter, but we handle POSIX here.
// UNC-style Windows paths also recover to a leading double separator on Windows.
return sanitized.replace(/-/g, path.sep)
}

View File

@ -0,0 +1,26 @@
import { afterEach, describe, expect, test } from 'bun:test'
import { join } from 'path'
import { tmpdir } from 'os'
import { CACHE_PATHS } from '../cachePaths.js'
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
afterEach(() => {
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
}
})
describe('CACHE_PATHS portable mode', () => {
test('places logs under CLAUDE_CONFIG_DIR when portable mode is active', () => {
const configDir = join(tmpdir(), 'cc-haha-portable-cache')
process.env.CLAUDE_CONFIG_DIR = configDir
expect(CACHE_PATHS.baseLogs().startsWith(join(configDir, 'Cache'))).toBe(true)
expect(CACHE_PATHS.errors().startsWith(join(configDir, 'Cache'))).toBe(true)
expect(CACHE_PATHS.messages().startsWith(join(configDir, 'Cache'))).toBe(true)
expect(CACHE_PATHS.mcpLogs('test:server').startsWith(join(configDir, 'Cache'))).toBe(true)
})
})

View File

@ -3,7 +3,13 @@ import { join } from 'path'
import { getFsImplementation } from './fsOperations.js'
import { djb2Hash } from './hash.js'
const paths = envPaths('claude-cli')
// When CLAUDE_CONFIG_DIR is set (portable mode), place cache under it
// so the install is fully self-contained. Otherwise fall back to the
// system default (%LOCALAPPDATA%\claude-cli-nodejs\Cache on Windows).
function getCacheRoot() {
const claudeConfigDir = (process.env as Record<string, string | undefined>).CLAUDE_CONFIG_DIR
return claudeConfigDir ? join(claudeConfigDir, 'Cache') : envPaths('claude-cli').cache
}
// Local sanitizePath using djb2Hash — NOT the shared version from
// sessionStoragePortable.ts which uses Bun.hash (wyhash) when available.
@ -23,14 +29,14 @@ function getProjectDir(cwd: string): string {
}
export const CACHE_PATHS = {
baseLogs: () => join(paths.cache, getProjectDir(getFsImplementation().cwd())),
baseLogs: () => join(getCacheRoot(), getProjectDir(getFsImplementation().cwd())),
errors: () =>
join(paths.cache, getProjectDir(getFsImplementation().cwd()), 'errors'),
join(getCacheRoot(), getProjectDir(getFsImplementation().cwd()), 'errors'),
messages: () =>
join(paths.cache, getProjectDir(getFsImplementation().cwd()), 'messages'),
join(getCacheRoot(), getProjectDir(getFsImplementation().cwd()), 'messages'),
mcpLogs: (serverName: string) =>
join(
paths.cache,
getCacheRoot(),
getProjectDir(getFsImplementation().cwd()),
// Sanitize server name for Windows compatibility (colons are reserved for drive letters)
`mcp-logs-${sanitizePath(serverName)}`,