* Add i18n infrastructure with String Catalog and Japanese translations Introduce String Catalog (.xcstrings) for localization support: - Localizable.xcstrings: 195 UI string entries with en and ja translations - InfoPlist.xcstrings: Info.plist strings (microphone usage, Finder menu items) - project.pbxproj: add xcstrings to build phase and ja to knownRegions * Replace hardcoded UI strings with String(localized:defaultValue:) Migrate all user-facing strings across 11 source files to use String(localized:defaultValue:) API (macOS 13+). Each string references a key in Localizable.xcstrings, with the English text preserved as defaultValue for fallback. Files modified: - KeyboardShortcutSettings: 28 shortcut labels - SocketControlSettings: mode names and descriptions - TabManager: placement labels, color names, close dialogs - BrowserPanel/BrowserPanelView: error pages, context menus, tooltips - UpdateViewModel/UpdatePopoverView/UpdatePill: update UI states - NotificationsPage: notification panel labels - SurfaceSearchOverlay: search bar placeholder and tooltips - AppDelegate: menus, dialogs, command palette items * Fix localization gaps from review feedback Address review comments from CodeRabbit, Greptile, and Cubic Dev AI: - Use interpolated String(localized:) instead of concatenation for version/progress strings in UpdateViewModel - Localize remaining hardcoded strings in AppDelegate: window labels, rename dialog, status menu items, unread notification count - Localize insecure HTTP alert body in BrowserPanel - Add 12 new entries to Localizable.xcstrings with Japanese translations * Fix String(localized:defaultValue:) keys to use StaticString The localized: parameter requires StaticString when defaultValue: is used. Move string interpolation from the key to defaultValue only, and revert maxWidthText to plain strings since they are only used for layout width calculation. * Localize remaining UI strings across all source files Add String(localized:defaultValue:) to all user-facing strings in: - cmuxApp.swift: settings screen, menus, about panel, dialogs (~180 strings) - ContentView.swift: command palette, sidebar context menu, dialogs (~200 strings) - Workspace.swift: rename/move/close tab dialogs, tooltips (~20 strings) - UpdateTitlebarAccessory.swift: titlebar tooltips, notifications popover (~10 strings) - TerminalNotificationStore.swift: notification permission dialog (4 strings) - CmuxWebView.swift: browser context menu items (2 strings) - AppDelegate.swift: CLI install/uninstall alerts (6 strings) Add 418 new entries to Localizable.xcstrings with Japanese translations. Extract sidebar context menu into separate @ViewBuilder to fix Swift type-checker timeout in large body. Fix xcstrings format specifiers for interpolated strings (%lld, %@). Total: 624 localization entries covering the full UI. * Address review feedback: fix missing localizations and terminology - Localize javaScriptDialogTitle URL branch in BrowserPanel - Localize cantReach error message in BrowserPanel - Localize close other tabs dialog message in TabManager - Localize workspace accessibility label in ContentView - Fix unread notification singular/plural (split into two keys) - Fix insecure connection apostrophe inconsistency (unify to U+2019) - Rename socketControl.fullOpen.description to socketControl.allowAll.description - Remove dead code: renameTargetNoun function - Fix terminology inconsistencies in xcstrings: - Unify "Developer Tools" to デベロッパツール - Unify "Jump to Latest Unread" phrasing - Unify "Flash Focused Panel" terminology - Fix dialog.enableNotifications.notNow translation * fix: address remaining PR 819 review feedback * fix: use a single localized key for close-other-tabs * fix: avoid inflection markup in close-other-tabs message * Address review feedback: localize tooltip, fix subtitle concat, unify keys - Localize menubar tooltip unread count (hardcoded English -> localized) - Replace subtitle string concatenation anti-pattern with single localized keys containing interpolation placeholders - Unify workspace fallback key to workspace.displayName.fallback - Remove unused workspace.defaultName key from xcstrings - Add Japanese translations for new tooltip and subtitle keys
254 lines
9.7 KiB
Swift
254 lines
9.7 KiB
Swift
import SwiftUI
|
|
|
|
struct NotificationsPage: View {
|
|
@EnvironmentObject var notificationStore: TerminalNotificationStore
|
|
@EnvironmentObject var tabManager: TabManager
|
|
@Binding var selection: SidebarSelection
|
|
@FocusState private var focusedNotificationId: UUID?
|
|
@AppStorage(KeyboardShortcutSettings.Action.jumpToUnread.defaultsKey) private var jumpToUnreadShortcutData = Data()
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
header
|
|
Divider()
|
|
|
|
if notificationStore.notifications.isEmpty {
|
|
emptyState
|
|
} else {
|
|
ScrollView {
|
|
LazyVStack(spacing: 8) {
|
|
ForEach(notificationStore.notifications) { notification in
|
|
NotificationRow(
|
|
notification: notification,
|
|
tabTitle: tabTitle(for: notification.tabId),
|
|
onOpen: {
|
|
// SwiftUI action closures are not guaranteed to run on the main actor.
|
|
// Ensure window focus + tab selection happens on the main thread.
|
|
DispatchQueue.main.async {
|
|
_ = AppDelegate.shared?.openNotification(
|
|
tabId: notification.tabId,
|
|
surfaceId: notification.surfaceId,
|
|
notificationId: notification.id
|
|
)
|
|
selection = .tabs
|
|
}
|
|
},
|
|
onClear: {
|
|
notificationStore.remove(id: notification.id)
|
|
},
|
|
focusedNotificationId: $focusedNotificationId
|
|
)
|
|
}
|
|
}
|
|
.padding(16)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color(nsColor: .windowBackgroundColor))
|
|
.onAppear(perform: setInitialFocus)
|
|
.onChange(of: notificationStore.notifications.first?.id) { _ in
|
|
setInitialFocus()
|
|
}
|
|
}
|
|
|
|
private func setInitialFocus() {
|
|
// Only set focus when the notifications page is visible
|
|
// to avoid stealing focus from the terminal when notifications arrive
|
|
guard selection == .notifications else { return }
|
|
guard let firstId = notificationStore.notifications.first?.id else {
|
|
focusedNotificationId = nil
|
|
return
|
|
}
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
|
|
focusedNotificationId = firstId
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack {
|
|
Text(String(localized: "notifications.title", defaultValue: "Notifications"))
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
Spacer()
|
|
|
|
if !notificationStore.notifications.isEmpty {
|
|
jumpToUnreadButton
|
|
|
|
Button(String(localized: "notifications.clearAll", defaultValue: "Clear All")) {
|
|
notificationStore.clearAll()
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 8) {
|
|
Image(systemName: "bell.slash")
|
|
.font(.system(size: 32))
|
|
.foregroundColor(.secondary)
|
|
Text(String(localized: "notifications.empty.title", defaultValue: "No notifications yet"))
|
|
.font(.headline)
|
|
Text(String(localized: "notifications.empty.description", defaultValue: "Desktop notifications will appear here for quick review."))
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var jumpToUnreadButton: some View {
|
|
if let key = jumpToUnreadShortcut.keyEquivalent {
|
|
Button(action: {
|
|
AppDelegate.shared?.jumpToLatestUnread()
|
|
}) {
|
|
HStack(spacing: 6) {
|
|
Text(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))
|
|
ShortcutAnnotation(text: jumpToUnreadShortcut.displayString)
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.keyboardShortcut(key, modifiers: jumpToUnreadShortcut.eventModifiers)
|
|
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
|
|
.disabled(!hasUnreadNotifications)
|
|
} else {
|
|
Button(action: {
|
|
AppDelegate.shared?.jumpToLatestUnread()
|
|
}) {
|
|
HStack(spacing: 6) {
|
|
Text(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread"))
|
|
ShortcutAnnotation(text: jumpToUnreadShortcut.displayString)
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.help(KeyboardShortcutSettings.Action.jumpToUnread.tooltip(String(localized: "notifications.jumpToLatestUnread", defaultValue: "Jump to Latest Unread")))
|
|
.disabled(!hasUnreadNotifications)
|
|
}
|
|
}
|
|
|
|
private var jumpToUnreadShortcut: StoredShortcut {
|
|
decodeShortcut(
|
|
from: jumpToUnreadShortcutData,
|
|
fallback: KeyboardShortcutSettings.Action.jumpToUnread.defaultShortcut
|
|
)
|
|
}
|
|
|
|
private var hasUnreadNotifications: Bool {
|
|
notificationStore.notifications.contains(where: { !$0.isRead })
|
|
}
|
|
|
|
private func decodeShortcut(from data: Data, fallback: StoredShortcut) -> StoredShortcut {
|
|
guard !data.isEmpty,
|
|
let shortcut = try? JSONDecoder().decode(StoredShortcut.self, from: data) else {
|
|
return fallback
|
|
}
|
|
return shortcut
|
|
}
|
|
|
|
private func tabTitle(for tabId: UUID) -> String? {
|
|
AppDelegate.shared?.tabTitle(for: tabId) ?? tabManager.tabs.first(where: { $0.id == tabId })?.title
|
|
}
|
|
}
|
|
|
|
private struct ShortcutAnnotation: View {
|
|
let text: String
|
|
|
|
var body: some View {
|
|
Text(text)
|
|
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(.primary)
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 2)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 5)
|
|
.fill(Color(nsColor: .controlBackgroundColor))
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct NotificationRow: View {
|
|
let notification: TerminalNotification
|
|
let tabTitle: String?
|
|
let onOpen: () -> Void
|
|
let onClear: () -> Void
|
|
let focusedNotificationId: FocusState<UUID?>.Binding
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Button(action: onOpen) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Circle()
|
|
.fill(notification.isRead ? Color.clear : cmuxAccentColor())
|
|
.frame(width: 8, height: 8)
|
|
.overlay(
|
|
Circle()
|
|
.stroke(cmuxAccentColor().opacity(notification.isRead ? 0.2 : 1), lineWidth: 1)
|
|
)
|
|
.padding(.top, 6)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(notification.title)
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
Spacer()
|
|
Text(notification.createdAt.formatted(date: .omitted, time: .shortened))
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
|
|
if !notification.body.isEmpty {
|
|
Text(notification.body)
|
|
.font(.subheadline)
|
|
.foregroundColor(.secondary)
|
|
.lineLimit(3)
|
|
}
|
|
|
|
if let tabTitle {
|
|
Text(tabTitle)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.trailing, 6)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityIdentifier("NotificationRow.\(notification.id.uuidString)")
|
|
.focusable()
|
|
.focused(focusedNotificationId, equals: notification.id)
|
|
.modifier(DefaultActionModifier(isActive: focusedNotificationId.wrappedValue == notification.id))
|
|
|
|
Button(action: onClear) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding(12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 10)
|
|
.fill(Color(nsColor: .controlBackgroundColor))
|
|
)
|
|
}
|
|
}
|
|
|
|
private struct DefaultActionModifier: ViewModifier {
|
|
let isActive: Bool
|
|
|
|
func body(content: Content) -> some View {
|
|
if isActive {
|
|
content.keyboardShortcut(.defaultAction)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|