Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CCMenu.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@
objects = {

/* Begin PBXBuildFile section */
DFEED0012E4A000100000001 /* DynamicFeedSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEED0012E4A000000000001 /* DynamicFeedSource.swift */; };
DFEED0022E4A000100000002 /* DynamicFeedSourceModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEED0022E4A000000000002 /* DynamicFeedSourceModel.swift */; };
DFEED0032E4A000100000003 /* DynamicFeedSyncService.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEED0032E4A000000000003 /* DynamicFeedSyncService.swift */; };
DFEED0042E4A000100000004 /* DynamicFeedSourceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEED0042E4A000000000004 /* DynamicFeedSourceTests.swift */; };
DFEED0052E4A000100000005 /* DynamicFeedSyncServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEED0052E4A000000000005 /* DynamicFeedSyncServiceTests.swift */; };
DFEED0062E4A000100000006 /* DynamicFeedSourceModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEED0062E4A000000000006 /* DynamicFeedSourceModelTests.swift */; };
DFEED0072E4A000100000007 /* DynamicFeedSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFEED0072E4A000000000007 /* DynamicFeedSettings.swift */; };
031FC8CC2B7929B7005E7F26 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 031FC8CB2B7929B7005E7F26 /* Keychain.swift */; };
0320309B2B2D27AF00D132C0 /* GitHubSheetModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0320309A2B2D27AF00D132C0 /* GitHubSheetModel.swift */; };
0322051A2B8EA73100205DC6 /* Hummingbird in Frameworks */ = {isa = PBXBuildFile; productRef = 032205192B8EA73100205DC6 /* Hummingbird */; };
Expand Down Expand Up @@ -144,6 +151,13 @@
/* End PBXContainerItemProxy section */

/* Begin PBXFileReference section */
DFEED0012E4A000000000001 /* DynamicFeedSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFeedSource.swift; sourceTree = "<group>"; };
DFEED0022E4A000000000002 /* DynamicFeedSourceModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFeedSourceModel.swift; sourceTree = "<group>"; };
DFEED0032E4A000000000003 /* DynamicFeedSyncService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFeedSyncService.swift; sourceTree = "<group>"; };
DFEED0042E4A000000000004 /* DynamicFeedSourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFeedSourceTests.swift; sourceTree = "<group>"; };
DFEED0052E4A000000000005 /* DynamicFeedSyncServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFeedSyncServiceTests.swift; sourceTree = "<group>"; };
DFEED0062E4A000000000006 /* DynamicFeedSourceModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFeedSourceModelTests.swift; sourceTree = "<group>"; };
DFEED0072E4A000000000007 /* DynamicFeedSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicFeedSettings.swift; sourceTree = "<group>"; };
031FC8CB2B7929B7005E7F26 /* Keychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; };
0320309A2B2D27AF00D132C0 /* GitHubSheetModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubSheetModel.swift; sourceTree = "<group>"; };
0322051B2B8EAAE100205DC6 /* CCMenuUITests.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CCMenuUITests.entitlements; sourceTree = "<group>"; };
Expand Down Expand Up @@ -427,6 +441,9 @@
03F9B88F297C6EEC00FA866E /* CompactRelativeFormatStyleTests.swift */,
3CB911D82B78EB3F009DF781 /* URLRequestExtensionTests.swift */,
2FA28CB152CE8C0CFD6EE3A8 /* NSImageExtensionTests.swift */,
DFEED0042E4A000000000004 /* DynamicFeedSourceTests.swift */,
DFEED0052E4A000000000005 /* DynamicFeedSyncServiceTests.swift */,
DFEED0062E4A000000000006 /* DynamicFeedSourceModelTests.swift */,
);
path = CCMenuTests;
sourceTree = "<group>";
Expand Down Expand Up @@ -480,6 +497,8 @@
039B524529676D0700994910 /* Build.swift */,
03B263F12B50968800CA989A /* StatusChange.swift */,
3C3ADA2A2BC0DAA500AEEEA1 /* PipelineDocument.swift */,
DFEED0012E4A000000000001 /* DynamicFeedSource.swift */,
DFEED0022E4A000000000002 /* DynamicFeedSourceModel.swift */,
);
path = Model;
sourceTree = "<group>";
Expand Down Expand Up @@ -518,6 +537,7 @@
03F31CF425B8D29E005299A8 /* SettingsView.swift */,
0329F89D25B0F0F10043FAB1 /* AppearanceSettings.swift */,
03F31CF925B8D52F005299A8 /* NotificationSettings.swift */,
DFEED0072E4A000000000007 /* DynamicFeedSettings.swift */,
03F31D0125B8D5F0005299A8 /* AdvancedSettings.swift */,
);
path = Settings;
Expand Down Expand Up @@ -568,6 +588,7 @@
03278EA12E1FEE2300995F34 /* GitLabFeedReader.swift */,
03278EA22E1FEE2300995F34 /* GitLabResponseParser.swift */,
03278EA02E1FEE2300995F34 /* GitLabAPI.swift */,
DFEED0032E4A000000000003 /* DynamicFeedSyncService.swift */,
);
path = "Server Monitor";
sourceTree = "<group>";
Expand Down Expand Up @@ -873,6 +894,10 @@
3CC483472BDE955C00E4730F /* PipelineSheetConfig.swift in Sources */,
0320309B2B2D27AF00D132C0 /* GitHubSheetModel.swift in Sources */,
03412AE32B5DCF4200EFDFCB /* CCTraySheetModel.swift in Sources */,
DFEED0012E4A000100000001 /* DynamicFeedSource.swift in Sources */,
DFEED0022E4A000100000002 /* DynamicFeedSourceModel.swift in Sources */,
DFEED0032E4A000100000003 /* DynamicFeedSyncService.swift in Sources */,
DFEED0072E4A000100000007 /* DynamicFeedSettings.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand All @@ -893,6 +918,9 @@
0362EBEF2B5313120079DEFE /* NotificationFactoryTests.swift in Sources */,
03B1D7632A6085F1007BCB8A /* PipelineRowModelTests.swift in Sources */,
03F9B890297C6EEC00FA866E /* CompactRelativeFormatStyleTests.swift in Sources */,
DFEED0042E4A000100000004 /* DynamicFeedSourceTests.swift in Sources */,
DFEED0052E4A000100000005 /* DynamicFeedSyncServiceTests.swift in Sources */,
DFEED0062E4A000100000006 /* DynamicFeedSourceModelTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down
1 change: 1 addition & 0 deletions CCMenu/Source/Miscellaneous/UserDefaultsExtension.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ enum AppIconVisibility: String, CaseIterable, Identifiable {
enum DefaultsKey: String {
case
pipelineList = "pipelines",
dynamicFeedSources = "DynamicFeedSources",
pollInterval = "PollInterval",
pollIntervalLowData = "PollIntervalLowData",
acceptInvalidCerts = "AcceptInvalidCerts",
Expand Down
57 changes: 57 additions & 0 deletions CCMenu/Source/Model/DynamicFeedSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright (c) Erik Doernenburg and contributors
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use these files except in compliance with the License.
*/

import Foundation

struct DynamicFeedSource: Identifiable, Equatable {

let id: String
var url: URL
var isEnabled: Bool
var removeDeletedPipelines: Bool
var lastSyncTime: Date?
var lastSyncError: String?

init(url: URL, id: String = UUID().uuidString) {
self.id = id
self.url = url
self.isEnabled = true
self.removeDeletedPipelines = true
}

init?(dictionary dict: [String: String]) {
guard
let id = dict["id"],
let urlString = dict["url"],
let url = URL(string: urlString),
!urlString.isEmpty,
let isEnabledString = dict["isEnabled"],
let removeDeletedString = dict["removeDeletedPipelines"]
else {
return nil
}

self.id = id
self.url = url
self.isEnabled = isEnabledString == "true"
self.removeDeletedPipelines = removeDeletedString == "true"
}

func toDictionary() -> [String: String] {
[
"id": id,
"url": url.absoluteString,
"isEnabled": isEnabled ? "true" : "false",
"removeDeletedPipelines": removeDeletedPipelines ? "true" : "false"
]
}

static func == (lhs: DynamicFeedSource, rhs: DynamicFeedSource) -> Bool {
lhs.id == rhs.id
}

}

55 changes: 55 additions & 0 deletions CCMenu/Source/Model/DynamicFeedSourceModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/*
* Copyright (c) Erik Doernenburg and contributors
* Licensed under the Apache License, Version 2.0 (the "License"); you may
* not use these files except in compliance with the License.
*/

import Foundation
import Combine

final class DynamicFeedSourceModel: ObservableObject {

static let shared: DynamicFeedSourceModel = {
let model = DynamicFeedSourceModel()
model.loadFromUserDefaults()
return model
}()

@Published var sources: [DynamicFeedSource] { didSet { saveToUserDefaults() } }

init() {
sources = []
}

func add(source: DynamicFeedSource) {
guard !sources.contains(where: { $0.id == source.id }) else { return }
sources.append(source)
}

func remove(sourceId: String) {
sources.removeAll { $0.id == sourceId }
}

func update(source: DynamicFeedSource) {
guard let idx = sources.firstIndex(where: { $0.id == source.id }) else { return }
sources[idx] = source
}

var enabledSources: [DynamicFeedSource] {
sources.filter { $0.isEnabled }
}

func loadFromUserDefaults() {
guard let dicts = UserDefaults.active.array(forKey: DefaultsKey.dynamicFeedSources.rawValue) as? [[String: String]] else {
return
}
sources = dicts.compactMap { DynamicFeedSource(dictionary: $0) }
}

private func saveToUserDefaults() {
let dicts = sources.map { $0.toDictionary() }
UserDefaults.active.set(dicts, forKey: DefaultsKey.dynamicFeedSources.rawValue)
}

}

8 changes: 7 additions & 1 deletion CCMenu/Source/Model/Pipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ struct Pipeline: Identifiable, Decodable {
var status: PipelineStatus
var connectionError: String?
var lastUpdated: Date?
var managedBySourceId: String? // ID of the DynamicFeedSource that manages this pipeline

init(name: String, feed: PipelineFeed) {
self.name = name
Expand Down Expand Up @@ -77,14 +78,19 @@ extension Pipeline {
return nil
}
self.init(name: name, feed: PipelineFeed(type: feedType, url: feedUrl, name: !feedName.isEmpty ? feedName : nil))
self.managedBySourceId = r["managedBySourceId"]
}

func reference() -> Dictionary<String, String> {
[ "name": self.name,
var dict = [ "name": self.name,
"feedType": self.feed.type.rawValue,
"feedUrl": self.feed.url.absoluteString,
"feedName": self.feed.name ?? "",
]
if let sourceId = managedBySourceId {
dict["managedBySourceId"] = sourceId
}
return dict
}

init?(legacyReference r: Dictionary<String, String>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,21 @@

import SwiftUI

enum ImportMode: String, CaseIterable {
case singleProject = "Single Project"
case allProjects = "All Projects"
}

struct AddCCTrayPipelineSheet: View {
@Binding var config: PipelineSheetConfig
@Environment(\.presentationMode) @Binding var presentation
@State var useBasicAuth = false
@State var credential = HTTPCredential(user: "", password: "")
@State var importMode: ImportMode = .singleProject
@State var removeDeletedPipelines = true
@StateObject private var projectList = CCTrayProjectList()
@StateObject private var builder = CCTrayPipelineBuilder()
@ObservedObject private var dynamicFeedSourceModel = DynamicFeedSourceModel.shared

var body: some View {
VStack {
Expand All @@ -35,27 +43,51 @@ struct AddCCTrayPipelineSheet: View {
Task { await projectList.updateProjects(url: $builder.feedUrl, credential: credentialOptional) }
}
}

Picker("Project:", selection: $projectList.selected) {
ForEach(projectList.items) { p in
Text(p.name).tag(p)
Picker("Import:", selection: $importMode) {
ForEach(ImportMode.allCases, id: \.self) { mode in
Text(mode.rawValue).tag(mode)
}
}
.accessibilityIdentifier("Project picker")
.disabled(!projectList.selected.isValid)
.onChange(of: projectList.selected) { _ in
builder.project = projectList.selected
}
.padding(.bottom)
.pickerStyle(.segmented)
.accessibilityIdentifier("Import mode picker")
.padding(.bottom, 4)

if importMode == .singleProject {
Picker("Project:", selection: $projectList.selected) {
ForEach(projectList.items) { p in
Text(p.name).tag(p)
}
}
.accessibilityIdentifier("Project picker")
.disabled(!projectList.selected.isValid)
.onChange(of: projectList.selected) { _ in
builder.project = projectList.selected
}
.padding(.bottom)

HStack {
TextField("Display name:", text: $builder.name)
.accessibilityIdentifier("Display name field")
Button("Reset", systemImage: "arrowshape.turn.up.backward") {
builder.setDefaultName()
HStack {
TextField("Display name:", text: $builder.name)
.accessibilityIdentifier("Display name field")
Button("Reset", systemImage: "arrowshape.turn.up.backward") {
builder.setDefaultName()
}
}
.padding(.bottom)
} else {
Picker("On removal:", selection: $removeDeletedPipelines) {
Text("Keep pipelines").tag(false)
Text("Remove pipelines").tag(true)
}
.accessibilityIdentifier("Removal behavior picker")
.padding(.bottom)

Text("All projects will be imported and kept in sync. When projects are removed from the feed, pipelines will be kept or removed based on your selection above.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.padding(.bottom)
}
.padding(.bottom)
}

HStack {
Expand All @@ -64,23 +96,51 @@ struct AddCCTrayPipelineSheet: View {
}
.keyboardShortcut(.cancelAction)
Button("Apply") {
let p = builder.makePipeline(credential: credentialOptional)
config.setPipeline(p)
if importMode == .allProjects {
addDynamicFeedSource()
} else {
let p = builder.makePipeline(credential: credentialOptional)
config.setPipeline(p)
}
presentation.dismiss()
}
.keyboardShortcut(.defaultAction)
.disabled(!builder.canMakePipeline)
.disabled(importMode == .allProjects ? !canAddDynamicFeed : !builder.canMakePipeline)
}
}
.frame(minWidth: 400)
.frame(idealWidth: 450)
.frame(minWidth: 450)
.frame(idealWidth: 500)
.padding()
}

private var credentialOptional: HTTPCredential? {
(useBasicAuth && !credential.isEmpty) ? credential : nil
}

private var canAddDynamicFeed: Bool {
guard !builder.feedUrl.isEmpty else { return false }
var urlString = builder.feedUrl
if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
urlString = "https://" + urlString
}
return URL(string: urlString) != nil
}

private func addDynamicFeedSource() {
var urlString = builder.feedUrl
if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
urlString = "https://" + urlString
}
guard let url = URL(string: urlString) else { return }

var source = DynamicFeedSource(url: url)
source.removeDeletedPipelines = removeDeletedPipelines
dynamicFeedSourceModel.add(source: source)

// Trigger an immediate sync
NotificationCenter.default.post(name: .dynamicFeedSyncRequested, object: nil)
}

}


Expand Down
Loading