diff --git a/macgit/App/CustomActionCommandState.swift b/macgit/App/CustomActionCommandState.swift new file mode 100644 index 0000000..d0b0514 --- /dev/null +++ b/macgit/App/CustomActionCommandState.swift @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import SwiftUI + +struct CustomActionCommandState: Equatable { + let context: CustomActionInvocationContext + let surface: CustomActionInvocationSurface + let hasActiveOperation: Bool +} + +struct CustomActionCommandStateKey: FocusedValueKey { + typealias Value = CustomActionCommandState +} + +extension FocusedValues { + var customActionCommandState: CustomActionCommandState? { + get { self[CustomActionCommandStateKey.self] } + 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 595e0f7..02c343a 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) + let customActionStore = CustomActionStore( + cloudStore: cloudFeaturesEnabled ? FirestoreCustomActionStore() : nil + ) + customActionStore.observeSession(accountController: accountController, appState: appState) + _customActionStore = StateObject(wrappedValue: customActionStore) _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,9 @@ struct macgitApp: App { cloudLifecycleController.start() await cloudLifecycleController.updateAccount(accountController.account) } + .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) } @@ -395,6 +406,30 @@ 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: { id, surface in + WindowScopedNotification.post( + name: .customActionMenuAction, + userInfo: ["id": id, "surface": surface] + ) + } + ) + } else { + Text("No Repository Open") + Divider() + CustomActionAddMenuButton() + } + } + + Divider() + Button("Search...") { WindowScopedNotification.post(name: .showSearchModal) } @@ -457,6 +492,7 @@ struct macgitApp: App { providerAccountController: providerAccountController, aiProviderController: aiProviderController, appUpdateController: appUpdateController, + customActionStore: customActionStore, selectedSection: $selectedAppSettingsSection ) .environmentObject(featureAccessController) @@ -468,4 +504,5 @@ struct macgitApp: App { .defaultLaunchBehavior(.suppressed) .windowResizability(.contentMinSize) } + } diff --git a/macgit/Models/CustomActionDefinition.swift b/macgit/Models/CustomActionDefinition.swift new file mode 100644 index 0000000..3772db5 --- /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: Equatable, 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 0000000..f823182 --- /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 0000000..906c2c8 --- /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 0000000..6efe752 --- /dev/null +++ b/macgit/Services/CustomActionArgumentParser.swift @@ -0,0 +1,201 @@ +// 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 { + if quote == .double, !"$`\"\\\n".contains(character) { current.append("\\") } + if character != "\n" { 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 { + 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) + } + } + } + } + } + + 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 0000000..28cc67a --- /dev/null +++ b/macgit/Services/CustomActionCloudStore.swift @@ -0,0 +1,11 @@ +// 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 +} diff --git a/macgit/Services/CustomActionExecutor.swift b/macgit/Services/CustomActionExecutor.swift new file mode 100644 index 0000000..ffb8f76 --- /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 0000000..97419e8 --- /dev/null +++ b/macgit/Services/CustomActionStore.swift @@ -0,0 +1,316 @@ +// 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" + static let pendingUpsertsKey = "customActions.pendingUpserts.v1" + static let pendingDeletionsKey = "customActions.pendingDeletions.v1" + + @Published private(set) var actions: [CustomActionDefinition] + @Published private(set) var syncError: String? + + 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 sessionObservation: AnyCancellable? + private var trustedFingerprints: [String: String] + private var executableOverrides: [String: String] + private var pendingUpserts: Set + private var pendingDeletions: Set + + 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)) ?? [:] + pendingUpserts = Self.decode(Set.self, from: userDefaults.data(forKey: Self.pendingUpsertsKey)) ?? [] + pendingDeletions = Self.decode(Set.self, from: userDefaults.data(forKey: Self.pendingDeletionsKey)) ?? [] + 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() + periodicSyncTask = nil + activeUID = enabled ? uid : nil + syncError = nil + 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 = [:] + } + 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 + 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 } + let generation = sessionGeneration + isSyncing = true + 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) + 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 { continue } + let version = mutationVersions[idString, default: 0] + try await cloudStore.upsert(action, uid: uid) + guard generation == sessionGeneration else { return } + if mutationVersions[idString, default: 0] == version { + pendingUpserts.remove(idString) + savePendingMutations() + } + } + let remote = try await cloudStore.load(uid: uid) + guard generation == sessionGeneration else { return } + applyRemote(remote) + syncError = nil + } catch { + 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) + } + + 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) } + 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 + executableOverrides[action.id.uuidString] = nil + 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) { + 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) { + 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() + 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 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() + } + + private func normalizeAndSave() { + 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: scopedKey(Self.actionsKey)) + } + } + + private func saveTrustedFingerprints() { + if let data = try? JSONEncoder().encode(trustedFingerprints) { + userDefaults.set(data, forKey: scopedKey(Self.trustKey)) + } + } + + private func saveExecutableOverrides() { + if let data = try? JSONEncoder().encode(executableOverrides) { + 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() + } + + private func savePendingMutations() { + if let data = try? JSONEncoder().encode(pendingUpserts) { + userDefaults.set(data, forKey: scopedKey(Self.pendingUpsertsKey)) + } + if let data = try? JSONEncoder().encode(pendingDeletions) { + userDefaults.set(data, forKey: scopedKey(Self.pendingDeletionsKey)) + } + } + + 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 0000000..cfba685 --- /dev/null +++ b/macgit/Services/FirestoreCustomActionStore.swift @@ -0,0 +1,119 @@ +// 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() + } + + 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 + } +} diff --git a/macgit/ViewModels/CustomActionDraft.swift b/macgit/ViewModels/CustomActionDraft.swift new file mode 100644 index 0000000..569f39e --- /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 039b50f..89a05fb 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 0b8a45b..b7b0b91 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 4214913..f9f438b 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 0000000..1bd6f70 --- /dev/null +++ b/macgit/Views/Common/CustomActionEditorSheet.swift @@ -0,0 +1,169 @@ +// 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: + sourcePathField(title: "Executable", prompt: "Choose an executable or enter its full path") + case .localScript: + sourcePathField(title: "Script", prompt: "Choose a script or enter its full path") + 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 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 + 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 0000000..6891693 --- /dev/null +++ b/macgit/Views/Common/CustomActionMenuContent.swift @@ -0,0 +1,55 @@ +// 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)") + } + } + 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/CustomActionOutputSheet.swift b/macgit/Views/Common/CustomActionOutputSheet.swift new file mode 100644 index 0000000..9e26aea --- /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 0000000..2c5c871 --- /dev/null +++ b/macgit/Views/Common/CustomActionSettingsRow.swift @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import SwiftUI + +struct CustomActionSettingsRow: View { + let action: CustomActionDefinition + let effectiveAction: CustomActionDefinition + let isTrusted: Bool + let onRemove: () -> Void + let onDuplicate: () -> Void + let onEdit: () -> Void + let onReview: () -> Void + let onLocate: () -> Void + + var body: some View { + HStack(spacing: 12) { + VStack(alignment: .leading, spacing: 3) { + Text(action.name) + Text(detail) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .help(detail) + } + + Spacer() + + if !action.isEnabled { + Text("Disabled").foregroundStyle(.secondary) + } + if needsExecutable { + Button("Locate…", action: onLocate) + } else if !isTrusted { + Button("Review…", action: onReview) + } else { + Label("Ready", systemImage: "checkmark.circle.fill") + .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) + } + + 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 0000000..2f72fbc --- /dev/null +++ b/macgit/Views/Common/CustomActionsSettingsView.swift @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import AppKit +import SwiftUI + +struct CustomActionsSettingsView: View { + @ObservedObject var store: CustomActionStore + @State private var editor: EditorPresentation? + + private struct EditorPresentation: Identifiable { + let id = UUID() + let action: CustomActionDefinition? + } + + 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 { + ForEach(store.actions) { action in + CustomActionSettingsRow( + action: action, + effectiveAction: store.effectiveAction(action), + isTrusted: store.isTrusted(action), + onRemove: { store.delete(action) }, + onDuplicate: { store.duplicate(action) }, + onEdit: { edit(action) }, + onReview: { edit(action) }, + onLocate: { locate(action) } + ) + } + .onMove(perform: store.move) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + HStack { + Button("Add", systemImage: "plus", action: add) + Spacer() + if let syncError = store.syncError { + Text(syncError) + .font(.caption) + .foregroundStyle(.red) + .lineLimit(2) + } + } + .fixedSize(horizontal: false, vertical: true) + } + .padding(24) + .sheet(item: $editor) { presentation in + CustomActionEditorSheet(action: presentation.action) { action in + store.saveEditedAction(action, original: presentation.action) + } + } + } + + private func add() { + editor = EditorPresentation(action: nil) + } + + private func edit(_ action: CustomActionDefinition) { + guard let current = store.action(id: action.id) else { return } + editor = EditorPresentation(action: current) + } + + 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) + } +} diff --git a/macgit/Views/FileStatus/FileStatusView.swift b/macgit/Views/FileStatus/FileStatusView.swift index 0dbe1d0..74876e9 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 25abe12..3811e7b 100644 --- a/macgit/Views/History/HistoryView.swift +++ b/macgit/Views/History/HistoryView.swift @@ -40,6 +40,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 @@ -122,7 +125,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 @@ -132,6 +137,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( @@ -658,6 +665,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, @@ -1118,6 +1126,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 d9ffbd8..6d99bc7 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 { @@ -722,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 { @@ -775,6 +801,78 @@ 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 + ) + } + + 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 +1341,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 +1383,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 0000000..12a4720 --- /dev/null +++ b/macgitTests/CustomActionTests.swift @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +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)! + 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)! + 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 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 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"#), + ["--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 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", + 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) + } +} + +@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 + } + + 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 + } +}