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 {
177177 defer { working = false }
178178 do {
179179 let renamed = try await client.run(["repo", "rename", repoPath, name], as: Renamed.self)
180 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
180181 return renamed?.repo
181182 } catch let error as GitbayError {
182183 actionError = error.userFacingMessage
@@ -201,6 +202,7 @@ final class RepoSettingsViewModel {
201202 defer { working = false }
202203 do {
203204 let moved = try await client.run(["repo", "transfer", repoPath, owner], as: Transferred.self)
205 NotificationCenter.default.post(name: .gitbayRepositoryChanged, object: repoPath)
204206 return moved?.repo
205207 } catch let error as GitbayError {
206208 // The owner is typed: a refusal of it says what to fix.
@@ -219,6 +221,13 @@ final class RepoSettingsViewModel {
219221 defer { working = false }
220222 do {
221223 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)
222231 return true
223232 } catch let error as GitbayError {
224233 actionError = error.userFacingMessage
gitbay/Views/Dashboard/DashboardView.swift +4
@@ -95,6 +95,10 @@ struct DashboardView: View {
9595 .onReceive(NotificationCenter.default.publisher(for: .gitbaySavedQueriesChanged)) { _ in
9696 Task { await model.load() }
9797 }
98 // A pinned repository deleted or moved elsewhere.
99 .onReceive(NotificationCenter.default.publisher(for: .gitbayRepositoryChanged)) { _ in
100 Task { await model.load() }
101 }
98102 .onChange(of: model.state.value?.unread) { _, count in
99103 if let count { unread = count }
100104 }
gitbay/Views/Repos/RepoSettingsView.swift +18 −1
@@ -24,6 +24,9 @@ struct RepoSettingsView: View {
2424 @State private var deleteConfirm = ""
2525 /// Set once the repository is deleted: nothing on this screen applies.
2626 @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?
2730 @State private var loadedOnce = false
2831 @State private var removingTopic: String?
2932 @State private var newGrantUser = ""
@@ -84,6 +87,9 @@ struct RepoSettingsView: View {
8487 .navigationTitle("Settings")
8588 .navigationBarTitleDisplayMode(.inline)
8689 .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 }
8793 await model.load()
8894 if !loadedOnce, let loaded = model.state.value {
8995 descriptionText = loaded.detail.description ?? ""
@@ -95,6 +101,8 @@ struct RepoSettingsView: View {
95101 .task { await model.loadGrants() }
96102 .task { await model.loadBranches() }
97103 .refreshable {
104 // Nothing to reload once the repository is gone or moved.
105 guard !deleted, renamedTo == nil else { return }
98106 await model.load()
99107 await model.loadDeps()
100108 await model.loadGrants()
@@ -126,6 +134,8 @@ struct RepoSettingsView: View {
126134 Task {
127135 if let path = await model.rename(to: newName) {
128136 renamedTo = path
137 } else {
138 refusal = model.actionError
129139 }
130140 }
131141 }
@@ -135,6 +145,11 @@ struct RepoSettingsView: View {
135145 } message: {
136146 Text("Clone URLs change. The old path stops resolving. Type \(model.repoPath) to confirm.")
137147 }
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 }
138153 .alert("Transfer \(model.repoPath)", isPresented: $transferring) {
139154 TextField("New owner", text: $newOwner)
140155 .autocorrectionDisabled()
@@ -146,6 +161,8 @@ struct RepoSettingsView: View {
146161 Task {
147162 if let path = await model.transfer(to: newOwner) {
148163 renamedTo = path
164 } else {
165 refusal = model.actionError
149166 }
150167 }
151168 }
@@ -161,7 +178,7 @@ struct RepoSettingsView: View {
161178 .textInputAutocapitalization(.never)
162179 Button("Delete", role: .destructive) {
163180 Task {
164 if await model.delete() { deleted = true }
181 if await model.delete() { deleted = true } else { refusal = model.actionError }
165182 }
166183 }
167184 .disabled(deleteConfirm != model.repoPath)
gitbay/Views/Repos/RepoView.swift +5
@@ -98,6 +98,11 @@ struct RepoView: View {
9898 .toolbar { toolbar }
9999 .task { await model.load() }
100100 .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 }
101106 .alert("Fork \(path)", isPresented: $confirmingFork) {
102107 TextField("New name (optional)", text: $forkName)
103108 .autocorrectionDisabled()
gitbayTests/RepoTransferDeleteTests.swift +10
@@ -59,4 +59,14 @@ struct RepoTransferDeleteTests {
5959 #expect(!(await model.delete()))
6060 #expect(model.actionError == "only the owner or an admin can delete cmc/app")
6161 }
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 }
6272}