Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ let appResources: ResourceFileElements = [
"supacode/AppIcon.icon",
"supacode/Assets.xcassets",
"supacode/notification.wav",
"supacode/Resources/Localizable.xcstrings",
]

let appBuildableFolders: [BuildableFolder] = [
Expand Down Expand Up @@ -303,6 +304,8 @@ let project = Project(
"ENABLE_HARDENED_RUNTIME": "YES",
"LD_RUNPATH_SEARCH_PATHS": "$(inherited) @executable_path/../Frameworks",
"OTHER_LDFLAGS": "$(inherited) -lc++",
// Extract SwiftUI LocalizedStringKey literals into Localizable.xcstrings at build time.
"SWIFT_EMIT_LOC_STRINGS": "YES",
],
debug: [
"CODE_SIGN_ENTITLEMENTS": "supacode/supacodeDebug.entitlements",
Expand Down
31 changes: 31 additions & 0 deletions SupacodeSettingsFeature/Reducer/SettingsFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,12 @@ public struct SettingsFeature {
public var confirmQuitMode: ConfirmQuitMode
public var terminateSessionsOnQuit: Bool
public var remoteSessionPersistenceEnabled: Bool
/// Chosen UI language. Persisted outside `GlobalSettings` because it maps to
/// the system `AppleLanguages` key and is read at the earliest launch point.
public var preferredLanguage: AppLanguage = .system
/// The language active when this process launched. When it differs from
/// `preferredLanguage`, the UI offers a relaunch to apply the change.
public var launchLanguage: AppLanguage = .system
public var cliInstallState = CLIInstallState.checking
/// Aggregate per-agent install state for the unified integration row.
public var agentIntegrationStates: [SkillAgent: AgentIntegrationRowState] = [:]
Expand All @@ -89,6 +95,12 @@ public struct SettingsFeature {
systemNotificationsEnabled || notificationSound != .never
}

/// True once the user picks a language different from the one this process
/// launched with, so the settings view can prompt for a relaunch.
public var languageNeedsRelaunch: Bool {
preferredLanguage != launchLanguage
}

public init(settings: GlobalSettings = .default) {
let normalizedDefaultEditorID = OpenWorktreeAction.normalizedDefaultEditorID(settings.defaultEditorID)
appearanceMode = settings.appearanceMode
Expand Down Expand Up @@ -174,6 +186,8 @@ public struct SettingsFeature {
case repositoriesChanged([SettingsRepositorySummary])
case setSelection(SettingsSection?)
case setSystemNotificationsEnabled(Bool)
case setPreferredLanguage(AppLanguage)
case relaunchForLanguageChangeTapped
case setAutomatedActionPolicy(AutomatedActionPolicy)
case showNotificationPermissionAlert(errorMessage: String?)
case updateShortcut(id: AppShortcutID, override: AppShortcutOverride?)
Expand Down Expand Up @@ -208,6 +222,9 @@ public struct SettingsFeature {
@CasePathable
public enum Delegate: Equatable {
case settingsChanged(GlobalSettings)
/// The user asked to relaunch so a language change takes effect. Handled by
/// the app reducer, which owns the clean-teardown relaunch.
case relaunchRequested
}

@Dependency(AnalyticsClient.self) private var analyticsClient
Expand All @@ -217,6 +234,7 @@ public struct SettingsFeature {
@Dependency(SystemNotificationClient.self) private var systemNotificationClient
@Dependency(NotificationSoundClient.self) private var notificationSoundClient
@Dependency(\.date.now) private var now
@Dependency(\.defaultAppStorage) private var defaultAppStorage

public init() {}

Expand All @@ -226,6 +244,9 @@ public struct SettingsFeature {
switch action {
case .task:
@Shared(.settingsFile) var settingsFile
let launchLanguage = AppLanguage.current(defaultAppStorage)
state.preferredLanguage = launchLanguage
state.launchLanguage = launchLanguage
return .concatenate(
.send(.settingsLoaded(settingsFile.global)),
.merge(
Expand Down Expand Up @@ -328,6 +349,16 @@ public struct SettingsFeature {
state.syncGlobalDefaults(from: state.globalSettings)
return persist(state)

case .setPreferredLanguage(let language):
state.preferredLanguage = language
// Persist the choice and mirror it into `AppleLanguages` immediately so a
// manual relaunch (not just our button) also picks up the new language.
AppLanguage.apply(language, to: defaultAppStorage)
return .none

case .relaunchForLanguageChangeTapped:
return .send(.delegate(.relaunchRequested))

case .setAutomatedActionPolicy(let policy):
state.automatedActionPolicy = policy
state.syncGlobalDefaults(from: state.globalSettings)
Expand Down
26 changes: 26 additions & 0 deletions SupacodeSettingsFeature/Views/AppearanceSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,21 @@ public struct AppearanceSettingsView: View {
Text("When off, honors your Ghostty config theme.")
}
}
Section("Language") {
Picker(selection: $store.preferredLanguage.sending(\.setPreferredLanguage)) {
ForEach(AppLanguage.allCases) { language in
languageLabel(for: language).tag(language)
}
} label: {
Text("Language")
Text("Changing the language requires relaunching Supacode.")
}
if store.languageNeedsRelaunch {
Button("Relaunch Supacode to Apply") {
store.send(.relaunchForLanguageChangeTapped)
}
}
}
Section {
Picker(selection: $store.confirmQuitMode) {
ForEach(ConfirmQuitMode.allCases, id: \.self) { mode in
Expand Down Expand Up @@ -110,4 +125,15 @@ public struct AppearanceSettingsView: View {

.navigationTitle("General")
}

/// Native names for concrete languages stay untranslated (convention); only
/// the "follow system" option is localized.
@ViewBuilder
private func languageLabel(for language: AppLanguage) -> some View {
switch language {
case .system: Text("Follow System")
case .english: Text(verbatim: "English")
case .simplifiedChinese: Text(verbatim: "简体中文")
}
}
}
54 changes: 54 additions & 0 deletions SupacodeSettingsShared/Models/AppLanguage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

/// The user's preferred UI language.
///
/// Applied by writing the `AppleLanguages` array into `UserDefaults`; because
/// AppKit resolves the bundle's localization once at process start, a change
/// only takes effect after the app relaunches. `.system` clears the override so
/// the app follows the macOS language order.
public enum AppLanguage: String, CaseIterable, Identifiable, Sendable {
case system
case english
case simplifiedChinese

public var id: String { rawValue }

/// The `AppleLanguages` override this choice writes, or `nil` to follow the
/// system (which clears any existing override).
public var appleLanguagesOverride: [String]? {
switch self {
case .system: nil
case .english: ["en"]
case .simplifiedChinese: ["zh-Hans"]
}
}

/// `UserDefaults` key persisting the choice across launches.
public static let storageKey = "preferredLanguage"
/// The system key that actually drives bundle localization resolution.
private static let appleLanguagesKey = "AppleLanguages"

/// Reads the persisted choice, defaulting to `.system`.
public static func current(_ defaults: UserDefaults = .standard) -> AppLanguage {
guard let raw = defaults.string(forKey: storageKey), let language = AppLanguage(rawValue: raw)
else { return .system }
return language
}

/// Persists the choice and writes the matching `AppleLanguages` override (or
/// clears it for `.system`). Takes effect on the next launch.
public static func apply(_ language: AppLanguage, to defaults: UserDefaults = .standard) {
defaults.set(language.rawValue, forKey: storageKey)
if let override = language.appleLanguagesOverride {
defaults.set(override, forKey: appleLanguagesKey)
} else {
defaults.removeObject(forKey: appleLanguagesKey)
}
}

/// Re-applies the persisted choice at launch so `AppleLanguages` stays in
/// sync even if it was cleared externally.
public static func syncAtLaunch(_ defaults: UserDefaults = .standard) {
apply(current(defaults), to: defaults)
}
}
30 changes: 30 additions & 0 deletions supacode/App/AppRelauncher.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import AppKit
import Foundation
import SupacodeSettingsShared

/// Relaunches the running app.
///
/// Spawns a detached shell that waits for this process to exit before reopening
/// the bundle — avoiding a second concurrent instance — then asks AppKit to
/// terminate so the normal `applicationWillTerminate` teardown (layout saves,
/// session persistence) runs first. Used to apply a language change, which only
/// takes effect on a fresh launch.
enum AppRelauncher {
@MainActor static func relaunch() {
let quotedBundlePath = "'" + Bundle.main.bundlePath.replacing("'", with: "'\\''") + "'"
let pid = ProcessInfo.processInfo.processIdentifier
let script =
"while /bin/kill -0 \(pid) >/dev/null 2>&1; do /bin/sleep 0.1; done; /usr/bin/open \(quotedBundlePath)"

let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/sh")
process.arguments = ["-c", script]
do {
try process.run()
} catch {
SupaLogger("Relaunch").error("Failed to spawn relaunch helper: \(error)")
return
}
NSApp.terminate(nil)
}
}
3 changes: 3 additions & 0 deletions supacode/App/supacodeApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ struct SupacodeApp: App {
@MainActor init() {
NSWindow.allowsAutomaticWindowTabbing = false
UserDefaults.standard.set(200, forKey: "NSInitialToolTipDelay")
// Keep `AppleLanguages` in sync with the saved language choice so the
// bundle resolves the right localization on this (and every) launch.
AppLanguage.syncAtLaunch()
// Fold the six legacy sidebar-state sources into `sidebar.json`
// before any @Shared binding observes them:
// 1. `@Shared(.appStorage("sidebarCollapsedRepositoryIDs"))`.
Expand Down
3 changes: 3 additions & 0 deletions supacode/Features/App/Reducer/AppFeature.swift
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,9 @@ struct AppFeature {
await terminalClient.send(.selectTab(worktree, tabID: tabId))
}

case .settings(.delegate(.relaunchRequested)):
return .run { _ in await AppRelauncher.relaunch() }

case .settings(.delegate(.settingsChanged(let settings))):
let shouldCheckSystemNotificationPermission =
settings.systemNotificationsEnabled && !state.lastKnownSystemNotificationsEnabled
Expand Down
5 changes: 5 additions & 0 deletions supacode/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>zh-Hans</string>
</array>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIconName</key>
Expand Down
Loading
Loading