fix(local-terminal): use platform default shell (#792)

This commit is contained in:
Kang committed 2026-09-26 19:00:35 +08:00
1 parent d48a0f8ba4
commit 1d0f302783
7 files changed
+103 -30

No files matched your search

+5
View File
@@ -35,6 +35,11 @@ pub struct StartRecordingRequest {
explicit_path: Option<String>,
}
#[tauri::command]
pub fn get_default_local_shell() -> String {
core::default_local_shell_path()
}
#[tauri::command]
pub async fn create_ssh_session(
app: tauri::AppHandle,
+1
View File
@@ -54,6 +54,7 @@ pub(crate) use session::{
SessionCwdReplacement, now_session_started_at, replace_cwd_state, session_command_channel,
update_cwd_if_changed,
};
pub(crate) use terminal_session::local::default_local_shell_path;
pub use terminal_session::local::{LocalSessionConfig, create_local_session};
pub use terminal_session::serial::{SerialConfig, create_serial_session, list_serial_ports};
pub use terminal_session::telnet::{
@@ -23,23 +23,23 @@ fn default_local_shell_args(program: &str) -> Vec<String> {
}
}
fn default_shell_spec() -> ShellCommandSpec {
pub(crate) fn default_local_shell_path() -> String {
#[cfg(target_os = "windows")]
{
ShellCommandSpec {
program: resolve_program_for_spawn("powershell.exe"),
args: Vec::new(),
resolution_source: ShellResolutionSource::Direct,
}
"powershell.exe".to_string()
}
#[cfg(not(target_os = "windows"))]
{
let program = std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string());
ShellCommandSpec {
args: default_local_shell_args(&program),
program,
resolution_source: ShellResolutionSource::Direct,
}
std::env::var("SHELL").unwrap_or_else(|_| "/bin/bash".to_string())
}
}
fn default_shell_spec() -> ShellCommandSpec {
let program = default_local_shell_path();
ShellCommandSpec {
args: default_local_shell_args(&program),
program: resolve_program_for_spawn(&program),
resolution_source: ShellResolutionSource::Direct,
}
}
+1
View File
@@ -212,6 +212,7 @@ pub fn run() {
cmd::session::create_ssh_session,
cmd::session::create_temporary_ssh_session,
cmd::session::create_multiplexed_ssh_session,
cmd::session::get_default_local_shell,
cmd::session::create_local_session,
cmd::session::create_telnet_session,
cmd::session::create_serial_session,
+34 -13
View File
@@ -16,6 +16,7 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { isMacOS, isWindows } from "@/lib/platform";
import type { RecordingMode } from "@/types/global";
interface LocalTerminalProps {
@@ -37,7 +38,17 @@ interface LocalTerminalProps {
setDynamicTabTitle: (v: boolean) => void;
}
const BUILTIN_SHELL_PATHS = ["powershell.exe", "cmd.exe", "bash", "wsl.exe", "wt.exe"] as const;
const BUILTIN_SHELL_PATHS: readonly string[] = isWindows
? ["powershell.exe", "cmd.exe", "bash", "wsl.exe", "wt.exe"]
: ["zsh", "bash", "fish"];
function shellBasename(path: string) {
return path.replace(/\\/g, "/").split("/").filter(Boolean).pop()?.toLowerCase() || path;
}
function matchesShellPreset(shellPath: string, preset: string) {
return shellPath === preset || shellBasename(shellPath) === shellBasename(preset);
}
export function LocalTerminal({
shellPath,
@@ -90,9 +101,8 @@ export function LocalTerminal({
<div className="flex flex-col gap-2 sm:flex-row">
<Select
value={
BUILTIN_SHELL_PATHS.includes(shellPath as (typeof BUILTIN_SHELL_PATHS)[number])
? shellPath
: "custom"
BUILTIN_SHELL_PATHS.find((preset) => matchesShellPreset(shellPath, preset)) ??
"custom"
}
onValueChange={handleShellSelectChange}
>
@@ -100,15 +110,26 @@ export function LocalTerminal({
<SelectValue placeholder={t("dialog.selectShell", "Select Shell")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="powershell.exe">
{t("dialog.shellPowerShell", "PowerShell")}
</SelectItem>
<SelectItem value="cmd.exe">{t("dialog.shellCmd", "Command Prompt")}</SelectItem>
<SelectItem value="bash">{t("dialog.shellBash", "Bash")}</SelectItem>
<SelectItem value="wsl.exe">{t("dialog.shellWsl", "WSL")}</SelectItem>
<SelectItem value="wt.exe">
{t("dialog.shellWindowsTerminal", "Windows Terminal")}
</SelectItem>
{isWindows ? (
<>
<SelectItem value="powershell.exe">
{t("dialog.shellPowerShell", "PowerShell")}
</SelectItem>
<SelectItem value="cmd.exe">{t("dialog.shellCmd", "Command Prompt")}</SelectItem>
<SelectItem value="bash">{t("dialog.shellBash", "Bash")}</SelectItem>
<SelectItem value="wsl.exe">{t("dialog.shellWsl", "WSL")}</SelectItem>
<SelectItem value="wt.exe">
{t("dialog.shellWindowsTerminal", "Windows Terminal")}
</SelectItem>
</>
) : (
<>
{isMacOS && <SelectItem value="zsh">Zsh</SelectItem>}
<SelectItem value="bash">{t("dialog.shellBash", "Bash")}</SelectItem>
<SelectItem value="fish">Fish</SelectItem>
{!isMacOS && <SelectItem value="zsh">Zsh</SelectItem>}
</>
)}
<SelectItem value="custom">{t("dialog.shellCustom", "Custom...")}</SelectItem>
</SelectContent>
</Select>
+27 -1
View File
@@ -7,6 +7,7 @@ const {
closeMock,
emitMock,
invokeMock,
localTerminalMock,
rdpFormMock,
serialFormMock,
sshFormMock,
@@ -16,6 +17,7 @@ const {
closeMock: vi.fn(),
emitMock: vi.fn(),
invokeMock: vi.fn(),
localTerminalMock: vi.fn(),
rdpFormMock: vi.fn(),
serialFormMock: vi.fn(),
sshFormMock: vi.fn(),
@@ -45,7 +47,12 @@ vi.mock("@tauri-apps/api/window", () => ({
}));
vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() }));
vi.mock("@/components/sessions/LocalTerminal", () => ({ LocalTerminal: () => null }));
vi.mock("@/components/sessions/LocalTerminal", () => ({
LocalTerminal: (props: Record<string, unknown>) => {
localTerminalMock(props);
return null;
},
}));
vi.mock("@/components/sessions/SerialForm", () => ({
SerialForm: (props: Record<string, unknown>) => {
serialFormMock(props);
@@ -252,18 +259,37 @@ describe("NewSessionPage", () => {
]);
case "list_serial_ports":
return Promise.resolve(["COM3"]);
case "get_default_local_shell":
return Promise.resolve("/bin/zsh");
case "save_connection":
return Promise.resolve(rdpConnection.id);
default:
return Promise.reject(new Error(`Unexpected command: ${command}`));
}
});
localTerminalMock.mockReset();
rdpFormMock.mockReset();
serialFormMock.mockReset();
sshFormMock.mockReset();
telnetFormMock.mockReset();
});
it("uses the backend-resolved default shell for a new local terminal", async () => {
window.history.replaceState({}, "", "/");
render(<NewSessionPage />);
fireEvent.mouseDown(screen.getByRole("tab", { name: "dialog.localTerminal" }), {
button: 0,
ctrlKey: false,
});
await waitFor(() => {
expect(localTerminalMock).toHaveBeenLastCalledWith(
expect.objectContaining({ shellPath: "/bin/zsh" }),
);
});
});
it("restores an RDP jump host and keeps it when saving without changes", async () => {
render(<NewSessionPage />);
+23 -4
View File
@@ -33,6 +33,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
import { useApp } from "@/context/AppContext";
import { getErrorMessage } from "@/lib/errors";
import { invoke } from "@/lib/invoke";
import { isWindows } from "@/lib/platform";
import { isValidSerialBaudRate, MAX_SERIAL_BAUD_RATE, MIN_SERIAL_BAUD_RATE } from "@/lib/serial";
import { validateSshAgentForwardingEndpoints } from "@/lib/sshAgent";
import type {
@@ -64,6 +65,7 @@ const MIN_SFTP_SHELL_DETECTION_TIMEOUT_MS = 100;
const MAX_SFTP_SHELL_DETECTION_TIMEOUT_MS = 60_000;
const DEFAULT_RDP_USERNAME = "Administrator";
const CUSTOM_ICON_EXTENSIONS = ["png", "jpg", "jpeg", "webp", "bmp", "gif"];
const FALLBACK_LOCAL_SHELL = isWindows ? "powershell.exe" : "/bin/bash";
const DEFAULT_SSH_ALGORITHMS: SshAlgorithmPreferences = {
mode: "compatible",
kex: [],
@@ -273,7 +275,8 @@ export default function NewSessionPage() {
>("zmodem");
// Local Terminal States
const [shellPath, setShellPath] = useState("powershell.exe");
const [defaultLocalShell, setDefaultLocalShell] = useState(FALLBACK_LOCAL_SHELL);
const [shellPath, setShellPath] = useState(FALLBACK_LOCAL_SHELL);
const [shellArgs, setShellArgs] = useState("");
const [workingDir, setWorkingDir] = useState("");
const [dynamicTabTitle, setDynamicTabTitle] = useState(false);
@@ -294,6 +297,16 @@ export default function NewSessionPage() {
const [recordingMode, setRecordingMode] = useState<RecordingMode>("transcript");
useEffect(() => {
invoke<string>("get_default_local_shell")
.then((value) => {
const resolvedShell = value.trim();
if (!resolvedShell) return;
setDefaultLocalShell(resolvedShell);
if (!editId) {
setShellPath((current) => (current === FALLBACK_LOCAL_SHELL ? resolvedShell : current));
}
})
.catch(() => undefined);
invoke<Group[]>("get_groups")
.then(setGroups)
.catch((e) => setError(getErrorMessage(e)));
@@ -389,7 +402,7 @@ export default function NewSessionPage() {
setTelnetSendNaws(found.send_naws ?? true);
setTelnetSendSga(found.send_sga ?? true);
} else if (found.type === "local_terminal") {
setShellPath(found.shell_path || "powershell.exe");
setShellPath(found.shell_path || FALLBACK_LOCAL_SHELL);
setShellArgs(found.shell_args || "");
setWorkingDir(found.working_dir || "");
setDynamicTabTitle(found.dynamic_tab_title ?? false);
@@ -440,6 +453,12 @@ export default function NewSessionPage() {
.catch((e) => setError(getErrorMessage(e)));
}, [appSettings.recording.auto_start, appSettings.recording.default_mode, editId, t]);
useEffect(() => {
if (initialData?.type === "local_terminal" && !initialData.shell_path?.trim()) {
setShellPath(defaultLocalShell);
}
}, [defaultLocalShell, initialData]);
const loadSerialPorts = useCallback(async () => {
setSerialPortsLoading(true);
setSerialPortsError("");
@@ -506,7 +525,7 @@ export default function NewSessionPage() {
setParity("none");
setStopBits("1");
setSerialModemUploadProtocol("zmodem");
setShellPath("powershell.exe");
setShellPath(defaultLocalShell);
setShellArgs("");
setWorkingDir("");
setDynamicTabTitle(false);
@@ -542,7 +561,7 @@ export default function NewSessionPage() {
setShowIconPicker(false);
setError("");
setConnecting(false);
}, [appSettings.recording.auto_start, appSettings.recording.default_mode, currentTab]);
}, [appSettings.recording.auto_start, appSettings.recording.default_mode, currentTab, defaultLocalShell]);
const handleTabChange = useCallback((value: string) => {
setCurrentTab(value);