设置 › 终端新增「本地终端」:指定 Shell;Windows 默认改用 PowerShell

本地终端运行的程序可以写路径或命令,带参数;输入框右端的下拉列出装了的常用 shell
(zsh、bash、sh、fish、nu、pwsh,从 /etc/shells、PATH 和 Homebrew 目录找),旁边
「选择文件…」用系统的文件选择框。留空是自动检测,新开和重新启动的本地终端生效。
只写一个 shell 时和默认一样按登录 shell 启动,带参数或不是 shell 的照写执行。

Windows 的默认 shell 原来是 cmd.exe,现在先用 PowerShell 7,其次 Windows PowerShell,
都没有才用 cmd.exe。

工作区改为每次打开、重启本地终端时向 LocalTerminalTransportProvider 要 factory,
UI 测试注入的 FixedLocalTerminalTransportProvider 记下每次要的 shell。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
XuzandClaude Opus 5.5 committed 2026-10-11 19:44:25 +08:00
1 parent 9799f88ade
commit cd489a5fc2
24 files changed
+1172 -55

No files matched your search

+2
View File
@@ -28,6 +28,7 @@ on:
- "src/sftp/sync.rs"
- "src/credential/credential_dialog.rs"
- "src/settings/settings_panel.rs"
- "src/terminal/local_shell.rs"
- "src/app/shortcuts.rs"
- "src/workspace/window_state.rs"
- "src/testing.rs"
@@ -57,4 +58,5 @@ jobs:
- run: "cargo test --lib cli::"
- run: "cargo test --lib update::"
- run: "cargo test --lib sftp::tests::local_listing"
- run: "cargo test --lib terminal::local_shell"
- run: cargo test --test cli_bin
+5 -1
View File
@@ -42,6 +42,9 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH
- 删除不确认:`hosts delete` 遇到有打开标签的主机报 `host_in_use`,`--force` 才关标签;删凭据时用它的主机改回自己登录。临时连接和外部连接只能 `show`。
- `sync` 只从本机到主机,大小和修改时间都没变的跳过;`--delete` 不删 `.filepart` 和 `.shellrs-….backup`,本地读不了的目录不清理,拒绝同步到 `/`。
- `exec --terminal`(`exec --json` 里 `"terminal": true`):给只允许登录一次的堡垒机(外部连接上 exec 重新登录会被拒,另开通道又会断会话)。把命令打进这台主机已打开的远程终端(右侧栏跟的那个,否则最新已连接的),所有远程终端都能用,不限外部连接。先发 ^C,**不做忙碌检测**(用户定的:用的人有基础,交给 Agent 的标签由用户负责;别加回光标行、密码提示之类的检查),只拒绝备用屏和同一终端已有一次运行。只支持 Linux 上的 bash、dash、busybox ash。输出经管道(没有颜色和分页器)、stderr 并入;`--json` 多一项 `context`(user、host、cwd、shell、interp)。
- **本地终端**:「设置 › 终端 › 本地终端 › Shell」是一个输入框(路径或命令加参数),右端下拉只列常用 shell 里装了的(Unix 是 `COMMON_SHELLS`:zsh、bash、sh、fish、nu、pwsh,从 `/etc/shells`、PATH 和 Homebrew 目录找;csh、ksh、dash 等不列,用户嫌长;Windows 是 pwsh、powershell、cmd),旁边「选择文件…」;这一行上下排(标题和说明在上,输入框占满整行)。新开和重启的本地终端生效,正在运行的不动。
- 留空是自动检测:Unix 是 `$SHELL` 或 passwd 里的登录 shell;Windows 优先 PowerShell 7,其次 Windows PowerShell,都没有才 cmd(用户定的:现代 Windows 应先 PowerShell)。
- 只写一个已知的 shell(`SHELL_NAMES`)时和默认一样按登录 shell 启动(`SHELL` 设成它、argv0 前加 `-`),否则 Homebrew 写在 `~/.zprofile` 的 PATH 就没了;带参数或不是 shell(如 `tmux`,它会在每个窗口起 `$SHELL`)的照写执行。
- **开始页**:没有标签时显示「最近连接」。「快速连接」搜索保存的主机、回车连接;多选做好但关着(常量 `MULTIPLE`)。
- **右侧栏**:窗口最右一列竖排的工具切换。
- 显示规则:只跟着 SSH 远程终端出现,SFTP、本地终端、设置、开始页都不显示。它是工作区级的一份,开合和所选工具在所有终端间共用。默认收起,宽 320 px,只能往宽里拉。⌘⌥B / Ctrl+Alt+B 切换。
@@ -130,7 +133,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- `SshConnector`:所有登录都走它(终端、SFTP、exec、测试连接、端口转发、外部 CLI)。
- 远程终端的传输:pty、系统探测、延迟、旁路 exec。
- 代理握手;外部 CLI 用的 `run_command`。
- `terminal/`:本地 PTY、驱动 `alacritty_terminal` 的引擎、`TerminalView`(网格、选区、查找、清屏)、关键字高亮(`highlight.rs`)、终端字体全局量、右侧栏工具共用的 `ExecTarget`。
- `terminal/`:本地 PTY 和它跑的 shell(`local_shell.rs`:命令行拆分、已安装的 shell、Windows 的默认 shell)、驱动 `alacritty_terminal` 的引擎、`TerminalView`(网格、选区、查找、清屏)、关键字高亮(`highlight.rs`)、终端字体全局量、右侧栏工具共用的 `ExecTarget`。
- `sftp/`:可注入的传输和本地目录接口、`RemotePath`、`RemoteFs` 协议适配、worker(批次、三次重连、空闲断网检测),以及:
- `upload.rs` / `download.rs`:断点续传,续传记录由 `journal.rs` 管;
- `operations.rs`:删除、重命名、新建、改权限;
@@ -353,6 +356,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试
- **查找条必须是终端 `div` 的兄弟**,不能是子元素,否则输入会被发给 shell;它还要 `.occlude()`。
- **清屏只在本地模拟器里做**,全屏程序运行时不做。终端里的 ⌘K 是清屏,比全局的 `FocusSearch` 深一层,所以优先。
- **本地终端每次打开、重启都向 `LocalTerminalTransportProvider` 要新的 factory**(同远程的 `factory_for`),带上当时设置里的 `LocalShell`;UI 测试注入 `FixedLocalTerminalTransportProvider`,它记下每次要的 shell。命令行在传输线程上解析(要查文件系统),拆不开(引号没配对)或起不来都报在终端里,报错里写着用户写的命令。
- **终端字体走全局量 `TerminalFont`**(终端模块不依赖 settings),格子在下一帧 prepaint 时按新尺寸重排。
- **终端颜色走全局量 `TerminalColors`**(`theme.rs`):`settings::apply` 按当前外观设成那一栏选中的主题,系统外观变化时也重设。终端格子、边距、选区、链接、颜色查询(OSC 4 / 10 / 11 / 12)和设置里的两个预览都从它取,不用界面主题的 `background` / `foreground`;查找高亮仍用界面的 `warning`。主题的 `key` 存进 `settings.json`,发布后不能改;读不出或深浅不符的 key 退回该外观的默认。
- **界面配色由所选主题推出**(`settings/app_theme.rs`):从 gpui-kit 默认的浅色或深色主题出发重新着色。灰色按它在默认背景与文字之间的位置,换到该主题背景与文字之间;默认里用作可读文字的灰色(对比度 ≥ 4.5)至少保留该主题正文对比度的 80%(最高 4.5)。`base.*` 按名字换成对应的 ANSI 色,其余彩色按色相换;`.light` 和彩色的 `*foreground` 删掉,交给 gpui-kit 自己推。ShellRS Light(以及「界面跟随主题」关闭时的浅色,`TerminalThemeSettings::app_theme`)直接用 gpui-kit 的默认浅色主题;ShellRS Dark(以及关闭时的深色)是 gpui-kit 默认深色主题按 `SOFT_DARK` 把每级灰换成炭灰,彩色不动。`settings::apply` 按名字(`theme_name`)判断要不要重建,把结果放进 `Theme` 的浅色或深色槽再 `Theme::change`。
+1
View File
@@ -109,6 +109,7 @@ ShellRS 同一时间只运行一个:已经开着时再打开一次,会把已
- **外观 → 主题**:左栏是浅色主题,右栏是深色主题,各选一套:应用外观为浅色时用左栏选中的那套,深色时用右栏的;跟随系统时随系统一起切换,标题栏的主题按钮切换时也一样。默认只有终端按所选主题配色,界面保持中性的灰色调(深色是不刺眼的炭灰,不是纯黑);打开上方的「界面跟随主题」,界面也按主题配色:侧栏、标签栏、对话框等用主题的底色和文字色,成功、警告、错误等状态用主题的绿、黄、红。每张卡片用该主题的颜色显示一段示例,选中的右上角有蓝色对勾。默认是 ShellRS Light 和 ShellRS Dark,与界面同色。内置浅色、深色各 10 套:浅色 ShellRS Light、Solarized Light、Tokyo Day、Catppuccin Latte、Flexoki Light、One Half Light、Gruvbox Light、Kanagawa Lotus、Rosé Pine Dawn、Everforest Light;深色 ShellRS Dark、Solarized Dark、Tokyo Night、Catppuccin Mocha、Flexoki Dark、One Half Dark、Gruvbox Dark、Kanagawa Wave、Dracula、Nord。同一主题的浅色版和深色版在两栏的同一行。改动立即作用于所有已打开的终端,字体和关键字高亮的预览也用当前主题;vim 等程序询问终端的背景色时,得到的是当前主题的颜色。**尚未实现:自定义主题、导入配色、按主机指定主题。**
- **终端 → 字体配置**:字体(只列出本机安装的等宽字体,默认是系统的等宽字体,macOS 上为 Menlo)、字号(10–28 像素,默认 13)、行高(字号的 1.0–2.0 倍,默认 1.54,即原来的 20 像素行高)。改动立即作用于所有已打开的终端;在终端里按 ⌘= / ⌘-(其他平台 Ctrl)也能逐级调字号,⌘0 回到默认。下方的预览按终端的排法显示字母、数字、易混字符、符号、中文、制表符和一行命令。设置文件里的字体在本机没有安装时,使用默认字体。
- **终端 → 终端交互**:「选中复制」打开后,用鼠标选中文字、松开时就复制到系统剪贴板,不用再按 ⌘C;默认关闭,单击没选中文字时剪贴板不变。「右键行为」决定在终端里按右键做什么:显示菜单(默认)、粘贴(直接粘贴剪贴板,同 ⌘V)、无动作。选后两种时,菜单里的复制、粘贴、查找、清屏仍可用快捷键,打开 SFTP、重新连接在标签的右键菜单里。改动立即作用于所有已打开的终端。
- **终端 → 本地终端**:「Shell」是新开或重新启动的本地终端运行的程序,写路径或命令,可以带参数(如 `/bin/zsh -l`、`pwsh.exe -NoLogo`);路径里有空格就用引号括起来(Windows 只认双引号)。输入框右端的下拉列出本机装了的常用 shell(macOS / Linux 是 zsh、bash、sh、fish、nu、pwsh 里装了的,包括 Homebrew 装的;csh、ksh、dash 等不列,要用就「选择文件…」;Windows 是 PowerShell 7(装了的话)、Windows PowerShell 和 cmd.exe),旁边的「选择文件…」用系统的文件选择框挑一个程序。留空(默认)时自动检测系统默认的 shell:macOS / Linux 是用户的登录 shell,Windows 优先 PowerShell 7,其次系统自带的 Windows PowerShell,都没有才用 cmd.exe。只写一个 shell、不带参数时(如 `/bin/bash`、`zsh`),和默认一样按登录 shell 启动,会读 `~/.zprofile` 等登录配置;带了参数或不是 shell 的程序就照写的启动。已经在运行的本地终端不受影响,重新启动后生效。
- **关键字高亮**:总开关、预览和规则表,终端里按规则给文字上色、匹配时提醒,见「终端」一节。
- **键盘快捷键**:主要功能的快捷键,分通用、标签、终端、编辑器四组,每行是功能名和它的按键。点按键进入「按下组合键」,按下新的组合就换上;Esc 或点别处放弃。行尾两个按钮:禁用(之后显示「无」,再点一下恢复默认)、恢复默认;页面上方的「重置」也能逐项恢复。改动立即生效,工具栏提示和右键菜单里显示的按键跟着变。
- 组合键要带 ⌘、⌥ 或 ⌃(其他平台 Ctrl 或 Alt),或者是 F1–F12;「切换到标签 1…9」按任意一个带修饰键的数字即可,九个一起换。
+32
View File
@@ -141,6 +141,38 @@ settings.notifications.bell_description:
zh-CN: 终端就在当前标签时不提示。
en: Not when the terminal is the current tab.
# 终端 › 本地终端.
settings.local_terminal.title:
zh-CN: 本地终端
en: Local terminal
settings.local_terminal.shell:
zh-CN: Shell
en: Shell
settings.local_terminal.shell_description:
zh-CN: 新开或重新启动的本地终端运行的程序:路径或命令,可以带参数。留空时自动检测系统默认的 shell。
en: "The program new and restarted local terminals run: a path or a command, with any arguments. Leave it empty to use the system’s default shell."
# The empty field and the first choice of its menu, with the shell that
# runs: 「自动检测(/bin/zsh)」.
settings.local_terminal.automatic:
zh-CN: 自动检测(%{shell})
en: Automatic (%{shell})
# The same before the default shell is known.
settings.local_terminal.automatic_unknown:
zh-CN: 自动检测
en: Automatic
settings.local_terminal.installed:
zh-CN: 已安装的 shell
en: Installed shells
settings.local_terminal.browse:
zh-CN: 选择文件…
en: Choose file…
settings.local_terminal.browse_prompt:
zh-CN: 选择 shell
en: Choose a shell
settings.local_terminal.keywords:
zh-CN: shell,本地终端,命令,参数,bash,zsh,fish,PowerShell,cmd
en: shell,local terminal,command,arguments,bash,zsh,fish,PowerShell,cmd
settings.highlight.options:
zh-CN: 常规
en: Options
+7
View File
@@ -170,6 +170,13 @@ terminal.pty.open_failed:
terminal.pty.shell_failed:
zh-CN: 无法启动系统默认 shell
en: Couldn’t start the default shell
# The program 设置 › 终端 › 本地终端 names, as written there, and why.
terminal.pty.command_failed:
zh-CN: 无法启动 %{command}:%{error}
en: "Couldn’t start %{command}: %{error}"
terminal.pty.unmatched_quote:
zh-CN: 设置 → 终端 → 本地终端里的 Shell 有引号没有配对:%{command}
en: "The shell in Settings → Terminal → Local terminal has an unmatched quote: %{command}"
terminal.pty.reader_failed:
zh-CN: 无法读取本地 PTY
en: Couldn’t read from the local PTY
+2 -1
View File
@@ -77,7 +77,8 @@ icon_assets!(
Zap,
ExternalLink,
Highlighter,
GripVertical
GripVertical,
FileSearch
]
);
+227
View File
@@ -0,0 +1,227 @@
//! 设置 › 终端 › 本地终端 › Shell: a text field for the program and its
//! arguments across the row, with a menu of the shells installed here at
//! its end and the system's file picker beside it. Every change goes to the settings at
//! once; a local terminal takes it when it opens or restarts.
use gpui_kit::component::{
ActiveTheme as _, Icon, IconName, Sizable as _,
button::{Button, ButtonVariants as _},
h_flex,
input::{Input, InputEvent, InputState},
menu::{DropdownMenu as _, PopupMenuItem},
setting::RenderOptions,
};
use gpui_kit::*;
use crate::app::CatalogIcon;
use crate::i18n::{UiLocale, t};
use crate::terminal::{default_shell_name, installed_shells, quote_word};
use super::SettingsStore;
/// The field's text, which keeps the caret while the settings change under
/// it, and what the menu offers.
pub struct LocalShellEditor {
store: Entity<SettingsStore>,
input: Entity<InputState>,
/// What 自动检测 runs here, and the shells installed; `None` until the
/// look in the background comes back.
found: Option<FoundShells>,
_subscriptions: [Subscription; 3],
_scan: Task<()>,
}
struct FoundShells {
default: String,
installed: Vec<String>,
}
impl LocalShellEditor {
pub fn new(store: Entity<SettingsStore>, window: &mut Window, cx: &mut Context<Self>) -> Self {
let shell = stored(&store, cx).unwrap_or_default();
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(t!("settings.local_terminal.automatic_unknown"))
.default_value(shell)
});
let subscriptions = [
cx.subscribe(&input, |this, input, event: &InputEvent, cx| {
if matches!(event, InputEvent::Change) {
let shell = input.read(cx).value().to_string();
this.write(Some(shell), cx);
}
}),
// The field's own edits come back the same, but for the blanks
// around them; anything else (重置, the settings file read
// anew) shows in it.
cx.observe_in(&store, window, |this, store, window, cx| {
let shell = stored(&store, cx).unwrap_or_default();
if this.input.read(cx).value().trim() != shell {
this.input
.update(cx, |input, cx| input.set_value(shell, window, cx));
}
}),
cx.observe_global_in::<UiLocale>(window, |this, window, cx| {
this.show_placeholder(window, cx)
}),
];
let scan = cx.spawn_in(window, async move |this, cx| {
let found = cx
.background_spawn(async {
FoundShells {
default: default_shell_name(),
installed: installed_shells(),
}
})
.await;
this.update_in(cx, |this, window, cx| {
this.found = Some(found);
this.show_placeholder(window, cx);
cx.notify();
})
.ok();
});
Self {
store,
input,
found: None,
_subscriptions: subscriptions,
_scan: scan,
}
}
/// 「自动检测(/bin/zsh)」, once the default shell is known.
fn automatic_label(&self) -> SharedString {
match &self.found {
Some(found) => t!(
"settings.local_terminal.automatic",
shell = found.default.as_str()
),
None => t!("settings.local_terminal.automatic_unknown"),
}
}
/// The empty field says what it runs.
fn show_placeholder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let placeholder = self.automatic_label();
self.input.update(cx, |input, cx| {
input.set_placeholder(placeholder, window, cx)
});
}
/// Run `shell` from now on, `None` for the default: from the menu, the
/// file picker or 重置.
pub(super) fn choose(
&mut self,
shell: Option<String>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.input.update(cx, |input, cx| {
input.set_value(shell.clone().unwrap_or_default(), window, cx)
});
self.write(shell, cx);
}
/// Ask for the program with the system's picker.
fn browse(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let receiver = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: Some(t!("settings.local_terminal.browse_prompt")),
});
cx.spawn_in(window, async move |this, cx| {
let Ok(Ok(Some(paths))) = receiver.await else {
return;
};
let Some(path) = paths.into_iter().next() else {
return;
};
this.update_in(cx, |this, window, cx| {
this.choose(Some(quote_word(&path.to_string_lossy())), window, cx)
})
.ok();
})
.detach();
}
fn write(&self, shell: Option<String>, cx: &mut Context<Self>) {
self.store.update(cx, |store, cx| {
store.update(|settings| settings.local_terminal.shell = shell, cx)
});
}
}
/// The shell the settings hold, `None` for the default.
fn stored(store: &Entity<SettingsStore>, cx: &App) -> Option<String> {
store.read(cx).settings().local_terminal.shell
}
/// The row's control, under its title: the field across the row, with the
/// installed shells in a menu at its end, and 选择文件… beside it. The menu
/// checks the shell in use.
pub(super) fn local_shell_field(
editor: &Entity<LocalShellEditor>,
options: &RenderOptions,
cx: &App,
) -> Div {
let this = editor.read(cx);
let current = stored(&this.store, cx);
let automatic = this.automatic_label();
let installed = this
.found
.as_ref()
.map(|found| found.installed.clone())
.unwrap_or_default();
let (menu_editor, browse_editor) = (editor.downgrade(), editor.downgrade());
let menu = Button::new("local-shell-menu")
.ghost()
.xsmall()
.icon(IconName::ChevronDown)
.tooltip(t!("settings.local_terminal.installed"))
.dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| {
let item = |label: SharedString, shell: Option<String>| {
let editor = menu_editor.clone();
PopupMenuItem::new(label)
.checked(current == shell)
.on_click(move |_, window, cx| {
editor
.update(cx, |editor, cx| editor.choose(shell.clone(), window, cx))
.ok();
})
};
let mut menu = menu.item(item(automatic.clone(), None));
if !installed.is_empty() {
menu = menu.separator();
}
installed.iter().fold(menu, |menu, shell| {
menu.item(item(shell.clone().into(), Some(shell.clone())))
})
});
h_flex()
.w_full()
.gap_2()
.child(
Input::new(&this.input)
.id("local-shell")
.aria_label(t!("settings.local_terminal.shell"))
.with_size(options.size())
.font_family(cx.theme().mono_font_family.clone())
.suffix(menu)
.flex_1()
.min_w_0(),
)
.child(
Button::new("browse-local-shell")
.outline()
.with_size(options.size())
.icon(Icon::new(CatalogIcon::FileSearch))
.label(t!("settings.local_terminal.browse"))
.on_click(move |_, window, cx| {
browse_editor
.update(cx, |editor, cx| editor.browse(window, cx))
.ok();
}),
)
}
+3 -2
View File
@@ -3,6 +3,7 @@
mod app_theme;
mod apply;
mod highlight_rules;
mod local_shell_editor;
mod model;
mod settings_panel;
mod shortcuts_editor;
@@ -12,8 +13,8 @@ mod terminal_themes;
pub use apply::apply;
pub use model::{
AnalyticsSettings, AppSettings, Appearance, Choice, ExternalCliSettings, InterfaceLanguage,
NotificationSettings, TerminalFontSettings, TerminalHighlightSettings, TerminalThemeSettings,
UpdateSettings, WindowSettings,
LocalTerminalSettings, NotificationSettings, TerminalFontSettings, TerminalHighlightSettings,
TerminalThemeSettings, UpdateSettings, WindowSettings,
};
pub use settings_panel::{SettingsPanel, SettingsPanelEvent};
pub use store::{SettingsProblem, SettingsStore, SettingsStoreEvent};
+36
View File
@@ -24,6 +24,8 @@ pub struct AppSettings {
pub terminal_font: TerminalFontSettings,
/// 终端 → 终端交互.
pub terminal_interaction: TerminalInteraction,
/// 终端 → 本地终端.
pub local_terminal: LocalTerminalSettings,
pub external_cli: ExternalCliSettings,
pub update: UpdateSettings,
/// 关于 → 隐私.
@@ -169,6 +171,16 @@ fn readable_rules<'de, D: Deserializer<'de>>(
.collect())
}
/// 终端 → 本地终端.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct LocalTerminalSettings {
/// The program new and restarted local terminals run, a path or a
/// command with any arguments, as typed. `None` is the system's default
/// shell, found anew each time.
pub shell: Option<String>,
}
/// 终端 → 通知: what a terminal may tell the user about while they look
/// elsewhere. Both on until the user turns them off.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -273,6 +285,11 @@ impl AppSettings {
/// file nor a typed value can give the terminal a size of zero.
pub fn normalized(mut self) -> Self {
self.terminal_font = self.terminal_font.normalized();
self.local_terminal.shell = self
.local_terminal
.shell
.map(|shell| shell.trim().to_string())
.filter(|shell| !shell.is_empty());
self
}
}
@@ -484,6 +501,25 @@ mod tests {
assert!(!settings.show_hidden.local);
}
#[test]
fn local_terminals_run_the_default_shell_unless_the_file_names_one() {
let settings: AppSettings = serde_json::from_str("{}").unwrap();
assert_eq!(settings.local_terminal.shell, None);
let settings: AppSettings =
serde_json::from_str(r#"{"local_terminal":{"shell":" /bin/zsh -l "}}"#).unwrap();
assert_eq!(
settings.normalized().local_terminal.shell.as_deref(),
Some("/bin/zsh -l")
);
let blank = AppSettings {
local_terminal: LocalTerminalSettings {
shell: Some(" ".into()),
},
..AppSettings::default()
};
assert_eq!(blank.normalized(), AppSettings::default());
}
#[test]
fn notifications_are_on_unless_the_file_says_otherwise() {
let settings: AppSettings = serde_json::from_str("{}").unwrap();
+30 -1
View File
@@ -35,6 +35,7 @@ use crate::terminal::{
use crate::update::{Phase, Tone, UpdateSnapshot, UpdateStep, Updater, build_info, platform};
use super::highlight_rules::HighlightRulesEditor;
use super::local_shell_editor::{LocalShellEditor, local_shell_field};
use super::shortcuts_editor::{ShortcutsEditor, shortcut_groups};
use super::terminal_themes::{app_follows_item, terminal_theme_item};
use super::{AppSettings, Choice, NotificationSettings, SettingsStore, WindowSettings};
@@ -63,6 +64,8 @@ pub struct SettingsPanel {
highlight_rules: Entity<HighlightRulesEditor>,
/// 键盘快捷键, which knows which shortcut is taking new keys.
shortcuts: Entity<ShortcutsEditor>,
/// 终端 › 本地终端's field, with the shells installed here.
local_shell: Entity<LocalShellEditor>,
/// The monospace families to choose the terminal font from; empty until
/// the scan in the background comes back.
font_families: &'static [SharedString],
@@ -72,7 +75,7 @@ pub struct SettingsPanel {
shown_category: Rc<Cell<usize>>,
focus_handle: FocusHandle,
tab_group: Option<WeakEntity<TabGroup>>,
_subscriptions: [Subscription; 4],
_subscriptions: [Subscription; 5],
_font_scan: Task<()>,
}
@@ -86,9 +89,11 @@ impl SettingsPanel {
) -> Self {
let highlight_rules = cx.new(|cx| HighlightRulesEditor::new(store.clone(), window, cx));
let shortcuts = cx.new(|cx| ShortcutsEditor::new(store.clone(), window, cx));
let local_shell = cx.new(|cx| LocalShellEditor::new(store.clone(), window, cx));
let subscriptions = [
cx.observe(&store, |_, _, cx| cx.notify()),
cx.observe(&shortcuts, |_, _, cx| cx.notify()),
cx.observe(&local_shell, |_, _, cx| cx.notify()),
cx.observe(&integration, |_, _, cx| cx.notify()),
cx.observe(&updater, |_, _, cx| cx.notify()),
];
@@ -110,6 +115,7 @@ impl SettingsPanel {
updater,
highlight_rules,
shortcuts,
local_shell,
font_families: &[],
shown_category: Rc::default(),
focus_handle: cx.focus_handle(),
@@ -387,9 +393,32 @@ fn terminal_groups(panel: &SettingsPanel, cx: &App) -> Vec<SettingGroup> {
]),
interaction_group(store),
notification_group(store),
local_terminal_group(panel),
]
}
/// 终端 → 本地终端: the program a local terminal runs. The row stacks, so a
/// path with its arguments has the page's width.
fn local_terminal_group(panel: &SettingsPanel) -> SettingGroup {
let (editor, reset) = (panel.local_shell.clone(), panel.local_shell.clone());
let store = panel.store.clone();
SettingGroup::new()
.title(t!("settings.local_terminal.title"))
.items([SettingItem::new(
t!("settings.local_terminal.shell"),
SettingField::render(move |options, _, cx| local_shell_field(&editor, options, cx))
.on_reset(
move |cx| store.read(cx).settings().local_terminal.shell.is_some(),
move |window, cx| {
reset.update(cx, |editor, cx| editor.choose(None, window, cx));
},
),
)
.description(t!("settings.local_terminal.shell_description"))
.layout(Axis::Vertical)
.keywords(keywords(t!("settings.local_terminal.keywords")))])
}
/// 终端 → 终端交互: what the mouse does besides selecting.
fn interaction_group(store: &Entity<SettingsStore>) -> SettingGroup {
let (reader, writer) = (store.clone(), store.clone());
+55 -20
View File
@@ -1,31 +1,46 @@
use std::ffi::OsString;
use std::io::{ErrorKind, Read, Write};
use std::sync::mpsc;
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::Duration;
use anyhow::{Context as _, Result};
use anyhow::{Context as _, Result, anyhow};
use async_channel::Sender;
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
use portable_pty::{PtySize, native_pty_system};
use crate::i18n::t;
use super::{
TerminalSize, TerminalTransport, TerminalTransportCommand, TerminalTransportEvent,
TerminalTransportFactory, send_event,
LocalShell, LocalTerminalTransportProvider, SharedTerminalTransportFactory, TerminalSize,
TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory,
send_event,
};
/// Local terminals in a PTY of this computer.
#[derive(Default)]
pub struct LocalPtyTransportFactory;
pub struct LocalPtyTransportProvider;
impl LocalTerminalTransportProvider for LocalPtyTransportProvider {
fn factory_for(&self, shell: &LocalShell) -> SharedTerminalTransportFactory {
Arc::new(LocalPtyTransportFactory {
shell: shell.clone(),
})
}
}
struct LocalPtyTransportFactory {
shell: LocalShell,
}
impl TerminalTransportFactory for LocalPtyTransportFactory {
fn create(&self) -> Box<dyn TerminalTransport> {
Box::new(LocalPtyTransport { command: None })
Box::new(LocalPtyTransport {
shell: self.shell.clone(),
})
}
}
struct LocalPtyTransport {
command: Option<Vec<OsString>>,
shell: LocalShell,
}
impl TerminalTransport for LocalPtyTransport {
@@ -40,18 +55,23 @@ impl TerminalTransport for LocalPtyTransport {
.openpty(to_pty_size(initial_size))
.context(t!("terminal.pty.open_failed"))?;
let mut command = self
.command
.map(CommandBuilder::from_argv)
.unwrap_or_else(CommandBuilder::new_default_prog);
let mut command = self.shell.command_builder()?;
command.env("TERM", "xterm-256color");
command.env("COLORTERM", "truecolor");
command.env("TERM_PROGRAM", "ShellRS");
let mut child = pair
.slave
.spawn_command(command)
.context(t!("terminal.pty.shell_failed"))?;
let mut child =
pair.slave
.spawn_command(command)
.map_err(|error| match self.shell.command() {
// Why, too: the path or the arguments are the user's.
Some(command) => anyhow!(t!(
"terminal.pty.command_failed",
command = command,
error = format!("{error:#}")
)),
None => error.context(t!("terminal.pty.shell_failed")),
})?;
drop(pair.slave);
let mut reader = pair
@@ -159,10 +179,12 @@ mod tests {
use super::*;
/// `/bin/sh -c script`, through the command line the settings hold.
fn shell_transport(script: &str) -> Box<dyn TerminalTransport> {
Box::new(LocalPtyTransport {
command: Some(vec!["/bin/sh".into(), "-c".into(), script.into()]),
})
let command = format!("/bin/sh -c {}", crate::terminal::quote_word(script));
LocalPtyTransportProvider
.factory_for(&LocalShell::new(&command))
.create()
}
fn drain_until_exit(
@@ -226,6 +248,19 @@ mod tests {
worker.join().unwrap().unwrap();
}
#[test]
fn a_command_that_cannot_start_says_which() {
let (_commands, command_receiver) = mpsc::channel();
let (events, _event_receiver) = async_channel::unbounded();
let error = LocalPtyTransportProvider
.factory_for(&LocalShell::new("/no/such/shell -l"))
.create()
.run(TerminalSize::DEFAULT, command_receiver, events)
.unwrap_err()
.to_string();
assert!(error.contains("无法启动 /no/such/shell -l"), "{error}");
}
#[test]
fn shutdown_terminates_and_reaps_the_child() {
let (commands, command_receiver) = mpsc::channel();
+582
View File
@@ -0,0 +1,582 @@
//! What a local terminal runs: the system's default shell, or the program
//! 设置 › 终端 › 本地终端 names; and the shells installed here, to offer
//! for it.
use std::ffi::OsString;
#[cfg(any(unix, test))]
use std::path::Path;
use std::path::PathBuf;
use anyhow::{Result, anyhow};
use portable_pty::CommandBuilder;
use crate::i18n::t;
/// The program a local terminal runs, as the user wrote it: a path or a
/// command, with any arguments. Without one, the system's default shell.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct LocalShell {
command: Option<String>,
}
impl LocalShell {
/// `command` without the blanks around it; blank is the default shell.
pub fn new(command: &str) -> Self {
let command = command.trim();
Self {
command: (!command.is_empty()).then(|| command.to_string()),
}
}
pub fn command(&self) -> Option<&str> {
self.command.as_deref()
}
/// What to spawn. Blocking: it looks at the file system, so it runs on
/// the transport's thread.
///
/// A shell named on its own starts as a login shell, as the default one
/// does, so it reads the same profile (Homebrew's `PATH` is set up in
/// `~/.zprofile`): portable-pty runs `$SHELL` with a `-` before its
/// name. Anything else, or a shell with arguments, runs as written.
pub(super) fn command_builder(&self) -> Result<CommandBuilder> {
let Some(command) = &self.command else {
return Ok(default_command());
};
let argv = split_command(command, QUOTING)
.ok_or_else(|| anyhow!(t!("terminal.pty.unmatched_quote", command = command)))?;
#[cfg(unix)]
if let [program] = argv.as_slice()
&& is_shell_name(program)
&& let Some(path) = find_program(program)
{
let mut builder = CommandBuilder::new_default_prog();
builder.env("SHELL", path);
return Ok(builder);
}
Ok(CommandBuilder::from_argv(
argv.into_iter().map(OsString::from).collect(),
))
}
}
/// The system's default shell. On Unix the user's: `$SHELL`, or the one
/// the passwd database names. On Windows PowerShell, as Windows Terminal
/// and VS Code open: PowerShell 7 where it is installed, otherwise the
/// Windows PowerShell every Windows has; only without either the command
/// prompt (`%ComSpec%`), portable-pty's own default.
fn default_command() -> CommandBuilder {
#[cfg(windows)]
if let Some(program) = windows_default_shell() {
return CommandBuilder::new(program);
}
CommandBuilder::new_default_prog()
}
/// What the default shell is here, for 「自动检测(/bin/zsh)」: its path
/// on Unix, its file name on Windows, as the shells offered are written.
/// Blocking.
pub fn default_shell_name() -> String {
#[cfg(windows)]
{
let program = windows_default_shell()
.unwrap_or_else(|| PathBuf::from(CommandBuilder::new_default_prog().get_shell()));
program
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| program.to_string_lossy().into_owned())
}
#[cfg(not(windows))]
CommandBuilder::new_default_prog().get_shell()
}
/// The shells to offer, each as the field would hold it. On Unix the
/// common ones that are here (`COMMON_SHELLS`), one line per program; on
/// Windows PowerShell 7 where it is installed, Windows PowerShell and the
/// command prompt. Blocking.
pub fn installed_shells() -> Vec<String> {
#[cfg(unix)]
{
let listed = std::fs::read_to_string("/etc/shells")
.map(|text| listed_shells(&text))
.unwrap_or_default();
let mut folders: Vec<PathBuf> = std::env::var_os("PATH")
.map(|paths| {
std::env::split_paths(&paths)
.filter(|folder| folder.is_absolute())
.collect()
})
.unwrap_or_default();
folders.extend(SHELL_FOLDERS.iter().map(PathBuf::from));
common_shells(&listed, &folders, is_executable, program_key)
}
#[cfg(windows)]
{
let mut shells = Vec::new();
if find_on_path("pwsh.exe").is_some() {
shells.push("pwsh.exe".to_string());
} else if let Some(pwsh) = installed_pwsh() {
shells.push(quote_word(&pwsh.to_string_lossy()));
}
if windows_powershell().is_some() {
shells.push("powershell.exe".to_string());
}
shells.push("cmd.exe".to_string());
shells
}
}
/// How the words of a command are quoted.
// Each platform uses only its own; the tests use both everywhere.
#[cfg_attr(not(test), allow(dead_code))]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Quoting {
/// As a POSIX shell reads them: single quotes, double quotes, and
/// backslashes that take the next character as it is.
Posix,
/// As Windows programs read their command line, near enough: double
/// quotes only; a backslash is part of the path.
Windows,
}
#[cfg(not(windows))]
const QUOTING: Quoting = Quoting::Posix;
#[cfg(windows)]
const QUOTING: Quoting = Quoting::Windows;
/// The words of `line`, split at blanks outside quotes. `None` when a
/// quote is left open.
fn split_command(line: &str, quoting: Quoting) -> Option<Vec<String>> {
let posix = quoting == Quoting::Posix;
let mut words = Vec::new();
let mut word = String::new();
// Whether a word has begun: `""` is a word, empty.
let mut in_word = false;
let mut chars = line.chars();
while let Some(c) = chars.next() {
match c {
c if c.is_whitespace() => {
if in_word {
words.push(std::mem::take(&mut word));
in_word = false;
}
}
'"' => {
in_word = true;
loop {
match chars.next()? {
'"' => break,
// Inside double quotes a backslash quotes only what
// would otherwise end or expand them.
'\\' if posix => match chars.next()? {
c @ ('"' | '\\' | '$' | '`') => word.push(c),
c => {
word.push('\\');
word.push(c);
}
},
c => word.push(c),
}
}
}
'\'' if posix => {
in_word = true;
loop {
match chars.next()? {
'\'' => break,
c => word.push(c),
}
}
}
'\\' if posix => {
in_word = true;
// A backslash at the very end stands for itself.
word.push(chars.next().unwrap_or('\\'));
}
c => {
in_word = true;
word.push(c);
}
}
}
if in_word {
words.push(word);
}
Some(words)
}
/// `word` as the field writes it, so `split_command` reads it back as one
/// word: a file chosen with the system's picker. On Windows it is wrapped
/// in double quotes when it has blanks; a path cannot have a double quote.
pub fn quote_word(word: &str) -> String {
quote_word_for(word, QUOTING)
}
fn quote_word_for(word: &str, quoting: Quoting) -> String {
match quoting {
Quoting::Posix => {
let plain = !word.is_empty()
&& word
.chars()
.all(|c| c.is_alphanumeric() || "/._-+@%:,=~".contains(c));
if plain {
word.to_string()
} else {
format!("'{}'", word.replace('\'', r"'\''"))
}
}
Quoting::Windows => {
if word.is_empty() || word.chars().any(char::is_whitespace) {
format!("\"{word}\"")
} else {
word.to_string()
}
}
}
}
/// Shells that know a `-` before their name means a login shell. Another
/// program named on its own is not given one: `tmux` would start `$SHELL`,
/// itself, in each of its windows.
#[cfg(any(unix, test))]
const SHELL_NAMES: &[&str] = &[
"ash", "bash", "csh", "dash", "elvish", "fish", "ksh", "mksh", "nu", "oksh", "pwsh", "sh",
"tcsh", "xonsh", "yash", "zsh",
];
#[cfg(any(unix, test))]
fn is_shell_name(program: &str) -> bool {
Path::new(program)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| SHELL_NAMES.contains(&name))
}
/// The shells offered, in this order. The others `/etc/shells` lists (csh,
/// tcsh, ksh, dash…) are left to the file picker: few use them, and they
/// made the menu long.
#[cfg(unix)]
const COMMON_SHELLS: &[&str] = &["zsh", "bash", "sh", "fish", "nu", "pwsh"];
/// Where a shell is looked for besides `PATH`: the system's folders, and
/// Homebrew's, which a GUI app's `PATH` leaves out and `/etc/shells` lists
/// only when told to.
#[cfg(unix)]
const SHELL_FOLDERS: &[&str] = &["/bin", "/usr/bin", "/usr/local/bin", "/opt/homebrew/bin"];
/// Of `COMMON_SHELLS`, those `usable`: as `/etc/shells` lists them first,
/// then as found in `folders`; one line per program, by `key`.
#[cfg(unix)]
fn common_shells<K: Eq + std::hash::Hash>(
listed: &[String],
folders: &[PathBuf],
usable: impl Fn(&Path) -> bool,
key: impl Fn(&Path) -> K,
) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
let mut shells = Vec::new();
for name in COMMON_SHELLS {
let named = listed
.iter()
.map(PathBuf::from)
.filter(|path| path.file_name() == Some(std::ffi::OsStr::new(name)));
for path in named.chain(folders.iter().map(|folder| folder.join(name))) {
if usable(&path) && seen.insert(key(&path)) {
shells.push(path.to_string_lossy().into_owned());
}
}
}
shells
}
/// The paths `/etc/shells` lists: one a line, without comments.
#[cfg(any(unix, test))]
fn listed_shells(text: &str) -> Vec<String> {
text.lines()
.map(|line| line.split('#').next().unwrap_or_default().trim())
.filter(|line| line.starts_with('/'))
.map(String::from)
.collect()
}
/// Where `program` is: itself when it is an absolute path, otherwise the
/// first of that name on `PATH`. `None` when it is not there, or not a
/// program; spawning it as written then says why.
#[cfg(unix)]
fn find_program(program: &str) -> Option<PathBuf> {
if program.contains('/') {
let path = PathBuf::from(program);
return (path.is_absolute() && is_executable(&path)).then_some(path);
}
let paths = std::env::var_os("PATH")?;
std::env::split_paths(&paths)
.map(|dir| dir.join(program))
.find(|candidate| is_executable(candidate))
}
#[cfg(unix)]
fn is_executable(path: &Path) -> bool {
use std::os::unix::fs::PermissionsExt as _;
path.metadata()
.is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
}
/// The same program wherever its folder is reached from: the folder with
/// its links resolved, and the name as it is, so `sh` stays apart from the
/// `dash` it may link to.
#[cfg(unix)]
fn program_key(path: &Path) -> (PathBuf, Option<OsString>) {
let folder = path.parent().unwrap_or(Path::new("/"));
(
std::fs::canonicalize(folder).unwrap_or_else(|_| folder.to_path_buf()),
path.file_name().map(OsString::from),
)
}
#[cfg(windows)]
fn windows_default_shell() -> Option<PathBuf> {
find_on_path("pwsh.exe")
.or_else(installed_pwsh)
.or_else(windows_powershell)
}
/// PowerShell 7 where its installer puts it, for when it is not on `PATH`.
#[cfg(windows)]
fn installed_pwsh() -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os("ProgramFiles")?).join(r"PowerShell\7\pwsh.exe");
path.is_file().then_some(path)
}
#[cfg(windows)]
fn windows_powershell() -> Option<PathBuf> {
let path = PathBuf::from(std::env::var_os("SystemRoot")?)
.join(r"System32\WindowsPowerShell\v1.0\powershell.exe");
path.is_file().then_some(path)
}
/// The first `name` on `PATH`. Not `is_file`: PowerShell 7 from the
/// Microsoft Store is an app execution alias, which cannot be followed.
#[cfg(windows)]
fn find_on_path(name: &str) -> Option<PathBuf> {
let paths = std::env::var_os("PATH")?;
std::env::split_paths(&paths)
.map(|dir| dir.join(name))
.find(|candidate| {
candidate
.symlink_metadata()
.is_ok_and(|metadata| !metadata.is_dir())
})
}
#[cfg(test)]
mod tests {
use super::*;
fn words(line: &str, quoting: Quoting) -> Vec<String> {
split_command(line, quoting).expect("quotes are closed")
}
#[test]
fn a_blank_command_is_the_default_shell() {
assert_eq!(LocalShell::new(" ").command(), None);
assert_eq!(LocalShell::new(""), LocalShell::default());
assert_eq!(
LocalShell::new(" /bin/zsh -l ").command(),
Some("/bin/zsh -l")
);
}
#[test]
fn posix_commands_split_as_a_shell_reads_them() {
let posix = Quoting::Posix;
assert_eq!(words("/bin/zsh", posix), ["/bin/zsh"]);
assert_eq!(
words(" /bin/bash --login -i ", posix),
["/bin/bash", "--login", "-i"]
);
assert_eq!(
words(r#"'/opt/My Shells/fish' -C "echo \"hi\" \$HOME \n""#, posix),
["/opt/My Shells/fish", "-C", r#"echo "hi" $HOME \n"#]
);
assert_eq!(words(r"/opt/My\ Shells/nu", posix), ["/opt/My Shells/nu"]);
assert_eq!(words(r#"sh -c '' """#, posix), ["sh", "-c", "", ""]);
assert_eq!(words(r"a'b'c\", posix), [r"abc\"]);
assert!(words("", posix).is_empty());
assert_eq!(split_command("sh -c 'exit", posix), None);
assert_eq!(split_command(r#"sh -c "exit\""#, posix), None);
}
#[test]
fn windows_commands_keep_their_backslashes() {
let windows = Quoting::Windows;
assert_eq!(
words(
r#""C:\Program Files\PowerShell\7\pwsh.exe" -NoLogo"#,
windows
),
[r"C:\Program Files\PowerShell\7\pwsh.exe", "-NoLogo"]
);
assert_eq!(
words(r"C:\Windows\System32\cmd.exe /k cd \d", windows),
[r"C:\Windows\System32\cmd.exe", "/k", "cd", r"\d"]
);
// A single quote is no quote to Windows.
assert_eq!(
words("cmd /c echo 'a b'", windows),
["cmd", "/c", "echo", "'a", "b'"]
);
assert_eq!(split_command(r#""C:\Program Files"#, windows), None);
}
#[test]
fn a_chosen_file_reads_back_as_one_word() {
for (quoting, path) in [
(Quoting::Posix, "/bin/zsh"),
(
Quoting::Posix,
"/Applications/My Shell.app/Contents/MacOS/sh",
),
(Quoting::Posix, "/opt/it's/fish"),
(Quoting::Posix, "/opt/$HOME/\"x\"\\y"),
(Quoting::Windows, r"C:\Windows\System32\cmd.exe"),
(Quoting::Windows, r"C:\Program Files\PowerShell\7\pwsh.exe"),
] {
let quoted = quote_word_for(path, quoting);
assert_eq!(words(&quoted, quoting), [path], "{quoted}");
}
assert_eq!(quote_word_for("/bin/zsh", Quoting::Posix), "/bin/zsh");
assert_eq!(
quote_word_for(r"C:\Program Files\Git\bin\bash.exe", Quoting::Windows),
r#""C:\Program Files\Git\bin\bash.exe""#
);
}
#[test]
fn only_shells_named_on_their_own_start_as_login_shells() {
assert!(is_shell_name("zsh"));
assert!(is_shell_name("/opt/homebrew/bin/fish"));
assert!(!is_shell_name("tmux"));
assert!(!is_shell_name("/usr/bin/zsh5"));
}
#[test]
fn etc_shells_lists_paths_without_its_comments() {
let text = "# List of acceptable shells for chpass(1).\n\n/bin/bash\n /bin/zsh # default\n/usr/bin/tmux\nnot-a-path\n";
assert_eq!(
listed_shells(text),
["/bin/bash", "/bin/zsh", "/usr/bin/tmux"]
);
}
#[cfg(unix)]
#[test]
fn a_shell_on_its_own_is_a_login_shell_and_anything_else_runs_as_written() {
let builder = LocalShell::new("sh").command_builder().unwrap();
assert!(builder.is_default_prog());
let shell = builder.get_env("SHELL").unwrap().to_str().unwrap();
assert!(shell.starts_with('/') && shell.ends_with("/sh"), "{shell}");
let builder = LocalShell::new("/bin/sh -l").command_builder().unwrap();
assert!(!builder.is_default_prog());
assert_eq!(builder.get_argv(), &["/bin/sh", "-l"]);
// Not a shell, or not there: spawning it as written says why.
for command in ["/usr/bin/env", "/no/such/zsh"] {
let builder = LocalShell::new(command).command_builder().unwrap();
assert!(!builder.is_default_prog());
assert_eq!(builder.get_argv(), &[command]);
}
assert!(
LocalShell::default()
.command_builder()
.unwrap()
.is_default_prog()
);
let error = LocalShell::new("sh -c 'exit")
.command_builder()
.unwrap_err()
.to_string();
assert!(error.contains("sh -c 'exit"), "{error}");
}
#[cfg(unix)]
#[test]
fn the_installed_shells_are_common_programs_here_and_include_sh() {
let shells = installed_shells();
assert!(shells.iter().all(|shell| is_executable(Path::new(shell))));
assert!(
shells.iter().all(|shell| COMMON_SHELLS
.iter()
.any(|name| shell.ends_with(&format!("/{name}")))),
"{shells:?}"
);
assert!(
shells.iter().any(|shell| shell.ends_with("/sh")),
"{shells:?}"
);
assert!(default_shell_name().starts_with('/'));
}
#[cfg(unix)]
#[test]
fn only_common_shells_are_offered_each_program_once() {
let listed = [
"/bin/bash",
"/bin/csh",
"/bin/dash",
"/bin/zsh",
"/usr/bin/bash",
"/bin/sh",
]
.map(String::from);
let folders = ["/usr/bin", "/opt/homebrew/bin"].map(PathBuf::from);
let here = [
"/bin/bash",
"/bin/csh",
"/bin/dash",
"/bin/zsh",
"/usr/bin/bash",
"/usr/bin/zsh",
"/bin/sh",
"/opt/homebrew/bin/bash",
"/opt/homebrew/bin/fish",
];
let usable = |path: &Path| here.iter().any(|here| Path::new(here) == path);
// `/bin` is `/usr/bin` here, as on a merged `/usr`.
let key = |path: &Path| path.to_string_lossy().replace("/usr/bin/", "/bin/");
assert_eq!(
common_shells(&listed, &folders, usable, key),
[
"/bin/zsh",
"/bin/bash",
"/opt/homebrew/bin/bash",
"/bin/sh",
"/opt/homebrew/bin/fish",
]
);
}
#[cfg(windows)]
#[test]
fn windows_starts_powershell_before_the_command_prompt() {
let shells = installed_shells();
assert_eq!(shells.last().map(String::as_str), Some("cmd.exe"));
assert!(
shells.iter().any(|shell| shell == "powershell.exe"),
"{shells:?}"
);
let default = default_shell_name();
assert!(
matches!(default.as_str(), "pwsh.exe" | "powershell.exe"),
"{default}"
);
let builder = LocalShell::default().command_builder().unwrap();
assert!(!builder.is_default_prog());
let program = PathBuf::from(&builder.get_argv()[0]);
assert_eq!(
program.file_name().unwrap().to_str(),
Some(default.as_str())
);
}
}
+11 -3
View File
@@ -88,9 +88,17 @@ impl LocalTerminalPanel {
self.terminal.read(cx).status(cx)
}
pub fn restart(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.terminal
.update(cx, |terminal, cx| terminal.restart(cx));
/// Start anew with `factory`, which runs the shell the settings name
/// now.
pub fn restart(
&mut self,
factory: SharedTerminalTransportFactory,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.terminal.update(cx, |terminal, cx| {
terminal.restart_with_factory(factory, cx)
});
let focus = self.terminal.read(cx).focus_handle();
window.focus(&focus, cx);
}
+6 -2
View File
@@ -6,6 +6,7 @@ mod font;
mod highlight;
mod links;
mod local_pty;
mod local_shell;
mod local_terminal_panel;
mod model;
mod mouse;
@@ -27,7 +28,8 @@ pub use font::{
pub use highlight::{
HighlightColor, HighlightRule, HighlightSet, PatternError, TerminalHighlights, default_rules,
};
pub use local_pty::LocalPtyTransportFactory;
pub use local_pty::LocalPtyTransportProvider;
pub use local_shell::{LocalShell, default_shell_name, installed_shells, quote_word};
pub use local_terminal_panel::{LocalTerminalPanel, LocalTerminalPanelEvent};
pub use model::{
LocalTerminalId, RemoteTerminalId, RightClick, TerminalInteraction, TerminalLifecycle,
@@ -47,7 +49,9 @@ pub use terminal_view::{
pub use theme::{TerminalColors, TerminalTheme};
pub(crate) use transport::send_event;
pub use transport::{
ExecRequest, ExecResult, FixedRemoteTerminalTransportProvider, RemoteTerminalTransportProvider,
ExecRequest, ExecResult, FixedLocalTerminalTransportProvider,
FixedRemoteTerminalTransportProvider, LocalTerminalTransportProvider,
RemoteTerminalTransportProvider, SharedLocalTerminalTransportProvider,
SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalTransport,
TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory,
};
+46 -2
View File
@@ -1,9 +1,9 @@
use std::sync::{Arc, mpsc};
use std::sync::{Arc, Mutex, mpsc};
use anyhow::Result;
use async_channel::Sender;
use super::TerminalSize;
use super::{LocalShell, TerminalSize};
use crate::connection::Latency;
use crate::connection::{ConnectionPrompt, ConnectionPromptReply};
use crate::host::{HostLogin, HostOs};
@@ -100,6 +100,50 @@ pub trait RemoteTerminalTransportProvider: Send + Sync + 'static {
pub type SharedRemoteTerminalTransportProvider = Arc<dyn RemoteTerminalTransportProvider>;
/// Creates the factory of a local terminal for the shell the settings name,
/// asked when it opens and when it restarts, so a changed setting reaches
/// the next one. Like the remote provider, the workspace owns this boundary
/// and UI tests inject a fake.
pub trait LocalTerminalTransportProvider: Send + Sync + 'static {
fn factory_for(&self, shell: &LocalShell) -> SharedTerminalTransportFactory;
}
pub type SharedLocalTerminalTransportProvider = Arc<dyn LocalTerminalTransportProvider>;
/// For tests: every local terminal gets the same factory, and the shells
/// asked for are kept.
pub struct FixedLocalTerminalTransportProvider {
factory: SharedTerminalTransportFactory,
shells: Mutex<Vec<LocalShell>>,
}
impl FixedLocalTerminalTransportProvider {
pub fn new(factory: SharedTerminalTransportFactory) -> Self {
Self {
factory,
shells: Default::default(),
}
}
/// The shell of each local terminal started, in order.
pub fn shells(&self) -> Vec<LocalShell> {
self.shells
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
}
impl LocalTerminalTransportProvider for FixedLocalTerminalTransportProvider {
fn factory_for(&self, shell: &LocalShell) -> SharedTerminalTransportFactory {
self.shells
.lock()
.unwrap_or_else(|error| error.into_inner())
.push(shell.clone());
self.factory.clone()
}
}
/// Provider useful for tests and embedders that want every host to use the
/// same factory.
pub struct FixedRemoteTerminalTransportProvider {
+21 -11
View File
@@ -67,10 +67,11 @@ use crate::sftp::{
};
use crate::shared::{commit_footer, confirm_danger, open_rename_tab_dialog};
use crate::terminal::{
DEFAULT_FONT_SIZE, LocalPtyTransportFactory, LocalTerminalId, LocalTerminalPanel,
DEFAULT_FONT_SIZE, LocalPtyTransportProvider, LocalShell, LocalTerminalId, LocalTerminalPanel,
LocalTerminalPanelEvent, RemoteTerminalId, SearchDirection,
SharedRemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalLifecycle,
TerminalPanel, TerminalPanelEvent, TerminalView,
SharedLocalTerminalTransportProvider, SharedRemoteTerminalTransportProvider,
SharedTerminalTransportFactory, TerminalLifecycle, TerminalPanel, TerminalPanelEvent,
TerminalView,
};
use crate::update::{UpdateServices, Updater, UpdaterEvent};
@@ -179,7 +180,7 @@ pub struct Workspace {
/// 匿名使用统计. Production only, like the CLI server: UI tests hand in
/// one that only records.
pub(super) analytics: Option<super::analytics::Reporting>,
local_terminal_factory: SharedTerminalTransportFactory,
local_terminal_provider: SharedLocalTerminalTransportProvider,
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
sftp_provider: SharedSftpTransportProvider,
pub(super) local_directory_provider: SharedLocalDirectoryProvider,
@@ -245,7 +246,7 @@ impl Workspace {
store,
settings,
remote,
Arc::new(LocalPtyTransportFactory),
Arc::new(LocalPtyTransportProvider),
sftp.clone(),
Arc::new(SystemLocalDirectoryProvider),
tester,
@@ -306,7 +307,7 @@ impl Workspace {
store: Entity<HostStore>,
settings: Entity<SettingsStore>,
remote_terminal_provider: SharedRemoteTerminalTransportProvider,
local_terminal_factory: SharedTerminalTransportFactory,
local_terminal_provider: SharedLocalTerminalTransportProvider,
sftp_provider: SharedSftpTransportProvider,
local_directory_provider: SharedLocalDirectoryProvider,
connection_tester: SharedConnectionTester,
@@ -540,7 +541,7 @@ impl Workspace {
cli_server: None,
updater,
analytics: None,
local_terminal_factory,
local_terminal_provider,
remote_terminal_provider,
sftp_provider,
local_directory_provider,
@@ -1164,8 +1165,8 @@ impl Workspace {
) {
let id = LocalTerminalId(self.next_local_terminal_id);
self.next_local_terminal_id += 1;
let (panel, subscription) =
new_local_terminal_panel(id, self.local_terminal_factory.clone(), window, cx);
let factory = self.local_terminal_factory(cx);
let (panel, subscription) = new_local_terminal_panel(id, factory, window, cx);
self._subscriptions.push(subscription);
let focus = panel.read(cx).focus_handle(cx);
self._subscriptions.push(follow_focus(
@@ -1736,11 +1737,20 @@ impl Workspace {
window: &mut Window,
cx: &mut Context<Self>,
) {
if let Some(panel) = self.local_terminals.get(&action.0) {
panel.update(cx, |panel, cx| panel.restart(window, cx));
if let Some(panel) = self.local_terminals.get(&action.0).cloned() {
let factory = self.local_terminal_factory(cx);
panel.update(cx, |panel, cx| panel.restart(factory, window, cx));
}
}
/// A transport for a local terminal starting now: the shell
/// 设置 › 终端 › 本地终端 names at this moment.
fn local_terminal_factory(&self, cx: &App) -> SharedTerminalTransportFactory {
let settings = self.settings.read(cx).settings();
let shell = LocalShell::new(settings.local_terminal.shell.as_deref().unwrap_or_default());
self.local_terminal_provider.factory_for(&shell)
}
/// The terminal of the center tab shown most recently, remote or local.
fn active_terminal(&self, cx: &App) -> Option<Entity<TerminalView>> {
match self.active_tab? {
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn open_workspace_with_tester(
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
tester.clone(),
+1 -1
View File
@@ -21,7 +21,7 @@ pub fn open_workspace_with_credentials(
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
tester,
+1 -1
View File
@@ -157,7 +157,7 @@ pub fn open_sized_workspace_with_forwards(
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
Arc::new(FakeConnectionTester::default()),
+4 -4
View File
@@ -42,10 +42,10 @@ pub use shellrs::sftp::{
SftpTransport, SftpTransportProvider, TextFile, UploadRequest,
};
pub use shellrs::terminal::{
FixedRemoteTerminalTransportProvider, LocalTerminalId, RemoteTerminalId,
RemoteTerminalTransportProvider, SharedTerminalTransportFactory, TerminalFont,
TerminalLifecycle, TerminalSize, TerminalTransport, TerminalTransportCommand,
TerminalTransportEvent, TerminalTransportFactory,
FixedLocalTerminalTransportProvider, FixedRemoteTerminalTransportProvider, LocalShell,
LocalTerminalId, RemoteTerminalId, RemoteTerminalTransportProvider,
SharedTerminalTransportFactory, TerminalFont, TerminalLifecycle, TerminalSize,
TerminalTransport, TerminalTransportCommand, TerminalTransportEvent, TerminalTransportFactory,
};
pub use shellrs::update::{
Channel, InstallKind, Installer, Relaunch, Release, Staged, TrustedKeys, Unsupported,
+1 -1
View File
@@ -455,7 +455,7 @@ pub fn open_workspace_with_services(
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
provider,
Arc::new(local),
Arc::new(FakeConnectionTester::default()),
+8 -1
View File
@@ -239,6 +239,13 @@ fn shell_answer(behavior: FakeBehavior, typed: &[u8]) -> Option<Vec<u8>> {
Some(answer.into_bytes())
}
/// Local terminals that start, and never print anything.
pub fn fake_local_terminals() -> Arc<FixedLocalTerminalTransportProvider> {
Arc::new(FixedLocalTerminalTransportProvider::new(Arc::new(
FakeTerminalFactory::default(),
)))
}
pub fn open_workspace_with_remote_factory(
cx: &mut TestAppContext,
store: HostStore,
@@ -256,7 +263,7 @@ pub fn open_workspace_with_remote_factory(
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
Arc::new(FakeConnectionTester::default()),
+89 -2
View File
@@ -7,12 +7,25 @@ use shellrs::terminal::RightClick;
fn open_workspace_with_factory(
cx: &mut TestAppContext,
factory: Arc<FakeTerminalFactory>,
) -> (WindowHandle<Root>, Entity<Workspace>) {
open_workspace_with_local_terminals(
cx,
Arc::new(FixedLocalTerminalTransportProvider::new(factory)),
size(px(1280.), px(800.)),
)
}
/// With `local` asked for each local terminal's start.
fn open_workspace_with_local_terminals(
cx: &mut TestAppContext,
local: Arc<FixedLocalTerminalTransportProvider>,
window_size: gpui_kit::Size<gpui_kit::Pixels>,
) -> (WindowHandle<Root>, Entity<Workspace>) {
init_app(cx);
// Dialogs still, as in every fixture: see `open_workspace_with_tester`.
cx.update(|cx| cx.set_reduce_motion(true));
let mut workspace = None;
let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| {
let handle = cx.open_window(window_size, |window, cx| {
let store = cx.new(|_| HostStore::seed());
let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new(
FakeTerminalFactory::default(),
@@ -22,7 +35,7 @@ fn open_workspace_with_factory(
store,
cx.new(|_| settings_store()),
remote,
factory.clone(),
local.clone(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
Arc::new(FakeConnectionTester::default()),
@@ -142,6 +155,80 @@ async fn tab_keys_are_sent_to_the_focused_terminal(cx: &mut TestAppContext) {
.unwrap();
}
#[gpui_kit::test]
async fn local_terminals_run_the_shell_the_settings_name(cx: &mut TestAppContext) {
let local = fake_local_terminals();
// Tall enough for the whole of 终端, whose groups the page's list
// draws only near the view.
let (handle, workspace) =
open_workspace_with_local_terminals(cx, local.clone(), size(px(1280.), px(1600.)));
let settings = cx.update(|cx| workspace.read(cx).settings().clone());
// Until one is named, the system's default shell.
in_frame(cx, handle, |window, cx| {
window.click("new-local-terminal", cx)
});
assert_eq!(local.shells(), [LocalShell::default()]);
// 终端 › 本地终端, the page's last group: the field is empty, and what
// is typed in it is the setting at once.
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
in_frame(cx, handle, |window, cx| {
window.within("settings").click("0-1", cx)
});
in_frame(cx, handle, |window, cx| {
// The field runs across the row under its title, 选择文件… at its
// end, inside the page.
let field = window.find("local-shell").bounds();
let choose = window.find("browse-local-shell").bounds();
let page = window.find("settings").bounds();
assert!(
field.size.width > page.size.width / 2.,
"{field:?} in {page:?}"
);
assert!(field.right() <= choose.left() && choose.right() <= page.right());
assert_eq!(window.find("local-shell").value(), Some(""));
window.click("local-shell", cx);
window.input("/bin/bash -l ", cx);
});
let shell =
|cx: &mut TestAppContext| cx.update(|cx| settings.read(cx).settings().local_terminal.shell);
assert_eq!(shell(cx).as_deref(), Some("/bin/bash -l"));
in_frame(cx, handle, |window, _| {
// The blank typed last stays in the field, ready for what follows.
assert_eq!(window.find("local-shell").value(), Some("/bin/bash -l "));
});
// The open terminal takes it when it restarts, a new one at once.
in_frame(cx, handle, |window, cx| {
window.click(("local-terminal-tab", 1_u64), cx)
});
in_frame(cx, handle, |window, cx| {
window.click(("restart-local-terminal-toolbar", 1_u64), cx)
});
in_frame(cx, handle, |window, cx| {
window.click("new-local-terminal", cx)
});
let bash = LocalShell::new("/bin/bash -l");
assert_eq!(
local.shells(),
[LocalShell::default(), bash.clone(), bash.clone()]
);
// Set back elsewhere (重置, the file read anew), the field follows.
settings.update(cx, |store, cx| {
store.update(|settings| settings.local_terminal.shell = None, cx)
});
in_frame(cx, handle, |window, cx| window.click("open-settings", cx));
in_frame(cx, handle, |window, _| {
assert_eq!(window.find("local-shell").value(), Some(""));
});
in_frame(cx, handle, |window, cx| {
window.click("new-local-terminal", cx)
});
assert_eq!(local.shells().last(), Some(&LocalShell::default()));
}
#[gpui_kit::test]
async fn exited_local_terminal_keeps_its_tab_and_restarts_fresh(cx: &mut TestAppContext) {
let factory = Arc::new(FakeTerminalFactory::exit_first());
+1 -1
View File
@@ -812,7 +812,7 @@ fn a_problem_found_while_the_window_is_built_is_shown_once_it_is_open(cx: &mut T
store,
cx.new(|_| settings_store()),
remote,
Arc::new(FakeTerminalFactory::default()),
fake_local_terminals(),
Arc::new(FakeSftpProvider::default()),
Arc::new(FakeLocalDirectory::default()),
Arc::new(FakeConnectionTester::default()),