feat(terminal): 本地终端下拉自动识别并列出 WSL 发行版,按发行版一键启动 (#182)

This commit is contained in:
user.email
2026-09-11 23:07:10 +08:00
parent 9b7b8256c1
commit 57e31731b6
11 changed files with 456 additions and 5 deletions
+2
View File
@@ -27,6 +27,7 @@ pub mod types;
mod windows_environment;
#[cfg(any(test, target_os = "windows"))]
mod windows_shell_integration;
mod wsl_distributions;
pub mod zmodem;
pub use exec_supervisor::TerminalExecError;
@@ -34,6 +35,7 @@ pub use local_shell::{
local_config_from_custom_profile, local_config_from_settings,
local_config_from_settings_with_profile,
};
pub use wsl_distributions::{WslDistribution, list_wsl_distributions, local_config_for_wsl_distro};
pub use performance_metrics::{
TERMINAL_PERFORMANCE_METRICS_ENV, TerminalActivity, TerminalInputMetricSource,
TerminalPerformanceMetrics, TerminalPerformanceSnapshot, TerminalPerformanceWindow,
+1 -1
View File
@@ -243,7 +243,7 @@ fn resolve_cmd() -> String {
}
#[cfg(any(test, target_os = "windows"))]
fn resolve_wsl() -> String {
pub(crate) fn resolve_wsl() -> String {
system32_path(&["wsl.exe"])
.or_else(|| find_in_path("wsl.exe"))
.unwrap_or_else(|| "wsl.exe".to_string())
+196
View File
@@ -0,0 +1,196 @@
use anyhow::Result;
#[cfg(any(test, target_os = "windows"))]
use anyhow::Context;
use crate::LocalConfig;
/// `wsl.exe --list --verbose` 报告的一个 WSL 发行版。
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WslDistribution {
/// 注册名(如 `Ubuntu-22.04`),用于 `wsl.exe -d <name>` 启动。
pub name: String,
/// 运行状态(如 `Running`/`Stopped`);仅用于展示与测试,允许缺失。
pub state: Option<String>,
/// WSL 版本(1/2);旧版 wsl.exe 输出可能缺失。
pub version: Option<u8>,
/// 是否为默认发行版(输出行首的 `*` 标记)。
pub is_default: bool,
}
#[cfg(any(test, target_os = "windows"))]
impl WslDistribution {
fn new(
name: String,
state: Option<String>,
version: Option<u8>,
is_default: bool,
) -> Self {
Self {
name,
state,
version,
is_default,
}
}
}
/// 识别已安装的 WSL 发行版(`wsl.exe --list --verbose`)。
///
/// 仅在 Windows 上可用;未安装 WSL、无发行版或 wsl.exe 不可用时返回错误。
pub fn list_wsl_distributions() -> Result<Vec<WslDistribution>> {
#[cfg(target_os = "windows")]
{
list_wsl_distributions_with(&crate::local_shell::resolve_wsl())
}
#[cfg(not(target_os = "windows"))]
{
anyhow::bail!("WSL is only available on Windows")
}
}
#[cfg(any(test, target_os = "windows"))]
pub(crate) fn list_wsl_distributions_with(wsl: &str) -> Result<Vec<WslDistribution>> {
let output = std::process::Command::new(wsl)
.args(["--list", "--verbose"])
.output()
.with_context(|| format!("failed to run {wsl} --list --verbose"))?;
if !output.status.success() {
// 未安装 WSL 或没有发行版时 wsl.exe 以非零码退出并输出本地化错误文本,
// 这里原样透出,由调用方决定按空结果还是提示处理。
anyhow::bail!("{}", decode_wsl_output(&output.stdout));
}
Ok(parse_wsl_list_output(&output.stdout))
}
/// 构造以指定发行版启动本地终端的配置(`wsl.exe -d <name>`)。
pub fn local_config_for_wsl_distro(distro: &str) -> Result<LocalConfig> {
#[cfg(target_os = "windows")]
{
local_config_for_wsl_distro_with(crate::local_shell::resolve_wsl(), distro)
}
#[cfg(not(target_os = "windows"))]
{
let _ = distro;
anyhow::bail!("WSL is only available on Windows")
}
}
#[cfg(any(test, target_os = "windows"))]
pub(crate) fn local_config_for_wsl_distro_with(
wsl: String,
distro: &str,
) -> Result<LocalConfig> {
let distro = distro.trim();
anyhow::ensure!(!distro.is_empty(), "WSL distribution name is required");
Ok(LocalConfig {
shell: Some(wsl),
args: vec!["--distribution".into(), distro.into()],
working_dir: None,
..LocalConfig::default()
})
}
/// 解析 `wsl.exe --list --verbose` 的输出。
///
/// 纯函数、跨平台可测:支持 wsl.exe 管道输出的 UTF-16LE(默认)与 UTF-8
/// 表头、错误文本等非数据行一律过滤为空结果。
#[cfg(any(test, target_os = "windows"))]
pub(crate) fn parse_wsl_list_output(raw: &[u8]) -> Vec<WslDistribution> {
decode_wsl_output(raw)
.lines()
.filter_map(parse_distribution_row)
.collect()
}
/// 单行解析规则(issue feigeCode/navop#182):
/// - 行首 `*` 标记默认发行版;
/// - 名称与第二列(状态)以连续两个以上空格分隔;
/// - 版本列必须能解析为数字,状态列是已知状态词时也接受缺失版本列——
/// 版本/状态双判据用于把本地化表头与错误文本从数据行中稳定排除。
#[cfg(any(test, target_os = "windows"))]
fn parse_distribution_row(line: &str) -> Option<WslDistribution> {
let line = line.trim();
if line.is_empty() {
return None;
}
let (is_default, line) = match line.strip_prefix('*') {
Some(rest) => (true, rest.trim()),
None => (false, line),
};
let columns = split_columns(line);
let name = columns.first()?.trim();
if name.is_empty() {
return None;
}
let state = columns.get(1).map(|column| column.trim().to_string());
let version = columns.get(2).and_then(|column| column.trim().parse::<u8>().ok());
let recognized_state = state
.as_deref()
.is_some_and(|state| KNOWN_STATES.contains(&state));
match (version, recognized_state) {
(Some(version), _) => Some(WslDistribution::new(
name.to_string(),
state,
Some(version),
is_default,
)),
(None, true) => Some(WslDistribution::new(name.to_string(), state, None, is_default)),
// 表头(版本列为 "VERSION" 等非数字)或错误文本行
(None, false) => None,
}
}
/// wsl.exe 表格状态列的已知词(状态词不参与本地化,仅作旧输出回退判据)。
#[cfg(any(test, target_os = "windows"))]
const KNOWN_STATES: [&str; 6] = [
"Running",
"Stopped",
"Installing",
"Uninstalling",
"Converting",
"Stopping",
];
/// 按连续两个以上空格拆分表格列;名称中的单个空格不受影响。
#[cfg(any(test, target_os = "windows"))]
fn split_columns(line: &str) -> Vec<&str> {
line.split(" ")
.map(str::trim)
.filter(|column| !column.is_empty())
.collect()
}
/// wsl.exe 在 stdout 被重定向时输出 UTF-16LE(可能带 BOM);否则按 UTF-8 处理。
#[cfg(any(test, target_os = "windows"))]
pub(crate) fn decode_wsl_output(raw: &[u8]) -> String {
if looks_like_utf16le(raw) {
let units = raw
.chunks_exact(2)
.map(|pair| u16::from_le_bytes([pair[0], pair[1]]))
.collect::<Vec<_>>();
String::from_utf16_lossy(&units)
} else {
String::from_utf8_lossy(raw).into_owned()
}
.chars()
.filter(|ch| *ch != '\u{feff}' && *ch != '\0')
.collect()
}
/// 无 BOM 时以零字节占比判定:UTF-16LE 编码的文本在奇数位大量为零字节。
#[cfg(any(test, target_os = "windows"))]
fn looks_like_utf16le(raw: &[u8]) -> bool {
if raw.starts_with(&[0xFF, 0xFE]) {
return true;
}
let odd_zero_bytes = raw
.iter()
.enumerate()
.filter(|(index, byte)| index % 2 == 1 && **byte == 0)
.count();
raw.len() >= 2 && odd_zero_bytes > raw.len() / 4
}
#[cfg(test)]
#[path = "wsl_distributions_tests.rs"]
mod tests;
@@ -0,0 +1,107 @@
use super::{WslDistribution, decode_wsl_output, local_config_for_wsl_distro_with, parse_wsl_list_output};
fn utf16le(text: &str) -> Vec<u8> {
text.encode_utf16().flat_map(u16::to_le_bytes).collect()
}
fn verbose_sample() -> String {
[
"\r\n",
" NAME STATE VERSION\r\n",
"* Ubuntu-22.04 Running 2\r\n",
" Debian Stopped 1\r\n",
" openEuler-24.03 Stopped 2\r\n",
]
.concat()
}
fn expected_distributions() -> Vec<WslDistribution> {
vec![
WslDistribution::new("Ubuntu-22.04".into(), Some("Running".into()), Some(2), true),
WslDistribution::new("Debian".into(), Some("Stopped".into()), Some(1), false),
WslDistribution::new("openEuler-24.03".into(), Some("Stopped".into()), Some(2), false),
]
}
#[test]
fn parses_verbose_utf16_output_with_default_marker() {
assert_eq!(expected_distributions(), parse_wsl_list_output(&utf16le(&verbose_sample())));
}
#[test]
fn parses_verbose_utf16_output_with_bom() {
let mut raw = vec![0xFF, 0xFE];
raw.extend(utf16le(&verbose_sample()));
assert_eq!(expected_distributions(), parse_wsl_list_output(&raw));
}
#[test]
fn parses_utf8_output_without_bom() {
assert_eq!(expected_distributions(), parse_wsl_list_output(verbose_sample().as_bytes()));
}
#[test]
fn parses_rows_missing_the_version_column_when_state_is_known() {
let distributions = parse_wsl_list_output(b"Ubuntu-22.04 Running\r\n".as_slice());
assert_eq!(
vec![WslDistribution::new("Ubuntu-22.04".into(), Some("Running".into()), None, false)],
distributions
);
}
#[test]
fn localized_header_and_error_text_yield_no_distributions() {
// 本地化表头的版本列不是数字,被稳定过滤;数据行不受表头本地化影响。
let localized = "\r\n 名称 状态 版本\r\n* Ubuntu-22.04 正在运行 2\r\n";
assert_eq!(
vec![WslDistribution::new("Ubuntu-22.04".into(), Some("正在运行".into()), Some(2), true)],
parse_wsl_list_output(localized.as_bytes())
);
let error = "适用于 Linux 的 Windows 子系统没有已安装的分发版。\r\n";
assert!(parse_wsl_list_output(&utf16le(error)).is_empty());
}
#[test]
fn header_only_output_yields_no_distributions() {
let header_only = "\r\n NAME STATE VERSION\r\n";
assert!(parse_wsl_list_output(&utf16le(header_only)).is_empty());
}
#[test]
fn empty_and_blank_output_yield_no_distributions() {
assert!(parse_wsl_list_output(&[]).is_empty());
assert!(parse_wsl_list_output(b"\r\n").is_empty());
}
#[test]
fn distro_launch_config_uses_the_distribution_argument_form() {
let config =
local_config_for_wsl_distro_with("C:\\Windows\\System32\\wsl.exe".into(), "Ubuntu-22.04")
.unwrap();
assert_eq!(
Some("C:\\Windows\\System32\\wsl.exe".to_string()),
config.shell
);
assert_eq!(
vec!["--distribution".to_string(), "Ubuntu-22.04".to_string()],
config.args
);
assert!(config.working_dir.is_none());
// 继承 LocalConfig::default() 的基础终端环境变量
assert!(config
.env
.contains(&("TERM".to_string(), "xterm-256color".to_string())));
}
#[test]
fn distro_launch_config_trims_names_and_rejects_blank_names() {
let config = local_config_for_wsl_distro_with("wsl.exe".into(), " Debian-12 ").unwrap();
assert_eq!(vec!["--distribution".to_string(), "Debian-12".to_string()], config.args);
assert!(local_config_for_wsl_distro_with("wsl.exe".into(), " ").is_err());
}
#[test]
fn decode_prefers_utf16_when_zero_bytes_dominate() {
assert_eq!("abc".to_string(), decode_wsl_output(&utf16le("abc")));
assert_eq!("abc".to_string(), decode_wsl_output(b"abc"));
}
+12
View File
@@ -231,6 +231,18 @@ Home:
en: "Invalid local terminal configuration: %{error}"
zh-CN: "本地终端配置无效:%{error}"
zh-HK: "本機終端設定無效:%{error}"
wsl_distributions_section:
en: WSL Distributions
zh-CN: WSL 发行版
zh-HK: WSL 發行版
wsl_distributions_default:
en: default
zh-CN: 默认
zh-HK: 預設
wsl_distributions_refresh:
en: Re-detect WSL
zh-CN: 重新识别 WSL
zh-HK: 重新識別 WSL
remote_desktop_parameters_invalid:
en: "Invalid remote desktop connection parameters: %{error}"
zh-CN: "远程桌面连接参数无效:%{error}"
+18
View File
@@ -1386,6 +1386,24 @@ impl HomePage {
self.add_local_terminal_tab(config, window, cx);
}
/// 以指定 WSL 发行版打开本地终端标签页(`wsl.exe -d <name>`feigeCode/navop#182)。
#[cfg(target_os = "windows")]
pub(crate) fn add_terminal_tab_with_wsl_distro(
&mut self,
distro: String,
window: &mut Window,
cx: &mut Context<Self>,
) {
let config = match terminal::local_config_for_wsl_distro(&distro) {
Ok(config) => config,
Err(error) => {
push_local_terminal_config_error(window, &error, cx);
return;
}
};
self.add_local_terminal_tab(config, window, cx);
}
fn add_terminal_tab_from_profile(
&mut self,
profile_kind: Option<LocalTerminalProfileKind>,
+3
View File
@@ -185,6 +185,9 @@ pub struct HomePage {
team_permissions: TeamPermissionSnapshot,
port_forwarding_runtime: Arc<tokio::sync::Mutex<PortForwardingRuntime>>,
pub(crate) external_driver_registry: IpcDriverRegistry,
/// Windows 已识别的 WSL 发行版缓存(issue #182);None=识别中,空列表=无发行版。
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
pub(crate) wsl_distributions: Option<Arc<Vec<terminal::WslDistribution>>>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
+22
View File
@@ -27,6 +27,28 @@ impl HomePage {
.detach();
}
/// 后台识别已安装的 WSL 发行版,供本地终端下拉菜单展示(feigeCode/navop#182)。
#[cfg(target_os = "windows")]
pub(super) fn load_wsl_distributions(&mut self, cx: &mut Context<Self>) {
let detect_task = cx.background_spawn(async move { terminal::list_wsl_distributions() });
cx.spawn(async move |this, cx: &mut AsyncApp| {
let distributions = match detect_task.await {
Ok(distributions) => distributions,
// 未安装 WSL、未启用 WSL 功能或没有发行版时识别失败属预期,按空列表处理。
Err(error) => {
tracing::info!("WSL 发行版识别失败: {error}");
Vec::new()
}
};
_ = this.update(cx, |this, cx| {
this.wsl_distributions = Some(Arc::new(distributions));
cx.notify();
});
})
.detach();
}
pub(super) fn load_connections(&mut self, cx: &mut Context<Self>) {
if self.saved_connections_locked() {
tracing::warn!("主密钥未解锁,暂缓加载本地连接,避免将加密密码解密为空");
+5
View File
@@ -103,6 +103,7 @@ impl HomePage {
tokio::sync::Mutex::new(PortForwardingRuntime::new()),
),
external_driver_registry: IpcDriverRegistry::empty(),
wsl_distributions: None,
};
// 使用持久化身份预载本地团队权限,不等待在线会话恢复。
@@ -110,6 +111,10 @@ impl HomePage {
// 异步加载工作区
page.load_workspaces(cx);
// Windows 下后台识别 WSL 发行版,供本地终端下拉菜单展示(feigeCode/navop#182)。
#[cfg(target_os = "windows")]
page.load_wsl_distributions(cx);
let has_repo_password = crypto::has_repo_password_set();
let settings = AppSettings::current(cx);
let master_key_policy =
+60 -2
View File
@@ -14,6 +14,9 @@ impl HomePage {
let profile_settings = AppSettings::global(cx).local_terminal_profile.clone();
let view = cx.entity();
let menu_view = view.clone();
// WSL 发行版在启动时后台识别(仅 Windows);克隆快照供菜单闭包使用。
#[cfg(target_os = "windows")]
let wsl_distributions = self.wsl_distributions.clone();
DropdownButton::new("local-terminal-dropdown")
.flex_shrink_0()
.button(
@@ -32,7 +35,7 @@ impl HomePage {
})),
)
.dropdown_menu_with_anchor(Anchor::TopRight, move |menu, _, _| {
launch_options(cfg!(target_os = "windows"), &profile_settings)
let menu = launch_options(cfg!(target_os = "windows"), &profile_settings)
.into_iter()
.fold(menu, |menu, (target, label)| {
let view = menu_view.clone();
@@ -52,8 +55,63 @@ impl HomePage {
});
},
))
})
});
#[cfg(target_os = "windows")]
let menu =
append_wsl_distributions(menu, wsl_distributions.clone(), menu_view.clone());
menu
})
.into_any_element()
}
}
/// 在本地终端菜单末尾追加 WSL 发行版区段(feigeCode/navop#182)。
///
/// 识别完成前与识别无结果时整段隐藏,避免占位闪烁;
/// 区段末尾提供「重新识别」入口,点击后重新后台识别(下次打开菜单生效)。
#[cfg(target_os = "windows")]
fn append_wsl_distributions(
menu: gpui_component::menu::PopupMenu,
distributions: Option<Arc<Vec<terminal::WslDistribution>>>,
home: Entity<HomePage>,
) -> gpui_component::menu::PopupMenu {
let Some(distributions) = distributions else {
return menu;
};
if distributions.is_empty() {
return menu;
}
let menu = menu
.separator()
.item(PopupMenuItem::label(t!("Home.wsl_distributions_section")));
let menu = distributions.iter().fold(menu, |menu, distribution| {
let home = home.clone();
let distro = distribution.name.clone();
let label = wsl_distro_item_label(distribution);
menu.item(PopupMenuItem::new(label).on_click(move |_, window, cx| {
home.update(cx, |home, cx| {
home.add_terminal_tab_with_wsl_distro(distro.clone(), window, cx)
});
}))
});
let home = home.clone();
menu.separator().item(
PopupMenuItem::new(t!("Home.wsl_distributions_refresh")).on_click(move |_, _, cx| {
home.update(cx, |home, cx| home.load_wsl_distributions(cx));
}),
)
}
/// 发行版条目文案:默认发行版追加「默认」标记,便于对齐 MobaXterm 的辨识体验。
#[cfg(target_os = "windows")]
fn wsl_distro_item_label(distribution: &terminal::WslDistribution) -> String {
if distribution.is_default {
format!(
"{} · {}",
distribution.name,
t!("Home.wsl_distributions_default")
)
} else {
distribution.name.clone()
}
}
+30 -2
View File
@@ -35,7 +35,35 @@ fn all_local_terminal_profiles_use_the_same_tab_opener() {
openers
.matches("self.add_local_terminal_tab(config, window, cx)")
.count(),
2,
"both custom and built-in profiles must use the shared activation path",
3,
"custom, built-in and WSL distro profiles must use the shared activation path",
);
}
#[test]
fn wsl_distro_menu_items_reuse_the_local_terminal_activation_path() {
let source = include_str!("../../home/home_tabs.rs");
let opener = source
.split(" pub(crate) fn add_terminal_tab_with_wsl_distro(")
.nth(1)
.and_then(|source| source.split(" fn add_terminal_tab_from_profile(").next())
.expect("WSL distro tab opener");
assert!(opener.contains("local_config_for_wsl_distro(&distro)"));
assert!(opener.contains("self.add_local_terminal_tab(config, window, cx)"));
let menu_source = include_str!("../../home_tab/local_terminal.rs");
// WSL 发行版区段仅在 Windows 上渲染,其余平台保持原菜单。
assert!(menu_source.contains("#[cfg(target_os = \"windows\")]\nfn append_wsl_distributions"));
assert!(menu_source.contains("wsl_distributions_section"));
assert!(menu_source.contains("add_terminal_tab_with_wsl_distro"));
// 区段入口仅在识别到发行版后出现,并提供重新识别操作。
assert!(menu_source.contains("wsl_distributions_refresh"));
assert!(menu_source.contains("load_wsl_distributions"));
let data_source = include_str!("../data.rs");
assert!(data_source.contains(
"#[cfg(target_os = \"windows\")]\n pub(super) fn load_wsl_distributions"
));
assert!(data_source.contains("terminal::list_wsl_distributions()"));
}