fix(i18n): say what this platform does, not what macOS does

Four pieces of user-facing wording described macOS as if it were the only
platform they were read on, in all three languages at once — each
translation had faithfully carried the English text's assumption across.

- Copy on select claimed "no ⌘C needed" everywhere. Off macOS the binding
  is Ctrl+Shift+C, so the sentence named a key that copies nothing.
- The blur switch was labelled "(macOS)" on a row Linux also renders and
  also honors. Windows gets the backdrop picker instead, so the label was
  wrong for every reader it had. It now says which compositors deliver it,
  because gpui's X11 backend does no blur at all and Wayland only does
  when the compositor offers a blur manager.
- X11 forwarding named XQuartz as the only prerequisite anyone could have;
  Windows needs an X server of its own and Linux needs nothing.
- The Explorer verbs were string literals, so a Chinese or Japanese
  install got English context-menu entries for the life of the install.

The Explorer labels are the one string in the product that outlives the
process that wrote it: Explorer reads them from the registry, not from
tty7. Registration now sets the locale before building the entries (that
process returns before the GUI path's set_locale ever runs), and a
language change in Settings restates them. Only keys that already exist
are rewritten — offering the menu is the installer's checkbox and
declining it is the user's, and changing a language must never be what
puts the verbs back.
This commit is contained in:
l0ng-ai
2026-08-20 18:57:50 +08:00
parent 8131ac2f93
commit e9564c5f7b
7 changed files with 225 additions and 13 deletions
+110 -4
View File
@@ -17,6 +17,8 @@ use std::path::{Path, PathBuf};
use anyhow::{Context as _, Result};
use crate::ui::i18n::{L10nKey, t};
const DIRECTORY_KEY: &str = r"Software\Classes\Directory\shell\tty7";
const BACKGROUND_KEY: &str = r"Software\Classes\Directory\Background\shell\tty7";
@@ -34,11 +36,19 @@ impl Location {
}
}
/// The wording Explorer shows, in the language the app is set to.
///
/// Explorer reads this string from the registry, not from tty7, so what is
/// written here is what a user sees until something writes it again — an
/// install-time snapshot of the locale. Two things keep that snapshot
/// honest: `register` sets the locale from the config before building the
/// entries, and [`refresh_labels`] restates them when the language changes
/// in Settings.
fn label(self) -> &'static str {
match self {
Self::Directory => "Open in tty7",
Self::Background => "Open tty7 here",
}
t(match self {
Self::Directory => L10nKey::ExplorerMenuOpenIn,
Self::Background => L10nKey::ExplorerMenuOpenHere,
})
}
fn placeholder(self) -> &'static str {
@@ -90,6 +100,25 @@ pub fn unregister() -> Result<()> {
platform_unregister()
}
/// Restate the verb labels in the language the UI now runs in.
///
/// The registry holds whatever wording was current when the installer ran, so
/// without this a user who switches tty7 to Chinese keeps English entries in
/// Explorer for the life of the install — the one place in the product where
/// the language setting would not reach.
///
/// Only keys that already exist are rewritten. Offering the menu is the
/// installer's checkbox and declining it is the user's decision; changing a
/// language must never be what puts the verbs back.
///
/// Best-effort by design: a failure here costs a log line, never a language
/// change the user asked for.
pub fn refresh_labels() {
if let Err(error) = platform_refresh_labels() {
log::warn!("could not restate the Explorer context-menu labels: {error}");
}
}
/// Build a command line without converting the executable path through UTF-8.
///
/// Quotes are unconditional: both the executable and the Explorer-substituted
@@ -251,6 +280,48 @@ mod windows {
notify_explorer();
Ok(())
}
/// The verb key, or `None` when tty7's menu is not installed.
///
/// Deliberately open rather than create: this is the call that makes
/// [`refresh_labels`] unable to resurrect a menu the user removed.
fn open_existing(path: &str) -> Result<Option<RegistryKey>> {
let path = wide(OsStr::new(path));
let mut key: HKEY = std::ptr::null_mut();
// SAFETY: `path` is a live, NUL-terminated UTF-16 string and `key` is a
// live local the API fills in only on success.
let code = unsafe {
RegOpenKeyExW(
HKEY_CURRENT_USER,
path.as_ptr(),
0,
KEY_READ | KEY_WRITE,
&mut key,
)
};
match code {
ERROR_SUCCESS => Ok(Some(RegistryKey(key))),
ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => Ok(None),
other => Err(io_error("opening the tty7 Explorer registry key", other)),
}
}
pub(super) fn refresh_labels() -> Result<()> {
let mut restated = false;
for location in [Location::Directory, Location::Background] {
let Some(key) = open_existing(location.key())? else {
continue;
};
set_string(&key, None, OsStr::new(location.label()))?;
restated = true;
}
// Only worth waking the shell when something actually moved; a user who
// never installed the menu changes languages for free.
if restated {
notify_explorer();
}
Ok(())
}
}
#[cfg(windows)]
@@ -263,6 +334,11 @@ fn platform_unregister() -> Result<()> {
windows::unregister()
}
#[cfg(windows)]
fn platform_refresh_labels() -> Result<()> {
windows::refresh_labels()
}
#[cfg(not(windows))]
fn platform_register() -> Result<()> {
anyhow::bail!("Windows Explorer integration is only available on Windows")
@@ -273,12 +349,23 @@ fn platform_unregister() -> Result<()> {
anyhow::bail!("Windows Explorer integration is only available on Windows")
}
/// Nothing to restate: the verbs exist only on Windows.
///
/// Silent rather than an error like the two above, because this one is called
/// on every language change on every platform. Refusing here would put a
/// warning in the log of every macOS and Linux user who picks a language.
#[cfg(not(windows))]
fn platform_refresh_labels() -> Result<()> {
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn registration_targets_both_directory_surfaces() {
crate::ui::i18n::set_locale("en");
let app = Path::new(r"C:\Program Files\tty7\tty7-app.exe");
let [directory, background] = registrations(app);
@@ -299,6 +386,25 @@ mod tests {
);
}
/// The entries Explorer shows were English whatever language tty7 ran in,
/// because the labels were string literals. They are the only wording in
/// the product that outlives the process that wrote it, so the guard is on
/// the label rather than on the registry write it feeds.
#[test]
fn the_verb_labels_follow_the_ui_language() {
crate::ui::i18n::set_locale("zh-CN");
assert_eq!(Location::Directory.label(), "在 tty7 中打开");
assert_eq!(Location::Background.label(), "在此处打开 tty7");
crate::ui::i18n::set_locale("ja-JP");
assert_eq!(Location::Directory.label(), "tty7 で開く");
assert_eq!(Location::Background.label(), "ここで tty7 を開く");
crate::ui::i18n::set_locale("en");
assert_eq!(Location::Directory.label(), "Open in tty7");
assert_eq!(Location::Background.label(), "Open tty7 here");
}
#[test]
fn commands_quote_even_paths_without_spaces() {
assert_eq!(
+5
View File
@@ -466,6 +466,11 @@ fn main() {
// the log is the only place the reason can survive.
if let Some(register) = explorer_menu_action_from(&args) {
let result = if register {
// The verb labels are localized, and this process stops at the
// `return` below — it never reaches the `set_locale` on the GUI
// path. Without this read every install would write English
// entries, whatever language the user runs tty7 in.
crate::ui::i18n::set_locale(&Config::load().gui_language);
crate::core::explorer_context_menu::register()
} else {
crate::core::explorer_context_menu::unregister()
+4
View File
@@ -5434,6 +5434,10 @@ impl Tty7App {
set_locale(code);
cx.global::<Config>().save();
set_menus(cx);
// Explorer reads its menu wording from the registry, so it is the one
// surface a language change does not reach on its own. No-op unless
// the user installed the context menu, and off Windows entirely.
crate::core::explorer_context_menu::refresh_labels();
self.refresh_locale_state(window, cx);
crate::ui::windows::WindowRegistry::refresh_locale(cx, Some(self.workspace));
}
+23 -3
View File
@@ -128,7 +128,13 @@ pub fn translate_en(key: L10nKey) -> &'static str {
"How opaque the window background is, for every theme. Below 100% the desktop shows through."
}
L10nKey::SettingsBlur => "Blur",
L10nKey::SettingsBlurDesc => "Blur whatever is behind a translucent window (macOS).",
L10nKey::SettingsBlurDesc => {
if cfg!(target_os = "macos") {
"Blur whatever is behind a translucent window."
} else {
"Blur whatever is behind a translucent window. Needs a compositor that offers it — KDE Plasma does; GNOME and plain X11 leave the window merely transparent."
}
}
L10nKey::SettingsBlurAutoDesc => {
"Blur whatever is behind a translucent window. Only applies while Background material is Auto."
}
@@ -172,6 +178,8 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::ThemeDuplicateFailed => "Could not duplicate the theme",
L10nKey::ThemeSaveFailed => "Could not save the theme",
L10nKey::OpenInFileManagerFailed => "Could not open {path}",
L10nKey::ExplorerMenuOpenIn => "Open in tty7",
L10nKey::ExplorerMenuOpenHere => "Open tty7 here",
L10nKey::SettingsCustomThemesIntro => {
"Duplicate a theme to edit its colors, or drop a tty7 YAML theme or iTerm2 .itermcolors file in the themes folder."
}
@@ -344,7 +352,15 @@ pub fn translate_en(key: L10nKey) -> &'static str {
L10nKey::SettingsConnectTimeout => "Connect timeout (s)",
L10nKey::SettingsConnectTimeoutDesc => "Blank = library default.",
L10nKey::SettingsX11Forwarding => "X11 forwarding",
L10nKey::SettingsX11ForwardingDesc => "Request X11 forwarding (needs XQuartz on macOS).",
L10nKey::SettingsX11ForwardingDesc => {
if cfg!(target_os = "macos") {
"Request X11 forwarding (needs XQuartz)."
} else if cfg!(target_os = "windows") {
"Request X11 forwarding (needs an X server running, such as VcXsrv or X410)."
} else {
"Request X11 forwarding."
}
}
L10nKey::SettingsShellIntegration => "Shell integration",
L10nKey::SettingsShellIntegrationDesc => {
"Let the remote shell report prompts, exit codes, and the working directory."
@@ -477,7 +493,11 @@ pub fn translate_en(key: L10nKey) -> &'static str {
}
L10nKey::SettingsCopyOnSelect => "Copy on select",
L10nKey::SettingsCopyOnSelectDesc => {
"Selecting text with the mouse copies it to the clipboard right away, no ⌘C needed."
if cfg!(target_os = "macos") {
"Selecting text with the mouse copies it to the clipboard right away, no ⌘C needed."
} else {
"Selecting text with the mouse copies it to the clipboard right away, no Ctrl+Shift+C needed."
}
}
L10nKey::SettingsTrimTrailingSpaces => "Trim trailing spaces on copy",
L10nKey::SettingsTrimTrailingSpacesDesc => {
+23 -3
View File
@@ -135,7 +135,13 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
"すべてのテーマにおけるウィンドウ背景の不透明度。100% 未満ではデスクトップが透けて見えます"
}
L10nKey::SettingsBlur => "背景のぼかし",
L10nKey::SettingsBlurDesc => "半透明ウィンドウの背後にあるものをぼかす(macOS)",
L10nKey::SettingsBlurDesc => {
if cfg!(target_os = "macos") {
"半透明ウィンドウの背後にあるものをぼかす"
} else {
"半透明ウィンドウの背後にあるものをぼかす。対応するコンポジターが必要です(KDE Plasma は対応、GNOME と素の X11 ではウィンドウが透けるだけです)"
}
}
L10nKey::SettingsBlurAutoDesc => {
"半透明ウィンドウの背後にあるものをぼかす。背景マテリアルが「自動」のときのみ有効です"
}
@@ -175,6 +181,8 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::ThemeDuplicateFailed => "テーマを複製できませんでした",
L10nKey::ThemeSaveFailed => "テーマを保存できませんでした",
L10nKey::OpenInFileManagerFailed => "{path} を開けませんでした",
L10nKey::ExplorerMenuOpenIn => "tty7 で開く",
L10nKey::ExplorerMenuOpenHere => "ここで tty7 を開く",
L10nKey::SettingsCustomThemesIntro => {
"テーマを複製して色を編集するか、tty7 の YAML テーマや iTerm2 の .itermcolors をテーマフォルダに置いてください"
}
@@ -349,7 +357,15 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsConnectTimeout => "接続タイムアウト(秒)",
L10nKey::SettingsConnectTimeoutDesc => "空欄 = ライブラリのデフォルト",
L10nKey::SettingsX11Forwarding => "X11 転送",
L10nKey::SettingsX11ForwardingDesc => "X11 転送を要求(macOS では XQuartz が必要)",
L10nKey::SettingsX11ForwardingDesc => {
if cfg!(target_os = "macos") {
"X11 転送を要求(XQuartz が必要)"
} else if cfg!(target_os = "windows") {
"X11 転送を要求(VcXsrv や X410 などの X サーバーの起動が必要)"
} else {
"X11 転送を要求"
}
}
L10nKey::SettingsShellIntegration => "シェル統合",
L10nKey::SettingsShellIntegrationDesc => {
"リモートシェルにプロンプト・終了コード・作業ディレクトリを報告させる"
@@ -488,7 +504,11 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> {
}
L10nKey::SettingsCopyOnSelect => "選択時に自動コピー",
L10nKey::SettingsCopyOnSelectDesc => {
"マウスでテキストを選択するとすぐにクリップボードへコピーされます。⌘C は不要です"
if cfg!(target_os = "macos") {
"マウスでテキストを選択するとすぐにクリップボードへコピーされます。⌘C は不要です"
} else {
"マウスでテキストを選択するとすぐにクリップボードへコピーされます。Ctrl+Shift+C は不要です"
}
}
L10nKey::SettingsTrimTrailingSpaces => "コピー時に末尾の空白を除去",
L10nKey::SettingsTrimTrailingSpacesDesc => "コピーした各行の末尾の空白を除去する",
+35
View File
@@ -207,6 +207,8 @@ l10n_keys! {
ThemeDuplicateFailed,
ThemeSaveFailed,
OpenInFileManagerFailed,
ExplorerMenuOpenIn,
ExplorerMenuOpenHere,
SettingsCustomThemesIntro,
SettingsDuplicateToEdit,
SettingsHosts,
@@ -1581,6 +1583,39 @@ mod tests {
}
}
/// Three settings rows talked about macOS as if it were the only platform
/// they were ever shown on: "(macOS)" on a blur switch Linux honors too,
/// "⌘C" on a shortcut that is Ctrl+Shift+C everywhere else, and XQuartz as
/// the only X server anyone could need. All three were wrong in all three
/// languages at once — each translation had faithfully carried the English
/// text's assumption across — which is why this walks every locale rather
/// than trusting en to stand for them.
#[test]
fn wording_that_names_a_platform_names_this_one() {
for lang in SUPPORTED_LANGUAGES {
set_locale(lang.code);
let code = lang.code;
let copy = t(L10nKey::SettingsCopyOnSelectDesc);
let x11 = t(L10nKey::SettingsX11ForwardingDesc);
// This row is shown on macOS and Linux alike (Windows gets the
// backdrop picker instead), and both honor the switch, so naming
// either one of them is wrong wherever it is read.
let blur = t(L10nKey::SettingsBlurDesc);
assert!(!blur.contains("macOS"), "{code} blur desc: {blur:?}");
if cfg!(target_os = "macos") {
assert!(copy.contains('⌘'), "{code} copy-on-select: {copy:?}");
assert!(x11.contains("XQuartz"), "{code} x11: {x11:?}");
} else {
assert!(!copy.contains('⌘'), "{code} copy-on-select: {copy:?}");
assert!(!x11.contains("XQuartz"), "{code} x11: {x11:?}");
}
}
set_locale(default_language_code());
}
#[test]
fn explicit_languages_select_the_right_locale() {
set_locale("zh-CN");
+25 -3
View File
@@ -117,7 +117,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
"窗口背景的不透明度,适用于所有主题。低于 100% 时可以看到桌面。"
}
L10nKey::SettingsBlur => "模糊",
L10nKey::SettingsBlurDesc => "模糊半透明窗口背后的内容(macOS)。",
L10nKey::SettingsBlurDesc => {
if cfg!(target_os = "macos") {
"模糊半透明窗口背后的内容。"
} else {
"模糊半透明窗口背后的内容。需要合成器支持——KDE Plasma 可以;GNOME 和裸 X11 下窗口只会变透明。"
}
}
L10nKey::SettingsBlurAutoDesc => {
"模糊半透明窗口背后的内容。仅在「背景材质」为「自动」时生效。"
}
@@ -155,6 +161,8 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::ThemeDuplicateFailed => "无法复制主题",
L10nKey::ThemeSaveFailed => "无法保存主题",
L10nKey::OpenInFileManagerFailed => "无法打开 {path}",
L10nKey::ExplorerMenuOpenIn => "在 tty7 中打开",
L10nKey::ExplorerMenuOpenHere => "在此处打开 tty7",
L10nKey::SettingsCustomThemesIntro => {
"复制一个主题即可在此编辑颜色,或把 tty7 YAML 主题、iTerm2 .itermcolors 文件放进主题文件夹。"
}
@@ -307,7 +315,15 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
L10nKey::SettingsConnectTimeout => "连接超时(秒)",
L10nKey::SettingsConnectTimeoutDesc => "留空 = 库默认值。",
L10nKey::SettingsX11Forwarding => "X11 转发",
L10nKey::SettingsX11ForwardingDesc => "请求 X11 转发(macOS 上需要 XQuartz)。",
L10nKey::SettingsX11ForwardingDesc => {
if cfg!(target_os = "macos") {
"请求 X11 转发(需要 XQuartz)。"
} else if cfg!(target_os = "windows") {
"请求 X11 转发(需要运行 X 服务端,如 VcXsrv 或 X410)。"
} else {
"请求 X11 转发。"
}
}
L10nKey::SettingsShellIntegration => "Shell 集成",
L10nKey::SettingsShellIntegrationDesc => "让远程 shell 报告提示符、退出码和工作目录。",
L10nKey::SettingsLoginScripts => "登录脚本",
@@ -420,7 +436,13 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> {
"双击选择光标下的完整 URL、文件路径、邮箱或成对的括号。"
}
L10nKey::SettingsCopyOnSelect => "选中即复制",
L10nKey::SettingsCopyOnSelectDesc => "用鼠标选中文本时立即复制到剪贴板,无需按 ⌘C。",
L10nKey::SettingsCopyOnSelectDesc => {
if cfg!(target_os = "macos") {
"用鼠标选中文本时立即复制到剪贴板,无需按 ⌘C。"
} else {
"用鼠标选中文本时立即复制到剪贴板,无需按 Ctrl+Shift+C。"
}
}
L10nKey::SettingsTrimTrailingSpaces => "复制时去除末尾空格",
L10nKey::SettingsTrimTrailingSpacesDesc => "去除每行复制文本末尾的空白。",
L10nKey::SettingsKeyboard => "键盘",