From b2b1c97798f586a332ce3069930a5f4c98ceefd6 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sun, 4 Oct 2026 14:12:31 +0700 Subject: [PATCH 1/8] feat: Add custom actions for repository, file, and commit workflows Introduce shared menu and settings surfaces for defining and running reusable actions, with Firebase-backed sync and execution output sheets. --- macgit/App/CustomActionCommandState.swift | 21 ++ macgit/App/macgitApp.swift | 37 ++ macgit/Models/CustomActionDefinition.swift | 127 +++++++ .../Models/CustomActionExecutionResult.swift | 36 ++ .../CustomActionOutputPresentation.swift | 8 + .../Services/CustomActionArgumentParser.swift | 199 +++++++++++ macgit/Services/CustomActionCloudStore.swift | 15 + macgit/Services/CustomActionExecutor.swift | 336 ++++++++++++++++++ macgit/Services/CustomActionStore.swift | 208 +++++++++++ .../Services/FirestoreCustomActionStore.swift | 155 ++++++++ macgit/ViewModels/CustomActionDraft.swift | 60 ++++ .../Views/Common/AppSettingsDetailView.swift | 3 + macgit/Views/Common/AppSettingsSection.swift | 6 + macgit/Views/Common/AppSettingsView.swift | 4 + .../Common/CustomActionEditorSheet.swift | 164 +++++++++ .../Common/CustomActionMenuContent.swift | 41 +++ .../Common/CustomActionOutputSheet.swift | 78 ++++ .../Common/CustomActionSettingsRow.swift | 56 +++ .../Common/CustomActionsSettingsView.swift | 117 ++++++ macgit/Views/FileStatus/FileStatusView.swift | 21 ++ macgit/Views/History/HistoryView.swift | 26 +- macgit/Views/MainWindow/MainWindowView.swift | 123 ++++++- macgitTests/CustomActionTests.swift | 125 +++++++ 23 files changed, 1963 insertions(+), 3 deletions(-) create mode 100644 macgit/App/CustomActionCommandState.swift create mode 100644 macgit/Models/CustomActionDefinition.swift create mode 100644 macgit/Models/CustomActionExecutionResult.swift create mode 100644 macgit/Models/CustomActionOutputPresentation.swift create mode 100644 macgit/Services/CustomActionArgumentParser.swift create mode 100644 macgit/Services/CustomActionCloudStore.swift create mode 100644 macgit/Services/CustomActionExecutor.swift create mode 100644 macgit/Services/CustomActionStore.swift create mode 100644 macgit/Services/FirestoreCustomActionStore.swift create mode 100644 macgit/ViewModels/CustomActionDraft.swift create mode 100644 macgit/Views/Common/CustomActionEditorSheet.swift create mode 100644 macgit/Views/Common/CustomActionMenuContent.swift create mode 100644 macgit/Views/Common/CustomActionOutputSheet.swift create mode 100644 macgit/Views/Common/CustomActionSettingsRow.swift create mode 100644 macgit/Views/Common/CustomActionsSettingsView.swift create mode 100644 macgitTests/CustomActionTests.swift diff --git a/macgit/App/CustomActionCommandState.swift b/macgit/App/CustomActionCommandState.swift new file mode 100644 index 00000000..9c3c91ed --- /dev/null +++ b/macgit/App/CustomActionCommandState.swift @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import SwiftUI + +struct CustomActionCommandState { + let context: CustomActionInvocationContext + let surface: CustomActionInvocationSurface + let hasActiveOperation: Bool + let run: (UUID, CustomActionInvocationSurface) -> Void +} + +struct CustomActionCommandStateKey: FocusedValueKey { + typealias Value = CustomActionCommandState +} + +extension FocusedValues { + var customActionCommandState: CustomActionCommandState? { + get { self[CustomActionCommandStateKey.self] } + set { self[CustomActionCommandStateKey.self] = newValue } + } +} diff --git a/macgit/App/macgitApp.swift b/macgit/App/macgitApp.swift index 595e0f71..669157d9 100644 --- a/macgit/App/macgitApp.swift +++ b/macgit/App/macgitApp.swift @@ -30,11 +30,13 @@ struct macgitApp: App { @StateObject private var featureAccessController: FeatureAccessController @StateObject private var repositoryVisibilityController: RepositoryVisibilityController @StateObject private var repositoryBookmarkController: RepositoryBookmarkController + @StateObject private var customActionStore: CustomActionStore @StateObject private var gitFlowConfigurationSyncController: GitFlowConfigurationSyncController @StateObject private var cloudLifecycleController: AppCloudLifecycleController @State private var selectedAppSettingsSection: AppSettingsSection = .general private let repositoryWindowLifecycleController = RepositoryWindowLifecycleController() @FocusedValue(\.repositoryWindowCommandState) private var repositoryWindowCommandState + @FocusedValue(\.customActionCommandState) private var customActionCommandState init() { NSWindow.allowsAutomaticWindowTabbing = true @@ -139,6 +141,11 @@ struct macgitApp: App { : nil ) _repositoryBookmarkController = StateObject(wrappedValue: repositoryBookmarkController) + _customActionStore = StateObject( + wrappedValue: CustomActionStore( + cloudStore: cloudFeaturesEnabled ? FirestoreCustomActionStore() : nil + ) + ) _gitFlowConfigurationSyncController = StateObject( wrappedValue: GitFlowConfigurationSyncController( cloudStore: cloudFeaturesEnabled @@ -238,6 +245,7 @@ struct macgitApp: App { .environmentObject(featureAccessController) .environmentObject(repositoryVisibilityController) .environmentObject(repositoryBookmarkController) + .environmentObject(customActionStore) .environmentObject(gitFlowConfigurationSyncController) .preferredColorScheme(appState.appearance.colorScheme) .font(appState.textSize.font) @@ -249,6 +257,12 @@ struct macgitApp: App { cloudLifecycleController.start() await cloudLifecycleController.updateAccount(accountController.account) } + .task(id: customActionSyncTaskID) { + await customActionStore.updateCloudSession( + uid: accountController.account?.uid, + enabled: appState.syncEnabled + ) + } .onChange(of: accountController.account?.uid, initial: true) { _, uid in aiProviderController.managedUsageController?.setSession(uid: uid) } @@ -395,6 +409,24 @@ struct macgitApp: App { Divider() + Menu("Custom Actions") { + if let state = customActionCommandState { + CustomActionMenuContent( + store: customActionStore, + surface: state.surface, + context: state.context, + hasActiveOperation: state.hasActiveOperation, + includesRepositoryActions: true, + onRun: state.run + ) + } else { + Text("No Repository Open") + } + } + .disabled(customActionCommandState == nil) + + Divider() + Button("Search...") { WindowScopedNotification.post(name: .showSearchModal) } @@ -457,6 +489,7 @@ struct macgitApp: App { providerAccountController: providerAccountController, aiProviderController: aiProviderController, appUpdateController: appUpdateController, + customActionStore: customActionStore, selectedSection: $selectedAppSettingsSection ) .environmentObject(featureAccessController) @@ -468,4 +501,8 @@ struct macgitApp: App { .defaultLaunchBehavior(.suppressed) .windowResizability(.contentMinSize) } + + private var customActionSyncTaskID: String { + "\(accountController.account?.uid ?? "guest"):\(appState.syncEnabled)" + } } diff --git a/macgit/Models/CustomActionDefinition.swift b/macgit/Models/CustomActionDefinition.swift new file mode 100644 index 00000000..10daee26 --- /dev/null +++ b/macgit/Models/CustomActionDefinition.swift @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import CryptoKit +import Foundation + +struct CustomActionAvailability: OptionSet, Codable, Hashable, Sendable { + let rawValue: Int + + static let repository = Self(rawValue: 1 << 0) + static let selectedFiles = Self(rawValue: 1 << 1) + static let selectedCommits = Self(rawValue: 1 << 2) +} + +enum CustomActionSourceKind: String, Codable, CaseIterable, Sendable { + case executable + case localScript + case syncedScript +} + +enum CustomActionScriptLanguage: String, Codable, CaseIterable, Sendable { + case sh + case bash + case zsh + case python + + var displayName: String { + switch self { + case .sh: "Shell" + case .bash: "Bash" + case .zsh: "Zsh" + case .python: "Python" + } + } + + static func inferred(from url: URL) -> Self? { + switch url.pathExtension.lowercased() { + case "sh": .sh + case "bash": .bash + case "zsh": .zsh + case "py": .python + default: nil + } + } +} + +struct CustomActionDefinition: Codable, Identifiable, Equatable, Hashable, Sendable { + static let schemaVersion = 1 + + var id: UUID + var name: String + var sourceKind: CustomActionSourceKind + var executablePath: String + var scriptLanguage: CustomActionScriptLanguage? + var scriptSource: String? + var sourceFileName: String? + var arguments: [String] + var availability: CustomActionAvailability + var isEnabled: Bool + var alwaysShowOutput: Bool + var sortIndex: Int + + init( + id: UUID = UUID(), + name: String, + sourceKind: CustomActionSourceKind = .executable, + executablePath: String, + scriptLanguage: CustomActionScriptLanguage? = nil, + scriptSource: String? = nil, + sourceFileName: String? = nil, + arguments: [String] = [], + availability: CustomActionAvailability = [.repository], + isEnabled: Bool = true, + alwaysShowOutput: Bool = false, + sortIndex: Int = 0 + ) { + self.id = id + self.name = name + self.sourceKind = sourceKind + self.executablePath = executablePath + self.scriptLanguage = scriptLanguage + self.scriptSource = scriptSource + self.sourceFileName = sourceFileName + self.arguments = arguments + self.availability = availability + self.isEnabled = isEnabled + self.alwaysShowOutput = alwaysShowOutput + self.sortIndex = sortIndex + } + + var trustFingerprint: String { + let payload = [ + sourceKind.rawValue, + executablePath, + scriptLanguage?.rawValue ?? "", + scriptSource ?? "", + arguments.joined(separator: "\u{0}"), + ].joined(separator: "\u{1f}") + return SHA256.hash(data: Data(payload.utf8)).map { String(format: "%02x", $0) }.joined() + } + + var duplicated: Self { + var copy = self + copy.id = UUID() + copy.name = "\(name) Copy" + return copy + } +} + +struct CustomActionInvocationContext: Equatable, Sendable { + let repositoryURL: URL + var filePaths: [String] + var commitHashes: [String] +} + +enum CustomActionInvocationSurface: Sendable { + case repository + case selectedFiles + case selectedCommits + + var availability: CustomActionAvailability { + switch self { + case .repository: .repository + case .selectedFiles: .selectedFiles + case .selectedCommits: .selectedCommits + } + } +} diff --git a/macgit/Models/CustomActionExecutionResult.swift b/macgit/Models/CustomActionExecutionResult.swift new file mode 100644 index 00000000..f8231827 --- /dev/null +++ b/macgit/Models/CustomActionExecutionResult.swift @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +enum CustomActionExecutionStatus: Equatable, Sendable { + case succeeded + case failed + case cancelled + + var title: String { + switch self { + case .succeeded: "Succeeded" + case .failed: "Failed" + case .cancelled: "Cancelled" + } + } +} + +struct CustomActionExecutionResult: Equatable, Sendable { + let action: CustomActionDefinition + let context: CustomActionInvocationContext + let status: CustomActionExecutionStatus + let exitCode: Int32? + let standardOutput: String + let standardError: String + let isStandardOutputTruncated: Bool + let isStandardErrorTruncated: Bool + let duration: TimeInterval + + var copiedOutput: String { + var sections: [String] = [] + if !standardOutput.isEmpty { sections.append("stdout:\n\(standardOutput)") } + if !standardError.isEmpty { sections.append("stderr:\n\(standardError)") } + return sections.joined(separator: "\n\n") + } +} diff --git a/macgit/Models/CustomActionOutputPresentation.swift b/macgit/Models/CustomActionOutputPresentation.swift new file mode 100644 index 00000000..906c2c8b --- /dev/null +++ b/macgit/Models/CustomActionOutputPresentation.swift @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +struct CustomActionOutputPresentation: Identifiable { + let id = UUID() + let result: CustomActionExecutionResult +} diff --git a/macgit/Services/CustomActionArgumentParser.swift b/macgit/Services/CustomActionArgumentParser.swift new file mode 100644 index 00000000..284f59b8 --- /dev/null +++ b/macgit/Services/CustomActionArgumentParser.swift @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +enum CustomActionArgumentParserError: LocalizedError, Equatable { + case unterminatedQuote + case trailingEscape + + var errorDescription: String? { + switch self { + case .unterminatedQuote: "Arguments contain an unterminated quote." + case .trailingEscape: "Arguments end with an incomplete escape." + } + } +} + +enum CustomActionArgumentParser { + private enum Quote { + case single + case double + } + + static func parse(_ value: String) throws -> [String] { + var arguments: [String] = [] + var current = "" + var quote: Quote? + var isEscaping = false + var hasToken = false + + for character in value { + if isEscaping { + current.append(character) + isEscaping = false + hasToken = true + continue + } + + if character == "\\", quote != .single { + isEscaping = true + hasToken = true + continue + } + + switch (quote, character) { + case (.single, "'"): + quote = nil + case (.double, "\""): + quote = nil + case (nil, "'"): + quote = .single + hasToken = true + case (nil, "\""): + quote = .double + hasToken = true + case (nil, let character) where character.isWhitespace: + if hasToken { + arguments.append(current) + current = "" + hasToken = false + } + default: + current.append(character) + hasToken = true + } + } + + if isEscaping { throw CustomActionArgumentParserError.trailingEscape } + if quote != nil { throw CustomActionArgumentParserError.unterminatedQuote } + if hasToken { arguments.append(current) } + return arguments + } + + static func joined(_ arguments: [String]) -> String { + arguments.map { argument in + guard argument.isEmpty || argument.contains(where: { $0.isWhitespace || "'\"\\".contains($0) }) else { + return argument + } + return "\"" + argument + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + "\"" + }.joined(separator: " ") + } +} + +enum CustomActionValidationError: LocalizedError, Equatable { + case emptyName + case missingExecutable + case executableNotAbsolute + case executableUnavailable + case missingScriptLanguage + case missingScriptSource + case scriptTooLarge + case missingAvailability + case unsupportedPlaceholder(String) + case embeddedPlaceholder(String) + + var errorDescription: String? { + switch self { + case .emptyName: "Enter an action name." + case .missingExecutable: "Choose an executable or script." + case .executableNotAbsolute: "Executable paths must be absolute." + case .executableUnavailable: "The executable or script is not available on this Mac." + case .missingScriptLanguage: "Choose the script language." + case .missingScriptSource: "The synced script is empty." + case .scriptTooLarge: "Synced scripts must be no larger than 256 KB." + case .missingAvailability: "Choose at least one availability context." + case .unsupportedPlaceholder(let value): "Unsupported placeholder: \(value)." + case .embeddedPlaceholder(let value): "\(value) must be a standalone argument." + } + } +} + +enum CustomActionValidator { + static let maximumScriptBytes = 256 * 1024 + private static let supportedPlaceholders: Set = ["$REPO", "$FILE", "$SHA"] + + static func validate(_ action: CustomActionDefinition, fileManager: FileManager = .default) throws { + guard !action.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + throw CustomActionValidationError.emptyName + } + guard !action.availability.isEmpty else { + throw CustomActionValidationError.missingAvailability + } + + switch action.sourceKind { + case .executable, .localScript: + guard !action.executablePath.isEmpty else { throw CustomActionValidationError.missingExecutable } + guard action.executablePath.hasPrefix("/") else { throw CustomActionValidationError.executableNotAbsolute } + guard fileManager.fileExists(atPath: action.executablePath) else { + throw CustomActionValidationError.executableUnavailable + } + if action.sourceKind == .executable, + !fileManager.isExecutableFile(atPath: action.executablePath) { + throw CustomActionValidationError.executableUnavailable + } + if action.sourceKind == .localScript, action.scriptLanguage == nil { + throw CustomActionValidationError.missingScriptLanguage + } + case .syncedScript: + guard action.scriptLanguage != nil else { throw CustomActionValidationError.missingScriptLanguage } + guard let source = action.scriptSource, !source.isEmpty else { + throw CustomActionValidationError.missingScriptSource + } + guard source.utf8.count <= maximumScriptBytes else { + throw CustomActionValidationError.scriptTooLarge + } + } + + for argument in action.arguments { + let placeholders = argument.split(whereSeparator: { $0.isWhitespace || $0 == "=" || $0 == ":" }) + .map(String.init) + .filter { $0.hasPrefix("$") } + for placeholder in placeholders { + if Self.supportedPlaceholders.contains(placeholder), argument != placeholder { + throw CustomActionValidationError.embeddedPlaceholder(placeholder) + } + if !Self.supportedPlaceholders.contains(placeholder) { + throw CustomActionValidationError.unsupportedPlaceholder(placeholder) + } + } + } + } + + static func unavailableReason( + for action: CustomActionDefinition, + surface: CustomActionInvocationSurface, + context: CustomActionInvocationContext, + isTrusted: Bool + ) -> String? { + guard action.isEnabled else { return "This action is disabled." } + guard action.availability.contains(surface.availability) else { + return "This action is not available in this context." + } + guard isTrusted else { return "Review and trust this action on this Mac before running it." } + if action.arguments.contains("$FILE"), context.filePaths.isEmpty { + return "Select one or more files first." + } + if action.arguments.contains("$SHA"), context.commitHashes.isEmpty { + return "Select one or more commits first." + } + return nil + } +} + +enum CustomActionArgumentExpander { + static func expand( + _ arguments: [String], + context: CustomActionInvocationContext + ) -> [String] { + arguments.flatMap { argument in + switch argument { + case "$REPO": [context.repositoryURL.path] + case "$FILE": context.filePaths + case "$SHA": context.commitHashes + default: [argument] + } + } + } +} diff --git a/macgit/Services/CustomActionCloudStore.swift b/macgit/Services/CustomActionCloudStore.swift new file mode 100644 index 00000000..4d3095db --- /dev/null +++ b/macgit/Services/CustomActionCloudStore.swift @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +@MainActor +protocol CustomActionCloudStore { + func load(uid: String) async throws -> [CustomActionDefinition] + func upsert(_ action: CustomActionDefinition, uid: String) async throws + func delete(id: UUID, uid: String) async throws + func updateOrder(_ actions: [CustomActionDefinition], uid: String) async throws + func observe( + uid: String, + onChange: @escaping (Result<[CustomActionDefinition], Error>) -> Void + ) -> ObservationToken +} diff --git a/macgit/Services/CustomActionExecutor.swift b/macgit/Services/CustomActionExecutor.swift new file mode 100644 index 00000000..ffb8f767 --- /dev/null +++ b/macgit/Services/CustomActionExecutor.swift @@ -0,0 +1,336 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Darwin +import Foundation + +private struct CustomActionProcessResult: Sendable { + let exitCode: Int32? + let standardOutput: Data + let standardError: Data + let isStandardOutputTruncated: Bool + let isStandardErrorTruncated: Bool + let wasCancelled: Bool +} + +private final class CustomActionProcessExecution: @unchecked Sendable { + private let executableURL: URL + private let arguments: [String] + private let directoryURL: URL + private let outputByteLimit: Int + private let lock = NSLock() + private let outputLock = NSLock() + private let outputGroup = DispatchGroup() + + private var process: Process? + private var continuation: CheckedContinuation? + private var didResume = false + private var wasCancelled = false + private var stdoutData = Data() + private var stderrData = Data() + private var stdoutTruncated = false + private var stderrTruncated = false + + init(executableURL: URL, arguments: [String], directoryURL: URL, outputByteLimit: Int) { + self.executableURL = executableURL + self.arguments = arguments + self.directoryURL = directoryURL + self.outputByteLimit = outputByteLimit + } + + func run() async -> CustomActionProcessResult { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + start(continuation) + } + } onCancel: { + cancel() + } + } + + private func start(_ continuation: CheckedContinuation) { + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.currentDirectoryURL = directoryURL + process.environment = ProcessInfo.processInfo.environment + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + lock.lock() + self.process = process + self.continuation = continuation + let cancelledBeforeStart = wasCancelled + lock.unlock() + guard !cancelledBeforeStart else { + finishWithoutProcess() + return + } + + outputGroup.enter() + outputGroup.enter() + process.terminationHandler = { [weak self, outputGroup] process in + outputGroup.notify(queue: .global(qos: .utility)) { + self?.finish(process) + } + } + + do { + try process.run() + drain(stdout.fileHandleForReading, isStandardError: false) + drain(stderr.fileHandleForReading, isStandardError: true) + lock.lock() + let cancelled = wasCancelled + lock.unlock() + if cancelled { cancel() } + } catch { + stdout.fileHandleForWriting.closeFile() + stderr.fileHandleForWriting.closeFile() + outputGroup.leave() + outputGroup.leave() + finishWithoutProcess(error: error.localizedDescription) + } + } + + private func drain(_ handle: FileHandle, isStandardError: Bool) { + let outputGroup = outputGroup + DispatchQueue.global(qos: .utility).async { [weak self, outputGroup] in + defer { outputGroup.leave() } + while true { + let data = handle.availableData + guard !data.isEmpty, let self else { return } + append(data, isStandardError: isStandardError) + } + } + } + + private func append(_ data: Data, isStandardError: Bool) { + outputLock.lock() + defer { outputLock.unlock() } + if isStandardError { + let remaining = max(0, outputByteLimit - stderrData.count) + stderrData.append(contentsOf: data.prefix(remaining)) + stderrTruncated = stderrTruncated || data.count > remaining + } else { + let remaining = max(0, outputByteLimit - stdoutData.count) + stdoutData.append(contentsOf: data.prefix(remaining)) + stdoutTruncated = stdoutTruncated || data.count > remaining + } + } + + private func finish(_ process: Process) { + outputLock.lock() + let stdout = stdoutData + let stderr = stderrData + let stdoutTruncated = stdoutTruncated + let stderrTruncated = stderrTruncated + outputLock.unlock() + lock.lock() + let cancelled = wasCancelled + lock.unlock() + resume(CustomActionProcessResult( + exitCode: process.terminationStatus, + standardOutput: stdout, + standardError: stderr, + isStandardOutputTruncated: stdoutTruncated, + isStandardErrorTruncated: stderrTruncated, + wasCancelled: cancelled + )) + } + + private func finishWithoutProcess(error: String? = nil) { + resume(CustomActionProcessResult( + exitCode: nil, + standardOutput: Data(), + standardError: Data((error ?? "Action cancelled before launch.").utf8), + isStandardOutputTruncated: false, + isStandardErrorTruncated: false, + wasCancelled: wasCancelled + )) + } + + private func cancel() { + lock.lock() + wasCancelled = true + let process = process + lock.unlock() + guard let process, process.isRunning else { return } + Self.terminateChildren(of: process.processIdentifier) + process.terminate() + } + + private static func terminateChildren(of pid: Int32) { + var children = [Int32](repeating: 0, count: 4096) + let capacity = Int32(children.count * MemoryLayout.size) + let count = children.withUnsafeMutableBytes { proc_listchildpids(pid, $0.baseAddress, capacity) } + guard count > 0 else { return } + for child in children.prefix(min(Int(count), children.count)) where child > 0 && child != pid { + terminateChildren(of: child) + kill(child, SIGTERM) + } + } + + private func resume(_ result: CustomActionProcessResult) { + lock.lock() + guard !didResume, let continuation else { + lock.unlock() + return + } + didResume = true + self.continuation = nil + self.process = nil + lock.unlock() + continuation.resume(returning: result) + } +} + +actor CustomActionExecutor { + static let outputByteLimit = 1_048_576 + + private let fileManager: FileManager + + init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + func execute( + action: CustomActionDefinition, + context: CustomActionInvocationContext + ) async -> CustomActionExecutionResult { + let startedAt = Date.now + do { + try CustomActionValidator.validate(action, fileManager: fileManager) + let command = try prepareCommand(action: action, context: context) + let processResult = await CustomActionProcessExecution( + executableURL: command.executableURL, + arguments: command.arguments, + directoryURL: context.repositoryURL, + outputByteLimit: Self.outputByteLimit + ).run() + let status: CustomActionExecutionStatus + if processResult.wasCancelled || Task.isCancelled { + status = .cancelled + } else if processResult.exitCode == 0 { + status = .succeeded + } else { + status = .failed + } + return result( + action: action, + context: context, + status: status, + processResult: processResult, + startedAt: startedAt + ) + } catch { + return CustomActionExecutionResult( + action: action, + context: context, + status: Task.isCancelled ? .cancelled : .failed, + exitCode: nil, + standardOutput: "", + standardError: error.localizedDescription, + isStandardOutputTruncated: false, + isStandardErrorTruncated: false, + duration: Date.now.timeIntervalSince(startedAt) + ) + } + } + + private func prepareCommand( + action: CustomActionDefinition, + context: CustomActionInvocationContext + ) throws -> (executableURL: URL, arguments: [String]) { + let expandedArguments = CustomActionArgumentExpander.expand(action.arguments, context: context) + switch action.sourceKind { + case .executable: + return (URL(fileURLWithPath: action.executablePath), expandedArguments) + case .localScript: + guard let language = action.scriptLanguage else { + throw CustomActionValidationError.missingScriptLanguage + } + return interpreterCommand( + language: language, + scriptURL: URL(fileURLWithPath: action.executablePath), + arguments: expandedArguments + ) + case .syncedScript: + guard let language = action.scriptLanguage, + let source = action.scriptSource else { + throw CustomActionValidationError.missingScriptSource + } + let scriptURL = try materialize( + source: source, + fileName: action.sourceFileName, + action: action + ) + return interpreterCommand( + language: language, + scriptURL: scriptURL, + arguments: expandedArguments + ) + } + } + + private func interpreterCommand( + language: CustomActionScriptLanguage, + scriptURL: URL, + arguments: [String] + ) -> (executableURL: URL, arguments: [String]) { + switch language { + case .sh: + (URL(fileURLWithPath: "/bin/sh"), [scriptURL.path] + arguments) + case .bash: + (URL(fileURLWithPath: "/bin/bash"), [scriptURL.path] + arguments) + case .zsh: + (URL(fileURLWithPath: "/bin/zsh"), [scriptURL.path] + arguments) + case .python: + (URL(fileURLWithPath: "/usr/bin/env"), ["python3", scriptURL.path] + arguments) + } + } + + private func materialize( + source: String, + fileName: String?, + action: CustomActionDefinition + ) throws -> URL { + let applicationSupport = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + let directory = applicationSupport + .appendingPathComponent("Commit+", isDirectory: true) + .appendingPathComponent("CustomActions", isDirectory: true) + .appendingPathComponent(action.id.uuidString, isDirectory: true) + .appendingPathComponent(action.trustFingerprint, isDirectory: true) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + let safeName = URL(fileURLWithPath: fileName ?? "script").lastPathComponent + let scriptURL = directory.appendingPathComponent(safeName.isEmpty ? "script" : safeName) + try Data(source.utf8).write(to: scriptURL, options: .atomic) + try fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: scriptURL.path) + return scriptURL + } + + private func result( + action: CustomActionDefinition, + context: CustomActionInvocationContext, + status: CustomActionExecutionStatus, + processResult: CustomActionProcessResult, + startedAt: Date + ) -> CustomActionExecutionResult { + CustomActionExecutionResult( + action: action, + context: context, + status: status, + exitCode: processResult.exitCode, + standardOutput: String(decoding: processResult.standardOutput, as: UTF8.self), + standardError: String(decoding: processResult.standardError, as: UTF8.self), + isStandardOutputTruncated: processResult.isStandardOutputTruncated, + isStandardErrorTruncated: processResult.isStandardErrorTruncated, + duration: Date.now.timeIntervalSince(startedAt) + ) + } +} diff --git a/macgit/Services/CustomActionStore.swift b/macgit/Services/CustomActionStore.swift new file mode 100644 index 00000000..73872aae --- /dev/null +++ b/macgit/Services/CustomActionStore.swift @@ -0,0 +1,208 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Combine +import Foundation + +@MainActor +final class CustomActionStore: ObservableObject { + static let actionsKey = "customActions.catalog.v1" + static let trustKey = "customActions.trust.v1" + static let executableOverridesKey = "customActions.executableOverrides.v1" + + @Published private(set) var actions: [CustomActionDefinition] + @Published private(set) var syncError: String? + + private let userDefaults: UserDefaults + private let cloudStore: CustomActionCloudStore? + private var cloudObservation: ObservationToken? + private var activeUID: String? + private var trustedFingerprints: [String: String] + private var executableOverrides: [String: String] + + init( + userDefaults: UserDefaults = .standard, + cloudStore: CustomActionCloudStore? = nil + ) { + self.userDefaults = userDefaults + self.cloudStore = cloudStore + actions = Self.decode([CustomActionDefinition].self, from: userDefaults.data(forKey: Self.actionsKey)) ?? [] + trustedFingerprints = Self.decode([String: String].self, from: userDefaults.data(forKey: Self.trustKey)) ?? [:] + executableOverrides = Self.decode([String: String].self, from: userDefaults.data(forKey: Self.executableOverridesKey)) ?? [:] + normalizeAndSave() + } + + func updateCloudSession(uid: String?, enabled: Bool) async { + if enabled, let uid, activeUID == uid, cloudObservation != nil { return } + cloudObservation?.cancel() + cloudObservation = nil + activeUID = nil + syncError = nil + + guard enabled, let uid, let cloudStore else { return } + activeUID = uid + do { + let remote = try await cloudStore.load(uid: uid) + guard activeUID == uid else { return } + if remote.isEmpty, !actions.isEmpty { + for action in actions { + try await cloudStore.upsert(action, uid: uid) + } + } else { + let localByID = Dictionary(uniqueKeysWithValues: actions.map { ($0.id, $0) }) + let remoteIDs = Set(remote.map(\.id)) + var merged = remote + merged.append(contentsOf: actions.filter { !remoteIDs.contains($0.id) }) + applyRemote(merged) + for action in localByID.values where !remoteIDs.contains(action.id) { + try await cloudStore.upsert(action, uid: uid) + } + } + beginObserving(uid: uid) + } catch { + guard activeUID == uid else { return } + syncError = error.localizedDescription + } + } + + func action(id: UUID) -> CustomActionDefinition? { + actions.first { $0.id == id }.map(effectiveAction) + } + + func effectiveAction(_ action: CustomActionDefinition) -> CustomActionDefinition { + guard let override = executableOverrides[action.id.uuidString] else { return action } + var resolved = action + resolved.executablePath = override + return resolved + } + + func isTrusted(_ action: CustomActionDefinition) -> Bool { + trustedFingerprints[action.id.uuidString] == effectiveAction(action).trustFingerprint + } + + func trust(_ action: CustomActionDefinition) { + trustedFingerprints[action.id.uuidString] = effectiveAction(action).trustFingerprint + saveTrustedFingerprints() + objectWillChange.send() + } + + func setExecutableOverride(_ path: String?, for action: CustomActionDefinition) { + if let path, !path.isEmpty { + executableOverrides[action.id.uuidString] = path + } else { + executableOverrides[action.id.uuidString] = nil + } + saveExecutableOverrides() + trustedFingerprints[action.id.uuidString] = nil + saveTrustedFingerprints() + objectWillChange.send() + } + + func upsert(_ action: CustomActionDefinition, trustOnThisMac: Bool = true) { + var normalized = action + if let index = actions.firstIndex(where: { $0.id == action.id }) { + normalized.sortIndex = actions[index].sortIndex + actions[index] = normalized + } else { + normalized.sortIndex = actions.count + actions.append(normalized) + } + normalizeAndSave() + if trustOnThisMac { trust(normalized) } + guard let uid = activeUID, let cloudStore else { return } + Task { @MainActor in + do { try await cloudStore.upsert(normalized, uid: uid) } + catch { self.syncError = error.localizedDescription } + } + } + + func delete(_ action: CustomActionDefinition) { + actions.removeAll { $0.id == action.id } + trustedFingerprints[action.id.uuidString] = nil + executableOverrides[action.id.uuidString] = nil + normalizeAndSave() + saveTrustedFingerprints() + saveExecutableOverrides() + guard let uid = activeUID, let cloudStore else { return } + Task { @MainActor in + do { try await cloudStore.delete(id: action.id, uid: uid) } + catch { self.syncError = error.localizedDescription } + } + } + + func duplicate(_ action: CustomActionDefinition) { + upsert(action.duplicated) + } + + func setEnabled(_ enabled: Bool, for action: CustomActionDefinition) { + var updated = action + updated.isEnabled = enabled + upsert(updated, trustOnThisMac: false) + } + + func move(fromOffsets: IndexSet, toOffset: Int) { + let orderedOffsets = fromOffsets.sorted() + let movingActions = orderedOffsets.map { actions[$0] } + for offset in orderedOffsets.reversed() { + actions.remove(at: offset) + } + let removedBeforeDestination = orderedOffsets.count { $0 < toOffset } + let insertionIndex = min(max(0, toOffset - removedBeforeDestination), actions.count) + actions.insert(contentsOf: movingActions, at: insertionIndex) + normalizeAndSave() + guard let uid = activeUID, let cloudStore else { return } + let snapshot = actions + Task { @MainActor in + do { try await cloudStore.updateOrder(snapshot, uid: uid) } + catch { self.syncError = error.localizedDescription } + } + } + + private func beginObserving(uid: String) { + guard let cloudStore else { return } + cloudObservation = cloudStore.observe(uid: uid) { [weak self] result in + Task { @MainActor in + guard let self, self.activeUID == uid else { return } + switch result { + case .success(let actions): + self.syncError = nil + self.applyRemote(actions) + case .failure(let error): + self.syncError = error.localizedDescription + } + } + } + } + + private func applyRemote(_ remote: [CustomActionDefinition]) { + actions = remote + normalizeAndSave() + } + + private func normalizeAndSave() { + actions = actions.enumerated().map { index, action in + var normalized = action + normalized.sortIndex = index + return normalized + } + if let data = try? JSONEncoder().encode(actions) { + userDefaults.set(data, forKey: Self.actionsKey) + } + } + + private func saveTrustedFingerprints() { + if let data = try? JSONEncoder().encode(trustedFingerprints) { + userDefaults.set(data, forKey: Self.trustKey) + } + } + + private func saveExecutableOverrides() { + if let data = try? JSONEncoder().encode(executableOverrides) { + userDefaults.set(data, forKey: Self.executableOverridesKey) + } + } + + private static func decode(_ type: T.Type, from data: Data?) -> T? { + guard let data else { return nil } + return try? JSONDecoder().decode(type, from: data) + } +} diff --git a/macgit/Services/FirestoreCustomActionStore.swift b/macgit/Services/FirestoreCustomActionStore.swift new file mode 100644 index 00000000..14a3644c --- /dev/null +++ b/macgit/Services/FirestoreCustomActionStore.swift @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import FirebaseFirestore +import Foundation + +enum FirestoreCustomActionDocument { + static func encode(_ action: CustomActionDefinition, updatedAt: Any) -> [String: Any] { + var data: [String: Any] = [ + "schemaVersion": CustomActionDefinition.schemaVersion, + "name": action.name, + "sourceKind": action.sourceKind.rawValue, + "executablePath": action.executablePath, + "arguments": action.arguments, + "availability": action.availability.rawValue, + "isEnabled": action.isEnabled, + "alwaysShowOutput": action.alwaysShowOutput, + "sortIndex": action.sortIndex, + "updatedAt": updatedAt, + ] + if let scriptLanguage = action.scriptLanguage { + data["scriptLanguage"] = scriptLanguage.rawValue + } + if let scriptSource = action.scriptSource { + data["scriptSource"] = scriptSource + } + if let sourceFileName = action.sourceFileName { + data["sourceFileName"] = sourceFileName + } + return data + } + + static func decode(id: String, data: [String: Any]) throws -> CustomActionDefinition { + guard let uuid = UUID(uuidString: id), + data["schemaVersion"] as? Int == CustomActionDefinition.schemaVersion, + let name = data["name"] as? String, + let sourceKindRaw = data["sourceKind"] as? String, + let sourceKind = CustomActionSourceKind(rawValue: sourceKindRaw), + let executablePath = data["executablePath"] as? String, + let arguments = data["arguments"] as? [String], + let availabilityRaw = data["availability"] as? Int, + let isEnabled = data["isEnabled"] as? Bool, + let alwaysShowOutput = data["alwaysShowOutput"] as? Bool, + let sortIndex = data["sortIndex"] as? Int, + data["updatedAt"] is Timestamp else { + throw CloudSettingsError.invalidDocument + } + + let scriptLanguage: CustomActionScriptLanguage? + if let raw = data["scriptLanguage"] as? String { + guard let decoded = CustomActionScriptLanguage(rawValue: raw) else { + throw CloudSettingsError.invalidDocument + } + scriptLanguage = decoded + } else { + scriptLanguage = nil + } + + return CustomActionDefinition( + id: uuid, + name: name, + sourceKind: sourceKind, + executablePath: executablePath, + scriptLanguage: scriptLanguage, + scriptSource: data["scriptSource"] as? String, + sourceFileName: data["sourceFileName"] as? String, + arguments: arguments, + availability: CustomActionAvailability(rawValue: availabilityRaw), + isEnabled: isEnabled, + alwaysShowOutput: alwaysShowOutput, + sortIndex: sortIndex + ) + } +} + +@MainActor +final class FirestoreCustomActionStore: CustomActionCloudStore { + private let firestore: Firestore + + init(firestore: Firestore = Firestore.firestore()) { + self.firestore = firestore + } + + func load(uid: String) async throws -> [CustomActionDefinition] { + let snapshot = try await collection(uid: uid).getDocuments() + return try snapshot.documents + .map { try FirestoreCustomActionDocument.decode(id: $0.documentID, data: $0.data()) } + .sorted(by: Self.order) + } + + func upsert(_ action: CustomActionDefinition, uid: String) async throws { + try await collection(uid: uid).document(action.id.uuidString).setData( + FirestoreCustomActionDocument.encode(action, updatedAt: FieldValue.serverTimestamp()) + ) + } + + func delete(id: UUID, uid: String) async throws { + try await collection(uid: uid).document(id.uuidString).delete() + } + + func updateOrder(_ actions: [CustomActionDefinition], uid: String) async throws { + let batch = firestore.batch() + for action in actions { + batch.updateData( + ["sortIndex": action.sortIndex, "updatedAt": FieldValue.serverTimestamp()], + forDocument: collection(uid: uid).document(action.id.uuidString) + ) + } + try await batch.commit() + } + + func observe( + uid: String, + onChange: @escaping (Result<[CustomActionDefinition], Error>) -> Void + ) -> ObservationToken { + let registration = collection(uid: uid).addSnapshotListener { snapshot, error in + if let error { + onChange(.failure(error)) + return + } + do { + let actions = try (snapshot?.documents ?? []) + .map { try FirestoreCustomActionDocument.decode(id: $0.documentID, data: $0.data()) } + .sorted(by: Self.order) + onChange(.success(actions)) + } catch { + onChange(.failure(error)) + } + } + return CustomActionFirestoreObservationToken(registration: registration) + } + + private func collection(uid: String) -> CollectionReference { + firestore.collection("users").document(uid).collection("customActions") + } + + private static func order(_ lhs: CustomActionDefinition, _ rhs: CustomActionDefinition) -> Bool { + if lhs.sortIndex == rhs.sortIndex { return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending } + return lhs.sortIndex < rhs.sortIndex + } +} + +private final class CustomActionFirestoreObservationToken: ObservationToken { + private var registration: ListenerRegistration? + + init(registration: ListenerRegistration) { + self.registration = registration + } + + func cancel() { + registration?.remove() + registration = nil + } + + deinit { registration?.remove() } +} diff --git a/macgit/ViewModels/CustomActionDraft.swift b/macgit/ViewModels/CustomActionDraft.swift new file mode 100644 index 00000000..569f39e2 --- /dev/null +++ b/macgit/ViewModels/CustomActionDraft.swift @@ -0,0 +1,60 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import Foundation + +struct CustomActionDraft { + var id: UUID + var name: String + var sourceKind: CustomActionSourceKind + var executablePath: String + var scriptLanguage: CustomActionScriptLanguage + var scriptSource: String + var sourceFileName: String + var rawArguments: String + var repositoryAvailable: Bool + var filesAvailable: Bool + var commitsAvailable: Bool + var isEnabled: Bool + var alwaysShowOutput: Bool + var sortIndex: Int + + init(action: CustomActionDefinition? = nil) { + id = action?.id ?? UUID() + name = action?.name ?? "" + sourceKind = action?.sourceKind ?? .executable + executablePath = action?.executablePath ?? "" + scriptLanguage = action?.scriptLanguage ?? .sh + scriptSource = action?.scriptSource ?? "" + sourceFileName = action?.sourceFileName ?? "script.sh" + rawArguments = CustomActionArgumentParser.joined(action?.arguments ?? []) + repositoryAvailable = action?.availability.contains(.repository) ?? true + filesAvailable = action?.availability.contains(.selectedFiles) ?? false + commitsAvailable = action?.availability.contains(.selectedCommits) ?? false + isEnabled = action?.isEnabled ?? true + alwaysShowOutput = action?.alwaysShowOutput ?? false + sortIndex = action?.sortIndex ?? 0 + } + + func definition() throws -> CustomActionDefinition { + var availability: CustomActionAvailability = [] + if repositoryAvailable { availability.insert(.repository) } + if filesAvailable { availability.insert(.selectedFiles) } + if commitsAvailable { availability.insert(.selectedCommits) } + let action = CustomActionDefinition( + id: id, + name: name.trimmingCharacters(in: .whitespacesAndNewlines), + sourceKind: sourceKind, + executablePath: executablePath, + scriptLanguage: sourceKind == .executable ? nil : scriptLanguage, + scriptSource: sourceKind == .syncedScript ? scriptSource : nil, + sourceFileName: sourceKind == .syncedScript ? sourceFileName : nil, + arguments: try CustomActionArgumentParser.parse(rawArguments), + availability: availability, + isEnabled: isEnabled, + alwaysShowOutput: alwaysShowOutput, + sortIndex: sortIndex + ) + try CustomActionValidator.validate(action) + return action + } +} diff --git a/macgit/Views/Common/AppSettingsDetailView.swift b/macgit/Views/Common/AppSettingsDetailView.swift index 039b50f3..89a05fb1 100644 --- a/macgit/Views/Common/AppSettingsDetailView.swift +++ b/macgit/Views/Common/AppSettingsDetailView.swift @@ -24,6 +24,7 @@ struct AppSettingsDetailView: View { @ObservedObject var providerAccountController: GitProviderAccountController @ObservedObject var aiProviderController: AIProviderController @ObservedObject var appUpdateController: AppUpdateController + @ObservedObject var customActionStore: CustomActionStore let restrictedAIProviderAccess: FeatureAccessDecision @Binding var aiProviderDrafts: [AIProviderConfigurationDraft] @@ -42,6 +43,8 @@ struct AppSettingsDetailView: View { ) case .integrations: IntegrationsSettingsView(appState: appState) + case .customActions: + CustomActionsSettingsView(store: customActionStore) case .aiProviders: AIProvidersSettingsView( controller: aiProviderController, diff --git a/macgit/Views/Common/AppSettingsSection.swift b/macgit/Views/Common/AppSettingsSection.swift index 0b8a45b7..b7b0b91b 100644 --- a/macgit/Views/Common/AppSettingsSection.swift +++ b/macgit/Views/Common/AppSettingsSection.swift @@ -23,6 +23,7 @@ enum AppSettingsSection: String, CaseIterable, Identifiable { case git case accounts case integrations + case customActions case aiProviders case update case advanced @@ -36,6 +37,7 @@ enum AppSettingsSection: String, CaseIterable, Identifiable { case .git: "Git" case .accounts: "Account" case .integrations: "Integrations" + case .customActions: "Custom Actions" case .aiProviders: "AI Providers" case .update: "Update" case .advanced: "Advanced" @@ -49,6 +51,7 @@ enum AppSettingsSection: String, CaseIterable, Identifiable { case .git: "point.3.connected.trianglepath.dotted" case .accounts: "person.crop.circle" case .integrations: "puzzlepiece.extension" + case .customActions: "terminal" case .aiProviders: "sparkles" case .update: "arrow.triangle.2.circlepath" case .advanced: "wrench.and.screwdriver" @@ -62,6 +65,7 @@ enum AppSettingsSection: String, CaseIterable, Identifiable { case .git: .green case .accounts: .indigo case .integrations: .teal + case .customActions: .mint case .aiProviders: .purple case .update: .cyan case .advanced: .brown @@ -80,6 +84,8 @@ enum AppSettingsSection: String, CaseIterable, Identifiable { "Manage your Commit+ account and Git provider connections." case .integrations: "Connect Commit+ with external tools and services." + case .customActions: + "Configure reusable repository, file, and commit actions." case .aiProviders: "Configure AI providers for commit-message generation." case .update: diff --git a/macgit/Views/Common/AppSettingsView.swift b/macgit/Views/Common/AppSettingsView.swift index 4214913d..f9f438bf 100644 --- a/macgit/Views/Common/AppSettingsView.swift +++ b/macgit/Views/Common/AppSettingsView.swift @@ -25,6 +25,7 @@ struct AppSettingsView: View { @ObservedObject var providerAccountController: GitProviderAccountController @ObservedObject var aiProviderController: AIProviderController @ObservedObject var appUpdateController: AppUpdateController + @ObservedObject var customActionStore: CustomActionStore @Binding private var selectedSection: AppSettingsSection @State private var aiProviderDrafts: [AIProviderConfigurationDraft] @State private var saveErrorMessage: String? @@ -37,6 +38,7 @@ struct AppSettingsView: View { providerAccountController: GitProviderAccountController, aiProviderController: AIProviderController, appUpdateController: AppUpdateController, + customActionStore: CustomActionStore, selectedSection: Binding ) { self.appState = appState @@ -45,6 +47,7 @@ struct AppSettingsView: View { self.providerAccountController = providerAccountController self.aiProviderController = aiProviderController self.appUpdateController = appUpdateController + self.customActionStore = customActionStore _selectedSection = selectedSection _aiProviderDrafts = State(initialValue: aiProviderController.configurationDrafts()) } @@ -71,6 +74,7 @@ struct AppSettingsView: View { providerAccountController: providerAccountController, aiProviderController: aiProviderController, appUpdateController: appUpdateController, + customActionStore: customActionStore, restrictedAIProviderAccess: restrictedAIProviderAccess, aiProviderDrafts: $aiProviderDrafts ) diff --git a/macgit/Views/Common/CustomActionEditorSheet.swift b/macgit/Views/Common/CustomActionEditorSheet.swift new file mode 100644 index 00000000..d4c29bd8 --- /dev/null +++ b/macgit/Views/Common/CustomActionEditorSheet.swift @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import SwiftUI + +struct CustomActionEditorSheet: View { + @Environment(\.dismiss) private var dismiss + @State private var draft: CustomActionDraft + @State private var validationMessage: String? + + let onSave: (CustomActionDefinition) -> Void + + init(action: CustomActionDefinition?, onSave: @escaping (CustomActionDefinition) -> Void) { + _draft = State(initialValue: CustomActionDraft(action: action)) + self.onSave = onSave + } + + var body: some View { + VStack(spacing: 0) { + Form { + TextField("Name", text: $draft.name) + + Picker("Type", selection: $draft.sourceKind) { + Text("Executable").tag(CustomActionSourceKind.executable) + Text("Local Script").tag(CustomActionSourceKind.localScript) + Text("Synced Script").tag(CustomActionSourceKind.syncedScript) + } + + sourceFields + + TextField("Arguments", text: $draft.rawArguments) + .font(.body.monospaced()) + + VStack(alignment: .leading, spacing: 5) { + Text("Placeholders must be standalone arguments.") + Text("$REPO — repository path $FILE — selected file paths $SHA — selected commit hashes") + .font(.caption.monospaced()) + } + .foregroundStyle(.secondary) + + Section("Availability") { + Toggle("Repository", isOn: $draft.repositoryAvailable) + Toggle("Selected Files", isOn: $draft.filesAvailable) + Toggle("Selected Commits", isOn: $draft.commitsAvailable) + } + + Toggle("Always Show Output", isOn: $draft.alwaysShowOutput) + + if draft.sourceKind == .syncedScript { + Text("The script source, arguments, and executable metadata are stored in your Firebase account when Settings Sync is enabled. Do not include credentials or tokens.") + .font(.caption) + .foregroundStyle(.secondary) + } + + if let validationMessage { + Text(validationMessage) + .foregroundStyle(.red) + .textSelection(.enabled) + } + } + .formStyle(.grouped) + .padding() + + Divider() + + HStack { + Spacer() + Button("Cancel", action: dismiss.callAsFunction) + .keyboardShortcut(.cancelAction) + Button("Save", action: save) + .keyboardShortcut(.defaultAction) + } + .padding() + } + .frame(minWidth: 620, minHeight: draft.sourceKind == .syncedScript ? 620 : 470) + } + + @ViewBuilder + private var sourceFields: some View { + switch draft.sourceKind { + case .executable: + LabeledContent("Executable") { + HStack { + TextField("/absolute/path/to/executable", text: $draft.executablePath) + .font(.body.monospaced()) + Button("Choose…", action: chooseSource) + } + } + case .localScript: + LabeledContent("Script") { + HStack { + TextField("/absolute/path/to/script", text: $draft.executablePath) + .font(.body.monospaced()) + Button("Choose…", action: chooseSource) + } + } + scriptLanguagePicker + case .syncedScript: + LabeledContent("Imported File") { + HStack { + Text(draft.sourceFileName) + .foregroundStyle(.secondary) + Button("Import…", action: chooseSource) + } + } + scriptLanguagePicker + TextEditor(text: $draft.scriptSource) + .font(.body.monospaced()) + .frame(minHeight: 220) + .border(.separator) + .accessibilityLabel("Script source") + } + } + + private var scriptLanguagePicker: some View { + Picker("Language", selection: $draft.scriptLanguage) { + ForEach(CustomActionScriptLanguage.allCases, id: \.self) { language in + Text(language.displayName).tag(language) + } + } + } + + private func chooseSource() { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + + if draft.sourceKind == .syncedScript { + do { + let data = try Data(contentsOf: url) + guard data.count <= CustomActionValidator.maximumScriptBytes, + let source = String(data: data, encoding: .utf8) else { + throw CustomActionValidationError.scriptTooLarge + } + draft.scriptSource = source + draft.sourceFileName = url.lastPathComponent + if let language = CustomActionScriptLanguage.inferred(from: url) { + draft.scriptLanguage = language + } + validationMessage = nil + } catch { + validationMessage = error.localizedDescription + } + } else { + draft.executablePath = url.path + if draft.sourceKind == .localScript, + let language = CustomActionScriptLanguage.inferred(from: url) { + draft.scriptLanguage = language + } + } + } + + private func save() { + do { + let action = try draft.definition() + onSave(action) + dismiss() + } catch { + validationMessage = error.localizedDescription + } + } +} diff --git a/macgit/Views/Common/CustomActionMenuContent.swift b/macgit/Views/Common/CustomActionMenuContent.swift new file mode 100644 index 00000000..289f6aa4 --- /dev/null +++ b/macgit/Views/Common/CustomActionMenuContent.swift @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import SwiftUI + +struct CustomActionMenuContent: View { + @ObservedObject var store: CustomActionStore + let surface: CustomActionInvocationSurface + let context: CustomActionInvocationContext + var hasActiveOperation = false + var includesRepositoryActions = false + let onRun: (UUID, CustomActionInvocationSurface) -> Void + + private var actions: [CustomActionDefinition] { + store.actions.filter { + $0.isEnabled + && ($0.availability.contains(surface.availability) + || (includesRepositoryActions && $0.availability.contains(.repository))) + } + } + + var body: some View { + if actions.isEmpty { + Text("No Custom Actions") + } else { + ForEach(actions) { action in + let invocationSurface: CustomActionInvocationSurface = action.availability.contains(surface.availability) + ? surface + : .repository + let reason = CustomActionValidator.unavailableReason( + for: store.effectiveAction(action), + surface: invocationSurface, + context: context, + isTrusted: store.isTrusted(action) + ) + Button(action.name) { onRun(action.id, invocationSurface) } + .disabled(hasActiveOperation || reason != nil) + .help(reason ?? "Run \(action.name)") + } + } + } +} diff --git a/macgit/Views/Common/CustomActionOutputSheet.swift b/macgit/Views/Common/CustomActionOutputSheet.swift new file mode 100644 index 00000000..9e26aea7 --- /dev/null +++ b/macgit/Views/Common/CustomActionOutputSheet.swift @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import SwiftUI + +struct CustomActionOutputSheet: View { + let result: CustomActionExecutionResult + let onRunAgain: () -> Void + let onClose: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(spacing: 10) { + Image(systemName: statusIcon) + .foregroundStyle(statusColor) + VStack(alignment: .leading, spacing: 2) { + Text(result.action.name).font(.headline) + Text(statusSummary).foregroundStyle(.secondary) + } + Spacer() + } + + outputSection("Standard Output", text: result.standardOutput, truncated: result.isStandardOutputTruncated) + outputSection("Standard Error", text: result.standardError, truncated: result.isStandardErrorTruncated) + + HStack { + Button("Copy Output") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(result.copiedOutput, forType: .string) + } + .disabled(result.copiedOutput.isEmpty) + Spacer() + Button("Run Again", action: onRunAgain) + Button("Close", action: onClose).keyboardShortcut(.defaultAction) + } + } + .padding(20) + .frame(minWidth: 680, minHeight: 480) + } + + private func outputSection(_ title: String, text: String, truncated: Bool) -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(title).font(.subheadline.weight(.semibold)) + if truncated { Text("Truncated at 1 MB").font(.caption).foregroundStyle(.secondary) } + } + TextEditor(text: .constant(text.isEmpty ? "No output" : text)) + .font(.system(.body, design: .monospaced)) + .scrollContentBackground(.hidden) + .background(Color(nsColor: .textBackgroundColor)) + .overlay { RoundedRectangle(cornerRadius: 5).stroke(.separator) } + .frame(minHeight: 130) + } + } + + private var statusSummary: String { + var value = result.status.title + if let exitCode = result.exitCode { value += " · Exit code \(exitCode)" } + value += String(format: " · %.2fs", result.duration) + return value + } + + private var statusIcon: String { + switch result.status { + case .succeeded: "checkmark.circle.fill" + case .failed: "xmark.circle.fill" + case .cancelled: "stop.circle.fill" + } + } + + private var statusColor: Color { + switch result.status { + case .succeeded: .green + case .failed: .red + case .cancelled: .orange + } + } +} diff --git a/macgit/Views/Common/CustomActionSettingsRow.swift b/macgit/Views/Common/CustomActionSettingsRow.swift new file mode 100644 index 00000000..626119de --- /dev/null +++ b/macgit/Views/Common/CustomActionSettingsRow.swift @@ -0,0 +1,56 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import SwiftUI + +struct CustomActionSettingsRow: View { + let action: CustomActionDefinition + let effectiveAction: CustomActionDefinition + let isTrusted: Bool + let onToggle: (Bool) -> Void + let onReview: () -> Void + let onLocate: () -> Void + + var body: some View { + HStack(spacing: 12) { + Toggle(action.name, isOn: Binding( + get: { action.isEnabled }, + set: onToggle + )) + .labelsHidden() + + VStack(alignment: .leading, spacing: 3) { + Text(action.name) + Text(detail) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + if needsExecutable { + Button("Locate…", action: onLocate) + } else if !isTrusted { + Button("Review…", action: onReview) + } else { + Label("Ready", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .labelStyle(.titleAndIcon) + } + } + .padding(.vertical, 4) + } + + private var detail: String { + switch action.sourceKind { + case .executable: effectiveAction.executablePath + case .localScript: effectiveAction.executablePath + case .syncedScript: "Synced \(action.scriptLanguage?.displayName ?? "script")" + } + } + + private var needsExecutable: Bool { + guard action.sourceKind != .syncedScript else { return false } + return !FileManager.default.fileExists(atPath: effectiveAction.executablePath) + } +} diff --git a/macgit/Views/Common/CustomActionsSettingsView.swift b/macgit/Views/Common/CustomActionsSettingsView.swift new file mode 100644 index 00000000..d686591f --- /dev/null +++ b/macgit/Views/Common/CustomActionsSettingsView.swift @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import SwiftUI + +struct CustomActionsSettingsView: View { + @ObservedObject var store: CustomActionStore + @State private var selection: UUID? + @State private var editingAction: CustomActionDefinition? + @State private var isPresentingEditor = false + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Custom Actions") + .font(.title2.bold()) + Text("Run reusable executables or scripts with repository, file, and commit context.") + .foregroundStyle(.secondary) + Text("When Settings Sync is enabled, action definitions—including script source and local paths—sync through your Firebase account. Each Mac must review changed actions before running them.") + .font(.caption) + .foregroundStyle(.secondary) + } + + Group { + if store.actions.isEmpty { + ContentUnavailableView( + "No Custom Actions", + systemImage: "terminal", + description: Text("Add an executable or import a script to get started.") + ) + } else { + List(selection: $selection) { + ForEach(store.actions) { action in + CustomActionSettingsRow( + action: action, + effectiveAction: store.effectiveAction(action), + isTrusted: store.isTrusted(action), + onToggle: { store.setEnabled($0, for: action) }, + onReview: { edit(action) }, + onLocate: { locate(action) } + ) + .tag(action.id) + .onTapGesture(count: 2) { edit(action) } + } + .onMove(perform: store.move) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + HStack { + Button("Add", systemImage: "plus", action: add) + Button("Remove", systemImage: "minus", action: remove) + .disabled(selectedAction == nil) + Button("Duplicate", systemImage: "plus.square.on.square", action: duplicate) + .disabled(selectedAction == nil) + Button("Edit…", action: editSelected) + .disabled(selectedAction == nil) + Spacer() + if let syncError = store.syncError { + Text(syncError) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } + } + } + .padding(24) + .sheet(isPresented: $isPresentingEditor) { + CustomActionEditorSheet(action: editingAction) { action in + store.upsert(action) + selection = action.id + } + } + } + + private var selectedAction: CustomActionDefinition? { + guard let selection else { return nil } + return store.actions.first { $0.id == selection } + } + + private func add() { + editingAction = nil + isPresentingEditor = true + } + + private func edit(_ action: CustomActionDefinition) { + editingAction = action + isPresentingEditor = true + } + + private func editSelected() { + guard let selectedAction else { return } + edit(selectedAction) + } + + private func remove() { + guard let selectedAction else { return } + store.delete(selectedAction) + selection = nil + } + + private func duplicate() { + guard let selectedAction else { return } + store.duplicate(selectedAction) + } + + private func locate(_ action: CustomActionDefinition) { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + guard panel.runModal() == .OK, let url = panel.url else { return } + store.setExecutableOverride(url.path, for: action) + store.trust(action) + } +} diff --git a/macgit/Views/FileStatus/FileStatusView.swift b/macgit/Views/FileStatus/FileStatusView.swift index 0dbe1d0a..74876e9a 100644 --- a/macgit/Views/FileStatus/FileStatusView.swift +++ b/macgit/Views/FileStatus/FileStatusView.swift @@ -33,6 +33,7 @@ struct FileStatusView: View { @ObservedObject var aiProviderController: AIProviderController @EnvironmentObject private var accountController: AccountSessionController @EnvironmentObject private var featureAccessController: FeatureAccessController + @EnvironmentObject private var customActionStore: CustomActionStore @ObservedObject var syncState: SyncState var undoManager: GitUndoManager? = nil var preferredRemote: String? = nil @@ -45,6 +46,8 @@ struct FileStatusView: View { var onAuthorizeCommit: () async -> Bool = { true } var onRequestPushAfterCommit: (String, String) async throws -> Void var onRunRepositoryOperation: RepositoryOperationRunner + var onCustomActionSelectionChanged: ([String]) -> Void = { _ in } + var onRunCustomAction: (UUID, [String]) -> Void = { _, _ in } @ObservedObject private var integrationSettings = IntegrationSettingsStore.shared @State private var gitStatus: GitStatus = GitStatus(staged: [], unstaged: [], untracked: []) @@ -306,6 +309,9 @@ struct FileStatusView: View { diffHunks = [] isLoadingDiff = newSelectionKey != nil } + .onChange(of: selectedActionFileKeys, initial: true) { _, _ in + onCustomActionSelectionChanged(actionSelection.selectedFiles.map(\.path)) + } .task(id: selectedFileKey) { guard let selectionKey = selectedFileKey, let file = selectedFile else { return } @@ -689,6 +695,20 @@ struct FileStatusView: View { .accessibilityAddTraits(isActionSelected ? .isSelected : []) .contextMenu { fileContextMenu(file: file, isStaged: isStaged) + Divider() + Menu("Custom Actions") { + let paths = actionSelection.files(for: .remove, fallback: file).map(\.path) + CustomActionMenuContent( + store: customActionStore, + surface: .selectedFiles, + context: CustomActionInvocationContext( + repositoryURL: repositoryURL, + filePaths: paths, + commitHashes: [] + ), + onRun: { id, _ in onRunCustomAction(id, paths) } + ) + } } } @@ -1625,6 +1645,7 @@ struct FileStatusView: View { !selectedActionFileKeys.contains(actionSelectionAnchorKey) { self.actionSelectionAnchorKey = selectedActionFileKeys.first } + onCustomActionSelectionChanged(actionSelection.selectedFiles.map(\.path)) } catch { errorMessage = error.localizedDescription showingError = true diff --git a/macgit/Views/History/HistoryView.swift b/macgit/Views/History/HistoryView.swift index 86842eea..77fec711 100644 --- a/macgit/Views/History/HistoryView.swift +++ b/macgit/Views/History/HistoryView.swift @@ -45,6 +45,9 @@ struct HistoryView: View { let onRequestExplainCommit: (Commit) -> Void let onRequestBrowseRevision: (Commit) -> Void @EnvironmentObject private var appState: AppState + @EnvironmentObject private var customActionStore: CustomActionStore + var onCustomActionSelectionChanged: ([String]) -> Void = { _ in } + var onRunCustomAction: (UUID, [String]) -> Void = { _, _ in } @State private var commits: [Commit] = [] @State private var graphModel: CommitGraphModel? = nil @@ -128,7 +131,9 @@ struct HistoryView: View { }, onRequestCheckout: @escaping (String, Bool) -> Void = { _, _ in }, onRequestExplainCommit: @escaping (Commit) -> Void = { _ in }, - onRequestBrowseRevision: @escaping (Commit) -> Void = { _ in } + onRequestBrowseRevision: @escaping (Commit) -> Void = { _ in }, + onCustomActionSelectionChanged: @escaping ([String]) -> Void = { _ in }, + onRunCustomAction: @escaping (UUID, [String]) -> Void = { _, _ in } ) { self.repositoryURL = repositoryURL self.selectedBranch = selectedBranch @@ -138,6 +143,8 @@ struct HistoryView: View { self.onRequestCheckout = onRequestCheckout self.onRequestExplainCommit = onRequestExplainCommit self.onRequestBrowseRevision = onRequestBrowseRevision + self.onCustomActionSelectionChanged = onCustomActionSelectionChanged + self.onRunCustomAction = onRunCustomAction let storedPageSize = UserDefaults.standard.integer(forKey: "advanced.historyLoadSize") self._paging = State( initialValue: HistoryPagingState( @@ -708,6 +715,7 @@ struct HistoryView: View { } .onChange(of: tableSelection) { oldSelection, newSelection in applyTableSelection(from: oldSelection, to: newSelection) + onCustomActionSelectionChanged(commits.map(\.hash).filter(newSelection.contains)) } .task(id: scrollTarget) { guard let scrollTarget, @@ -1085,6 +1093,22 @@ struct HistoryView: View { showingRevertConfirmation = true } .disabled(singleCommit == nil) + + Divider() + + Menu("Custom Actions") { + let hashes = contextCommits.map(\.hash) + CustomActionMenuContent( + store: customActionStore, + surface: .selectedCommits, + context: CustomActionInvocationContext( + repositoryURL: repositoryURL, + filePaths: [], + commitHashes: hashes + ), + onRun: { id, _ in onRunCustomAction(id, hashes) } + ) + } Divider() diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index d9ffbd80..0587e304 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -103,6 +103,7 @@ struct MainWindowView: View { @EnvironmentObject var featureAccessController: FeatureAccessController @EnvironmentObject var repositoryVisibilityController: RepositoryVisibilityController @EnvironmentObject var gitFlowConfigurationSyncController: GitFlowConfigurationSyncController + @EnvironmentObject var customActionStore: CustomActionStore @Environment(\.openWindow) private var openWindow let repoSettingsStore = RepoSettingsStore.shared let gitFlowConfigurationStore = GitFlowConfigurationStore() @@ -197,6 +198,10 @@ struct MainWindowView: View { @Environment(\.sheetPresentationCoordinator) private var promotionSheetCoordinator @StateObject private var repositoryAIChatController: RepositoryAIChatController @ObservedObject var operationProgress: RepositoryOperationProgress + private let customActionExecutor = CustomActionExecutor() + @State private var customActionFilePaths: [String] = [] + @State private var customActionCommitHashes: [String] = [] + @State private var customActionOutput: CustomActionOutputPresentation? init( repositoryURL: URL, @@ -376,6 +381,20 @@ struct MainWindowView: View { } } .replacingSheet(isPresented: $showingCommitSheet, onDismiss: performPendingToolbarCommit) { commitSheet } + .replacingSheet(item: $customActionOutput) { presentation in + CustomActionOutputSheet( + result: presentation.result, + onRunAgain: { + customActionOutput = nil + runCustomAction( + id: presentation.result.action.id, + context: presentation.result.context, + surface: invocationSurface(for: presentation.result.context) + ) + }, + onClose: { customActionOutput = nil } + ) + } .replacingSheet(item: $protectedBranchCommitController.warning, onDismiss: { protectedBranchCommitController.finish(.cancel) }) { warning in ProtectedBranchCommitSheet(warning: warning, skipWarnings: $repoSettings.skipProtectedBranchCommitWarnings) { decision in protectedBranchCommitController.finish(decision) @@ -661,6 +680,7 @@ struct MainWindowView: View { stashableCount: syncState.stashableCount )) .focusedSceneValue(\.gitFlowCommandState, gitFlowCommandState) + .focusedSceneValue(\.customActionCommandState, customActionCommandState) .frame(minWidth: 900, minHeight: 600) .task { await performInitialLoad() } .task { @@ -775,6 +795,81 @@ struct MainWindowView: View { operationProgress.run(message: message, operation: operation) } + private var customActionCommandState: CustomActionCommandState { + let surface: CustomActionInvocationSurface + let context: CustomActionInvocationContext + switch selectedItem { + case .item(.fileStatus): + surface = customActionFilePaths.isEmpty ? .repository : .selectedFiles + context = CustomActionInvocationContext( + repositoryURL: repositoryURL, + filePaths: customActionFilePaths, + commitHashes: [] + ) + case .item(.history), .branch, .worktree, .tag, .remoteBranch, .head: + surface = customActionCommitHashes.isEmpty ? .repository : .selectedCommits + context = CustomActionInvocationContext( + repositoryURL: repositoryURL, + filePaths: [], + commitHashes: customActionCommitHashes + ) + default: + surface = .repository + context = CustomActionInvocationContext( + repositoryURL: repositoryURL, + filePaths: [], + commitHashes: [] + ) + } + return CustomActionCommandState( + context: context, + surface: surface, + hasActiveOperation: operationProgress.activeOperation != nil, + run: { id, invocationSurface in + runCustomAction(id: id, context: context, surface: invocationSurface) + } + ) + } + + private func invocationSurface(for context: CustomActionInvocationContext) -> CustomActionInvocationSurface { + if !context.filePaths.isEmpty { return .selectedFiles } + if !context.commitHashes.isEmpty { return .selectedCommits } + return .repository + } + + private func runCustomAction( + id: UUID, + context: CustomActionInvocationContext, + surface: CustomActionInvocationSurface + ) { + guard operationProgress.activeOperation == nil, + let action = customActionStore.action(id: id) else { return } + if let reason = CustomActionValidator.unavailableReason( + for: action, + surface: surface, + context: context, + isTrusted: customActionStore.isTrusted(action) + ) { + syncState.showError(reason) + return + } + + operationProgress.run(message: "Running \(action.name)...") { + let result = await customActionExecutor.execute(action: action, context: context) + await syncState.refresh(repositoryURL: repositoryURL, force: true) + NotificationCenter.default.post( + name: .repositoryDidChange, + object: nil, + userInfo: ["repositoryURL": repositoryURL] + ) + if result.status != .succeeded || action.alwaysShowOutput { + customActionOutput = CustomActionOutputPresentation(result: result) + } else { + syncState.showInfo("\(action.name) completed successfully.") + } + } + } + private func clearReferenceDiff() { branchComparison?.cancel() branchComparison = nil @@ -1243,7 +1338,19 @@ struct MainWindowView: View { }, onAuthorizeCommit: authorizeProtectedBranchCommit, onRequestPushAfterCommit: pushAfterCommit, - onRunRepositoryOperation: runRepositoryOperation + onRunRepositoryOperation: runRepositoryOperation, + onCustomActionSelectionChanged: { customActionFilePaths = $0 }, + onRunCustomAction: { id, paths in + runCustomAction( + id: id, + context: CustomActionInvocationContext( + repositoryURL: repositoryURL, + filePaths: paths, + commitHashes: [] + ), + surface: .selectedFiles + ) + } ) case .item(.history), .branch, .worktree, .tag, .remoteBranch, .head: if let branchComparison { @@ -1273,7 +1380,19 @@ struct MainWindowView: View { onRunRepositoryOperation: runRepositoryOperation, onRequestCheckout: checkoutRequest, onRequestExplainCommit: explainCommitWithRepositoryAI, - onRequestBrowseRevision: { revisionBrowserWindow.show(revision: $0.hash, in: repositoryURL, credentialResolver: providerCredentialResolver) } + onRequestBrowseRevision: { revisionBrowserWindow.show(revision: $0.hash, in: repositoryURL, credentialResolver: providerCredentialResolver) }, + onCustomActionSelectionChanged: { customActionCommitHashes = $0 }, + onRunCustomAction: { id, hashes in + runCustomAction( + id: id, + context: CustomActionInvocationContext( + repositoryURL: repositoryURL, + filePaths: [], + commitHashes: hashes + ), + surface: .selectedCommits + ) + } ) } case .item(.reflog): diff --git a/macgitTests/CustomActionTests.swift b/macgitTests/CustomActionTests.swift new file mode 100644 index 00000000..3cecb59b --- /dev/null +++ b/macgitTests/CustomActionTests.swift @@ -0,0 +1,125 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import XCTest +@testable import macgit + +final class CustomActionTests: XCTestCase { + func testStorePersistsCatalogAndTrustSeparately() { + let suiteName = "CustomActionTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let action = CustomActionDefinition(name: "Environment", executablePath: "/usr/bin/env") + + let store = CustomActionStore(userDefaults: defaults) + store.upsert(action) + + let restored = CustomActionStore(userDefaults: defaults) + XCTAssertEqual(restored.actions, [action]) + XCTAssertTrue(restored.isTrusted(action)) + } + + func testArgumentParserPreservesQuotedArguments() throws { + XCTAssertEqual( + try CustomActionArgumentParser.parse(#"--flag "two words" 'three words' empty\ value"#), + ["--flag", "two words", "three words", "empty value"] + ) + } + + func testMultiValuePlaceholdersExpandAsSeparateArguments() { + let context = CustomActionInvocationContext( + repositoryURL: URL(fileURLWithPath: "/tmp/repo"), + filePaths: ["one.swift", "folder/two.swift"], + commitHashes: ["abc", "def"] + ) + + XCTAssertEqual( + CustomActionArgumentExpander.expand(["$REPO", "$FILE", "--", "$SHA"], context: context), + ["/tmp/repo", "one.swift", "folder/two.swift", "--", "abc", "def"] + ) + } + + func testValidatorRejectsEmbeddedPlaceholder() { + let action = CustomActionDefinition( + name: "Invalid", + executablePath: "/usr/bin/env", + arguments: ["--repo=$REPO"] + ) + + XCTAssertThrowsError(try CustomActionValidator.validate(action)) { error in + XCTAssertEqual(error as? CustomActionValidationError, .embeddedPlaceholder("$REPO")) + } + } + + func testAvailabilityRequiresSelectionAndLocalTrust() { + let action = CustomActionDefinition( + name: "Files", + executablePath: "/usr/bin/env", + arguments: ["$FILE"], + availability: [.selectedFiles] + ) + let context = CustomActionInvocationContext( + repositoryURL: URL(fileURLWithPath: "/tmp/repo"), + filePaths: [], + commitHashes: [] + ) + + XCTAssertNotNil(CustomActionValidator.unavailableReason( + for: action, + surface: .selectedFiles, + context: context, + isTrusted: false + )) + XCTAssertEqual(CustomActionValidator.unavailableReason( + for: action, + surface: .selectedFiles, + context: context, + isTrusted: true + ), "Select one or more files first.") + } + + func testExecutorCapturesProcessResult() async throws { + let repositoryURL = FileManager.default.temporaryDirectory + let action = CustomActionDefinition( + name: "Print", + executablePath: "/usr/bin/printf", + arguments: ["%s", "$REPO"] + ) + + let result = await CustomActionExecutor().execute( + action: action, + context: CustomActionInvocationContext( + repositoryURL: repositoryURL, + filePaths: [], + commitHashes: [] + ) + ) + + XCTAssertEqual(result.status, .succeeded) + XCTAssertEqual(result.exitCode, 0) + XCTAssertEqual(result.standardOutput, repositoryURL.path) + } + + func testExecutorCancellationTerminatesProcess() async throws { + let action = CustomActionDefinition( + name: "Sleep", + executablePath: "/bin/sleep", + arguments: ["5"] + ) + let executor = CustomActionExecutor() + let task = Task { + await executor.execute( + action: action, + context: CustomActionInvocationContext( + repositoryURL: FileManager.default.temporaryDirectory, + filePaths: [], + commitHashes: [] + ) + ) + } + try await Task.sleep(for: .milliseconds(100)) + task.cancel() + + let result = await task.value + XCTAssertEqual(result.status, .cancelled) + } +} From 4d67ce9e0c358c172d8042cee78a2cddd2bcfc67 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sun, 4 Oct 2026 14:35:11 +0700 Subject: [PATCH 2/8] refactor: Replace custom action observation with periodic sync Queue pending upserts and deletions for retry, initialize cloud data per UID, and route menu actions through scoped notifications. --- macgit/App/CustomActionCommandState.swift | 7 +- macgit/App/macgitApp.swift | 10 +- macgit/Models/CustomActionDefinition.swift | 2 +- macgit/Services/CustomActionCloudStore.swift | 4 - macgit/Services/CustomActionStore.swift | 152 ++++++++++++------ .../Services/FirestoreCustomActionStore.swift | 36 ----- macgit/Views/MainWindow/MainWindowView.swift | 11 +- macgitTests/CustomActionTests.swift | 44 +++++ 8 files changed, 166 insertions(+), 100 deletions(-) diff --git a/macgit/App/CustomActionCommandState.swift b/macgit/App/CustomActionCommandState.swift index 9c3c91ed..d0b05147 100644 --- a/macgit/App/CustomActionCommandState.swift +++ b/macgit/App/CustomActionCommandState.swift @@ -2,11 +2,10 @@ import SwiftUI -struct CustomActionCommandState { +struct CustomActionCommandState: Equatable { let context: CustomActionInvocationContext let surface: CustomActionInvocationSurface let hasActiveOperation: Bool - let run: (UUID, CustomActionInvocationSurface) -> Void } struct CustomActionCommandStateKey: FocusedValueKey { @@ -19,3 +18,7 @@ extension FocusedValues { set { self[CustomActionCommandStateKey.self] = newValue } } } + +extension Notification.Name { + static let customActionMenuAction = Notification.Name("macgit.customActionMenuAction") +} diff --git a/macgit/App/macgitApp.swift b/macgit/App/macgitApp.swift index 669157d9..fb53654c 100644 --- a/macgit/App/macgitApp.swift +++ b/macgit/App/macgitApp.swift @@ -263,6 +263,9 @@ struct macgitApp: App { enabled: appState.syncEnabled ) } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in + Task { await customActionStore.syncNow() } + } .onChange(of: accountController.account?.uid, initial: true) { _, uid in aiProviderController.managedUsageController?.setSession(uid: uid) } @@ -417,7 +420,12 @@ struct macgitApp: App { context: state.context, hasActiveOperation: state.hasActiveOperation, includesRepositoryActions: true, - onRun: state.run + onRun: { id, surface in + WindowScopedNotification.post( + name: .customActionMenuAction, + userInfo: ["id": id, "surface": surface] + ) + } ) } else { Text("No Repository Open") diff --git a/macgit/Models/CustomActionDefinition.swift b/macgit/Models/CustomActionDefinition.swift index 10daee26..3772db52 100644 --- a/macgit/Models/CustomActionDefinition.swift +++ b/macgit/Models/CustomActionDefinition.swift @@ -112,7 +112,7 @@ struct CustomActionInvocationContext: Equatable, Sendable { var commitHashes: [String] } -enum CustomActionInvocationSurface: Sendable { +enum CustomActionInvocationSurface: Equatable, Sendable { case repository case selectedFiles case selectedCommits diff --git a/macgit/Services/CustomActionCloudStore.swift b/macgit/Services/CustomActionCloudStore.swift index 4d3095db..28cc67ab 100644 --- a/macgit/Services/CustomActionCloudStore.swift +++ b/macgit/Services/CustomActionCloudStore.swift @@ -8,8 +8,4 @@ protocol CustomActionCloudStore { func upsert(_ action: CustomActionDefinition, uid: String) async throws func delete(id: UUID, uid: String) async throws func updateOrder(_ actions: [CustomActionDefinition], uid: String) async throws - func observe( - uid: String, - onChange: @escaping (Result<[CustomActionDefinition], Error>) -> Void - ) -> ObservationToken } diff --git a/macgit/Services/CustomActionStore.swift b/macgit/Services/CustomActionStore.swift index 73872aae..fccf1e2f 100644 --- a/macgit/Services/CustomActionStore.swift +++ b/macgit/Services/CustomActionStore.swift @@ -8,16 +8,23 @@ final class CustomActionStore: ObservableObject { static let actionsKey = "customActions.catalog.v1" static let trustKey = "customActions.trust.v1" static let executableOverridesKey = "customActions.executableOverrides.v1" + static let pendingUpsertsKey = "customActions.pendingUpserts.v1" + static let pendingDeletionsKey = "customActions.pendingDeletions.v1" + static let initializedCloudUIDsKey = "customActions.initializedCloudUIDs.v1" @Published private(set) var actions: [CustomActionDefinition] @Published private(set) var syncError: String? private let userDefaults: UserDefaults private let cloudStore: CustomActionCloudStore? - private var cloudObservation: ObservationToken? private var activeUID: String? + private var periodicSyncTask: Task? + private var isSyncing = false private var trustedFingerprints: [String: String] private var executableOverrides: [String: String] + private var pendingUpserts: Set + private var pendingDeletions: Set + private var initializedCloudUIDs: Set init( userDefaults: UserDefaults = .standard, @@ -28,36 +35,69 @@ final class CustomActionStore: ObservableObject { actions = Self.decode([CustomActionDefinition].self, from: userDefaults.data(forKey: Self.actionsKey)) ?? [] trustedFingerprints = Self.decode([String: String].self, from: userDefaults.data(forKey: Self.trustKey)) ?? [:] executableOverrides = Self.decode([String: String].self, from: userDefaults.data(forKey: Self.executableOverridesKey)) ?? [:] + pendingUpserts = Self.decode(Set.self, from: userDefaults.data(forKey: Self.pendingUpsertsKey)) ?? [] + pendingDeletions = Self.decode(Set.self, from: userDefaults.data(forKey: Self.pendingDeletionsKey)) ?? [] + initializedCloudUIDs = Self.decode(Set.self, from: userDefaults.data(forKey: Self.initializedCloudUIDsKey)) ?? [] normalizeAndSave() } func updateCloudSession(uid: String?, enabled: Bool) async { - if enabled, let uid, activeUID == uid, cloudObservation != nil { return } - cloudObservation?.cancel() - cloudObservation = nil + if enabled, let uid, activeUID == uid, periodicSyncTask != nil { return } + periodicSyncTask?.cancel() + periodicSyncTask = nil activeUID = nil syncError = nil - guard enabled, let uid, let cloudStore else { return } + guard enabled, let uid, cloudStore != nil else { return } activeUID = uid + await syncNow() + periodicSyncTask = Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(300)) + guard !Task.isCancelled else { return } + await self?.syncNow() + } + } + } + + func syncNow() async { + guard !isSyncing, let uid = activeUID, let cloudStore else { return } + isSyncing = true + defer { isSyncing = false } + do { - let remote = try await cloudStore.load(uid: uid) - guard activeUID == uid else { return } - if remote.isEmpty, !actions.isEmpty { - for action in actions { - try await cloudStore.upsert(action, uid: uid) + for idString in Array(pendingDeletions) { + guard let id = UUID(uuidString: idString) else { continue } + try await cloudStore.delete(id: id, uid: uid) + pendingDeletions.remove(idString) + savePendingMutations() + } + + for idString in Array(pendingUpserts) { + guard let id = UUID(uuidString: idString), + let action = actions.first(where: { $0.id == id }) else { + pendingUpserts.remove(idString) + continue } - } else { - let localByID = Dictionary(uniqueKeysWithValues: actions.map { ($0.id, $0) }) + try await cloudStore.upsert(action, uid: uid) + pendingUpserts.remove(idString) + savePendingMutations() + } + + var remote = try await cloudStore.load(uid: uid) + guard activeUID == uid else { return } + if !initializedCloudUIDs.contains(uid) { let remoteIDs = Set(remote.map(\.id)) - var merged = remote - merged.append(contentsOf: actions.filter { !remoteIDs.contains($0.id) }) - applyRemote(merged) - for action in localByID.values where !remoteIDs.contains(action.id) { + let localOnly = actions.filter { !remoteIDs.contains($0.id) } + for action in localOnly { try await cloudStore.upsert(action, uid: uid) } + remote.append(contentsOf: localOnly) + initializedCloudUIDs.insert(uid) + saveInitializedCloudUIDs() } - beginObserving(uid: uid) + applyRemote(remote) + syncError = nil } catch { guard activeUID == uid else { return } syncError = error.localizedDescription @@ -108,11 +148,7 @@ final class CustomActionStore: ObservableObject { } normalizeAndSave() if trustOnThisMac { trust(normalized) } - guard let uid = activeUID, let cloudStore else { return } - Task { @MainActor in - do { try await cloudStore.upsert(normalized, uid: uid) } - catch { self.syncError = error.localizedDescription } - } + markPendingUpsert(normalized.id) } func delete(_ action: CustomActionDefinition) { @@ -122,11 +158,9 @@ final class CustomActionStore: ObservableObject { normalizeAndSave() saveTrustedFingerprints() saveExecutableOverrides() - guard let uid = activeUID, let cloudStore else { return } - Task { @MainActor in - do { try await cloudStore.delete(id: action.id, uid: uid) } - catch { self.syncError = error.localizedDescription } - } + pendingUpserts.remove(action.id.uuidString) + pendingDeletions.insert(action.id.uuidString) + savePendingMutations() } func duplicate(_ action: CustomActionDefinition) { @@ -149,41 +183,34 @@ final class CustomActionStore: ObservableObject { let insertionIndex = min(max(0, toOffset - removedBeforeDestination), actions.count) actions.insert(contentsOf: movingActions, at: insertionIndex) normalizeAndSave() - guard let uid = activeUID, let cloudStore else { return } - let snapshot = actions - Task { @MainActor in - do { try await cloudStore.updateOrder(snapshot, uid: uid) } - catch { self.syncError = error.localizedDescription } - } - } - - private func beginObserving(uid: String) { - guard let cloudStore else { return } - cloudObservation = cloudStore.observe(uid: uid) { [weak self] result in - Task { @MainActor in - guard let self, self.activeUID == uid else { return } - switch result { - case .success(let actions): - self.syncError = nil - self.applyRemote(actions) - case .failure(let error): - self.syncError = error.localizedDescription - } - } - } + pendingUpserts.formUnion(actions.map { $0.id.uuidString }) + savePendingMutations() } private func applyRemote(_ remote: [CustomActionDefinition]) { - actions = remote - normalizeAndSave() + let normalizedActions = normalized(remote) + guard normalizedActions != actions else { return } + actions = normalizedActions + saveActions() } private func normalizeAndSave() { - actions = actions.enumerated().map { index, action in + let normalizedActions = normalized(actions) + if normalizedActions != actions { + actions = normalizedActions + } + saveActions() + } + + private func normalized(_ actions: [CustomActionDefinition]) -> [CustomActionDefinition] { + actions.enumerated().map { index, action in var normalized = action normalized.sortIndex = index return normalized } + } + + private func saveActions() { if let data = try? JSONEncoder().encode(actions) { userDefaults.set(data, forKey: Self.actionsKey) } @@ -201,6 +228,27 @@ final class CustomActionStore: ObservableObject { } } + private func markPendingUpsert(_ id: UUID) { + pendingDeletions.remove(id.uuidString) + pendingUpserts.insert(id.uuidString) + savePendingMutations() + } + + private func savePendingMutations() { + if let data = try? JSONEncoder().encode(pendingUpserts) { + userDefaults.set(data, forKey: Self.pendingUpsertsKey) + } + if let data = try? JSONEncoder().encode(pendingDeletions) { + userDefaults.set(data, forKey: Self.pendingDeletionsKey) + } + } + + private func saveInitializedCloudUIDs() { + if let data = try? JSONEncoder().encode(initializedCloudUIDs) { + userDefaults.set(data, forKey: Self.initializedCloudUIDsKey) + } + } + private static func decode(_ type: T.Type, from data: Data?) -> T? { guard let data else { return nil } return try? JSONDecoder().decode(type, from: data) diff --git a/macgit/Services/FirestoreCustomActionStore.swift b/macgit/Services/FirestoreCustomActionStore.swift index 14a3644c..cfba6855 100644 --- a/macgit/Services/FirestoreCustomActionStore.swift +++ b/macgit/Services/FirestoreCustomActionStore.swift @@ -108,27 +108,6 @@ final class FirestoreCustomActionStore: CustomActionCloudStore { try await batch.commit() } - func observe( - uid: String, - onChange: @escaping (Result<[CustomActionDefinition], Error>) -> Void - ) -> ObservationToken { - let registration = collection(uid: uid).addSnapshotListener { snapshot, error in - if let error { - onChange(.failure(error)) - return - } - do { - let actions = try (snapshot?.documents ?? []) - .map { try FirestoreCustomActionDocument.decode(id: $0.documentID, data: $0.data()) } - .sorted(by: Self.order) - onChange(.success(actions)) - } catch { - onChange(.failure(error)) - } - } - return CustomActionFirestoreObservationToken(registration: registration) - } - private func collection(uid: String) -> CollectionReference { firestore.collection("users").document(uid).collection("customActions") } @@ -138,18 +117,3 @@ final class FirestoreCustomActionStore: CustomActionCloudStore { return lhs.sortIndex < rhs.sortIndex } } - -private final class CustomActionFirestoreObservationToken: ObservationToken { - private var registration: ListenerRegistration? - - init(registration: ListenerRegistration) { - self.registration = registration - } - - func cancel() { - registration?.remove() - registration = nil - } - - deinit { registration?.remove() } -} diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index 0587e304..6d99bc7b 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -742,6 +742,12 @@ struct MainWindowView: View { let action = notification.userInfo?["action"] as? GitFlowMenuAction else { return } handleGitFlowMenuAction(action) } + .onReceive(NotificationCenter.default.publisher(for: .customActionMenuAction)) { notification in + guard windowContext.owns(notification), + let id = notification.userInfo?["id"] as? UUID, + let surface = notification.userInfo?["surface"] as? CustomActionInvocationSurface else { return } + runCustomAction(id: id, context: customActionCommandState.context, surface: surface) + } .onChange(of: repositoryAIChatController.workflowAccessNotice) { _, notice in guard let notice else { return } if notice.denial == .requiresPro { @@ -824,10 +830,7 @@ struct MainWindowView: View { return CustomActionCommandState( context: context, surface: surface, - hasActiveOperation: operationProgress.activeOperation != nil, - run: { id, invocationSurface in - runCustomAction(id: id, context: context, surface: invocationSurface) - } + hasActiveOperation: operationProgress.activeOperation != nil ) } diff --git a/macgitTests/CustomActionTests.swift b/macgitTests/CustomActionTests.swift index 3cecb59b..e1ebec25 100644 --- a/macgitTests/CustomActionTests.swift +++ b/macgitTests/CustomActionTests.swift @@ -18,6 +18,23 @@ final class CustomActionTests: XCTestCase { XCTAssertTrue(restored.isTrusted(action)) } + func testStoreDefersCloudWriteUntilScheduledSync() async { + let suiteName = "CustomActionTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let cloudStore = RecordingCustomActionCloudStore() + let store = CustomActionStore(userDefaults: defaults, cloudStore: cloudStore) + await store.updateCloudSession(uid: "user", enabled: true) + cloudStore.events = [] + + let action = CustomActionDefinition(name: "Environment", executablePath: "/usr/bin/env") + store.upsert(action) + + XCTAssertTrue(cloudStore.events.isEmpty) + await store.syncNow() + XCTAssertEqual(cloudStore.events, ["upsert", "load"]) + } + func testArgumentParserPreservesQuotedArguments() throws { XCTAssertEqual( try CustomActionArgumentParser.parse(#"--flag "two words" 'three words' empty\ value"#), @@ -123,3 +140,30 @@ final class CustomActionTests: XCTestCase { XCTAssertEqual(result.status, .cancelled) } } + +@MainActor +private final class RecordingCustomActionCloudStore: CustomActionCloudStore { + var events: [String] = [] + private var actions: [CustomActionDefinition] = [] + + func load(uid: String) async throws -> [CustomActionDefinition] { + events.append("load") + return actions + } + + func upsert(_ action: CustomActionDefinition, uid: String) async throws { + events.append("upsert") + actions.removeAll { $0.id == action.id } + actions.append(action) + } + + func delete(id: UUID, uid: String) async throws { + events.append("delete") + actions.removeAll { $0.id == id } + } + + func updateOrder(_ actions: [CustomActionDefinition], uid: String) async throws { + events.append("order") + self.actions = actions + } +} From a6436fad5d2c1ba499e6aaecd663271e10c5b8b8 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sun, 4 Oct 2026 18:49:29 +0700 Subject: [PATCH 3/8] fix: isolate custom action accounts and preserve concurrent sync edits --- .../Services/CustomActionArgumentParser.swift | 6 +- macgit/Services/CustomActionStore.swift | 103 ++++++++++-------- .../Common/CustomActionsSettingsView.swift | 1 - macgitTests/CustomActionTests.swift | 56 ++++++++++ 4 files changed, 116 insertions(+), 50 deletions(-) diff --git a/macgit/Services/CustomActionArgumentParser.swift b/macgit/Services/CustomActionArgumentParser.swift index 284f59b8..1760cf75 100644 --- a/macgit/Services/CustomActionArgumentParser.swift +++ b/macgit/Services/CustomActionArgumentParser.swift @@ -29,7 +29,8 @@ enum CustomActionArgumentParser { for character in value { if isEscaping { - current.append(character) + if quote == .double, !"$`\"\\\n".contains(character) { current.append("\\") } + if character != "\n" { current.append(character) } isEscaping = false hasToken = true continue @@ -154,9 +155,6 @@ enum CustomActionValidator { if Self.supportedPlaceholders.contains(placeholder), argument != placeholder { throw CustomActionValidationError.embeddedPlaceholder(placeholder) } - if !Self.supportedPlaceholders.contains(placeholder) { - throw CustomActionValidationError.unsupportedPlaceholder(placeholder) - } } } } diff --git a/macgit/Services/CustomActionStore.swift b/macgit/Services/CustomActionStore.swift index fccf1e2f..04607d0b 100644 --- a/macgit/Services/CustomActionStore.swift +++ b/macgit/Services/CustomActionStore.swift @@ -10,7 +10,6 @@ final class CustomActionStore: ObservableObject { static let executableOverridesKey = "customActions.executableOverrides.v1" static let pendingUpsertsKey = "customActions.pendingUpserts.v1" static let pendingDeletionsKey = "customActions.pendingDeletions.v1" - static let initializedCloudUIDsKey = "customActions.initializedCloudUIDs.v1" @Published private(set) var actions: [CustomActionDefinition] @Published private(set) var syncError: String? @@ -18,13 +17,15 @@ final class CustomActionStore: ObservableObject { private let userDefaults: UserDefaults private let cloudStore: CustomActionCloudStore? private var activeUID: String? + private var catalogUID: String? + private var sessionGeneration = 0 + private var mutationVersions: [String: Int] = [:] private var periodicSyncTask: Task? private var isSyncing = false private var trustedFingerprints: [String: String] private var executableOverrides: [String: String] private var pendingUpserts: Set private var pendingDeletions: Set - private var initializedCloudUIDs: Set init( userDefaults: UserDefaults = .standard, @@ -37,20 +38,28 @@ final class CustomActionStore: ObservableObject { executableOverrides = Self.decode([String: String].self, from: userDefaults.data(forKey: Self.executableOverridesKey)) ?? [:] pendingUpserts = Self.decode(Set.self, from: userDefaults.data(forKey: Self.pendingUpsertsKey)) ?? [] pendingDeletions = Self.decode(Set.self, from: userDefaults.data(forKey: Self.pendingDeletionsKey)) ?? [] - initializedCloudUIDs = Self.decode(Set.self, from: userDefaults.data(forKey: Self.initializedCloudUIDsKey)) ?? [] normalizeAndSave() } func updateCloudSession(uid: String?, enabled: Bool) async { - if enabled, let uid, activeUID == uid, periodicSyncTask != nil { return } + sessionGeneration &+= 1 + let generation = sessionGeneration periodicSyncTask?.cancel() periodicSyncTask = nil - activeUID = nil + activeUID = enabled ? uid : nil syncError = nil - - guard enabled, let uid, cloudStore != nil else { return } - activeUID = uid + if catalogUID != uid { + catalogUID = uid + actions = Self.decode([CustomActionDefinition].self, from: userDefaults.data(forKey: scopedKey(Self.actionsKey))) ?? [] + trustedFingerprints = Self.decode([String: String].self, from: userDefaults.data(forKey: scopedKey(Self.trustKey))) ?? [:] + executableOverrides = Self.decode([String: String].self, from: userDefaults.data(forKey: scopedKey(Self.executableOverridesKey))) ?? [:] + pendingUpserts = Self.decode(Set.self, from: userDefaults.data(forKey: scopedKey(Self.pendingUpsertsKey))) ?? [] + pendingDeletions = Self.decode(Set.self, from: userDefaults.data(forKey: scopedKey(Self.pendingDeletionsKey))) ?? [] + mutationVersions = [:] + } + guard enabled, uid != nil, cloudStore != nil else { return } await syncNow() + guard generation == sessionGeneration else { return } periodicSyncTask = Task { @MainActor [weak self] in while !Task.isCancelled { try? await Task.sleep(for: .seconds(300)) @@ -62,48 +71,51 @@ final class CustomActionStore: ObservableObject { func syncNow() async { guard !isSyncing, let uid = activeUID, let cloudStore else { return } + let generation = sessionGeneration isSyncing = true - defer { isSyncing = false } - + defer { + isSyncing = false + if generation != sessionGeneration { + Task { @MainActor [weak self] in await self?.syncNow() } + } + } do { for idString in Array(pendingDeletions) { guard let id = UUID(uuidString: idString) else { continue } + let version = mutationVersions[idString, default: 0] try await cloudStore.delete(id: id, uid: uid) - pendingDeletions.remove(idString) - savePendingMutations() + guard generation == sessionGeneration else { return } + if mutationVersions[idString, default: 0] == version { + pendingDeletions.remove(idString) + savePendingMutations() + } } - for idString in Array(pendingUpserts) { guard let id = UUID(uuidString: idString), - let action = actions.first(where: { $0.id == id }) else { - pendingUpserts.remove(idString) - continue - } + let action = actions.first(where: { $0.id == id }) else { continue } + let version = mutationVersions[idString, default: 0] try await cloudStore.upsert(action, uid: uid) - pendingUpserts.remove(idString) - savePendingMutations() - } - - var remote = try await cloudStore.load(uid: uid) - guard activeUID == uid else { return } - if !initializedCloudUIDs.contains(uid) { - let remoteIDs = Set(remote.map(\.id)) - let localOnly = actions.filter { !remoteIDs.contains($0.id) } - for action in localOnly { - try await cloudStore.upsert(action, uid: uid) + guard generation == sessionGeneration else { return } + if mutationVersions[idString, default: 0] == version { + pendingUpserts.remove(idString) + savePendingMutations() } - remote.append(contentsOf: localOnly) - initializedCloudUIDs.insert(uid) - saveInitializedCloudUIDs() } + let remote = try await cloudStore.load(uid: uid) + guard generation == sessionGeneration else { return } applyRemote(remote) syncError = nil } catch { - guard activeUID == uid else { return } + guard generation == sessionGeneration else { return } syncError = error.localizedDescription } } + private func scopedKey(_ key: String) -> String { + guard let catalogUID else { return key } + return key + ".account." + Data(catalogUID.utf8).base64EncodedString() + } + func action(id: UUID) -> CustomActionDefinition? { actions.first { $0.id == id }.map(effectiveAction) } @@ -158,13 +170,14 @@ final class CustomActionStore: ObservableObject { normalizeAndSave() saveTrustedFingerprints() saveExecutableOverrides() + mutationVersions[action.id.uuidString, default: 0] &+= 1 pendingUpserts.remove(action.id.uuidString) pendingDeletions.insert(action.id.uuidString) savePendingMutations() } func duplicate(_ action: CustomActionDefinition) { - upsert(action.duplicated) + upsert(action.duplicated, trustOnThisMac: isTrusted(action)) } func setEnabled(_ enabled: Bool, for action: CustomActionDefinition) { @@ -183,12 +196,17 @@ final class CustomActionStore: ObservableObject { let insertionIndex = min(max(0, toOffset - removedBeforeDestination), actions.count) actions.insert(contentsOf: movingActions, at: insertionIndex) normalizeAndSave() + for action in actions { mutationVersions[action.id.uuidString, default: 0] &+= 1 } pendingUpserts.formUnion(actions.map { $0.id.uuidString }) savePendingMutations() } private func applyRemote(_ remote: [CustomActionDefinition]) { - let normalizedActions = normalized(remote) + let localPending = actions.filter { pendingUpserts.contains($0.id.uuidString) } + let remoteUnchanged = remote.filter { + !pendingDeletions.contains($0.id.uuidString) && !pendingUpserts.contains($0.id.uuidString) + } + let normalizedActions = normalized((remoteUnchanged + localPending).sorted { $0.sortIndex < $1.sortIndex }) guard normalizedActions != actions else { return } actions = normalizedActions saveActions() @@ -212,23 +230,24 @@ final class CustomActionStore: ObservableObject { private func saveActions() { if let data = try? JSONEncoder().encode(actions) { - userDefaults.set(data, forKey: Self.actionsKey) + userDefaults.set(data, forKey: scopedKey(Self.actionsKey)) } } private func saveTrustedFingerprints() { if let data = try? JSONEncoder().encode(trustedFingerprints) { - userDefaults.set(data, forKey: Self.trustKey) + userDefaults.set(data, forKey: scopedKey(Self.trustKey)) } } private func saveExecutableOverrides() { if let data = try? JSONEncoder().encode(executableOverrides) { - userDefaults.set(data, forKey: Self.executableOverridesKey) + userDefaults.set(data, forKey: scopedKey(Self.executableOverridesKey)) } } private func markPendingUpsert(_ id: UUID) { + mutationVersions[id.uuidString, default: 0] &+= 1 pendingDeletions.remove(id.uuidString) pendingUpserts.insert(id.uuidString) savePendingMutations() @@ -236,16 +255,10 @@ final class CustomActionStore: ObservableObject { private func savePendingMutations() { if let data = try? JSONEncoder().encode(pendingUpserts) { - userDefaults.set(data, forKey: Self.pendingUpsertsKey) + userDefaults.set(data, forKey: scopedKey(Self.pendingUpsertsKey)) } if let data = try? JSONEncoder().encode(pendingDeletions) { - userDefaults.set(data, forKey: Self.pendingDeletionsKey) - } - } - - private func saveInitializedCloudUIDs() { - if let data = try? JSONEncoder().encode(initializedCloudUIDs) { - userDefaults.set(data, forKey: Self.initializedCloudUIDsKey) + userDefaults.set(data, forKey: scopedKey(Self.pendingDeletionsKey)) } } diff --git a/macgit/Views/Common/CustomActionsSettingsView.swift b/macgit/Views/Common/CustomActionsSettingsView.swift index d686591f..95a1209d 100644 --- a/macgit/Views/Common/CustomActionsSettingsView.swift +++ b/macgit/Views/Common/CustomActionsSettingsView.swift @@ -112,6 +112,5 @@ struct CustomActionsSettingsView: View { panel.allowsMultipleSelection = false guard panel.runModal() == .OK, let url = panel.url else { return } store.setExecutableOverride(url.path, for: action) - store.trust(action) } } diff --git a/macgitTests/CustomActionTests.swift b/macgitTests/CustomActionTests.swift index e1ebec25..f1930a31 100644 --- a/macgitTests/CustomActionTests.swift +++ b/macgitTests/CustomActionTests.swift @@ -35,6 +35,60 @@ final class CustomActionTests: XCTestCase { XCTAssertEqual(cloudStore.events, ["upsert", "load"]) } + func testAccountCatalogsAndGuestActionsRemainSeparate() async { + let suite = "CustomActionTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = CustomActionStore(userDefaults: defaults) + let guest = CustomActionDefinition(name: "Guest", executablePath: "/usr/bin/env") + let account = CustomActionDefinition(name: "Account", executablePath: "/usr/bin/env") + store.upsert(guest) + await store.updateCloudSession(uid: "A", enabled: false) + XCTAssertTrue(store.actions.isEmpty) + store.upsert(account) + await store.updateCloudSession(uid: "B", enabled: false) + XCTAssertTrue(store.actions.isEmpty) + await store.updateCloudSession(uid: "A", enabled: false) + XCTAssertEqual(store.actions, [account]) + await store.updateCloudSession(uid: nil, enabled: false) + XCTAssertEqual(store.actions, [guest]) + } + + func testLocalEditDuringRemoteLoadSurvives() async { + let suite = "CustomActionTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let cloud = RecordingCustomActionCloudStore() + let store = CustomActionStore(userDefaults: defaults, cloudStore: cloud) + await store.updateCloudSession(uid: "A", enabled: true) + let action = CustomActionDefinition(name: "New", executablePath: "/usr/bin/env") + cloud.onLoad = { store.upsert(action) } + await store.syncNow() + XCTAssertEqual(store.actions, [action]) + cloud.onLoad = nil + cloud.events = [] + await store.syncNow() + XCTAssertEqual(cloud.events, ["upsert", "load"]) + } + + func testDuplicateDoesNotTrustUnreviewedAction() { + let suite = "CustomActionTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = CustomActionStore(userDefaults: defaults) + let action = CustomActionDefinition(name: "Unreviewed", executablePath: "/usr/bin/env") + store.upsert(action, trustOnThisMac: false) + store.duplicate(action) + XCTAssertFalse(store.isTrusted(store.actions[1])) + } + + func testQuotedLiteralBackslashesAndDollarArguments() throws { + XCTAssertEqual(try CustomActionArgumentParser.parse(#""C:\path" "a\qb""#), [#"C:\path"#, #"a\qb"#]) + let action = CustomActionDefinition(name: "Literal", executablePath: "/usr/bin/env", arguments: ["price=$5", "--out=$HOME/file"]) + XCTAssertNoThrow(try CustomActionValidator.validate(action)) + XCTAssertEqual(try CustomActionArgumentParser.parse(CustomActionArgumentParser.joined(action.arguments)), action.arguments) + } + func testArgumentParserPreservesQuotedArguments() throws { XCTAssertEqual( try CustomActionArgumentParser.parse(#"--flag "two words" 'three words' empty\ value"#), @@ -144,10 +198,12 @@ final class CustomActionTests: XCTestCase { @MainActor private final class RecordingCustomActionCloudStore: CustomActionCloudStore { var events: [String] = [] + var onLoad: (() -> Void)? private var actions: [CustomActionDefinition] = [] func load(uid: String) async throws -> [CustomActionDefinition] { events.append("load") + onLoad?() return actions } From c7753a51994cc16b5b638ada855238e6bf97da0d Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sun, 4 Oct 2026 21:18:21 +0700 Subject: [PATCH 4/8] feat: improve custom action --- macgit/App/macgitApp.swift | 3 +- .../Common/CustomActionMenuContent.swift | 14 +++++++++ .../Common/CustomActionSettingsRow.swift | 23 +++++++++----- .../Common/CustomActionsSettingsView.swift | 31 ++++++++++++------- 4 files changed, 50 insertions(+), 21 deletions(-) diff --git a/macgit/App/macgitApp.swift b/macgit/App/macgitApp.swift index fb53654c..bbca9468 100644 --- a/macgit/App/macgitApp.swift +++ b/macgit/App/macgitApp.swift @@ -429,9 +429,10 @@ struct macgitApp: App { ) } else { Text("No Repository Open") + Divider() + CustomActionAddMenuButton() } } - .disabled(customActionCommandState == nil) Divider() diff --git a/macgit/Views/Common/CustomActionMenuContent.swift b/macgit/Views/Common/CustomActionMenuContent.swift index 289f6aa4..68916937 100644 --- a/macgit/Views/Common/CustomActionMenuContent.swift +++ b/macgit/Views/Common/CustomActionMenuContent.swift @@ -37,5 +37,19 @@ struct CustomActionMenuContent: View { .help(reason ?? "Run \(action.name)") } } + Divider() + CustomActionAddMenuButton() + } +} + + +struct CustomActionAddMenuButton: View { + var body: some View { + Button("Add new action", systemImage: "plus") { + WindowScopedNotification.post( + name: .showAppSettings, + userInfo: ["section": AppSettingsSection.customActions.rawValue] + ) + } } } diff --git a/macgit/Views/Common/CustomActionSettingsRow.swift b/macgit/Views/Common/CustomActionSettingsRow.swift index 626119de..1c0ae55f 100644 --- a/macgit/Views/Common/CustomActionSettingsRow.swift +++ b/macgit/Views/Common/CustomActionSettingsRow.swift @@ -18,15 +18,22 @@ struct CustomActionSettingsRow: View { )) .labelsHidden() - VStack(alignment: .leading, spacing: 3) { - Text(action.name) - Text(detail) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) + Button { + onToggle(!action.isEnabled) + } label: { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text(action.name) + Text(detail) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + } + Spacer() + } + .contentShape(Rectangle()) } - - Spacer() + .buttonStyle(.plain) if needsExecutable { Button("Locate…", action: onLocate) diff --git a/macgit/Views/Common/CustomActionsSettingsView.swift b/macgit/Views/Common/CustomActionsSettingsView.swift index 95a1209d..8ff4f453 100644 --- a/macgit/Views/Common/CustomActionsSettingsView.swift +++ b/macgit/Views/Common/CustomActionsSettingsView.swift @@ -6,8 +6,12 @@ import SwiftUI struct CustomActionsSettingsView: View { @ObservedObject var store: CustomActionStore @State private var selection: UUID? - @State private var editingAction: CustomActionDefinition? - @State private var isPresentingEditor = false + @State private var editor: EditorPresentation? + + private struct EditorPresentation: Identifiable { + let id = UUID() + let action: CustomActionDefinition? + } var body: some View { VStack(alignment: .leading, spacing: 16) { @@ -29,18 +33,21 @@ struct CustomActionsSettingsView: View { description: Text("Add an executable or import a script to get started.") ) } else { - List(selection: $selection) { + List { ForEach(store.actions) { action in CustomActionSettingsRow( action: action, effectiveAction: store.effectiveAction(action), isTrusted: store.isTrusted(action), - onToggle: { store.setEnabled($0, for: action) }, + onToggle: { + selection = action.id + store.setEnabled($0, for: action) + }, onReview: { edit(action) }, onLocate: { locate(action) } ) - .tag(action.id) - .onTapGesture(count: 2) { edit(action) } + .listRowBackground(action.isEnabled ? Color.accentColor.opacity(0.18) : Color.clear) + .simultaneousGesture(TapGesture(count: 2).onEnded { edit(action) }) } .onMove(perform: store.move) } @@ -66,8 +73,9 @@ struct CustomActionsSettingsView: View { } } .padding(24) - .sheet(isPresented: $isPresentingEditor) { - CustomActionEditorSheet(action: editingAction) { action in + .sheet(item: $editor) { presentation in + CustomActionEditorSheet(action: presentation.action) { action in + store.setExecutableOverride(nil, for: action) store.upsert(action) selection = action.id } @@ -80,13 +88,12 @@ struct CustomActionsSettingsView: View { } private func add() { - editingAction = nil - isPresentingEditor = true + editor = EditorPresentation(action: nil) } private func edit(_ action: CustomActionDefinition) { - editingAction = action - isPresentingEditor = true + guard let current = store.action(id: action.id) else { return } + editor = EditorPresentation(action: current) } private func editSelected() { From 3213d25a5d87e8d00586ce8db8f6f75deac307cc Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sun, 4 Oct 2026 21:46:32 +0700 Subject: [PATCH 5/8] fix: handle ui for custom action --- .../Common/CustomActionEditorSheet.swift | 33 +++++++------ .../Common/CustomActionSettingsRow.swift | 49 ++++++++++--------- .../Common/CustomActionsSettingsView.swift | 39 ++------------- 3 files changed, 50 insertions(+), 71 deletions(-) diff --git a/macgit/Views/Common/CustomActionEditorSheet.swift b/macgit/Views/Common/CustomActionEditorSheet.swift index d4c29bd8..1bd6f701 100644 --- a/macgit/Views/Common/CustomActionEditorSheet.swift +++ b/macgit/Views/Common/CustomActionEditorSheet.swift @@ -79,21 +79,9 @@ struct CustomActionEditorSheet: View { private var sourceFields: some View { switch draft.sourceKind { case .executable: - LabeledContent("Executable") { - HStack { - TextField("/absolute/path/to/executable", text: $draft.executablePath) - .font(.body.monospaced()) - Button("Choose…", action: chooseSource) - } - } + sourcePathField(title: "Executable", prompt: "Choose an executable or enter its full path") case .localScript: - LabeledContent("Script") { - HStack { - TextField("/absolute/path/to/script", text: $draft.executablePath) - .font(.body.monospaced()) - Button("Choose…", action: chooseSource) - } - } + sourcePathField(title: "Script", prompt: "Choose a script or enter its full path") scriptLanguagePicker case .syncedScript: LabeledContent("Imported File") { @@ -112,6 +100,23 @@ struct CustomActionEditorSheet: View { } } + private func sourcePathField(title: String, prompt: String) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(title) + Spacer() + Button("Choose…", action: chooseSource) + } + TextField("File path", text: $draft.executablePath, prompt: Text(prompt), axis: .vertical) + .labelsHidden() + .font(.body.monospaced()) + .textFieldStyle(.roundedBorder) + .lineLimit(2...5) + .frame(maxWidth: .infinity, alignment: .leading) + .help(draft.executablePath.isEmpty ? prompt : draft.executablePath) + } + } + private var scriptLanguagePicker: some View { Picker("Language", selection: $draft.scriptLanguage) { ForEach(CustomActionScriptLanguage.allCases, id: \.self) { language in diff --git a/macgit/Views/Common/CustomActionSettingsRow.swift b/macgit/Views/Common/CustomActionSettingsRow.swift index 1c0ae55f..2c5c8714 100644 --- a/macgit/Views/Common/CustomActionSettingsRow.swift +++ b/macgit/Views/Common/CustomActionSettingsRow.swift @@ -6,35 +6,29 @@ struct CustomActionSettingsRow: View { let action: CustomActionDefinition let effectiveAction: CustomActionDefinition let isTrusted: Bool - let onToggle: (Bool) -> Void + let onRemove: () -> Void + let onDuplicate: () -> Void + let onEdit: () -> Void let onReview: () -> Void let onLocate: () -> Void var body: some View { HStack(spacing: 12) { - Toggle(action.name, isOn: Binding( - get: { action.isEnabled }, - set: onToggle - )) - .labelsHidden() - - Button { - onToggle(!action.isEnabled) - } label: { - HStack { - VStack(alignment: .leading, spacing: 3) { - Text(action.name) - Text(detail) - .font(.caption.monospaced()) - .foregroundStyle(.secondary) - .lineLimit(1) - } - Spacer() - } - .contentShape(Rectangle()) + VStack(alignment: .leading, spacing: 3) { + Text(action.name) + Text(detail) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(detail) } - .buttonStyle(.plain) + Spacer() + + if !action.isEnabled { + Text("Disabled").foregroundStyle(.secondary) + } if needsExecutable { Button("Locate…", action: onLocate) } else if !isTrusted { @@ -44,6 +38,17 @@ struct CustomActionSettingsRow: View { .foregroundStyle(.green) .labelStyle(.titleAndIcon) } + + HStack(spacing: 8) { + Button("Remove", systemImage: "trash", action: onRemove) + .help("Remove \(action.name)") + Button("Duplicate", systemImage: "plus.square.on.square", action: onDuplicate) + .help("Duplicate \(action.name)") + Button("Edit", systemImage: "pencil", action: onEdit) + .help("Edit \(action.name)") + } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) } .padding(.vertical, 4) } diff --git a/macgit/Views/Common/CustomActionsSettingsView.swift b/macgit/Views/Common/CustomActionsSettingsView.swift index 8ff4f453..cc6b81a9 100644 --- a/macgit/Views/Common/CustomActionsSettingsView.swift +++ b/macgit/Views/Common/CustomActionsSettingsView.swift @@ -5,7 +5,6 @@ import SwiftUI struct CustomActionsSettingsView: View { @ObservedObject var store: CustomActionStore - @State private var selection: UUID? @State private var editor: EditorPresentation? private struct EditorPresentation: Identifiable { @@ -39,15 +38,12 @@ struct CustomActionsSettingsView: View { action: action, effectiveAction: store.effectiveAction(action), isTrusted: store.isTrusted(action), - onToggle: { - selection = action.id - store.setEnabled($0, for: action) - }, + onRemove: { store.delete(action) }, + onDuplicate: { store.duplicate(action) }, + onEdit: { edit(action) }, onReview: { edit(action) }, onLocate: { locate(action) } ) - .listRowBackground(action.isEnabled ? Color.accentColor.opacity(0.18) : Color.clear) - .simultaneousGesture(TapGesture(count: 2).onEnded { edit(action) }) } .onMove(perform: store.move) } @@ -57,12 +53,6 @@ struct CustomActionsSettingsView: View { HStack { Button("Add", systemImage: "plus", action: add) - Button("Remove", systemImage: "minus", action: remove) - .disabled(selectedAction == nil) - Button("Duplicate", systemImage: "plus.square.on.square", action: duplicate) - .disabled(selectedAction == nil) - Button("Edit…", action: editSelected) - .disabled(selectedAction == nil) Spacer() if let syncError = store.syncError { Text(syncError) @@ -71,22 +61,17 @@ struct CustomActionsSettingsView: View { .lineLimit(2) } } + .fixedSize(horizontal: false, vertical: true) } .padding(24) .sheet(item: $editor) { presentation in CustomActionEditorSheet(action: presentation.action) { action in store.setExecutableOverride(nil, for: action) store.upsert(action) - selection = action.id } } } - private var selectedAction: CustomActionDefinition? { - guard let selection else { return nil } - return store.actions.first { $0.id == selection } - } - private func add() { editor = EditorPresentation(action: nil) } @@ -96,22 +81,6 @@ struct CustomActionsSettingsView: View { editor = EditorPresentation(action: current) } - private func editSelected() { - guard let selectedAction else { return } - edit(selectedAction) - } - - private func remove() { - guard let selectedAction else { return } - store.delete(selectedAction) - selection = nil - } - - private func duplicate() { - guard let selectedAction else { return } - store.duplicate(selectedAction) - } - private func locate(_ action: CustomActionDefinition) { let panel = NSOpenPanel() panel.canChooseDirectories = false From b259c710115aa1a16859c7d5a5e9a6cb24686a25 Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sun, 4 Oct 2026 21:57:35 +0700 Subject: [PATCH 6/8] fix: reject embedded custom action placeholders consistently --- .../Services/CustomActionArgumentParser.swift | 16 +++++++----- macgitTests/CustomActionTests.swift | 26 +++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/macgit/Services/CustomActionArgumentParser.swift b/macgit/Services/CustomActionArgumentParser.swift index 1760cf75..6efe7523 100644 --- a/macgit/Services/CustomActionArgumentParser.swift +++ b/macgit/Services/CustomActionArgumentParser.swift @@ -148,12 +148,16 @@ enum CustomActionValidator { } for argument in action.arguments { - let placeholders = argument.split(whereSeparator: { $0.isWhitespace || $0 == "=" || $0 == ":" }) - .map(String.init) - .filter { $0.hasPrefix("$") } - for placeholder in placeholders { - if Self.supportedPlaceholders.contains(placeholder), argument != placeholder { - throw CustomActionValidationError.embeddedPlaceholder(placeholder) + for index in argument.indices where argument[index] == "$" { + let suffix = argument[index...] + for placeholder in Self.supportedPlaceholders where suffix.hasPrefix(placeholder) { + let remainder = suffix.dropFirst(placeholder.count) + if let next = remainder.first, next.isLetter || next.isNumber || next == "_" { + continue + } + if argument != placeholder { + throw CustomActionValidationError.embeddedPlaceholder(placeholder) + } } } } diff --git a/macgitTests/CustomActionTests.swift b/macgitTests/CustomActionTests.swift index f1930a31..6b5ab764 100644 --- a/macgitTests/CustomActionTests.swift +++ b/macgitTests/CustomActionTests.swift @@ -121,6 +121,32 @@ final class CustomActionTests: XCTestCase { } } + func testValidatorRejectsPlaceholdersAtAnyArgumentPosition() { + let cases: [(String, String)] = [ + ("$REPO/subdir", "$REPO"), ("$FILE,", "$FILE"), ("--x=$SHA^", "$SHA"), + ("prefix/$REPO", "$REPO"), ("($FILE)", "$FILE"), ("prefix$SHA", "$SHA"), + ("$HOME/$FILE", "$FILE"), ("$REPOSITORY/$REPO", "$REPO"), + ("$REPO $FILE", "$REPO"), ("$SHA$SHA", "$SHA") + ] + for (argument, placeholder) in cases { + let action = CustomActionDefinition(name: "Invalid", executablePath: "/usr/bin/env", arguments: [argument]) + XCTAssertThrowsError(try CustomActionValidator.validate(action), argument) { error in + XCTAssertEqual(error as? CustomActionValidationError, .embeddedPlaceholder(placeholder), argument) + } + } + } + + func testValidatorAllowsStandalonePlaceholdersAndLiteralDollarIdentifiers() { + let arguments = [ + "$REPO", "$FILE", "$SHA", "$REPOSITORY", "$REPO_ROOT", "$FILE2", "$SHADOW", + "$HOME/file", "price=$5", "prefix/$REPOSITORY", "$REPOé", "$REPO9" + ] + for argument in arguments { + let action = CustomActionDefinition(name: "Valid", executablePath: "/usr/bin/env", arguments: [argument]) + XCTAssertNoThrow(try CustomActionValidator.validate(action), argument) + } + } + func testAvailabilityRequiresSelectionAndLocalTrust() { let action = CustomActionDefinition( name: "Files", From 41e9a25f9bb643d25b9b69e9610bfe91e5eae6ea Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Sun, 4 Oct 2026 22:13:30 +0700 Subject: [PATCH 7/8] fix: preserve custom action overrides and app session lifecycle --- macgit/App/macgitApp.swift | 17 ++------ macgit/Services/CustomActionStore.swift | 42 ++++++++++++++++++- .../Common/CustomActionsSettingsView.swift | 3 +- macgitTests/CustomActionTests.swift | 28 +++++++++++++ 4 files changed, 74 insertions(+), 16 deletions(-) diff --git a/macgit/App/macgitApp.swift b/macgit/App/macgitApp.swift index bbca9468..02c343a8 100644 --- a/macgit/App/macgitApp.swift +++ b/macgit/App/macgitApp.swift @@ -141,11 +141,11 @@ struct macgitApp: App { : nil ) _repositoryBookmarkController = StateObject(wrappedValue: repositoryBookmarkController) - _customActionStore = StateObject( - wrappedValue: CustomActionStore( - cloudStore: cloudFeaturesEnabled ? FirestoreCustomActionStore() : nil - ) + let customActionStore = CustomActionStore( + cloudStore: cloudFeaturesEnabled ? FirestoreCustomActionStore() : nil ) + customActionStore.observeSession(accountController: accountController, appState: appState) + _customActionStore = StateObject(wrappedValue: customActionStore) _gitFlowConfigurationSyncController = StateObject( wrappedValue: GitFlowConfigurationSyncController( cloudStore: cloudFeaturesEnabled @@ -257,12 +257,6 @@ struct macgitApp: App { cloudLifecycleController.start() await cloudLifecycleController.updateAccount(accountController.account) } - .task(id: customActionSyncTaskID) { - await customActionStore.updateCloudSession( - uid: accountController.account?.uid, - enabled: appState.syncEnabled - ) - } .onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in Task { await customActionStore.syncNow() } } @@ -511,7 +505,4 @@ struct macgitApp: App { .windowResizability(.contentMinSize) } - private var customActionSyncTaskID: String { - "\(accountController.account?.uid ?? "guest"):\(appState.syncEnabled)" - } } diff --git a/macgit/Services/CustomActionStore.swift b/macgit/Services/CustomActionStore.swift index 04607d0b..b5a46ac7 100644 --- a/macgit/Services/CustomActionStore.swift +++ b/macgit/Services/CustomActionStore.swift @@ -22,6 +22,7 @@ final class CustomActionStore: ObservableObject { private var mutationVersions: [String: Int] = [:] private var periodicSyncTask: Task? private var isSyncing = false + private var sessionObservation: AnyCancellable? private var trustedFingerprints: [String: String] private var executableOverrides: [String: String] private var pendingUpserts: Set @@ -41,7 +42,30 @@ final class CustomActionStore: ObservableObject { normalizeAndSave() } + func observeSession(accountController: AccountSessionController, appState: AppState) { + let accountUID = accountController.$state.map { state -> String? in + guard case .authenticated(let account) = state else { return nil } + return account.uid + } + let session = accountUID.combineLatest(appState.$syncEnabled) + .removeDuplicates { previous, current in + previous.0 == current.0 && previous.1 == current.1 + } + sessionObservation = session.sink { [weak self] value in + guard let self else { return } + let generation = self.beginCloudSession(uid: value.0, enabled: value.1) + Task { @MainActor [weak self] in + await self?.startCloudSession(generation: generation) + } + } + } + func updateCloudSession(uid: String?, enabled: Bool) async { + let generation = beginCloudSession(uid: uid, enabled: enabled) + await startCloudSession(generation: generation) + } + + private func beginCloudSession(uid: String?, enabled: Bool) -> Int { sessionGeneration &+= 1 let generation = sessionGeneration periodicSyncTask?.cancel() @@ -57,7 +81,11 @@ final class CustomActionStore: ObservableObject { pendingDeletions = Self.decode(Set.self, from: userDefaults.data(forKey: scopedKey(Self.pendingDeletionsKey))) ?? [] mutationVersions = [:] } - guard enabled, uid != nil, cloudStore != nil else { return } + return generation + } + + private func startCloudSession(generation: Int) async { + guard generation == sessionGeneration, activeUID != nil, cloudStore != nil else { return } await syncNow() guard generation == sessionGeneration else { return } periodicSyncTask = Task { @MainActor [weak self] in @@ -163,6 +191,18 @@ final class CustomActionStore: ObservableObject { markPendingUpsert(normalized.id) } + func saveEditedAction(_ action: CustomActionDefinition, original: CustomActionDefinition?) { + var definition = action + if let original, let stored = actions.first(where: { $0.id == action.id }), + action.executablePath == original.executablePath, + action.sourceKind == original.sourceKind { + definition.executablePath = stored.executablePath + } else { + setExecutableOverride(nil, for: action) + } + upsert(definition) + } + func delete(_ action: CustomActionDefinition) { actions.removeAll { $0.id == action.id } trustedFingerprints[action.id.uuidString] = nil diff --git a/macgit/Views/Common/CustomActionsSettingsView.swift b/macgit/Views/Common/CustomActionsSettingsView.swift index cc6b81a9..2f72fbcd 100644 --- a/macgit/Views/Common/CustomActionsSettingsView.swift +++ b/macgit/Views/Common/CustomActionsSettingsView.swift @@ -66,8 +66,7 @@ struct CustomActionsSettingsView: View { .padding(24) .sheet(item: $editor) { presentation in CustomActionEditorSheet(action: presentation.action) { action in - store.setExecutableOverride(nil, for: action) - store.upsert(action) + store.saveEditedAction(action, original: presentation.action) } } } diff --git a/macgitTests/CustomActionTests.swift b/macgitTests/CustomActionTests.swift index 6b5ab764..9b81e8df 100644 --- a/macgitTests/CustomActionTests.swift +++ b/macgitTests/CustomActionTests.swift @@ -4,6 +4,34 @@ import XCTest @testable import macgit final class CustomActionTests: XCTestCase { + func testUnrelatedEditPreservesLocalExecutableOverrideAndStoredPath() { + let suite = "CustomActionTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = CustomActionStore(userDefaults: defaults) + let action = CustomActionDefinition(name: "Original", executablePath: "/remote/tool") + store.upsert(action, trustOnThisMac: false) + store.setExecutableOverride("/usr/bin/env", for: action) + let original = store.action(id: action.id)! + var edited = original + edited.name = "Renamed" + edited.arguments = ["$REPO"] + store.saveEditedAction(edited, original: original) + XCTAssertEqual(store.actions[0].executablePath, "/remote/tool") + XCTAssertEqual(store.action(id: action.id)?.executablePath, "/usr/bin/env") + XCTAssertTrue(store.isTrusted(store.actions[0])) + let restored = CustomActionStore(userDefaults: defaults) + XCTAssertEqual(restored.actions[0].executablePath, "/remote/tool") + XCTAssertEqual(restored.action(id: action.id)?.executablePath, "/usr/bin/env") + + var changedPath = store.action(id: action.id)! + changedPath.executablePath = "/usr/bin/printf" + store.saveEditedAction(changedPath, original: store.action(id: action.id)) + XCTAssertEqual(store.actions[0].executablePath, "/usr/bin/printf") + XCTAssertEqual(store.action(id: action.id)?.executablePath, "/usr/bin/printf") + XCTAssertTrue(store.isTrusted(store.actions[0])) + } + func testStorePersistsCatalogAndTrustSeparately() { let suiteName = "CustomActionTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! From 786b97da70df5b2918af0ee083003ada16d2f59f Mon Sep 17 00:00:00 2001 From: Thanh Tran Date: Mon, 5 Oct 2026 07:15:04 +0700 Subject: [PATCH 8/8] fix: preserve local executable override when duplicating actions --- macgit/Services/CustomActionStore.swift | 9 +++++++- macgitTests/CustomActionTests.swift | 28 +++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/macgit/Services/CustomActionStore.swift b/macgit/Services/CustomActionStore.swift index b5a46ac7..97419e80 100644 --- a/macgit/Services/CustomActionStore.swift +++ b/macgit/Services/CustomActionStore.swift @@ -217,7 +217,14 @@ final class CustomActionStore: ObservableObject { } func duplicate(_ action: CustomActionDefinition) { - upsert(action.duplicated, trustOnThisMac: isTrusted(action)) + guard let original = actions.first(where: { $0.id == action.id }) else { return } + let copy = original.duplicated + let trustOnThisMac = isTrusted(original) + if let override = executableOverrides[original.id.uuidString] { + executableOverrides[copy.id.uuidString] = override + saveExecutableOverrides() + } + upsert(copy, trustOnThisMac: trustOnThisMac) } func setEnabled(_ enabled: Bool, for action: CustomActionDefinition) { diff --git a/macgitTests/CustomActionTests.swift b/macgitTests/CustomActionTests.swift index 9b81e8df..12a47204 100644 --- a/macgitTests/CustomActionTests.swift +++ b/macgitTests/CustomActionTests.swift @@ -4,6 +4,34 @@ import XCTest @testable import macgit final class CustomActionTests: XCTestCase { + func testDuplicatePreservesLocalOverrideAndEffectiveTrust() { + for trusted in [false, true] { + let suite = "CustomActionTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + defer { defaults.removePersistentDomain(forName: suite) } + let store = CustomActionStore(userDefaults: defaults) + let action = CustomActionDefinition(name: "Original", executablePath: "/remote/tool") + store.upsert(action, trustOnThisMac: false) + store.setExecutableOverride("/usr/bin/env", for: action) + if trusted { store.trust(action) } + + // Callers may pass the effective action; its local path must never enter the catalog. + store.duplicate(store.action(id: action.id)!) + let copy = store.actions[1] + XCTAssertNotEqual(copy.id, action.id) + XCTAssertEqual(copy.executablePath, "/remote/tool") + XCTAssertEqual(store.action(id: copy.id)?.executablePath, "/usr/bin/env") + XCTAssertEqual(store.isTrusted(copy), trusted) + + let restored = CustomActionStore(userDefaults: defaults) + XCTAssertEqual(restored.actions[1].executablePath, "/remote/tool") + XCTAssertEqual(restored.action(id: copy.id)?.executablePath, "/usr/bin/env") + XCTAssertEqual(restored.isTrusted(copy), trusted) + restored.setExecutableOverride(nil, for: copy) + XCTAssertFalse(restored.isTrusted(copy)) + } + } + func testUnrelatedEditPreservesLocalExecutableOverrideAndStoredPath() { let suite = "CustomActionTests.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)!