Commit 61e8259c19

61e8259c19f4a797af6bb6a2e63a3366967cc51b

parent: 3ee1c43ddb

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-07 00:40 UTC

Fix build cancel label, release-list refresh, org rename prefill

Relabel the build toolbar action "Cancel Build" so it doesn't read as
dismiss. Reload ReleaseListView on reappear (guarded past the initial
.task) so a deleted release doesn't linger after the pop. Clear the
org rename field instead of prefilling the current name, so an
un-edited tap can't resubmit it. Close three test gaps (createOrg's
return value, the instance isCancellable property, actionError
clearing after a prior failure) and provision cancelSendsTheBuildNumber
for all three requests its flow actually makes.

Layout: unified · split

gitbay/Views/Builds/BuildDetailView.swift +1 −1
@@ -37,7 +37,7 @@ struct BuildDetailView: View {
37 if model.working { 37 if model.working {
38 ProgressView() 38 ProgressView()
39 } else { 39 } else {
40 Button("Cancel", role: .destructive) { 40 Button("Cancel Build", role: .destructive) {
41 confirmingCancel = true 41 confirmingCancel = true
42 } 42 }
43 .accessibilityIdentifier("build-cancel-button") 43 .accessibilityIdentifier("build-cancel-button")
gitbay/Views/Orgs/OrgView.swift +2 −2
@@ -35,7 +35,7 @@ struct OrgView: View {
35 .toolbar { 35 .toolbar {
36 ToolbarItem(placement: .topBarTrailing) { 36 ToolbarItem(placement: .topBarTrailing) {
37 Button { 37 Button {
38 newOrgName = model.orgName 38 newOrgName = ""
39 renamingOrg = true 39 renamingOrg = true
40 } label: { 40 } label: {
41 Image(systemName: "pencil") 41 Image(systemName: "pencil")
@@ -47,7 +47,7 @@ struct OrgView: View {
47 .task { await model.load() } 47 .task { await model.load() }
48 .refreshable { await model.load() } 48 .refreshable { await model.load() }
49 .alert("Rename \(model.orgName)", isPresented: $renamingOrg) { 49 .alert("Rename \(model.orgName)", isPresented: $renamingOrg) {
50 TextField("New name", text: $newOrgName) 50 TextField(model.orgName, text: $newOrgName)
51 .autocorrectionDisabled() 51 .autocorrectionDisabled()
52 .textInputAutocapitalization(.never) 52 .textInputAutocapitalization(.never)
53 Button("Rename") { 53 Button("Rename") {
gitbay/Views/Releases/ReleaseListView.swift +12 −1
@@ -7,6 +7,7 @@ struct ReleaseListView: View {
7 @State private var draftTag = "" 7 @State private var draftTag = ""
8 @State private var draftTitle = "" 8 @State private var draftTitle = ""
9 @State private var draftNotes = "" 9 @State private var draftNotes = ""
10 @State private var hasLoadedOnce = false
10 11
11 init(client: GitbayClient, repo: String) { 12 init(client: GitbayClient, repo: String) {
12 _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo)) 13 _model = State(initialValue: ReleaseListViewModel(client: client, repoPath: repo))
@@ -63,7 +64,17 @@ struct ReleaseListView: View {
63 composing = false 64 composing = false
64 } 65 }
65 } 66 }
66 .task { await model.load() } 67 .task {
68 await model.load()
69 hasLoadedOnce = true
70 }
71 // A deleted release pops back to this list; `.task` does not
72 // re-run on that pop, so a manual refresh here is what drops the
73 // now-gone row. Guarded so the initial `.task` load isn't repeated.
74 .onAppear {
75 guard hasLoadedOnce else { return }
76 Task { await model.load() }
77 }
67 .refreshable { await model.load() } 78 .refreshable { await model.load() }
68 } 79 }
69} 80}
gitbayTests/SmallActionTests.swift +50 −3
@@ -37,15 +37,40 @@ struct BuildCancelTests {
37 37
38 @Test func cancelSendsTheBuildNumber() async throws { 38 @Test func cancelSendsTheBuildNumber() async throws {
39 let (client, stub) = try makeClient() 39 let (client, stub) = try makeClient()
40 // The view model's own load response, then the write, then a reload. 40 // The write, then its reload — `build show` and `build log` fire
41 // concurrently, so each needs its own matched stub.
41 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966) 42 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966)
42 stub.enqueue(.init(status: 200, json: ok)) 43 stub.enqueue(.init(status: 200, json: ok))
43 stub.enqueue(.init(status: 200, json: ok)) 44 stub.enqueue(.init(status: 200, json: """
45 {"protocol_version":1,"data":{"number":966,"job":"test","status":"cancelled",\
46 "sha":"abc123","ref":"refs/heads/main","created_at":"2026-01-01T00:00:00Z"},\
47 "exit_code":0}
48 """, match: "argv=show"))
49 stub.enqueue(.init(status: 200, json: ok, match: "argv=log"))
44 await model.cancel() 50 await model.cancel()
45 51
46 let write = try #require(stub.seen.first { $0.method == "POST" }) 52 let write = try #require(stub.seen.first { $0.method == "POST" })
47 #expect(try argvOf(write) == ["build", "cancel", "krz/gitbay", "966"]) 53 #expect(try argvOf(write) == ["build", "cancel", "krz/gitbay", "966"])
48 } 54 }
55
56 /// The view reads the instance property, not the static function —
57 /// nothing pinned that it tracks the loaded build, or that it starts
58 /// out false before anything has loaded.
59 @Test func instanceIsCancellableTracksTheLoadedBuild() async throws {
60 let (client, stub) = try makeClient()
61 let model = BuildDetailViewModel(client: client, repoPath: "krz/gitbay", number: 966)
62 #expect(model.isCancellable == false)
63
64 stub.enqueue(.init(status: 200, json: """
65 {"protocol_version":1,"data":{"number":966,"job":"test","status":"running",\
66 "sha":"abc123","ref":"refs/heads/main","created_at":"2026-01-01T00:00:00Z"},\
67 "exit_code":0}
68 """, match: "argv=show"))
69 stub.enqueue(.init(status: 200, json: ok, match: "argv=log"))
70 await model.load()
71
72 #expect(model.isCancellable)
73 }
49} 74}
50 75
51@MainActor 76@MainActor
@@ -74,6 +99,27 @@ struct ReleaseDeleteTests {
74 #expect(deleted == false) 99 #expect(deleted == false)
75 #expect(model.actionError?.isEmpty == false) 100 #expect(model.actionError?.isEmpty == false)
76 } 101 }
102
103 /// A stale `actionError` surviving an unrelated later call has been the
104 /// bug five times over; every `perform` clears it up front, but nothing
105 /// pinned that.
106 @Test func actionErrorClearsOnALaterSuccess() async throws {
107 let (client, stub) = try makeClient()
108 let model = ReleaseDetailViewModel(client: client, repoPath: "krz/gitbay", tag: "v1.0.0")
109 stub.enqueue(.init(status: 200, json: """
110 {"protocol_version":1,"error":"deleting a release needs write access","exit_code":4}
111 """))
112 let firstDelete = await model.delete()
113
114 #expect(firstDelete == false)
115 #expect(model.actionError != nil)
116
117 stub.enqueue(.init(status: 200, json: ok))
118 let secondDelete = await model.delete()
119
120 #expect(secondDelete)
121 #expect(model.actionError == nil)
122 }
77} 123}
78 124
79@MainActor 125@MainActor
@@ -130,8 +176,9 @@ struct OrgCreateRenameTests {
130 stub.enqueue(.init(status: 200, json: ok)) 176 stub.enqueue(.init(status: 200, json: ok))
131 stub.enqueue(.init(status: 200, json: ok)) 177 stub.enqueue(.init(status: 200, json: ok))
132 let model = ProfileViewModel(client: client, name: "cmc") 178 let model = ProfileViewModel(client: client, name: "cmc")
133 _ = await model.createOrg("newco") 179 let created = await model.createOrg("newco")
134 180
181 #expect(created)
135 let write = try #require(stub.seen.first { $0.method == "POST" }) 182 let write = try #require(stub.seen.first { $0.method == "POST" })
136 #expect(try argvOf(write) == ["org", "create", "newco"]) 183 #expect(try argvOf(write) == ["org", "create", "newco"])
137 } 184 }