Commit 8046dd0f50
Verified · cmc
gitbay/ContentView.swift +2
| @@ -78,6 +78,8 @@ private struct RouteDestinations: ViewModifier { | ||
| 78 | 78 | RepoSettingsView(client: client, repo: repo) |
| 79 | 79 | case .grep(let repo): |
| 80 | 80 | GrepView(client: client, repo: repo) |
| 81 | case .blame(let repo, let path, let ref): | |
| 82 | BlameView(client: client, repo: repo, path: path, ref: ref) | |
| 81 | 83 | case .profile(let name): |
| 82 | 84 | ProfileView(client: client, name: name) |
| 83 | 85 | case .account: |
gitbay/Repos/BlameViewModel.swift added +102
| @@ -0,0 +1,102 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `repo blame` — one hunk per run of consecutive lines sharing a commit. | |
| 5 | nonisolated struct BlameListing: Decodable, Sendable, Hashable { | |
| 6 | let path: String | |
| 7 | let ref: String | |
| 8 | let file: String | |
| 9 | let from: Int | |
| 10 | let to: Int | |
| 11 | let totalLines: Int | |
| 12 | let hunks: [Hunk] | |
| 13 | ||
| 14 | enum CodingKeys: String, CodingKey { | |
| 15 | case path, ref, file, from, to, hunks | |
| 16 | case totalLines = "total_lines" | |
| 17 | } | |
| 18 | ||
| 19 | nonisolated struct Hunk: Decodable, Sendable, Hashable, Identifiable { | |
| 20 | let sha: String | |
| 21 | let authorName: String | |
| 22 | let authorEmail: String | |
| 23 | let date: Date | |
| 24 | let summary: String | |
| 25 | let startLine: Int | |
| 26 | let lines: [String] | |
| 27 | ||
| 28 | enum CodingKeys: String, CodingKey { | |
| 29 | case sha, summary, lines | |
| 30 | case authorName = "author_name" | |
| 31 | case authorEmail = "author_email" | |
| 32 | case date | |
| 33 | case startLine = "start_line" | |
| 34 | } | |
| 35 | ||
| 36 | var id: String { "\(sha):\(startLine)" } | |
| 37 | var shortSHA: String { String(sha.prefix(10)) } | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | /// Line attribution for one file, paged the way the server pages it. | |
| 42 | @Observable | |
| 43 | @MainActor | |
| 44 | final class BlameViewModel { | |
| 45 | ||
| 46 | private(set) var state: LoadState<BlameListing> = .loading | |
| 47 | private(set) var isLoadingMore = false | |
| 48 | /// Hunks accumulated across pages, in file order. | |
| 49 | private(set) var hunks: [BlameListing.Hunk] = [] | |
| 50 | ||
| 51 | private let client: GitbayClient | |
| 52 | let repoPath: String | |
| 53 | let filePath: String | |
| 54 | let ref: String? | |
| 55 | private var loadedThrough = 0 | |
| 56 | private var totalLines = 0 | |
| 57 | ||
| 58 | /// The server caps one call at its own page size; asking for more | |
| 59 | /// just gets clamped, so paging follows what it actually returned. | |
| 60 | init(client: GitbayClient, repoPath: String, filePath: String, ref: String? = nil) { | |
| 61 | self.client = client | |
| 62 | self.repoPath = repoPath | |
| 63 | self.filePath = filePath | |
| 64 | self.ref = ref | |
| 65 | } | |
| 66 | ||
| 67 | var hasMore: Bool { totalLines > 0 && loadedThrough < totalLines } | |
| 68 | ||
| 69 | var fileName: String { | |
| 70 | String(filePath.split(separator: "/").last ?? "") | |
| 71 | } | |
| 72 | ||
| 73 | func load() async { | |
| 74 | state = .loading | |
| 75 | hunks = [] | |
| 76 | loadedThrough = 0 | |
| 77 | totalLines = 0 | |
| 78 | await fetch(from: 1) | |
| 79 | } | |
| 80 | ||
| 81 | func loadMore() async { | |
| 82 | guard hasMore, !isLoadingMore else { return } | |
| 83 | isLoadingMore = true | |
| 84 | defer { isLoadingMore = false } | |
| 85 | await fetch(from: loadedThrough + 1) | |
| 86 | } | |
| 87 | ||
| 88 | private func fetch(from: Int) async { | |
| 89 | var argv = ["repo", "blame", repoPath, filePath, "--from", String(from)] | |
| 90 | if let ref { argv.append(contentsOf: ["--ref", ref]) } | |
| 91 | do { | |
| 92 | let page = try await client.read(argv, as: BlameListing.self) | |
| 93 | totalLines = page.totalLines | |
| 94 | loadedThrough = max(loadedThrough, page.to) | |
| 95 | hunks.append(contentsOf: page.hunks) | |
| 96 | state = .loaded(page) | |
| 97 | } catch { | |
| 98 | // A later page failing leaves what is on screen alone. | |
| 99 | if hunks.isEmpty { state = .from(error) } | |
| 100 | } | |
| 101 | } | |
| 102 | } | |
gitbay/Repos/FileViewModel.swift +34
| @@ -8,6 +8,8 @@ import Observation | ||
| 8 | 8 | final class FileViewModel { |
| 9 | 9 | |
| 10 | 10 | private(set) var state: LoadState<FileContent> = .loading |
| 11 | private(set) var actionError: String? | |
| 12 | private(set) var working = false | |
| 11 | 13 | |
| 12 | 14 | private let client: GitbayClient |
| 13 | 15 | let repoPath: String |
| @@ -34,4 +36,36 @@ final class FileViewModel { | ||
| 34 | 36 | state = .from(error) |
| 35 | 37 | } |
| 36 | 38 | } |
| 39 | ||
| 40 | /// The branch an edit commits to. `repo cat` echoes the ref it read, | |
| 41 | /// so editing writes back to whatever is on screen. | |
| 42 | var editableBranch: String? { | |
| 43 | guard let file = state.value, !file.binary, !file.isTruncated else { return nil } | |
| 44 | return file.ref | |
| 45 | } | |
| 46 | ||
| 47 | /// `repo commit-file` — the server refuses when the repository wants | |
| 48 | /// signed commits, when the account has no verified email, or when | |
| 49 | /// the repo is archived, and says which. | |
| 50 | func commit(content: String, message: String) async -> Bool { | |
| 51 | guard let branch = editableBranch else { return false } | |
| 52 | working = true | |
| 53 | actionError = nil | |
| 54 | defer { working = false } | |
| 55 | var argv = ["repo", "commit-file", repoPath, filePath, "--ref", branch, "--file", "-"] | |
| 56 | let trimmed = message.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 57 | if !trimmed.isEmpty { | |
| 58 | argv.append(contentsOf: ["--message", trimmed]) | |
| 59 | } | |
| 60 | do { | |
| 61 | try await client.run(argv, stdin: content) | |
| 62 | await load() | |
| 63 | return true | |
| 64 | } catch let error as GitbayError { | |
| 65 | actionError = error.userFacingMessage | |
| 66 | } catch { | |
| 67 | actionError = GitbayError.transport(error).userFacingMessage | |
| 68 | } | |
| 69 | return false | |
| 70 | } | |
| 37 | 71 | } |
gitbay/Views/Repos/BlameView.swift added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Who last touched each line, and why. Hunks share a commit, so the | |
| 4 | /// attribution shows once per run rather than once per line. | |
| 5 | struct BlameView: View { | |
| 6 | ||
| 7 | @State private var model: BlameViewModel | |
| 8 | ||
| 9 | init(client: GitbayClient, repo: String, path: String, ref: String?) { | |
| 10 | _model = State(initialValue: BlameViewModel( | |
| 11 | client: client, repoPath: repo, filePath: path, ref: ref | |
| 12 | )) | |
| 13 | } | |
| 14 | ||
| 15 | var body: some View { | |
| 16 | ZStack { | |
| 17 | Color.clear | |
| 18 | if !model.hunks.isEmpty { | |
| 19 | ScrollView([.horizontal, .vertical]) { | |
| 20 | VStack(alignment: .leading, spacing: 0) { | |
| 21 | ForEach(model.hunks) { hunk in | |
| 22 | HunkAttribution(hunk: hunk) | |
| 23 | ForEach(Array(hunk.lines.enumerated()), id: \.offset) { index, line in | |
| 24 | HStack(alignment: .top, spacing: 0) { | |
| 25 | Text(String(hunk.startLine + index)) | |
| 26 | .frame(width: 44, alignment: .trailing) | |
| 27 | .foregroundStyle(.tertiary) | |
| 28 | Text(line.isEmpty ? " " : line) | |
| 29 | .padding(.leading, 10) | |
| 30 | } | |
| 31 | .font(.gbMono(.caption2)) | |
| 32 | } | |
| 33 | } | |
| 34 | if model.hasMore { | |
| 35 | HStack { | |
| 36 | Spacer() | |
| 37 | ProgressView() | |
| 38 | Spacer() | |
| 39 | } | |
| 40 | .padding(.vertical, 12) | |
| 41 | .task(id: model.hunks.count) { await model.loadMore() } | |
| 42 | } | |
| 43 | } | |
| 44 | .padding(.vertical, 8) | |
| 45 | } | |
| 46 | } | |
| 47 | } | |
| 48 | .overlay { LoadStateOverlay(state: model.state) } | |
| 49 | .navigationTitle("Blame") | |
| 50 | .navigationBarTitleDisplayMode(.inline) | |
| 51 | .task { | |
| 52 | if model.hunks.isEmpty { await model.load() } | |
| 53 | } | |
| 54 | } | |
| 55 | } | |
| 56 | ||
| 57 | /// The commit a run of lines belongs to. | |
| 58 | private struct HunkAttribution: View { | |
| 59 | let hunk: BlameListing.Hunk | |
| 60 | ||
| 61 | var body: some View { | |
| 62 | HStack(spacing: 8) { | |
| 63 | Text(hunk.shortSHA) | |
| 64 | .font(.gbMono(.caption2)) | |
| 65 | .foregroundStyle(Color.gbAccent) | |
| 66 | Text(hunk.summary) | |
| 67 | .font(.gbSans(.caption2)) | |
| 68 | .lineLimit(1) | |
| 69 | Spacer(minLength: 12) | |
| 70 | Text(hunk.authorName) | |
| 71 | .font(.gbSans(.caption2)) | |
| 72 | .foregroundStyle(.secondary) | |
| 73 | .lineLimit(1) | |
| 74 | Text(hunk.date, format: .dateTime.year().month(.abbreviated).day()) | |
| 75 | .font(.gbSans(.caption2)) | |
| 76 | .foregroundStyle(.tertiary) | |
| 77 | } | |
| 78 | .padding(.horizontal, 10) | |
| 79 | .padding(.vertical, 4) | |
| 80 | .frame(minWidth: 360, alignment: .leading) | |
| 81 | .background(Color.gbFillSubtle) | |
| 82 | } | |
| 83 | } | |
gitbay/Views/Repos/FileEditSheet.swift added +68
| @@ -0,0 +1,68 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// Edit one file and commit it. The commit is unsigned — the server | |
| 4 | /// authors it — so a repository requiring signatures refuses, and the | |
| 5 | /// refusal is what the sheet shows. | |
| 6 | struct FileEditSheet: View { | |
| 7 | ||
| 8 | let model: FileViewModel | |
| 9 | let branch: String | |
| 10 | @Binding var content: String | |
| 11 | let onCommitted: () -> Void | |
| 12 | ||
| 13 | @Environment(\.dismiss) private var dismiss | |
| 14 | @State private var message = "" | |
| 15 | ||
| 16 | var body: some View { | |
| 17 | NavigationStack { | |
| 18 | Form { | |
| 19 | Section { | |
| 20 | TextEditor(text: $content) | |
| 21 | .font(.gbMono(.caption)) | |
| 22 | .frame(minHeight: 260) | |
| 23 | .autocorrectionDisabled() | |
| 24 | .textInputAutocapitalization(.never) | |
| 25 | .accessibilityIdentifier("file-edit-content") | |
| 26 | } header: { | |
| 27 | Text(model.fileName) | |
| 28 | } footer: { | |
| 29 | Text("Commits to \(branch) as an unsigned commit.") | |
| 30 | } | |
| 31 | Section("Message") { | |
| 32 | TextField("edit \(model.fileName)", text: $message) | |
| 33 | .autocorrectionDisabled() | |
| 34 | .accessibilityIdentifier("file-edit-message") | |
| 35 | } | |
| 36 | if let error = model.actionError { | |
| 37 | Section { | |
| 38 | Label(error, systemImage: "hand.raised") | |
| 39 | .foregroundStyle(Color.gbWarn) | |
| 40 | .font(.gbSans(.subheadline)) | |
| 41 | } | |
| 42 | } | |
| 43 | } | |
| 44 | .navigationTitle("Edit") | |
| 45 | .navigationBarTitleDisplayMode(.inline) | |
| 46 | .toolbar { | |
| 47 | ToolbarItem(placement: .cancellationAction) { | |
| 48 | Button("Cancel") { dismiss() } | |
| 49 | } | |
| 50 | ToolbarItem(placement: .confirmationAction) { | |
| 51 | if model.working { | |
| 52 | ProgressView() | |
| 53 | } else { | |
| 54 | Button("Commit") { | |
| 55 | Task { | |
| 56 | if await model.commit(content: content, message: message) { | |
| 57 | onCommitted() | |
| 58 | } | |
| 59 | } | |
| 60 | } | |
| 61 | .accessibilityIdentifier("file-edit-commit") | |
| 62 | } | |
| 63 | } | |
| 64 | } | |
| 65 | .interactiveDismissDisabled(model.working) | |
| 66 | } | |
| 67 | } | |
| 68 | } | |
gitbay/Views/Repos/FileView.swift +33
| @@ -4,6 +4,8 @@ struct FileView: View { | ||
| 4 | 4 | |
| 5 | 5 | @Environment(\.colorScheme) private var colorScheme |
| 6 | 6 | @State private var model: FileViewModel |
| 7 | @State private var editing = false | |
| 8 | @State private var draft = "" | |
| 7 | 9 | |
| 8 | 10 | init(client: GitbayClient, repo: String, path: String, ref: String?) { |
| 9 | 11 | _model = State(initialValue: FileViewModel( |
| @@ -22,6 +24,37 @@ struct FileView: View { | ||
| 22 | 24 | .overlay { LoadStateOverlay(state: model.state) } |
| 23 | 25 | .navigationTitle(model.fileName) |
| 24 | 26 | .navigationBarTitleDisplayMode(.inline) |
| 27 | .toolbar { | |
| 28 | ToolbarItem(placement: .topBarTrailing) { | |
| 29 | if model.state.value?.binary == false { | |
| 30 | Menu { | |
| 31 | NavigationLink(value: RepoRoute.blame( | |
| 32 | repo: model.repoPath, path: model.filePath, ref: model.ref | |
| 33 | )) { | |
| 34 | Label("Blame", systemImage: "person.crop.rectangle.stack") | |
| 35 | } | |
| 36 | if let branch = model.editableBranch { | |
| 37 | Button { | |
| 38 | draft = model.state.value?.content ?? "" | |
| 39 | editing = true | |
| 40 | } label: { | |
| 41 | Label("Edit on \(branch)", systemImage: "pencil") | |
| 42 | } | |
| 43 | } | |
| 44 | } label: { | |
| 45 | Image(systemName: "ellipsis.circle") | |
| 46 | } | |
| 47 | .accessibilityIdentifier("file-actions-menu") | |
| 48 | } | |
| 49 | } | |
| 50 | } | |
| 51 | .sheet(isPresented: $editing) { | |
| 52 | if let branch = model.editableBranch { | |
| 53 | FileEditSheet(model: model, branch: branch, content: $draft) { | |
| 54 | editing = false | |
| 55 | } | |
| 56 | } | |
| 57 | } | |
| 25 | 58 | .task { await model.load() } |
| 26 | 59 | } |
| 27 | 60 | |
gitbay/Views/Repos/RepoRoute.swift +1
| @@ -9,6 +9,7 @@ nonisolated enum RepoRoute: Hashable { | ||
| 9 | 9 | case log(repo: String) |
| 10 | 10 | case settings(repo: String) |
| 11 | 11 | case grep(repo: String) |
| 12 | case blame(repo: String, path: String, ref: String?) | |
| 12 | 13 | case profile(String) |
| 13 | 14 | case account |
| 14 | 15 | case addAccount |
gitbayTests/BlameEditTests.swift added +177
| @@ -0,0 +1,177 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | private func makeClient() throws -> (GitbayClient, StubProtocol.Box) { | |
| 6 | let box = StubProtocol.box() | |
| 7 | let client = GitbayClient( | |
| 8 | instance: try GitbayInstance(url: "https://gitbay.org"), | |
| 9 | token: "test-token", | |
| 10 | session: box.session() | |
| 11 | ) | |
| 12 | return (client, box) | |
| 13 | } | |
| 14 | ||
| 15 | private func argvOf(_ seen: StubProtocol.Seen) throws -> ([String], String?) { | |
| 16 | let body = try #require(try JSONSerialization.jsonObject(with: seen.body) as? [String: Any]) | |
| 17 | return (try #require(body["argv"] as? [String]), body["stdin"] as? String) | |
| 18 | } | |
| 19 | ||
| 20 | private func blamePage(from: Int, to: Int, total: Int, startLine: Int, lines: [String]) -> String { | |
| 21 | let joined = lines.map { "\"\($0)\"" }.joined(separator: ",") | |
| 22 | return """ | |
| 23 | {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"go.mod",\ | |
| 24 | "from":\(from),"to":\(to),"total_lines":\(total),"hunks":[\ | |
| 25 | {"sha":"24c0aad9bf75f925497b0f6de2c8e831e67eaac9","author_name":"Christian Cleberg",\ | |
| 26 | "author_email":"hello@cleberg.net","date":"2026-08-24T00:14:19Z",\ | |
| 27 | "summary":"rename: forge -> gitbay","start_line":\(startLine),"lines":[\(joined)]}\ | |
| 28 | ]},"exit_code":0} | |
| 29 | """ | |
| 30 | } | |
| 31 | ||
| 32 | @MainActor | |
| 33 | struct BlameViewModelTests { | |
| 34 | ||
| 35 | @Test func loadsTheFirstPageAndDecodesHunks() async throws { | |
| 36 | let (client, stub) = try makeClient() | |
| 37 | stub.enqueue(.init(status: 200, json: | |
| 38 | blamePage(from: 1, to: 2, total: 2, startLine: 1, lines: ["module gitbay.org/gitbay", ""]))) | |
| 39 | let model = BlameViewModel(client: client, repoPath: "krz/gitbay", filePath: "go.mod") | |
| 40 | ||
| 41 | await model.load() | |
| 42 | ||
| 43 | #expect(model.hunks.count == 1) | |
| 44 | #expect(model.hunks[0].shortSHA == "24c0aad9bf") | |
| 45 | #expect(model.hunks[0].authorName == "Christian Cleberg") | |
| 46 | #expect(!model.hasMore) | |
| 47 | let seen = try #require(stub.seen.first) | |
| 48 | #expect(seen.url.query() == | |
| 49 | "argv=repo&argv=blame&argv=krz/gitbay&argv=go.mod&argv=--from&argv=1") | |
| 50 | } | |
| 51 | ||
| 52 | @Test func pagesFromWhereTheServerStopped() async throws { | |
| 53 | let (client, stub) = try makeClient() | |
| 54 | // The server clamps its own span; paging follows what came back. | |
| 55 | stub.enqueue(.init(status: 200, json: | |
| 56 | blamePage(from: 1, to: 1000, total: 1500, startLine: 1, lines: ["a"]))) | |
| 57 | stub.enqueue(.init(status: 200, json: | |
| 58 | blamePage(from: 1001, to: 1500, total: 1500, startLine: 1001, lines: ["b"]))) | |
| 59 | let model = BlameViewModel(client: client, repoPath: "krz/gitbay", filePath: "big.go") | |
| 60 | ||
| 61 | await model.load() | |
| 62 | #expect(model.hasMore) | |
| 63 | ||
| 64 | await model.loadMore() | |
| 65 | #expect(model.hunks.count == 2) | |
| 66 | #expect(!model.hasMore) | |
| 67 | #expect(stub.seen[1].url.query()?.contains("argv=--from&argv=1001") == true) | |
| 68 | ||
| 69 | // Nothing further to ask for. | |
| 70 | await model.loadMore() | |
| 71 | #expect(stub.seen.count == 2) | |
| 72 | } | |
| 73 | ||
| 74 | @Test func refIsForwardedWhenSet() async throws { | |
| 75 | let (client, stub) = try makeClient() | |
| 76 | stub.enqueue(.init(status: 200, json: | |
| 77 | blamePage(from: 1, to: 1, total: 1, startLine: 1, lines: ["x"]))) | |
| 78 | let model = BlameViewModel( | |
| 79 | client: client, repoPath: "krz/gitbay", filePath: "go.mod", ref: "dev") | |
| 80 | ||
| 81 | await model.load() | |
| 82 | ||
| 83 | #expect(stub.seen.first?.url.query()?.hasSuffix("argv=--ref&argv=dev") == true) | |
| 84 | } | |
| 85 | ||
| 86 | @Test func aBinaryFileRefusalIsTheScreensState() async throws { | |
| 87 | let (client, stub) = try makeClient() | |
| 88 | stub.enqueue(.init(status: 400, json: | |
| 89 | #"{"protocol_version":1,"error":"logo.png is binary; there is nothing to attribute","exit_code":2}"#)) | |
| 90 | let model = BlameViewModel(client: client, repoPath: "krz/gitbay", filePath: "logo.png") | |
| 91 | ||
| 92 | await model.load() | |
| 93 | ||
| 94 | guard case .failed = model.state else { | |
| 95 | Issue.record("expected .failed, got \(model.state)") | |
| 96 | return | |
| 97 | } | |
| 98 | } | |
| 99 | } | |
| 100 | ||
| 101 | @MainActor | |
| 102 | struct FileEditTests { | |
| 103 | ||
| 104 | private let textFile = """ | |
| 105 | {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"notes.txt",\ | |
| 106 | "size":5,"binary":false,"content":"hello"},"exit_code":0} | |
| 107 | """ | |
| 108 | ||
| 109 | private func loadedModel(_ json: String? = nil) async throws -> (FileViewModel, StubProtocol.Box) { | |
| 110 | let (client, stub) = try makeClient() | |
| 111 | stub.enqueue(.init(status: 200, json: json ?? textFile, match: "argv=cat")) | |
| 112 | let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "notes.txt") | |
| 113 | await model.load() | |
| 114 | return (model, stub) | |
| 115 | } | |
| 116 | ||
| 117 | @Test func aTextFileIsEditableOnTheRefItWasReadFrom() async throws { | |
| 118 | let (model, _) = try await loadedModel() | |
| 119 | #expect(model.editableBranch == "main") | |
| 120 | } | |
| 121 | ||
| 122 | @Test func binaryAndTruncatedFilesAreNotEditable() async throws { | |
| 123 | let (binary, _) = try await loadedModel(""" | |
| 124 | {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"logo.png",\ | |
| 125 | "size":4,"binary":true,"base64":"iVBORw=="},"exit_code":0} | |
| 126 | """) | |
| 127 | #expect(binary.editableBranch == nil) | |
| 128 | ||
| 129 | let (truncated, _) = try await loadedModel(""" | |
| 130 | {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"big.log",\ | |
| 131 | "size":10,"binary":false,"truncated":true,"content":"partial..."},"exit_code":0} | |
| 132 | """) | |
| 133 | // Committing a truncated read would delete the rest of the file. | |
| 134 | #expect(truncated.editableBranch == nil) | |
| 135 | } | |
| 136 | ||
| 137 | @Test func commitSendsContentOverStdinWithTheBranch() async throws { | |
| 138 | let (model, stub) = try await loadedModel() | |
| 139 | stub.enqueue(.init(status: 200, json: | |
| 140 | #"{"protocol_version":1,"data":{"sha":"abc"},"exit_code":0}"#, match: "cmd")) | |
| 141 | stub.enqueue(.init(status: 200, json: textFile, match: "argv=cat")) | |
| 142 | ||
| 143 | let ok = await model.commit(content: "goodbye\n", message: " update notes ") | |
| 144 | ||
| 145 | #expect(ok) | |
| 146 | let (argv, stdin) = try argvOf(try #require(stub.seen.first { $0.method == "POST" })) | |
| 147 | #expect(argv == ["repo", "commit-file", "krz/gitbay", "notes.txt", | |
| 148 | "--ref", "main", "--file", "-", "--message", "update notes"]) | |
| 149 | #expect(stdin == "goodbye\n") | |
| 150 | } | |
| 151 | ||
| 152 | @Test func anEmptyMessageLetsTheServerNameTheCommit() async throws { | |
| 153 | let (model, stub) = try await loadedModel() | |
| 154 | stub.enqueue(.init(status: 200, json: | |
| 155 | #"{"protocol_version":1,"data":{"sha":"abc"},"exit_code":0}"#, match: "cmd")) | |
| 156 | stub.enqueue(.init(status: 200, json: textFile, match: "argv=cat")) | |
| 157 | ||
| 158 | _ = await model.commit(content: "x", message: " ") | |
| 159 | ||
| 160 | let (argv, _) = try argvOf(try #require(stub.seen.first { $0.method == "POST" })) | |
| 161 | #expect(!argv.contains("--message")) | |
| 162 | } | |
| 163 | ||
| 164 | @Test func aSignedCommitPolicyRefusalSurfacesVerbatim() async throws { | |
| 165 | let (model, stub) = try await loadedModel() | |
| 166 | stub.enqueue(.init(status: 403, json: | |
| 167 | #"{"protocol_version":1,"error":"krz/gitbay requires signed commits; this writes an unsigned one — push a signed commit instead","exit_code":4}"#, | |
| 168 | match: "cmd")) | |
| 169 | ||
| 170 | let ok = await model.commit(content: "x", message: "") | |
| 171 | ||
| 172 | #expect(!ok) | |
| 173 | #expect(model.actionError?.contains("requires signed commits") == true) | |
| 174 | // The file is still on screen. | |
| 175 | #expect(model.state.value != nil) | |
| 176 | } | |
| 177 | } | |
gitbayUITests/LiveSmokeUITests.swift +81
| @@ -163,6 +163,25 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 163 | 163 | XCTFail("\(name) tab did not come to front", file: file, line: line) |
| 164 | 164 | } |
| 165 | 165 | |
| 166 | /// Empty a text field before typing. Search fields keep their query | |
| 167 | /// across navigation, and the clear button is not reliably | |
| 168 | /// addressable, so this deletes character by character. | |
| 169 | func clearAndType(_ element: XCUIElement, _ text: String, | |
| 170 | file: StaticString = #filePath, line: UInt = #line) { | |
| 171 | element.tap() | |
| 172 | if !app.keyboards.firstMatch.waitForExistence(timeout: 5) { | |
| 173 | element.tap() | |
| 174 | XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5), | |
| 175 | "keyboard never appeared", file: file, line: line) | |
| 176 | } | |
| 177 | if let existing = element.value as? String, | |
| 178 | existing != element.placeholderValue, !existing.isEmpty { | |
| 179 | element.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, | |
| 180 | count: existing.count)) | |
| 181 | } | |
| 182 | element.typeText(text) | |
| 183 | } | |
| 184 | ||
| 166 | 185 | /// Tap a field and type into it, surviving the focus race: a tap can |
| 167 | 186 | /// land before the field is ready, and the keystrokes go nowhere. |
| 168 | 187 | func focusAndType(_ element: XCUIElement, _ text: String, |
| @@ -548,3 +567,65 @@ extension LiveSmokeUITests { | ||
| 548 | 567 | add(attachment) |
| 549 | 568 | } |
| 550 | 569 | } |
| 570 | ||
| 571 | extension LiveSmokeUITests { | |
| 572 | ||
| 573 | /// Blame and file editing — the two capabilities that were web-only | |
| 574 | /// until krz/gitbay#42 and #43 made them commands. Blame is read-only | |
| 575 | /// on a real repo; the edit writes to a scratch repo the runner | |
| 576 | /// creates and deletes over SSH. | |
| 577 | func testBlameAndEditFlows() throws { | |
| 578 | // --- blame on a real file --- | |
| 579 | openRepo("krz/gitbay") | |
| 580 | app.staticTexts["Files"].firstMatch.tap() | |
| 581 | let goMod = app.staticTexts["go.mod"].firstMatch | |
| 582 | XCTAssertTrue(goMod.waitForExistence(timeout: 15), "go.mod not in the tree") | |
| 583 | goMod.tap() | |
| 584 | XCTAssertTrue(app.descendants(matching: .any).matching(identifier: "file-actions-menu") | |
| 585 | .firstMatch.waitForExistence(timeout: 15), "file screen did not open") | |
| 586 | app.descendants(matching: .any).matching(identifier: "file-actions-menu") | |
| 587 | .firstMatch.tap() | |
| 588 | app.buttons["Blame"].firstMatch.tap() | |
| 589 | // Attribution shows the commit subject beside the lines. | |
| 590 | XCTAssertTrue(app.staticTexts | |
| 591 | .containing(NSPredicate(format: "label CONTAINS 'gitbay'")).firstMatch | |
| 592 | .waitForExistence(timeout: 20), "blame rendered no attribution") | |
| 593 | ||
| 594 | // --- edit on the scratch repo --- | |
| 595 | // Relaunch rather than reuse the list: `.searchable` keeps the | |
| 596 | // previous query, and clearing it from the harness is unreliable. | |
| 597 | app.terminate() | |
| 598 | app.launch() | |
| 599 | openRepo("cmc/ui-smoke-edit") | |
| 600 | app.staticTexts["Files"].firstMatch.tap() | |
| 601 | let notes = app.staticTexts["notes.txt"].firstMatch | |
| 602 | XCTAssertTrue(notes.waitForExistence(timeout: 15), "notes.txt not in the tree") | |
| 603 | notes.tap() | |
| 604 | ||
| 605 | let menu = app.descendants(matching: .any) | |
| 606 | .matching(identifier: "file-actions-menu").firstMatch | |
| 607 | XCTAssertTrue(menu.waitForExistence(timeout: 15)) | |
| 608 | menu.tap() | |
| 609 | let edit = app.buttons | |
| 610 | .containing(NSPredicate(format: "label BEGINSWITH 'Edit on'")).firstMatch | |
| 611 | XCTAssertTrue(edit.waitForExistence(timeout: 5), "edit action missing") | |
| 612 | edit.tap() | |
| 613 | ||
| 614 | let content = app.descendants(matching: .any) | |
| 615 | .matching(identifier: "file-edit-content").firstMatch | |
| 616 | XCTAssertTrue(content.waitForExistence(timeout: 10), "edit sheet did not open") | |
| 617 | focusAndType(content, " edited from the app") | |
| 618 | ||
| 619 | let message = app.descendants(matching: .any) | |
| 620 | .matching(identifier: "file-edit-message").firstMatch | |
| 621 | focusAndType(message, "edit from ios") | |
| 622 | ||
| 623 | app.descendants(matching: .any).matching(identifier: "file-edit-commit") | |
| 624 | .firstMatch.tap() | |
| 625 | XCTAssertTrue(waitForDisappearance(content, timeout: 20), "commit did not dismiss") | |
| 626 | // The reloaded file shows the committed text. | |
| 627 | XCTAssertTrue(app.staticTexts | |
| 628 | .containing(NSPredicate(format: "label CONTAINS 'edited from the app'")).firstMatch | |
| 629 | .waitForExistence(timeout: 20), "edit not reflected after commit") | |
| 630 | } | |
| 631 | } | |