diff --git a/AGENTS.md b/AGENTS.md index 160ffb8..d257445 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## 项目是什么 -ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。 +ShellRS(crate 与二进制都叫 `shellrs`)是一个类似 Xshell / WinSCP 的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案有简体中文和英文两种(中文是源语言,文案在 `locales/`,经 `crate::i18n::t!`),标识符用英文。 **保存的一项叫「主机」,代码里叫 `Host`。** 左侧列表里保存的一项(地址、端口、用户、认证方式)在界面上叫「主机」,量词用「台」,代码和数据库里一律叫 host(`Host`、`HostId`、`HostStore`、`host/` 模块、`hosts` 表)。它以前叫「会话」/ `Session`,界面、代码和表名都已改掉,新写的文案和标识符不要再用「会话」/ session 指它。填 IP / 域名的那个字段叫「地址」(`Host.address`、`hosts.address`),不叫「主机」(「主机密钥」「首次连接此主机」这些照旧)。SSH 协议里的 session 不在此列:「SSH 会话通道」、russh 的 `client::Session` / `server::Session`、`channel_open_session`、SFTP 会话照旧。 diff --git a/CLAUDE.md b/CLAUDE.md index dff9d38..a6a08ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,9 +4,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## 项目是什么 -ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案用中文,标识符用英文。功能说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。 +ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH 主机管理工具,基于 `gpui-kit` 0.6.1(GPUI + gpui-base + gpui-component)。界面文案有简体中文和英文两种(中文是源语言,见「多语言」),标识符用英文。功能说明见 `docs/manual.md`(`README.md` / `README.en.md` 是开源首页,截图在 `docs/screenshots/`)。 -- **术语。** 保存的一项叫「主机」(量词「台」),代码和库里叫 host(`Host`、`HostId`、`HostStore`、`hosts` 表)。它以前叫「会话」/ `Session`,已全部改掉,新代码和文案别再用。填 IP / 域名的字段叫「地址」(`Host.address`)。SSH 协议层的 session(「SSH 会话通道」、russh 的 `Session`、SFTP 会话)照旧。 +- **术语。** 保存的一项叫「主机」(量词「台」),代码和库里叫 host(`Host`、`HostId`、`HostStore`、`hosts` 表)。它以前叫「会话」/ `Session`,已全部改掉,新代码和文案别再用。填 IP / 域名的字段叫「地址」(`Host.address`)。SSH 协议层的 session(「SSH 会话通道」、russh 的 `Session`、SFTP 会话)照旧。英文用词见「多语言」的用词表。 - **两种不保存的连接。** 「外部连接」:堡垒机像调 Xshell(`ssh://`、`-url`)或 WinSCP(`sftp://`)一样拉起 ShellRS,只开一个终端或 SFTP 标签(`HostStore::is_external`)。「临时连接」:标题栏「临时连接…」打开,功能齐全。两者共同的「不保存」在代码里叫 temporary(`HostInfo::temporary`)。 - **全是真的。** SQLite(`~/Library/Application Support/shellrs/shellrs.db`,`SHELLRS_DATA_DIR` 可覆盖目录)、本地终端(`portable-pty` + `alacritty_terminal`)、SSH(`russh`)、SFTP(双栏、传输队列、断点续传)、端口转发(`-L` / `-R` / `-D`)、凭据、连接方式(直连、多级跳板、HTTP / SOCKS5 代理)、右侧栏七个工具、内置编辑器和预览、外部 CLI、在线升级、匿名使用统计(Aptabase)。密码和口令只进系统钥匙串(`keyring`),**数据库里永远不出现秘密**。 @@ -16,6 +16,7 @@ ShellRS(crate 与二进制都叫 `shellrs`)是 Xshell / WinSCP 式的 SSH 用户定过的事,不要改回去。细节以 `docs/manual.md` 和代码为准。 +- **界面语言**:「设置 › 外观 › 界面语言」有跟随系统(默认:中文系统用简体中文,其余用英文,系统没说时用中文)、简体中文、English。改了立刻生效,不用重启。老用户的 settings.json 里已存着 `zh-CN`,不迁移。框架用 gpui-kit 自带的 `rust-i18n`,不另引 Fluent 之类。 - **SFTP**:Dock 标签,每次「打开 SFTP」新开一个、各自连接。操作对标 WinSCP Commander:两行工具栏、路径标签、WinSCP 的列、高亮多选、「名称」格才是项目、框选。本地侧与远程侧对称,本地删除进废纸篓;书签按主机、按侧持久化。传输进行中还能再发起,新批次在标签底部的传输队列里排队,不做前台进度对话框。大小列默认整 KB,右键列标题可切换。「显示隐藏文件」(`.` 开头的)本地、远程各一个开关、互不影响,每侧对所有标签生效,存进设置,默认不显示;工具栏用睁眼 / 闭眼两个图标表示状态,不用选中效果;显示时隐藏文件的文字浅一些。SFTP 标签暂不做目录同步(外部 CLI 有 `shellrs sync`)、过滤、查找、目录树和 Dock 布局持久化。 - **主机树**:搜索匹配主机(名称、地址、用户)和分组名;分组名匹配时列出整个分组,回车只连本身匹配的第一台主机(按树的顺序)。首次启动是空库;分组可任意嵌套,主机可以不属于分组;删除分组连同子分组和主机,确认框写明数量。 - **主机对话框**: @@ -168,6 +169,45 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - **一个数据目录只跑一个 ShellRS。** 起图形界面之前先连外部 CLI 的套接字:连得上就发 `Request::Activate`(带启动链接)然后退出。为兼容已装的 CLI,不升 `PROTOCOL_VERSION`,新请求只加变体。 - **临时主机在 `HostStore` 的 `temporary` 里**:`host()` / `login()` 找得到,`hosts()` 看不到,也不写库。会写外键的检查用 `saved_host`。最后一个标签关掉就 `remove_temporary`。 +### 多语言 + +- **文案都在 `locales/<模块>.yml`**(rust-i18n 的 `_version: 2`):最外层直接写键的全名(`host.dialog.title_new:`),下面 `zh-CN`、`en` 并排。代码用 `crate::i18n::t!`(返回 `SharedString`)和 `tn!`(带数量,填 `%{count}`:英文数量为 1 取 `.one`,否则 `.other`;中文只写 `.other`)。不用 rust-i18n / gpui-kit 自己的 `t!`、`set_locale`。 +- **键说用途**(`host.dialog.title_new`),不拿中文句子当键。一句话一个键,不拼片段:`format!("无法{}{}", 动词, 对象)` 改成每种情况一个整句。插值写 `%{name}`;列表用 `i18n::join_list`(「A、B 和 C」/ "A, B and C")。以 `%`、`{` 开头的值、含 `: ` 的值和 `true`、纯数字这类值要加引号,否则 YAML 不当它是字符串,那条文案就悄悄没了。 +- **中文是源语言**:改中文时同一个键的英文一起改。转换旧代码时中文输出要和原来逐字相同。 +- **实时切换**:语言变了,`settings::apply` 调 `i18n::set_locale`、设全局量 `UiLocale`、`refresh`。文案在 render 时翻译,实体里不存翻译好的字符串,存键、枚举或原始数据。gpui-kit 要预先存下的(`InputState` 的占位、表格的列)用 `cx.observe_global_in::` 重设。终端回滚里已写下的提示、已弹出的通知、已存成 `String` 的报错留在旧语言,可以接受。 +- **启动**:`main` 读完设置、建任何东西之前先 `set_locale`;外部 CLI 的客户端按 settings.json 设,与应用一致。 +- **标签和静态表**:返回文案的函数返回 `SharedString`,不是 `&'static str`。`static` 表里存键,显示时 `t!(key)`;键要以完整的字面量出现在源码里,守卫测试靠它找没人用的文案。 +- **英文文风**按 Design Guides:sentence case;省略号用单字符 `…`;按钮写结果(Delete,不写 Confirm deletion);不写 Please、successfully、Are you sure;引号用 “ ” 和 ’。语言名用各自的语言写(「简体中文」「English」),这样的中文字面量所在行加 `// i18n: keep`。 +- **守卫测试**在 `src/i18n.rs`:两种语言都齐、占位一致、英文里没有中文和 `...`、代码里的键都有文案且文案都被用到、`TRANSLATED` 列出的目录没有中文字面量(`SHELLRS_I18N_CHECK=src/host,src/forward` 可临时追加目录)。 +- **测试**:单元测试默认就是中文。UI 夹具经 `support::init_app` 和 `settings_store()`:本线程的语言与别的线程隔开(`i18n::isolate_thread`),并固定中文;切到英文的测试不影响并行的其他测试,也碰不到 gpui-kit 的全局语言。 + +用词表(新文案照此,改要整体改): + +| 中文 | English | +|---|---| +| 主机(台) | host(1 host / 3 hosts) | +| 分组 / 地址 / 用户名 | group / address / username | +| 凭据 | credential | +| 密码 / 密钥 / 私钥 / 公钥 / 口令 | password / key / private key / public key / passphrase | +| 认证:密码 / 使用凭据 / 无密码 | Password / Credential / No password | +| 连接方式:直接连接 / SSH 跳板 / 代理连接 | Direct / Jump hosts / Proxy | +| 跳板主机 | jump host | +| 测试连接 | Test connection | +| 端口转发:本地 / 远程 / 动态;经由主机 | port forwarding: Local / Remote / Dynamic; via host | +| 临时连接… | Quick connect… | +| 开始页「快速连接」 | Find a host | +| 外部连接 | external connection | +| 最近连接 | Recent | +| 本地终端 / 远程终端 | local terminal / remote terminal | +| 传输队列 / 书签 | Transfers / Bookmarks | +| 显示隐藏文件 | Show hidden files | +| 废纸篓 | macOS 写 Trash,Windows 写 Recycle Bin | +| 右侧栏七个工具 | Snippets / History / Docker / Services / Processes / Network / Monitor | +| 设置七类(外观、终端、关键字高亮、键盘快捷键、外部 CLI、应用、关于) | Appearance / Terminal / Highlighting / Keyboard shortcuts / External CLI / General / About | +| 应用更新 / 稳定版 / 重启并安装 / 查看更新内容 | Updates / Stable / Restart to update / What’s new | +| 连接 / 断开连接 / 重新连接 | Connect / Disconnect / Reconnect | +| 已连接 / 未连接 / 连接中 | Connected / Not connected / Connecting | + ### GPUI / gpui-kit - **一个命令一个处理器。** 按钮、菜单、快捷键都只派发 `app/actions.rs` 里的动作,由 `Workspace` 处理,不写临时闭包直接改状态。例外是对话框内部自己的状态(预览的缩放):工作区够不着对话框里的视图,由视图自己处理。 diff --git a/Cargo.lock b/Cargo.lock index a6efb5d..fdd2d22 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7767,6 +7767,7 @@ dependencies = [ "rusqlite", "russh", "russh-sftp", + "rust-i18n", "semver", "serde", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 0ef1098..c5c8c49 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -59,6 +59,9 @@ rand = "0.9" # search, whose DFA cannot build `\b` and loses `^` after the first line. regex = "1" russh = "0.63" +# 多语言: the same crate gpui-kit translates its own text with, so one +# locale switches both. +rust-i18n = "4.2" russh-sftp = "=3.0.0" serde_json = "1" sys-locale = "0.3" diff --git a/build.rs b/build.rs index e12f4e5..e730995 100644 --- a/build.rs +++ b/build.rs @@ -4,6 +4,9 @@ fn main() { println!("cargo:rerun-if-changed=build.rs"); + // The text `rust_i18n::i18n!` reads while compiling, which the macro + // does not tell Cargo about. + println!("cargo:rerun-if-changed=locales"); println!("cargo:rerun-if-changed=assets/logo/shellrs.ico"); #[cfg(windows)] if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") { diff --git a/locales/app.yml b/locales/app.yml new file mode 100644 index 0000000..cf014b1 --- /dev/null +++ b/locales/app.yml @@ -0,0 +1,5 @@ +_version: 2 + +app.database_unavailable: + zh-CN: 无法打开本地数据库,本次运行的改动不会被保存:%{error} + en: "Couldn’t open the local database, so changes last only until ShellRS quits: %{error}" diff --git a/locales/common.yml b/locales/common.yml new file mode 100644 index 0000000..091aea0 --- /dev/null +++ b/locales/common.yml @@ -0,0 +1,28 @@ +_version: 2 + +# 多语言: one key per text, its Chinese and English side by side. Keys +# name what the text is for; see `src/i18n.rs`. + +# `join_list`: 「A、B 和 C」, "A, B and C". +common.list.separator: + zh-CN: 、 + en: ", " +common.list.and: + zh-CN: 和 + en: " and " + +common.cancel: + zh-CN: 取消 + en: Cancel +common.close: + zh-CN: 关闭 + en: Close +common.save: + zh-CN: 保存 + en: Save +common.delete: + zh-CN: 删除 + en: Delete +common.delete_title: + zh-CN: 删除“%{name}”? + en: Delete “%{name}”? diff --git a/locales/settings.yml b/locales/settings.yml new file mode 100644 index 0000000..6b47923 --- /dev/null +++ b/locales/settings.yml @@ -0,0 +1,24 @@ +_version: 2 + +settings.choice.system: + zh-CN: 跟随系统 + en: System +settings.appearance.dark: + zh-CN: 深色 + en: Dark +settings.appearance.light: + zh-CN: 浅色 + en: Light +settings.channel.stable: + zh-CN: 稳定版 + en: Stable + +settings.store.unreadable: + zh-CN: 无法读取设置,本次使用默认设置:%{error} + en: "Couldn’t read the settings, so this session uses the defaults: %{error}" +settings.store.unsaved: + zh-CN: 无法保存设置,本次运行的改动不会被保存:%{error} + en: "Settings can’t be saved, so changes last only until ShellRS quits: %{error}" +settings.store.save_failed: + zh-CN: 无法保存设置:%{error} + en: "Couldn’t save the settings: %{error}" diff --git a/locales/shared.yml b/locales/shared.yml new file mode 100644 index 0000000..ef38c6d --- /dev/null +++ b/locales/shared.yml @@ -0,0 +1,36 @@ +_version: 2 + +shared.tab.unsaved: + zh-CN: 未保存 + en: Unsaved changes +shared.host_mark.unknown_os: + zh-CN: 未探测到系统 + en: System not detected +shared.duration.days.one: + en: "%{count} day %{clock}" +shared.duration.days.other: + zh-CN: "%{count} 天 %{clock}" + en: "%{count} days %{clock}" + +shared.rename_tab.title: + zh-CN: 重命名标签 + en: Rename tab +shared.rename_tab.name: + zh-CN: 标签名称 + en: Tab name +shared.rename_tab.default: + zh-CN: 留空则使用「%{title}」。 + en: Leave empty to use “%{title}”. + +shared.tab_menu.close_left: + zh-CN: 关闭左侧 + en: Close to the left +shared.tab_menu.close_right: + zh-CN: 关闭右侧 + en: Close to the right +shared.tab_menu.close_others: + zh-CN: 关闭其他 + en: Close others +shared.tab_menu.close_all: + zh-CN: 关闭全部 + en: Close all diff --git a/src/app/mod.rs b/src/app/mod.rs index a41ae16..f89eee5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -25,6 +25,8 @@ pub use window_hiding::{bring_forward, hide_when_closed}; use gpui_kit::component::Theme; use gpui_kit::*; +use crate::i18n::t; + /// Key context of the host panel, for bindings that only apply there. pub const HOST_PANEL_CONTEXT: &str = "HostPanel"; pub const RECENT_HOSTS_CONTEXT: &str = "RecentHosts"; @@ -40,13 +42,21 @@ pub const IMAGE_PREVIEW_CONTEXT: &str = "ImagePreview"; pub const LOCAL_FILE_LIST_CONTEXT: &str = "LocalFileList"; pub const REMOTE_FILE_LIST_CONTEXT: &str = "RemoteFileList"; +/// What the window says when the local database cannot be opened: the run +/// goes on, keeping its changes in memory only. +pub fn database_unavailable(error: &anyhow::Error) -> SharedString { + t!("app.database_unavailable", error = error) +} + /// Initialize GPUI Kit and everything global to the application. pub fn init(cx: &mut App) { gpui_kit::init(cx); // Before ours: rebuilding the keymap starts from gpui-kit's own. shortcuts::keep_kit_bindings(cx); deepen_list_hover(cx); - gpui_kit::component::set_locale("zh-CN"); + // gpui-kit's own text in the language ShellRS's is in: the saved one, + // which `main` set before anything was built. + crate::i18n::set_locale(crate::i18n::locale()); // The defaults; the settings bring the user's changes once read. apply_shortcuts(&ShortcutOverrides::default(), cx); cx.on_action(|_: &Quit, cx: &mut App| quit::quit(cx)); diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 5f339d9..8f06eb7 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -277,6 +277,11 @@ enum CredentialsCommand { /// The command line entry: `args` without the program name. Returns the /// process exit code. pub fn main(args: Vec) -> i32 { + // In the language the app's window is in, as what the app sends back. + if let Ok(path) = crate::app::settings_path() { + let (settings, _) = crate::settings::SettingsStore::load(path); + crate::i18n::set_locale(settings.settings().language.resolved()); + } let cli = match Cli::try_parse_from(std::iter::once(OsString::from("shellrs")).chain(args)) { Ok(cli) => cli, Err(error) => { diff --git a/src/i18n.rs b/src/i18n.rs new file mode 100644 index 0000000..15b80ef --- /dev/null +++ b/src/i18n.rs @@ -0,0 +1,619 @@ +//! 多语言: ShellRS's own text, in Simplified Chinese and English. +//! +//! The text lives in `locales/*.yml`, one file per module, each key with +//! its `zh-CN` and `en` side by side. Code asks for a key with `t!`, or +//! `tn!` where a count picks the English singular. A key names what the +//! text is for (`host.dialog.title_new`), never the Chinese sentence, and a +//! sentence is always one key: each language has its own word order. +//! +//! The interface language changes while ShellRS runs (界面语言), so text is +//! translated where it is shown and a redraw brings the new language. +//! Entities that hand text to gpui-kit to keep (an input's placeholder, a +//! table's columns) give it again when `UiLocale` changes. + +use std::borrow::Cow; +use std::cell::Cell; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use gpui_kit::{Global, SharedString}; + +/// Every language there is text in. The first is the one the text is +/// written in, and what everything starts in until the settings are read: +/// unit tests see the text as written. +pub const LOCALES: [&str; 2] = ["zh-CN", "en"]; + +static LOCALE: AtomicUsize = AtomicUsize::new(0); + +thread_local! { + /// A thread's own language, once `isolate_thread` gave it one. + static THREAD_LOCALE: Cell> = const { Cell::new(None) }; +} + +/// The language ShellRS's text is in now, one of `LOCALES`. +pub fn locale() -> &'static str { + let index = THREAD_LOCALE + .with(Cell::get) + .unwrap_or_else(|| LOCALE.load(Ordering::Relaxed)); + LOCALES[index] +} + +/// Put ShellRS's text, and gpui-kit's own, in `locale`. One that is not in +/// `LOCALES` gives the language the text is written in. +pub fn set_locale(locale: &str) { + let index = LOCALES + .iter() + .position(|known| *known == locale) + .unwrap_or(0); + if THREAD_LOCALE.with(|own| own.get().is_some()) { + THREAD_LOCALE.with(|own| own.set(Some(index))); + return; + } + LOCALE.store(index, Ordering::Relaxed); + gpui_kit::component::set_locale(LOCALES[index]); +} + +/// From now on this thread has a language of its own, which `set_locale` +/// changes without touching any other thread or gpui-kit's text. For UI +/// tests: they run side by side in one process, and one that switches to +/// English must not switch the others. +#[doc(hidden)] +pub fn isolate_thread() { + let current = LOCALES.iter().position(|known| *known == locale()); + THREAD_LOCALE.with(|own| own.set(current)); +} + +/// The interface language the window was last brought in line with. +/// Entities that keep translated text observe it and give the text again. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct UiLocale(pub &'static str); + +impl Global for UiLocale {} + +/// The text of `key` in the interface language, with each `%{name}` in it +/// replaced, as a `SharedString`: `t!("host.panel.search")`, +/// `t!("host.delete.title", name = host.name)`. +macro_rules! t { + ($key:expr $(,)?) => { + crate::i18n::shared(crate::_rust_i18n_t!($key, locale = crate::i18n::locale())) + }; + ($key:expr, $($args:tt)+) => { + crate::i18n::shared(crate::_rust_i18n_t!( + $key, + locale = crate::i18n::locale(), + $($args)+ + )) + }; +} +pub(crate) use t; + +/// `t!` for text with a count in it, which also fills `%{count}`: the key's +/// `.one` text when the count is 1 and the language has one (English), its +/// `.other` text otherwise. Chinese has only `.other`. +macro_rules! tn { + ($key:literal, $count:expr $(, $($args:tt)+)?) => {{ + let count = $count; + crate::i18n::shared(crate::_rust_i18n_t!( + { + if count == 1 && crate::i18n::has(concat!($key, ".one")) { + concat!($key, ".one") + } else { + concat!($key, ".other") + } + }, + locale = crate::i18n::locale(), + count = count + $(, $($args)+)? + )) + }}; +} +pub(crate) use tn; + +/// Text as gpui wants it, without copying what is already `'static`. +#[doc(hidden)] +pub fn shared(text: Cow<'static, str>) -> SharedString { + match text { + Cow::Borrowed(text) => SharedString::new_static(text), + Cow::Owned(text) => text.into(), + } +} + +/// Whether the interface language has text for `key` of its own. +pub fn has(key: &str) -> bool { + crate::_rust_i18n_backend() + .translate(locale(), key) + .is_some() +} + +/// `items` as one phrase: 「A、B 和 C」, "A, B and C". +pub fn join_list>(items: &[S]) -> String { + let Some((last, rest)) = items.split_last() else { + return String::new(); + }; + let mut joined = String::new(); + for (index, item) in rest.iter().enumerate() { + if index > 0 { + joined = spaced(&joined, &t!("common.list.separator")); + } + joined = spaced(&joined, item.as_ref()); + } + if rest.is_empty() { + return last.as_ref().to_string(); + } + spaced(&spaced(&joined, &t!("common.list.and")), last.as_ref()) +} + +/// `left` and `right` written together, with the space Chinese text puts +/// between a Chinese character and a Latin letter or digit: 「标签和 1 个」. +fn spaced(left: &str, right: &str) -> String { + let han_meets_latin = match (left.chars().next_back(), right.chars().next()) { + (Some(before), Some(after)) => { + (is_han(before) && after.is_ascii_alphanumeric()) + || (before.is_ascii_alphanumeric() && is_han(after)) + } + _ => false, + }; + if han_meets_latin { + format!("{left} {right}") + } else { + format!("{left}{right}") + } +} + +fn is_han(c: char) -> bool { + matches!(c, '\u{3400}'..='\u{4dbf}' | '\u{4e00}'..='\u{9fff}' | '\u{f900}'..='\u{faff}') +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeMap, BTreeSet}; + use std::fs; + use std::path::{Path, PathBuf}; + + use regex::Regex; + + use super::*; + + /// Directories and files whose code is through with Chinese literals: + /// every text in them goes through `t!`. Grows as modules are + /// translated, until it is all of `src`. + const TRANSLATED: &[&str] = &[ + "src/i18n.rs", + "src/settings/model.rs", + "src/settings/store.rs", + "src/shared", + ]; + + /// Marks a line whose Chinese literal is meant to stay, such as a + /// language named in itself. + const KEEP: &str = "i18n: keep"; + + fn messages(locale: &str) -> BTreeMap { + crate::_rust_i18n_backend() + .messages_for_locale(locale) + .unwrap_or_default() + .into_iter() + .map(|(key, text)| (key.into_owned(), text.into_owned())) + .collect() + } + + fn sources(dir: &Path, files: &mut Vec) { + for entry in fs::read_dir(dir).expect("source directory") { + let path = entry.expect("source entry").path(); + if path.is_dir() { + sources(&path, files); + } else if path.extension().is_some_and(|extension| extension == "rs") { + files.push(path); + } + } + } + + fn all_sources() -> Vec<(PathBuf, String)> { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let mut files = Vec::new(); + sources(&root.join("src"), &mut files); + files.sort(); + files + .into_iter() + .map(|path| { + let text = fs::read_to_string(&path).expect("readable source"); + let relative = path + .strip_prefix(root) + .expect("under the crate") + .to_path_buf(); + (relative, text) + }) + .collect() + } + + fn is_cjk(c: char) -> bool { + matches!(c, + '\u{3000}'..='\u{303f}' // CJK punctuation: 、。「」 + | '\u{3400}'..='\u{4dbf}' + | '\u{4e00}'..='\u{9fff}' + | '\u{f900}'..='\u{faff}' + | '\u{ff00}'..='\u{ffef}' // full-width forms: ,:() + ) + } + + fn placeholders(text: &str) -> BTreeSet { + let pattern = Regex::new(r"%\{(\w+)\}").expect("pattern"); + pattern + .captures_iter(text) + .map(|capture| capture[1].to_string()) + .collect() + } + + /// The plural forms of one text share its key: `.one` and `.other`. + fn base_key(key: &str) -> &str { + key.strip_suffix(".one") + .or_else(|| key.strip_suffix(".other")) + .unwrap_or(key) + } + + #[test] + fn every_text_is_in_both_languages() { + let chinese = messages("zh-CN"); + let english = messages("en"); + assert!(!chinese.is_empty() && !english.is_empty()); + let mut problems = Vec::new(); + for key in chinese.keys() { + if key.ends_with(".one") { + problems.push(format!("{key}: Chinese has no singular")); + } else if !english.contains_key(key) { + problems.push(format!("{key}: no English")); + } + } + for key in english.keys() { + match key.strip_suffix(".one") { + Some(base) if !english.contains_key(&format!("{base}.other")) => { + problems.push(format!("{key}: no .other beside it")); + } + Some(_) => {} + None if !chinese.contains_key(key) => { + problems.push(format!("{key}: no Chinese")); + } + None => {} + } + } + assert!(problems.is_empty(), "{problems:#?}"); + } + + #[test] + fn both_languages_fill_the_same_values() { + let chinese = messages("zh-CN"); + let mut problems = Vec::new(); + for (key, text) in messages("en") { + let Some(base) = key.strip_suffix(".one") else { + if let Some(chinese) = chinese.get(&key) + && placeholders(chinese) != placeholders(&text) + { + problems.push(key); + } + continue; + }; + // "1 host" may say the number in words; it fills nothing else. + let other = chinese + .get(&format!("{base}.other")) + .map(|text| placeholders(text)); + if !placeholders(&text).is_subset(&other.unwrap_or_default()) { + problems.push(key); + } + } + assert!(problems.is_empty(), "{problems:#?}"); + } + + #[test] + fn texts_follow_the_interface_language_rules() { + let mut problems = Vec::new(); + for (key, text) in messages("en") { + if text.chars().any(is_cjk) { + problems.push(format!("{key}: Chinese in English: {text}")); + } + } + for locale in LOCALES { + for (key, text) in messages(locale) { + if text.contains("...") { + problems.push(format!("{key} ({locale}): three dots instead of …")); + } + // The list words carry their own spaces: ", ", " and ". + if text.trim() != text && !key.starts_with("common.list.") { + problems.push(format!("{key} ({locale}): spaces around the text")); + } + } + } + assert!(problems.is_empty(), "{problems:#?}"); + } + + #[test] + fn every_key_in_the_code_has_text_and_every_text_is_used() { + let chinese = messages("zh-CN"); + let call = Regex::new(r#"\b(tn?)!\(\s*"([^"]+)""#).expect("pattern"); + let sources = all_sources(); + let mut problems = Vec::new(); + for (path, text) in &sources { + // Doc comments show keys that are only examples. + let code = text + .lines() + .filter(|line| !line.trim_start().starts_with("//")) + .collect::>() + .join("\n"); + for capture in call.captures_iter(&code) { + let key = match &capture[1] { + "tn" => format!("{}.other", &capture[2]), + _ => capture[2].to_string(), + }; + if !chinese.contains_key(&key) { + problems.push(format!("{}: {key} has no text", path.display())); + } + } + } + let code = sources + .iter() + .map(|(_, text)| text.as_str()) + .collect::>() + .join("\n"); + for key in chinese.keys() { + let base = base_key(key); + if !code.contains(&format!("\"{base}\"")) { + problems.push(format!("{key} is used nowhere")); + } + } + assert!(problems.is_empty(), "{problems:#?}"); + } + + #[test] + fn translated_code_has_no_chinese_literals() { + // More to check while a module is being translated, comma-separated: + // `SHELLRS_I18N_CHECK=src/host,src/forward`. + let extra = std::env::var("SHELLRS_I18N_CHECK").unwrap_or_default(); + let prefixes = TRANSLATED + .iter() + .copied() + .chain( + extra + .split(',') + .map(str::trim) + .filter(|prefix| !prefix.is_empty()), + ) + .collect::>(); + let mut problems = Vec::new(); + for (path, text) in all_sources() { + let name = path.to_string_lossy().replace('\\', "/"); + let translated = prefixes + .iter() + .any(|prefix| name == *prefix || name.starts_with(&format!("{prefix}/"))); + if !translated || name.ends_with("tests.rs") { + continue; + } + let lines = text.lines().collect::>(); + for (line, literal) in literals_outside_tests(&text) { + if literal.chars().any(is_cjk) && !lines[line].contains(KEEP) { + problems.push(format!("{name}:{}: {literal}", line + 1)); + } + } + } + assert!(problems.is_empty(), "{problems:#?}"); + } + + #[test] + fn the_scanner_skips_comments_tests_and_lifetimes() { + let source = r##" + // "注释" + /* "块注释" /* "嵌套" */ */ + fn shown<'a>(x: &'a str) -> char { let _ = "显示"; let _ = r#"原样"#; '、' } + #[cfg(test)] + fn helper() { let _ = "测试"; } + #[cfg(test)] + mod tests { fn t() { let _ = "测试"; } } + const AFTER: &str = "之后"; + "##; + let found = literals_outside_tests(source) + .into_iter() + .map(|(_, literal)| literal) + .collect::>(); + assert_eq!(found, ["显示", "原样", "、", "之后"]); + } + + /// The string and char literals of `source` with their line (from 0), + /// leaving out comments and items under `#[cfg(test)]`. + fn literals_outside_tests(source: &str) -> Vec<(usize, String)> { + let chars = source.chars().collect::>(); + let mut found = Vec::new(); + let mut line = 0; + let mut i = 0; + // Set by `#[cfg(test)]`: the brace depth below which the item ends, + // once its body opened. + let mut skipping = false; + let mut skip_depth = None; + let mut depth = 0usize; + let at = |i: usize, text: &str| chars[i..].starts_with(&text.chars().collect::>()); + while i < chars.len() { + let c = chars[i]; + if c == '\n' { + line += 1; + i += 1; + } else if at(i, "//") { + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + } else if at(i, "/*") { + let mut nesting = 0; + while i < chars.len() { + if at(i, "/*") { + nesting += 1; + i += 2; + } else if at(i, "*/") { + nesting -= 1; + i += 2; + if nesting == 0 { + break; + } + } else { + if chars[i] == '\n' { + line += 1; + } + i += 1; + } + } + } else if at(i, "#[cfg(test)]") || at(i, "#[cfg(any(test") { + skipping = true; + i += 1; + } else if c == '"' + || ((c == 'r' || c == 'b') + && (i == 0 || !(chars[i - 1].is_alphanumeric() || chars[i - 1] == '_')) + && raw_or_byte_start(&chars[i..]).is_some()) + { + let start_line = line; + let (hashes, open) = if c == '"' { + (None, 1) + } else { + raw_or_byte_start(&chars[i..]).expect("checked") + }; + i += open; + let mut literal = String::new(); + while i < chars.len() { + let d = chars[i]; + match hashes { + None if d == '\\' => { + literal.push(d); + if let Some(&next) = chars.get(i + 1) { + literal.push(next); + if next == '\n' { + line += 1; + } + } + i += 2; + continue; + } + None if d == '"' => { + i += 1; + break; + } + Some(count) + if d == '"' + && chars[i + 1..] + .iter() + .take(count) + .filter(|h| **h == '#') + .count() + == count => + { + i += 1 + count; + break; + } + _ => {} + } + if d == '\n' { + line += 1; + } + literal.push(d); + i += 1; + } + if !skipping { + found.push((start_line, literal)); + } + } else if c == '\'' { + // A char literal ('x', '\n'), or a lifetime ('a). + let end = if chars.get(i + 1) == Some(&'\\') { + // The escaped character may itself be a quote: '\''. + chars[i + 3..] + .iter() + .position(|d| *d == '\'') + .map(|n| i + 3 + n) + } else if chars.get(i + 2) == Some(&'\'') { + Some(i + 2) + } else { + None + }; + match end { + Some(end) => { + if !skipping { + found.push((line, chars[i + 1..end].iter().collect())); + } + i = end + 1; + } + None => i += 1, + } + } else { + match c { + '{' => { + depth += 1; + if skipping && skip_depth.is_none() { + skip_depth = Some(depth); + } + } + '}' => { + if skip_depth == Some(depth) { + skipping = false; + skip_depth = None; + } + depth = depth.saturating_sub(1); + } + ';' if skipping && skip_depth.is_none() => skipping = false, + _ => {} + } + i += 1; + } + } + found + } + + /// For a raw or byte string starting here (`r"`, `r#"`, `b"`, `br#"`): + /// how many `#` close it (`None` for a byte string, which has escapes) + /// and how many characters open it. + fn raw_or_byte_start(chars: &[char]) -> Option<(Option, usize)> { + let mut i = 0; + let byte = chars.first() == Some(&'b'); + if byte { + i += 1; + } + if chars.get(i) == Some(&'r') { + i += 1; + let hashes = chars[i..].iter().take_while(|c| **c == '#').count(); + i += hashes; + (chars.get(i) == Some(&'"')).then_some((Some(hashes), i + 1)) + } else if byte && chars.get(i) == Some(&'"') { + Some((None, i + 1)) + } else { + None + } + } + + #[test] + fn a_count_of_one_takes_the_singular_where_there_is_one() { + isolate_thread(); + set_locale("en"); + assert_eq!( + tn!("shared.duration.days", 1, clock = "01:02:03"), + "1 day 01:02:03" + ); + assert_eq!( + tn!("shared.duration.days", 3, clock = "01:02:03"), + "3 days 01:02:03" + ); + set_locale("zh-CN"); + assert_eq!( + tn!("shared.duration.days", 1, clock = "01:02:03"), + "1 天 01:02:03" + ); + assert_eq!(join_list(&["甲", "乙", "丙"]), "甲、乙和丙"); + assert_eq!( + join_list(&["2 个远程终端", "1 个 SFTP 标签", "1 个本地终端"]), + "2 个远程终端、1 个 SFTP 标签和 1 个本地终端" + ); + set_locale("en"); + assert_eq!(join_list(&["a", "b", "c"]), "a, b and c"); + assert_eq!(join_list(&["a"]), "a"); + } + + #[test] + fn an_isolated_thread_keeps_its_language_to_itself() { + let other = std::thread::spawn(|| { + isolate_thread(); + set_locale("en"); + locale() + }); + assert_eq!(other.join().expect("thread"), "en"); + assert_eq!(locale(), "zh-CN"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 8579793..587e379 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,7 +6,7 @@ //! (凭据), `docker` (Docker), `monitor` (系统监控), `services` (系统服务), `processes` (进程管理), //! `netstat` (网络连接), `history` (历史命令), `snippets` (命令片段), `editor` (内置编辑器), //! `secrets` (系统钥匙串), `settings` (设置), `cli` (外部 CLI), `update` (在线升级), `analytics` -//! (匿名使用统计), `connection` 与 `shared` (跨模块共用), `workspace` (窗口壳), `app` (动作、快捷键、资源). +//! (匿名使用统计), `connection` 与 `shared` (跨模块共用), `i18n` (多语言), `workspace` (窗口壳), `app` (动作、快捷键、资源). pub mod analytics; pub mod app; @@ -19,6 +19,7 @@ pub mod explorer; pub mod forward; pub mod history; pub mod host; +pub mod i18n; pub mod monitor; pub mod netstat; pub mod processes; @@ -36,6 +37,9 @@ pub mod workspace; #[cfg(test)] mod testing; +// The text of every language, from `locales/`: see `i18n`. +rust_i18n::i18n!("locales", fallback = "zh-CN"); + /// Initialize GPUI Kit, locale, key bindings and global actions. Call once /// inside `Application::run` before creating any view. pub fn init(cx: &mut gpui_kit::App) { diff --git a/src/main.rs b/src/main.rs index 7d4e1b0..9afe5ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -6,7 +6,7 @@ use gpui_kit::component::{Root, notification::Notification}; use gpui_kit::*; use shellrs::host::{HostDatabase, HostStore}; -use shellrs::settings::SettingsStore; +use shellrs::settings::{SettingsProblem, SettingsStore}; fn main() { let args = shellrs::cli::command_line_arguments().unwrap_or_default(); @@ -39,12 +39,14 @@ fn main() { } }); app.run(|cx| { + // Read before anything is built: the settings choose the language + // every text is in, and 应用 › 窗口 where the window opens. + let (settings, settings_problem) = open_settings(); + shellrs::i18n::set_locale(settings.settings().language.resolved()); shellrs::init(cx); shellrs::app::show_logo_when_unbundled(cx); cx.activate(true); - // Read before the window opens: 应用 › 窗口 decides where. - let (settings, settings_problem) = open_settings(); let saved = shellrs::workspace::WindowState::load(&shellrs::app::window_state_path()); let options = shellrs::workspace::window_options(saved.as_ref(), settings.settings().window, cx); @@ -59,6 +61,7 @@ fn main() { let workspace = cx.new(|cx| shellrs::workspace::Workspace::new(store, settings, window, cx)); // Not pushed here: the window has no `Root` to show it yet. + let settings_problem = settings_problem.map(|problem| problem.message()); for problem in [problem, settings_problem].into_iter().flatten() { shellrs::workspace::notify_once_open(Notification::error(problem), window, cx); } @@ -96,23 +99,23 @@ fn open_store() -> (HostStore, Option) { None, ), Err(error) => { - eprintln!("shellrs: 无法打开本地数据库:{error}"); + eprintln!("shellrs: cannot open the local database: {error}"); ( HostStore::empty().with_secrets(secrets), - Some(format!("无法打开本地数据库,本次运行的改动不会被保存:{error}").into()), + Some(shellrs::app::database_unavailable(&error)), ) } } } /// Load the settings file. Without a data directory the settings still -/// work, only for this run; the returned message says so. -fn open_settings() -> (SettingsStore, Option) { +/// work, only for this run; the returned problem says so. +fn open_settings() -> (SettingsStore, Option) { match shellrs::app::settings_path() { Ok(path) => SettingsStore::load(path), Err(error) => ( SettingsStore::in_memory(), - Some(format!("无法保存设置,本次运行的改动不会被保存:{error}").into()), + Some(SettingsProblem::Unsaved(error.to_string())), ), } } diff --git a/src/settings/apply.rs b/src/settings/apply.rs index 9976bb4..b7e9e89 100644 --- a/src/settings/apply.rs +++ b/src/settings/apply.rs @@ -2,6 +2,7 @@ use gpui_kit::component::{ActiveTheme as _, Theme, ThemeMode}; use gpui_kit::*; use crate::explorer::{FileSizeFormat, ShowHiddenFiles}; +use crate::i18n::UiLocale; use crate::terminal::{TerminalColors, TerminalFont, TerminalHighlights, is_font_installed}; use super::AppSettings; @@ -37,11 +38,13 @@ pub fn apply(settings: AppSettings, window: &mut Window, cx: &mut App) { window.refresh(); } - let locale = settings - .language - .locale(sys_locale::get_locale().as_deref()); - if *gpui_kit::component::locale() != *locale { - gpui_kit::component::set_locale(locale); + // Text is translated where it is drawn, so a redraw has it in the new + // language; entities that hand text to gpui-kit to keep observe + // `UiLocale` and give it again. + let locale = settings.language.resolved(); + if cx.try_global::() != Some(&UiLocale(locale)) { + crate::i18n::set_locale(locale); + cx.set_global(UiLocale(locale)); window.refresh(); } diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 5b3efe5..0b83cb3 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -16,4 +16,4 @@ pub use model::{ UpdateSettings, WindowSettings, }; pub use settings_panel::{SettingsPanel, SettingsPanelEvent}; -pub use store::{SettingsStore, SettingsStoreEvent}; +pub use store::{SettingsProblem, SettingsStore, SettingsStoreEvent}; diff --git a/src/settings/model.rs b/src/settings/model.rs index d97b0d0..6ba1dfe 100644 --- a/src/settings/model.rs +++ b/src/settings/model.rs @@ -1,9 +1,10 @@ -use gpui_kit::WindowAppearance; use gpui_kit::component::ThemeMode; +use gpui_kit::{SharedString, WindowAppearance}; use serde::{Deserialize, Deserializer, Serialize}; use crate::app::ShortcutOverrides; use crate::explorer::{FileSizeFormat, ShowHiddenFiles}; +use crate::i18n::t; use crate::terminal::{ DEFAULT_FONT_SIZE, DEFAULT_LINE_HEIGHT, FONT_SIZE_RANGE, HighlightRule, LINE_HEIGHT_RANGE, TerminalTheme, default_rules, @@ -236,10 +237,10 @@ impl Choice for Channel { Channel::key(self) } - fn label(self) -> &'static str { + fn label(self) -> SharedString { match self { - Self::Stable => "稳定版", - Self::Beta => "Beta", + Self::Stable => t!("settings.channel.stable"), + Self::Beta => "Beta".into(), } } } @@ -302,22 +303,22 @@ pub trait Choice: Copy + PartialEq + 'static { const ALL: &'static [Self]; /// How the value is written in the settings file. fn key(self) -> &'static str; - fn label(self) -> &'static str; + fn label(self) -> SharedString; fn from_key(key: &str) -> Option { Self::ALL.iter().copied().find(|choice| choice.key() == key) } } -/// 界面语言. Only gpui-kit's own strings follow it for now; ShellRS's copy is -/// still Chinese until it is translated. +/// 界面语言, for ShellRS's text and gpui-kit's own. It changes the window +/// at once. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum InterfaceLanguage { + // A settings file written before there was English says `zh-CN`, as + // the default was then, and stays Chinese. + #[default] #[serde(rename = "system")] System, - // The default until the English copy exists: following an English - // system would only mix a few English words into a Chinese window. - #[default] #[serde(rename = "zh-CN")] SimplifiedChinese, #[serde(rename = "en")] @@ -335,13 +336,13 @@ impl Choice for InterfaceLanguage { } } - fn label(self) -> &'static str { + fn label(self) -> SharedString { match self { - Self::System => "跟随系统", - Self::SimplifiedChinese => "简体中文", + Self::System => t!("settings.choice.system"), // A language is named in itself, so it can be found by someone // who cannot read the rest of the menu. - Self::English => "English", + Self::SimplifiedChinese => "简体中文".into(), // i18n: keep + Self::English => "English".into(), } } } @@ -368,6 +369,11 @@ impl InterfaceLanguage { }, } } + + /// `locale` with the operating system's preferred language. + pub fn resolved(self) -> &'static str { + self.locale(sys_locale::get_locale().as_deref()) + } } /// 应用外观. @@ -393,11 +399,11 @@ impl Choice for Appearance { } } - fn label(self) -> &'static str { + fn label(self) -> SharedString { match self { - Self::Dark => "深色", - Self::Light => "浅色", - Self::System => "跟随系统", + Self::Dark => t!("settings.appearance.dark"), + Self::Light => t!("settings.appearance.light"), + Self::System => t!("settings.choice.system"), } } } diff --git a/src/settings/settings_panel.rs b/src/settings/settings_panel.rs index abd4fe9..bcbdeba 100644 --- a/src/settings/settings_panel.rs +++ b/src/settings/settings_panel.rs @@ -302,8 +302,7 @@ fn appearance_groups(panel: &SettingsPanel, _: &App) -> Vec { |settings| settings.language, |settings, language| settings.language = language, ), - ) - .description("英文界面尚在翻译中,目前只有部分文字会切换。"), + ), SettingItem::new( "应用外观", choice_field( @@ -1156,7 +1155,7 @@ fn choice_field( ) -> SettingField { let options = T::ALL .iter() - .map(|choice| (choice.key().into(), choice.label().into())) + .map(|choice| (choice.key().into(), choice.label())) .collect(); let (reader, writer) = (store.clone(), store.clone()); SettingField::dropdown( diff --git a/src/settings/store.rs b/src/settings/store.rs index a0c84d4..2cf8343 100644 --- a/src/settings/store.rs +++ b/src/settings/store.rs @@ -6,6 +6,7 @@ use std::{ use gpui_kit::*; use super::AppSettings; +use crate::i18n::t; #[derive(Clone, Debug)] pub enum SettingsStoreEvent { @@ -13,6 +14,26 @@ pub enum SettingsStoreEvent { PersistFailed(SharedString), } +/// Why the settings file is not in use. Told once the window is open, by +/// when the settings have chosen the interface language to tell it in. +#[derive(Clone, Debug)] +pub enum SettingsProblem { + /// The file cannot be read: this run has the defaults. + Unreadable(String), + /// There is no data directory to keep the file in: changes last only + /// this run. + Unsaved(String), +} + +impl SettingsProblem { + pub fn message(&self) -> SharedString { + match self { + Self::Unreadable(error) => t!("settings.store.unreadable", error = error), + Self::Unsaved(error) => t!("settings.store.unsaved", error = error), + } + } +} + /// The settings, shared by the settings page and the workspace that applies /// them. Like `HostStore`, memory is the source of truth and every change /// is written through to disk at once; a failed write keeps the change and @@ -36,9 +57,9 @@ impl SettingsStore { } /// Read the settings saved at `path`. A missing file means the defaults. - /// An unreadable one also gives the defaults, together with the message - /// to show; the file stays as it is until the next change replaces it. - pub fn load(path: PathBuf) -> (Self, Option) { + /// An unreadable one also gives the defaults, together with the problem + /// to tell; the file stays as it is until the next change replaces it. + pub fn load(path: PathBuf) -> (Self, Option) { let loaded = match fs::read(&path) { Ok(bytes) => serde_json::from_slice(&bytes).map_err(|error| error.to_string()), Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(AppSettings::default()), @@ -48,7 +69,7 @@ impl SettingsStore { Ok(settings) => (AppSettings::normalized(settings), None), Err(error) => ( AppSettings::default(), - Some(format!("无法读取设置,本次使用默认设置:{error}").into()), + Some(SettingsProblem::Unreadable(error)), ), }; ( @@ -71,9 +92,10 @@ impl SettingsStore { }; cx.notify(); if let Err(error) = written { - cx.emit(SettingsStoreEvent::PersistFailed( - format!("无法保存设置:{error}").into(), - )); + cx.emit(SettingsStoreEvent::PersistFailed(t!( + "settings.store.save_failed", + error = error + ))); } } @@ -114,7 +136,7 @@ mod tests { // Not `super::*`: it brings in `gpui_kit::*`, whose `test` shadows `#[test]`. use std::fs; - use super::SettingsStore; + use super::{SettingsProblem, SettingsStore}; use crate::settings::{AppSettings, Appearance, InterfaceLanguage}; #[test] @@ -160,7 +182,8 @@ mod tests { fs::write(&path, "{ not json").unwrap(); let (store, problem) = SettingsStore::load(path.clone()); assert_eq!(store.settings(), AppSettings::default()); - assert!(problem.unwrap().starts_with("无法读取设置")); + assert!(matches!(problem, Some(SettingsProblem::Unreadable(_)))); + assert!(problem.unwrap().message().starts_with("无法读取设置")); // Left alone until the user changes something. assert_eq!(fs::read_to_string(&path).unwrap(), "{ not json"); } diff --git a/src/shared/closable_tab.rs b/src/shared/closable_tab.rs index 7f9e87e..b99adfb 100644 --- a/src/shared/closable_tab.rs +++ b/src/shared/closable_tab.rs @@ -10,6 +10,7 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::app::ToggleHostPanel; +use crate::i18n::t; type MenuBuilder = Rc) -> PopupMenu>; @@ -92,7 +93,7 @@ impl RenderOnce for ClosableTabTitle { div() .id("modified") .test_support() - .aria_label("未保存") + .aria_label(t!("shared.tab.unsaved")) .size_1p5() .rounded_full() .bg(cx.theme().muted_foreground), @@ -104,7 +105,7 @@ impl RenderOnce for ClosableTabTitle { .ghost() .xsmall() .icon(IconName::Close) - .tooltip("关闭") + .tooltip(t!("common.close")) .on_click(move |_, window, cx| { // Closing must not also select or drag the tab. cx.stop_propagation(); diff --git a/src/shared/count_tabs.rs b/src/shared/count_tabs.rs index 4882179..e36dabc 100644 --- a/src/shared/count_tabs.rs +++ b/src/shared/count_tabs.rs @@ -19,7 +19,7 @@ use super::{Segment, SegmentedControl}; /// place: `within(id).click(1usize)`. pub fn count_tabs( id: &'static str, - tabs: impl IntoIterator, + tabs: impl IntoIterator, usize)>, selected: usize, on_click: impl Fn(&usize, &mut Window, &mut App) + 'static, ) -> impl IntoElement { diff --git a/src/shared/dialog.rs b/src/shared/dialog.rs index 453b51a..93a6568 100644 --- a/src/shared/dialog.rs +++ b/src/shared/dialog.rs @@ -11,10 +11,12 @@ use gpui_kit::component::{ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; +use crate::i18n::t; + /// A form dialog's footer: 取消, and the button that commits the form. pub fn commit_footer(id: &'static str, label: impl Into) -> DialogFooter { DialogFooter::new() - .child(DialogClose::new().trigger(|button| button.label("取消"))) + .child(DialogClose::new().trigger(|button| button.label(t!("common.cancel")))) .child(DialogAction::new().child(Button::new(id).primary().label(label))) } @@ -69,9 +71,9 @@ pub fn confirm_delete( cx: &mut App, ) { confirm_danger( - format!("删除“{name}”?").into(), + t!("common.delete_title", name = name), description, - "删除", + t!("common.delete"), on_delete, window, cx, @@ -83,11 +85,12 @@ pub fn confirm_delete( pub fn confirm_danger( title: SharedString, description: Option, - verb: &'static str, + verb: impl Into, on_ok: DeleteHandler, window: &mut Window, cx: &mut App, ) { + let verb = verb.into(); window.open_alert_dialog(cx, move |alert, _, _| { alert .title(title.clone()) @@ -96,9 +99,9 @@ pub fn confirm_danger( }) .button_props( DialogButtonProps::default() - .ok_text(verb) + .ok_text(verb.clone()) .ok_variant(ButtonVariant::Danger) - .cancel_text("取消"), + .cancel_text(t!("common.cancel")), ) .show_cancel(true) .on_ok({ diff --git a/src/shared/format.rs b/src/shared/format.rs index c1e995d..233e941 100644 --- a/src/shared/format.rs +++ b/src/shared/format.rs @@ -2,6 +2,8 @@ use std::time::Duration; +use crate::i18n::tn; + const UNITS: [&str; 5] = ["KB", "MB", "GB", "TB", "PB"]; /// 「421.02 MB」, in units of 1024. @@ -35,7 +37,7 @@ pub fn format_duration(duration: Duration) -> String { seconds % 60 ); if days > 0 { - format!("{days} 天 {clock}") + tn!("shared.duration.days", days, clock = clock).to_string() } else { clock } diff --git a/src/shared/host_mark.rs b/src/shared/host_mark.rs index 3ca4f04..68fcb9c 100644 --- a/src/shared/host_mark.rs +++ b/src/shared/host_mark.rs @@ -5,6 +5,7 @@ use gpui_kit::prelude::FluentBuilder as _; use gpui_kit::*; use crate::host::HostOs; +use crate::i18n::t; /// The mark that stands for a host: the operating system found on its host, /// drawn on a badge in that project's own colour, or the first character of @@ -95,7 +96,7 @@ impl RenderOnce for HostMark { .unwrap_or_default(), ) .into_any_element(), - SharedString::from("未探测到系统"), + t!("shared.host_mark.unknown_os"), ), }; let tooltip = description.clone(); diff --git a/src/shared/rename_tab_dialog.rs b/src/shared/rename_tab_dialog.rs index b489087..3ab0b0d 100644 --- a/src/shared/rename_tab_dialog.rs +++ b/src/shared/rename_tab_dialog.rs @@ -10,6 +10,7 @@ use gpui_kit::component::{ use gpui_kit::*; use super::commit_footer; +use crate::i18n::t; /// A tab whose label can be replaced by a title of its own. pub trait RenamableTab: Sized + 'static { @@ -42,8 +43,8 @@ impl Render for RenameTabForm { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { Form::new().child( Field::new() - .label("标签名称") - .description(format!("留空则使用「{}」。", self.default)) + .label(t!("shared.rename_tab.name")) + .description(t!("shared.rename_tab.default", title = self.default)) .child(Input::new(&self.title).id("tab-name").small()), ) } @@ -70,11 +71,11 @@ pub fn open_rename_tab_dialog( }); window.open_dialog(cx, move |dialog, _, _| { dialog - .title("重命名标签") + .title(t!("shared.rename_tab.title")) // Closed by its buttons or Escape, not by a click beside it. .overlay_closable(false) .child(form.clone()) - .footer(commit_footer("commit", "保存")) + .footer(commit_footer("commit", t!("common.save"))) .on_ok({ let form = form.clone(); move |_, _, cx| { diff --git a/src/shared/tab_menu.rs b/src/shared/tab_menu.rs index 088de4c..0d71ded 100644 --- a/src/shared/tab_menu.rs +++ b/src/shared/tab_menu.rs @@ -8,6 +8,7 @@ use crate::app::{ CenterTab, CloseEditor, CloseExplorer, CloseLocalTerminal, CloseScope, CloseSettings, CloseTabs, CloseTerminal, }; +use crate::i18n::t; /// The close commands every center tab's context menu ends with: this tab, /// and the ones to its left, to its right, the others and all of them. @@ -33,23 +34,23 @@ pub fn close_tab_items( |scope: CloseScope| position.is_none_or(|(len, ix)| scope.targets(len, ix).is_empty()); let batch = |scope| Box::new(CloseTabs { tab, scope }); - menu.menu("关闭", close_action(tab)) + menu.menu(t!("common.close"), close_action(tab)) .menu_with_disabled( - "关闭左侧", + t!("shared.tab_menu.close_left"), batch(CloseScope::Left), is_empty(CloseScope::Left), ) .menu_with_disabled( - "关闭右侧", + t!("shared.tab_menu.close_right"), batch(CloseScope::Right), is_empty(CloseScope::Right), ) .menu_with_disabled( - "关闭其他", + t!("shared.tab_menu.close_others"), batch(CloseScope::Others), is_empty(CloseScope::Others), ) - .menu("关闭全部", batch(CloseScope::All)) + .menu(t!("shared.tab_menu.close_all"), batch(CloseScope::All)) } fn close_action(tab: CenterTab) -> Box { diff --git a/tests/ui/support/connection.rs b/tests/ui/support/connection.rs index bb88d4b..0fabf07 100644 --- a/tests/ui/support/connection.rs +++ b/tests/ui/support/connection.rs @@ -12,7 +12,7 @@ pub fn open_workspace_with_tester( store: HostStore, tester: Arc, ) -> (WindowHandle, Entity) { - cx.update(shellrs::init); + init_app(cx); cx.update(|cx| cx.set_reduce_motion(true)); let mut workspace = None; let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { @@ -23,7 +23,7 @@ pub fn open_workspace_with_tester( let view = cx.new(|cx| { Workspace::new_with_services( store, - cx.new(|_| SettingsStore::in_memory()), + cx.new(|_| settings_store()), remote, Arc::new(FakeTerminalFactory::default()), Arc::new(FakeSftpProvider::default()), diff --git a/tests/ui/support/credential.rs b/tests/ui/support/credential.rs index 541a016..1cf302b 100644 --- a/tests/ui/support/credential.rs +++ b/tests/ui/support/credential.rs @@ -11,7 +11,7 @@ pub fn open_workspace_with_credentials( remote: Arc, tester: Arc, ) -> (WindowHandle, Entity) { - cx.update(shellrs::init); + init_app(cx); cx.update(|cx| cx.set_reduce_motion(true)); let mut workspace = None; let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { @@ -19,7 +19,7 @@ pub fn open_workspace_with_credentials( let view = cx.new(|cx| { Workspace::new_with_services( store, - cx.new(|_| SettingsStore::in_memory()), + cx.new(|_| settings_store()), remote, Arc::new(FakeTerminalFactory::default()), Arc::new(FakeSftpProvider::default()), diff --git a/tests/ui/support/forward.rs b/tests/ui/support/forward.rs index bf57b41..c4376bb 100644 --- a/tests/ui/support/forward.rs +++ b/tests/ui/support/forward.rs @@ -142,7 +142,7 @@ pub fn open_sized_workspace_with_forwards( provider: Arc, window_size: gpui_kit::Size, ) -> (WindowHandle, Entity) { - cx.update(shellrs::init); + init_app(cx); // The forward dialog's diagram plays a flow over real time, and dialogs // slide in: both would move targets between locating and clicking them. cx.update(|cx| cx.set_reduce_motion(true)); @@ -155,7 +155,7 @@ pub fn open_sized_workspace_with_forwards( let view = cx.new(|cx| { Workspace::new_with_services( store, - cx.new(|_| SettingsStore::in_memory()), + cx.new(|_| settings_store()), remote, Arc::new(FakeTerminalFactory::default()), Arc::new(FakeSftpProvider::default()), diff --git a/tests/ui/support/mod.rs b/tests/ui/support/mod.rs index 7c31421..c72bd1e 100644 --- a/tests/ui/support/mod.rs +++ b/tests/ui/support/mod.rs @@ -109,6 +109,23 @@ pub fn one_host_store(auth: AuthKind) -> (HostStore, HostId) { (store, id) } +/// What every fixture starts with: ShellRS's globals, in Chinese, the +/// language the assertions are written in. The test's interface language is +/// its own, so one that switches to English switches no other test. +pub fn init_app(cx: &mut TestAppContext) { + shellrs::i18n::isolate_thread(); + shellrs::i18n::set_locale("zh-CN"); + cx.update(shellrs::init); +} + +/// Settings kept in memory, in Chinese: by default the interface follows the +/// system, and CI's is English. +pub fn settings_store() -> SettingsStore { + let mut store = SettingsStore::in_memory(); + store.update_unnotified(|settings| settings.language = InterfaceLanguage::SimplifiedChinese); + store +} + pub fn appearance_dropdown(window: &mut gpui_kit::Window, item: usize) -> Option { window .within("settings") diff --git a/tests/ui/support/sftp.rs b/tests/ui/support/sftp.rs index 1d0ae53..d416610 100644 --- a/tests/ui/support/sftp.rs +++ b/tests/ui/support/sftp.rs @@ -435,7 +435,7 @@ pub fn open_workspace_with_services( provider: Arc, local: FakeLocalDirectory, ) -> (WindowHandle, Entity) { - cx.update(shellrs::init); + init_app(cx); // Dialogs slide in over real time; small targets such as checkboxes // would move between the frame that locates them and the click. cx.update(|cx| cx.set_reduce_motion(true)); @@ -448,7 +448,7 @@ pub fn open_workspace_with_services( let view = cx.new(|cx| { Workspace::new_with_services( store, - cx.new(|_| SettingsStore::in_memory()), + cx.new(|_| settings_store()), remote, Arc::new(FakeTerminalFactory::default()), provider, diff --git a/tests/ui/support/terminal.rs b/tests/ui/support/terminal.rs index fc4b895..a1e6191 100644 --- a/tests/ui/support/terminal.rs +++ b/tests/ui/support/terminal.rs @@ -204,7 +204,7 @@ pub fn open_workspace_with_remote_factory( store: HostStore, factory: Arc, ) -> (WindowHandle, Entity) { - cx.update(shellrs::init); + 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; @@ -214,7 +214,7 @@ pub fn open_workspace_with_remote_factory( let view = cx.new(|cx| { Workspace::new_with_services( store, - cx.new(|_| SettingsStore::in_memory()), + cx.new(|_| settings_store()), remote, Arc::new(FakeTerminalFactory::default()), Arc::new(FakeSftpProvider::default()), diff --git a/tests/ui/terminal.rs b/tests/ui/terminal.rs index 49d9a40..d80fb9e 100644 --- a/tests/ui/terminal.rs +++ b/tests/ui/terminal.rs @@ -7,7 +7,7 @@ fn open_workspace_with_factory( cx: &mut TestAppContext, factory: Arc, ) -> (WindowHandle, Entity) { - cx.update(shellrs::init); + 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; @@ -19,7 +19,7 @@ fn open_workspace_with_factory( let view = cx.new(|cx| { Workspace::new_with_services( store, - cx.new(|_| SettingsStore::in_memory()), + cx.new(|_| settings_store()), remote, factory.clone(), Arc::new(FakeSftpProvider::default()), diff --git a/tests/ui/workspace.rs b/tests/ui/workspace.rs index bbfeb24..6b3cb8b 100644 --- a/tests/ui/workspace.rs +++ b/tests/ui/workspace.rs @@ -797,7 +797,7 @@ async fn a_dialog_says_what_is_wrong_in_a_notification_that_goes_with_it(cx: &mu /// crashed the app at launch. #[gpui_kit::test] fn a_problem_found_while_the_window_is_built_is_shown_once_it_is_open(cx: &mut TestAppContext) { - cx.update(shellrs::init); + init_app(cx); let handle = cx.open_window(size(px(1280.), px(800.)), |window, cx| { let store = cx.new(|_| HostStore::empty()); let remote = Arc::new(FixedRemoteTerminalTransportProvider::new(Arc::new( @@ -806,7 +806,7 @@ fn a_problem_found_while_the_window_is_built_is_shown_once_it_is_open(cx: &mut T let view = cx.new(|cx| { Workspace::new_with_services( store, - cx.new(|_| SettingsStore::in_memory()), + cx.new(|_| settings_store()), remote, Arc::new(FakeTerminalFactory::default()), Arc::new(FakeSftpProvider::default()),