Commit 34ae1d1cb0
Unsigned
Layout: unified · split
gitbay/Repos/RepoSettingsViewModel.swift +24
| @@ -138,6 +138,30 @@ final class RepoSettingsViewModel { | ||
| 138 | 138 | await perform(["repo", "settings", "default-branch", repoPath, branch]) |
| 139 | 139 | } |
| 140 | 140 | |
| 141 | nonisolated private struct Renamed: Decodable, Sendable { | |
| 142 | let repo: String | |
| 143 | } | |
| 144 | ||
| 145 | /// `repo rename <owner/name> <new-name>`: the row and the directory | |
| 146 | /// move together and clone URLs change. Returns the new path; the | |
| 147 | /// caller navigates there, since this screen's path is now gone. | |
| 148 | func rename(to name: String) async -> String? { | |
| 149 | let name = name.trimmingCharacters(in: .whitespaces) | |
| 150 | guard !name.isEmpty else { return nil } | |
| 151 | working = true | |
| 152 | actionError = nil | |
| 153 | defer { working = false } | |
| 154 | do { | |
| 155 | let renamed = try await client.run(["repo", "rename", repoPath, name], as: Renamed.self) | |
| 156 | return renamed?.repo | |
| 157 | } catch let error as GitbayError { | |
| 158 | actionError = error.userFacingMessage | |
| 159 | } catch { | |
| 160 | actionError = GitbayError.transport(error).userFacingMessage | |
| 161 | } | |
| 162 | return nil | |
| 163 | } | |
| 164 | ||
| 141 | 165 | func loadBranches() async { |
| 142 | 166 | let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self) |
| 143 | 167 | branches = refs?.branches.map(\.name) ?? [] |
gitbay/Views/Repos/RepoSettingsView.swift +53
| @@ -10,6 +10,11 @@ struct RepoSettingsView: View { | ||
| 10 | 10 | @State private var newTopic = "" |
| 11 | 11 | @State private var newBranch = "" |
| 12 | 12 | @State private var newTagGlob = "" |
| 13 | @State private var renaming = false | |
| 14 | @State private var newName = "" | |
| 15 | /// Set once a rename succeeds: this screen's repository is gone and | |
| 16 | /// the renamed one is a value push away. | |
| 17 | @State private var renamedTo: String? | |
| 13 | 18 | @State private var loadedOnce = false |
| 14 | 19 | @State private var removingTopic: String? |
| 15 | 20 | @State private var newGrantUser = "" |
| @@ -22,6 +27,19 @@ struct RepoSettingsView: View { | ||
| 22 | 27 | |
| 23 | 28 | var body: some View { |
| 24 | 29 | List { |
| 30 | if let renamedTo { | |
| 31 | Section { | |
| 32 | NavigationLink(value: RepoRoute.repo(renamedTo)) { | |
| 33 | Label("Open \(renamedTo)", systemImage: "arrow.right.circle") | |
| 34 | .font(.gbSans(.subheadline)) | |
| 35 | } | |
| 36 | .accessibilityIdentifier("settings-renamed-link") | |
| 37 | } header: { | |
| 38 | Text("Renamed") | |
| 39 | } footer: { | |
| 40 | Text("This screen's repository moved; nothing here applies to it any more.") | |
| 41 | } | |
| 42 | } | |
| 25 | 43 | if let loaded = model.state.value { |
| 26 | 44 | if let error = model.actionError { |
| 27 | 45 | Section { |
| @@ -39,6 +57,7 @@ struct RepoSettingsView: View { | ||
| 39 | 57 | accessSection |
| 40 | 58 | webhooksSection |
| 41 | 59 | depsSection() |
| 60 | renameSection | |
| 42 | 61 | } |
| 43 | 62 | } |
| 44 | 63 | .overlay { LoadStateOverlay(state: model.state) } |
| @@ -76,6 +95,22 @@ struct RepoSettingsView: View { | ||
| 76 | 95 | } |
| 77 | 96 | Button("Cancel", role: .cancel) {} |
| 78 | 97 | } |
| 98 | .alert("Rename \(model.repoPath)", isPresented: $renaming) { | |
| 99 | TextField("New name", text: $newName) | |
| 100 | .autocorrectionDisabled() | |
| 101 | .textInputAutocapitalization(.never) | |
| 102 | Button("Rename") { | |
| 103 | Task { | |
| 104 | if let path = await model.rename(to: newName) { | |
| 105 | renamedTo = path | |
| 106 | } | |
| 107 | } | |
| 108 | } | |
| 109 | .disabled(newName.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 110 | Button("Cancel", role: .cancel) {} | |
| 111 | } message: { | |
| 112 | Text("Clone URLs change.") | |
| 113 | } | |
| 79 | 114 | .confirmationDialog( |
| 80 | 115 | "Remove topic \(removingTopic ?? "")?", |
| 81 | 116 | isPresented: Binding( |
| @@ -403,6 +438,24 @@ struct RepoSettingsView: View { | ||
| 403 | 438 | } |
| 404 | 439 | } |
| 405 | 440 | |
| 441 | /// `repo rename`. Delete and transfer stay SSH-only by design; a | |
| 442 | /// rename keeps the owner and is routine for someone with many repos. | |
| 443 | private var renameSection: some View { | |
| 444 | Section { | |
| 445 | Button { | |
| 446 | newName = String(model.repoPath.split(separator: "/").last ?? "") | |
| 447 | renaming = true | |
| 448 | } label: { | |
| 449 | Label("Rename repository", systemImage: "pencil") | |
| 450 | .font(.gbSans(.subheadline)) | |
| 451 | } | |
| 452 | .disabled(model.working) | |
| 453 | .accessibilityIdentifier("settings-rename-button") | |
| 454 | } footer: { | |
| 455 | Text("The owner stays; the name changes, and so do the clone URLs.") | |
| 456 | } | |
| 457 | } | |
| 458 | ||
| 406 | 459 | private func depsSection() -> some View { |
| 407 | 460 | Section { |
| 408 | 461 | if let deps = model.deps { |
gitbay/Views/Repos/RepoView.swift +1
| @@ -78,6 +78,7 @@ struct RepoView: View { | ||
| 78 | 78 | NavigationLink(value: RepoRoute.settings(repo: path)) { |
| 79 | 79 | Label("Settings", systemImage: "gearshape") |
| 80 | 80 | } |
| 81 | .accessibilityIdentifier("repo-settings-link") | |
| 81 | 82 | NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) { |
| 82 | 83 | Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle") |
| 83 | 84 | } |
gitbayTests/RepoManagementTests.swift +23
| @@ -122,6 +122,29 @@ struct RepoSettingsViewModelTests { | ||
| 122 | 122 | ]) |
| 123 | 123 | } |
| 124 | 124 | |
| 125 | @Test func renameSendsTheNewNameAndReturnsTheNewPath() async throws { | |
| 126 | let (model, stub) = try await loadedModel() | |
| 127 | stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"data":{"repo":"krz/forge","was":"krz/gitbay"},"exit_code":0}"#, match: "cmd")) | |
| 128 | ||
| 129 | let renamed = await model.rename(to: " forge ") | |
| 130 | ||
| 131 | #expect(renamed == "krz/forge") | |
| 132 | let write = try #require(stub.seen.first { $0.method == "POST" }) | |
| 133 | #expect(try argvOf(write) == ["repo", "rename", "krz/gitbay", "forge"]) | |
| 134 | // The old path is gone; nothing reloads it. | |
| 135 | #expect(stub.seen.filter { $0.method == "GET" }.count == 2) | |
| 136 | } | |
| 137 | ||
| 138 | @Test func aRefusedRenameSurfacesAndReturnsNil() async throws { | |
| 139 | let (model, stub) = try await loadedModel() | |
| 140 | stub.enqueue(.init(status: 403, json: #"{"protocol_version":1,"error":"admin access required","exit_code":4}"#, match: "cmd")) | |
| 141 | ||
| 142 | let renamed = await model.rename(to: "forge") | |
| 143 | ||
| 144 | #expect(renamed == nil) | |
| 145 | #expect(model.actionError == "admin access required") | |
| 146 | } | |
| 147 | ||
| 125 | 148 | @Test func branchesLoadForTheDefaultBranchPicker() async throws { |
| 126 | 149 | let (model, stub) = try await loadedModel() |
| 127 | 150 | stub.enqueue(.init(status: 200, json: """ |
gitbayUITests/LiveSmokeUITests.swift +56 −9
| @@ -66,6 +66,8 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 66 | 66 | /// outcome we want anyway. |
| 67 | 67 | private func deleteScratchRepo() { |
| 68 | 68 | _ = runCommand(["repo", "delete", Self.scratchRepo, "--yes"]) |
| 69 | // The rename round-trip's halfway name, in case a run died there. | |
| 70 | _ = runCommand(["repo", "delete", Self.scratchRepo + "-2", "--yes"]) | |
| 69 | 71 | } |
| 70 | 72 | |
| 71 | 73 | /// The issue the editing flow works on. It is a fixture, not a real |
| @@ -433,9 +435,9 @@ extension LiveSmokeUITests { | ||
| 433 | 435 | // lazy, so find the new repo through the filter. |
| 434 | 436 | XCTAssertTrue(waitForDisappearance(pathField, timeout: 15), |
| 435 | 437 | "create sheet did not dismiss") |
| 436 | let search = app.searchFields.firstMatch | |
| 437 | XCTAssertTrue(search.waitForExistence(timeout: 10)) | |
| 438 | focusAndType(search, "ui-smoke") | |
| 438 | let createSearch = app.searchFields.firstMatch | |
| 439 | XCTAssertTrue(createSearch.waitForExistence(timeout: 10)) | |
| 440 | focusAndType(createSearch, "ui-smoke") | |
| 439 | 441 | XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch |
| 440 | 442 | .waitForExistence(timeout: 15), "created repo not in the list") |
| 441 | 443 | |
| @@ -482,13 +484,53 @@ extension LiveSmokeUITests { | ||
| 482 | 484 | confirmHookRemove.tap() |
| 483 | 485 | XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed") |
| 484 | 486 | back() // webhooks -> settings |
| 485 | back() // settings -> repo | |
| 486 | back() // repo -> list | |
| 487 | ||
| 488 | // --- rename the scratch repo there and back --- | |
| 489 | // Each rename pushes the renamed repository; the round-trip ends | |
| 490 | // on cmc/ui-smoke again, several screens deep. | |
| 491 | var firstRename = true | |
| 492 | let rename: (String, String) -> Void = { newName, expectedPath in | |
| 493 | if firstRename { | |
| 494 | firstRename = false // already on the settings screen | |
| 495 | } else { | |
| 496 | // Fresh repo screen: scroll to the top so no row hides | |
| 497 | // under the navigation bar, then reach Settings. | |
| 498 | self.app.swipeDown() | |
| 499 | let settingsLink = self.app.descendants(matching: .any).matching(identifier: "repo-settings-link").firstMatch | |
| 500 | XCTAssertTrue(self.scrollTo(settingsLink), "settings link not reachable") | |
| 501 | settingsLink.tap() | |
| 502 | } | |
| 503 | let renameButton = self.app.descendants(matching: .any) | |
| 504 | .matching(identifier: "settings-rename-button").firstMatch | |
| 505 | XCTAssertTrue(self.scrollTo(renameButton, swipes: 10), "rename button not reachable") | |
| 506 | renameButton.tap() | |
| 507 | let field = self.app.textFields["New name"].firstMatch | |
| 508 | XCTAssertTrue(field.waitForExistence(timeout: 5), "rename alert did not open") | |
| 509 | field.tap() | |
| 510 | // Prefilled with the current name; clear it before typing. | |
| 511 | field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: 12) + newName) | |
| 512 | self.app.buttons["Rename"].firstMatch.tap() | |
| 513 | // The settings screen offers the renamed repository at the | |
| 514 | // top; the list is scrolled to its end, so go back up first. | |
| 515 | let renamedLink = self.app.descendants(matching: .any) | |
| 516 | .matching(identifier: "settings-renamed-link").firstMatch | |
| 517 | sleep(2) | |
| 518 | for _ in 0..<6 where !renamedLink.exists { self.app.swipeDown() } | |
| 519 | XCTAssertTrue(renamedLink.waitForExistence(timeout: 10), "rename did not land") | |
| 520 | renamedLink.tap() | |
| 521 | XCTAssertTrue(self.app.staticTexts[expectedPath].firstMatch.waitForExistence(timeout: 15), | |
| 522 | "renamed repo did not open as \(expectedPath)") | |
| 523 | } | |
| 524 | rename("ui-smoke-2", "cmc/ui-smoke-2") | |
| 525 | rename("ui-smoke", "cmc/ui-smoke") | |
| 487 | 526 | |
| 488 | 527 | // --- pin / unpin round-trip on krz/gitbay-ios --- |
| 489 | // Reuse the open search to get there. | |
| 490 | let clear = search.buttons.firstMatch | |
| 491 | if clear.exists { clear.tap() } | |
| 528 | // Start the list fresh; the rename left the stack several deep. | |
| 529 | app.terminate() | |
| 530 | app.launch() | |
| 531 | selectTab("Repositories") | |
| 532 | let search = app.searchFields.firstMatch | |
| 533 | XCTAssertTrue(search.waitForExistence(timeout: 10)) | |
| 492 | 534 | focusAndType(search, "krz/gitbay-ios") |
| 493 | 535 | let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch |
| 494 | 536 | XCTAssertTrue(repoRow.waitForExistence(timeout: 15)) |
| @@ -543,7 +585,8 @@ extension LiveSmokeUITests { | ||
| 543 | 585 | // --- settings: topic and merge-rule round-trips --- |
| 544 | 586 | // The links section can sit below the README fold, and the row's |
| 545 | 587 | // label is not always exposed as a static text. |
| 546 | let settingsLink = app.descendants(matching: .any)["Settings"].firstMatch | |
| 588 | let settingsLink = app.descendants(matching: .any) | |
| 589 | .matching(identifier: "repo-settings-link").firstMatch | |
| 547 | 590 | XCTAssertTrue(scrollTo(settingsLink), "settings link not reachable") |
| 548 | 591 | settingsLink.tap() |
| 549 | 592 | let addTopic = app.descendants(matching: .any) |
| @@ -633,6 +676,9 @@ extension LiveSmokeUITests { | ||
| 633 | 676 | |
| 634 | 677 | |
| 635 | 678 | back() // settings -> repo |
| 679 | // The repo screen keeps the scroll that reached Settings, which | |
| 680 | // can leave Builds under the navigation bar. | |
| 681 | app.swipeDown() | |
| 636 | 682 | |
| 637 | 683 | // --- nothing to trigger without a job file --- |
| 638 | 684 | // This repo has no .gitbay/ci.yml, so `build jobs` returns none |
| @@ -649,6 +695,7 @@ extension LiveSmokeUITests { | ||
| 649 | 695 | .firstMatch |
| 650 | 696 | XCTAssertFalse(anyJob.waitForExistence(timeout: 3), |
| 651 | 697 | "a repo with no job file offered a job to trigger") |
| 698 | ||
| 652 | 699 | } |
| 653 | 700 | |
| 654 | 701 | private func waitForValue(_ element: XCUIElement, _ value: String, |