Skip to content
Merged
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
175 changes: 175 additions & 0 deletions docs/superpowers/plans/2026-09-27-memory-cold-start.md

Large diffs are not rendered by default.

66 changes: 53 additions & 13 deletions macgit/App/AIProviderController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ final class AIProviderController: ObservableObject {
@Published private(set) var isGenerating = false
@Published private(set) var selectedProviderID: AIProviderID

@Published private(set) var availabilityRevision = 0
private var availabilityTasks: [AIProviderID: (id: UUID, task: Task<AIProviderAvailability, Never>)] = [:]
private var requestedProviderIDs: Set<AIProviderID> = []

let managedUsageController: CommitPlusAIUsageController?
private var usageObservation: AnyCancellable?
private let managedProviderAccess: () -> Bool
Expand Down Expand Up @@ -96,10 +100,6 @@ final class AIProviderController: ObservableObject {

for descriptor in registry.descriptors {
availabilityByProviderID[descriptor.id] = descriptor.isImplemented ? .checking : .comingSoon
if descriptor.billing == .bringYourOwnKey,
(try? credentialStore.apiKey(for: descriptor.id)) != nil {
configuredProviderIDs.insert(descriptor.id)
}
if descriptor.billing == .bringYourOwnKey,
let customModel = modelStore.customModel(for: descriptor.id) {
customModelsByProviderID[descriptor.id] = customModel
Expand Down Expand Up @@ -144,7 +144,7 @@ final class AIProviderController: ObservableObject {
}
selectedProviderID = id
defaults.set(id.rawValue, forKey: selectedProviderDefaultsKey)
if descriptor.billing == .commitPlus { Task { await managedUsageController?.refresh() } }
Task { await refreshAvailability(for: id) }
}

func isAPIKeyConfigured(for id: AIProviderID) -> Bool {
Expand Down Expand Up @@ -196,6 +196,7 @@ final class AIProviderController: ObservableObject {
}
try credentialStore.saveAPIKey(normalizedKey, for: id)
configuredProviderIDs.insert(id)
invalidateAvailability()
}

func removeAPIKey(for id: AIProviderID) throws {
Expand All @@ -204,6 +205,7 @@ final class AIProviderController: ObservableObject {
}
try credentialStore.deleteAPIKey(for: id)
configuredProviderIDs.remove(id)
invalidateAvailability()
if selectedProviderID == id {
selectProvider(.appleIntelligence)
}
Expand Down Expand Up @@ -244,6 +246,7 @@ final class AIProviderController: ObservableObject {
customModelsByProviderID[draft.id] = normalizedModel
}
}
if !drafts.isEmpty { invalidateAvailability() }
}

private func validateProviderAccess(_ descriptor: AIProviderDescriptor) throws {
Expand All @@ -260,18 +263,55 @@ final class AIProviderController: ObservableObject {
}
}

func refreshAvailability() async {
await withTaskGroup(of: (AIProviderID, AIProviderAvailability).self) { group in
for provider in registry.providers {
let id = provider.descriptor.id
group.addTask { (id, await provider.availability()) }
}
for await (id, availability) in group {
availabilityByProviderID[id] = availability
/// Invalidating is cheap and does not start provider work from the Welcome window.
/// Visible AI surfaces observe the revision and request their own refresh.
func invalidateAvailability() {
for request in availabilityTasks.values { request.task.cancel() }
availabilityTasks.removeAll()
for descriptor in registry.descriptors {
availabilityByProviderID[descriptor.id] = descriptor.isImplemented ? .checking : .comingSoon
}
availabilityRevision += 1
}

func refreshManagedUsageIfNeeded() async {
guard requestedProviderIDs.contains(.commitPlusAI), managedProviderAccess() else { return }
await refreshAvailability(for: .commitPlusAI)
}

func refreshAvailability(selectedOnly: Bool = false) async {
let ids = selectedOnly ? [selectedProviderID] : registry.providers.map { $0.descriptor.id }
await withTaskGroup(of: Void.self) { group in
for id in ids {
group.addTask { await self.refreshAvailability(for: id) }
}
}
}

private func refreshAvailability(for id: AIProviderID) async {
guard !Task.isCancelled, let provider = registry.provider(for: id) else { return }
requestedProviderIDs.insert(id)
let request: (id: UUID, task: Task<AIProviderAvailability, Never>)
if let pending = availabilityTasks[id] {
request = pending
} else {
if provider.descriptor.billing == .bringYourOwnKey {
if (try? credentialStore.apiKey(for: id)) != nil {
configuredProviderIDs.insert(id)
} else {
configuredProviderIDs.remove(id)
}
}
request = (UUID(), Task { await provider.availability() })
availabilityTasks[id] = request
}
let value = await request.task.value
// A changed account/key or a newer refresh must win over an old result.
guard availabilityTasks[id]?.id == request.id else { return }
availabilityTasks[id] = nil
availabilityByProviderID[id] = value
}

func generateCommitMessage(
repositoryURL: URL,
branchName: String?,
Expand Down
64 changes: 64 additions & 0 deletions macgit/App/AppCloudLifecycleController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
Comment thread
coderabbitai[bot] marked this conversation as resolved.
import Combine
import Foundation

/// Starts optional cloud work after the first window is ready and keeps it
/// process-scoped even when several SwiftUI windows request the same session.
@MainActor
final class AppCloudLifecycleController: ObservableObject {
private enum AccountRequest: Equatable {
case notStarted
case session(String?)
}

private let startFeaturePolicy: () -> Void
private let synchronizeAccount: (AccountSnapshot?) async -> Void
private var didStart = false
private var accountRequest = AccountRequest.notStarted
private var pendingAccount: (request: AccountRequest, account: AccountSnapshot?)?
private var isSynchronizingAccount = false
private var accountWaiters: [CheckedContinuation<Void, Never>] = []

init(
startFeaturePolicy: @escaping () -> Void,
synchronizeAccount: @escaping (AccountSnapshot?) async -> Void
) {
self.startFeaturePolicy = startFeaturePolicy
self.synchronizeAccount = synchronizeAccount
}

func start() {
guard !didStart else { return }
didStart = true
startFeaturePolicy()
}

func updateAccount(_ account: AccountSnapshot?) async {
let request = AccountRequest.session(account?.uid)
if accountRequest == request {
if isSynchronizingAccount {
await withCheckedContinuation { accountWaiters.append($0) }
}
return
}

accountRequest = request
pendingAccount = (request, account)
guard !isSynchronizingAccount else {
await withCheckedContinuation { accountWaiters.append($0) }
return
}

isSynchronizingAccount = true
while let pendingAccount {
self.pendingAccount = nil
// Account reconciliation mutates shared local stores. Complete the
// active session, then skip directly to the newest queued session.
await synchronizeAccount(pendingAccount.account)
}
isSynchronizingAccount = false
let waiters = accountWaiters
accountWaiters.removeAll()
waiters.forEach { $0.resume() }
}
}
10 changes: 10 additions & 0 deletions macgit/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ final class AppState: ObservableObject {
private static let historyIncludeRemotesKey = "historyIncludeRemotes"
private static let autoFetchEnabledKey = "autoFetchEnabled"
private static let refreshOnAppActiveKey = "refreshOnAppActive"
private static let fitRepositoryWindowsToScreenKey = "fitRepositoryWindowsToScreen"
private static let settingsSyncEnabledKey = "settingsSyncEnabled"
private static let searchFilterKey = "searchFilter"
private static let preferredSearchFileApplicationKey = "preferredSearchFileApplication"
Expand Down Expand Up @@ -191,6 +192,11 @@ final class AppState: ObservableObject {
}
}
}
@Published var fitRepositoryWindowsToScreen: Bool {
didSet {
userDefaults.set(fitRepositoryWindowsToScreen, forKey: Self.fitRepositoryWindowsToScreenKey)
}
}
@Published var syncEnabled: Bool {
didSet {
userDefaults.set(syncEnabled, forKey: Self.settingsSyncEnabledKey)
Expand Down Expand Up @@ -242,6 +248,9 @@ final class AppState: ObservableObject {
let historyIncludeRemotes = userDefaults.object(forKey: Self.historyIncludeRemotesKey) as? Bool ?? false
let autoFetchEnabled = userDefaults.object(forKey: Self.autoFetchEnabledKey) as? Bool ?? false
let refreshOnAppActive = userDefaults.object(forKey: Self.refreshOnAppActiveKey) as? Bool ?? true
let fitRepositoryWindowsToScreen = userDefaults.object(
forKey: Self.fitRepositoryWindowsToScreenKey
) as? Bool ?? false
let syncEnabled = userDefaults.object(forKey: Self.settingsSyncEnabledKey) as? Bool ?? false
let searchFilter = userDefaults.string(forKey: Self.searchFilterKey)
.flatMap(SearchFilter.init(rawValue:)) ?? .all
Expand Down Expand Up @@ -287,6 +296,7 @@ final class AppState: ObservableObject {
self.historyIncludeRemotes = historyIncludeRemotes
self.autoFetchEnabled = autoFetchEnabled
self.refreshOnAppActive = refreshOnAppActive
self.fitRepositoryWindowsToScreen = fitRepositoryWindowsToScreen
self.syncEnabled = syncEnabled
self.searchFilter = searchFilter
self.preferredSearchFileApplicationBundleIdentifier = preferredSearchFileApplicationBundleIdentifier
Expand Down
8 changes: 8 additions & 0 deletions macgit/App/FeatureAccessController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ final class FeatureAccessController: ObservableObject {
private let provider: FeaturePolicyProviding?
private let cache: FeaturePolicyCaching
private var observation: ObservationToken?
private var didStart = false

init(
provider: FeaturePolicyProviding?,
Expand All @@ -45,6 +46,13 @@ final class FeatureAccessController: ObservableObject {
policy = .bundled
}

}

/// The cached or bundled policy is immediately available. Live policy
/// updates begin once the first app window has finished initial setup.
func start() {
guard !didStart else { return }
didStart = true
startObservation()
}

Expand Down
12 changes: 6 additions & 6 deletions macgit/App/GitFlowConfigurationSyncController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ final class GitFlowConfigurationSyncController: ObservableObject {
let uploadID = pendingUploadID(uid: uid, repositoryID: identity.documentID)

do {
if let pendingVersion = pendingVersion(uploadID) {
if let pendingVersion = try await pendingVersion(uploadID) {
if case .value(let localConfiguration) = localResult {
try await upload(
localConfiguration,
Expand All @@ -117,7 +117,7 @@ final class GitFlowConfigurationSyncController: ObservableObject {
uid: uid
) {
let latestLocalResult = await localStore.loadResult(in: repositoryURL)
let pendingVersion = pendingVersion(uploadID)
let pendingVersion = try await pendingVersion(uploadID)
if pendingVersion != nil || localConfigurationChanged(
from: localResult,
to: latestLocalResult
Expand Down Expand Up @@ -233,20 +233,20 @@ final class GitFlowConfigurationSyncController: ObservableObject {
"\(uid)|\(repositoryID)"
}

private func pendingVersion(_ id: String) -> String? {
try? dataStore.value(String.self, in: "gitFlowPending", id: id)
private func pendingVersion(_ id: String) async throws -> String? {
try await dataStore.readValue(String.self, in: "gitFlowPending", id: id)
}

private func markPendingUpload(_ id: String) async throws -> String {
let version = UUID().uuidString
try await dataStore.transaction { transaction in
try await dataStore.transaction(reading: ["gitFlowPending"]) { transaction in
try transaction.set(version, in: "gitFlowPending", id: id)
}
return version
}

private func clearPendingUpload(_ id: String, version: String) async throws {
try await dataStore.transaction { transaction in
try await dataStore.transaction(reading: ["gitFlowPending"]) { transaction in
guard try transaction.value(String.self, in: "gitFlowPending", id: id) == version else { return }
transaction.remove(in: "gitFlowPending", id: id)
}
Expand Down
9 changes: 9 additions & 0 deletions macgit/App/GitProviderAccountController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ final class GitProviderAccountController: ObservableObject {
private let openURL: (URL) -> Bool
private let multipleAccountAccess: () -> FeatureAccessDecision
private let accountAccessPolicy = GitProviderAccountAccessPolicy()
private var cacheOwnerUID: String?
private var pendingOAuthSession: GitProviderOAuthSession?

private var accountOwnerID: String { store.accountOwnerID }
Expand Down Expand Up @@ -76,6 +77,12 @@ final class GitProviderAccountController: ObservableObject {

func updateMacgitAccount(_ account: AccountSnapshot?) async {
let previousAccounts = accounts
// Initial auth restoration (nil -> uid) should retain valid disk cache.
if let previousUID = cacheOwnerUID, previousUID != account?.uid {
accounts = []
await PullRequestDiskCache.shared.remove()
}
cacheOwnerUID = account?.uid
if account == nil {
pendingDeviceAuthorization = nil
pendingOAuthSession = nil
Expand Down Expand Up @@ -210,6 +217,8 @@ final class GitProviderAccountController: ObservableObject {
errorMessage = nil
do {
try tokenVault.deleteToken(for: account)
accounts.removeAll { $0.id == account.id }
await PullRequestDiskCache.shared.remove(accountID: account.id)
try await sshKeyStore.deleteKey(for: account)
try await store.delete(accountID: account.id)
accounts.removeAll { $0.id == account.id }
Expand Down
Loading
Loading