diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift index ff879930..ec81b132 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature+GithubIntegration.swift @@ -53,6 +53,10 @@ extension RepositoriesFeature { .cancellable(id: CancelID.delayedPRRefresh(worktreeID), cancelInFlight: true) case .repositoryPullRequestRefreshRequested(let repositoryRootURL, let worktreeIDs): + @Shared(.repositorySettings(repositoryRootURL)) var repositorySettings + guard repositorySettings.fetchesPullRequestState else { + return .none + } let worktrees = worktreeIDs.compactMap { state.worktree(for: $0) } guard let firstWorktree = worktrees.first, let repositoryID = state.repositoryID(containing: firstWorktree.id) diff --git a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift index 40736ceb..ce1a587b 100644 --- a/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift +++ b/supacode/Features/Repositories/Reducer/RepositoriesFeature.swift @@ -1189,6 +1189,10 @@ struct RepositoriesFeature { guard let worktree = state.worktree(for: worktreeID) else { return .none } + @Shared(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings + guard repositorySettings.observesLineDiffsAutomatically else { + return .none + } let worktreeURL = worktree.workingDirectory let gitClient = gitClient let previousLineChanges = normalizedLineChanges(state.worktreeInfoByID[worktreeID]) diff --git a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift index 6d4b8388..01b960e9 100644 --- a/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift +++ b/supacode/Features/RepositorySettings/Reducer/RepositorySettingsFeature.swift @@ -39,10 +39,18 @@ struct RepositorySettingsFeature { capabilities.supportsWorktrees } + var showsDiffSettings: Bool { + capabilities.supportsDiff + } + var showsPullRequestSettings: Bool { capabilities.supportsPullRequests } + var showsDiffsAndPullRequestSettings: Bool { + showsDiffSettings || showsPullRequestSettings + } + var showsSetupScriptSettings: Bool { capabilities.supportsWorktrees } diff --git a/supacode/Features/Settings/Models/RepositorySettings.swift b/supacode/Features/Settings/Models/RepositorySettings.swift index 79ceb7b0..fe808511 100644 --- a/supacode/Features/Settings/Models/RepositorySettings.swift +++ b/supacode/Features/Settings/Models/RepositorySettings.swift @@ -16,6 +16,14 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { var copyUntrackedOnWorktreeCreate: Bool? var pullRequestMergeStrategy: PullRequestMergeStrategy? var customTitle: String? + /// When `nil` (unset) or `true`, Prowl keeps the worktree line-change badges + /// up to date in the background. Set to `false` to skip the periodic `git diff` + /// work for large repositories. + var observeLineDiffsAutomatically: Bool? + /// When `nil` (unset) or `true`, Prowl periodically fetches pull request state + /// for this repository's branches. Set to `false` to skip background GitHub + /// queries (saving API rate-limit budget). + var fetchPullRequestState: Bool? private var schemaVersion: Int private enum CodingKeys: String, CodingKey { @@ -30,6 +38,8 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { case copyUntrackedOnWorktreeCreate case pullRequestMergeStrategy case customTitle + case observeLineDiffsAutomatically + case fetchPullRequestState } static let `default` = RepositorySettings( @@ -42,7 +52,9 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { copyIgnoredOnWorktreeCreate: nil, copyUntrackedOnWorktreeCreate: nil, pullRequestMergeStrategy: nil, - customTitle: nil + customTitle: nil, + observeLineDiffsAutomatically: nil, + fetchPullRequestState: nil ) init( @@ -55,7 +67,9 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { copyIgnoredOnWorktreeCreate: Bool? = nil, copyUntrackedOnWorktreeCreate: Bool? = nil, pullRequestMergeStrategy: PullRequestMergeStrategy? = nil, - customTitle: String? = nil + customTitle: String? = nil, + observeLineDiffsAutomatically: Bool? = nil, + fetchPullRequestState: Bool? = nil ) { self.setupScript = setupScript self.archiveScript = archiveScript @@ -67,6 +81,8 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { self.copyUntrackedOnWorktreeCreate = copyUntrackedOnWorktreeCreate self.pullRequestMergeStrategy = pullRequestMergeStrategy self.customTitle = customTitle + self.observeLineDiffsAutomatically = observeLineDiffsAutomatically + self.fetchPullRequestState = fetchPullRequestState schemaVersion = Self.currentSchemaVersion } @@ -93,6 +109,10 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { try container.decodeIfPresent(String.self, forKey: .worktreeBaseDirectoryPath) customTitle = try container.decodeIfPresent(String.self, forKey: .customTitle) + observeLineDiffsAutomatically = + try container.decodeIfPresent(Bool.self, forKey: .observeLineDiffsAutomatically) + fetchPullRequestState = + try container.decodeIfPresent(Bool.self, forKey: .fetchPullRequestState) if decodedSchemaVersion >= Self.currentSchemaVersion { copyIgnoredOnWorktreeCreate = try container.decodeIfPresent( @@ -137,6 +157,18 @@ nonisolated struct RepositorySettings: Codable, Equatable, Sendable { } extension RepositorySettings { + /// Resolved value for background line-change observation. Defaults to `true` + /// when the override is unset. + var observesLineDiffsAutomatically: Bool { + observeLineDiffsAutomatically ?? true + } + + /// Resolved value for background pull request state fetching. Defaults to + /// `true` when the override is unset. + var fetchesPullRequestState: Bool { + fetchPullRequestState ?? true + } + nonisolated private static func normalizeLegacyOverride( _ value: Value?, legacyDefault: Value diff --git a/supacode/Features/Settings/Views/RepositorySettingsView.swift b/supacode/Features/Settings/Views/RepositorySettingsView.swift index 51202dd7..a8683197 100644 --- a/supacode/Features/Settings/Views/RepositorySettingsView.swift +++ b/supacode/Features/Settings/Views/RepositorySettingsView.swift @@ -67,6 +67,14 @@ struct RepositorySettingsView: View { get: { settings.customTitle.wrappedValue ?? "" }, set: { settings.customTitle.wrappedValue = $0 }, ) + let observeLineDiffsAutomatically = Binding( + get: { settings.observeLineDiffsAutomatically.wrappedValue ?? true }, + set: { settings.observeLineDiffsAutomatically.wrappedValue = $0 }, + ) + let fetchPullRequestState = Binding( + get: { settings.fetchPullRequestState.wrappedValue ?? true }, + set: { settings.fetchPullRequestState.wrappedValue = $0 }, + ) let exampleWorktreePath = store.exampleWorktreePath let folderName = Repository.name(for: store.rootURL) @@ -185,24 +193,52 @@ struct RepositorySettingsView: View { } } - if store.showsPullRequestSettings { + if store.showsDiffsAndPullRequestSettings { Section { - Picker(selection: settings.pullRequestMergeStrategy) { - Text( - "Global \(Text(store.globalPullRequestMergeStrategy.title).foregroundStyle(.secondary))" + if store.showsDiffSettings { + Toggle(isOn: observeLineDiffsAutomatically) { + Text("Observe line diffs automatically") + Text( + "Keeps each workspace's line-change badge up to date in the background. " + + "Turn off for very large repositories to avoid background git diff work." + ) + } + .help( + "Refresh workspace line-change badges automatically. " + + "Disable to skip background git diff for large repositories." ) - .tag(PullRequestMergeStrategy?.none) - ForEach(PullRequestMergeStrategy.allCases) { strategy in - Text(strategy.title).tag(PullRequestMergeStrategy?.some(strategy)) + } + + if store.showsPullRequestSettings { + Toggle(isOn: fetchPullRequestState) { + Text("Fetch pull request state") + Text( + "Periodically checks pull request status (open, merged, checks) for this repository's branches. " + + "Turn off to skip background GitHub queries." + ) + } + .help( + "Fetch pull request status for this repository's branches. " + + "Disable to skip background GitHub queries and save API rate limit." + ) + + Picker(selection: settings.pullRequestMergeStrategy) { + Text( + "Global \(Text(store.globalPullRequestMergeStrategy.title).foregroundStyle(.secondary))" + ) + .tag(PullRequestMergeStrategy?.none) + ForEach(PullRequestMergeStrategy.allCases) { strategy in + Text(strategy.title).tag(PullRequestMergeStrategy?.some(strategy)) + } + } label: { + Text("Merge strategy") + Text("Used when merging PRs from the command palette.") } - } label: { - Text("Merge strategy") - Text("Used when merging PRs from the command palette.") } } header: { VStack(alignment: .leading, spacing: 4) { - Text("Pull Requests") - Text("Used when merging PRs from the command palette") + Text("Diffs & Pull Requests") + Text("Background refresh of line-change badges and pull request status") .foregroundStyle(.secondary) } } diff --git a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift index 0daa3f27..0a3e3918 100644 --- a/supacodeTests/BatchedPullRequestRefreshReducerTests.swift +++ b/supacodeTests/BatchedPullRequestRefreshReducerTests.swift @@ -169,6 +169,41 @@ struct BatchedPullRequestRefreshReducerTests { } await store.finish() } + + @Test(.dependencies) func refreshSkippedWhenPullRequestStateFetchDisabled() async { + let context = makeContext() + let enqueued = LockIsolated<[PullRequestRefreshCoordinator.Request]>([]) + var initialState = context.state + initialState.remoteInfoByRepositoryID[context.repository.id] = context.remoteInfo + + @Shared(.repositorySettings(context.repoRootURL)) var repositorySettings + $repositorySettings.withLock { $0.fetchPullRequestState = false } + + let store = TestStore(initialState: initialState) { + RepositoriesFeature() + } withDependencies: { + $0.pullRequestRefreshCoordinator = PullRequestRefreshCoordinatorClient( + enqueue: { request in + enqueued.withValue { $0.append(request) } + }, + cancelHost: { _ in }, + reset: {} + ) + } + + await store.send( + .worktreeInfoEvent( + .repositoryPullRequestRefresh( + repositoryRootURL: context.repoRootURL, + worktreeIDs: context.worktreeIDs + ) + ) + ) + await store.receive(\.githubIntegration.repositoryPullRequestRefreshRequested) + await store.finish() + + #expect(enqueued.value.isEmpty) + } } // MARK: - Fixtures diff --git a/supacodeTests/RepositoriesFeatureTests.swift b/supacodeTests/RepositoriesFeatureTests.swift index 0774d03a..aef64e4b 100644 --- a/supacodeTests/RepositoriesFeatureTests.swift +++ b/supacodeTests/RepositoriesFeatureTests.swift @@ -263,6 +263,35 @@ struct RepositoriesFeatureTests { } } + @Test(.dependencies) func filesChangedSkipsLineChangesWhenObservationDisabled() async { + let worktree = makeWorktree(id: "/tmp/repo/feature", name: "feature", repoRoot: "/tmp/repo") + let repository = makeRepository(id: "/tmp/repo", worktrees: [worktree]) + var state = makeState(repositories: [repository]) + state.worktreeInfoByID[worktree.id] = WorktreeInfoEntry( + addedLines: 12, + removedLines: 4, + pullRequest: nil + ) + + @Shared(.repositorySettings(repository.rootURL)) var repositorySettings + $repositorySettings.withLock { $0.observeLineDiffsAutomatically = false } + + let lineChangeRequests = LockIsolated(0) + let store = TestStore(initialState: state) { + RepositoriesFeature() + } withDependencies: { + $0.gitClient.lineChanges = { _ in + lineChangeRequests.withValue { $0 += 1 } + return (15, 9) + } + } + + await store.send(.worktreeInfoEvent(.filesChanged(worktreeID: worktree.id))) + await store.finish() + + #expect(lineChangeRequests.value == 0) + } + @Test func repositoryWorktreesChangedReloadsRepositories() async { let existingWorktree = makeWorktree(id: "/tmp/repo/main", name: "main") let discoveredWorktree = makeWorktree(id: "/tmp/repo/feature", name: "feature") diff --git a/supacodeTests/RepositorySettingsKeyTests.swift b/supacodeTests/RepositorySettingsKeyTests.swift index 847219c4..ee0497d5 100644 --- a/supacodeTests/RepositorySettingsKeyTests.swift +++ b/supacodeTests/RepositorySettingsKeyTests.swift @@ -136,6 +136,38 @@ struct RepositorySettingsKeyTests { #expect(decoded.pullRequestMergeStrategy == .merge) } + @Test func decodeMissingObservationOverridesDefaultsToEnabled() throws { + let data = Data( + """ + { + "setupScript": "", + "archiveScript": "", + "runScript": "echo run", + "openActionID": "automatic" + } + """.utf8 + ) + let settings = try JSONDecoder().decode(RepositorySettings.self, from: data) + + #expect(settings.observeLineDiffsAutomatically == nil) + #expect(settings.fetchPullRequestState == nil) + #expect(settings.observesLineDiffsAutomatically) + #expect(settings.fetchesPullRequestState) + } + + @Test func decodePreservesExplicitObservationOverrides() throws { + var settings = RepositorySettings.default + settings.observeLineDiffsAutomatically = false + settings.fetchPullRequestState = false + + let decoded = try JSONDecoder().decode(RepositorySettings.self, from: encode(settings)) + + #expect(decoded.observeLineDiffsAutomatically == false) + #expect(decoded.fetchPullRequestState == false) + #expect(!decoded.observesLineDiffsAutomatically) + #expect(!decoded.fetchesPullRequestState) + } + @Test(.dependencies) func loadPrefersLocalSupacodeJSONOverGlobalEntry() throws { let globalStorage = SettingsTestStorage() let localStorage = RepositoryLocalSettingsTestStorage()