From cd489a5fc2226f995d7de34714ea0cf1966e9c78 Mon Sep 17 00:00:00 2001 From: Xuz Date: Sun, 11 Oct 2026 19:44:17 +0800 Subject: [PATCH] =?UTF-8?q?=E8=AE=BE=E7=BD=AE=20=E2=80=BA=20=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E6=96=B0=E5=A2=9E=E3=80=8C=E6=9C=AC=E5=9C=B0=E7=BB=88?= =?UTF-8?q?=E7=AB=AF=E3=80=8D=EF=BC=9A=E6=8C=87=E5=AE=9A=20Shell=EF=BC=9BW?= =?UTF-8?q?indows=20=E9=BB=98=E8=AE=A4=E6=94=B9=E7=94=A8=20PowerShell?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本地终端运行的程序可以写路径或命令,带参数;输入框右端的下拉列出装了的常用 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 --- .github/workflows/windows.yml | 2 + CLAUDE.md | 6 +- docs/manual.md | 1 + locales/settings.yml | 32 ++ locales/terminal.yml | 7 + src/app/assets.rs | 3 +- src/settings/local_shell_editor.rs | 227 +++++++++++ src/settings/mod.rs | 5 +- src/settings/model.rs | 36 ++ src/settings/settings_panel.rs | 31 +- src/terminal/local_pty.rs | 75 +++- src/terminal/local_shell.rs | 582 +++++++++++++++++++++++++++ src/terminal/local_terminal_panel.rs | 14 +- src/terminal/mod.rs | 8 +- src/terminal/transport.rs | 48 ++- src/workspace/workspace_view.rs | 32 +- tests/ui/support/connection.rs | 2 +- tests/ui/support/credential.rs | 2 +- tests/ui/support/forward.rs | 2 +- tests/ui/support/mod.rs | 8 +- tests/ui/support/sftp.rs | 2 +- tests/ui/support/terminal.rs | 9 +- tests/ui/terminal.rs | 91 ++++- tests/ui/workspace.rs | 2 +- 24 files changed, 1172 insertions(+), 55 deletions(-) create mode 100644 src/settings/local_shell_editor.rs create mode 100644 src/terminal/local_shell.rs diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index c3e72bf..5ef2f4a 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 0222e7f..97885ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`。 diff --git a/docs/manual.md b/docs/manual.md index f0ec398..9d2c02a 100644 --- a/docs/manual.md +++ b/docs/manual.md @@ -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」按任意一个带修饰键的数字即可,九个一起换。 diff --git a/locales/settings.yml b/locales/settings.yml index 2dadf53..7c3ac8d 100644 --- a/locales/settings.yml +++ b/locales/settings.yml @@ -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 diff --git a/locales/terminal.yml b/locales/terminal.yml index bdeda0f..0609a02 100644 --- a/locales/terminal.yml +++ b/locales/terminal.yml @@ -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 diff --git a/src/app/assets.rs b/src/app/assets.rs index f025cea..445a549 100644 --- a/src/app/assets.rs +++ b/src/app/assets.rs @@ -77,7 +77,8 @@ icon_assets!( Zap, ExternalLink, Highlighter, - GripVertical + GripVertical, + FileSearch ] ); diff --git a/src/settings/local_shell_editor.rs b/src/settings/local_shell_editor.rs new file mode 100644 index 0000000..13c63fa --- /dev/null +++ b/src/settings/local_shell_editor.rs @@ -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, + input: Entity, + /// What 自动检测 runs here, and the shells installed; `None` until the + /// look in the background comes back. + found: Option, + _subscriptions: [Subscription; 3], + _scan: Task<()>, +} + +struct FoundShells { + default: String, + installed: Vec, +} + +impl LocalShellEditor { + pub fn new(store: Entity, window: &mut Window, cx: &mut Context) -> 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::(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) { + 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, + window: &mut Window, + cx: &mut Context, + ) { + 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) { + 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, cx: &mut Context) { + 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, cx: &App) -> Option { + 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, + 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| { + 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(); + }), + ) +} diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 0b83cb3..f833144 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -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}; diff --git a/src/settings/model.rs b/src/settings/model.rs index ae2be34..ec2f4fb 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -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, +} + /// 终端 → 通知: 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(); diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index 8765f7e..c03835d 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -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, /// 键盘快捷键, which knows which shortcut is taking new keys. shortcuts: Entity, + /// 终端 › 本地终端's field, with the shells installed here. + local_shell: Entity, /// 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>, focus_handle: FocusHandle, tab_group: Option>, - _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 { ]), 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) -> SettingGroup { let (reader, writer) = (store.clone(), store.clone()); diff --git a/src/terminal/local_pty.rs b/src/terminal/local_pty.rs index ca51edf..596a87b 100644 --- a/src/terminal/local_pty.rs +++ b/src/terminal/local_pty.rs @@ -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 { - Box::new(LocalPtyTransport { command: None }) + Box::new(LocalPtyTransport { + shell: self.shell.clone(), + }) } } struct LocalPtyTransport { - command: Option>, + 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 { - 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(); diff --git a/src/terminal/local_shell.rs b/src/terminal/local_shell.rs new file mode 100644 index 0000000..8c76d78 --- /dev/null +++ b/src/terminal/local_shell.rs @@ -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, +} + +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 { + 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 { + #[cfg(unix)] + { + let listed = std::fs::read_to_string("/etc/shells") + .map(|text| listed_shells(&text)) + .unwrap_or_default(); + let mut folders: Vec = 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> { + 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( + listed: &[String], + folders: &[PathBuf], + usable: impl Fn(&Path) -> bool, + key: impl Fn(&Path) -> K, +) -> Vec { + 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 { + 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 { + 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) { + 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 { + 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 { + 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 { + 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 { + 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 { + 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("ed, 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()) + ); + } +} diff --git a/src/terminal/local_terminal_panel.rs b/src/terminal/local_terminal_panel.rs index 7d812d4..5df8b95 100644 --- a/src/terminal/local_terminal_panel.rs +++ b/src/terminal/local_terminal_panel.rs @@ -88,9 +88,17 @@ impl LocalTerminalPanel { self.terminal.read(cx).status(cx) } - pub fn restart(&mut self, window: &mut Window, cx: &mut Context) { - 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.terminal.update(cx, |terminal, cx| { + terminal.restart_with_factory(factory, cx) + }); let focus = self.terminal.read(cx).focus_handle(); window.focus(&focus, cx); } diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 36b44c4..67e2f31 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -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, }; diff --git a/src/terminal/transport.rs b/src/terminal/transport.rs index 7e43cce..795ac40 100644 --- a/src/terminal/transport.rs +++ b/src/terminal/transport.rs @@ -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; +/// 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; + +/// For tests: every local terminal gets the same factory, and the shells +/// asked for are kept. +pub struct FixedLocalTerminalTransportProvider { + factory: SharedTerminalTransportFactory, + shells: Mutex>, +} + +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 { + 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 { diff --git a/src/workspace/workspace_view.rs b/src/workspace/workspace_view.rs index 673ac8e..7974872 100644 --- a/src/workspace/workspace_view.rs +++ b/src/workspace/workspace_view.rs @@ -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, - 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, settings: Entity, 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, ) { - 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> { match self.active_tab? { diff --git a/tests/ui/support/connection.rs b/tests/ui/support/connection.rs index 0fabf07..21e9d07 100644 --- a/tests/ui/support/connection.rs +++ b/tests/ui/support/connection.rs @@ -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(), diff --git a/tests/ui/support/credential.rs b/tests/ui/support/credential.rs index 1cf302b..d544b68 100644 --- a/tests/ui/support/credential.rs +++ b/tests/ui/support/credential.rs @@ -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, diff --git a/tests/ui/support/forward.rs b/tests/ui/support/forward.rs index c4376bb..7cc4056 100644 --- a/tests/ui/support/forward.rs +++ b/tests/ui/support/forward.rs @@ -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()), diff --git a/tests/ui/support/mod.rs b/tests/ui/support/mod.rs index 0ca110d..6545ebc 100644 --- a/tests/ui/support/mod.rs +++ b/tests/ui/support/mod.rs @@ -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, diff --git a/tests/ui/support/sftp.rs b/tests/ui/support/sftp.rs index 41a9462..0fd1a24 100644 --- a/tests/ui/support/sftp.rs +++ b/tests/ui/support/sftp.rs @@ -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()), diff --git a/tests/ui/support/terminal.rs b/tests/ui/support/terminal.rs index 5538c56..1263847 100644 --- a/tests/ui/support/terminal.rs +++ b/tests/ui/support/terminal.rs @@ -239,6 +239,13 @@ fn shell_answer(behavior: FakeBehavior, typed: &[u8]) -> Option> { Some(answer.into_bytes()) } +/// Local terminals that start, and never print anything. +pub fn fake_local_terminals() -> Arc { + 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()), diff --git a/tests/ui/terminal.rs b/tests/ui/terminal.rs index 82ce513..7f94bc4 100644 --- a/tests/ui/terminal.rs +++ b/tests/ui/terminal.rs @@ -7,12 +7,25 @@ use shellrs::terminal::RightClick; fn open_workspace_with_factory( cx: &mut TestAppContext, factory: Arc, +) -> (WindowHandle, Entity) { + 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, + window_size: gpui_kit::Size, ) -> (WindowHandle, Entity) { 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()); diff --git a/tests/ui/workspace.rs b/tests/ui/workspace.rs index d7b44aa..6d89069 100644 --- a/tests/ui/workspace.rs +++ b/tests/ui/workspace.rs @@ -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()),