mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Let Windows users choose a usable desktop terminal shell
The desktop terminal defaulted to COMSPEC on Windows, which commonly lands on cmd.exe and makes the settings terminal feel broken for users who expect PowerShell. This change adds a desktop-only terminal startup-shell setting in Settings, keeps the existing system default untouched unless the user opts in, and resolves the selected shell inside the Tauri PTY spawn path so docked terminals, terminal tabs, and the settings terminal stay aligned. Constraint: Existing terminal sessions and non-Windows defaults must keep their current behavior unless a Windows user explicitly changes the setting Rejected: Reuse settings.defaultShell | that setting already controls CLI ! commands and would couple unrelated shell semantics Rejected: Flip Windows default to pwsh automatically | too risky for users whose current COMSPEC-based terminal already works Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep desktop terminal shell selection separate from CLI shell routing unless both paths are redesigned together Tested: cd desktop && bun run test -- src/stores/settingsStore.test.ts src/pages/TerminalSettings.test.tsx Tested: cd desktop/src-tauri && cargo test desktop_terminal_shell_resolution -- --nocapture Tested: bun run check:desktop Tested: bun run check:native Not-tested: Real interactive spawn on a physical Windows machine
This commit is contained in:
parent
a4c92ec785
commit
8ff5353455
@ -297,6 +297,25 @@ struct TerminalExitPayload {
|
||||
signal: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DesktopTerminalSettingsFile {
|
||||
desktop_terminal: Option<DesktopTerminalConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct DesktopTerminalConfig {
|
||||
startup_shell: Option<String>,
|
||||
custom_shell_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TerminalHostPlatform {
|
||||
Windows,
|
||||
Posix,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_server_url(state: State<'_, ServerState>) -> Result<String, String> {
|
||||
let guard = state
|
||||
@ -612,7 +631,7 @@ fn terminal_spawn(
|
||||
cwd: Option<String>,
|
||||
) -> Result<TerminalSpawnResult, String> {
|
||||
let cwd_path = resolve_terminal_cwd(cwd)?;
|
||||
let shell = default_shell();
|
||||
let shell = resolved_terminal_shell()?;
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system
|
||||
.openpty(PtySize {
|
||||
@ -1033,6 +1052,78 @@ fn home_dir() -> Option<PathBuf> {
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn claude_config_dir() -> Option<PathBuf> {
|
||||
std::env::var_os("CLAUDE_CONFIG_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| home_dir().map(|path| path.join(".claude")))
|
||||
}
|
||||
|
||||
fn desktop_terminal_settings_path() -> Option<PathBuf> {
|
||||
claude_config_dir().map(|path| path.join("settings.json"))
|
||||
}
|
||||
|
||||
fn read_desktop_terminal_config() -> Option<DesktopTerminalConfig> {
|
||||
let path = desktop_terminal_settings_path()?;
|
||||
let contents = fs::read_to_string(path).ok()?;
|
||||
let settings = serde_json::from_str::<DesktopTerminalSettingsFile>(&contents).ok()?;
|
||||
settings.desktop_terminal
|
||||
}
|
||||
|
||||
fn resolved_terminal_shell() -> Result<String, String> {
|
||||
let system_default = default_shell();
|
||||
let platform = current_terminal_host_platform();
|
||||
let configured = read_desktop_terminal_config();
|
||||
let override_shell =
|
||||
resolve_desktop_terminal_shell(platform, configured.as_ref(), &system_default)?;
|
||||
Ok(override_shell.unwrap_or(system_default))
|
||||
}
|
||||
|
||||
fn current_terminal_host_platform() -> TerminalHostPlatform {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
TerminalHostPlatform::Windows
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
TerminalHostPlatform::Posix
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_desktop_terminal_shell(
|
||||
platform: TerminalHostPlatform,
|
||||
config: Option<&DesktopTerminalConfig>,
|
||||
_system_default: &str,
|
||||
) -> Result<Option<String>, String> {
|
||||
if platform != TerminalHostPlatform::Windows {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(config) = config else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(startup_shell) = config.startup_shell.as_deref().map(str::trim) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
match startup_shell {
|
||||
"" | "system" => Ok(None),
|
||||
"pwsh" => Ok(Some("pwsh.exe".to_string())),
|
||||
"powershell" => Ok(Some("powershell.exe".to_string())),
|
||||
"cmd" => Ok(Some("cmd.exe".to_string())),
|
||||
"custom" => {
|
||||
let path = config
|
||||
.custom_shell_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "custom terminal shell path is empty".to_string())?;
|
||||
Ok(Some(path.to_string()))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_shell() -> String {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
@ -1417,7 +1508,8 @@ mod tests {
|
||||
use super::{
|
||||
decode_terminal_output, default_utf8_locale, ensure_utf8_locale,
|
||||
has_meaningful_intersection, is_persistable_window_state, parse_env_block,
|
||||
run_notification_bridge, select_h5_dist_dir, StoredWindowState, SERVER_BIND_HOST,
|
||||
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};
|
||||
@ -1557,6 +1649,50 @@ mod tests {
|
||||
assert_eq!(env.get("LC_ALL").map(String::as_str), Some("C.UTF-8"));
|
||||
}
|
||||
|
||||
#[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"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn desktop_terminal_shell_resolution_supports_windows_pwsh_and_custom_path() {
|
||||
let pwsh = DesktopTerminalConfig {
|
||||
startup_shell: Some("pwsh".to_string()),
|
||||
custom_shell_path: None,
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_desktop_terminal_shell(
|
||||
TerminalHostPlatform::Windows,
|
||||
Some(&pwsh),
|
||||
"powershell.exe",
|
||||
)
|
||||
.expect("pwsh resolution should succeed"),
|
||||
Some("pwsh.exe".to_string())
|
||||
);
|
||||
|
||||
let custom = DesktopTerminalConfig {
|
||||
startup_shell: Some("custom".to_string()),
|
||||
custom_shell_path: Some("/tmp/custom-shell".to_string()),
|
||||
};
|
||||
assert_eq!(
|
||||
resolve_desktop_terminal_shell(
|
||||
TerminalHostPlatform::Windows,
|
||||
Some(&custom),
|
||||
"powershell.exe",
|
||||
)
|
||||
.expect("custom resolution should succeed"),
|
||||
Some("/tmp/custom-shell".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_sidecar_binds_lan_but_reports_loopback_control_url() {
|
||||
assert_eq!(SERVER_BIND_HOST, "0.0.0.0");
|
||||
|
||||
@ -181,6 +181,25 @@ export const en = {
|
||||
'settings.terminal.windowTitle': 'Host shell',
|
||||
'settings.terminal.unavailableTitle': 'Desktop runtime required',
|
||||
'settings.terminal.unavailableBody': 'Open this page in the packaged desktop app to start an interactive terminal.',
|
||||
'settings.terminal.preferencesTitle': 'Startup shell',
|
||||
'settings.terminal.preferencesBody': 'Use for new terminal sessions and after restart.',
|
||||
'settings.terminal.startupShell': 'Startup shell',
|
||||
'settings.terminal.customPath': 'Custom shell path',
|
||||
'settings.terminal.customPathPlaceholder': 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
||||
'settings.terminal.customPathRequired': 'Enter a shell path before saving.',
|
||||
'settings.terminal.customPathAbsolute': 'Custom shell path must be an absolute Windows path.',
|
||||
'settings.terminal.saveShell': 'Save shell',
|
||||
'settings.terminal.saveShellSuccess': 'Saved. Restart or open a new terminal to apply it.',
|
||||
'settings.terminal.shell.system': 'System default',
|
||||
'settings.terminal.shell.systemDesc': 'Keep the current COMSPEC-based behavior.',
|
||||
'settings.terminal.shell.pwsh': 'PowerShell 7 (pwsh)',
|
||||
'settings.terminal.shell.pwshDesc': 'Use pwsh.exe when it is installed.',
|
||||
'settings.terminal.shell.powershell': 'Windows PowerShell',
|
||||
'settings.terminal.shell.powershellDesc': 'Use the built-in powershell.exe.',
|
||||
'settings.terminal.shell.cmd': 'Command Prompt',
|
||||
'settings.terminal.shell.cmdDesc': 'Use cmd.exe explicitly.',
|
||||
'settings.terminal.shell.custom': 'Custom executable',
|
||||
'settings.terminal.shell.customDesc': 'Launch an absolute shell executable path.',
|
||||
'settings.terminal.status.idle': 'Idle',
|
||||
'settings.terminal.status.starting': 'Starting',
|
||||
'settings.terminal.status.running': 'Running',
|
||||
|
||||
@ -183,6 +183,25 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.terminal.windowTitle': '宿主机 Shell',
|
||||
'settings.terminal.unavailableTitle': '需要桌面端运行时',
|
||||
'settings.terminal.unavailableBody': '请在打包后的桌面端里打开这个页面,才能启动交互式终端。',
|
||||
'settings.terminal.preferencesTitle': '启动 Shell',
|
||||
'settings.terminal.preferencesBody': '用于新开的终端会话,以及点击重启后的终端。',
|
||||
'settings.terminal.startupShell': '启动 Shell',
|
||||
'settings.terminal.customPath': '自定义 Shell 路径',
|
||||
'settings.terminal.customPathPlaceholder': 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
||||
'settings.terminal.customPathRequired': '保存前请先填写 Shell 路径。',
|
||||
'settings.terminal.customPathAbsolute': '自定义 Shell 路径必须是绝对 Windows 路径。',
|
||||
'settings.terminal.saveShell': '保存 Shell',
|
||||
'settings.terminal.saveShellSuccess': '已保存。重启终端或新开终端后生效。',
|
||||
'settings.terminal.shell.system': '系统默认',
|
||||
'settings.terminal.shell.systemDesc': '保持当前基于 COMSPEC 的行为。',
|
||||
'settings.terminal.shell.pwsh': 'PowerShell 7 (pwsh)',
|
||||
'settings.terminal.shell.pwshDesc': '安装了 pwsh.exe 时优先使用它。',
|
||||
'settings.terminal.shell.powershell': 'Windows PowerShell',
|
||||
'settings.terminal.shell.powershellDesc': '使用系统自带的 powershell.exe。',
|
||||
'settings.terminal.shell.cmd': '命令提示符',
|
||||
'settings.terminal.shell.cmdDesc': '明确使用 cmd.exe。',
|
||||
'settings.terminal.shell.custom': '自定义可执行文件',
|
||||
'settings.terminal.shell.customDesc': '启动一个绝对路径的 Shell 可执行文件。',
|
||||
'settings.terminal.status.idle': '空闲',
|
||||
'settings.terminal.status.starting': '启动中',
|
||||
'settings.terminal.status.running': '运行中',
|
||||
|
||||
@ -112,7 +112,7 @@ export function Settings() {
|
||||
{activeTab === 'general' && <GeneralSettings />}
|
||||
{activeTab === 'h5Access' && <H5AccessSettings />}
|
||||
{activeTab === 'adapters' && <AdapterSettings />}
|
||||
{activeTab === 'terminal' && <TerminalSettings />}
|
||||
{activeTab === 'terminal' && <TerminalSettings showPreferences />}
|
||||
{activeTab === 'mcp' && <McpSettings />}
|
||||
{activeTab === 'agents' && <AgentsSettings />}
|
||||
{activeTab === 'skills' && <SkillSettings />}
|
||||
|
||||
@ -56,6 +56,13 @@ import { TerminalSettings } from './TerminalSettings'
|
||||
describe('TerminalSettings', () => {
|
||||
beforeEach(() => {
|
||||
useSettingsStore.setState({ locale: 'en' })
|
||||
useSettingsStore.setState({
|
||||
desktopTerminal: {
|
||||
startupShell: 'system',
|
||||
customShellPath: '',
|
||||
},
|
||||
setDesktopTerminal: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
terminalMocks.available = false
|
||||
terminalMocks.spawn.mockReset()
|
||||
terminalMocks.write.mockReset()
|
||||
@ -142,4 +149,17 @@ describe('TerminalSettings', () => {
|
||||
expect(terminalMocks.terminalInstance.write).toHaveBeenCalledWith('hello\r\n')
|
||||
expect(terminalMocks.terminalInstance.write).not.toHaveBeenCalledWith('ignored\r\n')
|
||||
})
|
||||
|
||||
it('shows Windows-only startup shell controls in settings mode', () => {
|
||||
vi.stubGlobal('navigator', {
|
||||
...navigator,
|
||||
platform: 'Win32',
|
||||
userAgent: 'Windows',
|
||||
})
|
||||
|
||||
render(<TerminalSettings showPreferences />)
|
||||
|
||||
expect(screen.getAllByText('Startup shell')).toHaveLength(2)
|
||||
expect(screen.getByText('Use for new terminal sessions and after restart.')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,8 +1,13 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { Terminal as XTermTerminal } from '@xterm/xterm'
|
||||
import type { FitAddon as XTermFitAddon } from '@xterm/addon-fit'
|
||||
import { useTranslation, type TranslationKey } from '../i18n'
|
||||
import { terminalApi } from '../api/terminal'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
import { Dropdown } from '../components/shared/Dropdown'
|
||||
import { Input } from '../components/shared/Input'
|
||||
import { Button } from '../components/shared/Button'
|
||||
import type { DesktopTerminalStartupShell } from '../types/settings'
|
||||
|
||||
type TerminalStatus = 'idle' | 'starting' | 'running' | 'exited' | 'error' | 'unavailable'
|
||||
|
||||
@ -24,6 +29,7 @@ type TerminalSettingsProps = {
|
||||
testId?: string
|
||||
workspace?: boolean
|
||||
docked?: boolean
|
||||
showPreferences?: boolean
|
||||
}
|
||||
|
||||
export function TerminalSettings({
|
||||
@ -35,8 +41,11 @@ export function TerminalSettings({
|
||||
testId = 'settings-terminal-host',
|
||||
workspace = false,
|
||||
docked = false,
|
||||
showPreferences = false,
|
||||
}: TerminalSettingsProps = {}) {
|
||||
const t = useTranslation()
|
||||
const desktopTerminal = useSettingsStore((state) => state.desktopTerminal)
|
||||
const setDesktopTerminal = useSettingsStore((state) => state.setDesktopTerminal)
|
||||
const hostRef = useRef<HTMLDivElement | null>(null)
|
||||
const terminalRef = useRef<XTermTerminal | null>(null)
|
||||
const fitRef = useRef<XTermFitAddon | null>(null)
|
||||
@ -45,6 +54,51 @@ export function TerminalSettings({
|
||||
const [status, setStatus] = useState<TerminalStatus>(() => terminalApi.isAvailable() ? 'idle' : 'unavailable')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [shellInfo, setShellInfo] = useState<{ shell: string; cwd: string } | null>(null)
|
||||
const [startupShell, setStartupShell] = useState<DesktopTerminalStartupShell>(desktopTerminal?.startupShell ?? 'system')
|
||||
const [customShellPath, setCustomShellPath] = useState(desktopTerminal?.customShellPath ?? '')
|
||||
const [preferencesError, setPreferencesError] = useState<string | null>(null)
|
||||
const [preferencesSaved, setPreferencesSaved] = useState(false)
|
||||
const [preferencesSaving, setPreferencesSaving] = useState(false)
|
||||
const isWindows = typeof navigator !== 'undefined' && /Win/i.test(navigator.platform || navigator.userAgent)
|
||||
|
||||
useEffect(() => {
|
||||
setStartupShell(desktopTerminal?.startupShell ?? 'system')
|
||||
setCustomShellPath(desktopTerminal?.customShellPath ?? '')
|
||||
}, [desktopTerminal])
|
||||
|
||||
useEffect(() => {
|
||||
if (!preferencesSaved) return
|
||||
const timer = window.setTimeout(() => setPreferencesSaved(false), 2500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [preferencesSaved])
|
||||
|
||||
const shellItems = useMemo(() => [
|
||||
{
|
||||
value: 'system' as const,
|
||||
label: t('settings.terminal.shell.system'),
|
||||
description: t('settings.terminal.shell.systemDesc'),
|
||||
},
|
||||
{
|
||||
value: 'pwsh' as const,
|
||||
label: t('settings.terminal.shell.pwsh'),
|
||||
description: t('settings.terminal.shell.pwshDesc'),
|
||||
},
|
||||
{
|
||||
value: 'powershell' as const,
|
||||
label: t('settings.terminal.shell.powershell'),
|
||||
description: t('settings.terminal.shell.powershellDesc'),
|
||||
},
|
||||
{
|
||||
value: 'cmd' as const,
|
||||
label: t('settings.terminal.shell.cmd'),
|
||||
description: t('settings.terminal.shell.cmdDesc'),
|
||||
},
|
||||
{
|
||||
value: 'custom' as const,
|
||||
label: t('settings.terminal.shell.custom'),
|
||||
description: t('settings.terminal.shell.customDesc'),
|
||||
},
|
||||
], [t])
|
||||
|
||||
const resizeSession = useCallback(() => {
|
||||
const terminal = terminalRef.current
|
||||
@ -202,6 +256,36 @@ export function TerminalSettings({
|
||||
terminalRef.current?.clear()
|
||||
}
|
||||
|
||||
const savePreferences = async () => {
|
||||
setPreferencesError(null)
|
||||
setPreferencesSaved(false)
|
||||
|
||||
const trimmedPath = customShellPath.trim()
|
||||
if (startupShell === 'custom') {
|
||||
if (!trimmedPath) {
|
||||
setPreferencesError(t('settings.terminal.customPathRequired'))
|
||||
return
|
||||
}
|
||||
if (!/^[A-Za-z]:[\\/]/.test(trimmedPath)) {
|
||||
setPreferencesError(t('settings.terminal.customPathAbsolute'))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setPreferencesSaving(true)
|
||||
try {
|
||||
await setDesktopTerminal({
|
||||
startupShell,
|
||||
customShellPath: trimmedPath,
|
||||
})
|
||||
setPreferencesSaved(true)
|
||||
} catch (err) {
|
||||
setPreferencesError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setPreferencesSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex h-full flex-col overflow-hidden ${
|
||||
docked
|
||||
@ -289,6 +373,80 @@ export function TerminalSettings({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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="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>
|
||||
)}
|
||||
|
||||
{status === 'unavailable' ? (
|
||||
<div className="flex flex-1 items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-8 text-center">
|
||||
<div>
|
||||
|
||||
@ -305,6 +305,127 @@ describe('settingsStore thinking persistence', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('settingsStore desktop terminal shell persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.clearAllMocks()
|
||||
window.localStorage.clear()
|
||||
})
|
||||
|
||||
it('hydrates desktop terminal settings from user settings and falls back to system defaults', async () => {
|
||||
vi.doMock('../api/settings', () => ({
|
||||
settingsApi: {
|
||||
getUser: vi.fn().mockResolvedValue({
|
||||
desktopTerminal: {
|
||||
startupShell: 'pwsh',
|
||||
customShellPath: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
||||
},
|
||||
}),
|
||||
updateUser: vi.fn(),
|
||||
getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }),
|
||||
setPermissionMode: vi.fn(),
|
||||
getCliLauncherStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/models', () => ({
|
||||
modelsApi: {
|
||||
list: vi.fn().mockResolvedValue({ models: [] }),
|
||||
getCurrent: vi.fn().mockResolvedValue({ model: null }),
|
||||
setCurrent: vi.fn(),
|
||||
getEffort: vi.fn().mockResolvedValue({ level: 'medium' }),
|
||||
setEffort: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/h5Access', () => ({
|
||||
h5AccessApi: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
settings: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
}),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
regenerate: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
|
||||
expect(useSettingsStore.getState().desktopTerminal).toEqual({
|
||||
startupShell: 'system',
|
||||
customShellPath: '',
|
||||
})
|
||||
|
||||
await useSettingsStore.getState().fetchAll()
|
||||
|
||||
expect(useSettingsStore.getState().desktopTerminal).toEqual({
|
||||
startupShell: 'pwsh',
|
||||
customShellPath: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe',
|
||||
})
|
||||
})
|
||||
|
||||
it('persists desktop terminal settings explicitly', async () => {
|
||||
const updateUser = vi.fn().mockResolvedValue({ ok: true })
|
||||
|
||||
vi.doMock('../api/settings', () => ({
|
||||
settingsApi: {
|
||||
getUser: vi.fn(),
|
||||
updateUser,
|
||||
getPermissionMode: vi.fn(),
|
||||
setPermissionMode: vi.fn(),
|
||||
getCliLauncherStatus: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/models', () => ({
|
||||
modelsApi: {
|
||||
list: vi.fn(),
|
||||
getCurrent: vi.fn(),
|
||||
setCurrent: vi.fn(),
|
||||
getEffort: vi.fn(),
|
||||
setEffort: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.doMock('../api/h5Access', () => ({
|
||||
h5AccessApi: {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
settings: {
|
||||
enabled: false,
|
||||
tokenPreview: null,
|
||||
allowedOrigins: [],
|
||||
publicBaseUrl: null,
|
||||
},
|
||||
}),
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
regenerate: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
const { useSettingsStore } = await import('./settingsStore')
|
||||
|
||||
await useSettingsStore.getState().setDesktopTerminal({
|
||||
startupShell: 'custom',
|
||||
customShellPath: 'C:\\tools\\pwsh.exe',
|
||||
})
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith({
|
||||
desktopTerminal: {
|
||||
startupShell: 'custom',
|
||||
customShellPath: 'C:\\tools\\pwsh.exe',
|
||||
},
|
||||
})
|
||||
expect(useSettingsStore.getState().desktopTerminal).toEqual({
|
||||
startupShell: 'custom',
|
||||
customShellPath: 'C:\\tools\\pwsh.exe',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('settingsStore theme persistence', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
|
||||
@ -3,7 +3,17 @@ import { ApiError } from '../api/client'
|
||||
import { settingsApi } from '../api/settings'
|
||||
import { modelsApi } from '../api/models'
|
||||
import { h5AccessApi } from '../api/h5Access'
|
||||
import { isThemeMode, type H5AccessSettings, type PermissionMode, type EffortLevel, type ModelInfo, type ThemeMode, type WebSearchSettings } from '../types/settings'
|
||||
import {
|
||||
isThemeMode,
|
||||
type DesktopTerminalSettings,
|
||||
type DesktopTerminalStartupShell,
|
||||
type H5AccessSettings,
|
||||
type PermissionMode,
|
||||
type EffortLevel,
|
||||
type ModelInfo,
|
||||
type ThemeMode,
|
||||
type WebSearchSettings,
|
||||
} from '../types/settings'
|
||||
import type { Locale } from '../i18n'
|
||||
import {
|
||||
APP_ZOOM_CONTROL_STEP,
|
||||
@ -42,6 +52,7 @@ type SettingsStore = {
|
||||
theme: ThemeMode
|
||||
skipWebFetchPreflight: boolean
|
||||
desktopNotificationsEnabled: boolean
|
||||
desktopTerminal: DesktopTerminalSettings
|
||||
webSearch: WebSearchSettings
|
||||
h5Access: H5AccessSettings
|
||||
h5AccessError: string | null
|
||||
@ -60,6 +71,7 @@ type SettingsStore = {
|
||||
setTheme: (theme: ThemeMode) => Promise<void>
|
||||
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
|
||||
setDesktopNotificationsEnabled: (enabled: boolean) => Promise<void>
|
||||
setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise<void>
|
||||
setWebSearch: (settings: WebSearchSettings) => Promise<void>
|
||||
enableH5Access: () => Promise<string>
|
||||
disableH5Access: () => Promise<void>
|
||||
@ -79,6 +91,11 @@ const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = {
|
||||
publicBaseUrl: null,
|
||||
}
|
||||
|
||||
const DEFAULT_DESKTOP_TERMINAL_SETTINGS: DesktopTerminalSettings = {
|
||||
startupShell: 'system',
|
||||
customShellPath: '',
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
permissionMode: 'default',
|
||||
currentModel: null,
|
||||
@ -90,6 +107,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
theme: useUIStore.getState().theme,
|
||||
skipWebFetchPreflight: true,
|
||||
desktopNotificationsEnabled: false,
|
||||
desktopTerminal: DEFAULT_DESKTOP_TERMINAL_SETTINGS,
|
||||
webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' },
|
||||
h5Access: DEFAULT_H5_ACCESS_SETTINGS,
|
||||
h5AccessError: null,
|
||||
@ -128,6 +146,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
theme,
|
||||
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
|
||||
desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true,
|
||||
desktopTerminal: normalizeDesktopTerminalSettings(userSettings.desktopTerminal),
|
||||
webSearch: normalizeWebSearchSettings(userSettings.webSearch),
|
||||
h5Access: h5AccessResult.settings,
|
||||
h5AccessError: h5AccessResult.error,
|
||||
@ -232,6 +251,18 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
setDesktopTerminal: async (settings) => {
|
||||
const prev = get().desktopTerminal
|
||||
const next = normalizeDesktopTerminalSettings(settings)
|
||||
set({ desktopTerminal: next })
|
||||
try {
|
||||
await settingsApi.updateUser({ desktopTerminal: next })
|
||||
} catch (error) {
|
||||
set({ desktopTerminal: prev })
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
setWebSearch: async (webSearch) => {
|
||||
const prev = get().webSearch
|
||||
const next = normalizeWebSearchSettings(webSearch)
|
||||
@ -320,6 +351,21 @@ function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): We
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDesktopTerminalSettings(
|
||||
settings: Partial<DesktopTerminalSettings> | undefined,
|
||||
): DesktopTerminalSettings {
|
||||
const startupShell = isDesktopTerminalStartupShell(settings?.startupShell)
|
||||
? settings.startupShell
|
||||
: DEFAULT_DESKTOP_TERMINAL_SETTINGS.startupShell
|
||||
|
||||
return {
|
||||
startupShell,
|
||||
customShellPath: typeof settings?.customShellPath === 'string'
|
||||
? settings.customShellPath
|
||||
: DEFAULT_DESKTOP_TERMINAL_SETTINGS.customShellPath,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5AccessSettings {
|
||||
return {
|
||||
enabled: settings?.enabled === true,
|
||||
@ -366,3 +412,11 @@ function isLegacyH5EndpointError(error: unknown) {
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback
|
||||
}
|
||||
|
||||
function isDesktopTerminalStartupShell(value: unknown): value is DesktopTerminalStartupShell {
|
||||
return value === 'system'
|
||||
|| value === 'pwsh'
|
||||
|| value === 'powershell'
|
||||
|| value === 'cmd'
|
||||
|| value === 'custom'
|
||||
}
|
||||
|
||||
@ -25,6 +25,18 @@ export type H5AccessSettings = {
|
||||
publicBaseUrl: string | null
|
||||
}
|
||||
|
||||
export type DesktopTerminalStartupShell =
|
||||
| 'system'
|
||||
| 'pwsh'
|
||||
| 'powershell'
|
||||
| 'cmd'
|
||||
| 'custom'
|
||||
|
||||
export type DesktopTerminalSettings = {
|
||||
startupShell: DesktopTerminalStartupShell
|
||||
customShellPath: string
|
||||
}
|
||||
|
||||
export type ModelInfo = {
|
||||
id: string
|
||||
name: string
|
||||
@ -43,5 +55,6 @@ export type UserSettings = {
|
||||
desktopNotificationsEnabled?: boolean
|
||||
webSearch?: WebSearchSettings
|
||||
language?: string
|
||||
desktopTerminal?: Partial<DesktopTerminalSettings>
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user