Commit 8af268e4f7

8af268e4f7016de42e46fb95cffe10fa64780131

parent: f390bcc59e

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-14 07:27 UTC

Transfer and delete: the screens underneath follow; refusals in view

The repository screen and the dashboard reload when a repository is
deleted, moved or renamed, rather than showing it until pulled; a
settings screen returned to after one keeps its notice instead of
reloading into not found. A refused rename, transfer or delete is shown
in an alert, since the screen's banner sits at the top of a long list.
A delete whose retry finds the repository gone is reported done.

Ref #31

Layout: unified · split

gitbay/Repos/RepoSettingsViewModel.swift +9
@@ -177,6 +177,7 @@ final class RepoSettingsViewModel {
177 defer { working = false } 177 defer { working = false }
178 do { 178 do {
179 let renamed = try await client.run(["repo", "rename", repoPath, name], as: Renamed.self) 179 let renamed = try await client.run(["repo", "rename", repoPath, name], as: Renamed.self)
180 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
180 return renamed?.repo 181 return renamed?.repo
181 } catch let error as GitbayError { 182 } catch let error as GitbayError {
182 actionError = error.userFacingMessage 183 actionError = error.userFacingMessage
@@ -201,6 +202,7 @@ final class RepoSettingsViewModel {
201 defer { working = false } 202 defer { working = false }
202 do { 203 do {
203 let moved = try await client.run(["repo", "transfer", repoPath, owner], as: Transferred.self) 204 let moved = try await client.run(["repo", "transfer", repoPath, owner], as: Transferred.self)
205 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
204 return moved?.repo 206 return moved?.repo
205 } catch let error as GitbayError { 207 } catch let error as GitbayError {
206 // The owner is typed: a refusal of it says what to fix. 208 // The owner is typed: a refusal of it says what to fix.
@@ -219,6 +221,13 @@ final class RepoSettingsViewModel {
219 defer { working = false } 221 defer { working = false }
220 do { 222 do {
221 try await client.run(["repo", "delete", repoPath, "--yes"]) 223 try await client.run(["repo", "delete", repoPath, "--yes"])
224 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
225 return true
226 } catch GitbayError.notFound {
227 // The client retries a failure once. A delete that removed the
228 // row and then failed cleaning up answers the retry with not
229 // found: the repository is gone either way.
230 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
222 return true 231 return true
223 } catch let error as GitbayError { 232 } catch let error as GitbayError {
224 actionError = error.userFacingMessage 233 actionError = error.userFacingMessage
gitbay/Views/Dashboard/DashboardView.swift +4
@@ -95,6 +95,10 @@ struct DashboardView: View {
95 .onReceive(NotificationCenter.default.publisher(for: .gitbaySavedQueriesChanged)) { _ in 95 .onReceive(NotificationCenter.default.publisher(for: .gitbaySavedQueriesChanged)) { _ in
96 Task { await model.load() } 96 Task { await model.load() }
97 } 97 }
98 // A pinned repository deleted or moved elsewhere.
99 .onReceive(NotificationCenter.default.publisher(for: .gitbayRepositoryChanged)) { _ in
100 Task { await model.load() }
101 }
98 .onChange(of: model.state.value?.unread) { _, count in 102 .onChange(of: model.state.value?.unread) { _, count in
99 if let count { unread = count } 103 if let count { unread = count }
100 } 104 }
gitbay/Views/Repos/RepoSettingsView.swift +18 −1
@@ -24,6 +24,9 @@ struct RepoSettingsView: View {
24 @State private var deleteConfirm = "" 24 @State private var deleteConfirm = ""
25 /// Set once the repository is deleted: nothing on this screen applies. 25 /// Set once the repository is deleted: nothing on this screen applies.
26 @State private var deleted = false 26 @State private var deleted = false
27 /// A refused rename, transfer or delete, shown where the user is: the
28 /// screen's banner sits at the top of a long list, out of sight.
29 @State private var refusal: String?
27 @State private var loadedOnce = false 30 @State private var loadedOnce = false
28 @State private var removingTopic: String? 31 @State private var removingTopic: String?
29 @State private var newGrantUser = "" 32 @State private var newGrantUser = ""
@@ -84,6 +87,9 @@ struct RepoSettingsView: View {
84 .navigationTitle("Settings") 87 .navigationTitle("Settings")
85 .navigationBarTitleDisplayMode(.inline) 88 .navigationBarTitleDisplayMode(.inline)
86 .task { 89 .task {
90 // A screen returned to after its repository moved or went has
91 // nothing to load; the notice is all it shows.
92 guard !deleted, renamedTo == nil else { return }
87 await model.load() 93 await model.load()
88 if !loadedOnce, let loaded = model.state.value { 94 if !loadedOnce, let loaded = model.state.value {
89 descriptionText = loaded.detail.description ?? "" 95 descriptionText = loaded.detail.description ?? ""
@@ -95,6 +101,8 @@ struct RepoSettingsView: View {
95 .task { await model.loadGrants() } 101 .task { await model.loadGrants() }
96 .task { await model.loadBranches() } 102 .task { await model.loadBranches() }
97 .refreshable { 103 .refreshable {
104 // Nothing to reload once the repository is gone or moved.
105 guard !deleted, renamedTo == nil else { return }
98 await model.load() 106 await model.load()
99 await model.loadDeps() 107 await model.loadDeps()
100 await model.loadGrants() 108 await model.loadGrants()
@@ -126,6 +134,8 @@ struct RepoSettingsView: View {
126 Task { 134 Task {
127 if let path = await model.rename(to: newName) { 135 if let path = await model.rename(to: newName) {
128 renamedTo = path 136 renamedTo = path
137 } else {
138 refusal = model.actionError
129 } 139 }
130 } 140 }
131 } 141 }
@@ -135,6 +145,11 @@ struct RepoSettingsView: View {
135 } message: { 145 } message: {
136 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.") 146 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
137 } 147 }
148 .alert("Not done", isPresented: Binding(get: { refusal != nil }, set: { if !$0 { refusal = nil } })) {
149 Button("OK", role: .cancel) {}
150 } message: {
151 Text(refusal ?? "")
152 }
138 .alert("Transfer \(model.repoPath)", isPresented: $transferring) { 153 .alert("Transfer \(model.repoPath)", isPresented: $transferring) {
139 TextField("New owner", text: $newOwner) 154 TextField("New owner", text: $newOwner)
140 .autocorrectionDisabled() 155 .autocorrectionDisabled()
@@ -146,6 +161,8 @@ struct RepoSettingsView: View {
146 Task { 161 Task {
147 if let path = await model.transfer(to: newOwner) { 162 if let path = await model.transfer(to: newOwner) {
148 renamedTo = path 163 renamedTo = path
164 } else {
165 refusal = model.actionError
149 } 166 }
150 } 167 }
151 } 168 }
@@ -161,7 +178,7 @@ struct RepoSettingsView: View {
161 .textInputAutocapitalization(.never) 178 .textInputAutocapitalization(.never)
162 Button("Delete", role: .destructive) { 179 Button("Delete", role: .destructive) {
163 Task { 180 Task {
164 if await model.delete() { deleted = true } 181 if await model.delete() { deleted = true } else { refusal = model.actionError }
165 } 182 }
166 } 183 }
167 .disabled(deleteConfirm != model.repoPath) 184 .disabled(deleteConfirm != model.repoPath)
gitbay/Views/Repos/RepoView.swift +5
@@ -98,6 +98,11 @@ struct RepoView: View {
98 .toolbar { toolbar } 98 .toolbar { toolbar }
99 .task { await model.load() } 99 .task { await model.load() }
100 .refreshable { await model.load() } 100 .refreshable { await model.load() }
101 // Settings, pushed on top, may delete or move this repository;
102 // reloading turns this screen into the not-found it now is.
103 .onReceive(NotificationCenter.default.publisher(for: .gitbayRepositoryChanged)) { note in
104 if note.object as? String == path { Task { await model.load() } }
105 }
101 .alert("Fork \(path)", isPresented: $confirmingFork) { 106 .alert("Fork \(path)", isPresented: $confirmingFork) {
102 TextField("New name (optional)", text: $forkName) 107 TextField("New name (optional)", text: $forkName)
103 .autocorrectionDisabled() 108 .autocorrectionDisabled()
gitbayTests/RepoTransferDeleteTests.swift +10
@@ -59,4 +59,14 @@ struct RepoTransferDeleteTests {
59 #expect(!(await model.delete())) 59 #expect(!(await model.delete()))
60 #expect(model.actionError == "only the owner or an admin can delete cmc/app") 60 #expect(model.actionError == "only the owner or an admin can delete cmc/app")
61 } 61 }
62
63 @Test func aDeleteWhoseRetryFindsNothingIsDone() async throws {
64 let (client, stub) = try makeClient()
65 stub.enqueue(.init(status: 500, json: #"{"protocol_version":1,"error":"removing the directory: busy","exit_code":1}"#, match: "cmd"))
66 stub.enqueue(.init(status: 404, json: #"{"protocol_version":1,"error":"no repository cmc/app","exit_code":3}"#, match: "cmd"))
67 let model = RepoSettingsViewModel(client: client, repoPath: "cmc/app")
68
69 #expect(await model.delete())
70 #expect(model.actionError == nil)
71 }
62} 72}