设置 › 终端新增「本地终端」:指定 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:
1 parent
9799f88ade
commit
cd489a5fc2
24 files changed
+1172
-55
No files matched your search
@@ -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
|
||||
@@ -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`。
|
||||
|
||||
@@ -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」按任意一个带修饰键的数字即可,九个一起换。
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -77,7 +77,8 @@ icon_assets!(
|
||||
Zap,
|
||||
ExternalLink,
|
||||
Highlighter,
|
||||
GripVertical
|
||||
GripVertical,
|
||||
FileSearch
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
@@ -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
@@ -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};
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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("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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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
@@ -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());
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in new issue
Block a user