Merge remote-tracking branch 'origin/main' into review-fix/pr115

This commit is contained in:
kuddev committed 2026-09-13 20:59:25 +08:00
commit 073adf34c9
18 files changed
+649 -123

No files matched your search

+24
View File
@@ -105,6 +105,30 @@ Files successfully loaded through `require` are added to the live-reload watch
list. Extra local files can be registered with
`pebrel.add_to_config_reload_watch_list(path)`.
## Terminal Selection Colors (TOML Compatibility)
For a legacy `pebrel.toml` configuration, the GPUI terminal accepts explicit
selection foreground and background colors in `#rrggbb` or `0xrrggbb` form:
```toml
[colors.selection]
foreground = "#102030"
background = "#e5e9f0"
```
Previously the GPUI adapter loaded the selection background but ignored the
foreground, which could leave selected text unreadable against a custom
background. It also cleared a custom foreground when applying a theme such as
Paper that does not declare selection colors. Both values now survive loading
and themes that do not override them. A theme that explicitly declares selection
colors, such as Nord, retains its existing precedence.
Missing or invalid foreground colors keep the existing fallback. Relative
`CellForeground` / `CellBackground` values are not added by this change. The
regression tests cover parsing and theme precedence; this does not change the
answer reader's selection overlay, formula selection layout, or the default
selection contrast. No new setting, dependency, or per-frame work is introduced.
## Platform Values
The module exposes stable runtime paths and platform data:
+47
View File
@@ -378,6 +378,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}",
@@ -464,5 +466,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}"
}
}
+47
View File
@@ -378,6 +378,8 @@
"save_copy": "保存副本"
},
"files": {
"path_unavailable": "路径已不存在",
"delete_failed": "删除失败:{error}",
"ignore_add": "加入 .gitignore",
"ignore_remove": "从 .gitignore 移除",
"ignore_added": "已写入 .gitignore:{entry}",
@@ -464,5 +466,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}"
}
}
+14 -8
View File
@@ -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<crate::shell_detect::WslCwd>,
/// Visible feedback for an invalid/disappeared custom root.
root_notice: Option<String>,
root_notice: Option<PanelNotice>,
/// Flattened visible tree rows for the Files view.
rows: Vec<FileRow>,
/// 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<std::sync::atomic::AtomicBool>,
/// Last operation's error (empty = success), shown on the summary line.
op_error: std::sync::Arc<std::sync::Mutex<String>>,
op_error: std::sync::Arc<std::sync::Mutex<PanelNotice>>,
/// 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<String> {
self.localized_root_notice(crate::i18n::UiLanguage::ZhCn)
}
pub fn localized_root_notice(&self, language: crate::i18n::UiLanguage) -> Option<String> {
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<PanelNotice>) {
self.root_notice = Some(message.into());
}
fn current_index_root(&self) -> Option<FileIndexRoot> {
+130
View File
@@ -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<String> 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);
}
}
}
+1 -1
View File
@@ -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(&notice, summary_cols), Rgb::new(sk.danger.r, sk.danger.g, sk.danger.b))
} else {
(
panel
+7 -2
View File
@@ -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]
+13 -9
View File
@@ -252,11 +252,15 @@ impl SidePanel {
/// Last mutation's error, if any (cleared by the next successful op).
pub fn op_error(&self) -> Option<String> {
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<String>) {
pub fn localized_op_error(&self, language: crate::i18n::UiLanguage) -> Option<String> {
let text = self.op_error.lock().ok()?.text(language);
(!text.is_empty()).then_some(text)
}
fn set_op_error(&mut self, message: impl Into<PanelNotice>) {
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()));
}
}
+70 -2
View File
@@ -270,7 +270,9 @@ fn apply_theme(palette: &mut Palette, theme: nebula_settings::ThemeName) {
if let Some(selection) = exact.selection_background {
palette.selection = rgba8(selection);
}
palette.selection_foreground = exact.selection_foreground.map(rgba8);
if let Some(selection_foreground) = exact.selection_foreground {
palette.selection_foreground = Some(rgba8(selection_foreground));
}
return;
}
if term.is_light {
@@ -552,6 +554,7 @@ fn build_palette(raw: &RawColors) -> Palette {
// 用户显式选区色保持主应用的不透明语义。
palette.selection = selection;
}
palette.selection_foreground = raw.selection.foreground.as_deref().and_then(parse_rgb);
let ansi8 = |group: &RawAnsi8| -> [Option<gpui::Rgba>; 8] {
[
@@ -604,7 +607,10 @@ fn build_palette(raw: &RawColors) -> Palette {
#[cfg(test)]
mod tests {
use super::{Settings, apply_theme, resolve_ui_language, rgba8, runtime_background};
use super::{
RawColors, Settings, apply_theme, build_palette, resolve_ui_language, rgba8,
runtime_background,
};
use crate::display::UiLanguage;
use crate::gpui_shell::terminal::colors::Palette;
use nebula_settings::{LanguagePref, ThemeName};
@@ -652,6 +658,68 @@ mod tests {
assert_eq!(palette.selection_foreground, None);
}
#[test]
fn explicit_selection_colors_are_loaded_together() {
let raw: RawColors = toml::from_str(
r##"
[selection]
foreground = "#102030"
background = "0xe5e9f0"
"##,
)
.unwrap();
let palette = build_palette(&raw);
assert_eq!(palette.selection_foreground, Some(rgba8([0x10, 0x20, 0x30])));
assert_eq!(palette.selection, rgba8([0xe5, 0xe9, 0xf0]));
}
#[test]
fn invalid_or_relative_selection_foreground_keeps_default() {
for foreground in ["#invalid", "#fff", "CellForeground", "CellBackground"] {
let raw: RawColors = toml::from_str(&format!(
"[selection]\nforeground = {foreground:?}\nbackground = \"#e5e9f0\"\n"
))
.unwrap();
let palette = build_palette(&raw);
assert_eq!(palette.selection_foreground, None, "{foreground}");
assert_eq!(palette.selection, rgba8([0xe5, 0xe9, 0xf0]));
}
}
#[test]
fn theme_without_selection_colors_preserves_user_foreground() {
let foreground = rgba8([0x10, 0x20, 0x30]);
let background = rgba8([0xe5, 0xe9, 0xf0]);
for theme in [ThemeName::Nebula, ThemeName::Paper] {
let mut palette = Palette {
selection_foreground: Some(foreground),
selection: background,
..Palette::default()
};
apply_theme(&mut palette, theme);
assert_eq!(palette.selection_foreground, Some(foreground));
assert_eq!(palette.selection, background);
}
}
#[test]
fn theme_with_selection_colors_keeps_its_existing_precedence() {
let mut palette = Palette {
selection_foreground: Some(rgba8([1, 2, 3])),
selection: rgba8([4, 5, 6]),
..Palette::default()
};
apply_theme(&mut palette, ThemeName::Nord);
assert_eq!(palette.selection_foreground, Some(rgba8([0x2e, 0x34, 0x40])));
assert_eq!(palette.selection, rgba8([0xe5, 0xe9, 0xf0]));
}
#[test]
fn themes_apply_only_the_colors_they_declare() {
let mut nord = Palette::default();
+1 -1
View File
@@ -27,7 +27,7 @@ pub struct Palette {
/// 选区叠加色。主应用默认是反色语义;本壳以半透明叠加近似,
/// 用户显式配置 `colors.selection.background` 时用不透明具体色。
pub selection: Rgba,
/// 主题明确声明的选区文字色;普通 Nebula 主题保留原字色。
/// 主题或用户明确声明的选区文字色;未声明时保留原字色。
pub selection_foreground: Option<Rgba>,
/// ANSI 0-15。
pub ansi: [Rgba; 16],
+5 -5
View File
@@ -920,7 +920,7 @@ pub struct NebulaWorkspace {
_command_manager_subscription: Subscription,
/// Git/SVN 提交信息输入(GPUI 输入组件);提交动作直达共享模型
/// `vcs_commit_message`,不经旧壳的内部输入状态机。
git_commit_input: Entity<InputState>,
git_commit_input: vcs_panel::CommitInput,
/// Git 树"丢弃改动"的二次确认(路径);任何其他 VCS 操作都清掉它。
vcs_discard_confirm: Option<String>,
/// 命令面板的行覆盖:`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<Self>) {
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()
@@ -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<Self>,
) {
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();
@@ -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);
})),
@@ -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))),
),
)
@@ -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<Self>) -> 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<Self>) -> gpui::AnyElement {
pub(super) fn render_git_tree(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>,
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 年前");
}
}
@@ -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<InputState>,
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<Mutex<Option<String>>>,
}
impl Render for CommitInputProbe {
fn render(
&mut self,
window: &mut Window,
cx: &mut gpui::Context<Self>,
) -> 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;
}
}
@@ -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);
}
}
}
+16
View File
@@ -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");
}