From 87138f20a625506e696c2f3ce57537217930ed1e Mon Sep 17 00:00:00 2001 From: gao-jian-bin <1820474883@qq.com> Date: Sun, 13 Sep 2026 00:51:00 +0800 Subject: [PATCH] fix(i18n): make Git drawer follow the application language --- nebula_app/i18n/en-US.json | 47 +++++ nebula_app/i18n/zh-CN.json | 47 +++++ nebula_app/src/display/side_panel/mod.rs | 22 ++- nebula_app/src/display/side_panel/notice.rs | 130 ++++++++++++++ nebula_app/src/display/side_panel/render.rs | 2 +- nebula_app/src/display/side_panel/tests.rs | 9 +- nebula_app/src/display/side_panel/vcs.rs | 22 ++- nebula_app/src/gpui_shell/workspace.rs | 10 +- .../src/gpui_shell/workspace/file_tree.rs | 23 +-- .../src/gpui_shell/workspace/sidebar.rs | 5 +- .../src/gpui_shell/workspace/top_tabs.rs | 2 +- .../src/gpui_shell/workspace/vcs_panel.rs | 165 +++++++++--------- .../workspace/vcs_panel/commit_input.rs | 113 ++++++++++++ .../workspace/vcs_panel/relative_time.rs | 61 +++++++ nebula_app/tests/i18n_contract.rs | 16 ++ 15 files changed, 554 insertions(+), 120 deletions(-) create mode 100644 nebula_app/src/display/side_panel/notice.rs create mode 100644 nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs create mode 100644 nebula_app/src/gpui_shell/workspace/vcs_panel/relative_time.rs diff --git a/nebula_app/i18n/en-US.json b/nebula_app/i18n/en-US.json index 40365c5..cee0540 100644 --- a/nebula_app/i18n/en-US.json +++ b/nebula_app/i18n/en-US.json @@ -376,6 +376,8 @@ "save_copy": "Save a copy" }, "files": { + "path_unavailable": "The path no longer exists", + "delete_failed": "Could not delete: {error}", "ignore_add": "Add to .gitignore", "ignore_remove": "Remove from .gitignore", "ignore_added": "Added to .gitignore: {entry}", @@ -462,5 +464,50 @@ "exported": "Host metadata exported without credentials", "imported": "Added {added} hosts; skipped {skipped} existing hosts", "imported_partial": "Added {added} hosts, but could not update their visibility. Retry the import: {error}" + }, + "vcs": { + "changes": "Changes", + "staged": "Staged", + "merge_conflicts": "Merge conflicts", + "no_conflicts": "No unresolved conflicts", + "no_changes": "No changes", + "no_history": "No commits yet", + "no_branch": "(no branch)", + "confirm_discard": "Discard?", + "discard": "Discard changes", + "stage": "Stage", + "unstage": "Unstage", + "resolve_in_merge_editor": "Resolve in the three-way merge editor", + "commit_changes": "Commit / changes", + "graph": "Graph", + "history_graph": "Commit graph", + "conflicts": "Conflicts", + "resolve_conflicts": "Resolve conflicts", + "commit": "Commit", + "commit_placeholder": "Commit message...", + "stage_all": "Stage all", + "pull": "Pull", + "push": "Push", + "push_ahead": "Push ↑{count}", + "follow_directory": "Follow terminal directory", + "not_repository": "The current directory is not in a Git/SVN repository", + "refresh_status": "Refresh {vcs} status", + "close_status": "Close {vcs} status", + "toggle_git": "Git status (Ctrl+Shift+G)", + "time_now": "just now", + "time_minute": "1 minute ago", + "time_minutes": "{count} minutes ago", + "time_hour": "1 hour ago", + "time_hours": "{count} hours ago", + "time_day": "1 day ago", + "time_days": "{count} days ago", + "time_month": "1 month ago", + "time_months": "{count} months ago", + "time_year": "1 year ago", + "time_years": "{count} years ago", + "directory_unavailable": "The selected directory is unavailable", + "following_directory": "The selected directory is unavailable; following the current directory", + "command_failed": "{program} failed", + "task_start_failed": "Could not start the version control task: {error}" } } diff --git a/nebula_app/i18n/zh-CN.json b/nebula_app/i18n/zh-CN.json index 09d7b8a..650fc46 100644 --- a/nebula_app/i18n/zh-CN.json +++ b/nebula_app/i18n/zh-CN.json @@ -376,6 +376,8 @@ "save_copy": "保存副本" }, "files": { + "path_unavailable": "路径已不存在", + "delete_failed": "删除失败:{error}", "ignore_add": "加入 .gitignore", "ignore_remove": "从 .gitignore 移除", "ignore_added": "已写入 .gitignore:{entry}", @@ -462,5 +464,50 @@ "exported": "主机信息已导出,不含凭据", "imported": "已新增 {added} 台主机,跳过 {skipped} 台已有主机", "imported_partial": "已新增 {added} 台主机,但显示状态未能更新。请重试导入:{error}" + }, + "vcs": { + "changes": "变更", + "staged": "已暂存", + "merge_conflicts": "合并冲突", + "no_conflicts": "没有待解决的冲突", + "no_changes": "没有更改", + "no_history": "还没有提交历史", + "no_branch": "(无分支)", + "confirm_discard": "确认丢弃", + "discard": "丢弃改动", + "stage": "暂存", + "unstage": "取消暂存", + "resolve_in_merge_editor": "在三栏合并器中解决", + "commit_changes": "提交 / 变更", + "graph": "线路", + "history_graph": "历史线路", + "conflicts": "冲突", + "resolve_conflicts": "解决冲突", + "commit": "提交", + "commit_placeholder": "提交信息…", + "stage_all": "全部暂存", + "pull": "拉取", + "push": "推送", + "push_ahead": "推送 ↑{count}", + "follow_directory": "回到终端当前目录", + "not_repository": "当前目录不在 Git/SVN 仓库中", + "refresh_status": "刷新 {vcs} 状态", + "close_status": "关闭 {vcs} 状态", + "toggle_git": "Git 状态 (Ctrl+Shift+G)", + "time_now": "刚刚", + "time_minute": "1 分钟前", + "time_minutes": "{count} 分钟前", + "time_hour": "1 小时前", + "time_hours": "{count} 小时前", + "time_day": "1 天前", + "time_days": "{count} 天前", + "time_month": "1 个月前", + "time_months": "{count} 个月前", + "time_year": "1 年前", + "time_years": "{count} 年前", + "directory_unavailable": "所选目录不可用", + "following_directory": "所选目录不可用,已跟随当前目录", + "command_failed": "{program} 失败", + "task_start_failed": "无法启动版本控制任务: {error}" } } diff --git a/nebula_app/src/display/side_panel/mod.rs b/nebula_app/src/display/side_panel/mod.rs index 2215aa6..5a5f90d 100644 --- a/nebula_app/src/display/side_panel/mod.rs +++ b/nebula_app/src/display/side_panel/mod.rs @@ -25,12 +25,14 @@ use unicode_width::UnicodeWidthChar; mod enumerate; mod gitignore; mod icons; +mod notice; #[cfg(feature = "legacy-shell")] mod render; mod search; #[cfg(test)] mod tests; mod vcs; +pub(crate) use notice::PanelNotice; // 子模块的项一律 `pub(crate)`:本 crate 是 bin,没有下游用户,所以拆分不必 // 把内部实现抬到 `pub`。glob 转发让 `display::side_panel::X` 这层路径不变。 @@ -395,7 +397,7 @@ pub struct SidePanel { /// `custom_root` 互斥,且不改变终端实际 cwd / Git 跟随位置。 custom_wsl_root: Option, /// Visible feedback for an invalid/disappeared custom root. - root_notice: Option, + root_notice: Option, /// Flattened visible tree rows for the Files view. rows: Vec, /// Last unfiltered tree snapshot. Search results replace `rows`, but @@ -448,7 +450,7 @@ pub struct SidePanel { /// Set by the worker when it finishes — `sync` folds it into a refresh. op_done: std::sync::Arc, /// Last operation's error (empty = success), shown on the summary line. - op_error: std::sync::Arc>, + op_error: std::sync::Arc>, /// A snapshot worker (fs walk + git subprocesses) is in flight. Guards /// against stacking workers; a refresh requested meanwhile re-arms /// `needs_refresh` and runs after this one lands. @@ -617,7 +619,7 @@ impl SidePanel { let custom_invalidated = self.custom_root.as_ref().is_some_and(|root| !root.is_dir()); if custom_invalidated { self.custom_root = None; - self.root_notice = Some("所选目录不可用,已跟随当前目录".to_owned()); + self.root_notice = Some(PanelNotice::FollowingDirectory); } let next_root = self .custom_root @@ -718,7 +720,7 @@ impl SidePanel { /// belongs to one window and this field is deliberately never serialized. pub fn set_custom_root(&mut self, root: PathBuf) -> bool { if !root.is_dir() { - self.root_notice = Some("所选目录不可用".to_owned()); + self.root_notice = Some(PanelNotice::DirectoryUnavailable); return false; } let changed = self.custom_root.as_ref() != Some(&root) || self.root.as_ref() != Some(&root); @@ -826,8 +828,12 @@ impl SidePanel { self.scroll = 0; } - pub fn root_notice(&self) -> Option<&str> { - self.root_notice.as_deref() + pub fn root_notice(&self) -> Option { + self.localized_root_notice(crate::i18n::UiLanguage::ZhCn) + } + + pub fn localized_root_notice(&self, language: crate::i18n::UiLanguage) -> Option { + self.root_notice.as_ref().map(|notice| notice.text(language)) } /// Only real Git file rows are interactive. Section headers and the blank @@ -854,8 +860,8 @@ impl SidePanel { } /// 面板顶部的一句话提示(复用根目录不可用的同一条 UI)。 - pub fn set_notice(&mut self, message: String) { - self.root_notice = Some(message); + pub fn set_notice(&mut self, message: impl Into) { + self.root_notice = Some(message.into()); } fn current_index_root(&self) -> Option { diff --git a/nebula_app/src/display/side_panel/notice.rs b/nebula_app/src/display/side_panel/notice.rs new file mode 100644 index 0000000..87ddaaa --- /dev/null +++ b/nebula_app/src/display/side_panel/notice.rs @@ -0,0 +1,130 @@ +//! Store application notices as outcomes so a cached result follows the UI language. +//! Command output and user content remain verbatim. + +use crate::i18n::{Message, UiLanguage}; + +#[derive(Clone, Debug)] +pub(crate) enum PanelNotice { + Raw(String), + DirectoryUnavailable, + FollowingDirectory, + CommandFailed(String), + TaskStartFailed(String), + PathUnavailable, + DeleteFailed(String), + IgnoreAdded(String), + IgnoreRemoved(String), + IgnorePresent(String), + IgnoreVisible(String), + IgnoreFailed(String), +} + +impl Default for PanelNotice { + fn default() -> Self { + Self::Raw(String::new()) + } +} + +impl From for PanelNotice { + fn from(value: String) -> Self { + Self::Raw(value) + } +} + +impl From<&str> for PanelNotice { + fn from(value: &str) -> Self { + Self::Raw(value.to_owned()) + } +} + +impl PanelNotice { + pub(super) fn text(&self, language: UiLanguage) -> String { + match self { + Self::Raw(text) => text.clone(), + Self::DirectoryUnavailable => { + language.text(Message::VcsDirectoryUnavailable).to_owned() + }, + Self::FollowingDirectory => language.text(Message::VcsFollowingDirectory).to_owned(), + Self::CommandFailed(program) => { + language.format(Message::VcsCommandFailed, &[("program", program)]) + }, + Self::TaskStartFailed(error) => { + language.format(Message::VcsTaskStartFailed, &[("error", error)]) + }, + Self::PathUnavailable => language.text(Message::FilesPathUnavailable).to_owned(), + Self::DeleteFailed(error) => { + language.format(Message::FilesDeleteFailed, &[("error", error)]) + }, + Self::IgnoreAdded(entry) => { + language.format(Message::FilesIgnoreAdded, &[("entry", entry)]) + }, + Self::IgnoreRemoved(entry) => { + language.format(Message::FilesIgnoreRemoved, &[("entry", entry)]) + }, + Self::IgnorePresent(entry) => { + language.format(Message::FilesIgnorePresent, &[("entry", entry)]) + }, + Self::IgnoreVisible(entry) => { + language.format(Message::FilesIgnoreVisible, &[("entry", entry)]) + }, + Self::IgnoreFailed(error) => { + language.format(Message::FilesIgnoreFailed, &[("error", error)]) + }, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cached_notices_follow_language_changes_without_repeating_the_operation() { + let notice = PanelNotice::FollowingDirectory; + assert_eq!(notice.text(UiLanguage::ZhCn), "所选目录不可用,已跟随当前目录"); + assert_eq!( + notice.text(UiLanguage::EnUs), + "The selected directory is unavailable; following the current directory" + ); + assert_eq!(notice.text(UiLanguage::FrFr), notice.text(UiLanguage::EnUs)); + assert_eq!( + PanelNotice::DirectoryUnavailable.text(UiLanguage::EnUs), + "The selected directory is unavailable" + ); + assert_eq!(PanelNotice::CommandFailed("git".into()).text(UiLanguage::EnUs), "git failed"); + assert_eq!(PanelNotice::CommandFailed("git".into()).text(UiLanguage::ZhCn), "git 失败"); + } + + #[test] + fn external_errors_and_placeholder_like_arguments_are_preserved() { + let stderr = "fatal: 无法读取 分支/{error}"; + for language in UiLanguage::ALL { + assert_eq!(PanelNotice::Raw(stderr.into()).text(*language), stderr); + } + let notice = PanelNotice::TaskStartFailed("OS error: {program}".into()); + assert_eq!( + notice.text(UiLanguage::EnUs), + "Could not start the version control task: OS error: {program}" + ); + } + + #[test] + fn file_notices_can_be_reused_by_the_git_drawer_in_a_new_language() { + let cases = [ + (PanelNotice::PathUnavailable, "The path no longer exists"), + (PanelNotice::DeleteFailed("denied".into()), "Could not delete: denied"), + (PanelNotice::IgnoreAdded("/build/".into()), "Added to .gitignore: /build/"), + (PanelNotice::IgnoreRemoved("/build/".into()), "No longer ignored: /build/"), + (PanelNotice::IgnorePresent("/build/".into()), "Already ignored: /build/"), + (PanelNotice::IgnoreVisible("/build/".into()), "Already visible: /build/"), + ( + PanelNotice::IgnoreFailed("denied".into()), + "Could not change Git ignore rules: denied", + ), + ]; + for (notice, english) in cases { + assert_ne!(notice.text(UiLanguage::ZhCn), english); + assert_eq!(notice.text(UiLanguage::EnUs), english); + } + } +} diff --git a/nebula_app/src/display/side_panel/render.rs b/nebula_app/src/display/side_panel/render.rs index b11c250..790dd4d 100644 --- a/nebula_app/src/display/side_panel/render.rs +++ b/nebula_app/src/display/side_panel/render.rs @@ -481,7 +481,7 @@ pub(crate) fn draw_text( as usize) .max(4); let (summary, summary_ink) = if let Some(notice) = panel.root_notice() { - (clip_tail(notice, summary_cols), Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b)) + (clip_tail(¬ice, summary_cols), Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b)) } else { ( panel diff --git a/nebula_app/src/display/side_panel/tests.rs b/nebula_app/src/display/side_panel/tests.rs index 7f34b3b..d6fcc3e 100644 --- a/nebula_app/src/display/side_panel/tests.rs +++ b/nebula_app/src/display/side_panel/tests.rs @@ -143,7 +143,12 @@ mod tests { assert!(panel.sync(Some(cwd.clone()))); assert!(!panel.custom_root_active()); assert_eq!(panel.root(), Some(cwd.as_path())); - assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录")); + assert_eq!(panel.root_notice().as_deref(), Some("所选目录不可用,已跟随当前目录")); + + assert_eq!( + panel.localized_root_notice(crate::i18n::UiLanguage::EnUs).as_deref(), + Some("The selected directory is unavailable; following the current directory") + ); panel.wait_snapshot(); panel.file_index.release_for_test(); @@ -165,7 +170,7 @@ mod tests { assert!(panel.sync(Some(root.clone()))); assert!(!panel.custom_root_active()); assert_eq!(panel.root(), Some(root.as_path())); - assert_eq!(panel.root_notice(), Some("所选目录不可用,已跟随当前目录")); + assert_eq!(panel.root_notice().as_deref(), Some("所选目录不可用,已跟随当前目录")); } #[test] diff --git a/nebula_app/src/display/side_panel/vcs.rs b/nebula_app/src/display/side_panel/vcs.rs index 0f49bf9..8624413 100644 --- a/nebula_app/src/display/side_panel/vcs.rs +++ b/nebula_app/src/display/side_panel/vcs.rs @@ -252,11 +252,15 @@ impl SidePanel { /// Last mutation's error, if any (cleared by the next successful op). pub fn op_error(&self) -> Option { - let e = self.op_error.lock().ok()?; - (!e.is_empty()).then(|| e.clone()) + self.localized_op_error(crate::i18n::UiLanguage::ZhCn) } - fn set_op_error(&mut self, message: impl Into) { + pub fn localized_op_error(&self, language: crate::i18n::UiLanguage) -> Option { + let text = self.op_error.lock().ok()?.text(language); + (!text.is_empty()).then_some(text) + } + + fn set_op_error(&mut self, message: impl Into) { if let Ok(mut error) = self.op_error.lock() { *error = message.into(); } @@ -274,7 +278,7 @@ impl SidePanel { let done = self.op_done.clone(); let error = self.op_error.clone(); if let Ok(mut message) = error.lock() { - message.clear(); + *message = PanelNotice::default(); } let display_name = program.display().to_string(); let spawn_result = @@ -287,16 +291,16 @@ impl SidePanel { cmd.creation_flags(0x0800_0000); // CREATE_NO_WINDOW } let msg = match cmd.output() { - Ok(out) if out.status.success() => String::new(), + Ok(out) if out.status.success() => PanelNotice::default(), Ok(out) => { let err = String::from_utf8_lossy(&out.stderr); // First meaningful line is enough for a status strip. err.lines() .find(|l| !l.trim().is_empty()) - .unwrap_or(&format!("{display_name} 失败")) - .to_string() + .map(|line| PanelNotice::Raw(line.to_owned())) + .unwrap_or_else(|| PanelNotice::CommandFailed(display_name.clone())) }, - Err(e) => format!("{display_name}: {e}"), + Err(e) => PanelNotice::Raw(format!("{display_name}: {e}")), }; if let Ok(mut slot) = error.lock() { *slot = msg; @@ -306,7 +310,7 @@ impl SidePanel { }); if let Err(spawn_error) = spawn_result { self.op_running.store(false, Ordering::Relaxed); - self.set_op_error(format!("无法启动版本控制任务: {spawn_error}")); + self.set_op_error(PanelNotice::TaskStartFailed(spawn_error.to_string())); } } diff --git a/nebula_app/src/gpui_shell/workspace.rs b/nebula_app/src/gpui_shell/workspace.rs index 9a7cf81..47c1fe6 100644 --- a/nebula_app/src/gpui_shell/workspace.rs +++ b/nebula_app/src/gpui_shell/workspace.rs @@ -920,7 +920,7 @@ pub struct NebulaWorkspace { _command_manager_subscription: Subscription, /// Git/SVN 提交信息输入(GPUI 输入组件);提交动作直达共享模型 /// `vcs_commit_message`,不经旧壳的内部输入状态机。 - git_commit_input: Entity, + git_commit_input: vcs_panel::CommitInput, /// Git 树"丢弃改动"的二次确认(路径);任何其他 VCS 操作都清掉它。 vcs_discard_confirm: Option, /// 命令面板的行覆盖:`None` = 常规命令目录,`Some` = 某个专用列表 @@ -1098,7 +1098,7 @@ impl NebulaWorkspace { workspace_ui_language().pick("搜索文件和文件夹…", "Search files and folders..."), ) }); - let git_commit_input = cx.new(|cx| InputState::new(window, cx).placeholder("提交信息…")); + let git_commit_input = vcs_panel::CommitInput::new(window, cx); let command_palette_subscription = cx.subscribe_in( &command_palette_input, window, @@ -2592,12 +2592,12 @@ impl NebulaWorkspace { /// 提交按钮/Enter:读 GPUI 输入框的消息直达共享模型(git 提交暂存区、 /// svn 提交工作副本),成功入队后清空输入。 fn submit_vcs_commit(&mut self, window: &mut Window, cx: &mut Context) { - let message = self.git_commit_input.read(cx).value().trim().to_string(); + let message = self.git_commit_input.input.read(cx).value().trim().to_string(); if message.is_empty() { return; } self.side_panel.vcs_commit_message(&message); - self.git_commit_input.update(cx, |input, cx| input.set_value("", window, cx)); + self.git_commit_input.input.update(cx, |input, cx| input.set_value("", window, cx)); cx.notify(); } @@ -3201,7 +3201,7 @@ impl NebulaWorkspace { // 页签——用户想看的永远是"当前这台机器上的文件"。 crate::display::side_panel::PanelView::Files if remote => self.render_remote_files(cx), crate::display::side_panel::PanelView::Files => self.render_file_tree(cx), - crate::display::side_panel::PanelView::Git => self.render_git_tree(cx), + crate::display::side_panel::PanelView::Git => self.render_git_tree(window, cx), }; div() .h_full() diff --git a/nebula_app/src/gpui_shell/workspace/file_tree.rs b/nebula_app/src/gpui_shell/workspace/file_tree.rs index 24216b3..a1ba0ec 100644 --- a/nebula_app/src/gpui_shell/workspace/file_tree.rs +++ b/nebula_app/src/gpui_shell/workspace/file_tree.rs @@ -3,6 +3,7 @@ //! 几何与密度以旧 OpenGL 壳的 `display::side_panel::panel_layout` //! (side_panel.rs:1695)和它的行水洗(同文件 2712-2800)为基准,不自创数字。 +use crate::display::side_panel::PanelNotice; use std::ops::Range; use std::path::PathBuf; @@ -546,7 +547,7 @@ impl NebulaWorkspace { })), ), ) - .when_some(self.side_panel.root_notice(), |panel, notice| { + .when_some(self.side_panel.localized_root_notice(crate::gpui_shell::config::ui_language(cx)), |panel, notice| { panel.child(div().text_xs().text_color(theme.warning).child(notice.to_owned())) }) .child(search_box) @@ -774,7 +775,7 @@ impl NebulaWorkspace { cx: &mut Context, ) { if !path.exists() { - self.side_panel.set_notice("路径已不存在".to_owned()); + self.side_panel.set_notice(PanelNotice::PathUnavailable); cx.notify(); return; } @@ -810,7 +811,7 @@ impl NebulaWorkspace { this.side_panel.request_refresh(); this.sync_side_panel_to_active(false, cx); }, - Err(error) => this.side_panel.set_notice(format!("删除失败:{error}")), + Err(error) => this.side_panel.set_notice(PanelNotice::DeleteFailed(error)), } cx.notify(); }); @@ -836,22 +837,16 @@ impl NebulaWorkspace { let result = task.await; let _ = this.update(cx, |this, cx| { use crate::display::side_panel::IgnoreOutcome; - use crate::i18n::Message; - let language = crate::gpui_shell::config::ui_language(cx); let notice = match result { - Ok(IgnoreOutcome::Added { entry, .. }) => { - language.format(Message::FilesIgnoreAdded, &[("entry", &entry)]) - }, - Ok(IgnoreOutcome::Removed { entry, .. }) => { - language.format(Message::FilesIgnoreRemoved, &[("entry", &entry)]) - }, + Ok(IgnoreOutcome::Added { entry, .. }) => PanelNotice::IgnoreAdded(entry), + Ok(IgnoreOutcome::Removed { entry, .. }) => PanelNotice::IgnoreRemoved(entry), Ok(IgnoreOutcome::AlreadyPresent { entry }) => { - language.format(Message::FilesIgnorePresent, &[("entry", &entry)]) + PanelNotice::IgnorePresent(entry) }, Ok(IgnoreOutcome::AlreadyVisible { entry }) => { - language.format(Message::FilesIgnoreVisible, &[("entry", &entry)]) + PanelNotice::IgnoreVisible(entry) }, - Err(error) => language.format(Message::FilesIgnoreFailed, &[("error", &error)]), + Err(error) => PanelNotice::IgnoreFailed(error), }; this.side_panel.set_notice(notice); this.side_panel.request_refresh(); diff --git a/nebula_app/src/gpui_shell/workspace/sidebar.rs b/nebula_app/src/gpui_shell/workspace/sidebar.rs index 4b114c7..9949947 100644 --- a/nebula_app/src/gpui_shell/workspace/sidebar.rs +++ b/nebula_app/src/gpui_shell/workspace/sidebar.rs @@ -1005,7 +1005,10 @@ impl NebulaWorkspace { .icon(IconName::Github) .ghost() .selected(git_active) - .tooltip("Git 状态 (Ctrl+Shift+G)") + .tooltip( + crate::gpui_shell::config::ui_language(cx) + .text(crate::i18n::Message::VcsToggleGit), + ) .on_click(cx.listener(|this, _, _, cx| { this.toggle_git_tree(cx); })), diff --git a/nebula_app/src/gpui_shell/workspace/top_tabs.rs b/nebula_app/src/gpui_shell/workspace/top_tabs.rs index f9968fc..d50dd06 100644 --- a/nebula_app/src/gpui_shell/workspace/top_tabs.rs +++ b/nebula_app/src/gpui_shell/workspace/top_tabs.rs @@ -702,7 +702,7 @@ impl NebulaWorkspace { .icon(IconName::Github) .ghost() .selected(git_active) - .tooltip("Git 状态 (Ctrl+Shift+G)") + .tooltip(crate::gpui_shell::config::ui_language(cx).text(crate::i18n::Message::VcsToggleGit)) .on_click(cx.listener(|this, _, _, cx| this.toggle_git_tree(cx))), ), ) diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel.rs index 39cdce0..b8788fe 100644 --- a/nebula_app/src/gpui_shell/workspace/vcs_panel.rs +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel.rs @@ -6,12 +6,19 @@ //! 在搬的过程中悄悄改掉某个 trait 的引入方式。 use super::*; +use crate::i18n::{Message, UiLanguage}; use gpui_component::menu::PopupMenuItem; +mod commit_input; +mod relative_time; +pub(super) use commit_input::CommitInput; +use relative_time::git_relative_time_at; + impl NebulaWorkspace { pub(super) fn render_side_panel_switch(&self, cx: &mut Context) -> impl IntoElement { use crate::display::side_panel::PanelView; + let language = crate::gpui_shell::config::ui_language(cx); let files = self.side_panel.view == PanelView::Files; let git = self.side_panel.view == PanelView::Git; let git_count = self @@ -30,7 +37,7 @@ impl NebulaWorkspace { .child( Button::new("side-panel-files") .icon(IconName::FolderClosed) - .label("文件") + .label(language.text(Message::CommonFiles)) .small() .selected(files) .on_click(cx.listener(|this, _, _, cx| { @@ -50,7 +57,13 @@ impl NebulaWorkspace { ) } - pub(super) fn render_git_tree(&mut self, cx: &mut Context) -> gpui::AnyElement { + pub(super) fn render_git_tree( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> gpui::AnyElement { + let language = crate::gpui_shell::config::ui_language(cx); + self.git_commit_input.sync_language(window, cx); let view_switch = self.render_side_panel_switch(cx).into_any_element(); let theme = cx.theme(); let muted = theme.muted_foreground; @@ -72,8 +85,8 @@ impl NebulaWorkspace { let vcs = git.as_ref().map(|info| info.vcs); let git_view = self.side_panel.git_view; let op_running = self.side_panel.op_running(); - let op_error = self.side_panel.op_error(); - // 浏览定位是否生效——决定要不要画"回到终端当前目录"。链式构建里不能 + let op_error = self.side_panel.localized_op_error(language); + // 浏览定位是否生效——决定要不要画“回到终端当前目录”。链式构建里不能 // 再借 `self`,所以这些和下面的弱引用都在这里一次取好。 let browsing_elsewhere = self.side_panel.custom_root_active(); // 下拉菜单的动作要在自己的闭包里回到本实体;`cx.listener` 只能给 @@ -106,7 +119,7 @@ impl NebulaWorkspace { .px_2() .text_sm() .text_color(muted) - .child("还没有提交历史") + .child(language.text(Message::VcsNoHistory)) .into_any_element(), ); } @@ -148,7 +161,7 @@ impl NebulaWorkspace { let meta = format!( "{} · {} · {}", commit.author, - git_relative_time(commit.timestamp), + git_relative_time(commit.timestamp, language), commit.short_hash ); rows.push( @@ -188,10 +201,11 @@ impl NebulaWorkspace { info.conflicts.iter().map(|(_, path)| path.as_str()).collect(); // 变更页保留三组;冲突页只列冲突。路径从后两组过滤,因为数据层 // 为旧壳兼容仍把冲突同时留在 staged/unstaged。 - let mut sections: Vec<(&str, Vec<&(char, String)>, RowOps)> = Vec::new(); + let mut sections: Vec<(&str, &str, Vec<&(char, String)>, RowOps)> = Vec::new(); if !info.conflicts.is_empty() { sections.push(( - "合并冲突", + "conflicts", + language.text(Message::VcsMergeConflicts), info.conflicts.iter().collect(), if is_git { RowOps::Conflict } else { RowOps::Svn }, )); @@ -201,25 +215,28 @@ impl NebulaWorkspace { match info.vcs { VcsKind::Git if git_view == GitPanelView::Changes => { sections.push(( - "已暂存", + "staged", + language.text(Message::VcsStaged), info.staged.iter().filter(not_conflicted).collect(), RowOps::Staged, )); sections.push(( - "变更", + "changes", + language.text(Message::VcsChanges), info.unstaged.iter().filter(not_conflicted).collect(), RowOps::Unstaged, )); }, VcsKind::Git => {}, VcsKind::Svn => sections.push(( + "svn", "修改", info.unstaged.iter().filter(not_conflicted).collect(), RowOps::Svn, )), VcsKind::SvnRepository => {}, } - let clean = sections.iter().all(|(_, entries, _)| entries.is_empty()); + let clean = sections.iter().all(|(_, _, entries, _)| entries.is_empty()); if clean && info.vcs != VcsKind::SvnRepository { rows.push( div() @@ -228,15 +245,15 @@ impl NebulaWorkspace { .text_sm() .text_color(muted) .child(if is_git && git_view == GitPanelView::Conflicts { - "没有待解决的冲突" + language.text(Message::VcsNoConflicts) } else { - "没有更改" + language.text(Message::VcsNoChanges) }) .into_any_element(), ); } let discard_confirm = self.vcs_discard_confirm.clone(); - for (section, entries, ops) in sections { + for (section_id, section, entries, ops) in sections { if entries.is_empty() { continue; } @@ -272,7 +289,7 @@ impl NebulaWorkspace { None => (relative_path.clone(), String::new()), }; let row_group = - SharedString::from(format!("vcs-row-actions-{section}-{index}")); + SharedString::from(format!("vcs-row-actions-{section_id}-{index}")); let open_path = path.clone(); let stage_path = relative_path.clone(); let svn_add_path = relative_path.clone(); @@ -294,7 +311,7 @@ impl NebulaWorkspace { rows.push( h_flex() .id(SharedString::from(format!( - "git-tree-row-{section}-{index}-{relative_path}" + "git-tree-row-{section_id}-{index}-{relative_path}" ))) .group(row_group.clone()) .h(px(30.0)) @@ -340,7 +357,7 @@ impl NebulaWorkspace { .when(can_discard, |row| { row.child( Button::new(SharedString::from(format!( - "vcs-discard-{section}-{index}" + "vcs-discard-{section_id}-{index}" ))) .map(|button| { if discard_armed { @@ -348,7 +365,7 @@ impl NebulaWorkspace { .label(if svn_revert { "确认还原" } else { - "确认丢弃" + language.text(Message::VcsConfirmDiscard) }) .danger() .xsmall() @@ -360,7 +377,7 @@ impl NebulaWorkspace { .tooltip(if svn_revert { "还原 SVN 改动" } else { - "丢弃改动" + language.text(Message::VcsDiscard) }) } }) @@ -396,12 +413,12 @@ impl NebulaWorkspace { .when(ops == RowOps::Unstaged && is_git, |row| { row.child( Button::new(SharedString::from(format!( - "vcs-stage-{section}-{index}" + "vcs-stage-{section_id}-{index}" ))) .icon(IconName::Plus) .ghost() .xsmall() - .tooltip("暂存") + .tooltip(language.text(Message::VcsStage)) .invisible() .group_hover(row_group.clone(), |button| button.visible()) .on_click( @@ -416,7 +433,7 @@ impl NebulaWorkspace { .when(svn_add, |row| { row.child( Button::new(SharedString::from(format!( - "svn-add-{section}-{index}" + "svn-add-{section_id}-{index}" ))) .icon(IconName::Plus) .ghost() @@ -436,7 +453,7 @@ impl NebulaWorkspace { .when(svn_resolve, |row| { row.child( Button::new(SharedString::from(format!( - "svn-resolve-{section}-{index}" + "svn-resolve-{section_id}-{index}" ))) .label("解决") .ghost() @@ -461,18 +478,18 @@ impl NebulaWorkspace { &menu_target, &menu_path, index, - section, + section_id, )) }) .when(ops == RowOps::Staged, |row| { row.child( Button::new(SharedString::from(format!( - "vcs-unstage-{section}-{index}" + "vcs-unstage-{section_id}-{index}" ))) .icon(IconName::Minus) .ghost() .xsmall() - .tooltip("取消暂存") + .tooltip(language.text(Message::VcsUnstage)) .invisible() .group_hover(row_group.clone(), |button| button.visible()) .on_click( @@ -487,7 +504,7 @@ impl NebulaWorkspace { .when(ops == RowOps::Conflict && is_git, |row| { row.child( Button::new(SharedString::from(format!( - "git-resolve-{section}-{index}" + "git-resolve-{section_id}-{index}" ))) .icon( Icon::new(Icon::empty()) @@ -495,7 +512,7 @@ impl NebulaWorkspace { ) .ghost() .xsmall() - .tooltip("在三栏合并器中解决") + .tooltip(language.text(Message::VcsResolveInMergeEditor)) .on_click( cx.listener(move |this, _, window, cx| { this.open_git_merge_tab( @@ -541,7 +558,7 @@ impl NebulaWorkspace { .child(div().font_family(symbol).text_sm().text_color(muted).child("\u{ea68}")) .child(div().flex_1().min_w_0().text_sm().truncate().child( if info.branch.is_empty() { - "(no branch)".to_owned() + language.text(Message::VcsNoBranch).to_owned() } else { info.branch.clone() }, @@ -576,9 +593,11 @@ impl NebulaWorkspace { .flex_1() .ghost() .small() - .when(git_view == GitPanelView::Changes, |button| button.label("变更")) + .when(git_view == GitPanelView::Changes, |button| { + button.label(language.text(Message::VcsChanges)) + }) .selected(git_view == GitPanelView::Changes) - .tooltip("提交 / 变更") + .tooltip(language.text(Message::VcsCommitChanges)) .on_click(cx.listener(|this, _, _, cx| { this.side_panel.select_git_view(GitPanelView::Changes); cx.notify(); @@ -593,9 +612,11 @@ impl NebulaWorkspace { .flex_1() .ghost() .small() - .when(git_view == GitPanelView::History, |button| button.label("线路")) + .when(git_view == GitPanelView::History, |button| { + button.label(language.text(Message::VcsGraph)) + }) .selected(git_view == GitPanelView::History) - .tooltip("历史线路") + .tooltip(language.text(Message::VcsHistoryGraph)) .on_click(cx.listener(|this, _, _, cx| { this.side_panel.select_git_view(GitPanelView::History); cx.notify(); @@ -610,9 +631,11 @@ impl NebulaWorkspace { .flex_1() .ghost() .small() - .when(git_view == GitPanelView::Conflicts, |button| button.label("冲突")) + .when(git_view == GitPanelView::Conflicts, |button| { + button.label(language.text(Message::VcsConflicts)) + }) .selected(git_view == GitPanelView::Conflicts) - .tooltip("解决冲突") + .tooltip(language.text(Message::VcsResolveConflicts)) .on_click(cx.listener(|this, _, _, cx| { this.side_panel.select_git_view(GitPanelView::Conflicts); cx.notify(); @@ -628,7 +651,14 @@ impl NebulaWorkspace { .flex_shrink_0() .px(px(6.0)) .items_center() - .child(div().flex_1().min_w_0().text_sm().font_semibold().child("历史线路")) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .font_semibold() + .child(language.text(Message::VcsHistoryGraph)), + ) .child(div().text_xs().text_color(muted).child(info.history.len().to_string())) }); @@ -717,10 +747,10 @@ impl NebulaWorkspace { h_flex() .gap_1() .items_center() - .child(div().flex_1().min_w_0().child(Input::new(&self.git_commit_input))) + .child(div().flex_1().min_w_0().child(Input::new(&self.git_commit_input.input))) .child( Button::new("vcs-commit") - .label("提交") + .label(language.text(Message::VcsCommit)) .small() .disabled(!commit_ready) .on_click(cx.listener(|this, _, window, cx| { @@ -738,7 +768,7 @@ impl NebulaWorkspace { .items_center() .child( Button::new("git-stage-all") - .label("全部暂存") + .label(language.text(Message::VcsStageAll)) .small() .disabled(op_running || unstaged_len == 0) .on_click(cx.listener(|this, _, _, cx| { @@ -748,7 +778,7 @@ impl NebulaWorkspace { ) .child( Button::new("git-pull") - .label("拉取") + .label(language.text(Message::VcsPull)) .small() .disabled(op_running) .on_click(cx.listener(|this, _, _, cx| { @@ -759,9 +789,12 @@ impl NebulaWorkspace { .child( Button::new("git-push") .label(if ahead > 0 { - SharedString::from(format!("推送 ↑{ahead}")) + SharedString::from(language.format( + Message::VcsPushAhead, + &[("count", &ahead.to_string())], + )) } else { - SharedString::from("推送") + SharedString::from(language.text(Message::VcsPush)) }) .small() .disabled(op_running || ahead == 0) @@ -893,7 +926,7 @@ impl NebulaWorkspace { .when(browsing_elsewhere, |panel| { panel.child( Button::new("vcs-follow-cwd") - .label("回到终端当前目录") + .label(language.text(Message::VcsFollowDirectory)) .ghost() .xsmall() .on_click(cx.listener(|this, _, _, cx| { @@ -918,11 +951,11 @@ impl NebulaWorkspace { .when_some(action_strip, |panel, row| panel.child(row)) .when(git.is_none(), |panel| { panel.child( - div().py_3().text_sm().text_color(muted).child("当前目录不在 Git/SVN 仓库中"), + div().py_3().text_sm().text_color(muted).child(language.text(Message::VcsNotRepository)), ) }) - .when_some(self.side_panel.root_notice(), |panel, notice| { - panel.child(div().text_xs().text_color(theme.warning).child(notice.to_owned())) + .when_some(self.side_panel.localized_root_notice(language), |panel, notice| { + panel.child(div().text_xs().text_color(theme.warning).child(notice)) }) .child( v_flex() @@ -940,7 +973,7 @@ impl NebulaWorkspace { .icon(IconName::Redo2) .ghost() .xsmall() - .tooltip(format!("刷新 {vcs_label} 状态")) + .tooltip(language.format(Message::VcsRefreshStatus, &[("vcs", vcs_label)])) .on_click(cx.listener(|this, _, _, cx| { this.side_panel.request_refresh(); this.sync_side_panel_to_active(false, cx); @@ -952,7 +985,7 @@ impl NebulaWorkspace { .icon(IconName::Close) .ghost() .xsmall() - .tooltip(format!("关闭 {vcs_label} 状态")) + .tooltip(language.format(Message::VcsCloseStatus, &[("vcs", vcs_label)])) .on_click(cx.listener(|this, _, _, cx| { this.toggle_git_tree(cx); })), @@ -1019,11 +1052,11 @@ impl NebulaWorkspace { target: &gpui::WeakEntity, path: &str, index: usize, - section: &str, + section_id: &str, ) -> impl IntoElement { let target = target.clone(); let path = path.to_owned(); - Button::new(SharedString::from(format!("svn-row-more-{section}-{index}"))) + Button::new(SharedString::from(format!("svn-row-more-{section_id}-{index}"))) .icon(IconName::Ellipsis) .ghost() .xsmall() @@ -1427,31 +1460,17 @@ fn human_size(bytes: u64) -> String { /// 提交时间的人读形式。Git 提供 Unix 秒,显示层自己计算,结果不受机器上 /// `git log` 的 locale 影响;未来时间(系统时钟回拨)按“刚刚”处理。 -fn git_relative_time(timestamp: i64) -> String { +fn git_relative_time(timestamp: i64, language: UiLanguage) -> String { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs() as i64) .unwrap_or(timestamp); - git_relative_time_at(timestamp, now) -} - -fn git_relative_time_at(timestamp: i64, now: i64) -> String { - let seconds = now.saturating_sub(timestamp).max(0); - match seconds { - 0..=59 => "刚刚".to_owned(), - 60..=3599 => format!("{} 分钟前", seconds / 60), - 3600..=86_399 => format!("{} 小时前", seconds / 3600), - 86_400..=2_592_000 => format!("{} 天前", seconds / 86_400), - 2_592_001..=31_536_000 => format!("{} 个月前", seconds / 2_592_000), - _ => format!("{} 年前", seconds / 31_536_000), - } + git_relative_time_at(timestamp, now, language) } #[cfg(test)] mod tests { - use super::{ - GitLaneEdgeKind, GitRefKind, git_graph_rows, git_ref_labels, git_relative_time_at, - }; + use super::{GitLaneEdgeKind, GitRefKind, git_graph_rows, git_ref_labels}; use crate::display::side_panel::GitCommit; #[test] @@ -1500,16 +1519,4 @@ mod tests { assert_eq!(refs[3].label, "feature/ui"); assert_eq!(refs[3].kind, GitRefKind::Local); } - - #[test] - fn git_history_relative_time_uses_stable_boundaries() { - let now = 100_000_000; - assert_eq!(git_relative_time_at(now + 1, now), "刚刚"); - assert_eq!(git_relative_time_at(now - 59, now), "刚刚"); - assert_eq!(git_relative_time_at(now - 60, now), "1 分钟前"); - assert_eq!(git_relative_time_at(now - 3_600, now), "1 小时前"); - assert_eq!(git_relative_time_at(now - 86_400, now), "1 天前"); - assert_eq!(git_relative_time_at(now - 2_592_001, now), "1 个月前"); - assert_eq!(git_relative_time_at(now - 31_536_001, now), "1 年前"); - } } diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs new file mode 100644 index 0000000..c774b5d --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel/commit_input.rs @@ -0,0 +1,113 @@ +//! Keep the commit draft while refreshing its placeholder after a language change. + +use gpui::{App, AppContext as _, Entity, Window}; +use gpui_component::input::InputState; + +use crate::gpui_shell::config::ui_language; +use crate::i18n::{Message, UiLanguage}; + +pub(in crate::gpui_shell::workspace) struct CommitInput { + pub(in crate::gpui_shell::workspace) input: Entity, + language: UiLanguage, +} + +#[cfg(all(test, feature = "gpui-test-support"))] +mod tests { + use super::*; + use gpui::{Element as _, IntoElement as _, Render, RenderOnce as _, div}; + use gpui_component::{ElementExt as _, input::Input}; + use std::sync::{Arc, Mutex}; + + struct CommitInputProbe { + commit: CommitInput, + placeholder: Arc>>, + } + + impl Render for CommitInputProbe { + fn render( + &mut self, + window: &mut Window, + cx: &mut gpui::Context, + ) -> impl gpui::IntoElement { + self.commit.sync_language(window, cx); + let input = self.commit.input.clone(); + let placeholder = self.placeholder.clone(); + div().on_prepaint(move |_, window, cx| { + let element = Input::new(&input).render(window, cx).into_element(); + let mut node = gpui::accesskit::Node::new(gpui::accesskit::Role::TextInput); + element.write_a11y_info(&mut node); + *placeholder.lock().unwrap() = node.placeholder().map(str::to_owned); + }) + } + } + + #[gpui::test] + fn commit_placeholder_tracks_language_without_replacing_the_draft( + cx: &mut gpui::TestAppContext, + ) { + cx.update(gpui_component::init); + let placeholder = Arc::new(Mutex::new(None)); + let captured = placeholder.clone(); + // The early-startup fallback is English even before Settings is registered. + let (probe, cx) = cx.add_window_view(|window, cx| CommitInputProbe { + commit: CommitInput::new(window, cx), + placeholder, + }); + cx.update(|window, cx| { + let _ = window.draw(cx); + }); + assert_eq!(captured.lock().unwrap().as_deref(), Some("Commit message...")); + let input = probe.read_with(cx, |probe, _| probe.commit.input.clone()); + cx.update(|window, cx| { + input.update(cx, |input, cx| { + input.set_value("fix: 保留 draft", window, cx); + input.set_selected_range(0..3, cx); + }); + }); + let original_id = input.entity_id(); + let original_cursor = input.read_with(cx, |input, _| input.cursor_position()); + let original_selection = input.read_with(cx, |input, _| input.selected_value()); + assert_eq!(original_selection, "fix"); + for (language, expected) in [ + (UiLanguage::ZhCn, "提交信息…"), + (UiLanguage::for_locale(Some("en-GB")), "Commit message..."), + (UiLanguage::FrFr, "Commit message..."), + (UiLanguage::ZhCn, "提交信息…"), + ] { + cx.update(|window, cx| { + let mut settings = + crate::gpui_shell::config::Settings::load(nebula_settings::ThemeName::Nord); + settings.ui_language = language; + cx.set_global(settings); + probe.update(cx, |_, cx| cx.notify()); + let _ = window.draw(cx); + }); + assert_eq!(captured.lock().unwrap().as_deref(), Some(expected)); + assert_eq!(probe.read_with(cx, |probe, _| probe.commit.input.entity_id()), original_id); + assert_eq!(input.read_with(cx, |input, _| input.value()), "fix: 保留 draft"); + assert_eq!(input.read_with(cx, |input, _| input.cursor_position()), original_cursor); + assert_eq!(input.read_with(cx, |input, _| input.selected_value()), original_selection); + } + } +} + +impl CommitInput { + pub(in crate::gpui_shell::workspace) fn new(window: &mut Window, cx: &mut App) -> Self { + let language = ui_language(cx); + let input = cx.new(|cx| { + InputState::new(window, cx).placeholder(language.text(Message::VcsCommitPlaceholder)) + }); + Self { input, language } + } + + pub(super) fn sync_language(&mut self, window: &mut Window, cx: &mut App) { + let language = ui_language(cx); + if self.language == language { + return; + } + self.input.update(cx, |input, cx| { + input.set_placeholder(language.text(Message::VcsCommitPlaceholder), window, cx); + }); + self.language = language; + } +} diff --git a/nebula_app/src/gpui_shell/workspace/vcs_panel/relative_time.rs b/nebula_app/src/gpui_shell/workspace/vcs_panel/relative_time.rs new file mode 100644 index 0000000..1b5285d --- /dev/null +++ b/nebula_app/src/gpui_shell/workspace/vcs_panel/relative_time.rs @@ -0,0 +1,61 @@ +//! Localized Git history timestamps, independent of the renderer and system clock. + +use crate::i18n::{Message, UiLanguage}; + +pub(super) fn git_relative_time_at(timestamp: i64, now: i64, language: UiLanguage) -> String { + let seconds = now.saturating_sub(timestamp).max(0); + let (count, singular, plural) = match seconds { + 0..=59 => return language.text(Message::VcsTimeNow).to_owned(), + 60..=3599 => (seconds / 60, Message::VcsTimeMinute, Message::VcsTimeMinutes), + 3600..=86_399 => (seconds / 3600, Message::VcsTimeHour, Message::VcsTimeHours), + 86_400..=2_592_000 => (seconds / 86_400, Message::VcsTimeDay, Message::VcsTimeDays), + 2_592_001..=31_536_000 => { + (seconds / 2_592_000, Message::VcsTimeMonth, Message::VcsTimeMonths) + }, + _ => (seconds / 31_536_000, Message::VcsTimeYear, Message::VcsTimeYears), + }; + if count == 1 { + language.text(singular).to_owned() + } else { + language.format(plural, &[("count", &count.to_string())]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn git_history_relative_time_uses_stable_boundaries() { + let now = 100_000_000; + assert_eq!(git_relative_time_at(now + 1, now, UiLanguage::ZhCn), "刚刚"); + assert_eq!(git_relative_time_at(now - 59, now, UiLanguage::ZhCn), "刚刚"); + assert_eq!(git_relative_time_at(now - 60, now, UiLanguage::ZhCn), "1 分钟前"); + assert_eq!(git_relative_time_at(now - 3_600, now, UiLanguage::ZhCn), "1 小时前"); + assert_eq!(git_relative_time_at(now - 86_400, now, UiLanguage::ZhCn), "1 天前"); + assert_eq!(git_relative_time_at(now - 2_592_001, now, UiLanguage::ZhCn), "1 个月前"); + assert_eq!(git_relative_time_at(now - 31_536_001, now, UiLanguage::ZhCn), "1 年前"); + } + + #[test] + fn git_history_time_uses_the_active_language_and_english_fallback() { + let now = 100_000_000; + for (seconds, expected) in [ + (-1, "just now"), + (59, "just now"), + (60, "1 minute ago"), + (120, "2 minutes ago"), + (3_600, "1 hour ago"), + (7_200, "2 hours ago"), + (86_400, "1 day ago"), + (172_800, "2 days ago"), + (2_592_001, "1 month ago"), + (5_184_000, "2 months ago"), + (31_536_001, "1 year ago"), + (63_072_000, "2 years ago"), + ] { + assert_eq!(git_relative_time_at(now - seconds, now, UiLanguage::EnUs), expected); + assert_eq!(git_relative_time_at(now - seconds, now, UiLanguage::FrFr), expected); + } + } +} diff --git a/nebula_app/tests/i18n_contract.rs b/nebula_app/tests/i18n_contract.rs index 0862483..42062dc 100644 --- a/nebula_app/tests/i18n_contract.rs +++ b/nebula_app/tests/i18n_contract.rs @@ -2,6 +2,10 @@ mod catalog_builder; #[path = "../src/i18n/mod.rs"] mod i18n; +#[path = "../src/display/side_panel/notice.rs"] +mod panel_notice; +#[path = "../src/gpui_shell/workspace/vcs_panel/relative_time.rs"] +mod vcs_relative_time; use std::alloc::{GlobalAlloc, Layout, System}; use std::cell::Cell; @@ -48,6 +52,8 @@ fn first_and_repeated_translation_lookups_allocate_nothing() { for language in i18n::UiLanguage::ALL { for _ in 0..1_000 { black_box(language.text(black_box(i18n::Message::SettingsSidebarNetwork))); + black_box(language.text(black_box(i18n::Message::VcsChanges))); + black_box(language.text(black_box(i18n::Message::VcsCommitPlaceholder))); black_box(language.tr(black_box("settings.sidebar.network"))); black_box(language.pick(black_box("网络"), black_box("Network"))); black_box(language.pick(black_box("新文案"), black_box("Unmigrated text"))); @@ -62,3 +68,13 @@ fn embedded_translations_stay_within_the_initial_payload_budget() { assert!(i18n::TRANSLATED_BYTES < 256 * 1024); assert!(i18n::MESSAGE_COUNT >= 200); } + +#[test] +fn vcs_messages_follow_resolved_english_and_fall_back_for_partial_locales() { + let english = i18n::UiLanguage::for_locale(Some("en-GB")); + assert_eq!(english.tr("vcs.changes"), "Changes"); + assert_eq!(english.tr("vcs.commit_placeholder"), "Commit message..."); + assert_eq!(i18n::UiLanguage::ZhCn.tr("vcs.changes"), "变更"); + assert_eq!(i18n::UiLanguage::FrFr.tr("vcs.changes"), "Changes"); + assert_eq!(english.tr_args("vcs.refresh_status", &[("vcs", "Git")]), "Refresh Git status"); +}