Commit d2243f1bfc
Verified · cmc
gitbay/MRs/UnifiedDiff.swift +23
| @@ -49,12 +49,35 @@ nonisolated struct UnifiedDiff: Sendable, Hashable { | ||
| 49 | 49 | let text: String |
| 50 | 50 | |
| 51 | 51 | var id: String { "\(oldNumber ?? 0):\(newNumber ?? 0):\(text)" } |
| 52 | ||
| 53 | /// Whether a review thread hangs off this line. `side` picks | |
| 54 | /// which numbering the thread was recorded against; a stale | |
| 55 | /// thread anchors nowhere, because the head it referenced is | |
| 56 | /// gone. | |
| 57 | func anchors(_ thread: ReviewThread, in file: File) -> Bool { | |
| 58 | guard !thread.stale, thread.path == file.displayPath else { return false } | |
| 59 | return switch thread.side { | |
| 60 | case "old": oldNumber == Int(thread.line) | |
| 61 | default: newNumber == Int(thread.line) | |
| 62 | } | |
| 63 | } | |
| 52 | 64 | } |
| 53 | 65 | |
| 54 | 66 | /// Total across files, for the summary row. |
| 55 | 67 | var additions: Int { files.reduce(0) { $0 + $1.additions } } |
| 56 | 68 | var deletions: Int { files.reduce(0) { $0 + $1.deletions } } |
| 57 | 69 | |
| 70 | /// Whether this diff still shows the line a thread was left on. A | |
| 71 | /// thread the diff has moved past renders in its own section rather | |
| 72 | /// than vanishing. | |
| 73 | func anchors(_ thread: ReviewThread) -> Bool { | |
| 74 | files.contains { file in | |
| 75 | file.hunks.contains { hunk in | |
| 76 | hunk.lines.contains { $0.anchors(thread, in: file) } | |
| 77 | } | |
| 78 | } | |
| 79 | } | |
| 80 | ||
| 58 | 81 | // MARK: - Parsing |
| 59 | 82 | |
| 60 | 83 | static func parse(_ text: String) -> UnifiedDiff { |
gitbay/Repos/RepoDetailViewModel.swift +2
| @@ -10,6 +10,7 @@ final class RepoDetailViewModel { | ||
| 10 | 10 | private(set) var state: LoadState<RepoDetail> = .loading |
| 11 | 11 | /// README markdown, when the root tree has one. Absent is normal. |
| 12 | 12 | private(set) var readme: String? |
| 13 | private(set) var readmeName: String? | |
| 13 | 14 | /// Whether this repo is on the account's dashboard. nil until known — |
| 14 | 15 | /// pin state only exists in the dashboard aggregate. |
| 15 | 16 | private(set) var isPinned: Bool? |
| @@ -83,6 +84,7 @@ final class RepoDetailViewModel { | ||
| 83 | 84 | guard let file = try? await client.read( |
| 84 | 85 | ["repo", "cat", path, candidate.name], as: FileContent.self |
| 85 | 86 | ), !file.binary, let content = file.content else { return } |
| 87 | readmeName = candidate.name | |
| 86 | 88 | readme = content |
| 87 | 89 | } |
| 88 | 90 | } |
gitbay/Repos/RepoModels.swift +14
| @@ -38,6 +38,20 @@ nonisolated struct RepoDetail: Decodable, Sendable, Hashable { | ||
| 38 | 38 | var isArchived: Bool { archived ?? false } |
| 39 | 39 | } |
| 40 | 40 | |
| 41 | /// The shape a `repo refs <owner/name>` command would return. No such | |
| 42 | /// command exists yet — krz/gitbay#45 proposes it, and until it lands | |
| 43 | /// branch names are typed, not picked. Unused on purpose. | |
| 44 | nonisolated struct RepoRefs: Decodable, Sendable, Hashable { | |
| 45 | let branches: [RepoRef] | |
| 46 | let tags: [RepoRef] | |
| 47 | } | |
| 48 | ||
| 49 | nonisolated struct RepoRef: Decodable, Sendable, Hashable, Identifiable { | |
| 50 | let name: String | |
| 51 | let sha: String | |
| 52 | var id: String { name } | |
| 53 | } | |
| 54 | ||
| 41 | 55 | /// `repo tree` — one directory listing. |
| 42 | 56 | nonisolated struct TreeListing: Decodable, Sendable, Hashable { |
| 43 | 57 | let path: String |
gitbay/Views/MRs/DiffView.swift +30 −20
| @@ -4,50 +4,52 @@ import SwiftUI | ||
| 4 | 4 | /// text so the screen is reachable by value from anywhere. |
| 5 | 5 | struct DiffView: View { |
| 6 | 6 | |
| 7 | private let client: GitbayClient | |
| 8 | private let repo: String | |
| 9 | private let number: Int64 | |
| 10 | @State private var state: LoadState<UnifiedDiff> = .loading | |
| 7 | @State private var model: MRDetailViewModel | |
| 11 | 8 | |
| 12 | 9 | init(client: GitbayClient, repo: String, number: Int64) { |
| 13 | self.client = client | |
| 14 | self.repo = repo | |
| 15 | self.number = number | |
| 10 | _model = State(initialValue: MRDetailViewModel( | |
| 11 | client: client, repoPath: repo, number: number | |
| 12 | )) | |
| 16 | 13 | } |
| 17 | 14 | |
| 18 | 15 | var body: some View { |
| 19 | 16 | ZStack { |
| 20 | 17 | Color.clear |
| 21 | if let diff = state.value, !diff.files.isEmpty { | |
| 18 | if let diff = model.diff, !diff.files.isEmpty { | |
| 22 | 19 | List { |
| 23 | 20 | ForEach(diff.files) { file in |
| 24 | FileDiffSection(file: file) | |
| 21 | FileDiffSection(file: file, model: model) | |
| 22 | } | |
| 23 | let detached = model.threads.filter { thread in | |
| 24 | thread.stale || !diff.anchors(thread) | |
| 25 | } | |
| 26 | if !detached.isEmpty { | |
| 27 | Section("Threads on earlier revisions") { | |
| 28 | ForEach(detached) { thread in | |
| 29 | ReviewThreadView(thread: thread, model: model) | |
| 30 | } | |
| 31 | } | |
| 25 | 32 | } |
| 26 | 33 | } |
| 27 | 34 | .listStyle(.plain) |
| 28 | } else if case .loaded = state { | |
| 35 | } else if model.state.value != nil { | |
| 29 | 36 | ContentUnavailableView { |
| 30 | 37 | Label("No changes", systemImage: "plus.forwardslash.minus") |
| 31 | 38 | } |
| 32 | 39 | } |
| 33 | 40 | } |
| 34 | .overlay { LoadStateOverlay(state: state) } | |
| 41 | .overlay { LoadStateOverlay(state: model.state) } | |
| 35 | 42 | .navigationTitle("Diff") |
| 36 | 43 | .navigationBarTitleDisplayMode(.inline) |
| 37 | .task { | |
| 38 | do { | |
| 39 | let text = try await client.readText(["mr", "diff", repo, String(number)]) | |
| 40 | state = .loaded(UnifiedDiff.parse(text)) | |
| 41 | } catch { | |
| 42 | state = .from(error) | |
| 43 | } | |
| 44 | } | |
| 44 | .task { await model.load() } | |
| 45 | .refreshable { await model.load() } | |
| 45 | 46 | } |
| 46 | 47 | } |
| 47 | 48 | |
| 48 | 49 | private struct FileDiffSection: View { |
| 49 | 50 | |
| 50 | 51 | let file: UnifiedDiff.File |
| 52 | let model: MRDetailViewModel | |
| 51 | 53 | @State private var collapsed = false |
| 52 | 54 | |
| 53 | 55 | var body: some View { |
| @@ -59,7 +61,7 @@ private struct FileDiffSection: View { | ||
| 59 | 61 | .foregroundStyle(.secondary) |
| 60 | 62 | } else { |
| 61 | 63 | ForEach(file.hunks) { hunk in |
| 62 | HunkView(hunk: hunk) | |
| 64 | HunkView(file: file, hunk: hunk, model: model) | |
| 63 | 65 | } |
| 64 | 66 | } |
| 65 | 67 | } |
| @@ -93,7 +95,9 @@ private struct FileDiffSection: View { | ||
| 93 | 95 | |
| 94 | 96 | private struct HunkView: View { |
| 95 | 97 | |
| 98 | let file: UnifiedDiff.File | |
| 96 | 99 | let hunk: UnifiedDiff.Hunk |
| 100 | let model: MRDetailViewModel | |
| 97 | 101 | |
| 98 | 102 | var body: some View { |
| 99 | 103 | ScrollView(.horizontal) { |
| @@ -106,6 +110,12 @@ private struct HunkView: View { | ||
| 106 | 110 | .background(Color.gbFillSubtle) |
| 107 | 111 | ForEach(hunk.lines) { line in |
| 108 | 112 | LineView(line: line) |
| 113 | ForEach(model.threads.filter { line.anchors($0, in: file) }) { thread in | |
| 114 | ReviewThreadView(thread: thread, model: model) | |
| 115 | .padding(.vertical, 6) | |
| 116 | .padding(.horizontal, 8) | |
| 117 | .background(Color.gbFillSubtle) | |
| 118 | } | |
| 109 | 119 | } |
| 110 | 120 | } |
| 111 | 121 | } |
gitbay/Views/MRs/MRView.swift +2 −2
| @@ -194,7 +194,7 @@ struct MRView: View { | ||
| 194 | 194 | private var threadsSection: some View { |
| 195 | 195 | Section("Threads — \(model.unresolvedCount) unresolved") { |
| 196 | 196 | ForEach(model.threads) { thread in |
| 197 | ThreadView(thread: thread, model: model) | |
| 197 | ReviewThreadView(thread: thread, model: model) | |
| 198 | 198 | } |
| 199 | 199 | } |
| 200 | 200 | } |
| @@ -299,7 +299,7 @@ struct MRView: View { | ||
| 299 | 299 | } |
| 300 | 300 | |
| 301 | 301 | /// One review thread: anchor, comments, reply, resolve. |
| 302 | private struct ThreadView: View { | |
| 302 | struct ReviewThreadView: View { | |
| 303 | 303 | |
| 304 | 304 | let thread: ReviewThread |
| 305 | 305 | let model: MRDetailViewModel |
gitbay/Views/Repos/ReadmeView.swift added +216
| @@ -0,0 +1,216 @@ | ||
| 1 | import Foundation | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// README rendering follows the file's format. Org is parsed as Org; | |
| 5 | /// everything else keeps the established Markdown renderer. | |
| 6 | struct ReadmeView: View { | |
| 7 | let name: String | |
| 8 | let content: String | |
| 9 | ||
| 10 | /// Format follows the file name, never a guess at the content. | |
| 11 | var isOrg: Bool { name.lowercased().hasSuffix(".org") } | |
| 12 | ||
| 13 | var body: some View { | |
| 14 | if isOrg { | |
| 15 | OrgDocumentView(document: OrgDocument.parse(content)) | |
| 16 | } else { | |
| 17 | MarkdownView(markdown: content) | |
| 18 | } | |
| 19 | } | |
| 20 | } | |
| 21 | ||
| 22 | nonisolated struct OrgDocument: Sendable, Hashable { | |
| 23 | let blocks: [OrgBlock] | |
| 24 | ||
| 25 | static func parse(_ source: String) -> OrgDocument { | |
| 26 | var result: [OrgBlock] = [] | |
| 27 | var lines = source.components(separatedBy: "\n")[...] | |
| 28 | ||
| 29 | while let raw = lines.first { | |
| 30 | let line = raw.trimmingCharacters(in: .whitespaces) | |
| 31 | if line.isEmpty { | |
| 32 | lines = lines.dropFirst() | |
| 33 | continue | |
| 34 | } | |
| 35 | let lower = line.lowercased() | |
| 36 | if lower.hasPrefix("#+title:") { | |
| 37 | result.append(.heading(level: 1, text: String(line.dropFirst(8)).trimmingCharacters(in: .whitespaces))) | |
| 38 | lines = lines.dropFirst() | |
| 39 | continue | |
| 40 | } | |
| 41 | if lower.hasPrefix("#+begin_src") || lower.hasPrefix("#+begin_example") { | |
| 42 | let language = lower.hasPrefix("#+begin_src") | |
| 43 | ? String(line.dropFirst(11)).trimmingCharacters(in: .whitespaces) | |
| 44 | : "" | |
| 45 | lines = lines.dropFirst() | |
| 46 | var body: [String] = [] | |
| 47 | while let next = lines.first, | |
| 48 | !next.trimmingCharacters(in: .whitespaces).lowercased().hasPrefix("#+end_") { | |
| 49 | body.append(next) | |
| 50 | lines = lines.dropFirst() | |
| 51 | } | |
| 52 | if !lines.isEmpty { lines = lines.dropFirst() } | |
| 53 | result.append(.code(language: language, text: body.joined(separator: "\n"))) | |
| 54 | continue | |
| 55 | } | |
| 56 | if lower == "#+begin_quote" { | |
| 57 | lines = lines.dropFirst() | |
| 58 | var body: [String] = [] | |
| 59 | while let next = lines.first, | |
| 60 | next.trimmingCharacters(in: .whitespaces).lowercased() != "#+end_quote" { | |
| 61 | body.append(next.trimmingCharacters(in: .whitespaces)) | |
| 62 | lines = lines.dropFirst() | |
| 63 | } | |
| 64 | if !lines.isEmpty { lines = lines.dropFirst() } | |
| 65 | result.append(.quote(body.joined(separator: " "))) | |
| 66 | continue | |
| 67 | } | |
| 68 | if line.first == "*" { | |
| 69 | let stars = line.prefix(while: { $0 == "*" }).count | |
| 70 | if line.dropFirst(stars).first == " " { | |
| 71 | result.append(.heading( | |
| 72 | level: min(stars, 4), | |
| 73 | text: String(line.dropFirst(stars)).trimmingCharacters(in: .whitespaces) | |
| 74 | )) | |
| 75 | lines = lines.dropFirst() | |
| 76 | continue | |
| 77 | } | |
| 78 | } | |
| 79 | if line.hasPrefix("- ") || line.hasPrefix("+ ") { | |
| 80 | var items: [String] = [] | |
| 81 | while let next = lines.first?.trimmingCharacters(in: .whitespaces), | |
| 82 | next.hasPrefix("- ") || next.hasPrefix("+ ") { | |
| 83 | items.append(String(next.dropFirst(2))) | |
| 84 | lines = lines.dropFirst() | |
| 85 | } | |
| 86 | result.append(.bullet(items)) | |
| 87 | continue | |
| 88 | } | |
| 89 | if line.range(of: #"^\d+[.)] "#, options: .regularExpression) != nil { | |
| 90 | var items: [String] = [] | |
| 91 | while let next = lines.first?.trimmingCharacters(in: .whitespaces), | |
| 92 | let range = next.range(of: #"^\d+[.)] "#, options: .regularExpression) { | |
| 93 | items.append(String(next[range.upperBound...])) | |
| 94 | lines = lines.dropFirst() | |
| 95 | } | |
| 96 | result.append(.ordered(items)) | |
| 97 | continue | |
| 98 | } | |
| 99 | if lower.hasPrefix("#+") { | |
| 100 | lines = lines.dropFirst() // document metadata, not prose | |
| 101 | continue | |
| 102 | } | |
| 103 | ||
| 104 | var paragraph: [String] = [] | |
| 105 | while let next = lines.first { | |
| 106 | let trimmed = next.trimmingCharacters(in: .whitespaces) | |
| 107 | let nextLower = trimmed.lowercased() | |
| 108 | if trimmed.isEmpty || trimmed.hasPrefix("* ") || trimmed.hasPrefix("- ") | |
| 109 | || trimmed.hasPrefix("+ ") || nextLower.hasPrefix("#+") { | |
| 110 | break | |
| 111 | } | |
| 112 | paragraph.append(trimmed) | |
| 113 | lines = lines.dropFirst() | |
| 114 | } | |
| 115 | result.append(.paragraph(paragraph.joined(separator: " "))) | |
| 116 | } | |
| 117 | return OrgDocument(blocks: result) | |
| 118 | } | |
| 119 | } | |
| 120 | ||
| 121 | nonisolated enum OrgBlock: Sendable, Hashable { | |
| 122 | case heading(level: Int, text: String) | |
| 123 | case code(language: String, text: String) | |
| 124 | case quote(String) | |
| 125 | case bullet([String]) | |
| 126 | case ordered([String]) | |
| 127 | case paragraph(String) | |
| 128 | } | |
| 129 | ||
| 130 | private struct OrgDocumentView: View { | |
| 131 | let document: OrgDocument | |
| 132 | ||
| 133 | var body: some View { | |
| 134 | VStack(alignment: .leading, spacing: 12) { | |
| 135 | ForEach(Array(document.blocks.enumerated()), id: \.offset) { _, block in | |
| 136 | blockView(block) | |
| 137 | } | |
| 138 | } | |
| 139 | } | |
| 140 | ||
| 141 | @ViewBuilder | |
| 142 | private func blockView(_ block: OrgBlock) -> some View { | |
| 143 | switch block { | |
| 144 | case .heading(let level, let text): | |
| 145 | inline(text) | |
| 146 | .font(headingFont(level)) | |
| 147 | .padding(.top, level <= 2 ? 8 : 4) | |
| 148 | case .code(let language, let text): | |
| 149 | VStack(alignment: .leading, spacing: 4) { | |
| 150 | if !language.isEmpty { | |
| 151 | Text(language) | |
| 152 | .font(.gbMono(.caption2)) | |
| 153 | .foregroundStyle(.secondary) | |
| 154 | } | |
| 155 | ScrollView(.horizontal) { | |
| 156 | Text(text) | |
| 157 | .font(.gbMono(.caption)) | |
| 158 | .padding(10) | |
| 159 | } | |
| 160 | } | |
| 161 | .background(Color.gbCodeBackground, in: RoundedRectangle(cornerRadius: 2)) | |
| 162 | case .quote(let text): | |
| 163 | HStack(spacing: 10) { | |
| 164 | RoundedRectangle(cornerRadius: 2).fill(.tertiary).frame(width: 3) | |
| 165 | inline(text).foregroundStyle(.secondary) | |
| 166 | } | |
| 167 | .fixedSize(horizontal: false, vertical: true) | |
| 168 | case .bullet(let items): | |
| 169 | list(items, ordered: false) | |
| 170 | case .ordered(let items): | |
| 171 | list(items, ordered: true) | |
| 172 | case .paragraph(let text): | |
| 173 | inline(text) | |
| 174 | } | |
| 175 | } | |
| 176 | ||
| 177 | private func list(_ items: [String], ordered: Bool) -> some View { | |
| 178 | VStack(alignment: .leading, spacing: 4) { | |
| 179 | ForEach(Array(items.enumerated()), id: \.offset) { index, item in | |
| 180 | HStack(alignment: .firstTextBaseline, spacing: 8) { | |
| 181 | Text(ordered ? "\(index + 1)." : "•") | |
| 182 | inline(item) | |
| 183 | } | |
| 184 | } | |
| 185 | } | |
| 186 | } | |
| 187 | ||
| 188 | private func inline(_ source: String) -> Text { | |
| 189 | var markdown = source | |
| 190 | markdown = replacing(markdown, pattern: #"\[\[([^\]]+)\]\[([^\]]+)\]\]"#, template: "[$2]($1)") | |
| 191 | markdown = replacing(markdown, pattern: #"\[\[([^\]]+)\]\]"#, template: "<$1>") | |
| 192 | markdown = replacing(markdown, pattern: #"=([^=]+)="#, template: "`$1`") | |
| 193 | if let attributed = try? AttributedString( | |
| 194 | markdown: markdown, | |
| 195 | options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) | |
| 196 | ) { | |
| 197 | return Text(attributed) | |
| 198 | } | |
| 199 | return Text(source) | |
| 200 | } | |
| 201 | ||
| 202 | private func replacing(_ source: String, pattern: String, template: String) -> String { | |
| 203 | guard let expression = try? NSRegularExpression(pattern: pattern) else { return source } | |
| 204 | let range = NSRange(source.startIndex..., in: source) | |
| 205 | return expression.stringByReplacingMatches(in: source, range: range, withTemplate: template) | |
| 206 | } | |
| 207 | ||
| 208 | private func headingFont(_ level: Int) -> Font { | |
| 209 | switch level { | |
| 210 | case 1: .gbSans(.title2).bold() | |
| 211 | case 2: .gbSans(.title3).bold() | |
| 212 | case 3: .gbSans(.headline) | |
| 213 | default: .gbSans(.subheadline).bold() | |
| 214 | } | |
| 215 | } | |
| 216 | } | |
gitbay/Views/Repos/RepoView.swift +1 −1
| @@ -56,7 +56,7 @@ struct RepoView: View { | ||
| 56 | 56 | |
| 57 | 57 | if let readme = model.readme { |
| 58 | 58 | Section("README") { |
| 59 | MarkdownView(markdown: readme) | |
| 59 | ReadmeView(name: model.readmeName ?? "README.md", content: readme) | |
| 60 | 60 | .padding(.vertical, 4) |
| 61 | 61 | } |
| 62 | 62 | } |
gitbayTests/MRViewModelTests.swift +64
| @@ -277,3 +277,67 @@ struct MRDetailViewModelTests { | ||
| 277 | 277 | #expect(body["stdin"] as? String == "because 5xx is transient") |
| 278 | 278 | } |
| 279 | 279 | } |
| 280 | ||
| 281 | /// Where a review thread hangs in the diff. Threads the diff has moved | |
| 282 | /// past must not vanish — they render in their own section. | |
| 283 | struct ThreadAnchoringTests { | |
| 284 | ||
| 285 | private func thread( | |
| 286 | path: String, line: Int64, side: String = "new", stale: Bool = false | |
| 287 | ) throws -> ReviewThread { | |
| 288 | let json = """ | |
| 289 | {"id":1,"path":"\(path)","side":"\(side)","line":\(line),"stale":\(stale),\ | |
| 290 | "comments":[{"id":1,"author":"krz","body":"why?",\ | |
| 291 | "created_at":"2026-08-20T10:30:00.000Z"}]} | |
| 292 | """ | |
| 293 | let decoder = JSONDecoder() | |
| 294 | decoder.dateDecodingStrategy = .iso8601 | |
| 295 | return try decoder.decode(ReviewThread.self, from: Data(json.utf8)) | |
| 296 | } | |
| 297 | ||
| 298 | private let diff = UnifiedDiff.parse(""" | |
| 299 | diff --git a/main.go b/main.go | |
| 300 | --- a/main.go | |
| 301 | +++ b/main.go | |
| 302 | @@ -1,4 +1,5 @@ | |
| 303 | package main | |
| 304 | -import "fmt" | |
| 305 | +import ( | |
| 306 | +\t"fmt" | |
| 307 | +) | |
| 308 | """) | |
| 309 | ||
| 310 | @Test func aThreadAnchorsToItsLineOnTheNewSide() throws { | |
| 311 | // "+import (" is new line 2. | |
| 312 | let subject = try thread(path: "main.go", line: 2) | |
| 313 | #expect(diff.anchors(subject)) | |
| 314 | ||
| 315 | let file = try #require(diff.files.first) | |
| 316 | let line = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 }) | |
| 317 | #expect(line.anchors(subject, in: file)) | |
| 318 | } | |
| 319 | ||
| 320 | @Test func anOldSideThreadAnchorsToTheDeletedLine() throws { | |
| 321 | // `-import "fmt"` is old line 2 and has no new number. | |
| 322 | let subject = try thread(path: "main.go", line: 2, side: "old") | |
| 323 | #expect(diff.anchors(subject)) | |
| 324 | ||
| 325 | let file = try #require(diff.files.first) | |
| 326 | let deletion = try #require(file.hunks.first?.lines.first { $0.kind == .deletion }) | |
| 327 | #expect(deletion.anchors(subject, in: file)) | |
| 328 | // The same line number on the other side is a different anchor. | |
| 329 | let addition = try #require(file.hunks.first?.lines.first { $0.newNumber == 2 }) | |
| 330 | #expect(!addition.anchors(subject, in: file)) | |
| 331 | } | |
| 332 | ||
| 333 | @Test func aStaleThreadAnchorsNowhere() throws { | |
| 334 | // Its head is gone, so the line numbers cannot be trusted. | |
| 335 | let subject = try thread(path: "main.go", line: 2, stale: true) | |
| 336 | #expect(!diff.anchors(subject)) | |
| 337 | } | |
| 338 | ||
| 339 | @Test func aThreadOnAnotherFileOrLineDoesNotAnchor() throws { | |
| 340 | #expect(!diff.anchors(try thread(path: "other.go", line: 2))) | |
| 341 | #expect(!diff.anchors(try thread(path: "main.go", line: 999))) | |
| 342 | } | |
| 343 | } | |
gitbayTests/ReadmeOrgTests.swift added +98
| @@ -0,0 +1,98 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import gitbay | |
| 4 | ||
| 5 | /// README.org rendered as Org, not Markdown (krz/gitbay-ios#4). The | |
| 6 | /// markdown block parser turned `#+title:` into a heading and left | |
| 7 | /// `[[link]]` raw. | |
| 8 | struct OrgDocumentTests { | |
| 9 | ||
| 10 | @Test func titleAndStarHeadingsBecomeHeadings() { | |
| 11 | let doc = OrgDocument.parse(""" | |
| 12 | #+title: orgo | |
| 13 | #+author: krz | |
| 14 | ||
| 15 | * Install | |
| 16 | ** From source | |
| 17 | """) | |
| 18 | ||
| 19 | #expect(doc.blocks == [ | |
| 20 | .heading(level: 1, text: "orgo"), | |
| 21 | .heading(level: 1, text: "Install"), | |
| 22 | .heading(level: 2, text: "From source"), | |
| 23 | ]) | |
| 24 | } | |
| 25 | ||
| 26 | @Test func metadataOtherThanTitleIsDropped() { | |
| 27 | let doc = OrgDocument.parse("#+options: toc:nil\n#+startup: showall\n\nProse.") | |
| 28 | #expect(doc.blocks == [.paragraph("Prose.")]) | |
| 29 | } | |
| 30 | ||
| 31 | @Test func sourceBlocksKeepTheirLanguageAndBody() { | |
| 32 | let doc = OrgDocument.parse(""" | |
| 33 | #+begin_src sh | |
| 34 | gitbay repo list | |
| 35 | gitbay mr create | |
| 36 | #+end_src | |
| 37 | """) | |
| 38 | #expect(doc.blocks == [ | |
| 39 | .code(language: "sh", text: "gitbay repo list\ngitbay mr create"), | |
| 40 | ]) | |
| 41 | } | |
| 42 | ||
| 43 | @Test func examplesAndQuotesAreTheirOwnBlocks() { | |
| 44 | let doc = OrgDocument.parse(""" | |
| 45 | #+begin_example | |
| 46 | $ gitbay whoami | |
| 47 | #+end_example | |
| 48 | ||
| 49 | #+begin_quote | |
| 50 | SSH is the API. | |
| 51 | #+end_quote | |
| 52 | """) | |
| 53 | #expect(doc.blocks == [ | |
| 54 | .code(language: "", text: "$ gitbay whoami"), | |
| 55 | .quote("SSH is the API."), | |
| 56 | ]) | |
| 57 | } | |
| 58 | ||
| 59 | @Test func bothListFormsParse() { | |
| 60 | let doc = OrgDocument.parse(""" | |
| 61 | - one | |
| 62 | + two | |
| 63 | ||
| 64 | 1. first | |
| 65 | 2) second | |
| 66 | """) | |
| 67 | #expect(doc.blocks == [ | |
| 68 | .bullet(["one", "two"]), | |
| 69 | .ordered(["first", "second"]), | |
| 70 | ]) | |
| 71 | } | |
| 72 | ||
| 73 | @Test func aBoldStarLineIsNotAHeading() { | |
| 74 | // "*bold*" has no space after the star, so it is prose. | |
| 75 | let doc = OrgDocument.parse("*bold* opening line") | |
| 76 | #expect(doc.blocks == [.paragraph("*bold* opening line")]) | |
| 77 | } | |
| 78 | ||
| 79 | @Test func paragraphsRunUntilABlankLineOrBlockStart() { | |
| 80 | let doc = OrgDocument.parse(""" | |
| 81 | One sentence | |
| 82 | continued here. | |
| 83 | * Heading | |
| 84 | """) | |
| 85 | #expect(doc.blocks == [ | |
| 86 | .paragraph("One sentence continued here."), | |
| 87 | .heading(level: 1, text: "Heading"), | |
| 88 | ]) | |
| 89 | } | |
| 90 | ||
| 91 | @Test func readmeViewPicksTheRendererByExtension() { | |
| 92 | // Only the name decides; content is not sniffed. | |
| 93 | #expect(ReadmeView(name: "README.org", content: "* x").isOrg) | |
| 94 | #expect(ReadmeView(name: "readme.ORG", content: "* x").isOrg) | |
| 95 | #expect(!ReadmeView(name: "README.md", content: "# x").isOrg) | |
| 96 | #expect(!ReadmeView(name: "README", content: "x").isOrg) | |
| 97 | } | |
| 98 | } | |
gitbayUITests/LiveSmokeUITests.swift +65 −50
| @@ -83,8 +83,7 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 83 | 83 | let source = app.descendants(matching: .any) |
| 84 | 84 | .matching(identifier: "mr-source").firstMatch |
| 85 | 85 | XCTAssertTrue(source.waitForExistence(timeout: 5), "MR create sheet did not open") |
| 86 | source.tap() | |
| 87 | source.typeText("ui-smoke") | |
| 86 | focusAndType(source, "ui-smoke") | |
| 88 | 87 | |
| 89 | 88 | let target = app.descendants(matching: .any) |
| 90 | 89 | .matching(identifier: "mr-target").firstMatch |
| @@ -93,8 +92,7 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 93 | 92 | |
| 94 | 93 | let mrTitle = app.descendants(matching: .any) |
| 95 | 94 | .matching(identifier: "mr-title").firstMatch |
| 96 | mrTitle.tap() | |
| 97 | mrTitle.typeText("UI smoke: mr create from the app") | |
| 95 | focusAndType(mrTitle, "UI smoke: mr create from the app") | |
| 98 | 96 | |
| 99 | 97 | app.descendants(matching: .any).matching(identifier: "mr-submit") |
| 100 | 98 | .firstMatch.tap() |
| @@ -121,24 +119,63 @@ final class LiveSmokeUITests: XCTestCase { | ||
| 121 | 119 | // MARK: - Helpers |
| 122 | 120 | |
| 123 | 121 | private func openRepo(_ path: String) { |
| 124 | let tab = app.buttons["Repositories"].firstMatch | |
| 125 | XCTAssertTrue(tab.waitForExistence(timeout: 10)) | |
| 126 | tab.tap() | |
| 122 | selectTab("Repositories") | |
| 123 | ||
| 124 | // `.searchable` keeps the field tucked above the list until it is | |
| 125 | // scrolled into view. | |
| 127 | 126 | let search = app.searchFields.firstMatch |
| 128 | XCTAssertTrue(search.waitForExistence(timeout: 10)) | |
| 129 | search.tap() | |
| 130 | search.typeText(path) | |
| 127 | if !search.waitForExistence(timeout: 5) { | |
| 128 | app.swipeDown() | |
| 129 | XCTAssertTrue(search.waitForExistence(timeout: 10), "search field never appeared") | |
| 130 | } | |
| 131 | focusAndType(search, path) | |
| 132 | ||
| 131 | 133 | let row = app.staticTexts[path].firstMatch |
| 132 | 134 | XCTAssertTrue(row.waitForExistence(timeout: 15), "\(path) not in the repo list") |
| 133 | 135 | row.tap() |
| 134 | 136 | // Repo screen is loaded once its links render. |
| 135 | XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10)) | |
| 137 | XCTAssertTrue(app.staticTexts["Files"].firstMatch.waitForExistence(timeout: 10), | |
| 138 | "repo screen did not open") | |
| 136 | 139 | } |
| 137 | 140 | |
| 138 | 141 | private func back() { |
| 139 | 142 | app.navigationBars.buttons.firstMatch.tap() |
| 140 | 143 | } |
| 141 | 144 | |
| 145 | /// Switch tabs and wait until that tab is actually front. A tap | |
| 146 | /// dispatched before the app is interactive — which happens right | |
| 147 | /// after launch when there is no sign-in to slow things down — is | |
| 148 | /// swallowed silently, so this taps until the tab reports selected. | |
| 149 | func selectTab(_ name: String, | |
| 150 | file: StaticString = #filePath, line: UInt = #line) { | |
| 151 | let tab = app.tabBars.buttons[name].firstMatch | |
| 152 | XCTAssertTrue(tab.waitForExistence(timeout: 15), | |
| 153 | "\(name) tab missing", file: file, line: line) | |
| 154 | // The app is interactive once its own chrome is hittable. | |
| 155 | XCTAssertTrue(tab.isHittable || tab.waitForExistence(timeout: 5), | |
| 156 | "\(name) tab never became hittable", file: file, line: line) | |
| 157 | ||
| 158 | for _ in 0..<4 { | |
| 159 | if tab.isSelected, app.navigationBars[name].firstMatch.exists { return } | |
| 160 | tab.tap() | |
| 161 | if app.navigationBars[name].firstMatch.waitForExistence(timeout: 5) { return } | |
| 162 | } | |
| 163 | XCTFail("\(name) tab did not come to front", file: file, line: line) | |
| 164 | } | |
| 165 | ||
| 166 | /// Tap a field and type into it, surviving the focus race: a tap can | |
| 167 | /// land before the field is ready, and the keystrokes go nowhere. | |
| 168 | func focusAndType(_ element: XCUIElement, _ text: String, | |
| 169 | file: StaticString = #filePath, line: UInt = #line) { | |
| 170 | element.tap() | |
| 171 | if !app.keyboards.firstMatch.waitForExistence(timeout: 5) { | |
| 172 | element.tap() | |
| 173 | XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5), | |
| 174 | "keyboard never appeared for \(element)", file: file, line: line) | |
| 175 | } | |
| 176 | element.typeText(text) | |
| 177 | } | |
| 178 | ||
| 142 | 179 | private func waitForDisappearance(_ element: XCUIElement, timeout: TimeInterval) -> Bool { |
| 143 | 180 | let predicate = NSPredicate(format: "exists == false") |
| 144 | 181 | let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) |
| @@ -156,16 +193,13 @@ extension LiveSmokeUITests { | ||
| 156 | 193 | // --- repo create first: it ends in the list's search state, |
| 157 | 194 | // which the next step reuses. (Scratch repo; the runner deletes |
| 158 | 195 | // it over SSH afterwards — deletion is CLI-only by design.) |
| 159 | let tab = app.buttons["Repositories"].firstMatch | |
| 160 | XCTAssertTrue(tab.waitForExistence(timeout: 10)) | |
| 161 | tab.tap() | |
| 196 | selectTab("Repositories") | |
| 162 | 197 | app.descendants(matching: .any).matching(identifier: "repo-create-button") |
| 163 | 198 | .firstMatch.tap() |
| 164 | 199 | let pathField = app.descendants(matching: .any) |
| 165 | 200 | .matching(identifier: "repo-create-path").firstMatch |
| 166 | 201 | XCTAssertTrue(pathField.waitForExistence(timeout: 5)) |
| 167 | pathField.tap() | |
| 168 | pathField.typeText("ui-smoke") | |
| 202 | focusAndType(pathField, "ui-smoke") | |
| 169 | 203 | app.switches.firstMatch.tap() // Private on |
| 170 | 204 | app.descendants(matching: .any).matching(identifier: "repo-create-submit") |
| 171 | 205 | .firstMatch.tap() |
| @@ -175,16 +209,15 @@ extension LiveSmokeUITests { | ||
| 175 | 209 | "create sheet did not dismiss") |
| 176 | 210 | let search = app.searchFields.firstMatch |
| 177 | 211 | XCTAssertTrue(search.waitForExistence(timeout: 10)) |
| 178 | search.tap() | |
| 179 | search.typeText("ui-smoke") | |
| 212 | focusAndType(search, "ui-smoke") | |
| 180 | 213 | XCTAssertTrue(app.staticTexts["cmc/ui-smoke"].firstMatch |
| 181 | 214 | .waitForExistence(timeout: 15), "created repo not in the list") |
| 182 | 215 | |
| 183 | 216 | // --- pin / unpin round-trip on krz/gitbay-ios --- |
| 184 | 217 | // Reuse the open search to get there. |
| 185 | 218 | let clear = search.buttons.firstMatch |
| 186 | if clear.exists { clear.tap() } else { search.tap() } | |
| 187 | search.typeText("krz/gitbay-ios") | |
| 219 | if clear.exists { clear.tap() } | |
| 220 | focusAndType(search, "krz/gitbay-ios") | |
| 188 | 221 | let repoRow = app.staticTexts["krz/gitbay-ios"].firstMatch |
| 189 | 222 | XCTAssertTrue(repoRow.waitForExistence(timeout: 15)) |
| 190 | 223 | repoRow.tap() |
| @@ -207,8 +240,7 @@ extension LiveSmokeUITests { | ||
| 207 | 240 | let addTopic = app.descendants(matching: .any) |
| 208 | 241 | .matching(identifier: "settings-add-topic").firstMatch |
| 209 | 242 | XCTAssertTrue(addTopic.waitForExistence(timeout: 10), "settings did not load") |
| 210 | addTopic.tap() | |
| 211 | addTopic.typeText("ios") | |
| 243 | focusAndType(addTopic, "ios") | |
| 212 | 244 | app.descendants(matching: .any).matching(identifier: "settings-add-topic-submit") |
| 213 | 245 | .firstMatch.tap() |
| 214 | 246 | let chip = app.staticTexts["ios"].firstMatch |
| @@ -243,8 +275,7 @@ extension LiveSmokeUITests { | ||
| 243 | 275 | .firstMatch.tap() |
| 244 | 276 | let jobField = app.textFields.firstMatch |
| 245 | 277 | XCTAssertTrue(jobField.waitForExistence(timeout: 5), "trigger alert missing") |
| 246 | jobField.tap() | |
| 247 | jobField.typeText("ci") | |
| 278 | focusAndType(jobField, "ci") | |
| 248 | 279 | app.buttons["Trigger"].firstMatch.tap() |
| 249 | 280 | // "no job X" for an unknown job, "has no .gitbay/ci.yml" when the |
| 250 | 281 | // repo has no CI config at all. |
| @@ -268,9 +299,7 @@ extension LiveSmokeUITests { | ||
| 268 | 299 | /// profiles. No cleanup needed. |
| 269 | 300 | func testDiscoveryFlows() throws { |
| 270 | 301 | // --- feed renders events and navigates --- |
| 271 | let feedTab = app.buttons["Feed"].firstMatch | |
| 272 | XCTAssertTrue(feedTab.waitForExistence(timeout: 10)) | |
| 273 | feedTab.tap() | |
| 302 | selectTab("Feed") | |
| 274 | 303 | let firstEvent = app.cells.firstMatch |
| 275 | 304 | XCTAssertTrue(firstEvent.waitForExistence(timeout: 15), "feed rendered no events") |
| 276 | 305 | firstEvent.tap() |
| @@ -281,12 +310,10 @@ extension LiveSmokeUITests { | ||
| 281 | 310 | |
| 282 | 311 | // --- server-side search: "astronomy" is only a topic, invisible |
| 283 | 312 | // to the client-side path/description filter --- |
| 284 | let repoTab = app.buttons["Repositories"].firstMatch | |
| 285 | repoTab.tap() | |
| 313 | selectTab("Repositories") | |
| 286 | 314 | let search = app.searchFields.firstMatch |
| 287 | 315 | XCTAssertTrue(search.waitForExistence(timeout: 10)) |
| 288 | search.tap() | |
| 289 | search.typeText("astronomy") | |
| 316 | focusAndType(search, "astronomy") | |
| 290 | 317 | let hit = app.staticTexts["krz/space-wiki"].firstMatch |
| 291 | 318 | XCTAssertTrue(hit.waitForExistence(timeout: 15), |
| 292 | 319 | "server-side topic search found nothing") |
| @@ -306,8 +333,7 @@ extension LiveSmokeUITests { | ||
| 306 | 333 | app.staticTexts["Search in Files"].firstMatch.tap() |
| 307 | 334 | let grepField = app.searchFields.firstMatch |
| 308 | 335 | XCTAssertTrue(grepField.waitForExistence(timeout: 10)) |
| 309 | grepField.tap() | |
| 310 | grepField.typeText("space") | |
| 336 | focusAndType(grepField, "space") | |
| 311 | 337 | app.keyboards.buttons["search"].firstMatch.tap() |
| 312 | 338 | let match = app.cells.firstMatch |
| 313 | 339 | XCTAssertTrue(match.waitForExistence(timeout: 15), "grep returned no matches") |
| @@ -355,8 +381,7 @@ extension LiveSmokeUITests { | ||
| 355 | 381 | let tag = app.descendants(matching: .any) |
| 356 | 382 | .matching(identifier: "release-tag").firstMatch |
| 357 | 383 | XCTAssertTrue(tag.waitForExistence(timeout: 5)) |
| 358 | tag.tap() | |
| 359 | tag.typeText("v9.9.9") | |
| 384 | focusAndType(tag, "v9.9.9") | |
| 360 | 385 | app.descendants(matching: .any).matching(identifier: "release-submit") |
| 361 | 386 | .firstMatch.tap() |
| 362 | 387 | XCTAssertTrue(app.staticTexts |
| @@ -390,8 +415,7 @@ extension LiveSmokeUITests { | ||
| 390 | 415 | let paste = app.descendants(matching: .any) |
| 391 | 416 | .matching(identifier: "key-paste-text").firstMatch |
| 392 | 417 | XCTAssertTrue(paste.waitForExistence(timeout: 5)) |
| 393 | paste.tap() | |
| 394 | paste.typeText("not a key") | |
| 418 | focusAndType(paste, "not a key") | |
| 395 | 419 | app.descendants(matching: .any).matching(identifier: "key-paste-submit") |
| 396 | 420 | .firstMatch.tap() |
| 397 | 421 | XCTAssertTrue(app.staticTexts |
| @@ -403,13 +427,7 @@ extension LiveSmokeUITests { | ||
| 403 | 427 | let code = app.descendants(matching: .any) |
| 404 | 428 | .matching(identifier: "email-code").firstMatch |
| 405 | 429 | XCTAssertTrue(code.waitForExistence(timeout: 5)) |
| 406 | code.tap() | |
| 407 | XCTAssertTrue(app.keyboards.firstMatch.waitForExistence(timeout: 5)) | |
| 408 | code.typeText("000000") | |
| 409 | if (code.value as? String)?.contains("000000") != true { | |
| 410 | // The first keystrokes can race focus; type once more. | |
| 411 | code.typeText("000000") | |
| 412 | } | |
| 430 | focusAndType(code, "000000") | |
| 413 | 431 | let verify = app.descendants(matching: .any) |
| 414 | 432 | .matching(identifier: "email-verify").firstMatch |
| 415 | 433 | XCTAssertTrue(verify.waitForExistence(timeout: 5)) |
| @@ -444,8 +462,7 @@ extension LiveSmokeUITests { | ||
| 444 | 462 | let teamField = app.descendants(matching: .any) |
| 445 | 463 | .matching(identifier: "org-new-team").firstMatch |
| 446 | 464 | XCTAssertTrue(teamField.waitForExistence(timeout: 5)) |
| 447 | teamField.tap() | |
| 448 | teamField.typeText("ui-smoke") | |
| 465 | focusAndType(teamField, "ui-smoke") | |
| 449 | 466 | app.descendants(matching: .any).matching(identifier: "org-new-team-submit") |
| 450 | 467 | .firstMatch.tap() |
| 451 | 468 | let teamRow = app.staticTexts["ui-smoke"].firstMatch |
| @@ -455,8 +472,7 @@ extension LiveSmokeUITests { | ||
| 455 | 472 | teamRow.tap() |
| 456 | 473 | let repoField = app.textFields["org/repo"].firstMatch |
| 457 | 474 | XCTAssertTrue(repoField.waitForExistence(timeout: 10)) |
| 458 | repoField.tap() | |
| 459 | repoField.typeText("krz/gitbay-ios") | |
| 475 | focusAndType(repoField, "krz/gitbay-ios") | |
| 460 | 476 | app.buttons["Grant"].firstMatch.tap() |
| 461 | 477 | let grantRow = app.staticTexts["krz/gitbay-ios"].firstMatch |
| 462 | 478 | XCTAssertTrue(grantRow.waitForExistence(timeout: 15), "grant not listed") |
| @@ -520,8 +536,7 @@ extension LiveSmokeUITests { | ||
| 520 | 536 | XCTFail("device is signed out and no GITBAY_UITEST_TOKEN was provided") |
| 521 | 537 | return |
| 522 | 538 | } |
| 523 | tokenField.tap() | |
| 524 | tokenField.typeText(token + "\n") | |
| 539 | focusAndType(tokenField, token + "\n") | |
| 525 | 540 | XCTAssertTrue(app.staticTexts["Dashboard"].firstMatch |
| 526 | 541 | .waitForExistence(timeout: 20), "sign-in did not land") |
| 527 | 542 | } |