Commit 6d46dd6ba2
Unsigned
Layout: unified · split
gitbay/Repos/RepoCreateViewModel.swift +20 −4
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import Observation |
| 3 | 3 | |
| 4 | /// `repo create <owner/name> [--private]`. | |
| 4 | /// `repo create <owner/name> [--private]` and | |
| 5 | /// `repo import <owner/name> --from <url> [--private] [--token-stdin]`. | |
| 5 | 6 | @Observable |
| 6 | 7 | @MainActor |
| 7 | 8 | final class RepoCreateViewModel { |
| @@ -21,13 +22,28 @@ final class RepoCreateViewModel { | ||
| 21 | 22 | |
| 22 | 23 | /// Returns the created repo's path, or nil with `errorMessage` set. |
| 23 | 24 | func create(path: String, isPrivate: Bool) async -> String? { |
| 25 | var argv = ["repo", "create", path.trimmingCharacters(in: .whitespaces)] | |
| 26 | if isPrivate { argv.append("--private") } | |
| 27 | return await run(argv, stdin: nil) | |
| 28 | } | |
| 29 | ||
| 30 | /// The token, when there is one, travels on stdin and never in the | |
| 31 | /// URL: the server refuses credentials embedded in `--from`. | |
| 32 | func importRepo(path: String, from: String, isPrivate: Bool, token: String) async -> String? { | |
| 33 | var argv = ["repo", "import", path.trimmingCharacters(in: .whitespaces), | |
| 34 | "--from", from.trimmingCharacters(in: .whitespaces)] | |
| 35 | if isPrivate { argv.append("--private") } | |
| 36 | let token = token.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 37 | if !token.isEmpty { argv.append("--token-stdin") } | |
| 38 | return await run(argv, stdin: token.isEmpty ? nil : token + "\n") | |
| 39 | } | |
| 40 | ||
| 41 | private func run(_ argv: [String], stdin: String?) async -> String? { | |
| 24 | 42 | working = true |
| 25 | 43 | errorMessage = nil |
| 26 | 44 | defer { working = false } |
| 27 | 45 | do { |
| 28 | var argv = ["repo", "create", path.trimmingCharacters(in: .whitespaces)] | |
| 29 | if isPrivate { argv.append("--private") } | |
| 30 | let created = try await client.run(argv, as: Created.self) | |
| 46 | let created = try await client.run(argv, stdin: stdin, as: Created.self) | |
| 31 | 47 | return created?.path |
| 32 | 48 | } catch let error as GitbayError { |
| 33 | 49 | errorMessage = error.userFacingMessage |
gitbay/Views/Repos/RepoListView.swift +97 −2
| @@ -6,6 +6,7 @@ struct RepoListView: View { | ||
| 6 | 6 | @State private var model: RepoListViewModel |
| 7 | 7 | @State private var createModel: RepoCreateViewModel |
| 8 | 8 | @State private var composing = false |
| 9 | @State private var importing = false | |
| 9 | 10 | |
| 10 | 11 | init(client: GitbayClient) { |
| 11 | 12 | _model = State(initialValue: RepoListViewModel(client: client)) |
| @@ -34,8 +35,9 @@ struct RepoListView: View { | ||
| 34 | 35 | .accessibilityIdentifier("repo-bookmarks-button") |
| 35 | 36 | } |
| 36 | 37 | ToolbarItem(placement: .topBarTrailing) { |
| 37 | Button { | |
| 38 | composing = true | |
| 38 | Menu { | |
| 39 | Button("New Repository", systemImage: "plus") { composing = true } | |
| 40 | Button("Import from a Remote", systemImage: "square.and.arrow.down") { importing = true } | |
| 39 | 41 | } label: { |
| 40 | 42 | Image(systemName: "plus") |
| 41 | 43 | } |
| @@ -51,6 +53,15 @@ struct RepoListView: View { | ||
| 51 | 53 | Task { await model.load() } |
| 52 | 54 | } |
| 53 | 55 | } |
| 56 | .sheet(isPresented: $importing) { | |
| 57 | RepoImportSheet( | |
| 58 | model: createModel, | |
| 59 | ownerPrefix: session.current?.username ?? "" | |
| 60 | ) { | |
| 61 | importing = false | |
| 62 | Task { await model.load() } | |
| 63 | } | |
| 64 | } | |
| 54 | 65 | .task { await model.load() } |
| 55 | 66 | .refreshable { await model.load() } |
| 56 | 67 | } |
| @@ -124,6 +135,90 @@ struct RepoCreateSheet: View { | ||
| 124 | 135 | } |
| 125 | 136 | } |
| 126 | 137 | |
| 138 | /// `repo import <owner/name> --from <url> [--private] [--token-stdin]`. | |
| 139 | /// A token for a private source goes over stdin; the command refuses one | |
| 140 | /// embedded in the URL. | |
| 141 | struct RepoImportSheet: View { | |
| 142 | ||
| 143 | let model: RepoCreateViewModel | |
| 144 | let ownerPrefix: String | |
| 145 | let onImported: () -> Void | |
| 146 | ||
| 147 | @Environment(\.dismiss) private var dismiss | |
| 148 | @State private var path = "" | |
| 149 | @State private var from = "" | |
| 150 | @State private var isPrivate = false | |
| 151 | @State private var token = "" | |
| 152 | @State private var seeded = false | |
| 153 | ||
| 154 | private var pathLooksRight: Bool { | |
| 155 | path.contains("/") && !path.hasSuffix("/") && !path.hasPrefix("/") | |
| 156 | } | |
| 157 | ||
| 158 | var body: some View { | |
| 159 | NavigationStack { | |
| 160 | Form { | |
| 161 | Section { | |
| 162 | TextField("owner/name", text: $path) | |
| 163 | .autocorrectionDisabled() | |
| 164 | .textInputAutocapitalization(.never) | |
| 165 | .accessibilityIdentifier("repo-import-path") | |
| 166 | TextField("https://…/repo.git", text: $from) | |
| 167 | .autocorrectionDisabled() | |
| 168 | .textInputAutocapitalization(.never) | |
| 169 | .keyboardType(.URL) | |
| 170 | .accessibilityIdentifier("repo-import-from") | |
| 171 | } footer: { | |
| 172 | Text("The source is cloned once; it is not kept as a mirror.") | |
| 173 | } | |
| 174 | Section { | |
| 175 | Toggle("Private", isOn: $isPrivate) | |
| 176 | SecureField("Token (private source)", text: $token) | |
| 177 | .autocorrectionDisabled() | |
| 178 | .textInputAutocapitalization(.never) | |
| 179 | } footer: { | |
| 180 | Text("A token is sent once to clone and is not stored.") | |
| 181 | } | |
| 182 | if let error = model.errorMessage { | |
| 183 | Section { | |
| 184 | GBNotice(error) | |
| 185 | } | |
| 186 | } | |
| 187 | } | |
| 188 | .navigationTitle("Import Repository") | |
| 189 | .navigationBarTitleDisplayMode(.inline) | |
| 190 | .toolbar { | |
| 191 | ToolbarItem(placement: .cancellationAction) { | |
| 192 | Button("Cancel") { dismiss() } | |
| 193 | } | |
| 194 | ToolbarItem(placement: .confirmationAction) { | |
| 195 | if model.working { | |
| 196 | ProgressView() | |
| 197 | } else { | |
| 198 | Button("Import") { | |
| 199 | Task { | |
| 200 | if await model.importRepo( | |
| 201 | path: path, from: from, isPrivate: isPrivate, token: token) != nil { | |
| 202 | onImported() | |
| 203 | } | |
| 204 | } | |
| 205 | } | |
| 206 | .disabled(!pathLooksRight || from.trimmingCharacters(in: .whitespaces).isEmpty) | |
| 207 | .accessibilityIdentifier("repo-import-submit") | |
| 208 | } | |
| 209 | } | |
| 210 | } | |
| 211 | .interactiveDismissDisabled(model.working) | |
| 212 | .onAppear { | |
| 213 | if !seeded { | |
| 214 | path = ownerPrefix.isEmpty ? "" : ownerPrefix + "/" | |
| 215 | seeded = true | |
| 216 | } | |
| 217 | } | |
| 218 | } | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 127 | 222 | struct RepoRow: View { |
| 128 | 223 | let repo: RepoSummary |
| 129 | 224 | |
gitbayTests/RepoManagementTests.swift +31
| @@ -262,6 +262,37 @@ struct RepoCreateViewModelTests { | ||
| 262 | 262 | #expect(try argvOf(try #require(stub.seen.first)) == ["repo", "create", "cmc/notes"]) |
| 263 | 263 | } |
| 264 | 264 | |
| 265 | @Test func importSendsTheSourceAndTheTokenOverStdin() async throws { | |
| 266 | let (client, stub) = try makeClient() | |
| 267 | stub.enqueue(.init(status: 200, json: | |
| 268 | #"{"protocol_version":1,"data":{"path":"cmc/mirror","visibility":"private"},"exit_code":0}"#)) | |
| 269 | let model = RepoCreateViewModel(client: client) | |
| 270 | ||
| 271 | let path = await model.importRepo( | |
| 272 | path: " cmc/mirror ", from: "https://example.com/x/y.git", isPrivate: true, token: "s3cret") | |
| 273 | ||
| 274 | #expect(path == "cmc/mirror") | |
| 275 | let write = try #require(stub.seen.first) | |
| 276 | #expect(try argvOf(write) == ["repo", "import", "cmc/mirror", | |
| 277 | "--from", "https://example.com/x/y.git", "--private", "--token-stdin"]) | |
| 278 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | |
| 279 | #expect(body["stdin"] as? String == "s3cret\n") | |
| 280 | } | |
| 281 | ||
| 282 | @Test func importWithoutATokenSendsNoStdin() async throws { | |
| 283 | let (client, stub) = try makeClient() | |
| 284 | stub.enqueue(.init(status: 200, json: | |
| 285 | #"{"protocol_version":1,"data":{"path":"cmc/mirror","visibility":"public"},"exit_code":0}"#)) | |
| 286 | let model = RepoCreateViewModel(client: client) | |
| 287 | ||
| 288 | _ = await model.importRepo(path: "cmc/mirror", from: "https://example.com/x/y.git", isPrivate: false, token: "") | |
| 289 | ||
| 290 | let write = try #require(stub.seen.first) | |
| 291 | #expect(try argvOf(write) == ["repo", "import", "cmc/mirror", "--from", "https://example.com/x/y.git"]) | |
| 292 | let body = try #require(try JSONSerialization.jsonObject(with: write.body) as? [String: Any]) | |
| 293 | #expect(body["stdin"] == nil) | |
| 294 | } | |
| 295 | ||
| 265 | 296 | @Test func aRefusalSurfaces() async throws { |
| 266 | 297 | let (client, stub) = try makeClient() |
| 267 | 298 | stub.enqueue(.init(status: 403, json: |
gitbayUITests/LiveSmokeUITests.swift +3
| @@ -406,6 +406,9 @@ extension LiveSmokeUITests { | ||
| 406 | 406 | selectTab("Repositories") |
| 407 | 407 | app.descendants(matching: .any).matching(identifier: "repo-create-button") |
| 408 | 408 | .firstMatch.tap() |
| 409 | let newRepo = app.buttons["New Repository"].firstMatch | |
| 410 | XCTAssertTrue(newRepo.waitForExistence(timeout: 5), "create menu did not open") | |
| 411 | newRepo.tap() | |
| 409 | 412 | let pathField = app.descendants(matching: .any) |
| 410 | 413 | .matching(identifier: "repo-create-path").firstMatch |
| 411 | 414 | XCTAssertTrue(pathField.waitForExistence(timeout: 5)) |