Skip to content

Commit f4718e7

Browse files
committed
Implemented UI to add GitLab pipeline (#27).
As an experiment I tried to implement the UI completely with an AI-assistant, using prompts only. A few times, when the AI-assistant got "stuck", I looked at the code a bit so that I could give hints to the AI via the prompts, but I didn't write any code. I stopped when the happy path worked. This commit is the result of this "coding" session. For the record, I used Windsurf with Claude 3.7 Sonnet spending 38 prompt credits. At this time, for the model: 1 credit == 1 prompt. Was it faster than writing the code directly? I'm not sure, especially when considering that I still have to review the code in the next session. Was it more fun? I don't think so. And I say this as someone who isn't particularly fond of SwiftUI.
1 parent 0f08d70 commit f4718e7

File tree

9 files changed

+434
-5
lines changed

9 files changed

+434
-5
lines changed

CCMenu.xcodeproj/project.pbxproj

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,11 @@
8585
03DA98E02B44BF620073F5BB /* GitHubPipelineBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DA98DF2B44BF620073F5BB /* GitHubPipelineBuilder.swift */; };
8686
03DA98E22B44CE1A0073F5BB /* GitHubAuthenticator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DA98E12B44CE1A0073F5BB /* GitHubAuthenticator.swift */; };
8787
03DC9BBA2E240D170000E22E /* GitHubAPITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DC9BB92E240D030000E22E /* GitHubAPITests.swift */; };
88+
03DC9BDC2E2ABB9F0000E22E /* GitLabProjectList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DC9BD92E2ABB9F0000E22E /* GitLabProjectList.swift */; };
89+
03DC9BDD2E2ABB9F0000E22E /* GitLabSheetModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DC9BDA2E2ABB9F0000E22E /* GitLabSheetModel.swift */; };
90+
03DC9BDE2E2ABB9F0000E22E /* GitLabBranchList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DC9BD72E2ABB9F0000E22E /* GitLabBranchList.swift */; };
91+
03DC9BDF2E2ABB9F0000E22E /* GitLabPipelineBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DC9BD82E2ABB9F0000E22E /* GitLabPipelineBuilder.swift */; };
92+
03DC9BE02E2ABB9F0000E22E /* AddGitLabPipelineSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DC9BD62E2ABB9F0000E22E /* AddGitLabPipelineSheet.swift */; };
8893
03DD697C2B61A26900D7AD9D /* CCTrayPipelineBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DD697B2B61A26900D7AD9D /* CCTrayPipelineBuilder.swift */; };
8994
03DD697E2B646E3800D7AD9D /* NSColorExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DD697D2B646E3800D7AD9D /* NSColorExtensions.swift */; };
9095
03DF19072D025292007D1A64 /* ComboBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03DF19062D025292007D1A64 /* ComboBox.swift */; };
@@ -220,6 +225,11 @@
220225
03DA98DF2B44BF620073F5BB /* GitHubPipelineBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubPipelineBuilder.swift; sourceTree = "<group>"; };
221226
03DA98E12B44CE1A0073F5BB /* GitHubAuthenticator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubAuthenticator.swift; sourceTree = "<group>"; };
222227
03DC9BB92E240D030000E22E /* GitHubAPITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitHubAPITests.swift; sourceTree = "<group>"; };
228+
03DC9BD62E2ABB9F0000E22E /* AddGitLabPipelineSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddGitLabPipelineSheet.swift; sourceTree = "<group>"; };
229+
03DC9BD72E2ABB9F0000E22E /* GitLabBranchList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitLabBranchList.swift; sourceTree = "<group>"; };
230+
03DC9BD82E2ABB9F0000E22E /* GitLabPipelineBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitLabPipelineBuilder.swift; sourceTree = "<group>"; };
231+
03DC9BD92E2ABB9F0000E22E /* GitLabProjectList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitLabProjectList.swift; sourceTree = "<group>"; };
232+
03DC9BDA2E2ABB9F0000E22E /* GitLabSheetModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GitLabSheetModel.swift; sourceTree = "<group>"; };
223233
03DD697B2B61A26900D7AD9D /* CCTrayPipelineBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CCTrayPipelineBuilder.swift; sourceTree = "<group>"; };
224234
03DD697D2B646E3800D7AD9D /* NSColorExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSColorExtensions.swift; sourceTree = "<group>"; };
225235
03DF19062D025292007D1A64 /* ComboBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComboBox.swift; sourceTree = "<group>"; };
@@ -497,6 +507,7 @@
497507
03F682FC25C73584005D56D9 /* EditPipelineSheet.swift */,
498508
03412ADD2B5DCC1600EFDFCB /* CCTray Sheets */,
499509
0331B3012B3C9A60000FE134 /* GitHub Sheets */,
510+
03DC9BDB2E2ABB9F0000E22E /* GitLab Sheets */,
500511
);
501512
path = "Pipeline Window";
502513
sourceTree = "<group>";
@@ -561,6 +572,18 @@
561572
path = "Server Monitor";
562573
sourceTree = "<group>";
563574
};
575+
03DC9BDB2E2ABB9F0000E22E /* GitLab Sheets */ = {
576+
isa = PBXGroup;
577+
children = (
578+
03DC9BD62E2ABB9F0000E22E /* AddGitLabPipelineSheet.swift */,
579+
03DC9BD72E2ABB9F0000E22E /* GitLabBranchList.swift */,
580+
03DC9BD82E2ABB9F0000E22E /* GitLabPipelineBuilder.swift */,
581+
03DC9BD92E2ABB9F0000E22E /* GitLabProjectList.swift */,
582+
03DC9BDA2E2ABB9F0000E22E /* GitLabSheetModel.swift */,
583+
);
584+
path = "GitLab Sheets";
585+
sourceTree = "<group>";
586+
};
564587
3C95D34F2BAC601C007AED1E /* CCMenuIntegrationTests */ = {
565588
isa = PBXGroup;
566589
children = (
@@ -792,6 +815,11 @@
792815
03CC61D12BA783EE0008EE76 /* NetworkMonitor.swift in Sources */,
793816
03825DE3259CFF0A00DEB003 /* Pipeline.swift in Sources */,
794817
03F31D0225B8D5F0005299A8 /* AdvancedSettings.swift in Sources */,
818+
03DC9BDC2E2ABB9F0000E22E /* GitLabProjectList.swift in Sources */,
819+
03DC9BDD2E2ABB9F0000E22E /* GitLabSheetModel.swift in Sources */,
820+
03DC9BDE2E2ABB9F0000E22E /* GitLabBranchList.swift in Sources */,
821+
03DC9BDF2E2ABB9F0000E22E /* GitLabPipelineBuilder.swift in Sources */,
822+
03DC9BE02E2ABB9F0000E22E /* AddGitLabPipelineSheet.swift in Sources */,
795823
039B524629676D0700994910 /* Build.swift in Sources */,
796824
3C3ADA2B2BC0DAA500AEEEA1 /* PipelineDocument.swift in Sources */,
797825
03DA98E22B44CE1A0073F5BB /* GitHubAuthenticator.swift in Sources */,
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/*
2+
* Copyright (c) Erik Doernenburg and contributors
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
* not use these files except in compliance with the License.
5+
*/
6+
7+
import SwiftUI
8+
9+
struct AddGitLabPipelineSheet: View {
10+
@Binding var config: PipelineSheetConfig
11+
@Environment(\.presentationMode) @Binding var presentation
12+
@StateObject private var owner = DebouncedText()
13+
@StateObject private var project = DebouncedText()
14+
@StateObject private var projectList = GitLabProjectList()
15+
@StateObject private var branch = DebouncedText()
16+
@StateObject private var branchList = GitLabBranchList()
17+
@StateObject private var builder = GitLabPipelineBuilder()
18+
@State private var selectedProjectId: Int? = nil
19+
20+
var body: some View {
21+
VStack {
22+
Text("Add GitLab pipeline")
23+
.font(.headline)
24+
.padding(.bottom)
25+
Text("Enter a GitLab user or group name to fetch projects. If there are many projects only the most recently updated will be shown.\n\nAuthentication is not required for public projects.")
26+
.fixedSize(horizontal: false, vertical: true)
27+
.padding(.bottom)
28+
Form {
29+
TextField("User or Group:", text: $owner.input, prompt: Text("user or group name"))
30+
.accessibilityIdentifier("Owner field")
31+
.autocorrectionDisabled(true)
32+
.onReceive(owner.$text) { t in
33+
if t.isEmpty {
34+
projectList.clearProjects()
35+
} else {
36+
Task {
37+
await projectList.updateProjects(name: t, token: nil)
38+
}
39+
}
40+
}
41+
.onSubmit {
42+
owner.takeInput()
43+
}
44+
45+
LabeledContent("Project:") {
46+
ComboBox(items: projectList.items.map({ $0.displayName }), text: $project.input)
47+
.accessibilityIdentifier("Project combo box")
48+
.disabled(owner.text.isEmpty || projectList.items.isEmpty || !projectList.items[0].isValid)
49+
.onReceive(project.$text) { t in
50+
if let selectedProject = projectList.items.first(where: { $0.displayName == t }) {
51+
builder.project = selectedProject
52+
builder.setDefaultName()
53+
54+
if selectedProject.isValid {
55+
selectedProjectId = selectedProject.id
56+
Task {
57+
await branchList.updateBranches(projectId: String(selectedProject.id), token: nil)
58+
}
59+
} else {
60+
selectedProjectId = nil
61+
branchList.clearBranches()
62+
}
63+
}
64+
}
65+
.onSubmit {
66+
project.takeInput()
67+
if let selectedProject = projectList.items.first(where: { $0.displayName == project.text }) {
68+
if selectedProject.isValid {
69+
Task {
70+
await branchList.updateBranches(projectId: String(selectedProject.id), token: nil)
71+
}
72+
}
73+
}
74+
}
75+
.onReceive(projectList.$items) { items in
76+
if let firstValidProject = items.first(where: { $0.isValid }) {
77+
project.text = firstValidProject.displayName
78+
builder.project = firstValidProject
79+
builder.setDefaultName()
80+
81+
// Load branches for the automatically selected project
82+
Task {
83+
await branchList.updateBranches(projectId: String(firstValidProject.id), token: nil)
84+
}
85+
}
86+
}
87+
.onSubmit {
88+
project.takeInput()
89+
}
90+
}
91+
92+
LabeledContent("Branch:") {
93+
ComboBox(items: branchList.items.map({ $0.name }), text: $branch.input)
94+
.accessibilityIdentifier("Branch combo box")
95+
.disabled(builder.project == nil || !builder.project!.isValid)
96+
.onReceive(branch.$text) { t in
97+
builder.branch = t
98+
}
99+
.onReceive(branchList.$items) { items in
100+
if !items.isEmpty && items[0].isValid {
101+
branch.text = items.first?.name ?? ""
102+
}
103+
}
104+
.onSubmit {
105+
branch.takeInput()
106+
}
107+
}
108+
.padding(.bottom)
109+
110+
HStack {
111+
TextField("Display name:", text: $builder.name)
112+
.accessibilityIdentifier("Display name field")
113+
Button("Reset", systemImage: "arrowshape.turn.up.backward") {
114+
builder.setDefaultName()
115+
}
116+
}
117+
.padding(.bottom)
118+
}
119+
120+
HStack {
121+
Button("Cancel") {
122+
presentation.dismiss()
123+
}
124+
.keyboardShortcut(.cancelAction)
125+
Button("Apply") {
126+
Task {
127+
if let p = await builder.makePipeline(token: nil) {
128+
config.setPipeline(p)
129+
presentation.dismiss()
130+
}
131+
// TODO: show error
132+
}
133+
}
134+
.keyboardShortcut(.defaultAction)
135+
.disabled(!builder.canMakePipeline)
136+
}
137+
}
138+
.frame(minWidth: 400)
139+
.frame(idealWidth: 450)
140+
.padding()
141+
}
142+
}
143+
144+
struct AddGitLabPipelineSheet_Previews: PreviewProvider {
145+
static var previews: some View {
146+
Group {
147+
// AddGitLabPipelineSheet(config: $config)
148+
}
149+
}
150+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/*
2+
* Copyright (c) Erik Doernenburg and contributors
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
* not use these files except in compliance with the License.
5+
*/
6+
7+
import Foundation
8+
9+
@MainActor
10+
class GitLabBranchList: ObservableObject {
11+
@Published private(set) var items = [GitLabBranch()] { didSet { selected = items[0] }}
12+
@Published var selected = GitLabBranch()
13+
14+
func updateBranches(projectId: String, token: String?) async {
15+
items = [GitLabBranch(message: "updating")]
16+
items = await fetchBranches(projectId: projectId, token: token)
17+
}
18+
19+
private func fetchBranches(projectId: String, token: String?) async -> [GitLabBranch] {
20+
let request = GitLabAPI.requestForBranches(projectId: projectId, token: token)
21+
var branches = await fetchBranches(request: request)
22+
23+
if branches.count > 0 && !branches[0].isValid {
24+
return branches
25+
}
26+
27+
// Add empty string branch as the first item (represents "all branches")
28+
branches.insert(GitLabBranch(name: ""), at: 0)
29+
30+
if branches.count == 1 {
31+
branches = [GitLabBranch()]
32+
}
33+
34+
return branches
35+
}
36+
37+
private func fetchBranches(request: URLRequest) async -> [GitLabBranch] {
38+
let (branches, message): ([GitLabBranch]?, String) = await GitLabAPI.sendRequest(request: request)
39+
guard let branches else {
40+
return [GitLabBranch(message: message)]
41+
}
42+
return branches
43+
}
44+
45+
func clearBranches() {
46+
items = [GitLabBranch()]
47+
}
48+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright (c) Erik Doernenburg and contributors
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
* not use these files except in compliance with the License.
5+
*/
6+
7+
import Foundation
8+
9+
class GitLabPipelineBuilder: ObservableObject {
10+
@Published var name: String = ""
11+
var project: GitLabProject?
12+
var branch: String? { didSet { setDefaultName() } }
13+
14+
func setDefaultName() {
15+
var newName = ""
16+
if let project = project, project.isValid {
17+
newName = project.displayName
18+
if let branch = branch, !branch.isEmpty, branch != "all branches" {
19+
newName.append(String(format: " | %@", branch))
20+
}
21+
}
22+
name = newName
23+
}
24+
25+
var canMakePipeline: Bool {
26+
guard !name.isEmpty else { return false }
27+
guard let project = project, project.isValid else { return false }
28+
return true
29+
}
30+
31+
func makePipeline(token: String?) async -> Pipeline? {
32+
guard !name.isEmpty else { return nil }
33+
guard let project = project, project.isValid else { return nil }
34+
35+
// Convert project ID to string
36+
let projectId = String(project.id)
37+
38+
// Get branch name if specified, otherwise nil for all branches
39+
let branchName = branch == "all branches" || branch?.isEmpty == true ? nil : branch
40+
41+
// Create URL for GitLab pipeline feed
42+
let url = GitLabAPI.feedUrl(projectId: projectId, branch: branchName)
43+
44+
// Verify the URL works by fetching pipelines
45+
if let request = GitLabAPI.requestForFeed(feed: PipelineFeed(type: .gitlab, url: url), token: token),
46+
let result = await fetchPipelines(request: request), result == 200 {
47+
48+
// Create pipeline feed and pipeline
49+
let feed = PipelineFeed(type: .gitlab, url: url)
50+
let pipeline = Pipeline(name: name, feed: feed)
51+
return pipeline
52+
}
53+
54+
return nil
55+
}
56+
57+
private func fetchPipelines(request: URLRequest) async -> Int? {
58+
do {
59+
let (_, response) = try await URLSession.feedSession.data(for: request)
60+
guard let response = response as? HTTPURLResponse else { throw URLError(.unsupportedURL) }
61+
return response.statusCode
62+
} catch {
63+
return nil
64+
}
65+
}
66+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
* Copyright (c) Erik Doernenburg and contributors
3+
* Licensed under the Apache License, Version 2.0 (the "License"); you may
4+
* not use these files except in compliance with the License.
5+
*/
6+
7+
import Foundation
8+
9+
@MainActor
10+
class GitLabProjectList: ObservableObject {
11+
@Published private(set) var items = [GitLabProject()]
12+
13+
func updateProjects(name: String, token: String?) async {
14+
items = [GitLabProject(message: "updating")]
15+
items = await fetchProjects(name: name, token: token)
16+
}
17+
18+
private func fetchProjects(name: String, token: String?) async -> [GitLabProject] {
19+
// First try as user
20+
let userProjectsRequest = GitLabAPI.requestForUserProjects(user: name, token: token)
21+
var projects = await fetchProjects(request: userProjectsRequest)
22+
23+
// If user projects request failed or returned no valid projects, try as group
24+
if projects.isEmpty || !projects[0].isValid {
25+
let groupProjectsRequest = GitLabAPI.requestForGroupProjects(group: name, token: token)
26+
projects = await fetchProjects(request: groupProjectsRequest)
27+
}
28+
29+
// If both failed, return empty result with appropriate message
30+
if projects.isEmpty || !projects[0].isValid {
31+
return [GitLabProject(message: "No projects found")]
32+
}
33+
34+
// Sort projects by name
35+
projects.sort(by: { p1, p2 in p1.name.lowercased().compare(p2.name.lowercased()) == .orderedAscending })
36+
37+
return projects
38+
}
39+
40+
private func fetchProjects(request: URLRequest) async -> [GitLabProject] {
41+
let (projects, message): ([GitLabProject]?, String) = await GitLabAPI.sendRequest(request: request)
42+
guard let projects else {
43+
return [GitLabProject(message: message)]
44+
}
45+
return projects
46+
}
47+
48+
func clearProjects() {
49+
items = [GitLabProject()]
50+
}
51+
}

0 commit comments

Comments
 (0)