Commit 34ae1d1cb0
Unsigned
Layout: unified · split
gitbay/Repos/RepoSettingsViewModel.swift +24
| @@ -138,6 +138,30 @@ final class RepoSettingsViewModel { | |||
| 138 | await perform(["repo", "settings", "default-branch", repoPath, branch]) | 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 | func loadBranches() async { | 165 | func loadBranches() async { |
| 142 | let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self) | 166 | let refs = try? await client.read(["repo", "refs", repoPath], as: RepoRefs.self) |
| 143 | branches = refs?.branches.map(\.name) ?? [] | 167 | branches = refs?.branches.map(\.name) ?? [] |
gitbay/Views/Repos/RepoSettingsView.swift +53
| @@ -10,6 +10,11 @@ struct RepoSettingsView: View { | |||
| 10 | @State private var newTopic = "" | 10 | @State private var newTopic = "" |
| 11 | @State private var newBranch = "" | 11 | @State private var newBranch = "" |
| 12 | @State private var newTagGlob = "" | 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 | @State private var loadedOnce = false | 18 | @State private var loadedOnce = false |
| 14 | @State private var removingTopic: String? | 19 | @State private var removingTopic: String? |
| 15 | @State private var newGrantUser = "" | 20 | @State private var newGrantUser = "" |
| @@ -22,6 +27,19 @@ struct RepoSettingsView: View { | |||
| 22 | 27 | ||
| 23 | var body: some View { | 28 | var body: some View { |
| 24 | List { | 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 | if let loaded = model.state.value { | 43 | if let loaded = model.state.value { |
| 26 | if let error = model.actionError { | 44 | if let error = model.actionError { |
| 27 | Section { | 45 | Section { |
| @@ -39,6 +57,7 @@ struct RepoSettingsView: View { | |||
| 39 | accessSection | 57 | accessSection |
| 40 | webhooksSection | 58 | webhooksSection |
| 41 | depsSection() | 59 | depsSection() |
| 60 | renameSection | ||
| 42 | } | 61 | } |
| 43 | } | 62 | } |
| 44 | .overlay { LoadStateOverlay(state: model.state) } | 63 | .overlay { LoadStateOverlay(state: model.state) } |
| @@ -76,6 +95,22 @@ struct RepoSettingsView: View { | |||
| 76 | } | 95 | } |
| 77 | Button("Cancel", role: .cancel) {} | 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 | .confirmationDialog( | 114 | .confirmationDialog( |
| 80 | "Remove topic \(removingTopic ?? "")?", | 115 | "Remove topic \(removingTopic ?? "")?", |
| 81 | isPresented: Binding( | 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 | private func depsSection() -> some View { | 459 | private func depsSection() -> some View { |
| 407 | Section { | 460 | Section { |
| 408 | if let deps = model.deps { | 461 | if let deps = model.deps { |
gitbay/Views/Repos/RepoView.swift +1
| @@ -78,6 +78,7 @@ struct RepoView: View { | |||
| 78 | NavigationLink(value: RepoRoute.settings(repo: path)) { | 78 | NavigationLink(value: RepoRoute.settings(repo: path)) { |
| 79 | Label("Settings", systemImage: "gearshape") | 79 | Label("Settings", systemImage: "gearshape") |
| 80 | } | 80 | } |
| 81 | .accessibilityIdentifier("repo-settings-link") | ||
| 81 | NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) { | 82 | NavigationLink(value: RepoRoute.profile(String(path.split(separator: "/").first ?? ""))) { |
| 82 | Label(String(path.split(separator: "/").first ?? ""), systemImage: "person.crop.circle") | 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 | @Test func branchesLoadForTheDefaultBranchPicker() async throws { | 148 | @Test func branchesLoadForTheDefaultBranchPicker() async throws { |
| 126 | let (model, stub) = try await loadedModel() | 149 | let (model, stub) = try await loadedModel() |
| 127 | stub.enqueue(.init(status: 200, json: """ | 150 | stub.enqueue(.init(status: 200, json: """ |
gitbayUITests/LiveSmokeUITests.swift +56 −9
| @@ -66,6 +66,8 @@ final class LiveSmokeUITests: XCTestCase { | |||
| 66 | /// outcome we want anyway. | 66 | /// outcome we want anyway. |
| 67 | private func deleteScratchRepo() { | 67 | private func deleteScratchRepo() { |
| 68 | _ = runCommand(["repo", "delete", Self.scratchRepo, "--yes"]) | 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 | /// The issue the editing flow works on. It is a fixture, not a real | 73 | /// The issue the editing flow works on. It is a fixture, not a real |
| @@ -433,9 +435,9 @@ extension LiveSmokeUITests { | |||
| 433 | // lazy, so find the new repo through the filter. | 435 | // lazy, so find the new repo through the filter. |
| 434 | XCTAssertTrue(waitForDisappearance(pathField, timeout: 15), | 436 | XCTAssertTrue(waitForDisappearance(pathField, timeout: 15), |
| 435 | "create sheet did not dismiss") | 437 | "create sheet did not dismiss") |
| 436 | let search = app.searchFields.firstMatch | 438 | let createSearch = app.searchFields.firstMatch |
| 437 | XCTAssertTrue(search.waitForExistence(timeout: 10)) | 439 | XCTAssertTrue(createSearch.waitForExistence(timeout: 10)) |
| 438 | focusAndType(search, "ui-smoke") | 440 | focusAndType(createSearch, "ui-smoke") |
| 439 | XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch | 441 | XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch |
| 440 | .waitForExistence(timeout: 15), "created repo not in the list") | 442 | .waitForExistence(timeout: 15), "created repo not in the list") |
| 441 | 443 | ||
| @@ -482,13 +484,53 @@ extension LiveSmokeUITests { | |||
| 482 | confirmHookRemove.tap() | 484 | confirmHookRemove.tap() |
| 483 | XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed") | 485 | XCTAssertTrue(waitForDisappearance(hookRow, timeout: 15), "webhook not removed") |
| 484 | back() // webhooks -> settings | 486 | back() // webhooks -> settings |
| 485 | back() // settings -> repo | 487 | |
| 486 | back() // repo -> list | 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 | // --- pin / unpin round-trip on krz/gitbay-ios --- | 527 | // --- pin / unpin round-trip on krz/gitbay-ios --- |
| 489 | // Reuse the open search to get there. | 528 | // Start the list fresh; the rename left the stack several deep. |
| 490 | let clear = search.buttons.firstMatch | 529 | app.terminate() |
| 491 | if clear.exists { clear.tap() } | 530 | app.launch() |
| 531 | selectTab("Repositories") | ||
| 532 | let search = app.searchFields.firstMatch | ||
| 533 | XCTAssertTrue(search.waitForExistence(timeout: 10)) | ||
| 492 | focusAndType(search, "krz/gitbay-ios") | 534 | focusAndType(search, "krz/gitbay-ios") |
| 493 | let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch | 535 | let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch |
| 494 | XCTAssertTrue(repoRow.waitForExistence(timeout: 15)) | 536 | XCTAssertTrue(repoRow.waitForExistence(timeout: 15)) |
| @@ -543,7 +585,8 @@ extension LiveSmokeUITests { | |||
| 543 | // --- settings: topic and merge-rule round-trips --- | 585 | // --- settings: topic and merge-rule round-trips --- |
| 544 | // The links section can sit below the README fold, and the row's | 586 | // The links section can sit below the README fold, and the row's |
| 545 | // label is not always exposed as a static text. | 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 | XCTAssertTrue(scrollTo(settingsLink), "settings link not reachable") | 590 | XCTAssertTrue(scrollTo(settingsLink), "settings link not reachable") |
| 548 | settingsLink.tap() | 591 | settingsLink.tap() |
| 549 | let addTopic = app.descendants(matching: .any) | 592 | let addTopic = app.descendants(matching: .any) |
| @@ -633,6 +676,9 @@ extension LiveSmokeUITests { | |||
| 633 | 676 | ||
| 634 | 677 | ||
| 635 | back() // settings -> repo | 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 | // --- nothing to trigger without a job file --- | 683 | // --- nothing to trigger without a job file --- |
| 638 | // This repo has no .gitbay/ci.yml, so `build jobs` returns none | 684 | // This repo has no .gitbay/ci.yml, so `build jobs` returns none |
| @@ -649,6 +695,7 @@ extension LiveSmokeUITests { | |||
| 649 | .firstMatch | 695 | .firstMatch |
| 650 | XCTAssertFalse(anyJob.waitForExistence(timeout: 3), | 696 | XCTAssertFalse(anyJob.waitForExistence(timeout: 3), |
| 651 | "a repo with no job file offered a job to trigger") | 697 | "a repo with no job file offered a job to trigger") |
| 698 | |||
| 652 | } | 699 | } |
| 653 | 700 | ||
| 654 | private func waitForValue(_ element: XCUIElement, _ value: String, | 701 | private func waitForValue(_ element: XCUIElement, _ value: String, |