diff --git a/.codegraph-wsl/.gitignore b/.codegraph-wsl/.gitignore new file mode 100644 index 0000000..d20c0fe --- /dev/null +++ b/.codegraph-wsl/.gitignore @@ -0,0 +1,5 @@ +# CodeGraph data files — local to each machine, not for committing. +# Ignore everything in .codegraph/ except this file itself, so transient +# files (the database, daemon.pid, sockets, logs) never show up in git. +* +!.gitignore diff --git a/crates/rgitui_diff/src/lib.rs b/crates/rgitui_diff/src/lib.rs index f04a3b7..f9699d6 100644 --- a/crates/rgitui_diff/src/lib.rs +++ b/crates/rgitui_diff/src/lib.rs @@ -21,6 +21,7 @@ use rgitui_git::{ ConflictSnapshot, DiffLine, FileDiff, MergeSection, ThreeWayFileDiff, WorktreePatchDirection, WorktreePatchScope, WorktreePatchSource, }; +use rgitui_settings::{SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Appearance, Color, StyledExt, ThemeState}; use rgitui_ui::{ Badge, Button, ButtonSize, ButtonStyle, EstimatedListScroll, Icon, IconName, IconSize, Label, @@ -885,6 +886,9 @@ impl DiffViewer { this.rehighlight(cx); }) .detach(); + // Re-render immediately when the interface language changes. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); Self { diff: None, @@ -3412,11 +3416,12 @@ impl DiffViewer { if self.file_menu_operations().is_empty() { return div().into_any_element(); } + let language = cx.global::().settings().language; let tooltip: SharedString = match self.source.revision_label() { Some(revision) => format!("Apply or revert this whole file from {revision}").into(), None => "Whole-file operations".into(), }; - Button::new("diff-file-menu", "File") + Button::new("diff-file-menu", language.tr(TrKey::DiffFileBtn)) .size(ButtonSize::Compact) .style(ButtonStyle::Subtle) .tooltip(tooltip) @@ -3549,6 +3554,7 @@ impl Render for DiffViewer { self.sbs_rows.len() ); let colors = cx.colors(); + let language = cx.global::().settings().language; let has_content = self.diff.is_some() || self.three_way_diff.is_some(); if self.error.is_some() || self.loading || !has_content { @@ -3610,7 +3616,7 @@ impl Render for DiffViewer { ) .when(is_conflict, |element| { element.child( - Button::new("conflict-error-open", "Open File") + Button::new("conflict-error-open", language.tr(TrKey::DiffOpenFile)) .size(ButtonSize::Compact) .style(ButtonStyle::Outlined) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -3631,12 +3637,12 @@ impl Render for DiffViewer { .color(Color::Placeholder), ) .child( - Label::new("Select a file to view changes") + Label::new(language.tr(TrKey::DiffEmptyTitle)) .size(LabelSize::Small) .color(Color::Muted), ) .child( - Label::new("Click a file in the sidebar or detail panel") + Label::new(language.tr(TrKey::DiffEmptyDesc)) .size(LabelSize::XSmall) .color(Color::Placeholder), ) @@ -5207,7 +5213,7 @@ impl Render for DiffViewer { )), ) .child( - Button::new("conflict-open-editor", "Open File") + Button::new("conflict-open-editor", language.tr(TrKey::DiffOpenFile)) .size(ButtonSize::Compact) .style(ButtonStyle::Subtle) .tooltip("Open the working file in your configured editor") diff --git a/crates/rgitui_git/src/project/diff.rs b/crates/rgitui_git/src/project/diff.rs index ae36c31..0fdf835 100644 --- a/crates/rgitui_git/src/project/diff.rs +++ b/crates/rgitui_git/src/project/diff.rs @@ -1,9 +1,11 @@ use anyhow::Result; use git2::{DiffOptions, IndexEntry, MergeFileOptions, Repository}; +use rgitui_settings::TrKey; use std::path::{Path, PathBuf}; use crate::types::*; +use super::op_i18n::{op_err_text, op_msg}; use super::refresh::gather_refresh_data_lightweight_cached; use super::RefreshData; @@ -780,8 +782,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Stage, - "Stage hunk failed", - e.to_string(), + op_msg(cx, TrKey::ErrStageHunkFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -863,8 +865,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Unstage, - "Unstage hunk failed", - e.to_string(), + op_msg(cx, TrKey::ErrUnstageHunkFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -953,8 +955,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Stage, - "Stage lines failed", - e.to_string(), + op_msg(cx, TrKey::ErrStageLinesFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -1045,8 +1047,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Unstage, - "Unstage lines failed", - e.to_string(), + op_msg(cx, TrKey::ErrUnstageLinesFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); diff --git a/crates/rgitui_git/src/project/local_ops.rs b/crates/rgitui_git/src/project/local_ops.rs index 57cc804..df4f7cf 100644 --- a/crates/rgitui_git/src/project/local_ops.rs +++ b/crates/rgitui_git/src/project/local_ops.rs @@ -5,10 +5,12 @@ use std::io::{Read as _, Seek as _, Write as _}; use std::path::{Path, PathBuf}; use rgitui_settings::current_git_auth_runtime; +use rgitui_settings::TrKey; use crate::types::*; use super::auth::inject_https_credentials; +use super::op_i18n::{op_err_text, op_error, op_msg}; use super::refresh::{gather_refresh_data, gather_refresh_data_lightweight_cached}; use super::worktree_patch::{clean_worktree_bytes, smudge_canonical_bytes}; use super::{ensure_clean_worktree, head_branch_name, GitProject, GitProjectEvent, RefreshData}; @@ -29,7 +31,7 @@ use super::{ensure_clean_worktree, head_branch_name, GitProject, GitProjectEvent fn finish_merge_commit(repo: &Repository) -> Result { let merge_head_path = repo.path().join("MERGE_HEAD"); if !merge_head_path.exists() { - anyhow::bail!("Repository is not in a merge state (no MERGE_HEAD to continue)."); + return Err(op_error(TrKey::ErrNotMergeState, vec![])); } let mut index = repo.index()?; @@ -139,17 +141,20 @@ fn checkout_tree_safe(repo: &Repository, target: &git2::Object<'_>, operation: & Err(err) if err.code() == git2::ErrorCode::Conflict => { let paths = conflicts.into_inner(); if paths.is_empty() { - anyhow::bail!( - "{} would overwrite local changes. Commit, stash, or discard them first.", - operation - ); + return Err(op_error( + TrKey::ErrOverwriteLocalFmt, + vec![operation.to_string()], + )); } - anyhow::bail!( - "{} would overwrite {}. Commit, stash, move, or delete {} first.", - operation, - format_path_list(&paths), - if paths.len() == 1 { "it" } else { "them" } - ); + let key = if paths.len() == 1 { + TrKey::ErrOverwritePathOneFmt + } else { + TrKey::ErrOverwritePathManyFmt + }; + Err(op_error( + key, + vec![operation.to_string(), format_path_list(&paths)], + )) } Err(err) => Err(err.into()), } @@ -191,10 +196,10 @@ impl GitProject { let mut index = repo.index()?; for path in &task_paths { if index.conflict_get(path).is_ok() { - anyhow::bail!( - "'{}' has unresolved conflicts. Open the conflict resolver before staging it.", - path.display() - ); + return Err(op_error( + TrKey::ErrStagingConflictFmt, + vec![path.display().to_string()], + )); } if worktree_path.join(path).exists() { index.add_path(path)?; @@ -235,8 +240,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Stage, - "Stage failed", - e.to_string(), + op_msg(cx, TrKey::ErrStageFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -328,8 +333,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Unstage, - "Unstage failed", - e.to_string(), + op_msg(cx, TrKey::ErrUnstageFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -417,8 +422,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Stage, - "Stage all failed", - e.to_string(), + op_msg(cx, TrKey::ErrStageAllFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -488,8 +493,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Unstage, - "Unstage all failed", - e.to_string(), + op_msg(cx, TrKey::ErrUnstageAllFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -538,7 +543,7 @@ impl GitProject { let sig = repo.signature()?; let mut index = repo.index()?; if index.is_empty() { - anyhow::bail!("There are no staged changes to commit.") + return Err(op_error(TrKey::ErrNoStagedChanges, vec![])); } let tree_oid = index.write_tree()?; let tree = repo.find_tree(tree_oid)?; @@ -556,9 +561,7 @@ impl GitProject { | git2::RepositoryState::RebaseMerge ) { - anyhow::bail!( - "Cannot amend during a rebase. Continue or abort the rebase first." - ); + return Err(op_error(TrKey::ErrCannotAmendRebase, vec![])); } // A plain commit made while a merge is in progress must finalize @@ -711,11 +714,11 @@ impl GitProject { operation_id, GitOperationKind::Commit, if amend { - "Amend failed" + op_msg(cx, TrKey::ErrAmendFailed, vec![]) } else { - "Commit failed" + op_msg(cx, TrKey::ErrCommitFailed, vec![]) }, - e.to_string(), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -761,11 +764,10 @@ impl GitProject { let remote_ref = format!("refs/remotes/{}", task_name); if let Ok(remote_obj) = repo.revparse_single(&remote_ref) { let Some((_remote, short)) = task_name.split_once('/') else { - anyhow::bail!( - "Invalid remote branch name '{}'. \ - Expected 'remote/branch' format.", - task_name - ); + return Err(op_error( + TrKey::ErrInvalidRemoteBranchFmt, + vec![task_name.clone()], + )); }; let local_branch_name = short; @@ -774,11 +776,10 @@ impl GitProject { .find_branch(local_branch_name, git2::BranchType::Local) .is_ok() { - anyhow::bail!( - "A local branch named '{}' already exists. \ - Please delete or rename it first.", - local_branch_name - ); + return Err(op_error( + TrKey::ErrLocalBranchExistsFmt, + vec![local_branch_name.to_string()], + )); } // Create the local tracking branch at the remote's commit. @@ -794,18 +795,20 @@ impl GitProject { (remote_obj, local_branch_name.to_string(), true) } else { - anyhow::bail!( - "Branch '{}' not found as a local or remote branch. \ - Try fetching to update remote refs.", - task_name - ); + return Err(op_error( + TrKey::ErrBranchNotFoundFmt, + vec![task_name.clone()], + )); } } }; // Bail if already on the target branch (use local name for tracking). if current_branch.as_deref() == Some(local_branch_name.as_str()) { - anyhow::bail!("Already on branch '{}'.", local_branch_name); + return Err(op_error( + TrKey::ErrAlreadyOnBranchFmt, + vec![local_branch_name.clone()], + )); } let head_ref = if is_tracking { @@ -852,8 +855,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Checkout, - format!("Checkout of '{}' failed", name), - e.to_string(), + op_msg(cx, TrKey::ErrCheckoutBranchFailedFmt, vec![name.clone()]), + op_err_text(cx, &e), (None, Some(name.clone()), true), cx, ); @@ -915,8 +918,12 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Checkout, - format!("Checkout of {} failed", short_id), - e.to_string(), + op_msg( + cx, + TrKey::ErrCheckoutCommitFailedFmt, + vec![short_id.clone()], + ), + op_err_text(cx, &e), (None, Some(short_id.clone()), true), cx, ); @@ -981,8 +988,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Checkout, - format!("Checkout of tag '{}' failed", name), - e.to_string(), + op_msg(cx, TrKey::ErrCheckoutTagFailedFmt, vec![name.clone()]), + op_err_text(cx, &e), (None, Some(name.clone()), true), cx, ); @@ -1028,7 +1035,7 @@ impl GitProject { } else { let obj = repo.revparse_single(r)?; obj.peel_to_commit().map_err(|_| { - anyhow::anyhow!("'{}' does not resolve to a commit", r) + op_error(TrKey::ErrBaseNotCommitFmt, vec![r.to_string()]) })? } } else { @@ -1061,8 +1068,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Branch, - format!("Branch '{}' could not be created", name), - e.to_string(), + op_msg(cx, TrKey::ErrBranchCreateFailedFmt, vec![name.clone()]), + op_err_text(cx, &e), (None, Some(name.clone()), false), cx, ); @@ -1118,8 +1125,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Branch, - format!("Delete branch '{}' failed", name), - e.to_string(), + op_msg(cx, TrKey::ErrBranchDeleteFailedFmt, vec![name.clone()]), + op_err_text(cx, &e), (None, Some(name.clone()), false), cx, ); @@ -1182,8 +1189,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Branch, - format!("Rename branch '{}' failed", old_name), - e.to_string(), + op_msg(cx, TrKey::ErrBranchRenameFailedFmt, vec![old_name.clone()]), + op_err_text(cx, &e), (None, Some(old_name.clone()), false), cx, ); @@ -1244,8 +1251,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Tag, - format!("Tag '{}' could not be created", name), - e.to_string(), + op_msg(cx, TrKey::ErrTagCreateFailedFmt, vec![name.clone()]), + op_err_text(cx, &e), (None, this.head_branch.clone(), false), cx, ); @@ -1300,8 +1307,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Tag, - format!("Delete tag '{}' failed", name), - e.to_string(), + op_msg(cx, TrKey::ErrTagDeleteFailedFmt, vec![name.clone()]), + op_err_text(cx, &e), (None, this.head_branch.clone(), false), cx, ); @@ -1364,8 +1371,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Stash, - "Save stash failed", - e.to_string(), + op_msg(cx, TrKey::ErrStashSaveFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -1445,8 +1452,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Stash, - format!("Pop stash #{} failed", index), - e.to_string(), + op_msg(cx, TrKey::ErrStashPopFailedFmt, vec![index.to_string()]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -1530,8 +1537,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Stash, - format!("Apply stash #{} failed", index), - e.to_string(), + op_msg(cx, TrKey::ErrStashApplyFailedFmt, vec![index.to_string()]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -1585,8 +1592,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Stash, - format!("Drop stash #{} failed", index), - e.to_string(), + op_msg(cx, TrKey::ErrStashDropFailedFmt, vec![index.to_string()]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -1649,7 +1656,10 @@ impl GitProject { })?; let stash_oid = *stash_oids.get(stash_index).ok_or_else(|| { - anyhow::anyhow!("Stash index {} out of range", stash_index) + op_error( + TrKey::ErrStashIndexOutOfRangeFmt, + vec![stash_index.to_string()], + ) })?; // `git stash branch`: create the branch at the commit HEAD was @@ -1699,8 +1709,12 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Stash, - format!("Create branch from stash #{} failed", stash_index), - e.to_string(), + op_msg( + cx, + TrKey::ErrStashBranchFailedFmt, + vec![stash_index.to_string()], + ), + op_err_text(cx, &e), (None, current_branch, false), cx, ); @@ -1803,8 +1817,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Discard, - "Discard changes failed", - e.to_string(), + op_msg(cx, TrKey::ErrDiscardFailed, vec![]), + op_err_text(cx, &e), (None, this.head_branch.clone(), false), cx, ); @@ -1911,8 +1925,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Clean, - "Clean failed", - e.to_string(), + op_msg(cx, TrKey::ErrCleanFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -1976,8 +1990,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Reset, - "Reset to HEAD failed", - e.to_string(), + op_msg(cx, TrKey::ErrResetHeadFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -2047,8 +2061,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Reset, - format!("Reset to {} failed", short_id), - e.to_string(), + op_msg(cx, TrKey::ErrResetFailedFmt, vec![short_id.clone()]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -2116,8 +2130,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Reset, - format!("Soft reset to {} failed", short_id), - e.to_string(), + op_msg(cx, TrKey::ErrResetSoftFailedFmt, vec![short_id.clone()]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -2187,8 +2201,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Reset, - format!("Mixed reset to {} failed", short_id), - e.to_string(), + op_msg(cx, TrKey::ErrResetMixedFailedFmt, vec![short_id.clone()]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -2253,8 +2267,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Revert, - format!("Revert of {} needs conflict resolution", short_id), - "Resolve the conflicts in the working tree, then commit the revert manually.".to_string(), + op_msg(cx, TrKey::ErrRevertConflictFmt, vec![short_id.clone()]), + op_msg(cx, TrKey::ErrRevertGuide, vec![]), (None, branch_name.clone(), false), cx, ); @@ -2276,8 +2290,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Revert, - format!("Revert of {} failed", short_id), - e.to_string(), + op_msg(cx, TrKey::ErrRevertFailedFmt, vec![short_id.clone()]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -2342,8 +2356,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::CherryPick, - format!("Cherry-pick of {} needs conflict resolution", short_id), - "Resolve the conflicts in the working tree, then commit the cherry-pick manually.".to_string(), + op_msg(cx, TrKey::ErrCherryPickConflictFmt, vec![short_id.clone()]), + op_msg(cx, TrKey::ErrCherryPickGuide, vec![]), (None, branch_name.clone(), false), cx, ); @@ -2365,8 +2379,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::CherryPick, - format!("Cherry-pick of {} failed", short_id), - e.to_string(), + op_msg(cx, TrKey::ErrCherryPickFailedFmt, vec![short_id.clone()]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -2446,8 +2460,12 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Merge, - format!("Failed to abort {}", state_label.to_lowercase()), - e.to_string(), + op_msg( + cx, + TrKey::ErrAbortFailedFmt, + vec![state_label.to_lowercase()], + ), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -2500,9 +2518,7 @@ impl GitProject { let index = repo.index()?; if index.has_conflicts() { - anyhow::bail!( - "There are still unresolved conflicts. Resolve all conflicts before continuing." - ); + return Err(op_error(TrKey::ErrUnresolvedConflicts, vec![])); } drop(index); @@ -2512,10 +2528,12 @@ impl GitProject { drop(repo); run_continue_subcommand(&worktree_path, subcommand)? } - None => anyhow::bail!( - "There is no {} to continue.", - state.label().to_lowercase() - ), + None => { + return Err(op_error( + TrKey::ErrNothingToContinueFmt, + vec![state.label().to_lowercase()], + )); + } }; let data = gather_refresh_data(&repo_path, commit_limit)?; @@ -2541,8 +2559,12 @@ impl GitProject { this.fail_op( operation_id, kind, - format!("Could not continue {}", state_label.to_lowercase()), - e.to_string(), + op_msg( + cx, + TrKey::ErrContinueFailedFmt, + vec![state_label.to_lowercase()], + ), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -2667,7 +2689,7 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Merge, - format!("Merge conflicts in '{}'", branch_name), + op_msg(cx, TrKey::ErrMergeConflictFmt, vec![branch_name.clone()]), user_msg, (None, current_branch.clone(), false), cx, @@ -2687,8 +2709,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Merge, - format!("Merge of '{}' failed", branch_name), - e.to_string(), + op_msg(cx, TrKey::ErrMergeFailedFmt, vec![branch_name.clone()]), + op_err_text(cx, &e), (None, current_branch.clone(), false), cx, ); @@ -2748,8 +2770,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::RemoveRemote, - "Removing remote failed", - e.to_string(), + op_msg(cx, TrKey::ErrRemoveRemoteFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -2838,7 +2860,7 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Clone, - "Clone failed", + op_msg(cx, TrKey::ErrCloneFailed, vec![]), toast_message, (None, None, false), cx, @@ -2910,8 +2932,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Bisect, - "Failed to start bisect", - e.to_string(), + op_msg(cx, TrKey::ErrBisectStartFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -3016,8 +3038,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Bisect, - format!("Failed to mark {} as good", short_id), - e.to_string(), + op_msg(cx, TrKey::ErrBisectGoodFailedFmt, vec![short_id.clone()]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -3122,8 +3144,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Bisect, - format!("Failed to mark {} as bad", short_id), - e.to_string(), + op_msg(cx, TrKey::ErrBisectBadFailedFmt, vec![short_id.clone()]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -3210,7 +3232,7 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Bisect, - "Bisect exhausted".to_string(), + op_msg(cx, TrKey::ErrBisectExhausted, vec![]), msg, (None, branch_name.clone(), false), cx, @@ -3247,8 +3269,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Bisect, - format!("Failed to skip {}", short_id), - e.to_string(), + op_msg(cx, TrKey::ErrBisectSkipFailedFmt, vec![short_id.clone()]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -3315,8 +3337,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Bisect, - "Failed to reset bisect", - e.to_string(), + op_msg(cx, TrKey::ErrBisectResetFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); @@ -3391,8 +3413,12 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Worktree, - format!("Create worktree '{}' failed", name_clone), - e.to_string(), + op_msg( + cx, + TrKey::ErrWorktreeCreateFailedFmt, + vec![name_clone.clone()], + ), + op_err_text(cx, &e), (None, this.head_branch.clone(), false), cx, ); @@ -3456,8 +3482,12 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Worktree, - format!("Remove worktree '{}' failed", display_path), - e.to_string(), + op_msg( + cx, + TrKey::ErrWorktreeRemoveFailedFmt, + vec![display_path.clone()], + ), + op_err_text(cx, &e), (None, this.head_branch.clone(), false), cx, ); @@ -3569,8 +3599,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::ResolveConflict, - "Conflict resolution failed", - error.to_string(), + op_msg(cx, TrKey::ErrConflictResolveFailed, vec![]), + op_err_text(cx, &error), (None, branch_name.clone(), false), cx, ); @@ -3676,8 +3706,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::ResolveConflict, - "Conflict resolution failed", - e.to_string(), + op_msg(cx, TrKey::ErrConflictResolveFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); diff --git a/crates/rgitui_git/src/project/mod.rs b/crates/rgitui_git/src/project/mod.rs index 69fb405..094ecd0 100644 --- a/crates/rgitui_git/src/project/mod.rs +++ b/crates/rgitui_git/src/project/mod.rs @@ -9,6 +9,7 @@ mod file_history; mod history_cache; mod local_ops; mod network; +mod op_i18n; mod rebase; mod reflog; mod refresh; @@ -29,6 +30,8 @@ use std::sync::{Arc, Mutex}; use crate::types::*; use argsafe::{validate_branch_name, validate_remote_name}; +use op_i18n::{op_err_text, op_error}; +use rgitui_settings::TrKey; /// Normalize UNC paths (`\\server\share\…` → `//server/share/…`) for libgit2 /// on Windows. No-op on other platforms. @@ -103,11 +106,11 @@ fn parse_remote_tracking_ref(name: &str) -> Option<(String, String)> { fn head_branch_name(repo: &Repository) -> Result { let head = repo.head()?; if !head.is_branch() { - anyhow::bail!("HEAD is detached. Switch to a branch before running this operation."); + return Err(op_error(TrKey::ErrHeadDetached, vec![])); } head.shorthand() .map(str::to_string) - .ok_or_else(|| anyhow::anyhow!("Failed to determine the current branch name")) + .ok_or_else(|| op_error(TrKey::ErrBranchNameUnknown, vec![])) } /// Whether the working tree or index carries changes to **tracked** files. @@ -134,11 +137,10 @@ fn repo_has_worktree_changes(repo: &Repository) -> Result { fn ensure_clean_worktree(repo: &Repository, operation: &str) -> Result<()> { if repo_has_worktree_changes(repo)? { - anyhow::bail!( - "{} requires a clean working tree. Commit, stash, or discard your changes to \ - tracked files first.", - operation - ); + return Err(op_error( + TrKey::ErrCleanWorktreeFmt, + vec![operation.to_string()], + )); } Ok(()) } @@ -157,7 +159,7 @@ fn default_remote_name(repo: &Repository) -> Result { let remote_names = repo.remotes()?; if remote_names.is_empty() { - anyhow::bail!("No remotes configured. Add one with: git remote add origin ") + return Err(op_error(TrKey::ErrNoRemotes, vec![])); } if remote_names.iter().flatten().any(|name| name == "origin") { @@ -169,7 +171,7 @@ fn default_remote_name(repo: &Repository) -> Result { .flatten() .next() .map(str::to_string) - .ok_or_else(|| anyhow::anyhow!("No usable git remotes are configured."))?; + .ok_or_else(|| op_error(TrKey::ErrNoUsableRemotes, vec![]))?; // Read straight out of .git/config, which git does not validate. validate_remote_name(&name)?; Ok(name) @@ -550,11 +552,12 @@ impl GitProject { let operation_id = self.begin_operation(kind, summary.clone(), None, branch_name.clone(), cx); self.note_operation_worktree(operation_id, worktree_path); + let details = op_err_text(cx, &error); self.fail_op( operation_id, kind, summary, - error.to_string(), + details, (None, branch_name, retryable), cx, ); diff --git a/crates/rgitui_git/src/project/network.rs b/crates/rgitui_git/src/project/network.rs index 92554ce..3fdd9d2 100644 --- a/crates/rgitui_git/src/project/network.rs +++ b/crates/rgitui_git/src/project/network.rs @@ -1,12 +1,14 @@ use anyhow::{Context as _, Result}; use git2::Repository; use gpui::{AsyncApp, Context, Task, WeakEntity}; +use rgitui_settings::TrKey; use std::path::Path; use crate::types::*; use super::argsafe::{validate_branch_name, validate_remote_name}; use super::auth::run_git_network_command; +use super::op_i18n::{op_err_text, op_msg}; use super::refresh::gather_refresh_data; use super::{ ensure_clean_worktree, head_branch_name, pull_target, push_target, GitProject, GitProjectEvent, @@ -34,7 +36,7 @@ impl GitProject { Err(error) => { return self.fail_to_start_task( GitOperationKind::Fetch, - "Fetch could not start", + op_msg(cx, TrKey::ErrFetchNotStarted, vec![]), error, true, worktree_path, @@ -57,7 +59,7 @@ impl GitProject { Err(error) => { return self.fail_to_start_task( GitOperationKind::Pull, - "Pull could not start", + op_msg(cx, TrKey::ErrPullNotStarted, vec![]), error, true, worktree_path, @@ -85,7 +87,7 @@ impl GitProject { Err(error) => { return self.fail_to_start_task( GitOperationKind::Push, - "Push could not start", + op_msg(cx, TrKey::ErrPushNotStarted, vec![]), error, true, worktree_path, @@ -165,8 +167,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Fetch, - format!("Fetch from '{}' failed", remote_name), - e.to_string(), + op_msg(cx, TrKey::ErrFetchFailedFmt, vec![remote_name.clone()]), + op_err_text(cx, &e), (Some(remote_name.clone()), this.head_branch.clone(), true), cx, ); @@ -199,7 +201,7 @@ impl GitProject { Err(error) => { return self.fail_to_start_task( GitOperationKind::Pull, - "Pull could not start", + op_msg(cx, TrKey::ErrPullNotStarted, vec![]), error, true, worktree_path, @@ -307,7 +309,7 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Pull, - format!("Pull from '{}' has conflicts", remote_name), + op_msg(cx, TrKey::ErrPullConflictFmt, vec![remote_name.clone()]), user_msg, (Some(remote_name.clone()), Some(branch_name.clone()), false), cx, @@ -330,8 +332,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Pull, - format!("Pull from '{}' failed", remote_name), - e.to_string(), + op_msg(cx, TrKey::ErrPullFailedFmt, vec![remote_name.clone()]), + op_err_text(cx, &e), (Some(remote_name.clone()), Some(branch_name.clone()), true), cx, ); @@ -365,7 +367,7 @@ impl GitProject { Err(error) => { return self.fail_to_start_task( GitOperationKind::Push, - "Push could not start", + op_msg(cx, TrKey::ErrPushNotStarted, vec![]), error, true, worktree_path, @@ -490,8 +492,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Push, - format!("Push to '{}' failed", remote_name), - e.to_string(), + op_msg(cx, TrKey::ErrPushFailedFmt, vec![remote_name.clone()]), + op_err_text(cx, &e), ( Some(remote_name.clone()), Some(remote_branch_name.clone()), diff --git a/crates/rgitui_git/src/project/op_i18n.rs b/crates/rgitui_git/src/project/op_i18n.rs new file mode 100644 index 0000000..2cbb106 --- /dev/null +++ b/crates/rgitui_git/src/project/op_i18n.rs @@ -0,0 +1,450 @@ +//! Localizable user-facing git operation errors. +//! +//! High-frequency operation failures (checkout, pull/push, stash, rebase, +//! reset, and friends) surface to the user as toasts via failed +//! `OperationUpdated` events. Their templates live as [`TrKey`]s in +//! `rgitui_settings` so the English rendering stays byte-identical to the +//! legacy `format!` strings while Chinese renders alongside. +//! +//! Dynamic values (branch names, SHAs, paths, counts) are only ever +//! substituted into `{}` placeholders — never translated. Anything not +//! modelled here (raw `git` CLI output, libgit2 internals, filesystem +//! errors) keeps its English text as the fallback. + +use gpui::Context; +use rgitui_settings::{Language, SettingsState, TrKey}; + +use super::GitProject; + +/// A user-facing git error template plus its placeholder arguments. +/// +/// `Display` always renders the English template so logs, `Result` chains, +/// and the English UI stay byte-identical to the legacy `format!` strings. +/// The toast path renders through [`render_err_text`] with the current +/// interface language instead. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct LocalizedOpError { + pub key: TrKey, + pub args: Vec, +} + +impl std::fmt::Display for LocalizedOpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{}", + render_op_template(Language::English, self.key, &self.args) + ) + } +} + +impl std::error::Error for LocalizedOpError {} + +/// Build an [`anyhow::Error`] carrying a localizable template. +/// +/// Use this wherever the code previously did +/// `anyhow::bail!("... {} ...", value)` for a user-facing failure. +pub(crate) fn op_error(key: TrKey, args: Vec) -> anyhow::Error { + anyhow::Error::new(LocalizedOpError { key, args }) +} + +/// Substitute `{}` placeholders in order. Mirrors the existing UI pattern of +/// chained `.replacen("{}", value, 1)` calls. +pub(crate) fn render_op_template(lang: Language, key: TrKey, args: &[String]) -> String { + let mut out = lang.tr(key).to_string(); + for arg in args { + let Some(pos) = out.find("{}") else { + break; + }; + out.replace_range(pos..pos + 2, arg); + } + out +} + +/// The interface language for operation messages, defaulting to English when +/// no settings state is installed (e.g. bare entity tests). +pub(crate) fn op_lang(cx: &Context) -> Language { + cx.try_global::() + .map(|s| s.settings().language) + .unwrap_or_default() +} + +/// Localize a failure summary or static detail template at the call site. +pub(crate) fn op_msg(cx: &Context, key: TrKey, args: Vec) -> String { + render_op_template(op_lang(cx), key, &args) +} + +/// Localize an [`anyhow::Error`] for a failure toast. +/// +/// A [`LocalizedOpError`] anywhere in the chain renders in the current +/// language; anything else (raw `git` output, libgit2/filesystem internals) +/// falls back to its English text unchanged. +pub(crate) fn op_err_text(cx: &Context, error: &anyhow::Error) -> String { + render_err_text(op_lang(cx), error) +} + +/// Pure core of [`op_err_text`], testable without a GPUI context. +pub(crate) fn render_err_text(lang: Language, error: &anyhow::Error) -> String { + if let Some(localized) = error + .chain() + .find_map(|cause| cause.downcast_ref::()) + { + return render_op_template(lang, localized.key, &localized.args); + } + error.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn en(key: TrKey, args: &[&str]) -> String { + let owned: Vec = args.iter().map(|s| s.to_string()).collect(); + render_op_template(Language::English, key, &owned) + } + + fn zh(key: TrKey, args: &[&str]) -> String { + let owned: Vec = args.iter().map(|s| s.to_string()).collect(); + render_op_template(Language::SimplifiedChinese, key, &owned) + } + + #[test] + fn english_failure_summaries_match_legacy_format_strings() { + assert_eq!(en(TrKey::ErrStageFailed, &[]), "Stage failed"); + assert_eq!(en(TrKey::ErrUnstageFailed, &[]), "Unstage failed"); + assert_eq!(en(TrKey::ErrStageAllFailed, &[]), "Stage all failed"); + assert_eq!(en(TrKey::ErrUnstageAllFailed, &[]), "Unstage all failed"); + assert_eq!(en(TrKey::ErrCommitFailed, &[]), "Commit failed"); + assert_eq!(en(TrKey::ErrAmendFailed, &[]), "Amend failed"); + assert_eq!( + en(TrKey::ErrCheckoutBranchFailedFmt, &["master"]), + "Checkout of 'master' failed" + ); + assert_eq!( + en(TrKey::ErrCheckoutCommitFailedFmt, &["a1b2c3d"]), + "Checkout of a1b2c3d failed" + ); + assert_eq!( + en(TrKey::ErrCheckoutTagFailedFmt, &["v1.0"]), + "Checkout of tag 'v1.0' failed" + ); + assert_eq!( + en(TrKey::ErrBranchCreateFailedFmt, &["feat"]), + "Branch 'feat' could not be created" + ); + assert_eq!( + en(TrKey::ErrBranchDeleteFailedFmt, &["feat"]), + "Delete branch 'feat' failed" + ); + assert_eq!( + en(TrKey::ErrBranchRenameFailedFmt, &["old"]), + "Rename branch 'old' failed" + ); + assert_eq!( + en(TrKey::ErrTagCreateFailedFmt, &["v2"]), + "Tag 'v2' could not be created" + ); + assert_eq!( + en(TrKey::ErrTagDeleteFailedFmt, &["v2"]), + "Delete tag 'v2' failed" + ); + assert_eq!(en(TrKey::ErrStashSaveFailed, &[]), "Save stash failed"); + assert_eq!( + en(TrKey::ErrStashPopFailedFmt, &["0"]), + "Pop stash #0 failed" + ); + assert_eq!( + en(TrKey::ErrStashApplyFailedFmt, &["1"]), + "Apply stash #1 failed" + ); + assert_eq!( + en(TrKey::ErrStashDropFailedFmt, &["2"]), + "Drop stash #2 failed" + ); + assert_eq!( + en(TrKey::ErrStashBranchFailedFmt, &["0"]), + "Create branch from stash #0 failed" + ); + assert_eq!(en(TrKey::ErrDiscardFailed, &[]), "Discard changes failed"); + assert_eq!(en(TrKey::ErrCleanFailed, &[]), "Clean failed"); + assert_eq!(en(TrKey::ErrResetHeadFailed, &[]), "Reset to HEAD failed"); + assert_eq!( + en(TrKey::ErrResetFailedFmt, &["a1b2c3d"]), + "Reset to a1b2c3d failed" + ); + assert_eq!( + en(TrKey::ErrResetSoftFailedFmt, &["a1b2c3d"]), + "Soft reset to a1b2c3d failed" + ); + assert_eq!( + en(TrKey::ErrResetMixedFailedFmt, &["a1b2c3d"]), + "Mixed reset to a1b2c3d failed" + ); + assert_eq!( + en(TrKey::ErrRevertConflictFmt, &["a1b2c3d"]), + "Revert of a1b2c3d needs conflict resolution" + ); + assert_eq!( + en(TrKey::ErrRevertFailedFmt, &["a1b2c3d"]), + "Revert of a1b2c3d failed" + ); + assert_eq!( + en(TrKey::ErrRevertGuide, &[]), + "Resolve the conflicts in the working tree, then commit the revert manually." + ); + assert_eq!( + en(TrKey::ErrCherryPickConflictFmt, &["a1b2c3d"]), + "Cherry-pick of a1b2c3d needs conflict resolution" + ); + assert_eq!( + en(TrKey::ErrCherryPickFailedFmt, &["a1b2c3d"]), + "Cherry-pick of a1b2c3d failed" + ); + assert_eq!( + en(TrKey::ErrCherryPickGuide, &[]), + "Resolve the conflicts in the working tree, then commit the cherry-pick manually." + ); + assert_eq!( + en(TrKey::ErrAbortFailedFmt, &["merge"]), + "Failed to abort merge" + ); + assert_eq!( + en(TrKey::ErrContinueFailedFmt, &["rebase"]), + "Could not continue rebase" + ); + assert_eq!( + en(TrKey::ErrMergeConflictFmt, &["feat"]), + "Merge conflicts in 'feat'" + ); + assert_eq!( + en(TrKey::ErrMergeFailedFmt, &["feat"]), + "Merge of 'feat' failed" + ); + assert_eq!( + en(TrKey::ErrRemoveRemoteFailed, &[]), + "Removing remote failed" + ); + assert_eq!(en(TrKey::ErrCloneFailed, &[]), "Clone failed"); + assert_eq!( + en(TrKey::ErrBisectStartFailed, &[]), + "Failed to start bisect" + ); + assert_eq!( + en(TrKey::ErrBisectGoodFailedFmt, &["a1b2c3d"]), + "Failed to mark a1b2c3d as good" + ); + assert_eq!( + en(TrKey::ErrBisectBadFailedFmt, &["a1b2c3d"]), + "Failed to mark a1b2c3d as bad" + ); + assert_eq!(en(TrKey::ErrBisectExhausted, &[]), "Bisect exhausted"); + assert_eq!( + en(TrKey::ErrBisectSkipFailedFmt, &["HEAD"]), + "Failed to skip HEAD" + ); + assert_eq!( + en(TrKey::ErrBisectResetFailed, &[]), + "Failed to reset bisect" + ); + assert_eq!( + en(TrKey::ErrWorktreeCreateFailedFmt, &["wt"]), + "Create worktree 'wt' failed" + ); + assert_eq!( + en(TrKey::ErrWorktreeRemoveFailedFmt, &["/tmp/wt"]), + "Remove worktree '/tmp/wt' failed" + ); + assert_eq!( + en(TrKey::ErrFetchFailedFmt, &["origin"]), + "Fetch from 'origin' failed" + ); + assert_eq!( + en(TrKey::ErrPullConflictFmt, &["origin"]), + "Pull from 'origin' has conflicts" + ); + assert_eq!( + en(TrKey::ErrPullFailedFmt, &["origin"]), + "Pull from 'origin' failed" + ); + assert_eq!( + en(TrKey::ErrPushFailedFmt, &["origin"]), + "Push to 'origin' failed" + ); + assert_eq!(en(TrKey::ErrFetchNotStarted, &[]), "Fetch could not start"); + assert_eq!(en(TrKey::ErrPullNotStarted, &[]), "Pull could not start"); + assert_eq!(en(TrKey::ErrPushNotStarted, &[]), "Push could not start"); + assert_eq!( + en(TrKey::ErrRebasePaused, &[]), + "Rebase paused due to conflicts" + ); + assert_eq!(en(TrKey::ErrRebaseFailed, &[]), "Interactive rebase failed"); + assert_eq!(en(TrKey::ErrStageHunkFailed, &[]), "Stage hunk failed"); + assert_eq!(en(TrKey::ErrUnstageHunkFailed, &[]), "Unstage hunk failed"); + assert_eq!(en(TrKey::ErrStageLinesFailed, &[]), "Stage lines failed"); + assert_eq!( + en(TrKey::ErrUnstageLinesFailed, &[]), + "Unstage lines failed" + ); + assert_eq!( + en(TrKey::ErrConflictResolveFailed, &[]), + "Conflict resolution failed" + ); + } + + #[test] + fn english_error_details_match_legacy_format_strings() { + assert_eq!( + en(TrKey::ErrAlreadyOnBranchFmt, &["master"]), + "Already on branch 'master'." + ); + assert_eq!( + en(TrKey::ErrCleanWorktreeFmt, &["Checkout"]), + "Checkout requires a clean working tree. Commit, stash, or discard your changes to tracked files first." + ); + assert_eq!( + en(TrKey::ErrHeadDetached, &[]), + "HEAD is detached. Switch to a branch before running this operation." + ); + assert_eq!( + en(TrKey::ErrBranchNameUnknown, &[]), + "Failed to determine the current branch name" + ); + assert_eq!( + en(TrKey::ErrNoRemotes, &[]), + "No remotes configured. Add one with: git remote add origin " + ); + assert_eq!( + en(TrKey::ErrNoUsableRemotes, &[]), + "No usable git remotes are configured." + ); + assert_eq!( + en(TrKey::ErrNoStagedChanges, &[]), + "There are no staged changes to commit." + ); + assert_eq!( + en(TrKey::ErrCannotAmendRebase, &[]), + "Cannot amend during a rebase. Continue or abort the rebase first." + ); + assert_eq!( + en(TrKey::ErrOverwriteLocalFmt, &["Checkout"]), + "Checkout would overwrite local changes. Commit, stash, or discard them first." + ); + assert_eq!( + en(TrKey::ErrOverwritePathOneFmt, &["Checkout", "a.txt"]), + "Checkout would overwrite a.txt. Commit, stash, move, or delete it first." + ); + assert_eq!( + en( + TrKey::ErrOverwritePathManyFmt, + &["Merge", "a.txt, b.txt and 1 more"] + ), + "Merge would overwrite a.txt, b.txt and 1 more. Commit, stash, move, or delete them first." + ); + assert_eq!( + en(TrKey::ErrStagingConflictFmt, &["a.txt"]), + "'a.txt' has unresolved conflicts. Open the conflict resolver before staging it." + ); + assert_eq!( + en(TrKey::ErrBranchNotFoundFmt, &["nope"]), + "Branch 'nope' not found as a local or remote branch. Try fetching to update remote refs." + ); + assert_eq!( + en(TrKey::ErrLocalBranchExistsFmt, &["feat"]), + "A local branch named 'feat' already exists. Please delete or rename it first." + ); + assert_eq!( + en(TrKey::ErrInvalidRemoteBranchFmt, &["origin"]), + "Invalid remote branch name 'origin'. Expected 'remote/branch' format." + ); + assert_eq!( + en(TrKey::ErrNotMergeState, &[]), + "Repository is not in a merge state (no MERGE_HEAD to continue)." + ); + assert_eq!( + en(TrKey::ErrUnresolvedConflicts, &[]), + "There are still unresolved conflicts. Resolve all conflicts before continuing." + ); + assert_eq!( + en(TrKey::ErrNothingToContinueFmt, &["merge"]), + "There is no merge to continue." + ); + assert_eq!( + en(TrKey::ErrNoRebaseEntries, &[]), + "No entries provided for interactive rebase" + ); + assert_eq!( + en(TrKey::ErrRebasePlanMismatchFmt, &["3"]), + "Interactive rebase plan does not match the current branch's history (the selected commits are not exactly the last 3 first-parent commits of HEAD). Refresh and try again." + ); + assert_eq!( + en(TrKey::ErrStashIndexOutOfRangeFmt, &["9"]), + "Stash index 9 out of range" + ); + assert_eq!( + en(TrKey::ErrBaseNotCommitFmt, &["nope"]), + "'nope' does not resolve to a commit" + ); + } + + #[test] + fn chinese_renders_keep_dynamic_values_untranslated() { + let rendered = zh(TrKey::ErrCheckoutBranchFailedFmt, &["master"]); + assert_eq!(rendered, "检出 'master' 失败"); + assert!(!rendered.contains("{}")); + + let rendered = zh(TrKey::ErrAlreadyOnBranchFmt, &["master"]); + assert_eq!(rendered, "已位于分支 'master'。"); + + let rendered = zh(TrKey::ErrCleanWorktreeFmt, &["Checkout"]); + assert!(rendered.contains("Checkout"), "{rendered}"); + + let rendered = zh(TrKey::ErrRebasePlanMismatchFmt, &["3"]); + assert!(rendered.contains('3'), "{rendered}"); + assert!(!rendered.contains("{}"), "{rendered}"); + + let rendered = zh(TrKey::ErrOverwritePathManyFmt, &["Merge", "a.txt, b.txt"]); + assert!(rendered.contains("Merge"), "{rendered}"); + assert!(rendered.contains("a.txt, b.txt"), "{rendered}"); + + // Static templates render without placeholders too. + assert!(!zh(TrKey::ErrHeadDetached, &[]).is_empty()); + assert!(!zh(TrKey::ErrRebaseFailed, &[]).contains("{}")); + } + + #[test] + fn localized_error_displays_english_and_renders_current_language() { + let error = op_error(TrKey::ErrAlreadyOnBranchFmt, vec!["master".to_string()]); + assert_eq!(error.to_string(), "Already on branch 'master'."); + assert_eq!( + render_err_text(Language::English, &error), + "Already on branch 'master'." + ); + assert_eq!( + render_err_text(Language::SimplifiedChinese, &error), + "已位于分支 'master'。" + ); + } + + #[test] + fn plain_errors_fall_back_to_english_unchanged() { + let error = anyhow::anyhow!("git pull failed: some raw stderr line"); + assert_eq!( + render_err_text(Language::SimplifiedChinese, &error), + "git pull failed: some raw stderr line" + ); + assert_eq!( + render_err_text(Language::English, &error), + "git pull failed: some raw stderr line" + ); + } + + #[test] + fn localized_cause_inside_context_still_translates() { + let error = op_error(TrKey::ErrNoStagedChanges, vec![]); + let wrapped = error.context("commit preflight"); + assert_eq!( + render_err_text(Language::SimplifiedChinese, &wrapped), + "没有已暂存的更改可提交。" + ); + } +} diff --git a/crates/rgitui_git/src/project/rebase.rs b/crates/rgitui_git/src/project/rebase.rs index c85bd6c..cda6c7f 100644 --- a/crates/rgitui_git/src/project/rebase.rs +++ b/crates/rgitui_git/src/project/rebase.rs @@ -1,11 +1,13 @@ use anyhow::{Context as _, Result}; use git2::Repository; use gpui::{AsyncApp, Context, Task, WeakEntity}; +use rgitui_settings::TrKey; use crate::types::*; use super::argsafe::sh_quote; use super::ensure_clean_worktree; +use super::op_i18n::{op_err_text, op_error, op_msg}; use super::refresh::gather_refresh_data; use super::{GitProject, GitProjectEvent, RefreshData}; @@ -39,7 +41,7 @@ impl GitProject { .background_executor() .spawn(async move { if entries.is_empty() { - anyhow::bail!("No entries provided for interactive rebase"); + return Err(op_error(TrKey::ErrNoRebaseEntries, vec![])); } let base_oid = { @@ -73,12 +75,10 @@ impl GitProject { let range_set: std::collections::HashSet = range.iter().copied().collect(); if plan_set != range_set { - anyhow::bail!( - "Interactive rebase plan does not match the current branch's \ - history (the selected commits are not exactly the last {} \ - first-parent commits of HEAD). Refresh and try again.", - entries.len() - ); + return Err(op_error( + TrKey::ErrRebasePlanMismatchFmt, + vec![entries.len().to_string()], + )); } base.to_string() @@ -217,7 +217,7 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Rebase, - "Rebase paused due to conflicts", + op_msg(cx, TrKey::ErrRebasePaused, vec![]), user_msg, (None, branch_name.clone(), false), cx, @@ -241,8 +241,8 @@ impl GitProject { this.fail_op( operation_id, GitOperationKind::Rebase, - "Interactive rebase failed", - e.to_string(), + op_msg(cx, TrKey::ErrRebaseFailed, vec![]), + op_err_text(cx, &e), (None, branch_name.clone(), false), cx, ); diff --git a/crates/rgitui_graph/src/lib.rs b/crates/rgitui_graph/src/lib.rs index 7f0fa67..02e96ce 100644 --- a/crates/rgitui_graph/src/lib.rs +++ b/crates/rgitui_graph/src/lib.rs @@ -17,7 +17,7 @@ use gpui::{ use rgitui_git::{ compact_ref_labels, compute_graph, CommitInfo, FileChangeKind, GraphEdge, GraphRow, RefLabel, }; -use rgitui_settings::{GraphStyle, SettingsState}; +use rgitui_settings::{GraphStyle, Language, SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ AvatarCache, Badge, CheckState, Checkbox, Icon, IconName, IconSize, Label, LabelSize, Tooltip, @@ -86,8 +86,8 @@ enum GraphMenuAction { /// One row of the commit graph context menu. struct GraphMenuItem { - /// Row label. - label: &'static str, + /// Row label, resolved through [`Language::tr`] at render time. + label: TrKey, /// Leading icon. icon: IconName, /// What clicking the row does. @@ -100,7 +100,7 @@ struct GraphMenuItem { } impl GraphMenuItem { - const fn new(label: &'static str, icon: IconName, action: GraphMenuAction) -> Self { + const fn new(label: TrKey, icon: IconName, action: GraphMenuAction) -> Self { Self { label, icon, @@ -130,39 +130,43 @@ impl GraphMenuItem { /// by hand. const GRAPH_MENU_ITEMS: &[GraphMenuItem] = &[ GraphMenuItem::new( - "Cherry-pick commit", + TrKey::MenuCherryPick, IconName::GitCommit, GraphMenuAction::CherryPick, ), - GraphMenuItem::new("Revert commit", IconName::Undo, GraphMenuAction::Revert), + GraphMenuItem::new(TrKey::MenuRevert, IconName::Undo, GraphMenuAction::Revert), GraphMenuItem::new( - "Checkout commit", + TrKey::MenuCheckout, IconName::Check, GraphMenuAction::Checkout, ), GraphMenuItem::new( - "Create branch here", + TrKey::MenuCreateBranch, IconName::GitBranch, GraphMenuAction::CreateBranch, ), - GraphMenuItem::new("Create tag here", IconName::Tag, GraphMenuAction::CreateTag), GraphMenuItem::new( - "Mark as good (bisect)", + TrKey::MenuCreateTag, + IconName::Tag, + GraphMenuAction::CreateTag, + ), + GraphMenuItem::new( + TrKey::MenuBisectGood, IconName::Check, GraphMenuAction::BisectGood, ) .grouped(), GraphMenuItem::new( - "Mark as bad (bisect)", + TrKey::MenuBisectBad, IconName::X, GraphMenuAction::BisectBad, ) .destructive(), - GraphMenuItem::new("Reset to here", IconName::Trash, GraphMenuAction::Reset) + GraphMenuItem::new(TrKey::MenuReset, IconName::Trash, GraphMenuAction::Reset) .grouped() .destructive(), GraphMenuItem::new( - "Interactive Rebase", + TrKey::RebaseTitle, IconName::GitMerge, GraphMenuAction::InteractiveRebase, ) @@ -170,24 +174,28 @@ const GRAPH_MENU_ITEMS: &[GraphMenuItem] = &[ // Only offered while the selection could actually be squashed — see // `GraphView::context_menu_items`. GraphMenuItem::new( - "Squash selected commits", + TrKey::MenuSquash, IconName::GitMerge, GraphMenuAction::SquashSelected, ), - GraphMenuItem::new("Copy SHA", IconName::Copy, GraphMenuAction::CopySha).grouped(), + GraphMenuItem::new(TrKey::MenuCopySha, IconName::Copy, GraphMenuAction::CopySha).grouped(), GraphMenuItem::new( - "Copy commit message", + TrKey::MenuCopyMsg, IconName::Edit, GraphMenuAction::CopyMessage, ), GraphMenuItem::new( - "Copy author name", + TrKey::MenuCopyAuthor, IconName::User, GraphMenuAction::CopyAuthor, ), - GraphMenuItem::new("Copy date", IconName::Clock, GraphMenuAction::CopyDate), GraphMenuItem::new( - "View on GitHub", + TrKey::MenuCopyDate, + IconName::Clock, + GraphMenuAction::CopyDate, + ), + GraphMenuItem::new( + TrKey::MenuViewGithub, IconName::ExternalLink, GraphMenuAction::ViewOnGithub, ), @@ -781,9 +789,17 @@ impl EventEmitter for GraphView {} impl GraphView { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let search_editor = cx.new(|cx| { let mut ti = rgitui_ui::TextInput::new(cx); - ti.set_placeholder("Search commits..."); + ti.set_placeholder(language.tr(TrKey::SearchCommitsPh)); ti }); @@ -1772,7 +1788,15 @@ impl Render for GraphView { self.graph_rows.len(), self.selected_index ); - let colors = cx.colors(); + let colors = cx.colors().clone(); + + let language = cx.global::().settings().language; + // Keep the search placeholder in the active language: the editor is + // created once in `new`, so without this it would freeze in the + // language that was active at startup. + self.search_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::SearchCommitsPh)); + }); if self.total_list_items() == 0 { return div() @@ -1799,7 +1823,7 @@ impl Render for GraphView { .color(Color::Muted), ) .child( - Label::new("Graph") + Label::new(language.tr(TrKey::GraphPanelTitle)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::SEMIBOLD), @@ -1817,7 +1841,7 @@ impl Render for GraphView { .color(Color::Muted), ) .child( - Label::new("No commits to display") + Label::new(language.tr(TrKey::GraphEmpty)) .color(Color::Muted) .size(LabelSize::Small), ), @@ -1862,7 +1886,7 @@ impl Render for GraphView { ..head_color }; let head_badge_text = if head_detached { - "HEAD (detached)" + language.tr(TrKey::HeadDetachedBadge) } else { "HEAD" }; @@ -1979,7 +2003,7 @@ impl Render for GraphView { .color(Color::Muted), ) .child( - Label::new("Graph") + Label::new(language.tr(TrKey::GraphPanelTitle)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::SEMIBOLD), @@ -1991,7 +2015,7 @@ impl Render for GraphView { .child(div().w(px(16.)).flex_shrink_0()) .child( div().w(px(80.)).flex_shrink_0().child( - Label::new("Hash") + Label::new(language.tr(TrKey::GraphColHash)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::SEMIBOLD), @@ -2000,7 +2024,7 @@ impl Render for GraphView { .when(show_subject_column, |el| { el.child( div().flex_1().child( - Label::new("Message") + Label::new(language.tr(TrKey::GraphColMessage)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::SEMIBOLD), @@ -2030,7 +2054,7 @@ impl Render for GraphView { .color(Color::Muted), ) .child( - Label::new("Author") + Label::new(language.tr(TrKey::GraphColAuthor)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::SEMIBOLD), @@ -2072,7 +2096,7 @@ impl Render for GraphView { .color(Color::Muted), ) .child( - Label::new("Date") + Label::new(language.tr(TrKey::GraphColDate)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::SEMIBOLD), @@ -2103,9 +2127,9 @@ impl Render for GraphView { }; let my_commits_tooltip: SharedString = if my_commits_active { - "Showing only your commits. Click to show all commits.".into() + language.tr(TrKey::MyCommitsOn).into() } else { - "Show only your commits. Click to filter by current user.".into() + language.tr(TrKey::MyCommitsOff).into() }; header = header @@ -2267,6 +2291,7 @@ impl Render for GraphView { graph_style, node_lane: position.node_lane, parent_lane: position.parent_lane, + language, }); } @@ -2760,7 +2785,7 @@ impl Render for GraphView { let is_dragging_this = dragging_oid == Some(oid); // Opacity: dim when dragging this row; subtle when not dragging; hidden when not. let grip_opacity = if is_dragging_this { 0.3 } else { 0.0 }; - let grip_tooltip = Tooltip::text("Drag to rebase"); + let grip_tooltip = Tooltip::text(language.tr(TrKey::GripRebase)); let entity_for_grip = view.clone(); let oid_for_grip = oid; row = row.child( @@ -2817,7 +2842,7 @@ impl Render for GraphView { message_col = message_col.child( div() .flex_shrink_0() - .child(Badge::new("✓ Signed").color(Color::Success).bold()), + .child(Badge::new(language.tr(TrKey::SignedBadge)).color(Color::Success).bold()), ); } @@ -2991,7 +3016,7 @@ impl Render for GraphView { let match_count_text: SharedString = if self.search_editor.read(cx).is_empty() { String::new().into() } else if no_matches { - "No matches".into() + language.tr(TrKey::NoMatches).into() } else { format!("{}/{}", self.current_match + 1, self.filter_matches.len()).into() }; @@ -3125,7 +3150,7 @@ impl Render for GraphView { .color(Color::Muted), ) .child( - Label::new("Load more commits") + Label::new(language.tr(TrKey::LoadMore)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -3237,7 +3262,7 @@ impl Render for GraphView { .color(icon_color), ) .child( - Label::new(SharedString::from(item.label)) + Label::new(language.tr(item.label)) .size(LabelSize::XSmall) .color(label_color), ), @@ -3271,9 +3296,12 @@ impl Render for GraphView { let view_ref_badges = cx.weak_entity(); let sha_length_label: SharedString = match self.sha_display_length { - 0 => "Short (7)".into(), - 40 => "Full (40)".into(), - n => format!("{} chars", n).into(), + 0 => language.tr(TrKey::ShaShort).into(), + 40 => language.tr(TrKey::ShaFull).into(), + n => language + .tr(TrKey::ShaCharsFmt) + .replacen("{}", &n.to_string(), 1) + .into(), }; let subject_state = if cx.global::().settings().show_subject_column { CheckState::Checked @@ -3341,7 +3369,7 @@ impl Render for GraphView { }) .child( div().px(px(10.)).py(px(4.)).child( - Label::new("Display Settings") + Label::new(language.tr(TrKey::DisplaySettings)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::SEMIBOLD), @@ -3386,7 +3414,9 @@ impl Render for GraphView { .size(IconSize::XSmall) .color(Color::Muted), ) - .child(Label::new("SHA length:").size(LabelSize::XSmall)) + .child( + Label::new(language.tr(TrKey::ShaLengthLabel)).size(LabelSize::XSmall), + ) .child( Label::new(sha_length_label) .size(LabelSize::XSmall) @@ -3418,7 +3448,9 @@ impl Render for GraphView { .ok(); }) .child(Checkbox::new("cb-subject-col", subject_state)) - .child(Label::new("Show subject column").size(LabelSize::XSmall)), + .child( + Label::new(language.tr(TrKey::ShowSubjectCol)).size(LabelSize::XSmall), + ), ) .child( div() @@ -3441,7 +3473,9 @@ impl Render for GraphView { .ok(); }) .child(Checkbox::new("cb-author-col", author_state)) - .child(Label::new("Show author column").size(LabelSize::XSmall)), + .child( + Label::new(language.tr(TrKey::ShowAuthorCol)).size(LabelSize::XSmall), + ), ) .child( div() @@ -3465,7 +3499,9 @@ impl Render for GraphView { .ok(); }) .child(Checkbox::new("cb-author-email", author_email_state)) - .child(Label::new("Show author email").size(LabelSize::XSmall)), + .child( + Label::new(language.tr(TrKey::ShowAuthorEmail)).size(LabelSize::XSmall), + ), ) .child( div() @@ -3488,7 +3524,7 @@ impl Render for GraphView { .ok(); }) .child(Checkbox::new("cb-date-col", date_state)) - .child(Label::new("Show date column").size(LabelSize::XSmall)), + .child(Label::new(language.tr(TrKey::ShowDateCol)).size(LabelSize::XSmall)), ) .child( div() @@ -3511,7 +3547,9 @@ impl Render for GraphView { .ok(); }) .child(Checkbox::new("cb-absolute-dates", absolute_dates_state)) - .child(Label::new("Absolute dates").size(LabelSize::XSmall)), + .child( + Label::new(language.tr(TrKey::AbsoluteDates)).size(LabelSize::XSmall), + ), ) .child( div() @@ -3534,7 +3572,7 @@ impl Render for GraphView { .ok(); }) .child(Checkbox::new("cb-avatars", avatars_state)) - .child(Label::new("Show avatars").size(LabelSize::XSmall)), + .child(Label::new(language.tr(TrKey::ShowAvatars)).size(LabelSize::XSmall)), ) .child( div() @@ -3564,7 +3602,7 @@ impl Render for GraphView { .ok(); }) .child(Checkbox::new("cb-graph-lanes", graph_lanes_state)) - .child(Label::new("Show graph lanes").size(LabelSize::XSmall)), + .child(Label::new(language.tr(TrKey::ShowLanes)).size(LabelSize::XSmall)), ) .child( div() @@ -3587,7 +3625,7 @@ impl Render for GraphView { .ok(); }) .child(Checkbox::new("cb-ref-badges", ref_badges_state)) - .child(Label::new("Show branch/tag badges").size(LabelSize::XSmall)), + .child(Label::new(language.tr(TrKey::ShowBadges)).size(LabelSize::XSmall)), ); container = container.child(popover); @@ -3638,6 +3676,8 @@ struct WorkingTreeRowParams { node_lane: usize, /// Lane occupied by the visible HEAD commit this pseudo-node descends from. parent_lane: Option, + /// Interface language for the row's status text. + language: Language, } /// Render the virtual "Working Tree" row that appears at the top of the graph. @@ -3678,6 +3718,7 @@ fn render_working_tree_row(params: WorkingTreeRowParams) -> gpui::AnyElement { graph_style, node_lane, parent_lane, + language, } = params; let bg = if selected { selected_bg @@ -3734,16 +3775,16 @@ fn render_working_tree_row(params: WorkingTreeRowParams) -> gpui::AnyElement { // failed to load. let head_label = branch_name .filter(|branch| !branch.is_empty()) - .or_else(|| head_detached.then(|| "detached HEAD".to_string())); + .or_else(|| head_detached.then(|| language.tr(TrKey::DetachedHeadWord).to_string())); let row_title = if is_orphan_worktree { head_label .clone() - .map(|label| format!("{label} (no commits)")) - .unwrap_or_else(|| "No commits yet".to_string()) + .map(|label| format!("{label} {}", language.tr(TrKey::NoCommitsSuffix))) + .unwrap_or_else(|| language.tr(TrKey::NoCommitsYet).to_string()) } else if let Some(label) = head_label.clone() { - format!("Pending changes on {label}") + format!("{} {label}", language.tr(TrKey::PendingOnPre)) } else { - "Pending changes".to_string() + language.tr(TrKey::PendingChanges).to_string() }; let row_badge_color = if is_current_worktree || is_orphan_worktree { Color::Warning @@ -3993,11 +4034,10 @@ fn render_working_tree_row(params: WorkingTreeRowParams) -> gpui::AnyElement { .child(Badge::new(worktree_name).color(badge_color).bold()), ); if is_orphan_worktree { - message_col = message_col.child( - div() - .flex_shrink_0() - .child(Badge::new("New branch").color(Color::Warning)), - ); + message_col = + message_col.child(div().flex_shrink_0().child( + Badge::new(language.tr(TrKey::NewBranchBadge)).color(Color::Warning), + )); } if has_changes { @@ -4177,7 +4217,8 @@ mod tests { fn every_menu_row_is_distinct_and_no_group_starts_the_menu() { let mut seen: Vec = Vec::new(); for item in GRAPH_MENU_ITEMS { - assert!(!item.label.is_empty()); + assert!(!Language::English.tr(item.label).is_empty()); + assert!(!Language::SimplifiedChinese.tr(item.label).is_empty()); assert!( !seen.contains(&item.action), "{:?} appears twice in the menu", diff --git a/crates/rgitui_settings/src/lib.rs b/crates/rgitui_settings/src/lib.rs index 095c6d1..7813e52 100644 --- a/crates/rgitui_settings/src/lib.rs +++ b/crates/rgitui_settings/src/lib.rs @@ -201,275 +201,3375 @@ impl FromStr for Compactness { } } -/// Application settings persisted to disk. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AppSettings { - #[serde(default = "default_settings_version")] - pub version: u32, - #[serde(default = "default_theme")] - pub theme: String, - #[serde(default)] - pub ui_font: String, - #[serde(default)] - pub ai: AiSettings, - #[serde(default)] - pub git: GitSettings, - #[serde(default)] - pub recent_repos: Vec, - #[serde(default = "default_max_recent")] - pub max_recent_repos: usize, - #[serde(default)] - pub last_workspace: Vec, - #[serde(default)] - pub layout: LayoutSettings, - #[serde(default)] - pub workspaces: Vec, - #[serde(default)] - pub active_workspace_id: Option, - #[serde(default)] - pub clean_exit: bool, - #[serde(default)] - pub compactness: Compactness, - #[serde(default = "default_appearance_mode")] - pub appearance_mode: AppearanceMode, - #[serde(default)] - pub terminal_command: String, - #[serde(default)] - pub editor_command: String, - #[serde(default = "default_font_size")] - pub font_size: u32, - #[serde(default = "default_show_line_numbers_in_diff")] - pub show_line_numbers_in_diff: bool, - #[serde(default)] - pub diff_view_mode: DiffViewMode, - #[serde(default)] - pub diff_wrap_lines: bool, - #[serde(default)] - pub graph_style: GraphStyle, - #[serde(default = "default_show_subject_column")] - pub show_subject_column: bool, - #[serde(default = "default_author_column_width")] - pub author_column_width: f32, - #[serde(default = "default_date_column_width")] - pub date_column_width: f32, - #[serde(default)] - pub auto_fetch_interval: AutoFetchInterval, - #[serde(default = "default_confirm_destructive")] - pub confirm_destructive_operations: bool, - #[serde(default = "default_auto_check_updates")] - pub auto_check_updates: bool, - /// Timestamp of the most recent successful GitHub release check. Used to - /// throttle background update polling so restarts don't spam the API. - #[serde(default)] - pub last_update_check_at: Option>, - #[serde(default = "default_commit_limit")] - pub commit_limit: usize, - #[serde(default)] - pub watch_all_worktrees: bool, - /// Show each staged and unstaged file's added and removed line counts in - /// the sidebar. Off by default: counting them diffs every changed file on - /// each status refresh. - #[serde(default)] - pub show_change_line_stats: bool, - /// Last known position/size of the standalone Settings window. Restored - /// on next open if the saved origin still falls within a connected display. - #[serde(default)] - pub settings_window_bounds: Option, -} - -/// Serializable rectangle in screen coordinates. Used to persist window -/// geometry across sessions independently of GPUI's `Bounds`. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct SavedWindowBounds { - pub x: f32, - pub y: f32, - pub width: f32, - pub height: f32, -} - -/// Current settings version. Increment when making breaking changes. -const CURRENT_SETTINGS_VERSION: u32 = 3; - -fn default_settings_version() -> u32 { - CURRENT_SETTINGS_VERSION -} - -fn default_theme() -> String { - "Catppuccin Mocha".into() -} - -fn default_appearance_mode() -> AppearanceMode { - AppearanceMode::Auto -} - -fn default_font_size() -> u32 { - 14 -} - -fn default_show_line_numbers_in_diff() -> bool { - true -} - -fn default_show_subject_column() -> bool { - true -} - -fn default_author_column_width() -> f32 { - 140.0 -} - -fn default_date_column_width() -> f32 { - 100.0 -} - -fn default_confirm_destructive() -> bool { - true -} - -fn default_auto_check_updates() -> bool { - true -} - -fn default_commit_limit() -> usize { - 1000 -} - -/// A persisted local workspace snapshot. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct StoredWorkspace { - pub id: String, - pub name: String, - #[serde(default)] - pub repos: Vec, - #[serde(default)] - pub active_repo_index: usize, - #[serde(default)] - pub layout: LayoutSettings, - #[serde(default = "utc_now")] - pub created_at: DateTime, - #[serde(default = "utc_now")] - pub last_opened_at: DateTime, -} - -/// Persisted layout dimensions. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct LayoutSettings { - #[serde(default = "default_sidebar_width")] - pub sidebar_width: f32, - #[serde(default = "default_detail_panel_width")] - pub detail_panel_width: f32, - #[serde(default = "default_diff_viewer_height")] - pub diff_viewer_height: f32, - #[serde(default = "default_commit_input_height")] - pub commit_input_height: f32, - /// Whether the commit graph is hidden, handing its space to the bottom - /// panel. Files written before the option existed load with it off. - #[serde(default)] - pub graph_hidden: bool, -} - -fn default_sidebar_width() -> f32 { - 276.0 -} -fn default_detail_panel_width() -> f32 { - 352.0 -} -fn default_diff_viewer_height() -> f32 { - 345.0 -} -fn default_commit_input_height() -> f32 { - 385.0 -} - -impl Default for LayoutSettings { - fn default() -> Self { - Self { - sidebar_width: default_sidebar_width(), - detail_panel_width: default_detail_panel_width(), - diff_viewer_height: default_diff_viewer_height(), - commit_input_height: default_commit_input_height(), - graph_hidden: false, - } - } -} - -fn default_max_recent() -> usize { - 20 -} - -/// The AI providers rgitui can talk to. +/// Interface language for localizable UI text. /// -/// Persisted by its lowercase id, and the single source of truth for endpoint -/// shape, auth style and default model. Dispatching on a bare string is what -/// let a hand-edited `settings.json` reach the network layer before failing. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -#[derive(Default)] -pub enum AiProvider { +/// The stable on-disk id is [`Language::id`] (`"en"` / `"zh-CN"`); the +/// dropdown always shows [`Language::native_name`] so each option is +/// recognizable regardless of the active language. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum Language { #[default] - Gemini, - #[serde(rename = "openai")] - OpenAi, - Anthropic, - #[serde(rename = "deepseek")] - DeepSeek, - #[serde(rename = "openrouter")] - OpenRouter, + English, + SimplifiedChinese, } -impl AiProvider { - /// Every provider, in the order the settings UI lists them. - pub const ALL: &'static [AiProvider] = &[ - AiProvider::Gemini, - AiProvider::OpenAi, - AiProvider::Anthropic, - AiProvider::DeepSeek, - AiProvider::OpenRouter, - ]; +impl Language { + /// Every language, in the order the settings UI lists them. + pub const ALL: &'static [Language] = &[Language::English, Language::SimplifiedChinese]; - /// The stable id used in `settings.json`, keychain accounts and catalogue - /// cache filenames. Changing one needs a migration. + /// Stable identifier used on disk and as the settings dropdown id. pub fn id(self) -> &'static str { match self { - AiProvider::Gemini => "gemini", - AiProvider::OpenAi => "openai", - AiProvider::Anthropic => "anthropic", - AiProvider::DeepSeek => "deepseek", - AiProvider::OpenRouter => "openrouter", + Language::English => "en", + Language::SimplifiedChinese => "zh-CN", } } - pub fn display_name(self) -> &'static str { + /// Native display name. Deliberately not translated: "English" must read + /// "English" even while the UI is Chinese, and vice versa. + pub fn native_name(self) -> &'static str { match self { - AiProvider::Gemini => "Google Gemini", - AiProvider::OpenAi => "OpenAI", - AiProvider::Anthropic => "Anthropic", - AiProvider::DeepSeek => "DeepSeek", - AiProvider::OpenRouter => "OpenRouter", + Language::English => "English", + Language::SimplifiedChinese => "简体中文", } } - /// Parse a persisted id. Case- and whitespace-insensitive, so a hand-edited - /// `"Anthropic"` resolves instead of silently falling back. - pub fn from_id(value: &str) -> Option { - let normalized = value.trim().to_ascii_lowercase(); - Self::ALL - .iter() - .copied() - .find(|provider| provider.id() == normalized) - } - - /// The GA model a fresh install (or a provider switch with no remembered - /// choice) uses: cheap, fast and tool-capable, because commit-message - /// generation does not need a frontier model. - pub fn default_model(self) -> &'static str { - match self { - AiProvider::Gemini => "gemini-3.1-flash-lite", - AiProvider::OpenAi => "gpt-5.6-luna", - AiProvider::Anthropic => "claude-haiku-4-5", - AiProvider::DeepSeek => "deepseek-v4-flash", - AiProvider::OpenRouter => "google/gemini-3.1-flash-lite", - } + /// Localize `key`. Missing Chinese entries fall back to English by + /// construction: every arm pairs `(self, key)` explicitly. + pub fn tr(self, key: TrKey) -> &'static str { + match (self, key) { + (_, TrKey::Preferences) => match self { + Language::English => "Preferences", + Language::SimplifiedChinese => "偏好设置", + }, + (_, TrKey::SectionAppearance) => match self { + Language::English => "Appearance", + Language::SimplifiedChinese => "外观", + }, + (_, TrKey::SectionAi) => "AI", + (_, TrKey::SectionAuth) => match self { + Language::English => "Auth", + Language::SimplifiedChinese => "认证", + }, + (_, TrKey::SectionGeneral) => match self { + Language::English => "General", + Language::SimplifiedChinese => "通用", + }, + (_, TrKey::GeneralTitle) => match self { + Language::English => "General", + Language::SimplifiedChinese => "通用", + }, + (_, TrKey::GeneralDesc) => match self { + Language::English => "Application preferences and behavior.", + Language::SimplifiedChinese => "应用偏好与行为。", + }, + (_, TrKey::LanguageTitle) => match self { + Language::English => "Language", + Language::SimplifiedChinese => "语言", + }, + (_, TrKey::LanguageDesc) => match self { + Language::English => "Switch the interface language.", + Language::SimplifiedChinese => "切换界面语言。", + }, + (_, TrKey::MaxRecentTitle) => match self { + Language::English => "Max Recent Repositories", + Language::SimplifiedChinese => "最大最近仓库数", + }, + (_, TrKey::MaxRecentDesc) => match self { + Language::English => "Number of repos shown in the recent list.", + Language::SimplifiedChinese => "最近列表中显示的仓库数量。", + }, + (_, TrKey::UiDensityTitle) => match self { + Language::English => "UI Density", + Language::SimplifiedChinese => "界面密度", + }, + (_, TrKey::UiDensityDesc) => match self { + Language::English => "Adjust the spacing and sizing of UI elements.", + Language::SimplifiedChinese => "调整界面元素的间距与尺寸。", + }, + (_, TrKey::DensityCompact) => match self { + Language::English => "Compact", + Language::SimplifiedChinese => "紧凑", + }, + (_, TrKey::DensityDefault) => match self { + Language::English => "Default", + Language::SimplifiedChinese => "默认", + }, + (_, TrKey::DensityComfortable) => match self { + Language::English => "Comfortable", + Language::SimplifiedChinese => "舒适", + }, + (_, TrKey::FontSizeTitle) => match self { + Language::English => "Font Size", + Language::SimplifiedChinese => "字号", + }, + (_, TrKey::FontSizeDesc) => match self { + Language::English => "Base font size for the user interface (8 - 24).", + Language::SimplifiedChinese => "界面基础字号(8 - 24)。", + }, + (_, TrKey::GeneralPageDesc) => match self { + Language::English => "General application behavior and workspace defaults.", + Language::SimplifiedChinese => "通用应用行为与工作区默认设置。", + }, + (_, TrKey::ThemeSectionDesc) => match self { + Language::English => "Customize the look and feel of the application.", + Language::SimplifiedChinese => "自定义应用的外观与体验。", + }, + (_, TrKey::ThemePageDesc) => match self { + Language::English => "Theme, layout, and visual defaults for the application.", + Language::SimplifiedChinese => "应用的主题、布局与视觉默认设置。", + }, + (_, TrKey::AppearanceModeTitle) => match self { + Language::English => "Appearance Mode", + Language::SimplifiedChinese => "外观模式", + }, + (_, TrKey::AppearanceModeDesc) => match self { + Language::English => "Choose whether to show light or dark themes, or auto-detect.", + Language::SimplifiedChinese => "选择显示浅色或深色主题,或跟随系统自动切换。", + }, + (_, TrKey::ModeAuto) => match self { + Language::English => "Auto", + Language::SimplifiedChinese => "自动", + }, + (_, TrKey::ModeLight) => match self { + Language::English => "Light", + Language::SimplifiedChinese => "浅色", + }, + (_, TrKey::ModeDark) => match self { + Language::English => "Dark", + Language::SimplifiedChinese => "深色", + }, + (_, TrKey::ColorThemeTitle) => match self { + Language::English => "Color Theme", + Language::SimplifiedChinese => "配色主题", + }, + (_, TrKey::ColorThemeDesc) => match self { + Language::English => "Select a theme to change all interface colors.", + Language::SimplifiedChinese => "选择主题以更改全部界面颜色。", + }, + (_, TrKey::ActiveBadge) => match self { + Language::English => "Active", + Language::SimplifiedChinese => "使用中", + }, + (_, TrKey::CustomThemeTitle) => match self { + Language::English => "Custom Theme Editor", + Language::SimplifiedChinese => "自定义主题编辑器", + }, + (_, TrKey::CustomThemeDesc) => match self { + Language::English => "Edit colors, create custom themes, and export as JSON.", + Language::SimplifiedChinese => "编辑颜色、创建自定义主题并导出为 JSON。", + }, + (_, TrKey::EditThemeBtn) => match self { + Language::English => "Edit Theme", + Language::SimplifiedChinese => "编辑主题", + }, + (_, TrKey::AccountsTitle) => match self { + Language::English => "Accounts", + Language::SimplifiedChinese => "账户", + }, + (_, TrKey::AccountsDesc) => match self { + Language::English => "Account profiles, HTTPS tokens, and SSH configuration.", + Language::SimplifiedChinese => "账户档案、HTTPS 令牌与 SSH 配置。", + }, + (_, TrKey::AccountsHeader) => match self { + Language::English => "Accounts & Credentials", + Language::SimplifiedChinese => "账户与凭证", + }, + (_, TrKey::AccountsSectionDesc) => match self { + Language::English => "Use browser sign-in for GitHub or GitLab, manage all account profiles in one place, and keep SSH separate from HTTPS tokens.", + Language::SimplifiedChinese => "使用浏览器登录 GitHub 或 GitLab,在一处管理所有账户档案,并将 SSH 与 HTTPS 令牌分开管理。", + }, + (_, TrKey::QuickSetupTitle) => match self { + Language::English => "Quick Setup", + Language::SimplifiedChinese => "快速设置", + }, + (_, TrKey::QuickSetupDesc) => match self { + Language::English => "Start by adding the provider account you want to use. Browser sign-in is the primary path. Manual setup is available for self-hosted or custom HTTPS remotes.", + Language::SimplifiedChinese => "先添加要使用的服务商账户。推荐使用浏览器登录;自建或自定义 HTTPS 远程可使用手动设置。", + }, + (_, TrKey::SignInGitHub) => match self { + Language::English => "Sign in with GitHub", + Language::SimplifiedChinese => "使用 GitHub 登录", + }, + (_, TrKey::AddGitLab) => match self { + Language::English => "Add GitLab Account", + Language::SimplifiedChinese => "添加 GitLab 账户", + }, + (_, TrKey::ManualCustom) => match self { + Language::English => "Manual / Custom Host", + Language::SimplifiedChinese => "手动 / 自定义主机", + }, + (_, TrKey::AuthProfilesTitle) => match self { + Language::English => "Profiles", + Language::SimplifiedChinese => "账户档案", + }, + (_, TrKey::AuthProfilesDesc) => match self { + Language::English => "All saved accounts live here. Select one to connect it, paste a token, or adjust host matching.", + Language::SimplifiedChinese => "所有已保存的账户都在这里。选择一个进行连接、粘贴令牌或调整主机匹配。", + }, + (_, TrKey::AuthProfilesEmptyTitle) => match self { + Language::English => "No provider accounts configured yet.", + Language::SimplifiedChinese => "尚未配置服务商账户。", + }, + (_, TrKey::AuthProfilesEmptyDesc) => match self { + Language::English => "Add a GitHub account to start with the default flow, or choose Manual / Custom Host for enterprise and other HTTPS remotes.", + Language::SimplifiedChinese => "先添加 GitHub 账户以使用默认流程;企业版与其他 HTTPS 远程请选择“手动 / 自定义主机”。", + }, + (_, TrKey::AuthHttpsFallbackTitle) => match self { + Language::English => "Fallback HTTPS Token / PAT", + Language::SimplifiedChinese => "备用 HTTPS 令牌 / PAT", + }, + (_, TrKey::AuthHttpsFallbackDesc) => match self { + Language::English => "Used only when no profile-specific account matches the remote host. Leave this empty unless you truly need a generic fallback.", + Language::SimplifiedChinese => "仅在没有账户档案匹配远程主机时使用。除非确实需要通用备用,否则请留空。", + }, + (_, TrKey::AuthSshOverrideTitle) => match self { + Language::English => "SSH Key Override", + Language::SimplifiedChinese => "SSH 密钥覆盖", + }, + (_, TrKey::AuthSshOverrideDesc) => match self { + Language::English => "Optional. If set, this key is tried first for SSH remotes. If SSH fails and you have a GitHub account above, rgitui automatically falls back to HTTPS.", + Language::SimplifiedChinese => "可选。如果设置,SSH 远程将优先尝试此密钥。如果 SSH 失败且上方已配置 GitHub 账户,rgitui 会自动回退到 HTTPS。", + }, + (_, TrKey::AuthSignCommitsTitle) => match self { + Language::English => "Sign Commits", + Language::SimplifiedChinese => "签署提交", + }, + (_, TrKey::AuthSignCommitsDesc) => match self { + Language::English => "Sign commits with your configured GPG key.", + Language::SimplifiedChinese => "使用你配置的 GPG 密钥签署提交。", + }, + (_, TrKey::AuthGpgKeyTitle) => match self { + Language::English => "GPG Key ID", + Language::SimplifiedChinese => "GPG 密钥 ID", + }, + (_, TrKey::AuthGpgKeyDesc) => match self { + Language::English => "Leave empty to use your existing git config default signing key.", + Language::SimplifiedChinese => "留空则使用现有 git 配置中的默认签名密钥。", + }, + (_, TrKey::AuthHowTitle) => match self { + Language::English => "How Authentication Works", + Language::SimplifiedChinese => "认证工作原理", + }, + (_, TrKey::AuthHowDesc) => match self { + Language::English => "rgitui detects the remote URL protocol and authenticates accordingly.", + Language::SimplifiedChinese => "rgitui 会检测远程 URL 协议并据此进行认证。", + }, + (_, TrKey::AuthHowHttps) => match self { + Language::English => "HTTPS remotes: uses your GitHub account token (above), or falls back to your system git credential helper (e.g. gh auth).", + Language::SimplifiedChinese => "HTTPS 远程:使用你的 GitHub 账户令牌(见上方),或回退到系统 git 凭证助手(例如 gh auth)。", + }, + (_, TrKey::AuthHowSshFallback) => match self { + Language::English => "SSH remotes: if no SSH key is configured above, and your system has an HTTPS credential helper, rgitui automatically rewrites SSH to HTTPS so your GitHub login works.", + Language::SimplifiedChinese => "SSH 远程:如果上方未配置 SSH 密钥,且系统装有 HTTPS 凭证助手,rgitui 会自动将 SSH 改写为 HTTPS,以便 GitHub 登录生效。", + }, + (_, TrKey::AuthHowSshKey) => match self { + Language::English => "SSH remotes with a key: uses the SSH key path above (or ssh-agent) directly.", + Language::SimplifiedChinese => "带密钥的 SSH 远程:直接使用上方的 SSH 密钥路径(或 ssh-agent)。", + }, + (_, TrKey::AiPageDesc) => match self { + Language::English => "Providers, models, and how commit messages get written.", + Language::SimplifiedChinese => "服务商、模型,以及提交信息的生成方式。", + }, + (_, TrKey::AiSectionConnection) => match self { + Language::English => "CONNECTION", + Language::SimplifiedChinese => "连接", + }, + (_, TrKey::AiSectionBehaviour) => match self { + Language::English => "BEHAVIOUR", + Language::SimplifiedChinese => "行为", + }, + (_, TrKey::AiStatusTitle) => match self { + Language::English => "AI Commit Messages", + Language::SimplifiedChinese => "AI 提交信息", + }, + (_, TrKey::AiEnabledToggle) => match self { + Language::English => "Enabled", + Language::SimplifiedChinese => "启用", + }, + (_, TrKey::AiStatusOff) => match self { + Language::English => "AI is turned off. Nothing will be sent to any provider.", + Language::SimplifiedChinese => "AI 已关闭,不会向任何服务商发送内容。", + }, + (_, TrKey::AiStatusTestingFmt) => match self { + Language::English => "Testing {}…", + Language::SimplifiedChinese => "正在测试 {}…", + }, + (_, TrKey::AiStatusRejectedFmt) => match self { + Language::English => "{} rejected this key.", + Language::SimplifiedChinese => "{} 拒绝了此密钥。", + }, + (_, TrKey::AiStatusAddKeyFmt) => match self { + Language::English => "Add a {} API key to get started.", + Language::SimplifiedChinese => "添加 {} API 密钥以开始使用。", + }, + (_, TrKey::AiConnectTitle) => match self { + Language::English => "Connect an AI provider", + Language::SimplifiedChinese => "连接 AI 服务商", + }, + (_, TrKey::AiConnectDesc) => match self { + Language::English => "rgitui writes commit messages from your staged diff.", + Language::SimplifiedChinese => "rgitui 会根据已暂存的 diff 编写提交信息。", + }, + (_, TrKey::AiApiKeyLabel) => match self { + Language::English => "API key", + Language::SimplifiedChinese => "API 密钥", + }, + (_, TrKey::AiGetKeyBtn) => match self { + Language::English => "Get a key", + Language::SimplifiedChinese => "获取密钥", + }, + (_, TrKey::AiKeyVerifiedFmt) => match self { + Language::English => "Verified {} · stored in {}", + Language::SimplifiedChinese => "已验证({})·存储于 {}", + }, + (_, TrKey::AiKeyStoredHintFmt) => match self { + Language::English => "Key stored in {}. Test it to confirm it works.", + Language::SimplifiedChinese => "密钥存储于 {},点击测试确认其可用。", + }, + (_, TrKey::AiNoKeyHintFmt) => match self { + Language::English => "No API key needed — requests go to {}.", + Language::SimplifiedChinese => "无需 API 密钥——请求将发送至 {}。", + }, + (_, TrKey::AiKeysStoredNoteFmt) => match self { + Language::English => { + "Keys are stored in {}, never in settings.json, and are only read when a request is sent." + } + Language::SimplifiedChinese => { + "密钥存储于 {},从不写入 settings.json,仅在发送请求时读取。" + } + }, + (_, TrKey::AiModelLabel) => match self { + Language::English => "Model", + Language::SimplifiedChinese => "模型", + }, + (_, TrKey::AiModelCountFmt) => match self { + Language::English => "{} models · {}", + Language::SimplifiedChinese => "{} 个模型 · {}", + }, + (_, TrKey::AiRefreshBtn) => match self { + Language::English => "Refresh", + Language::SimplifiedChinese => "刷新", + }, + (_, TrKey::AiRefreshingBtn) => match self { + Language::English => "Refreshing…", + Language::SimplifiedChinese => "刷新中…", + }, + (_, TrKey::AiModelMissingFmt) => match self { + Language::English => { + "`{}` is not in {}'s current model list. It may have been retired." + } + Language::SimplifiedChinese => "`{}` 不在 {} 当前的模型列表中,可能已被下线。", + }, + (_, TrKey::AiUseSuggestionFmt) => match self { + Language::English => "Use {}", + Language::SimplifiedChinese => "使用 {}", + }, + (_, TrKey::AiConnectBtn) => match self { + Language::English => "Connect", + Language::SimplifiedChinese => "连接", + }, + (_, TrKey::AiUseProviderBtn) => match self { + Language::English => "Use this provider", + Language::SimplifiedChinese => "使用此服务商", + }, + (_, TrKey::AiTestBtn) => match self { + Language::English => "Test", + Language::SimplifiedChinese => "测试", + }, + (_, TrKey::AiTestingBtn) => match self { + Language::English => "Testing…", + Language::SimplifiedChinese => "测试中…", + }, + (_, TrKey::AiRemoveKeyBtn) => match self { + Language::English => "Remove key", + Language::SimplifiedChinese => "移除密钥", + }, + (_, TrKey::AiAdvancedLabel) => match self { + Language::English => "Advanced", + Language::SimplifiedChinese => "高级", + }, + (_, TrKey::AiBaseUrlLabel) => "Base URL", + (_, TrKey::AiBaseUrlOverriddenFmt) => match self { + Language::English => { + "Requests go to {} instead of {}. Your API key is sent to that host." + } + Language::SimplifiedChinese => { + "请求将发送至 {} 而非 {},您的 API 密钥也会被发送到该主机。" + } + }, + (_, TrKey::AiBaseUrlEmptyFmt) => match self { + Language::English => { + "Empty means use {}. Only OpenAI-compatible providers honour an override." + } + Language::SimplifiedChinese => { + "留空表示使用 {},仅 OpenAI 兼容服务商支持覆盖此项。" + } + }, + (_, TrKey::AiAttributionTitle) => match self { + Language::English => "Send attribution headers", + Language::SimplifiedChinese => "发送归因请求头", + }, + (_, TrKey::AiAttributionDesc) => match self { + Language::English => { + "Adds HTTP-Referer and X-Title so rgitui appears on OpenRouter's public leaderboard. Never functional." + } + Language::SimplifiedChinese => { + "附加 HTTP-Referer 与 X-Title 请求头,使 rgitui 出现在 OpenRouter 公开排行榜上,不影响任何功能。" + } + }, + (_, TrKey::AiCommitStyleTitle) => match self { + Language::English => "Commit style", + Language::SimplifiedChinese => "提交信息风格", + }, + (_, TrKey::AiCommitStyleDesc) => match self { + Language::English => "How the AI should format commit messages.", + Language::SimplifiedChinese => "AI 组织提交信息内容的方式。", + }, + (_, TrKey::AiCtxTitle) => match self { + Language::English => "Include project context", + Language::SimplifiedChinese => "包含项目上下文", + }, + (_, TrKey::AiCtxDesc) => match self { + Language::English => { + "Adds README.md, CLAUDE.md and AGENTS.md to the prompt. ~4k extra tokens per request." + } + Language::SimplifiedChinese => { + "将 README.md、CLAUDE.md 与 AGENTS.md 加入提示词,每次请求约多消耗 4k token。" + } + }, + (_, TrKey::AiToolsTitle) => match self { + Language::English => "Let the model read files", + Language::SimplifiedChinese => "允许模型读取文件", + }, + (_, TrKey::AiToolsDesc) => match self { + Language::English => { + "The model may request file contents and commit history. Slower and more expensive; usually a better message." + } + Language::SimplifiedChinese => { + "模型可按需读取文件内容与提交历史。速度更慢、费用更高,但提交信息通常更好。" + } + }, + (_, TrKey::AiKeyPlaceholderFmt) => match self { + Language::English => "Paste your {} API key", + Language::SimplifiedChinese => "粘贴您的 {} API 密钥", + }, + (_, TrKey::ShowLineNumbersTitle) => match self { + Language::English => "Show Line Numbers in Diff", + Language::SimplifiedChinese => "在 Diff 中显示行号", + }, + (_, TrKey::ShowLineNumbersDesc) => match self { + Language::English => "Display line numbers alongside diff content.", + Language::SimplifiedChinese => "在 diff 内容旁显示行号。", + }, + (_, TrKey::WrapLinesTitle) => match self { + Language::English => "Wrap Long Lines in Diff", + Language::SimplifiedChinese => "在 Diff 中自动换行", + }, + (_, TrKey::WrapLinesDesc) => match self { + Language::English => "Wrap overflowing lines instead of scrolling horizontally.", + Language::SimplifiedChinese => "超长行自动换行,而非横向滚动。", + }, + (_, TrKey::DiffModeTitle) => match self { + Language::English => "Default Diff View Mode", + Language::SimplifiedChinese => "默认 Diff 视图", + }, + (_, TrKey::DiffModeDesc) => match self { + Language::English => "Choose how diffs are displayed.", + Language::SimplifiedChinese => "选择 diff 的显示方式。", + }, + (_, TrKey::DiffUnified) => match self { + Language::English => "Unified", + Language::SimplifiedChinese => "统一视图", + }, + (_, TrKey::DiffSideBySide) => match self { + Language::English => "Side-by-Side", + Language::SimplifiedChinese => "并排视图", + }, + (_, TrKey::GraphTitle) => match self { + Language::English => "Graph Style", + Language::SimplifiedChinese => "提交图样式", + }, + (_, TrKey::GraphDesc) => match self { + Language::English => "Visual style for the commit graph rendering.", + Language::SimplifiedChinese => "提交图渲染的视觉样式。", + }, + (_, TrKey::GraphRails) => match self { + Language::English => "Rails", + Language::SimplifiedChinese => "轨道", + }, + (_, TrKey::GraphCurved) => match self { + Language::English => "Curved", + Language::SimplifiedChinese => "曲线", + }, + (_, TrKey::GraphAngular) => match self { + Language::English => "Angular", + Language::SimplifiedChinese => "折线", + }, + (_, TrKey::ShowSubjectTitle) => match self { + Language::English => "Show Subject Column", + Language::SimplifiedChinese => "显示主题列", + }, + (_, TrKey::ShowSubjectDesc) => match self { + Language::English => "Display commit subject in the graph.", + Language::SimplifiedChinese => "在提交图中显示提交主题。", + }, + (_, TrKey::AutoFetchTitle) => match self { + Language::English => "Auto-Fetch Interval", + Language::SimplifiedChinese => "自动获取间隔", + }, + (_, TrKey::AutoFetchDesc) => match self { + Language::English => "How often to automatically fetch from remotes in the background.", + Language::SimplifiedChinese => "后台自动从远程获取的时间间隔。", + }, + (_, TrKey::ConfirmDestructiveTitle) => match self { + Language::English => "Confirm Before Destructive Operations", + Language::SimplifiedChinese => "危险操作前确认", + }, + (_, TrKey::ConfirmDestructiveDesc) => match self { + Language::English => "Show a confirmation dialog before force push, branch delete, discard changes, and similar actions.", + Language::SimplifiedChinese => "在强制推送、删除分支、丢弃更改等操作前弹出确认框。", + }, + (_, TrKey::CheckUpdatesTitle) => match self { + Language::English => "Check for Updates on Startup", + Language::SimplifiedChinese => "启动时检查更新", + }, + (_, TrKey::CheckUpdatesDesc) => match self { + Language::English => "Contact api.github.com once per day to see if a newer release is available. Turn off to keep rgitui offline.", + Language::SimplifiedChinese => "每天联系一次 api.github.com 检查新版本。关闭可保持 rgitui 离线。", + }, + (_, TrKey::WatchWorktreesTitle) => match self { + Language::English => "Watch All Worktrees", + Language::SimplifiedChinese => "监视所有工作树", + }, + (_, TrKey::WatchWorktreesDesc) => match self { + Language::English => "Refresh the graph whenever files change in any linked worktree, not just the current one. Useful when you have multiple worktrees open and work is happening in them in parallel.", + Language::SimplifiedChinese => "任何关联工作树文件变化时都刷新提交图,而不仅是当前工作树。并行使用多个工作树时很有用。", + }, + (_, TrKey::ExternalToolsTitle) => match self { + Language::English => "External Tools", + Language::SimplifiedChinese => "外部工具", + }, + (_, TrKey::ExternalToolsDesc) => match self { + Language::English => "Select a detected application or enter a custom command.", + Language::SimplifiedChinese => "选择检测到的应用或输入自定义命令。", + }, + (_, TrKey::TerminalLabel) => match self { + Language::English => "Terminal", + Language::SimplifiedChinese => "终端", + }, + (_, TrKey::EditorLabel) => match self { + Language::English => "Editor", + Language::SimplifiedChinese => "编辑器", + }, + (_, TrKey::CustomCommandLabel) => match self { + Language::English => "Custom command (overrides selection)", + Language::SimplifiedChinese => "自定义命令(覆盖所选项)", + }, + (_, TrKey::ShortcutsTitle) => match self { + Language::English => "Keyboard Shortcuts", + Language::SimplifiedChinese => "键盘快捷键", + }, + (_, TrKey::ShortcutsDesc) => match self { + Language::English => "The bindings in force for a few common actions. Open the full reference from the workspace for all of them.", + Language::SimplifiedChinese => "几个常用操作的当前绑定。从工作区可打开完整快捷键参考。", + }, + (_, TrKey::ConfigTitle) => match self { + Language::English => "Config File", + Language::SimplifiedChinese => "配置文件", + }, + (_, TrKey::ConfigDesc) => match self { + Language::English => "Location of the settings file on disk.", + Language::SimplifiedChinese => "磁盘上设置文件的位置。", + }, + (_, TrKey::RevealBtn) => match self { + Language::English => "Reveal", + Language::SimplifiedChinese => "打开所在目录", + }, + (_, TrKey::EditKeymapBtn) => match self { + Language::English => "Edit keymap.json", + Language::SimplifiedChinese => "编辑 keymap.json", + }, + (_, TrKey::SavedFeedback) => match self { + Language::English => "Saved", + Language::SimplifiedChinese => "已保存", + }, + (_, TrKey::TbFetch) => match self { + Language::English => "Fetch", + Language::SimplifiedChinese => "获取", + }, + (_, TrKey::TbFetching) => match self { + Language::English => "Fetching...", + Language::SimplifiedChinese => "获取中…", + }, + (_, TrKey::TbPull) => match self { + Language::English => "Pull", + Language::SimplifiedChinese => "拉取", + }, + (_, TrKey::TbPulling) => match self { + Language::English => "Pulling...", + Language::SimplifiedChinese => "拉取中…", + }, + (_, TrKey::TbPush) => match self { + Language::English => "Push", + Language::SimplifiedChinese => "推送", + }, + (_, TrKey::TbPushing) => match self { + Language::English => "Pushing...", + Language::SimplifiedChinese => "推送中…", + }, + (_, TrKey::TbBranch) => match self { + Language::English => "Branch", + Language::SimplifiedChinese => "分支", + }, + (_, TrKey::TbStash) => match self { + Language::English => "Stash", + Language::SimplifiedChinese => "储藏", + }, + (_, TrKey::TbPop) => match self { + Language::English => "Pop", + Language::SimplifiedChinese => "弹出", + }, + (_, TrKey::TbCreatePr) => match self { + Language::English => "Create PR", + Language::SimplifiedChinese => "创建 PR", + }, + (_, TrKey::StatusClean) => match self { + Language::English => "Clean", + Language::SimplifiedChinese => "干净", + }, + (_, TrKey::StatusDetached) => match self { + Language::English => "DETACHED", + Language::SimplifiedChinese => "已分离", + }, + (_, TrKey::SideBranches) => match self { + Language::English => "Branches", + Language::SimplifiedChinese => "分支", + }, + (_, TrKey::SideRemotes) => match self { + Language::English => "Remotes", + Language::SimplifiedChinese => "远程", + }, + (_, TrKey::SideRemoteBranches) => match self { + Language::English => "Remote Branches", + Language::SimplifiedChinese => "远程分支", + }, + (_, TrKey::SideTags) => match self { + Language::English => "Tags", + Language::SimplifiedChinese => "标签", + }, + (_, TrKey::SideStashes) => match self { + Language::English => "Stashes", + Language::SimplifiedChinese => "储藏", + }, + (_, TrKey::SideWorktrees) => match self { + Language::English => "Worktrees", + Language::SimplifiedChinese => "工作树", + }, + (_, TrKey::SideStaged) => match self { + Language::English => "Staged", + Language::SimplifiedChinese => "已暂存", + }, + (_, TrKey::SideUnstaged) => match self { + Language::English => "Unstaged", + Language::SimplifiedChinese => "未暂存", + }, + (_, TrKey::SideStageAll) => match self { + Language::English => "Stage All", + Language::SimplifiedChinese => "全部暂存", + }, + (_, TrKey::SideUnstageAll) => match self { + Language::English => "Unstage All", + Language::SimplifiedChinese => "全部取消暂存", + }, + (_, TrKey::SideNewWorktree) => match self { + Language::English => "New Worktree", + Language::SimplifiedChinese => "新建工作树", + }, + (_, TrKey::SideWorkingClean) => match self { + Language::English => "Working tree clean", + Language::SimplifiedChinese => "工作树干净", + }, + (_, TrKey::SideNoStaged) => match self { + Language::English => "No staged changes", + Language::SimplifiedChinese => "暂无已暂存的更改", + }, + (_, TrKey::CommitBtn) => match self { + Language::English => "Commit", + Language::SimplifiedChinese => "提交", + }, + (_, TrKey::CommitNoStaged) => match self { + Language::English => "No Staged Changes", + Language::SimplifiedChinese => "暂无已暂存的更改", + }, + (_, TrKey::CommitNoMessage) => match self { + Language::English => "No Message", + Language::SimplifiedChinese => "请输入提交信息", + }, + (_, TrKey::CommitAmendBtn) => match self { + Language::English => "Amend Commit", + Language::SimplifiedChinese => "修正提交", + }, + (_, TrKey::AmendBtn) => match self { + Language::English => "Amend", + Language::SimplifiedChinese => "修正", + }, + (_, TrKey::ClearBtn) => match self { + Language::English => "Clear", + Language::SimplifiedChinese => "清空", + }, + (_, TrKey::SummaryLabel) => match self { + Language::English => "Summary", + Language::SimplifiedChinese => "摘要", + }, + (_, TrKey::DescLabel) => match self { + Language::English => "Description", + Language::SimplifiedChinese => "详细描述", + }, + (_, TrKey::CoAuthorsLabel) => match self { + Language::English => "Co-Authors", + Language::SimplifiedChinese => "共同作者", + }, + (_, TrKey::NoStagedHint) => match self { + Language::English => "No staged changes", + Language::SimplifiedChinese => "暂无已暂存的更改", + }, + (_, TrKey::CancelBtn) => match self { + Language::English => "Cancel", + Language::SimplifiedChinese => "取消", + }, + (_, TrKey::ConfirmHint) => match self { + Language::English => "Enter to confirm | Esc to cancel", + Language::SimplifiedChinese => "回车确认 | Esc 取消", + }, + (_, TrKey::CfConfirm) => match self { + Language::English => "Confirm", + Language::SimplifiedChinese => "确认", + }, + (_, TrKey::CfDiscard) => match self { + Language::English => "Discard", + Language::SimplifiedChinese => "丢弃", + }, + (_, TrKey::CfClean) => match self { + Language::English => "Clean", + Language::SimplifiedChinese => "清理", + }, + (_, TrKey::CfDeleteBranch) => match self { + Language::English => "Delete Branch", + Language::SimplifiedChinese => "删除分支", + }, + (_, TrKey::CfDeleteTag) => match self { + Language::English => "Delete Tag", + Language::SimplifiedChinese => "删除标签", + }, + (_, TrKey::CfRemove) => match self { + Language::English => "Remove", + Language::SimplifiedChinese => "移除", + }, + (_, TrKey::CfDropStash) => match self { + Language::English => "Drop Stash", + Language::SimplifiedChinese => "丢弃储藏", + }, + (_, TrKey::CfReset) => match self { + Language::English => "Reset", + Language::SimplifiedChinese => "重置", + }, + (_, TrKey::CfAbort) => match self { + Language::English => "Abort", + Language::SimplifiedChinese => "中止", + }, + (_, TrKey::CfForcePush) => match self { + Language::English => "Force Push", + Language::SimplifiedChinese => "强制推送", + }, + (_, TrKey::CfRemoveWorktree) => match self { + Language::English => "Remove Worktree", + Language::SimplifiedChinese => "移除工作树", + }, + (_, TrKey::DiffFileBtn) => match self { + Language::English => "File", + Language::SimplifiedChinese => "文件", + }, + (_, TrKey::DiffEmptyTitle) => match self { + Language::English => "Select a file to view changes", + Language::SimplifiedChinese => "选择文件以查看更改", + }, + (_, TrKey::DiffEmptyDesc) => match self { + Language::English => "Click a file in the sidebar or detail panel", + Language::SimplifiedChinese => "点击侧边栏或详情面板中的文件", + }, + (_, TrKey::DiffOpenFile) => match self { + Language::English => "Open File", + Language::SimplifiedChinese => "打开文件", + }, + (_, TrKey::DetailTitle) => match self { + Language::English => "Details", + Language::SimplifiedChinese => "详情", + }, + (_, TrKey::DetailEmptyTitle) => match self { + Language::English => "No commit selected", + Language::SimplifiedChinese => "未选择提交", + }, + (_, TrKey::DetailEmptyDesc) => match self { + Language::English => "Select a commit from the graph to view details", + Language::SimplifiedChinese => "从提交图中选择一个提交以查看详情", + }, + (_, TrKey::HomeSlogan) => match self { + Language::English => "A workspace-oriented desktop Git client", + Language::SimplifiedChinese => "面向工作区的桌面 Git 客户端", + }, + (_, TrKey::OpenRepoTitle) => match self { + Language::English => "Open Repository", + Language::SimplifiedChinese => "打开仓库", + }, + (_, TrKey::HomeNewWorkspace) => match self { + Language::English => "New Workspace", + Language::SimplifiedChinese => "新建工作区", + }, + (_, TrKey::HomeRestoreLast) => match self { + Language::English => "Restore Last", + Language::SimplifiedChinese => "恢复上次", + }, + (_, TrKey::HomeRecentWorkspaces) => match self { + Language::English => "Recent Workspaces", + Language::SimplifiedChinese => "最近的工作区", + }, + (_, TrKey::HomeRecentRepos) => match self { + Language::English => "Recent Repositories", + Language::SimplifiedChinese => "最近的仓库", + }, + (_, TrKey::WsSummaryFmt) => match self { + Language::English => "{} repositories | updated {}", + Language::SimplifiedChinese => "{} 个仓库 | 更新于 {}", + }, + (_, TrKey::LoadingDots) => match self { + Language::English => "Loading...", + Language::SimplifiedChinese => "加载中…", + }, + (_, TrKey::OpRetry) => match self { + Language::English => "Retry", + Language::SimplifiedChinese => "重试", + }, + (_, TrKey::ConflictOneFmt) => match self { + Language::English => "{} file with conflicts -- resolve before continuing", + Language::SimplifiedChinese => "有 {} 个文件存在冲突——继续前请先解决", + }, + (_, TrKey::ConflictManyFmt) => match self { + Language::English => "{} files with conflicts -- resolve before continuing", + Language::SimplifiedChinese => "有 {} 个文件存在冲突——继续前请先解决", + }, + (_, TrKey::ConflictResolved) => match self { + Language::English => "All conflicts resolved -- ready to continue", + Language::SimplifiedChinese => "所有冲突已解决——可以继续", + }, + (_, TrKey::ConflictContinue) => match self { + Language::English => "Continue", + Language::SimplifiedChinese => "继续", + }, + (_, TrKey::WorktreeExit) => match self { + Language::English => "Exit Worktree", + Language::SimplifiedChinese => "退出工作树", + }, + (_, TrKey::InspectingPre) => match self { + Language::English => "Inspecting worktree:", + Language::SimplifiedChinese => "正在查看工作树:", + }, + (_, TrKey::DetachedHeadWord) => match self { + Language::English => "detached HEAD", + Language::SimplifiedChinese => "已分离的 HEAD", + }, + (_, TrKey::BottomDiff) => match self { + Language::English => "Diff", + Language::SimplifiedChinese => "差异", + }, + (_, TrKey::BottomDiffTip) => match self { + Language::English => "Show diff (d)", + Language::SimplifiedChinese => "显示差异 (d)", + }, + (_, TrKey::BottomHistory) => match self { + Language::English => "History", + Language::SimplifiedChinese => "历史", + }, + (_, TrKey::HistLoading) => match self { + Language::English => "Preparing file history...", + Language::SimplifiedChinese => "正在准备文件历史…", + }, + (_, TrKey::HistShow) => match self { + Language::English => "Show file history (h)", + Language::SimplifiedChinese => "显示文件历史 (h)", + }, + (_, TrKey::HistUnavailable) => match self { + Language::English => "No committed history is available for this file", + Language::SimplifiedChinese => "该文件暂无已提交历史", + }, + (_, TrKey::BottomBlame) => match self { + Language::English => "Blame", + Language::SimplifiedChinese => "追溯", + }, + (_, TrKey::BlameLoading) => match self { + Language::English => "Preparing blame...", + Language::SimplifiedChinese => "正在准备追溯…", + }, + (_, TrKey::BlameShow) => match self { + Language::English => "Show blame (b)", + Language::SimplifiedChinese => "显示追溯 (b)", + }, + (_, TrKey::BlameUnavailable) => match self { + Language::English => "Blame is unavailable for this file at the selected commit", + Language::SimplifiedChinese => "所选提交中的该文件无法追溯", + }, + (_, TrKey::GraphShowTip) => match self { + Language::English => "Show commit graph", + Language::SimplifiedChinese => "显示提交图", + }, + (_, TrKey::GraphHideTip) => match self { + Language::English => "Hide commit graph", + Language::SimplifiedChinese => "隐藏提交图", + }, + (_, TrKey::DetachedTitlePre) => match self { + Language::English => "HEAD is detached at", + Language::SimplifiedChinese => "HEAD 已分离,位于", + }, + (_, TrKey::DetachedConsequence) => match self { + Language::English => "Commits made here belong to no branch", + Language::SimplifiedChinese => "此处所做的提交不属于任何分支", + }, + (_, TrKey::DetachedReturnPre) => match self { + Language::English => "Return to", + Language::SimplifiedChinese => "返回", + }, + (_, TrKey::DetachedReturnTipPre) => match self { + Language::English => "Check out '", + Language::SimplifiedChinese => "检出 '", + }, + (_, TrKey::DetachedReturnTipPost) => match self { + Language::English => "', the branch HEAD was on before", + Language::SimplifiedChinese => "',HEAD 之前所在的分支", + }, + (_, TrKey::DetachedSwitch) => match self { + Language::English => "Switch Branch…", + Language::SimplifiedChinese => "切换分支…", + }, + (_, TrKey::DetachedShowGraph) => match self { + Language::English => "Show in Graph", + Language::SimplifiedChinese => "在提交图中显示", + }, + (_, TrKey::UpdateMsgFmt) => match self { + Language::English => "rgitui {} is available (you have {})", + Language::SimplifiedChinese => "rgitui {} 可用(当前版本 {})", + }, + (_, TrKey::UpdateDownload) => match self { + Language::English => "Download", + Language::SimplifiedChinese => "下载", + }, + (_, TrKey::MenuCherryPick) => match self { + Language::English => "Cherry-pick commit", + Language::SimplifiedChinese => "拣选提交", + }, + (_, TrKey::MenuRevert) => match self { + Language::English => "Revert commit", + Language::SimplifiedChinese => "还原提交", + }, + (_, TrKey::MenuCheckout) => match self { + Language::English => "Checkout commit", + Language::SimplifiedChinese => "检出提交", + }, + (_, TrKey::MenuCreateBranch) => match self { + Language::English => "Create branch here", + Language::SimplifiedChinese => "在此创建分支", + }, + (_, TrKey::MenuCreateTag) => match self { + Language::English => "Create tag here", + Language::SimplifiedChinese => "在此创建标签", + }, + (_, TrKey::MenuBisectGood) => match self { + Language::English => "Mark as good (bisect)", + Language::SimplifiedChinese => "标记为正常(bisect)", + }, + (_, TrKey::MenuBisectBad) => match self { + Language::English => "Mark as bad (bisect)", + Language::SimplifiedChinese => "标记为有问题(bisect)", + }, + (_, TrKey::MenuReset) => match self { + Language::English => "Reset to here", + Language::SimplifiedChinese => "重置到此处", + }, + (_, TrKey::RebaseTitle) => match self { + Language::English => "Interactive Rebase", + Language::SimplifiedChinese => "交互式变基", + }, + (_, TrKey::MenuSquash) => match self { + Language::English => "Squash selected commits", + Language::SimplifiedChinese => "压缩所选提交", + }, + (_, TrKey::MenuCopySha) => match self { + Language::English => "Copy SHA", + Language::SimplifiedChinese => "复制 SHA", + }, + (_, TrKey::MenuCopyMsg) => match self { + Language::English => "Copy commit message", + Language::SimplifiedChinese => "复制提交信息", + }, + (_, TrKey::MenuCopyAuthor) => match self { + Language::English => "Copy author name", + Language::SimplifiedChinese => "复制作者姓名", + }, + (_, TrKey::MenuCopyDate) => match self { + Language::English => "Copy date", + Language::SimplifiedChinese => "复制日期", + }, + (_, TrKey::MenuViewGithub) => match self { + Language::English => "View on GitHub", + Language::SimplifiedChinese => "在 GitHub 上查看", + }, + (_, TrKey::GraphPanelTitle) => match self { + Language::English => "Graph", + Language::SimplifiedChinese => "提交图", + }, + (_, TrKey::GraphEmpty) => match self { + Language::English => "No commits to display", + Language::SimplifiedChinese => "暂无提交可显示", + }, + (_, TrKey::GraphColHash) => match self { + Language::English => "Hash", + Language::SimplifiedChinese => "哈希", + }, + (_, TrKey::GraphColMessage) => match self { + Language::English => "Message", + Language::SimplifiedChinese => "提交信息", + }, + (_, TrKey::GraphColAuthor) => match self { + Language::English => "Author", + Language::SimplifiedChinese => "作者", + }, + (_, TrKey::GraphColDate) => match self { + Language::English => "Date", + Language::SimplifiedChinese => "日期", + }, + (_, TrKey::MyCommitsOn) => match self { + Language::English => "Showing only your commits. Click to show all commits.", + Language::SimplifiedChinese => "仅显示你的提交,点击显示全部提交。", + }, + (_, TrKey::MyCommitsOff) => match self { + Language::English => "Show only your commits. Click to filter by current user.", + Language::SimplifiedChinese => "仅显示你的提交,点击按当前用户过滤。", + }, + (_, TrKey::GripRebase) => match self { + Language::English => "Drag to rebase", + Language::SimplifiedChinese => "拖拽以变基", + }, + (_, TrKey::NoMatches) => match self { + Language::English => "No matches", + Language::SimplifiedChinese => "无匹配项", + }, + (_, TrKey::LoadMore) => match self { + Language::English => "Load more commits", + Language::SimplifiedChinese => "加载更多提交", + }, + (_, TrKey::ShaShort) => match self { + Language::English => "Short (7)", + Language::SimplifiedChinese => "短 (7)", + }, + (_, TrKey::ShaFull) => match self { + Language::English => "Full (40)", + Language::SimplifiedChinese => "完整 (40)", + }, + (_, TrKey::ShaCharsFmt) => match self { + Language::English => "{} chars", + Language::SimplifiedChinese => "{} 字符", + }, + (_, TrKey::DisplaySettings) => match self { + Language::English => "Display Settings", + Language::SimplifiedChinese => "显示设置", + }, + (_, TrKey::ShaLengthLabel) => match self { + Language::English => "SHA length:", + Language::SimplifiedChinese => "SHA 长度:", + }, + (_, TrKey::ShowSubjectCol) => match self { + Language::English => "Show subject column", + Language::SimplifiedChinese => "显示主题列", + }, + (_, TrKey::ShowAuthorCol) => match self { + Language::English => "Show author column", + Language::SimplifiedChinese => "显示作者列", + }, + (_, TrKey::ShowAuthorEmail) => match self { + Language::English => "Show author email", + Language::SimplifiedChinese => "显示作者邮箱", + }, + (_, TrKey::ShowDateCol) => match self { + Language::English => "Show date column", + Language::SimplifiedChinese => "显示日期列", + }, + (_, TrKey::AbsoluteDates) => match self { + Language::English => "Absolute dates", + Language::SimplifiedChinese => "绝对时间", + }, + (_, TrKey::ShowAvatars) => match self { + Language::English => "Show avatars", + Language::SimplifiedChinese => "显示头像", + }, + (_, TrKey::ShowLanes) => match self { + Language::English => "Show graph lanes", + Language::SimplifiedChinese => "显示提交图轨道", + }, + (_, TrKey::ShowBadges) => match self { + Language::English => "Show branch/tag badges", + Language::SimplifiedChinese => "显示分支/标签徽章", + }, + (_, TrKey::HeadDetachedBadge) => match self { + Language::English => "HEAD (detached)", + Language::SimplifiedChinese => "HEAD(已分离)", + }, + (_, TrKey::PendingOnPre) => match self { + Language::English => "Pending changes on", + Language::SimplifiedChinese => "待提交更改于", + }, + (_, TrKey::PendingChanges) => match self { + Language::English => "Pending changes", + Language::SimplifiedChinese => "待提交的更改", + }, + (_, TrKey::NoCommitsYet) => match self { + Language::English => "No commits yet", + Language::SimplifiedChinese => "暂无提交", + }, + (_, TrKey::NoCommitsSuffix) => match self { + Language::English => "(no commits)", + Language::SimplifiedChinese => "(暂无提交)", + }, + (_, TrKey::NewBranchBadge) => match self { + Language::English => "New branch", + Language::SimplifiedChinese => "新分支", + }, + (_, TrKey::SearchCommitsPh) => match self { + Language::English => "Search commits...", + Language::SimplifiedChinese => "搜索提交…", + }, + (_, TrKey::PrsTitle) => match self { + Language::English => "Pull Requests", + Language::SimplifiedChinese => "拉取请求", + }, + (_, TrKey::PrsTab) => match self { + Language::English => "PRs", + Language::SimplifiedChinese => "PR", + }, + (_, TrKey::PrDetail) => match self { + Language::English => "Pull Request Detail", + Language::SimplifiedChinese => "拉取请求详情", + }, + (_, TrKey::IssuesTitle) => match self { + Language::English => "Issues", + Language::SimplifiedChinese => "议题", + }, + (_, TrKey::IssueDetail) => match self { + Language::English => "Issue Detail", + Language::SimplifiedChinese => "议题详情", + }, + (_, TrKey::FilterOpen) => match self { + Language::English => "Open", + Language::SimplifiedChinese => "开启", + }, + (_, TrKey::FilterClosed) => match self { + Language::English => "Closed", + Language::SimplifiedChinese => "已关闭", + }, + (_, TrKey::FilterAll) => match self { + Language::English => "All", + Language::SimplifiedChinese => "全部", + }, + (_, TrKey::PrNew) => match self { + Language::English => "New pull request", + Language::SimplifiedChinese => "新建拉取请求", + }, + (_, TrKey::PrStateMerged) => match self { + Language::English => "Merged", + Language::SimplifiedChinese => "已合并", + }, + (_, TrKey::DraftBadge) => match self { + Language::English => "Draft", + Language::SimplifiedChinese => "草稿", + }, + (_, TrKey::LoadingComments) => match self { + Language::English => "Loading comments...", + Language::SimplifiedChinese => "正在加载评论…", + }, + (_, TrKey::CommentsFailedPre) => match self { + Language::English => "Failed to load comments", + Language::SimplifiedChinese => "加载评论失败", + }, + (_, TrKey::OneComment) => match self { + Language::English => "1 comment", + Language::SimplifiedChinese => "1 条评论", + }, + (_, TrKey::ManyCommentsFmt) => match self { + Language::English => "{} comments", + Language::SimplifiedChinese => "{} 条评论", + }, + (_, TrKey::GhSignInPrs) => match self { + Language::English => "Sign in to view pull requests", + Language::SimplifiedChinese => "登录后查看拉取请求", + }, + (_, TrKey::GhSignInIssues) => match self { + Language::English => "Sign in to view issues", + Language::SimplifiedChinese => "登录后查看议题", + }, + (_, TrKey::GhAuthDesc) => match self { + Language::English => "This repository is private or rate-limited. Add a GitHub token in Settings — for organization repos you may need a fine-grained token approved by an org owner.", + Language::SimplifiedChinese => "该仓库为私有或已触发限流。请在设置中添加 GitHub 令牌——组织仓库可能需要经组织所有者批准的细粒度令牌。", + }, + (_, TrKey::TryFilterHint) => match self { + Language::English => "Try a different filter or check back later", + Language::SimplifiedChinese => "换个过滤条件或稍后再看", + }, + (_, TrKey::PrEmptyFmt) => match self { + Language::English => "No {} pull requests found", + Language::SimplifiedChinese => "没有找到{}的拉取请求", + }, + (_, TrKey::IssueEmptyFmt) => match self { + Language::English => "No {} issues found", + Language::SimplifiedChinese => "没有找到{}的议题", + }, + (_, TrKey::ReviewActions) => match self { + Language::English => "Review Actions", + Language::SimplifiedChinese => "评审操作", + }, + (_, TrKey::ReviewApprove) => match self { + Language::English => "Approve", + Language::SimplifiedChinese => "批准", + }, + (_, TrKey::ReviewRequest) => match self { + Language::English => "Request Changes", + Language::SimplifiedChinese => "请求修改", + }, + (_, TrKey::ReviewCommentBtn) => match self { + Language::English => "Comment", + Language::SimplifiedChinese => "评论", + }, + (_, TrKey::ReviewSubmitting) => match self { + Language::English => "Submitting...", + Language::SimplifiedChinese => "提交中…", + }, + (_, TrKey::ReviewCommentPh) => match self { + Language::English => "Leave a review comment (optional)", + Language::SimplifiedChinese => "留下评审意见(可选)", + }, + (_, TrKey::SearchIssuesTip) => match self { + Language::English => "Search issues", + Language::SimplifiedChinese => "搜索议题", + }, + (_, TrKey::SearchIssuesPh) => match self { + Language::English => "Search issues...", + Language::SimplifiedChinese => "搜索议题…", + }, + (_, TrKey::RepoPathLabel) => match self { + Language::English => "Repository path", + Language::SimplifiedChinese => "仓库路径", + }, + (_, TrKey::BrowseBtn) => match self { + Language::English => "Browse", + Language::SimplifiedChinese => "浏览", + }, + (_, TrKey::CloneBtn) => match self { + Language::English => "Clone", + Language::SimplifiedChinese => "克隆", + }, + (_, TrKey::NoMatchRepos) => match self { + Language::English => "No matching repositories", + Language::SimplifiedChinese => "没有匹配的仓库", + }, + (_, TrKey::NoRecentRepos) => match self { + Language::English => "No recent repositories", + Language::SimplifiedChinese => "暂无最近仓库", + }, + (_, TrKey::OpenBtn) => match self { + Language::English => "Open", + Language::SimplifiedChinese => "打开", + }, + (_, TrKey::EnterRepoPathPh) => match self { + Language::English => "Enter repository path...", + Language::SimplifiedChinese => "输入仓库路径…", + }, + (_, TrKey::CloneTitle) => match self { + Language::English => "Clone Repository", + Language::SimplifiedChinese => "克隆仓库", + }, + (_, TrKey::CloneUrlLabel) => match self { + Language::English => "URL", + Language::SimplifiedChinese => "链接", + }, + (_, TrKey::ClonePathLabel) => match self { + Language::English => "Path", + Language::SimplifiedChinese => "路径", + }, + (_, TrKey::CloneUrlPh) => match self { + Language::English => "Repository URL (e.g. https://github.com/user/repo.git)", + Language::SimplifiedChinese => "仓库链接(例如 https://github.com/user/repo.git)", + }, + (_, TrKey::ClonePathPh) => match self { + Language::English => "Destination Path", + Language::SimplifiedChinese => "目标路径", + }, + (_, TrKey::CloneHint) => match self { + Language::English => "Enter a repository URL and a destination path to clone.", + Language::SimplifiedChinese => "输入仓库链接与目标路径以克隆。", + }, + (_, TrKey::CloningBtn) => match self { + Language::English => "Cloning…", + Language::SimplifiedChinese => "克隆中…", + }, + (_, TrKey::NameLabel) => match self { + Language::English => "Name", + Language::SimplifiedChinese => "名称", + }, + (_, TrKey::WtPathLabel) => match self { + Language::English => "Directory Path", + Language::SimplifiedChinese => "目录路径", + }, + (_, TrKey::WtBranchLabel) => match self { + Language::English => "Branch (optional)", + Language::SimplifiedChinese => "分支(可选)", + }, + (_, TrKey::CreateBtn) => match self { + Language::English => "Create", + Language::SimplifiedChinese => "创建", + }, + (_, TrKey::WtNamePh) => match self { + Language::English => "Worktree name (e.g. feature-x)...", + Language::SimplifiedChinese => "工作树名称(例如 feature-x)…", + }, + (_, TrKey::WtPathPh) => match self { + Language::English => "Directory path (e.g. /home/user/projects/myrepo-feature-x)...", + Language::SimplifiedChinese => "目录路径(例如 /home/user/projects/myrepo-feature-x)…", + }, + (_, TrKey::WtBranchPh) => match self { + Language::English => "Branch (optional, defaults to current branch)...", + Language::SimplifiedChinese => "分支(可选,默认为当前分支)…", + }, + (_, TrKey::TagTitle) => match self { + Language::English => "Create Tag", + Language::SimplifiedChinese => "创建标签", + }, + (_, TrKey::TagNameLabel) => match self { + Language::English => "Tag name", + Language::SimplifiedChinese => "标签名", + }, + (_, TrKey::TagAtCommit) => match self { + Language::English => "At commit", + Language::SimplifiedChinese => "位于提交", + }, + (_, TrKey::TagCreateHint) => match self { + Language::English => "Enter to create | Esc to cancel", + Language::SimplifiedChinese => "回车创建 | Esc 取消", + }, + (_, TrKey::StashTitle) => match self { + Language::English => "Create Stash", + Language::SimplifiedChinese => "创建储藏", + }, + (_, TrKey::StashMsgLabel) => match self { + Language::English => "Message (optional)", + Language::SimplifiedChinese => "留言(可选)", + }, + (_, TrKey::StashMsgHint) => match self { + Language::English => "Leave empty for default: \"WIP on \"", + Language::SimplifiedChinese => "留空使用默认留言:“WIP on ”", + }, + (_, TrKey::RenameTitle) => match self { + Language::English => "Rename Branch", + Language::SimplifiedChinese => "重命名分支", + }, + (_, TrKey::RenameCurrent) => match self { + Language::English => "Current name", + Language::SimplifiedChinese => "当前名称", + }, + (_, TrKey::RenameNew) => match self { + Language::English => "New name", + Language::SimplifiedChinese => "新名称", + }, + (_, TrKey::RenameHint) => match self { + Language::English => "Enter to rename | Esc to cancel", + Language::SimplifiedChinese => "回车重命名 | Esc 取消", + }, + (_, TrKey::RenameBtn) => match self { + Language::English => "Rename", + Language::SimplifiedChinese => "重命名", + }, + (_, TrKey::BranchTitle) => match self { + Language::English => "Create Branch", + Language::SimplifiedChinese => "创建分支", + }, + (_, TrKey::BranchNameLabel) => match self { + Language::English => "Branch name", + Language::SimplifiedChinese => "分支名", + }, + (_, TrKey::BranchBasedOn) => match self { + Language::English => "Based on", + Language::SimplifiedChinese => "基于", + }, + (_, TrKey::BranchNamePh) => match self { + Language::English => "Enter branch name...", + Language::SimplifiedChinese => "输入分支名…", + }, + (_, TrKey::StashBranchTitle) => match self { + Language::English => "Create Branch from Stash", + Language::SimplifiedChinese => "从储藏创建分支", + }, + (_, TrKey::StashFrom) => match self { + Language::English => "From", + Language::SimplifiedChinese => "来自", + }, + (_, TrKey::CreatePrTitle) => match self { + Language::English => "New Pull Request", + Language::SimplifiedChinese => "新建拉取请求", + }, + (_, TrKey::PrInto) => match self { + Language::English => "into", + Language::SimplifiedChinese => "到", + }, + (_, TrKey::PrTitleLabel) => match self { + Language::English => "Title", + Language::SimplifiedChinese => "标题", + }, + (_, TrKey::PrTitlePh) => match self { + Language::English => "Pull request title", + Language::SimplifiedChinese => "拉取请求标题", + }, + (_, TrKey::PrDescPh) => match self { + Language::English => "Add a description (optional)", + Language::SimplifiedChinese => "添加描述(可选)", + }, + (_, TrKey::PrDraftToggle) => match self { + Language::English => "Create as draft pull request", + Language::SimplifiedChinese => "创建为草稿拉取请求", + }, + (_, TrKey::PrHint) => match self { + Language::English => "Enter for a new line | Shift+Enter to create | Esc to cancel", + Language::SimplifiedChinese => "回车换行 | Shift+回车创建 | Esc 取消", + }, + (_, TrKey::PrSubmit) => match self { + Language::English => "Create pull request", + Language::SimplifiedChinese => "创建拉取请求", + }, + (_, TrKey::PrCreating) => match self { + Language::English => "Creating pull request...", + Language::SimplifiedChinese => "正在创建拉取请求…", + }, + (_, TrKey::RebasePick) => match self { + Language::English => "Pick", + Language::SimplifiedChinese => "拣选", + }, + (_, TrKey::RebaseReword) => match self { + Language::English => "Reword", + Language::SimplifiedChinese => "改写信息", + }, + (_, TrKey::RebaseSquash) => match self { + Language::English => "Squash", + Language::SimplifiedChinese => "压缩", + }, + (_, TrKey::RebaseFixup) => match self { + Language::English => "Fixup", + Language::SimplifiedChinese => "并入", + }, + (_, TrKey::RebaseDrop) => match self { + Language::English => "Drop", + Language::SimplifiedChinese => "丢弃", + }, + (_, TrKey::RebaseHeaderFmt) => match self { + Language::English => "Rebasing {} commits onto {}", + Language::SimplifiedChinese => "正在将 {} 个提交变基到 {}", + }, + (_, TrKey::RebasePh) => match self { + Language::English => "Enter new commit message...", + Language::SimplifiedChinese => "输入新的提交信息…", + }, + (_, TrKey::RebaseDragTip) => match self { + Language::English => "Drag to reorder", + Language::SimplifiedChinese => "拖拽以排序", + }, + (_, TrKey::RebaseHintNav) => match self { + Language::English => "j/k Navigate", + Language::SimplifiedChinese => "j/k 导航", + }, + (_, TrKey::RebaseHintReorder) => match self { + Language::English => "Ctrl+Up/Down Reorder", + Language::SimplifiedChinese => "Ctrl+Up/Down 排序", + }, + (_, TrKey::RebaseHintAction) => match self { + Language::English => "p/r/s/f/d Set action", + Language::SimplifiedChinese => "p/r/s/f/d 设置动作", + }, + (_, TrKey::RebaseHintStart) => match self { + Language::English => "Enter Start", + Language::SimplifiedChinese => "回车开始", + }, + (_, TrKey::RebaseHintCancel) => match self { + Language::English => "Esc Cancel", + Language::SimplifiedChinese => "Esc 取消", + }, + (_, TrKey::RebaseStart) => match self { + Language::English => "Start Rebase", + Language::SimplifiedChinese => "开始变基", + }, + (_, TrKey::BisectTitle) => match self { + Language::English => "Bisect", + Language::SimplifiedChinese => "二分查找", + }, + (_, TrKey::BisectStarted) => match self { + Language::English => "started", + Language::SimplifiedChinese => "已开始", + }, + (_, TrKey::BisectBadFmt) => match self { + Language::English => "{} bad", + Language::SimplifiedChinese => "不良 {}", + }, + (_, TrKey::BisectGoodFmt) => match self { + Language::English => "{} good", + Language::SimplifiedChinese => "良好 {}", + }, + (_, TrKey::BisectSkipFmt) => match self { + Language::English => "{} skip", + Language::SimplifiedChinese => "跳过 {}", + }, + (_, TrKey::BisectLeftFmt) => match self { + Language::English => "~{} steps left", + Language::SimplifiedChinese => "还剩约 {} 步", + }, + (_, TrKey::BisectEntriesFmt) => match self { + Language::English => "{} entries", + Language::SimplifiedChinese => "{} 条记录", + }, + (_, TrKey::BisectEmpty) => match self { + Language::English => "No bisect in progress", + Language::SimplifiedChinese => "暂无进行中的二分查找", + }, + (_, TrKey::BisectEmptyHint) => match self { + Language::English => "Use 'Git: Bisect Start' to begin", + Language::SimplifiedChinese => "使用 'Git: Bisect Start' 开始", + }, + (_, TrKey::SplashSubtitle) => match self { + Language::English => "GPU-accelerated Git", + Language::SimplifiedChinese => "GPU 加速的 Git", + }, + (_, TrKey::SplashLoading) => match self { + Language::English => "Loading workspace…", + Language::SimplifiedChinese => "正在加载工作区…", + }, + (_, TrKey::ShortcutsCloseHint) => match self { + Language::English => "Press Esc or click outside to close", + Language::SimplifiedChinese => "按 Esc 或点击外部关闭", + }, + (_, TrKey::MoreActionsPre) => match self { + Language::English => "More actions", + Language::SimplifiedChinese => "更多操作", + }, + (_, TrKey::ThemeTitle) => match self { + Language::English => "Theme Editor", + Language::SimplifiedChinese => "主题编辑器", + }, + (_, TrKey::ThemeColors) => match self { + Language::English => "Colors", + Language::SimplifiedChinese => "颜色", + }, + (_, TrKey::ThemeStatusColors) => match self { + Language::English => "Status Colors", + Language::SimplifiedChinese => "状态颜色", + }, + (_, TrKey::ThemeDark) => match self { + Language::English => "dark", + Language::SimplifiedChinese => "深色", + }, + (_, TrKey::ThemeLight) => match self { + Language::English => "light", + Language::SimplifiedChinese => "浅色", + }, + (_, TrKey::ThemeHint) => match self { + Language::English => "Tab to move · Enter to save · Esc to close", + Language::SimplifiedChinese => "Tab 移动 · 回车保存 · Esc 关闭", + }, + (_, TrKey::SaveBtn) => match self { + Language::English => "Save", + Language::SimplifiedChinese => "保存", + }, + (_, TrKey::ThemeBackground) => match self { + Language::English => "Background", + Language::SimplifiedChinese => "背景", + }, + (_, TrKey::ThemeSurface) => match self { + Language::English => "Surface", + Language::SimplifiedChinese => "表面", + }, + (_, TrKey::ThemeElevated) => match self { + Language::English => "Elevated Surface", + Language::SimplifiedChinese => "浮层", + }, + (_, TrKey::ThemeBorder) => match self { + Language::English => "Border", + Language::SimplifiedChinese => "边框", + }, + (_, TrKey::ThemeText) => match self { + Language::English => "Text", + Language::SimplifiedChinese => "文本", + }, + (_, TrKey::ThemeTextMuted) => match self { + Language::English => "Text Muted", + Language::SimplifiedChinese => "次要文本", + }, + (_, TrKey::ThemeTextAccent) => match self { + Language::English => "Text Accent", + Language::SimplifiedChinese => "强调文本", + }, + (_, TrKey::ThemePlaceholder) => match self { + Language::English => "Text Placeholder", + Language::SimplifiedChinese => "占位文本", + }, + (_, TrKey::ThemeIcon) => match self { + Language::English => "Icon", + Language::SimplifiedChinese => "图标", + }, + (_, TrKey::ThemeFocusRing) => match self { + Language::English => "Focus Ring", + Language::SimplifiedChinese => "聚焦环", + }, + (_, TrKey::ThemeSelectedBorder) => match self { + Language::English => "Selected Border", + Language::SimplifiedChinese => "选中边框", + }, + (_, TrKey::ThemeAdded) => match self { + Language::English => "Added", + Language::SimplifiedChinese => "新增", + }, + (_, TrKey::ThemeModified) => match self { + Language::English => "Modified", + Language::SimplifiedChinese => "修改", + }, + (_, TrKey::ThemeDeleted) => match self { + Language::English => "Deleted", + Language::SimplifiedChinese => "删除", + }, + (_, TrKey::ThemeUntracked) => match self { + Language::English => "Untracked", + Language::SimplifiedChinese => "未跟踪", + }, + (_, TrKey::ThemeConflict) => match self { + Language::English => "Conflict", + Language::SimplifiedChinese => "冲突", + }, + (_, TrKey::ThemeRenamed) => match self { + Language::English => "Renamed", + Language::SimplifiedChinese => "重命名", + }, + (_, TrKey::ThemeError) => match self { + Language::English => "Error", + Language::SimplifiedChinese => "错误", + }, + (_, TrKey::ThemeWarning) => match self { + Language::English => "Warning", + Language::SimplifiedChinese => "警告", + }, + (_, TrKey::ThemeSuccess) => match self { + Language::English => "Success", + Language::SimplifiedChinese => "成功", + }, + (_, TrKey::ThemeInfo) => match self { + Language::English => "Info", + Language::SimplifiedChinese => "信息", + }, + (_, TrKey::TipOpenRepo) => match self { + Language::English => "Open repository", + Language::SimplifiedChinese => "打开仓库", + }, + (_, TrKey::TipMerged) => match self { + Language::English => "Merged into current branch", + Language::SimplifiedChinese => "已合并到当前分支", + }, + (_, TrKey::TipCopyBranch) => match self { + Language::English => "Copy branch name", + Language::SimplifiedChinese => "复制分支名", + }, + (_, TrKey::TipCheckoutBranch) => match self { + Language::English => "Checkout branch", + Language::SimplifiedChinese => "检出分支", + }, + (_, TrKey::TipMergeBranch) => match self { + Language::English => "Merge into current branch", + Language::SimplifiedChinese => "合并到当前分支", + }, + (_, TrKey::TipRenameBranch) => match self { + Language::English => "Rename branch", + Language::SimplifiedChinese => "重命名分支", + }, + (_, TrKey::TipDeleteBranch) => match self { + Language::English => "Delete branch", + Language::SimplifiedChinese => "删除分支", + }, + (_, TrKey::TipFetchRemote) => match self { + Language::English => "Fetch from remote", + Language::SimplifiedChinese => "从远程获取", + }, + (_, TrKey::TipPullRemote) => match self { + Language::English => "Pull from remote", + Language::SimplifiedChinese => "从远程拉取", + }, + (_, TrKey::TipPushRemote) => match self { + Language::English => "Push to remote", + Language::SimplifiedChinese => "推送到远程", + }, + (_, TrKey::TipRemoveRemote) => match self { + Language::English => "Remove remote", + Language::SimplifiedChinese => "移除远程", + }, + (_, TrKey::TipCheckoutTag) => match self { + Language::English => "Checkout tag", + Language::SimplifiedChinese => "检出标签", + }, + (_, TrKey::TipDeleteTag) => match self { + Language::English => "Delete tag", + Language::SimplifiedChinese => "删除标签", + }, + (_, TrKey::ApplyStashLbl) => match self { + Language::English => "Apply stash", + Language::SimplifiedChinese => "应用储藏", + }, + (_, TrKey::PopStashLbl) => match self { + Language::English => "Pop stash", + Language::SimplifiedChinese => "弹出储藏", + }, + (_, TrKey::TipStashBranch) => match self { + Language::English => "Create branch from stash", + Language::SimplifiedChinese => "从储藏创建分支", + }, + (_, TrKey::TipDropStash) => match self { + Language::English => "Drop stash", + Language::SimplifiedChinese => "丢弃储藏", + }, + (_, TrKey::TipRemoveWorktree) => match self { + Language::English => "Remove worktree", + Language::SimplifiedChinese => "移除工作树", + }, + (_, TrKey::TipUnstageFile) => match self { + Language::English => "Unstage file", + Language::SimplifiedChinese => "取消暂存文件", + }, + (_, TrKey::TipStageFile) => match self { + Language::English => "Stage file", + Language::SimplifiedChinese => "暂存文件", + }, + (_, TrKey::TipConflictResolver) => match self { + Language::English => "Open conflict resolver", + Language::SimplifiedChinese => "打开冲突解决器", + }, + (_, TrKey::TipDiscardChanges) => match self { + Language::English => "Discard changes", + Language::SimplifiedChinese => "丢弃更改", + }, + (_, TrKey::TipFilterLocal) => match self { + Language::English => "Filter branches by name or last used", + Language::SimplifiedChinese => "按名称或上次使用过滤分支", + }, + (_, TrKey::TipFilterRemote) => match self { + Language::English => "Filter remote branches by name or last used", + Language::SimplifiedChinese => "按名称或上次使用过滤远程分支", + }, + (_, TrKey::TipStagedOpts) => match self { + Language::English => "Staged list options", + Language::SimplifiedChinese => "已暂存列表选项", + }, + (_, TrKey::TipUnstagedOpts) => match self { + Language::English => "Unstaged list options", + Language::SimplifiedChinese => "未暂存列表选项", + }, + (_, TrKey::TipCopySha) => match self { + Language::English => "Copy commit SHA", + Language::SimplifiedChinese => "复制提交 SHA", + }, + (_, TrKey::TipGpg) => match self { + Language::English => "GPG-signed commit", + Language::SimplifiedChinese => "GPG 签名提交", + }, + (_, TrKey::TipCherryPick) => match self { + Language::English => "Cherry-pick this commit", + Language::SimplifiedChinese => "拣选此提交", + }, + (_, TrKey::TipClearSearch) => match self { + Language::English => "Clear search (Esc)", + Language::SimplifiedChinese => "清除搜索(Esc)", + }, + (_, TrKey::FlatViewTip) => match self { + Language::English => "Switch to Tree view (v)", + Language::SimplifiedChinese => "切换到树视图 (v)", + }, + (_, TrKey::TreeViewTip) => match self { + Language::English => "Switch to Flat view (v)", + Language::SimplifiedChinese => "切换到平铺视图 (v)", + }, + (_, TrKey::NoFilesTip) => match self { + Language::English => "No changed files to display", + Language::SimplifiedChinese => "暂无更改的文件", + }, + (_, TrKey::ClearSearchTip) => match self { + Language::English => "Clear file search to switch views", + Language::SimplifiedChinese => "清除文件搜索以切换视图", + }, + (_, TrKey::CommitSummaryPh) => match self { + Language::English => "Commit summary...", + Language::SimplifiedChinese => "提交摘要…", + }, + (_, TrKey::CommitDescPh) => match self { + Language::English => "Optional extended description...", + Language::SimplifiedChinese => "可选的详细描述…", + }, + (_, TrKey::CoAuthorNamePh) => match self { + Language::English => "Co-author name...", + Language::SimplifiedChinese => "共同作者姓名…", + }, + (_, TrKey::CoAuthorEmailPh) => match self { + Language::English => "Co-author email...", + Language::SimplifiedChinese => "共同作者邮箱…", + }, + (_, TrKey::TipExpandCommit) => match self { + Language::English => "Expand commit panel", + Language::SimplifiedChinese => "展开提交面板", + }, + (_, TrKey::TipCollapseCommit) => match self { + Language::English => "Collapse commit panel", + Language::SimplifiedChinese => "折叠提交面板", + }, + (_, TrKey::TipExplorer) => match self { + Language::English => "Open in file explorer", + Language::SimplifiedChinese => "在文件管理器中打开", + }, + (_, TrKey::TipTerminal) => match self { + Language::English => "Open terminal", + Language::SimplifiedChinese => "打开终端", + }, + (_, TrKey::TipEditor) => match self { + Language::English => "Open in editor", + Language::SimplifiedChinese => "在编辑器中打开", + }, + (_, TrKey::TitleDetachedTag) => match self { + Language::English => "(detached)", + Language::SimplifiedChinese => "(已分离)", + }, + (_, TrKey::TipApplyKeep) => match self { + Language::English => "Apply stash (keep)", + Language::SimplifiedChinese => "应用储藏(保留)", + }, + (_, TrKey::TipPopRemove) => match self { + Language::English => "Pop stash (remove)", + Language::SimplifiedChinese => "弹出储藏(移除)", + }, + (_, TrKey::StashesEmpty) => match self { + Language::English => "No stashes", + Language::SimplifiedChinese => "暂无储藏", + }, + (_, TrKey::StashesEmptyHint) => match self { + Language::English => "Use the toolbar to stash changes", + Language::SimplifiedChinese => "使用工具栏储藏更改", + }, + (_, TrKey::SearchPh) => match self { + Language::English => "Search across all files... (press Enter)", + Language::SimplifiedChinese => "搜索全部文件…(回车)", + }, + (_, TrKey::SearchEmpty) => match self { + Language::English => "Type to search across all files", + Language::SimplifiedChinese => "输入以搜索全部文件", + }, + (_, TrKey::SearchHint) => match self { + Language::English => "Press Enter to search | j/k to navigate", + Language::SimplifiedChinese => "回车搜索 | j/k 导航", + }, + (_, TrKey::FilterFilesPh) => match self { + Language::English => "Filter files...", + Language::SimplifiedChinese => "过滤文件…", + }, + (_, TrKey::FilterBranchesPh) => match self { + Language::English => "Filter branches...", + Language::SimplifiedChinese => "过滤分支…", + }, + (_, TrKey::FilterRemotePh) => match self { + Language::English => "Filter remote branches...", + Language::SimplifiedChinese => "过滤远程分支…", + }, + (_, TrKey::SetHttpsTokenPh) => match self { + Language::English => "Paste a generic HTTPS token only if you need a fallback...", + Language::SimplifiedChinese => "仅在需要备用时粘贴通用 HTTPS 令牌…", + }, + (_, TrKey::SetGpgPh) => match self { + Language::English => "No explicit GPG key configured", + Language::SimplifiedChinese => "未配置 GPG 密钥", + }, + (_, TrKey::SetUsuallyEmpty) => match self { + Language::English => "Usually left empty", + Language::SimplifiedChinese => "通常留空", + }, + (_, TrKey::SetAccountTokenPh) => match self { + Language::English => "Paste the access token for this account...", + Language::SimplifiedChinese => "粘贴此账户的访问令牌…", + }, + (_, TrKey::SetCustomCmdPh) => match self { + Language::English => "Custom command override...", + Language::SimplifiedChinese => "自定义命令覆盖…", + }, + (_, TrKey::TipPaste) => match self { + Language::English => "Paste from clipboard", + Language::SimplifiedChinese => "从剪贴板粘贴", + }, + (_, TrKey::TipHide) => match self { + Language::English => "Hide", + Language::SimplifiedChinese => "隐藏", + }, + (_, TrKey::TipShow) => match self { + Language::English => "Show", + Language::SimplifiedChinese => "显示", + }, + (_, TrKey::CopiedFb) => match self { + Language::English => "Copied!", + Language::SimplifiedChinese => "已复制!", + }, + (_, TrKey::FindingBranches) => match self { + Language::English => "Finding branches…", + Language::SimplifiedChinese => "正在查找分支…", + }, + (_, TrKey::ContainedIn) => match self { + Language::English => "CONTAINED IN", + Language::SimplifiedChinese => "包含于", + }, + (_, TrKey::SearchFilesHint) => match self { + Language::English => "/ to search files", + Language::SimplifiedChinese => "/ 搜索文件", + }, + (_, TrKey::SortLastUsed) => match self { + Language::English => "Last used", + Language::SimplifiedChinese => "上次使用", + }, + (_, TrKey::OnlyMine) => match self { + Language::English => "Only my branches", + Language::SimplifiedChinese => "仅我的分支", + }, + (_, TrKey::PopBranchFilters) => match self { + Language::English => "Branch filters", + Language::SimplifiedChinese => "分支过滤器", + }, + (_, TrKey::PopRemoteFilters) => match self { + Language::English => "Remote branch filters", + Language::SimplifiedChinese => "远程分支过滤器", + }, + (_, TrKey::PopStagedFiles) => match self { + Language::English => "Staged files", + Language::SimplifiedChinese => "已暂存文件", + }, + (_, TrKey::PopUnstagedFiles) => match self { + Language::English => "Unstaged files", + Language::SimplifiedChinese => "未暂存文件", + }, + (_, TrKey::ShowLineCounts) => match self { + Language::English => "Show line counts", + Language::SimplifiedChinese => "显示行数统计", + }, + (_, TrKey::ChangeListHint) => match self { + Language::English => "Applies to staged and unstaged files.", + Language::SimplifiedChinese => "适用于已暂存与未暂存文件。", + }, + (_, TrKey::NoRemotes) => match self { + Language::English => "No remotes configured", + Language::SimplifiedChinese => "未配置远程", + }, + (_, TrKey::NoTags) => match self { + Language::English => "No tags", + Language::SimplifiedChinese => "暂无标签", + }, + (_, TrKey::NoWorktrees) => match self { + Language::English => "No worktrees", + Language::SimplifiedChinese => "暂无工作树", + }, + (_, TrKey::CurrentTag) => match self { + Language::English => "(current)", + Language::SimplifiedChinese => "(当前)", + }, + (_, TrKey::StashMenuCreateBranch) => match self { + Language::English => "Create branch", + Language::SimplifiedChinese => "创建分支", + }, + (_, TrKey::BlameEmpty) => match self { + Language::English => "Select a file to view blame", + Language::SimplifiedChinese => "选择文件以查看追溯", + }, + (_, TrKey::FileHistoryTitle) => match self { + Language::English => "File History", + Language::SimplifiedChinese => "文件历史", + }, + (_, TrKey::HistoryEmpty) => match self { + Language::English => "Select a file to view history", + Language::SimplifiedChinese => "选择文件以查看历史", + }, + (_, TrKey::ReflogTitle) => match self { + Language::English => "Reflog", + Language::SimplifiedChinese => "引用日志", + }, + (_, TrKey::ReflogEmpty) => match self { + Language::English => "No reflog entries", + Language::SimplifiedChinese => "暂无引用日志", + }, + (_, TrKey::ReflogHint) => match self { + Language::English => "Reflog tracks HEAD changes", + Language::SimplifiedChinese => "引用日志记录 HEAD 的变化", + }, + (_, TrKey::SubmodulesTitle) => match self { + Language::English => "Submodules", + Language::SimplifiedChinese => "子模块", + }, + (_, TrKey::SubmodulesEmpty) => match self { + Language::English => "No submodules found", + Language::SimplifiedChinese => "未找到子模块", + }, + (_, TrKey::SubmodulesHint) => match self { + Language::English => "Add submodules to your repository", + Language::SimplifiedChinese => "向仓库添加子模块", + }, + (_, TrKey::CommitsCountFmt) => match self { + Language::English => "{} commits", + Language::SimplifiedChinese => "{} 个提交", + }, + (_, TrKey::BhTab) => match self { + Language::English => "Branch Health", + Language::SimplifiedChinese => "分支健康", + }, + (_, TrKey::BhUnmerged) => match self { + Language::English => "Unmerged", + Language::SimplifiedChinese => "未合并", + }, + (_, TrKey::BhStale) => match self { + Language::English => "Stale", + Language::SimplifiedChinese => "长期未动", + }, + (_, TrKey::BhDiverged) => match self { + Language::English => "Diverged", + Language::SimplifiedChinese => "已分叉", + }, + (_, TrKey::BhTotal) => match self { + Language::English => "Total", + Language::SimplifiedChinese => "总数", + }, + (_, TrKey::BhMerged) => match self { + Language::English => "Merged", + Language::SimplifiedChinese => "已合并", + }, + (_, TrKey::AgeAny) => match self { + Language::English => "Any time", + Language::SimplifiedChinese => "不限时间", + }, + (_, TrKey::AgeSevenDays) => match self { + Language::English => "Last 7 days", + Language::SimplifiedChinese => "近 7 天", + }, + (_, TrKey::AgeThirtyDays) => match self { + Language::English => "Last 30 days", + Language::SimplifiedChinese => "近 30 天", + }, + (_, TrKey::AgeNinetyDays) => match self { + Language::English => "Last 90 days", + Language::SimplifiedChinese => "近 90 天", + }, + (_, TrKey::AgeOneYear) => match self { + Language::English => "Last year", + Language::SimplifiedChinese => "近一年", + }, + (_, TrKey::TipCreatePrFull) => match self { + Language::English => "Create GitHub pull request", + Language::SimplifiedChinese => "创建 GitHub 拉取请求", + }, + (_, TrKey::TipSearchCommits) => match self { + Language::English => "Search commits", + Language::SimplifiedChinese => "搜索提交", + }, + (_, TrKey::TipRefresh) => match self { + Language::English => "Refresh", + Language::SimplifiedChinese => "刷新", + }, + (_, TrKey::TipSettings) => match self { + Language::English => "Settings", + Language::SimplifiedChinese => "设置", + }, + (_, TrKey::BlameEmptyHint) => match self { + Language::English => "Press 'b' on a file to see line-by-line attribution", + Language::SimplifiedChinese => "在文件上按 'b' 查看逐行归属", + }, + (_, TrKey::HistoryEmptyHint) => match self { + Language::English => "Press 'h' on a file to see commits", + Language::SimplifiedChinese => "在文件上按 'h' 查看提交", + }, + (_, TrKey::SearchResultsFmt) => match self { + Language::English => "{} results", + Language::SimplifiedChinese => "{} 个结果", + }, + (_, TrKey::StagedOneFmt) => match self { + Language::English => "{} file staged", + Language::SimplifiedChinese => "已暂存 {} 个文件", + }, + (_, TrKey::StagedManyFmt) => match self { + Language::English => "{} files staged", + Language::SimplifiedChinese => "已暂存 {} 个文件", + }, + (_, TrKey::StagedNone) => match self { + Language::English => "No files staged", + Language::SimplifiedChinese => "没有暂存的文件", + }, + (_, TrKey::DescCount11) => match self { + Language::English => "{} char, {} line", + Language::SimplifiedChinese => "{} 字符,{} 行", + }, + (_, TrKey::DescCount1N) => match self { + Language::English => "{} char, {} lines", + Language::SimplifiedChinese => "{} 字符,{} 行", + }, + (_, TrKey::DescCountN1) => match self { + Language::English => "{} chars, {} line", + Language::SimplifiedChinese => "{} 字符,{} 行", + }, + (_, TrKey::DescCountNN) => match self { + Language::English => "{} chars, {} lines", + Language::SimplifiedChinese => "{} 字符,{} 行", + }, + (_, TrKey::StatusStagedFmt) => match self { + Language::English => "{} staged", + Language::SimplifiedChinese => "已暂存 {}", + }, + (_, TrKey::StatusChangedFmt) => match self { + Language::English => "{} changed", + Language::SimplifiedChinese => "已更改 {}", + }, + (_, TrKey::ErrStageFailed) => match self { + Language::English => "Stage failed", + Language::SimplifiedChinese => "暂存失败", + }, + (_, TrKey::ErrUnstageFailed) => match self { + Language::English => "Unstage failed", + Language::SimplifiedChinese => "取消暂存失败", + }, + (_, TrKey::ErrStageAllFailed) => match self { + Language::English => "Stage all failed", + Language::SimplifiedChinese => "全部暂存失败", + }, + (_, TrKey::ErrUnstageAllFailed) => match self { + Language::English => "Unstage all failed", + Language::SimplifiedChinese => "全部取消暂存失败", + }, + (_, TrKey::ErrCommitFailed) => match self { + Language::English => "Commit failed", + Language::SimplifiedChinese => "提交失败", + }, + (_, TrKey::ErrAmendFailed) => match self { + Language::English => "Amend failed", + Language::SimplifiedChinese => "修正提交失败", + }, + (_, TrKey::ErrCheckoutBranchFailedFmt) => match self { + Language::English => "Checkout of '{}' failed", + Language::SimplifiedChinese => "检出 '{}' 失败", + }, + (_, TrKey::ErrCheckoutCommitFailedFmt) => match self { + Language::English => "Checkout of {} failed", + Language::SimplifiedChinese => "检出 {} 失败", + }, + (_, TrKey::ErrCheckoutTagFailedFmt) => match self { + Language::English => "Checkout of tag '{}' failed", + Language::SimplifiedChinese => "检出标签 '{}' 失败", + }, + (_, TrKey::ErrBranchCreateFailedFmt) => match self { + Language::English => "Branch '{}' could not be created", + Language::SimplifiedChinese => "无法创建分支 '{}'", + }, + (_, TrKey::ErrBranchDeleteFailedFmt) => match self { + Language::English => "Delete branch '{}' failed", + Language::SimplifiedChinese => "删除分支 '{}' 失败", + }, + (_, TrKey::ErrBranchRenameFailedFmt) => match self { + Language::English => "Rename branch '{}' failed", + Language::SimplifiedChinese => "重命名分支 '{}' 失败", + }, + (_, TrKey::ErrTagCreateFailedFmt) => match self { + Language::English => "Tag '{}' could not be created", + Language::SimplifiedChinese => "无法创建标签 '{}'", + }, + (_, TrKey::ErrTagDeleteFailedFmt) => match self { + Language::English => "Delete tag '{}' failed", + Language::SimplifiedChinese => "删除标签 '{}' 失败", + }, + (_, TrKey::ErrStashSaveFailed) => match self { + Language::English => "Save stash failed", + Language::SimplifiedChinese => "保存储藏失败", + }, + (_, TrKey::ErrStashPopFailedFmt) => match self { + Language::English => "Pop stash #{} failed", + Language::SimplifiedChinese => "弹出储藏 #{} 失败", + }, + (_, TrKey::ErrStashApplyFailedFmt) => match self { + Language::English => "Apply stash #{} failed", + Language::SimplifiedChinese => "应用储藏 #{} 失败", + }, + (_, TrKey::ErrStashDropFailedFmt) => match self { + Language::English => "Drop stash #{} failed", + Language::SimplifiedChinese => "丢弃储藏 #{} 失败", + }, + (_, TrKey::ErrStashBranchFailedFmt) => match self { + Language::English => "Create branch from stash #{} failed", + Language::SimplifiedChinese => "从储藏 #{} 创建分支失败", + }, + (_, TrKey::ErrDiscardFailed) => match self { + Language::English => "Discard changes failed", + Language::SimplifiedChinese => "丢弃更改失败", + }, + (_, TrKey::ErrCleanFailed) => match self { + Language::English => "Clean failed", + Language::SimplifiedChinese => "清理失败", + }, + (_, TrKey::ErrResetHeadFailed) => match self { + Language::English => "Reset to HEAD failed", + Language::SimplifiedChinese => "重置到 HEAD 失败", + }, + (_, TrKey::ErrResetFailedFmt) => match self { + Language::English => "Reset to {} failed", + Language::SimplifiedChinese => "重置到 {} 失败", + }, + (_, TrKey::ErrResetSoftFailedFmt) => match self { + Language::English => "Soft reset to {} failed", + Language::SimplifiedChinese => "软重置到 {} 失败", + }, + (_, TrKey::ErrResetMixedFailedFmt) => match self { + Language::English => "Mixed reset to {} failed", + Language::SimplifiedChinese => "混合重置到 {} 失败", + }, + (_, TrKey::ErrRevertConflictFmt) => match self { + Language::English => "Revert of {} needs conflict resolution", + Language::SimplifiedChinese => "还原 {} 需要解决冲突", + }, + (_, TrKey::ErrRevertFailedFmt) => match self { + Language::English => "Revert of {} failed", + Language::SimplifiedChinese => "还原 {} 失败", + }, + (_, TrKey::ErrRevertGuide) => match self { + Language::English => "Resolve the conflicts in the working tree, then commit the revert manually.", + Language::SimplifiedChinese => "请解决工作树中的冲突,然后手动提交此次还原。", + }, + (_, TrKey::ErrCherryPickConflictFmt) => match self { + Language::English => "Cherry-pick of {} needs conflict resolution", + Language::SimplifiedChinese => "拣选 {} 需要解决冲突", + }, + (_, TrKey::ErrCherryPickFailedFmt) => match self { + Language::English => "Cherry-pick of {} failed", + Language::SimplifiedChinese => "拣选 {} 失败", + }, + (_, TrKey::ErrCherryPickGuide) => match self { + Language::English => "Resolve the conflicts in the working tree, then commit the cherry-pick manually.", + Language::SimplifiedChinese => "请解决工作树中的冲突,然后手动提交此次拣选。", + }, + (_, TrKey::ErrAbortFailedFmt) => match self { + Language::English => "Failed to abort {}", + Language::SimplifiedChinese => "中止 {} 失败", + }, + (_, TrKey::ErrContinueFailedFmt) => match self { + Language::English => "Could not continue {}", + Language::SimplifiedChinese => "无法继续 {}", + }, + (_, TrKey::ErrMergeConflictFmt) => match self { + Language::English => "Merge conflicts in '{}'", + Language::SimplifiedChinese => "合并 '{}' 存在冲突", + }, + (_, TrKey::ErrMergeFailedFmt) => match self { + Language::English => "Merge of '{}' failed", + Language::SimplifiedChinese => "合并 '{}' 失败", + }, + (_, TrKey::ErrRemoveRemoteFailed) => match self { + Language::English => "Removing remote failed", + Language::SimplifiedChinese => "移除远程失败", + }, + (_, TrKey::ErrCloneFailed) => match self { + Language::English => "Clone failed", + Language::SimplifiedChinese => "克隆失败", + }, + (_, TrKey::ErrBisectStartFailed) => match self { + Language::English => "Failed to start bisect", + Language::SimplifiedChinese => "启动 bisect 失败", + }, + (_, TrKey::ErrBisectGoodFailedFmt) => match self { + Language::English => "Failed to mark {} as good", + Language::SimplifiedChinese => "无法将 {} 标记为正常", + }, + (_, TrKey::ErrBisectBadFailedFmt) => match self { + Language::English => "Failed to mark {} as bad", + Language::SimplifiedChinese => "无法将 {} 标记为有问题", + }, + (_, TrKey::ErrBisectExhausted) => match self { + Language::English => "Bisect exhausted", + Language::SimplifiedChinese => "Bisect 已穷尽候选提交", + }, + (_, TrKey::ErrBisectSkipFailedFmt) => match self { + Language::English => "Failed to skip {}", + Language::SimplifiedChinese => "无法跳过 {}", + }, + (_, TrKey::ErrBisectResetFailed) => match self { + Language::English => "Failed to reset bisect", + Language::SimplifiedChinese => "重置 bisect 失败", + }, + (_, TrKey::ErrWorktreeCreateFailedFmt) => match self { + Language::English => "Create worktree '{}' failed", + Language::SimplifiedChinese => "创建工作树 '{}' 失败", + }, + (_, TrKey::ErrWorktreeRemoveFailedFmt) => match self { + Language::English => "Remove worktree '{}' failed", + Language::SimplifiedChinese => "移除工作树 '{}' 失败", + }, + (_, TrKey::ErrFetchFailedFmt) => match self { + Language::English => "Fetch from '{}' failed", + Language::SimplifiedChinese => "从 '{}' 获取失败", + }, + (_, TrKey::ErrPullConflictFmt) => match self { + Language::English => "Pull from '{}' has conflicts", + Language::SimplifiedChinese => "从 '{}' 拉取存在冲突", + }, + (_, TrKey::ErrPullFailedFmt) => match self { + Language::English => "Pull from '{}' failed", + Language::SimplifiedChinese => "从 '{}' 拉取失败", + }, + (_, TrKey::ErrPushFailedFmt) => match self { + Language::English => "Push to '{}' failed", + Language::SimplifiedChinese => "向 '{}' 推送失败", + }, + (_, TrKey::ErrFetchNotStarted) => match self { + Language::English => "Fetch could not start", + Language::SimplifiedChinese => "获取无法启动", + }, + (_, TrKey::ErrPullNotStarted) => match self { + Language::English => "Pull could not start", + Language::SimplifiedChinese => "拉取无法启动", + }, + (_, TrKey::ErrPushNotStarted) => match self { + Language::English => "Push could not start", + Language::SimplifiedChinese => "推送无法启动", + }, + (_, TrKey::ErrRebasePaused) => match self { + Language::English => "Rebase paused due to conflicts", + Language::SimplifiedChinese => "变基因冲突已暂停", + }, + (_, TrKey::ErrRebaseFailed) => match self { + Language::English => "Interactive rebase failed", + Language::SimplifiedChinese => "交互式变基失败", + }, + (_, TrKey::ErrStageHunkFailed) => match self { + Language::English => "Stage hunk failed", + Language::SimplifiedChinese => "暂存代码块失败", + }, + (_, TrKey::ErrUnstageHunkFailed) => match self { + Language::English => "Unstage hunk failed", + Language::SimplifiedChinese => "取消暂存代码块失败", + }, + (_, TrKey::ErrStageLinesFailed) => match self { + Language::English => "Stage lines failed", + Language::SimplifiedChinese => "暂存选中行失败", + }, + (_, TrKey::ErrUnstageLinesFailed) => match self { + Language::English => "Unstage lines failed", + Language::SimplifiedChinese => "取消暂存选中行失败", + }, + (_, TrKey::ErrConflictResolveFailed) => match self { + Language::English => "Conflict resolution failed", + Language::SimplifiedChinese => "冲突解决失败", + }, + (_, TrKey::ErrAlreadyOnBranchFmt) => match self { + Language::English => "Already on branch '{}'.", + Language::SimplifiedChinese => "已位于分支 '{}'。", + }, + (_, TrKey::ErrCleanWorktreeFmt) => match self { + Language::English => "{} requires a clean working tree. Commit, stash, or discard your changes to tracked files first.", + Language::SimplifiedChinese => "{} 需要干净的工作树,请先提交、储藏或丢弃已跟踪文件的更改。", + }, + (_, TrKey::ErrHeadDetached) => match self { + Language::English => "HEAD is detached. Switch to a branch before running this operation.", + Language::SimplifiedChinese => "HEAD 已分离,请先切换到某个分支再执行此操作。", + }, + (_, TrKey::ErrBranchNameUnknown) => match self { + Language::English => "Failed to determine the current branch name", + Language::SimplifiedChinese => "无法确定当前分支名", + }, + (_, TrKey::ErrNoRemotes) => match self { + Language::English => "No remotes configured. Add one with: git remote add origin ", + Language::SimplifiedChinese => "尚未配置远程仓库,请先添加:git remote add origin ", + }, + (_, TrKey::ErrNoUsableRemotes) => match self { + Language::English => "No usable git remotes are configured.", + Language::SimplifiedChinese => "没有可用的 git 远程配置。", + }, + (_, TrKey::ErrNoStagedChanges) => match self { + Language::English => "There are no staged changes to commit.", + Language::SimplifiedChinese => "没有已暂存的更改可提交。", + }, + (_, TrKey::ErrCannotAmendRebase) => match self { + Language::English => "Cannot amend during a rebase. Continue or abort the rebase first.", + Language::SimplifiedChinese => "变基期间无法修正提交,请先继续或中止变基。", + }, + (_, TrKey::ErrOverwriteLocalFmt) => match self { + Language::English => "{} would overwrite local changes. Commit, stash, or discard them first.", + Language::SimplifiedChinese => "{} 会覆盖本地更改,请先提交、储藏或丢弃它们。", + }, + (_, TrKey::ErrOverwritePathOneFmt) => match self { + Language::English => "{} would overwrite {}. Commit, stash, move, or delete it first.", + Language::SimplifiedChinese => "{} 会覆盖 {},请先提交、储藏、移动或删除该文件。", + }, + (_, TrKey::ErrOverwritePathManyFmt) => match self { + Language::English => "{} would overwrite {}. Commit, stash, move, or delete them first.", + Language::SimplifiedChinese => "{} 会覆盖 {},请先提交、储藏、移动或删除这些文件。", + }, + (_, TrKey::ErrStagingConflictFmt) => match self { + Language::English => "'{}' has unresolved conflicts. Open the conflict resolver before staging it.", + Language::SimplifiedChinese => "'{}' 存在未解决的冲突,请先打开冲突解决器再暂存。", + }, + (_, TrKey::ErrBranchNotFoundFmt) => match self { + Language::English => "Branch '{}' not found as a local or remote branch. Try fetching to update remote refs.", + Language::SimplifiedChinese => "找不到分支 '{}'(本地和远程均无),请先获取以更新远程引用。", + }, + (_, TrKey::ErrLocalBranchExistsFmt) => match self { + Language::English => "A local branch named '{}' already exists. Please delete or rename it first.", + Language::SimplifiedChinese => "本地分支 '{}' 已存在,请先删除或重命名它。", + }, + (_, TrKey::ErrInvalidRemoteBranchFmt) => match self { + Language::English => "Invalid remote branch name '{}'. Expected 'remote/branch' format.", + Language::SimplifiedChinese => "远程分支名 '{}' 无效,应为 'remote/branch' 格式。", + }, + (_, TrKey::ErrNotMergeState) => match self { + Language::English => "Repository is not in a merge state (no MERGE_HEAD to continue).", + Language::SimplifiedChinese => "仓库当前不在合并状态(缺少 MERGE_HEAD,无法继续)。", + }, + (_, TrKey::ErrUnresolvedConflicts) => match self { + Language::English => "There are still unresolved conflicts. Resolve all conflicts before continuing.", + Language::SimplifiedChinese => "仍有未解决的冲突,请先解决所有冲突再继续。", + }, + (_, TrKey::ErrNothingToContinueFmt) => match self { + Language::English => "There is no {} to continue.", + Language::SimplifiedChinese => "当前没有可继续的 {}。", + }, + (_, TrKey::ErrNoRebaseEntries) => match self { + Language::English => "No entries provided for interactive rebase", + Language::SimplifiedChinese => "未提供交互式变基条目", + }, + (_, TrKey::ErrRebasePlanMismatchFmt) => match self { + Language::English => "Interactive rebase plan does not match the current branch's history (the selected commits are not exactly the last {} first-parent commits of HEAD). Refresh and try again.", + Language::SimplifiedChinese => "交互式变基计划与当前分支历史不匹配(所选提交并非 HEAD 末尾连续的 {} 个第一父系提交),请刷新后重试。", + }, + (_, TrKey::ErrStashIndexOutOfRangeFmt) => match self { + Language::English => "Stash index {} out of range", + Language::SimplifiedChinese => "储藏索引 {} 超出范围", + }, + (_, TrKey::ErrBaseNotCommitFmt) => match self { + Language::English => "'{}' does not resolve to a commit", + Language::SimplifiedChinese => "'{}' 无法解析为提交", + }, + (_, TrKey::SignedBadge) => match self { + Language::English => "✓ Signed", + Language::SimplifiedChinese => "✓ 已签名", + }, + } + } +} + +impl fmt::Display for Language { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.native_name()) + } +} + +impl FromStr for Language { + type Err = String; + fn from_str(s: &str) -> std::result::Result { + match s.to_lowercase().replace('-', "_").as_str() { + "en" | "english" => Ok(Language::English), + "zh_cn" | "zh" | "chinese" | "simplified_chinese" | "简体中文" => { + Ok(Language::SimplifiedChinese) + } + _ => Err(format!("Unknown language: {}", s)), + } + } +} + +/// Keys for localizable UI text. One variant per localizable string; +/// rendering code passes these to [`Language::tr`] instead of hardcoding +/// English. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TrKey { + Preferences, + SectionAppearance, + SectionAi, + SectionAuth, + SectionGeneral, + GeneralTitle, + GeneralDesc, + LanguageTitle, + LanguageDesc, + MaxRecentTitle, + MaxRecentDesc, + UiDensityTitle, + UiDensityDesc, + DensityCompact, + DensityDefault, + DensityComfortable, + FontSizeTitle, + FontSizeDesc, + GeneralPageDesc, + ThemeSectionDesc, + ThemePageDesc, + AppearanceModeTitle, + AppearanceModeDesc, + ModeAuto, + ModeLight, + ModeDark, + ColorThemeTitle, + ColorThemeDesc, + ActiveBadge, + CustomThemeTitle, + CustomThemeDesc, + EditThemeBtn, + AccountsTitle, + AccountsDesc, + AccountsHeader, + AccountsSectionDesc, + QuickSetupTitle, + QuickSetupDesc, + SignInGitHub, + AddGitLab, + ManualCustom, + AuthProfilesTitle, + AuthProfilesDesc, + AuthProfilesEmptyTitle, + AuthProfilesEmptyDesc, + AuthHttpsFallbackTitle, + AuthHttpsFallbackDesc, + AuthSshOverrideTitle, + AuthSshOverrideDesc, + AuthSignCommitsTitle, + AuthSignCommitsDesc, + AuthGpgKeyTitle, + AuthGpgKeyDesc, + AuthHowTitle, + AuthHowDesc, + AuthHowHttps, + AuthHowSshFallback, + AuthHowSshKey, + AiPageDesc, + AiSectionConnection, + AiSectionBehaviour, + AiStatusTitle, + AiEnabledToggle, + AiStatusOff, + AiStatusTestingFmt, + AiStatusRejectedFmt, + AiStatusAddKeyFmt, + AiConnectTitle, + AiConnectDesc, + AiApiKeyLabel, + AiGetKeyBtn, + AiKeyVerifiedFmt, + AiKeyStoredHintFmt, + AiNoKeyHintFmt, + AiKeysStoredNoteFmt, + AiModelLabel, + AiModelCountFmt, + AiRefreshBtn, + AiRefreshingBtn, + AiModelMissingFmt, + AiUseSuggestionFmt, + AiConnectBtn, + AiUseProviderBtn, + AiTestBtn, + AiTestingBtn, + AiRemoveKeyBtn, + AiAdvancedLabel, + AiBaseUrlLabel, + AiBaseUrlOverriddenFmt, + AiBaseUrlEmptyFmt, + AiAttributionTitle, + AiAttributionDesc, + AiCommitStyleTitle, + AiCommitStyleDesc, + AiCtxTitle, + AiCtxDesc, + AiToolsTitle, + AiToolsDesc, + AiKeyPlaceholderFmt, + ShowLineNumbersTitle, + ShowLineNumbersDesc, + WrapLinesTitle, + WrapLinesDesc, + DiffModeTitle, + DiffModeDesc, + DiffUnified, + DiffSideBySide, + GraphTitle, + GraphDesc, + GraphRails, + GraphCurved, + GraphAngular, + ShowSubjectTitle, + ShowSubjectDesc, + AutoFetchTitle, + AutoFetchDesc, + ConfirmDestructiveTitle, + ConfirmDestructiveDesc, + CheckUpdatesTitle, + CheckUpdatesDesc, + WatchWorktreesTitle, + WatchWorktreesDesc, + ExternalToolsTitle, + ExternalToolsDesc, + TerminalLabel, + EditorLabel, + CustomCommandLabel, + ShortcutsTitle, + ShortcutsDesc, + ConfigTitle, + ConfigDesc, + RevealBtn, + EditKeymapBtn, + SavedFeedback, + TbFetch, + TbFetching, + TbPull, + TbPulling, + TbPush, + TbPushing, + TbBranch, + TbStash, + TbPop, + TbCreatePr, + StatusClean, + StatusDetached, + SideBranches, + SideRemotes, + SideRemoteBranches, + SideTags, + SideStashes, + SideWorktrees, + SideStaged, + SideUnstaged, + SideStageAll, + SideUnstageAll, + SideNewWorktree, + SideWorkingClean, + SideNoStaged, + CommitBtn, + CommitNoStaged, + CommitNoMessage, + CommitAmendBtn, + AmendBtn, + ClearBtn, + SummaryLabel, + DescLabel, + CoAuthorsLabel, + NoStagedHint, + CancelBtn, + ConfirmHint, + CfConfirm, + CfDiscard, + CfClean, + CfDeleteBranch, + CfDeleteTag, + CfRemove, + CfDropStash, + CfReset, + CfAbort, + CfForcePush, + CfRemoveWorktree, + DiffFileBtn, + DiffEmptyTitle, + DiffEmptyDesc, + DiffOpenFile, + DetailTitle, + DetailEmptyTitle, + DetailEmptyDesc, + HomeSlogan, + OpenRepoTitle, + HomeNewWorkspace, + HomeRestoreLast, + HomeRecentWorkspaces, + HomeRecentRepos, + WsSummaryFmt, + LoadingDots, + OpRetry, + ConflictOneFmt, + ConflictManyFmt, + ConflictResolved, + ConflictContinue, + WorktreeExit, + InspectingPre, + DetachedHeadWord, + BottomDiff, + BottomDiffTip, + BottomHistory, + HistLoading, + HistShow, + HistUnavailable, + BottomBlame, + BlameLoading, + BlameShow, + BlameUnavailable, + GraphShowTip, + GraphHideTip, + DetachedTitlePre, + DetachedConsequence, + DetachedReturnPre, + DetachedReturnTipPre, + DetachedReturnTipPost, + DetachedSwitch, + DetachedShowGraph, + UpdateMsgFmt, + UpdateDownload, + MenuCherryPick, + MenuRevert, + MenuCheckout, + MenuCreateBranch, + MenuCreateTag, + MenuBisectGood, + MenuBisectBad, + MenuReset, + RebaseTitle, + MenuSquash, + MenuCopySha, + MenuCopyMsg, + MenuCopyAuthor, + MenuCopyDate, + MenuViewGithub, + GraphPanelTitle, + GraphEmpty, + GraphColHash, + GraphColMessage, + GraphColAuthor, + GraphColDate, + MyCommitsOn, + MyCommitsOff, + GripRebase, + NoMatches, + LoadMore, + ShaShort, + ShaFull, + ShaCharsFmt, + DisplaySettings, + ShaLengthLabel, + ShowSubjectCol, + ShowAuthorCol, + ShowAuthorEmail, + ShowDateCol, + AbsoluteDates, + ShowAvatars, + ShowLanes, + ShowBadges, + HeadDetachedBadge, + PendingOnPre, + PendingChanges, + NoCommitsYet, + NoCommitsSuffix, + NewBranchBadge, + SearchCommitsPh, + PrsTitle, + PrsTab, + PrDetail, + IssuesTitle, + IssueDetail, + FilterOpen, + FilterClosed, + FilterAll, + PrNew, + PrStateMerged, + DraftBadge, + LoadingComments, + CommentsFailedPre, + OneComment, + ManyCommentsFmt, + GhSignInPrs, + GhSignInIssues, + GhAuthDesc, + TryFilterHint, + PrEmptyFmt, + IssueEmptyFmt, + ReviewActions, + ReviewApprove, + ReviewRequest, + ReviewCommentBtn, + ReviewSubmitting, + ReviewCommentPh, + SearchIssuesTip, + SearchIssuesPh, + RepoPathLabel, + BrowseBtn, + CloneBtn, + NoMatchRepos, + NoRecentRepos, + OpenBtn, + EnterRepoPathPh, + CloneTitle, + CloneUrlLabel, + ClonePathLabel, + CloneUrlPh, + ClonePathPh, + CloneHint, + CloningBtn, + NameLabel, + WtPathLabel, + WtBranchLabel, + CreateBtn, + WtNamePh, + WtPathPh, + WtBranchPh, + TagTitle, + TagNameLabel, + TagAtCommit, + TagCreateHint, + StashTitle, + StashMsgLabel, + StashMsgHint, + RenameTitle, + RenameCurrent, + RenameNew, + RenameHint, + RenameBtn, + BranchTitle, + BranchNameLabel, + BranchBasedOn, + BranchNamePh, + StashBranchTitle, + StashFrom, + CreatePrTitle, + PrInto, + PrTitleLabel, + PrTitlePh, + PrDescPh, + PrDraftToggle, + PrHint, + PrSubmit, + PrCreating, + RebasePick, + RebaseReword, + RebaseSquash, + RebaseFixup, + RebaseDrop, + RebaseHeaderFmt, + RebasePh, + RebaseDragTip, + RebaseHintNav, + RebaseHintReorder, + RebaseHintAction, + RebaseHintStart, + RebaseHintCancel, + RebaseStart, + BisectTitle, + BisectStarted, + BisectBadFmt, + BisectGoodFmt, + BisectSkipFmt, + BisectLeftFmt, + BisectEntriesFmt, + BisectEmpty, + BisectEmptyHint, + SplashSubtitle, + SplashLoading, + ShortcutsCloseHint, + MoreActionsPre, + ThemeTitle, + ThemeColors, + ThemeStatusColors, + ThemeDark, + ThemeLight, + ThemeHint, + SaveBtn, + ThemeBackground, + ThemeSurface, + ThemeElevated, + ThemeBorder, + ThemeText, + ThemeTextMuted, + ThemeTextAccent, + ThemePlaceholder, + ThemeIcon, + ThemeFocusRing, + ThemeSelectedBorder, + ThemeAdded, + ThemeModified, + ThemeDeleted, + ThemeUntracked, + ThemeConflict, + ThemeRenamed, + ThemeError, + ThemeWarning, + ThemeSuccess, + ThemeInfo, + TipOpenRepo, + TipMerged, + TipCopyBranch, + TipCheckoutBranch, + TipMergeBranch, + TipRenameBranch, + TipDeleteBranch, + TipFetchRemote, + TipPullRemote, + TipPushRemote, + TipRemoveRemote, + TipCheckoutTag, + TipDeleteTag, + ApplyStashLbl, + PopStashLbl, + TipStashBranch, + TipDropStash, + TipRemoveWorktree, + TipUnstageFile, + TipStageFile, + TipConflictResolver, + TipDiscardChanges, + TipFilterLocal, + TipFilterRemote, + TipStagedOpts, + TipUnstagedOpts, + TipCopySha, + TipGpg, + TipCherryPick, + TipClearSearch, + FlatViewTip, + TreeViewTip, + NoFilesTip, + ClearSearchTip, + CommitSummaryPh, + CommitDescPh, + CoAuthorNamePh, + CoAuthorEmailPh, + TipExpandCommit, + TipCollapseCommit, + TipExplorer, + TipTerminal, + TipEditor, + TitleDetachedTag, + TipApplyKeep, + TipPopRemove, + StashesEmpty, + StashesEmptyHint, + SearchPh, + SearchEmpty, + SearchHint, + FilterFilesPh, + FilterBranchesPh, + FilterRemotePh, + SetHttpsTokenPh, + SetGpgPh, + SetUsuallyEmpty, + SetAccountTokenPh, + SetCustomCmdPh, + TipPaste, + TipHide, + TipShow, + CopiedFb, + FindingBranches, + ContainedIn, + SearchFilesHint, + SortLastUsed, + OnlyMine, + PopBranchFilters, + PopRemoteFilters, + PopStagedFiles, + PopUnstagedFiles, + ShowLineCounts, + ChangeListHint, + NoRemotes, + NoTags, + NoWorktrees, + CurrentTag, + StashMenuCreateBranch, + BlameEmpty, + FileHistoryTitle, + HistoryEmpty, + ReflogTitle, + ReflogEmpty, + ReflogHint, + SubmodulesTitle, + SubmodulesEmpty, + SubmodulesHint, + CommitsCountFmt, + BhTab, + BhUnmerged, + BhStale, + BhDiverged, + BhTotal, + BhMerged, + AgeAny, + AgeSevenDays, + AgeThirtyDays, + AgeNinetyDays, + AgeOneYear, + TipCreatePrFull, + TipSearchCommits, + TipRefresh, + TipSettings, + BlameEmptyHint, + HistoryEmptyHint, + SearchResultsFmt, + StagedOneFmt, + StagedManyFmt, + StagedNone, + DescCount11, + DescCount1N, + DescCountN1, + DescCountNN, + StatusStagedFmt, + StatusChangedFmt, + SignedBadge, + ErrStageFailed, + ErrUnstageFailed, + ErrStageAllFailed, + ErrUnstageAllFailed, + ErrCommitFailed, + ErrAmendFailed, + ErrCheckoutBranchFailedFmt, + ErrCheckoutCommitFailedFmt, + ErrCheckoutTagFailedFmt, + ErrBranchCreateFailedFmt, + ErrBranchDeleteFailedFmt, + ErrBranchRenameFailedFmt, + ErrTagCreateFailedFmt, + ErrTagDeleteFailedFmt, + ErrStashSaveFailed, + ErrStashPopFailedFmt, + ErrStashApplyFailedFmt, + ErrStashDropFailedFmt, + ErrStashBranchFailedFmt, + ErrDiscardFailed, + ErrCleanFailed, + ErrResetHeadFailed, + ErrResetFailedFmt, + ErrResetSoftFailedFmt, + ErrResetMixedFailedFmt, + ErrRevertConflictFmt, + ErrRevertFailedFmt, + ErrRevertGuide, + ErrCherryPickConflictFmt, + ErrCherryPickFailedFmt, + ErrCherryPickGuide, + ErrAbortFailedFmt, + ErrContinueFailedFmt, + ErrMergeConflictFmt, + ErrMergeFailedFmt, + ErrRemoveRemoteFailed, + ErrCloneFailed, + ErrBisectStartFailed, + ErrBisectGoodFailedFmt, + ErrBisectBadFailedFmt, + ErrBisectExhausted, + ErrBisectSkipFailedFmt, + ErrBisectResetFailed, + ErrWorktreeCreateFailedFmt, + ErrWorktreeRemoveFailedFmt, + ErrFetchFailedFmt, + ErrPullConflictFmt, + ErrPullFailedFmt, + ErrPushFailedFmt, + ErrFetchNotStarted, + ErrPullNotStarted, + ErrPushNotStarted, + ErrRebasePaused, + ErrRebaseFailed, + ErrStageHunkFailed, + ErrUnstageHunkFailed, + ErrStageLinesFailed, + ErrUnstageLinesFailed, + ErrConflictResolveFailed, + ErrAlreadyOnBranchFmt, + ErrCleanWorktreeFmt, + ErrHeadDetached, + ErrBranchNameUnknown, + ErrNoRemotes, + ErrNoUsableRemotes, + ErrNoStagedChanges, + ErrCannotAmendRebase, + ErrOverwriteLocalFmt, + ErrOverwritePathOneFmt, + ErrOverwritePathManyFmt, + ErrStagingConflictFmt, + ErrBranchNotFoundFmt, + ErrLocalBranchExistsFmt, + ErrInvalidRemoteBranchFmt, + ErrNotMergeState, + ErrUnresolvedConflicts, + ErrNothingToContinueFmt, + ErrNoRebaseEntries, + ErrRebasePlanMismatchFmt, + ErrStashIndexOutOfRangeFmt, + ErrBaseNotCommitFmt, +} + +/// Application settings persisted to disk. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AppSettings { + #[serde(default = "default_settings_version")] + pub version: u32, + #[serde(default = "default_theme")] + pub theme: String, + #[serde(default)] + pub ui_font: String, + #[serde(default)] + pub ai: AiSettings, + #[serde(default)] + pub git: GitSettings, + #[serde(default)] + pub recent_repos: Vec, + #[serde(default = "default_max_recent")] + pub max_recent_repos: usize, + #[serde(default)] + pub last_workspace: Vec, + #[serde(default)] + pub layout: LayoutSettings, + #[serde(default)] + pub workspaces: Vec, + #[serde(default)] + pub active_workspace_id: Option, + #[serde(default)] + pub clean_exit: bool, + #[serde(default)] + pub compactness: Compactness, + #[serde(default = "default_appearance_mode")] + pub appearance_mode: AppearanceMode, + #[serde(default)] + pub language: Language, + #[serde(default)] + pub terminal_command: String, + #[serde(default)] + pub editor_command: String, + #[serde(default = "default_font_size")] + pub font_size: u32, + #[serde(default = "default_show_line_numbers_in_diff")] + pub show_line_numbers_in_diff: bool, + #[serde(default)] + pub diff_view_mode: DiffViewMode, + #[serde(default)] + pub diff_wrap_lines: bool, + #[serde(default)] + pub graph_style: GraphStyle, + #[serde(default = "default_show_subject_column")] + pub show_subject_column: bool, + #[serde(default = "default_author_column_width")] + pub author_column_width: f32, + #[serde(default = "default_date_column_width")] + pub date_column_width: f32, + #[serde(default)] + pub auto_fetch_interval: AutoFetchInterval, + #[serde(default = "default_confirm_destructive")] + pub confirm_destructive_operations: bool, + #[serde(default = "default_auto_check_updates")] + pub auto_check_updates: bool, + /// Timestamp of the most recent successful GitHub release check. Used to + /// throttle background update polling so restarts don't spam the API. + #[serde(default)] + pub last_update_check_at: Option>, + #[serde(default = "default_commit_limit")] + pub commit_limit: usize, + #[serde(default)] + pub watch_all_worktrees: bool, + /// Show each staged and unstaged file's added and removed line counts in + /// the sidebar. Off by default: counting them diffs every changed file on + /// each status refresh. + #[serde(default)] + pub show_change_line_stats: bool, + /// Last known position/size of the standalone Settings window. Restored + /// on next open if the saved origin still falls within a connected display. + #[serde(default)] + pub settings_window_bounds: Option, +} + +/// Serializable rectangle in screen coordinates. Used to persist window +/// geometry across sessions independently of GPUI's `Bounds`. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct SavedWindowBounds { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +/// Current settings version. Increment when making breaking changes. +const CURRENT_SETTINGS_VERSION: u32 = 4; + +fn default_settings_version() -> u32 { + CURRENT_SETTINGS_VERSION +} + +fn default_theme() -> String { + "Catppuccin Mocha".into() +} + +fn default_appearance_mode() -> AppearanceMode { + AppearanceMode::Auto +} + +fn default_font_size() -> u32 { + 14 +} + +fn default_show_line_numbers_in_diff() -> bool { + true +} + +fn default_show_subject_column() -> bool { + true +} + +fn default_author_column_width() -> f32 { + 140.0 +} + +fn default_date_column_width() -> f32 { + 100.0 +} + +fn default_confirm_destructive() -> bool { + true +} + +fn default_auto_check_updates() -> bool { + true +} + +fn default_commit_limit() -> usize { + 1000 +} + +/// A persisted local workspace snapshot. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct StoredWorkspace { + pub id: String, + pub name: String, + #[serde(default)] + pub repos: Vec, + #[serde(default)] + pub active_repo_index: usize, + #[serde(default)] + pub layout: LayoutSettings, + #[serde(default = "utc_now")] + pub created_at: DateTime, + #[serde(default = "utc_now")] + pub last_opened_at: DateTime, +} + +/// Persisted layout dimensions. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct LayoutSettings { + #[serde(default = "default_sidebar_width")] + pub sidebar_width: f32, + #[serde(default = "default_detail_panel_width")] + pub detail_panel_width: f32, + #[serde(default = "default_diff_viewer_height")] + pub diff_viewer_height: f32, + #[serde(default = "default_commit_input_height")] + pub commit_input_height: f32, + /// Whether the commit graph is hidden, handing its space to the bottom + /// panel. Files written before the option existed load with it off. + #[serde(default)] + pub graph_hidden: bool, +} + +fn default_sidebar_width() -> f32 { + 276.0 +} +fn default_detail_panel_width() -> f32 { + 352.0 +} +fn default_diff_viewer_height() -> f32 { + 345.0 +} +fn default_commit_input_height() -> f32 { + 385.0 +} + +impl Default for LayoutSettings { + fn default() -> Self { + Self { + sidebar_width: default_sidebar_width(), + detail_panel_width: default_detail_panel_width(), + diff_viewer_height: default_diff_viewer_height(), + commit_input_height: default_commit_input_height(), + graph_hidden: false, + } + } +} + +fn default_max_recent() -> usize { + 20 +} + +/// The AI providers rgitui can talk to. +/// +/// Persisted by its lowercase id, and the single source of truth for endpoint +/// shape, auth style and default model. Dispatching on a bare string is what +/// let a hand-edited `settings.json` reach the network layer before failing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +#[derive(Default)] +pub enum AiProvider { + #[default] + Gemini, + #[serde(rename = "openai")] + OpenAi, + Anthropic, + #[serde(rename = "deepseek")] + DeepSeek, + #[serde(rename = "openrouter")] + OpenRouter, +} + +impl AiProvider { + /// Every provider, in the order the settings UI lists them. + pub const ALL: &'static [AiProvider] = &[ + AiProvider::Gemini, + AiProvider::OpenAi, + AiProvider::Anthropic, + AiProvider::DeepSeek, + AiProvider::OpenRouter, + ]; + + /// The stable id used in `settings.json`, keychain accounts and catalogue + /// cache filenames. Changing one needs a migration. + pub fn id(self) -> &'static str { + match self { + AiProvider::Gemini => "gemini", + AiProvider::OpenAi => "openai", + AiProvider::Anthropic => "anthropic", + AiProvider::DeepSeek => "deepseek", + AiProvider::OpenRouter => "openrouter", + } + } + + pub fn display_name(self) -> &'static str { + match self { + AiProvider::Gemini => "Google Gemini", + AiProvider::OpenAi => "OpenAI", + AiProvider::Anthropic => "Anthropic", + AiProvider::DeepSeek => "DeepSeek", + AiProvider::OpenRouter => "OpenRouter", + } + } + + /// Parse a persisted id. Case- and whitespace-insensitive, so a hand-edited + /// `"Anthropic"` resolves instead of silently falling back. + pub fn from_id(value: &str) -> Option { + let normalized = value.trim().to_ascii_lowercase(); + Self::ALL + .iter() + .copied() + .find(|provider| provider.id() == normalized) + } + + /// The GA model a fresh install (or a provider switch with no remembered + /// choice) uses: cheap, fast and tool-capable, because commit-message + /// generation does not need a frontier model. + pub fn default_model(self) -> &'static str { + match self { + AiProvider::Gemini => "gemini-3.1-flash-lite", + AiProvider::OpenAi => "gpt-5.6-luna", + AiProvider::Anthropic => "claude-haiku-4-5", + AiProvider::DeepSeek => "deepseek-v4-flash", + AiProvider::OpenRouter => "google/gemini-3.1-flash-lite", + } } /// Where the user creates an API key for this provider. @@ -768,6 +3868,7 @@ impl Default for AppSettings { clean_exit: true, // First run is considered clean compactness: Compactness::default(), appearance_mode: default_appearance_mode(), + language: Language::default(), terminal_command: String::new(), editor_command: String::new(), font_size: default_font_size(), @@ -1073,6 +4174,15 @@ impl SettingsState { log::info!("Migrated settings from version 2 to 3"); } + // Migration 3 -> 4: interface language added. Existing installs keep + // English; serde defaults already cover the missing field. + if self.settings.version == 3 { + self.settings.language = Language::default(); + self.settings.version = 4; + migrated = true; + log::info!("Migrated settings from version 3 to 4"); + } + // Ensure version is current if self.settings.version < CURRENT_SETTINGS_VERSION { self.settings.version = CURRENT_SETTINGS_VERSION; @@ -1709,261 +4819,1480 @@ fn resolve_git_https_token(settings: &GitSettings) -> Option { } else { settings.legacy_https_token.clone() } -} +} + +fn resolve_provider_token(provider: &GitProviderSettings) -> Option { + if provider.has_token { + read_secret(&git_provider_account(&provider.id)).or_else(|| provider.legacy_token.clone()) + } else { + provider.legacy_token.clone() + } +} + +fn secret_entry(account: &str) -> Result { + Ok(Entry::new(KEYRING_SERVICE, account)?) +} + +fn write_secret(account: &str, value: Option<&str>) -> Result { + let entry = secret_entry(account)?; + match value { + Some(secret) if !secret.trim().is_empty() => { + entry.set_password(secret)?; + Ok(true) + } + _ => { + let _ = entry.delete_credential(); + Ok(false) + } + } +} + +fn read_secret(account: &str) -> Option { + let entry = match secret_entry(account) { + Ok(e) => e, + Err(e) => { + log::warn!("Failed to create keyring entry for '{}': {}", account, e); + return None; + } + }; + match entry.get_password() { + Ok(password) => Some(password), + Err(keyring::Error::NoEntry) => None, + Err(e) => { + log::warn!("Failed to read secret for '{}': {}", account, e); + None + } + } +} + +fn delete_secret(account: &str) -> Result<()> { + let entry = secret_entry(account)?; + let _ = entry.delete_credential(); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_settings_state() -> SettingsState { + SettingsState { + settings: AppSettings::default(), + config_path: PathBuf::from("/tmp/rgitui-test-settings.json"), + load_warnings: Vec::new(), + } + } + + // ── Layout ──────────────────────────────────────────────────── + + /// A layout saved before the graph could be hidden must keep showing it. + #[test] + fn layout_without_graph_hidden_shows_the_graph() { + let layout: LayoutSettings = serde_json::from_str( + r#"{"sidebar_width":300.0,"detail_panel_width":400.0,"diff_viewer_height":500.0,"commit_input_height":350.0}"#, + ) + .expect("legacy layout parses"); + assert!(!layout.graph_hidden); + assert_eq!(layout.diff_viewer_height, 500.0); + } + + #[test] + fn graph_hidden_round_trips() { + let layout = LayoutSettings { + graph_hidden: true, + ..LayoutSettings::default() + }; + let json = serde_json::to_string(&layout).expect("layout serializes"); + let restored: LayoutSettings = serde_json::from_str(&json).expect("layout parses"); + assert_eq!(restored, layout); + } + + // ── AI provider catalogue coherence ─────────────────────────── + + /// The bug this guards is not hypothetical: the shipped default was + /// `gemini-2.0-flash`, which appeared in no picker, so a fresh install + /// rendered the Model row with nothing selected at all. + #[test] + fn default_model_is_the_default_provider_model() { + assert_eq!(default_ai_model(), default_ai_provider().default_model()); + } + + #[test] + fn every_provider_has_a_distinct_id_and_a_default_model() { + let mut ids: Vec<&str> = AiProvider::ALL.iter().map(|p| p.id()).collect(); + let count = ids.len(); + ids.sort_unstable(); + ids.dedup(); + assert_eq!(ids.len(), count, "provider ids must be unique"); + + for provider in AiProvider::ALL { + assert!(!provider.default_model().is_empty()); + assert!(provider.key_url().starts_with("https://")); + assert!(!provider.display_name().is_empty()); + assert_eq!(AiProvider::from_id(provider.id()), Some(*provider)); + } + } + + #[test] + fn no_default_model_is_a_retired_id() { + for provider in AiProvider::ALL { + assert_eq!( + retired_model_successor(provider.default_model()), + None, + "{} ships a retired default", + provider.id() + ); + } + } + + #[test] + fn provider_id_parsing_is_lenient_about_case_and_whitespace() { + assert_eq!( + AiProvider::from_id("Anthropic"), + Some(AiProvider::Anthropic) + ); + assert_eq!(AiProvider::from_id(" OpenAI "), Some(AiProvider::OpenAi)); + assert_eq!( + AiProvider::from_id("openrouter"), + Some(AiProvider::OpenRouter) + ); + assert_eq!(AiProvider::from_id("bard"), None); + } + + #[test] + fn only_openai_compatible_providers_accept_a_base_url_override() { + assert!(AiProvider::OpenAi.is_openai_compatible()); + assert!(AiProvider::DeepSeek.is_openai_compatible()); + assert!(AiProvider::OpenRouter.is_openai_compatible()); + assert!(!AiProvider::Gemini.is_openai_compatible()); + assert!(!AiProvider::Anthropic.is_openai_compatible()); + } + + // ── AiSettings model memory ─────────────────────────────────── + + #[test] + fn switching_provider_preserves_each_providers_model_choice() { + let mut ai = AiSettings::default(); + ai.set_active_model("gemini-3.1-pro-preview"); + + ai.set_active_provider(AiProvider::OpenAi); + assert_eq!(ai.model, AiProvider::OpenAi.default_model()); + ai.set_active_model("gpt-5.4"); + + ai.set_active_provider(AiProvider::Gemini); + assert_eq!(ai.model, "gemini-3.1-pro-preview"); + + ai.set_active_provider(AiProvider::OpenAi); + assert_eq!(ai.model, "gpt-5.4"); + } + + #[test] + fn model_for_never_returns_another_providers_model() { + let mut ai = AiSettings::default(); + ai.set_active_model("gemini-3.1-pro-preview"); + assert_eq!( + ai.model_for(AiProvider::Anthropic), + AiProvider::Anthropic.default_model() + ); + } + + #[test] + fn key_flags_are_tracked_per_provider() { + let mut ai = AiSettings::default(); + ai.set_has_key_for(AiProvider::Gemini, true); + assert!(ai.has_key_for(AiProvider::Gemini)); + assert!(ai.has_api_key, "the active provider mirror must follow"); + assert!(!ai.has_key_for(AiProvider::Anthropic)); + + // Switching to a provider with no key must not keep asserting + // "connected" — that false state is what enabled the AI button for a + // provider the app had no credential for. + ai.set_active_provider(AiProvider::Anthropic); + assert!(!ai.has_api_key); + } + + // ── settings file compatibility ─────────────────────────────── + + #[test] + fn v2_settings_load_with_every_new_field_defaulted() { + let json = r#"{ + "version": 2, + "ai": { "provider": "openai", "model": "gpt-5.4", "has_api_key": true } + }"#; + let settings: AppSettings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.ai.provider, AiProvider::OpenAi); + assert_eq!(settings.ai.model, "gpt-5.4"); + assert!(settings.ai.has_api_key_for.is_empty()); + assert!(settings.ai.models_by_provider.is_empty()); + assert!(settings.ai.base_url_override.is_empty()); + assert!(settings.ai.openrouter_attribution); + } -fn resolve_provider_token(provider: &GitProviderSettings) -> Option { - if provider.has_token { - read_secret(&git_provider_account(&provider.id)).or_else(|| provider.legacy_token.clone()) - } else { - provider.legacy_token.clone() + #[test] + fn an_unknown_provider_falls_back_instead_of_failing_the_file() { + let json = r#"{ "version": 3, "ai": { "provider": "bard" } }"#; + let settings: AppSettings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.ai.provider, AiProvider::default()); + // And the raw value is still recoverable, which is what lets `init` + // tell the user which id it did not understand. + assert_eq!(raw_ai_provider(json).as_deref(), Some("bard")); } -} - -fn secret_entry(account: &str) -> Result { - Ok(Entry::new(KEYRING_SERVICE, account)?) -} -fn write_secret(account: &str, value: Option<&str>) -> Result { - let entry = secret_entry(account)?; - match value { - Some(secret) if !secret.trim().is_empty() => { - entry.set_password(secret)?; - Ok(true) - } - _ => { - let _ = entry.delete_credential(); - Ok(false) - } + #[test] + fn a_miscased_provider_resolves_rather_than_falling_back() { + let json = r#"{ "ai": { "provider": "Anthropic" } }"#; + let settings: AppSettings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.ai.provider, AiProvider::Anthropic); } -} -fn read_secret(account: &str) -> Option { - let entry = match secret_entry(account) { - Ok(e) => e, - Err(e) => { - log::warn!("Failed to create keyring entry for '{}': {}", account, e); - return None; - } - }; - match entry.get_password() { - Ok(password) => Some(password), - Err(keyring::Error::NoEntry) => None, - Err(e) => { - log::warn!("Failed to read secret for '{}': {}", account, e); - None - } + #[test] + fn the_api_key_never_reaches_the_settings_file() { + let mut settings = AppSettings::default(); + settings.ai.legacy_api_key = Some("sk-should-never-be-written".into()); + let json = serde_json::to_string(&settings).unwrap(); + assert!(!json.contains("sk-should-never-be-written")); } -} -fn delete_secret(account: &str) -> Result<()> { - let entry = secret_entry(account)?; - let _ = entry.delete_credential(); - Ok(()) -} + #[test] + fn v2_to_v3_remaps_retired_model_ids() { + let mut state = test_settings_state(); + state.settings.version = 2; + state.settings.ai.model = "gemini-2.0-flash".into(); -#[cfg(test)] -mod tests { - use super::*; + assert!(state.migrate_settings()); - fn test_settings_state() -> SettingsState { - SettingsState { - settings: AppSettings::default(), - config_path: PathBuf::from("/tmp/rgitui-test-settings.json"), - load_warnings: Vec::new(), - } + assert_eq!(state.settings.version, CURRENT_SETTINGS_VERSION); + assert_eq!(state.settings.ai.model, "gemini-3.1-flash-lite"); } - // ── Layout ──────────────────────────────────────────────────── - - /// A layout saved before the graph could be hidden must keep showing it. #[test] - fn layout_without_graph_hidden_shows_the_graph() { - let layout: LayoutSettings = serde_json::from_str( - r#"{"sidebar_width":300.0,"detail_panel_width":400.0,"diff_viewer_height":500.0,"commit_input_height":350.0}"#, - ) - .expect("legacy layout parses"); - assert!(!layout.graph_hidden); - assert_eq!(layout.diff_viewer_height, 500.0); - } + fn v2_to_v3_leaves_a_live_model_alone() { + let mut state = test_settings_state(); + state.settings.version = 2; + state.settings.ai.model = "gemini-2.5-pro".into(); - #[test] - fn graph_hidden_round_trips() { - let layout = LayoutSettings { - graph_hidden: true, - ..LayoutSettings::default() - }; - let json = serde_json::to_string(&layout).expect("layout serializes"); - let restored: LayoutSettings = serde_json::from_str(&json).expect("layout parses"); - assert_eq!(restored, layout); + state.migrate_settings(); + + assert_eq!(state.settings.ai.model, "gemini-2.5-pro"); } - // ── AI provider catalogue coherence ─────────────────────────── + // ── interface language ────────────────────────────────────── - /// The bug this guards is not hypothetical: the shipped default was - /// `gemini-2.0-flash`, which appeared in no picker, so a fresh install - /// rendered the Model row with nothing selected at all. #[test] - fn default_model_is_the_default_provider_model() { - assert_eq!(default_ai_model(), default_ai_provider().default_model()); + fn language_ids_are_stable_and_unique() { + assert_eq!(Language::English.id(), "en"); + assert_eq!(Language::SimplifiedChinese.id(), "zh-CN"); + assert_eq!(Language::ALL.len(), 2); + assert_eq!("en".parse::().unwrap(), Language::English); + assert_eq!( + "zh-CN".parse::().unwrap(), + Language::SimplifiedChinese + ); + assert_eq!( + "zh_cn".parse::().unwrap(), + Language::SimplifiedChinese + ); + assert_eq!( + "简体中文".parse::().unwrap(), + Language::SimplifiedChinese + ); + assert!("fr".parse::().is_err()); } #[test] - fn every_provider_has_a_distinct_id_and_a_default_model() { - let mut ids: Vec<&str> = AiProvider::ALL.iter().map(|p| p.id()).collect(); - let count = ids.len(); - ids.sort_unstable(); - ids.dedup(); - assert_eq!(ids.len(), count, "provider ids must be unique"); - - for provider in AiProvider::ALL { - assert!(!provider.default_model().is_empty()); - assert!(provider.key_url().starts_with("https://")); - assert!(!provider.display_name().is_empty()); - assert_eq!(AiProvider::from_id(provider.id()), Some(*provider)); - } + fn language_dropdown_labels_stay_native() { + assert_eq!(Language::English.native_name(), "English"); + assert_eq!(Language::SimplifiedChinese.native_name(), "简体中文"); } #[test] - fn no_default_model_is_a_retired_id() { - for provider in AiProvider::ALL { - assert_eq!( - retired_model_successor(provider.default_model()), - None, - "{} ships a retired default", - provider.id() - ); + fn every_translated_key_is_non_empty_in_both_languages() { + let keys = [ + TrKey::Preferences, + TrKey::SectionAppearance, + TrKey::SectionAi, + TrKey::SectionAuth, + TrKey::SectionGeneral, + TrKey::GeneralTitle, + TrKey::GeneralDesc, + TrKey::LanguageTitle, + TrKey::LanguageDesc, + TrKey::MaxRecentTitle, + TrKey::MaxRecentDesc, + TrKey::UiDensityTitle, + TrKey::UiDensityDesc, + TrKey::DensityCompact, + TrKey::DensityDefault, + TrKey::DensityComfortable, + TrKey::FontSizeTitle, + TrKey::FontSizeDesc, + TrKey::GeneralPageDesc, + TrKey::ThemeSectionDesc, + TrKey::ThemePageDesc, + TrKey::AppearanceModeTitle, + TrKey::AppearanceModeDesc, + TrKey::ModeAuto, + TrKey::ModeLight, + TrKey::ModeDark, + TrKey::ColorThemeTitle, + TrKey::ColorThemeDesc, + TrKey::ActiveBadge, + TrKey::CustomThemeTitle, + TrKey::CustomThemeDesc, + TrKey::EditThemeBtn, + TrKey::AccountsTitle, + TrKey::AccountsDesc, + TrKey::AccountsHeader, + TrKey::AccountsSectionDesc, + TrKey::QuickSetupTitle, + TrKey::QuickSetupDesc, + TrKey::SignInGitHub, + TrKey::AddGitLab, + TrKey::ManualCustom, + TrKey::AuthProfilesTitle, + TrKey::AuthProfilesDesc, + TrKey::AuthProfilesEmptyTitle, + TrKey::AuthProfilesEmptyDesc, + TrKey::AuthHttpsFallbackTitle, + TrKey::AuthHttpsFallbackDesc, + TrKey::AuthSshOverrideTitle, + TrKey::AuthSshOverrideDesc, + TrKey::AuthSignCommitsTitle, + TrKey::AuthSignCommitsDesc, + TrKey::AuthGpgKeyTitle, + TrKey::AuthGpgKeyDesc, + TrKey::AuthHowTitle, + TrKey::AuthHowDesc, + TrKey::AuthHowHttps, + TrKey::AuthHowSshFallback, + TrKey::AuthHowSshKey, + TrKey::AiPageDesc, + TrKey::AiSectionConnection, + TrKey::AiSectionBehaviour, + TrKey::AiStatusTitle, + TrKey::AiEnabledToggle, + TrKey::AiStatusOff, + TrKey::AiStatusTestingFmt, + TrKey::AiStatusRejectedFmt, + TrKey::AiStatusAddKeyFmt, + TrKey::AiConnectTitle, + TrKey::AiConnectDesc, + TrKey::AiApiKeyLabel, + TrKey::AiGetKeyBtn, + TrKey::AiKeyVerifiedFmt, + TrKey::AiKeyStoredHintFmt, + TrKey::AiNoKeyHintFmt, + TrKey::AiKeysStoredNoteFmt, + TrKey::AiModelLabel, + TrKey::AiModelCountFmt, + TrKey::AiRefreshBtn, + TrKey::AiRefreshingBtn, + TrKey::AiModelMissingFmt, + TrKey::AiUseSuggestionFmt, + TrKey::AiConnectBtn, + TrKey::AiUseProviderBtn, + TrKey::AiTestBtn, + TrKey::AiTestingBtn, + TrKey::AiRemoveKeyBtn, + TrKey::AiAdvancedLabel, + TrKey::AiBaseUrlLabel, + TrKey::AiBaseUrlOverriddenFmt, + TrKey::AiBaseUrlEmptyFmt, + TrKey::AiAttributionTitle, + TrKey::AiAttributionDesc, + TrKey::AiCommitStyleTitle, + TrKey::AiCommitStyleDesc, + TrKey::AiCtxTitle, + TrKey::AiCtxDesc, + TrKey::AiToolsTitle, + TrKey::AiToolsDesc, + TrKey::AiKeyPlaceholderFmt, + TrKey::ShowLineNumbersTitle, + TrKey::ShowLineNumbersDesc, + TrKey::WrapLinesTitle, + TrKey::WrapLinesDesc, + TrKey::DiffModeTitle, + TrKey::DiffModeDesc, + TrKey::DiffUnified, + TrKey::DiffSideBySide, + TrKey::GraphTitle, + TrKey::GraphDesc, + TrKey::GraphRails, + TrKey::GraphCurved, + TrKey::GraphAngular, + TrKey::ShowSubjectTitle, + TrKey::ShowSubjectDesc, + TrKey::AutoFetchTitle, + TrKey::AutoFetchDesc, + TrKey::ConfirmDestructiveTitle, + TrKey::ConfirmDestructiveDesc, + TrKey::CheckUpdatesTitle, + TrKey::CheckUpdatesDesc, + TrKey::WatchWorktreesTitle, + TrKey::WatchWorktreesDesc, + TrKey::ExternalToolsTitle, + TrKey::ExternalToolsDesc, + TrKey::TerminalLabel, + TrKey::EditorLabel, + TrKey::CustomCommandLabel, + TrKey::ShortcutsTitle, + TrKey::ShortcutsDesc, + TrKey::ConfigTitle, + TrKey::ConfigDesc, + TrKey::RevealBtn, + TrKey::EditKeymapBtn, + TrKey::SavedFeedback, + TrKey::TbFetch, + TrKey::TbFetching, + TrKey::TbPull, + TrKey::TbPulling, + TrKey::TbPush, + TrKey::TbPushing, + TrKey::TbBranch, + TrKey::TbStash, + TrKey::TbPop, + TrKey::TbCreatePr, + TrKey::StatusClean, + TrKey::StatusDetached, + TrKey::SideBranches, + TrKey::SideRemotes, + TrKey::SideRemoteBranches, + TrKey::SideTags, + TrKey::SideStashes, + TrKey::SideWorktrees, + TrKey::SideStaged, + TrKey::SideUnstaged, + TrKey::SideStageAll, + TrKey::SideUnstageAll, + TrKey::SideNewWorktree, + TrKey::SideWorkingClean, + TrKey::SideNoStaged, + TrKey::CommitBtn, + TrKey::CommitNoStaged, + TrKey::CommitNoMessage, + TrKey::CommitAmendBtn, + TrKey::AmendBtn, + TrKey::ClearBtn, + TrKey::SummaryLabel, + TrKey::DescLabel, + TrKey::CoAuthorsLabel, + TrKey::NoStagedHint, + TrKey::CancelBtn, + TrKey::ConfirmHint, + TrKey::CfConfirm, + TrKey::CfDiscard, + TrKey::CfClean, + TrKey::CfDeleteBranch, + TrKey::CfDeleteTag, + TrKey::CfRemove, + TrKey::CfDropStash, + TrKey::CfReset, + TrKey::CfAbort, + TrKey::CfForcePush, + TrKey::CfRemoveWorktree, + TrKey::DiffFileBtn, + TrKey::DiffEmptyTitle, + TrKey::DiffEmptyDesc, + TrKey::DiffOpenFile, + TrKey::DetailTitle, + TrKey::DetailEmptyTitle, + TrKey::DetailEmptyDesc, + TrKey::HomeSlogan, + TrKey::OpenRepoTitle, + TrKey::HomeNewWorkspace, + TrKey::HomeRestoreLast, + TrKey::HomeRecentWorkspaces, + TrKey::HomeRecentRepos, + TrKey::WsSummaryFmt, + TrKey::LoadingDots, + TrKey::OpRetry, + TrKey::ConflictOneFmt, + TrKey::ConflictManyFmt, + TrKey::ConflictResolved, + TrKey::ConflictContinue, + TrKey::WorktreeExit, + TrKey::InspectingPre, + TrKey::DetachedHeadWord, + TrKey::BottomDiff, + TrKey::BottomDiffTip, + TrKey::BottomHistory, + TrKey::HistLoading, + TrKey::HistShow, + TrKey::HistUnavailable, + TrKey::BottomBlame, + TrKey::BlameLoading, + TrKey::BlameShow, + TrKey::BlameUnavailable, + TrKey::GraphShowTip, + TrKey::GraphHideTip, + TrKey::DetachedTitlePre, + TrKey::DetachedConsequence, + TrKey::DetachedReturnPre, + TrKey::DetachedReturnTipPre, + TrKey::DetachedReturnTipPost, + TrKey::DetachedSwitch, + TrKey::DetachedShowGraph, + TrKey::UpdateMsgFmt, + TrKey::UpdateDownload, + TrKey::MenuCherryPick, + TrKey::MenuRevert, + TrKey::MenuCheckout, + TrKey::MenuCreateBranch, + TrKey::MenuCreateTag, + TrKey::MenuBisectGood, + TrKey::MenuBisectBad, + TrKey::MenuReset, + TrKey::RebaseTitle, + TrKey::MenuSquash, + TrKey::MenuCopySha, + TrKey::MenuCopyMsg, + TrKey::MenuCopyAuthor, + TrKey::MenuCopyDate, + TrKey::MenuViewGithub, + TrKey::GraphPanelTitle, + TrKey::GraphEmpty, + TrKey::GraphColHash, + TrKey::GraphColMessage, + TrKey::GraphColAuthor, + TrKey::GraphColDate, + TrKey::MyCommitsOn, + TrKey::MyCommitsOff, + TrKey::GripRebase, + TrKey::NoMatches, + TrKey::LoadMore, + TrKey::ShaShort, + TrKey::ShaFull, + TrKey::ShaCharsFmt, + TrKey::DisplaySettings, + TrKey::ShaLengthLabel, + TrKey::ShowSubjectCol, + TrKey::ShowAuthorCol, + TrKey::ShowAuthorEmail, + TrKey::ShowDateCol, + TrKey::AbsoluteDates, + TrKey::ShowAvatars, + TrKey::ShowLanes, + TrKey::ShowBadges, + TrKey::HeadDetachedBadge, + TrKey::PendingOnPre, + TrKey::PendingChanges, + TrKey::NoCommitsYet, + TrKey::NoCommitsSuffix, + TrKey::NewBranchBadge, + TrKey::SearchCommitsPh, + TrKey::PrsTitle, + TrKey::PrsTab, + TrKey::PrDetail, + TrKey::IssuesTitle, + TrKey::IssueDetail, + TrKey::FilterOpen, + TrKey::FilterClosed, + TrKey::FilterAll, + TrKey::PrNew, + TrKey::PrStateMerged, + TrKey::DraftBadge, + TrKey::LoadingComments, + TrKey::CommentsFailedPre, + TrKey::OneComment, + TrKey::ManyCommentsFmt, + TrKey::GhSignInPrs, + TrKey::GhSignInIssues, + TrKey::GhAuthDesc, + TrKey::TryFilterHint, + TrKey::PrEmptyFmt, + TrKey::IssueEmptyFmt, + TrKey::ReviewActions, + TrKey::ReviewApprove, + TrKey::ReviewRequest, + TrKey::ReviewCommentBtn, + TrKey::ReviewSubmitting, + TrKey::ReviewCommentPh, + TrKey::SearchIssuesTip, + TrKey::SearchIssuesPh, + TrKey::RepoPathLabel, + TrKey::BrowseBtn, + TrKey::CloneBtn, + TrKey::NoMatchRepos, + TrKey::NoRecentRepos, + TrKey::OpenBtn, + TrKey::EnterRepoPathPh, + TrKey::CloneTitle, + TrKey::CloneUrlLabel, + TrKey::ClonePathLabel, + TrKey::CloneUrlPh, + TrKey::ClonePathPh, + TrKey::CloneHint, + TrKey::CloningBtn, + TrKey::NameLabel, + TrKey::WtPathLabel, + TrKey::WtBranchLabel, + TrKey::CreateBtn, + TrKey::WtNamePh, + TrKey::WtPathPh, + TrKey::WtBranchPh, + TrKey::TagTitle, + TrKey::TagNameLabel, + TrKey::TagAtCommit, + TrKey::TagCreateHint, + TrKey::StashTitle, + TrKey::StashMsgLabel, + TrKey::StashMsgHint, + TrKey::RenameTitle, + TrKey::RenameCurrent, + TrKey::RenameNew, + TrKey::RenameHint, + TrKey::RenameBtn, + TrKey::BranchTitle, + TrKey::BranchNameLabel, + TrKey::BranchBasedOn, + TrKey::BranchNamePh, + TrKey::StashBranchTitle, + TrKey::StashFrom, + TrKey::CreatePrTitle, + TrKey::PrInto, + TrKey::PrTitleLabel, + TrKey::PrTitlePh, + TrKey::PrDescPh, + TrKey::PrDraftToggle, + TrKey::PrHint, + TrKey::PrSubmit, + TrKey::PrCreating, + TrKey::RebasePick, + TrKey::RebaseReword, + TrKey::RebaseSquash, + TrKey::RebaseFixup, + TrKey::RebaseDrop, + TrKey::RebaseHeaderFmt, + TrKey::RebasePh, + TrKey::RebaseDragTip, + TrKey::RebaseHintNav, + TrKey::RebaseHintReorder, + TrKey::RebaseHintAction, + TrKey::RebaseHintStart, + TrKey::RebaseHintCancel, + TrKey::RebaseStart, + TrKey::BisectTitle, + TrKey::BisectStarted, + TrKey::BisectBadFmt, + TrKey::BisectGoodFmt, + TrKey::BisectSkipFmt, + TrKey::BisectLeftFmt, + TrKey::BisectEntriesFmt, + TrKey::BisectEmpty, + TrKey::BisectEmptyHint, + TrKey::SplashSubtitle, + TrKey::SplashLoading, + TrKey::ShortcutsCloseHint, + TrKey::MoreActionsPre, + TrKey::ThemeTitle, + TrKey::ThemeColors, + TrKey::ThemeStatusColors, + TrKey::ThemeDark, + TrKey::ThemeLight, + TrKey::ThemeHint, + TrKey::SaveBtn, + TrKey::ThemeBackground, + TrKey::ThemeSurface, + TrKey::ThemeElevated, + TrKey::ThemeBorder, + TrKey::ThemeText, + TrKey::ThemeTextMuted, + TrKey::ThemeTextAccent, + TrKey::ThemePlaceholder, + TrKey::ThemeIcon, + TrKey::ThemeFocusRing, + TrKey::ThemeSelectedBorder, + TrKey::ThemeAdded, + TrKey::ThemeModified, + TrKey::ThemeDeleted, + TrKey::ThemeUntracked, + TrKey::ThemeConflict, + TrKey::ThemeRenamed, + TrKey::ThemeError, + TrKey::ThemeWarning, + TrKey::ThemeSuccess, + TrKey::ThemeInfo, + TrKey::TipOpenRepo, + TrKey::TipMerged, + TrKey::TipCopyBranch, + TrKey::TipCheckoutBranch, + TrKey::TipMergeBranch, + TrKey::TipRenameBranch, + TrKey::TipDeleteBranch, + TrKey::TipFetchRemote, + TrKey::TipPullRemote, + TrKey::TipPushRemote, + TrKey::TipRemoveRemote, + TrKey::TipCheckoutTag, + TrKey::TipDeleteTag, + TrKey::ApplyStashLbl, + TrKey::PopStashLbl, + TrKey::TipStashBranch, + TrKey::TipDropStash, + TrKey::TipRemoveWorktree, + TrKey::TipUnstageFile, + TrKey::TipStageFile, + TrKey::TipConflictResolver, + TrKey::TipDiscardChanges, + TrKey::TipFilterLocal, + TrKey::TipFilterRemote, + TrKey::TipStagedOpts, + TrKey::TipUnstagedOpts, + TrKey::TipCopySha, + TrKey::TipGpg, + TrKey::TipCherryPick, + TrKey::TipClearSearch, + TrKey::FlatViewTip, + TrKey::TreeViewTip, + TrKey::NoFilesTip, + TrKey::ClearSearchTip, + TrKey::CommitSummaryPh, + TrKey::CommitDescPh, + TrKey::CoAuthorNamePh, + TrKey::CoAuthorEmailPh, + TrKey::TipExpandCommit, + TrKey::TipCollapseCommit, + TrKey::TipExplorer, + TrKey::TipTerminal, + TrKey::TipEditor, + TrKey::TitleDetachedTag, + TrKey::TipApplyKeep, + TrKey::TipPopRemove, + TrKey::StashesEmpty, + TrKey::StashesEmptyHint, + TrKey::SearchPh, + TrKey::SearchEmpty, + TrKey::SearchHint, + TrKey::FilterFilesPh, + TrKey::FilterBranchesPh, + TrKey::FilterRemotePh, + TrKey::SetHttpsTokenPh, + TrKey::SetGpgPh, + TrKey::SetUsuallyEmpty, + TrKey::SetAccountTokenPh, + TrKey::SetCustomCmdPh, + TrKey::TipPaste, + TrKey::TipHide, + TrKey::TipShow, + TrKey::CopiedFb, + TrKey::FindingBranches, + TrKey::ContainedIn, + TrKey::SearchFilesHint, + TrKey::SortLastUsed, + TrKey::OnlyMine, + TrKey::PopBranchFilters, + TrKey::PopRemoteFilters, + TrKey::PopStagedFiles, + TrKey::PopUnstagedFiles, + TrKey::ShowLineCounts, + TrKey::ChangeListHint, + TrKey::NoRemotes, + TrKey::NoTags, + TrKey::NoWorktrees, + TrKey::CurrentTag, + TrKey::StashMenuCreateBranch, + TrKey::BlameEmpty, + TrKey::FileHistoryTitle, + TrKey::HistoryEmpty, + TrKey::ReflogTitle, + TrKey::ReflogEmpty, + TrKey::ReflogHint, + TrKey::SubmodulesTitle, + TrKey::SubmodulesEmpty, + TrKey::SubmodulesHint, + TrKey::CommitsCountFmt, + TrKey::BhTab, + TrKey::BhUnmerged, + TrKey::BhStale, + TrKey::BhDiverged, + TrKey::BhTotal, + TrKey::BhMerged, + TrKey::AgeAny, + TrKey::AgeSevenDays, + TrKey::AgeThirtyDays, + TrKey::AgeNinetyDays, + TrKey::AgeOneYear, + TrKey::TipCreatePrFull, + TrKey::TipSearchCommits, + TrKey::TipRefresh, + TrKey::TipSettings, + TrKey::BlameEmptyHint, + TrKey::HistoryEmptyHint, + TrKey::SearchResultsFmt, + TrKey::StagedOneFmt, + TrKey::StagedManyFmt, + TrKey::StagedNone, + TrKey::DescCount11, + TrKey::DescCount1N, + TrKey::DescCountN1, + TrKey::DescCountNN, + TrKey::StatusStagedFmt, + TrKey::StatusChangedFmt, + TrKey::SignedBadge, + TrKey::ErrStageFailed, + TrKey::ErrUnstageFailed, + TrKey::ErrStageAllFailed, + TrKey::ErrUnstageAllFailed, + TrKey::ErrCommitFailed, + TrKey::ErrAmendFailed, + TrKey::ErrCheckoutBranchFailedFmt, + TrKey::ErrCheckoutCommitFailedFmt, + TrKey::ErrCheckoutTagFailedFmt, + TrKey::ErrBranchCreateFailedFmt, + TrKey::ErrBranchDeleteFailedFmt, + TrKey::ErrBranchRenameFailedFmt, + TrKey::ErrTagCreateFailedFmt, + TrKey::ErrTagDeleteFailedFmt, + TrKey::ErrStashSaveFailed, + TrKey::ErrStashPopFailedFmt, + TrKey::ErrStashApplyFailedFmt, + TrKey::ErrStashDropFailedFmt, + TrKey::ErrStashBranchFailedFmt, + TrKey::ErrDiscardFailed, + TrKey::ErrCleanFailed, + TrKey::ErrResetHeadFailed, + TrKey::ErrResetFailedFmt, + TrKey::ErrResetSoftFailedFmt, + TrKey::ErrResetMixedFailedFmt, + TrKey::ErrRevertConflictFmt, + TrKey::ErrRevertFailedFmt, + TrKey::ErrRevertGuide, + TrKey::ErrCherryPickConflictFmt, + TrKey::ErrCherryPickFailedFmt, + TrKey::ErrCherryPickGuide, + TrKey::ErrAbortFailedFmt, + TrKey::ErrContinueFailedFmt, + TrKey::ErrMergeConflictFmt, + TrKey::ErrMergeFailedFmt, + TrKey::ErrRemoveRemoteFailed, + TrKey::ErrCloneFailed, + TrKey::ErrBisectStartFailed, + TrKey::ErrBisectGoodFailedFmt, + TrKey::ErrBisectBadFailedFmt, + TrKey::ErrBisectExhausted, + TrKey::ErrBisectSkipFailedFmt, + TrKey::ErrBisectResetFailed, + TrKey::ErrWorktreeCreateFailedFmt, + TrKey::ErrWorktreeRemoveFailedFmt, + TrKey::ErrFetchFailedFmt, + TrKey::ErrPullConflictFmt, + TrKey::ErrPullFailedFmt, + TrKey::ErrPushFailedFmt, + TrKey::ErrFetchNotStarted, + TrKey::ErrPullNotStarted, + TrKey::ErrPushNotStarted, + TrKey::ErrRebasePaused, + TrKey::ErrRebaseFailed, + TrKey::ErrStageHunkFailed, + TrKey::ErrUnstageHunkFailed, + TrKey::ErrStageLinesFailed, + TrKey::ErrUnstageLinesFailed, + TrKey::ErrConflictResolveFailed, + TrKey::ErrAlreadyOnBranchFmt, + TrKey::ErrCleanWorktreeFmt, + TrKey::ErrHeadDetached, + TrKey::ErrBranchNameUnknown, + TrKey::ErrNoRemotes, + TrKey::ErrNoUsableRemotes, + TrKey::ErrNoStagedChanges, + TrKey::ErrCannotAmendRebase, + TrKey::ErrOverwriteLocalFmt, + TrKey::ErrOverwritePathOneFmt, + TrKey::ErrOverwritePathManyFmt, + TrKey::ErrStagingConflictFmt, + TrKey::ErrBranchNotFoundFmt, + TrKey::ErrLocalBranchExistsFmt, + TrKey::ErrInvalidRemoteBranchFmt, + TrKey::ErrNotMergeState, + TrKey::ErrUnresolvedConflicts, + TrKey::ErrNothingToContinueFmt, + TrKey::ErrNoRebaseEntries, + TrKey::ErrRebasePlanMismatchFmt, + TrKey::ErrStashIndexOutOfRangeFmt, + TrKey::ErrBaseNotCommitFmt, + ]; + for key in keys { + assert!(!Language::English.tr(key).is_empty()); + assert!(!Language::SimplifiedChinese.tr(key).is_empty()); } + assert_eq!(Language::SimplifiedChinese.tr(TrKey::SectionAi), "AI"); } #[test] - fn provider_id_parsing_is_lenient_about_case_and_whitespace() { + fn english_branches_keep_the_exact_source_literals() { + // Rendering code previously hardcoded these strings; the English + // branch must preserve them byte for byte. + assert_eq!(Language::English.tr(TrKey::DiffUnified), "Unified"); + assert_eq!(Language::English.tr(TrKey::DiffSideBySide), "Side-by-Side"); + assert_eq!(Language::English.tr(TrKey::GraphRails), "Rails"); + assert_eq!(Language::English.tr(TrKey::GraphCurved), "Curved"); + assert_eq!(Language::English.tr(TrKey::GraphAngular), "Angular"); + assert_eq!(Language::English.tr(TrKey::ModeAuto), "Auto"); + assert_eq!(Language::English.tr(TrKey::ModeLight), "Light"); + assert_eq!(Language::English.tr(TrKey::ModeDark), "Dark"); + assert_eq!(Language::English.tr(TrKey::StatusDetached), "DETACHED"); + assert_eq!( + Language::English.tr(TrKey::ConfirmHint), + "Enter to confirm | Esc to cancel" + ); + assert_eq!(Language::English.tr(TrKey::CfForcePush), "Force Push"); + assert_eq!(Language::English.tr(TrKey::RevealBtn), "Reveal"); + assert_eq!( + Language::English.tr(TrKey::EditKeymapBtn), + "Edit keymap.json" + ); + assert_eq!( + Language::English.tr(TrKey::HomeSlogan), + "A workspace-oriented desktop Git client" + ); + assert_eq!( + Language::English.tr(TrKey::ConflictResolved), + "All conflicts resolved -- ready to continue" + ); + assert_eq!( + Language::English.tr(TrKey::DetachedConsequence), + "Commits made here belong to no branch" + ); + assert_eq!( + Language::English.tr(TrKey::MenuCherryPick), + "Cherry-pick commit" + ); + assert_eq!( + Language::English.tr(TrKey::MyCommitsOff), + "Show only your commits. Click to filter by current user." + ); + assert_eq!( + Language::English.tr(TrKey::DisplaySettings), + "Display Settings" + ); + assert_eq!(Language::English.tr(TrKey::LoadMore), "Load more commits"); + assert_eq!(Language::English.tr(TrKey::PrDetail), "Pull Request Detail"); + assert_eq!(Language::English.tr(TrKey::GhAuthDesc), "This repository is private or rate-limited. Add a GitHub token in Settings — for organization repos you may need a fine-grained token approved by an org owner."); + assert_eq!( + Language::English.tr(TrKey::ReviewRequest), + "Request Changes" + ); + assert_eq!( + Language::English.tr(TrKey::TagCreateHint), + "Enter to create | Esc to cancel" + ); + assert_eq!( + Language::English.tr(TrKey::PrHint), + "Enter for a new line | Shift+Enter to create | Esc to cancel" + ); + assert_eq!( + Language::English.tr(TrKey::RebaseHintReorder), + "Ctrl+Up/Down Reorder" + ); + assert_eq!( + Language::English.tr(TrKey::BisectEmptyHint), + "Use 'Git: Bisect Start' to begin" + ); + assert_eq!( + Language::English.tr(TrKey::ThemeHint), + "Tab to move · Enter to save · Esc to close" + ); + assert_eq!( + Language::English.tr(TrKey::SearchHint), + "Press Enter to search | j/k to navigate" + ); + assert_eq!( + Language::English.tr(TrKey::CloneUrlPh), + "Repository URL (e.g. https://github.com/user/repo.git)" + ); + assert_eq!(Language::English.tr(TrKey::AuthProfilesTitle), "Profiles"); + assert_eq!( + Language::English.tr(TrKey::AuthProfilesDesc), + "All saved accounts live here. Select one to connect it, paste a token, or adjust host matching." + ); + assert_eq!( + Language::English.tr(TrKey::AuthProfilesEmptyTitle), + "No provider accounts configured yet." + ); + assert_eq!( + Language::English.tr(TrKey::AuthProfilesEmptyDesc), + "Add a GitHub account to start with the default flow, or choose Manual / Custom Host for enterprise and other HTTPS remotes." + ); + assert_eq!( + Language::English.tr(TrKey::AuthHttpsFallbackTitle), + "Fallback HTTPS Token / PAT" + ); + assert_eq!( + Language::English.tr(TrKey::AuthHttpsFallbackDesc), + "Used only when no profile-specific account matches the remote host. Leave this empty unless you truly need a generic fallback." + ); + assert_eq!( + Language::English.tr(TrKey::AuthSshOverrideTitle), + "SSH Key Override" + ); + assert_eq!( + Language::English.tr(TrKey::AuthSshOverrideDesc), + "Optional. If set, this key is tried first for SSH remotes. If SSH fails and you have a GitHub account above, rgitui automatically falls back to HTTPS." + ); + assert_eq!( + Language::English.tr(TrKey::AuthSignCommitsTitle), + "Sign Commits" + ); + assert_eq!( + Language::English.tr(TrKey::AuthSignCommitsDesc), + "Sign commits with your configured GPG key." + ); + assert_eq!(Language::English.tr(TrKey::AuthGpgKeyTitle), "GPG Key ID"); + assert_eq!( + Language::English.tr(TrKey::AuthGpgKeyDesc), + "Leave empty to use your existing git config default signing key." + ); + assert_eq!( + Language::English.tr(TrKey::AuthHowTitle), + "How Authentication Works" + ); + assert_eq!( + Language::English.tr(TrKey::AuthHowDesc), + "rgitui detects the remote URL protocol and authenticates accordingly." + ); + assert_eq!( + Language::English.tr(TrKey::AuthHowHttps), + "HTTPS remotes: uses your GitHub account token (above), or falls back to your system git credential helper (e.g. gh auth)." + ); + assert_eq!( + Language::English.tr(TrKey::AuthHowSshFallback), + "SSH remotes: if no SSH key is configured above, and your system has an HTTPS credential helper, rgitui automatically rewrites SSH to HTTPS so your GitHub login works." + ); + assert_eq!( + Language::English.tr(TrKey::AuthHowSshKey), + "SSH remotes with a key: uses the SSH key path above (or ssh-agent) directly." + ); + assert_eq!( + Language::English.tr(TrKey::AiStatusTitle), + "AI Commit Messages" + ); + assert_eq!(Language::English.tr(TrKey::AiEnabledToggle), "Enabled"); + assert_eq!( + Language::English.tr(TrKey::AiStatusOff), + "AI is turned off. Nothing will be sent to any provider." + ); + assert_eq!( + Language::English.tr(TrKey::AiStatusTestingFmt), + "Testing {}…" + ); + assert_eq!( + Language::English.tr(TrKey::AiStatusRejectedFmt), + "{} rejected this key." + ); + assert_eq!( + Language::English.tr(TrKey::AiStatusAddKeyFmt), + "Add a {} API key to get started." + ); + assert_eq!( + Language::English.tr(TrKey::AiConnectTitle), + "Connect an AI provider" + ); + assert_eq!( + Language::English.tr(TrKey::AiConnectDesc), + "rgitui writes commit messages from your staged diff." + ); + assert_eq!(Language::English.tr(TrKey::AiApiKeyLabel), "API key"); + assert_eq!(Language::English.tr(TrKey::AiGetKeyBtn), "Get a key"); + assert_eq!( + Language::English.tr(TrKey::AiKeyVerifiedFmt), + "Verified {} · stored in {}" + ); + assert_eq!( + Language::English.tr(TrKey::AiKeyStoredHintFmt), + "Key stored in {}. Test it to confirm it works." + ); + assert_eq!( + Language::English.tr(TrKey::AiNoKeyHintFmt), + "No API key needed — requests go to {}." + ); + assert_eq!( + Language::English.tr(TrKey::AiKeysStoredNoteFmt), + "Keys are stored in {}, never in settings.json, and are only read when a request is sent." + ); + assert_eq!(Language::English.tr(TrKey::AiModelLabel), "Model"); + assert_eq!( + Language::English.tr(TrKey::AiModelCountFmt), + "{} models · {}" + ); + assert_eq!(Language::English.tr(TrKey::AiRefreshBtn), "Refresh"); + assert_eq!(Language::English.tr(TrKey::AiRefreshingBtn), "Refreshing…"); + assert_eq!( + Language::English.tr(TrKey::AiModelMissingFmt), + "`{}` is not in {}'s current model list. It may have been retired." + ); + assert_eq!(Language::English.tr(TrKey::AiUseSuggestionFmt), "Use {}"); + assert_eq!(Language::English.tr(TrKey::AiConnectBtn), "Connect"); + assert_eq!( + Language::English.tr(TrKey::AiUseProviderBtn), + "Use this provider" + ); + assert_eq!(Language::English.tr(TrKey::AiTestBtn), "Test"); + assert_eq!(Language::English.tr(TrKey::AiTestingBtn), "Testing…"); + assert_eq!(Language::English.tr(TrKey::AiRemoveKeyBtn), "Remove key"); + assert_eq!(Language::English.tr(TrKey::AiAdvancedLabel), "Advanced"); + assert_eq!(Language::English.tr(TrKey::AiBaseUrlLabel), "Base URL"); + assert_eq!( + Language::English.tr(TrKey::AiBaseUrlOverriddenFmt), + "Requests go to {} instead of {}. Your API key is sent to that host." + ); + assert_eq!( + Language::English.tr(TrKey::AiBaseUrlEmptyFmt), + "Empty means use {}. Only OpenAI-compatible providers honour an override." + ); + assert_eq!( + Language::English.tr(TrKey::AiAttributionTitle), + "Send attribution headers" + ); + assert_eq!( + Language::English.tr(TrKey::AiAttributionDesc), + "Adds HTTP-Referer and X-Title so rgitui appears on OpenRouter's public leaderboard. Never functional." + ); + assert_eq!( + Language::English.tr(TrKey::AiCommitStyleTitle), + "Commit style" + ); + assert_eq!( + Language::English.tr(TrKey::AiCommitStyleDesc), + "How the AI should format commit messages." + ); + assert_eq!( + Language::English.tr(TrKey::AiCtxTitle), + "Include project context" + ); + assert_eq!( + Language::English.tr(TrKey::AiCtxDesc), + "Adds README.md, CLAUDE.md and AGENTS.md to the prompt. ~4k extra tokens per request." + ); + assert_eq!( + Language::English.tr(TrKey::AiToolsTitle), + "Let the model read files" + ); + assert_eq!( + Language::English.tr(TrKey::AiToolsDesc), + "The model may request file contents and commit history. Slower and more expensive; usually a better message." + ); + assert_eq!( + Language::English.tr(TrKey::AiKeyPlaceholderFmt), + "Paste your {} API key" + ); + // Git operation errors: English must stay byte-identical to the + // legacy `format!` strings they replace. + assert_eq!(Language::English.tr(TrKey::ErrStageFailed), "Stage failed"); assert_eq!( - AiProvider::from_id("Anthropic"), - Some(AiProvider::Anthropic) + Language::English.tr(TrKey::ErrUnstageFailed), + "Unstage failed" ); - assert_eq!(AiProvider::from_id(" OpenAI "), Some(AiProvider::OpenAi)); assert_eq!( - AiProvider::from_id("openrouter"), - Some(AiProvider::OpenRouter) + Language::English.tr(TrKey::ErrStageAllFailed), + "Stage all failed" ); - assert_eq!(AiProvider::from_id("bard"), None); - } - - #[test] - fn only_openai_compatible_providers_accept_a_base_url_override() { - assert!(AiProvider::OpenAi.is_openai_compatible()); - assert!(AiProvider::DeepSeek.is_openai_compatible()); - assert!(AiProvider::OpenRouter.is_openai_compatible()); - assert!(!AiProvider::Gemini.is_openai_compatible()); - assert!(!AiProvider::Anthropic.is_openai_compatible()); - } - - // ── AiSettings model memory ─────────────────────────────────── - - #[test] - fn switching_provider_preserves_each_providers_model_choice() { - let mut ai = AiSettings::default(); - ai.set_active_model("gemini-3.1-pro-preview"); - - ai.set_active_provider(AiProvider::OpenAi); - assert_eq!(ai.model, AiProvider::OpenAi.default_model()); - ai.set_active_model("gpt-5.4"); - - ai.set_active_provider(AiProvider::Gemini); - assert_eq!(ai.model, "gemini-3.1-pro-preview"); - - ai.set_active_provider(AiProvider::OpenAi); - assert_eq!(ai.model, "gpt-5.4"); - } - - #[test] - fn model_for_never_returns_another_providers_model() { - let mut ai = AiSettings::default(); - ai.set_active_model("gemini-3.1-pro-preview"); assert_eq!( - ai.model_for(AiProvider::Anthropic), - AiProvider::Anthropic.default_model() + Language::English.tr(TrKey::ErrUnstageAllFailed), + "Unstage all failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrCommitFailed), + "Commit failed" + ); + assert_eq!(Language::English.tr(TrKey::ErrAmendFailed), "Amend failed"); + assert_eq!( + Language::English.tr(TrKey::ErrCheckoutBranchFailedFmt), + "Checkout of '{}' failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrCheckoutCommitFailedFmt), + "Checkout of {} failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrCheckoutTagFailedFmt), + "Checkout of tag '{}' failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrBranchCreateFailedFmt), + "Branch '{}' could not be created" + ); + assert_eq!( + Language::English.tr(TrKey::ErrBranchDeleteFailedFmt), + "Delete branch '{}' failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrBranchRenameFailedFmt), + "Rename branch '{}' failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrTagCreateFailedFmt), + "Tag '{}' could not be created" + ); + assert_eq!( + Language::English.tr(TrKey::ErrTagDeleteFailedFmt), + "Delete tag '{}' failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrStashSaveFailed), + "Save stash failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrStashPopFailedFmt), + "Pop stash #{} failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrStashApplyFailedFmt), + "Apply stash #{} failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrStashDropFailedFmt), + "Drop stash #{} failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrStashBranchFailedFmt), + "Create branch from stash #{} failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrDiscardFailed), + "Discard changes failed" + ); + assert_eq!(Language::English.tr(TrKey::ErrCleanFailed), "Clean failed"); + assert_eq!( + Language::English.tr(TrKey::ErrResetHeadFailed), + "Reset to HEAD failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrResetFailedFmt), + "Reset to {} failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrResetSoftFailedFmt), + "Soft reset to {} failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrResetMixedFailedFmt), + "Mixed reset to {} failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrRevertConflictFmt), + "Revert of {} needs conflict resolution" + ); + assert_eq!( + Language::English.tr(TrKey::ErrRevertFailedFmt), + "Revert of {} failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrRevertGuide), + "Resolve the conflicts in the working tree, then commit the revert manually." + ); + assert_eq!( + Language::English.tr(TrKey::ErrCherryPickConflictFmt), + "Cherry-pick of {} needs conflict resolution" + ); + assert_eq!( + Language::English.tr(TrKey::ErrCherryPickFailedFmt), + "Cherry-pick of {} failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrCherryPickGuide), + "Resolve the conflicts in the working tree, then commit the cherry-pick manually." + ); + assert_eq!( + Language::English.tr(TrKey::ErrAbortFailedFmt), + "Failed to abort {}" + ); + assert_eq!( + Language::English.tr(TrKey::ErrContinueFailedFmt), + "Could not continue {}" + ); + assert_eq!( + Language::English.tr(TrKey::ErrMergeConflictFmt), + "Merge conflicts in '{}'" + ); + assert_eq!( + Language::English.tr(TrKey::ErrMergeFailedFmt), + "Merge of '{}' failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrRemoveRemoteFailed), + "Removing remote failed" + ); + assert_eq!(Language::English.tr(TrKey::ErrCloneFailed), "Clone failed"); + assert_eq!( + Language::English.tr(TrKey::ErrBisectStartFailed), + "Failed to start bisect" + ); + assert_eq!( + Language::English.tr(TrKey::ErrBisectGoodFailedFmt), + "Failed to mark {} as good" + ); + assert_eq!( + Language::English.tr(TrKey::ErrBisectBadFailedFmt), + "Failed to mark {} as bad" + ); + assert_eq!( + Language::English.tr(TrKey::ErrBisectExhausted), + "Bisect exhausted" + ); + assert_eq!( + Language::English.tr(TrKey::ErrBisectSkipFailedFmt), + "Failed to skip {}" + ); + assert_eq!( + Language::English.tr(TrKey::ErrBisectResetFailed), + "Failed to reset bisect" + ); + assert_eq!( + Language::English.tr(TrKey::ErrWorktreeCreateFailedFmt), + "Create worktree '{}' failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrWorktreeRemoveFailedFmt), + "Remove worktree '{}' failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrFetchFailedFmt), + "Fetch from '{}' failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrPullConflictFmt), + "Pull from '{}' has conflicts" + ); + assert_eq!( + Language::English.tr(TrKey::ErrPullFailedFmt), + "Pull from '{}' failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrPushFailedFmt), + "Push to '{}' failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrFetchNotStarted), + "Fetch could not start" + ); + assert_eq!( + Language::English.tr(TrKey::ErrPullNotStarted), + "Pull could not start" + ); + assert_eq!( + Language::English.tr(TrKey::ErrPushNotStarted), + "Push could not start" + ); + assert_eq!( + Language::English.tr(TrKey::ErrRebasePaused), + "Rebase paused due to conflicts" + ); + assert_eq!( + Language::English.tr(TrKey::ErrRebaseFailed), + "Interactive rebase failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrStageHunkFailed), + "Stage hunk failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrUnstageHunkFailed), + "Unstage hunk failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrStageLinesFailed), + "Stage lines failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrUnstageLinesFailed), + "Unstage lines failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrConflictResolveFailed), + "Conflict resolution failed" + ); + assert_eq!( + Language::English.tr(TrKey::ErrAlreadyOnBranchFmt), + "Already on branch '{}'." + ); + assert_eq!(Language::English.tr(TrKey::ErrCleanWorktreeFmt), "{} requires a clean working tree. Commit, stash, or discard your changes to tracked files first."); + assert_eq!( + Language::English.tr(TrKey::ErrHeadDetached), + "HEAD is detached. Switch to a branch before running this operation." + ); + assert_eq!( + Language::English.tr(TrKey::ErrBranchNameUnknown), + "Failed to determine the current branch name" + ); + assert_eq!( + Language::English.tr(TrKey::ErrNoRemotes), + "No remotes configured. Add one with: git remote add origin " + ); + assert_eq!( + Language::English.tr(TrKey::ErrNoUsableRemotes), + "No usable git remotes are configured." + ); + assert_eq!( + Language::English.tr(TrKey::ErrNoStagedChanges), + "There are no staged changes to commit." + ); + assert_eq!( + Language::English.tr(TrKey::ErrCannotAmendRebase), + "Cannot amend during a rebase. Continue or abort the rebase first." + ); + assert_eq!( + Language::English.tr(TrKey::ErrOverwriteLocalFmt), + "{} would overwrite local changes. Commit, stash, or discard them first." + ); + assert_eq!( + Language::English.tr(TrKey::ErrOverwritePathOneFmt), + "{} would overwrite {}. Commit, stash, move, or delete it first." + ); + assert_eq!( + Language::English.tr(TrKey::ErrOverwritePathManyFmt), + "{} would overwrite {}. Commit, stash, move, or delete them first." + ); + assert_eq!( + Language::English.tr(TrKey::ErrStagingConflictFmt), + "'{}' has unresolved conflicts. Open the conflict resolver before staging it." + ); + assert_eq!(Language::English.tr(TrKey::ErrBranchNotFoundFmt), "Branch '{}' not found as a local or remote branch. Try fetching to update remote refs."); + assert_eq!( + Language::English.tr(TrKey::ErrLocalBranchExistsFmt), + "A local branch named '{}' already exists. Please delete or rename it first." + ); + assert_eq!( + Language::English.tr(TrKey::ErrInvalidRemoteBranchFmt), + "Invalid remote branch name '{}'. Expected 'remote/branch' format." + ); + assert_eq!( + Language::English.tr(TrKey::ErrNotMergeState), + "Repository is not in a merge state (no MERGE_HEAD to continue)." + ); + assert_eq!( + Language::English.tr(TrKey::ErrUnresolvedConflicts), + "There are still unresolved conflicts. Resolve all conflicts before continuing." + ); + assert_eq!( + Language::English.tr(TrKey::ErrNothingToContinueFmt), + "There is no {} to continue." + ); + assert_eq!( + Language::English.tr(TrKey::ErrNoRebaseEntries), + "No entries provided for interactive rebase" + ); + assert_eq!(Language::English.tr(TrKey::ErrRebasePlanMismatchFmt), "Interactive rebase plan does not match the current branch's history (the selected commits are not exactly the last {} first-parent commits of HEAD). Refresh and try again."); + assert_eq!( + Language::English.tr(TrKey::ErrStashIndexOutOfRangeFmt), + "Stash index {} out of range" + ); + assert_eq!( + Language::English.tr(TrKey::ErrBaseNotCommitFmt), + "'{}' does not resolve to a commit" ); } #[test] - fn key_flags_are_tracked_per_provider() { - let mut ai = AiSettings::default(); - ai.set_has_key_for(AiProvider::Gemini, true); - assert!(ai.has_key_for(AiProvider::Gemini)); - assert!(ai.has_api_key, "the active provider mirror must follow"); - assert!(!ai.has_key_for(AiProvider::Anthropic)); - - // Switching to a provider with no key must not keep asserting - // "connected" — that false state is what enabled the AI button for a - // provider the app had no credential for. - ai.set_active_provider(AiProvider::Anthropic); - assert!(!ai.has_api_key); - } - - // ── settings file compatibility ─────────────────────────────── - - #[test] - fn v2_settings_load_with_every_new_field_defaulted() { - let json = r#"{ - "version": 2, - "ai": { "provider": "openai", "model": "gpt-5.4", "has_api_key": true } - }"#; - let settings: AppSettings = serde_json::from_str(json).unwrap(); - assert_eq!(settings.ai.provider, AiProvider::OpenAi); - assert_eq!(settings.ai.model, "gpt-5.4"); - assert!(settings.ai.has_api_key_for.is_empty()); - assert!(settings.ai.models_by_provider.is_empty()); - assert!(settings.ai.base_url_override.is_empty()); - assert!(settings.ai.openrouter_attribution); - } - - #[test] - fn an_unknown_provider_falls_back_instead_of_failing_the_file() { - let json = r#"{ "version": 3, "ai": { "provider": "bard" } }"#; - let settings: AppSettings = serde_json::from_str(json).unwrap(); - assert_eq!(settings.ai.provider, AiProvider::default()); - // And the raw value is still recoverable, which is what lets `init` - // tell the user which id it did not understand. - assert_eq!(raw_ai_provider(json).as_deref(), Some("bard")); - } - - #[test] - fn a_miscased_provider_resolves_rather_than_falling_back() { - let json = r#"{ "ai": { "provider": "Anthropic" } }"#; - let settings: AppSettings = serde_json::from_str(json).unwrap(); - assert_eq!(settings.ai.provider, AiProvider::Anthropic); - } - - #[test] - fn the_api_key_never_reaches_the_settings_file() { - let mut settings = AppSettings::default(); - settings.ai.legacy_api_key = Some("sk-should-never-be-written".into()); - let json = serde_json::to_string(&settings).unwrap(); - assert!(!json.contains("sk-should-never-be-written")); - } + fn v3_settings_default_to_english_and_migrate_to_v4() { + let settings: AppSettings = + serde_json::from_str(r#"{ "version": 3 }"#).expect("v3 file parses"); + assert_eq!(settings.language, Language::English); - #[test] - fn v2_to_v3_remaps_retired_model_ids() { let mut state = test_settings_state(); - state.settings.version = 2; - state.settings.ai.model = "gemini-2.0-flash".into(); - + state.settings.version = 3; assert!(state.migrate_settings()); - assert_eq!(state.settings.version, CURRENT_SETTINGS_VERSION); - assert_eq!(state.settings.ai.model, "gemini-3.1-flash-lite"); - } - - #[test] - fn v2_to_v3_leaves_a_live_model_alone() { - let mut state = test_settings_state(); - state.settings.version = 2; - state.settings.ai.model = "gemini-2.5-pro".into(); - - state.migrate_settings(); - - assert_eq!(state.settings.ai.model, "gemini-2.5-pro"); + assert_eq!(state.settings.language, Language::English); } #[test] diff --git a/crates/rgitui_workspace/src/bisect_view.rs b/crates/rgitui_workspace/src/bisect_view.rs index cdcb10e..e16adc6 100644 --- a/crates/rgitui_workspace/src/bisect_view.rs +++ b/crates/rgitui_workspace/src/bisect_view.rs @@ -8,7 +8,7 @@ use gpui::{ ScrollStrategy, SharedString, Styled, UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::{BisectDecision, BisectLogEntry}; -use rgitui_settings::SettingsState; +use rgitui_settings::{SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; @@ -46,6 +46,10 @@ impl EventEmitter for BisectView {} impl BisectView { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); Self { entries: Arc::new(Vec::new()), scroll_handle: UniformListScrollHandle::new(), @@ -136,6 +140,7 @@ impl BisectView { } fn render_header(&self, cx: &mut Context, count: usize) -> gpui::Div { + let language = cx.global::().settings().language; let colors = cx.colors(); let (good, bad, skip, start) = self.decision_counts(); @@ -164,14 +169,14 @@ impl BisectView { .color(Color::Accent), ) .child( - Label::new("Bisect") + Label::new(language.tr(TrKey::BisectTitle)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), ) .when(start > 0, |el| { el.child( - Label::new("started") + Label::new(language.tr(TrKey::BisectStarted)) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -179,45 +184,66 @@ impl BisectView { .child(div().flex_1()) // Bad count .child( - Label::new(format!("{} bad", bad)) - .size(LabelSize::XSmall) - .color(if bad > 0 { Color::Error } else { Color::Muted }), + Label::new( + language + .tr(TrKey::BisectBadFmt) + .replacen("{}", &bad.to_string(), 1), + ) + .size(LabelSize::XSmall) + .color(if bad > 0 { Color::Error } else { Color::Muted }), ) // Good count .child( - Label::new(format!("{} good", good)) - .size(LabelSize::XSmall) - .color(if good > 0 { - Color::Success - } else { - Color::Muted - }), + Label::new( + language + .tr(TrKey::BisectGoodFmt) + .replacen("{}", &good.to_string(), 1), + ) + .size(LabelSize::XSmall) + .color(if good > 0 { + Color::Success + } else { + Color::Muted + }), ) // Skip count .when(skip > 0, |el| { el.child( - Label::new(format!("{} skip", skip)) - .size(LabelSize::XSmall) - .color(Color::Warning), + Label::new(language.tr(TrKey::BisectSkipFmt).replacen( + "{}", + &skip.to_string(), + 1, + )) + .size(LabelSize::XSmall) + .color(Color::Warning), ) }) // Remaining steps .when(remaining > 0, |el| { el.child( - Label::new(format!("~{} steps left", remaining)) - .size(LabelSize::XSmall) - .color(Color::Accent), + Label::new(language.tr(TrKey::BisectLeftFmt).replacen( + "{}", + &remaining.to_string(), + 1, + )) + .size(LabelSize::XSmall) + .color(Color::Accent), ) }) // Entry count .child( - Label::new(format!("{} entries", count)) - .size(LabelSize::XSmall) - .color(Color::Placeholder), + Label::new(language.tr(TrKey::BisectEntriesFmt).replacen( + "{}", + &count.to_string(), + 1, + )) + .size(LabelSize::XSmall) + .color(Color::Placeholder), ) } fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { + let language = cx.global::().settings().language; let editor_bg = cx.colors().editor_background; let ghost_bg = cx.colors().ghost_element_background; div() @@ -242,12 +268,12 @@ impl BisectView { .color(Color::Placeholder), ) .child( - Label::new("No bisect in progress") + Label::new(language.tr(TrKey::BisectEmpty)) .size(LabelSize::Small) .color(Color::Muted), ) .child( - Label::new("Use 'Git: Bisect Start' to begin") + Label::new(language.tr(TrKey::BisectEmptyHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), diff --git a/crates/rgitui_workspace/src/blame_view.rs b/crates/rgitui_workspace/src/blame_view.rs index d8b7bd2..1556883 100644 --- a/crates/rgitui_workspace/src/blame_view.rs +++ b/crates/rgitui_workspace/src/blame_view.rs @@ -9,7 +9,7 @@ use gpui::{ UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::BlameLine; -use rgitui_settings::SettingsState; +use rgitui_settings::{SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; @@ -40,6 +40,10 @@ impl EventEmitter for BlameView {} impl BlameView { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); Self { lines: Arc::new(Vec::new()), file_path: None, @@ -143,6 +147,7 @@ impl BlameView { } fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { + let language = cx.global::().settings().language; let colors = cx.colors(); div() @@ -167,7 +172,7 @@ impl BlameView { .color(Color::Muted), ) .child( - Label::new("Blame") + Label::new(language.tr(TrKey::BottomBlame)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -189,12 +194,12 @@ impl BlameView { .color(Color::Placeholder), ) .child( - Label::new("Select a file to view blame") + Label::new(language.tr(TrKey::BlameEmpty)) .size(LabelSize::Small) .color(Color::Muted), ) .child( - Label::new("Press 'b' on a file to see line-by-line attribution") + Label::new(language.tr(TrKey::BlameEmptyHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -207,6 +212,7 @@ impl BlameView { impl Render for BlameView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); + let language = cx.global::().settings().language; if self.lines.is_empty() { return self.render_empty_state(cx); @@ -462,7 +468,7 @@ impl Render for BlameView { .color(Color::Accent), ) .child( - Label::new("Blame") + Label::new(language.tr(TrKey::BottomBlame)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), diff --git a/crates/rgitui_workspace/src/branch_dialog.rs b/crates/rgitui_workspace/src/branch_dialog.rs index b4cb38b..271ebca 100644 --- a/crates/rgitui_workspace/src/branch_dialog.rs +++ b/crates/rgitui_workspace/src/branch_dialog.rs @@ -10,6 +10,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; /// Events emitted by the branch creation dialog. #[derive(Debug, Clone)] @@ -35,9 +36,17 @@ impl EventEmitter for BranchDialog {} impl BranchDialog { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Enter branch name..."); + ti.set_placeholder(language.tr(TrKey::BranchNamePh)); ti }); cx.subscribe( @@ -185,6 +194,14 @@ impl Render for BranchDialog { } let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); + self.editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::BranchNamePh)); + }); + let branch_name = self.editor.read(cx).text().to_string(); let has_error = self.error_message.is_some(); let can_create = !branch_name.is_empty() && !has_error; @@ -232,7 +249,7 @@ impl Render for BranchDialog { ), ) .child( - Label::new("Create Branch") + Label::new(language.tr(TrKey::BranchTitle)) .size(LabelSize::Large) .weight(gpui::FontWeight::BOLD), ), @@ -243,7 +260,7 @@ impl Render for BranchDialog { .v_flex() .gap(px(6.)) .child( - Label::new("Branch name") + Label::new(language.tr(TrKey::BranchNameLabel)) .size(LabelSize::Small) .weight(gpui::FontWeight::MEDIUM) .color(Color::Muted), @@ -268,7 +285,7 @@ impl Render for BranchDialog { .gap(px(8.)) .items_center() .child( - Label::new("Based on") + Label::new(language.tr(TrKey::BranchBasedOn)) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -326,7 +343,7 @@ impl Render for BranchDialog { // dialog-footer component (parameterised by verb/modifier) so all // dialogs share one style; cross-file refactor, deferred. .child( - Label::new("Enter to create | Esc to cancel") + Label::new(language.tr(TrKey::TagCreateHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ) @@ -338,7 +355,7 @@ impl Render for BranchDialog { .justify_end() .w_full() .child( - Button::new("cancel-branch", "Cancel") + Button::new("cancel-branch", language.tr(TrKey::CancelBtn)) .size(ButtonSize::Default) .style(ButtonStyle::Subtle) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -346,7 +363,7 @@ impl Render for BranchDialog { })), ) .child( - Button::new("create-branch", "Create Branch") + Button::new("create-branch", language.tr(TrKey::BranchTitle)) .icon(IconName::GitBranch) .size(ButtonSize::Default) .style(ButtonStyle::Filled) diff --git a/crates/rgitui_workspace/src/branch_health_panel.rs b/crates/rgitui_workspace/src/branch_health_panel.rs index dbf39a5..2843b8d 100644 --- a/crates/rgitui_workspace/src/branch_health_panel.rs +++ b/crates/rgitui_workspace/src/branch_health_panel.rs @@ -13,6 +13,7 @@ use gpui::{ UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::{BranchInfo, GitProject, GitProjectEvent}; +use rgitui_settings::{SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Badge, Icon, IconName, IconSize, Label, LabelSize}; @@ -52,6 +53,10 @@ pub struct BranchHealthPanel { impl BranchHealthPanel { pub fn new(cx: &mut Context, project: WeakEntity) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); let branches = project .upgrade() .map(|proj| proj.read(cx).branches().to_vec()) @@ -134,6 +139,7 @@ impl BranchHealthPanel { impl Render for BranchHealthPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); + let language = cx.global::().settings().language; let panel_bg = colors.panel_background; let (total, unmerged, stale, diverged) = self.stats; @@ -160,10 +166,20 @@ impl Render for BranchHealthPanel { .py_2() .gap_2() .flex_wrap() - .child(self.stat_badge("Total", total, Color::Default, cx)) - .child(self.stat_badge("Unmerged", unmerged, Color::Warning, cx)) - .child(self.stat_badge("Stale", stale, Color::Muted, cx)) - .child(self.stat_badge("Diverged", diverged, Color::Accent, cx)), + .child(self.stat_badge(language.tr(TrKey::BhTotal), total, Color::Default, cx)) + .child(self.stat_badge( + language.tr(TrKey::BhUnmerged), + unmerged, + Color::Warning, + cx, + )) + .child(self.stat_badge(language.tr(TrKey::BhStale), stale, Color::Muted, cx)) + .child(self.stat_badge( + language.tr(TrKey::BhDiverged), + diverged, + Color::Accent, + cx, + )), ) .child( div() @@ -233,17 +249,25 @@ impl Render for BranchHealthPanel { // Merged badge if !is_head { if is_merged == Some(true) { - row = row.child(Badge::new("Merged").color(Color::Muted)); + row = row.child( + Badge::new(language.tr(TrKey::BhMerged)) + .color(Color::Muted), + ); } else if is_merged == Some(false) { - row = - row.child(Badge::new("Unmerged").color(Color::Warning)); + row = row.child( + Badge::new(language.tr(TrKey::BhUnmerged)) + .color(Color::Warning), + ); } } // Stale badge if let Some(time) = last_time { if (now - time) > STALE_SECONDS && !is_head { - row = row.child(Badge::new("Stale").color(Color::Muted)); + row = row.child( + Badge::new(language.tr(TrKey::BhStale)) + .color(Color::Muted), + ); } } @@ -377,6 +401,7 @@ fn compute_branch_stats(branches: &[BranchInfo]) -> (usize, usize, usize, usize) impl BranchHealthPanel { fn render_toolbar(&self, cx: &mut Context) -> gpui::AnyElement { + let language = cx.global::().settings().language; let colors = cx.colors(); let filters = vec![ BranchHealthFilter::All, @@ -396,7 +421,14 @@ impl BranchHealthPanel { .border_color(colors.border_variant) .children(filters.into_iter().map(|f| { let active = self.filter == f; - let label: SharedString = f.label().into(); + let label: SharedString = language + .tr(match f { + BranchHealthFilter::All => TrKey::FilterAll, + BranchHealthFilter::Unmerged => TrKey::BhUnmerged, + BranchHealthFilter::Stale => TrKey::BhStale, + BranchHealthFilter::Diverged => TrKey::BhDiverged, + }) + .into(); let filter_name = f.label(); div() .id(ElementId::Name(format!("bh-filter-{}", filter_name).into())) diff --git a/crates/rgitui_workspace/src/commit_panel.rs b/crates/rgitui_workspace/src/commit_panel.rs index ece766d..f487803 100644 --- a/crates/rgitui_workspace/src/commit_panel.rs +++ b/crates/rgitui_workspace/src/commit_panel.rs @@ -4,6 +4,7 @@ use gpui::{ SharedString, Window, }; use rgitui_ai::CommitStyle; +use rgitui_settings::{Language, SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Button, ButtonSize, ButtonStyle, CheckState, Checkbox, IconButton, IconName, Label, LabelSize, @@ -173,25 +174,35 @@ impl EventEmitter for CommitPanel {} impl CommitPanel { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + // Interface language for the placeholders below; the editors are + // created once, so `render` re-applies them on every language change. + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let summary_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Commit summary..."); + ti.set_placeholder(language.tr(TrKey::CommitSummaryPh)); ti }); let description_editor = cx.new(|cx| { let mut ti = TextInput::new(cx).multiline(); - ti.set_placeholder("Optional extended description..."); + ti.set_placeholder(language.tr(TrKey::CommitDescPh)); ti.set_font_size(px(12.0)); ti }); let new_author_name = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Co-author name..."); + ti.set_placeholder(language.tr(TrKey::CoAuthorNamePh)); ti }); let new_author_email = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Co-author email..."); + ti.set_placeholder(language.tr(TrKey::CoAuthorEmailPh)); ti }); @@ -417,15 +428,15 @@ impl CommitPanel { self.focus_handle.contains_focused(window, cx) } - fn commit_button_label(&self, summary_empty: bool) -> &'static str { + fn commit_button_label(&self, summary_empty: bool, language: Language) -> &'static str { if self.staged_count == 0 { - "No Staged Changes" + language.tr(TrKey::CommitNoStaged) } else if summary_empty { - "No Message" + language.tr(TrKey::CommitNoMessage) } else if self.amend { - "Amend Commit" + language.tr(TrKey::CommitAmendBtn) } else { - "Commit" + language.tr(TrKey::CommitBtn) } } @@ -714,6 +725,25 @@ impl CommitPanel { impl Render for CommitPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|state| state.settings().language) + .unwrap_or_default(); + // Keep the placeholders in the active language: the editors are + // created once in `new`, so without this they would freeze in the + // language that was active at startup. + self.summary_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::CommitSummaryPh)); + }); + self.description_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::CommitDescPh)); + }); + self.new_author_name.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::CoAuthorNamePh)); + }); + self.new_author_email.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::CoAuthorEmailPh)); + }); let summary_empty = self.summary_editor.read(cx).is_empty(); let can_commit = !summary_empty && self.staged_count > 0; let summary_len = self.summary_editor.read(cx).text().chars().count(); @@ -725,14 +755,15 @@ impl Render for CommitPanel { }; let staged_label: SharedString = if self.staged_count > 0 { - format!( - "{} file{} staged", - self.staged_count, - if self.staged_count == 1 { "" } else { "s" } - ) + (if self.staged_count == 1 { + language.tr(TrKey::StagedOneFmt) + } else { + language.tr(TrKey::StagedManyFmt) + }) + .replacen("{}", &self.staged_count.to_string(), 1) .into() } else { - "No files staged".into() + language.tr(TrKey::StagedNone).into() }; // Conventional commits: 50 chars is the recommended limit, 72 is the hard wrap limit. @@ -747,19 +778,21 @@ impl Render for CommitPanel { }; let desc_count_label: SharedString = if description_len > 0 { - format!( - "{} char{}, {} line{}", - description_len, - if description_len == 1 { "" } else { "s" }, - description_lines, - if description_lines == 1 { "" } else { "s" }, - ) - .into() + let template = match (description_len == 1, description_lines == 1) { + (true, true) => language.tr(TrKey::DescCount11), + (true, false) => language.tr(TrKey::DescCount1N), + (false, true) => language.tr(TrKey::DescCountN1), + (false, false) => language.tr(TrKey::DescCountNN), + }; + template + .replacen("{}", &description_len.to_string(), 1) + .replacen("{}", &description_lines.to_string(), 1) + .into() } else { SharedString::default() }; - let commit_label = self.commit_button_label(summary_empty); + let commit_label = self.commit_button_label(summary_empty, language); // `has_ai_api_key()` reads the cached flag. The old // `ai_api_key().is_some()` deep-cloned the AI key, the HTTPS token and @@ -822,9 +855,9 @@ impl Render for CommitPanel { .size(ButtonSize::Compact) .color(Color::Muted) .tooltip(if self.collapsed { - "Expand commit panel" + language.tr(TrKey::TipExpandCommit) } else { - "Collapse commit panel" + language.tr(TrKey::TipCollapseCommit) }) .on_click(cx.listener( |this, _: &ClickEvent, _, cx| { @@ -834,7 +867,7 @@ impl Render for CommitPanel { )), ) .child( - Label::new("Commit") + Label::new(language.tr(TrKey::CommitBtn)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -893,7 +926,7 @@ impl Render for CommitPanel { .h_flex() .items_center() .child( - Label::new("Summary") + Label::new(language.tr(TrKey::SummaryLabel)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::MEDIUM), @@ -920,7 +953,7 @@ impl Render for CommitPanel { .h_flex() .items_center() .child( - Label::new("Description") + Label::new(language.tr(TrKey::DescLabel)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::MEDIUM), @@ -951,7 +984,7 @@ impl Render for CommitPanel { .w_full() .items_center() .child( - Label::new("Co-Authors") + Label::new(language.tr(TrKey::CoAuthorsLabel)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::MEDIUM), @@ -1120,7 +1153,7 @@ impl Render for CommitPanel { }, )) .child( - Label::new("Amend") + Label::new(language.tr(TrKey::AmendBtn)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -1129,7 +1162,7 @@ impl Render for CommitPanel { !summary_empty || !self.description_editor.read(cx).is_empty(), |el| { el.child( - Button::new("clear-btn", "Clear") + Button::new("clear-btn", language.tr(TrKey::ClearBtn)) .icon(IconName::X) .size(ButtonSize::Compact) .style(ButtonStyle::Subtle) @@ -1155,7 +1188,7 @@ impl Render for CommitPanel { .child(div().flex_1()) .when(self.staged_count == 0, |el| { el.child( - Label::new("No staged changes") + Label::new(language.tr(TrKey::NoStagedHint)) .size(LabelSize::XSmall) .color(Color::Warning), ) diff --git a/crates/rgitui_workspace/src/confirm_dialog.rs b/crates/rgitui_workspace/src/confirm_dialog.rs index 63d6f0a..fcc6eb2 100644 --- a/crates/rgitui_workspace/src/confirm_dialog.rs +++ b/crates/rgitui_workspace/src/confirm_dialog.rs @@ -1,5 +1,6 @@ use gpui::prelude::*; use gpui::{div, px, ClickEvent, Context, EventEmitter, FocusHandle, Render, SharedString, Window}; +use rgitui_settings::{SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Button, ButtonSize, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize}; @@ -45,6 +46,10 @@ impl EventEmitter for ConfirmDialog {} impl ConfirmDialog { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); Self { visible: false, title: String::new(), @@ -150,21 +155,25 @@ impl ConfirmDialog { } } - fn confirm_label(&self) -> &'static str { - match &self.action { - Some(ConfirmAction::DiscardFile(_) | ConfirmAction::DiscardAll) => "Discard", - Some(ConfirmAction::CleanUntracked) => "Clean", - Some(ConfirmAction::BranchDelete(_)) => "Delete Branch", - Some(ConfirmAction::TagDelete(_)) => "Delete Tag", - Some(ConfirmAction::RemoveRemote(_)) => "Remove", - Some(ConfirmAction::StashDrop(_)) => "Drop Stash", - Some(ConfirmAction::ResetHard(_)) => "Reset", - Some(ConfirmAction::ResetSoft(_)) => "Reset", - Some(ConfirmAction::ResetMixed(_)) => "Reset", - Some(ConfirmAction::AbortMerge) => "Abort", - Some(ConfirmAction::ForcePush) => "Force Push", - Some(ConfirmAction::WorktreeRemove(_)) => "Remove Worktree", - None => "Confirm", + /// The [`TrKey`] for an action's confirm button, so it renders through + /// the active language. English stays the source of truth for the literal + /// text: `confirm_tr_keys_cover_every_action_in_english` below locks each + /// mapping, and `rgitui_settings` locks the literals themselves. + fn confirm_tr_key(action: &Option) -> TrKey { + match action { + Some(ConfirmAction::DiscardFile(_) | ConfirmAction::DiscardAll) => TrKey::CfDiscard, + Some(ConfirmAction::CleanUntracked) => TrKey::CfClean, + Some(ConfirmAction::BranchDelete(_)) => TrKey::CfDeleteBranch, + Some(ConfirmAction::TagDelete(_)) => TrKey::CfDeleteTag, + Some(ConfirmAction::RemoveRemote(_)) => TrKey::CfRemove, + Some(ConfirmAction::StashDrop(_)) => TrKey::CfDropStash, + Some(ConfirmAction::ResetHard(_)) => TrKey::CfReset, + Some(ConfirmAction::ResetSoft(_)) => TrKey::CfReset, + Some(ConfirmAction::ResetMixed(_)) => TrKey::CfReset, + Some(ConfirmAction::AbortMerge) => TrKey::CfAbort, + Some(ConfirmAction::ForcePush) => TrKey::CfForcePush, + Some(ConfirmAction::WorktreeRemove(_)) => TrKey::CfRemoveWorktree, + None => TrKey::CfConfirm, } } } @@ -181,11 +190,12 @@ impl Render for ConfirmDialog { } let colors = cx.colors().clone(); + let language = cx.global::().settings().language; let title: SharedString = self.title.clone().into(); let message: SharedString = self.message.clone().into(); let icon = self.severity_icon(); let color = self.severity_color(); - let confirm_label = self.confirm_label(); + let confirm_label = language.tr(Self::confirm_tr_key(&self.action)); let is_destructive = self.is_destructive(); let icon_bg = color.color(cx); @@ -268,7 +278,7 @@ impl Render for ConfirmDialog { .w_full() .gap_2() .child( - Label::new("Enter to confirm | Esc to cancel") + Label::new(language.tr(TrKey::ConfirmHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ) @@ -280,14 +290,17 @@ impl Render for ConfirmDialog { .justify_end() .w_full() .child( - Button::new("confirm-cancel", "Cancel") - .size(ButtonSize::Default) - .style(ButtonStyle::Subtle) - .on_click(cx.listener( - |this, _: &ClickEvent, _, cx| { - this.cancel(cx); - }, - )), + Button::new( + "confirm-cancel", + language.tr(TrKey::CancelBtn), + ) + .size(ButtonSize::Default) + .style(ButtonStyle::Subtle) + .on_click( + cx.listener(|this, _: &ClickEvent, _, cx| { + this.cancel(cx); + }), + ), ) .child( Button::new("confirm-ok", confirm_label) @@ -392,33 +405,38 @@ mod tests { assert!(!check_destructive(&ConfirmAction::ForcePush)); } - // confirm_label + // confirm_tr_key #[test] - fn confirm_label_matches_action() { - fn label_for(action: &ConfirmAction) -> &'static str { - match action { - ConfirmAction::DiscardFile(_) | ConfirmAction::DiscardAll => "Discard", - ConfirmAction::CleanUntracked => "Clean", - ConfirmAction::BranchDelete(_) => "Delete Branch", - ConfirmAction::TagDelete(_) => "Delete Tag", - ConfirmAction::RemoveRemote(_) => "Remove", - ConfirmAction::StashDrop(_) => "Drop Stash", - ConfirmAction::ResetHard(_) - | ConfirmAction::ResetSoft(_) - | ConfirmAction::ResetMixed(_) => "Reset", - ConfirmAction::AbortMerge => "Abort", - ConfirmAction::ForcePush => "Force Push", - ConfirmAction::WorktreeRemove(_) => "Remove Worktree", - } + fn confirm_tr_keys_cover_every_action_in_english() { + use rgitui_settings::Language; + let cases: Vec<(Option, &'static str)> = vec![ + (Some(ConfirmAction::DiscardFile("a".into())), "Discard"), + (Some(ConfirmAction::DiscardAll), "Discard"), + (Some(ConfirmAction::CleanUntracked), "Clean"), + ( + Some(ConfirmAction::BranchDelete("x".into())), + "Delete Branch", + ), + (Some(ConfirmAction::TagDelete("v".into())), "Delete Tag"), + (Some(ConfirmAction::RemoveRemote("o".into())), "Remove"), + (Some(ConfirmAction::StashDrop(0)), "Drop Stash"), + (Some(ConfirmAction::ResetHard("HEAD".into())), "Reset"), + (Some(ConfirmAction::ResetSoft("HEAD".into())), "Reset"), + (Some(ConfirmAction::ResetMixed("HEAD".into())), "Reset"), + (Some(ConfirmAction::AbortMerge), "Abort"), + (Some(ConfirmAction::ForcePush), "Force Push"), + ( + Some(ConfirmAction::WorktreeRemove("/tmp/wt".into())), + "Remove Worktree", + ), + (None, "Confirm"), + ]; + for (action, expected) in cases { + let key = ConfirmDialog::confirm_tr_key(&action); + assert_eq!(Language::English.tr(key), expected); + assert!(!Language::SimplifiedChinese.tr(key).is_empty()); } - assert_eq!(label_for(&ConfirmAction::StashDrop(0)), "Drop Stash"); - assert_eq!( - label_for(&ConfirmAction::BranchDelete("x".into())), - "Delete Branch" - ); - assert_eq!(label_for(&ConfirmAction::ForcePush), "Force Push"); - assert_eq!(label_for(&ConfirmAction::ResetHard("HEAD".into())), "Reset"); } // severity_icon diff --git a/crates/rgitui_workspace/src/create_pr_dialog.rs b/crates/rgitui_workspace/src/create_pr_dialog.rs index 4009af1..54f40eb 100644 --- a/crates/rgitui_workspace/src/create_pr_dialog.rs +++ b/crates/rgitui_workspace/src/create_pr_dialog.rs @@ -15,6 +15,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; /// Events emitted by the PR creation dialog. #[derive(Debug, Clone)] @@ -51,15 +52,23 @@ impl EventEmitter for CreatePrDialog {} impl CreatePrDialog { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let title_input = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Pull request title"); + ti.set_placeholder(language.tr(TrKey::PrTitlePh)); ti }); let body_input = cx.new(|cx| { let mut ti = TextInput::new(cx).multiline(); - ti.set_placeholder("Add a description (optional)"); + ti.set_placeholder(language.tr(TrKey::PrDescPh)); ti }); @@ -261,6 +270,16 @@ impl Render for CreatePrDialog { } let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); + self.title_input.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::PrTitlePh)); + }); + self.body_input.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::PrDescPh)); + }); let head_label: SharedString = self.head_branch.clone().into(); let base_label: SharedString = self.base_branch.clone().into(); @@ -329,7 +348,7 @@ impl Render for CreatePrDialog { ), ) .child( - Label::new("New Pull Request") + Label::new(language.tr(TrKey::CreatePrTitle)) .size(LabelSize::Large) .weight(gpui::FontWeight::BOLD), ), @@ -355,7 +374,7 @@ impl Render for CreatePrDialog { .color(Color::Accent), ) .child( - Label::new("into") + Label::new(language.tr(TrKey::PrInto)) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -368,7 +387,7 @@ impl Render for CreatePrDialog { // Title input .child( div().v_flex().gap_1().child( - Label::new("Title") + Label::new(language.tr(TrKey::PrTitleLabel)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -388,7 +407,7 @@ impl Render for CreatePrDialog { // Body input .child( div().v_flex().gap_1().child( - Label::new("Description") + Label::new(language.tr(TrKey::DescLabel)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -429,7 +448,7 @@ impl Render for CreatePrDialog { )), ) .child( - Label::new("Create as draft pull request") + Label::new(language.tr(TrKey::PrDraftToggle)) .size(LabelSize::Small) .color(Color::Muted), ), @@ -470,11 +489,9 @@ impl Render for CreatePrDialog { .w_full() .gap_2() .child( - Label::new( - "Enter for a new line | Shift+Enter to create | Esc to cancel", - ) - .size(LabelSize::XSmall) - .color(Color::Placeholder), + Label::new(language.tr(TrKey::PrHint)) + .size(LabelSize::XSmall) + .color(Color::Placeholder), ) .child( div() @@ -485,31 +502,37 @@ impl Render for CreatePrDialog { .w_full() .pr(px(4.)) .child( - Button::new("create-pr-cancel", "Cancel") - .size(ButtonSize::Default) - .style(ButtonStyle::Subtle) - .on_click(cx.listener( - |this, _: &ClickEvent, _, cx| { - this.cancel(cx); - }, - )), + Button::new( + "create-pr-cancel", + language.tr(TrKey::CancelBtn), + ) + .size(ButtonSize::Default) + .style(ButtonStyle::Subtle) + .on_click( + cx.listener(|this, _: &ClickEvent, _, cx| { + this.cancel(cx); + }), + ), ) .child( - Button::new("create-pr-submit", "Create pull request") - .icon(IconName::GitPullRequest) - .size(ButtonSize::Default) - .style(if self.is_loading { - ButtonStyle::Subtle - } else { - ButtonStyle::Filled - }) - .color(Color::Accent) - .disabled(self.is_loading) - .on_click(cx.listener( - |this, _: &ClickEvent, _, cx| { - this.submit(cx); - }, - )), + Button::new( + "create-pr-submit", + language.tr(TrKey::PrSubmit), + ) + .icon(IconName::GitPullRequest) + .size(ButtonSize::Default) + .style(if self.is_loading { + ButtonStyle::Subtle + } else { + ButtonStyle::Filled + }) + .color(Color::Accent) + .disabled(self.is_loading) + .on_click( + cx.listener(|this, _: &ClickEvent, _, cx| { + this.submit(cx); + }), + ), ), ), ) @@ -546,7 +569,7 @@ impl Render for CreatePrDialog { .color(Color::Accent), ) .child( - Label::new("Creating pull request...") + Label::new(language.tr(TrKey::PrCreating)) .size(LabelSize::Small) .color(Color::Default), ), diff --git a/crates/rgitui_workspace/src/detail_panel.rs b/crates/rgitui_workspace/src/detail_panel.rs index a11cf64..8667691 100644 --- a/crates/rgitui_workspace/src/detail_panel.rs +++ b/crates/rgitui_workspace/src/detail_panel.rs @@ -13,7 +13,7 @@ use rgitui_git::{ compact_ref_labels, BranchInfo, CommitDiff, CommitInfo, FileChangeKind, FileDiff, RefLabel, Signature, }; -use rgitui_settings::SettingsState; +use rgitui_settings::{Language, SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ AvatarCache, Badge, ButtonSize, ButtonStyle, DiffStat, Icon, IconButton, IconName, IconSize, @@ -80,10 +80,10 @@ impl FileViewMode { } } - fn toggle_tooltip(self) -> &'static str { + fn toggle_tooltip(self, language: Language) -> &'static str { match self { - Self::Flat => "Switch to Tree view (v)", - Self::Tree => "Switch to Flat view (v)", + Self::Flat => language.tr(TrKey::FlatViewTip), + Self::Tree => language.tr(TrKey::TreeViewTip), } } @@ -107,13 +107,14 @@ fn file_view_toggle_tooltip( file_count: usize, is_searching: bool, file_view_mode: FileViewMode, + language: Language, ) -> &'static str { if file_count == 0 { - "No changed files to display" + language.tr(TrKey::NoFilesTip) } else if is_searching { - "Clear file search to switch views" + language.tr(TrKey::ClearSearchTip) } else { - file_view_mode.toggle_tooltip() + file_view_mode.toggle_tooltip(language) } } @@ -481,9 +482,17 @@ impl EventEmitter for DetailPanel {} impl DetailPanel { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let file_search_editor = cx.new(|cx| { let mut input = TextInput::new(cx); - input.set_placeholder("Filter files..."); + input.set_placeholder(language.tr(TrKey::FilterFilesPh)); input.set_compact(true); input }); @@ -1263,6 +1272,7 @@ impl Render for DetailPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); let compact = &cx.global::().settings().compactness; + let language = cx.global::().settings().language; // Compactness-scaled spacing values for header and message cards let header_pad_h = px(compact.spacing(12.0)); @@ -1296,7 +1306,7 @@ impl Render for DetailPanel { .color(Color::Muted), ) .child( - Label::new("Details") + Label::new(language.tr(TrKey::DetailTitle)) .size(LabelSize::Small) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), @@ -1320,13 +1330,13 @@ impl Render for DetailPanel { .color(Color::Placeholder), ) .child( - Label::new("No commit selected") + Label::new(language.tr(TrKey::DetailEmptyTitle)) .size(LabelSize::Small) .color(Color::Muted) .weight(gpui::FontWeight::SEMIBOLD), ) .child( - Label::new("Select a commit from the graph to view details") + Label::new(language.tr(TrKey::DetailEmptyDesc)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -1402,7 +1412,7 @@ impl Render for DetailPanel { .color(Color::Muted), ) .child( - Label::new("Details") + Label::new(language.tr(TrKey::DetailTitle)) .size(LabelSize::Small) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), @@ -1413,8 +1423,12 @@ impl Render for DetailPanel { let is_searching = is_file_searching(self.file_search_active, &self.file_search_query); let toggle_disabled = !can_toggle_file_view(file_count, is_searching); - let toggle_tooltip = - file_view_toggle_tooltip(file_count, is_searching, self.file_view_mode); + let toggle_tooltip = file_view_toggle_tooltip( + file_count, + is_searching, + self.file_view_mode, + language, + ); IconButton::new("view-mode-toggle", self.file_view_mode.toggle_icon()) .size(ButtonSize::Compact) @@ -1508,7 +1522,7 @@ impl Render for DetailPanel { IconButton::new("copy-sha-btn", sha_icon) .size(ButtonSize::Compact) .style(ButtonStyle::Transparent) - .tooltip("Copy commit SHA") + .tooltip(language.tr(TrKey::TipCopySha)) .on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { cx.write_to_clipboard(ClipboardItem::new_string( sha_copy_clone.to_string(), @@ -1519,7 +1533,7 @@ impl Render for DetailPanel { ) .when(sha_copied, |el| { el.child( - Label::new("Copied!") + Label::new(language.tr(TrKey::CopiedFb)) .size(LabelSize::XSmall) .color(Color::Success), ) @@ -1549,7 +1563,7 @@ impl Render for DetailPanel { div() .id("signed-commit") .flex_shrink_0() - .tooltip(Tooltip::text("GPG-signed commit")) + .tooltip(Tooltip::text(language.tr(TrKey::TipGpg))) .child( Icon::new(IconName::Lock) .size(IconSize::XSmall) @@ -1670,7 +1684,7 @@ impl Render for DetailPanel { .color(Color::Muted), ) .child( - Label::new("Finding branches…") + Label::new(language.tr(TrKey::FindingBranches)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -1678,7 +1692,7 @@ impl Render for DetailPanel { } else if !contained_filtered.is_empty() { let mut contained_row = div().v_flex().gap(px(4.)); contained_row = contained_row.child( - Label::new("CONTAINED IN") + Label::new(language.tr(TrKey::ContainedIn)) .size(LabelSize::XSmall) .color(Color::Muted) .weight(gpui::FontWeight::SEMIBOLD), @@ -1749,7 +1763,7 @@ impl Render for DetailPanel { ) .when(summary_copied, |el| { el.child( - Label::new("Copied!") + Label::new(language.tr(TrKey::CopiedFb)) .size(LabelSize::XSmall) .color(Color::Success), ) @@ -1759,7 +1773,7 @@ impl Render for DetailPanel { IconButton::new("cherry-pick-btn", IconName::GitCommit) .size(ButtonSize::Compact) .style(ButtonStyle::Transparent) - .tooltip("Cherry-pick this commit") + .tooltip(language.tr(TrKey::TipCherryPick)) .on_click(cx.listener(move |_this, _: &ClickEvent, _, cx| { cx.emit(DetailPanelEvent::CherryPick(sha_for_cherry.to_string())); })), @@ -1799,7 +1813,7 @@ impl Render for DetailPanel { .color(Color::Muted), ) .child( - Label::new("Description") + Label::new(language.tr(TrKey::DescLabel)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -1829,7 +1843,7 @@ impl Render for DetailPanel { .child(render_markdown(&description, window, cx)) .when(desc_copied, |el| { el.child( - Label::new("Copied!") + Label::new(language.tr(TrKey::CopiedFb)) .size(LabelSize::XSmall) .color(Color::Success), ) @@ -1871,61 +1885,63 @@ impl Render for DetailPanel { let total_deletions = diff.total_deletions; // Build the header with optional search input - let header_children = |cx: &mut Context| -> Vec { - let mut children: Vec = vec![ - Icon::new(IconName::File) - .size(IconSize::XSmall) - .color(Color::Muted) - .into_any_element(), - Label::new(file_count_text.clone()) - .size(LabelSize::XSmall) - .weight(gpui::FontWeight::SEMIBOLD) - .color(Color::Muted) - .into_any_element(), - ]; + let header_children = + |cx: &mut Context| -> Vec { + let mut children: Vec = vec![ + Icon::new(IconName::File) + .size(IconSize::XSmall) + .color(Color::Muted) + .into_any_element(), + Label::new(file_count_text.clone()) + .size(LabelSize::XSmall) + .weight(gpui::FontWeight::SEMIBOLD) + .color(Color::Muted) + .into_any_element(), + ]; - if is_searching { - let search_input: gpui::AnyElement = div() - .flex_1() - .h_flex() - .items_center() - .gap_1() - .child( - Icon::new(IconName::Search) - .size(IconSize::XSmall) - .color(Color::Muted), - ) - .child( + if is_searching { + let search_input: gpui::AnyElement = div() .flex_1() - .min_w_0() - .h(px(24.)) - .overflow_hidden() - .child(self.file_search_editor.clone()), - ) - .child( - IconButton::new("clear-search", IconName::X) - .size(ButtonSize::Compact) - .style(ButtonStyle::Transparent) - .tooltip("Clear search (Esc)") - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.clear_file_search(cx); - })) - .into_any_element(), - ) - .into_any_element(); - children.push(search_input); - } else { - children.push(div().flex_1().into_any_element()); - } + .h_flex() + .items_center() + .gap_1() + .child( + Icon::new(IconName::Search) + .size(IconSize::XSmall) + .color(Color::Muted), + ) + .child(div().flex_1().min_w_0().h(px(24.)).overflow_hidden().child( + { + self.file_search_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::FilterFilesPh)); + }); + self.file_search_editor.clone() + }, + )) + .child( + IconButton::new("clear-search", IconName::X) + .size(ButtonSize::Compact) + .style(ButtonStyle::Transparent) + .tooltip(language.tr(TrKey::TipClearSearch)) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.clear_file_search(cx); + })) + .into_any_element(), + ) + .into_any_element(); + children.push(search_input); + } else { + children.push(div().flex_1().into_any_element()); + } - // Diff stat - let diff_stat: gpui::AnyElement = - DiffStat::new(total_additions, total_deletions).into_any_element(); - children.push(diff_stat); + // Diff stat + let diff_stat: gpui::AnyElement = + DiffStat::new(total_additions, total_deletions).into_any_element(); + children.push(diff_stat); - children - }; + children + }; let header_children = header_children(cx); let mut header = div() @@ -1957,7 +1973,7 @@ impl Render for DetailPanel { .items_center() .gap_1() .child( - Label::new("/ to search files") + Label::new(language.tr(TrKey::SearchFilesHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ) @@ -2558,11 +2574,11 @@ mod tests { #[test] fn test_file_view_mode_tooltips_describe_next_action() { assert_eq!( - FileViewMode::Flat.toggle_tooltip(), + FileViewMode::Flat.toggle_tooltip(Language::English), "Switch to Tree view (v)" ); assert_eq!( - FileViewMode::Tree.toggle_tooltip(), + FileViewMode::Tree.toggle_tooltip(Language::English), "Switch to Flat view (v)" ); } @@ -2570,15 +2586,15 @@ mod tests { #[test] fn test_file_view_toggle_tooltip_describes_disabled_state() { assert_eq!( - file_view_toggle_tooltip(0, false, FileViewMode::Flat), + file_view_toggle_tooltip(0, false, FileViewMode::Flat, Language::English), "No changed files to display" ); assert_eq!( - file_view_toggle_tooltip(2, true, FileViewMode::Flat), + file_view_toggle_tooltip(2, true, FileViewMode::Flat, Language::English), "Clear file search to switch views" ); assert_eq!( - file_view_toggle_tooltip(2, false, FileViewMode::Flat), + file_view_toggle_tooltip(2, false, FileViewMode::Flat, Language::English), "Switch to Tree view (v)" ); } diff --git a/crates/rgitui_workspace/src/file_history_view.rs b/crates/rgitui_workspace/src/file_history_view.rs index cb1f06a..ee3cfb2 100644 --- a/crates/rgitui_workspace/src/file_history_view.rs +++ b/crates/rgitui_workspace/src/file_history_view.rs @@ -8,7 +8,7 @@ use gpui::{ UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::CommitInfo; -use rgitui_settings::SettingsState; +use rgitui_settings::{SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize}; @@ -38,6 +38,10 @@ impl EventEmitter for FileHistoryView {} impl FileHistoryView { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); Self { commits: Arc::new(Vec::new()), file_path: None, @@ -130,6 +134,7 @@ impl FileHistoryView { } fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { + let language = cx.global::().settings().language; let colors = cx.colors(); div() @@ -154,7 +159,7 @@ impl FileHistoryView { .color(Color::Muted), ) .child( - Label::new("File History") + Label::new(language.tr(TrKey::FileHistoryTitle)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -176,12 +181,12 @@ impl FileHistoryView { .color(Color::Placeholder), ) .child( - Label::new("Select a file to view history") + Label::new(language.tr(TrKey::HistoryEmpty)) .size(LabelSize::Small) .color(Color::Muted), ) .child( - Label::new("Press 'h' on a file to see commits") + Label::new(language.tr(TrKey::HistoryEmptyHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -194,6 +199,7 @@ impl FileHistoryView { impl Render for FileHistoryView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); + let language = cx.global::().settings().language; if self.commits.is_empty() { return self.render_empty_state(cx); @@ -400,7 +406,7 @@ impl Render for FileHistoryView { .color(Color::Accent), ) .child( - Label::new("File History") + Label::new(language.tr(TrKey::FileHistoryTitle)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), @@ -412,9 +418,13 @@ impl Render for FileHistoryView { ) .child(div().flex_1()) .child( - Label::new(SharedString::from(format!("{} commits", count))) - .size(LabelSize::XSmall) - .color(Color::Placeholder), + Label::new(language.tr(TrKey::CommitsCountFmt).replacen( + "{}", + &count.to_string(), + 1, + )) + .size(LabelSize::XSmall) + .color(Color::Placeholder), ), ) .child(list) diff --git a/crates/rgitui_workspace/src/interactive_rebase.rs b/crates/rgitui_workspace/src/interactive_rebase.rs index 905b979..fc21cf1 100644 --- a/crates/rgitui_workspace/src/interactive_rebase.rs +++ b/crates/rgitui_workspace/src/interactive_rebase.rs @@ -9,6 +9,7 @@ use rgitui_ui::{Button, ButtonSize, ButtonStyle, Label, LabelSize, TextInput, To use crate::keymap; use crate::CommandId; +use rgitui_settings::{Language, SettingsState, TrKey}; /// The action to perform on a commit during interactive rebase. #[derive(Debug, Clone, PartialEq)] @@ -22,6 +23,10 @@ pub enum RebaseAction { impl RebaseAction { /// Short display label for the action. + /// + /// Rendering uses [`Self::label_tr`] instead; this stays so the unit tests + /// keep pinning the exact source literals. + #[allow(dead_code)] fn label(&self) -> &'static str { match self { RebaseAction::Pick => "Pick", @@ -32,6 +37,17 @@ impl RebaseAction { } } + /// Localized display label for the action. + fn label_tr(&self, language: Language) -> &'static str { + match self { + RebaseAction::Pick => language.tr(TrKey::RebasePick), + RebaseAction::Reword(_) => language.tr(TrKey::RebaseReword), + RebaseAction::Squash => language.tr(TrKey::RebaseSquash), + RebaseAction::Fixup => language.tr(TrKey::RebaseFixup), + RebaseAction::Drop => language.tr(TrKey::RebaseDrop), + } + } + /// Cycle to the next action. fn next(&self) -> RebaseAction { match self { @@ -118,6 +134,14 @@ impl EventEmitter for InteractiveRebase {} impl InteractiveRebase { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); Self { visible: false, pending_focus: false, @@ -127,7 +151,7 @@ impl InteractiveRebase { editing_reword: None, reword_editor: cx.new(|cx| { let mut input = TextInput::new(cx); - input.set_placeholder("Enter new commit message..."); + input.set_placeholder(language.tr(TrKey::RebasePh)); input }), focus_handle: cx.focus_handle(), @@ -468,6 +492,13 @@ impl InteractiveRebase { impl Render for InteractiveRebase { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); + self.reword_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::RebasePh)); + }); if !self.visible { return div().id("interactive-rebase").into_any_element(); @@ -479,8 +510,11 @@ impl Render for InteractiveRebase { } let entry_count = self.entries.len(); - let header_text: SharedString = - format!("Rebasing {} commits onto {}", entry_count, self.target_ref).into(); + let header_text: SharedString = language + .tr(TrKey::RebaseHeaderFmt) + .replacen("{}", &entry_count.to_string(), 1) + .replacen("{}", &self.target_ref, 1) + .into(); // Bounds tracker for the entries container let bounds_tracker = cx.weak_entity(); @@ -538,7 +572,7 @@ impl Render for InteractiveRebase { let oid_short: SharedString = SharedString::from(entry.oid[..7.min(entry.oid.len())].to_string()); - let action_label: SharedString = entry.action.label().into(); + let action_label: SharedString = entry.action.label_tr(language).into(); let action_color = entry.action.color(); let is_dropped = matches!(entry.action, RebaseAction::Drop); @@ -639,7 +673,7 @@ impl Render for InteractiveRebase { .items_center() .justify_center() .cursor_grab() - .tooltip(Tooltip::text("Drag to reorder")) + .tooltip(Tooltip::text(language.tr(TrKey::RebaseDragTip))) .on_mouse_down( gpui::MouseButton::Left, move |_: &MouseDownEvent, _: &mut Window, cx: &mut App| { @@ -751,7 +785,7 @@ impl Render for InteractiveRebase { .gap_3() // Title .child( - Label::new("Interactive Rebase") + Label::new(language.tr(TrKey::RebaseTitle)) .size(LabelSize::Large) .weight(FontWeight::BOLD) .color(Color::Default), @@ -771,27 +805,27 @@ impl Render for InteractiveRebase { .gap_3() .pt_1() .child( - Label::new("j/k Navigate") + Label::new(language.tr(TrKey::RebaseHintNav)) .size(LabelSize::XSmall) .color(Color::Muted), ) .child( - Label::new("Ctrl+Up/Down Reorder") + Label::new(language.tr(TrKey::RebaseHintReorder)) .size(LabelSize::XSmall) .color(Color::Muted), ) .child( - Label::new("p/r/s/f/d Set action") + Label::new(language.tr(TrKey::RebaseHintAction)) .size(LabelSize::XSmall) .color(Color::Muted), ) .child( - Label::new("Enter Start") + Label::new(language.tr(TrKey::RebaseHintStart)) .size(LabelSize::XSmall) .color(Color::Muted), ) .child( - Label::new("Esc Cancel") + Label::new(language.tr(TrKey::RebaseHintCancel)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -805,7 +839,7 @@ impl Render for InteractiveRebase { .gap_2() .pt_2() .child( - Button::new("rebase-cancel", "Cancel") + Button::new("rebase-cancel", language.tr(TrKey::CancelBtn)) .size(ButtonSize::Default) .style(ButtonStyle::Subtle) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -813,7 +847,7 @@ impl Render for InteractiveRebase { })), ) .child( - Button::new("rebase-start", "Start Rebase") + Button::new("rebase-start", language.tr(TrKey::RebaseStart)) .size(ButtonSize::Default) .style(ButtonStyle::Filled) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { diff --git a/crates/rgitui_workspace/src/issues_panel.rs b/crates/rgitui_workspace/src/issues_panel.rs index 5c681c7..47b6d11 100644 --- a/crates/rgitui_workspace/src/issues_panel.rs +++ b/crates/rgitui_workspace/src/issues_panel.rs @@ -21,6 +21,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; #[derive(Clone, Debug)] pub struct Issue { @@ -76,6 +77,9 @@ impl IssueFilter { } } + /// English filter vocabulary. Rendering uses [`TrKey`] instead; this stays + /// so the unit tests keep pinning the exact source literals. + #[allow(dead_code)] fn label(&self) -> &str { match self { IssueFilter::Open => "Open", @@ -173,6 +177,14 @@ pub struct IssuesPanel { impl IssuesPanel { pub(crate) fn new(cx: &mut Context, github_data: Entity) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); Self { github_data, issues: Vec::new().into(), @@ -202,7 +214,7 @@ impl IssuesPanel { query_input: { let query_input = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Search issues..."); + ti.set_placeholder(language.tr(TrKey::SearchIssuesPh)); ti }); let input = query_input.clone(); @@ -717,6 +729,10 @@ impl IssuesPanel { } fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { + let language = cx.global::().settings().language; + self.query_input.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::SearchIssuesPh)); + }); let colors = cx.colors(); if self.view_mode == IssuesPanelView::Detail { @@ -739,7 +755,7 @@ impl IssuesPanel { })), ) .child( - Label::new("Issue Detail") + Label::new(language.tr(TrKey::IssueDetail)) .size(LabelSize::Small) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), @@ -770,7 +786,7 @@ impl IssuesPanel { .color(Color::Accent), ) .child( - Label::new("Issues") + Label::new(language.tr(TrKey::IssuesTitle)) .size(LabelSize::Small) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), @@ -796,7 +812,7 @@ impl IssuesPanel { .border_color(colors.border_variant) .overflow_hidden() .child( - Button::new("filter-open", "Open") + Button::new("filter-open", language.tr(TrKey::FilterOpen)) .size(ButtonSize::Compact) .style(if is_open { ButtonStyle::Filled @@ -808,7 +824,7 @@ impl IssuesPanel { })), ) .child( - Button::new("filter-closed", "Closed") + Button::new("filter-closed", language.tr(TrKey::FilterClosed)) .size(ButtonSize::Compact) .style(if is_closed { ButtonStyle::Filled @@ -820,7 +836,7 @@ impl IssuesPanel { })), ) .child( - Button::new("filter-all", "All") + Button::new("filter-all", language.tr(TrKey::FilterAll)) .size(ButtonSize::Compact) .style(if is_all { ButtonStyle::Filled @@ -847,7 +863,7 @@ impl IssuesPanel { IconButton::new("issues-search", IconName::Search) .size(ButtonSize::Compact) .style(ButtonStyle::Subtle) - .tooltip("Search issues") + .tooltip(language.tr(TrKey::SearchIssuesTip)) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { this.toggle_search(cx); })), @@ -869,6 +885,7 @@ impl IssuesPanel { } fn render_detail(&self, window: &Window, cx: &mut Context) -> impl IntoElement { + let language = cx.global::().settings().language; let colors = cx.colors(); let Some(issue) = &self.selected_issue else { return div().into_any_element(); @@ -880,8 +897,16 @@ impl IssuesPanel { let created: SharedString = issue.created_at.clone().into(); let (state_label, state_icon, state_color) = match issue.state { - IssueState::Open => ("Open", IconName::DotOutline, Color::Success), - IssueState::Closed => ("Closed", IconName::CheckCircle, Color::Accent), + IssueState::Open => ( + language.tr(TrKey::FilterOpen), + IconName::DotOutline, + Color::Success, + ), + IssueState::Closed => ( + language.tr(TrKey::FilterClosed), + IconName::CheckCircle, + Color::Accent, + ), }; let mut content = div() @@ -1013,13 +1038,14 @@ impl IssuesPanel { .color(Color::Muted), ) .child( - Label::new("Loading comments...") + Label::new(language.tr(TrKey::LoadingComments)) .size(LabelSize::Small) .color(Color::Muted), ), ); } else if let Some(err) = &self.comments_error { - let err_text: SharedString = format!("Failed to load comments: {err}").into(); + let err_text: SharedString = + format!("{}: {err}", language.tr(TrKey::CommentsFailedPre)).into(); content = content.child( div() .h_flex() @@ -1041,12 +1067,14 @@ impl IssuesPanel { ); } else if !self.selected_comments.is_empty() { let comment_count = self.selected_comments.len(); - let comments_header: SharedString = format!( - "{} comment{}", - comment_count, - if comment_count == 1 { "" } else { "s" } - ) - .into(); + let comments_header: SharedString = if comment_count == 1 { + language.tr(TrKey::OneComment).into() + } else { + language + .tr(TrKey::ManyCommentsFmt) + .replacen("{}", &comment_count.to_string(), 1) + .into() + }; content = content.child( div() .h_flex() @@ -1193,6 +1221,7 @@ impl IssuesPanel { impl Render for IssuesPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let language = cx.global::().settings().language; let panel_bg = cx.colors().panel_background; let ghost_selected = cx.colors().ghost_element_selected; let text_accent = cx.colors().text_accent; @@ -1226,8 +1255,8 @@ impl Render for IssuesPanel { return panel .child(self.render_empty_state( IconName::Settings, - "Sign in to view issues", - "This repository is private or rate-limited. Add a GitHub token in Settings — for organization repos you may need a fine-grained token approved by an org owner.", + language.tr(TrKey::GhSignInIssues), + language.tr(TrKey::GhAuthDesc), cx, )) .into_any_element(); @@ -1271,7 +1300,7 @@ impl Render for IssuesPanel { ), ) .child( - Button::new("retry-issues", "Retry") + Button::new("retry-issues", language.tr(TrKey::OpRetry)) .icon(IconName::Refresh) .size(ButtonSize::Default) .style(ButtonStyle::Filled) @@ -1286,12 +1315,20 @@ impl Render for IssuesPanel { } if self.issues.is_empty() { - let empty_msg = format!("No {} issues found", self.filter.label().to_lowercase()); + let filter_word = match &self.filter { + IssueFilter::Open => language.tr(TrKey::FilterOpen), + IssueFilter::Closed => language.tr(TrKey::FilterClosed), + IssueFilter::All => language.tr(TrKey::FilterAll), + } + .to_lowercase(); + let empty_msg = language + .tr(TrKey::IssueEmptyFmt) + .replacen("{}", &filter_word, 1); return panel .child(self.render_empty_state( IconName::CheckCircle, &empty_msg, - "Try a different filter or check back later", + language.tr(TrKey::TryFilterHint), cx, )) .into_any_element(); diff --git a/crates/rgitui_workspace/src/keymap/registry.rs b/crates/rgitui_workspace/src/keymap/registry.rs index 80b316d..c809b5b 100644 --- a/crates/rgitui_workspace/src/keymap/registry.rs +++ b/crates/rgitui_workspace/src/keymap/registry.rs @@ -658,6 +658,29 @@ impl CommandId { self.meta().description() } + /// Localized description for the settings shortcuts quick reference. + /// + /// Only the eight quick-reference rows have a Chinese string; every other + /// command falls back to the English doc comment so `docs/KEYBINDINGS.md` + /// and the keymap schema stay byte-identical to [`CommandId::description`]. + pub fn description_tr(self, lang: rgitui_settings::Language) -> &'static str { + use rgitui_settings::Language::{English, SimplifiedChinese}; + match lang { + English => self.description(), + SimplifiedChinese => match self { + CommandId::CommandPalette => "切换命令面板。", + CommandId::Search => "搜索提交图。", + CommandId::StageAll => "暂存工作树中的所有更改。", + CommandId::UnstageAll => "取消暂存所有已暂存的更改。", + CommandId::Commit => "使用提交面板中的信息提交已暂存的更改。", + CommandId::OpenRepo => "打开仓库选择器。", + CommandId::Refresh => "从磁盘重新加载仓库状态。", + CommandId::Settings => "打开设置窗口。", + _ => self.description(), + }, + } + } + /// Whether this command is offered in the command palette. pub fn in_palette(self) -> bool { self.meta().in_palette @@ -1169,4 +1192,84 @@ mod tests { ); } } + + /// The eight settings quick-reference rows. Both languages must be + /// non-empty so no row ever renders blank. + #[test] + fn quick_reference_descriptions_are_translated_in_both_languages() { + use rgitui_settings::Language; + let ids = [ + CommandId::CommandPalette, + CommandId::Search, + CommandId::StageAll, + CommandId::UnstageAll, + CommandId::Commit, + CommandId::OpenRepo, + CommandId::Refresh, + CommandId::Settings, + ]; + for id in ids { + assert!( + !id.description_tr(Language::English).is_empty(), + "{id:?} has no English description" + ); + assert!( + !id.description_tr(Language::SimplifiedChinese).is_empty(), + "{id:?} has no Chinese description" + ); + } + } + + /// The English branch must preserve the doc-comment literals byte for + /// byte, and Chinese must differ from English for the eight rows. + #[test] + fn quick_reference_english_descriptions_keep_the_source_literals() { + use rgitui_settings::Language; + let expected: &[(CommandId, &str)] = &[ + (CommandId::CommandPalette, "Toggle the command palette."), + (CommandId::Search, "Search the commit graph."), + ( + CommandId::StageAll, + "Stage every change in the working tree.", + ), + ( + CommandId::UnstageAll, + "Unstage everything currently staged.", + ), + ( + CommandId::Commit, + "Commit the staged changes using the message in the commit panel.", + ), + (CommandId::OpenRepo, "Open the repository picker."), + (CommandId::Refresh, "Reload the repository state from disk."), + (CommandId::Settings, "Open the settings window."), + ]; + for (id, literal) in expected { + assert_eq!(id.description(), *literal, "{id:?} doc comment drifted"); + assert_eq!( + id.description_tr(Language::English), + *literal, + "{id:?} English translation drifted from the doc comment" + ); + assert_ne!( + id.description_tr(Language::SimplifiedChinese), + *literal, + "{id:?} Chinese translation is still English" + ); + } + } + + /// Commands outside the quick reference fall back to the English doc + /// comment in both languages. + #[test] + fn untranslated_commands_fall_back_to_english() { + use rgitui_settings::Language; + for id in [CommandId::Fetch, CommandId::Push, CommandId::Blame] { + assert_eq!(id.description_tr(Language::English), id.description()); + assert_eq!( + id.description_tr(Language::SimplifiedChinese), + id.description() + ); + } + } } diff --git a/crates/rgitui_workspace/src/prs_panel.rs b/crates/rgitui_workspace/src/prs_panel.rs index 2fbd585..bc6fb97 100644 --- a/crates/rgitui_workspace/src/prs_panel.rs +++ b/crates/rgitui_workspace/src/prs_panel.rs @@ -25,6 +25,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; #[derive(Clone, Debug)] pub struct PullRequest { @@ -127,6 +128,9 @@ impl PrFilter { } } + /// English filter vocabulary. Rendering uses [`TrKey`] instead; this stays + /// so the unit tests keep pinning the exact source literals. + #[allow(dead_code)] fn label(&self) -> &str { match self { PrFilter::Open => "Open", @@ -215,6 +219,14 @@ impl PrsPanel { workspace: WeakEntity, github_data: Entity, ) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); Self { github_data, prs: Vec::new().into(), @@ -243,7 +255,7 @@ impl PrsPanel { review_submitting: false, review_comment_input: cx.new(|cx| { let mut ti = TextInput::new(cx).multiline(); - ti.set_placeholder("Leave a review comment (optional)"); + ti.set_placeholder(language.tr(TrKey::ReviewCommentPh)); ti }), review_result: None, @@ -634,6 +646,13 @@ impl PrsPanel { } fn render_review_actions(&self, cx: &mut Context) -> impl IntoElement { + let language = cx.global::().settings().language; + // Keep the review placeholder in the active language: the editor is + // created once in `new`, so without this it would freeze in the + // language that was active at startup. + self.review_comment_input.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::ReviewCommentPh)); + }); let mut el = div().v_flex().w_full().gap(px(8.)); // Review comment input @@ -646,7 +665,7 @@ impl PrsPanel { let submitting = self.review_submitting; buttons = buttons.child( - Button::new("pr-review-approve", "Approve") + Button::new("pr-review-approve", language.tr(TrKey::ReviewApprove)) .style(ButtonStyle::Tinted(TintColor::Success)) .size(ButtonSize::Compact) .disabled(submitting) @@ -656,17 +675,20 @@ impl PrsPanel { ); buttons = buttons.child( - Button::new("pr-review-request-changes", "Request Changes") - .style(ButtonStyle::Tinted(TintColor::Error)) - .size(ButtonSize::Compact) - .disabled(submitting) - .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { - this.submit_review(ReviewAction::RequestChanges, cx); - })), + Button::new( + "pr-review-request-changes", + language.tr(TrKey::ReviewRequest), + ) + .style(ButtonStyle::Tinted(TintColor::Error)) + .size(ButtonSize::Compact) + .disabled(submitting) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.submit_review(ReviewAction::RequestChanges, cx); + })), ); buttons = buttons.child( - Button::new("pr-review-comment", "Comment") + Button::new("pr-review-comment", language.tr(TrKey::ReviewCommentBtn)) .style(ButtonStyle::Subtle) .size(ButtonSize::Compact) .disabled(submitting) @@ -687,7 +709,7 @@ impl PrsPanel { .color(Color::Muted), ) .child( - Label::new("Submitting...") + Label::new(language.tr(TrKey::ReviewSubmitting)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -728,7 +750,7 @@ impl PrsPanel { .border_t_1() .border_color(cx.colors().border_variant) .child( - Label::new("Review Actions") + Label::new(language.tr(TrKey::ReviewActions)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -781,6 +803,7 @@ impl PrsPanel { } fn render_toolbar(&self, cx: &mut Context) -> impl IntoElement { + let language = cx.global::().settings().language; if self.view_mode == PrsPanelView::Detail { return div() .h_flex() @@ -801,7 +824,7 @@ impl PrsPanel { })), ) .child( - Label::new("Pull Request Detail") + Label::new(language.tr(TrKey::PrDetail)) .size(LabelSize::Small) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), @@ -831,7 +854,7 @@ impl PrsPanel { .color(Color::Accent), ) .child( - Label::new("Pull Requests") + Label::new(language.tr(TrKey::PrsTitle)) .size(LabelSize::Small) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), @@ -849,7 +872,7 @@ impl PrsPanel { .border_color(cx.colors().border_variant) .overflow_hidden() .child( - Button::new("pr-filter-open", "Open") + Button::new("pr-filter-open", language.tr(TrKey::FilterOpen)) .size(ButtonSize::Compact) .style(if is_open { ButtonStyle::Filled @@ -861,7 +884,7 @@ impl PrsPanel { })), ) .child( - Button::new("pr-filter-closed", "Closed") + Button::new("pr-filter-closed", language.tr(TrKey::FilterClosed)) .size(ButtonSize::Compact) .style(if is_closed { ButtonStyle::Filled @@ -873,7 +896,7 @@ impl PrsPanel { })), ) .child( - Button::new("pr-filter-all", "All") + Button::new("pr-filter-all", language.tr(TrKey::FilterAll)) .size(ButtonSize::Compact) .style(if is_all { ButtonStyle::Filled @@ -886,7 +909,7 @@ impl PrsPanel { ), ) .child( - Button::new("prs-new", "New pull request") + Button::new("prs-new", language.tr(TrKey::PrNew)) .size(ButtonSize::Compact) .style(ButtonStyle::Filled) .color(Color::Accent) @@ -906,6 +929,7 @@ impl PrsPanel { } fn render_detail(&self, window: &Window, cx: &mut Context) -> impl IntoElement { + let language = cx.global::().settings().language; let Some(pr) = &self.selected_pr else { return div().into_any_element(); }; @@ -917,9 +941,21 @@ impl PrsPanel { let created: SharedString = pr.created_at.clone().into(); let (state_label, state_icon, state_color) = match pr.state { - PrState::Open => ("Open", IconName::GitPullRequest, Color::Success), - PrState::Closed => ("Closed", IconName::GitPullRequest, Color::Error), - PrState::Merged => ("Merged", IconName::GitMerge, Color::Accent), + PrState::Open => ( + language.tr(TrKey::FilterOpen), + IconName::GitPullRequest, + Color::Success, + ), + PrState::Closed => ( + language.tr(TrKey::FilterClosed), + IconName::GitPullRequest, + Color::Error, + ), + PrState::Merged => ( + language.tr(TrKey::PrStateMerged), + IconName::GitMerge, + Color::Accent, + ), }; let mut content = div() @@ -975,7 +1011,8 @@ impl PrsPanel { ); if pr.draft { - meta_row = meta_row.child(Badge::new("Draft").color(Color::Muted)); + meta_row = + meta_row.child(Badge::new(language.tr(TrKey::DraftBadge)).color(Color::Muted)); } meta_row = meta_row @@ -1078,13 +1115,14 @@ impl PrsPanel { .color(Color::Muted), ) .child( - Label::new("Loading comments...") + Label::new(language.tr(TrKey::LoadingComments)) .size(LabelSize::Small) .color(Color::Muted), ), ); } else if let Some(err) = &self.comments_error { - let err_text: SharedString = format!("Failed to load comments: {err}").into(); + let err_text: SharedString = + format!("{}: {err}", language.tr(TrKey::CommentsFailedPre)).into(); content = content.child( div() .h_flex() @@ -1106,12 +1144,14 @@ impl PrsPanel { ); } else if !self.selected_comments.is_empty() { let comment_count = self.selected_comments.len(); - let comments_header: SharedString = format!( - "{} comment{}", - comment_count, - if comment_count == 1 { "" } else { "s" } - ) - .into(); + let comments_header: SharedString = if comment_count == 1 { + language.tr(TrKey::OneComment).into() + } else { + language + .tr(TrKey::ManyCommentsFmt) + .replacen("{}", &comment_count.to_string(), 1) + .into() + }; content = content.child( div() .h_flex() @@ -1256,6 +1296,7 @@ impl PrsPanel { impl Render for PrsPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let language = cx.global::().settings().language; let panel_bg = cx.colors().panel_background; let ghost_selected = cx.colors().ghost_element_selected; let text_accent = cx.colors().text_accent; @@ -1283,8 +1324,8 @@ impl Render for PrsPanel { return panel .child(self.render_empty_state( IconName::Settings, - "Sign in to view pull requests", - "This repository is private or rate-limited. Add a GitHub token in Settings — for organization repos you may need a fine-grained token approved by an org owner.", + language.tr(TrKey::GhSignInPrs), + language.tr(TrKey::GhAuthDesc), cx, )) .into_any_element(); @@ -1328,7 +1369,7 @@ impl Render for PrsPanel { ), ) .child( - Button::new("retry-prs", "Retry") + Button::new("retry-prs", language.tr(TrKey::OpRetry)) .icon(IconName::Refresh) .size(ButtonSize::Default) .style(ButtonStyle::Filled) @@ -1343,15 +1384,20 @@ impl Render for PrsPanel { } if self.prs.is_empty() { - let empty_msg = format!( - "No {} pull requests found", - self.filter.label().to_lowercase() - ); + let filter_word = match &self.filter { + PrFilter::Open => language.tr(TrKey::FilterOpen), + PrFilter::Closed => language.tr(TrKey::FilterClosed), + PrFilter::All => language.tr(TrKey::FilterAll), + } + .to_lowercase(); + let empty_msg = language + .tr(TrKey::PrEmptyFmt) + .replacen("{}", &filter_word, 1); return panel .child(self.render_empty_state( IconName::CheckCircle, &empty_msg, - "Try a different filter or check back later", + language.tr(TrKey::TryFilterHint), cx, )) .into_any_element(); @@ -1438,7 +1484,9 @@ impl Render for PrsPanel { ); if is_draft { - row = row.child(Badge::new("Draft").color(Color::Muted)); + row = row.child( + Badge::new(language.tr(TrKey::DraftBadge)).color(Color::Muted), + ); } if !labels.is_empty() { diff --git a/crates/rgitui_workspace/src/reflog_view.rs b/crates/rgitui_workspace/src/reflog_view.rs index 2ea13d8..2107c84 100644 --- a/crates/rgitui_workspace/src/reflog_view.rs +++ b/crates/rgitui_workspace/src/reflog_view.rs @@ -8,7 +8,7 @@ use gpui::{ UniformListScrollHandle, WeakEntity, Window, }; use rgitui_git::ReflogEntryInfo; -use rgitui_settings::SettingsState; +use rgitui_settings::{SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; @@ -43,6 +43,10 @@ impl EventEmitter for ReflogView {} impl ReflogView { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); Self { entries: Arc::new(Vec::new()), scroll_handle: UniformListScrollHandle::new(), @@ -119,6 +123,7 @@ impl ReflogView { } fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { + let language = cx.global::().settings().language; let colors = cx.colors(); div() @@ -143,7 +148,7 @@ impl ReflogView { .color(Color::Muted), ) .child( - Label::new("Reflog") + Label::new(language.tr(TrKey::ReflogTitle)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -165,12 +170,12 @@ impl ReflogView { .color(Color::Placeholder), ) .child( - Label::new("No reflog entries") + Label::new(language.tr(TrKey::ReflogEmpty)) .size(LabelSize::Small) .color(Color::Muted), ) .child( - Label::new("Reflog tracks HEAD changes") + Label::new(language.tr(TrKey::ReflogHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -183,6 +188,7 @@ impl ReflogView { impl Render for ReflogView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); + let language = cx.global::().settings().language; if self.entries.is_empty() { return self.render_empty_state(cx); @@ -403,7 +409,7 @@ impl Render for ReflogView { .color(Color::Accent), ) .child( - Label::new("Reflog") + Label::new(language.tr(TrKey::ReflogTitle)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), diff --git a/crates/rgitui_workspace/src/rename_dialog.rs b/crates/rgitui_workspace/src/rename_dialog.rs index c3e7fd6..26ba994 100644 --- a/crates/rgitui_workspace/src/rename_dialog.rs +++ b/crates/rgitui_workspace/src/rename_dialog.rs @@ -10,6 +10,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; /// Events emitted by the rename dialog. #[derive(Debug, Clone)] @@ -35,6 +36,10 @@ impl EventEmitter for RenameDialog {} impl RenameDialog { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); let editor = cx.new(|cx| { let mut ti = TextInput::new(cx); ti.set_placeholder("new-branch-name"); @@ -178,6 +183,11 @@ impl Render for RenameDialog { } let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); + let new_name = self.editor.read(cx).text().to_string(); let is_empty = new_name.is_empty(); let has_error = self.error_message.is_some(); @@ -227,7 +237,7 @@ impl Render for RenameDialog { ), ) .child( - Label::new("Rename Branch") + Label::new(language.tr(TrKey::RenameTitle)) .size(LabelSize::Large) .weight(gpui::FontWeight::BOLD), ), @@ -239,7 +249,7 @@ impl Render for RenameDialog { .gap(px(8.)) .items_center() .child( - Label::new("Current name") + Label::new(language.tr(TrKey::RenameCurrent)) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -271,7 +281,7 @@ impl Render for RenameDialog { .v_flex() .gap(px(6.)) .child( - Label::new("New name") + Label::new(language.tr(TrKey::RenameNew)) .size(LabelSize::Small) .weight(gpui::FontWeight::MEDIUM) .color(Color::Muted), @@ -318,7 +328,7 @@ impl Render for RenameDialog { .justify_between() .items_center() .child( - Label::new("Enter to rename | Esc to cancel") + Label::new(language.tr(TrKey::RenameHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ) @@ -327,7 +337,7 @@ impl Render for RenameDialog { .h_flex() .gap_2() .child( - Button::new("cancel-rename", "Cancel") + Button::new("cancel-rename", language.tr(TrKey::CancelBtn)) .size(ButtonSize::Default) .style(ButtonStyle::Subtle) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -335,7 +345,7 @@ impl Render for RenameDialog { })), ) .child( - Button::new("do-rename", "Rename") + Button::new("do-rename", language.tr(TrKey::RenameBtn)) .icon(IconName::Edit) .size(ButtonSize::Default) .style(ButtonStyle::Filled) diff --git a/crates/rgitui_workspace/src/repo_clone_dialog.rs b/crates/rgitui_workspace/src/repo_clone_dialog.rs index 773decd..02f21b2 100644 --- a/crates/rgitui_workspace/src/repo_clone_dialog.rs +++ b/crates/rgitui_workspace/src/repo_clone_dialog.rs @@ -11,6 +11,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; #[derive(Debug, Clone)] pub enum RepoCloneEvent { @@ -37,14 +38,22 @@ pub struct RepoCloneDialog { impl RepoCloneDialog { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let url_editor = cx.new(|cx| { let mut input = TextInput::new(cx); - input.set_placeholder("Repository URL (e.g. https://github.com/user/repo.git)"); + input.set_placeholder(language.tr(TrKey::CloneUrlPh)); input }); let path_editor = cx.new(|cx| { let mut input = TextInput::new(cx); - input.set_placeholder("Destination Path"); + input.set_placeholder(language.tr(TrKey::ClonePathPh)); input }); @@ -224,6 +233,16 @@ impl Render for RepoCloneDialog { } let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); + self.url_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::CloneUrlPh)); + }); + self.path_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::ClonePathPh)); + }); let url = self.url_editor.read(cx).text().trim().to_string(); let path = self.path_editor.read(cx).text().trim().to_string(); let can_clone = !url.is_empty() && !path.is_empty(); @@ -269,7 +288,7 @@ impl Render for RepoCloneDialog { ), ) .child( - Label::new("Clone Repository") + Label::new(language.tr(TrKey::CloneTitle)) .size(LabelSize::Large) .weight(FontWeight::BOLD), ), @@ -280,7 +299,7 @@ impl Render for RepoCloneDialog { .v_flex() .gap(px(6.)) .child( - Label::new("URL") + Label::new(language.tr(TrKey::CloneUrlLabel)) .size(LabelSize::Small) .weight(FontWeight::MEDIUM) .color(Color::Muted), @@ -293,7 +312,7 @@ impl Render for RepoCloneDialog { .v_flex() .gap(px(6.)) .child( - Label::new("Path") + Label::new(language.tr(TrKey::ClonePathLabel)) .size(LabelSize::Small) .weight(FontWeight::MEDIUM) .color(Color::Muted), @@ -305,7 +324,7 @@ impl Render for RepoCloneDialog { .items_center() .child(div().flex_1().child(self.path_editor.clone())) .child( - Button::new("browse-path", "Browse") + Button::new("browse-path", language.tr(TrKey::BrowseBtn)) .style(ButtonStyle::Subtle) .icon(IconName::Folder) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -317,7 +336,7 @@ impl Render for RepoCloneDialog { if !can_clone { modal = modal.child( - Label::new("Enter a repository URL and a destination path to clone.") + Label::new(language.tr(TrKey::CloneHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ); @@ -340,20 +359,27 @@ impl Render for RepoCloneDialog { .justify_end() .gap(px(8.)) .child( - Button::new("cancel", "Cancel") + Button::new("cancel", language.tr(TrKey::CancelBtn)) .style(ButtonStyle::Subtle) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { this.hide(cx); })), ) .child( - Button::new("clone", if self.cloning { "Cloning…" } else { "Clone" }) - .style(ButtonStyle::Filled) - .color(Color::Accent) - .disabled(!can_clone || self.cloning) - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.clone_repo(cx); - })), + Button::new( + "clone", + if self.cloning { + language.tr(TrKey::CloningBtn) + } else { + language.tr(TrKey::CloneBtn) + }, + ) + .style(ButtonStyle::Filled) + .color(Color::Accent) + .disabled(!can_clone || self.cloning) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.clone_repo(cx); + })), ), ); diff --git a/crates/rgitui_workspace/src/repo_opener.rs b/crates/rgitui_workspace/src/repo_opener.rs index 1407a91..1fc5108 100644 --- a/crates/rgitui_workspace/src/repo_opener.rs +++ b/crates/rgitui_workspace/src/repo_opener.rs @@ -12,6 +12,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; #[derive(Debug, Clone)] pub enum RepoOpenerEvent { @@ -33,9 +34,17 @@ impl EventEmitter for RepoOpener {} impl RepoOpener { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Enter repository path..."); + ti.set_placeholder(language.tr(TrKey::EnterRepoPathPh)); ti }); cx.subscribe( @@ -235,6 +244,13 @@ impl Render for RepoOpener { } let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); + self.editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::EnterRepoPathPh)); + }); let mut modal = div() .id("repo-opener-modal") @@ -276,7 +292,7 @@ impl Render for RepoOpener { .color(Color::Muted), ) .child( - Label::new("Open Repository") + Label::new(language.tr(TrKey::OpenRepoTitle)) .size(LabelSize::Large) .weight(FontWeight::SEMIBOLD), ), @@ -310,7 +326,7 @@ impl Render for RepoOpener { .v_flex() .gap(px(8.)) .child( - Label::new("Repository path") + Label::new(language.tr(TrKey::RepoPathLabel)) .size(LabelSize::Small) .weight(FontWeight::MEDIUM) .color(Color::Muted), @@ -333,7 +349,7 @@ impl Render for RepoOpener { .child(div().flex_1().child(self.editor.clone())), ) .child( - Button::new("browse-folder", "Browse") + Button::new("browse-folder", language.tr(TrKey::BrowseBtn)) .style(ButtonStyle::Subtle) .icon(IconName::Folder) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -341,7 +357,7 @@ impl Render for RepoOpener { })), ) .child( - Button::new("clone-repo", "Clone") + Button::new("clone-repo", language.tr(TrKey::CloneBtn)) .style(ButtonStyle::Subtle) .icon(IconName::Plus) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -366,7 +382,7 @@ impl Render for RepoOpener { .border_color(colors.border_variant) .items_center() .child( - Label::new("Recent Repositories") + Label::new(language.tr(TrKey::HomeRecentRepos)) .size(LabelSize::XSmall) .weight(FontWeight::SEMIBOLD) .color(Color::Muted), @@ -461,7 +477,7 @@ impl Render for RepoOpener { .color(Color::Placeholder), ) .child( - Label::new("No matching repositories") + Label::new(language.tr(TrKey::NoMatchRepos)) .size(LabelSize::Small) .color(Color::Muted), ), @@ -487,7 +503,7 @@ impl Render for RepoOpener { .color(Color::Placeholder), ) .child( - Label::new("No recent repositories") + Label::new(language.tr(TrKey::NoRecentRepos)) .size(LabelSize::Small) .color(Color::Muted), ), @@ -505,17 +521,19 @@ impl Render for RepoOpener { .border_color(colors.border_variant) .bg(colors.surface_background) .child( - Button::new("cancel-open", "Cancel") + Button::new("cancel-open", language.tr(TrKey::CancelBtn)) .style(ButtonStyle::Subtle) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { this.dismiss(cx); })), ) - .child(Button::new("open-repo", "Open").on_click(cx.listener( - |this, _: &ClickEvent, _, cx| { - this.try_open(cx); - }, - ))), + .child( + Button::new("open-repo", language.tr(TrKey::OpenBtn)).on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.try_open(cx); + }, + )), + ), ); div() diff --git a/crates/rgitui_workspace/src/search_panel.rs b/crates/rgitui_workspace/src/search_panel.rs index 2c6774d..35cceae 100644 --- a/crates/rgitui_workspace/src/search_panel.rs +++ b/crates/rgitui_workspace/src/search_panel.rs @@ -13,6 +13,7 @@ use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, TextInput, TextInput use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; const SEARCH_RESULT_ROW_HEIGHT: f32 = 28.0; const SEARCH_RESULTS_PAGE_SIZE: usize = 250; @@ -102,9 +103,17 @@ impl EventEmitter for GlobalSearchView {} impl GlobalSearchView { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let query_input = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Search across all files... (press Enter)"); + ti.set_placeholder(language.tr(TrKey::SearchPh)); ti }); @@ -332,7 +341,11 @@ impl Render for GlobalSearchView { return div().id("global-search-view").into_any_element(); } - let colors = cx.colors(); + let colors = cx.colors().clone(); + let language = cx.global::().settings().language; + self.query_input.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::SearchPh)); + }); let query = self.query.clone(); let loading = self.loading; let error = self.error.clone(); @@ -375,6 +388,7 @@ impl GlobalSearchView { cx: &mut Context, ) -> impl IntoElement { let colors = cx.colors(); + let language = cx.global::().settings().language; div() .id("global-search-toolbar") @@ -405,9 +419,13 @@ impl GlobalSearchView { .child(self.query_input.clone()) }) .child( - Label::new(format!("{} results", result_count)) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(language.tr(TrKey::SearchResultsFmt).replacen( + "{}", + &result_count.to_string(), + 1, + )) + .size(LabelSize::XSmall) + .color(Color::Muted), ) } @@ -418,6 +436,7 @@ impl GlobalSearchView { content_state: SearchContentState, cx: &mut Context, ) -> impl IntoElement { + let language = cx.global::().settings().language; let colors = cx.colors(); if content_state == SearchContentState::Error { @@ -467,12 +486,12 @@ impl GlobalSearchView { .color(Color::Placeholder), ) .child( - Label::new("Type to search across all files") + Label::new(language.tr(TrKey::SearchEmpty)) .size(LabelSize::Small) .color(Color::Muted), ) .child( - Label::new("Press Enter to search | j/k to navigate") + Label::new(language.tr(TrKey::SearchHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), diff --git a/crates/rgitui_workspace/src/settings_window/ai_section.rs b/crates/rgitui_workspace/src/settings_window/ai_section.rs index 9c72c3c..b31d2fc 100644 --- a/crates/rgitui_workspace/src/settings_window/ai_section.rs +++ b/crates/rgitui_workspace/src/settings_window/ai_section.rs @@ -19,7 +19,7 @@ use rgitui_ai::catalog::{ ToolSupport, }; use rgitui_ai::CommitStyle; -use rgitui_settings::{AiProvider, SettingsState}; +use rgitui_settings::{AiProvider, SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color}; use rgitui_ui::{ Button, ButtonSize, ButtonStyle, CheckState, Checkbox, ConnectionState, Disclosure, Icon, @@ -521,35 +521,47 @@ impl SettingsView { let provider = self.ai_provider; let state = self.connection_state(provider); let enabled = self.ai_enabled; + let language = self.language; - let detail = if !enabled { - "AI is turned off. Nothing will be sent to any provider.".to_string() - } else { - match state { - ConnectionState::Connected => { - let verified = self - .ai_verified_at - .get(&provider) - .map(|at| format!(" · verified {}", relative_age(at.elapsed()))) - .unwrap_or_default(); - format!( - "{} · {}{}", + let detail = + if !enabled { + language.tr(TrKey::AiStatusOff).to_string() + } else { + match state { + ConnectionState::Connected => { + let verified = self + .ai_verified_at + .get(&provider) + .map(|at| format!(" · verified {}", relative_age(at.elapsed()))) + .unwrap_or_default(); + format!( + "{} · {}{}", + provider.display_name(), + self.ai_model, + verified + ) + } + ConnectionState::Testing => language.tr(TrKey::AiStatusTestingFmt).replacen( + "{}", provider.display_name(), - self.ai_model, - verified - ) - } - ConnectionState::Testing => format!("Testing {}…", provider.display_name()), - ConnectionState::Failed => self - .ai_connection_error - .get(&provider) - .cloned() - .unwrap_or_else(|| format!("{} rejected this key.", provider.display_name())), - ConnectionState::Unconfigured => { - format!("Add a {} API key to get started.", provider.display_name()) + 1, + ), + ConnectionState::Failed => self + .ai_connection_error + .get(&provider) + .cloned() + .unwrap_or_else(|| { + language.tr(TrKey::AiStatusRejectedFmt).replacen( + "{}", + provider.display_name(), + 1, + ) + }), + ConnectionState::Unconfigured => language + .tr(TrKey::AiStatusAddKeyFmt) + .replacen("{}", provider.display_name(), 1), } - } - }; + }; div() .flex() @@ -569,7 +581,7 @@ impl SettingsView { } else { ConnectionState::Unconfigured }, - "AI Commit Messages", + language.tr(TrKey::AiStatusTitle), ) .detail(detail), ), @@ -589,7 +601,7 @@ impl SettingsView { this.save_settings(cx); })) .child( - Label::new("Enabled") + Label::new(language.tr(TrKey::AiEnabledToggle)) .size(LabelSize::Small) .color(Color::Muted), ) @@ -606,10 +618,15 @@ impl SettingsView { /// The AI page body. pub(super) fn render_ai_section(&mut self, cx: &mut Context) -> impl IntoElement { + let language = self.language; let mut section = div().flex().flex_col().w_full().min_w_0().gap(px(16.)); - section = section.child(Self::section_label_row("CONNECTION")); + section = section.child(Self::section_label_row( + language.tr(TrKey::AiSectionConnection), + )); section = section.child(self.render_provider_accordion(cx)); - section = section.child(Self::section_label_row("BEHAVIOUR")); + section = section.child(Self::section_label_row( + language.tr(TrKey::AiSectionBehaviour), + )); section = section.child(self.render_behaviour_card(cx)); section } @@ -763,6 +780,7 @@ impl SettingsView { // `div().flex().flex_col()`, never `v_flex()`/`h_flex()` here: the // forced vertical centring in the shared helpers is a recurring cause // of broken scroll containers and misaligned children. + let language = self.language; let mut body = div() .flex() .flex_col() @@ -790,12 +808,12 @@ impl SettingsView { .color(Color::Accent), ) .child( - Label::new("Connect an AI provider") + Label::new(language.tr(TrKey::AiConnectTitle)) .size(LabelSize::Default) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new("rgitui writes commit messages from your staged diff.") + Label::new(language.tr(TrKey::AiConnectDesc)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -808,14 +826,14 @@ impl SettingsView { let status_line: Option = match state { ConnectionState::Connected if self.provider_has_key(provider) => Some( match self.ai_verified_at.get(&provider) { - Some(at) => format!( - "Verified {} · stored in {}", - relative_age(at.elapsed()), - credential_store_name() - ), - None => format!( - "Key stored in {}. Test it to confirm it works.", - credential_store_name() + Some(at) => language + .tr(TrKey::AiKeyVerifiedFmt) + .replacen("{}", &relative_age(at.elapsed()), 1) + .replacen("{}", credential_store_name(), 1), + None => language.tr(TrKey::AiKeyStoredHintFmt).replacen( + "{}", + credential_store_name(), + 1, ), } .into(), @@ -823,27 +841,31 @@ impl SettingsView { // A keyless custom endpoint is configured without a stored key, // so it needs its own line rather than the "add a key" default. ConnectionState::Connected if configured => Some( - format!( - "No API key needed — requests go to {}.", - rgitui_ai::effective_host(provider, &self.ai_base_url_override) - ) - .into(), + language + .tr(TrKey::AiNoKeyHintFmt) + .replacen( + "{}", + &rgitui_ai::effective_host(provider, &self.ai_base_url_override), + 1, + ) + .into(), + ), + ConnectionState::Testing => Some( + language + .tr(TrKey::AiStatusTestingFmt) + .replacen("{}", provider.display_name(), 1) + .into(), ), - ConnectionState::Testing => { - Some(format!("Testing {}…", provider.display_name()).into()) - } ConnectionState::Failed => self .ai_connection_error .get(&provider) .cloned() .map(SharedString::from), _ => Some( - format!( - "Keys are stored in {}, never in settings.json, and are only read when a \ - request is sent.", - credential_store_name() - ) - .into(), + language + .tr(TrKey::AiKeysStoredNoteFmt) + .replacen("{}", credential_store_name(), 1) + .into(), ), }; if let Some(line) = status_line { @@ -897,7 +919,7 @@ impl SettingsView { .items_center() .gap(px(8.)) .child( - Label::new("API key") + Label::new(self.language.tr(TrKey::AiApiKeyLabel)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) @@ -914,7 +936,7 @@ impl SettingsView { .child( Button::new( ElementId::Name(format!("ai-key-url-{}", provider.id()).into()), - "Get a key", + self.language.tr(TrKey::AiGetKeyBtn), ) .style(ButtonStyle::Subtle) .size(ButtonSize::Compact) @@ -948,7 +970,7 @@ impl SettingsView { ) .size(ButtonSize::Compact) .color(Color::Muted) - .tooltip("Paste from clipboard") + .tooltip(self.language.tr(TrKey::TipPaste)) .tab_index(tab_base + 1) .on_click(cx.listener( move |this, _: &ClickEvent, _, cx| { @@ -968,7 +990,11 @@ impl SettingsView { ) .size(ButtonSize::Compact) .color(Color::Muted) - .tooltip(if unmasked { "Hide" } else { "Show" }) + .tooltip(if unmasked { + self.language.tr(TrKey::TipHide) + } else { + self.language.tr(TrKey::TipShow) + }) .tab_index(tab_base + 2) .on_click(cx.listener( move |this, _: &ClickEvent, _, cx| { @@ -1012,7 +1038,7 @@ impl SettingsView { .w_full() .gap(px(4.)) .child( - Label::new("Model") + Label::new(self.language.tr(TrKey::AiModelLabel)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) @@ -1069,10 +1095,12 @@ impl SettingsView { match &status { PinnedModelStatus::Missing { suggestion } => { let mut warning = div().flex().flex_row().items_center().gap(px(6.)).child( - Label::new(format!( - "`{pinned}` is not in {}'s current model list. It may have been retired.", - provider.display_name() - )) + Label::new( + self.language + .tr(TrKey::AiModelMissingFmt) + .replacen("{}", pinned.as_str(), 1) + .replacen("{}", provider.display_name(), 1), + ) .size(LabelSize::XSmall) .color(Color::Warning), ); @@ -1082,7 +1110,11 @@ impl SettingsView { ElementId::Name( format!("ai-model-suggestion-{}", provider.id()).into(), ), - format!("Use {suggestion}"), + self.language.tr(TrKey::AiUseSuggestionFmt).replacen( + "{}", + &suggestion, + 1, + ), ) .style(ButtonStyle::Subtle) .size(ButtonSize::Compact) @@ -1120,11 +1152,12 @@ impl SettingsView { .items_center() .gap(px(8.)) .child( - Label::new(format!( - "{} models · {}", - models.len(), - catalog_source_label(source) - )) + Label::new( + self.language + .tr(TrKey::AiModelCountFmt) + .replacen("{}", &models.len().to_string(), 1) + .replacen("{}", &catalog_source_label(source), 1), + ) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -1133,9 +1166,9 @@ impl SettingsView { Button::new( ElementId::Name(format!("ai-model-refresh-{}", provider.id()).into()), if refreshing { - "Refreshing…" + self.language.tr(TrKey::AiRefreshingBtn) } else { - "Refresh" + self.language.tr(TrKey::AiRefreshBtn) }, ) .style(ButtonStyle::Subtle) @@ -1186,7 +1219,7 @@ impl SettingsView { .child( Button::new( ElementId::Name(format!("ai-connect-{}", provider.id()).into()), - "Connect", + self.language.tr(TrKey::AiConnectBtn), ) .style(ButtonStyle::Filled) .size(ButtonSize::Compact) @@ -1209,7 +1242,7 @@ impl SettingsView { row = row.child( Button::new( ElementId::Name(format!("ai-use-{}", provider.id()).into()), - "Use this provider", + self.language.tr(TrKey::AiUseProviderBtn), ) .style(ButtonStyle::Filled) .size(ButtonSize::Compact) @@ -1225,7 +1258,11 @@ impl SettingsView { row.child( Button::new( ElementId::Name(format!("ai-test-{}", provider.id()).into()), - if testing { "Testing…" } else { "Test" }, + if testing { + self.language.tr(TrKey::AiTestingBtn) + } else { + self.language.tr(TrKey::AiTestBtn) + }, ) .style(ButtonStyle::Outlined) .size(ButtonSize::Compact) @@ -1240,7 +1277,7 @@ impl SettingsView { .child( Button::new( ElementId::Name(format!("ai-remove-{}", provider.id()).into()), - "Remove key", + self.language.tr(TrKey::AiRemoveKeyBtn), ) .style(ButtonStyle::Subtle) .size(ButtonSize::Compact) @@ -1275,10 +1312,11 @@ impl SettingsView { cx: &mut Context, ) -> gpui::AnyElement { let open = self.ai_advanced_open; + let language = self.language; let mut column = div().flex().flex_col().w_full().gap(px(6.)).child( Disclosure::new( ElementId::Name(format!("ai-advanced-{}", provider.id()).into()), - "Advanced", + language.tr(TrKey::AiAdvancedLabel), open, ) .tab_index(tab_base + 9) @@ -1297,7 +1335,7 @@ impl SettingsView { column = column .child( - Label::new("Base URL") + Label::new(language.tr(TrKey::AiBaseUrlLabel)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) @@ -1306,15 +1344,14 @@ impl SettingsView { Label::new(if overridden { // Say plainly where the key goes. A user pointing this at // a third-party gateway should see that stated. - format!( - "Requests go to {host} instead of {}. Your API key is sent to that host.", - provider.default_host() - ) + language + .tr(TrKey::AiBaseUrlOverriddenFmt) + .replacen("{}", host.as_str(), 1) + .replacen("{}", provider.default_host(), 1) } else { - format!( - "Empty means use {}. Only OpenAI-compatible providers honour an override.", - provider.default_host() - ) + language + .tr(TrKey::AiBaseUrlEmptyFmt) + .replacen("{}", provider.default_host(), 1) }) .size(LabelSize::XSmall) .color(if overridden { @@ -1351,14 +1388,14 @@ impl SettingsView { div() .flex() .flex_col() - .child(Label::new("Send attribution headers").size(LabelSize::Small)) .child( - Label::new( - "Adds HTTP-Referer and X-Title so rgitui appears on \ - OpenRouter's public leaderboard. Never functional.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(language.tr(TrKey::AiAttributionTitle)) + .size(LabelSize::Small), + ) + .child( + Label::new(language.tr(TrKey::AiAttributionDesc)) + .size(LabelSize::XSmall) + .color(Color::Muted), ), ), ); @@ -1368,13 +1405,14 @@ impl SettingsView { } fn render_behaviour_card(&mut self, cx: &mut Context) -> impl IntoElement { + let language = self.language; let style = CommitStyle::from_id(&self.ai_commit_style).unwrap_or_default(); let ids: Vec<&str> = CommitStyle::ALL.iter().map(|style| style.id()).collect(); Self::setting_card(cx) .child(Self::setting_label( - "Commit style", - "How the AI should format commit messages.", + language.tr(TrKey::AiCommitStyleTitle), + language.tr(TrKey::AiCommitStyleDesc), )) .child(self.pill_group( "commit-style", @@ -1397,9 +1435,8 @@ impl SettingsView { .child(self.render_behaviour_toggle( BehaviourToggle { id: "ai-inject-ctx", - title: "Include project context", - detail: - "Adds README.md, CLAUDE.md and AGENTS.md to the prompt. ~4k extra tokens per request.", + title: language.tr(TrKey::AiCtxTitle), + detail: language.tr(TrKey::AiCtxDesc), checked: self.ai_inject_project_context, tab_index: SETTINGS_TAB_INDEX_BASE + 80, }, @@ -1412,9 +1449,8 @@ impl SettingsView { .child(self.render_behaviour_toggle( BehaviourToggle { id: "ai-use-tools", - title: "Let the model read files", - detail: "The model may request file contents and commit history. Slower and \ - more expensive; usually a better message.", + title: language.tr(TrKey::AiToolsTitle), + detail: language.tr(TrKey::AiToolsDesc), checked: self.ai_use_tools, tab_index: SETTINGS_TAB_INDEX_BASE + 81, }, diff --git a/crates/rgitui_workspace/src/settings_window/view.rs b/crates/rgitui_workspace/src/settings_window/view.rs index 716cd0d..34ba4bf 100644 --- a/crates/rgitui_workspace/src/settings_window/view.rs +++ b/crates/rgitui_workspace/src/settings_window/view.rs @@ -21,7 +21,7 @@ use gpui::{ use rgitui_ai::catalog::{CatalogSource, ModelInfo}; use rgitui_settings::{ config_dir, AiProvider, AppearanceMode, AutoFetchInterval, Compactness, DiffViewMode, - GitProviderSettings, GraphStyle, SettingsState, + GitProviderSettings, GraphStyle, Language, SettingsState, TrKey, }; use rgitui_theme::{ActiveTheme, Color, StyledExt, ThemeState}; use rgitui_ui::{ @@ -435,6 +435,10 @@ pub struct SettingsView { // General state /// Auto-fetch interval, chosen from a dropdown rather than a pill row. auto_fetch_select: Entity, + /// Interface language dropdown. Options show native names so each choice + /// is recognizable regardless of the active language. + language_select: Entity, + pub(super) language: Language, max_recent_repos: usize, compactness: Compactness, font_size: u32, @@ -560,7 +564,11 @@ impl SettingsView { .global::() .ai_api_key_for(*provider) .unwrap_or_default(); - let placeholder = format!("Paste your {} API key", provider.display_name()); + let placeholder = settings.language.tr(TrKey::AiKeyPlaceholderFmt).replacen( + "{}", + provider.display_name(), + 1, + ); let editor = cx.new(|cx| { let mut input = TextInput::new(cx); input.set_placeholder(placeholder); @@ -648,6 +656,34 @@ impl SettingsView { ) .detach(); + let language_select = cx.new(|cx| { + let mut select = rgitui_ui::Select::new("language-select", cx); + select.set_options( + Language::ALL + .iter() + .map(|language| { + rgitui_ui::SelectOption::new(language.id(), language.native_name()) + }) + .collect(), + cx, + ); + select.set_selected(Some(settings.language.id().into()), cx); + select + }); + cx.subscribe( + &language_select, + |this: &mut Self, _, event: &rgitui_ui::SelectEvent, cx| { + if let rgitui_ui::SelectEvent::Changed(id) = event { + // Same drift guard as the auto-fetch dropdown above. + if let Ok(language) = id.parse::() { + this.language = language; + this.save_settings(cx); + } + } + }, + ) + .detach(); + let ai_model_picker = cx.new(|cx| { let mut picker = Picker::new(cx); // A model newer than the catalogue, or one only a gateway serves, @@ -679,7 +715,7 @@ impl SettingsView { let git_https_token_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Paste a generic HTTPS token only if you need a fallback..."); + ti.set_placeholder(settings.language.tr(TrKey::SetHttpsTokenPh)); ti.set_masked(true); if !git_https_token_val.is_empty() { ti.set_text(&git_https_token_val, cx); @@ -698,7 +734,7 @@ impl SettingsView { let git_gpg_key_id_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("No explicit GPG key configured"); + ti.set_placeholder(settings.language.tr(TrKey::SetGpgPh)); if !git_gpg_key_id_val.is_empty() { ti.set_text(&git_gpg_key_id_val, cx); } @@ -727,7 +763,7 @@ impl SettingsView { let provider_username_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Usually left empty"); + ti.set_placeholder(settings.language.tr(TrKey::SetUsuallyEmpty)); if let Some(p) = first_provider { ti.set_text(&p.username, cx); } @@ -736,7 +772,7 @@ impl SettingsView { let provider_token_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Paste the access token for this account..."); + ti.set_placeholder(settings.language.tr(TrKey::SetAccountTokenPh)); ti.set_masked(true); if let Some(p) = first_provider { ti.set_text(&p.token, cx); @@ -746,7 +782,7 @@ impl SettingsView { let terminal_command_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Custom command override..."); + ti.set_placeholder(settings.language.tr(TrKey::SetCustomCmdPh)); if !settings.terminal_command.is_empty() { ti.set_text(&settings.terminal_command, cx); } @@ -755,7 +791,7 @@ impl SettingsView { let editor_command_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Custom command override..."); + ti.set_placeholder(settings.language.tr(TrKey::SetCustomCmdPh)); if !settings.editor_command.is_empty() { ti.set_text(&settings.editor_command, cx); } @@ -1011,6 +1047,8 @@ impl SettingsView { show_subject_column: settings.show_subject_column, auto_fetch_interval: settings.auto_fetch_interval, auto_fetch_select, + language: settings.language, + language_select, confirm_destructive_operations: settings.confirm_destructive_operations, auto_check_updates: settings.auto_check_updates, watch_all_worktrees: settings.watch_all_worktrees, @@ -1091,6 +1129,7 @@ impl SettingsView { self.graph_style = s.graph_style; self.show_subject_column = s.show_subject_column; self.auto_fetch_interval = s.auto_fetch_interval; + self.language = s.language; self.confirm_destructive_operations = s.confirm_destructive_operations; self.auto_check_updates = s.auto_check_updates; self.watch_all_worktrees = s.watch_all_worktrees; @@ -1129,6 +1168,10 @@ impl SettingsView { self.auto_fetch_select.update(cx, |select, cx| { select.set_selected(Some(interval.into()), cx) }); + let language_id = self.language.id(); + self.language_select.update(cx, |select, cx| { + select.set_selected(Some(language_id.into()), cx) + }); self.git_https_token_editor .update(cx, |e, cx| e.set_text(git_https_token_val, cx)); self.git_ssh_key_path_editor @@ -1251,12 +1294,17 @@ impl SettingsView { if self.dismiss_model_picker(cx) { return true; } - let select_open = self.auto_fetch_select.read(cx).is_open(); - if select_open { + let auto_fetch_open = self.auto_fetch_select.read(cx).is_open(); + if auto_fetch_open { self.auto_fetch_select .update(cx, |select, cx| select.close(cx)); } - select_open + let language_open = self.language_select.read(cx).is_open(); + if language_open { + self.language_select + .update(cx, |select, cx| select.close(cx)); + } + auto_fetch_open || language_open } /// Commit any text fields that have not yet been submitted via Enter and @@ -1340,6 +1388,7 @@ impl SettingsView { state.settings_mut().graph_style = self.graph_style; state.settings_mut().show_subject_column = self.show_subject_column; state.settings_mut().auto_fetch_interval = self.auto_fetch_interval; + state.settings_mut().language = self.language; state.settings_mut().confirm_destructive_operations = self.confirm_destructive_operations; state.settings_mut().auto_check_updates = self.auto_check_updates; @@ -2157,12 +2206,25 @@ impl SettingsView { // ── Sidebar navigation ────────────────────────────────────────────── fn render_sidebar(&self, cx: &mut Context) -> impl IntoElement { let colors = cx.colors(); + let language = self.language; - let sections: [(SettingsSection, IconName, &str); 4] = [ - (SettingsSection::Theme, IconName::Eye, "Appearance"), - (SettingsSection::Ai, IconName::Sparkle, "AI"), - (SettingsSection::Auth, IconName::GitBranch, "Auth"), - (SettingsSection::General, IconName::Settings, "General"), + let sections: [(SettingsSection, IconName, TrKey); 4] = [ + ( + SettingsSection::Theme, + IconName::Eye, + TrKey::SectionAppearance, + ), + (SettingsSection::Ai, IconName::Sparkle, TrKey::SectionAi), + ( + SettingsSection::Auth, + IconName::GitBranch, + TrKey::SectionAuth, + ), + ( + SettingsSection::General, + IconName::Settings, + TrKey::SectionGeneral, + ), ]; let mut sidebar = div() @@ -2192,15 +2254,16 @@ impl SettingsView { .color(Color::Muted), ) .child( - Label::new("Preferences") + Label::new(language.tr(TrKey::Preferences)) .size(LabelSize::Small) .weight(FontWeight::BOLD) .color(Color::Muted), ), ); - for (index, (section, icon, label)) in sections.into_iter().enumerate() { + for (index, (section, icon, key)) in sections.into_iter().enumerate() { let is_active = section == self.active_section; + let label = language.tr(key); let label_str: SharedString = label.into(); sidebar = sidebar.child( @@ -2269,30 +2332,32 @@ impl SettingsView { // ── Theme section ─────────────────────────────────────────────────── fn render_theme_section(&self, cx: &mut Context) -> impl IntoElement { let colors = cx.colors(); + let language = self.language; let mut section = div().v_flex().w_full().gap(px(16.)); section = section.child(Self::section_header( IconName::Eye, - "Appearance", - "Customize the look and feel of the application.", + language.tr(TrKey::SectionAppearance), + language.tr(TrKey::ThemeSectionDesc), )); // Appearance Mode (Auto/Light/Dark) let mut app_card = Self::setting_card(cx); app_card = app_card.child(Self::setting_label( - "Appearance Mode", - "Choose whether to show light or dark themes, or auto-detect.", + language.tr(TrKey::AppearanceModeTitle), + language.tr(TrKey::AppearanceModeDesc), )); let appearance_mode = cx.global::().settings().appearance_mode; let mut app_options = div().h_flex().w_full().gap(px(8.)); - for (mode, label) in [ - (AppearanceMode::Auto, "Auto"), - (AppearanceMode::Light, "Light"), - (AppearanceMode::Dark, "Dark"), + for (mode, key) in [ + (AppearanceMode::Auto, TrKey::ModeAuto), + (AppearanceMode::Light, TrKey::ModeLight), + (AppearanceMode::Dark, TrKey::ModeDark), ] { + let label = language.tr(key); let is_selected = mode == appearance_mode; let colors_clone = colors.clone(); @@ -2328,8 +2393,8 @@ impl SettingsView { // Theme grid let mut card = Self::setting_card(cx); card = card.child(Self::setting_label( - "Color Theme", - "Select a theme to change all interface colors.", + language.tr(TrKey::ColorThemeTitle), + language.tr(TrKey::ColorThemeDesc), )); let mut theme_grid = div().v_flex().w_full().gap(px(6.)); @@ -2403,7 +2468,7 @@ impl SettingsView { .bg(colors.hint_background) .items_center() .child( - Label::new("Active") + Label::new(language.tr(TrKey::ActiveBadge)) .size(LabelSize::XSmall) .color(Color::Accent) .weight(FontWeight::SEMIBOLD), @@ -2430,20 +2495,18 @@ impl SettingsView { .flex_1() .gap(px(2.)) .child( - Label::new("Custom Theme Editor") + Label::new(language.tr(TrKey::CustomThemeTitle)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new( - "Edit colors, create custom themes, and export as JSON.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(language.tr(TrKey::CustomThemeDesc)) + .size(LabelSize::XSmall) + .color(Color::Muted), ), ) .child( - Button::new("open-theme-editor", "Edit Theme") + Button::new("open-theme-editor", language.tr(TrKey::EditThemeBtn)) .size(ButtonSize::Compact) .style(ButtonStyle::Subtle) .icon(IconName::Edit) @@ -2464,18 +2527,19 @@ impl SettingsView { // ── Git section ────────────────────────────────────────────────────── fn render_git_section(&self, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); + let language = self.language; let mut section = div().v_flex().w_full().min_w_0().gap(px(16.)); section = section.child(Self::section_header( IconName::GitBranch, - "Accounts & Credentials", - "Use browser sign-in for GitHub or GitLab, manage all account profiles in one place, and keep SSH separate from HTTPS tokens.", + language.tr(TrKey::AccountsHeader), + language.tr(TrKey::AccountsSectionDesc), )); let mut quick_setup = Self::setting_card(cx); quick_setup = quick_setup .child(Self::setting_label( - "Quick Setup", - "Start by adding the provider account you want to use. Browser sign-in is the primary path. Manual setup is available for self-hosted or custom HTTPS remotes.", + language.tr(TrKey::QuickSetupTitle), + language.tr(TrKey::QuickSetupDesc), )) .child( div() @@ -2483,7 +2547,7 @@ impl SettingsView { .flex_wrap() .gap(px(8.)) .child( - Button::new("add-github-account", "Sign in with GitHub") + Button::new("add-github-account", language.tr(TrKey::SignInGitHub)) .style(ButtonStyle::Filled) .size(ButtonSize::Compact) .icon(IconName::Plus) @@ -2492,7 +2556,7 @@ impl SettingsView { })), ) .child( - Button::new("add-gitlab-account", "Add GitLab Account") + Button::new("add-gitlab-account", language.tr(TrKey::AddGitLab)) .style(ButtonStyle::Outlined) .size(ButtonSize::Compact) .icon(IconName::Plus) @@ -2501,7 +2565,7 @@ impl SettingsView { })), ) .child( - Button::new("add-custom-account", "Manual / Custom Host") + Button::new("add-custom-account", language.tr(TrKey::ManualCustom)) .style(ButtonStyle::Outlined) .size(ButtonSize::Compact) .icon(IconName::ExternalLink) @@ -2668,8 +2732,8 @@ impl SettingsView { let mut profiles_card = Self::setting_card(cx); profiles_card = profiles_card.child(Self::setting_label( - "Profiles", - "All saved accounts live here. Select one to connect it, paste a token, or adjust host matching.", + language.tr(TrKey::AuthProfilesTitle), + language.tr(TrKey::AuthProfilesDesc), )); if self.git_providers.is_empty() { @@ -2684,16 +2748,14 @@ impl SettingsView { .border_1() .border_color(colors.border_variant) .child( - Label::new("No provider accounts configured yet.") + Label::new(language.tr(TrKey::AuthProfilesEmptyTitle)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new( - "Add a GitHub account to start with the default flow, or choose Manual / Custom Host for enterprise and other HTTPS remotes.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(language.tr(TrKey::AuthProfilesEmptyDesc)) + .size(LabelSize::XSmall) + .color(Color::Muted), ), ); } else { @@ -3079,8 +3141,8 @@ impl SettingsView { let mut https_card = Self::setting_card(cx); https_card = https_card .child(Self::setting_label( - "Fallback HTTPS Token / PAT", - "Used only when no profile-specific account matches the remote host. Leave this empty unless you truly need a generic fallback.", + language.tr(TrKey::AuthHttpsFallbackTitle), + language.tr(TrKey::AuthHttpsFallbackDesc), )) .child(self.masked_editor_row( "git-https-token-input", @@ -3098,8 +3160,8 @@ impl SettingsView { let mut ssh_card = Self::setting_card(cx); ssh_card = ssh_card .child(Self::setting_label( - "SSH Key Override", - "Optional. If set, this key is tried first for SSH remotes. If SSH fails and you have a GitHub account above, rgitui automatically falls back to HTTPS.", + language.tr(TrKey::AuthSshOverrideTitle), + language.tr(TrKey::AuthSshOverrideDesc), )) .child( div() @@ -3107,13 +3169,9 @@ impl SettingsView { .w_full() .gap(px(6.)) .child( - div() - .flex_1() - .min_w_0() - .child(self.icon_editor_row( - &self.git_ssh_key_path_editor, - IconName::File, - )), + div().flex_1().min_w_0().child( + self.icon_editor_row(&self.git_ssh_key_path_editor, IconName::File), + ), ) .when(has_ssh_key, |el| { el.child( @@ -3145,17 +3203,12 @@ impl SettingsView { .border_1() .border_color(colors.border_variant) .child( - div() - .v_flex() - .flex_1() - .min_w_0() - .gap(px(2.)) - .child( - Label::new(format!("Detected: {}", key_path.clone())) - .size(LabelSize::XSmall) - .color(Color::Muted) - .truncate(), - ), + div().v_flex().flex_1().min_w_0().gap(px(2.)).child( + Label::new(format!("Detected: {}", key_path.clone())) + .size(LabelSize::XSmall) + .color(Color::Muted) + .truncate(), + ), ) .when(!already_selected, |el| { el.child( @@ -3188,12 +3241,12 @@ impl SettingsView { .flex_1() .gap(px(2.)) .child( - Label::new("Sign Commits") + Label::new(language.tr(TrKey::AuthSignCommitsTitle)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new("Sign commits with your configured GPG key.") + Label::new(language.tr(TrKey::AuthSignCommitsDesc)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -3217,8 +3270,8 @@ impl SettingsView { ), ) .child(Self::setting_label( - "GPG Key ID", - "Leave empty to use your existing git config default signing key.", + language.tr(TrKey::AuthGpgKeyTitle), + language.tr(TrKey::AuthGpgKeyDesc), )) .child(self.icon_editor_row(&self.git_gpg_key_id_editor, IconName::Eye)); section = section.child(gpg_card); @@ -3229,29 +3282,23 @@ impl SettingsView { .v_flex() .gap(px(6.)) .child(Self::setting_label( - "How Authentication Works", - "rgitui detects the remote URL protocol and authenticates accordingly.", + language.tr(TrKey::AuthHowTitle), + language.tr(TrKey::AuthHowDesc), )) .child( - Label::new( - "HTTPS remotes: uses your GitHub account token (above), or falls back to your system git credential helper (e.g. gh auth).", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(language.tr(TrKey::AuthHowHttps)) + .size(LabelSize::XSmall) + .color(Color::Muted), ) .child( - Label::new( - "SSH remotes: if no SSH key is configured above, and your system has an HTTPS credential helper, rgitui automatically rewrites SSH to HTTPS so your GitHub login works.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(language.tr(TrKey::AuthHowSshFallback)) + .size(LabelSize::XSmall) + .color(Color::Muted), ) .child( - Label::new( - "SSH remotes with a key: uses the SSH key path above (or ssh-agent) directly.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(language.tr(TrKey::AuthHowSshKey)) + .size(LabelSize::XSmall) + .color(Color::Muted), ), ), ); @@ -3263,15 +3310,44 @@ impl SettingsView { fn render_general_section(&self, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); let current_max = self.max_recent_repos; + let language = self.language; + let language_select = self.language_select.clone(); let mut section = div().v_flex().w_full().gap(px(16.)); section = section.child(Self::section_header( IconName::Settings, - "General", - "Application preferences and behavior.", + language.tr(TrKey::GeneralTitle), + language.tr(TrKey::GeneralDesc), )); + // Language card + let mut language_card = Self::setting_card(cx); + language_card = language_card.child( + div() + .h_flex() + .w_full() + .items_center() + .child( + div() + .v_flex() + .flex_1() + .gap(px(2.)) + .child( + Label::new(language.tr(TrKey::LanguageTitle)) + .size(LabelSize::Small) + .weight(FontWeight::SEMIBOLD), + ) + .child( + Label::new(language.tr(TrKey::LanguageDesc)) + .size(LabelSize::XSmall) + .color(Color::Muted), + ), + ) + .child(language_select), + ); + section = section.child(language_card); + // Recent repos card let mut repos_card = Self::setting_card(cx); repos_card = repos_card.child( @@ -3285,12 +3361,12 @@ impl SettingsView { .flex_1() .gap(px(2.)) .child( - Label::new("Max Recent Repositories") + Label::new(language.tr(TrKey::MaxRecentTitle)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new("Number of repos shown in the recent list.") + Label::new(language.tr(TrKey::MaxRecentDesc)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -3353,14 +3429,18 @@ impl SettingsView { .v_flex() .gap(px(8.)) .child(Self::setting_label( - "UI Density", - "Adjust the spacing and sizing of UI elements.", + language.tr(TrKey::UiDensityTitle), + language.tr(TrKey::UiDensityDesc), )) .child({ - let options: [(&str, &str, Compactness); 3] = [ - ("compact", "Compact", Compactness::Compact), - ("default", "Default", Compactness::Default), - ("comfortable", "Comfortable", Compactness::Comfortable), + let options: [(&str, TrKey, Compactness); 3] = [ + ("compact", TrKey::DensityCompact, Compactness::Compact), + ("default", TrKey::DensityDefault, Compactness::Default), + ( + "comfortable", + TrKey::DensityComfortable, + Compactness::Comfortable, + ), ]; let mut row = div() .h_flex() @@ -3368,7 +3448,8 @@ impl SettingsView { .p(px(3.)) .rounded(px(8.)) .bg(colors.element_background); - for (id, label, variant) in options { + for (id, key, variant) in options { + let label = language.tr(key); let is_selected = current_compactness == variant; let hover_bg = colors.ghost_element_hover; let mut btn = div() @@ -3422,12 +3503,12 @@ impl SettingsView { .flex_1() .gap(px(2.)) .child( - Label::new("Font Size") + Label::new(language.tr(TrKey::FontSizeTitle)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new("Base font size for the user interface (8 - 24).") + Label::new(language.tr(TrKey::FontSizeDesc)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -3499,12 +3580,12 @@ impl SettingsView { .flex_1() .gap(px(2.)) .child( - Label::new("Show Line Numbers in Diff") + Label::new(language.tr(TrKey::ShowLineNumbersTitle)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new("Display line numbers alongside diff content.") + Label::new(language.tr(TrKey::ShowLineNumbersDesc)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -3538,16 +3619,14 @@ impl SettingsView { .flex_1() .gap(px(2.)) .child( - Label::new("Wrap Long Lines in Diff") + Label::new(language.tr(TrKey::WrapLinesTitle)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new( - "Wrap overflowing lines instead of scrolling horizontally.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(language.tr(TrKey::WrapLinesDesc)) + .size(LabelSize::XSmall) + .color(Color::Muted), ), ) .child( @@ -3573,23 +3652,37 @@ impl SettingsView { .v_flex() .gap(px(8.)) .child(Self::setting_label( - "Default Diff View Mode", - "Choose how diffs are displayed.", + language.tr(TrKey::DiffModeTitle), + language.tr(TrKey::DiffModeDesc), )) - .child(self.pill_group( - "diff-view-mode", - &["Unified", "Side-by-Side"], - &self.diff_view_mode.to_string(), - |this, value, cx| { - this.diff_view_mode = match value.as_str() { - "Unified" => DiffViewMode::Unified, - "Side-by-Side" => DiffViewMode::SideBySide, - _ => DiffViewMode::Unified, - }; - this.save_settings(cx); - }, - cx, - )), + .child({ + let selected = match self.diff_view_mode { + DiffViewMode::Unified => language.tr(TrKey::DiffUnified), + DiffViewMode::SideBySide => language.tr(TrKey::DiffSideBySide), + }; + self.pill_group( + "diff-view-mode", + &[ + language.tr(TrKey::DiffUnified), + language.tr(TrKey::DiffSideBySide), + ], + selected, + |this, value, cx| { + // The pill hands back the visible (translated) + // label, so accept either language. + this.diff_view_mode = if value + == Language::SimplifiedChinese.tr(TrKey::DiffSideBySide) + || value == Language::English.tr(TrKey::DiffSideBySide) + { + DiffViewMode::SideBySide + } else { + DiffViewMode::Unified + }; + this.save_settings(cx); + }, + cx, + ) + }), ); section = section.child(diff_card); @@ -3600,24 +3693,44 @@ impl SettingsView { .v_flex() .gap(px(8.)) .child(Self::setting_label( - "Graph Style", - "Visual style for the commit graph rendering.", - )) - .child(self.pill_group( - "graph-style", - &["Rails", "Curved", "Angular"], - &self.graph_style.to_string(), - |this, value, cx| { - this.graph_style = match value.as_str() { - "Rails" => GraphStyle::Rails, - "Curved" => GraphStyle::Curved, - "Angular" => GraphStyle::Angular, - _ => GraphStyle::Rails, - }; - this.save_settings(cx); - }, - cx, + language.tr(TrKey::GraphTitle), + language.tr(TrKey::GraphDesc), )) + .child({ + let selected = match self.graph_style { + GraphStyle::Rails => language.tr(TrKey::GraphRails), + GraphStyle::Curved => language.tr(TrKey::GraphCurved), + GraphStyle::Angular => language.tr(TrKey::GraphAngular), + }; + self.pill_group( + "graph-style", + &[ + language.tr(TrKey::GraphRails), + language.tr(TrKey::GraphCurved), + language.tr(TrKey::GraphAngular), + ], + selected, + |this, value, cx| { + // The pill hands back the visible (translated) + // label, so accept either language. + let zh = Language::SimplifiedChinese; + let en = Language::English; + this.graph_style = if value == zh.tr(TrKey::GraphCurved) + || value == en.tr(TrKey::GraphCurved) + { + GraphStyle::Curved + } else if value == zh.tr(TrKey::GraphAngular) + || value == en.tr(TrKey::GraphAngular) + { + GraphStyle::Angular + } else { + GraphStyle::Rails + }; + this.save_settings(cx); + }, + cx, + ) + }) .child({ let show_subject = self.show_subject_column; div() @@ -3631,12 +3744,12 @@ impl SettingsView { .flex_1() .gap(px(2.)) .child( - Label::new("Show Subject Column") + Label::new(language.tr(TrKey::ShowSubjectTitle)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new("Display commit subject in the graph.") + Label::new(language.tr(TrKey::ShowSubjectDesc)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -3669,8 +3782,8 @@ impl SettingsView { .v_flex() .gap(px(8.)) .child(Self::setting_label( - "Auto-Fetch Interval", - "How often to automatically fetch from remotes in the background.", + language.tr(TrKey::AutoFetchTitle), + language.tr(TrKey::AutoFetchDesc), )) // A `Select`, not a pill row: five fixed-width pills is the // widest closed choice on the page, and unlike commit style @@ -3693,16 +3806,14 @@ impl SettingsView { .flex_1() .gap(px(2.)) .child( - Label::new("Confirm Before Destructive Operations") + Label::new(language.tr(TrKey::ConfirmDestructiveTitle)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new( - "Show a confirmation dialog before force push, branch delete, discard changes, and similar actions.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(language.tr(TrKey::ConfirmDestructiveDesc)) + .size(LabelSize::XSmall) + .color(Color::Muted), ), ) .child( @@ -3740,16 +3851,14 @@ impl SettingsView { .flex_1() .gap(px(2.)) .child( - Label::new("Check for Updates on Startup") + Label::new(language.tr(TrKey::CheckUpdatesTitle)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new( - "Contact api.github.com once per day to see if a newer release is available. Turn off to keep rgitui offline.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(language.tr(TrKey::CheckUpdatesDesc)) + .size(LabelSize::XSmall) + .color(Color::Muted), ), ) .child( @@ -3786,16 +3895,14 @@ impl SettingsView { .flex_1() .gap(px(2.)) .child( - Label::new("Watch All Worktrees") + Label::new(language.tr(TrKey::WatchWorktreesTitle)) .size(LabelSize::Small) .weight(FontWeight::SEMIBOLD), ) .child( - Label::new( - "Refresh the graph whenever files change in any linked worktree, not just the current one. Useful when you have multiple worktrees open and work is happening in them in parallel.", - ) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(language.tr(TrKey::WatchWorktreesDesc)) + .size(LabelSize::XSmall) + .color(Color::Muted), ), ) .child( @@ -3835,7 +3942,7 @@ impl SettingsView { .any(|app| app.command == editor_custom_text); let mut terminal_section = div().v_flex().gap(px(6.)).child( - Label::new("Terminal") + Label::new(language.tr(TrKey::TerminalLabel)) .size(LabelSize::XSmall) .weight(FontWeight::SEMIBOLD), ); @@ -3924,7 +4031,7 @@ impl SettingsView { .v_flex() .gap(px(2.)) .child( - Label::new("Custom command (overrides selection)") + Label::new(language.tr(TrKey::CustomCommandLabel)) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -3932,7 +4039,7 @@ impl SettingsView { ); let mut editor_section = div().v_flex().gap(px(6.)).child( - Label::new("Editor") + Label::new(language.tr(TrKey::EditorLabel)) .size(LabelSize::XSmall) .weight(FontWeight::SEMIBOLD), ); @@ -4018,7 +4125,7 @@ impl SettingsView { .v_flex() .gap(px(2.)) .child( - Label::new("Custom command (overrides selection)") + Label::new(language.tr(TrKey::CustomCommandLabel)) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -4030,8 +4137,8 @@ impl SettingsView { .v_flex() .gap(px(16.)) .child(Self::setting_label( - "External Tools", - "Select a detected application or enter a custom command.", + language.tr(TrKey::ExternalToolsTitle), + language.tr(TrKey::ExternalToolsDesc), )) .child(terminal_section) .child(editor_section), @@ -4043,9 +4150,8 @@ impl SettingsView { // it follows the user's keymap.json instead of quoting a default. let mut shortcuts_card = Self::setting_card(cx); let mut shortcuts_list = div().v_flex().gap(px(8.)).child(Self::setting_label( - "Keyboard Shortcuts", - "The bindings in force for a few common actions. Open the full \ - reference from the workspace for all of them.", + language.tr(TrKey::ShortcutsTitle), + language.tr(TrKey::ShortcutsDesc), )); for id in QUICK_REFERENCE_COMMANDS { shortcuts_list = shortcuts_list.child(self.render_shortcut_row(*id, cx)); @@ -4064,8 +4170,8 @@ impl SettingsView { .v_flex() .gap(px(8.)) .child(Self::setting_label( - "Config File", - "Location of the settings file on disk.", + language.tr(TrKey::ConfigTitle), + language.tr(TrKey::ConfigDesc), )) .child( div() @@ -4081,7 +4187,7 @@ impl SettingsView { ), ) .child( - Button::new("reveal-config-dir", "Reveal") + Button::new("reveal-config-dir", language.tr(TrKey::RevealBtn)) .style(ButtonStyle::Subtle) .size(ButtonSize::Compact) .icon(IconName::Folder) @@ -4117,7 +4223,8 @@ impl SettingsView { /// keymap currently binds it to. /// /// A binding the user defined is tinted and labelled, matching the badge in - /// the full shortcut reference. + /// the full shortcut reference. The description is localized; the keystroke + /// comes from the keymap in force and is never translated. fn render_shortcut_row(&self, id: CommandId, cx: &Context) -> impl IntoElement { let colors = cx.colors(); let summary = crate::keymap::summary(cx); @@ -4138,7 +4245,7 @@ impl SettingsView { .gap(px(6.)) .py(px(4.)) .child( - Label::new(SharedString::from(id.description())) + Label::new(SharedString::from(id.description_tr(self.language))) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -4172,6 +4279,10 @@ impl SettingsView { fn keymap_file_row(cx: &Context) -> impl IntoElement { let path = crate::keymap::keymap_path(); let path_display = path.display().to_string(); + let language = cx + .try_global::() + .map(|state| state.settings().language) + .unwrap_or_default(); let editor_command = cx .try_global::() .map(|state| state.settings().editor_command.clone()) @@ -4191,7 +4302,7 @@ impl SettingsView { ), ) .child( - Button::new("open-keymap-file", "Edit keymap.json") + Button::new("open-keymap-file", language.tr(TrKey::EditKeymapBtn)) .style(ButtonStyle::Subtle) .size(ButtonSize::Compact) .icon(IconName::Settings) @@ -4218,6 +4329,39 @@ impl SettingsView { window: &mut Window, cx: &mut Context, ) -> AnyElement { + // Keep input placeholders in the active language: the editors are + // created once in `new`, so without this they would freeze in the + // language that was active at startup. Example values (URLs, hosts, + // key paths) stay as-is. + let sync = self.language; + self.git_https_token_editor.update(cx, |ed, _| { + ed.set_placeholder(sync.tr(TrKey::SetHttpsTokenPh)); + }); + self.git_gpg_key_id_editor.update(cx, |ed, _| { + ed.set_placeholder(sync.tr(TrKey::SetGpgPh)); + }); + self.provider_username_editor.update(cx, |ed, _| { + ed.set_placeholder(sync.tr(TrKey::SetUsuallyEmpty)); + }); + self.provider_token_editor.update(cx, |ed, _| { + ed.set_placeholder(sync.tr(TrKey::SetAccountTokenPh)); + }); + self.terminal_command_editor.update(cx, |ed, _| { + ed.set_placeholder(sync.tr(TrKey::SetCustomCmdPh)); + }); + self.editor_command_editor.update(cx, |ed, _| { + ed.set_placeholder(sync.tr(TrKey::SetCustomCmdPh)); + }); + for provider in AiProvider::ALL { + if let Some(editor) = self.ai_key_editors.get(provider) { + let placeholder = + sync.tr(TrKey::AiKeyPlaceholderFmt) + .replacen("{}", provider.display_name(), 1); + editor.update(cx, |ed, _| { + ed.set_placeholder(placeholder); + }); + } + } // Give wrapping text a definite width during GPUI's measurement pass. // A percentage width beneath the scroll container is resolved too late // by the macOS text system and can cache a one-glyph wrap width. @@ -4238,22 +4382,23 @@ impl SettingsView { // The title names the page. Every section previously rendered the // literal string "Preferences", so the most prominent text on screen // carried no information at all. + let language = self.language; let (page_title, page_subtitle) = match self.active_section { SettingsSection::Theme => ( - "Appearance", - "Theme, layout, and visual defaults for the application.", + language.tr(TrKey::SectionAppearance), + language.tr(TrKey::ThemePageDesc), ), SettingsSection::Ai => ( - "AI", - "Providers, models, and how commit messages get written.", + language.tr(TrKey::SectionAi), + language.tr(TrKey::AiPageDesc), ), SettingsSection::Auth => ( - "Accounts", - "Account profiles, HTTPS tokens, and SSH configuration.", + language.tr(TrKey::AccountsTitle), + language.tr(TrKey::AccountsDesc), ), SettingsSection::General => ( - "General", - "General application behavior and workspace defaults.", + language.tr(TrKey::GeneralTitle), + language.tr(TrKey::GeneralPageDesc), ), }; @@ -4304,7 +4449,7 @@ impl SettingsView { Label::new(if is_error { message } else { - "Saved".to_string() + self.language.tr(TrKey::SavedFeedback).to_string() }) .size(LabelSize::XSmall) .color(if is_error { Color::Error } else { Color::Muted }) diff --git a/crates/rgitui_workspace/src/shortcuts_help.rs b/crates/rgitui_workspace/src/shortcuts_help.rs index cff36ff..e69ee4a 100644 --- a/crates/rgitui_workspace/src/shortcuts_help.rs +++ b/crates/rgitui_workspace/src/shortcuts_help.rs @@ -24,6 +24,7 @@ use rgitui_ui::{ use crate::keymap::{self, CommandBindings, CommandGroup, KeymapSummary, NoteSeverity}; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; #[derive(Debug, Clone)] pub enum ShortcutsHelpEvent { @@ -258,6 +259,10 @@ impl Render for ShortcutsHelp { let keymap_file = keymap::keymap_path().display().to_string(); let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let viewport = window.viewport_size(); let viewport_width = f32::from(viewport.width); let viewport_height = f32::from(viewport.height); @@ -388,7 +393,7 @@ impl Render for ShortcutsHelp { .min_w_0() .gap(px(2.)) .child( - Label::new("Keyboard Shortcuts") + Label::new(language.tr(TrKey::ShortcutsTitle)) .size(LabelSize::Large) .weight(FontWeight::SEMIBOLD), ) @@ -401,7 +406,7 @@ impl Render for ShortcutsHelp { ), ) .child( - Button::new("shortcuts-open-keymap", "Edit keymap.json") + Button::new("shortcuts-open-keymap", language.tr(TrKey::EditKeymapBtn)) .style(ButtonStyle::Subtle) .size(ButtonSize::Compact) .icon(IconName::Settings) @@ -473,7 +478,7 @@ impl Render for ShortcutsHelp { .border_color(colors.border_variant) .bg(colors.surface_background) .child( - Label::new("Press Esc or click outside to close") + Label::new(language.tr(TrKey::ShortcutsCloseHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ) @@ -481,9 +486,12 @@ impl Render for ShortcutsHelp { palette_hint.filter(|_| viewport_width >= 520.0), |footer, hint| { footer.child( - Label::new(SharedString::from(format!("More actions: {hint}"))) - .size(LabelSize::XSmall) - .color(Color::Muted), + Label::new(SharedString::from(format!( + "{}: {hint}", + language.tr(TrKey::MoreActionsPre) + ))) + .size(LabelSize::XSmall) + .color(Color::Muted), ) }, ), diff --git a/crates/rgitui_workspace/src/sidebar.rs b/crates/rgitui_workspace/src/sidebar.rs index 62ba71d..249cfc0 100644 --- a/crates/rgitui_workspace/src/sidebar.rs +++ b/crates/rgitui_workspace/src/sidebar.rs @@ -17,7 +17,7 @@ use gpui::{ use rgitui_git::{ BranchInfo, FileChangeKind, FileStatus, RemoteInfo, StashEntry, TagInfo, WorktreeInfo, }; -use rgitui_settings::{Compactness, SettingsState}; +use rgitui_settings::{Compactness, SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Badge, Button, ButtonSize, ButtonStyle, CheckState, Checkbox, DiffStat, IconButton, IconName, @@ -181,6 +181,9 @@ impl BranchAgeFilter { Self::OneYear, ]; + /// English age-filter vocabulary. Rendering uses [`TrKey`] instead; this + /// stays as documentation of the exact source literals. + #[allow(dead_code)] fn label(self) -> &'static str { match self { Self::Any => "Any time", @@ -483,6 +486,10 @@ impl EventEmitter for Sidebar {} impl Sidebar { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); let expanded_sections = vec![ SidebarSection::LocalBranches, SidebarSection::Remotes, @@ -500,15 +507,19 @@ impl Sidebar { SidebarItem::SectionHeader(SidebarSection::UnstagedChanges), ]; + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let branch_filter_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Filter branches..."); + ti.set_placeholder(language.tr(TrKey::FilterBranchesPh)); ti.set_compact(true); ti }); let remote_branch_filter_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Filter remote branches..."); + ti.set_placeholder(language.tr(TrKey::FilterRemotePh)); ti.set_compact(true); ti }); @@ -1776,11 +1787,18 @@ impl Sidebar { target: BranchFilterTarget, cx: &mut Context, ) -> gpui::Stateful { + let language = cx.global::().settings().language; + self.branch_filter_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::FilterBranchesPh)); + }); + self.remote_branch_filter_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::FilterRemotePh)); + }); let selected_bg = cx.colors().ghost_element_selected; let hover_bg = cx.colors().ghost_element_hover; let (title, editor, active_age, filters_active) = match target { BranchFilterTarget::Local => ( - "Branch filters", + language.tr(TrKey::PopBranchFilters), self.branch_filter_editor.clone(), self.local_branch_age_filter, !self.branch_filter.is_empty() @@ -1788,7 +1806,7 @@ impl Sidebar { || self.local_branch_age_filter != BranchAgeFilter::Any, ), BranchFilterTarget::Remote => ( - "Remote branch filters", + language.tr(TrKey::PopRemoteFilters), self.remote_branch_filter_editor.clone(), self.remote_branch_age_filter, !self.remote_branch_filter.is_empty() @@ -1805,13 +1823,13 @@ impl Sidebar { .color(Color::Default), ) .child( - Label::new("Name") + Label::new(language.tr(TrKey::NameLabel)) .size(LabelSize::XSmall) .color(Color::Muted), ) .child(div().w_full().child(editor)) .child( - Label::new("Last used") + Label::new(language.tr(TrKey::SortLastUsed)) .size(LabelSize::XSmall) .color(Color::Muted), ); @@ -1849,13 +1867,19 @@ impl Sidebar { ) })) .child( - Label::new(age_filter.label()) - .size(LabelSize::XSmall) - .color(if selected { - Color::Accent - } else { - Color::Default - }), + Label::new(match age_filter { + BranchAgeFilter::Any => language.tr(TrKey::AgeAny), + BranchAgeFilter::SevenDays => language.tr(TrKey::AgeSevenDays), + BranchAgeFilter::ThirtyDays => language.tr(TrKey::AgeThirtyDays), + BranchAgeFilter::NinetyDays => language.tr(TrKey::AgeNinetyDays), + BranchAgeFilter::OneYear => language.tr(TrKey::AgeOneYear), + }) + .size(LabelSize::XSmall) + .color(if selected { + Color::Accent + } else { + Color::Default + }), ), ); } @@ -1894,7 +1918,7 @@ impl Sidebar { }), ) .child( - Label::new("Only my branches") + Label::new(language.tr(TrKey::OnlyMine)) .size(LabelSize::XSmall) .color(Color::Default), ), @@ -1904,7 +1928,7 @@ impl Sidebar { let weak = weak.clone(); popover = popover.child( div().w_full().flex().justify_end().child( - Button::new("clear-branch-filters", "Clear") + Button::new("clear-branch-filters", language.tr(TrKey::ClearBtn)) .size(ButtonSize::Compact) .style(ButtonStyle::Subtle) .disabled(!filters_active) @@ -1925,13 +1949,14 @@ impl Sidebar { list: ChangeList, cx: &mut Context, ) -> gpui::Stateful { + let language = cx.global::().settings().language; let enabled = cx .global::() .settings() .show_change_line_stats; let title = match list { - ChangeList::Staged => "Staged files", - ChangeList::Unstaged => "Unstaged files", + ChangeList::Staged => language.tr(TrKey::PopStagedFiles), + ChangeList::Unstaged => language.tr(TrKey::PopUnstagedFiles), }; let weak = cx.weak_entity(); self.popover_frame("change-list-popover", CHANGE_LIST_POPOVER_WIDTH, cx) @@ -1950,7 +1975,7 @@ impl Sidebar { CheckState::Unchecked }, ) - .label("Show line counts") + .label(language.tr(TrKey::ShowLineCounts)) .on_toggle(move |_: &mut Window, cx: &mut App| { weak.update(cx, |_, cx| { cx.emit(SidebarEvent::SetChangeLineStats(!enabled)); @@ -1959,7 +1984,7 @@ impl Sidebar { }), ) .child( - Label::new("Applies to staged and unstaged files.") + Label::new(language.tr(TrKey::ChangeListHint)) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -1974,6 +1999,7 @@ impl Render for Sidebar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); let compactness = cx.global::().settings().compactness; + let language = cx.global::().settings().language; let item_h = compactness.spacing(24.0); let header_h = compactness.spacing(26.0); let list_heights = self.plan_list_heights(item_h, header_h, compactness); @@ -2072,7 +2098,7 @@ impl Render for Sidebar { IconButton::new("sidebar-open-repo", IconName::Plus) .size(ButtonSize::Compact) .color(Color::Muted) - .tooltip("Open repository") + .tooltip(language.tr(TrKey::TipOpenRepo)) .on_click(cx.listener(|_this, _: &ClickEvent, _, cx| { cx.emit(SidebarEvent::OpenRepo); })), @@ -2134,7 +2160,7 @@ impl Render for Sidebar { .color(Color::Muted), ) .child( - Label::new("Branches") + Label::new(language.tr(TrKey::SideBranches)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -2143,7 +2169,7 @@ impl Render for Sidebar { .child(self.render_popover_trigger( SidebarPopover::BranchFilter(BranchFilterTarget::Local), "local-branch-filters", - "Filter branches by name or last used", + language.tr(TrKey::TipFilterLocal), local_filters_active, cx, )) @@ -2333,7 +2359,7 @@ impl Render for Sidebar { div() .id(SharedString::from(format!("merged-branch-{i}"))) .flex_shrink_0() - .tooltip(Tooltip::text("Merged into current branch")) + .tooltip(Tooltip::text(language.tr(TrKey::TipMerged))) .child( rgitui_ui::Icon::new(IconName::GitMerge) .size(rgitui_ui::IconSize::XSmall) @@ -2382,7 +2408,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Muted) - .tooltip("Copy branch name") + .tooltip(language.tr(TrKey::TipCopyBranch)) .on_click(move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = w_cp.clone().update(cx, |_this, cx| { cx.emit(SidebarEvent::BranchCopyName(bn_cp.to_string())); @@ -2407,7 +2433,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Success) - .tooltip("Checkout branch") + .tooltip(language.tr(TrKey::TipCheckoutBranch)) .on_click(move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = w_co.clone().update(cx, |_this, cx| { cx.emit(SidebarEvent::BranchCheckout(bn_co.to_string())); @@ -2421,7 +2447,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Muted) - .tooltip("Merge into current branch") + .tooltip(language.tr(TrKey::TipMergeBranch)) .on_click(move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = w_mg.clone().update(cx, |_this, cx| { cx.emit(SidebarEvent::MergeBranch(bn_mg.to_string())); @@ -2435,7 +2461,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Muted) - .tooltip("Rename branch") + .tooltip(language.tr(TrKey::TipRenameBranch)) .on_click(move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = w_rn.clone().update(cx, |_this, cx| { cx.emit(SidebarEvent::BranchRename(bn_rn.to_string())); @@ -2449,7 +2475,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Deleted) - .tooltip("Delete branch") + .tooltip(language.tr(TrKey::TipDeleteBranch)) .on_click(move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = w_dl.clone().update(cx, |_this, cx| { cx.emit(SidebarEvent::BranchDelete(bn_dl.to_string())); @@ -2523,7 +2549,7 @@ impl Render for Sidebar { .color(Color::Muted), ) .child( - Label::new("Remotes") + Label::new(language.tr(TrKey::SideRemotes)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -2557,7 +2583,7 @@ impl Render for Sidebar { .px(px(16.)) .items_center() .child( - Label::new("No remotes configured") + Label::new(language.tr(TrKey::NoRemotes)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -2644,7 +2670,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Info) - .tooltip("Fetch from remote") + .tooltip(language.tr(TrKey::TipFetchRemote)) .on_click( move |_: &ClickEvent, _, cx: &mut App| { let _ = @@ -2666,7 +2692,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Warning) - .tooltip("Pull from remote") + .tooltip(language.tr(TrKey::TipPullRemote)) .on_click( move |_: &ClickEvent, _, cx: &mut App| { let _ = @@ -2688,7 +2714,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Success) - .tooltip("Push to remote") + .tooltip(language.tr(TrKey::TipPushRemote)) .on_click( move |_: &ClickEvent, _, cx: &mut App| { let _ = @@ -2710,7 +2736,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Deleted) - .tooltip("Remove remote") + .tooltip(language.tr(TrKey::TipRemoveRemote)) .on_click( move |_: &ClickEvent, _, cx: &mut App| { let _ = @@ -2783,7 +2809,7 @@ impl Render for Sidebar { .color(Color::Muted), ) .child( - Label::new("Remote Branches") + Label::new(language.tr(TrKey::SideRemoteBranches)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -2792,7 +2818,7 @@ impl Render for Sidebar { .child(self.render_popover_trigger( SidebarPopover::BranchFilter(BranchFilterTarget::Remote), "remote-branch-filters", - "Filter remote branches by name or last used", + language.tr(TrKey::TipFilterRemote), remote_filters_active, cx, )) @@ -2983,7 +3009,7 @@ impl Render for Sidebar { .color(Color::Muted), ) .child( - Label::new("Tags") + Label::new(language.tr(TrKey::SideTags)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -3017,7 +3043,7 @@ impl Render for Sidebar { .px(px(16.)) .items_center() .child( - Label::new("No tags") + Label::new(language.tr(TrKey::NoTags)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -3112,7 +3138,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Muted) - .tooltip("Checkout tag") + .tooltip(language.tr(TrKey::TipCheckoutTag)) .on_click( move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = @@ -3135,7 +3161,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Deleted) - .tooltip("Delete tag") + .tooltip(language.tr(TrKey::TipDeleteTag)) .on_click( move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = @@ -3211,7 +3237,7 @@ impl Render for Sidebar { .color(Color::Muted), ) .child( - Label::new("Stashes") + Label::new(language.tr(TrKey::SideStashes)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -3245,7 +3271,7 @@ impl Render for Sidebar { .px(px(16.)) .items_center() .child( - Label::new("No stashes") + Label::new(language.tr(TrKey::StashesEmpty)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -3347,7 +3373,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Success) - .tooltip("Apply stash") + .tooltip(language.tr(TrKey::ApplyStashLbl)) .on_click( move |_: &ClickEvent, _: &mut Window, cx: &mut App| { @@ -3371,7 +3397,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Info) - .tooltip("Pop stash") + .tooltip(language.tr(TrKey::PopStashLbl)) .on_click( move |_: &ClickEvent, _: &mut Window, cx: &mut App| { @@ -3394,7 +3420,7 @@ impl Render for Sidebar { IconName::GitBranch, ) .size(ButtonSize::Compact) - .tooltip("Create branch from stash") + .tooltip(language.tr(TrKey::TipStashBranch)) .on_click( move |_: &ClickEvent, _: &mut Window, cx: &mut App| { @@ -3418,7 +3444,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Deleted) - .tooltip("Drop stash") + .tooltip(language.tr(TrKey::TipDropStash)) .on_click( move |_: &ClickEvent, _: &mut Window, cx: &mut App| { @@ -3486,7 +3512,7 @@ impl Render for Sidebar { .color(Color::Muted), ) .child( - Label::new("Worktrees") + Label::new(language.tr(TrKey::SideWorktrees)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -3498,7 +3524,7 @@ impl Render for Sidebar { .min_w_0() .overflow_hidden() .child( - Button::new("new-worktree", "New Worktree") + Button::new("new-worktree", language.tr(TrKey::SideNewWorktree)) .icon(IconName::Plus) .size(ButtonSize::Compact) .style(ButtonStyle::Subtle) @@ -3537,7 +3563,7 @@ impl Render for Sidebar { .px(px(16.)) .items_center() .child( - Label::new("No worktrees") + Label::new(language.tr(TrKey::NoWorktrees)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -3619,7 +3645,7 @@ impl Render for Sidebar { )) .when(worktree.is_current, |el| { el.child( - Label::new("(current)") + Label::new(language.tr(TrKey::CurrentTag)) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -3649,7 +3675,7 @@ impl Render for Sidebar { ) .size(ButtonSize::Compact) .color(Color::Deleted) - .tooltip("Remove worktree") + .tooltip(language.tr(TrKey::TipRemoveWorktree)) .on_click(move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = w_rm.clone().update(cx, |_: &mut Sidebar, cx| { cx.emit(SidebarEvent::WorktreeRemove( @@ -3711,7 +3737,7 @@ impl Render for Sidebar { .color(Color::Muted), ) .child( - Label::new("Staged") + Label::new(language.tr(TrKey::SideStaged)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -3761,7 +3787,7 @@ impl Render for Sidebar { .when(has_staged, |el| { el.child( div().id("unstage-all-btn").child( - Button::new("unstage-all", "Unstage All") + Button::new("unstage-all", language.tr(TrKey::SideUnstageAll)) .icon(IconName::Minus) .size(ButtonSize::Compact) .style(ButtonStyle::Subtle) @@ -3775,7 +3801,7 @@ impl Render for Sidebar { .child(self.render_popover_trigger( SidebarPopover::ChangeListOptions(ChangeList::Staged), "staged-options", - "Staged list options", + language.tr(TrKey::TipStagedOpts), show_line_stats, cx, )) @@ -3815,7 +3841,7 @@ impl Render for Sidebar { .px(px(16.)) .items_center() .child( - Label::new("No staged changes") + Label::new(language.tr(TrKey::SideNoStaged)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -3923,7 +3949,7 @@ impl Render for Sidebar { .text_color(Color::Added.color(cx)) .hover(|s| s.bg(colors.ghost_element_hover)) .cursor_pointer() - .tooltip(Tooltip::text("Unstage file")) + .tooltip(Tooltip::text(language.tr(TrKey::TipUnstageFile))) .on_click({ let w_unstg = w.clone(); move |_: &ClickEvent, _: &mut Window, cx: &mut App| { @@ -4047,7 +4073,7 @@ impl Render for Sidebar { .color(Color::Muted), ) .child( - Label::new("Unstaged") + Label::new(language.tr(TrKey::SideUnstaged)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -4109,7 +4135,7 @@ impl Render for Sidebar { .when(has_unstaged, |el| { el.child( div().id("stage-all-btn").child( - Button::new("stage-all", "Stage All") + Button::new("stage-all", language.tr(TrKey::SideStageAll)) .icon(IconName::Plus) .size(ButtonSize::Compact) .style(ButtonStyle::Subtle) @@ -4123,7 +4149,7 @@ impl Render for Sidebar { .child(self.render_popover_trigger( SidebarPopover::ChangeListOptions(ChangeList::Unstaged), "unstaged-options", - "Unstaged list options", + language.tr(TrKey::TipUnstagedOpts), show_line_stats, cx, )) @@ -4163,7 +4189,7 @@ impl Render for Sidebar { .px(px(16.)) .items_center() .child( - Label::new("Working tree clean") + Label::new(language.tr(TrKey::SideWorkingClean)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -4267,7 +4293,7 @@ impl Render for Sidebar { .icon(IconName::FileConflict) .size(ButtonSize::Compact) .style(ButtonStyle::Tinted(rgitui_ui::TintColor::Warning)) - .tooltip("Open conflict resolver") + .tooltip(language.tr(TrKey::TipConflictResolver)) .on_click(move |_: &ClickEvent, _: &mut Window, cx: &mut App| { w_resolve .clone() @@ -4304,7 +4330,7 @@ impl Render for Sidebar { .text_color(Color::Deleted.color(cx)) .hover(|s| s.bg(colors.ghost_element_hover)) .cursor_pointer() - .tooltip(crate::keymap::command_tooltip("Discard changes", CommandId::DiscardRow)) + .tooltip(crate::keymap::command_tooltip(language.tr(TrKey::TipDiscardChanges), CommandId::DiscardRow)) .on_click({ let w_dis = w.clone(); move |_: &ClickEvent, _: &mut Window, cx: &mut App| { @@ -4332,7 +4358,7 @@ impl Render for Sidebar { .text_color(Color::Added.color(cx)) .hover(|s| s.bg(colors.ghost_element_hover)) .cursor_pointer() - .tooltip(Tooltip::text("Stage file")) + .tooltip(Tooltip::text(language.tr(TrKey::TipStageFile))) .on_click({ let w_stg = w.clone(); move |_: &ClickEvent, _: &mut Window, cx: &mut App| { @@ -4517,7 +4543,7 @@ impl Render for Sidebar { .color(Color::Muted), ) .child( - Label::new("Apply stash") + Label::new(language.tr(TrKey::ApplyStashLbl)) .size(LabelSize::XSmall) .color(Color::Default), ), @@ -4560,7 +4586,7 @@ impl Render for Sidebar { .color(Color::Muted), ) .child( - Label::new("Pop stash") + Label::new(language.tr(TrKey::PopStashLbl)) .size(LabelSize::XSmall) .color(Color::Default), ), @@ -4603,7 +4629,7 @@ impl Render for Sidebar { .color(Color::Muted), ) .child( - Label::new("Create branch") + Label::new(language.tr(TrKey::StashMenuCreateBranch)) .size(LabelSize::XSmall) .color(Color::Default), ), @@ -4646,7 +4672,7 @@ impl Render for Sidebar { .color(Color::Deleted), ) .child( - Label::new("Drop stash") + Label::new(language.tr(TrKey::TipDropStash)) .size(LabelSize::XSmall) .color(Color::Default), ), diff --git a/crates/rgitui_workspace/src/splash_screen.rs b/crates/rgitui_workspace/src/splash_screen.rs index 1e72381..7a2b717 100644 --- a/crates/rgitui_workspace/src/splash_screen.rs +++ b/crates/rgitui_workspace/src/splash_screen.rs @@ -3,6 +3,7 @@ use std::time::{Duration, Instant}; use gpui::prelude::*; use gpui::{canvas, div, point, px, App, Bounds, Context, PathBuilder, Pixels, Render, Window}; +use rgitui_settings::{SettingsState, TrKey}; use rgitui_theme::{lane_color, ActiveTheme, StyledExt}; // ─── Pre-computed trig ────────────────────────────────────────────── @@ -391,6 +392,12 @@ impl Render for SplashScreen { let subtitle_t = ((elapsed.as_secs_f32() - 0.3).max(0.0) / 0.5).min(1.0); let colors = cx.colors(); + // The splash can paint before settings finish loading, so fall + // back to English rather than requiring the global. + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let text_color = colors.text; let muted = colors.text_muted; let accent = colors.text_accent; @@ -409,7 +416,7 @@ impl Render for SplashScreen { let subtitle = div() .text_size(px(11.)) .child(typewriter_text( - "GPU-accelerated Git", + language.tr(TrKey::SplashSubtitle), subtitle_t, muted, hidden, @@ -436,7 +443,7 @@ impl Render for SplashScreen { let loading = div() .text_size(px(10.)) .child(typewriter_text( - "Loading workspace\u{2026}", + language.tr(TrKey::SplashLoading), loading_t, accent, hidden, diff --git a/crates/rgitui_workspace/src/stash_branch_dialog.rs b/crates/rgitui_workspace/src/stash_branch_dialog.rs index f962f08..a20ad66 100644 --- a/crates/rgitui_workspace/src/stash_branch_dialog.rs +++ b/crates/rgitui_workspace/src/stash_branch_dialog.rs @@ -15,6 +15,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; /// Events emitted by the stash branch dialog. #[derive(Debug, Clone)] @@ -44,9 +45,17 @@ impl EventEmitter for StashBranchDialog {} impl StashBranchDialog { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Enter branch name..."); + ti.set_placeholder(language.tr(TrKey::BranchNamePh)); ti }); cx.subscribe( @@ -192,6 +201,14 @@ impl Render for StashBranchDialog { } let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); + self.editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::BranchNamePh)); + }); + let branch_name = self.editor.read(cx).text().to_string(); let has_error = self.error_message.is_some(); let can_create = !branch_name.is_empty() && !has_error; @@ -250,7 +267,7 @@ impl Render for StashBranchDialog { ), ) .child( - Label::new("Create Branch from Stash") + Label::new(language.tr(TrKey::StashBranchTitle)) .size(LabelSize::Large) .weight(gpui::FontWeight::BOLD), ), @@ -263,7 +280,7 @@ impl Render for StashBranchDialog { .gap(px(6.)) .items_center() .child( - Label::new("From") + Label::new(language.tr(TrKey::StashFrom)) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -296,7 +313,7 @@ impl Render for StashBranchDialog { .v_flex() .gap(px(6.)) .child( - Label::new("Branch name") + Label::new(language.tr(TrKey::BranchNameLabel)) .size(LabelSize::Small) .weight(gpui::FontWeight::MEDIUM) .color(Color::Muted), @@ -345,7 +362,7 @@ impl Render for StashBranchDialog { .w_full() .gap_2() .child( - Label::new("Enter to create | Esc to cancel") + Label::new(language.tr(TrKey::TagCreateHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ) @@ -357,7 +374,7 @@ impl Render for StashBranchDialog { .justify_end() .w_full() .child( - Button::new("cancel-stash-branch", "Cancel") + Button::new("cancel-stash-branch", language.tr(TrKey::CancelBtn)) .size(ButtonSize::Default) .style(ButtonStyle::Subtle) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -365,7 +382,7 @@ impl Render for StashBranchDialog { })), ) .child( - Button::new("create-stash-branch", "Create Branch") + Button::new("create-stash-branch", language.tr(TrKey::BranchTitle)) .icon(IconName::GitBranch) .size(ButtonSize::Default) .style(ButtonStyle::Filled) diff --git a/crates/rgitui_workspace/src/stash_save_dialog.rs b/crates/rgitui_workspace/src/stash_save_dialog.rs index 498624f..7ba3498 100644 --- a/crates/rgitui_workspace/src/stash_save_dialog.rs +++ b/crates/rgitui_workspace/src/stash_save_dialog.rs @@ -13,6 +13,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; /// Events emitted by the stash save dialog. #[derive(Debug, Clone, PartialEq)] @@ -40,6 +41,10 @@ impl EventEmitter for StashSaveDialog {} impl StashSaveDialog { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); let editor = cx.new(|cx| { let mut ti = TextInput::new(cx); ti.set_placeholder("WIP: "); @@ -143,6 +148,10 @@ impl Render for StashSaveDialog { } let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let accent_color = Color::Accent.color(cx); let icon_bg = gpui::Hsla { @@ -215,7 +224,7 @@ impl Render for StashSaveDialog { ), ) .child( - Label::new("Create Stash") + Label::new(language.tr(TrKey::StashTitle)) .size(LabelSize::Large) .weight(gpui::FontWeight::BOLD), ), @@ -228,13 +237,13 @@ impl Render for StashSaveDialog { .v_flex() .gap_1() .child( - Label::new("Message (optional)") + Label::new(language.tr(TrKey::StashMsgLabel)) .size(LabelSize::Small) .color(Color::Muted), ) .child(self.editor.clone()) .child( - Label::new("Leave empty for default: \"WIP on \"") + Label::new(language.tr(TrKey::StashMsgHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -260,7 +269,7 @@ impl Render for StashSaveDialog { .w_full() .gap_4() .child( - Label::new("Enter to confirm | Esc to cancel") + Label::new(language.tr(TrKey::ConfirmHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ) @@ -272,7 +281,7 @@ impl Render for StashSaveDialog { .justify_end() .w_full() .child( - Button::new("stash-save-cancel", "Cancel") + Button::new("stash-save-cancel", language.tr(TrKey::CancelBtn)) .size(ButtonSize::Default) .style(ButtonStyle::Subtle) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -280,7 +289,7 @@ impl Render for StashSaveDialog { })), ) .child( - Button::new("stash-save-confirm", "Create Stash") + Button::new("stash-save-confirm", language.tr(TrKey::StashTitle)) .size(ButtonSize::Default) .style(ButtonStyle::Tinted(TintColor::Accent)) .disabled(self.error_message.is_some()) diff --git a/crates/rgitui_workspace/src/stashes_panel.rs b/crates/rgitui_workspace/src/stashes_panel.rs index d6ee93f..47ee74d 100644 --- a/crates/rgitui_workspace/src/stashes_panel.rs +++ b/crates/rgitui_workspace/src/stashes_panel.rs @@ -18,6 +18,7 @@ use rgitui_ui::{ }; use crate::workspace::Workspace; +use rgitui_settings::{SettingsState, TrKey}; /// The main stashes panel. pub struct StashesPanel { @@ -34,6 +35,10 @@ impl StashesPanel { project: WeakEntity, workspace: WeakEntity, ) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); let stashes = project .upgrade() .map(|proj| proj.read(cx).stashes().to_vec()) @@ -153,6 +158,7 @@ impl StashesPanel { impl Render for StashesPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); + let panel_bg = colors.panel_background; let stashes = self.stashes.clone(); @@ -179,6 +185,8 @@ impl Render for StashesPanel { impl StashesPanel { fn render_toolbar(&self, cx: &mut Context, empty: bool) -> gpui::AnyElement { let colors = cx.colors(); + let language = cx.global::().settings().language; + let count: SharedString = format!("{}", self.stashes.len()).into(); div() @@ -198,7 +206,7 @@ impl StashesPanel { .color(Color::Accent), ) .child( - Label::new("Stashes") + Label::new(language.tr(TrKey::SideStashes)) .size(LabelSize::Small) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), @@ -218,6 +226,8 @@ impl StashesPanel { fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { let colors = cx.colors(); + let language = cx.global::().settings().language; + div() .flex_1() .flex() @@ -238,13 +248,13 @@ impl StashesPanel { .color(Color::Placeholder), ) .child( - Label::new("No stashes") + Label::new(language.tr(TrKey::StashesEmpty)) .size(LabelSize::Small) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), ) .child( - Label::new("Use the toolbar to stash changes") + Label::new(language.tr(TrKey::StashesEmptyHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -258,6 +268,7 @@ impl StashesPanel { cx: &mut Context, ) -> gpui::AnyElement { let colors = cx.colors(); + let language = cx.global::().settings().language; // Clone everything the uniform_list closure needs — it requires 'static let stash_bg = colors.panel_background; let hover_bg = colors.ghost_element_hover; @@ -331,7 +342,7 @@ impl StashesPanel { .size(btn_size) .style(btn_style) .color(Color::Success) - .tooltip("Apply stash (keep)") + .tooltip(language.tr(TrKey::TipApplyKeep)) .on_click( move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = w.update(cx, |this: &mut StashesPanel, cx| { @@ -353,7 +364,7 @@ impl StashesPanel { .size(btn_size) .style(btn_style) .color(Color::Accent) - .tooltip("Pop stash (remove)") + .tooltip(language.tr(TrKey::TipPopRemove)) .on_click( move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = w.update(cx, |this: &mut StashesPanel, cx| { @@ -375,7 +386,7 @@ impl StashesPanel { .size(btn_size) .style(btn_style) .color(Color::Default) - .tooltip("Create branch from stash") + .tooltip(language.tr(TrKey::TipStashBranch)) .on_click( move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = w.update(cx, |this: &mut StashesPanel, cx| { @@ -397,7 +408,7 @@ impl StashesPanel { .size(btn_size) .style(btn_style) .color(Color::Error) - .tooltip("Drop stash") + .tooltip(language.tr(TrKey::TipDropStash)) .on_click( move |_: &ClickEvent, _: &mut Window, cx: &mut App| { let _ = w.update(cx, |this: &mut StashesPanel, cx| { diff --git a/crates/rgitui_workspace/src/status_bar.rs b/crates/rgitui_workspace/src/status_bar.rs index 3957c25..bb67aaf 100644 --- a/crates/rgitui_workspace/src/status_bar.rs +++ b/crates/rgitui_workspace/src/status_bar.rs @@ -1,5 +1,6 @@ use gpui::prelude::*; use gpui::{div, px, App, SharedString, Window}; +use rgitui_settings::{SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize}; @@ -96,7 +97,11 @@ impl StatusBar { self } - fn render_left_section(&self, colors: &rgitui_theme::ThemeColors) -> gpui::Div { + fn render_left_section( + &self, + colors: &rgitui_theme::ThemeColors, + language: rgitui_settings::Language, + ) -> gpui::Div { let has_branch = !self.branch_name.is_empty(); let branch_color = if self.head_detached { Color::Warning @@ -124,7 +129,7 @@ impl StatusBar { ); if self.head_detached { branch_row = branch_row.child( - Label::new("DETACHED") + Label::new(language.tr(TrKey::StatusDetached)) .size(LabelSize::XSmall) .color(Color::Warning), ); @@ -250,13 +255,20 @@ impl StatusBar { left } - fn render_right_section(&self, colors: &rgitui_theme::ThemeColors) -> gpui::Div { + fn render_right_section( + &self, + colors: &rgitui_theme::ThemeColors, + language: rgitui_settings::Language, + ) -> gpui::Div { let has_branch = !self.branch_name.is_empty(); let mut right = div().h_flex().items_center().gap(px(10.)); if self.staged_count > 0 { - let staged_text: SharedString = format!("{} staged", self.staged_count).into(); + let staged_text: SharedString = language + .tr(TrKey::StatusStagedFmt) + .replacen("{}", &self.staged_count.to_string(), 1) + .into(); right = right.child( div() .h_flex() @@ -280,7 +292,10 @@ impl StatusBar { } if self.unstaged_count > 0 { - let unstaged_text: SharedString = format!("{} changed", self.unstaged_count).into(); + let unstaged_text: SharedString = language + .tr(TrKey::StatusChangedFmt) + .replacen("{}", &self.unstaged_count.to_string(), 1) + .into(); right = right.child( div() .h_flex() @@ -315,7 +330,7 @@ impl StatusBar { .color(Color::Success), ) .child( - Label::new("Clean") + Label::new(language.tr(TrKey::StatusClean)) .size(LabelSize::XSmall) .color(Color::Muted), ), @@ -352,9 +367,10 @@ impl StatusBar { impl RenderOnce for StatusBar { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let colors = cx.colors(); + let language = cx.global::().settings().language; - let left = self.render_left_section(colors); - let right = self.render_right_section(colors); + let left = self.render_left_section(colors, language); + let right = self.render_right_section(colors, language); div() .h_flex() diff --git a/crates/rgitui_workspace/src/submodule_view.rs b/crates/rgitui_workspace/src/submodule_view.rs index a92c12f..24d0236 100644 --- a/crates/rgitui_workspace/src/submodule_view.rs +++ b/crates/rgitui_workspace/src/submodule_view.rs @@ -13,6 +13,7 @@ use rgitui_ui::{Icon, IconName, IconSize, Label, LabelSize, Tooltip}; use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; const SUBMODULE_ICON: IconName = IconName::GitBranch; @@ -38,6 +39,10 @@ impl EventEmitter for SubmoduleView {} impl SubmoduleView { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); Self { submodules: Arc::new(Vec::new()), scroll_handle: UniformListScrollHandle::new(), @@ -110,6 +115,7 @@ impl SubmoduleView { } fn render_empty_state(&self, cx: &mut Context) -> gpui::AnyElement { + let language = cx.global::().settings().language; let colors = cx.colors(); div() @@ -134,7 +140,7 @@ impl SubmoduleView { .color(Color::Muted), ) .child( - Label::new("Submodules") + Label::new(language.tr(TrKey::SubmodulesTitle)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -156,12 +162,12 @@ impl SubmoduleView { .color(Color::Placeholder), ) .child( - Label::new("No submodules found") + Label::new(language.tr(TrKey::SubmodulesEmpty)) .size(LabelSize::Small) .color(Color::Muted), ) .child( - Label::new("Add submodules to your repository") + Label::new(language.tr(TrKey::SubmodulesHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ), @@ -174,6 +180,7 @@ impl SubmoduleView { impl Render for SubmoduleView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let colors = cx.colors().clone(); + let language = cx.global::().settings().language; if self.submodules.is_empty() { return self.render_empty_state(cx); @@ -383,7 +390,7 @@ impl Render for SubmoduleView { .color(Color::Accent), ) .child( - Label::new("Submodules") + Label::new(language.tr(TrKey::SubmodulesTitle)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Default), diff --git a/crates/rgitui_workspace/src/tag_dialog.rs b/crates/rgitui_workspace/src/tag_dialog.rs index 5da8152..c458141 100644 --- a/crates/rgitui_workspace/src/tag_dialog.rs +++ b/crates/rgitui_workspace/src/tag_dialog.rs @@ -10,6 +10,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; /// Events emitted by the tag creation dialog. #[derive(Debug, Clone)] @@ -37,6 +38,10 @@ impl EventEmitter for TagDialog {} impl TagDialog { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); let editor = cx.new(|cx| { let mut ti = TextInput::new(cx); ti.set_placeholder("v1.0.0"); @@ -178,7 +183,12 @@ impl Render for TagDialog { } let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let tag_name = self.editor.read(cx).text().to_string(); + let has_error = self.error_message.is_some(); let can_create = !tag_name.is_empty() && !has_error; @@ -223,7 +233,7 @@ impl Render for TagDialog { ), ) .child( - Label::new("Create Tag") + Label::new(language.tr(TrKey::TagTitle)) .size(LabelSize::Large) .weight(gpui::FontWeight::BOLD), ), @@ -234,7 +244,7 @@ impl Render for TagDialog { .v_flex() .gap(px(6.)) .child( - Label::new("Tag name") + Label::new(language.tr(TrKey::TagNameLabel)) .size(LabelSize::Small) .weight(gpui::FontWeight::MEDIUM) .color(Color::Muted), @@ -259,7 +269,7 @@ impl Render for TagDialog { .gap(px(8.)) .items_center() .child( - Label::new("At commit") + Label::new(language.tr(TrKey::TagAtCommit)) .size(LabelSize::XSmall) .color(Color::Muted), ) @@ -314,7 +324,7 @@ impl Render for TagDialog { .w_full() .gap_2() .child( - Label::new("Enter to create | Esc to cancel") + Label::new(language.tr(TrKey::TagCreateHint)) .size(LabelSize::XSmall) .color(Color::Placeholder), ) @@ -326,7 +336,7 @@ impl Render for TagDialog { .justify_end() .w_full() .child( - Button::new("cancel-tag", "Cancel") + Button::new("cancel-tag", language.tr(TrKey::CancelBtn)) .size(ButtonSize::Default) .style(ButtonStyle::Subtle) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -334,7 +344,7 @@ impl Render for TagDialog { })), ) .child( - Button::new("create-tag", "Create Tag") + Button::new("create-tag", language.tr(TrKey::TagTitle)) .icon(IconName::Tag) .size(ButtonSize::Default) .style(ButtonStyle::Filled) diff --git a/crates/rgitui_workspace/src/theme_editor_dialog.rs b/crates/rgitui_workspace/src/theme_editor_dialog.rs index 7eeb2eb..ad12696 100644 --- a/crates/rgitui_workspace/src/theme_editor_dialog.rs +++ b/crates/rgitui_workspace/src/theme_editor_dialog.rs @@ -12,6 +12,7 @@ use rgitui_ui::{Button, ButtonStyle, Icon, IconName, IconSize, Label, LabelSize, use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; use std::sync::Arc; #[derive(Debug, Clone)] @@ -21,25 +22,21 @@ pub enum ThemeEditorEvent { } struct ColorFieldEntry { - label: String, + label: TrKey, getter: fn(&ThemeColors) -> Hsla, setter: fn(&mut ThemeColors, Hsla), } -type ColorFieldSpec<'a> = ( - &'a str, - fn(&ThemeColors) -> Hsla, - fn(&mut ThemeColors, Hsla), -); +type ColorFieldSpec = (TrKey, fn(&ThemeColors) -> Hsla, fn(&mut ThemeColors, Hsla)); struct StatusFieldEntry { - label: String, + label: TrKey, getter: fn(&StatusColors) -> Hsla, setter: fn(&mut StatusColors, Hsla), } -type StatusFieldSpec<'a> = ( - &'a str, +type StatusFieldSpec = ( + TrKey, fn(&StatusColors) -> Hsla, fn(&mut StatusColors, Hsla), ); @@ -85,6 +82,10 @@ impl ThemeEditorDialog { } pub fn new(theme: Arc, cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); let (color_fields, color_inputs) = Self::build_color_fields(&theme.colors, cx); let (status_fields, status_inputs) = Self::build_status_fields(&theme.status, cx); Self { @@ -116,73 +117,89 @@ impl ThemeEditorDialog { colors: &ThemeColors, cx: &mut Context, ) -> (Vec, Vec>) { - let field_specs: Vec> = vec![ + let field_specs: Vec = vec![ ( - "Background", + TrKey::ThemeBackground, |c: &ThemeColors| c.background, |c: &mut ThemeColors, v| c.background = v, ), ( - "Surface", + TrKey::ThemeSurface, |c: &ThemeColors| c.surface_background, |c, v| c.surface_background = v, ), ( - "Elevated Surface", + TrKey::ThemeElevated, |c: &ThemeColors| c.elevated_surface_background, |c, v| c.elevated_surface_background = v, ), - ("Border", |c: &ThemeColors| c.border, |c, v| c.border = v), - ("Text", |c: &ThemeColors| c.text, |c, v| c.text = v), ( - "Text Muted", + TrKey::ThemeBorder, + |c: &ThemeColors| c.border, + |c, v| c.border = v, + ), + ( + TrKey::ThemeText, + |c: &ThemeColors| c.text, + |c, v| c.text = v, + ), + ( + TrKey::ThemeTextMuted, |c: &ThemeColors| c.text_muted, |c, v| c.text_muted = v, ), ( - "Text Accent", + TrKey::ThemeTextAccent, |c: &ThemeColors| c.text_accent, |c, v| c.text_accent = v, ), ( - "Text Placeholder", + TrKey::ThemePlaceholder, |c: &ThemeColors| c.text_placeholder, |c, v| c.text_placeholder = v, ), - ("Icon", |c: &ThemeColors| c.icon, |c, v| c.icon = v), ( - "Focus Ring", + TrKey::ThemeIcon, + |c: &ThemeColors| c.icon, + |c, v| c.icon = v, + ), + ( + TrKey::ThemeFocusRing, |c: &ThemeColors| c.border_focused, |c, v| c.border_focused = v, ), ( - "Selected Border", + TrKey::ThemeSelectedBorder, |c: &ThemeColors| c.border_selected, |c, v| c.border_selected = v, ), - ("Added", |c: &ThemeColors| c.vc_added, |c, v| c.vc_added = v), ( - "Modified", + TrKey::ThemeAdded, + |c: &ThemeColors| c.vc_added, + |c, v| c.vc_added = v, + ), + ( + TrKey::ThemeModified, |c: &ThemeColors| c.vc_modified, |c, v| c.vc_modified = v, ), ( - "Deleted", + TrKey::ThemeDeleted, |c: &ThemeColors| c.vc_deleted, |c, v| c.vc_deleted = v, ), ( - "Untracked", + TrKey::ThemeUntracked, |c: &ThemeColors| c.vc_untracked, |c, v| c.vc_untracked = v, ), ( - "Conflict", + TrKey::ThemeConflict, |c: &ThemeColors| c.vc_conflict, |c, v| c.vc_conflict = v, ), ( - "Renamed", + TrKey::ThemeRenamed, |c: &ThemeColors| c.vc_renamed, |c, v| c.vc_renamed = v, ), @@ -199,7 +216,7 @@ impl ThemeEditorDialog { }); inputs.push(input); entries.push(ColorFieldEntry { - label: label.to_string(), + label, getter, setter, }); @@ -211,23 +228,27 @@ impl ThemeEditorDialog { status: &StatusColors, cx: &mut Context, ) -> (Vec, Vec>) { - let field_specs: Vec> = vec![ + let field_specs: Vec = vec![ ( - "Error", + TrKey::ThemeError, |c: &StatusColors| c.error, |c: &mut StatusColors, v| c.error = v, ), ( - "Warning", + TrKey::ThemeWarning, |c: &StatusColors| c.warning, |c, v| c.warning = v, ), ( - "Success", + TrKey::ThemeSuccess, |c: &StatusColors| c.success, |c, v| c.success = v, ), - ("Info", |c: &StatusColors| c.info, |c, v| c.info = v), + ( + TrKey::ThemeInfo, + |c: &StatusColors| c.info, + |c, v| c.info = v, + ), ]; let mut inputs = vec![]; @@ -241,7 +262,7 @@ impl ThemeEditorDialog { }); inputs.push(input); entries.push(StatusFieldEntry { - label: label.to_string(), + label, getter, setter, }); @@ -407,6 +428,10 @@ impl Render for ThemeEditorDialog { } let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let invalid_border = cx.status().error; let valid_border = colors.border_transparent; let theme = self.editable_theme.clone(); @@ -478,7 +503,7 @@ impl Render for ThemeEditorDialog { div() .v_flex() .child( - Label::new("Theme Editor") + Label::new(language.tr(TrKey::ThemeTitle)) .size(LabelSize::Large) .weight(gpui::FontWeight::BOLD), ) @@ -487,9 +512,9 @@ impl Render for ThemeEditorDialog { "{} · {}", theme_name, if appearance == Appearance::Dark { - "dark" + language.tr(TrKey::ThemeDark) } else { - "light" + language.tr(TrKey::ThemeLight) } )) .size(LabelSize::Small) @@ -513,7 +538,7 @@ impl Render for ThemeEditorDialog { .v_flex() .gap_2() .child( - Label::new("Colors") + Label::new(language.tr(TrKey::ThemeColors)) .size(LabelSize::Small) .weight(gpui::FontWeight::BOLD) .color(Color::Muted), @@ -523,7 +548,7 @@ impl Render for ThemeEditorDialog { |(i, input_ent)| { let hex_val = input_ent.read(cx).text().to_string(); let field = &self.color_fields[i]; - let label_text = field.label.clone(); + let label_text = field.label; let fallback = (field.getter)(&self.editable_theme.colors); let invalid = @@ -543,7 +568,7 @@ impl Render for ThemeEditorDialog { }) .child( div().w(px(180.)).overflow_hidden().child( - Label::new(label_text) + Label::new(language.tr(label_text)) .size(LabelSize::Small) .truncate(), ), @@ -569,7 +594,7 @@ impl Render for ThemeEditorDialog { .v_flex() .gap_2() .child( - Label::new("Status Colors") + Label::new(language.tr(TrKey::ThemeStatusColors)) .size(LabelSize::Small) .weight(gpui::FontWeight::BOLD) .color(Color::Muted), @@ -579,7 +604,7 @@ impl Render for ThemeEditorDialog { |(i, input_ent)| { let hex_val = input_ent.read(cx).text().to_string(); let field = &self.status_fields[i]; - let label_text = field.label.clone(); + let label_text = field.label; let fallback = (field.getter)(&self.editable_theme.status); let invalid = @@ -599,7 +624,7 @@ impl Render for ThemeEditorDialog { }) .child( div().w(px(180.)).overflow_hidden().child( - Label::new(label_text) + Label::new(language.tr(label_text)) .size(LabelSize::Small) .truncate(), ), @@ -648,7 +673,7 @@ impl Render for ThemeEditorDialog { (message.clone(), color) } None => ( - "Tab to move · Enter to save · Esc to close".to_string(), + language.tr(TrKey::ThemeHint).to_string(), Color::Placeholder, ), }; @@ -656,12 +681,12 @@ impl Render for ThemeEditorDialog { }) .child( div().h_flex().gap_2().children([ - Button::new("btn-cancel", "Cancel") + Button::new("btn-cancel", language.tr(TrKey::CancelBtn)) .style(ButtonStyle::Subtle) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { this.dismiss(cx); })), - Button::new("btn-save", "Save") + Button::new("btn-save", language.tr(TrKey::SaveBtn)) .style(ButtonStyle::Filled) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { this.save(cx); diff --git a/crates/rgitui_workspace/src/title_bar.rs b/crates/rgitui_workspace/src/title_bar.rs index 4218ec2..112033e 100644 --- a/crates/rgitui_workspace/src/title_bar.rs +++ b/crates/rgitui_workspace/src/title_bar.rs @@ -7,6 +7,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; type ClickHandler = Box; @@ -113,6 +114,12 @@ impl TitleBar { impl RenderOnce for TitleBar { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let colors = cx.colors().clone(); + // The title bar can paint before settings finish loading, so fall + // back to English rather than requiring the global. + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let branch_color = if self.head_detached { Color::Warning @@ -123,7 +130,12 @@ impl RenderOnce for TitleBar { let hover_bg = colors.ghost_element_active; let branch_bg = colors.ghost_element_hover; let tooltip: SharedString = if self.head_detached { - format!("HEAD is detached at {}", self.branch_name).into() + format!( + "{} {}", + language.tr(TrKey::DetachedTitlePre), + self.branch_name + ) + .into() } else { self.branch_name.clone() }; @@ -154,7 +166,7 @@ impl RenderOnce for TitleBar { ) .when(self.head_detached, |el| { el.child( - Label::new("(detached)") + Label::new(language.tr(TrKey::TitleDetachedTag)) .size(LabelSize::XSmall) .color(Color::Warning), ) diff --git a/crates/rgitui_workspace/src/toolbar.rs b/crates/rgitui_workspace/src/toolbar.rs index 7a971b4..f9a8b91 100644 --- a/crates/rgitui_workspace/src/toolbar.rs +++ b/crates/rgitui_workspace/src/toolbar.rs @@ -1,7 +1,7 @@ use gpui::prelude::*; use gpui::SharedString; use gpui::{div, px, ClickEvent, Context, EventEmitter, Render, Window}; -use rgitui_settings::SettingsState; +use rgitui_settings::{SettingsState, TrKey}; use rgitui_theme::{ActiveTheme, Color, StyledExt}; use rgitui_ui::{ Badge, Button, Icon, IconButton, IconName, IconSize, Indicator, Label, LabelSize, Tooltip, @@ -16,7 +16,7 @@ type TooltipFactory = Box gpui::Any struct ToolbarButtonState { disabled: bool, loading: bool, - tooltip_text: &'static str, + tooltip_text: TrKey, /// The command the button runs, if it is one a keystroke can reach. The /// tooltip shows that command's current binding rather than a literal. command: Option, @@ -180,7 +180,8 @@ impl Toolbar { Color::Default }; - let tooltip_fn = Self::build_tooltip(state.tooltip_text, state.command); + let language = cx.global::().settings().language; + let tooltip_fn = Self::build_tooltip(language.tr(state.tooltip_text), state.command); div() .id(id) @@ -203,20 +204,21 @@ impl Toolbar { } fn render_left_group(&mut self, cx: &mut Context) -> gpui::Div { + let language = cx.global::().settings().language; let fetch_label = if self.is_fetching { - "Fetching..." + language.tr(TrKey::TbFetching) } else { - "Fetch" + language.tr(TrKey::TbFetch) }; let pull_label = if self.is_pulling { - "Pulling..." + language.tr(TrKey::TbPulling) } else { - "Pull" + language.tr(TrKey::TbPull) }; let push_label = if self.is_pushing { - "Pushing..." + language.tr(TrKey::TbPushing) } else { - "Push" + language.tr(TrKey::TbPush) }; div() @@ -237,7 +239,7 @@ impl Toolbar { ToolbarButtonState { disabled: self.is_fetching, loading: self.is_fetching, - tooltip_text: "Fetch from remote", + tooltip_text: TrKey::TipFetchRemote, command: Some(CommandId::Fetch), }, cx, @@ -255,7 +257,7 @@ impl Toolbar { ToolbarButtonState { disabled: !self.can_pull, loading: self.is_pulling, - tooltip_text: "Pull from remote", + tooltip_text: TrKey::TipPullRemote, command: Some(CommandId::Pull), }, cx, @@ -280,7 +282,7 @@ impl Toolbar { ToolbarButtonState { disabled: !self.can_push, loading: self.is_pushing, - tooltip_text: "Push to remote", + tooltip_text: TrKey::TipPushRemote, command: Some(CommandId::Push), }, cx, @@ -309,7 +311,7 @@ impl Toolbar { .child(VerticalDivider::new()) // Branch operations group .child( - Button::new("tb-branch", "Branch") + Button::new("tb-branch", language.tr(TrKey::TbBranch)) .icon(IconName::GitBranch) .tooltip_fn(keymap::command_tooltip( "Create new branch", @@ -327,7 +329,7 @@ impl Toolbar { .items_center() .gap(px(2.)) .child( - Button::new("tb-stash", "Stash") + Button::new("tb-stash", language.tr(TrKey::TbStash)) .icon(IconName::Stash) .disabled(!self.has_changes) .tooltip_fn(keymap::command_tooltip( @@ -339,7 +341,7 @@ impl Toolbar { })), ) .child( - Button::new("tb-pop", "Pop") + Button::new("tb-pop", language.tr(TrKey::TbPop)) .icon(IconName::Undo) .disabled(!self.has_stashes) .tooltip_fn(keymap::command_tooltip( @@ -354,11 +356,11 @@ impl Toolbar { .child(VerticalDivider::new()) // PR creation group .child( - Button::new("tb-pr", "Create PR") + Button::new("tb-pr", language.tr(TrKey::TbCreatePr)) .icon(IconName::GitPullRequest) .disabled(!self.has_github_token) .tooltip_fn(keymap::command_tooltip( - "Create GitHub pull request", + language.tr(TrKey::TipCreatePrFull), CommandId::CreatePr, )) .on_click( @@ -368,6 +370,7 @@ impl Toolbar { } fn render_right_group(&mut self, cx: &mut Context) -> gpui::Div { + let language = cx.global::().settings().language; div() .h_flex() .items_center() @@ -381,7 +384,7 @@ impl Toolbar { .child( IconButton::new("tb-explorer", IconName::Folder) .color(Color::Muted) - .tooltip("Open in file explorer") + .tooltip(language.tr(TrKey::TipExplorer)) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::OpenFileExplorer) })), @@ -389,7 +392,7 @@ impl Toolbar { .child( IconButton::new("tb-terminal", IconName::Terminal) .color(Color::Muted) - .tooltip("Open terminal") + .tooltip(language.tr(TrKey::TipTerminal)) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::OpenTerminal) })), @@ -397,7 +400,7 @@ impl Toolbar { .child( IconButton::new("tb-editor", IconName::ExternalLink) .color(Color::Muted) - .tooltip("Open in editor") + .tooltip(language.tr(TrKey::TipEditor)) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::OpenEditor) })), @@ -414,7 +417,7 @@ impl Toolbar { IconButton::new("tb-search", IconName::Search) .color(Color::Muted) .tooltip_fn(keymap::command_tooltip( - "Search commits", + language.tr(TrKey::TipSearchCommits), CommandId::Search, )) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { @@ -424,7 +427,10 @@ impl Toolbar { .child( IconButton::new("tb-refresh", IconName::Refresh) .color(Color::Muted) - .tooltip_fn(keymap::command_tooltip("Refresh", CommandId::Refresh)) + .tooltip_fn(keymap::command_tooltip( + language.tr(TrKey::TipRefresh), + CommandId::Refresh, + )) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::Refresh) })), @@ -432,7 +438,10 @@ impl Toolbar { .child( IconButton::new("tb-settings", IconName::Settings) .color(Color::Muted) - .tooltip_fn(keymap::command_tooltip("Settings", CommandId::Settings)) + .tooltip_fn(keymap::command_tooltip( + language.tr(TrKey::TipSettings), + CommandId::Settings, + )) .on_click(cx.listener(|_, _: &ClickEvent, _, cx| { cx.emit(ToolbarEvent::Settings) })), diff --git a/crates/rgitui_workspace/src/workspace/layout.rs b/crates/rgitui_workspace/src/workspace/layout.rs index 3e1d91a..416ebed 100644 --- a/crates/rgitui_workspace/src/workspace/layout.rs +++ b/crates/rgitui_workspace/src/workspace/layout.rs @@ -12,6 +12,7 @@ use rgitui_ui::{ use crate::keymap; use crate::{CommandId, StatusBar, TitleBar, ToastKind}; +use rgitui_settings::TrKey; use super::{ BottomPanelMode, CommitInputResize, DetailPanelResize, DiffViewerResize, FocusedPanel, @@ -240,6 +241,11 @@ impl Render for Workspace { let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); + let ui_font = { let configured = cx .try_global::() @@ -344,14 +350,22 @@ impl Render for Workspace { let inspecting_banner_label: Option = active_tab.inspecting_worktree.as_ref().map(|inspecting| { let head = inspected.and_then(|worktree| { - worktree - .branch - .clone() - .or_else(|| worktree.head_detached.then(|| "detached HEAD".to_string())) + worktree.branch.clone().or_else(|| { + worktree + .head_detached + .then(|| language.tr(TrKey::DetachedHeadWord).to_string()) + }) }); match head { - Some(head) => format!("Inspecting worktree: {} ({})", inspecting.name, head), - None => format!("Inspecting worktree: {}", inspecting.name), + Some(head) => { + format!( + "{} {} ({})", + language.tr(TrKey::InspectingPre), + inspecting.name, + head + ) + } + None => format!("{} {}", language.tr(TrKey::InspectingPre), inspecting.name), } .into() }); @@ -569,7 +583,7 @@ impl Render for Workspace { .when(is_failure && update.retryable, |el| { let retry_update = update.clone(); el.child( - Button::new("operation-retry", "Retry") + Button::new("operation-retry", language.tr(TrKey::OpRetry)) .size(ButtonSize::Compact) .style(ButtonStyle::Filled) .on_click(cx.listener(move |this, _: &gpui::ClickEvent, _, cx| { @@ -635,14 +649,15 @@ impl Render for Workspace { let has_conflicts = conflict_count > 0; let state_label: SharedString = repo_state.label().into(); let detail_msg: SharedString = if has_conflicts { - format!( - "{} file{} with conflicts -- resolve before continuing", - conflict_count, - if conflict_count == 1 { "" } else { "s" } - ) + (if conflict_count == 1 { + language.tr(TrKey::ConflictOneFmt) + } else { + language.tr(TrKey::ConflictManyFmt) + }) + .replacen("{}", &conflict_count.to_string(), 1) .into() } else { - "All conflicts resolved -- ready to continue".into() + language.tr(TrKey::ConflictResolved).into() }; el.child( @@ -701,18 +716,23 @@ impl Render for Workspace { // than one that can only report why it did nothing. .when(repo_state.continue_subcommand().is_some(), |el| { el.child( - Button::new("conflict-continue", "Continue") - .size(ButtonSize::Compact) - .style(ButtonStyle::Filled) - .color(Color::Success) - .disabled(has_conflicts) - .on_click(cx.listener(|this, _: &gpui::ClickEvent, _, cx| { + Button::new( + "conflict-continue", + language.tr(TrKey::ConflictContinue), + ) + .size(ButtonSize::Compact) + .style(ButtonStyle::Filled) + .color(Color::Success) + .disabled(has_conflicts) + .on_click(cx.listener( + |this, _: &gpui::ClickEvent, _, cx| { this.execute_command(CommandId::ContinueMerge, cx); - })), + }, + )), ) }) .child( - Button::new("conflict-abort", "Abort") + Button::new("conflict-abort", language.tr(TrKey::CfAbort)) .size(ButtonSize::Compact) .style(ButtonStyle::Subtle) .color(Color::Error) @@ -751,13 +771,18 @@ impl Render for Workspace { // Not "back to main": leaving inspection returns to // whichever checkout rgitui was opened on, which may // be on any branch — or be a linked worktree itself. - Button::new("exit-worktree-inspection", "Exit Worktree") - .size(ButtonSize::Compact) - .style(ButtonStyle::Subtle) - .color(Color::Warning) - .on_click(cx.listener(|this, _: &gpui::ClickEvent, _, cx| { + Button::new( + "exit-worktree-inspection", + language.tr(TrKey::WorktreeExit), + ) + .size(ButtonSize::Compact) + .style(ButtonStyle::Subtle) + .color(Color::Warning) + .on_click(cx.listener( + |this, _: &gpui::ClickEvent, _, cx| { super::events::set_inspecting_worktree(this, None, cx); - })), + }, + )), ), ) }) @@ -922,7 +947,7 @@ impl Render for Workspace { op.label.clone() } }) - .unwrap_or_else(|| "Loading...".into()); + .unwrap_or_else(|| language.tr(TrKey::LoadingDots).into()); el.child( div() @@ -1030,19 +1055,19 @@ impl Render for Workspace { let history_enabled = history_availability == ViewAvailability::Available; let history_tooltip = match history_availability { - ViewAvailability::Loading => "Preparing file history...", - ViewAvailability::Available => "Show file history (h)", + ViewAvailability::Loading => language.tr(TrKey::HistLoading), + ViewAvailability::Available => language.tr(TrKey::HistShow), ViewAvailability::Unavailable => { - "No committed history is available for this file" + language.tr(TrKey::HistUnavailable) } }; let blame_enabled = blame_availability == ViewAvailability::Available; let blame_tooltip = match blame_availability { - ViewAvailability::Loading => "Preparing blame...", - ViewAvailability::Available => "Show blame (b)", + ViewAvailability::Loading => language.tr(TrKey::BlameLoading), + ViewAvailability::Available => language.tr(TrKey::BlameShow), ViewAvailability::Unavailable => { - "Blame is unavailable for this file at the selected commit" + language.tr(TrKey::BlameUnavailable) } }; @@ -1062,9 +1087,9 @@ impl Render for Workspace { .color(Color::Muted) .tooltip_fn(keymap::command_tooltip( if graph_hidden { - "Show commit graph" + language.tr(TrKey::GraphShowTip) } else { - "Hide commit graph" + language.tr(TrKey::GraphHideTip) }, CommandId::ToggleGraph, )) @@ -1093,11 +1118,11 @@ impl Render for Workspace { .child( make_tab( "bottom-tab-diff", - "Diff", + language.tr(TrKey::BottomDiff), BottomPanelMode::Diff, bottom_panel_mode, true, - "Show diff (d)", + language.tr(TrKey::BottomDiffTip), ) .on_click( move |_: &ClickEvent, _: &mut Window, cx: &mut App| { @@ -1117,7 +1142,7 @@ impl Render for Workspace { .child( make_tab( "bottom-tab-history", - "History", + language.tr(TrKey::BottomHistory), BottomPanelMode::FileHistory, bottom_panel_mode, history_enabled, @@ -1157,7 +1182,7 @@ impl Render for Workspace { .child( make_tab( "bottom-tab-blame", - "Blame", + language.tr(TrKey::BottomBlame), BottomPanelMode::Blame, bottom_panel_mode, blame_enabled, @@ -1321,7 +1346,7 @@ impl Render for Workspace { .ok(); }) .child( - Label::new("Details") + Label::new(language.tr(TrKey::DetailTitle)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(if is_details { @@ -1365,7 +1390,7 @@ impl Render for Workspace { .ok(); }) .child( - Label::new("Issues") + Label::new(language.tr(TrKey::IssuesTitle)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(if is_issues { @@ -1409,7 +1434,7 @@ impl Render for Workspace { .ok(); }) .child( - Label::new("PRs") + Label::new(language.tr(TrKey::PrsTab)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(if is_prs { @@ -1445,7 +1470,7 @@ impl Render for Workspace { .ok(); }) .child( - Label::new("Branch Health") + Label::new(language.tr(TrKey::BhTab)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(if is_bh { @@ -1608,29 +1633,39 @@ impl Workspace { head: DetachedHead, cx: &mut Context, ) -> gpui::AnyElement { - let title: SharedString = format!("HEAD is detached at {}", head.commit).into(); - let consequence = "Commits made here belong to no branch"; + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); + let title: SharedString = + format!("{} {}", language.tr(TrKey::DetachedTitlePre), head.commit).into(); + let consequence = language.tr(TrKey::DetachedConsequence); let detail: SharedString = match head.summary { Some(summary) => format!("{summary} · {consequence}").into(), None => consequence.into(), }; let way_back = match head.previous_branch { - Some(branch) => Button::new("detached-head-return", format!("Return to {branch}")) - .tooltip(format!( - "Check out '{branch}', the branch HEAD was on before" - )) - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.switch_to_previous_branch(cx); - })), - None => Button::new("detached-head-switch", "Switch Branch…").on_click(cx.listener( - |this, _: &ClickEvent, window, cx| { + Some(branch) => Button::new( + "detached-head-return", + format!("{} {branch}", language.tr(TrKey::DetachedReturnPre)), + ) + .tooltip(format!( + "{}{}{}", + language.tr(TrKey::DetachedReturnTipPre), + branch, + language.tr(TrKey::DetachedReturnTipPost) + )) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.switch_to_previous_branch(cx); + })), + None => Button::new("detached-head-switch", language.tr(TrKey::DetachedSwitch)) + .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { if let Some(tab) = this.tabs.get(this.active_tab) { tab.sidebar.update(cx, |sidebar, cx| { sidebar.ensure_branches_visible(window, cx) }); } - }, - )), + })), } .size(ButtonSize::Compact) .style(ButtonStyle::Filled) @@ -1672,7 +1707,7 @@ impl Workspace { ), ) .child( - Button::new("detached-head-show", "Show in Graph") + Button::new("detached-head-show", language.tr(TrKey::DetachedShowGraph)) .size(ButtonSize::Compact) .style(ButtonStyle::Subtle) .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { @@ -1687,15 +1722,19 @@ impl Workspace { /// release. Contains a "Download" button that opens the release URL and /// an "X" button to dismiss for the remainder of the session. pub(super) fn render_update_banner(&self, cx: &mut Context) -> Option { + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let update = self.update_notification.as_ref()?.clone(); let accent = cx.status().info; let bg = cx.status().info_background; let release_url = SharedString::from(update.release_url.clone()); - let message: SharedString = format!( - "rgitui {} is available (you have {})", - update.latest_version, update.current_version - ) - .into(); + let message: SharedString = language + .tr(TrKey::UpdateMsgFmt) + .replacen("{}", &update.latest_version, 1) + .replacen("{}", &update.current_version, 1) + .into(); let url_for_open = update.release_url.clone(); @@ -1735,7 +1774,7 @@ impl Workspace { ), ) .child( - Button::new("update-download", "Download") + Button::new("update-download", language.tr(TrKey::UpdateDownload)) .size(ButtonSize::Compact) .style(ButtonStyle::Filled) .on_click(cx.listener(move |_this, _: &ClickEvent, _, cx| { @@ -1756,6 +1795,10 @@ impl Workspace { pub(super) fn render_welcome_interactive(&self, cx: &mut Context) -> impl IntoElement { let colors = cx.colors(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let recent_workspaces = cx .try_global::() .map(|settings| settings.recent_workspaces(5)) @@ -1797,7 +1840,7 @@ impl Workspace { .weight(gpui::FontWeight::BOLD), ) .child( - Label::new("A workspace-oriented desktop Git client") + Label::new(language.tr(TrKey::HomeSlogan)) .color(Color::Muted) .size(LabelSize::Small), ) @@ -1807,17 +1850,22 @@ impl Workspace { .gap_2() .mt(px(4.)) .child( - Button::new("workspace-home-open-repo", "Open Repository") - .style(ButtonStyle::Filled) - .icon(IconName::Folder) - .on_click(cx.listener(|this, _: &gpui::ClickEvent, _, cx| { + Button::new( + "workspace-home-open-repo", + language.tr(TrKey::OpenRepoTitle), + ) + .style(ButtonStyle::Filled) + .icon(IconName::Folder) + .on_click(cx.listener( + |this, _: &gpui::ClickEvent, _, cx| { this.overlays.repo_opener.update(cx, |opener, cx| { opener.toggle_visible(cx); }); - })), + }, + )), ) .child( - Button::new("workspace-home-new", "New Workspace") + Button::new("workspace-home-new", language.tr(TrKey::HomeNewWorkspace)) .style(ButtonStyle::Outlined) .icon(IconName::Plus) .on_click(cx.listener(|this, _: &gpui::ClickEvent, _, cx| { @@ -1829,19 +1877,24 @@ impl Workspace { ) .when(!recent_workspaces.is_empty(), |buttons| { buttons.child( - Button::new("workspace-home-restore", "Restore Last") - .style(ButtonStyle::Subtle) - .icon(IconName::Clock) - .on_click(cx.listener(|this, _: &gpui::ClickEvent, _, cx| { + Button::new( + "workspace-home-restore", + language.tr(TrKey::HomeRestoreLast), + ) + .style(ButtonStyle::Subtle) + .icon(IconName::Clock) + .on_click(cx.listener( + |this, _: &gpui::ClickEvent, _, cx| { this.restore_last_workspace(cx); - })), + }, + )), ) }), ); if !recent_workspaces.is_empty() { let mut workspaces_list = div().v_flex().w_full().mt(px(4.)).gap(px(8.)).child( - Label::new("Recent Workspaces") + Label::new(language.tr(TrKey::HomeRecentWorkspaces)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), @@ -1850,15 +1903,19 @@ impl Workspace { for (i, workspace) in recent_workspaces.iter().enumerate() { let workspace_id = workspace.id.clone(); let workspace_name: SharedString = workspace.name.clone().into(); - let summary: SharedString = format!( - "{} repositories | updated {}", - workspace.repos.len(), - workspace - .last_opened_at - .with_timezone(&chrono::Local) - .format("%Y-%m-%d %H:%M") - ) - .into(); + let summary: SharedString = language + .tr(TrKey::WsSummaryFmt) + .replacen("{}", &workspace.repos.len().to_string(), 1) + .replacen( + "{}", + &workspace + .last_opened_at + .with_timezone(&chrono::Local) + .format("%Y-%m-%d %H:%M") + .to_string(), + 1, + ) + .into(); let repo_preview: SharedString = workspace .repos .iter() @@ -1933,7 +1990,7 @@ impl Workspace { if !recent_repos.is_empty() { let mut repos_list = div().v_flex().w_full().mt(px(4.)).gap(px(8.)).child( - Label::new("Recent Repositories") + Label::new(language.tr(TrKey::HomeRecentRepos)) .size(LabelSize::XSmall) .weight(gpui::FontWeight::SEMIBOLD) .color(Color::Muted), diff --git a/crates/rgitui_workspace/src/workspace/mod.rs b/crates/rgitui_workspace/src/workspace/mod.rs index ef9c9d8..af0f4c5 100644 --- a/crates/rgitui_workspace/src/workspace/mod.rs +++ b/crates/rgitui_workspace/src/workspace/mod.rs @@ -429,6 +429,11 @@ impl Workspace { } else { rgitui_settings::LayoutSettings::default() }; + // Re-render the whole workspace chrome (toolbar, sidebar, commit + // panel, dialogs) immediately when the interface language changes, + // mirroring the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); let sidebar_width = layout_settings .sidebar_width .clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH); @@ -988,6 +993,16 @@ impl Workspace { "Menlo", #[cfg(target_os = "linux")] "DejaVu Sans Mono", + // CJK fallbacks so the Chinese UI does not render as tofu. These + // are system fonts on their respective platforms; nothing is + // embedded, so installs without them simply fall through. + "Noto Sans CJK SC", + "Source Han Sans SC", + "WenQuanYi Micro Hei", + #[cfg(target_os = "windows")] + "Microsoft YaHei", + #[cfg(target_os = "macos")] + "PingFang SC", "Courier New", ]; let fallbacks: Vec = candidates diff --git a/crates/rgitui_workspace/src/worktree_dialog.rs b/crates/rgitui_workspace/src/worktree_dialog.rs index 195d7e4..7499e7e 100644 --- a/crates/rgitui_workspace/src/worktree_dialog.rs +++ b/crates/rgitui_workspace/src/worktree_dialog.rs @@ -8,6 +8,7 @@ use rgitui_ui::{ use crate::keymap; use crate::CommandId; +use rgitui_settings::{SettingsState, TrKey}; /// Events emitted by the worktree creation dialog. #[derive(Debug, Clone)] @@ -39,21 +40,29 @@ impl EventEmitter for WorktreeDialog {} impl WorktreeDialog { pub fn new(cx: &mut Context) -> Self { + // Re-render immediately when the interface language changes, mirroring + // the `ThemeState` observer in `DiffViewer::new`. + cx.observe_global::(|_, cx| cx.notify()) + .detach(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); let name_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Worktree name (e.g. feature-x)..."); + ti.set_placeholder(language.tr(TrKey::WtNamePh)); ti }); let path_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Directory path (e.g. /home/user/projects/myrepo-feature-x)..."); + ti.set_placeholder(language.tr(TrKey::WtPathPh)); ti }); let branch_editor = cx.new(|cx| { let mut ti = TextInput::new(cx); - ti.set_placeholder("Branch (optional, defaults to current branch)..."); + ti.set_placeholder(language.tr(TrKey::WtBranchPh)); ti }); @@ -222,6 +231,19 @@ impl Render for WorktreeDialog { } let colors = cx.colors().clone(); + let language = cx + .try_global::() + .map(|s| s.settings().language) + .unwrap_or_default(); + self.name_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::WtNamePh)); + }); + self.path_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::WtPathPh)); + }); + self.branch_editor.update(cx, |ed, _| { + ed.set_placeholder(language.tr(TrKey::WtBranchPh)); + }); let name_text = self.name_editor.read(cx).text().to_string(); let path_text = self.path_editor.read(cx).text().to_string(); let has_error = self.error_message.is_some(); @@ -270,7 +292,7 @@ impl Render for WorktreeDialog { ), ) .child( - Label::new("New Worktree") + Label::new(language.tr(TrKey::SideNewWorktree)) .size(LabelSize::Large) .color(Color::Default), ), @@ -283,7 +305,7 @@ impl Render for WorktreeDialog { .v_flex() .gap_1() .child( - Label::new("Name") + Label::new(language.tr(TrKey::NameLabel)) .size(LabelSize::Small) .color(Color::Muted), ) @@ -297,7 +319,7 @@ impl Render for WorktreeDialog { .v_flex() .gap_1() .child( - Label::new("Directory Path") + Label::new(language.tr(TrKey::WtPathLabel)) .size(LabelSize::Small) .color(Color::Muted), ) @@ -311,7 +333,7 @@ impl Render for WorktreeDialog { .v_flex() .gap_1() .child( - Label::new("Branch (optional)") + Label::new(language.tr(TrKey::WtBranchLabel)) .size(LabelSize::Small) .color(Color::Muted), ) @@ -336,7 +358,7 @@ impl Render for WorktreeDialog { .justify_end() .gap_2() .child( - Button::new("worktree-dialog-cancel", "Cancel") + Button::new("worktree-dialog-cancel", language.tr(TrKey::CancelBtn)) .style(ButtonStyle::Subtle) .size(ButtonSize::Compact) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { @@ -344,7 +366,7 @@ impl Render for WorktreeDialog { })), ) .child( - Button::new("worktree-dialog-create", "Create") + Button::new("worktree-dialog-create", language.tr(TrKey::CreateBtn)) .style(if can_create { ButtonStyle::Tinted(TintColor::Accent) } else {