mirror of
https://github.com/Homebrew/BrewUI.git
synced 2026-10-02 00:24:33 +08:00
Localise BrewUIComponents copy
Every literal in the shared components now goes through String(localized:bundle:comment:) or Text(verbatim:), and the target's catalog is synced from the compiler's extraction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
294b922c76
commit
35ea43d3db
@@ -9,7 +9,7 @@ import Foundation
|
||||
/// the UI-free `BrewConfigSnapshot` never carries section labels (`AGENTS.md` — presentation boundary).
|
||||
struct ConfigSectionItem: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let title: LocalizedStringResource
|
||||
let rows: [ConfigDisplayRow]
|
||||
}
|
||||
|
||||
|
||||
@@ -15,9 +15,27 @@ extension ConfigViewModel {
|
||||
/// `HOMEBREW_*` Environment surface is handled by the editor card and intentionally absent here.
|
||||
func sections(for snapshot: BrewConfigSnapshot) -> [ConfigSectionItem] {
|
||||
var result: [ConfigSectionItem] = []
|
||||
appendIfNonEmpty(&result, id: "homebrew", title: "Homebrew", group: .homebrew, in: snapshot.entries)
|
||||
appendIfNonEmpty(&result, id: "system", title: "System", group: .system, in: snapshot.entries)
|
||||
appendIfNonEmpty(&result, id: "build", title: "Build settings", group: .build, in: snapshot.entries)
|
||||
appendIfNonEmpty(
|
||||
&result,
|
||||
id: "homebrew",
|
||||
title: LocalizedStringResource("Homebrew", bundle: #bundle, comment: "Configuration card title: brew version, prefix, taps"),
|
||||
group: .homebrew,
|
||||
in: snapshot.entries,
|
||||
)
|
||||
appendIfNonEmpty(
|
||||
&result,
|
||||
id: "system",
|
||||
title: LocalizedStringResource("System", bundle: #bundle, comment: "Configuration card title: macOS, CPU, Xcode"),
|
||||
group: .system,
|
||||
in: snapshot.entries,
|
||||
)
|
||||
appendIfNonEmpty(
|
||||
&result,
|
||||
id: "build",
|
||||
title: LocalizedStringResource("Build settings", bundle: #bundle, comment: "Configuration card title: HOMEBREW_* build options"),
|
||||
group: .build,
|
||||
in: snapshot.entries,
|
||||
)
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -47,10 +65,11 @@ extension ConfigViewModel {
|
||||
}
|
||||
|
||||
private func sectionReport(for section: ConfigSectionItem) -> String {
|
||||
let title = String(localized: section.title)
|
||||
let body = section.rows
|
||||
.map { "\($0.label): \($0.value)" }
|
||||
.joined(separator: "\n")
|
||||
return body.isEmpty ? "\(section.title)\n(none)" : "\(section.title)\n\(body)"
|
||||
return body.isEmpty ? "\(title)\n(none)" : "\(title)\n\(body)"
|
||||
}
|
||||
|
||||
/// `brew` could not be located — the view shows a dedicated empty state rather than the error chrome.
|
||||
@@ -82,7 +101,7 @@ extension ConfigViewModel {
|
||||
private func appendIfNonEmpty(
|
||||
_ result: inout [ConfigSectionItem],
|
||||
id: String,
|
||||
title: String,
|
||||
title: LocalizedStringResource,
|
||||
group: ConfigGroup,
|
||||
in entries: [BrewConfigEntry],
|
||||
) {
|
||||
|
||||
@@ -29,8 +29,8 @@ struct ConsoleToolbar: View {
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Hide console")
|
||||
.accessibilityLabel("Hide console")
|
||||
.help(hideLabel)
|
||||
.accessibilityLabel(Text(hideLabel))
|
||||
.axid(.consoleToggle)
|
||||
}
|
||||
.padding(.horizontal, BrewSpacing.md)
|
||||
@@ -38,12 +38,16 @@ struct ConsoleToolbar: View {
|
||||
.background(Color.brewSurface)
|
||||
}
|
||||
|
||||
private var hideLabel: LocalizedStringResource {
|
||||
LocalizedStringResource("Hide console", bundle: #bundle, comment: "Console toolbar: collapse button")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var jobPills: some View {
|
||||
let selectedID = viewModel.selectedJob?.id
|
||||
let orderedJobs = viewModel.orderedJobs
|
||||
if orderedJobs.isEmpty {
|
||||
Text("No activity")
|
||||
Text("No activity", bundle: #bundle, comment: "Console toolbar title when nothing has run")
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
.padding(.leading, BrewSpacing.xs)
|
||||
@@ -67,23 +71,27 @@ struct ConsoleToolbar: View {
|
||||
private var actionButtons: some View {
|
||||
if let job = viewModel.selectedJob {
|
||||
// Saving ends in a save panel, which is its own confirmation.
|
||||
BrewActionButton("Save", systemImage: "square.and.arrow.down", help: "Save output to file") {
|
||||
BrewActionButton(
|
||||
LocalizedStringResource("Save", bundle: #bundle, comment: "Console toolbar: save output to a file"),
|
||||
systemImage: "square.and.arrow.down",
|
||||
help: LocalizedStringResource("Save output to file", bundle: #bundle, comment: "Tooltip: console save button"),
|
||||
) {
|
||||
ConsoleOutputExport.save(job)
|
||||
}
|
||||
BrewActionButton(
|
||||
"Copy",
|
||||
LocalizedStringResource("Copy", bundle: #bundle, comment: "Console toolbar: copy output"),
|
||||
systemImage: "doc.on.doc",
|
||||
confirmationTitle: "Copied",
|
||||
help: "Copy output to clipboard",
|
||||
confirmationTitle: LocalizedStringResource("Copied", bundle: #bundle, comment: "Console toolbar: shown briefly after copying"),
|
||||
help: LocalizedStringResource("Copy output to clipboard", bundle: #bundle, comment: "Tooltip: console copy button"),
|
||||
) {
|
||||
ConsoleOutputExport.copy(job)
|
||||
}
|
||||
}
|
||||
BrewActionButton(
|
||||
"Clear",
|
||||
LocalizedStringResource("Clear", bundle: #bundle, comment: "Console toolbar: remove finished jobs"),
|
||||
systemImage: "trash",
|
||||
confirmationTitle: "Cleared",
|
||||
help: "Clear completed jobs",
|
||||
confirmationTitle: LocalizedStringResource("Cleared", bundle: #bundle, comment: "Console toolbar: shown briefly after clearing"),
|
||||
help: LocalizedStringResource("Clear completed jobs", bundle: #bundle, comment: "Tooltip: console clear button"),
|
||||
) {
|
||||
viewModel.clearCompleted()
|
||||
}
|
||||
@@ -123,7 +131,7 @@ private struct JobPill: View {
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Dismiss")
|
||||
.help(String(localized: "Dismiss", bundle: #bundle, comment: "Tooltip: close a finished job tab"))
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, BrewSpacing.sm)
|
||||
|
||||
@@ -139,27 +139,31 @@ private struct DiscoverPackageDetailMetadataSection: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
|
||||
PackageDetailSectionHeading(title: "Details")
|
||||
PackageDetailSectionHeading(title: LocalizedStringResource("Details", bundle: #bundle, comment: "Package detail section heading"))
|
||||
if let installedVersion = viewModel.installedVersionLabel {
|
||||
detailRow(
|
||||
label: "Installed",
|
||||
label: String(localized: "Installed", bundle: #bundle, comment: "Package detail row: installed version(s)"),
|
||||
value: installedVersion,
|
||||
valueColor: viewModel.isInstalledVersionOutdated ? .brewStatusWarning : .brewTextPrimary,
|
||||
valueFontWeight: .heavy,
|
||||
)
|
||||
}
|
||||
detailRow(label: "Latest version", value: viewModel.stableVersionLabel)
|
||||
detailRow(label: String(localized: "Latest version", bundle: #bundle, comment: "Package detail row"), value: viewModel.stableVersionLabel)
|
||||
if viewModel.showsInstallMetrics {
|
||||
detailRow(label: "30-day installs", value: viewModel.installs30DayLabel)
|
||||
detailRow(label: String(
|
||||
localized: "30-day installs",
|
||||
bundle: #bundle,
|
||||
comment: "Package detail row: Homebrew analytics install count",
|
||||
), value: viewModel.installs30DayLabel)
|
||||
}
|
||||
if let dateValue = viewModel.installDateValue {
|
||||
detailRow(label: "Installed on", value: dateValue)
|
||||
detailRow(label: String(localized: "Installed on", bundle: #bundle, comment: "Package detail row: install date"), value: dateValue)
|
||||
}
|
||||
if let reason = viewModel.installReasonValue {
|
||||
detailRow(label: "Install reason", value: reason)
|
||||
detailRow(label: String(localized: "Install reason", bundle: #bundle, comment: "Package detail row: on request or as a dependency"), value: reason)
|
||||
}
|
||||
if let license = viewModel.licenseLabel {
|
||||
detailRow(label: "License", value: license)
|
||||
detailRow(label: String(localized: "License", bundle: #bundle, comment: "Package detail row"), value: license)
|
||||
}
|
||||
if let tap = viewModel.tapDisplayValue {
|
||||
sourceRow(tap: tap, url: viewModel.sourceURL)
|
||||
@@ -186,7 +190,7 @@ private struct DiscoverPackageDetailMetadataSection: View {
|
||||
|
||||
private func sourceRow(tap: String, url: URL?) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
|
||||
Text("Source")
|
||||
Text("Source", bundle: #bundle, comment: "Package detail row: source tap")
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
@@ -211,7 +215,7 @@ private struct DiscoverPackageDetailMetadataSection: View {
|
||||
|
||||
private func homepageRow(url: URL) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
|
||||
Text("Homepage")
|
||||
Text("Homepage", bundle: #bundle, comment: "Package detail row: homepage link")
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
@@ -233,10 +237,10 @@ private struct DiscoverPackageDetailDependenciesSection: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.xs) {
|
||||
PackageDetailSectionHeading(title: "Dependencies")
|
||||
PackageDetailSectionHeading(title: LocalizedStringResource("Dependencies", bundle: #bundle, comment: "Package detail section heading"))
|
||||
let deps = viewModel.dependencyNames
|
||||
if deps.isEmpty {
|
||||
Text("No dependencies.")
|
||||
Text("No dependencies.", bundle: #bundle, comment: "Package detail: empty dependency list")
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
} else {
|
||||
@@ -262,11 +266,11 @@ private struct DiscoverPackageInstallSection: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
|
||||
PackageDetailSectionHeading(title: "Install")
|
||||
PackageDetailSectionHeading(title: LocalizedStringResource("Install", bundle: #bundle, comment: "Package detail section heading"))
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.md) {
|
||||
CommandBlockView(
|
||||
command: viewModel.installCommand,
|
||||
summaryText: "Installs this package on your Mac",
|
||||
summaryText: LocalizedStringResource("Installs this package on your Mac", bundle: #bundle, comment: "Command block summary above brew install"),
|
||||
)
|
||||
|
||||
Button {
|
||||
@@ -277,12 +281,12 @@ private struct DiscoverPackageInstallSection: View {
|
||||
.controlSize(.small)
|
||||
.frame(minWidth: 120)
|
||||
} else {
|
||||
Text("Install")
|
||||
Text("Install", bundle: #bundle, comment: "Discover detail: install button")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isInstalling)
|
||||
.accessibilityLabel(Text("Install"))
|
||||
.accessibilityLabel(String(localized: "Install", bundle: #bundle, comment: "VoiceOver: install button"))
|
||||
.axid(.installButton)
|
||||
|
||||
if let installErrorMessage = viewModel.installErrorMessage {
|
||||
@@ -299,10 +303,10 @@ private struct DiscoverPackageInstallSection: View {
|
||||
struct DiscoverPackageDetailPlaceholder: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
|
||||
Text("No selection")
|
||||
Text("No selection", bundle: #bundle, comment: "Package detail placeholder title when nothing is selected")
|
||||
.font(.brewTitle2)
|
||||
.foregroundStyle(Color.brewTextPrimary)
|
||||
Text("Choose a package from Discover to see details.")
|
||||
Text("Choose a package from Discover to see details.", bundle: #bundle, comment: "Package detail placeholder body when nothing is selected")
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
Spacer()
|
||||
|
||||
@@ -102,7 +102,11 @@ struct DoctorIssueDetailView: View {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
|
||||
captionText(block.caption)
|
||||
if steps.contains(where: \.needsAdmin) {
|
||||
Label("Needs admin · runs in Terminal", systemImage: "lock.fill")
|
||||
Label(String(
|
||||
localized: "Needs admin · runs in Terminal",
|
||||
bundle: #bundle,
|
||||
comment: "Doctor fix note: the command needs sudo so it opens in Terminal",
|
||||
), systemImage: "lock.fill")
|
||||
.font(.brewCaption)
|
||||
.foregroundStyle(Color.brewTextTertiary)
|
||||
}
|
||||
@@ -127,12 +131,12 @@ struct DoctorIssueDetailView: View {
|
||||
.controlSize(.small)
|
||||
.frame(minWidth: 120)
|
||||
} else {
|
||||
Text("Run Fix")
|
||||
Text("Run Fix", bundle: #bundle, comment: "Doctor detail: run the suggested fix button")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(viewModel.isFixRunning(item))
|
||||
.accessibilityLabel("Run Fix")
|
||||
.accessibilityLabel(String(localized: "Run Fix", bundle: #bundle, comment: "VoiceOver: run-fix button"))
|
||||
|
||||
if let fixError = viewModel.fixError(item) {
|
||||
Text(fixError)
|
||||
@@ -176,7 +180,11 @@ struct DoctorIssueDetailView: View {
|
||||
// MARK: - Raw output
|
||||
|
||||
private var rawOutputSection: some View {
|
||||
CommandBlockView(command: item.rawText, title: "Raw output", collapsible: true)
|
||||
CommandBlockView(command: item.rawText, title: LocalizedStringResource(
|
||||
"Raw output",
|
||||
bundle: #bundle,
|
||||
comment: "Doctor detail: collapsible header for the raw brew doctor text",
|
||||
), collapsible: true)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,7 +201,11 @@ private struct DoctorSeverityBadge: View {
|
||||
.padding(.horizontal, BrewSpacing.sm)
|
||||
.padding(.vertical, BrewSpacing.xxs)
|
||||
.background(DoctorSeverityStyle.background(severity), in: Capsule())
|
||||
.accessibilityLabel("Severity: \(DoctorSeverityStyle.displayName(severity))")
|
||||
.accessibilityLabel(String(
|
||||
localized: "Severity: \(DoctorSeverityStyle.displayName(severity))",
|
||||
bundle: #bundle,
|
||||
comment: "VoiceOver: Doctor severity badge; %@ is the severity name",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,10 +232,10 @@ private struct DoctorLinkRow: View {
|
||||
struct DoctorDetailPlaceholder: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
|
||||
Text("No selection")
|
||||
Text("No selection", bundle: #bundle, comment: "Doctor detail placeholder title when nothing is selected")
|
||||
.font(.brewTitle2)
|
||||
.foregroundStyle(Color.brewTextPrimary)
|
||||
Text("Run diagnostics, then choose an issue to see details and fixes.")
|
||||
Text("Run diagnostics, then choose an issue to see details and fixes.", bundle: #bundle, comment: "Doctor detail placeholder body when nothing is selected")
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
Spacer()
|
||||
|
||||
@@ -28,14 +28,18 @@ struct DoctorView: View {
|
||||
private var header: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.xs) {
|
||||
Text("Doctor")
|
||||
Text("Doctor", bundle: #bundle, comment: "Doctor tab heading")
|
||||
.font(.brewTitle2)
|
||||
.foregroundStyle(Color.brewTextPrimary)
|
||||
Text(viewModel.subtitle)
|
||||
.font(.brewSubheadline)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
if let lastCheckedAt = viewModel.lastCheckedAt {
|
||||
LastUpdatedLabel(lead: "Last checked", date: lastCheckedAt)
|
||||
LastUpdatedLabel(lead: LocalizedStringResource(
|
||||
"Last checked",
|
||||
bundle: #bundle,
|
||||
comment: "Doctor header: lead-in before a relative time, e.g. “Last checked 5 minutes ago”",
|
||||
), date: lastCheckedAt)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -54,16 +58,16 @@ struct DoctorView: View {
|
||||
if viewModel.isRefreshing {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel("Re-checking")
|
||||
.accessibilityLabel(String(localized: "Re-checking", bundle: #bundle, comment: "VoiceOver: Doctor re-check spinner"))
|
||||
}
|
||||
if viewModel.rawDoctorOutput != nil {
|
||||
Button("Copy output") {
|
||||
Button(String(localized: "Copy output", bundle: #bundle, comment: "Doctor header: copy raw brew doctor output")) {
|
||||
viewModel.copyDoctorOutput()
|
||||
}
|
||||
.controlSize(.small)
|
||||
.accessibilityLabel("Copy brew doctor output")
|
||||
.accessibilityLabel(String(localized: "Copy brew doctor output", bundle: #bundle, comment: "VoiceOver: Doctor copy-output button"))
|
||||
}
|
||||
Button("Run Again") {
|
||||
Button(String(localized: "Run Again", bundle: #bundle, comment: "Doctor header: re-run brew doctor")) {
|
||||
Task { await viewModel.load(forceRefresh: true) }
|
||||
}
|
||||
.controlSize(.small)
|
||||
@@ -92,10 +96,10 @@ struct DoctorView: View {
|
||||
Image(systemName: "checkmark.seal.fill")
|
||||
.font(.system(size: 44))
|
||||
.foregroundStyle(Color.brewStatusSuccess)
|
||||
Text("Your system is ready to brew")
|
||||
Text("Your system is ready to brew", bundle: #bundle, comment: "Doctor healthy state title")
|
||||
.font(.brewTitle3)
|
||||
.foregroundStyle(Color.brewTextPrimary)
|
||||
Text("brew doctor found no problems.")
|
||||
Text("brew doctor found no problems.", bundle: #bundle, comment: "Doctor healthy state body")
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
}
|
||||
@@ -138,7 +142,7 @@ struct DoctorView: View {
|
||||
}
|
||||
.focused($isFocused)
|
||||
.listStyle(.inset)
|
||||
.accessibilityLabel("Doctor issues")
|
||||
.accessibilityLabel(String(localized: "Doctor issues", bundle: #bundle, comment: "VoiceOver: the Doctor issues list"))
|
||||
.onKeyPress(.upArrow) {
|
||||
viewModel.selectPrevious()
|
||||
return .handled
|
||||
@@ -163,7 +167,7 @@ private struct DoctorSeveritySectionHeader: View {
|
||||
|
||||
private struct DoctorReassuranceNote: View {
|
||||
var body: some View {
|
||||
NoteCallout(DoctorCopy.warningPreamble, tone: .info)
|
||||
NoteCallout(verbatim: DoctorCopy.warningPreamble, tone: .info)
|
||||
.padding(.horizontal, BrewSpacing.lg)
|
||||
.padding(.bottom, BrewSpacing.sm)
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ final class UpgradesViewModel {
|
||||
/// subtitle is a static tab description owned by `MainWindowView`.
|
||||
var outdatedSubtitle: String {
|
||||
if shouldShowInitialLoadingIndicator {
|
||||
return String(localized: "Loading packages…", comment: "Upgrades tab subtitle while fetching")
|
||||
return String(localized: "Loading packages…", bundle: #bundle, comment: "Upgrades tab subtitle while fetching")
|
||||
}
|
||||
if showsUpgradeCheckFailure {
|
||||
return Self.upgradeCheckFailedTitle
|
||||
@@ -121,12 +121,14 @@ final class UpgradesViewModel {
|
||||
// The count came from the last check that succeeded, so it must not read as current.
|
||||
return String(
|
||||
localized: "\(subtitle) — last check failed",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades tab subtitle when cached upgrades are shown after a failed re-check",
|
||||
)
|
||||
}
|
||||
|
||||
static let upgradeCheckFailedTitle = String(
|
||||
localized: "Couldn't check for upgrades",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades tab: the outdated check failed, so the tab cannot report an answer",
|
||||
)
|
||||
|
||||
@@ -137,11 +139,13 @@ final class UpgradesViewModel {
|
||||
case 1:
|
||||
String(
|
||||
localized: "1 package can be upgraded",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades tab subtitle for a single outdated package",
|
||||
)
|
||||
default:
|
||||
String(
|
||||
localized: "\(totalOutdatedCount) packages can be upgraded",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades tab subtitle when multiple packages are outdated",
|
||||
)
|
||||
}
|
||||
@@ -155,17 +159,20 @@ final class UpgradesViewModel {
|
||||
if visible > 0 {
|
||||
return String(
|
||||
localized: "Showing \(visible) of \(total) upgrades",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades tab subtitle while filtering with at least one match",
|
||||
)
|
||||
}
|
||||
if total == 1 {
|
||||
return String(
|
||||
localized: "No matches in 1 outdated package",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades tab subtitle when filters hide the single available upgrade",
|
||||
)
|
||||
}
|
||||
return String(
|
||||
localized: "No matches in \(total) outdated packages",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades tab subtitle when filters hide every available upgrade",
|
||||
)
|
||||
}
|
||||
@@ -333,21 +340,24 @@ extension UpgradesViewModel {
|
||||
upgradeSelection.displayCommand
|
||||
}
|
||||
|
||||
var bulkUpgradeSummary: String {
|
||||
var bulkUpgradeSummary: LocalizedStringResource {
|
||||
switch upgradeSelection {
|
||||
case .all:
|
||||
String(
|
||||
localized: "Upgrades every outdated package",
|
||||
LocalizedStringResource(
|
||||
"Upgrades every outdated package",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades header command summary for an unfiltered batch",
|
||||
)
|
||||
case .formulae:
|
||||
String(
|
||||
localized: "Upgrades every outdated formula",
|
||||
LocalizedStringResource(
|
||||
"Upgrades every outdated formula",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades header command summary scoped to formulae",
|
||||
)
|
||||
case .casks:
|
||||
String(
|
||||
localized: "Upgrades every outdated cask",
|
||||
LocalizedStringResource(
|
||||
"Upgrades every outdated cask",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades header command summary scoped to casks",
|
||||
)
|
||||
case let .explicit(names):
|
||||
@@ -355,15 +365,17 @@ extension UpgradesViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private static func searchedUpgradeSummary(count: Int) -> String {
|
||||
private static func searchedUpgradeSummary(count: Int) -> LocalizedStringResource {
|
||||
if count == 1 {
|
||||
return String(
|
||||
localized: "Upgrades the 1 package matching your search",
|
||||
return LocalizedStringResource(
|
||||
"Upgrades the 1 package matching your search",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades header command summary for a single searched package",
|
||||
)
|
||||
}
|
||||
return String(
|
||||
localized: "Upgrades the \(count) packages matching your search",
|
||||
return LocalizedStringResource(
|
||||
"Upgrades the \(count) packages matching your search",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades header command summary for multiple searched packages",
|
||||
)
|
||||
}
|
||||
@@ -379,6 +391,7 @@ extension UpgradesViewModel {
|
||||
if isFilteringOutEveryUpgrade {
|
||||
return String(
|
||||
localized: "Nothing to upgrade here",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades header stand-in when filters hide every available upgrade",
|
||||
)
|
||||
}
|
||||
@@ -399,6 +412,7 @@ extension UpgradesViewModel {
|
||||
}
|
||||
return String(
|
||||
localized: "\(message)\n\nUntil this succeeds the app can't tell whether anything needs upgrading.",
|
||||
bundle: #bundle,
|
||||
comment: "Upgrades empty state under a failed check: brew's error, then why the list is empty",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@ struct InstalledPackageDetailHeroSection: View {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.brewTitle3)
|
||||
.foregroundStyle(Color.brewStatusSuccess)
|
||||
.accessibilityLabel("Installed")
|
||||
.accessibilityLabel(String(localized: "Installed", bundle: #bundle, comment: "VoiceOver: package is installed"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,26 +90,27 @@ struct InstalledPackageDetailMetadataSection: View {
|
||||
let viewModel: InstalledPackageDetailViewModel
|
||||
|
||||
private let labelWidth: CGFloat = 100
|
||||
private let yes = String(localized: "Yes", bundle: #bundle, comment: "Package detail value for a true flag")
|
||||
|
||||
var body: some View {
|
||||
let metadata = viewModel.metadataItem
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
|
||||
PackageDetailSectionHeading(title: "Details")
|
||||
PackageDetailSectionHeading(title: LocalizedStringResource("Details", bundle: #bundle, comment: "Package detail section heading"))
|
||||
detailRow(
|
||||
label: "Installed",
|
||||
label: String(localized: "Installed", bundle: #bundle, comment: "Package detail row: installed version(s)"),
|
||||
value: metadata.installedVersionsValue,
|
||||
valueColor: metadata.isOutdated ? .brewStatusWarning : .brewTextPrimary,
|
||||
valueFontWeight: .heavy,
|
||||
)
|
||||
detailRow(label: "Latest version", value: metadata.latestVersionValue)
|
||||
detailRow(label: String(localized: "Latest version", bundle: #bundle, comment: "Package detail row"), value: metadata.latestVersionValue)
|
||||
if let dateValue = metadata.installDateValue {
|
||||
detailRow(label: "Installed on", value: dateValue)
|
||||
detailRow(label: String(localized: "Installed on", bundle: #bundle, comment: "Package detail row: install date"), value: dateValue)
|
||||
}
|
||||
if let reason = metadata.installReasonValue {
|
||||
detailRow(label: "Install reason", value: reason)
|
||||
detailRow(label: String(localized: "Install reason", bundle: #bundle, comment: "Package detail row: on request or as a dependency"), value: reason)
|
||||
}
|
||||
if let license = metadata.licenseValue {
|
||||
detailRow(label: "License", value: license)
|
||||
detailRow(label: String(localized: "License", bundle: #bundle, comment: "Package detail row"), value: license)
|
||||
}
|
||||
if let tap = metadata.tapDisplayValue {
|
||||
sourceRow(tap: tap, url: metadata.sourceURL)
|
||||
@@ -118,10 +119,10 @@ struct InstalledPackageDetailMetadataSection: View {
|
||||
homepageRow(url: homepageURL, title: metadata.homepageDisplayTitle ?? homepageURL.absoluteString)
|
||||
}
|
||||
if metadata.isPinned {
|
||||
detailRow(label: "Pinned", value: "Yes")
|
||||
detailRow(label: String(localized: "Pinned", bundle: #bundle, comment: "Package detail row: brew pin"), value: yes)
|
||||
}
|
||||
if metadata.isKegOnly {
|
||||
detailRow(label: "Keg-only", value: "Yes")
|
||||
detailRow(label: String(localized: "Keg-only", bundle: #bundle, comment: "Package detail row: formula is keg-only"), value: yes)
|
||||
}
|
||||
if let caveats = metadata.caveatsText {
|
||||
caveatsCallout(text: caveats)
|
||||
@@ -145,7 +146,7 @@ struct InstalledPackageDetailMetadataSection: View {
|
||||
|
||||
private func sourceRow(tap: String, url: URL?) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
|
||||
Text("Source")
|
||||
Text("Source", bundle: #bundle, comment: "Package detail row: source tap")
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
@@ -170,7 +171,7 @@ struct InstalledPackageDetailMetadataSection: View {
|
||||
|
||||
private func homepageRow(url: URL, title: String) -> some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
|
||||
Text("Homepage")
|
||||
Text("Homepage", bundle: #bundle, comment: "Package detail row: homepage link")
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
.frame(width: labelWidth, alignment: .leading)
|
||||
@@ -187,7 +188,7 @@ struct InstalledPackageDetailMetadataSection: View {
|
||||
}
|
||||
|
||||
private func caveatsCallout(text: String) -> some View {
|
||||
NoteCallout(text)
|
||||
NoteCallout(verbatim: text)
|
||||
.padding(.top, BrewSpacing.lg)
|
||||
}
|
||||
}
|
||||
@@ -200,7 +201,7 @@ struct InstalledPackageDetailDependentsSection: View {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
|
||||
dependentsHeading
|
||||
if viewModel.dependentRelationships.isEmpty {
|
||||
Text("No dependents.")
|
||||
Text("No dependents.", bundle: #bundle, comment: "Package detail: nothing depends on this package")
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
} else {
|
||||
@@ -215,7 +216,7 @@ struct InstalledPackageDetailDependentsSection: View {
|
||||
|
||||
private var dependentsHeading: some View {
|
||||
HStack(spacing: BrewSpacing.sm) {
|
||||
PackageDetailSectionHeading(title: "Dependents")
|
||||
PackageDetailSectionHeading(title: LocalizedStringResource("Dependents", bundle: #bundle, comment: "Package detail section heading: packages that depend on this one"))
|
||||
if let badgeTitle = viewModel.uninstallItem.usedByBlockingBadgeTitle {
|
||||
Text(badgeTitle)
|
||||
.font(.brewCaption2.weight(.semibold))
|
||||
@@ -238,7 +239,7 @@ struct UninstallBlockedCallout: View {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.brewWarningGlyphStyle()
|
||||
.font(.brewSubheadline)
|
||||
Text("\(Text(lead).fontWeight(.semibold)) \(bodyText)")
|
||||
(Text(lead).fontWeight(.semibold) + Text(verbatim: " ") + Text(bodyText))
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextPrimary)
|
||||
}
|
||||
@@ -256,7 +257,7 @@ struct InstalledPackageDetailRelationshipList: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.xs) {
|
||||
if relationships.isEmpty {
|
||||
Text("None.")
|
||||
Text("None.", bundle: #bundle, comment: "Package detail: empty relationship list")
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
} else {
|
||||
@@ -295,8 +296,16 @@ struct InstalledPackageDetailRelationshipList: View {
|
||||
.disabled(!isInstalled)
|
||||
.accessibilityLabel(
|
||||
isInstalled
|
||||
? "Open installed package \(relationship.displayName)"
|
||||
: "\(relationship.displayName), not installed",
|
||||
? String(
|
||||
localized: "Open installed package \(relationship.displayName)",
|
||||
bundle: #bundle,
|
||||
comment: "VoiceOver: dependency row that jumps to that package; %@ is its name",
|
||||
)
|
||||
: String(
|
||||
localized: "\(relationship.displayName), not installed",
|
||||
bundle: #bundle,
|
||||
comment: "VoiceOver: dependency row for a package that is not installed; %@ is its name",
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,9 +112,9 @@ struct InstalledPackageDetailView: View {
|
||||
InstalledPackageDetailMetadataSection(viewModel: viewModel)
|
||||
PackageDetailSectionDivider()
|
||||
PackageRelationshipSection(
|
||||
title: "Dependencies",
|
||||
title: LocalizedStringResource("Dependencies", bundle: #bundle, comment: "Package detail section heading"),
|
||||
relationships: viewModel.dependencyRelationships,
|
||||
emptyText: "No dependencies.",
|
||||
emptyText: LocalizedStringResource("No dependencies.", bundle: #bundle, comment: "Package detail: empty dependency list"),
|
||||
dotStyle: .neutral,
|
||||
onSelectInstalledPackage: onSelectInstalledPackage,
|
||||
)
|
||||
@@ -128,9 +128,9 @@ struct InstalledPackageDetailView: View {
|
||||
|
||||
/// Heading + always-expanded relationship list, used for Dependencies.
|
||||
private struct PackageRelationshipSection: View {
|
||||
let title: String
|
||||
let title: LocalizedStringResource
|
||||
let relationships: [PackageRelationshipItem]
|
||||
let emptyText: String
|
||||
let emptyText: LocalizedStringResource
|
||||
let dotStyle: PackageRelationshipDotStyle
|
||||
let onSelectInstalledPackage: (InstalledBrewPackage.ID) -> Void
|
||||
|
||||
@@ -159,14 +159,14 @@ private struct InstalledPackageDetailUninstallChrome: View {
|
||||
var body: some View {
|
||||
let uninstall = viewModel.uninstallItem
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
|
||||
Text("Uninstall")
|
||||
Text("Uninstall", bundle: #bundle, comment: "Package detail section heading")
|
||||
.font(.brewSubheadline.weight(.semibold))
|
||||
.foregroundStyle(Color.brewTextPrimary)
|
||||
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.md) {
|
||||
CommandBlockView(
|
||||
command: uninstall.displayCommand,
|
||||
summaryText: "Uninstalls this package from this Mac",
|
||||
summaryText: LocalizedStringResource("Uninstalls this package from this Mac", bundle: #bundle, comment: "Command block summary above brew uninstall"),
|
||||
)
|
||||
|
||||
Button {
|
||||
@@ -198,7 +198,7 @@ private struct InstalledPackageDetailUninstallChrome: View {
|
||||
Button(uninstall.primaryButtonTitle, role: .destructive) {
|
||||
viewModel.uninstallSelectedPackage()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
Button(String(localized: "Cancel", bundle: #bundle, comment: "Uninstall confirmation: keep the package"), role: .cancel) {}
|
||||
} message: {
|
||||
Text(uninstall.confirmationMessage)
|
||||
}
|
||||
@@ -227,14 +227,18 @@ private struct InstalledPackageDetailUpgradeChrome: View {
|
||||
var body: some View {
|
||||
let upgrade = viewModel.upgradeItem
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
|
||||
Text("Upgrade")
|
||||
Text("Upgrade", bundle: #bundle, comment: "Package detail section heading")
|
||||
.font(.brewSubheadline.weight(.semibold))
|
||||
.foregroundStyle(Color.brewTextPrimary)
|
||||
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.md) {
|
||||
CommandBlockView(
|
||||
command: upgrade.displayCommand,
|
||||
summaryText: "Upgrades this package to the latest available version",
|
||||
summaryText: LocalizedStringResource(
|
||||
"Upgrades this package to the latest available version",
|
||||
bundle: #bundle,
|
||||
comment: "Command block summary above brew upgrade",
|
||||
),
|
||||
)
|
||||
|
||||
if let title = upgrade.primaryButtonTitle {
|
||||
@@ -271,10 +275,10 @@ private struct InstalledPackageDetailUpgradeChrome: View {
|
||||
struct InstalledPackageDetailPlaceholder: View {
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.sm) {
|
||||
Text("No selection")
|
||||
Text("No selection", bundle: #bundle, comment: "Package detail placeholder title when nothing is selected")
|
||||
.font(.brewTitle2)
|
||||
.foregroundStyle(Color.brewTextPrimary)
|
||||
Text("Choose a package from the list to see details.")
|
||||
Text("Choose a package from the list to see details.", bundle: #bundle, comment: "Package detail placeholder body when nothing is selected")
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
Spacer()
|
||||
|
||||
@@ -13,7 +13,7 @@ public struct RefreshCommands: Commands {
|
||||
|
||||
public var body: some Commands {
|
||||
CommandGroup(after: .sidebar) {
|
||||
Button("Refresh") { refreshAll?() }
|
||||
Button(String(localized: "Refresh", bundle: #bundle, comment: "View menu: refresh every surface (⌘R)")) { refreshAll?() }
|
||||
.keyboardShortcut("r", modifiers: .command)
|
||||
.disabled(refreshAll == nil)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ public struct SearchCommands: Commands {
|
||||
|
||||
public var body: some Commands {
|
||||
CommandGroup(after: .textEditing) {
|
||||
Button("Find") { focusSearchField?() }
|
||||
Button(String(localized: "Find", bundle: #bundle, comment: "Edit menu: focus the search field (⌘F)")) { focusSearchField?() }
|
||||
.keyboardShortcut("f") // ⌘F
|
||||
.disabled(focusSearchField == nil)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,71 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
|
||||
"%@ %@" : {
|
||||
"comment" : "Last-updated label: %1$@ is the lead phrase (e.g. “Last checked”), %2$@ the relative time",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "new",
|
||||
"value" : "%1$@ %2$@"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"%lld days ago" : {
|
||||
"comment" : "Relative time, %lld ≥ 2"
|
||||
},
|
||||
"%lld hours ago" : {
|
||||
"comment" : "Relative time, %lld ≥ 2"
|
||||
},
|
||||
"%lld minutes ago" : {
|
||||
"comment" : "Relative time, %lld ≥ 2"
|
||||
},
|
||||
"1 day ago" : {
|
||||
"comment" : "Relative time"
|
||||
},
|
||||
"1 hour ago" : {
|
||||
"comment" : "Relative time"
|
||||
},
|
||||
"1 minute ago" : {
|
||||
"comment" : "Relative time"
|
||||
},
|
||||
"Copied" : {
|
||||
"comment" : "Command block: shown briefly after copying"
|
||||
},
|
||||
"Copy" : {
|
||||
"comment" : "Command block: copy the command"
|
||||
},
|
||||
"Copy all" : {
|
||||
"comment" : "Command block: copy every command"
|
||||
},
|
||||
"Could not find Homebrew. Install it or ensure brew is in the default location." : {
|
||||
"comment" : "Error when the brew executable is not in either default prefix"
|
||||
},
|
||||
"Find" : {
|
||||
"comment" : "Edit menu: focus the search field (⌘F)"
|
||||
},
|
||||
"Homebrew command failed." : {
|
||||
"comment" : "Error when brew exits non-zero without printing anything to stderr"
|
||||
},
|
||||
"Homebrew returned output the app couldn’t read." : {
|
||||
"comment" : "Error when brew succeeded but its JSON output failed to decode"
|
||||
},
|
||||
"Refresh" : {
|
||||
"comment" : "View menu: refresh every surface (⌘R)"
|
||||
},
|
||||
"Retry" : {
|
||||
"comment" : "Error state: retry the failed load"
|
||||
},
|
||||
"Terminal command" : {
|
||||
"comment" : "Command block header, one command"
|
||||
},
|
||||
"Terminal commands" : {
|
||||
"comment" : "Command block header, several commands"
|
||||
},
|
||||
"just now" : {
|
||||
"comment" : "Relative time: under a minute ago"
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
}
|
||||
@@ -68,7 +68,7 @@ struct ErrorStateView: View {
|
||||
.foregroundStyle(Color.brewStatusError)
|
||||
.multilineTextAlignment(.center)
|
||||
if let onRetry {
|
||||
Button("Retry", action: onRetry)
|
||||
Button(String(localized: "Retry", bundle: #bundle, comment: "Error state: retry the failed load"), action: onRetry)
|
||||
.axid(.errorRetryButton)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ import SwiftUI
|
||||
/// An action that leaves no visible trace (copying to the pasteboard, clearing a list) passes a
|
||||
/// `confirmationTitle`: the button swaps to a tick and that title for a few seconds.
|
||||
public struct BrewActionButton: View {
|
||||
private let title: String
|
||||
private let title: LocalizedStringResource
|
||||
private let systemImage: String
|
||||
private let confirmationTitle: String?
|
||||
private let help: String?
|
||||
private let confirmationTitle: LocalizedStringResource?
|
||||
private let help: LocalizedStringResource?
|
||||
private let action: () -> Void
|
||||
|
||||
@State private var isHovered = false
|
||||
@@ -23,10 +23,10 @@ public struct BrewActionButton: View {
|
||||
private static let confirmationDuration: Duration = .seconds(5)
|
||||
|
||||
public init(
|
||||
_ title: String,
|
||||
_ title: LocalizedStringResource,
|
||||
systemImage: String,
|
||||
confirmationTitle: String? = nil,
|
||||
help: String? = nil,
|
||||
confirmationTitle: LocalizedStringResource? = nil,
|
||||
help: LocalizedStringResource? = nil,
|
||||
action: @escaping () -> Void,
|
||||
) {
|
||||
self.title = title
|
||||
@@ -54,7 +54,7 @@ public struct BrewActionButton: View {
|
||||
.onHover { isHovered = $0 }
|
||||
.help(help ?? title)
|
||||
// The label changes while confirming; what a screen reader or a UI test matches on must not.
|
||||
.accessibilityLabel(title)
|
||||
.accessibilityLabel(Text(title))
|
||||
}
|
||||
|
||||
private func confirm() {
|
||||
@@ -74,10 +74,15 @@ public struct BrewActionButton: View {
|
||||
|
||||
/// What a ``BrewActionButton`` shows right now.
|
||||
struct BrewActionButtonAppearance: Equatable {
|
||||
let title: String
|
||||
let title: LocalizedStringResource
|
||||
let systemImage: String
|
||||
|
||||
init(title: String, systemImage: String, confirmationTitle: String?, isConfirming: Bool) {
|
||||
init(
|
||||
title: LocalizedStringResource,
|
||||
systemImage: String,
|
||||
confirmationTitle: LocalizedStringResource?,
|
||||
isConfirming: Bool,
|
||||
) {
|
||||
if isConfirming, let confirmationTitle {
|
||||
self.title = confirmationTitle
|
||||
self.systemImage = "checkmark"
|
||||
@@ -118,18 +123,22 @@ private struct BrewActionButtonStyle: ButtonStyle {
|
||||
#if DEBUG
|
||||
#Preview {
|
||||
HStack(spacing: BrewSpacing.xs) {
|
||||
BrewActionButton("Save", systemImage: "square.and.arrow.down", help: "Save output to file") {}
|
||||
BrewActionButton(
|
||||
"Copy",
|
||||
systemImage: "doc.on.doc",
|
||||
confirmationTitle: "Copied",
|
||||
help: "Copy output to clipboard",
|
||||
previewCopy("Save"),
|
||||
systemImage: "square.and.arrow.down",
|
||||
help: previewCopy("Save output to file"),
|
||||
) {}
|
||||
BrewActionButton(
|
||||
"Clear",
|
||||
previewCopy("Copy"),
|
||||
systemImage: "doc.on.doc",
|
||||
confirmationTitle: previewCopy("Copied"),
|
||||
help: previewCopy("Copy output to clipboard"),
|
||||
) {}
|
||||
BrewActionButton(
|
||||
previewCopy("Clear"),
|
||||
systemImage: "trash",
|
||||
confirmationTitle: "Cleared",
|
||||
help: "Clear completed jobs",
|
||||
confirmationTitle: previewCopy("Cleared"),
|
||||
help: previewCopy("Clear completed jobs"),
|
||||
) {}
|
||||
}
|
||||
.padding()
|
||||
|
||||
@@ -11,11 +11,16 @@ import SwiftUI
|
||||
/// renders a numbered list with one "Copy all" affordance instead of N loose buttons.
|
||||
public struct CommandBlockView: View {
|
||||
let commands: [String]
|
||||
let summaryText: String?
|
||||
let title: String?
|
||||
let summaryText: LocalizedStringResource?
|
||||
let title: LocalizedStringResource?
|
||||
let collapsible: Bool
|
||||
|
||||
public init(command: String, summaryText: String? = nil, title: String? = nil, collapsible: Bool = false) {
|
||||
public init(
|
||||
command: String,
|
||||
summaryText: LocalizedStringResource? = nil,
|
||||
title: LocalizedStringResource? = nil,
|
||||
collapsible: Bool = false,
|
||||
) {
|
||||
commands = [command]
|
||||
self.summaryText = summaryText
|
||||
self.title = title
|
||||
@@ -23,7 +28,12 @@ public struct CommandBlockView: View {
|
||||
_isExpanded = State(initialValue: !collapsible)
|
||||
}
|
||||
|
||||
public init(commands: [String], summaryText: String? = nil, title: String? = nil, collapsible: Bool = false) {
|
||||
public init(
|
||||
commands: [String],
|
||||
summaryText: LocalizedStringResource? = nil,
|
||||
title: LocalizedStringResource? = nil,
|
||||
collapsible: Bool = false,
|
||||
) {
|
||||
self.commands = commands
|
||||
self.summaryText = summaryText
|
||||
self.title = title
|
||||
@@ -69,7 +79,11 @@ public struct CommandBlockView: View {
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
}
|
||||
Spacer()
|
||||
BrewActionButton(copyTitle, systemImage: "doc.on.doc", confirmationTitle: "Copied") {
|
||||
BrewActionButton(
|
||||
copyTitle,
|
||||
systemImage: "doc.on.doc",
|
||||
confirmationTitle: LocalizedStringResource("Copied", bundle: #bundle, comment: "Command block: shown briefly after copying"),
|
||||
) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(commands.joined(separator: "\n"), forType: .string)
|
||||
}
|
||||
@@ -79,12 +93,16 @@ public struct CommandBlockView: View {
|
||||
.background(Color.brewSurfaceRecessed)
|
||||
}
|
||||
|
||||
private var headerTitle: String {
|
||||
commands.count > 1 ? "Terminal commands" : "Terminal command"
|
||||
private var headerTitle: LocalizedStringResource {
|
||||
commands.count > 1
|
||||
? LocalizedStringResource("Terminal commands", bundle: #bundle, comment: "Command block header, several commands")
|
||||
: LocalizedStringResource("Terminal command", bundle: #bundle, comment: "Command block header, one command")
|
||||
}
|
||||
|
||||
private var copyTitle: String {
|
||||
commands.count > 1 ? "Copy all" : "Copy"
|
||||
private var copyTitle: LocalizedStringResource {
|
||||
commands.count > 1
|
||||
? LocalizedStringResource("Copy all", bundle: #bundle, comment: "Command block: copy every command")
|
||||
: LocalizedStringResource("Copy", bundle: #bundle, comment: "Command block: copy the command")
|
||||
}
|
||||
|
||||
/// Deliberately not on ``Color/brewTerminal``: light-on-dark text under the system selection
|
||||
@@ -103,7 +121,7 @@ public struct CommandBlockView: View {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.xs) {
|
||||
ForEach(Array(commands.enumerated()), id: \.offset) { index, command in
|
||||
HStack(alignment: .firstTextBaseline, spacing: BrewSpacing.sm) {
|
||||
Text("\(index + 1).")
|
||||
Text(verbatim: "\(index + 1).")
|
||||
.font(.brewCode)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
Text(command)
|
||||
@@ -119,7 +137,7 @@ public struct CommandBlockView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private func footer(_ text: String) -> some View {
|
||||
private func footer(_ text: LocalizedStringResource) -> some View {
|
||||
Text(text)
|
||||
.font(.brewCaption)
|
||||
.foregroundStyle(Color.brewTextSecondary)
|
||||
@@ -135,7 +153,7 @@ public struct CommandBlockView: View {
|
||||
#Preview("Single") {
|
||||
CommandBlockView(
|
||||
command: "brew upgrade --formula git",
|
||||
summaryText: "Upgrades this package to the latest available version",
|
||||
summaryText: previewCopy("Upgrades this package to the latest available version"),
|
||||
)
|
||||
.frame(width: 360)
|
||||
.padding()
|
||||
@@ -147,7 +165,7 @@ public struct CommandBlockView: View {
|
||||
"rm -rf /usr/local/Library",
|
||||
"brew tap homebrew/core",
|
||||
],
|
||||
summaryText: "Re-establishes the Homebrew/core tap",
|
||||
summaryText: previewCopy("Re-establishes the Homebrew/core tap"),
|
||||
)
|
||||
.frame(width: 360)
|
||||
.padding()
|
||||
|
||||
@@ -6,43 +6,52 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Spelled out rather than left to `RelativeDateTimeFormatter`, which follows the system locale and would
|
||||
/// put a translated phrase after an English lead-in.
|
||||
/// Coarser than `RelativeDateTimeFormatter` on purpose: minute granularity, no seconds, no "in …".
|
||||
public enum RelativeTimeText {
|
||||
public static func string(for date: Date, relativeTo now: Date) -> String {
|
||||
let seconds = now.timeIntervalSince(date)
|
||||
guard seconds >= 60 else {
|
||||
// Also covers a future date: a clock that moved backwards should read as "now", not a countdown.
|
||||
return "just now"
|
||||
return String(localized: "just now", bundle: #bundle, comment: "Relative time: under a minute ago")
|
||||
}
|
||||
let minutes = Int(seconds / 60)
|
||||
if minutes < 60 {
|
||||
return "\(minutes) \(minutes == 1 ? "minute" : "minutes") ago"
|
||||
return minutes == 1
|
||||
? String(localized: "1 minute ago", bundle: #bundle, comment: "Relative time")
|
||||
: String(localized: "\(minutes) minutes ago", bundle: #bundle, comment: "Relative time, %lld ≥ 2")
|
||||
}
|
||||
let hours = minutes / 60
|
||||
if hours < 24 {
|
||||
return "\(hours) \(hours == 1 ? "hour" : "hours") ago"
|
||||
return hours == 1
|
||||
? String(localized: "1 hour ago", bundle: #bundle, comment: "Relative time")
|
||||
: String(localized: "\(hours) hours ago", bundle: #bundle, comment: "Relative time, %lld ≥ 2")
|
||||
}
|
||||
let days = hours / 24
|
||||
return "\(days) \(days == 1 ? "day" : "days") ago"
|
||||
return days == 1
|
||||
? String(localized: "1 day ago", bundle: #bundle, comment: "Relative time")
|
||||
: String(localized: "\(days) days ago", bundle: #bundle, comment: "Relative time, %lld ≥ 2")
|
||||
}
|
||||
}
|
||||
|
||||
public struct LastUpdatedLabel: View {
|
||||
private let lead: String
|
||||
private let lead: LocalizedStringResource
|
||||
private let date: Date
|
||||
|
||||
/// `lead` is the phrase the relative time is appended to, e.g. `"Last checked"`.
|
||||
public init(lead: String, date: Date) {
|
||||
public init(lead: LocalizedStringResource, date: Date) {
|
||||
self.lead = lead
|
||||
self.date = date
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
TimelineView(.periodic(from: date, by: 60)) { context in
|
||||
Text("\(lead) \(RelativeTimeText.string(for: date, relativeTo: context.date))")
|
||||
.font(.brewCaption)
|
||||
.foregroundStyle(Color.brewTextTertiary)
|
||||
// One key, not concatenation: translations reorder the two halves.
|
||||
Text(LocalizedStringResource(
|
||||
"\(String(localized: lead)) \(RelativeTimeText.string(for: date, relativeTo: context.date))",
|
||||
bundle: #bundle,
|
||||
comment: "Last-updated label: %1$@ is the lead phrase (e.g. “Last checked”), %2$@ the relative time",
|
||||
))
|
||||
.font(.brewCaption)
|
||||
.foregroundStyle(Color.brewTextTertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,8 +59,8 @@ public struct LastUpdatedLabel: View {
|
||||
#if DEBUG
|
||||
#Preview("Last updated") {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.xs) {
|
||||
LastUpdatedLabel(lead: "Last checked", date: .now)
|
||||
LastUpdatedLabel(lead: "Last checked", date: .now.addingTimeInterval(-3600))
|
||||
LastUpdatedLabel(lead: previewCopy("Last checked"), date: .now)
|
||||
LastUpdatedLabel(lead: previewCopy("Last checked"), date: .now.addingTimeInterval(-3600))
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
@@ -30,11 +30,16 @@ public enum NoteCalloutTone: Sendable {
|
||||
}
|
||||
|
||||
public struct NoteCallout: View {
|
||||
private let text: String
|
||||
private let text: Text
|
||||
private let tone: NoteCalloutTone
|
||||
|
||||
public init(_ text: String, tone: NoteCalloutTone = .brand) {
|
||||
self.text = text
|
||||
public init(_ text: LocalizedStringResource, tone: NoteCalloutTone = .brand) {
|
||||
self.text = Text(text)
|
||||
self.tone = tone
|
||||
}
|
||||
|
||||
public init(verbatim text: String, tone: NoteCalloutTone = .brand) {
|
||||
self.text = Text(verbatim: text)
|
||||
self.tone = tone
|
||||
}
|
||||
|
||||
@@ -43,7 +48,7 @@ public struct NoteCallout: View {
|
||||
Image(systemName: "info.circle.fill")
|
||||
.font(.brewSubheadline)
|
||||
.foregroundStyle(tone.iconColor)
|
||||
Text(text)
|
||||
text
|
||||
.font(.brewCallout)
|
||||
.foregroundStyle(Color.brewTextPrimary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
@@ -60,9 +65,9 @@ public struct NoteCallout: View {
|
||||
#if DEBUG
|
||||
#Preview("Note callout") {
|
||||
VStack(alignment: .leading, spacing: BrewSpacing.md) {
|
||||
NoteCallout("Casks and formulae are installed to different prefixes.")
|
||||
NoteCallout(verbatim: "Casks and formulae are installed to different prefixes.")
|
||||
NoteCallout(
|
||||
"Please note that these warnings are just used to help the Homebrew maintainers.",
|
||||
verbatim: "Please note that these warnings are just used to help the Homebrew maintainers.",
|
||||
tone: .info,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@ import SwiftUI
|
||||
|
||||
/// Section heading used across package-detail surfaces.
|
||||
public struct PackageDetailSectionHeading: View {
|
||||
let title: String
|
||||
let title: LocalizedStringResource
|
||||
|
||||
public init(title: String) {
|
||||
public init(title: LocalizedStringResource) {
|
||||
self.title = title
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
//
|
||||
// PreviewCopy.swift
|
||||
// BrewUIComponents
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
#if DEBUG
|
||||
/// Sample copy for `#Preview` bodies. A literal written straight into a
|
||||
/// `LocalizedStringResource` parameter is an extraction site; routing through a runtime `String`
|
||||
/// keeps preview text out of the catalogs.
|
||||
public func previewCopy(_ text: String) -> LocalizedStringResource {
|
||||
LocalizedStringResource(stringLiteral: text)
|
||||
}
|
||||
#endif
|
||||
@@ -29,7 +29,7 @@ struct ConfigViewModelTests {
|
||||
return
|
||||
}
|
||||
let sections = viewModel.sections
|
||||
#expect(sections.map(\.title) == ["Homebrew", "System", "Build settings"])
|
||||
#expect(sections.map { String(localized: $0.title) } == ["Homebrew", "System", "Build settings"])
|
||||
|
||||
let homebrew = sections.first { $0.id == "homebrew" }
|
||||
#expect(homebrew?.rows.map(\.label) == ["HOMEBREW_VERSION", "HOMEBREW_PREFIX"])
|
||||
|
||||
@@ -203,13 +203,13 @@ struct UpgradesViewModelScopeTests {
|
||||
|
||||
@Test @MainActor func `bulkUpgradeSummary describes the scoped selection`() {
|
||||
let vm = Self.makeViewModel(packages: Self.mixedOutdated)
|
||||
#expect(vm.bulkUpgradeSummary == "Upgrades every outdated package")
|
||||
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades every outdated package")
|
||||
|
||||
vm.scope = .formulae
|
||||
#expect(vm.bulkUpgradeSummary == "Upgrades every outdated formula")
|
||||
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades every outdated formula")
|
||||
|
||||
vm.scope = .casks
|
||||
#expect(vm.bulkUpgradeSummary == "Upgrades every outdated cask")
|
||||
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades every outdated cask")
|
||||
}
|
||||
|
||||
@Test @MainActor func `bulkUpgradeSummary counts the searched packages`() {
|
||||
@@ -220,13 +220,13 @@ struct UpgradesViewModelScopeTests {
|
||||
])
|
||||
|
||||
vm.searchQuery = "git"
|
||||
#expect(vm.bulkUpgradeSummary == "Upgrades the 2 packages matching your search")
|
||||
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades the 2 packages matching your search")
|
||||
|
||||
vm.searchQuery = "wget"
|
||||
#expect(vm.bulkUpgradeSummary == "Upgrades the 1 package matching your search")
|
||||
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades the 1 package matching your search")
|
||||
|
||||
vm.searchQuery = "no-such-package"
|
||||
#expect(vm.bulkUpgradeSummary == "Upgrades every outdated package")
|
||||
#expect(String(localized: vm.bulkUpgradeSummary) == "Upgrades every outdated package")
|
||||
}
|
||||
|
||||
// MARK: - Empty upgrade action
|
||||
|
||||
@@ -32,7 +32,7 @@ struct BrewActionButtonAppearanceTests {
|
||||
isConfirming: true,
|
||||
)
|
||||
|
||||
#expect((appearance.title, appearance.systemImage) == ("Cleared", "checkmark"))
|
||||
#expect((String(localized: appearance.title), appearance.systemImage) == ("Cleared", "checkmark"))
|
||||
}
|
||||
|
||||
@Test func `a button without a confirmation title never changes`() {
|
||||
@@ -43,6 +43,6 @@ struct BrewActionButtonAppearanceTests {
|
||||
isConfirming: true,
|
||||
)
|
||||
|
||||
#expect((appearance.title, appearance.systemImage) == ("Save", "square.and.arrow.down"))
|
||||
#expect((String(localized: appearance.title), appearance.systemImage) == ("Save", "square.and.arrow.down"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user