diff --git a/crates/herdr-gpui/locales/en-US/code-view.ftl b/crates/herdr-gpui/locales/en-US/code-view.ftl new file mode 100644 index 000000000..df2f1b514 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/code-view.ftl @@ -0,0 +1,19 @@ +# Code tabs: a source file shown read-only, with its definitions listed as +# an outline. File text and definition names are never translated. + +# The outline's heading; $count is how many definitions it lists. +code-view-outline-count = Outline · { $count } +code-view-outline = Outline +code-view-hide-outline = Hide Outline +code-view-open-in-editor = Open in Editor +code-view-reading = Reading file… +code-view-no-workspace = Open a workspace first +code-view-too-many-tabs = Too many tabs are open +# What each definition in the outline is. +code-view-kind-function = function +code-view-kind-method = method +code-view-kind-type = type +code-view-kind-module = module +code-view-kind-constant = constant +code-view-kind-implementation = impl +code-view-kind-macro = macro diff --git a/crates/herdr-gpui/locales/en-US/editor.ftl b/crates/herdr-gpui/locales/en-US/editor.ftl new file mode 100644 index 000000000..7b10c6d27 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/editor.ftl @@ -0,0 +1,3 @@ +# Opening a file in the user's terminal editor. $error is why it failed. + +editor-not-opened = The editor did not open: { $error } diff --git a/crates/herdr-gpui/locales/en-US/log-window.ftl b/crates/herdr-gpui/locales/en-US/log-window.ftl new file mode 100644 index 000000000..e8a6a8904 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/log-window.ftl @@ -0,0 +1,40 @@ +# The log window: the app's own log and, when automations run, each host's +# automation daemon log. Log records themselves are never translated. + +log-window-title = Logs +log-window-search-placeholder = Search, namespace:herdr_gpui target:terminal_painter +# The minimum level menu's button; $level is a log level such as INFO, kept as is. +log-window-minimum = Minimum: { $level } v +log-window-minimum-menu = Minimum level +log-window-pause = Pause +log-window-resume = Resume tail +log-window-copy = Copy +log-window-working = Working... +log-window-export = Export... +log-window-entries = Log entries +log-window-empty = No matching logs. +# The status line. $path is where the app's log file is saved. +log-window-saved = Saved to { $path }. Review before sharing. +log-window-not-saved = Not saved: no state directory. Review before sharing. +log-window-choose-destination = Choose an export destination... +log-window-preparing-clipboard = Preparing clipboard... +log-window-export-cancelled = Export cancelled. +log-window-no-save-dialog = Unable to open save dialog. +log-window-copied = Filtered logs copied. Review before sharing. +log-window-exported = Filtered logs exported. Review before sharing. +log-window-export-failed = Export failed: { $error } +log-window-unreadable = Unable to read logs: { $error } +# Which log is shown: the app's own, or a host's automation daemon's. +log-window-source = Source +log-window-source-app = App +log-window-source-automations = Automations on { $host } +log-window-reading = Reading… +# $source is log-window-source-automations. +log-window-automations-off = { $source } · not read while automations are turned off +log-window-log-tail = { $source } · the end of a longer log +log-window-log-whole = { $source } · the whole log +log-window-host-unreadable = Unable to read { $host }: { $error } +# The status bar: rows shown, records dropped, whether the tail is followed, +# and $status, one of the status messages above. +log-window-summary-live = { $shown } shown | { $dropped } dropped | LIVE | { $status } +log-window-summary-paused = { $shown } shown | { $dropped } dropped | PAUSED | { $status } diff --git a/crates/herdr-gpui/locales/en-US/palette-go-to.ftl b/crates/herdr-gpui/locales/en-US/palette-go-to.ftl new file mode 100644 index 000000000..dc838cb47 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/palette-go-to.ftl @@ -0,0 +1,33 @@ +# The command palette's Go To rows: what kind of place each row is, an +# agent's status, the agent filter, and the preview of the selected row. +# Workspace, tab, pane, and agent names are data. + +# The badge at the end of a row, naming what the row goes to. +palette-go-to-workspace = Workspace +palette-go-to-tab = Tab +palette-go-to-terminal = Terminal +palette-go-to-note = Note +# An agent's status, as its row's badge and the preview show it. +palette-go-to-blocked = blocked +palette-go-to-done = done +palette-go-to-working = working +palette-go-to-idle = idle +# Searched for, never shown: what every agent row is. +palette-go-to-agent = agent +# The agent filter's choices (Alt with a letter picks one). +palette-go-to-filter-all = All agents +palette-go-to-filter-blocked = Blocked +palette-go-to-filter-working = Working +palette-go-to-filter-idle = Idle +palette-go-to-filter-done = Done +# The preview's facts about a row whose screen is not on hand. +palette-go-to-fact-status = Status +palette-go-to-fact-directory = Directory +palette-go-to-fact-workspace = Workspace +palette-go-to-fact-tab = Tab +palette-go-to-fact-branch = Branch +palette-go-to-fact-path = Path +palette-go-to-fact-tabs = Tabs +# Over a pane's last frame; $age is how long ago, such as "3 min ago". +palette-go-to-last-seen = Last seen { $age } +palette-go-to-connection-changed = The selected connection changed. Reopen the palette. diff --git a/crates/herdr-gpui/locales/en-US/palette-projects.ftl b/crates/herdr-gpui/locales/en-US/palette-projects.ftl new file mode 100644 index 000000000..5640d2ede --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/palette-projects.ftl @@ -0,0 +1,10 @@ +# The command palette's project rows: local checkouts under the configured +# project roots, and the row that makes a new one. Paths and URLs are data. + +# A local project's detail, before its path. +palette-projects-local = Local { $path } +# $name is what was typed, $path where it goes, $url the repository cloned. +palette-projects-create-folder = Create folder "{ $name }" +palette-projects-new-in = New project in { $path } +palette-projects-clone = Clone "{ $name }" +palette-projects-clone-into = git clone { $url } into { $path } diff --git a/crates/herdr-gpui/locales/en-US/teleport.ftl b/crates/herdr-gpui/locales/en-US/teleport.ftl new file mode 100644 index 000000000..f42b9e0c2 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/teleport.ftl @@ -0,0 +1,77 @@ +# Teleport: moving a worktree, its tabs, and its agents to another host. +# Host, repository, branch, tab, and program names are data, as are +# commands the destination runs. + +teleport-title = Teleport +# Under the title: $worktree is the worktree moved, $host where it is now. +teleport-subtitle = { $worktree } from { $host } +teleport-no-hosts = No other host is saved. Add one from Devices, then try again. +teleport-host-connected = Connected +teleport-host-ssh = Not connected; reached over SSH +# $host is the destination being checked. +teleport-checking = Checking { $host } and the running programs... +# The review's first line: where the work goes and how. $host is the +# destination, $repository the repository open there, $path a checkout. +teleport-to-origin = To { $host } · { $repository }. Same origin remote. +teleport-to-remote = To { $host } · { $repository }. Shares a remote. +teleport-to-name = To { $host } · { $repository }. Same name only; remotes differ. +teleport-to-reclaim = To { $host } · { $repository }. Brings the work back to the checkout it left. +teleport-to-existing = To { $host } · { $path }. Opens the checkout already there. +teleport-to-clone = To { $host } · { $path }. Clones the repository there first. +teleport-to-copy = To { $host } · { $path }. Copies the repository there first. +teleport-state = Branch { $branch } · { $unpushed -> + [one] { $unpushed } unpushed commit + *[other] { $unpushed } unpushed commits +} · { $changed -> + [one] { $changed } changed file + *[other] { $changed } changed files +}, { $untracked } untracked +teleport-github-token = { $host } cannot reach GitHub by itself, so your GitHub CLI token is installed for this repository there, for git pull and push. +teleport-github-unavailable = { $host } cannot reach GitHub, and the GitHub CLI here is not signed in, so pull and push will not work there. +teleport-name-only = Matched by name only: this repository's remotes differ there. +# A tab of the move, numbered from 1, with the label the user gave it. +teleport-tab = Tab { $number } +teleport-tab-labeled = Tab { $number }: { $label } +# What a pane runs on the destination. $program is a program's name, +# $agent the agent that continues, $command the command line. +teleport-pane-shell = Shell +teleport-pane-resume = { $agent }: resume session +teleport-pane-handoff-again = { $program }: handoff note, then start again +teleport-pane-handoff-to = { $program }: handoff note, continued by { $agent } +teleport-pane-start = Start { $command } +teleport-pane-run = Run { $command } +teleport-pane-missing = { $program }: not installed there, skipped +teleport-left-behind = Ignored files such as .env stay behind. The workspace here then closes; its checkout stays and can be reopened with Open worktree. +teleport-moving-to = Moving to { $host }... +# The move's steps, as the progress line names them. +teleport-step-starting = Starting +teleport-step-discover = finding matching repositories +teleport-step-review = reviewing the move +teleport-step-clone = copying the repository to the destination +teleport-step-open = opening the repository on the destination +teleport-step-handoff = asking agents for handoff notes +teleport-step-capture = capturing commits and changes +teleport-step-transfer = copying changes to the destination +teleport-step-fetch = fetching the branch on the destination +teleport-step-create-worktree = creating the destination worktree +teleport-step-restore = restoring uncommitted changes +teleport-step-tabs = recreating tabs +teleport-step-retire = stopping programs in the source workspace +teleport-step-credentials = installing GitHub credentials on the destination +teleport-step-sessions = moving agent sessions +teleport-step-launch = starting programs +teleport-keeps-moving = Closing this dialog does not stop the move. +teleport-source-closes-later = The source workspace closes only once the destination is ready. +teleport-review = Review +teleport-moving = Moving... +teleport-close = Close +teleport-hide = Hide +teleport-cancel = Cancel +# Flashes when the move ends with the dialog closed. +teleport-failed = Teleport failed: { $error } +teleport-done = Teleported to { $host } +# $first is the first warning, from the move itself. +teleport-done-warnings = { $count -> + [one] Teleported to { $host } with one warning: { $first } + *[other] Teleported to { $host } with { $count } warnings: { $first } +} diff --git a/crates/herdr-gpui/locales/en-US/theme-picker.ftl b/crates/herdr-gpui/locales/en-US/theme-picker.ftl new file mode 100644 index 000000000..08e8493e4 --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/theme-picker.ftl @@ -0,0 +1,19 @@ +# The color scheme picker the menu opens. Theme names are data. + +theme-picker-title = Color Scheme +theme-picker-close = Close +theme-picker-search = Search themes... +# How many themes match the search, of all there are. +theme-picker-count = { $total -> + [one] { $shown } of { $total } theme + *[other] { $shown } of { $total } themes +} +theme-picker-empty = No matching themes. Try a shorter search. +# Beside the theme the window shows now. +theme-picker-current = Current +theme-picker-saving = Saving theme... Please wait. +theme-picker-loading = Loading theme... Esc to cancel. +theme-picker-hint = Hover or Up / Down to preview. Enter or click a theme to save. Star a theme to keep it at the top. +theme-picker-star = Star theme +theme-picker-unstar = Unstar theme +theme-picker-favorites-failed = Could not save favorite themes: { $error } diff --git a/crates/herdr-gpui/locales/en-US/update-panel.ftl b/crates/herdr-gpui/locales/en-US/update-panel.ftl new file mode 100644 index 000000000..2bff9984a --- /dev/null +++ b/crates/herdr-gpui/locales/en-US/update-panel.ftl @@ -0,0 +1,55 @@ +# App Updates: checking GitHub for a new release, downloading, installing, +# and showing its release notes. Versions and the notes are data. + +update-panel-title = App Updates +update-panel-subtitle = Updates from GitHub +update-panel-close = Close +update-panel-current-version = Current version +update-panel-latest-version = Latest version +update-panel-not-known = Not yet known +update-panel-whats-new = What's new +# $reason says why, in the updater's own words. +update-panel-disabled = In-app updates unavailable: { $reason } +update-panel-idle = Check GitHub for a new app release. +update-panel-checking = Checking for updates... +update-panel-current = You are running the latest available release. +update-panel-available = A new app release is available. +update-panel-verifying = Download complete. Verifying the update... +update-panel-downloading = Downloading the update +update-panel-downloading-version = Downloading { $version } +update-panel-ready-windows = Download and publisher signature verified. Install when you are ready: Herdr UI quits, its installer runs, and it reopens; your daemon and terminal sessions stay running. +update-panel-ready = Download verified. Install when you are ready; system packages may ask for administrator authentication. +update-panel-installing = Preparing installation and restart. Herdr will quit when the update is ready. +update-panel-homebrew = A new app release is available. Homebrew will install it, refreshing its package metadata if needed. +update-panel-restart-needed = Installed { $version }. Restart to finish; your daemon and terminal sessions stay running. +update-panel-restarting = Starting the updated app... +update-panel-cancelling = Cancelling update... Waiting for current I/O to finish or time out. +update-panel-failed = Update failed: { $error } +# While a system package or Homebrew installs the update. +update-panel-step-approve = Approve the system authentication prompt to install the package... +update-panel-step-asking = Asking Homebrew to upgrade the cask... +update-panel-step-checking = Checking the installed Homebrew cask version... +# $line is a line Homebrew printed, shown as it came. +update-panel-step-homebrew = Homebrew: { $line } +update-panel-step-preparing = Preparing installation... +update-panel-step-refreshing = Refreshing Homebrew metadata with brew update... +update-panel-step-retrying = Retrying Homebrew cask upgrade after refresh... +update-panel-step-starting = Starting Homebrew... +# Under the progress bar. $amount is decimal megabytes with one place. +update-panel-megabytes = { $amount } MB +update-panel-downloaded = { $received } downloaded +update-panel-progress = { $received } of { $total } +update-panel-percent = { $percent }% +update-panel-verified-note = Downloads are verified before installation. Your daemon and terminal sessions stay running. +# Shown only in QA's synthetic update preview. +update-panel-preview = QA preview +update-panel-preview-note = Synthetic update state only. No network or installation is performed. Close or Escape dismisses this preview. +update-panel-manual-releases = Manual Releases +update-panel-release-notes = Release Notes +update-panel-retry = Retry +update-panel-check = Check for Updates +update-panel-download = Download +update-panel-install = Install Update +update-panel-upgrade = Update with Homebrew +update-panel-restart = Restart +update-panel-cancel = Cancel diff --git a/crates/herdr-gpui/src/code_view.rs b/crates/herdr-gpui/src/code_view.rs index 08785293f..645c22ecf 100644 --- a/crates/herdr-gpui/src/code_view.rs +++ b/crates/herdr-gpui/src/code_view.rs @@ -9,6 +9,7 @@ //! executor, colours last, so its text shows before a large file is //! coloured. Drawing only reads what those produced. +// i18n: migrated mod render; use crate::{ @@ -16,6 +17,7 @@ use crate::{ browser::{Location, Store, TabId}, code_index::{self, Kind, Language}, editor::EditorTarget, + i18n::fl, review::highlight::{self, Span}, window::Flash, }; @@ -220,7 +222,7 @@ impl HerdrWindow { cx: &mut Context, ) { let Some((scope, workspace)) = self.browser_key() else { - self.show_flash(Flash::warning("Open a workspace first"), cx); + self.show_flash(Flash::warning(fl!("code-view-no-workspace")), cx); return; }; let Some(path) = target.path.to_str().map(str::to_owned) else { @@ -248,7 +250,7 @@ impl HerdrWindow { }) }); let Some(id) = opened else { - self.show_flash(Flash::warning("Too many tabs are open"), cx); + self.show_flash(Flash::warning(fl!("code-view-too-many-tabs")), cx); return; }; self.ensure_code_view(id, cx); @@ -462,5 +464,18 @@ impl HerdrWindow { } } +/// What a definition is, as the outline names it. +fn kind_label(kind: Kind) -> String { + match kind { + Kind::Function => fl!("code-view-kind-function"), + Kind::Method => fl!("code-view-kind-method"), + Kind::Type => fl!("code-view-kind-type"), + Kind::Module => fl!("code-view-kind-module"), + Kind::Constant => fl!("code-view-kind-constant"), + Kind::Implementation => fl!("code-view-kind-implementation"), + Kind::Macro => fl!("code-view-kind-macro"), + } +} + #[cfg(test)] mod tests; diff --git a/crates/herdr-gpui/src/code_view/render.rs b/crates/herdr-gpui/src/code_view/render.rs index 0d62a024b..9dfef449e 100644 --- a/crates/herdr-gpui/src/code_view/render.rs +++ b/crates/herdr-gpui/src/code_view/render.rs @@ -1,11 +1,11 @@ //! A code tab paints the read file and its outline; reading lives elsewhere. -use super::{CodeView, State, Text}; -use crate::fonts::StyledFont; +use super::{CodeView, State, Text, kind_label}; use crate::{ HerdrWindow, browser::{Slot, Tab, TabId}, }; +use crate::{fonts::StyledFont, i18n::fl}; use gpui::{prelude::*, *}; use std::{cell::Cell, rc::Rc}; @@ -97,7 +97,7 @@ impl HerdrWindow { .px(px(10.)) .py(px(6.)) .text_color(rgb(theme.muted)) - .child(format!("Outline \u{00b7} {}", text.outline.len())), + .child(fl!("code-view-outline-count", count = text.outline.len())), ) .child( uniform_list( @@ -133,7 +133,7 @@ impl HerdrWindow { div() .flex_none() .text_color(rgb(this.theme.muted)) - .child(entry.kind.label()), + .child(kind_label(entry.kind)), ) .on_click(cx.listener(move |this, _, _, cx| { if let Some(view) = this.code_views.get_mut(&id) { @@ -158,7 +158,7 @@ impl HerdrWindow { .as_ref() .map(crate::browser::Location::display) .unwrap_or_default(); - let button = |label: &'static str, selector: &'static str| { + let button = |label: String, selector: &'static str| { div() .id(selector) .debug_selector(move || selector.into()) @@ -190,9 +190,9 @@ impl HerdrWindow { .child( button( if view.outline { - "Hide Outline" + fl!("code-view-hide-outline") } else { - "Outline" + fl!("code-view-outline") }, "code-outline-toggle", ) @@ -204,7 +204,7 @@ impl HerdrWindow { })), ) .child( - button("Open in Editor", "code-open-editor").on_click( + button(fl!("code-view-open-in-editor"), "code-open-editor").on_click( cx.listener(move |this, _, _, cx| this.open_code_view_in_editor(id, cx)), ), ) @@ -219,7 +219,7 @@ impl HerdrWindow { State::Loading => div() .p_3() .text_color(rgb(theme.muted)) - .child("Reading file\u{2026}") + .child(fl!("code-view-reading")) .into_any_element(), State::Failed(error) => div() .debug_selector(|| "code-error".into()) diff --git a/crates/herdr-gpui/src/code_view/tests.rs b/crates/herdr-gpui/src/code_view/tests.rs index d573666a8..84cb5a083 100644 --- a/crates/herdr-gpui/src/code_view/tests.rs +++ b/crates/herdr-gpui/src/code_view/tests.rs @@ -213,4 +213,5 @@ fn reopening_a_code_tab_reads_its_file_again(cx: &mut TestAppContext) { }); } +mod en_xa; mod top_row; diff --git a/crates/herdr-gpui/src/code_view/tests/en_xa.rs b/crates/herdr-gpui/src/code_view/tests/en_xa.rs new file mode 100644 index 000000000..f17c42fed --- /dev/null +++ b/crates/herdr-gpui/src/code_view/tests/en_xa.rs @@ -0,0 +1,48 @@ +//! The `en-XA` leak test (`crate::i18n::leak_tests`) for a code tab: its +//! header, outline heading, and definition kinds are messages; the file's +//! path, lines, and definition names are data. +use super::*; +use crate::i18n::leak_tests::{assert_translated, draw, under_en_xa}; +use gpui::{px, size}; + +#[test] +fn a_code_tab_shows_only_translated_text_under_en_xa() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("lib.rs"); + let source = "pub struct Pane;\nimpl Pane {\n fn run(&self) {}\n}\n"; + std::fs::write(&path, source).unwrap(); + under_en_xa("code tab en-XA leak", |cx, recorder| { + let (view, cx) = window(cx); + cx.simulate_resize(size(px(1600.), px(900.))); + draw(cx); + recorder.take(); + let target = EditorTarget { + path: path.clone(), + line: Some(1), + }; + cx.update(|window, cx| { + view.update(cx, |view, cx| view.open_code_view(&target, window, cx)) + }); + cx.run_until_parked(); + draw(cx); + draw(cx); + let mut data: Vec = source.lines().map(|line| line.trim().to_owned()).collect(); + view.read_with(cx, |view, _| { + let text = view.code_views.values().next().unwrap().text().unwrap(); + data.extend(text.outline.iter().map(|entry| entry.name.clone())); + }); + data.push("lib.rs".into()); + // The header truncates the path to its width, so it may show only + // a piece of it, such as the drive letter of a Windows temp path. + let shown_path = path.display().to_string(); + let shown: Vec = recorder + .take() + .into_iter() + .filter(|line| { + let piece = line.trim().trim_end_matches('\u{2026}'); + piece.is_empty() || !shown_path.contains(piece) + }) + .collect(); + assert_translated("code tab", &shown, &data); + }); +} diff --git a/crates/herdr-gpui/src/control/server/settings/services.rs b/crates/herdr-gpui/src/control/server/settings/services.rs index dcce3520a..42eaedb05 100644 --- a/crates/herdr-gpui/src/control/server/settings/services.rs +++ b/crates/herdr-gpui/src/control/server/settings/services.rs @@ -15,10 +15,11 @@ use crate::{ use gpui::{App, WindowHandle}; use std::time::Duration; -/// The state's short name, its detail, and the version it names. -pub(super) fn describe(state: &State) -> (&'static str, Option<&str>, Option<&str>) { +/// The state's short name, its detail, and the version it names. The +/// detail is English: the command line's words are not localized. +pub(super) fn describe(state: &State) -> (&'static str, Option, Option<&str>) { match state { - State::Disabled(reason) => ("disabled", Some(reason), None), + State::Disabled(reason) => ("disabled", Some(reason.clone()), None), State::Idle => ("idle", None, None), State::Checking => ("checking", None, None), State::Current => ("current", None, None), @@ -27,11 +28,11 @@ pub(super) fn describe(state: &State) -> (&'static str, Option<&str>, Option<&st State::Ready { version } => ("ready", None, Some(version)), State::Installing => ("installing", None, None), State::Homebrew { version, .. } => ("homebrew", None, Some(version)), - State::Upgrading { detail } => ("upgrading", Some(detail), None), + State::Upgrading { detail } => ("upgrading", Some(detail.to_string()), None), State::Restart { version } => ("restart", None, Some(version)), State::Restarting => ("restarting", None, None), State::Cancelling => ("cancelling", None, None), - State::Error(message) => ("error", Some(message), None), + State::Error(message) => ("error", Some(message.clone()), None), } } @@ -41,7 +42,7 @@ fn reply(updater: &Updater) -> Response { reply: Reply::Update { state: state.to_owned(), latest: version.or(updater.latest_version()).map(str::to_owned), - detail: detail.map(str::to_owned), + detail, }, } } diff --git a/crates/herdr-gpui/src/control/server/settings/tests.rs b/crates/herdr-gpui/src/control/server/settings/tests.rs index 680309c48..37efaf776 100644 --- a/crates/herdr-gpui/src/control/server/settings/tests.rs +++ b/crates/herdr-gpui/src/control/server/settings/tests.rs @@ -87,7 +87,7 @@ fn every_reach_has_a_stable_name() { fn update_states_are_named_with_their_version_or_reason() { assert_eq!( describe(&State::Disabled("Local build".into())), - ("disabled", Some("Local build"), None) + ("disabled", Some("Local build".to_owned()), None) ); assert_eq!(describe(&State::Checking), ("checking", None, None)); assert_eq!( @@ -99,7 +99,13 @@ fn update_states_are_named_with_their_version_or_reason() { ); assert_eq!( describe(&State::Error("offline".into())), - ("error", Some("offline"), None) + ("error", Some("offline".to_owned()), None) + ); + assert_eq!( + describe(&State::Upgrading { + detail: crate::updater::Progress::Starting + }), + ("upgrading", Some("Starting Homebrew...".to_owned()), None) ); } diff --git a/crates/herdr-gpui/src/editor.rs b/crates/herdr-gpui/src/editor.rs index 750de6e4a..645d764f3 100644 --- a/crates/herdr-gpui/src/editor.rs +++ b/crates/herdr-gpui/src/editor.rs @@ -17,6 +17,7 @@ mod error; mod nvim; +// i18n: migrated pub(crate) use error::Error; use crate::{HerdrWindow, NavigationTarget}; @@ -119,6 +120,7 @@ fn placed(command: &str) -> String { } /// The pane's own editor, which listens on `$2` when it is Neovim. +// i18n: data const DEFAULT_SCRIPT: &str = r#"e=${VISUAL:-${EDITOR:-vi}}; case "${e##*/}" in nvim*) [ -n "$2" ] && exec $e --listen "$2" "+$1" "$0";; esac; exec $e "+$1" "$0""#; /// A file to open, at a line when one is known. @@ -165,9 +167,10 @@ pub(crate) fn command_line( .and_then(Path::to_str) .filter(|socket| quotable(socket)) else { - return Ok(format!(" exec sh -c '{script}' '{path}' {line}")); + return Ok(format!(" exec sh -c '{script}' '{path}' {line}")); // i18n: data }; Ok(format!( + // i18n: data " exec sh -c 'mkdir -p -m 700 \"${{2%/*}}\" || set -- \"$1\" \"\"; {script}' '{path}' {line} '{socket}'" )) } @@ -472,7 +475,10 @@ impl HerdrWindow { } Err(error) => { self.show_flash( - crate::window::Flash::warning(format!("The editor did not open: {error}")), + crate::window::Flash::warning(crate::i18n::fl!( + "editor-not-opened", + error = error.to_string() + )), cx, ); } diff --git a/crates/herdr-gpui/src/editor/nvim.rs b/crates/herdr-gpui/src/editor/nvim.rs index 1afdef23a..f9c320d05 100644 --- a/crates/herdr-gpui/src/editor/nvim.rs +++ b/crates/herdr-gpui/src/editor/nvim.rs @@ -81,7 +81,7 @@ pub(super) fn request(target: &EditorTarget) -> Option { let path = path.replace('\'', "''"); let mut commands = vec![format!("'drop ' .. fnameescape('{path}')")]; if let Some(line) = target.line { - commands.push(format!("'call cursor({line}, 1)'")); + commands.push(format!("'call cursor({line}, 1)'")); // i18n: data commands.push("'normal! zz'".to_owned()); } Some(format!("execute([{}])", commands.join(", "))) diff --git a/crates/herdr-gpui/src/log_window.rs b/crates/herdr-gpui/src/log_window.rs index d8fc81a66..5647da631 100644 --- a/crates/herdr-gpui/src/log_window.rs +++ b/crates/herdr-gpui/src/log_window.rs @@ -1,7 +1,9 @@ +// i18n: migrated use crate::{ config::{Config, FontConfig, Theme}, diagnostics::{self, Record}, fonts::StyledFont, + i18n::fl, search_input::{Changed, SearchInput}, }; use gpui::{prelude::*, *}; @@ -92,7 +94,7 @@ fn open_deferred(cx: &mut App) { WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), window_min_size: Some(size(px(620.), px(360.))), - titlebar: Some(crate::titlebar::options("Logs")), + titlebar: Some(crate::titlebar::options(&fl!("log-window-title"))), app_owns_titlebar_drag: cfg!(target_os = "macos"), ..Default::default() }, @@ -212,7 +214,7 @@ impl LogWindow { let appearance = cx.default_global::().clone(); search.update(cx, |input, cx| { input.set_appearance(appearance.config.ui.clone(), appearance.theme.clone(), cx); - input.set_placeholder("Search, namespace:herdr_gpui target:terminal_painter", cx); + input.set_placeholder_with(|| fl!("log-window-search-placeholder"), cx); window.focus(&input.focus, cx); }); let appearance_subscription = cx.observe_global::(|this, cx| { @@ -317,7 +319,10 @@ impl LogWindow { } // Not logged: a failing read would log on every change. Err(error) => { - this.status = format!("Unable to read logs: {}", error.kind()) + this.status = fl!( + "log-window-unreadable", + error = error.kind().to_string() + ) } } cx.notify(); @@ -333,8 +338,8 @@ impl LogWindow { } }); let app_status = match diagnostics::path() { - Some(path) => format!("Saved to {}. Review before sharing.", path.display()), - None => "Not saved: no state directory. Review before sharing.".into(), + Some(path) => fl!("log-window-saved", path = path.display().to_string()), + None => fl!("log-window-not-saved"), }; Self { appearance, @@ -369,11 +374,10 @@ impl LogWindow { } self.exporting = true; self.status = if save { - "Choose an export destination..." + fl!("log-window-choose-destination") } else { - "Preparing clipboard..." - } - .into(); + fl!("log-window-preparing-clipboard") + }; let records = self.retained.clone(); let query = self.search.read(cx).text().to_owned(); let minimum = self.minimum; @@ -389,11 +393,10 @@ impl LogWindow { let _ = this.update(cx, |this, cx| { this.exporting = false; this.status = if cancelled { - "Export cancelled." + fl!("log-window-export-cancelled") } else { - "Unable to open save dialog." - } - .into(); + fl!("log-window-no-save-dialog") + }; cx.notify(); }); return; @@ -418,14 +421,13 @@ impl LogWindow { match result { Ok(Some(text)) => { cx.write_to_clipboard(ClipboardItem::new_string(text)); - this.status = "Filtered logs copied. Review before sharing.".into(); - } - Ok(None) => { - this.status = "Filtered logs exported. Review before sharing.".into() + this.status = fl!("log-window-copied"); } + Ok(None) => this.status = fl!("log-window-exported"), Err(error) => { tracing::warn!(kind = ?error.kind(), "Log export failed"); - this.status = format!("Export failed: {}", error.kind()); + this.status = + fl!("log-window-export-failed", error = error.kind().to_string()); } } cx.notify(); @@ -556,7 +558,7 @@ impl Render for LogWindow { .child( div() .text_size(px(config.ui.size + 4.)) - .child("Logs"), + .child(fl!("log-window-title")), ) .children(self.render_sources(cx)) .child(self.search.clone()) @@ -567,7 +569,7 @@ impl Render for LogWindow { .items_center() .gap_2() .child( - button("minimum-level", format!("Minimum: {} v", self.minimum), theme) + button("minimum-level", fl!("log-window-minimum", level = self.minimum.to_string()), theme) .track_focus(&self.level_focus) .border_1() .border_color(if self.level_focus.is_focused(window) { palette_color(theme, 4) } else { rgb(theme.active) }) @@ -583,7 +585,7 @@ impl Render for LogWindow { deferred( anchored().child(crate::motion::pop_in( div().id("level-menu").debug_selector(|| "level-menu".into()) - .role(Role::Menu).aria_label("Minimum level") + .role(Role::Menu).aria_label(fl!("log-window-minimum-menu")) .absolute().top_full().left_0().w(px(180.)) .p_1().bg(rgb(theme.surface)).border_1().border_color(rgb(theme.active)) .occlude().track_focus(&self.menu_focus) @@ -618,9 +620,9 @@ impl Render for LogWindow { button( "follow", if self.following { - "Pause" + fl!("log-window-pause") } else { - "Resume tail" + fl!("log-window-resume") }, theme, ) @@ -633,16 +635,16 @@ impl Render for LogWindow { )), ) .child( - button("copy", "Copy", theme) + button("copy", fl!("log-window-copy"), theme) .on_click(cx.listener(|this, _, _, cx| this.share(false, cx))), ) .child( button( "export", if self.exporting { - "Working..." + fl!("log-window-working") } else { - "Export..." + fl!("log-window-export") }, theme, ) @@ -654,7 +656,7 @@ impl Render for LogWindow { div() .id("log-rows") .role(Role::List) - .aria_label("Log entries") + .aria_label(fl!("log-window-entries")) .flex_1() .min_h_0() .overflow_hidden() @@ -663,7 +665,7 @@ impl Render for LogWindow { cx.notify(); })) .when(self.rows.is_empty(), |el| { - el.child(div().p_3().child("No matching logs.")) + el.child(div().p_3().child(fl!("log-window-empty"))) }) .when(!self.rows.is_empty(), |el| { el.child( @@ -751,13 +753,12 @@ impl Render for LogWindow { ) }) .child({ - let summary = format!( - "{} shown | {} dropped | {} | {}", - self.rows.len(), - self.dropped, - if self.following { "LIVE" } else { "PAUSED" }, - self.status - ); + let (shown, dropped) = (self.rows.len(), self.dropped); + let summary = if self.following { + fl!("log-window-summary-live", shown = shown, dropped = dropped, status = self.status.as_str()) + } else { + fl!("log-window-summary-paused", shown = shown, dropped = dropped, status = self.status.as_str()) + }; div() .id("log-status") .role(Role::Status) diff --git a/crates/herdr-gpui/src/log_window/source.rs b/crates/herdr-gpui/src/log_window/source.rs index e5151b777..d959bd47a 100644 --- a/crates/herdr-gpui/src/log_window/source.rs +++ b/crates/herdr-gpui/src/log_window/source.rs @@ -7,7 +7,7 @@ //! and otherwise shown as plain text stripped of control characters. use super::{LogWindow, button, filtered}; -use crate::{automations::LogSource, diagnostics::Record}; +use crate::{automations::LogSource, diagnostics::Record, i18n::fl}; use gpui::{prelude::*, *}; use serde_json::{Map, Value}; use std::{ @@ -37,8 +37,13 @@ pub(super) enum Source { impl Source { pub(super) fn label(&self) -> String { match self { - Self::App => "App".to_owned(), - Self::Automations(source) => format!("Automations on {}", source.label), + Self::App => fl!("log-window-source-app"), + Self::Automations(source) => { + fl!( + "log-window-source-automations", + host = source.label.as_str() + ) + } } } } @@ -156,7 +161,7 @@ impl LogWindow { self.scroll.reset(0); self.status = match &source { Source::App => self.app_status.clone(), - Source::Automations(_) => "Reading…".to_owned(), + Source::Automations(_) => fl!("log-window-reading"), }; if let Source::Automations(log) = &source { self.remote = Some(self.follow(log.clone(), cx)); @@ -180,9 +185,9 @@ impl LogWindow { // no more. let off = crate::automations::LogSources::off(cx); if off && this.following { - let status = format!( - "{} · not read while automations are turned off", - Source::Automations(log.clone()).label() + let status = fl!( + "log-window-automations-off", + source = Source::Automations(log.clone()).label() ); if this.status != status { this.status = status; @@ -247,19 +252,20 @@ impl LogWindow { this.rows = rows; this.retained = retained; if let Some(truncated) = truncated { - this.status = format!( - "{} · {}", - Source::Automations(log.clone()).label(), - if truncated { - "the end of a longer log" - } else { - "the whole log" - } - ); + let source = Source::Automations(log.clone()).label(); + this.status = if truncated { + fl!("log-window-log-tail", source = source) + } else { + fl!("log-window-log-whole", source = source) + }; } } Err(error) => { - this.status = format!("Unable to read {}: {error}", log.label); + this.status = fl!( + "log-window-host-unreadable", + host = log.label.as_str(), + error = error.to_string() + ); } } cx.notify(); @@ -297,7 +303,11 @@ impl LogWindow { .flex_wrap() .items_center() .gap_2() - .child(div().text_color(rgb(theme.muted)).child("Source")) + .child( + div() + .text_color(rgb(theme.muted)) + .child(fl!("log-window-source")), + ) .children(choices.into_iter().enumerate().map(|(index, choice)| { let on = choice == self.source; button(format!("log-source-{index}"), choice.label(), theme) diff --git a/crates/herdr-gpui/src/log_window/tests.rs b/crates/herdr-gpui/src/log_window/tests.rs index 6388876c8..a1218edfd 100644 --- a/crates/herdr-gpui/src/log_window/tests.rs +++ b/crates/herdr-gpui/src/log_window/tests.rs @@ -1,6 +1,7 @@ use super::*; use core::prelude::v1::test; +mod en_xa; mod line_height; mod sources; mod window_commands; @@ -29,8 +30,8 @@ fn concrete_default_fonts_and_shared_native_decoration() { "Menlo" } ); - let options = crate::titlebar::options("Logs"); - assert_eq!(options.title.unwrap().as_ref(), "Logs"); + let options = crate::titlebar::options(&fl!("log-window-title")); + assert_eq!(options.title.unwrap().as_ref(), fl!("log-window-title")); assert_eq!(options.appears_transparent, cfg!(target_os = "macos")); assert_eq!( options.traffic_light_position, @@ -407,10 +408,7 @@ fn copy_uses_current_query_and_levels_before_rows_refresh(cx: &mut TestAppContex assert_eq!(rows[0].message, "slow paint elapsed_ms=32"); assert_eq!(rows[0].level, Level::WARN); assert!(!view.read(cx).exporting); - assert_eq!( - view.read(cx).status, - "Filtered logs copied. Review before sharing." - ); + assert_eq!(view.read(cx).status, fl!("log-window-copied")); }); } diff --git a/crates/herdr-gpui/src/log_window/tests/en_xa.rs b/crates/herdr-gpui/src/log_window/tests/en_xa.rs new file mode 100644 index 000000000..3e9a55233 --- /dev/null +++ b/crates/herdr-gpui/src/log_window/tests/en_xa.rs @@ -0,0 +1,13 @@ +//! The `en-XA` leak test (`crate::i18n::leak_tests`) for the log window: +//! its controls and status are messages; the records it lists are not. +use super::*; +use crate::i18n::leak_tests::{assert_translated, draw, under_en_xa}; + +#[test] +fn the_log_window_shows_only_translated_text_under_en_xa() { + under_en_xa("log window en-XA leak", |cx, recorder| { + let (_view, cx) = cx.add_window_view(LogWindow::new); + draw(cx); + assert_translated("log window", &recorder.take(), &[]); + }); +} diff --git a/crates/herdr-gpui/src/log_window/tests/sources.rs b/crates/herdr-gpui/src/log_window/tests/sources.rs index f90111184..8e88a0505 100644 --- a/crates/herdr-gpui/src/log_window/tests/sources.rs +++ b/crates/herdr-gpui/src/log_window/tests/sources.rs @@ -66,7 +66,7 @@ fn the_picker_lists_hosts_with_a_daemon_log(cx: &mut TestAppContext) { assert!(cx.debug_bounds("log-source-1").is_some()); assert_eq!( Source::Automations(ssh_source()).label(), - "Automations on buildbox" + fl!("log-window-source-automations", host = "buildbox") ); view.read_with(cx, |view, _| { assert_eq!(view.source, Source::App); @@ -88,7 +88,10 @@ fn a_host_log_is_not_read_while_automations_are_off(cx: &mut TestAppContext) { view.read_with(cx, |view, _| { assert_eq!( view.status, - "Automations on buildbox · not read while automations are turned off" + fl!( + "log-window-automations-off", + source = fl!("log-window-source-automations", host = "buildbox") + ) ); }); } diff --git a/crates/herdr-gpui/src/palette.rs b/crates/herdr-gpui/src/palette.rs index b037ca6af..e6e6e40e5 100644 --- a/crates/herdr-gpui/src/palette.rs +++ b/crates/herdr-gpui/src/palette.rs @@ -1,3 +1,4 @@ +// i18n: migrated use crate::{ HerdrWindow, OwnedNavigationTarget, Result, controls::{COMMANDS, Command}, @@ -796,7 +797,10 @@ impl HerdrWindow { entries.extend(palette.projects.projects.iter().map(|project| { let mut entry = Entry::new( project.label.clone(), - format!("Local {}", project.path.display()), + fl!( + "palette-projects-local", + path = project.path.display().to_string() + ), fl!("palette-badge-project"), Action::Project(project.clone()), None, diff --git a/crates/herdr-gpui/src/palette/activate.rs b/crates/herdr-gpui/src/palette/activate.rs index 115ef405c..c59e22f08 100644 --- a/crates/herdr-gpui/src/palette/activate.rs +++ b/crates/herdr-gpui/src/palette/activate.rs @@ -56,7 +56,7 @@ impl HerdrWindow { }; if !self.menu_target_current() { if let Some(palette) = &mut self.menu.palette { - palette.error = Some("The selected connection changed. Reopen the palette.".into()); + palette.error = Some(crate::i18n::fl!("palette-go-to-connection-changed")); } cx.notify(); return; diff --git a/crates/herdr-gpui/src/palette/agent_filter.rs b/crates/herdr-gpui/src/palette/agent_filter.rs index 02063974a..a7e73a92b 100644 --- a/crates/herdr-gpui/src/palette/agent_filter.rs +++ b/crates/herdr-gpui/src/palette/agent_filter.rs @@ -4,7 +4,7 @@ //! a terminal without an agent has none and only passes [`AgentFilter::All`]. use super::{Entry, Filter, Selection, search}; -use crate::HerdrWindow; +use crate::{HerdrWindow, i18n::fl}; use gpui::{Context, Keystroke, Modifiers}; use herdr_client::protocol::AgentStatus; use std::collections::{BTreeMap, HashMap}; @@ -28,13 +28,24 @@ impl AgentFilter { Self::Done, ]; - pub(super) fn label(self) -> &'static str { + /// The chip's name in test selectors, the same in every language. + pub(super) fn name(self) -> &'static str { match self { - Self::All => "All agents", - Self::Blocked => "Blocked", - Self::Working => "Working", - Self::Idle => "Idle", - Self::Done => "Done", + Self::All => "all-agents", + Self::Blocked => "blocked", + Self::Working => "working", + Self::Idle => "idle", + Self::Done => "done", + } + } + + pub(super) fn label(self) -> String { + match self { + Self::All => fl!("palette-go-to-filter-all"), + Self::Blocked => fl!("palette-go-to-filter-blocked"), + Self::Working => fl!("palette-go-to-filter-working"), + Self::Idle => fl!("palette-go-to-filter-idle"), + Self::Done => fl!("palette-go-to-filter-done"), } } diff --git a/crates/herdr-gpui/src/palette/go_to.rs b/crates/herdr-gpui/src/palette/go_to.rs index 1a3417351..288700223 100644 --- a/crates/herdr-gpui/src/palette/go_to.rs +++ b/crates/herdr-gpui/src/palette/go_to.rs @@ -3,7 +3,7 @@ //! destination still exists. use super::{Action, Entry, Error, Source, frecency, projects}; -use crate::{NavigationTarget, Result}; +use crate::{NavigationTarget, Result, i18n::fl}; use gpui::SharedString; use herdr_client::protocol::{AgentStatus, ClientShellSnapshot}; @@ -38,7 +38,8 @@ pub(super) fn destination_exists( } } -pub(super) fn status_badge(status: AgentStatus) -> &'static str { +/// An agent's status word, searched for in English whatever the language. +fn status_keyword(status: AgentStatus) -> &'static str { match status { AgentStatus::Blocked => "blocked", AgentStatus::Done => "done", @@ -48,6 +49,17 @@ pub(super) fn status_badge(status: AgentStatus) -> &'static str { } } +/// An agent's status word, as its badge shows it. +pub(super) fn status_badge(status: AgentStatus) -> String { + match status { + AgentStatus::Blocked => fl!("palette-go-to-blocked"), + AgentStatus::Done => fl!("palette-go-to-done"), + AgentStatus::Working => fl!("palette-go-to-working"), + AgentStatus::Idle => fl!("palette-go-to-idle"), + AgentStatus::Unknown => String::new(), + } +} + /// One host's Go To rows: each workspace, its tabs when it has a choice of /// them, then every pane, one row per agent or terminal so no split is hidden /// behind its tab. @@ -78,7 +90,7 @@ pub(super) fn go_to_entries( let mut entry = Entry::new( workspace.label.clone(), detail, - "Workspace", + fl!("palette-go-to-workspace"), go(NavigationTarget::Workspace(workspace.workspace_id.clone())), None, ); @@ -109,7 +121,7 @@ pub(super) fn go_to_entries( let mut entry = Entry::new( tab.label.clone(), detail, - "Tab", + fl!("palette-go-to-tab"), go(NavigationTarget::Tab(tab.tab_id.clone())), Some(workspace_row), ); @@ -145,8 +157,7 @@ pub(super) fn go_to_entries( .as_deref() .map(str::trim) .filter(|label| !label.is_empty()) - .unwrap_or("Terminal") - .to_owned(), + .map_or_else(|| fl!("palette-go-to-terminal"), str::to_owned), }; let path = pane.foreground_cwd.as_deref().or(pane.cwd.as_deref()); let detail = [host, Some(workspace.label.as_str()), tab, path] @@ -158,12 +169,18 @@ pub(super) fn go_to_entries( // An agent is also found by its kind, name, and plain status word, // whatever its row is titled or its integration labels the state, // and by the titles and Rename label its sidebar row can show. + // Status and kind words match in English and as shown. let keywords = agent.map_or(String::new(), |agent| { + let badge = status_badge(agent.agent_status); + let kind = fl!("palette-go-to-agent"); [ agent.agent.as_deref(), agent.name.as_deref(), - Some(status_badge(agent.agent_status)), + Some(status_keyword(agent.agent_status)), + Some(badge.as_str()), + // i18n: data Some("agent"), + Some(kind.as_str()), agent.title.as_deref(), agent.terminal_title_stripped.as_deref(), pane.label.as_deref(), @@ -176,11 +193,15 @@ pub(super) fn go_to_entries( let mut entry = Entry::with_keywords( name, detail, - agent.map_or(SharedString::new_static("Terminal"), |agent| { - crate::sidebar::state_label(agent, status_badge(agent.agent_status)) - .into_owned() - .into() - }), + agent.map_or_else( + || SharedString::from(fl!("palette-go-to-terminal")), + |agent| { + let badge = status_badge(agent.agent_status); + crate::sidebar::state_label(agent, &badge) + .into_owned() + .into() + }, + ), go(NavigationTarget::Pane(pane.pane_id.clone())), parent, &keywords, @@ -234,7 +255,7 @@ pub(super) fn note_entries( entries.push(Entry::with_keywords( workspace.label.clone(), detail, - "Note", + fl!("palette-go-to-note"), Action::Note { endpoint: checkout.endpoint.clone(), boot: snapshot.boot_id.clone(), diff --git a/crates/herdr-gpui/src/palette/preview.rs b/crates/herdr-gpui/src/palette/preview.rs index c179c70dd..6f9f7d6d7 100644 --- a/crates/herdr-gpui/src/palette/preview.rs +++ b/crates/herdr-gpui/src/palette/preview.rs @@ -11,6 +11,7 @@ use super::{go_to::status_badge, last_seen::LastSeen, projects}; use crate::{ NavigationTarget, config::Theme, + i18n::fl, terminal_painter::{Ground, Layer, Part, Span, TerminalPainter}, }; use gpui::*; @@ -112,14 +113,14 @@ fn facts(snapshot: &ClientShellSnapshot, target: NavigationTarget<&str>) -> Opti .as_deref() .map(str::trim) .filter(|label| !label.is_empty()) - .unwrap_or("Terminal"); + .map_or_else(|| fl!("palette-go-to-terminal"), str::to_owned); match agent(&pane.pane_id) { Some(agent) => ( crate::sidebar::agent_name(agent).to_owned(), - crate::sidebar::state_label(agent, status_badge(agent.agent_status)).into_owned(), + crate::sidebar::state_label(agent, &status_badge(agent.agent_status)).into_owned(), ), None => ( - name.to_owned(), + name, pane.foreground_cwd .as_deref() .or(pane.cwd.as_deref()) @@ -134,16 +135,16 @@ fn facts(snapshot: &ClientShellSnapshot, target: NavigationTarget<&str>) -> Opti let (name, state) = pane_fact(pane); let mut facts = Vec::new(); if agent(id).is_some() { - facts.push(("Status".into(), state)); + facts.push((fl!("palette-go-to-fact-status"), state)); } if let Some(cwd) = pane.foreground_cwd.as_deref().or(pane.cwd.as_deref()) { - facts.push(("Directory".into(), cwd.into())); + facts.push((fl!("palette-go-to-fact-directory"), cwd.into())); } if let Some(workspace) = workspace(&pane.workspace_id) { - facts.push(("Workspace".into(), workspace.label.clone())); + facts.push((fl!("palette-go-to-fact-workspace"), workspace.label.clone())); } if let Some(tab) = tab(&pane.tab_id) { - facts.push(("Tab".into(), tab.label.clone())); + facts.push((fl!("palette-go-to-fact-tab"), tab.label.clone())); } (name, facts) } @@ -151,7 +152,7 @@ fn facts(snapshot: &ClientShellSnapshot, target: NavigationTarget<&str>) -> Opti let tab = tab(id)?; let mut facts = Vec::new(); if let Some(workspace) = workspace(&tab.workspace_id) { - facts.push(("Workspace".into(), workspace.label.clone())); + facts.push((fl!("palette-go-to-fact-workspace"), workspace.label.clone())); } facts.extend( snapshot @@ -166,17 +167,17 @@ fn facts(snapshot: &ClientShellSnapshot, target: NavigationTarget<&str>) -> Opti let workspace = workspace(id)?; let mut facts = Vec::new(); if let Some(branch) = &workspace.branch { - facts.push(("Branch".into(), branch.clone())); + facts.push((fl!("palette-go-to-fact-branch"), branch.clone())); } if let Some(root) = projects::launch_root(snapshot, id) { - facts.push(("Path".into(), root.into())); + facts.push((fl!("palette-go-to-fact-path"), root.into())); } let tabs = snapshot .tabs .iter() .filter(|t| t.workspace_id == id) .count(); - facts.push(("Tabs".into(), tabs.to_string())); + facts.push((fl!("palette-go-to-fact-tabs"), tabs.to_string())); facts.extend( snapshot .panes diff --git a/crates/herdr-gpui/src/palette/project_create.rs b/crates/herdr-gpui/src/palette/project_create.rs index 2f3ea2eb9..a4e9d3caf 100644 --- a/crates/herdr-gpui/src/palette/project_create.rs +++ b/crates/herdr-gpui/src/palette/project_create.rs @@ -7,6 +7,7 @@ //! convention project discovery already uses. use super::{Action, Entry, Error, Palette, projects}; +use crate::i18n::fl; use crate::pull_request::run; use std::{ fs, @@ -228,18 +229,25 @@ fn new_project_entry(roots: &[String], query: &str) -> Option { let plan = plan(roots, query).ok().flatten()?; let (label, detail) = match &plan.source { Source::Folder => ( - format!("Create folder \"{}\"", plan.label), - format!("New project in {}", plan.path.display()), + fl!("palette-projects-create-folder", name = plan.label.as_str()), + fl!( + "palette-projects-new-in", + path = plan.path.display().to_string() + ), ), Source::Clone { url } => ( - format!("Clone \"{}\"", plan.label), - format!("git clone {url} into {}", plan.path.display()), + fl!("palette-projects-clone", name = plan.label.as_str()), + fl!( + "palette-projects-clone-into", + url = url.as_str(), + path = plan.path.display().to_string() + ), ), }; Some(Entry::with_keywords( label, detail, - "Project", + fl!("palette-badge-project"), Action::NewProject(plan), None, // The raw query, so the row is offered for exactly what was typed. diff --git a/crates/herdr-gpui/src/palette/projects.rs b/crates/herdr-gpui/src/palette/projects.rs index 54dff8b5a..f64952856 100644 --- a/crates/herdr-gpui/src/palette/projects.rs +++ b/crates/herdr-gpui/src/palette/projects.rs @@ -225,156 +225,4 @@ pub(crate) fn launch_root<'a>( } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn expands_paths_without_shell_execution_and_rejects_unset_variables() -> anyhow::Result<()> { - let root = if cfg!(windows) { - "C:/projects" - } else { - "/projects" - }; - let home = if cfg!(windows) { - "C:/Users/me" - } else { - "/home/me" - }; - let get = |name: &str| (name == "ROOT").then(|| OsString::from(root)); - assert_eq!( - expand("$ROOT/work", get, None)?, - Path::new(root).join("work") - ); - assert_eq!( - expand("${ROOT}/work", get, None)?, - Path::new(root).join("work") - ); - assert_eq!( - expand("~/work", get, Some(Path::new(home)))?, - Path::new(home).join("work") - ); - assert!(matches!( - expand("$MISSING/work", get, None), - Err(crate::Error::Palette(Error::ProjectVariable(_))) - )); - assert!(matches!( - expand("relative", get, None), - Err(crate::Error::Palette(Error::ProjectPath)) - )); - assert!(expand("${ROOT", get, None).is_err()); - assert!(expand("$(touch x)", get, None).is_err()); - Ok(()) - } - - #[test] - fn discovers_only_immediate_visible_folders_and_deduplicates_roots() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - fs::create_dir_all(root.path().join("alpha/nested"))?; - fs::create_dir(root.path().join(".hidden"))?; - fs::write(root.path().join("file"), "")?; - let missing = root.path().join("missing").to_string_lossy().into_owned(); - let name = root.path().to_string_lossy().into_owned(); - let result = collect(&[missing, name.clone(), name], &AtomicBool::new(false)); - assert_eq!(result.projects.len(), 1); - assert_eq!(result.projects[0].label, "alpha"); - assert_eq!(result.errors.len(), 1); - use std::error::Error as _; - assert!(result.errors[0].source().is_some()); - validate(&result.projects[0])?; - let cancelled = collect( - &[root.path().to_string_lossy().into_owned()], - &AtomicBool::new(true), - ); - assert!(cancelled.projects.is_empty()); - Ok(()) - } - - /// A root short enough for a plain path can hold children that are not: - /// one too long for it, and one whose name ends in a dot. Discovery lists - /// both in the form `validate` computes, so neither reads as removed. - #[cfg(windows)] - #[test] - fn discovered_children_validate_when_only_the_root_is_plain() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - let long = "a".repeat(255); - let verbatim = fs::canonicalize(root.path())?; - fs::create_dir(verbatim.join(&long))?; - fs::create_dir(verbatim.join("dot."))?; - let result = collect( - &[root.path().to_string_lossy().into_owned()], - &AtomicBool::new(false), - ); - assert!(result.errors.is_empty(), "{:?}", result.errors); - assert_eq!(result.projects.len(), 2); - for project in &result.projects { - assert!( - project.path.to_string_lossy().starts_with(r"\\?\"), - "{}", - project.path.display() - ); - validate(project)?; - } - Ok(()) - } - - #[test] - fn limits_filesystem_work() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - for index in 0..=MAX_PROJECTS { - fs::create_dir(root.path().join(format!("project-{index}")))?; - } - let result = collect( - &[root.path().to_string_lossy().into_owned()], - &AtomicBool::new(false), - ); - assert_eq!(result.projects.len(), MAX_PROJECTS); - assert!(matches!( - result.errors.as_slice(), - [crate::Error::Palette(Error::ProjectLimit)] - )); - Ok(()) - } - - #[test] - fn matches_only_the_root_panes_launch_directory() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - let path = super::super::canonical::canonicalize(root.path())?; - let nested = path.join("nested"); - fs::create_dir(&nested)?; - let mut snapshot: ClientShellSnapshot = serde_json::from_str(include_str!( - "../../../herdr-protocol/tests/fixtures/endpoint-snapshot-v1.json" - ))?; - snapshot.panes[0].cwd = Some(path.to_string_lossy().into_owned()); - snapshot.panes[0].foreground_cwd = Some(nested.to_string_lossy().into_owned()); - assert_eq!(workspace_for_path(&snapshot, &path), Some("w1".into())); - assert_eq!(workspace_for_path(&snapshot, &nested), None); - let mut split = snapshot.panes[0].clone(); - split.pane_id = "split".into(); - split.cwd = Some(nested.to_string_lossy().into_owned()); - snapshot.panes.push(split); - assert_eq!(workspace_for_path(&snapshot, &nested), None); - Ok(()) - } - - #[cfg(unix)] - #[test] - fn skips_child_symlinks_and_rejects_a_replaced_directory() -> anyhow::Result<()> { - let root = tempfile::tempdir()?; - let outside = tempfile::tempdir()?; - std::os::unix::fs::symlink(outside.path(), root.path().join("link"))?; - let path = root.path().join("project"); - fs::create_dir(&path)?; - let result = collect( - &[root.path().to_string_lossy().into_owned()], - &AtomicBool::new(false), - ); - assert_eq!(result.projects.len(), 1); - fs::remove_dir(&path)?; - std::os::unix::fs::symlink(outside.path(), &path)?; - assert!(matches!( - validate(&result.projects[0]), - Err(crate::Error::Palette(Error::ProjectRemoved)) - )); - Ok(()) - } -} +mod tests; diff --git a/crates/herdr-gpui/src/palette/projects/tests.rs b/crates/herdr-gpui/src/palette/projects/tests.rs new file mode 100644 index 000000000..bcb7469fe --- /dev/null +++ b/crates/herdr-gpui/src/palette/projects/tests.rs @@ -0,0 +1,151 @@ +use super::*; + +#[test] +fn expands_paths_without_shell_execution_and_rejects_unset_variables() -> anyhow::Result<()> { + let root = if cfg!(windows) { + "C:/projects" + } else { + "/projects" + }; + let home = if cfg!(windows) { + "C:/Users/me" + } else { + "/home/me" + }; + let get = |name: &str| (name == "ROOT").then(|| OsString::from(root)); + assert_eq!( + expand("$ROOT/work", get, None)?, + Path::new(root).join("work") + ); + assert_eq!( + expand("${ROOT}/work", get, None)?, + Path::new(root).join("work") + ); + assert_eq!( + expand("~/work", get, Some(Path::new(home)))?, + Path::new(home).join("work") + ); + assert!(matches!( + expand("$MISSING/work", get, None), + Err(crate::Error::Palette(Error::ProjectVariable(_))) + )); + assert!(matches!( + expand("relative", get, None), + Err(crate::Error::Palette(Error::ProjectPath)) + )); + assert!(expand("${ROOT", get, None).is_err()); + assert!(expand("$(touch x)", get, None).is_err()); + Ok(()) +} + +#[test] +fn discovers_only_immediate_visible_folders_and_deduplicates_roots() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + fs::create_dir_all(root.path().join("alpha/nested"))?; + fs::create_dir(root.path().join(".hidden"))?; + fs::write(root.path().join("file"), "")?; + let missing = root.path().join("missing").to_string_lossy().into_owned(); + let name = root.path().to_string_lossy().into_owned(); + let result = collect(&[missing, name.clone(), name], &AtomicBool::new(false)); + assert_eq!(result.projects.len(), 1); + assert_eq!(result.projects[0].label, "alpha"); + assert_eq!(result.errors.len(), 1); + use std::error::Error as _; + assert!(result.errors[0].source().is_some()); + validate(&result.projects[0])?; + let cancelled = collect( + &[root.path().to_string_lossy().into_owned()], + &AtomicBool::new(true), + ); + assert!(cancelled.projects.is_empty()); + Ok(()) +} + +/// A root short enough for a plain path can hold children that are not: +/// one too long for it, and one whose name ends in a dot. Discovery lists +/// both in the form `validate` computes, so neither reads as removed. +#[cfg(windows)] +#[test] +fn discovered_children_validate_when_only_the_root_is_plain() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let long = "a".repeat(255); + let verbatim = fs::canonicalize(root.path())?; + fs::create_dir(verbatim.join(&long))?; + fs::create_dir(verbatim.join("dot."))?; + let result = collect( + &[root.path().to_string_lossy().into_owned()], + &AtomicBool::new(false), + ); + assert!(result.errors.is_empty(), "{:?}", result.errors); + assert_eq!(result.projects.len(), 2); + for project in &result.projects { + assert!( + project.path.to_string_lossy().starts_with(r"\\?\"), + "{}", + project.path.display() + ); + validate(project)?; + } + Ok(()) +} + +#[test] +fn limits_filesystem_work() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + for index in 0..=MAX_PROJECTS { + fs::create_dir(root.path().join(format!("project-{index}")))?; + } + let result = collect( + &[root.path().to_string_lossy().into_owned()], + &AtomicBool::new(false), + ); + assert_eq!(result.projects.len(), MAX_PROJECTS); + assert!(matches!( + result.errors.as_slice(), + [crate::Error::Palette(Error::ProjectLimit)] + )); + Ok(()) +} + +#[test] +fn matches_only_the_root_panes_launch_directory() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let path = super::super::canonical::canonicalize(root.path())?; + let nested = path.join("nested"); + fs::create_dir(&nested)?; + let mut snapshot: ClientShellSnapshot = serde_json::from_str(include_str!( + "../../../../herdr-protocol/tests/fixtures/endpoint-snapshot-v1.json" + ))?; + snapshot.panes[0].cwd = Some(path.to_string_lossy().into_owned()); + snapshot.panes[0].foreground_cwd = Some(nested.to_string_lossy().into_owned()); + assert_eq!(workspace_for_path(&snapshot, &path), Some("w1".into())); + assert_eq!(workspace_for_path(&snapshot, &nested), None); + let mut split = snapshot.panes[0].clone(); + split.pane_id = "split".into(); + split.cwd = Some(nested.to_string_lossy().into_owned()); + snapshot.panes.push(split); + assert_eq!(workspace_for_path(&snapshot, &nested), None); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn skips_child_symlinks_and_rejects_a_replaced_directory() -> anyhow::Result<()> { + let root = tempfile::tempdir()?; + let outside = tempfile::tempdir()?; + std::os::unix::fs::symlink(outside.path(), root.path().join("link"))?; + let path = root.path().join("project"); + fs::create_dir(&path)?; + let result = collect( + &[root.path().to_string_lossy().into_owned()], + &AtomicBool::new(false), + ); + assert_eq!(result.projects.len(), 1); + fs::remove_dir(&path)?; + std::os::unix::fs::symlink(outside.path(), &path)?; + assert!(matches!( + validate(&result.projects[0]), + Err(crate::Error::Palette(Error::ProjectRemoved)) + )); + Ok(()) +} diff --git a/crates/herdr-gpui/src/palette/render.rs b/crates/herdr-gpui/src/palette/render.rs index 651904180..0e941ab89 100644 --- a/crates/herdr-gpui/src/palette/render.rs +++ b/crates/herdr-gpui/src/palette/render.rs @@ -103,12 +103,7 @@ impl HerdrWindow { .children(AgentFilter::ALL.into_iter().map(|filter| { div() .id(("palette-agent-filter", filter as usize)) - .debug_selector(move || { - format!( - "palette-agent-filter-{}", - filter.label().to_lowercase().replace(' ', "-") - ) - }) + .debug_selector(move || format!("palette-agent-filter-{}", filter.name())) .px_2() .py_1() .rounded(px(crate::config::corners::CONTROL)) @@ -286,7 +281,7 @@ impl HerdrWindow { .border_1() .border_color(rgb(theme.active)) .text_color(rgb(theme.muted)) - .child(format!("Last seen {age}")), + .child(fl!("palette-go-to-last-seen", age = age)), ) }) } diff --git a/crates/herdr-gpui/src/palette/tests.rs b/crates/herdr-gpui/src/palette/tests.rs index 5b8fdc371..d1be753d4 100644 --- a/crates/herdr-gpui/src/palette/tests.rs +++ b/crates/herdr-gpui/src/palette/tests.rs @@ -6,6 +6,7 @@ use herdr_client::protocol::ClientShellCommand; mod agent_filter; mod background_ranking; +mod en_xa; mod frecency_ranking; mod go_to_search; mod notes; diff --git a/crates/herdr-gpui/src/palette/tests/en_xa.rs b/crates/herdr-gpui/src/palette/tests/en_xa.rs new file mode 100644 index 000000000..6f27ec3fe --- /dev/null +++ b/crates/herdr-gpui/src/palette/tests/en_xa.rs @@ -0,0 +1,48 @@ +//! The `en-XA` leak test (`crate::i18n::leak_tests`) for Go To: each row's +//! badge, the agent filter, and the preview's facts are messages; the names +//! and paths of the host's workspaces, tabs, panes, and agents are data. +use super::*; +use crate::i18n::leak_tests::{assert_translated, draw, under_en_xa}; + +#[test] +fn go_to_shows_only_translated_text_under_en_xa() { + under_en_xa("Go To en-XA leak", |cx, recorder| { + let (view, cx) = cx.add_window_view(fixture_window); + cx.simulate_resize(size(px(1400.), px(900.))); + draw(cx); + recorder.take(); + cx.update(|window, cx| { + view.update(cx, |view, cx| { + view.open_palette(Filter::Navigation, window, cx) + }) + }); + draw(cx); + let data = view.read_with(cx, |view, _| { + let palette = view.menu.palette.as_ref().unwrap(); + let snapshot = view.live.snapshot.as_ref().unwrap(); + let mut data: Vec = palette + .entries + .iter() + .filter(|entry| matches!(entry.action, Action::Go { .. })) + .flat_map(|entry| [entry.label.to_string(), entry.detail.to_string()]) + .collect(); + data.extend(snapshot.workspaces.iter().map(|w| w.label.clone())); + data.extend(snapshot.tabs.iter().map(|t| t.label.clone())); + data.extend( + snapshot + .panes + .iter() + .flat_map(|p| [p.cwd.clone(), p.foreground_cwd.clone()]) + .flatten(), + ); + data.extend(snapshot.workspaces.iter().filter_map(|w| w.branch.clone())); + // Agents' names, and the state labels their integrations report. + for agent in &snapshot.agents { + data.push(crate::sidebar::agent_name(agent).to_owned()); + data.extend(agent.state_labels.iter().map(|(_, label)| label.clone())); + } + data + }); + assert_translated("Go To", &recorder.take(), &data); + }); +} diff --git a/crates/herdr-gpui/src/release_notes.rs b/crates/herdr-gpui/src/release_notes.rs index 2ccce80ec..ed3feab30 100644 --- a/crates/herdr-gpui/src/release_notes.rs +++ b/crates/herdr-gpui/src/release_notes.rs @@ -5,6 +5,7 @@ //! and only ever drawn as text. Links become clickable ranges that open in the //! browser on click, and only `http`/`https` targets qualify. Commands shown in //! code spans or blocks are never run. +// i18n: migrated use crate::{ config::{FontConfig, Theme}, fonts::StyledFont, @@ -548,102 +549,4 @@ pub(crate) fn line_highlights( } #[cfg(test)] -mod structure; - -#[cfg(test)] -mod tests { - use super::*; - - fn kinds(lines: &[Line]) -> Vec { - lines.iter().map(|line| line.kind).collect() - } - - #[core::prelude::v1::test] - fn markdown_structure_becomes_typed_lines() { - let lines = - parse("### Added\n\n\n- New `herdr x` flag\nPlain text\n```\nrun --this\n```\n\n"); - assert_eq!( - kinds(&lines), - [ - Kind::Heading, - Kind::Blank, - Kind::Bullet, - Kind::Paragraph, - Kind::Code, - ] - ); - assert_eq!(lines[0].text, "Added"); - assert_eq!(lines[2].text, "New herdr x flag"); - assert_eq!(&lines[2].text[lines[2].code[0].clone()], "herdr x"); - assert_eq!(lines[4].text, "run --this"); - assert!(lines[4].links.is_empty() && lines[4].code.is_empty()); - } - - #[core::prelude::v1::test] - fn only_web_links_become_clickable() { - let line = &parse( - "See [docs](https://herdr.dev/a?b=1) or https://example.com/x). \ - [bad](javascript:alert(1)) file:///etc/passwd [x](http://) `https://in.code`", - )[0]; - let urls: Vec<_> = line.links.iter().map(|link| link.url.as_str()).collect(); - assert_eq!(urls, ["https://herdr.dev/a?b=1", "https://example.com/x"]); - assert_eq!(&line.text[line.links[0].range.clone()], "docs"); - assert!(line.text.contains("[bad](javascript:alert(1))")); - assert!(line.text.contains("file:///etc/passwd")); - assert_eq!(&line.text[line.code[0].clone()], "https://in.code"); - } - - #[core::prelude::v1::test] - fn highlights_are_in_text_order() { - let line = &parse("[a](https://a.example) `b` https://c.example **d**")[0]; - let starts: Vec<_> = line - .highlights() - .map(|(range, mark)| (range.start, mark)) - .collect(); - assert_eq!( - starts, - [ - (0, Mark::Link), - (2, Mark::Code), - (4, Mark::Link), - (22, Mark::Bold) - ] - ); - assert_eq!(line.text, "a b https://c.example d"); - } - - #[core::prelude::v1::test] - fn controls_and_bidi_overrides_are_stripped() { - let lines = - parse("- \u{1b}[31mred\u{7} \u{202e}txt\u{2066}\r\n```\n\u{1b}]52;c;x\u{7}\n```"); - assert_eq!(lines[0].text, "[31mred txt"); - assert_eq!(lines[1].text, "]52;c;x"); - } - - #[core::prelude::v1::test] - fn bodies_are_bounded() { - let body = "x\n".repeat(MAX_LINES * 2); - assert_eq!(parse(&body).len(), MAX_LINES); - let long = "é".repeat(MAX_BODY); - let lines = parse(&long); - assert_eq!(lines.len(), 1); - assert_eq!(lines[0].text.chars().count(), MAX_LINE); - let url = format!("https://example.com/{}", "a".repeat(MAX_URL)); - assert!(parse(&url)[0].links.is_empty()); - } - - #[core::prelude::v1::test] - fn prepared_lines_follow_the_body() { - let prepared = Prepared::default(); - let first = prepared.lines("- one"); - assert!(Rc::ptr_eq(&first, &prepared.lines("- one"))); - assert_eq!(prepared.lines("- two")[0].text, "two"); - } - - #[core::prelude::v1::test] - fn unmatched_markers_stay_literal() { - let line = &parse("a `b [c](d e **** **f")[0]; - assert_eq!(line.text, "a `b [c](d e **** **f"); - assert!(line.code.is_empty() && line.bold.is_empty() && line.links.is_empty()); - } -} +mod tests; diff --git a/crates/herdr-gpui/src/release_notes/tests.rs b/crates/herdr-gpui/src/release_notes/tests.rs new file mode 100644 index 000000000..e2b7020ca --- /dev/null +++ b/crates/herdr-gpui/src/release_notes/tests.rs @@ -0,0 +1,95 @@ +#![allow(clippy::unwrap_used)] +use super::*; + +mod structure; + +fn kinds(lines: &[Line]) -> Vec { + lines.iter().map(|line| line.kind).collect() +} + +#[core::prelude::v1::test] +fn markdown_structure_becomes_typed_lines() { + let lines = parse("### Added\n\n\n- New `herdr x` flag\nPlain text\n```\nrun --this\n```\n\n"); + assert_eq!( + kinds(&lines), + [ + Kind::Heading, + Kind::Blank, + Kind::Bullet, + Kind::Paragraph, + Kind::Code, + ] + ); + assert_eq!(lines[0].text, "Added"); + assert_eq!(lines[2].text, "New herdr x flag"); + assert_eq!(&lines[2].text[lines[2].code[0].clone()], "herdr x"); + assert_eq!(lines[4].text, "run --this"); + assert!(lines[4].links.is_empty() && lines[4].code.is_empty()); +} + +#[core::prelude::v1::test] +fn only_web_links_become_clickable() { + let line = &parse( + "See [docs](https://herdr.dev/a?b=1) or https://example.com/x). \ + [bad](javascript:alert(1)) file:///etc/passwd [x](http://) `https://in.code`", + )[0]; + let urls: Vec<_> = line.links.iter().map(|link| link.url.as_str()).collect(); + assert_eq!(urls, ["https://herdr.dev/a?b=1", "https://example.com/x"]); + assert_eq!(&line.text[line.links[0].range.clone()], "docs"); + assert!(line.text.contains("[bad](javascript:alert(1))")); + assert!(line.text.contains("file:///etc/passwd")); + assert_eq!(&line.text[line.code[0].clone()], "https://in.code"); +} + +#[core::prelude::v1::test] +fn highlights_are_in_text_order() { + let line = &parse("[a](https://a.example) `b` https://c.example **d**")[0]; + let starts: Vec<_> = line + .highlights() + .map(|(range, mark)| (range.start, mark)) + .collect(); + assert_eq!( + starts, + [ + (0, Mark::Link), + (2, Mark::Code), + (4, Mark::Link), + (22, Mark::Bold) + ] + ); + assert_eq!(line.text, "a b https://c.example d"); +} + +#[core::prelude::v1::test] +fn controls_and_bidi_overrides_are_stripped() { + let lines = parse("- \u{1b}[31mred\u{7} \u{202e}txt\u{2066}\r\n```\n\u{1b}]52;c;x\u{7}\n```"); + assert_eq!(lines[0].text, "[31mred txt"); + assert_eq!(lines[1].text, "]52;c;x"); +} + +#[core::prelude::v1::test] +fn bodies_are_bounded() { + let body = "x\n".repeat(MAX_LINES * 2); + assert_eq!(parse(&body).len(), MAX_LINES); + let long = "é".repeat(MAX_BODY); + let lines = parse(&long); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].text.chars().count(), MAX_LINE); + let url = format!("https://example.com/{}", "a".repeat(MAX_URL)); + assert!(parse(&url)[0].links.is_empty()); +} + +#[core::prelude::v1::test] +fn prepared_lines_follow_the_body() { + let prepared = Prepared::default(); + let first = prepared.lines("- one"); + assert!(Rc::ptr_eq(&first, &prepared.lines("- one"))); + assert_eq!(prepared.lines("- two")[0].text, "two"); +} + +#[core::prelude::v1::test] +fn unmatched_markers_stay_literal() { + let line = &parse("a `b [c](d e **** **f")[0]; + assert_eq!(line.text, "a `b [c](d e **** **f"); + assert!(line.code.is_empty() && line.bold.is_empty() && line.links.is_empty()); +} diff --git a/crates/herdr-gpui/src/release_notes/structure.rs b/crates/herdr-gpui/src/release_notes/tests/structure.rs similarity index 100% rename from crates/herdr-gpui/src/release_notes/structure.rs rename to crates/herdr-gpui/src/release_notes/tests/structure.rs diff --git a/crates/herdr-gpui/src/sidebar/layout_tests/update_download_caption.rs b/crates/herdr-gpui/src/sidebar/layout_tests/update_download_caption.rs index 845b6f80d..99ee90637 100644 --- a/crates/herdr-gpui/src/sidebar/layout_tests/update_download_caption.rs +++ b/crates/herdr-gpui/src/sidebar/layout_tests/update_download_caption.rs @@ -1,23 +1,42 @@ use super::*; -use crate::{update_panel::download_caption, updater::State}; +use crate::{i18n::fl, update_panel::download_caption, updater::State}; fn caption(received: u64, total: u64) -> Option<(String, Option)> { download_caption(&State::Downloading { received, total }) } +fn megabytes(amount: &str) -> String { + fl!("update-panel-megabytes", amount = amount) +} + +fn progress(received: &str, total: &str) -> String { + fl!( + "update-panel-progress", + received = megabytes(received), + total = megabytes(total) + ) +} + +fn percent(percent: &str) -> String { + fl!("update-panel-percent", percent = percent) +} + #[test] fn download_sizes_read_in_megabytes() { assert_eq!( caption(42_318_544, 96_104_448), - Some(("42.3 MB of 96.1 MB".into(), Some("44%".into()))) + Some((progress("42.3", "96.1"), Some(percent("44")))) ); assert_eq!( caption(0, 100_000_000), - Some(("0.0 MB of 100.0 MB".into(), Some("0%".into()))) + Some((progress("0.0", "100.0"), Some(percent("0")))) ); assert_eq!( caption(42_318_544, 0), - Some(("42.3 MB downloaded".into(), None)), + Some(( + fl!("update-panel-downloaded", received = megabytes("42.3")), + None + )), "an unknown size shows what arrived and no percentage" ); } diff --git a/crates/herdr-gpui/src/sidebar/layout_tests/update_panel.rs b/crates/herdr-gpui/src/sidebar/layout_tests/update_panel.rs index 02625af84..31ad74b59 100644 --- a/crates/herdr-gpui/src/sidebar/layout_tests/update_panel.rs +++ b/crates/herdr-gpui/src/sidebar/layout_tests/update_panel.rs @@ -65,7 +65,9 @@ fn a_homebrew_upgrade_in_progress_offers_nothing_to_interrupt(cx: &mut gpui::Tes // Homebrew output is arbitrary length; a long line must not burst the panel. let states = [ State::Upgrading { - detail: "==> Downloading ".to_owned() + &"herdr".repeat(24), + detail: crate::updater::Progress::Homebrew( + "==> Downloading ".to_owned() + &"herdr".repeat(24), + ), }, State::Restarting, ]; diff --git a/crates/herdr-gpui/src/teleport.rs b/crates/herdr-gpui/src/teleport.rs index d678fcc4e..b3b94097e 100644 --- a/crates/herdr-gpui/src/teleport.rs +++ b/crates/herdr-gpui/src/teleport.rs @@ -1,5 +1,7 @@ //! Teleport: `herdr_remote::teleport`, plus its review and progress view. +// i18n: migrated +mod text; mod ui; pub(crate) use herdr_remote::teleport::{snapshot::SessionKind, *}; diff --git a/crates/herdr-gpui/src/teleport/text.rs b/crates/herdr-gpui/src/teleport/text.rs new file mode 100644 index 000000000..bd1d859f0 --- /dev/null +++ b/crates/herdr-gpui/src/teleport/text.rs @@ -0,0 +1,87 @@ +//! What the Teleport dialog says about a move, from `teleport.ftl`. +//! `herdr_remote::teleport` keeps its English labels for logs and errors. + +use crate::i18n::fl; +use crate::teleport::{ + Step, + job::{Action, Candidate, Destination}, + launch::{Work, command_line}, + provision::Arrival, + remote::MatchReason, +}; + +/// The review's first line: where the worktree goes, and how. +pub(super) fn destination(candidate: &Candidate) -> String { + let host = candidate.place.label.as_str(); + match &candidate.destination { + Destination::Open { repository, reason } => { + let repository = repository.label.as_str(); + match reason { + MatchReason::Origin => { + fl!("teleport-to-origin", host = host, repository = repository) + } + MatchReason::Remote => { + fl!("teleport-to-remote", host = host, repository = repository) + } + MatchReason::Name => fl!("teleport-to-name", host = host, repository = repository), + } + } + Destination::Reclaim { repository, .. } => fl!( + "teleport-to-reclaim", + host = host, + repository = repository.label.as_str() + ), + Destination::Arrive(Arrival::Existing { path, .. }) => { + fl!("teleport-to-existing", host = host, path = path.as_str()) + } + Destination::Arrive(Arrival::Clone { path }) => match candidate.origin { + Some(_) => fl!("teleport-to-clone", host = host, path = path.as_str()), + None => fl!("teleport-to-copy", host = host, path = path.as_str()), + }, + } +} + +/// What a pane will run on the destination, as the review lists it. +pub(super) fn pane(work: &Work, action: &Action) -> String { + // i18n: data + let program = work.program().unwrap_or("shell"); + match action { + Action::Shell => fl!("teleport-pane-shell"), + Action::Resume(agent) => fl!("teleport-pane-resume", agent = agent.binary()), + Action::Handoff { to, .. } if to.binary() == program => { + fl!("teleport-pane-handoff-again", program = program) + } + Action::Handoff { to, .. } => fl!( + "teleport-pane-handoff-to", + program = program, + agent = to.binary() + ), + Action::Start(argv) => fl!("teleport-pane-start", command = command_line(argv)), + Action::Run(argv) => fl!("teleport-pane-run", command = command_line(argv)), + Action::Missing(program) => fl!("teleport-pane-missing", program = program.as_str()), + } +} + +/// The step a move is on, or that it is starting. +pub(super) fn step(step: Option) -> String { + let Some(step) = step else { + return fl!("teleport-step-starting"); + }; + match step { + Step::Discover => fl!("teleport-step-discover"), + Step::Review => fl!("teleport-step-review"), + Step::Clone => fl!("teleport-step-clone"), + Step::Open => fl!("teleport-step-open"), + Step::Handoff => fl!("teleport-step-handoff"), + Step::Capture => fl!("teleport-step-capture"), + Step::Transfer => fl!("teleport-step-transfer"), + Step::Fetch => fl!("teleport-step-fetch"), + Step::CreateWorktree => fl!("teleport-step-create-worktree"), + Step::Restore => fl!("teleport-step-restore"), + Step::Tabs => fl!("teleport-step-tabs"), + Step::Retire => fl!("teleport-step-retire"), + Step::Credentials => fl!("teleport-step-credentials"), + Step::Sessions => fl!("teleport-step-sessions"), + Step::Launch => fl!("teleport-step-launch"), + } +} diff --git a/crates/herdr-gpui/src/teleport/ui.rs b/crates/herdr-gpui/src/teleport/ui.rs index 786663f14..919c56840 100644 --- a/crates/herdr-gpui/src/teleport/ui.rs +++ b/crates/herdr-gpui/src/teleport/ui.rs @@ -5,19 +5,18 @@ //! cancels discovery and review; a move in progress keeps going and reports //! its outcome as a flash, because stopping halfway would strand work. +use super::text; use crate::teleport::{ - carry::describe, error::{Error, Step}, job::{ - self, Action, Candidate, Destination, GitHubAccess, HostRepositories, Outcome, Place, - Review, Source, + self, Action, Candidate, GitHubAccess, HostRepositories, Outcome, Place, Review, Source, }, marks::{Destination as MarkDestination, Mark}, - provision::Arrival, remote::MatchReason, }; use crate::{ HerdrWindow, NavigationTarget, + i18n::fl, menu::Page, progress::{self, Progress}, window::Flash, @@ -159,7 +158,7 @@ impl Teleport { }; let candidate = Candidate { place: place.clone(), - destination: Destination::Arrive(Arrival::Clone { + destination: job::Destination::Arrive(crate::teleport::provision::Arrival::Clone { path: "/tmp/repo".into(), }), origin: None, @@ -281,37 +280,6 @@ impl Teleport { } } -fn reason_text(reason: MatchReason) -> &'static str { - match reason { - MatchReason::Origin => "Same origin remote", - MatchReason::Remote => "Shares a remote", - MatchReason::Name => "Same name only; remotes differ", - } -} - -/// What a destination row says: where the worktree goes, and how. -fn destination_text(candidate: &Candidate) -> (String, String) { - match &candidate.destination { - Destination::Open { repository, reason } => { - (repository.label.clone(), reason_text(*reason).to_owned()) - } - Destination::Reclaim { repository, .. } => ( - repository.label.clone(), - "Brings the work back to the checkout it left".to_owned(), - ), - Destination::Arrive(Arrival::Existing { path, .. }) => { - (path.clone(), "Opens the checkout already there".to_owned()) - } - Destination::Arrive(Arrival::Clone { path }) => ( - path.clone(), - match candidate.origin { - Some(_) => "Clones the repository there first".to_owned(), - None => "Copies the repository there first".to_owned(), - }, - ), - } -} - impl HerdrWindow { pub(crate) fn poll_teleport(&mut self, window: &mut Window, cx: &mut Context) { if self.teleport_marks.poll() { @@ -340,7 +308,10 @@ impl HerdrWindow { Ok(outcome) => outcome, Err(error) if !open => { self.teleport = None; - self.show_flash(Flash::warning(format!("Teleport failed: {error}")), cx); + self.show_flash( + Flash::warning(fl!("teleport-failed", error = error.to_string())), + cx, + ); return; } Err(_) => return cx.notify(), @@ -368,10 +339,12 @@ impl HerdrWindow { }, }); let flash = match outcome.warnings.first() { - None => Flash::success(format!("Teleported to {destination}")), - Some(first) => Flash::warning(format!( - "Teleported to {destination} with {} warning(s): {first}", - outcome.warnings.len() + None => Flash::success(fl!("teleport-done", host = destination.as_str())), + Some(first) => Flash::warning(fl!( + "teleport-done-warnings", + host = destination.as_str(), + count = outcome.warnings.len(), + first = first.as_str() )), }; self.show_flash(flash, cx); @@ -504,9 +477,7 @@ impl HerdrWindow { let (primary, armed) = match &teleport.stage { Stage::Choosing => { if teleport.hosts.is_empty() { - body = body.child(line( - "No other host is saved. Add one from Devices, then try again.".into(), - )); + body = body.child(line(fl!("teleport-no-hosts"))); } for (index, host) in teleport.hosts.iter().enumerate() { let selected = teleport.selected == Some(index); @@ -514,9 +485,9 @@ impl HerdrWindow { // others are reached over SSH once chosen. let connected = host.repositories.is_some(); let detail = if connected { - "Connected" + fl!("teleport-host-connected") } else { - "Not connected; reached over SSH" + fl!("teleport-host-ssh") }; body = body.child( div() @@ -566,60 +537,58 @@ impl HerdrWindow { })), ); } - ("Review", teleport.selected.is_some()) + (fl!("teleport-review"), teleport.selected.is_some()) } Stage::Reviewing(place) => { body = body.child(bar(Progress::Busy)).child( - line(format!( - "Checking {} and the running programs...", - place.label - )) - .text_color(muted), + line(fl!("teleport-checking", host = place.label.as_str())).text_color(muted), ); - ("Teleport", false) + (fl!("teleport-title"), false) } Stage::Ready(candidate, review) => { let state = &review.state; body = body - .child({ - let (title, detail) = destination_text(candidate); - line(format!("To {} · {title}. {detail}.", candidate.place.label)) - }) - .child(line(format!( - "Branch {} · {} unpushed commit(s) · {} changed file(s), {} untracked", - review.branch, state.unpushed, state.changed, state.untracked + .child(line(text::destination(candidate))) + .child(line(fl!( + "teleport-state", + branch = review.branch.as_str(), + unpushed = state.unpushed, + changed = state.changed, + untracked = state.untracked ))); match review.github { GitHubAccess::Direct => {} GitHubAccess::Token => { - body = body.child(line(format!( - "{} cannot reach GitHub by itself, so your GitHub CLI token is installed for this repository there, for git pull and push.", - candidate.place.label + body = body.child(line(fl!( + "teleport-github-token", + host = candidate.place.label.as_str() ))); } GitHubAccess::Unavailable => { body = body.child( - line(format!( - "{} cannot reach GitHub, and the GitHub CLI here is not signed in, so pull and push will not work there.", - candidate.place.label + line(fl!( + "teleport-github-unavailable", + host = candidate.place.label.as_str() )) .text_color(danger), ); } } if review.reason == Some(MatchReason::Name) { - body = body.child( - line( - "Matched by name only: this repository's remotes differ there.".into(), - ) - .text_color(danger), - ); + body = body.child(line(fl!("teleport-name-only")).text_color(danger)); } for (index, tab) in review.tabs.iter().enumerate() { - let title = tab.label.clone().map_or_else( - || format!("Tab {}", index + 1), - |label| format!("Tab {}: {label}", index + 1), - ); + let number = index + 1; + let title = match &tab.label { + None => fl!("teleport-tab", number = number), + Some(label) => { + fl!( + "teleport-tab-labeled", + number = number, + label = label.as_str() + ) + } + }; body = body.child( div() .pt(px(4.)) @@ -633,29 +602,27 @@ impl HerdrWindow { .pl(px(12.)) .truncate() .when(missing, |row| row.text_color(danger)) - .child(describe(&pane.work, &pane.action)), + .child(text::pane(&pane.work, &pane.action)), ); } } - body = body.child(line( - "Ignored files such as .env stay behind. The workspace here then closes; its checkout stays and can be reopened with Open worktree.".into(), - ).text_color(subtext)); - ("Teleport", true) + body = body.child(line(fl!("teleport-left-behind")).text_color(subtext)); + (fl!("teleport-title"), true) } Stage::Moving(candidate, step) => { body = body - .child(line(format!("Moving to {}...", candidate.place.label))) + .child(line(fl!( + "teleport-moving-to", + host = candidate.place.label.as_str() + ))) .child(bar(Progress::Working(step.map_or(0., Step::progress)))) .child( - line(step.map_or("Starting", Step::label).to_owned()) + line(text::step(*step)) .debug_selector(|| "teleport-step".into()) .text_color(muted), ) - .child( - line("Closing this dialog does not stop the move.".into()) - .text_color(subtext), - ); - ("Moving...", false) + .child(line(fl!("teleport-keeps-moving")).text_color(subtext)); + (fl!("teleport-moving"), false) } Stage::Failed(error) => { body = body @@ -664,14 +631,8 @@ impl HerdrWindow { .debug_selector(|| "teleport-error".into()) .text_color(danger), ) - .child( - line( - "The source workspace closes only once the destination is ready." - .into(), - ) - .text_color(subtext), - ); - ("Close", true) + .child(line(fl!("teleport-source-closes-later")).text_color(subtext)); + (fl!("teleport-close"), true) } }; let button = |id: &'static str| { @@ -713,11 +674,12 @@ impl HerdrWindow { div() .text_size(px(font.size * 1.35)) .font_weight(FontWeight::SEMIBOLD) - .child("Teleport"), + .child(fl!("teleport-title")), ) - .child(div().truncate().text_color(muted).child(format!( - "{} from {}", - teleport.label, teleport.source.place.label + .child(div().truncate().text_color(muted).child(fl!( + "teleport-subtitle", + worktree = teleport.label.as_str(), + host = teleport.source.place.label.as_str() ))), ), ) @@ -741,7 +703,11 @@ impl HerdrWindow { button("teleport-cancel") .border_color(rgb(theme.active)) .hover(|button| button.bg(rgb(theme.active))) - .child(if teleport.moving() { "Hide" } else { "Cancel" }) + .child(if teleport.moving() { + fl!("teleport-hide") + } else { + fl!("teleport-cancel") + }) .on_click(cx.listener(|this, _, window, cx| { cx.stop_propagation(); this.dismiss_menu(window, cx); @@ -771,3 +737,7 @@ impl HerdrWindow { ) } } + +#[cfg(test)] +#[allow(clippy::unwrap_used)] +mod tests; diff --git a/crates/herdr-gpui/src/teleport/ui/tests.rs b/crates/herdr-gpui/src/teleport/ui/tests.rs new file mode 100644 index 000000000..16dd1f0c6 --- /dev/null +++ b/crates/herdr-gpui/src/teleport/ui/tests.rs @@ -0,0 +1,124 @@ +//! A Teleport dialog at each stage, with what the stage shows as data. +use super::*; +use crate::teleport::{Host, Node, Repository, SourceState, TabPlan, launch::Work}; +use herdr_client::ConnectTarget; + +mod en_xa; + +fn place(label: &str) -> Place { + Place { + endpoint_id: label.into(), + label: label.into(), + host: Host::new(&ConnectTarget::Local).unwrap(), + } +} + +fn source() -> Source { + Source { + place: place("laptop"), + workspace_id: "w1".into(), + custom_label: None, + repo_key: "repo".into(), + repo_label: "herdr".into(), + branch: Some("feature".into()), + tab_labels: Default::default(), + } +} + +fn candidate() -> Candidate { + Candidate { + place: place("buildbox"), + destination: job::Destination::Open { + repository: Repository { + key: "repo".into(), + label: "herdr".into(), + workspace_id: "w9".into(), + }, + reason: MatchReason::Name, + }, + origin: None, + } +} + +fn review(github: GitHubAccess) -> Review { + let pane = |id: &str, action: Action| job::PanePlan { + pane_id: id.into(), + cwd: None, + work: Work::Shell, + action, + }; + Review { + checkout: "/src/herdr".into(), + branch: "feature".into(), + state: SourceState { + branch: Some("feature".into()), + head: "abc".into(), + unpushed: 1, + changed: 2, + untracked: 3, + }, + tabs: vec![ + TabPlan { + label: None, + tree: Node::Pane("p1".into()), + panes: vec![ + pane("p1", Action::Shell), + pane("p2", Action::Run(vec!["cargo".into(), "test".into()])), + ], + }, + TabPlan { + label: Some("agents".into()), + tree: Node::Pane("p3".into()), + panes: vec![pane("p3", Action::Missing("aider".into()))], + }, + ], + reason: Some(MatchReason::Name), + github, + } +} + +/// The dialog in each stage it shows. +pub(super) fn stages() -> Vec<(&'static str, Teleport)> { + let at = |stage: Stage| { + let mut teleport = Teleport::start(source(), "feature".into(), Vec::new()); + teleport.stage = stage; + teleport + }; + let mut choosing = Teleport::start(source(), "feature".into(), Vec::new()); + choosing.hosts = vec![ + HostRepositories { + place: place("buildbox"), + repositories: Some(Vec::new()), + retired: Vec::new(), + }, + HostRepositories { + place: place("nas"), + repositories: None, + retired: Vec::new(), + }, + ]; + vec![ + ("no hosts", at(Stage::Choosing)), + ("choosing", choosing), + ("reviewing", at(Stage::Reviewing(place("buildbox")))), + ( + "ready, with a token", + at(Stage::Ready( + candidate(), + Box::new(review(GitHubAccess::Token)), + )), + ), + ( + "ready, without GitHub", + at(Stage::Ready( + candidate(), + Box::new(review(GitHubAccess::Unavailable)), + )), + ), + ("starting", at(Stage::Moving(candidate(), None))), + ("moving", at(Stage::Moving(candidate(), Some(Step::Fetch)))), + ] +} + +/// The host and worktree names the stages show. +pub(super) const DATA: &[&str] = &["laptop", "buildbox", "nas", "feature", "herdr"]; diff --git a/crates/herdr-gpui/src/teleport/ui/tests/en_xa.rs b/crates/herdr-gpui/src/teleport/ui/tests/en_xa.rs new file mode 100644 index 000000000..5da099566 --- /dev/null +++ b/crates/herdr-gpui/src/teleport/ui/tests/en_xa.rs @@ -0,0 +1,29 @@ +//! The `en-XA` leak test (`crate::i18n::leak_tests`) for the Teleport +//! dialog at each stage: everything but host, repository, and program names +//! is a message. +use super::*; +use crate::i18n::leak_tests::{assert_translated, draw, under_en_xa}; +use core::prelude::v1::test; + +#[test] +fn every_teleport_stage_shows_only_translated_text_under_en_xa() { + under_en_xa("Teleport en-XA leak", |cx, recorder| { + let data: Vec = DATA.iter().map(|&name| name.to_owned()).collect(); + for (name, teleport) in stages() { + let (view, cx) = cx.add_window_view(crate::sidebar::layout_tests::fixture_window); + cx.simulate_resize(size(px(1200.), px(900.))); + draw(cx); + recorder.take(); + cx.update(|window, cx| { + view.update(cx, |view, cx| { + view.menu.page = Some(Page::Teleport); + view.teleport = Some(teleport); + window.focus(&view.menu.focus, cx); + cx.notify(); + }) + }); + draw(cx); + assert_translated(&format!("Teleport, {name}"), &recorder.take(), &data); + } + }); +} diff --git a/crates/herdr-gpui/src/theme_picker.rs b/crates/herdr-gpui/src/theme_picker.rs index 6d2286d29..dff6d09d7 100644 --- a/crates/herdr-gpui/src/theme_picker.rs +++ b/crates/herdr-gpui/src/theme_picker.rs @@ -1,6 +1,8 @@ +// i18n: migrated use crate::{ HerdrWindow, config::{Theme, ThemeName}, + i18n::fl, menu::Page, search_input::SearchInput, }; @@ -77,6 +79,9 @@ impl HerdrWindow { picker } else { let search = cx.new(SearchInput::new); + search.update(cx, |input, cx| { + input.set_placeholder_with(|| fl!("theme-picker-search"), cx) + }); let subscription = cx.subscribe( &search, |this, search, _: &crate::search_input::Changed, cx| { @@ -463,7 +468,7 @@ impl HerdrWindow { .flex_1() .text_size(px(font.size * 1.35)) .font_weight(FontWeight::SEMIBOLD) - .child("Color Scheme"), + .child(fl!("theme-picker-title")), ) .child( div() @@ -474,17 +479,17 @@ impl HerdrWindow { .cursor_pointer() .rounded(px(crate::config::corners::CONTROL)) .hover(|s| s.bg(rgb(theme.active))) - .child("Close") + .child(fl!("theme-picker-close")) .on_click(cx.listener(|this, _, window, cx| { this.dismiss_menu(window, cx) })), ), ) .child(div().pt(px(12.)).child(picker.search.clone())) - .child(div().pt(px(8.)).text_color(rgb(theme.muted)).child(format!( - "{} of {} themes", - picker.filtered.len(), - picker.names.len() + .child(div().pt(px(8.)).text_color(rgb(theme.muted)).child(fl!( + "theme-picker-count", + shown = picker.filtered.len(), + total = picker.names.len() ))), ) .when(picker.filtered.is_empty(), |panel| { @@ -494,7 +499,7 @@ impl HerdrWindow { .flex_1() .p(px(16.)) .text_color(rgb(theme.muted)) - .child("No matching themes. Try a shorter search."), + .child(fl!("theme-picker-empty")), ) }) .when(!picker.filtered.is_empty(), |panel| { @@ -551,18 +556,15 @@ impl HerdrWindow { row.child( div() .text_color(rgb(this.theme.muted)) - .child("Current"), + .child(fl!("theme-picker-current")), ) }) // The outline shows on the row under // the pointer or keys; a star stays. .when(favorite || selected, |row| { - row.child(this.favorite_star( - index, - &name, - favorite, - cx, - )) + row.child( + this.favorite_star(index, &name, favorite, cx), + ) }) .on_hover(cx.listener(move |this, hovered, _, cx| { if *hovered { @@ -600,14 +602,15 @@ impl HerdrWindow { .border_color(rgb(theme.active)) .text_color(rgb(theme.muted)) .child(if picker.saving { - "Saving theme... Please wait.".to_owned() - } else if let Some(error) = picker.error.as_ref().or(picker.favorites_error.as_ref()) { + fl!("theme-picker-saving") + } else if let Some(error) = + picker.error.as_ref().or(picker.favorites_error.as_ref()) + { error.clone() } else if picker.accepting { - "Loading theme... Esc to cancel.".to_owned() + fl!("theme-picker-loading") } else { - "Hover or Up / Down to preview. Enter or click a theme to save. Star a theme to keep it at the top." - .to_owned() + fl!("theme-picker-hint") }), ) } diff --git a/crates/herdr-gpui/src/theme_picker/favorites.rs b/crates/herdr-gpui/src/theme_picker/favorites.rs index ef9bd4467..533306e11 100644 --- a/crates/herdr-gpui/src/theme_picker/favorites.rs +++ b/crates/herdr-gpui/src/theme_picker/favorites.rs @@ -6,6 +6,7 @@ use crate::{ Config, favorite_themes::{self, FavoriteEdit}, }, + i18n::fl, }; use gpui::{prelude::*, *}; @@ -43,9 +44,9 @@ impl HerdrWindow { .debug_selector(move || format!("theme-star-{index}")) .role(Role::Button) .aria_label(if favorite { - "Unstar theme" + fl!("theme-picker-unstar") } else { - "Star theme" + fl!("theme-picker-star") }) .flex_none() .size(px(22.)) @@ -152,7 +153,10 @@ impl HerdrWindow { } Err(error) => { tracing::warn!(%error, "Could not save favorite themes"); - picker.favorites_error = Some(format!("Could not save favorite themes: {error}")); + picker.favorites_error = Some(fl!( + "theme-picker-favorites-failed", + error = error.to_string() + )); // The next star retries these, unless it clicked the same // theme again; the loop stops so a failing file is not // rewritten on its own. diff --git a/crates/herdr-gpui/src/theme_picker/tests.rs b/crates/herdr-gpui/src/theme_picker/tests.rs index 5d5d2f79e..a649648a8 100644 --- a/crates/herdr-gpui/src/theme_picker/tests.rs +++ b/crates/herdr-gpui/src/theme_picker/tests.rs @@ -2,6 +2,8 @@ use super::*; use core::prelude::v1::test; +mod en_xa; + #[gpui::test] fn saving_blocks_cancel_replacement_and_reload_until_reconciled(cx: &mut TestAppContext) { let (view, cx) = cx.add_window_view(crate::sidebar::layout_tests::fixture_window); diff --git a/crates/herdr-gpui/src/theme_picker/tests/en_xa.rs b/crates/herdr-gpui/src/theme_picker/tests/en_xa.rs new file mode 100644 index 000000000..5f05411c1 --- /dev/null +++ b/crates/herdr-gpui/src/theme_picker/tests/en_xa.rs @@ -0,0 +1,21 @@ +//! The `en-XA` leak test (`crate::i18n::leak_tests`) for the color scheme +//! picker: its frame and status are messages; theme names are data. +use super::*; +use crate::i18n::leak_tests::{assert_translated, draw, under_en_xa}; + +#[test] +fn the_theme_picker_shows_only_translated_text_under_en_xa() { + under_en_xa("theme picker en-XA leak", |cx, recorder| { + let (view, cx) = cx.add_window_view(crate::sidebar::layout_tests::fixture_window); + cx.simulate_resize(size(px(800.), px(600.))); + draw(cx); + recorder.take(); + cx.update(|window, cx| view.update(cx, |view, cx| view.open_theme_picker(window, cx))); + draw(cx); + // Every theme there is may be listed: their names are data. + let names = view.read_with(cx, |view, _| { + view.menu.themes.as_ref().unwrap().names.clone() + }); + assert_translated("theme picker", &recorder.take(), &names); + }); +} diff --git a/crates/herdr-gpui/src/update_panel.rs b/crates/herdr-gpui/src/update_panel.rs index 3382168fa..2a99c3ade 100644 --- a/crates/herdr-gpui/src/update_panel.rs +++ b/crates/herdr-gpui/src/update_panel.rs @@ -1,5 +1,7 @@ +// i18n: migrated use crate::{ APP_VERSION, HerdrWindow, + i18n::fl, menu::Page, progress::{self, Progress}, updater::State, @@ -64,7 +66,11 @@ fn release_notes_section( // own minimum: a smaller section would let them draw over what follows. .min_h(px(font.line_height() * (1. + min_lines) + 4.)) .gap(px(4.)) - .child(div().font_weight(FontWeight::SEMIBOLD).child("What's new")) + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child(fl!("update-panel-whats-new")), + ) .child( div() .relative() @@ -154,9 +160,27 @@ fn update_progress(state: &State, accent: Hsla, track: Hsla) -> Option
{ )) } +/// What an upgrade is doing: the updater's own steps are messages, and a +/// line the package manager printed is data. +fn step(progress: &crate::updater::Progress) -> String { + use crate::updater::Progress; + match progress { + Progress::Approve => fl!("update-panel-step-approve"), + Progress::Asking => fl!("update-panel-step-asking"), + Progress::Checking => fl!("update-panel-step-checking"), + Progress::Homebrew(line) => fl!("update-panel-step-homebrew", line = line.as_str()), + Progress::Package(line) => line.clone(), + Progress::Preparing => fl!("update-panel-step-preparing"), + Progress::Refreshing => fl!("update-panel-step-refreshing"), + Progress::Retrying => fl!("update-panel-step-retrying"), + Progress::Starting => fl!("update-panel-step-starting"), + } +} + /// Decimal megabytes with one place, as browsers and Finder show downloads. fn megabytes(bytes: u64) -> String { - format!("{:.1} MB", bytes as f64 / 1_000_000.) + let amount = format!("{:.1}", bytes as f64 / 1_000_000.); + fl!("update-panel-megabytes", amount = amount) } /// The line under a download's bar: how much has arrived, and the percentage @@ -167,14 +191,24 @@ pub(crate) fn download_caption(state: &State) -> Option<(String, Option) return None; }; if total == 0 { - return Some((format!("{} downloaded", megabytes(received)), None)); + return Some(( + fl!("update-panel-downloaded", received = megabytes(received)), + None, + )); } if received >= total { return None; } Some(( - format!("{} of {}", megabytes(received), megabytes(total)), - Some(format!("{:.0}%", received as f64 / total as f64 * 100.)), + fl!( + "update-panel-progress", + received = megabytes(received), + total = megabytes(total) + ), + Some(fl!( + "update-panel-percent", + percent = format!("{:.0}", received as f64 / total as f64 * 100.) + )), )) } @@ -230,72 +264,62 @@ impl HerdrWindow { let accent = crate::menu::accent(theme); let state = self.update_preview.as_ref().unwrap_or(self.updater.state()); let (message, action) = match state { - State::Disabled(reason) => (format!("In-app updates unavailable: {reason}"), None), - State::Idle => ( - "Check GitHub for a new app release.".into(), - Some(UpdateAction::Check), - ), - State::Checking => ("Checking for updates...".into(), Some(UpdateAction::Cancel)), - State::Current => ( - "You are running the latest available release.".into(), - Some(UpdateAction::Check), - ), - State::Available { .. } => ( - "A new app release is available.".into(), - Some(UpdateAction::Download), - ), + State::Disabled(reason) => { + (fl!("update-panel-disabled", reason = reason.as_str()), None) + } + State::Idle => (fl!("update-panel-idle"), Some(UpdateAction::Check)), + State::Checking => (fl!("update-panel-checking"), Some(UpdateAction::Cancel)), + State::Current => (fl!("update-panel-current"), Some(UpdateAction::Check)), + State::Available { .. } => { + (fl!("update-panel-available"), Some(UpdateAction::Download)) + } State::Downloading { received, total } => ( if *total > 0 && received >= total { - "Download complete. Verifying the update...".into() + fl!("update-panel-verifying") } else { self.updater.latest_version().map_or_else( - || "Downloading the update".into(), - |version| format!("Downloading {version}"), + || fl!("update-panel-downloading"), + |version| fl!("update-panel-downloading-version", version = version), ) }, Some(UpdateAction::Cancel), ), State::Ready { .. } => ( if cfg!(windows) { - "Download and publisher signature verified. Install when you are ready: Herdr UI quits, its installer runs, and it reopens; your daemon and terminal sessions stay running." + fl!("update-panel-ready-windows") } else { - "Download verified. Install when you are ready; system packages may ask for administrator authentication." - } - .into(), + fl!("update-panel-ready") + }, Some(UpdateAction::Install), ), - State::Installing => ( - "Preparing installation and restart. Herdr will quit when the update is ready." - .into(), - Some(UpdateAction::Cancel), - ), - State::Homebrew { .. } => ( - "A new app release is available. Homebrew will install it, refreshing its package metadata if needed.".into(), - Some(UpdateAction::Upgrade), - ), + State::Installing => (fl!("update-panel-installing"), Some(UpdateAction::Cancel)), + State::Homebrew { .. } => (fl!("update-panel-homebrew"), Some(UpdateAction::Upgrade)), State::Upgrading { detail } => ( - detail.clone(), + step(detail), // Homebrew is never interrupted mid-upgrade: see updater::cancel. None, ), State::Restart { version } => ( - format!("Installed {version}. Restart to finish; your daemon and terminal sessions stay running."), + fl!("update-panel-restart-needed", version = version.as_str()), Some(UpdateAction::Restart), ), - State::Restarting => ("Starting the updated app...".into(), None), - State::Cancelling => ( - "Cancelling update... Waiting for current I/O to finish or time out.".into(), - None, + State::Restarting => (fl!("update-panel-restarting"), None), + State::Cancelling => (fl!("update-panel-cancelling"), None), + State::Error(error) => ( + fl!("update-panel-failed", error = error.to_string()), + Some(UpdateAction::Check), ), - State::Error(error) => (format!("Update failed: {error}"), Some(UpdateAction::Check)), }; let latest = match state { State::Available { version, .. } | State::Ready { version } | State::Homebrew { version, .. } - | State::Restart { version } => version.as_str(), - State::Current => APP_VERSION, - _ => self.updater.latest_version().unwrap_or("Not yet known"), + | State::Restart { version } => version.clone(), + State::Current => APP_VERSION.to_owned(), + _ => self + .updater + .latest_version() + .map_or_else(|| fl!("update-panel-not-known"), str::to_owned), }; // Notes are shown while an update is on offer, not once it is under way. let notes = match state { @@ -344,9 +368,13 @@ impl HerdrWindow { div() .text_size(px(font.size * 1.35)) .font_weight(FontWeight::SEMIBOLD) - .child("App Updates"), + .child(fl!("update-panel-title")), ) - .child(div().text_color(rgb(theme.muted)).child("Updates from GitHub")), + .child( + div() + .text_color(rgb(theme.muted)) + .child(fl!("update-panel-subtitle")), + ), ) .child( div() @@ -359,7 +387,7 @@ impl HerdrWindow { .rounded(px(crate::config::corners::CONTROL)) .text_color(rgb(theme.muted)) .hover(|s| s.bg(rgb(theme.active)).text_color(rgb(theme.foreground))) - .child("Close") + .child(fl!("update-panel-close")) .on_click(cx.listener(|this, _, window, cx| { cx.stop_propagation(); this.dismiss_menu(window, cx); @@ -377,65 +405,95 @@ impl HerdrWindow { .p(px(16.)) .gap(px(16.)) .child( - div() - .flex() - .flex_col() - .gap(px(8.)) - .children([ - ("Current version", APP_VERSION, "app-update-current-version"), - ("Latest version", latest, "app-update-latest-version"), - ].into_iter().map(|(label, value, selector)| { + div().flex().flex_col().gap(px(8.)).children( + [ + ( + fl!("update-panel-current-version"), + APP_VERSION.to_owned(), + "app-update-current-version", + ), + ( + fl!("update-panel-latest-version"), + latest, + "app-update-latest-version", + ), + ] + .into_iter() + .map(|(label, value, selector)| { div() .flex() .items_center() .gap(px(16.)) - .child(div().flex_none().w(px(font.size * 8.)).text_color(rgb(theme.muted)).child(label)) - .child(div().debug_selector(move || selector.into()).flex_1().min_w_0().truncate().child(value.to_owned())) - })), + .child( + div() + .flex_none() + .w(px(font.size * 8.)) + .text_color(rgb(theme.muted)) + .child(label), + ) + .child( + div() + .debug_selector(move || selector.into()) + .flex_1() + .min_w_0() + .truncate() + .child(value), + ) + }), + ), ) .child(div().font_weight(FontWeight::SEMIBOLD).child(message)) // Only when there is something to read: a release published // without notes leaves the dialog exactly as it was. - .when_some(notes.filter(|notes| !notes.trim().is_empty()), |body, notes| { - body.child(release_notes_section( - &self.app_update_notes, - &self.app_update_notes_scroll, - notes, - theme, - font, - &self.config.terminal, - )) - }) - .children(update_progress(state, accent.into(), rgb(theme.active).into()).map( - |bar| { - div().flex().flex_col().gap(px(8.)).child(bar).when_some( - download_caption(state), - |progress, (sizes, percent)| { - progress.child( - div() - .debug_selector(|| "app-update-download-size".into()) - .flex() - .justify_between() - .gap(px(12.)) - .text_size(px(font.size - 1.)) - .text_color(rgb(theme.subtext())) - .child(div().min_w_0().truncate().child(sizes)) - .children(percent.map(|percent| { - div() - .debug_selector(|| { - "app-update-download-percent".into() - }) - .flex_none() - .child(percent) - })), - ) - }, - ) + .when_some( + notes.filter(|notes| !notes.trim().is_empty()), + |body, notes| { + body.child(release_notes_section( + &self.app_update_notes, + &self.app_update_notes_scroll, + notes, + theme, + font, + &self.config.terminal, + )) }, - )) - .child(div().text_color(rgb(theme.subtext())).child( - "Downloads are verified before installation. Your daemon and terminal sessions stay running.", - )) + ) + .children( + update_progress(state, accent.into(), rgb(theme.active).into()).map( + |bar| { + div().flex().flex_col().gap(px(8.)).child(bar).when_some( + download_caption(state), + |progress, (sizes, percent)| { + progress.child( + div() + .debug_selector(|| { + "app-update-download-size".into() + }) + .flex() + .justify_between() + .gap(px(12.)) + .text_size(px(font.size - 1.)) + .text_color(rgb(theme.subtext())) + .child(div().min_w_0().truncate().child(sizes)) + .children(percent.map(|percent| { + div() + .debug_selector(|| { + "app-update-download-percent".into() + }) + .flex_none() + .child(percent) + })), + ) + }, + ) + }, + ), + ) + .child( + div() + .text_color(rgb(theme.subtext())) + .child(fl!("update-panel-verified-note")), + ) .when(self.update_preview.is_some(), |body| { body.child( div() @@ -443,10 +501,17 @@ impl HerdrWindow { .p(px(12.)) .rounded(px(crate::config::corners::CONTROL)) .bg(rgb(theme.background)) - .child(div().font_weight(FontWeight::SEMIBOLD).child("QA preview")) - .child(div().pt(px(4.)).text_color(rgb(theme.subtext())).child( - "Synthetic update state only. No network or installation is performed. Close or Escape dismisses this preview.", - )), + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child(fl!("update-panel-preview")), + ) + .child( + div() + .pt(px(4.)) + .text_color(rgb(theme.subtext())) + .child(fl!("update-panel-preview-note")), + ), ) }), ); @@ -466,7 +531,7 @@ impl HerdrWindow { .cursor_pointer() .text_color(rgb(theme.muted)) .hover(|s| s.bg(rgb(theme.active)).text_color(rgb(theme.foreground))) - .child("Manual Releases") + .child(fl!("update-panel-manual-releases")) .on_click(|_, _, cx| { cx.stop_propagation(); cx.open_url(crate::updater::RELEASES_PAGE); @@ -483,7 +548,7 @@ impl HerdrWindow { .cursor_pointer() .text_color(rgb(theme.muted)) .hover(|s| s.bg(rgb(theme.active)).text_color(rgb(theme.foreground))) - .child("Release Notes") + .child(fl!("update-panel-release-notes")) .on_click(move |_, _, cx| { cx.stop_propagation(); cx.open_url(&url); @@ -492,13 +557,15 @@ impl HerdrWindow { }); if let Some(action) = action { let label = match action { - UpdateAction::Check if matches!(state, State::Error(_)) => "Retry", - UpdateAction::Check => "Check for Updates", - UpdateAction::Download => "Download", - UpdateAction::Install => "Install Update", - UpdateAction::Upgrade => "Update with Homebrew", - UpdateAction::Restart => "Restart", - UpdateAction::Cancel => "Cancel", + UpdateAction::Check if matches!(state, State::Error(_)) => { + fl!("update-panel-retry") + } + UpdateAction::Check => fl!("update-panel-check"), + UpdateAction::Download => fl!("update-panel-download"), + UpdateAction::Install => fl!("update-panel-install"), + UpdateAction::Upgrade => fl!("update-panel-upgrade"), + UpdateAction::Restart => fl!("update-panel-restart"), + UpdateAction::Cancel => fl!("update-panel-cancel"), }; buttons = buttons.child( div() @@ -563,3 +630,6 @@ impl HerdrWindow { ) } } + +#[cfg(test)] +mod tests; diff --git a/crates/herdr-gpui/src/update_panel/tests.rs b/crates/herdr-gpui/src/update_panel/tests.rs new file mode 100644 index 000000000..da3980686 --- /dev/null +++ b/crates/herdr-gpui/src/update_panel/tests.rs @@ -0,0 +1 @@ +mod en_xa; diff --git a/crates/herdr-gpui/src/update_panel/tests/en_xa.rs b/crates/herdr-gpui/src/update_panel/tests/en_xa.rs new file mode 100644 index 000000000..e10106ca7 --- /dev/null +++ b/crates/herdr-gpui/src/update_panel/tests/en_xa.rs @@ -0,0 +1,89 @@ +//! The `en-XA` leak test (`crate::i18n::leak_tests`) for App Updates in +//! each state: versions, the release notes, which come from GitHub, and the +//! lines a package manager prints are data. +#![allow(clippy::unwrap_used)] + +use crate::i18n::leak_tests::{assert_translated, draw, under_en_xa}; +use crate::updater::{PREVIEW_NOTES, Progress, State}; +use crate::{APP_VERSION, release_notes}; +use gpui::{px, size}; + +/// A line Homebrew or a system package printed. +const OUTPUT: &str = "==> Downloading herdr-gpui"; + +fn states() -> Vec { + let version = || "9999.0.0".to_owned(); + let upgrading = [ + Progress::Approve, + Progress::Asking, + Progress::Checking, + Progress::Homebrew(OUTPUT.into()), + Progress::Package(OUTPUT.into()), + Progress::Preparing, + Progress::Refreshing, + Progress::Retrying, + Progress::Starting, + ] + .into_iter() + .map(|detail| State::Upgrading { detail }); + let mut states = vec![ + State::Disabled("Local builds never check.".into()), + State::Idle, + State::Checking, + State::Current, + State::Available { + version: version(), + notes: PREVIEW_NOTES.into(), + }, + State::Downloading { + received: 25_000_000, + total: 100_000_000, + }, + State::Downloading { + received: 25_000_000, + total: 0, + }, + State::Ready { version: version() }, + State::Installing, + State::Homebrew { + version: version(), + notes: String::new(), + }, + State::Restart { version: version() }, + State::Restarting, + State::Cancelling, + ]; + states.extend(upgrading); + states +} + +#[test] +fn every_update_state_shows_only_translated_text_under_en_xa() { + under_en_xa("App Updates en-XA leak", |cx, recorder| { + let mut data: Vec = release_notes::parse(PREVIEW_NOTES) + .into_iter() + .map(|line| line.text) + .collect(); + data.extend([ + APP_VERSION.to_owned(), + "9999.0.0".to_owned(), + OUTPUT.to_owned(), + ]); + for state in states() { + let (view, cx) = cx.add_window_view(crate::sidebar::layout_tests::fixture_window); + cx.simulate_resize(size(px(1200.), px(1600.))); + draw(cx); + recorder.take(); + let name = format!("App Updates, {state:?}"); + cx.update(|window, cx| { + view.update(cx, |view, cx| { + view.open_app_update(false, window, cx); + view.update_preview = Some(state); + cx.notify(); + }) + }); + draw(cx); + assert_translated(&name, &recorder.take(), &data); + } + }); +} diff --git a/crates/herdr-gpui/src/window/render.rs b/crates/herdr-gpui/src/window/render.rs index 2156aa4ac..bc69e9037 100644 --- a/crates/herdr-gpui/src/window/render.rs +++ b/crates/herdr-gpui/src/window/render.rs @@ -863,7 +863,7 @@ impl Render for HerdrWindow { |this, _: &crate::actions::ShowUpdateHomebrewPreview, window, cx| { this.open_update_progress_preview( crate::updater::State::Upgrading { - detail: "Refreshing Homebrew metadata with brew update...".into(), + detail: crate::updater::Progress::Refreshing, }, window, cx, diff --git a/crates/herdr-remote/src/teleport.rs b/crates/herdr-remote/src/teleport.rs index 760074a1c..5d76cd9a8 100644 --- a/crates/herdr-remote/src/teleport.rs +++ b/crates/herdr-remote/src/teleport.rs @@ -38,7 +38,12 @@ pub use { }, }; #[cfg(any(test, feature = "test-support"))] -pub use {job::Repository, marks::Destination as MarkDestination}; +pub use { + git::SourceState, + job::{Repository, plan::TabPlan}, + layout::Node, + marks::Destination as MarkDestination, +}; /// The host Teleport, fan-out, and dispatch script for an endpoint, if they /// can: each drives the endpoint's session through `herdr_cli`, which cannot diff --git a/crates/herdr-updater/src/brew.rs b/crates/herdr-updater/src/brew.rs index 73abb2d2d..71c3d6ba7 100644 --- a/crates/herdr-updater/src/brew.rs +++ b/crates/herdr-updater/src/brew.rs @@ -13,6 +13,7 @@ //! upgrade lands, and the upgrade itself is never interrupted: killing Homebrew //! mid-move can leave no installed app at all. +use super::Progress; use super::error::{Error, Result}; use super::release; use std::{ @@ -304,7 +305,7 @@ pub(super) fn upgrade( current: &str, expected: &str, cancel: &AtomicBool, - mut progress: impl FnMut(String), + mut progress: impl FnMut(Progress), ) -> Result { if cancel.load(Ordering::Acquire) { return Err(Error::Cancelled); @@ -316,9 +317,9 @@ pub(super) fn upgrade( let mut refreshed = false; loop { progress(if refreshed { - "Retrying Homebrew cask upgrade after refresh...".to_owned() + Progress::Retrying } else { - "Asking Homebrew to upgrade the cask...".to_owned() + Progress::Asking }); if !refreshed && cancel.load(Ordering::Acquire) { return Err(Error::Cancelled); @@ -330,8 +331,10 @@ pub(super) fn upgrade( upgrade.env("HOMEBREW_NO_AUTO_UPDATE", "1"); } upgrade.args(["upgrade", "--cask", TOKEN]); - run(upgrade, remaining(), None, &mut progress)?; - progress("Checking the installed Homebrew cask version...".to_owned()); + run(upgrade, remaining(), None, &mut |line| { + progress(Progress::Homebrew(line)); + })?; + progress(Progress::Checking); let installed = installed(cask, remaining())?; let new = release::parse_version(&installed).ok_or(Error::BrewVersion)?; if new > old && new >= offered { @@ -346,10 +349,12 @@ pub(super) fn upgrade( } // A successful upgrade can still use cached metadata or a tap that has // not published the offer yet. Command failures never reach this retry. - progress("Refreshing Homebrew metadata with brew update...".to_owned()); + progress(Progress::Refreshing); let mut update = command(cask); update.arg("update"); - run(update, remaining(), None, &mut progress)?; + run(update, remaining(), None, &mut |line| { + progress(Progress::Homebrew(line)); + })?; refreshed = true; } } diff --git a/crates/herdr-updater/src/brew/tests.rs b/crates/herdr-updater/src/brew/tests.rs index 980ea4164..89a32a4fe 100644 --- a/crates/herdr-updater/src/brew/tests.rs +++ b/crates/herdr-updater/src/brew/tests.rs @@ -327,13 +327,13 @@ fn stale_or_intermediate_versions_refresh_and_retry_once() -> anyhow::Result<()> assert_eq!( progress, [ - "Asking Homebrew to upgrade the cask...", - "upgrade output", - "Checking the installed Homebrew cask version...", - "Refreshing Homebrew metadata with brew update...", - "Retrying Homebrew cask upgrade after refresh...", - "upgrade output", - "Checking the installed Homebrew cask version...", + Progress::Asking, + Progress::Homebrew("upgrade output".into()), + Progress::Checking, + Progress::Refreshing, + Progress::Retrying, + Progress::Homebrew("upgrade output".into()), + Progress::Checking, ] ); } @@ -407,7 +407,7 @@ fn cancellation_before_spawn_runs_nothing_but_mutation_is_not_interrupted() -> a let cancel = AtomicBool::new(false); assert_eq!( upgrade(&cask, "20260921.1", "20260921.3", &cancel, |line| { - if line == "upgrade output" { + if line == Progress::Homebrew("upgrade output".into()) { cancel.store(true, Ordering::Release); } })?, diff --git a/crates/herdr-updater/src/lib.rs b/crates/herdr-updater/src/lib.rs index 25e8123b7..83ef4c792 100644 --- a/crates/herdr-updater/src/lib.rs +++ b/crates/herdr-updater/src/lib.rs @@ -8,9 +8,11 @@ mod channel; mod error; +mod progress; pub use channel::UpdateChannel; pub use error::{Error, Result}; pub use error::{quota_exhausted, rate_limit_reset, retry_hint}; +pub use progress::Progress; #[cfg(unix)] mod brew; #[cfg(unix)] @@ -90,7 +92,7 @@ pub enum State { notes: String, }, Upgrading { - detail: String, + detail: Progress, }, Restart { version: String, @@ -387,7 +389,7 @@ impl Updater { self.send( Operation::Install, State::Upgrading { - detail: "Preparing installation...".into(), + detail: Progress::Preparing, }, ); } @@ -398,7 +400,7 @@ impl Updater { self.send( Operation::Upgrade, State::Upgrading { - detail: "Starting Homebrew...".into(), + detail: Progress::Starting, }, ); } @@ -685,14 +687,7 @@ fn worker( &offer.manifest.version, &cancelled, |detail| { - publish( - &mailbox, - generation, - State::Upgrading { - detail: format!("Homebrew: {detail}"), - }, - None, - ); + publish(&mailbox, generation, State::Upgrading { detail }, None); }, ) .map(|version| { diff --git a/crates/herdr-updater/src/linux.rs b/crates/herdr-updater/src/linux.rs index 593642da8..ebda278a2 100644 --- a/crates/herdr-updater/src/linux.rs +++ b/crates/herdr-updater/src/linux.rs @@ -1,6 +1,7 @@ //! Native Linux packages are installed by their owning package manager. All //! queries, downloads, authentication prompts and process waits run on the worker. use super::{ + Progress, error::{Error, Result}, install, release::{self, Format}, @@ -281,7 +282,7 @@ fn installed_version(format: Format, cancel: &AtomicBool) -> Result { pub(super) fn install( mut prepared: Prepared, cancel: &AtomicBool, - progress: impl FnMut(String), + mut progress: impl FnMut(Progress), ) -> Result { if cancel.load(Ordering::Acquire) { return Err(Error::Cancelled); @@ -299,7 +300,8 @@ pub(super) fn install( .arg(helper::FLAG) .arg(prepared.stage.path()) .stdin(Stdio::null()); - if let Err(error) = transact(command, progress) { + // The helper prints its progress, so its own steps come back as lines. + if let Err(error) = transact(command, |line| progress(Progress::from_helper(line))) { if matches!(error, Error::PackageCommand { .. }) { prepared.stage.disable_cleanup(false); } @@ -314,7 +316,7 @@ fn apply( stage: &Path, key: &str, cancel: &AtomicBool, - mut progress: impl FnMut(String), + mut progress: impl FnMut(Progress), ) -> Result<()> { if locate(&package.executable, |command| { install::output(command, cancel) @@ -336,8 +338,10 @@ fn apply( if !Path::new("/usr/bin/pkexec").is_file() { return Err(Error::MissingPolicyKit); } - progress("Approve the system authentication prompt to install the package...".into()); - transact(helper::elevated_command(stage)?, &mut progress)?; + progress(Progress::Approve); + transact(helper::elevated_command(stage)?, |line| { + progress(Progress::Package(line)); + })?; // Ignore cancellation after the transaction starts, including GUI teardown. let uncancelled = AtomicBool::new(false); let version = installed_version(package.format, &uncancelled)?; diff --git a/crates/herdr-updater/src/progress.rs b/crates/herdr-updater/src/progress.rs new file mode 100644 index 000000000..8826ed286 --- /dev/null +++ b/crates/herdr-updater/src/progress.rs @@ -0,0 +1,61 @@ +//! What an upgrade in progress is doing. The updater's own steps are +//! variants, so the app can word them in the user's language; a line a +//! package manager printed is data and is shown as it came. + +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Progress { + /// Linux: the package transaction waits for the system's + /// authentication prompt. + Approve, + /// Homebrew is asked to upgrade the cask, the first time. + Asking, + /// Homebrew's installed version is read back. + Checking, + /// A line Homebrew printed, bounded and stripped of control bytes. + Homebrew(String), + /// A line the system package transaction printed, bounded and stripped + /// of control bytes. + Package(String), + /// The release is being installed, before the package manager starts. + Preparing, + /// Homebrew's metadata is refreshed with `brew update`. + Refreshing, + /// Homebrew is asked again after the refresh. + Retrying, + /// Homebrew has been asked to start, before its first line. + Starting, +} + +impl Progress { + /// A line the Linux install helper printed: the helper reports its + /// steps through [`fmt::Display`], so the one it names is parsed back. + #[cfg(any(target_os = "linux", test))] + pub(crate) fn from_helper(line: String) -> Self { + if line == Self::Approve.to_string() { + Self::Approve + } else { + Self::Package(line) + } + } +} + +/// English, for the command line and the log. +impl fmt::Display for Progress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Approve => { + f.write_str("Approve the system authentication prompt to install the package...") + } + Self::Asking => f.write_str("Asking Homebrew to upgrade the cask..."), + Self::Checking => f.write_str("Checking the installed Homebrew cask version..."), + Self::Homebrew(line) => write!(f, "Homebrew: {line}"), + Self::Package(line) => f.write_str(line), + Self::Preparing => f.write_str("Preparing installation..."), + Self::Refreshing => f.write_str("Refreshing Homebrew metadata with brew update..."), + Self::Retrying => f.write_str("Retrying Homebrew cask upgrade after refresh..."), + Self::Starting => f.write_str("Starting Homebrew..."), + } + } +} diff --git a/crates/herdr-updater/src/tests.rs b/crates/herdr-updater/src/tests.rs index cbadfe103..7ab5beca9 100644 --- a/crates/herdr-updater/src/tests.rs +++ b/crates/herdr-updater/src/tests.rs @@ -3,13 +3,14 @@ use anyhow::Context as _; mod channels; mod packages; +mod progress; #[test] fn homebrew_work_is_never_cancelled_and_a_relaunch_only_quits() -> Result<()> { let mut updater = Updater::default(); for state in [ State::Upgrading { - detail: "==> Downloading".into(), + detail: Progress::Homebrew("==> Downloading".into()), }, State::Restarting, ] { @@ -49,7 +50,7 @@ fn only_a_waiting_release_marks_an_update_available() { }, State::Installing, State::Upgrading { - detail: String::new(), + detail: Progress::Preparing, }, State::Restarting, State::Cancelling, diff --git a/crates/herdr-updater/src/tests/progress.rs b/crates/herdr-updater/src/tests/progress.rs new file mode 100644 index 000000000..a5144f130 --- /dev/null +++ b/crates/herdr-updater/src/tests/progress.rs @@ -0,0 +1,17 @@ +use super::*; + +#[test] +fn the_helper_s_own_step_comes_back_typed_and_package_lines_stay_data() { + assert_eq!( + Progress::from_helper(Progress::Approve.to_string()), + Progress::Approve + ); + assert_eq!( + Progress::from_helper("Unpacking herdr-gpui ...".into()), + Progress::Package("Unpacking herdr-gpui ...".into()) + ); + assert_eq!( + Progress::Homebrew("==> Downloading".into()).to_string(), + "Homebrew: ==> Downloading" + ); +} diff --git a/crates/herdr-updater/src/unsupported.rs b/crates/herdr-updater/src/unsupported.rs index 55a7b38b4..0b1cbdbac 100644 --- a/crates/herdr-updater/src/unsupported.rs +++ b/crates/herdr-updater/src/unsupported.rs @@ -21,7 +21,7 @@ pub(super) mod brew { _current: &str, _expected: &str, _cancel: &AtomicBool, - _progress: impl FnMut(String), + _progress: impl FnMut(crate::Progress), ) -> Result { Err(Error::UnsupportedTarget) } diff --git a/docs/i18n.md b/docs/i18n.md index bf7209cd3..c3e8e52a0 100644 --- a/docs/i18n.md +++ b/docs/i18n.md @@ -127,9 +127,10 @@ Each window keeps an `en-XA` leak test in its module's `tests/en_xa.rs`: `i18n::leak_tests::under_en_xa` opens the surfaces, `draw` records each line GPUI shapes, and `assert_translated` fails on a line without the pseudo-locale's brackets. Shortcut keys and data such as user names are -passed as allowed. Today the palette, every Settings page, every Cloud -devices card, and the menu bar have one; a module migrated later adds its -own. +passed as allowed. Today the palette (Commands and Go To), every Settings +page, every Cloud devices card, the menu bar, the log window, the theme +picker, code tabs, App Updates, and each stage of Teleport have one; a +module migrated later adds its own. ## Adding a language @@ -207,9 +208,11 @@ glyphs in the wrong order. See [Right to left](#right-to-left). Phase 2's string migration is not finished. Merged: the command catalog, the palette's own text (#874), the Settings window and the menu bar (#880), -the language picker (Phase 5), and the tab, pane, and group menus with the -close confirmation and the dialogs' text field. Most of the app still shows -English literals: +the language picker (Phase 5), the tab, pane, and group menus with the +close confirmation and the dialogs' text field, and the code view and +editor, the log window, the theme picker, App Updates and its release +notes, Teleport's dialog, and the palette's Go To and project rows and +badges. Most of the app still shows English literals: - Dialogs and popover menus in `menu/` (about 700 literals): the worktree popover and its dialogs, devices, Git and pull requests, sessions, and @@ -222,9 +225,7 @@ English literals: requests, issues, Security, Review, Usage, Automations (`overview/`, `orchestrator/`, `pull_request/`, `security/`, `review/`, `usage/`, `automations/`), and the devices overview. -- Chats (`chat/`), the browser (`browser/`), the code view and editor, the - log window, the theme picker, release notes, Teleport, and the palette's - Go To rows and badges. +- Chats (`chat/`) and the browser (`browser/`). - Localized error messages. Errors still reach the window as an `$error` placeable holding their English `Display`. The plan is a localized message per user-facing error enum, chosen by variant at the