Skip to content
Merged
24 changes: 24 additions & 0 deletions macgit/App/CustomActionCommandState.swift
Original file line number Diff line number Diff line change
@@ -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")
}
37 changes: 37 additions & 0 deletions macgit/App/macgitApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -457,6 +492,7 @@ struct macgitApp: App {
providerAccountController: providerAccountController,
aiProviderController: aiProviderController,
appUpdateController: appUpdateController,
customActionStore: customActionStore,
selectedSection: $selectedAppSettingsSection
)
.environmentObject(featureAccessController)
Expand All @@ -468,4 +504,5 @@ struct macgitApp: App {
.defaultLaunchBehavior(.suppressed)
.windowResizability(.contentMinSize)
}

}
127 changes: 127 additions & 0 deletions macgit/Models/CustomActionDefinition.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
36 changes: 36 additions & 0 deletions macgit/Models/CustomActionExecutionResult.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}
8 changes: 8 additions & 0 deletions macgit/Models/CustomActionOutputPresentation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// SPDX-License-Identifier: AGPL-3.0-or-later

import Foundation

struct CustomActionOutputPresentation: Identifiable {
let id = UUID()
let result: CustomActionExecutionResult
}
Loading
Loading