feat(ui): GUI localization for en and zh-Hans (#303)

* feat(ui): add GUI localization for en and zh-Hans

* feat(ui): localize search placeholders and relative time

* feat(ui): localize palette, switcher, and sftp strings

* feat(ui): localize home shortcut labels

* feat(ui): localize tray, ssh prompt, and editor strings

* feat(ui): add plural/select i18n helpers and localize sftp/settings labels

* feat(ui): localize settings search, forwards panel, and file tree

* feat(ui): localize code editor and right panel

* feat(ui): localize stop/delete workspace confirmations with plural support

* feat(ui): localize diff overlay with plural-aware summary

* feat(ui): localize pending pane, worktree prompt, and home time strings

* feat(ui): localize app menus, tray, tab strip/sidebar, and remote status strings

* feat(ui): localize switcher, file_tree, machine_mirror fallback strings

* feat(ui): localize ssh prompts, theme presets, host error wrapper, and finish remote strings

* feat(ui): localize command palette strings

* feat(ui): localize app.rs notifications, prompts, placeholders, and parse errors

* feat(ui): localize remaining theme, switcher, settings, and sftp strings

* style: cargo fmt

* feat(ui): add language selector to settings

* fix(ui): refresh locales across windows

* refactor(ui): make GUI language selection explicit

* fix(ui): localize Explorer settings after merge

* fix(ui): keep persisted theme names out of the GUI locale

A theme's name is data, not chrome: it is written into the theme YAML and
matched back with `trim_end_matches(" (custom)")`. Translating it meant a
Chinese GUI forked "Nord" into "Nord(自定义)", the next fork stacked a second
suffix on it, and the name stayed Chinese after switching back to English. The
derived-name fallback had the same problem. Both are English again.

Also in this pass:

- Give each test thread its own locale override. The locale is process-wide and
  tests run in parallel, so the two tests that switched to zh-CN could flip the
  language out from under another thread's English assertions.
- Rebuild the menu bar when gui_language changes in config.json, the way the
  in-app picker already does — otherwise the menus kept the old language.
- Document the values the setting actually accepts. The docs still described
  `auto` and `zh-Hans`, which sanitize() resets to `en`.
- Put the English words back into the Chinese search keywords for the language
  setting; the other 58 keyword sets keep them.
- Drop the unused is_zh_hans helper.

---------

Co-authored-by: thomas <thomas@gmail.com>
Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
Hongwei Qin
2026-08-03 23:48:29 +08:00
committed by GitHub
co-authored by thomas l0ng-ai
parent e8525e1b33
commit 8a342f2ca9
30 changed files with 6242 additions and 1530 deletions
+26
View File
@@ -167,6 +167,10 @@ pub struct Config {
/// package manager's copy — and do not want it shadowed.
#[serde(default = "default_true")]
pub install_cli_on_path: bool,
/// GUI-only locale selection. Values: `en` or `zh-CN`.
/// CLI output stays English so agent/script integrations are stable.
#[serde(default = "default_gui_language")]
pub gui_language: String,
#[serde(default = "default_notify_threshold_secs")]
pub notify_threshold_secs: u64,
#[serde(default = "default_true")]
@@ -409,6 +413,7 @@ impl Default for Config {
notify_on_command_finish: NotifyMode::Unfocused,
check_for_updates: true,
install_cli_on_path: true,
gui_language: default_gui_language(),
notify_threshold_secs: default_notify_threshold_secs(),
restore_session: true,
show_tray_icon: true,
@@ -496,6 +501,10 @@ impl Config {
{
self.link_file_command = None;
}
match self.gui_language.as_str() {
"en" | "zh-CN" => {}
_ => self.gui_language = default_gui_language(),
}
}
pub fn save(&self) {
@@ -647,6 +656,10 @@ fn default_true() -> bool {
true
}
fn default_gui_language() -> String {
"en".to_string()
}
fn default_word_separators() -> String {
",│`|:\"' ()[]{}<>\t".to_string()
}
@@ -1076,6 +1089,19 @@ mod tests {
assert_eq!(clamp(100_000), 3600);
}
#[test]
fn gui_language_defaults_to_english_and_rejects_unsupported_values() {
let cfg = Config::default();
assert_eq!(cfg.gui_language, "en");
let cfg: Config = serde_json::from_str(r#"{"gui_language": "zh-CN"}"#).unwrap();
assert_eq!(cfg.gui_language, "zh-CN");
let mut cfg: Config = serde_json::from_str(r#"{"gui_language": "ko"}"#).unwrap();
cfg.sanitize();
assert_eq!(cfg.gui_language, "en");
}
#[test]
fn keybinding_preset_and_prefix_default_and_round_trip() {
let cfg = Config::default();
+13
View File
@@ -120,3 +120,16 @@ a brief pause; `prefix` + an unbound key passes straight through.
- The PTY is read at device speed and parsed in large batches, off the render path
- Hot paths are lock-free — a big `cat` never waits on drawing
- The daemon buffers up to 16 MiB ahead of the window before backpressure applies
## Localization
The GUI ships English and Simplified Chinese strings. Pick one in Settings →
Appearance → Language, or in `config.json`:
```json
{ "gui_language": "zh-CN" }
```
`en` and `zh-CN` are the only accepted values; anything else falls back to `en`.
The choice is explicit — the system language is never inferred. CLI output stays
English so agent and script integrations keep a stable, predictable surface.
+12
View File
@@ -116,3 +116,15 @@ Aider、Amp、OpenCode 等约 17 个)并在其外围加功能 —— 绝不包
- 以设备速度读取 PTY,在渲染路径之外成批解析
- 热路径全程无锁 —— 再大的 `cat` 也不会阻塞在渲染上
- 触发背压前,守护进程最多可领先窗口缓冲 16 MiB
## 本地化
GUI 目前提供英文和简体中文两套文案。在「设置 → 外观 → 语言」中选择,或直接改
`config.json`
```json
{ "gui_language": "zh-CN" }
```
只接受 `en``zh-CN` 两个值,其它值一律回落到 `en`。语言必须显式指定,不会
去猜系统语言。CLI 输出保持英文,保证 agent、脚本和开发者工作流的输出稳定可预测。
+10 -1
View File
@@ -74,10 +74,17 @@ fn spawn_config_watcher(cx: &mut App) {
while rx.try_recv().is_ok() {}
cx.update(|cx| {
cx.set_global(Config::load());
let config = Config::load();
crate::ui::i18n::set_locale(&config.gui_language);
cx.set_global(config);
crate::ui::presets::load_registry(cx);
crate::ui::theme::apply_cursor_hide_mode(cx);
crate::ui::theme::apply_theme(None, cx);
// The menu bar is built once from the current locale, so editing
// gui_language by hand has to rebuild it the same way the
// in-app language picker does.
crate::ui::theme::set_menus(cx);
crate::ui::windows::WindowRegistry::refresh_locale(cx, None);
cx.refresh_windows();
});
}
@@ -319,6 +326,7 @@ fn main() {
return;
}
let config = crate::core::config::Config::load();
let gui_language = config.gui_language.clone();
// After the PATH enrichment above, which is what makes the candidate scan
// see the user's real PATH rather than the stub a Finder launch inherits —
@@ -344,6 +352,7 @@ fn main() {
cx.activate(true);
#[cfg(target_os = "macos")]
set_dock_icon_for_bare_binary();
crate::ui::i18n::set_locale(&gui_language);
cx.set_global(Config::load());
crate::ui::theme::refresh_system_appearance(cx);
crate::core::session::WorkspaceStore::init(cx);
+306 -152
View File
@@ -27,13 +27,15 @@ use crate::core::window_state::{WindowGeometry as _, WindowState};
use crate::daemon::protocol::{RemoteContext, ShellSpec, ssh_option_takes_value};
use crate::terminal::view::{ChildExited, TerminalView};
use crate::ui::host_registry::HostId;
use crate::ui::i18n::{L10nKey, set_locale, t, t_fmt};
use crate::ui::palette::{
ChromeState, Command, CommandGroup, CommandKind, PaletteEvent, PaletteView,
};
use crate::ui::pane::{CloseOutcome, Dir, Pane, PaneSlot};
use crate::ui::presets::Fill;
use crate::ui::settings::{
Recording, SettingsSection, SettingsState, ThemeEditor, humanize_action,
ExplorerContextMenuNote, Recording, SettingsSection, SettingsState, ThemeEditor,
humanize_action,
};
use crate::ui::theme::{apply_theme, set_menus, window_background};
@@ -479,27 +481,21 @@ impl Tty7App {
};
let ours = crate::daemon::protocol::PROTOCOL_VERSION;
let detail = match mismatch.version {
Some(v) => format!(
"The server holding your shells is from another build \
(v{}, protocol {} — this app speaks {}). You can keep using it and \
your shells stay, but features whose wire format changed may \
misbehave until it's restarted. Restarting starts a clean server: \
tabs reopen with fresh shells and anything running in them is \
terminated.",
v.build, v.protocol, ours
Some(v) => t_fmt(
L10nKey::AppRestartServerMismatchDetail,
&[
("build", &v.build.to_string()),
("protocol", &v.protocol.to_string()),
("ours", &ours.to_string()),
],
),
None => "The server holding your shells is from an older \
version of the app. You can keep using it and your shells stay, \
but newer features may misbehave until it's restarted. Restarting \
starts a clean server: tabs reopen with fresh shells and anything \
running in them is terminated."
.to_string(),
None => t(L10nKey::AppRestartServerOldDetail).to_string(),
};
let answer = window.prompt(
PromptLevel::Warning,
"Restart Server?",
t(L10nKey::AppRestartServerTitle),
Some(&detail),
&["Keep Shells", "Restart"],
&[t(L10nKey::AppKeepShells), t(L10nKey::AppRestart)],
cx,
);
cx.spawn(async move |this, cx| {
@@ -560,7 +556,9 @@ impl Tty7App {
let mf_bind_port = cx.new(|cx| InputState::new(window, cx).placeholder("8080"));
let mf_target_host = cx.new(|cx| InputState::new(window, cx).placeholder("127.0.0.1"));
let mf_target_port = cx.new(|cx| InputState::new(window, cx).placeholder("80"));
let mf_description = cx.new(|cx| InputState::new(window, cx).placeholder("description"));
let mf_description = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(L10nKey::AppPlaceholderDescription))
});
let sidebar_width = cx.global::<Config>().sidebar_width;
let right_panel_width = cx.global::<Config>().right_panel_width;
let right_panel_visible = cx.global::<Config>().right_panel_visible;
@@ -627,14 +625,18 @@ impl Tty7App {
},
some => tabs_from_session(pane_ws.as_ref(), workspace, some, font_size, window, cx),
};
let sidebar_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search tabs…"));
let sidebar_search = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchTabs))
});
let sidebar_search_sub =
cx.subscribe_in(&sidebar_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
}
});
let file_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search files…"));
let file_search = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchFiles))
});
let file_search_sub = cx.subscribe_in(&file_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
cx.notify();
@@ -753,14 +755,12 @@ impl Tty7App {
let answer = window.prompt(
PromptLevel::Info,
"Close Window?",
Some(
"Your sessions keep running in the background. This \
workspace will be waiting on the home page, and in the \
workspace menu in the title bar, the next time you open \
tty7.",
),
&["Cancel", "Close"],
t(crate::ui::i18n::L10nKey::CloseWindowTitle),
Some(t(crate::ui::i18n::L10nKey::CloseWindowBody)),
&[
t(crate::ui::i18n::L10nKey::Cancel),
t(crate::ui::i18n::L10nKey::Close),
],
cx,
);
let close_confirmed = close_confirmed.clone();
@@ -976,7 +976,7 @@ impl Tty7App {
window,
cx,
) else {
window.push_notification("Could not reopen the tab: no terminal started", cx);
window.push_notification(t(L10nKey::AppReopenTabFailed), cx);
self.closed.push(st);
return;
};
@@ -1105,14 +1105,12 @@ impl Tty7App {
window.activate_window();
let answer = window.prompt(
PromptLevel::Warning,
"Quit and Stop Server?",
Some(
"This quits tty7 and stops the background server — anything \
still running in your shells is terminated. Your tabs and \
layout are kept and reopen with fresh shells next launch. \
(Plain Quit keeps shells running.)",
),
&["Cancel", "Quit and Stop"],
t(crate::ui::i18n::L10nKey::QuitStopServerTitle),
Some(t(crate::ui::i18n::L10nKey::QuitStopServerBody)),
&[
t(crate::ui::i18n::L10nKey::Cancel),
t(crate::ui::i18n::L10nKey::QuitAndStop),
],
cx,
);
cx.spawn(async move |_this, cx| {
@@ -1135,10 +1133,7 @@ impl Tty7App {
let label = crate::ui::remote_connect::label_for(&target, cx);
if !target.is_ssh() {
window.push_notification(
format!(
"tty7 can only restart the server on machines it reaches over SSH. \
{label} is served from this computer — stop its workspace instead."
),
t_fmt(L10nKey::AppRestartServerNotSsh, &[("label", &label)]),
cx,
);
return;
@@ -1149,13 +1144,9 @@ impl Tty7App {
pub(crate) fn restart_daemon(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let answer = window.prompt(
PromptLevel::Warning,
"Restart Server?",
Some(
"This stops every running shell on this computer — anything still \
running in them will be terminated. Your tabs and layout are kept \
and reopened with fresh shells.",
),
&["Cancel", "Restart"],
t(L10nKey::AppRestartServerTitle),
Some(t(L10nKey::AppRestartServerBody)),
&[t(crate::ui::i18n::L10nKey::Cancel), t(L10nKey::AppRestart)],
cx,
);
cx.spawn(async move |this, cx| {
@@ -1538,17 +1529,29 @@ impl Tty7App {
let seed_specs: [(ThemeEdit, &str, u32); 5] = [
(
ThemeEdit::Background,
"Background",
t(L10nKey::AppThemeColorBackground),
theme.background_color(),
),
(ThemeEdit::Foreground, "Foreground", theme.foreground),
(ThemeEdit::Accent, "Accent", theme.accent),
(
ThemeEdit::Foreground,
t(L10nKey::AppThemeColorForeground),
theme.foreground,
),
(
ThemeEdit::Accent,
t(L10nKey::AppThemeColorAccent),
theme.accent,
),
(
ThemeEdit::Cursor,
"Cursor",
t(L10nKey::AppThemeColorCursor),
theme.caret.unwrap_or(theme.accent),
),
(ThemeEdit::Selection, "Selection", neutrals.selection),
(
ThemeEdit::Selection,
t(L10nKey::AppThemeColorSelection),
neutrals.selection,
),
];
let mut subs = Vec::new();
@@ -1956,13 +1959,10 @@ impl Tty7App {
crate::core::explorer_context_menu::unregister()
};
let note = match result {
Ok(()) if register => {
"Registered. Right-click a folder or folder background in Explorer to open it in tty7."
.to_string()
}
Ok(()) => "Unregistered from Windows Explorer.".to_string(),
Err(error) if register => format!("Could not register: {error}"),
Err(error) => format!("Could not unregister: {error}"),
Ok(()) if register => ExplorerContextMenuNote::Registered,
Ok(()) => ExplorerContextMenuNote::Unregistered,
Err(error) if register => ExplorerContextMenuNote::RegisterFailed(error.to_string()),
Err(error) => ExplorerContextMenuNote::UnregisterFailed(error.to_string()),
};
let status =
crate::core::explorer_context_menu::status().map_err(|error| error.to_string());
@@ -2310,7 +2310,10 @@ impl Tty7App {
Ok(view) => view,
Err(e) => {
log::error!("new tab spawn failed: {e}");
window.push_notification(format!("Could not open a terminal: {e}"), cx);
window.push_notification(
t_fmt(L10nKey::AppOpenTerminalFailed, &[("error", &e.to_string())]),
cx,
);
return;
}
};
@@ -2339,7 +2342,13 @@ impl Tty7App {
Ok(view) => view,
Err(e) => {
log::error!("native SSH spawn failed: {e}");
window.push_notification(format!("SSH connection failed: {e}"), cx);
window.push_notification(
t_fmt(
L10nKey::AppSshConnectionFailed,
&[("error", &e.to_string())],
),
cx,
);
return;
}
};
@@ -2366,7 +2375,10 @@ impl Tty7App {
Ok(view) => view,
Err(e) => {
log::error!("native SSH respawn failed: {e}");
window.push_notification(format!("SSH reconnect failed: {e}"), cx);
window.push_notification(
t_fmt(L10nKey::AppSshReconnectFailed, &[("error", &e.to_string())]),
cx,
);
return;
}
};
@@ -2403,7 +2415,13 @@ impl Tty7App {
Ok(view) => PaneSlot::Ready(view),
Err(e) => {
log::error!("native SSH split spawn failed: {e}");
window.push_notification(format!("SSH connection failed: {e}"), cx);
window.push_notification(
t_fmt(
L10nKey::AppSshConnectionFailed,
&[("error", &e.to_string())],
),
cx,
);
return;
}
}
@@ -2422,7 +2440,10 @@ impl Tty7App {
Ok(view) => view,
Err(e) => {
log::error!("split spawn failed: {e}");
window.push_notification(format!("Could not split the pane: {e}"), cx);
window.push_notification(
t_fmt(L10nKey::AppSplitPaneFailed, &[("error", &e.to_string())]),
cx,
);
return;
}
}
@@ -2701,31 +2722,32 @@ impl Tty7App {
},
move |_this, found, cx| {
let Some(wt) = found else { return };
let path = wt.path.display().to_string();
let detail = if wt.dirty {
format!(
"The closed tab's worktree at {} has uncommitted changes.",
wt.path.display()
)
t_fmt(L10nKey::AppWorktreeRemoveDetailDirty, &[("path", &path)])
} else {
format!(
"The closed tab's worktree at {} is clean.",
wt.path.display()
)
t_fmt(L10nKey::AppWorktreeRemoveDetailClean, &[("path", &path)])
};
let title = format!("Remove worktree \"{}\"?", wt.branch);
let title = t_fmt(L10nKey::AppWorktreeRemoveTitle, &[("branch", &wt.branch)]);
let level = if wt.dirty {
PromptLevel::Warning
} else {
PromptLevel::Info
};
let remove_label = if wt.dirty {
"Discard Changes & Remove"
t(L10nKey::AppWorktreeDiscardAndRemove)
} else {
"Remove Worktree"
t(L10nKey::AppWorktreeRemove)
};
cx.spawn(async move |this, cx| {
let Ok(answer) = this.update_in(cx, |_, window, cx| {
window.prompt(level, &title, Some(&detail), &["Keep", remove_label], cx)
window.prompt(
level,
&title,
Some(&detail),
&[t(L10nKey::AppWorktreeKeep), remove_label],
cx,
)
}) else {
return;
};
@@ -2742,11 +2764,16 @@ impl Tty7App {
move |h| crate::core::worktree::remove(h, &wt, force),
move |_this, result, window, cx| match result {
Ok(()) => window.push_notification(
format!("Removed worktree \"{branch}\""),
t_fmt(L10nKey::AppWorktreeRemoved, &[("branch", &branch)]),
cx,
),
Err(e) => window.push_notification(
t_fmt(
L10nKey::AppWorktreeRemoveFailed,
&[("error", &e.to_string())],
),
cx,
),
Err(e) => window
.push_notification(format!("Worktree removal failed: {e}"), cx),
},
);
});
@@ -2889,13 +2916,16 @@ impl Tty7App {
Ok(view) => view,
Err(e) => {
log::error!("fork spawn failed: {e}");
window.push_notification(format!("Could not open a terminal: {e}"), cx);
window.push_notification(
t_fmt(L10nKey::AppOpenTerminalFailed, &[("error", &e.to_string())]),
cx,
);
return;
}
};
let Some(terminal) = new.terminal() else {
log::error!("fork spawn produced a pane that is still connecting");
window.push_notification("Could not fork: the pane is still connecting", cx);
window.push_notification(t(L10nKey::AppForkStillConnecting), cx);
return;
};
terminal.read(cx).run_command_line(&cmd);
@@ -2961,38 +2991,32 @@ impl Tty7App {
let view = source.read(cx);
let (agent, session, remote) = (view.agent(), view.agent_session(), view.remote_context());
let Some(agent) = agent else {
window.push_notification("This pane isn't running a coding agent", cx);
window.push_notification(t(L10nKey::AppPaneNoCodingAgent), cx);
return None;
};
let name = agent.display_name();
if agent.fork_label().is_none() {
window.push_notification(format!("tty7 has no fork command for {name}"), cx);
window.push_notification(t_fmt(L10nKey::AppForkNoCommand, &[("name", &name)]), cx);
return None;
}
if remote.is_some() {
window.push_notification(
format!("{name} sessions can only be forked from a local pane"),
cx,
);
window.push_notification(t_fmt(L10nKey::AppForkLocalOnly, &[("name", &name)]), cx);
return None;
}
let session = session.unwrap_or_default();
let Some(id) = session.session_id.as_deref() else {
window.push_notification(
format!("tty7 hasn't seen a {name} session id in this pane — install its hooks in Settings → Agents"),
cx,
);
window.push_notification(t_fmt(L10nKey::AppForkNoSessionId, &[("name", &name)]), cx);
return None;
};
let Some(cmd) = agent.fork_command(id, session.launch_argv.as_deref()) else {
window.push_notification(format!("{name}'s session id isn't a plain token"), cx);
window.push_notification(
t_fmt(L10nKey::AppForkSessionIdNotToken, &[("name", &name)]),
cx,
);
return None;
};
if session.status == AgentStatus::Working {
window.push_notification(
format!("{name} is mid-turn — the fork won't include the turn in flight"),
cx,
);
window.push_notification(t_fmt(L10nKey::AppForkMidTurn, &[("name", &name)]), cx);
}
Some(cmd)
}
@@ -3038,7 +3062,7 @@ impl Tty7App {
cx: &mut Context<Self>,
) {
let Some((host, cwd)) = self.tab_host_cwd(index, window, cx) else {
window.push_notification("This tab has no working directory yet", cx);
window.push_notification(t(L10nKey::AppTabNoWorkingDirectory), cx);
return;
};
let sheet_host = host.clone();
@@ -3050,7 +3074,10 @@ impl Tty7App {
move |h| crate::core::worktree::defaults(h, &probe_cwd),
move |this, result, window, cx| match result {
Ok(defaults) => this.open_worktree_prompt(sheet_host, cwd, defaults, window, cx),
Err(e) => window.push_notification(format!("New worktree failed: {e}"), cx),
Err(e) => window.push_notification(
t_fmt(L10nKey::AppNewWorktreeFailed, &[("error", &e.to_string())]),
cx,
),
},
);
}
@@ -3074,7 +3101,10 @@ impl Tty7App {
Ok(view) => view,
Err(e) => {
log::error!("worktree tab spawn failed: {e}");
window.push_notification(format!("Could not open a terminal: {e}"), cx);
window.push_notification(
t_fmt(L10nKey::AppOpenTerminalFailed, &[("error", &e.to_string())]),
cx,
);
return;
}
};
@@ -3214,7 +3244,7 @@ impl Tty7App {
};
commands.push(
Command::new(
format!("SSH: {title}"),
t_fmt(L10nKey::AppCmdSshProfileTitle, &[("title", &title)]),
CommandKind::ConnectSavedProfile(p.id),
)
.with_subtitle(subtitle)
@@ -3229,7 +3259,7 @@ impl Tty7App {
let label = self.tab_label(tab, i, None, cx);
commands.push(
Command::new(
format!("Switch to Tab: {label}"),
t_fmt(L10nKey::AppCmdSwitchToTab, &[("label", &label)]),
CommandKind::ActivateTab(i),
)
.in_group(CommandGroup::TabsPanes),
@@ -3430,10 +3460,7 @@ impl Tty7App {
fn deliver_agent_prompt(&mut self, prompt: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(target) = self.agent_target_leaf(cx) else {
crate::terminal::notify_desktop(
Some("tty7"),
"No running coding agent found — start one (claude, codex, …) in a pane first.",
);
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppNoRunningCodingAgent));
return;
};
target.read(cx).send_agent_prompt(prompt);
@@ -3456,10 +3483,7 @@ impl Tty7App {
None => (None, None),
};
let Some(selection) = selection else {
crate::terminal::notify_desktop(
Some("tty7"),
"Nothing selected — select some terminal output first.",
);
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppNothingSelected));
return;
};
let cwd = cwd.map(|c| c.to_string_lossy().into_owned());
@@ -3480,7 +3504,7 @@ impl Tty7App {
Some((view.host(cx)?, view.host_cwd()?))
});
let Some((host, cwd)) = target else {
crate::terminal::notify_desktop(Some("tty7"), "This pane has no known directory.");
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppPaneNoKnownDirectory));
return;
};
crate::ui::host_ops::HostOps::run_in(
@@ -3503,7 +3527,7 @@ impl Tty7App {
Some(prompt) => this.deliver_agent_prompt(&prompt, window, cx),
None => crate::terminal::notify_desktop(
Some("tty7"),
&format!("No uncommitted changes in {cwd_s} (or not a git repository)."),
&t_fmt(L10nKey::AppNoUncommittedChanges, &[("cwd", &cwd_s)]),
),
}
},
@@ -3520,12 +3544,15 @@ impl Tty7App {
let mut subs = Vec::new();
let (font_select, font_bold_select, font_italic_select) =
self.build_font_selects(&mut subs, window, cx);
let language_select = self.build_language_select(&mut subs, window, cx);
let (shell_program_input, shell_args_input, wd_path_input) =
self.build_shell_inputs(&mut subs, window, cx);
let link_file_command_input = self.build_link_file_command_input(&mut subs, window, cx);
let scroll_slider = self.build_scroll_slider(&mut subs, window, cx);
let window_opacity_slider = self.build_window_opacity_slider(&mut subs, window, cx);
let theme_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search themes…"));
let theme_search = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchThemes))
});
subs.push(
cx.subscribe_in(&theme_search, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
@@ -3533,8 +3560,9 @@ impl Tty7App {
}
}),
);
let settings_search =
cx.new(|cx| InputState::new(window, cx).placeholder("Search settings…"));
let settings_search = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::SearchSettings))
});
subs.push(
cx.subscribe_in(&settings_search, window, |this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
@@ -3544,7 +3572,9 @@ impl Tty7App {
}),
);
let ssh_filter = cx.new(|cx| InputState::new(window, cx).placeholder("Filter hosts…"));
let ssh_filter = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(crate::ui::i18n::L10nKey::FilterHosts))
});
subs.push(
cx.subscribe_in(&ssh_filter, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
@@ -3553,8 +3583,9 @@ impl Tty7App {
}),
);
let ssh_quick_connect =
cx.new(|cx| InputState::new(window, cx).placeholder("user@host or user@host:port"));
let ssh_quick_connect = cx.new(|cx| {
InputState::new(window, cx).placeholder(t(L10nKey::AppPlaceholderSshQuickConnect))
});
subs.push(
cx.subscribe_in(&ssh_quick_connect, window, |_this, _i, ev, _w, cx| {
if matches!(ev, InputEvent::Change) {
@@ -3570,6 +3601,7 @@ impl Tty7App {
font_select,
font_bold_select,
font_italic_select,
language_select,
shell_program_input,
shell_args_input,
wd_path_input,
@@ -3646,7 +3678,7 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context<Self>| {
let mut rows = Vec::with_capacity(names.len() + 1);
rows.push(crate::ui::settings::FONT_DEFAULT_LABEL.to_string());
rows.push(crate::ui::settings::font_default_label().to_string());
rows.extend(names.iter().cloned());
let selected = value
.as_ref()
@@ -3694,6 +3726,117 @@ impl Tty7App {
(font_select, font_bold_select, font_italic_select)
}
fn build_language_select(
&mut self,
subs: &mut Vec<Subscription>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Entity<SelectState<SearchableVec<String>>> {
const CODES: &[&str] = &["en", "zh-CN"];
let labels = || {
vec![
t(L10nKey::SettingsLanguageEnglish).to_string(),
t(L10nKey::SettingsLanguageChinese).to_string(),
]
};
let cfg = cx.global::<Config>();
let current = Self::normalize_gui_language(&cfg.gui_language);
let rows = labels();
let selected = CODES.iter().position(|c| *c == current).unwrap_or(0);
let language_select = cx.new(|cx| {
SelectState::new(
SearchableVec::new(rows),
Some(IndexPath::default().row(selected)),
window,
cx,
)
});
subs.push(cx.subscribe_in(
&language_select,
window,
move |this, _select, ev: &SelectEvent<SearchableVec<String>>, window, cx| {
if let SelectEvent::Confirm(Some(label)) = ev {
let rows = labels();
if let Some(idx) = rows.iter().position(|r| r == label) {
this.set_gui_language(CODES[idx], window, cx);
}
}
},
));
language_select
}
fn normalize_gui_language(code: &str) -> &'static str {
match code {
"zh-CN" => "zh-CN",
_ => "en",
}
}
pub(crate) fn set_gui_language(
&mut self,
code: &'static str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let code = Self::normalize_gui_language(code);
{
let cfg = cx.global_mut::<Config>();
cfg.gui_language = code.to_string();
}
set_locale(code);
cx.global::<Config>().save();
set_menus(cx);
self.refresh_locale_state(window, cx);
crate::ui::windows::WindowRegistry::refresh_locale(cx, Some(self.workspace));
}
pub(crate) fn refresh_locale_state(&mut self, window: &mut Window, cx: &mut Context<Self>) {
const CODES: &[&str] = &["en", "zh-CN"];
self.sidebar_search.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::SearchTabs), window, cx)
});
self.file_search.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::SearchFiles), window, cx)
});
if let Some(s) = self.active_settings() {
let rows = vec![
t(L10nKey::SettingsLanguageEnglish).to_string(),
t(L10nKey::SettingsLanguageChinese).to_string(),
];
s.language_select.update(cx, |state, cx| {
state.set_items(SearchableVec::new(rows), window, cx);
let code = Self::normalize_gui_language(&cx.global::<Config>().gui_language);
let selected = CODES.iter().position(|c| *c == code).unwrap_or(0);
state.set_selected_index(Some(IndexPath::default().row(selected)), window, cx);
});
s.search.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::SearchSettings), window, cx)
});
s.theme_search.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::SearchThemes), window, cx)
});
s.ssh_filter.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::FilterHosts), window, cx)
});
s.ssh_quick_connect.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::AppPlaceholderSshQuickConnect), window, cx)
});
s.shell_args_input.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::AppPlaceholderNone), window, cx)
});
if !cfg!(windows) {
s.shell_program_input.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::AppPlaceholderLoginShell), window, cx)
});
}
s.link_file_command_input.update(cx, |state, cx| {
state.set_placeholder(t(L10nKey::AppPlaceholderOpenInDefaultApp), window, cx)
});
}
cx.notify();
}
fn build_shell_inputs(
&mut self,
subs: &mut Vec<Subscription>,
@@ -3709,7 +3852,7 @@ impl Tty7App {
let platform_default = if cfg!(windows) {
"PowerShell"
} else {
"login shell"
t(L10nKey::AppPlaceholderLoginShell)
};
let shell_program_input = cx.new(|cx| {
InputState::new(window, cx)
@@ -3718,7 +3861,7 @@ impl Tty7App {
});
let shell_args_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("none")
.placeholder(t(L10nKey::AppPlaceholderNone))
.default_value(shell_args)
});
let wd_path_input = cx.new(|cx| {
@@ -3767,7 +3910,7 @@ impl Tty7App {
.unwrap_or_default();
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("open in default app")
.placeholder(t(L10nKey::AppPlaceholderOpenInDefaultApp))
.default_value(value)
});
subs.push(
@@ -3927,7 +4070,7 @@ impl Tty7App {
}
fn commit_font_family_emphasis(&mut self, bold: bool, name: String, cx: &mut Context<Self>) {
let family = (name != crate::ui::settings::FONT_DEFAULT_LABEL).then_some(name);
let family = (name != crate::ui::settings::font_default_label()).then_some(name);
for tab in &self.tabs {
for leaf in tab.pane.terminals() {
let family = family.clone();
@@ -4270,7 +4413,7 @@ impl Tty7App {
use crate::ui::settings::AgentHooksMachine;
let mut out = vec![AgentHooksMachine {
host: crate::ui::host_ops::HostId::LOCAL,
label: "This Computer".to_string(),
label: t(L10nKey::AppAgentHooksThisComputer).to_string(),
}];
let configured = crate::ui::remote_connect::available_hosts(cx);
for id in crate::ui::host_registry::HostRegistry::ids(cx) {
@@ -4281,7 +4424,7 @@ impl Tty7App {
.iter()
.find(|h| h.target.host_id() == id)
.map(|h| h.label.clone())
.unwrap_or_else(|| "Remote machine".to_string());
.unwrap_or_else(|| t(L10nKey::AppAgentHooksRemoteMachine).to_string());
out.push(AgentHooksMachine { host: id, label });
}
out
@@ -4332,8 +4475,7 @@ impl Tty7App {
};
let Some((host, home)) = self.agent_hooks_link(host_id, cx) else {
if let Some(s) = self.settings.as_mut() {
s.agent_hooks_states =
AgentHooksView::Unavailable(Self::AGENT_HOOKS_OFFLINE.into());
s.agent_hooks_states = AgentHooksView::Unavailable(Self::agent_hooks_offline_msg());
}
cx.notify();
return;
@@ -4365,9 +4507,7 @@ impl Tty7App {
s.agent_hooks_states = match rows {
Some(rows) => AgentHooksView::Ready(rows),
None => AgentHooksView::Unavailable(
"tty7 could not work out this computer's home directory, so there is \
nowhere to install to."
.into(),
t(L10nKey::AppAgentHooksNoHomeDir).to_string(),
),
};
cx.notify();
@@ -4376,10 +4516,9 @@ impl Tty7App {
);
}
const AGENT_HOOKS_OFFLINE: &'static str = concat!(
"Not connected to this machine, so its agent config can't be read or ",
"written. Open a workspace on it and come back."
);
fn agent_hooks_offline_msg() -> String {
t(L10nKey::AppAgentHooksOffline).to_string()
}
fn agent_hooks_link(
&self,
@@ -4426,9 +4565,9 @@ impl Tty7App {
};
let Some((host, home)) = self.agent_hooks_link(host_id, cx) else {
if let Some(s) = self.settings.as_mut() {
s.agent_hooks_note = Some((agent, Self::AGENT_HOOKS_OFFLINE.to_string()));
s.agent_hooks_note = Some((agent, Self::agent_hooks_offline_msg()));
s.agent_hooks_states = crate::ui::settings::AgentHooksView::Unavailable(
Self::AGENT_HOOKS_OFFLINE.into(),
Self::agent_hooks_offline_msg(),
);
}
cx.notify();
@@ -4441,8 +4580,9 @@ impl Tty7App {
move |h| {
let target = match &home {
Some(home) => HookTarget::remote(h, home.clone()),
None => HookTarget::local(h)
.ok_or_else(|| anyhow::anyhow!("cannot resolve home directory"))?,
None => HookTarget::local(h).ok_or_else(|| {
anyhow::anyhow!("{}", t(L10nKey::AppAgentHooksHomeDirUnresolved))
})?,
};
if install {
crate::core::agent_hooks::install_hooks(&target, agent)
@@ -4456,7 +4596,9 @@ impl Tty7App {
agent,
match result {
Ok(summary) => summary,
Err(e) => format!("Failed: {e}"),
Err(e) => {
t_fmt(L10nKey::AppAgentHooksOpFailed, &[("error", &e.to_string())])
}
},
));
}
@@ -4600,10 +4742,12 @@ impl Tty7App {
.find(|(a, k)| *k == spec && *a != action)
.map(|(a, _)| a);
let note = displaced.as_ref().map(|other| {
format!(
"{} took the shortcut from {}, which is now unset.",
humanize_action(&action),
humanize_action(other)
t_fmt(
L10nKey::AppKeybindingDisplacedNote,
&[
("action", &humanize_action(&action)),
("previous", &humanize_action(other)),
],
)
});
self.update_config(cx, |cfg| {
@@ -5630,7 +5774,7 @@ pub(crate) fn new_terminal(
.workspace
.as_ref()
.map(|w| w.target.to_string())
.unwrap_or_else(|| "the local server".to_string());
.unwrap_or_else(|| t(L10nKey::AppLocalServerName).to_string());
let pending = cx.new(|cx| crate::ui::pending_pane::PendingPane::new(machine, spawn, cx));
cx.subscribe_in(
&pending,
@@ -5796,7 +5940,7 @@ pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, S
use crate::core::ssh_profile::{SshProfile, parse_quick_connect};
let mut words = parse_ssh_option_words(input)
.map_err(|_| "Unbalanced quotes in the SSH command".to_string())?;
.map_err(|_| t(L10nKey::AppSshParseUnbalancedQuotes).to_string())?;
if words.first().is_some_and(|word| word == "ssh") {
words.remove(0);
}
@@ -5811,7 +5955,7 @@ pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, S
while i < words.len() {
let word = words[i].clone();
if word == "--" {
return Err("Remote commands aren't supported here".to_string());
return Err(t(L10nKey::AppSshParseNoRemoteCommands).to_string());
}
if let Some((flag, attached)) = ssh_short_flag(&word) {
let value = if ssh_option_takes_value(flag) {
@@ -5821,7 +5965,12 @@ pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, S
i += 1;
match words.get(i) {
Some(v) => v.clone(),
None => return Err(format!("-{flag} needs a value")),
None => {
return Err(t_fmt(
L10nKey::AppSshParseFlagNeedsValue,
&[("flag", &flag.to_string())],
));
}
}
}
} else {
@@ -5834,7 +5983,9 @@ pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, S
.parse::<u16>()
.ok()
.filter(|&p| p != 0)
.ok_or_else(|| format!("Invalid port \u{201c}{value}\u{201d}"))?,
.ok_or_else(|| {
t_fmt(L10nKey::AppSshParseInvalidPort, &[("value", &value)])
})?,
)
}
'l' => user = Some(value),
@@ -5844,7 +5995,10 @@ pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, S
_ => {}
}
} else if word.starts_with('-') {
return Err(format!("Unsupported option \u{201c}{word}\u{201d}"));
return Err(t_fmt(
L10nKey::AppSshParseUnsupportedOption,
&[("option", &word)],
));
} else if target.is_none() {
target = Some(word);
} else {
@@ -5853,9 +6007,9 @@ pub(crate) fn parse_ssh_connect_input(input: &str) -> Result<ParsedSshConnect, S
i += 1;
}
let target = target.ok_or_else(|| "Enter a host to connect to".to_string())?;
let target = target.ok_or_else(|| t(L10nKey::AppSshParseEnterHost).to_string())?;
let qc = parse_quick_connect(&target)
.ok_or_else(|| format!("Can't parse host \u{201c}{target}\u{201d}"))?;
.ok_or_else(|| t_fmt(L10nKey::AppSshParseBadHost, &[("host", &target)]))?;
let mut profile = SshProfile::new(qc.host.clone());
profile.host = qc.host;
@@ -5897,7 +6051,7 @@ fn apply_ssh_o_option(
val.parse::<u16>()
.ok()
.filter(|&p| p != 0)
.ok_or_else(|| format!("Invalid port \u{201c}{val}\u{201d}"))?,
.ok_or_else(|| t_fmt(L10nKey::AppSshParseInvalidPort, &[("value", val)]))?,
)
}
"proxyjump" => *jump = Some(val.to_string()),
+66 -21
View File
@@ -15,6 +15,7 @@ use gpui_component::{
use crate::ui::app::Tty7App;
use crate::ui::host_ops::{HostOps, MTime, SharedHost, WatchSub};
use crate::ui::i18n::{L10nKey, t, t_fmt};
const MAX_FILE_BYTES: u64 = 4 * 1024 * 1024;
@@ -362,24 +363,43 @@ impl Tty7App {
let path = h.canonicalize(&p).unwrap_or(p);
let meta = match h.stat(&path) {
Ok(m) => m,
Err(e) => return Err(format!("Can't open {}: {e}", path.display())),
Err(e) => {
return Err(t_fmt(
L10nKey::EditorCantOpen,
&[("path", &path.display().to_string()), ("e", &e.to_string())],
));
}
};
if meta.len > MAX_FILE_BYTES {
return Err(format!(
"\"{}\" is too large for the editor ({} MB)",
path.display(),
meta.len / (1024 * 1024)
return Err(t_fmt(
L10nKey::EditorFileTooLarge,
&[
("path", &path.display().to_string()),
("size", &(meta.len / (1024 * 1024)).to_string()),
],
));
}
let bytes = match h.read_file(&path, MAX_FILE_BYTES) {
Ok(b) => b,
Err(e) => return Err(format!("Can't read {}: {e}", path.display())),
Err(e) => {
return Err(t_fmt(
L10nKey::EditorCantRead,
&[("path", &path.display().to_string()), ("e", &e.to_string())],
));
}
};
if looks_binary(&bytes) {
return Err(format!("\"{}\" looks like a binary file", path.display()));
return Err(t_fmt(
L10nKey::EditorBinaryFile,
&[("path", &path.display().to_string())],
));
}
let text = String::from_utf8(bytes)
.map_err(|_| format!("\"{}\" is not valid UTF-8", path.display()))?;
let text = String::from_utf8(bytes).map_err(|_| {
t_fmt(
L10nKey::EditorNotUtf8,
&[("path", &path.display().to_string())],
)
})?;
Ok((path, text, meta.mtime))
},
move |app, opened, window, cx| match opened {
@@ -603,7 +623,7 @@ impl Tty7App {
Ok(mtime) => {
f.disk_mtime = mtime;
}
Err(e) => HostOps::notify_err(window, cx, "Save failed", &e),
Err(e) => HostOps::notify_err(window, cx, t(L10nKey::EditorSaveFailed), &e),
}
if landing.clean {
f.dirty = false;
@@ -658,9 +678,13 @@ impl Tty7App {
let name = f.label();
let answer = window.prompt(
PromptLevel::Warning,
&format!("\"{name}\" has unsaved changes"),
&t_fmt(L10nKey::EditorUnsavedChanges, &[("name", &name)]),
None,
&["Save", "Discard", "Cancel"],
&[
t(L10nKey::Save),
t(L10nKey::EditorDiscard),
t(L10nKey::Cancel),
],
cx,
);
let id = f.input.entity_id();
@@ -937,7 +961,9 @@ impl Tty7App {
.when(name.is_none(), |d| {
d.text_color(cx.theme().muted_foreground)
})
.child(name.unwrap_or_else(|| SharedString::from("No file open"))),
.child(
name.unwrap_or_else(|| SharedString::from(t(L10nKey::EditorNoFileOpen))),
),
)
.when(dirty, |d| {
d.child(
@@ -958,7 +984,7 @@ impl Tty7App {
cx,
)
.rounded_lg()
.tooltip("Back to Terminal (Esc)")
.tooltip(t(L10nKey::EditorBackToTerminal))
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_code_panel(window, cx);
})),
@@ -992,7 +1018,14 @@ impl Tty7App {
let active = code.and_then(|c| c.active_file());
let cursor: Option<SharedString> = active.map(|f| {
let pos = f.input.read(cx).cursor_position();
format!("Ln {}, Col {}", pos.line + 1, pos.character + 1).into()
t_fmt(
L10nKey::EditorLnCol,
&[
("line", &(pos.line + 1).to_string()),
("column", &(pos.character + 1).to_string()),
],
)
.into()
});
let wrap: Option<bool> = active.map(|f| f.wrap);
let is_markdown = active.is_some_and(|f| language_for_path(&f.path) == "markdown");
@@ -1016,7 +1049,11 @@ impl Tty7App {
.when(is_markdown, |this| {
this.child(
Button::new("status-md-preview")
.label(if preview { "Edit" } else { "Preview" })
.label(if preview {
t(L10nKey::EditorEdit)
} else {
t(L10nKey::EditorPreview)
})
.custom(crate::ui::tab_strip::chrome_tile_variant(cx))
.xsmall()
.on_click(cx.listener(|this, _, _w, cx| {
@@ -1033,7 +1070,11 @@ impl Tty7App {
.when_some(wrap, |this, wrap| {
this.child(
Button::new("status-wrap")
.label(if wrap { "Wrap: on" } else { "Wrap: off" })
.label(if wrap {
t(L10nKey::EditorWrapOn)
} else {
t(L10nKey::EditorWrapOff)
})
.custom(crate::ui::tab_strip::chrome_tile_variant(cx))
.xsmall()
.on_click(cx.listener(|this, _, window, cx| {
@@ -1069,7 +1110,9 @@ impl Tty7App {
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("Open a file from the file tree"),
.child(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::OpenFileFromTree,
)),
)
}
@@ -1087,10 +1130,12 @@ impl Tty7App {
.border_b_1()
.border_color(cx.theme().border)
.text_sm()
.child(div().flex_1().child("File changed on disk"))
.child(div().flex_1().child(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::FileChangedOnDisk,
)))
.child(
Button::new("editor-conflict-reload")
.label("Reload")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Reload))
.small()
.on_click(cx.listener(move |this, _, window, cx| {
this.editor_reload_from_disk(tab_ix, ix, window, cx);
@@ -1098,7 +1143,7 @@ impl Tty7App {
)
.child(
Button::new("editor-conflict-keep")
.label("Keep mine")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::KeepMine))
.ghost()
.small()
.on_click(cx.listener(move |this, _, _w, cx| {
+42 -49
View File
@@ -13,6 +13,7 @@ use crate::terminal::git_diff::{
MAX_RENDERED_FILES, Truncation,
};
use crate::ui::app::Tty7App;
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
use crate::ui::rounding;
use crate::ui::rounding::RoundedCorners as _;
@@ -241,14 +242,13 @@ impl Tty7App {
let overlay = self.tabs.get(self.active)?.diff_overlay.as_ref()?;
let content = match &overlay.load {
DiffLoad::Loading => self.diff_message("Reading diff…", cx),
DiffLoad::NotARepo => self.diff_message("Not a git repository", cx),
DiffLoad::Ready(snap) if empty_snapshot(snap) && snap.read_failed => self.diff_message(
"Couldn't read the working-tree diff — retrying on the next refresh.",
cx,
),
DiffLoad::Loading => self.diff_message(t(L10nKey::DiffReading), cx),
DiffLoad::NotARepo => self.diff_message(t(L10nKey::DiffNotARepo), cx),
DiffLoad::Ready(snap) if empty_snapshot(snap) && snap.read_failed => {
self.diff_message(t(L10nKey::DiffReadFailed), cx)
}
DiffLoad::Ready(snap) if empty_snapshot(snap) => {
self.diff_message("Working tree clean", cx)
self.diff_message(t(L10nKey::DiffWorkingTreeClean), cx)
}
DiffLoad::Ready(snap) => {
self.diff_file_list(snap, &overlay.expanded, focused_file(snap, overlay), cx)
@@ -367,13 +367,9 @@ impl Tty7App {
.when(
matches!(overlay.load, DiffLoad::Ready(_)) && overlay.focus.is_none(),
|bar| {
let mut summary = format!(
"{} changed file{}",
files,
if files == 1 { "" } else { "s" }
);
let mut summary = t_plural(L10nKey::DiffChangedFiles, files, &[]);
if untracked > 0 {
summary.push_str(&format!(" · {untracked} untracked"));
summary.push_str(&t_plural(L10nKey::DiffUntrackedCount, untracked, &[]));
}
bar.child(
div()
@@ -406,7 +402,7 @@ impl Tty7App {
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("refreshing…"),
.child(t(L10nKey::Refreshing)),
)
},
)
@@ -421,7 +417,7 @@ impl Tty7App {
cx,
)
.rounded_lg()
.tooltip("Close Diff (Esc)")
.tooltip(t(L10nKey::DiffCloseTooltip))
.on_click(cx.listener(|this, _, window, cx| {
this.close_diff_overlay(window, cx);
})),
@@ -478,10 +474,7 @@ impl Tty7App {
.py_1p5()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!(
"… and {rest} more changed file{} — run `git diff` in the terminal to see them.",
if rest == 1 { "" } else { "s" }
)),
.child(t_plural(L10nKey::DiffMoreFiles, rest, &[])),
);
}
if focused.is_none() && !snap.untracked.is_empty() {
@@ -502,10 +495,9 @@ impl Tty7App {
stats: &DiffStats,
cx: &Context<Self>,
) -> AnyElement {
let text = format!(
"This working tree is too large to render efficiently ({}). Every file is \
collapsed — expand individual files, or run `git diff` in the terminal.",
oversized_summary(snap, stats),
let text = t_fmt(
L10nKey::DiffOversizedNotice,
&[("summary", &oversized_summary(snap, stats))],
);
div()
.w_full()
@@ -607,7 +599,7 @@ impl Tty7App {
.flex_shrink_0()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child("binary"),
.child(t(L10nKey::Binary)),
);
}
if file.added > 0 {
@@ -671,15 +663,11 @@ impl Tty7App {
}
if let Some(reason) = file.truncated {
let note = match reason {
Truncation::PerFile => format!(
"Diff truncated at {} lines — run `git diff` in the terminal for the rest.",
git_diff::MAX_LINES_PER_FILE
Truncation::PerFile => t_fmt(
L10nKey::DiffTruncatedPerFile,
&[("limit", &git_diff::MAX_LINES_PER_FILE.to_string())],
),
Truncation::Budget => {
"Body not loaded — this working tree is past tty7's diff budget. \
Run `git diff` in the terminal for this file."
.to_string()
}
Truncation::Budget => t(L10nKey::DiffTruncatedBudget).to_string(),
};
body = body.child(
div()
@@ -778,7 +766,7 @@ impl Tty7App {
.bg(cx.theme().secondary)
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!("Untracked files ({total})")),
.child(t_plural(L10nKey::DiffUntrackedHeader, total, &[])),
);
for path in untracked {
section = section.child(
@@ -809,9 +797,7 @@ impl Tty7App {
.py_1()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(format!(
"… and {rest} more — run `git status` in the terminal to see them.",
)),
.child(t_plural(L10nKey::DiffMoreUntracked, rest, &[])),
);
}
section.into_any_element()
@@ -843,31 +829,36 @@ fn file_expanded(file: &FileDiff, expanded: &HashMap<String, bool>, collapse_all
}
fn oversized_summary(snap: &DiffSnapshot, stats: &DiffStats) -> String {
let mut parts = vec![format!(
"{} changed file{}",
snap.files.len(),
if snap.files.len() == 1 { "" } else { "s" }
)];
let mut parts = vec![t_plural(L10nKey::DiffChangedFiles, snap.files.len(), &[])];
let (added, removed) = stats.totals;
let total_lines = (added + removed) as usize;
let loaded = stats.retained_lines;
let budget = stats.budget_exhausted;
let per_file = stats.per_file_truncated;
parts.push(match (budget, per_file) {
(false, false) => format!("{total_lines} diff lines"),
(false, false) => t_plural(L10nKey::DiffLines, total_lines, &[]),
_ => {
let cap = match (budget, per_file) {
(true, true) => "tty7's budget and the per-file cap",
(true, false) => "tty7's budget",
_ => "the per-file cap",
let cap_key = match (budget, per_file) {
(true, true) => L10nKey::DiffBudgetAndCap,
(true, false) => L10nKey::DiffBudget,
_ => L10nKey::DiffPerFileCap,
};
format!(
"{total_lines} changed lines, {loaded} diff rows loaded before {cap} cut the rest"
t_fmt(
L10nKey::DiffChangedLines,
&[
("total", &total_lines.to_string()),
("loaded", &loaded.to_string()),
("cap", t(cap_key)),
],
)
}
});
if stats.untracked_count > 0 {
parts.push(format!("{} untracked", stats.untracked_count));
parts.push(t_plural(
L10nKey::DiffUntrackedSummary,
stats.untracked_count,
&[],
));
}
parts.join(", ")
}
@@ -948,6 +939,7 @@ fn split_hunk(lines: &[git_diff::DiffLine]) -> Vec<SplitRow> {
mod tests {
use super::*;
use crate::terminal::git_diff::{DiffLine, LineKind};
use crate::ui::i18n::set_locale;
fn line(kind: LineKind, old: Option<u32>, new: Option<u32>, text: &str) -> DiffLine {
DiffLine {
@@ -1037,6 +1029,7 @@ mod tests {
}
fn banner(snap: &DiffSnapshot) -> String {
set_locale("en");
oversized_summary(snap, &snap.stats())
}
+111 -84
View File
@@ -6,6 +6,7 @@ use crate::core::config::RightPanelTab;
use crate::ui::app::Tty7App;
use crate::ui::host_ops::{ByHost, HostId, HostOps, InFlight, SharedHost, WatchSub};
use crate::ui::host_registry::HostRegistry;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, ExternalPaths, FocusHandle, KeyDownEvent, MouseButton,
@@ -904,9 +905,9 @@ impl Tty7App {
};
let input = cx.new(|cx| {
let mut st = InputState::new(window, cx).placeholder(match edit_for {
TreeEditKind::NewFile => "file name",
TreeEditKind::NewFolder => "folder name",
TreeEditKind::Rename => "new name",
TreeEditKind::NewFile => t(L10nKey::FileTreePlaceholderFileName),
TreeEditKind::NewFolder => t(L10nKey::FileTreePlaceholderFolderName),
TreeEditKind::Rename => t(L10nKey::FileTreePlaceholderNewName),
});
st.set_value(initial, window, cx);
st
@@ -1048,15 +1049,15 @@ impl Tty7App {
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| path.display().to_string());
let detail = if is_dir {
"The folder and everything inside it will be deleted."
t(L10nKey::FileTreeDeleteFolderBody)
} else {
"The file will be deleted."
t(L10nKey::FileTreeDeleteFileBody)
};
let answer = window.prompt(
PromptLevel::Warning,
&format!("Delete \"{name}\"?"),
&t_fmt(L10nKey::FileTreeDeleteTitle, &[("name", &name)]),
Some(detail),
&["Cancel", "Delete"],
&[t(L10nKey::Cancel), t(L10nKey::Delete)],
cx,
);
cx.spawn_in(window, async move |app, cx| {
@@ -1096,7 +1097,12 @@ impl Tty7App {
}
Err(e) => {
app.file_tree.rollback(id, &parent, rollback);
HostOps::notify_err(window, cx, "Delete failed", &e);
HostOps::notify_err(
window,
cx,
t(L10nKey::FileTreeDeleteFailed),
&e,
);
}
}
cx.notify();
@@ -1123,10 +1129,7 @@ impl Tty7App {
fn file_tree_attach_to_agent(&mut self, path: &Path, cx: &mut Context<Self>) {
let Some(target) = self.agent_target_leaf(cx) else {
crate::terminal::notify_desktop(
Some("tty7"),
"No running coding agent found — start one (claude, codex, …) in a pane first.",
);
crate::terminal::notify_desktop(Some("tty7"), t(L10nKey::AppNoRunningCodingAgent));
return;
};
let rel = self
@@ -1353,86 +1356,110 @@ impl Tty7App {
let p = path.to_path_buf();
if !is_dir {
menu = menu.item(PopupMenuItem::new("Open").on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.open_file_in_editor(&p, window, cx));
}
}));
menu = menu.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextOpen)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.open_file_in_editor(&p, window, cx));
}
}),
);
}
if is_dir {
menu = menu.item(PopupMenuItem::new("cd Here").on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.file_tree_cd(&p, window, cx));
}
}));
menu = menu.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextCdHere)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.file_tree_cd(&p, window, cx));
}
}),
);
}
menu = menu
.item(PopupMenuItem::new("Insert Path in Terminal").on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
if let Some(leaf) = this
.tabs
.get(this.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
{
leaf.update(cx, |view, cx| view.paste(shell_quote(&p), cx));
}
});
}
}))
.item(PopupMenuItem::new("Attach to Agent").on_click({
let app = app.clone();
let p = p.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.file_tree_attach_to_agent(&p, cx));
}
}))
.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextInsertPath)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
if let Some(leaf) = this
.tabs
.get(this.active)
.and_then(|t| t.pane.focused_or_first(window, cx))
{
leaf.update(cx, |view, cx| view.paste(shell_quote(&p), cx));
}
});
}
}),
)
.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextAttachAgent)).on_click({
let app = app.clone();
let p = p.clone();
move |_, _window, cx| {
let _ = app.update(cx, |this, cx| this.file_tree_attach_to_agent(&p, cx));
}
}),
)
.separator()
.item(PopupMenuItem::new("New File").on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.file_tree_begin_edit(TreeEditKind::NewFile, &p, is_dir, window, cx)
});
}
}))
.item(PopupMenuItem::new("New Folder").on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.file_tree_begin_edit(TreeEditKind::NewFolder, &p, is_dir, window, cx)
});
}
}));
.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextNewFile)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.file_tree_begin_edit(TreeEditKind::NewFile, &p, is_dir, window, cx)
});
}
}),
)
.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextNewFolder)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.file_tree_begin_edit(
TreeEditKind::NewFolder,
&p,
is_dir,
window,
cx,
)
});
}
}),
);
if !is_root {
menu = menu.item(PopupMenuItem::new("Rename").on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.file_tree_begin_edit(TreeEditKind::Rename, &p, is_dir, window, cx)
});
}
}));
menu = menu.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextRename)).on_click({
let app = app.clone();
let p = p.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
this.file_tree_begin_edit(TreeEditKind::Rename, &p, is_dir, window, cx)
});
}
}),
);
}
menu = menu
.separator()
.item(PopupMenuItem::new("Copy Path").on_click({
let p = p.clone();
move |_, _window, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(p.display().to_string()));
}
}))
.item(
PopupMenuItem::new(t(L10nKey::FileTreeContextCopyPath)).on_click({
let p = p.clone();
move |_, _window, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(
p.display().to_string(),
));
}
}),
)
.item(
PopupMenuItem::new(crate::ui::right_panel::reveal_label()).on_click({
let p = p.clone();
@@ -1447,7 +1474,7 @@ impl Tty7App {
if !is_root {
menu = menu.separator().item(
PopupMenuItem::element(move |_window, _cx| {
div().text_color(danger).child("Delete")
div().text_color(danger).child(t(L10nKey::Delete))
})
.on_click({
let app = app.clone();
@@ -1466,9 +1493,9 @@ impl Tty7App {
fn dotfiles_menu_item(show_hidden: bool, app: &gpui::WeakEntity<Tty7App>) -> PopupMenuItem {
let app = app.clone();
PopupMenuItem::new(if show_hidden {
"Hide Dotfiles"
t(L10nKey::FileTreeContextHideDotfiles)
} else {
"Show Dotfiles"
t(L10nKey::FileTreeContextShowDotfiles)
})
.on_click(move |_, _window, cx| {
let _ = app.update(cx, |this, cx| {
+33 -16
View File
@@ -6,6 +6,7 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_f
use crate::daemon::protocol::{ForwardStatus, ManagedForward, SshForwardKind};
use crate::terminal::view::TerminalView;
use crate::ui::app::{CONTENT_INSET, Tty7App};
use crate::ui::i18n::{L10nKey, t, t_fmt};
impl Tty7App {
pub(crate) fn render_ssh_status_strip(
@@ -45,15 +46,15 @@ impl Tty7App {
.font_weight(FontWeight::MEDIUM)
.text_color(theme.foreground)
.child(if host.is_empty() {
"Disconnected".to_string()
t(L10nKey::ForwardDisconnected).to_string()
} else {
format!("Disconnected from {host}")
t_fmt(L10nKey::ForwardDisconnectedFrom, &[("host", &host)])
}),
)
.child(div().child("· ⌘⇧R"))
.child(
Button::new("ssh-reconnect")
.label("Reconnect")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Reconnect))
.primary()
.small()
.on_click(
@@ -92,13 +93,13 @@ impl Tty7App {
.child(
div()
.font_weight(FontWeight::SEMIBOLD)
.child("Close this SSH connection?"),
.child(t(crate::ui::i18n::L10nKey::CloseSshConnectionTitle)),
)
.child(
div()
.text_sm()
.text_color(theme.muted_foreground)
.child("The connection is live. Closing will end it."),
.child(t(crate::ui::i18n::L10nKey::CloseSshConnectionBody)),
)
.child(
h_flex()
@@ -106,7 +107,7 @@ impl Tty7App {
.gap_2()
.child(
Button::new("ssh-close-cancel")
.label("Keep")
.label(t(crate::ui::i18n::L10nKey::Keep))
.small()
.on_click(
cx.listener(|this, _, _window, cx| this.cancel_ssh_close(cx)),
@@ -114,7 +115,7 @@ impl Tty7App {
)
.child(
Button::new("ssh-close-confirm")
.label("Close")
.label(t(crate::ui::i18n::L10nKey::Close))
.primary()
.small()
.on_click(cx.listener(|this, _, window, cx| {
@@ -151,7 +152,11 @@ impl Tty7App {
.w(px(24.))
.h(px(24.))
.rounded_md()
.tooltip(if open { "Cancel" } else { "Add forward" })
.tooltip(if open {
t(L10nKey::Cancel)
} else {
t(L10nKey::ForwardTooltipAdd)
})
.on_click(cx.listener(move |this, _, window, cx| {
this.toggle_managed_forward_form(pane_id, window, cx)
}))
@@ -173,7 +178,7 @@ impl Tty7App {
Some(
v_flex()
.child(self.panel_subtitle("Forwards", true, Some(add), cx))
.child(self.panel_subtitle(t(L10nKey::ForwardPanelTitle), true, Some(add), cx))
.when(managed.is_empty() && !open, |this| {
this.child(
div()
@@ -181,7 +186,7 @@ impl Tty7App {
.py(px(2.))
.text_size(px(12.))
.text_color(cx.theme().muted_foreground)
.child("None."),
.child(crate::ui::i18n::t(crate::ui::i18n::L10nKey::None)),
)
})
.when(!managed.is_empty(), |this| this.child(list))
@@ -293,7 +298,7 @@ impl Tty7App {
.w(px(18.))
.h(px(18.))
.rounded(px(4.))
.tooltip("Remove")
.tooltip(t(L10nKey::ForwardTooltipRemove))
.on_click(cx.listener(
move |this, _, _window, cx| {
this.remove_managed_forward(pane_id, forward_id, cx)
@@ -343,7 +348,11 @@ impl Tty7App {
.child(self.segmented_on(
sf,
"ssh-managed-forward-kind",
&["Local", "Remote", "Dynamic"],
&[
t(L10nKey::ForwardLocal),
t(L10nKey::ForwardRemote),
t(L10nKey::ForwardDynamic),
],
selected,
cx,
move |this, ix, _window, cx| {
@@ -356,7 +365,7 @@ impl Tty7App {
},
))
.child(pair(
"bind",
t(L10nKey::ForwardBindLabel),
&self.loopback_panel.mf_bind_host,
&self.loopback_panel.mf_bind_port,
))
@@ -364,7 +373,11 @@ impl Tty7App {
div()
.opacity(if needs_target { 1.0 } else { 0.4 })
.child(pair(
if needs_target { "to" } else { "SOCKS" },
if needs_target {
t(L10nKey::ForwardToLabel)
} else {
t(L10nKey::ForwardSocksLabel)
},
&self.loopback_panel.mf_target_host,
&self.loopback_panel.mf_target_port,
)),
@@ -377,7 +390,7 @@ impl Tty7App {
.pt(px(1.))
.child(
Button::new(("ssh-managed-forward-cancel", pane_id))
.label("Cancel")
.label(t(L10nKey::Cancel))
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, window, cx| {
@@ -386,7 +399,11 @@ impl Tty7App {
)
.child(
Button::new(("ssh-managed-forward-add", pane_id))
.label(if editing { "Save" } else { "Add" })
.label(if editing {
t(L10nKey::Save)
} else {
t(L10nKey::ForwardAdd)
})
.primary()
.xsmall()
.on_click(cx.listener(move |this, _, window, cx| {
+48 -21
View File
@@ -10,6 +10,7 @@ use gpui_component::{ActiveTheme as _, IconName, Sizable as _, h_flex, v_flex};
use crate::core::session::{SessionPane, SessionTab};
use crate::ui::app::Tty7App;
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
const LOGO: [&str; 4] = [
" ▄▄▄ ▄▄▄ ▄ ▄ ▄▄▄▄",
@@ -20,14 +21,14 @@ const LOGO: [&str; 4] = [
const LOGO_PX: f32 = 20.0;
const HOME_SHORTCUTS: [(&str, &str); 7] = [
("NewTab", "New Tab"),
("ReopenClosedTab", "Reopen Closed Tab"),
("ToggleSwitcher", "Switch Workspace"),
("TogglePalette", "Command Palette"),
("SplitRight", "Split Right"),
("SplitDown", "Split Down"),
("OpenSettings", "Settings…"),
const HOME_SHORTCUTS: [&str; 7] = [
"NewTab",
"ReopenClosedTab",
"ToggleSwitcher",
"TogglePalette",
"SplitRight",
"SplitDown",
"OpenSettings",
];
const CLOSED_LABEL_MAX: usize = 20;
@@ -70,17 +71,17 @@ pub(crate) fn now_secs() -> u64 {
pub(crate) fn relative_time(now: u64, then: u64) -> String {
if then == 0 || then >= now {
return "just now".to_string();
return t(L10nKey::HomeTimeJustNow).to_string();
}
let secs = now - then;
match secs {
s if s < 60 => "just now".to_string(),
s if s < 3600 => format!("{} min ago", s / 60),
s if s < 7200 => "1 hour ago".to_string(),
s if s < 86_400 => format!("{} hours ago", s / 3600),
s if s < 172_800 => "yesterday".to_string(),
s if s < 604_800 => format!("{} days ago", s / 86_400),
_ => "over a week ago".to_string(),
s if s < 60 => t(L10nKey::HomeTimeJustNow).to_string(),
s if s < 3_600 => t_plural(L10nKey::HomeTimeMinutesAgo, (s / 60) as usize, &[]),
s if s < 7_200 => t(L10nKey::HomeTimeHourAgo).to_string(),
s if s < 86_400 => t_plural(L10nKey::HomeTimeHoursAgo, (s / 3_600) as usize, &[]),
s if s < 172_800 => t(L10nKey::HomeTimeYesterday).to_string(),
s if s < 604_800 => t_plural(L10nKey::HomeTimeDaysAgo, (s / 86_400) as usize, &[]),
_ => t(L10nKey::HomeTimeOverWeekAgo).to_string(),
}
}
@@ -109,6 +110,25 @@ fn key_hint(action: &str, cx: &App) -> Option<String> {
Some(Kbd::format(&stroke))
}
fn home_shortcut_label(action: &str, closed: Option<&str>) -> String {
let label = match action {
"NewTab" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeNewTab),
"ReopenClosedTab" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeReopenClosedTab),
"ToggleSwitcher" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeSwitchWorkspace),
"TogglePalette" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeCommandPalette),
"SplitRight" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeSplitRight),
"SplitDown" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeSplitDown),
"OpenSettings" => crate::ui::i18n::t(crate::ui::i18n::L10nKey::HomeSettings),
_ => action,
};
if action == "ReopenClosedTab" {
if let Some(name) = closed {
return t_fmt(L10nKey::HomeReopenNamed, &[("name", name)]);
}
}
label.to_string()
}
impl Tty7App {
pub(crate) fn render_home(&self, cx: &mut Context<Self>) -> impl IntoElement + use<> {
let theme = cx.theme();
@@ -133,11 +153,9 @@ impl Tty7App {
let closed_hint = self.closed.last().and_then(closed_tab_label);
let mut list = v_flex().gap_2().w(px(300.)).text_sm().text_color(muted);
for (action, label) in HOME_SHORTCUTS {
let (label, emphasized) = match (&closed_hint, action) {
(Some(name), "ReopenClosedTab") => (format!("Reopen \u{201c}{name}\u{201d}"), true),
_ => (label.to_string(), false),
};
for action in HOME_SHORTCUTS {
let emphasized = closed_hint.is_some() && action == "ReopenClosedTab";
let label = home_shortcut_label(action, closed_hint.as_deref());
list = list.child(
h_flex()
.items_center()
@@ -219,6 +237,7 @@ impl Tty7App {
#[cfg(test)]
mod tests {
use super::*;
use crate::ui::i18n::set_locale;
use std::path::PathBuf;
fn leaf(cwd: Option<&str>) -> SessionPane {
@@ -311,6 +330,7 @@ mod tests {
#[test]
fn relative_time_reads_coarsely_across_the_ranges() {
set_locale("en");
let now = 10_000_000u64;
assert_eq!(relative_time(now, now), "just now");
assert_eq!(relative_time(now, now - 30), "just now");
@@ -320,10 +340,17 @@ mod tests {
assert_eq!(relative_time(now, now - 90_000), "yesterday");
assert_eq!(relative_time(now, now - 3 * 86_400), "3 days ago");
assert_eq!(relative_time(now, now - 30 * 86_400), "over a week ago");
set_locale("zh-CN");
assert_eq!(relative_time(now, now - 30), "刚刚");
assert_eq!(relative_time(now, now - 120), "2 分钟前");
assert_eq!(relative_time(now, now - 3600), "1 小时前");
assert_eq!(relative_time(now, now - 90_000), "昨天");
}
#[test]
fn relative_time_never_renders_a_negative_age() {
set_locale("en");
let now = 1_000_000u64;
assert_eq!(relative_time(now, 0), "just now");
assert_eq!(relative_time(now, now + 5_000), "just now");
+9 -1
View File
@@ -5,6 +5,8 @@ use std::hash::Hash;
use gpui::{App, Context, Window};
use gpui_component::WindowExt as _;
use crate::ui::i18n::{L10nKey, t_fmt};
#[allow(unused_imports)]
pub use tty7_core::host::{
Entry, Host, HostId, MTime, Meta, Output, SearchHit, SharedHost, WatchSub,
@@ -200,7 +202,13 @@ impl HostOps {
}
pub fn notify_err(window: &mut Window, cx: &mut App, context: &str, err: &std::io::Error) {
window.push_notification(format!("{context}: {err}"), cx);
window.push_notification(
t_fmt(
L10nKey::HostOpsError,
&[("context", context), ("error", &err.to_string())],
),
cx,
);
}
}
+3896
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -6,6 +6,7 @@ use tty7_core::daemon::control::{ControlRequest, ReplyOk};
use tty7_core::host::HostId;
use crate::core::session::WorkspaceId;
use crate::ui::i18n::{L10nKey, t};
#[derive(Default)]
pub struct MachineMirrors {
@@ -264,7 +265,7 @@ pub fn display_name_of(ws: &Workspace, panes: &[PaneRecord]) -> String {
.map(|n| n.to_string_lossy().into_owned())
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "Untitled".to_string())
.unwrap_or_else(|| t(L10nKey::WindowUntitled).to_string())
}
pub fn subject_path_of(ws: &Workspace, panes: &[PaneRecord]) -> Option<String> {
+1
View File
@@ -10,6 +10,7 @@ pub mod home;
pub mod host_ops;
#[allow(dead_code)]
pub mod host_registry;
pub mod i18n;
pub mod keymap;
pub mod local_link;
pub mod machine_mirror;
+116 -99
View File
@@ -12,6 +12,7 @@ use uuid::Uuid;
use crate::core::config::{Config, RightPanelTab, TabBarPosition};
use crate::core::ssh_profile::parse_quick_connect;
use crate::ui::i18n::{L10nKey, t, t_fmt};
#[derive(Clone, PartialEq, Eq)]
pub enum CommandKind {
@@ -297,13 +298,13 @@ impl CommandGroup {
fn title(self) -> &'static str {
match self {
CommandGroup::TabsPanes => "Tabs & Panes",
CommandGroup::Workspaces => "Workspaces",
CommandGroup::View => "View",
CommandGroup::Terminal => "Terminal",
CommandGroup::Ssh => "SSH",
CommandGroup::Agents => "Agents",
CommandGroup::Application => "Application",
CommandGroup::TabsPanes => t(L10nKey::CmdGroupTabsPanes),
CommandGroup::Workspaces => t(L10nKey::CmdGroupWorkspaces),
CommandGroup::View => t(L10nKey::CmdGroupView),
CommandGroup::Terminal => t(L10nKey::CmdGroupTerminal),
CommandGroup::Ssh => t(L10nKey::CmdGroupSsh),
CommandGroup::Agents => t(L10nKey::CmdGroupAgents),
CommandGroup::Application => t(L10nKey::CmdGroupApplication),
}
}
}
@@ -350,127 +351,134 @@ impl Command {
let right_panel_open = chrome.right_panel_visible;
let tabs = [
Command::new("New Tab", NewTab),
Command::new("New Worktree Tab", NewWorktreeTab)
.with_subtitle("isolated checkout on a fresh branch"),
Command::new("Rename Tab…", RenameTab),
Command::new("Split Right", SplitRight),
Command::new("Split Down", SplitDown),
Command::new("Zoom Pane", ToggleMaximizePane),
Command::new("Next Pane", NextPane),
Command::new("Previous Pane", PrevPane),
Command::new("Focus Pane Left", FocusPaneLeft),
Command::new("Focus Pane Right", FocusPaneRight),
Command::new("Focus Pane Up", FocusPaneUp),
Command::new("Focus Pane Down", FocusPaneDown),
Command::new("Resize Pane Left", ResizePaneLeft),
Command::new("Resize Pane Right", ResizePaneRight),
Command::new("Resize Pane Up", ResizePaneUp),
Command::new("Resize Pane Down", ResizePaneDown),
Command::new("Swap Pane Next", SwapPaneNext),
Command::new("Swap Pane Previous", SwapPanePrev),
Command::new("Next Tab", NextTab),
Command::new("Previous Tab", PrevTab),
Command::new("Copy Working Directory", CopyWorkingDirectory),
Command::new("Copy Session ID", CopyAgentSessionId)
.with_subtitle("the coding agent's own session id"),
Command::new("Fork Session", ForkAgentSession)
.with_subtitle("branch this agent session into a new tab"),
Command::new("Mark Tab as Unread", MarkTabUnread),
Command::new("Close Pane / Tab", ClosePane),
Command::new("Close Other Tabs", CloseOtherTabs),
Command::new("Close Tabs to the Right", CloseTabsToTheRight),
Command::new("Reopen Closed Tab", ReopenClosedTab),
Command::new(t(L10nKey::CmdNewTab), NewTab),
Command::new(t(L10nKey::CmdNewWorktreeTab), NewWorktreeTab)
.with_subtitle(t(L10nKey::CmdNewWorktreeTabSubtitle)),
Command::new(t(L10nKey::CmdRenameTab), RenameTab),
Command::new(t(L10nKey::CmdSplitRight), SplitRight),
Command::new(t(L10nKey::CmdSplitDown), SplitDown),
Command::new(t(L10nKey::CmdZoomPane), ToggleMaximizePane),
Command::new(t(L10nKey::CmdNextPane), NextPane),
Command::new(t(L10nKey::CmdPreviousPane), PrevPane),
Command::new(t(L10nKey::CmdFocusPaneLeft), FocusPaneLeft),
Command::new(t(L10nKey::CmdFocusPaneRight), FocusPaneRight),
Command::new(t(L10nKey::CmdFocusPaneUp), FocusPaneUp),
Command::new(t(L10nKey::CmdFocusPaneDown), FocusPaneDown),
Command::new(t(L10nKey::CmdResizePaneLeft), ResizePaneLeft),
Command::new(t(L10nKey::CmdResizePaneRight), ResizePaneRight),
Command::new(t(L10nKey::CmdResizePaneUp), ResizePaneUp),
Command::new(t(L10nKey::CmdResizePaneDown), ResizePaneDown),
Command::new(t(L10nKey::CmdSwapPaneNext), SwapPaneNext),
Command::new(t(L10nKey::CmdSwapPanePrevious), SwapPanePrev),
Command::new(t(L10nKey::CmdNextTab), NextTab),
Command::new(t(L10nKey::CmdPreviousTab), PrevTab),
Command::new(t(L10nKey::CmdCopyWorkingDirectory), CopyWorkingDirectory),
Command::new(t(L10nKey::CmdCopySessionId), CopyAgentSessionId)
.with_subtitle(t(L10nKey::CmdCopySessionIdSubtitle)),
Command::new(t(L10nKey::CmdForkSession), ForkAgentSession)
.with_subtitle(t(L10nKey::CmdForkSessionSubtitle)),
Command::new(t(L10nKey::CmdMarkTabAsUnread), MarkTabUnread),
Command::new(t(L10nKey::CmdClosePaneTab), ClosePane),
Command::new(t(L10nKey::CmdCloseOtherTabs), CloseOtherTabs),
Command::new(t(L10nKey::CmdCloseTabsToTheRight), CloseTabsToTheRight),
Command::new(t(L10nKey::CmdReopenClosedTab), ReopenClosedTab),
];
let workspaces = [
Command::new("New Workspace", NewWorkspace),
Command::new("Switch Workspace…", OpenWorkspacePicker),
Command::new("Rename Workspace…", RenameWorkspace),
Command::new("Stop Workspace…", StopWorkspace)
.with_subtitle("ends its shells, keeps the layout"),
Command::new("Delete Workspace…", DeleteWorkspace)
.with_subtitle("ends its shells and forgets the layout"),
Command::new(t(L10nKey::CmdNewWorkspace), NewWorkspace),
Command::new(t(L10nKey::CmdSwitchWorkspace), OpenWorkspacePicker),
Command::new(t(L10nKey::CmdRenameWorkspace), RenameWorkspace),
Command::new(t(L10nKey::CmdStopWorkspace), StopWorkspace)
.with_subtitle(t(L10nKey::CmdStopWorkspaceSubtitle)),
Command::new(t(L10nKey::CmdDeleteWorkspace), DeleteWorkspace)
.with_subtitle(t(L10nKey::CmdDeleteWorkspaceSubtitle)),
];
let view = [
Command::new(
if sidebar_hidden {
"Show Left Sidebar"
t(L10nKey::CmdShowLeftSidebar)
} else {
"Hide Left Sidebar"
t(L10nKey::CmdHideLeftSidebar)
},
ToggleLeftPanel,
),
Command::new(
if right_panel_open {
"Hide Right Panel"
t(L10nKey::CmdHideRightPanel)
} else {
"Show Right Panel"
t(L10nKey::CmdShowRightPanel)
},
ToggleRightPanel,
),
Command::new("Show Code Panel", ToggleCodePanel),
Command::new(t(L10nKey::CmdShowCodePanel), ToggleCodePanel),
Command::new(
if tab_bar_left {
"Tab Bar: Move to Top"
t(L10nKey::CmdTabBarMoveToTop)
} else {
"Tab Bar: Move to Left Sidebar"
t(L10nKey::CmdTabBarMoveToLeftSidebar)
},
ToggleTabSidebar,
),
Command::new("Right Panel: Info", ShowRightPanel(RightPanelTab::Info)),
Command::new(
"Right Panel: Outline",
t(L10nKey::CmdRightPanelInfo),
ShowRightPanel(RightPanelTab::Info),
),
Command::new(
t(L10nKey::CmdRightPanelOutline),
ShowRightPanel(RightPanelTab::Outline),
),
Command::new(
"Right Panel: Changes",
t(L10nKey::CmdRightPanelChanges),
ShowRightPanel(RightPanelTab::Changes),
),
Command::new("Right Panel: Files", ShowRightPanel(RightPanelTab::Files)),
Command::new("Change Theme…", OpenThemePicker),
Command::new("Reset Font Size", ResetFontSize),
Command::new("Enter Full Screen", ToggleFullscreen),
Command::new(
t(L10nKey::CmdRightPanelFiles),
ShowRightPanel(RightPanelTab::Files),
),
Command::new(t(L10nKey::CmdChangeTheme), OpenThemePicker),
Command::new(t(L10nKey::CmdResetFontSize), ResetFontSize),
Command::new(t(L10nKey::CmdEnterFullScreen), ToggleFullscreen),
];
let terminal = [
Command::new("Clear Scrollback", ClearTerminal),
Command::new("Find in Terminal…", FindInTerminal),
Command::new("Find Next", FindNext),
Command::new("Find Previous", FindPrevious),
Command::new("Copy", CopyText),
Command::new("Cut", CutText),
Command::new("Paste", PasteText),
Command::new("Select All", SelectAllText),
Command::new(t(L10nKey::CmdClearScrollback), ClearTerminal),
Command::new(t(L10nKey::CmdFindInTerminal), FindInTerminal),
Command::new(t(L10nKey::CmdFindNext), FindNext),
Command::new(t(L10nKey::CmdFindPrevious), FindPrevious),
Command::new(t(L10nKey::CmdCopy), CopyText),
Command::new(t(L10nKey::CmdCut), CutText),
Command::new(t(L10nKey::CmdPaste), PasteText),
Command::new(t(L10nKey::CmdSelectAll), SelectAllText),
];
let ssh = [
Command::new("SSH: Add Connection…", OpenSshConnectInput),
Command::new("SSH: Manage Profiles…", OpenSshProfiles),
Command::new("SSH: Reconnect", RestartSshSession),
Command::new("SSH: Remote Files", ToggleSftp),
Command::new("SSH: Port Forwarding", ShowSshForwards),
Command::new(t(L10nKey::CmdSshAddConnection), OpenSshConnectInput),
Command::new(t(L10nKey::CmdSshManageProfiles), OpenSshProfiles),
Command::new(t(L10nKey::CmdSshReconnect), RestartSshSession),
Command::new(t(L10nKey::CmdSshRemoteFiles), ToggleSftp),
Command::new(t(L10nKey::CmdSshPortForwarding), ShowSshForwards),
];
let agents = [
Command::new("Agent: Send Selection", SendSelectionToAgent)
.with_subtitle("selection → running coding agent"),
Command::new("Agent: Send Git Diff for Review", SendGitDiffToAgent)
.with_subtitle("git diff → running coding agent"),
Command::new(t(L10nKey::CmdAgentSendSelection), SendSelectionToAgent)
.with_subtitle(t(L10nKey::CmdAgentSendSelectionSubtitle)),
Command::new(t(L10nKey::CmdAgentSendGitDiffForReview), SendGitDiffToAgent)
.with_subtitle(t(L10nKey::CmdAgentSendGitDiffSubtitle)),
];
let application = [
Command::new("Settings…", OpenSettings),
Command::new("Keyboard Shortcuts", ShowKeyboardShortcuts),
Command::new("About tty7", About),
Command::new("Check for Updates…", CheckForUpdates),
Command::new("Documentation", OpenDocumentation),
Command::new("Join the Discord", OpenDiscord),
Command::new("Report an Issue…", ReportIssue),
Command::new("Restart Server…", RestartDaemon)
.with_subtitle("ends every running shell; layout is kept"),
Command::new("Quit tty7", Quit).with_subtitle("shells keep running"),
Command::new(t(L10nKey::CmdSettings), OpenSettings),
Command::new(t(L10nKey::CmdKeyboardShortcuts), ShowKeyboardShortcuts),
Command::new(t(L10nKey::CmdAboutTty7), About),
Command::new(t(L10nKey::CmdCheckForUpdates), CheckForUpdates),
Command::new(t(L10nKey::CmdDocumentation), OpenDocumentation),
Command::new(t(L10nKey::CmdJoinDiscord), OpenDiscord),
Command::new(t(L10nKey::CmdReportIssue), ReportIssue),
Command::new(t(L10nKey::CmdRestartServer), RestartDaemon)
.with_subtitle(t(L10nKey::CmdRestartServerSubtitle)),
Command::new(t(L10nKey::CmdQuitTty7), Quit)
.with_subtitle(t(L10nKey::CmdQuitTty7Subtitle)),
];
let mut out = Vec::new();
@@ -504,10 +512,11 @@ impl Command {
}
fn ssh_connect_command(input: &str) -> Command {
let title = if input.trim().is_empty() {
"SSH: Add Connection…".to_string()
let trimmed = input.trim();
let title = if trimmed.is_empty() {
t(L10nKey::CmdSshAddConnection).to_string()
} else {
format!("SSH: Connect {}", input.trim())
t_fmt(L10nKey::CmdSshConnectWithInput, &[("input", trimmed)])
};
Command::new(title, CommandKind::OpenSshConnect(input.to_string()))
}
@@ -644,7 +653,7 @@ impl PaletteDelegate {
recent.truncate(RECENT_ROWS);
if !recent.is_empty() {
sections.push(Section {
title: Some("Recent".into()),
title: Some(t(L10nKey::CmdRecent).into()),
commands: recent.into_iter().map(|(_, c)| c.clone()).collect(),
});
}
@@ -675,11 +684,11 @@ impl PaletteDelegate {
let target = query.trim().to_string();
vec![
Command::new(
format!("Connect to \"{target}\""),
t_fmt(L10nKey::CmdQuickConnect, &[("target", &target)]),
CommandKind::QuickConnect(target.clone()),
),
Command::new(
format!("Save \"{target}\" as profile…"),
t_fmt(L10nKey::CmdQuickConnectSaveProfile, &[("target", &target)]),
CommandKind::SaveQuickConnect(target),
),
]
@@ -803,11 +812,13 @@ impl ListDelegate for PaletteDelegate {
.items_center()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("No matching commands")
.child(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::NoMatchingCommands,
))
.child(
div()
.text_xs()
.child("Type user@host to connect over SSH instead."),
.child(crate::ui::i18n::t(crate::ui::i18n::L10nKey::ConnectSshHint)),
)
}
@@ -840,7 +851,12 @@ impl ListDelegate for PaletteDelegate {
.justify_between()
.child(left);
if cmd.kind.edit_variant().is_some() {
row = row.child(div().text_xs().text_color(muted).child("→ edit"));
row = row.child(
div()
.text_xs()
.text_color(muted)
.child(crate::ui::i18n::t(crate::ui::i18n::L10nKey::EditHint)),
);
}
if let Some(tokens) = keys {
row = row.child(h_flex().gap_1().children(tokens.into_iter().map(move |t| {
@@ -958,8 +974,8 @@ impl PaletteView {
fn search_placeholder(&self) -> &'static str {
match self.menu {
PaletteMenu::SshConnect => "user@host [-p 2222 -J jump]",
PaletteMenu::Root => "Search or type user@host to connect…",
PaletteMenu::Theme => "Search…",
PaletteMenu::Root => t(crate::ui::i18n::L10nKey::SearchCommandsOrHost),
PaletteMenu::Theme => t(crate::ui::i18n::L10nKey::SearchTheme),
}
}
@@ -1094,6 +1110,7 @@ mod tests {
#[test]
fn host_like_queries_get_connect_and_save_rows() {
crate::ui::i18n::set_locale("en");
for q in [
"deploy@10.0.0.5",
"host.example.com",
@@ -1105,8 +1122,8 @@ mod tests {
assert_eq!(
titles,
vec![
format!("Connect to \"{q}\""),
format!("Save \"{q}\" as profile…"),
t_fmt(L10nKey::CmdQuickConnect, &[("target", q)]),
t_fmt(L10nKey::CmdQuickConnectSaveProfile, &[("target", q)]),
],
"query {q:?}"
);
+10 -13
View File
@@ -9,6 +9,7 @@ use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_f
use crate::daemon::protocol::ShellSpec;
use crate::terminal::PaneWorkspace;
use crate::ui::i18n::{L10nKey, t_fmt};
#[derive(Clone)]
pub struct PendingSpawn {
@@ -93,23 +94,19 @@ impl Render for PendingPane {
},
),
)
.child(
div()
.text_sm()
.text_color(muted)
.child(format!("Connecting to {}", self.machine)),
)
.child(div().text_sm().text_color(muted).child(t_fmt(
L10nKey::PendingConnecting,
&[("machine", &self.machine)],
)))
.into_any_element(),
PendingState::Failed(reason) => v_flex()
.items_center()
.gap(px(10.))
.max_w(px(420.))
.child(
div()
.text_sm()
.text_color(theme.foreground)
.child(format!("Couldn't reach {}", self.machine)),
)
.child(div().text_sm().text_color(theme.foreground).child(t_fmt(
L10nKey::PendingUnreachable,
&[("machine", &self.machine)],
)))
.child(
div()
.text_xs()
@@ -119,7 +116,7 @@ impl Render for PendingPane {
)
.child(
Button::new("pending-pane-retry")
.label("Try Again")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::TryAgain))
.ghost()
.small()
.on_click(cx.listener(|this, _, _window, cx| {
+14
View File
@@ -499,6 +499,9 @@ pub fn fork_to_file(t: &Theme) -> std::io::Result<String> {
n += 1;
}
let mut copy = t.clone();
// The name lands in the YAML on disk and is matched back with
// `trim_end_matches(" (custom)")`, so it stays English in every locale —
// a translated suffix would survive a language switch and stack up.
copy.name = format!("{} (custom)", t.name.trim_end_matches(" (custom)"));
crate::core::config::write_atomic(
&dir.join(format!("{stem}.yaml")),
@@ -592,6 +595,8 @@ fn id_and_name(path: &std::path::Path) -> (String, String) {
(
stem,
if name.is_empty() {
// Theme names are data — they get written back to the YAML file,
// so the fallback stays English rather than following the GUI.
"Theme".into()
} else {
name
@@ -1442,6 +1447,15 @@ ansi:
assert_eq!(name, "Solarized Dark");
}
#[test]
fn theme_names_stay_english_under_a_translated_gui() {
crate::ui::i18n::set_locale("zh-CN");
// A stem of only separators leaves nothing to title-case.
let (_, name) = id_and_name(std::path::Path::new("/x/_.yaml"));
assert_eq!(name, "Theme");
crate::ui::i18n::set_locale("en");
}
#[test]
fn mix_blends_channels() {
assert_eq!(mix(0x000000, 0xffffff, 0.0), 0x000000);
+130 -69
View File
@@ -15,6 +15,7 @@ use crate::daemon::install::{
};
use crate::daemon::protocol::{AuthPromptKind, AuthResponse, NativeSshSpec};
use crate::daemon::router::RouteHeader;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use tty7_core::host::remote::RemoteHost;
use tty7_core::host::{Host as _, HostId};
@@ -106,12 +107,14 @@ fn local_stdio_host() -> Option<HostChoice> {
};
Some(HostChoice {
label: format!("{target}"),
detail: format!("{program} --stdio (this computer)"),
detail: format!("{program} --stdio ({})", t(L10nKey::RemoteThisComputer)),
target,
})
}
const WSL_DETAIL: &str = "WSL · this computer";
fn wsl_detail() -> String {
format!("WSL · {}", t(L10nKey::RemoteThisComputer))
}
fn wsl_hosts(cx: &App) -> Vec<HostChoice> {
let names = cx
@@ -129,7 +132,7 @@ fn wsl_choices(names: &[String]) -> Vec<HostChoice> {
distro: distro.clone(),
},
label: distro.clone(),
detail: WSL_DETAIL.to_string(),
detail: wsl_detail(),
})
.collect()
}
@@ -197,7 +200,7 @@ pub fn spec_for(target: &RemoteTarget, cx: &App) -> Result<NativeSshSpec, String
.ssh_profiles
.iter()
.find(|p| p.id == *id)
.ok_or_else(|| "that saved SSH profile no longer exists".to_string())?;
.ok_or_else(|| t(L10nKey::RemoteProfileMissing).to_string())?;
Ok(crate::ui::ssh_connect::build_native_ssh_spec(
profile,
&cfg.ssh_profiles,
@@ -207,7 +210,7 @@ pub fn spec_for(target: &RemoteTarget, cx: &App) -> Result<NativeSshSpec, String
}
RemoteTarget::Alias { alias } => {
let resolved = crate::core::ssh_config::resolve_alias_to_profile(alias)
.ok_or_else(|| format!("`{alias}` is no longer in ~/.ssh/config"))?;
.ok_or_else(|| t_fmt(L10nKey::RemoteAliasMissing, &[("alias", alias)]))?;
Ok(crate::ui::ssh_connect::native_spec_from_transient_profile(
&resolved.profile,
resolved.proxy_jump,
@@ -228,10 +231,8 @@ pub fn spec_for(target: &RemoteTarget, cx: &App) -> Result<NativeSshSpec, String
cfg.verify_host_keys,
))
}
RemoteTarget::Wsl { .. } => Err("a WSL workspace has no SSH connection".to_string()),
RemoteTarget::LocalStdio { .. } => {
Err("a local --stdio workspace has no SSH connection".to_string())
}
RemoteTarget::Wsl { .. } => Err(t(L10nKey::RemoteWslNoSsh).to_string()),
RemoteTarget::LocalStdio { .. } => Err(t(L10nKey::RemoteLocalStdioNoSsh).to_string()),
}
}
@@ -260,22 +261,42 @@ pub fn connect_blocking(
label: &str,
) -> Result<Connected, String> {
note_origin(&header.target, target);
crate::daemon::spawn::ensure_running()
.map_err(|e| format!("tty7's local server could not be started: {e}"))?;
crate::daemon::spawn::ensure_running().map_err(|e| {
t_fmt(
L10nKey::RemoteDaemonStartFailed,
&[("error", &e.to_string())],
)
})?;
let stream = crate::daemon::transport::connect()
.map_err(|e| format!("could not reach tty7's local server: {e}"))?;
let stream = crate::daemon::transport::connect().map_err(|e| {
t_fmt(
L10nKey::RemoteDaemonUnreachable,
&[("error", &e.to_string())],
)
})?;
let mut stream = stream;
crate::daemon::router::negotiate(&mut stream, &header)
.map_err(|e| format!("could not reach {label}: {e}"))?;
crate::daemon::router::negotiate(&mut stream, &header).map_err(|e| {
t_fmt(
L10nKey::RemoteHostUnreachable,
&[("machine", label), ("error", &e.to_string())],
)
})?;
let hello = ControlHello::host_rpc(new_session_token(), client_hostname());
let host = handshake(stream, &target.connection_key(), &hello)
.map_err(|e| format!("{label} answered, but not as a tty7 server: {e}"))?;
let host = handshake(stream, &target.connection_key(), &hello).map_err(|e| {
t_fmt(
L10nKey::RemoteHostNotTty7,
&[("machine", label), ("error", &e.to_string())],
)
})?;
let rows = list_workspaces(&host)
.map_err(|e| format!("connected to {label}, but its workspace list failed: {e}"))?;
let rows = list_workspaces(&host).map_err(|e| {
t_fmt(
L10nKey::RemoteWorkspaceListFailed,
&[("machine", label), ("error", &e.to_string())],
)
})?;
let home = host.home();
refresh_agent_hooks_once(&host, &home);
Ok(Connected { host, home, rows })
@@ -321,7 +342,10 @@ pub fn list_workspaces(host: &Arc<RemoteHost>) -> io::Result<Vec<RemoteWorkspace
ReplyOk::MachineTree(machine) => Ok(rows_from_machine(&machine)),
other => Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("the server answered a machine tree with {other:?}"),
t_fmt(
L10nKey::RemoteMachineTreeUnexpectedReply,
&[("reply", &format!("{other:?}"))],
),
)),
}
}
@@ -405,36 +429,37 @@ impl HostLinks {
}
pub fn install_detail(request: &InstallRequest) -> String {
format!(
"tty7 will write its server binary to {machine} so this machine can host \
workspaces there. Nothing else on {machine} is touched, and no sudo is used.\n\
\n\
Path\u{2003}{path}\n\
Version\u{2003}{version} ({asset})\n\
Size\u{2003}{size}\n\
From\u{2003}{url}\n\
SHA-256\u{2003}{sha}\n\
\n\
Later upgrades on this machine install silently.",
machine = request.host,
path = request.remote_path,
version = request.version,
asset = request.asset,
size = human_bytes(request.size_bytes),
url = request.source_url,
sha = request.sha256,
t_fmt(
L10nKey::RemoteInstallDetail,
&[
("machine", &request.host),
("path", &request.remote_path),
(
"version",
&format!("{} ({})", request.version, request.asset),
),
("size", &human_bytes(request.size_bytes)),
("from", &request.source_url),
("sha256", &request.sha256),
("path_label", t(L10nKey::RemoteInstallPathLabel)),
("version_label", t(L10nKey::RemoteInstallVersionLabel)),
("size_label", t(L10nKey::RemoteInstallSizeLabel)),
("from_label", t(L10nKey::RemoteInstallFromLabel)),
("sha_label", t(L10nKey::RemoteInstallShaLabel)),
("silent_upgrades", t(L10nKey::RemoteInstallSilentUpgrades)),
],
)
}
pub fn install_title(request: &InstallRequest) -> String {
format!("Install tty7's server on \u{201c}{}\u{201d}?", request.host)
t_fmt(L10nKey::RemoteInstallTitle, &[("machine", &request.host)])
}
pub fn human_bytes(n: u64) -> String {
const KIB: f64 = 1024.0;
let n = n as f64;
if n < KIB {
return format!("{} bytes", n as u64);
return format!("{} {}", n as u64, t(L10nKey::RemoteInstallBytes));
}
let units = ["KiB", "MiB", "GiB"];
let mut value = n / KIB;
@@ -615,29 +640,34 @@ pub(crate) fn claim_mailbox() -> std::sync::MutexGuard<'static, ()> {
MAILBOX_TURN.lock().unwrap_or_else(|e| e.into_inner())
}
pub const MISMATCH_ANSWERS: [&str; 2] = ["Cancel", "Restart Server"];
pub fn mismatch_answers() -> [&'static str; 2] {
[t(L10nKey::Cancel), t(L10nKey::RestartServer)]
}
pub fn mismatch_detail(m: &MismatchedRemoteDaemon) -> String {
let running = match (&m.running_version, &m.running_exe) {
(Some(v), Some(exe)) => format!("{v} (from {exe})"),
(Some(v), Some(exe)) => t_fmt(
L10nKey::RemoteMismatchVersionFromExe,
&[("version", v), ("exe", exe)],
),
(Some(v), None) => v.clone(),
(None, Some(exe)) => format!("an unknown build (from {exe})"),
(None, None) => "an unknown build".to_string(),
(None, Some(exe)) => t_fmt(L10nKey::RemoteMismatchUnknownBuildFromExe, &[("exe", exe)]),
(None, None) => t(L10nKey::RemoteMismatchUnknownBuild).to_string(),
};
format!(
"{host} is serving tty7 sessions from {running}, which speaks a protocol \
this client ({wanted}) cannot. tty7 has installed a matching server there, \
but the one already running is the one your sessions are on.\n\
\n\
Restart Server\u{2003}starts {wanted} there and ends every session it is hosting.\n\
Cancel\u{2003}leaves {host} exactly as it is. This window will not connect.",
host = m.host,
wanted = m.wanted_version,
t_fmt(
L10nKey::RemoteMismatchDetail,
&[
("machine", &m.host),
("running", &running),
("wanted", &m.wanted_version),
("restart_server", t(L10nKey::RestartServer)),
("cancel", t(L10nKey::Cancel)),
],
)
}
pub fn mismatch_title(m: &MismatchedRemoteDaemon) -> String {
format!("Restart tty7's server on \u{201c}{}\u{201d}?", m.host)
t_fmt(L10nKey::RemoteMismatchTitle, &[("machine", &m.host)])
}
pub fn mismatch_target(m: &MismatchedRemoteDaemon) -> Option<RemoteTarget> {
@@ -646,17 +676,26 @@ pub fn mismatch_target(m: &MismatchedRemoteDaemon) -> Option<RemoteTarget> {
pub fn restart_server_blocking(header: RouteHeader, label: &str) -> Result<(), String> {
let action = header.action;
crate::daemon::spawn::ensure_running()
.map_err(|e| format!("tty7's local server could not be started: {e}"))?;
let mut stream = crate::daemon::transport::connect()
.map_err(|e| format!("could not reach tty7's local server: {e}"))?;
let ack = crate::daemon::router::negotiate(&mut stream, &header)
.map_err(|e| format!("could not restart tty7's server on {label}: {e}"))?;
crate::daemon::spawn::ensure_running().map_err(|e| {
t_fmt(
L10nKey::RemoteDaemonStartFailed,
&[("error", &e.to_string())],
)
})?;
let mut stream = crate::daemon::transport::connect().map_err(|e| {
t_fmt(
L10nKey::RemoteDaemonUnreachable,
&[("error", &e.to_string())],
)
})?;
let ack = crate::daemon::router::negotiate(&mut stream, &header).map_err(|e| {
t_fmt(
L10nKey::RemoteServerRestartFailed,
&[("machine", label), ("error", &e.to_string())],
)
})?;
if !ack.performed(action) {
return Err(format!(
"this machine's tty7 daemon is an older build and cannot restart the server on \
{label}. Quit tty7 (which stops the daemon) and open it again, then retry."
));
return Err(t_fmt(L10nKey::RemoteDaemonTooOld, &[("machine", label)]));
}
Ok(())
}
@@ -679,6 +718,7 @@ mod tests {
#[test]
fn the_install_prompt_states_every_field_of_the_request() {
crate::ui::i18n::set_locale("en");
let request = request();
let detail = install_detail(&request);
for needle in [
@@ -694,12 +734,22 @@ mod tests {
);
}
assert!(detail.contains("9.0 MiB"), "{detail}");
assert!(install_title(&request).contains("me@build-box:22"));
assert_eq!(
install_title(&request),
t_fmt(
L10nKey::RemoteInstallTitle,
&[("machine", "me@build-box:22")]
)
);
}
#[test]
fn human_bytes_reads_in_binary_units() {
assert_eq!(human_bytes(512), "512 bytes");
crate::ui::i18n::set_locale("en");
assert_eq!(
human_bytes(512),
format!("512 {}", t(L10nKey::RemoteInstallBytes))
);
assert_eq!(human_bytes(1024), "1.0 KiB");
assert_eq!(human_bytes(1_572_864), "1.5 MiB");
assert_eq!(human_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB");
@@ -859,6 +909,7 @@ mod tests {
#[test]
fn the_mismatch_prompt_names_the_host_and_both_versions() {
crate::ui::i18n::set_locale("en");
let m = MismatchedRemoteDaemon {
host: "me@build-box:22".into(),
running_version: Some("0.8.0".into()),
@@ -869,25 +920,35 @@ mod tests {
assert!(detail.contains("0.8.0"), "{detail}");
assert!(detail.contains("0.9.1"), "{detail}");
assert!(detail.contains("me@build-box:22"), "{detail}");
assert!(mismatch_title(&m).contains("me@build-box:22"));
assert_eq!(
mismatch_title(&m),
t_fmt(
L10nKey::RemoteMismatchTitle,
&[("machine", "me@build-box:22")]
)
);
let unknown = MismatchedRemoteDaemon {
running_version: None,
running_exe: None,
..m
};
assert!(mismatch_detail(&unknown).contains("an unknown build"));
assert!(
mismatch_detail(&unknown).contains(t(L10nKey::RemoteMismatchUnknownBuild)),
"{detail}"
);
}
#[test]
fn the_mismatch_detail_explains_every_answer_the_prompt_offers() {
crate::ui::i18n::set_locale("en");
let detail = mismatch_detail(&MismatchedRemoteDaemon {
host: "me@build-box:22".into(),
running_version: Some("0.8.0".into()),
running_exe: None,
wanted_version: "0.9.1".into(),
});
for answer in MISMATCH_ANSWERS {
for answer in mismatch_answers() {
assert!(detail.contains(answer), "{answer} is unexplained: {detail}");
}
}
+83 -78
View File
@@ -11,6 +11,7 @@ use crate::core::session::{RemoteRef, RemoteTarget, WorkspaceId, WorkspaceStore}
use crate::daemon::control::{ControlEvent, ControlRequest, ReplyOk};
use crate::daemon::install::InstallDecision;
use crate::ui::app::Tty7App;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::remote_connect::{self, HostChoice, RemoteWorkspaceRow};
pub enum ConnectFlow {
@@ -40,35 +41,47 @@ impl RemoteStatus {
pub fn strip_message(&self, machine: &str) -> Option<String> {
match self {
RemoteStatus::Attached => None,
RemoteStatus::Disconnected => Some(format!("Not connected to {machine}")),
RemoteStatus::Connecting => Some(format!("Connecting to {machine}")),
RemoteStatus::Reconnecting { attempt: 0 } => {
Some(format!("Reconnecting to {machine}"))
RemoteStatus::Disconnected => Some(t_fmt(
L10nKey::RemoteStripDisconnected,
&[("machine", machine)],
)),
RemoteStatus::Connecting => Some(t_fmt(
L10nKey::RemoteStripConnecting,
&[("machine", machine)],
)),
RemoteStatus::Reconnecting { attempt: 0 } => Some(t_fmt(
L10nKey::RemoteStripReconnecting,
&[("machine", machine)],
)),
RemoteStatus::Reconnecting { attempt } => Some(t_fmt(
L10nKey::RemoteStripReconnectingAttempt,
&[("machine", machine), ("count", &(attempt + 1).to_string())],
)),
RemoteStatus::Preempted { by } => {
Some(t_fmt(L10nKey::RemoteStripPreempted, &[("by", by)]))
}
RemoteStatus::Reconnecting { attempt } => Some(format!(
"Reconnecting to {machine}… (attempt {})",
attempt + 1
RemoteStatus::Failed(e) => Some(t_fmt(
L10nKey::RemoteStripFailed,
&[("machine", machine), ("error", e)],
)),
RemoteStatus::Preempted { by } => Some(format!("This workspace was opened on {by}")),
RemoteStatus::Failed(e) => Some(format!("Not connected to {machine}{e}")),
}
}
pub fn input_notice(&self) -> Option<&'static str> {
match self {
RemoteStatus::Attached => None,
RemoteStatus::Preempted { .. } => Some("Opened elsewhere — typing has no effect"),
_ => Some("Not connected — typing has no effect"),
RemoteStatus::Preempted { .. } => Some(t(L10nKey::RemoteNoticePreempted)),
_ => Some(t(L10nKey::RemoteNoticeDisconnected)),
}
}
pub fn action_label(&self) -> Option<&'static str> {
match self {
RemoteStatus::Attached | RemoteStatus::Connecting => None,
RemoteStatus::Reconnecting { .. } => Some("Retry Now"),
RemoteStatus::Preempted { .. } => Some("Take Back"),
RemoteStatus::Disconnected => Some("Connect"),
RemoteStatus::Failed(_) => Some("Retry"),
RemoteStatus::Reconnecting { .. } => Some(t(L10nKey::RemoteActionRetryNow)),
RemoteStatus::Preempted { .. } => Some(t(L10nKey::RemoteActionTakeBack)),
RemoteStatus::Disconnected => Some(t(L10nKey::RemoteActionConnect)),
RemoteStatus::Failed(_) => Some(t(L10nKey::RemoteActionRetry)),
}
}
@@ -184,11 +197,7 @@ impl Tty7App {
_ => {
let machine = self.remote_machine_label(cx);
window.push_notification(
format!(
"This window is a workspace on {machine}, but tty7 has no connection \
details for it any more check that its SSH profile or ~/.ssh/config \
entry still exists."
),
t_fmt(L10nKey::RemoteNoConnectionDetails, &[("machine", &machine)]),
cx,
);
false
@@ -206,7 +215,7 @@ impl Tty7App {
pub(crate) fn remote_machine_label(&self, cx: &gpui::App) -> String {
match WorkspaceStore::remote_ref(cx, self.workspace) {
Some(host) => host.target.to_string(),
None => "this computer".to_string(),
None => t(L10nKey::RemoteThisComputer).to_string(),
}
}
@@ -437,7 +446,7 @@ impl Tty7App {
PromptLevel::Warning,
&title,
Some(&detail),
&["Cancel", "Install"],
&[t(L10nKey::Cancel), t(L10nKey::SettingsInstall)],
cx,
);
cx.spawn(async move |_, _| {
@@ -458,7 +467,7 @@ impl Tty7App {
PromptLevel::Warning,
&title,
Some(&detail),
&remote_connect::MISMATCH_ANSWERS,
&remote_connect::mismatch_answers(),
cx,
);
cx.spawn(async move |this, cx| {
@@ -481,7 +490,7 @@ impl Tty7App {
) {
let label = mismatch.host.clone();
match remote_connect::mismatch_target(&mismatch)
.ok_or_else(|| format!("tty7 no longer has a way to reach {label}"))
.ok_or_else(|| t_fmt(L10nKey::RemoteNoRouteToHost, &[("machine", &label)]))
{
Ok(target) => self.restart_remote_server(target, label, window, cx),
Err(e) => Tty7App::report_restart_failure(&label, &e, window, cx),
@@ -497,13 +506,9 @@ impl Tty7App {
) {
let answer = window.prompt(
PromptLevel::Warning,
&format!("Restart tty7's server on \u{201c}{label}\u{201d}?"),
Some(&format!(
"This stops every shell on {label} — anything still running in them \
will be terminated, including shells this window is not showing. \
Workspaces and layouts are kept and come back with fresh shells."
)),
&["Cancel", "Restart Server"],
&t_fmt(L10nKey::RemoteRestartTitle, &[("machine", &label)]),
Some(&t_fmt(L10nKey::RemoteRestartBody, &[("machine", &label)])),
&[t(L10nKey::Cancel), t(L10nKey::RestartServer)],
cx,
);
cx.spawn(async move |this, cx| {
@@ -567,16 +572,9 @@ impl Tty7App {
) {
let answer = window.prompt(
PromptLevel::Warning,
&format!("Restart tty7's server on \u{201c}{label}\u{201d}?"),
Some(&format!(
"The tty7-server running on {label} speaks a protocol this client cannot. \
tty7 will restart the service there onto one that does, installing it \
first if {label} does not already have it.\n\
\n\
Every session running on {label} ends, including any this window is not \
connected to."
)),
&["Cancel", "Restart Server"],
&t_fmt(L10nKey::RemoteRestartTitle, &[("machine", &label)]),
Some(&t_fmt(L10nKey::RemoteReplaceBody, &[("machine", &label)])),
&[t(L10nKey::Cancel), t(L10nKey::RestartServer)],
cx,
);
cx.spawn(async move |this, cx| {
@@ -640,12 +638,12 @@ impl Tty7App {
) {
let answer = window.prompt(
PromptLevel::Warning,
&format!("tty7's server on \u{201c}{label}\u{201d} was not restarted"),
Some(&format!(
"{error}\n\nSessions still running there are on the older build. If they are \
gone, reconnecting starts this build's server."
&t_fmt(L10nKey::RemoteRestartFailedTitle, &[("machine", label)]),
Some(&t_fmt(
L10nKey::RemoteRestartFailedBody,
&[("error", error)],
)),
&["OK"],
&[t(L10nKey::Ok)],
cx,
);
cx.spawn(async move |_, _| {
@@ -1441,24 +1439,28 @@ mod tests {
#[test]
fn the_status_strip_speaks_unless_everything_is_working() {
crate::ui::i18n::set_locale("en");
assert_eq!(RemoteStatus::Attached.strip_message("build-box"), None);
assert_eq!(
RemoteStatus::Disconnected
.strip_message("build-box")
.as_deref(),
Some("Not connected to build-box")
RemoteStatus::Disconnected.strip_message("build-box"),
Some(t_fmt(
L10nKey::RemoteStripDisconnected,
&[("machine", "build-box")]
))
);
assert_eq!(
RemoteStatus::Connecting
.strip_message("build-box")
.as_deref(),
Some("Connecting to build-box…")
RemoteStatus::Connecting.strip_message("build-box"),
Some(t_fmt(
L10nKey::RemoteStripConnecting,
&[("machine", "build-box")]
))
);
assert_eq!(
RemoteStatus::Failed("connection refused".into())
.strip_message("build-box")
.as_deref(),
Some("Not connected to build-box — connection refused")
RemoteStatus::Failed("connection refused".into()).strip_message("build-box"),
Some(t_fmt(
L10nKey::RemoteStripFailed,
&[("machine", "build-box"), ("error", "connection refused")]
))
);
}
@@ -1626,39 +1628,40 @@ mod tests {
#[test]
fn every_state_says_what_it_means_for_the_keyboard() {
crate::ui::i18n::set_locale("en");
let cases = [
(RemoteStatus::Attached, true, None, None),
(
RemoteStatus::Disconnected,
false,
Some("Not connected — typing has no effect"),
Some("Connect"),
Some(t(L10nKey::RemoteNoticeDisconnected)),
Some(t(L10nKey::RemoteActionConnect)),
),
(
RemoteStatus::Connecting,
false,
Some("Not connected — typing has no effect"),
Some(t(L10nKey::RemoteNoticeDisconnected)),
None,
),
(
RemoteStatus::Reconnecting { attempt: 2 },
false,
Some("Not connected — typing has no effect"),
Some("Retry Now"),
Some(t(L10nKey::RemoteNoticeDisconnected)),
Some(t(L10nKey::RemoteActionRetryNow)),
),
(
RemoteStatus::Preempted {
by: "desktop".into(),
},
false,
Some("Opened elsewhere — typing has no effect"),
Some("Take Back"),
Some(t(L10nKey::RemoteNoticePreempted)),
Some(t(L10nKey::RemoteActionTakeBack)),
),
(
RemoteStatus::Failed("no route to host".into()),
false,
Some("Not connected — typing has no effect"),
Some("Retry"),
Some(t(L10nKey::RemoteNoticeDisconnected)),
Some(t(L10nKey::RemoteActionRetry)),
),
];
for (status, accepts, notice, action) in cases {
@@ -1670,26 +1673,28 @@ mod tests {
#[test]
fn the_new_states_name_what_happened() {
crate::ui::i18n::set_locale("en");
assert_eq!(
RemoteStatus::Reconnecting { attempt: 0 }
.strip_message("build-box")
.as_deref(),
Some("Reconnecting to build-box…"),
RemoteStatus::Reconnecting { attempt: 0 }.strip_message("build-box"),
Some(t_fmt(
L10nKey::RemoteStripReconnecting,
&[("machine", "build-box")]
)),
"the first attempt does not need a count"
);
assert_eq!(
RemoteStatus::Reconnecting { attempt: 3 }
.strip_message("build-box")
.as_deref(),
Some("Reconnecting to build-box… (attempt 4)")
RemoteStatus::Reconnecting { attempt: 3 }.strip_message("build-box"),
Some(t_fmt(
L10nKey::RemoteStripReconnectingAttempt,
&[("machine", "build-box"), ("count", "4")]
))
);
assert_eq!(
RemoteStatus::Preempted {
by: "desktop".into()
}
.strip_message("build-box")
.as_deref(),
Some("This workspace was opened on desktop")
.strip_message("build-box"),
Some(t_fmt(L10nKey::RemoteStripPreempted, &[("by", "desktop")]))
);
}
+48 -41
View File
@@ -14,6 +14,7 @@ use crate::ui::app::{
CONTENT_INSET, TILE_GLYPH_SM, TILE_SIZE_SM, Tty7App, tile_trailing_inset,
tile_trailing_inset_sm,
};
use crate::ui::i18n::{L10nKey, t, t_plural};
use crate::ui::scrollbar::with_vertical_scrollbar;
pub(crate) const MIN_WIDTH: f32 = 216.;
@@ -355,7 +356,7 @@ impl Tty7App {
}
fn render_panel_info(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
let title = self.panel_title("Info", None, None, window, cx);
let title = self.panel_title(t(L10nKey::PanelInfoTitle), None, None, window, cx);
let mut rows: Vec<(&'static str, String)> = Vec::new();
let mut cwd_for_actions: Option<PathBuf> = None;
let mut pane_id: Option<u64> = None;
@@ -370,16 +371,16 @@ impl Tty7App {
.map(|p| p.to_path_buf())
.or_else(|| view.cwd())
{
rows.push(("cwd", compact_path(&cwd)));
rows.push((t(L10nKey::PanelCwd), compact_path(&cwd)));
cwd_for_actions = Some(cwd);
}
let shell = match view.shell_spec().map(|s| s.program.clone()) {
Some(program) => crate::core::shells::default_shell_name(Some(&program)),
None => self.default_shell_label(cx),
};
rows.push(("shell", shell));
rows.push((t(L10nKey::PanelShell), shell));
if let Some(ssh) = view.ssh_spec() {
rows.push(("ssh", ssh.host.clone()));
rows.push((t(L10nKey::PanelSsh), ssh.host.clone()));
}
let connected_ssh = view
.remote_context()
@@ -393,8 +394,11 @@ impl Tty7App {
}
}
if let Some(git) = tab.git_status(Some(window), cx) {
rows.push(("branch", git.branch.clone()));
rows.push(("changes", format!("+{} {}", git.added, git.removed)));
rows.push((t(L10nKey::PanelBranch), git.branch.clone()));
rows.push((
t(L10nKey::PanelChangesRow),
format!("+{} {}", git.added, git.removed),
));
}
if let Some(agent) = tab.agent(cx) {
let name = agent.display_name();
@@ -402,15 +406,15 @@ impl Tty7App {
Some(s) => format!("{name} · {}", agent_status_label(s)),
None => name.to_string(),
};
rows.push(("agent", status));
rows.push((t(L10nKey::PanelAgent), status));
}
}
if rows.is_empty() {
return self.panel_scroll(
self.panel_empty(
"No active session.",
Some("Open a tab to see its shell, directory, and processes here."),
t(L10nKey::PanelNoSession),
Some(t(L10nKey::PanelNoSessionHint)),
cx,
),
title,
@@ -449,7 +453,7 @@ impl Tty7App {
}
let inner = v_flex()
.child(self.panel_subtitle("Session", false, None, cx))
.child(self.panel_subtitle(t(L10nKey::PanelSessionSubtitle), false, None, cx))
.child(list)
.when_some(cwd_for_actions, |this, cwd| {
this.child(self.cwd_actions(cwd, cx))
@@ -491,7 +495,7 @@ impl Tty7App {
cx,
)
.rounded_md()
.tooltip("Copy Path")
.tooltip(t(L10nKey::FileTreeContextCopyPath))
.on_click(move |_, _window, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(
cwd.display().to_string(),
@@ -575,7 +579,7 @@ impl Tty7App {
}
Some(
v_flex()
.child(self.panel_subtitle("Processes", true, None, cx))
.child(self.panel_subtitle(t(L10nKey::PanelProcessesSubtitle), true, None, cx))
.child(list)
.into_any_element(),
)
@@ -613,7 +617,7 @@ impl Tty7App {
}
Some(
v_flex()
.child(self.panel_subtitle("Ports", true, None, cx))
.child(self.panel_subtitle(t(L10nKey::PanelPortsSubtitle), true, None, cx))
.child(list)
.into_any_element(),
)
@@ -708,11 +712,11 @@ impl Tty7App {
.get(self.active)
.and_then(|t| t.detail_pane(window, cx))
else {
let title = self.panel_title("Outline", None, None, window, cx);
let title = self.panel_title(t(L10nKey::PanelOutlineTitle), None, None, window, cx);
return self.panel_scroll(
self.panel_empty(
"No active session.",
Some("Open a tab to see its shell, directory, and processes here."),
t(L10nKey::PanelNoSession),
Some(t(L10nKey::PanelNoSessionHint)),
cx,
),
title,
@@ -720,17 +724,23 @@ impl Tty7App {
};
let count = leaf.read(cx).command_marks().len();
if count == 0 {
let title = self.panel_title("Outline", None, None, window, cx);
let title = self.panel_title(t(L10nKey::PanelOutlineTitle), None, None, window, cx);
return self.panel_scroll(
self.panel_empty(
"No commands recorded for this pane.",
Some("Run a command — shell integration marks each one so you can jump back to it."),
t(L10nKey::PanelNoCommands),
Some(t(L10nKey::PanelNoCommandsHint)),
cx,
),
title,
);
}
let title = self.panel_title("Outline", Some(count.to_string()), None, window, cx);
let title = self.panel_title(
t(L10nKey::PanelOutlineTitle),
Some(count.to_string()),
None,
window,
cx,
);
let mono = cx.theme().mono_font_family.clone();
let mut list = v_flex().px(px(CONTENT_INSET - 4.)).py(px(2.)).gap(px(1.));
@@ -812,11 +822,11 @@ impl Tty7App {
});
let Some((host, cwd)) = target else {
let title = self.panel_title("Changes", None, None, window, cx);
let title = self.panel_title(t(L10nKey::PanelChangesTitle), None, None, window, cx);
return self.panel_scroll(
self.panel_empty(
"No working directory.",
Some("This pane has not reported one yet."),
t(L10nKey::PanelNoWorkingDirectory),
Some(t(L10nKey::PanelNoWorkingDirectoryHint)),
cx,
),
title,
@@ -838,20 +848,20 @@ impl Tty7App {
}
_ => None,
};
let title = self.panel_title("Changes", count, None, window, cx);
let title = self.panel_title(t(L10nKey::PanelChangesTitle), count, None, window, cx);
let mono = cx.theme().mono_font_family.clone();
let inner = match &self.right_panel.diff {
None => self.panel_empty("Loading…", None, cx),
None => self.panel_empty(t(L10nKey::PanelLoading), None, cx),
Some(None) => self.panel_empty(
"Not a git repository.",
Some("cd into one and this tab lists its uncommitted changes."),
t(L10nKey::PanelNotAGitRepo),
Some(t(L10nKey::PanelNotAGitRepoHint)),
cx,
),
Some(Some(snap)) if snap.files.is_empty() && snap.untracked.is_empty() => self
.panel_empty(
"No uncommitted changes.",
Some("The working tree is clean."),
t(L10nKey::PanelNoChanges),
Some(t(L10nKey::PanelNoChangesHint)),
cx,
),
Some(Some(snap)) => {
@@ -930,10 +940,7 @@ impl Tty7App {
.py(px(3.))
.text_size(px(11.5))
.text_color(cx.theme().muted_foreground)
.child(format!(
"… and {rest} more changed file{} — run `git diff` to see them.",
if rest == 1 { "" } else { "s" }
)),
.child(t_plural(L10nKey::PanelMoreChangedFiles, rest, &[])),
);
}
if untracked > 0 {
@@ -952,7 +959,7 @@ impl Tty7App {
div()
.text_size(px(11.5))
.text_color(cx.theme().muted_foreground)
.child(format!("{untracked} untracked")),
.child(t_plural(L10nKey::PanelUntracked, untracked, &[])),
),
);
}
@@ -1007,7 +1014,7 @@ impl Tty7App {
return self.render_panel_sftp(host.unwrap_or_default(), window, cx);
}
let title = self.panel_title("Files", None, None, window, cx);
let title = self.panel_title(t(L10nKey::PanelFilesTitle), None, None, window, cx);
let search = self.panel_search(&self.file_search.clone(), cx);
let rows = self.render_file_tree_rows(window, cx);
v_flex()
@@ -1071,19 +1078,19 @@ pub(crate) fn info_chip(
pub fn reveal_label() -> &'static str {
if cfg!(target_os = "macos") {
"Reveal in Finder"
t(L10nKey::PanelRevealInFinder)
} else {
"Open Folder"
t(L10nKey::PanelOpenFolder)
}
}
fn agent_status_label(status: crate::core::cli_agent::AgentStatus) -> &'static str {
use crate::core::cli_agent::AgentStatus::*;
match status {
Idle => "idle",
Working => "working",
Waiting => "waiting",
Done => "done",
Idle => t(L10nKey::PanelAgentIdle),
Working => t(L10nKey::PanelAgentWorking),
Waiting => t(L10nKey::PanelAgentWaiting),
Done => t(L10nKey::PanelAgentDone),
}
}
+785 -592
View File
File diff suppressed because it is too large Load Diff
+115 -57
View File
@@ -20,6 +20,7 @@ use crate::daemon::protocol::{
use crate::daemon::ssh::sftp::{remote_basename, remote_join, remote_parent, safe_local_name};
use crate::terminal::RemoteTerminal;
use crate::ui::app::{CONTENT_INSET, Tty7App};
use crate::ui::i18n::{L10nKey, t, t_fmt};
#[derive(Clone, Copy)]
enum SftpMenuAction {
@@ -70,7 +71,10 @@ impl SftpRoute {
};
match RemoteTerminal::on_workspace(req) {
Ok(crate::daemon::protocol::DaemonMsg::SftpEntries(e)) => Ok(e),
Ok(other) => Err(format!("unexpected reply: {other:?}")),
Ok(other) => Err(t_fmt(
L10nKey::SftpErrorUnexpectedReply,
&[("reply", &format!("{other:?}"))],
)),
Err(e) => Err(e.to_string()),
}
}
@@ -83,7 +87,10 @@ impl SftpRoute {
};
match RemoteTerminal::on_workspace(req) {
Ok(crate::daemon::protocol::DaemonMsg::SftpOpResult(r)) => r,
Ok(other) => SftpOpResult::Error(format!("unexpected reply: {other:?}")),
Ok(other) => SftpOpResult::Error(t_fmt(
L10nKey::SftpErrorUnexpectedReply,
&[("reply", &format!("{other:?}"))],
)),
Err(e) => SftpOpResult::Error(e.to_string()),
}
}
@@ -98,7 +105,10 @@ impl SftpRoute {
};
match RemoteTerminal::on_workspace(req) {
Ok(crate::daemon::protocol::DaemonMsg::SftpTransferStarted { job_id }) => Ok(job_id),
Ok(other) => Err(format!("unexpected reply: {other:?}")),
Ok(other) => Err(t_fmt(
L10nKey::SftpErrorUnexpectedReply,
&[("reply", &format!("{other:?}"))],
)),
Err(e) => Err(e.to_string()),
}
}
@@ -146,7 +156,10 @@ pub(crate) struct SftpPanelState {
impl SftpPanelState {
pub(crate) fn new(window: &mut Window, cx: &mut Context<Tty7App>) -> Self {
let filter_input = cx.new(|cx| InputState::new(window, cx).placeholder("Search"));
let filter_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Search))
});
let sub = cx.subscribe_in(&filter_input, window, |_this, _input, ev, _w, cx| {
if matches!(ev, gpui_component::input::InputEvent::Change) {
cx.notify();
@@ -496,7 +509,10 @@ impl Tty7App {
return;
};
if !safe_local_name(&entry.name) {
self.sftp_panel.error = Some(format!("refusing unsafe remote name {:?}", entry.name));
self.sftp_panel.error = Some(t_fmt(
L10nKey::SftpErrorUnsafeRemoteName,
&[("name", &format!("{:?}", entry.name))],
));
cx.notify();
return;
}
@@ -595,13 +611,19 @@ impl Tty7App {
}
pub(crate) fn sftp_begin_new_folder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("New folder name"));
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(crate::ui::i18n::t(crate::ui::i18n::L10nKey::NewFolderName))
});
self.sftp_panel.editing = Some(SftpEdit::NewFolder(input));
cx.notify();
}
pub(crate) fn sftp_begin_new_file(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let input = cx.new(|cx| InputState::new(window, cx).placeholder("New file name"));
let input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder(crate::ui::i18n::t(crate::ui::i18n::L10nKey::NewFileName))
});
self.sftp_panel.editing = Some(SftpEdit::NewFile(input));
cx.notify();
}
@@ -685,7 +707,8 @@ impl Tty7App {
mode,
}),
Err(_) => {
self.sftp_panel.error = Some("invalid octal mode".to_string());
self.sftp_panel.error =
Some(t(L10nKey::SftpErrorInvalidOctalMode).to_string());
cx.notify();
return;
}
@@ -832,7 +855,13 @@ impl Tty7App {
cx: &mut Context<Self>,
) -> AnyElement {
let controls = self.sftp_controls(cx);
let title = self.panel_title("Files", Some(host), Some(controls), window, cx);
let title = self.panel_title(
t(L10nKey::SftpPanelTitleFiles),
Some(host),
Some(controls),
window,
cx,
);
let breadcrumb = self.render_sftp_breadcrumb(cx);
let filter = div()
.id("panel-sftp-filter")
@@ -885,7 +914,7 @@ impl Tty7App {
false,
cx,
)
.tooltip("Refresh")
.tooltip(t(L10nKey::SftpTooltipRefresh))
.on_click(cx.listener(|this, _, _w, cx| this.sftp_refresh(cx))),
),
)
@@ -897,16 +926,19 @@ impl Tty7App {
false,
cx,
)
.tooltip("More")
.tooltip(t(L10nKey::SftpTooltipMore))
.dropdown_menu_with_anchor(gpui::Anchor::TopRight, {
let app = cx.entity().downgrade();
move |menu, _window, _cx| {
let mut menu = menu.min_w(px(190.));
for (label, action) in [
("New folder", SftpMenuAction::NewFolder),
("New file", SftpMenuAction::NewFile),
("Upload…", SftpMenuAction::Upload),
("Go to shell directory", SftpMenuAction::GotoShellCwd),
(t(L10nKey::SftpMenuNewFolder), SftpMenuAction::NewFolder),
(t(L10nKey::SftpMenuNewFile), SftpMenuAction::NewFile),
(t(L10nKey::SftpMenuUpload), SftpMenuAction::Upload),
(
t(L10nKey::SftpMenuGotoShellCwd),
SftpMenuAction::GotoShellCwd,
),
] {
menu = menu.item(PopupMenuItem::new(label).on_click({
let app = app.clone();
@@ -919,9 +951,9 @@ impl Tty7App {
}
menu.separator().item(
PopupMenuItem::new(if history {
"Hide transfer history"
t(L10nKey::SftpMenuHideTransferHistory)
} else {
"Transfer history"
t(L10nKey::SftpMenuTransferHistory)
})
.on_click({
let app = app.clone();
@@ -1027,12 +1059,15 @@ impl Tty7App {
let border = cx.theme().border;
let foreground = cx.theme().foreground;
let (title, input): (String, _) = match self.sftp_panel.editing.as_ref()? {
SftpEdit::NewFolder(input) => ("New folder".to_string(), input),
SftpEdit::NewFile(input) => ("New file".to_string(), input),
SftpEdit::Rename { input, .. } => ("Rename".to_string(), input),
SftpEdit::NewFolder(input) => (t(L10nKey::SftpEditNewFolder).to_string(), input),
SftpEdit::NewFile(input) => (t(L10nKey::SftpEditNewFile).to_string(), input),
SftpEdit::Rename { input, .. } => (t(L10nKey::SftpEditRename).to_string(), input),
SftpEdit::Chmod {
readable, input, ..
} => (format!("Permissions · {readable}"), input),
} => (
t_fmt(L10nKey::SftpEditPermissions, &[("mode", readable)]),
input,
),
};
Some(
v_flex()
@@ -1058,14 +1093,14 @@ impl Tty7App {
.justify_end()
.child(
Button::new("sftp-edit-cancel")
.label("Cancel")
.label(t(L10nKey::Cancel))
.ghost()
.xsmall()
.on_click(cx.listener(|this, _, _w, cx| this.sftp_cancel_edit(cx))),
)
.child(
Button::new("sftp-edit-ok")
.label("OK")
.label(t(L10nKey::Ok))
.xsmall()
.primary()
.on_click(cx.listener(|this, _, _w, cx| this.sftp_commit_edit(cx))),
@@ -1105,12 +1140,12 @@ impl Tty7App {
let show_go_up = self.sftp_panel.cwd != "/" && filter.trim().is_empty();
if entries.is_empty() && !show_go_up {
let text = if self.sftp_panel.loading {
"Loading…"
let text: gpui::SharedString = if self.sftp_panel.loading {
t(L10nKey::SftpLoading).into()
} else {
"Empty directory."
t(L10nKey::SftpEmptyDirectory).into()
};
return container.child(note(text.into(), muted));
return container.child(note(text, muted));
}
let mut list = v_flex().gap(px(1.)).py(px(2.));
@@ -1220,7 +1255,11 @@ impl Tty7App {
) -> gpui_component::menu::PopupMenu {
let mut menu = menu.min_w(px(180.));
let primary_label = if dir_like { "Open" } else { "Download" };
let primary_label = if dir_like {
t(L10nKey::SftpContextOpen)
} else {
t(L10nKey::Download)
};
menu = menu.item(PopupMenuItem::new(primary_label).on_click({
let app = app.clone();
let entry = entry.clone();
@@ -1231,18 +1270,20 @@ impl Tty7App {
}));
if is_symlink {
menu = menu.item(PopupMenuItem::new("Follow symlink").on_click({
let app = app.clone();
let entry = entry.clone();
move |_, _window, cx| {
menu = menu.item(
PopupMenuItem::new(t(L10nKey::SftpContextFollowSymlink)).on_click({
let app = app.clone();
let entry = entry.clone();
let _ = app.update(cx, |this, cx| this.sftp_follow_symlink(entry, cx));
}
}));
move |_, _window, cx| {
let entry = entry.clone();
let _ = app.update(cx, |this, cx| this.sftp_follow_symlink(entry, cx));
}
}),
);
}
menu = menu
.item(PopupMenuItem::new("Rename").on_click({
.item(PopupMenuItem::new(t(L10nKey::SftpContextRename)).on_click({
let app = app.clone();
let name = entry.name.clone();
move |_, window, cx| {
@@ -1250,7 +1291,7 @@ impl Tty7App {
let _ = app.update(cx, |this, cx| this.sftp_begin_rename(name, window, cx));
}
}))
.item(PopupMenuItem::new("chmod…").on_click({
.item(PopupMenuItem::new(t(L10nKey::SftpContextChmod)).on_click({
let app = app.clone();
let entry = entry.clone();
move |_, window, cx| {
@@ -1261,15 +1302,17 @@ impl Tty7App {
.separator();
menu.item(
PopupMenuItem::element(move |_window, _cx| div().text_color(danger).child("Delete"))
.on_click({
let app = app.clone();
PopupMenuItem::element(move |_window, _cx| {
div().text_color(danger).child(t(L10nKey::Delete))
})
.on_click({
let app = app.clone();
let entry = entry.clone();
move |_, _window, cx| {
let entry = entry.clone();
move |_, _window, cx| {
let entry = entry.clone();
let _ = app.update(cx, |this, cx| this.sftp_delete_entry(entry, cx));
}
}),
let _ = app.update(cx, |this, cx| this.sftp_delete_entry(entry, cx));
}
}),
)
}
@@ -1312,11 +1355,20 @@ impl Tty7App {
0.0
};
let summary = if running > 0 {
format!("{running} transferring · {pct:.0}%")
t_fmt(
L10nKey::SftpTransferSummaryRunning,
&[
("count", &running.to_string()),
("pct", &format!("{pct:.0}")),
],
)
} else if failed > 0 {
format!("{failed} failed")
t_fmt(
L10nKey::SftpTransferSummaryFailed,
&[("count", &failed.to_string())],
)
} else {
"Transfers".to_string()
t(L10nKey::SftpTransferSummaryIdle).to_string()
};
let summary_color = if running == 0 && failed > 0 {
danger
@@ -1363,7 +1415,7 @@ impl Tty7App {
.w(px(18.))
.h(px(18.))
.rounded(px(4.))
.tooltip("Dismiss")
.tooltip(t(L10nKey::Dismiss))
.on_click(cx.listener(|this, _, _w, cx| this.sftp_dismiss_tray(cx))),
),
);
@@ -1383,7 +1435,7 @@ impl Tty7App {
.py(px(3.))
.text_size(px(11.5))
.text_color(muted)
.child("No transfers yet."),
.child(t(L10nKey::SftpNoTransfers)),
)
} else {
let mut list = v_flex().px(px(CONTENT_INSET)).pb(px(6.)).gap(px(6.));
@@ -1430,14 +1482,20 @@ impl Tty7App {
0.0
};
let status = match job.state {
SftpJobState::Running => format!(
"{} / {} ({pct:.0}%)",
human_size(job.bytes_done),
human_size(job.bytes_total)
SftpJobState::Running => t_fmt(
L10nKey::SftpTransferProgress,
&[
("done", &human_size(job.bytes_done)),
("total", &human_size(job.bytes_total)),
("pct", &format!("{pct:.0}")),
],
),
SftpJobState::Done => "done".to_string(),
SftpJobState::Cancelled => "cancelled".to_string(),
SftpJobState::Error => job.error.clone().unwrap_or_else(|| "error".to_string()),
SftpJobState::Done => t(L10nKey::SftpTransferDone).to_string(),
SftpJobState::Cancelled => t(L10nKey::SftpTransferCancelled).to_string(),
SftpJobState::Error => job
.error
.clone()
.unwrap_or_else(|| t(L10nKey::SftpTransferError).to_string()),
};
let status_color = match job.state {
SftpJobState::Error => danger,
+73 -39
View File
@@ -579,7 +579,7 @@ impl Tty7App {
.child(div().flex_1().text_sm().child(text.to_string()))
.child(
Button::new(("ssh-banner-dismiss", ix))
.label("Dismiss")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Dismiss))
.small()
.ghost()
.on_click(cx.listener(move |this, _, _w, cx| this.dismiss_ssh_banner(ix, cx))),
@@ -590,23 +590,37 @@ impl Tty7App {
fn render_ssh_sheet(&self, model: &PromptModel, cx: &mut Context<Self>) -> AnyElement {
let danger = cx.theme().danger;
let (title, danger_sheet) = match model {
PromptModel::Password { user, host, .. } => {
(format!("Password for {user}@{host}"), false)
}
PromptModel::KeyPassphrase { key_path, .. } => {
(format!("Passphrase for {key_path}"), false)
}
PromptModel::Password { user, host, .. } => (
crate::ui::i18n::t_fmt(
crate::ui::i18n::L10nKey::SshPromptPasswordFor,
&[("user", user), ("host", host)],
),
false,
),
PromptModel::KeyPassphrase { key_path, .. } => (
crate::ui::i18n::t_fmt(
crate::ui::i18n::L10nKey::SshPromptPassphraseFor,
&[("key_path", key_path)],
),
false,
),
PromptModel::KeyboardInteractive { name, .. } => {
let label = if name.is_empty() {
"Two-factor authentication".to_string()
crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptTwoFactor).to_string()
} else {
name.clone()
};
(label, false)
}
PromptModel::HostKeyUnknown { host, .. } => (format!("Unknown host {host}"), false),
PromptModel::HostKeyUnknown { host, .. } => (
crate::ui::i18n::t_fmt(
crate::ui::i18n::L10nKey::SshPromptUnknownHost,
&[("host", host)],
),
false,
),
PromptModel::HostKeyChanged { .. } => (
"Host key CHANGED — possible man-in-the-middle".to_string(),
crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptHostKeyChanged).to_string(),
true,
),
};
@@ -645,16 +659,16 @@ impl Tty7App {
PromptModel::Password { rejected, .. } => {
let mut c = card;
if *rejected {
c = c.child(
div()
.text_xs()
.text_color(danger)
.child("The stored password was rejected. Enter a new one."),
);
c = c.child(div().text_xs().text_color(danger).child(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::StoredPasswordRejected,
)));
}
c.child(self.render_ssh_input(0))
.child(self.render_ssh_remember(cx))
.child(self.render_ssh_actions("Connect", cx))
.child(self.render_ssh_actions(
crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptConnect),
cx,
))
}
PromptModel::KeyPassphrase { comment, .. } => {
let mut c = card;
@@ -668,7 +682,10 @@ impl Tty7App {
}
c.child(self.render_ssh_input(0))
.child(self.render_ssh_remember(cx))
.child(self.render_ssh_actions("Unlock", cx))
.child(self.render_ssh_actions(
crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptUnlock),
cx,
))
}
PromptModel::KeyboardInteractive {
instructions,
@@ -683,7 +700,10 @@ impl Tty7App {
c = c.child(div().text_xs().child(row.text.clone()));
c = c.child(self.render_ssh_input(i));
}
c.child(self.render_ssh_actions("Submit", cx))
c.child(self.render_ssh_actions(
crate::ui::i18n::t(crate::ui::i18n::L10nKey::SshPromptSubmit),
cx,
))
}
PromptModel::HostKeyUnknown {
algorithm,
@@ -703,16 +723,21 @@ impl Tty7App {
.gap_2()
.child(
Button::new("ssh-hk-trust")
.label("Trust")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Trust))
.small()
.primary()
.on_click(cx.listener(|this, _, window, cx| {
this.trust_ssh_host_key(window, cx)
})),
)
.child(Button::new("ssh-hk-abort").label("Abort").small().on_click(
cx.listener(|this, _, window, cx| this.cancel_ssh_prompt(window, cx)),
)),
.child(
Button::new("ssh-hk-abort")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Abort))
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.cancel_ssh_prompt(window, cx)
})),
),
),
PromptModel::HostKeyChanged {
algorithm,
@@ -721,35 +746,39 @@ impl Tty7App {
port,
host,
} => card
.child(div().text_xs().text_color(danger).child(
"The host key differs from the one previously trusted. This may be an attack.",
))
.child(div().text_xs().text_color(danger).child(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::SshPromptHostKeyChangedBody,
)))
.child(div().text_xs().child(format!("{host}:{port} {algorithm}")))
.child(
div()
.text_xs()
.font_family("monospace")
.child(format!("new {fingerprint}")),
.child(crate::ui::i18n::t_fmt(
crate::ui::i18n::L10nKey::SshPromptNewKey,
&[("fingerprint", &fingerprint)],
)),
)
.child(
div()
.text_xs()
.font_family("monospace")
.text_color(cx.theme().muted_foreground)
.child(format!("old {old_fingerprint}")),
)
.child(
div()
.text_xs()
.child("Type \"yes\" to override and trust the new key, or Esc to abort."),
.child(crate::ui::i18n::t_fmt(
crate::ui::i18n::L10nKey::SshPromptOldKey,
&[("old_fingerprint", &old_fingerprint)],
)),
)
.child(div().text_xs().child(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::HostKeyOverrideMessage,
)))
.child(self.render_ssh_input(0))
.child(
h_flex()
.gap_2()
.child(
Button::new("ssh-hkc-abort")
.label("Abort")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Abort))
.small()
.primary()
.on_click(cx.listener(|this, _, window, cx| {
@@ -758,7 +787,7 @@ impl Tty7App {
)
.child(
Button::new("ssh-hkc-override")
.label("Override")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Override))
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.submit_ssh_prompt(window, cx)
@@ -781,7 +810,9 @@ impl Tty7App {
h_flex()
.child(
Checkbox::new("ssh-remember")
.label("Remember (keychain)")
.label(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::RememberKeychain,
))
.checked(self.ssh_prompt.remember)
.on_click(cx.listener(|this, _, _w, cx| this.toggle_ssh_remember(cx))),
)
@@ -802,9 +833,12 @@ impl Tty7App {
),
)
.child(
Button::new("ssh-cancel").label("Cancel").small().on_click(
cx.listener(|this, _, window, cx| this.cancel_ssh_prompt(window, cx)),
),
Button::new("ssh-cancel")
.label(crate::ui::i18n::t(crate::ui::i18n::L10nKey::Cancel))
.small()
.on_click(
cx.listener(|this, _, window, cx| this.cancel_ssh_prompt(window, cx)),
),
)
.into_any_element()
}
+61 -39
View File
@@ -16,6 +16,7 @@ use crate::core::session::WorkspaceStore;
use crate::daemon::install::InstallPhase;
use crate::terminal::pane_liveness::Liveness;
use crate::ui::app::Tty7App;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::remote_connect::{self, HostChoice, RemoteWorkspaceRow, human_bytes};
use crate::ui::remote_workspace::ConnectFlow;
@@ -108,8 +109,11 @@ impl Tty7App {
pub(crate) fn open_switcher(&mut self, window: &mut Window, cx: &mut Context<Self>) {
remote_connect::register(cx);
remote_connect::sweep_wsl(cx);
let query =
cx.new(|cx| InputState::new(window, cx).placeholder("Search workspaces and machines"));
let query = cx.new(|cx| {
InputState::new(window, cx).placeholder(crate::ui::i18n::t(
crate::ui::i18n::L10nKey::SearchWorkspacesAndMachines,
))
});
query.update(cx, |state, cx| state.focus(window, cx));
let subs = vec![cx.subscribe_in(
&query,
@@ -152,7 +156,11 @@ impl Tty7App {
let store = WorkspaceStore::all(app);
for w in &store.views {
let (key, label, target) = match w.host.as_ref() {
None => (String::new(), "This Computer".to_string(), None),
None => (
String::new(),
t(L10nKey::SwitcherThisComputer).to_string(),
None,
),
Some(r) => {
let key = r.target.to_string();
(key.clone(), key, Some(r.target.clone()))
@@ -175,7 +183,7 @@ impl Tty7App {
groups[slot].rows.push(Row {
id: w.id,
name: crate::ui::machine_mirror::display_name(app, w)
.unwrap_or_else(|| "Untitled".to_string()),
.unwrap_or_else(|| t(L10nKey::WindowUntitled).to_string()),
path: crate::ui::machine_mirror::subject_path(app, w)
.map(|p| crate::ui::home::display_path(std::path::Path::new(&p)))
.unwrap_or_default(),
@@ -213,7 +221,7 @@ impl Tty7App {
0,
Group {
key: String::new(),
label: "This Computer".to_string(),
label: t(L10nKey::SwitcherThisComputer).to_string(),
endpoint: String::new(),
target: None,
link: Link::Offline,
@@ -436,7 +444,7 @@ impl Tty7App {
.py(px(14.))
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("No workspace or machine matches."),
.child(t(L10nKey::SwitcherNoMatch)),
);
}
@@ -537,7 +545,7 @@ impl Tty7App {
GUTTER,
Icon::new(IconName::Plus).size(px(ICON)).text_color(dim),
))
.child("Add SSH Host…")
.child(t(L10nKey::AddSshHost))
.on_click(cx.listener(|this, _, window, cx| {
this.close_switcher(window, cx);
this.open_settings_section(
@@ -563,7 +571,7 @@ impl Tty7App {
.border_color(border)
.child(""),
)
.child("click for a new window"),
.child(t(L10nKey::ClickForNewWindow)),
)
}
@@ -630,7 +638,7 @@ impl Tty7App {
"switcher-retry:{}",
group.key
)))
.label("Try Again")
.label(t(L10nKey::TryAgain))
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, _window, cx| {
@@ -655,7 +663,7 @@ impl Tty7App {
"switcher-replace:{}",
group.key
)))
.label("Restart Server")
.label(t(L10nKey::RestartServer))
.ghost()
.xsmall()
.on_click(cx.listener(move |this, _, window, cx| {
@@ -693,19 +701,20 @@ impl Tty7App {
let accent = theme.warning;
let fraction = phase.fraction().unwrap_or(0.0);
let caption = match phase {
InstallPhase::Restarting => "Restarting tty7's server\u{2026}".to_string(),
InstallPhase::Restarting => t(L10nKey::SwitcherRestartingServer).to_string(),
InstallPhase::Downloading { done, total } => match total {
Some(total) => format!(
"Downloading tty7's server\u{2026} {} / {}",
human_bytes(done),
human_bytes(total)
Some(total) => t_fmt(
L10nKey::SwitcherDownloadingServerWithTotal,
&[("done", &human_bytes(done)), ("total", &human_bytes(total))],
),
None => t_fmt(
L10nKey::SwitcherDownloadingServerNoTotal,
&[("done", &human_bytes(done))],
),
None => format!("Downloading tty7's server\u{2026} {}", human_bytes(done)),
},
InstallPhase::Uploading { done, total } => format!(
"Copying tty7's server\u{2026} {} / {}",
human_bytes(done),
human_bytes(total)
InstallPhase::Uploading { done, total } => t_fmt(
L10nKey::SwitcherCopyingServer,
&[("done", &human_bytes(done)), ("total", &human_bytes(total))],
),
};
@@ -784,17 +793,25 @@ impl Tty7App {
let (dot, word): (Option<gpui::Hsla>, Option<&'static str>) = match group.link {
Link::Local => (None, None),
Link::Connected => (Some(gpui::rgb(crate::ui::tab_strip::LIVE_DOT).into()), None),
Link::Connecting if matches!(group.installing, Some(InstallPhase::Restarting)) => {
(Some(theme.warning), Some("restarting…"))
}
Link::Connecting if group.installing.is_some() => {
(Some(theme.warning), Some("installing…"))
}
Link::Connecting => (Some(theme.warning), Some("connecting…")),
Link::Failed => (Some(theme.danger), Some("couldn't connect")),
Link::Connecting if matches!(group.installing, Some(InstallPhase::Restarting)) => (
Some(theme.warning),
Some(t(L10nKey::SwitcherStatusRestarting)),
),
Link::Connecting if group.installing.is_some() => (
Some(theme.warning),
Some(t(L10nKey::SwitcherStatusInstalling)),
),
Link::Connecting => (
Some(theme.warning),
Some(t(L10nKey::SwitcherStatusConnecting)),
),
Link::Failed => (
Some(theme.danger),
Some(t(L10nKey::SwitcherStatusConnectFailed)),
),
Link::Offline => (
Some(gpui::rgb(crate::ui::tab_strip::UNKNOWN_DOT).into()),
Some("not connected"),
Some(t(L10nKey::SwitcherStatusNotConnected)),
),
};
let word_color = match group.link {
@@ -927,9 +944,9 @@ impl Tty7App {
let key = row.id.element_key() as usize;
let badge = if row.current {
Some(("this window", true))
Some((t(L10nKey::SwitcherThisWindow), true))
} else if row.open {
Some(("open", false))
Some((t(L10nKey::SwitcherOpen), false))
} else {
None
};
@@ -1054,7 +1071,12 @@ impl Tty7App {
GUTTER,
Icon::new(IconName::Globe).size(px(ICON)).text_color(dim),
))
.child(div().text_sm().text_color(muted).child("Other Machines"))
.child(
div()
.text_sm()
.text_color(muted)
.child(t(L10nKey::OtherMachines)),
)
.child(div().flex_1())
.child(
div()
@@ -1202,7 +1224,7 @@ fn group_menu(
let gref = group.clone();
let can_create = group.target.is_none() || group.home.is_some();
let menu = menu.item(
PopupMenuItem::new("New Workspace")
PopupMenuItem::new(t(L10nKey::AppMenuNewWorkspace))
.disabled(!can_create)
.on_click(move |_, window, cx| {
let _ = a1.update(cx, |this, cx| this.switcher_new(&gref, window, cx));
@@ -1215,7 +1237,7 @@ fn group_menu(
let restartable = target.is_ssh();
let (label, for_restart) = (group.label.clone(), target.clone());
let menu = menu.separator().item(
PopupMenuItem::new("Disconnect")
PopupMenuItem::new(t(L10nKey::SwitcherDisconnect))
.disabled(!connected)
.on_click(move |_, _window, cx| {
let _ = a2.update(cx, |this, cx| this.switcher_disconnect(&target, cx));
@@ -1225,7 +1247,7 @@ fn group_menu(
return menu;
}
menu.item(
PopupMenuItem::new("Restart Server…").on_click(move |_, window, cx| {
PopupMenuItem::new(t(L10nKey::AppMenuRestartServer)).on_click(move |_, window, cx| {
let _ = a3.update(cx, |this, cx| {
this.confirm_restart_remote_server(for_restart.clone(), label.clone(), window, cx);
});
@@ -1242,14 +1264,14 @@ fn row_menu(
let (id, adopt) = (row.id, row.adopt.is_some());
let stoppable = row.live;
menu.item(
PopupMenuItem::new("Rename…")
PopupMenuItem::new(t(L10nKey::SwitcherRename))
.disabled(adopt)
.on_click(move |_, window, cx| {
let _ = a1.update(cx, |this, cx| this.switcher_rename(id, window, cx));
}),
)
.item(
PopupMenuItem::new("Open in New Window")
PopupMenuItem::new(t(L10nKey::SwitcherOpenInNewWindow))
.disabled(adopt)
.on_click(move |_, window, cx| {
let _ = a2.update(cx, |this, cx| {
@@ -1260,7 +1282,7 @@ fn row_menu(
)
.separator()
.item(
PopupMenuItem::new("Stop Workspace…")
PopupMenuItem::new(t(L10nKey::AppMenuStopWorkspace))
.disabled(adopt || !stoppable)
.on_click(move |_, window, cx| {
let _ = a3.update(cx, |this, cx| {
@@ -1270,7 +1292,7 @@ fn row_menu(
}),
)
.item(
PopupMenuItem::new("Delete Workspace…")
PopupMenuItem::new(t(L10nKey::AppMenuDeleteWorkspace))
.disabled(adopt)
.on_click(move |_, window, cx| {
let _ = a4.update(cx, |this, cx| {
+3 -2
View File
@@ -16,6 +16,7 @@ use crate::core::config::{Config, SidebarGrouping};
use crate::terminal::git_status::GitStatusCache;
use crate::ui::app::{TITLE_BAR_HEIGHT, Tty7App};
use crate::ui::hints::tab_badge_label;
use crate::ui::i18n::{L10nKey, t};
use crate::ui::reorder::{self, Reorder, Surface};
use crate::ui::tab_strip::{DragTab, REORDER_SLIDE_MS};
@@ -554,7 +555,7 @@ impl Tty7App {
cx,
)
.rounded_lg()
.tooltip("Hide Sidebar")
.tooltip(t(L10nKey::TabTooltipHideSidebar))
.on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))),
),
);
@@ -784,7 +785,7 @@ fn sidebar_sections(keys: &[Option<PathBuf>]) -> Vec<Section> {
if !scratch.is_empty() {
sections.push(Section {
key: None,
name: Some("Scratch".into()),
name: Some(t(L10nKey::SidebarScratchGroup).to_string()),
tabs: scratch,
});
}
+44 -38
View File
@@ -20,6 +20,7 @@ use crate::core::shells::DetectedShell;
use crate::daemon::protocol::ShellSpec;
use crate::ui::app::{TILE_GLYPH, TILE_GLYPH_LINE, TILE_SIZE, Tab, Tty7App, tile_trailing_inset};
use crate::ui::hints::tab_badge_label;
use crate::ui::i18n::{L10nKey, t, t_fmt};
use crate::ui::reorder::{self, Reorder, Surface};
pub(crate) const REORDER_SLIDE_MS: u64 = 140;
@@ -327,14 +328,14 @@ impl Tty7App {
cx,
)
.rounded_lg()
.tooltip("More")
.tooltip(t(L10nKey::TabTooltipMore))
.dropdown_menu_with_anchor(
gpui::Anchor::TopRight,
move |menu, _window, _cx| {
menu.min_w(px(200.))
.action_context(action_ctx.clone())
.menu("Command Palette…", Box::new(TogglePalette))
.menu("Settings…", Box::new(OpenSettings))
.menu(t(L10nKey::AppMenuCommandPalette), Box::new(TogglePalette))
.menu(t(L10nKey::AppMenuSettings), Box::new(OpenSettings))
},
),
)
@@ -362,9 +363,9 @@ impl Tty7App {
)
.rounded_lg()
.tooltip(if panel_open {
"Hide Detail Panel"
t(L10nKey::TabTooltipHideDetailPanel)
} else {
"Show Detail Panel"
t(L10nKey::TabTooltipShowDetailPanel)
})
.on_click(cx.listener(|this, _, _window, cx| {
this.toggle_right_panel(cx);
@@ -387,26 +388,26 @@ impl Tty7App {
(
RightPanelTab::Info,
Icon::empty().path("icons/info.svg"),
"Info",
L10nKey::PanelInfoTitle,
),
(
RightPanelTab::Outline,
Icon::empty().path("icons/list.svg"),
"Outline",
L10nKey::PanelOutlineTitle,
),
(
RightPanelTab::Changes,
Icon::empty().path("icons/git-branch.svg"),
"Changes",
L10nKey::PanelChangesTitle,
),
(
RightPanelTab::Files,
Icon::new(IconName::FolderClosed),
"Files",
L10nKey::PanelFilesTitle,
),
]
.into_iter()
.map(|(tab, icon, label)| {
.map(|(tab, icon, label_key)| {
div()
.occlude()
.flex_shrink_0()
@@ -419,9 +420,9 @@ impl Tty7App {
.rounded_lg()
.tooltip(match (tab, changed) {
(RightPanelTab::Changes, Some(n)) => {
SharedString::from(format!("{label} · {n}"))
SharedString::from(format!("{} · {n}", t(label_key)))
}
_ => SharedString::from(label),
_ => SharedString::from(t(label_key)),
})
.on_click(cx.listener(move |this, _, _window, cx| {
this.set_right_panel_tab(tab, cx);
@@ -534,7 +535,10 @@ impl Tty7App {
let raw = tab.leaf_title(window, cx);
let label = short_title(&raw);
if label.trim().is_empty() {
format!("Shell {}", index + 1)
t_fmt(
L10nKey::TabUnnamedShell,
&[("n", &((index + 1).to_string()))],
)
} else {
label
}
@@ -565,7 +569,7 @@ impl Tty7App {
.child(
div()
.text_color(cx.theme().muted_foreground)
.child("default"),
.child(t(L10nKey::ShellDefault)),
)
})
} else {
@@ -581,13 +585,13 @@ impl Tty7App {
}
if shells.is_empty() {
let open_default = app.clone();
menu = menu.item(
PopupMenuItem::new("New Tab").on_click(move |_, window, cx| {
menu = menu.item(PopupMenuItem::new(t(L10nKey::AppMenuNewTab)).on_click(
move |_, window, cx| {
if let Some(app) = open_default.upgrade() {
app.update(cx, |this, cx| this.new_tab(window, cx));
}
}),
);
},
));
}
menu
})
@@ -610,7 +614,7 @@ impl Tty7App {
let has_cwd = cwd.is_some();
let mut menu = menu.min_w(px(200.));
menu = menu.item(PopupMenuItem::new("Rename Tab").on_click({
menu = menu.item(PopupMenuItem::new(t(L10nKey::AppMenuRenameTab)).on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.start_rename(index, window, cx));
@@ -622,7 +626,7 @@ impl Tty7App {
let done = tab.and_then(|t| t.agent_status(cx))
== Some(crate::core::cli_agent::AgentStatus::Done);
menu = menu.item(
PopupMenuItem::new("Mark as Unread")
PopupMenuItem::new(t(L10nKey::TabContextMarkUnread))
.disabled(!done)
.on_click({
let app = app.clone();
@@ -635,14 +639,14 @@ impl Tty7App {
let in_repo = this.tab_is_in_repo(index, window, cx);
if in_repo {
menu = menu
.separator()
.item(PopupMenuItem::new("New Worktree Tab").on_click({
menu = menu.separator().item(
PopupMenuItem::new(t(L10nKey::AppMenuNewWorktreeTab)).on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.new_worktree_tab(index, window, cx));
}
}));
}),
);
}
let agent_session = this.tab_agent_session(index, window, cx);
@@ -673,7 +677,7 @@ impl Tty7App {
menu = menu
.separator()
.item(PopupMenuItem::new("Split Right").on_click({
.item(PopupMenuItem::new(t(L10nKey::AppMenuSplitRight)).on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
@@ -682,7 +686,7 @@ impl Tty7App {
});
}
}))
.item(PopupMenuItem::new("Split Down").on_click({
.item(PopupMenuItem::new(t(L10nKey::AppMenuSplitDown)).on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| {
@@ -693,7 +697,7 @@ impl Tty7App {
}));
menu = menu.separator().item(
PopupMenuItem::new("Copy Working Directory")
PopupMenuItem::new(t(L10nKey::AppMenuCopyWorkingDirectory))
.disabled(!has_cwd)
.on_click(move |_, _window, cx| {
if let Some(cwd) = cwd.as_ref() {
@@ -706,7 +710,7 @@ impl Tty7App {
if let Some(session_id) = agent_session.map(|(_, s)| s.session_id) {
menu = menu.item(
PopupMenuItem::new("Copy Session ID")
PopupMenuItem::new(t(L10nKey::AppMenuCopySessionId))
.disabled(session_id.is_none())
.on_click(move |_, _window, cx| {
if let Some(id) = session_id.as_ref() {
@@ -717,14 +721,16 @@ impl Tty7App {
}
menu.separator()
.item(PopupMenuItem::new("Close Tab").on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.close_tab(index, window, cx));
}
}))
.item(
PopupMenuItem::new("Close Other Tabs")
PopupMenuItem::new(t(L10nKey::TabContextCloseTab)).on_click({
let app = app.clone();
move |_, window, cx| {
let _ = app.update(cx, |this, cx| this.close_tab(index, window, cx));
}
}),
)
.item(
PopupMenuItem::new(t(L10nKey::AppMenuCloseOtherTabs))
.disabled(tab_count <= 1)
.on_click({
let app = app.clone();
@@ -736,9 +742,9 @@ impl Tty7App {
)
.item(
PopupMenuItem::new(if below_wording {
"Close Tabs Below"
t(L10nKey::TabContextCloseTabsBelow)
} else {
"Close Tabs to the Right"
t(L10nKey::AppMenuCloseTabsRight)
})
.disabled(index + 1 >= tab_count)
.on_click({
@@ -1054,7 +1060,7 @@ impl Tty7App {
cx,
)
.rounded_lg()
.tooltip("Show Sidebar")
.tooltip(t(L10nKey::TabTooltipShowSidebar))
.on_click(cx.listener(|this, _, _window, cx| this.toggle_left_panel(cx))),
),
)
+67 -60
View File
@@ -11,6 +11,7 @@ use crate::terminal::view::{
ClearScrollback, CopyText, CutText, FindInTerminal, FindNext, FindPrevious, PasteText,
RedoEdit, SelectAll, UndoEdit,
};
use crate::ui::i18n::{L10nKey, t};
use crate::ui::presets;
use crate::ui::presets::Fill;
@@ -21,84 +22,87 @@ pub(crate) fn traffic_light_position() -> Point<Pixels> {
pub(crate) fn set_menus(cx: &mut App) {
cx.set_menus([
Menu::new("tty7").items([
MenuItem::action("About tty7", About),
MenuItem::action("Check for Updates…", CheckForUpdates),
MenuItem::action(t(L10nKey::AppMenuAbout), About),
MenuItem::action(t(L10nKey::AppMenuCheckForUpdates), CheckForUpdates),
MenuItem::separator(),
MenuItem::action("Settings…", OpenSettings),
MenuItem::action(t(L10nKey::AppMenuSettings), OpenSettings),
MenuItem::separator(),
MenuItem::os_submenu("Services", SystemMenuType::Services),
MenuItem::os_submenu(t(L10nKey::AppMenuServices), SystemMenuType::Services),
MenuItem::separator(),
MenuItem::action("Hide tty7", HideApp),
MenuItem::action("Hide Others", HideOthers),
MenuItem::action("Show All", ShowAll),
MenuItem::action(t(L10nKey::AppMenuHideApp), HideApp),
MenuItem::action(t(L10nKey::AppMenuHideOthers), HideOthers),
MenuItem::action(t(L10nKey::AppMenuShowAll), ShowAll),
MenuItem::separator(),
MenuItem::action("Quit tty7", Quit),
MenuItem::action(t(L10nKey::AppMenuQuit), Quit),
]),
Menu::new("File").items([
MenuItem::action("New Tab", NewTab),
MenuItem::action("New Workspace", NewWorkspace),
MenuItem::action("New Worktree Tab", NewWorktreeTab),
Menu::new(t(L10nKey::AppMenuFile)).items([
MenuItem::action(t(L10nKey::AppMenuNewTab), NewTab),
MenuItem::action(t(L10nKey::AppMenuNewWorkspace), NewWorkspace),
MenuItem::action(t(L10nKey::AppMenuNewWorktreeTab), NewWorktreeTab),
MenuItem::separator(),
MenuItem::action("Split Right", SplitRight),
MenuItem::action("Split Down", SplitDown),
MenuItem::action(t(L10nKey::AppMenuSplitRight), SplitRight),
MenuItem::action(t(L10nKey::AppMenuSplitDown), SplitDown),
MenuItem::separator(),
MenuItem::action("Rename Tab…", RenameTab),
MenuItem::action("Copy Working Directory", CopyWorkingDirectory),
MenuItem::action("Copy Session ID", CopyAgentSessionId),
MenuItem::action("Fork Session", ForkAgentSession),
MenuItem::action(t(L10nKey::AppMenuRenameTab), RenameTab),
MenuItem::action(
t(L10nKey::AppMenuCopyWorkingDirectory),
CopyWorkingDirectory,
),
MenuItem::action(t(L10nKey::AppMenuCopySessionId), CopyAgentSessionId),
MenuItem::action(t(L10nKey::AppMenuForkSession), ForkAgentSession),
MenuItem::separator(),
MenuItem::action("Close Pane / Tab", CloseActiveTab),
MenuItem::action("Close Other Tabs", CloseOtherTabs),
MenuItem::action("Close Tabs to the Right", CloseTabsToTheRight),
MenuItem::action("Reopen Closed Tab", ReopenClosedTab),
MenuItem::action(t(L10nKey::AppMenuClosePaneTab), CloseActiveTab),
MenuItem::action(t(L10nKey::AppMenuCloseOtherTabs), CloseOtherTabs),
MenuItem::action(t(L10nKey::AppMenuCloseTabsRight), CloseTabsToTheRight),
MenuItem::action(t(L10nKey::AppMenuReopenClosedTab), ReopenClosedTab),
MenuItem::separator(),
MenuItem::action("Rename Workspace…", RenameWorkspace),
MenuItem::action("Stop Workspace…", StopWorkspace),
MenuItem::action(t(L10nKey::AppMenuRenameWorkspace), RenameWorkspace),
MenuItem::action(t(L10nKey::AppMenuStopWorkspace), StopWorkspace),
MenuItem::separator(),
MenuItem::action("Delete Workspace…", DeleteWorkspace),
MenuItem::action(t(L10nKey::AppMenuDeleteWorkspace), DeleteWorkspace),
]),
Menu::new("Edit").items([
MenuItem::os_action("Undo", UndoEdit, OsAction::Undo),
MenuItem::os_action("Redo", RedoEdit, OsAction::Redo),
Menu::new(t(L10nKey::AppMenuEdit)).items([
MenuItem::os_action(t(L10nKey::AppMenuUndo), UndoEdit, OsAction::Undo),
MenuItem::os_action(t(L10nKey::AppMenuRedo), RedoEdit, OsAction::Redo),
MenuItem::separator(),
MenuItem::os_action("Cut", CutText, OsAction::Cut),
MenuItem::os_action("Copy", CopyText, OsAction::Copy),
MenuItem::os_action("Paste", PasteText, OsAction::Paste),
MenuItem::os_action("Select All", SelectAll, OsAction::SelectAll),
MenuItem::os_action(t(L10nKey::AppMenuCut), CutText, OsAction::Cut),
MenuItem::os_action(t(L10nKey::AppMenuCopy), CopyText, OsAction::Copy),
MenuItem::os_action(t(L10nKey::AppMenuPaste), PasteText, OsAction::Paste),
MenuItem::os_action(t(L10nKey::AppMenuSelectAll), SelectAll, OsAction::SelectAll),
MenuItem::separator(),
MenuItem::action("Find…", FindInTerminal),
MenuItem::action("Find Next", FindNext),
MenuItem::action("Find Previous", FindPrevious),
MenuItem::action(t(L10nKey::AppMenuFind), FindInTerminal),
MenuItem::action(t(L10nKey::AppMenuFindNext), FindNext),
MenuItem::action(t(L10nKey::AppMenuFindPrevious), FindPrevious),
]),
Menu::new("View").items([
MenuItem::action("Command Palette…", TogglePalette),
Menu::new(t(L10nKey::AppMenuView)).items([
MenuItem::action(t(L10nKey::AppMenuCommandPalette), TogglePalette),
MenuItem::separator(),
MenuItem::action("Increase Font Size", IncreaseFontSize),
MenuItem::action("Decrease Font Size", DecreaseFontSize),
MenuItem::action("Reset Font Size", ResetFontSize),
MenuItem::action(t(L10nKey::AppMenuIncreaseFontSize), IncreaseFontSize),
MenuItem::action(t(L10nKey::AppMenuDecreaseFontSize), DecreaseFontSize),
MenuItem::action(t(L10nKey::AppMenuResetFontSize), ResetFontSize),
MenuItem::separator(),
MenuItem::action("Left Sidebar", ToggleLeftPanel),
MenuItem::action("Right Panel", ToggleRightPanel),
MenuItem::action("Code Panel", ToggleCodePanel),
MenuItem::action("Tab Bar Position", ToggleTabSidebar),
MenuItem::action(t(L10nKey::AppMenuLeftSidebar), ToggleLeftPanel),
MenuItem::action(t(L10nKey::AppMenuRightPanel), ToggleRightPanel),
MenuItem::action(t(L10nKey::AppMenuCodePanel), ToggleCodePanel),
MenuItem::action(t(L10nKey::AppMenuTabBarPosition), ToggleTabSidebar),
MenuItem::separator(),
MenuItem::action("Focus Next Pane", FocusNextPane),
MenuItem::action("Focus Previous Pane", FocusPrevPane),
MenuItem::action("Zoom Pane", ToggleMaximizePane),
MenuItem::action(t(L10nKey::AppMenuFocusNextPane), FocusNextPane),
MenuItem::action(t(L10nKey::AppMenuFocusPreviousPane), FocusPrevPane),
MenuItem::action(t(L10nKey::AppMenuZoomPane), ToggleMaximizePane),
MenuItem::separator(),
MenuItem::action("Clear Scrollback", ClearScrollback),
MenuItem::action(t(L10nKey::AppMenuClearScrollback), ClearScrollback),
MenuItem::separator(),
MenuItem::action("Enter Full Screen", ToggleFullscreen),
MenuItem::action(t(L10nKey::AppMenuEnterFullscreen), ToggleFullscreen),
]),
Menu::new("Window").items(window_menu_items(cx)),
Menu::new("Help").items([
MenuItem::action("tty7 Documentation", OpenDocumentation),
MenuItem::action("Keyboard Shortcuts", ShowKeyboardShortcuts),
Menu::new(t(L10nKey::AppMenuWindow)).items(window_menu_items(cx)),
Menu::new(t(L10nKey::AppMenuHelp)).items([
MenuItem::action(t(L10nKey::AppMenuDocumentation), OpenDocumentation),
MenuItem::action(t(L10nKey::AppMenuKeyboardShortcuts), ShowKeyboardShortcuts),
MenuItem::separator(),
MenuItem::action("Join the Discord", OpenDiscord),
MenuItem::action("Report an Issue…", ReportIssue),
MenuItem::action(t(L10nKey::AppMenuJoinDiscord), OpenDiscord),
MenuItem::action(t(L10nKey::AppMenuReportIssue), ReportIssue),
MenuItem::separator(),
MenuItem::action("Restart Server…", RestartDaemon),
MenuItem::action(t(L10nKey::AppMenuRestartServer), RestartDaemon),
]),
]);
}
@@ -114,8 +118,8 @@ fn window_menu_items(cx: &App) -> Vec<MenuItem> {
let slot_action = crate::ui::tab_strip::select_workspace_action;
let mut items = vec![
MenuItem::action("Minimize", MinimizeWindow),
MenuItem::action("Zoom", ZoomWindow),
MenuItem::action(t(L10nKey::AppMenuMinimize), MinimizeWindow),
MenuItem::action(t(L10nKey::AppMenuZoom), ZoomWindow),
MenuItem::separator(),
];
let workspace_start = items.len();
@@ -132,7 +136,7 @@ fn window_menu_items(cx: &App) -> Vec<MenuItem> {
}
}
let name = crate::ui::machine_mirror::display_name(cx, workspace)
.unwrap_or_else(|| "Untitled".to_string());
.unwrap_or_else(|| t(L10nKey::WindowUntitled).to_string());
let label = if *open {
name
} else {
@@ -151,7 +155,10 @@ fn window_menu_items(cx: &App) -> Vec<MenuItem> {
});
}
if items.len() == workspace_start {
items.push(MenuItem::action("New Workspace", NewWorkspace));
items.push(MenuItem::action(
t(L10nKey::AppMenuNewWorkspace),
NewWorkspace,
));
}
items
}
+59 -25
View File
@@ -11,6 +11,7 @@ use sni::Backend;
use crate::core::cli_agent::AgentStatus;
use crate::core::config::{Config, NotifyMode};
use crate::ui::i18n::{L10nKey, t};
use gpui::App;
const POLL: std::time::Duration = std::time::Duration::from_millis(1000);
@@ -59,9 +60,18 @@ impl TraySnapshot {
let count = |s: AgentStatus| self.agents.iter().filter(|a| a.status == s).count();
let mut parts = Vec::new();
for (n, word) in [
(count(AgentStatus::Waiting), "waiting"),
(count(AgentStatus::Working), "working"),
(count(AgentStatus::Done), "done"),
(
count(AgentStatus::Waiting),
t(crate::ui::i18n::L10nKey::PanelAgentWaiting),
),
(
count(AgentStatus::Working),
t(crate::ui::i18n::L10nKey::PanelAgentWorking),
),
(
count(AgentStatus::Done),
t(crate::ui::i18n::L10nKey::PanelAgentDone),
),
] {
if n > 0 {
parts.push(format!("{n} {word}"));
@@ -90,19 +100,23 @@ pub(crate) enum SpecItem {
}
pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec<SpecItem> {
let item = |id: &str, label: String| SpecItem::Item {
let item = |id: &str, label: &str| SpecItem::Item {
id: id.to_string(),
label,
label: label.to_string(),
checked: None,
avatar: None,
};
let mut items = vec![item("show", "Show tty7".into()), SpecItem::Separator];
let mut items = vec![item("show", t(L10nKey::TrayShowTty7)), SpecItem::Separator];
for a in &snap.agents {
let state = match a.status {
AgentStatus::Waiting => " — needs input",
AgentStatus::Working => " — working",
AgentStatus::Done => " — done",
AgentStatus::Idle => "",
AgentStatus::Waiting => {
format!("{}", t(crate::ui::i18n::L10nKey::TrayAgentNeedsInput))
}
AgentStatus::Working => {
format!("{}", t(crate::ui::i18n::L10nKey::PanelAgentWorking))
}
AgentStatus::Done => format!("{}", t(crate::ui::i18n::L10nKey::PanelAgentDone)),
AgentStatus::Idle => String::new(),
};
items.push(SpecItem::Item {
id: format!("agent:{}", a.leaf_id),
@@ -121,18 +135,33 @@ pub(crate) fn menu_spec(snap: &TraySnapshot) -> Vec<SpecItem> {
avatar: None,
};
items.push(SpecItem::Submenu {
label: "Notifications".into(),
label: t(L10nKey::TrayNotifications).to_string(),
items: vec![
notify("notify:never", "Never", NotifyMode::Never),
notify("notify:unfocused", "When Unfocused", NotifyMode::Unfocused),
notify("notify:always", "Always", NotifyMode::Always),
notify(
"notify:never",
t(L10nKey::NotifyModeNever),
NotifyMode::Never,
),
notify(
"notify:unfocused",
t(L10nKey::NotifyModeUnfocused),
NotifyMode::Unfocused,
),
notify(
"notify:always",
t(L10nKey::NotifyModeAlways),
NotifyMode::Always,
),
],
});
items.push(item("settings", "Settings…".into()));
items.push(item("updates", "Check for Updates…".into()));
items.push(item("settings", t(L10nKey::AppMenuSettings)));
items.push(item("updates", t(L10nKey::AppMenuCheckForUpdates)));
items.push(SpecItem::Separator);
items.push(item("quit", "Quit tty7".into()));
items.push(item("quit-stop", "Quit and Stop Server…".into()));
items.push(item("quit", t(L10nKey::AppMenuQuit)));
items.push(item(
"quit-stop",
crate::ui::i18n::t(crate::ui::i18n::L10nKey::TrayQuitStopServer),
));
items
}
@@ -199,18 +228,23 @@ mod tests {
#[test]
fn attention_follows_waiting_and_tooltip_counts() {
crate::ui::i18n::set_locale("en");
assert!(snapshot_with_agent(AgentStatus::Waiting).attention());
assert!(!snapshot_with_agent(AgentStatus::Working).attention());
assert!(!snapshot_with_agent(AgentStatus::Done).attention());
assert_eq!(
snapshot_with_agent(AgentStatus::Waiting).tooltip(),
"tty7 — 1 waiting"
format!(
"tty7 — 1 {}",
t(crate::ui::i18n::L10nKey::PanelAgentWaiting)
)
);
assert_eq!(TraySnapshot::default().tooltip(), "tty7");
}
#[test]
fn menu_spec_shape() {
crate::ui::i18n::set_locale("en");
let empty = menu_spec(&TraySnapshot::default());
let labels: Vec<_> = empty
.iter()
@@ -223,12 +257,12 @@ mod tests {
assert_eq!(
labels,
[
"Show tty7",
"Notifications",
"Settings…",
"Check for Updates…",
"Quit tty7",
"Quit and Stop Server…"
t(L10nKey::TrayShowTty7),
t(L10nKey::TrayNotifications),
t(L10nKey::AppMenuSettings),
t(L10nKey::AppMenuCheckForUpdates),
t(L10nKey::AppMenuQuit),
t(L10nKey::TrayQuitStopServer),
]
);
assert!(
+43 -24
View File
@@ -8,6 +8,7 @@ use crate::core::config::{Config, StartupMode};
use crate::core::session::{WorkspaceId, WorkspaceStore};
use crate::core::window_state::{WindowGeometry as _, WindowState};
use crate::ui::app::Tty7App;
use crate::ui::i18n::{L10nKey, t, t_fmt, t_plural};
const CASCADE_STEP: f32 = 28.0;
@@ -105,6 +106,23 @@ impl WindowRegistry {
.map(|w| w.app.clone())
}
pub fn refresh_locale(cx: &mut App, except: Option<WorkspaceId>) {
Self::sweep(cx);
let windows: Vec<_> = cx
.global::<Self>()
.windows
.iter()
.filter(|entry| Some(entry.workspace) != except)
.map(|entry| (entry.handle, entry.app.clone()))
.collect();
for (handle, app) in windows {
let _ = handle.update(cx, |_, window, cx| {
let _ = app.update(cx, |app, cx| app.refresh_locale_state(window, cx));
window.refresh();
});
}
}
fn register(
cx: &mut App,
workspace: WorkspaceId,
@@ -303,22 +321,11 @@ pub fn confirm_and_delete(cx: &mut App, window: &mut Window, workspace: Workspac
fn destructive_detail(live: Option<usize>, verb: &str) -> String {
match (live, verb) {
(None, "Delete") => "Its machine could not be reached. Any shells still running there \
will be ended, and the layout forgotten."
.to_string(),
(None, _) => {
"Its machine could not be reached. Any shells still running there will be ended."
.to_string()
}
(Some(0), _) => "Its layout and working directories will be forgotten.".to_string(),
(Some(1), "Delete") => {
"1 running shell will be ended and its layout forgotten.".to_string()
}
(Some(n), "Delete") => {
format!("{n} running shells will be ended and the layout forgotten.")
}
(Some(1), _) => "1 running shell will be ended.".to_string(),
(Some(n), _) => format!("{n} running shells will be ended."),
(None, "Delete") => t(L10nKey::WindowDeleteUnreachable).to_string(),
(None, _) => t(L10nKey::WindowStopUnreachable).to_string(),
(Some(0), _) => t_plural(L10nKey::WindowStopShells, 0, &[]),
(Some(n), "Delete") => t_plural(L10nKey::WindowDeleteShells, n, &[]),
(Some(n), _) => t_plural(L10nKey::WindowStopShells, n, &[]),
}
}
@@ -330,7 +337,7 @@ fn confirm_destructive(
act: fn(&mut App, WorkspaceId),
) {
let name = crate::ui::machine_mirror::display_name_for(cx, workspace)
.unwrap_or_else(|| "this workspace".to_string());
.unwrap_or_else(|| t(L10nKey::WindowThisWorkspace).to_string());
let query = pane_count_query(cx, workspace);
let handle = window.window_handle();
@@ -349,12 +356,22 @@ fn confirm_destructive(
}
let detail = destructive_detail(live, verb);
let verb_key = if verb == "Delete" {
L10nKey::WindowDelete
} else {
L10nKey::WindowStop
};
let verb_label = t(verb_key);
let title = t_fmt(
L10nKey::WindowConfirmTitle,
&[("verb", verb_label), ("name", &name)],
);
let Ok(answer) = handle.update(cx, |_, window, cx| {
window.prompt(
gpui::PromptLevel::Warning,
&format!("{verb} Workspace \u{201c}{name}\u{201d}?"),
&title,
Some(&detail),
&["Cancel", verb],
&[t(L10nKey::Cancel), verb_label],
cx,
)
}) else {
@@ -537,6 +554,7 @@ fn cascade(bounds: Bounds<gpui::Pixels>, existing: usize) -> Bounds<gpui::Pixels
mod tests {
use super::*;
use crate::core::session::{WindowView, WindowViews};
use crate::ui::i18n::{L10nKey, set_locale, t_plural};
fn bounds_at(x: f32, y: f32) -> Bounds<gpui::Pixels> {
Bounds {
@@ -614,25 +632,26 @@ mod tests {
#[test]
fn the_confirmation_says_which_of_the_three_answers_it_got() {
set_locale("en");
assert_eq!(
destructive_detail(Some(1), "Stop"),
"1 running shell will be ended."
t_plural(L10nKey::WindowStopShells, 1, &[])
);
assert_eq!(
destructive_detail(Some(3), "Stop"),
"3 running shells will be ended."
t_plural(L10nKey::WindowStopShells, 3, &[])
);
assert_eq!(
destructive_detail(Some(1), "Delete"),
"1 running shell will be ended and its layout forgotten."
t_plural(L10nKey::WindowDeleteShells, 1, &[])
);
assert_eq!(
destructive_detail(Some(3), "Delete"),
"3 running shells will be ended and the layout forgotten."
t_plural(L10nKey::WindowDeleteShells, 3, &[])
);
assert_eq!(
destructive_detail(Some(0), "Delete"),
"Its layout and working directories will be forgotten."
t_plural(L10nKey::WindowStopShells, 0, &[])
);
for verb in ["Stop", "Delete"] {
+16 -8
View File
@@ -7,6 +7,7 @@ use gpui_component::{
use crate::core::worktree::{WorktreeDefaults, WorktreeRequest};
use crate::ui::app::Tty7App;
use crate::ui::i18n::{L10nKey, t, t_fmt};
pub(crate) struct WorktreePrompt {
host: crate::ui::host_ops::SharedHost,
@@ -78,7 +79,7 @@ impl Tty7App {
let base = p.base.read(cx).value().trim().to_string();
let (name, branch) = match (name.is_empty(), branch.is_empty()) {
(true, true) => {
window.push_notification("The worktree needs a name", cx);
window.push_notification(t(L10nKey::WorktreePromptNeedsName), cx);
return;
}
(true, false) => (branch.clone(), branch),
@@ -113,7 +114,10 @@ impl Tty7App {
if let Some(p) = this.worktree_prompt.as_mut() {
p.busy = false;
}
window.push_notification(format!("New worktree failed: {e}"), cx);
window.push_notification(
t_fmt(L10nKey::AppNewWorktreeFailed, &[("error", &e.to_string())]),
cx,
);
cx.notify();
}
},
@@ -162,9 +166,9 @@ impl Tty7App {
div()
.text_sm()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child("New Worktree Tab"),
.child(t(L10nKey::WorktreePromptTitle)),
)
.child(field("Worktree Name", &p.name))
.child(field(t(L10nKey::WorktreePromptName), &p.name))
.child(
div()
.text_xs()
@@ -172,14 +176,18 @@ impl Tty7App {
.text_color(muted)
.child(preview),
)
.child(field("New Branch", &p.branch))
.child(field("Start From", &p.base))
.child(field(t(L10nKey::WorktreePromptBranch), &p.branch))
.child(field(t(L10nKey::WorktreePromptBase), &p.base))
.child(
h_flex()
.gap_2()
.child(
Button::new("worktree-create")
.label(if p.busy { "Creating…" } else { "Create" })
.label(if p.busy {
t(L10nKey::WorktreePromptCreating)
} else {
t(L10nKey::WorktreePromptCreate)
})
.small()
.primary()
.disabled(p.busy)
@@ -189,7 +197,7 @@ impl Tty7App {
)
.child(
Button::new("worktree-cancel")
.label("Cancel")
.label(t(L10nKey::Cancel))
.small()
.on_click(cx.listener(|this, _, window, cx| {
this.cancel_worktree_prompt(window, cx)