fix(i18n): make Git drawer follow the application language
This commit is contained in:
1 parent
ce068600b2
commit
87138f20a6
15 files changed
+554
-120
No files matched your search
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in new issue
Block a user