a native ios client for gitbay

client ios swift

https://gitbay.org

Commit d98ae629c2

d98ae629c2da5d3b04e7f934da79415d185ce4df

parent: 80e6955a77

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-27T04:55:45Z

repos: list, browse, read files, history

repo list with client-side filtering, repo show with topics and branch,
repo tree navigation, repo cat with Highlightr syntax highlighting
(hutch's approach, 2.3.0 pinned), and repo log with the server's
signature verdict rendered as a badge per state — unsigned commits get
no badge, unknown future states decode without sinking the log.

READMEs are found by name in the root tree and rendered with a compact
block-level markdown view. Binary files say so; truncated files carry a
banner with the served size. Envelope dates decode as RFC 3339.

Verified on the simulator against gitbay.org, including reading a
private repo's source.

Ref #11
gitbay.xcodeproj/project.pbxproj +28
@@ -6,6 +6,10 @@
66 objectVersion = 77;
77 objects = {
88
9/* Begin PBXBuildFile section */
10 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; };
11/* End PBXBuildFile section */
12
913/* Begin PBXContainerItemProxy section */
1014 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */ = {
1115 isa = PBXContainerItemProxy;
@@ -39,6 +43,7 @@
3943 isa = PBXFrameworksBuildPhase;
4044 buildActionMask = 2147483647;
4145 files = (
46 8B289850303FF914001BF44A /* Highlightr in Frameworks */,
4247 );
4348 runOnlyForDeploymentPostprocessing = 0;
4449 };
@@ -90,6 +95,7 @@
9095 );
9196 name = gitbay;
9297 packageProductDependencies = (
98 8B2898B1303FF3E5001BF44A /* Highlightr */,
9399 );
94100 productName = gitbay;
95101 productReference = 8B289812303FF3E5001BF44A /* gitbay.app */;
@@ -146,6 +152,9 @@
146152 );
147153 mainGroup = 8B289809303FF3E5001BF44A;
148154 minimizedProjectReferenceProxies = 1;
155 packageReferences = (
156 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */,
157 );
149158 preferredProjectObjectVersion = 77;
150159 productRefGroup = 8B289813303FF3E5001BF44A /* Products */;
151160 projectDirPath = "";
@@ -456,6 +465,25 @@
456465 defaultConfigurationName = Release;
457466 };
458467/* End XCConfigurationList section */
468
469/* Begin XCRemoteSwiftPackageReference section */
470 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */ = {
471 isa = XCRemoteSwiftPackageReference;
472 repositoryURL = "https://github.com/raspu/Highlightr";
473 requirement = {
474 kind = upToNextMajorVersion;
475 minimumVersion = 2.1.0;
476 };
477 };
478/* End XCRemoteSwiftPackageReference section */
479
480/* Begin XCSwiftPackageProductDependency section */
481 8B2898B1303FF3E5001BF44A /* Highlightr */ = {
482 isa = XCSwiftPackageProductDependency;
483 package = 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */;
484 productName = Highlightr;
485 };
486/* End XCSwiftPackageProductDependency section */
459487 };
460488 rootObject = 8B28980A303FF3E5001BF44A /* Project object */;
461489}
gitbay.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved added +15
@@ -0,0 +1,15 @@
1{
2 "originHash" : "0a8fce6d3cd251ad12ea6d2f48f55bfdc4fa43f93e39dce01f4b2baa78e6feb8",
3 "pins" : [
4 {
5 "identity" : "highlightr",
6 "kind" : "remoteSourceControl",
7 "location" : "https://github.com/raspu/Highlightr",
8 "state" : {
9 "revision" : "05e7fcc63b33925cd0c1faaa205cdd5681e7bbef",
10 "version" : "2.3.0"
11 }
12 }
13 ],
14 "version" : 3
15}
gitbay/ContentView.swift +19 −36
@@ -6,49 +6,32 @@ struct ContentView: View {
66
77 var body: some View {
88 NavigationStack {
9 if let account = session.current {
10 SignedInPlaceholderView(account: account)
9 if let client = session.client {
10 RepoListView(client: client)
11 .id(session.current?.id) // fresh screens on account switch
12 .navigationDestination(for: RepoRoute.self) { route in
13 destination(route, client: client)
14 }
1115 } else {
1216 SignInView()
1317 }
1418 }
1519 }
16}
17
18/// Stands in until the repo list lands; proves sign-in, account switching
19/// and sign-out end to end.
20private struct SignedInPlaceholderView: View {
21
22 @Environment(SessionStore.self) private var session
23 let account: Account
24
25 var body: some View {
26 List {
27 Section("Signed in") {
28 LabeledContent("Account", value: account.username)
29 LabeledContent(
30 "Instance",
31 value: account.instance.baseURL.host() ?? account.instance.baseURL.absoluteString
32 )
33 }
34
35 if session.accounts.count > 1 {
36 Section("Switch account") {
37 ForEach(session.accounts.filter { $0.id != account.id }) { other in
38 Button(other.label) {
39 session.activate(other)
40 }
41 }
42 }
43 }
4420
45 Section {
46 Button("Sign Out", role: .destructive) {
47 session.remove(account)
48 }
49 }
21 @ViewBuilder
22 private func destination(_ route: RepoRoute, client: GitbayClient) -> some View {
23 switch route {
24 case .repo(let path):
25 RepoView(client: client, path: path)
26 case .tree(let repo, let directory, let ref):
27 TreeView(client: client, repo: repo, directory: directory, ref: ref)
28 case .file(let repo, let path, let ref):
29 FileView(client: client, repo: repo, path: path, ref: ref)
30 case .log(let repo):
31 LogView(client: client, repo: repo)
32 case .addAccount:
33 SignInView()
5034 }
51 .navigationTitle("gitbay")
5235 }
5336 }
5437
gitbay/Networking/GitbayClient.swift +9 −2
@@ -207,6 +207,13 @@ nonisolated final class GitbayClient: Sendable {
207207
208208 private enum Surface { case read, command }
209209
210 /// Dates on the wire are RFC 3339 (`repo log`, `mr show`, ).
211 private static func decoder() -> JSONDecoder {
212 let decoder = JSONDecoder()
213 decoder.dateDecodingStrategy = .iso8601
214 return decoder
215 }
216
210217 private func decode<Payload: Decodable & Sendable>(
211218 _ body: Data,
212219 status: Int,
@@ -218,7 +225,7 @@ nonisolated final class GitbayClient: Sendable {
218225 // has an error envelope never matches the expected payload.
219226 let probe: Envelope<NoPayload>
220227 do {
221 probe = try JSONDecoder().decode(Envelope<NoPayload>.self, from: body)
228 probe = try Self.decoder().decode(Envelope<NoPayload>.self, from: body)
222229 } catch {
223230 throw GitbayError.decoding(error)
224231 }
@@ -229,7 +236,7 @@ nonisolated final class GitbayClient: Sendable {
229236 throw error
230237 }
231238 do {
232 return try JSONDecoder().decode(Envelope<Payload>.self, from: body)
239 return try Self.decoder().decode(Envelope<Payload>.self, from: body)
233240 } catch {
234241 throw GitbayError.decoding(error)
235242 }
gitbay/Repos/FileViewModel.swift added +37
@@ -0,0 +1,37 @@
1import Foundation
2import Observation
3
4/// One file via `repo cat`: text renders (highlighted when the language is
5/// known), binary says so, truncated says so never a guess.
6@Observable
7@MainActor
8final class FileViewModel {
9
10 private(set) var state: LoadState<FileContent> = .loading
11
12 private let client: GitbayClient
13 let repoPath: String
14 let filePath: String
15 let ref: String?
16
17 init(client: GitbayClient, repoPath: String, filePath: String, ref: String? = nil) {
18 self.client = client
19 self.repoPath = repoPath
20 self.filePath = filePath
21 self.ref = ref
22 }
23
24 var fileName: String {
25 String(filePath.split(separator: "/").last ?? "")
26 }
27
28 func load() async {
29 var argv = ["repo", "cat", repoPath, filePath]
30 if let ref { argv.append(contentsOf: ["--ref", ref]) }
31 do {
32 state = .loaded(try await client.read(argv, as: FileContent.self))
33 } catch {
34 state = .from(error)
35 }
36 }
37}
gitbay/Repos/LoadState.swift added +25
@@ -0,0 +1,25 @@
1import Foundation
2
3/// One screen's loading lifecycle. `empty` is a first-class state, not a
4/// failure exit 3 lands here, and so does a list with nothing in it.
5nonisolated enum LoadState<Value: Sendable>: Sendable {
6 case loading
7 case loaded(Value)
8 case empty(String)
9 case failed(String)
10
11 var value: Value? {
12 if case .loaded(let value) = self { return value }
13 return nil
14 }
15}
16
17extension LoadState {
18 /// Map any thrown error onto the state a screen should show.
19 static func from(_ error: any Error) -> LoadState {
20 guard let error = error as? GitbayError else {
21 return .failed(GitbayError.transport(error).userFacingMessage)
22 }
23 return error.isEmptyState ? .empty(error.userFacingMessage) : .failed(error.userFacingMessage)
24 }
25}
gitbay/Repos/LogViewModel.swift added +27
@@ -0,0 +1,27 @@
1import Foundation
2import Observation
3
4/// `repo log`: the default branch's history, signature verdict per commit.
5@Observable
6@MainActor
7final class LogViewModel {
8
9 private(set) var state: LoadState<[Commit]> = .loading
10
11 private let client: GitbayClient
12 let repoPath: String
13
14 init(client: GitbayClient, repoPath: String) {
15 self.client = client
16 self.repoPath = repoPath
17 }
18
19 func load() async {
20 do {
21 let commits = try await client.readList(["repo", "log", repoPath], of: Commit.self)
22 state = commits.isEmpty ? .empty("No commits yet.") : .loaded(commits)
23 } catch {
24 state = .from(error)
25 }
26 }
27}
gitbay/Repos/RepoDetailViewModel.swift added +48
@@ -0,0 +1,48 @@
1import Foundation
2import Observation
3
4/// The repo screen: header from `repo show`, README found in the root
5/// tree and fetched with `repo cat`.
6@Observable
7@MainActor
8final class RepoDetailViewModel {
9
10 private(set) var state: LoadState<RepoDetail> = .loading
11 /// README markdown, when the root tree has one. Absent is normal.
12 private(set) var readme: String?
13
14 private let client: GitbayClient
15 private let path: String
16
17 init(client: GitbayClient, path: String) {
18 self.client = client
19 self.path = path
20 }
21
22 func load() async {
23 do {
24 let detail = try await client.read(["repo", "show", path], as: RepoDetail.self)
25 state = .loaded(detail)
26 } catch {
27 state = .from(error)
28 return
29 }
30 await loadReadme()
31 }
32
33 private func loadReadme() async {
34 // The README is whatever the root tree calls one, matched the way
35 // the web UI matches: README, README.md, readme.org, and so on.
36 guard let listing = try? await client.read(
37 ["repo", "tree", path], as: TreeListing.self
38 ) else { return }
39 let candidate = listing.entries.first {
40 !$0.isDirectory && $0.name.lowercased().hasPrefix("readme")
41 }
42 guard let candidate else { return }
43 guard let file = try? await client.read(
44 ["repo", "cat", path, candidate.name], as: FileContent.self
45 ), !file.binary, let content = file.content else { return }
46 readme = content
47 }
48}
gitbay/Repos/RepoListViewModel.swift added +38
@@ -0,0 +1,38 @@
1import Foundation
2import Observation
3
4/// The `repo list` screen: everything you own or can reach, filterable.
5@Observable
6@MainActor
7final class RepoListViewModel {
8
9 private(set) var state: LoadState<[RepoSummary]> = .loading
10 var searchText = ""
11
12 private let client: GitbayClient
13
14 init(client: GitbayClient) {
15 self.client = client
16 }
17
18 var visibleRepos: [RepoSummary] {
19 guard let repos = state.value else { return [] }
20 let query = searchText.trimmingCharacters(in: .whitespaces).lowercased()
21 guard !query.isEmpty else { return repos }
22 return repos.filter {
23 $0.path.lowercased().contains(query)
24 || ($0.description ?? "").lowercased().contains(query)
25 }
26 }
27
28 func load() async {
29 do {
30 let repos = try await client.readList(["repo", "list"], of: RepoSummary.self)
31 state = repos.isEmpty
32 ? .empty("No repositories yet. Create one over SSH: gitbay repo create <name>")
33 : .loaded(repos.sorted { $0.path < $1.path })
34 } catch {
35 state = .from(error)
36 }
37 }
38}
gitbay/Repos/RepoModels.swift added +138
@@ -0,0 +1,138 @@
1import Foundation
2
3/// One row of `repo list`.
4nonisolated struct RepoSummary: Decodable, Sendable, Hashable, Identifiable {
5 let path: String
6 let visibility: String
7 let description: String?
8 let archived: Bool?
9
10 var id: String { path }
11 var isArchived: Bool { archived ?? false }
12 var isPrivate: Bool { visibility == "private" }
13
14 /// "krz/gitbay" ("krz", "gitbay")
15 var owner: String { String(path.split(separator: "/").first ?? "") }
16 var name: String { String(path.split(separator: "/").last ?? "") }
17}
18
19/// `repo show <owner/name>`.
20nonisolated struct RepoDetail: Decodable, Sendable, Hashable {
21 let path: String
22 let description: String?
23 let website: String?
24 let visibility: String
25 let defaultBranch: String
26 let protectedBranches: [String]?
27 let archived: Bool?
28 let topics: [String]?
29
30 enum CodingKeys: String, CodingKey {
31 case path, description, website, visibility, archived, topics
32 case defaultBranch = "default_branch"
33 case protectedBranches = "protected_branches"
34 }
35
36 var isArchived: Bool { archived ?? false }
37}
38
39/// `repo tree` one directory listing.
40nonisolated struct TreeListing: Decodable, Sendable, Hashable {
41 let path: String
42 let ref: String
43 let dir: String
44 let entries: [TreeEntry]
45}
46
47/// One entry of a tree. The sha is the git object id content at a sha
48/// never changes, which is what makes caching on it sound.
49nonisolated struct TreeEntry: Decodable, Sendable, Hashable, Identifiable {
50 let name: String
51 let type: String // blob | tree
52 let mode: String
53 let sha: String
54 let size: Int64?
55
56 var id: String { sha + name }
57 var isDirectory: Bool { type == "tree" }
58 var isSymlink: Bool { mode == "120000" }
59}
60
61/// `repo cat` one file. Exactly one of `content`/`base64` is set.
62nonisolated struct FileContent: Decodable, Sendable, Hashable {
63 let path: String
64 let ref: String
65 let file: String
66 let size: Int
67 let binary: Bool
68 let truncated: Bool?
69 let content: String?
70 let base64: String?
71
72 var isTruncated: Bool { truncated ?? false }
73
74 var data: Data? {
75 if let content { return Data(content.utf8) }
76 if let base64 { return Data(base64Encoded: base64) }
77 return nil
78 }
79}
80
81/// `repo log` one commit, with its signature verdict.
82nonisolated struct Commit: Decodable, Sendable, Hashable, Identifiable {
83 let sha: String
84 let subject: String
85 let authorName: String
86 let authorEmail: String
87 let committerEmail: String?
88 let date: Date
89 let signature: Signature
90
91 enum CodingKeys: String, CodingKey {
92 case sha, subject, date, signature
93 case authorName = "author_name"
94 case authorEmail = "author_email"
95 case committerEmail = "committer_email"
96 }
97
98 var id: String { sha }
99 var shortSHA: String { String(sha.prefix(10)) }
100
101 nonisolated struct Signature: Decodable, Sendable, Hashable {
102 let state: SignatureState
103 let signer: String?
104 let keyFingerprint: String?
105
106 enum CodingKeys: String, CodingKey {
107 case state, signer
108 case keyFingerprint = "key_fingerprint"
109 }
110 }
111}
112
113/// The server's verdict on a commit signature; `internal/sig/verify.go`.
114/// Unknown future states decode as `.unrecognized` rather than failing the
115/// whole log.
116nonisolated enum SignatureState: Sendable, Hashable, Decodable {
117 case verified
118 case signedUnknownKey
119 case signedEmailMismatch
120 case signedKeyExpired
121 case signedKeyRevoked
122 case badSignature
123 case unsigned
124 case unrecognized(String)
125
126 init(from decoder: any Decoder) throws {
127 switch try decoder.singleValueContainer().decode(String.self) {
128 case "verified": self = .verified
129 case "signed_unknown_key": self = .signedUnknownKey
130 case "signed_email_mismatch": self = .signedEmailMismatch
131 case "signed_key_expired": self = .signedKeyExpired
132 case "signed_key_revoked": self = .signedKeyRevoked
133 case "bad_signature": self = .badSignature
134 case "unsigned": self = .unsigned
135 case let other: self = .unrecognized(other)
136 }
137 }
138}
gitbay/Repos/TreeViewModel.swift added +48
@@ -0,0 +1,48 @@
1import Foundation
2import Observation
3
4/// One directory of `repo tree`. Navigation pushes a new instance per
5/// level, so back is free and each level revalidates independently.
6@Observable
7@MainActor
8final class TreeViewModel {
9
10 private(set) var state: LoadState<TreeListing> = .loading
11
12 private let client: GitbayClient
13 let repoPath: String
14 let directory: String
15 let ref: String?
16
17 init(client: GitbayClient, repoPath: String, directory: String = "", ref: String? = nil) {
18 self.client = client
19 self.repoPath = repoPath
20 self.directory = directory
21 self.ref = ref
22 }
23
24 /// Directories first, then files, both alphabetically the ordering
25 /// every forge uses.
26 var entries: [TreeEntry] {
27 guard let listing = state.value else { return [] }
28 return listing.entries.sorted {
29 if $0.isDirectory != $1.isDirectory { return $0.isDirectory }
30 return $0.name.localizedStandardCompare($1.name) == .orderedAscending
31 }
32 }
33
34 func load() async {
35 var argv = ["repo", "tree", repoPath]
36 if !directory.isEmpty { argv.append(directory) }
37 if let ref { argv.append(contentsOf: ["--ref", ref]) }
38 do {
39 state = .loaded(try await client.read(argv, as: TreeListing.self))
40 } catch {
41 state = .from(error)
42 }
43 }
44
45 func childDirectory(_ entry: TreeEntry) -> String {
46 directory.isEmpty ? entry.name : "\(directory)/\(entry.name)"
47 }
48}
gitbay/Views/Repos/FileView.swift added +80
@@ -0,0 +1,80 @@
1import SwiftUI
2
3struct FileView: View {
4
5 @Environment(\.colorScheme) private var colorScheme
6 @State private var model: FileViewModel
7
8 init(client: GitbayClient, repo: String, path: String, ref: String?) {
9 _model = State(initialValue: FileViewModel(
10 client: client, repoPath: repo, filePath: path, ref: ref
11 ))
12 }
13
14 var body: some View {
15 Group {
16 if let file = model.state.value {
17 content(file)
18 } else {
19 Color.clear
20 }
21 }
22 .overlay { LoadStateOverlay(state: model.state) }
23 .navigationTitle(model.fileName)
24 .navigationBarTitleDisplayMode(.inline)
25 .task { await model.load() }
26 }
27
28 @ViewBuilder
29 private func content(_ file: FileContent) -> some View {
30 if file.binary {
31 ContentUnavailableView {
32 Label("Binary file", systemImage: "doc.zipper")
33 } description: {
34 Text("\(file.file) · \(Int64(file.size).formatted(.byteCount(style: .file)))")
35 }
36 } else if let text = file.content {
37 VStack(spacing: 0) {
38 if file.isTruncated {
39 Label(
40 "Truncated — showing the first \(Int64(file.size).formatted(.byteCount(style: .file))).",
41 systemImage: "scissors"
42 )
43 .font(.caption)
44 .frame(maxWidth: .infinity, alignment: .leading)
45 .padding(8)
46 .background(.yellow.opacity(0.15))
47 }
48 CodeScrollView(text: text, fileName: model.fileName, colorScheme: colorScheme)
49 }
50 }
51 }
52}
53
54/// Both-axis scrolling for code, highlighted when the language is known.
55private struct CodeScrollView: View {
56
57 let text: String
58 let fileName: String
59 let colorScheme: ColorScheme
60
61 var body: some View {
62 ScrollView([.horizontal, .vertical]) {
63 code
64 .padding(12)
65 .frame(maxWidth: .infinity, alignment: .leading)
66 }
67 .font(.caption.monospaced())
68 }
69
70 private var code: some View {
71 let font = UIFont.monospacedSystemFont(
72 ofSize: UIFont.preferredFont(forTextStyle: .caption1).pointSize, weight: .regular
73 )
74 let highlighter = SyntaxHighlighter(colorScheme: colorScheme)
75 if let highlighted = highlighter.highlight(text, fileName: fileName, font: font) {
76 return Text(AttributedString(highlighted))
77 }
78 return Text(text)
79 }
80}
gitbay/Views/Repos/LoadStateOverlay.swift added +33
@@ -0,0 +1,33 @@
1import SwiftUI
2
3/// The three non-content states of any screen, rendered consistently.
4struct LoadStateOverlay<Value: Sendable>: View {
5
6 let state: LoadState<Value>
7 /// Whether the underlying screen is showing nothing a filter can
8 /// empty a loaded list.
9 var isEmpty = false
10
11 var body: some View {
12 switch state {
13 case .loading:
14 ProgressView()
15 case .empty(let message):
16 ContentUnavailableView {
17 Label("Nothing here", systemImage: "tray")
18 } description: {
19 Text(message)
20 }
21 case .failed(let message):
22 ContentUnavailableView {
23 Label("Could not load", systemImage: "wifi.exclamationmark")
24 } description: {
25 Text(message)
26 }
27 case .loaded:
28 if isEmpty {
29 ContentUnavailableView.search
30 }
31 }
32 }
33}
gitbay/Views/Repos/LogView.swift added +92
@@ -0,0 +1,92 @@
1import SwiftUI
2
3struct LogView: View {
4
5 @State private var model: LogViewModel
6
7 init(client: GitbayClient, repo: String) {
8 _model = State(initialValue: LogViewModel(client: client, repoPath: repo))
9 }
10
11 var body: some View {
12 List {
13 ForEach(model.state.value ?? []) { commit in
14 CommitRow(commit: commit)
15 }
16 }
17 .overlay { LoadStateOverlay(state: model.state) }
18 .navigationTitle("History")
19 .navigationBarTitleDisplayMode(.inline)
20 .task { await model.load() }
21 .refreshable { await model.load() }
22 }
23}
24
25struct CommitRow: View {
26 let commit: Commit
27
28 var body: some View {
29 VStack(alignment: .leading, spacing: 4) {
30 Text(commit.subject)
31 .font(.subheadline.weight(.medium))
32 .lineLimit(2)
33 HStack(spacing: 6) {
34 Text(commit.shortSHA)
35 .font(.caption.monospaced())
36 .foregroundStyle(.secondary)
37 SignatureBadge(signature: commit.signature)
38 Spacer()
39 Text(commit.authorName)
40 .font(.caption)
41 .foregroundStyle(.secondary)
42 .lineLimit(1)
43 Text(commit.date, format: .relative(presentation: .named))
44 .font(.caption)
45 .foregroundStyle(.tertiary)
46 }
47 }
48 .padding(.vertical, 2)
49 }
50}
51
52/// The server's signature verdict, worn as a small badge. The states come
53/// from the registry; the app only chooses glyph and colour.
54struct SignatureBadge: View {
55 let signature: Commit.Signature
56
57 var body: some View {
58 if let (icon, color, text) = presentation {
59 Label {
60 Text(text)
61 } icon: {
62 Image(systemName: icon)
63 }
64 .font(.caption2)
65 .foregroundStyle(color)
66 .labelStyle(.titleAndIcon)
67 }
68 }
69
70 /// Unsigned commits are the norm no badge at all. Everything else
71 /// says what the server concluded.
72 private var presentation: (String, Color, String)? {
73 switch signature.state {
74 case .unsigned:
75 nil
76 case .verified:
77 ("checkmark.seal.fill", .green, signature.signer ?? "verified")
78 case .signedUnknownKey:
79 ("questionmark.diamond", .secondary, "unknown key")
80 case .signedEmailMismatch:
81 ("exclamationmark.triangle", .orange, "email mismatch")
82 case .signedKeyExpired:
83 ("clock.badge.exclamationmark", .orange, "key expired")
84 case .signedKeyRevoked:
85 ("xmark.seal", .red, "key revoked")
86 case .badSignature:
87 ("xmark.seal.fill", .red, "bad signature")
88 case .unrecognized(let state):
89 ("questionmark.circle", .secondary, state)
90 }
91 }
92}
gitbay/Views/Repos/MarkdownView.swift added +177
@@ -0,0 +1,177 @@
1import SwiftUI
2
3/// Block-level markdown for READMEs: headings, fenced code, lists, quotes,
4/// paragraphs. Inline styling comes from AttributedString's own markdown
5/// parsing; blocks are split here because it only does inline.
6struct MarkdownView: View {
7
8 let markdown: String
9
10 var body: some View {
11 VStack(alignment: .leading, spacing: 12) {
12 ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
13 blockView(block)
14 }
15 }
16 }
17
18 // MARK: - Block model
19
20 private enum Block {
21 case heading(level: Int, text: String)
22 case code(String)
23 case quote(String)
24 case bullet([String])
25 case ordered([String])
26 case rule
27 case paragraph(String)
28 }
29
30 private var blocks: [Block] {
31 var blocks: [Block] = []
32 var lines = markdown.components(separatedBy: "\n")[...]
33
34 while let line = lines.first {
35 let trimmed = line.trimmingCharacters(in: .whitespaces)
36
37 if trimmed.hasPrefix("```") {
38 lines = lines.dropFirst()
39 var code: [String] = []
40 while let next = lines.first,
41 !next.trimmingCharacters(in: .whitespaces).hasPrefix("```") {
42 code.append(next)
43 lines = lines.dropFirst()
44 }
45 lines = lines.dropFirst() // closing fence
46 blocks.append(.code(code.joined(separator: "\n")))
47 continue
48 }
49 if trimmed.hasPrefix("#") {
50 let level = trimmed.prefix(while: { $0 == "#" }).count
51 let text = trimmed.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces)
52 blocks.append(.heading(level: min(level, 4), text: text))
53 lines = lines.dropFirst()
54 continue
55 }
56 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
57 blocks.append(.rule)
58 lines = lines.dropFirst()
59 continue
60 }
61 if trimmed.hasPrefix(">") {
62 var quote: [String] = []
63 while let next = lines.first?.trimmingCharacters(in: .whitespaces), next.hasPrefix(">") {
64 quote.append(next.dropFirst().trimmingCharacters(in: .whitespaces))
65 lines = lines.dropFirst()
66 }
67 blocks.append(.quote(quote.joined(separator: " ")))
68 continue
69 }
70 if trimmed.hasPrefix("- ") || trimmed.hasPrefix("* ") || trimmed.hasPrefix("+ ") {
71 var items: [String] = []
72 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
73 next.hasPrefix("- ") || next.hasPrefix("* ") || next.hasPrefix("+ ") {
74 items.append(String(next.dropFirst(2)))
75 lines = lines.dropFirst()
76 }
77 blocks.append(.bullet(items))
78 continue
79 }
80 if trimmed.range(of: #"^\d+\. "#, options: .regularExpression) != nil {
81 var items: [String] = []
82 while let next = lines.first?.trimmingCharacters(in: .whitespaces),
83 let range = next.range(of: #"^\d+\. "#, options: .regularExpression) {
84 items.append(String(next[range.upperBound...]))
85 lines = lines.dropFirst()
86 }
87 blocks.append(.ordered(items))
88 continue
89 }
90 if trimmed.isEmpty {
91 lines = lines.dropFirst()
92 continue
93 }
94 // Paragraph: consume until a blank line or another block start.
95 var paragraph: [String] = []
96 while let next = lines.first {
97 let t = next.trimmingCharacters(in: .whitespaces)
98 if t.isEmpty || t.hasPrefix("#") || t.hasPrefix("```") || t.hasPrefix(">")
99 || t.hasPrefix("- ") || t.hasPrefix("* ") {
100 break
101 }
102 paragraph.append(t)
103 lines = lines.dropFirst()
104 }
105 blocks.append(.paragraph(paragraph.joined(separator: " ")))
106 }
107 return blocks
108 }
109
110 // MARK: - Rendering
111
112 @ViewBuilder
113 private func blockView(_ block: Block) -> some View {
114 switch block {
115 case .heading(let level, let text):
116 inline(text)
117 .font(headingFont(level))
118 .padding(.top, level <= 2 ? 8 : 4)
119 case .code(let code):
120 ScrollView(.horizontal) {
121 Text(code)
122 .font(.caption.monospaced())
123 .padding(10)
124 }
125 .background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 8))
126 case .quote(let text):
127 HStack(spacing: 10) {
128 RoundedRectangle(cornerRadius: 2)
129 .fill(.tertiary)
130 .frame(width: 3)
131 inline(text).foregroundStyle(.secondary)
132 }
133 .fixedSize(horizontal: false, vertical: true)
134 case .bullet(let items):
135 VStack(alignment: .leading, spacing: 4) {
136 ForEach(Array(items.enumerated()), id: \.offset) { _, item in
137 HStack(alignment: .firstTextBaseline, spacing: 8) {
138 Text("")
139 inline(item)
140 }
141 }
142 }
143 case .ordered(let items):
144 VStack(alignment: .leading, spacing: 4) {
145 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
146 HStack(alignment: .firstTextBaseline, spacing: 8) {
147 Text("\(index + 1).").monospacedDigit()
148 inline(item)
149 }
150 }
151 }
152 case .rule:
153 Divider()
154 case .paragraph(let text):
155 inline(text)
156 }
157 }
158
159 private func inline(_ text: String) -> Text {
160 if let attributed = try? AttributedString(
161 markdown: text,
162 options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)
163 ) {
164 return Text(attributed)
165 }
166 return Text(text)
167 }
168
169 private func headingFont(_ level: Int) -> Font {
170 switch level {
171 case 1: .title2.bold()
172 case 2: .title3.bold()
173 case 3: .headline
174 default: .subheadline.bold()
175 }
176 }
177}
gitbay/Views/Repos/RepoListView.swift added +88
@@ -0,0 +1,88 @@
1import SwiftUI
2
3struct RepoListView: View {
4
5 @Environment(SessionStore.self) private var session
6 @State private var model: RepoListViewModel
7
8 init(client: GitbayClient) {
9 _model = State(initialValue: RepoListViewModel(client: client))
10 }
11
12 var body: some View {
13 List {
14 ForEach(model.visibleRepos) { repo in
15 NavigationLink(value: RepoRoute.repo(repo.path)) {
16 RepoRow(repo: repo)
17 }
18 }
19 }
20 .overlay { LoadStateOverlay(state: model.state, isEmpty: model.visibleRepos.isEmpty) }
21 .searchable(text: Bindable(model).searchText, prompt: "Filter repositories")
22 .navigationTitle("Repositories")
23 .toolbar { AccountMenu() }
24 .task { await model.load() }
25 .refreshable { await model.load() }
26 }
27}
28
29private struct RepoRow: View {
30 let repo: RepoSummary
31
32 var body: some View {
33 VStack(alignment: .leading, spacing: 3) {
34 HStack(spacing: 6) {
35 Text(repo.path)
36 .font(.body.weight(.medium))
37 .lineLimit(1)
38 if repo.isPrivate {
39 Image(systemName: "lock.fill")
40 .font(.caption2)
41 .foregroundStyle(.secondary)
42 }
43 if repo.isArchived {
44 Text("archived")
45 .font(.caption2)
46 .padding(.horizontal, 5)
47 .padding(.vertical, 1)
48 .background(.quaternary, in: Capsule())
49 }
50 }
51 if let description = repo.description, !description.isEmpty {
52 Text(description)
53 .font(.subheadline)
54 .foregroundStyle(.secondary)
55 .lineLimit(2)
56 }
57 }
58 .padding(.vertical, 2)
59 }
60}
61
62/// The account switcher and sign-out, on every top-level screen.
63struct AccountMenu: ToolbarContent {
64
65 @Environment(SessionStore.self) private var session
66
67 var body: some ToolbarContent {
68 ToolbarItem(placement: .topBarTrailing) {
69 Menu {
70 if let current = session.current {
71 Section(current.label) {
72 Button("Sign Out", role: .destructive) {
73 session.remove(current)
74 }
75 }
76 }
77 ForEach(session.accounts.filter { $0.id != session.current?.id }) { account in
78 Button(account.label) {
79 session.activate(account)
80 }
81 }
82 NavigationLink("Add Account", value: RepoRoute.addAccount)
83 } label: {
84 Image(systemName: "person.crop.circle")
85 }
86 }
87 }
88}
gitbay/Views/Repos/RepoRoute.swift added +11
@@ -0,0 +1,11 @@
1import Foundation
2
3/// Navigation destinations for the repo stack. Values, so navigation is
4/// testable and deep-linkable later.
5nonisolated enum RepoRoute: Hashable {
6 case repo(String)
7 case tree(repo: String, directory: String, ref: String?)
8 case file(repo: String, path: String, ref: String?)
9 case log(repo: String)
10 case addAccount
11}
gitbay/Views/Repos/RepoView.swift added +85
@@ -0,0 +1,85 @@
1import SwiftUI
2
3struct RepoView: View {
4
5 @State private var model: RepoDetailViewModel
6 private let path: String
7
8 init(client: GitbayClient, path: String) {
9 self.path = path
10 _model = State(initialValue: RepoDetailViewModel(client: client, path: path))
11 }
12
13 var body: some View {
14 List {
15 if let detail = model.state.value {
16 header(detail)
17
18 Section {
19 NavigationLink(value: RepoRoute.tree(repo: path, directory: "", ref: nil)) {
20 Label("Files", systemImage: "folder")
21 }
22 NavigationLink(value: RepoRoute.log(repo: path)) {
23 Label("History", systemImage: "clock")
24 }
25 }
26
27 if let readme = model.readme {
28 Section("README") {
29 MarkdownView(markdown: readme)
30 .padding(.vertical, 4)
31 }
32 }
33 }
34 }
35 .overlay { LoadStateOverlay(state: model.state) }
36 .navigationTitle(String(path.split(separator: "/").last ?? ""))
37 .navigationBarTitleDisplayMode(.inline)
38 .task { await model.load() }
39 .refreshable { await model.load() }
40 }
41
42 @ViewBuilder
43 private func header(_ detail: RepoDetail) -> some View {
44 Section {
45 VStack(alignment: .leading, spacing: 8) {
46 HStack(spacing: 6) {
47 Text(detail.path)
48 .font(.headline)
49 if detail.visibility == "private" {
50 Image(systemName: "lock.fill")
51 .font(.caption)
52 .foregroundStyle(.secondary)
53 }
54 if detail.isArchived {
55 Text("archived")
56 .font(.caption2)
57 .padding(.horizontal, 5)
58 .padding(.vertical, 1)
59 .background(.quaternary, in: Capsule())
60 }
61 }
62 if let description = detail.description, !description.isEmpty {
63 Text(description)
64 .font(.subheadline)
65 .foregroundStyle(.secondary)
66 }
67 if let website = detail.website, let url = URL(string: website) {
68 Link(website, destination: url)
69 .font(.caption)
70 .lineLimit(1)
71 }
72 HStack(spacing: 12) {
73 Label(detail.defaultBranch, systemImage: "arrow.triangle.branch")
74 if let topics = detail.topics, !topics.isEmpty {
75 Text(topics.joined(separator: " · "))
76 .lineLimit(1)
77 }
78 }
79 .font(.caption)
80 .foregroundStyle(.secondary)
81 }
82 .padding(.vertical, 4)
83 }
84 }
85}
gitbay/Views/Repos/SyntaxHighlighter.swift added +87
@@ -0,0 +1,87 @@
1import Foundation
2import Highlightr
3import SwiftUI
4import UIKit
5
6/// Adapted from hutch: Highlightr wraps a JavaScriptCore context and is
7/// not thread-safe, so an instance stays where it was made. A `nil` result
8/// means "show plain text", never an error.
9nonisolated final class SyntaxHighlighter {
10
11 private let highlightr: Highlightr?
12 private let supported: Set<String>
13
14 init(colorScheme: ColorScheme) {
15 let engine = Highlightr()
16 // Xcode-like in light, muted in dark, so code reads as native.
17 engine?.setTheme(to: colorScheme == .dark ? "atom-one-dark" : "xcode")
18 highlightr = engine
19 supported = Set(engine?.supportedLanguages() ?? [])
20 }
21
22 func highlight(_ code: String, fileName: String, font: UIFont) -> NSAttributedString? {
23 guard let highlightr, let language = Self.language(for: fileName),
24 supported.contains(language) else {
25 return nil
26 }
27 highlightr.theme.setCodeFont(font)
28 return highlightr.highlight(code, as: language, fastRender: true)
29 }
30
31 /// File extension highlight.js language name. Unknown is fine; the
32 /// caller falls back to plain text.
33 static func language(for fileName: String) -> String? {
34 let name = fileName.lowercased()
35 if name == "makefile" { return "makefile" }
36 if name == "dockerfile" { return "dockerfile" }
37 guard let ext = name.split(separator: ".").last, ext != name else { return nil }
38 switch ext {
39 case "swift": return "swift"
40 case "go": return "go"
41 case "rs": return "rust"
42 case "py": return "python"
43 case "rb": return "ruby"
44 case "js", "mjs", "cjs": return "javascript"
45 case "ts", "tsx": return "typescript"
46 case "jsx": return "javascript"
47 case "c", "h": return "c"
48 case "cc", "cpp", "cxx", "hpp": return "cpp"
49 case "m": return "objectivec"
50 case "cs": return "csharp"
51 case "java": return "java"
52 case "kt", "kts": return "kotlin"
53 case "sh", "bash", "zsh": return "bash"
54 case "fish": return "shell"
55 case "php": return "php"
56 case "pl": return "perl"
57 case "lua": return "lua"
58 case "html", "htm": return "xml"
59 case "xml", "plist", "storyboard", "xib": return "xml"
60 case "css": return "css"
61 case "scss", "sass": return "scss"
62 case "json": return "json"
63 case "yml", "yaml": return "yaml"
64 case "toml", "ini", "cfg": return "ini"
65 case "md", "markdown": return "markdown"
66 case "org": return "plaintext"
67 case "sql": return "sql"
68 case "diff", "patch": return "diff"
69 case "proto": return "protobuf"
70 case "cmake": return "cmake"
71 case "gradle": return "gradle"
72 case "ex", "exs": return "elixir"
73 case "erl": return "erlang"
74 case "hs": return "haskell"
75 case "ml", "mli": return "ocaml"
76 case "clj", "cljs": return "clojure"
77 case "scala": return "scala"
78 case "r": return "r"
79 case "dart": return "dart"
80 case "zig": return "plaintext"
81 case "vim": return "vim"
82 case "tex": return "latex"
83 case "txt", "text", "log": return nil
84 default: return nil
85 }
86 }
87}
gitbay/Views/Repos/TreeView.swift added +72
@@ -0,0 +1,72 @@
1import SwiftUI
2
3struct TreeView: View {
4
5 @State private var model: TreeViewModel
6
7 init(client: GitbayClient, repo: String, directory: String, ref: String?) {
8 _model = State(initialValue: TreeViewModel(
9 client: client, repoPath: repo, directory: directory, ref: ref
10 ))
11 }
12
13 var body: some View {
14 List {
15 ForEach(model.entries) { entry in
16 if entry.isDirectory {
17 NavigationLink(value: RepoRoute.tree(
18 repo: model.repoPath,
19 directory: model.childDirectory(entry),
20 ref: model.ref
21 )) {
22 EntryRow(entry: entry)
23 }
24 } else {
25 NavigationLink(value: RepoRoute.file(
26 repo: model.repoPath,
27 path: model.childDirectory(entry),
28 ref: model.ref
29 )) {
30 EntryRow(entry: entry)
31 }
32 }
33 }
34 }
35 .overlay { LoadStateOverlay(state: model.state) }
36 .navigationTitle(model.directory.isEmpty
37 ? String(model.repoPath.split(separator: "/").last ?? "")
38 : String(model.directory.split(separator: "/").last ?? ""))
39 .navigationBarTitleDisplayMode(.inline)
40 .task { await model.load() }
41 .refreshable { await model.load() }
42 }
43}
44
45private struct EntryRow: View {
46 let entry: TreeEntry
47
48 var body: some View {
49 HStack {
50 Label {
51 Text(entry.name)
52 .lineLimit(1)
53 } icon: {
54 Image(systemName: icon)
55 .foregroundStyle(entry.isDirectory ? Color.accentColor : .secondary)
56 }
57 Spacer()
58 if let size = entry.size, !entry.isDirectory {
59 Text(size.formatted(.byteCount(style: .file)))
60 .font(.caption)
61 .foregroundStyle(.tertiary)
62 .monospacedDigit()
63 }
64 }
65 }
66
67 private var icon: String {
68 if entry.isDirectory { return "folder.fill" }
69 if entry.isSymlink { return "arrow.turn.down.right" }
70 return "doc"
71 }
72}
gitbayTests/RepoViewModelTests.swift added +263
@@ -0,0 +1,263 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private 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
15private let repoListJSON = """
16 {"protocol_version":1,"data":[\
17 {"path":"krz/hutch","visibility":"public","description":"SourceHut iOS client"},\
18 {"path":"krz/gitbay","visibility":"public","description":"a CLI-first git forge"},\
19 {"path":"krz/secrets","visibility":"private","archived":true}\
20 ],"exit_code":0}
21 """
22
23private let repoShowJSON = """
24 {"protocol_version":1,"data":{"path":"krz/gitbay","description":"a CLI-first git forge",\
25 "visibility":"public","default_branch":"main","topics":["git","forge"],\
26 "protected_branches":["main"]},"exit_code":0}
27 """
28
29private let rootTreeJSON = """
30 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"","entries":[\
31 {"name":"internal","type":"tree","mode":"040000","sha":"aaa1"},\
32 {"name":"README.md","type":"blob","mode":"100644","sha":"bbb2","size":1200},\
33 {"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}\
34 ]},"exit_code":0}
35 """
36
37private let readmeJSON = """
38 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"README.md",\
39 "size":18,"binary":false,"content":"# gitbay\\n\\na forge"},"exit_code":0}
40 """
41
42private let binaryFileJSON = """
43 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","file":"logo.png",\
44 "size":4,"binary":true,"base64":"iVBORw=="},"exit_code":0}
45 """
46
47private let logJSON = """
48 {"protocol_version":1,"data":[\
49 {"sha":"65ba14e0000000000000000000000000000000ab","subject":"httpd: GET /api/v1/read",\
50 "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-20T10:00:00Z",\
51 "signature":{"state":"verified","signer":"krz","key_fingerprint":"SHA256:abc"}},\
52 {"sha":"7953e780000000000000000000000000000000cd","subject":"httpd: rate limit",\
53 "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-19T10:00:00Z",\
54 "signature":{"state":"unsigned"}},\
55 {"sha":"40dab3e0000000000000000000000000000000ef","subject":"control: repo tree and cat",\
56 "author_name":"krz","author_email":"krz@gitbay.org","date":"2026-08-18T10:00:00Z",\
57 "signature":{"state":"one_day_a_new_state"}}\
58 ],"exit_code":0}
59 """
60
61@MainActor
62struct RepoListViewModelTests {
63
64 @Test func loadsAndSortsByPath() async throws {
65 let (client, stub) = try makeClient()
66 stub.enqueue(.init(status: 200, json: repoListJSON))
67 let model = RepoListViewModel(client: client)
68
69 await model.load()
70
71 #expect(model.visibleRepos.map(\.path) == ["krz/gitbay", "krz/hutch", "krz/secrets"])
72 #expect(model.visibleRepos[2].isPrivate)
73 #expect(model.visibleRepos[2].isArchived)
74 }
75
76 @Test func filterMatchesPathAndDescription() async throws {
77 let (client, stub) = try makeClient()
78 stub.enqueue(.init(status: 200, json: repoListJSON))
79 let model = RepoListViewModel(client: client)
80 await model.load()
81
82 model.searchText = "SourceHut"
83 #expect(model.visibleRepos.map(\.path) == ["krz/hutch"])
84
85 model.searchText = "gitb"
86 #expect(model.visibleRepos.map(\.path) == ["krz/gitbay"])
87 }
88
89 @Test func anEmptyListIsAnEmptyStateNotAnError() async throws {
90 let (client, stub) = try makeClient()
91 stub.enqueue(.init(status: 200, json: #"{"protocol_version":1,"exit_code":0}"#))
92 let model = RepoListViewModel(client: client)
93
94 await model.load()
95
96 guard case .empty = model.state else {
97 Issue.record("expected .empty, got \(model.state)")
98 return
99 }
100 }
101}
102
103@MainActor
104struct RepoDetailViewModelTests {
105
106 @Test func loadsHeaderThenFindsAndFetchesReadme() async throws {
107 let (client, stub) = try makeClient()
108 stub.enqueue(.init(status: 200, json: repoShowJSON))
109 stub.enqueue(.init(status: 200, json: rootTreeJSON))
110 stub.enqueue(.init(status: 200, json: readmeJSON))
111 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
112
113 await model.load()
114
115 let detail = try #require(model.state.value)
116 #expect(detail.defaultBranch == "main")
117 #expect(detail.topics == ["git", "forge"])
118 #expect(model.readme == "# gitbay\n\na forge")
119 // The README was fetched by name from the tree listing.
120 let catRequest = try #require(stub.seen.last)
121 #expect(catRequest.url.query()?.contains("argv=README.md") == true)
122 }
123
124 @Test func aRepoWithoutAReadmeIsFine() async throws {
125 let (client, stub) = try makeClient()
126 stub.enqueue(.init(status: 200, json: repoShowJSON))
127 stub.enqueue(.init(status: 200, json: """
128 {"protocol_version":1,"data":{"path":"krz/gitbay","ref":"main","dir":"",\
129 "entries":[{"name":"main.go","type":"blob","mode":"100644","sha":"ccc3","size":300}]},\
130 "exit_code":0}
131 """))
132 let model = RepoDetailViewModel(client: client, path: "krz/gitbay")
133
134 await model.load()
135
136 #expect(model.state.value != nil)
137 #expect(model.readme == nil)
138 #expect(stub.seen.count == 2) // no cat issued
139 }
140
141 @Test func aMissingRepoIsAnEmptyState() async throws {
142 let (client, stub) = try makeClient()
143 stub.enqueue(.init(status: 404, json:
144 #"{"protocol_version":1,"error":"no such repository krz/nope","exit_code":3}"#))
145 let model = RepoDetailViewModel(client: client, path: "krz/nope")
146
147 await model.load()
148
149 guard case .empty(let message) = model.state else {
150 Issue.record("expected .empty, got \(model.state)")
151 return
152 }
153 #expect(message == "no such repository krz/nope")
154 }
155}
156
157@MainActor
158struct TreeViewModelTests {
159
160 @Test func directoriesSortFirstThenAlphabetical() async throws {
161 let (client, stub) = try makeClient()
162 stub.enqueue(.init(status: 200, json: rootTreeJSON))
163 let model = TreeViewModel(client: client, repoPath: "krz/gitbay")
164
165 await model.load()
166
167 #expect(model.entries.map(\.name) == ["internal", "main.go", "README.md"])
168 }
169
170 @Test func childPathsNestCorrectly() async throws {
171 let (client, stub) = try makeClient()
172 stub.enqueue(.init(status: 200, json: rootTreeJSON))
173 let root = TreeViewModel(client: client, repoPath: "krz/gitbay")
174 await root.load()
175 let dir = try #require(root.entries.first { $0.isDirectory })
176
177 #expect(root.childDirectory(dir) == "internal")
178
179 let nested = TreeViewModel(client: client, repoPath: "krz/gitbay", directory: "internal")
180 #expect(nested.childDirectory(dir) == "internal/internal")
181 }
182
183 @Test func refIsForwardedWhenSet() async throws {
184 let (client, stub) = try makeClient()
185 stub.enqueue(.init(status: 200, json: rootTreeJSON))
186 let model = TreeViewModel(
187 client: client, repoPath: "krz/gitbay", directory: "internal", ref: "dev"
188 )
189
190 await model.load()
191
192 let seen = try #require(stub.seen.first)
193 #expect(seen.url.query() ==
194 "argv=repo&argv=tree&argv=krz/gitbay&argv=internal&argv=--ref&argv=dev")
195 }
196}
197
198@MainActor
199struct FileViewModelTests {
200
201 @Test func textFileLoads() async throws {
202 let (client, stub) = try makeClient()
203 stub.enqueue(.init(status: 200, json: readmeJSON))
204 let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "README.md")
205
206 await model.load()
207
208 let file = try #require(model.state.value)
209 #expect(file.content?.hasPrefix("# gitbay") == true)
210 #expect(!file.binary)
211 #expect(model.fileName == "README.md")
212 }
213
214 @Test func binaryFileCarriesBase64NotText() async throws {
215 let (client, stub) = try makeClient()
216 stub.enqueue(.init(status: 200, json: binaryFileJSON))
217 let model = FileViewModel(client: client, repoPath: "krz/gitbay", filePath: "logo.png")
218
219 await model.load()
220
221 let file = try #require(model.state.value)
222 #expect(file.binary)
223 #expect(file.content == nil)
224 #expect(file.data != nil)
225 }
226}
227
228@MainActor
229struct LogViewModelTests {
230
231 @Test func commitsDecodeWithSignatureStates() async throws {
232 let (client, stub) = try makeClient()
233 stub.enqueue(.init(status: 200, json: logJSON))
234 let model = LogViewModel(client: client, repoPath: "krz/gitbay")
235
236 await model.load()
237
238 let commits = try #require(model.state.value)
239 #expect(commits.count == 3)
240 #expect(commits[0].signature.state == .verified)
241 #expect(commits[0].signature.signer == "krz")
242 #expect(commits[1].signature.state == .unsigned)
243 // A state this build has never heard of must not sink the log.
244 #expect(commits[2].signature.state == .unrecognized("one_day_a_new_state"))
245 #expect(commits[0].shortSHA == "65ba14e000")
246 }
247}
248
249struct SyntaxHighlighterLanguageTests {
250
251 @Test func commonExtensionsResolve() {
252 #expect(SyntaxHighlighter.language(for: "GitbayClient.swift") == "swift")
253 #expect(SyntaxHighlighter.language(for: "read.go") == "go")
254 #expect(SyntaxHighlighter.language(for: "Makefile") == "makefile")
255 #expect(SyntaxHighlighter.language(for: "index.test.ts") == "typescript")
256 }
257
258 @Test func unknownExtensionsFallBackToPlain() {
259 #expect(SyntaxHighlighter.language(for: "notes.txt") == nil)
260 #expect(SyntaxHighlighter.language(for: "LICENSE") == nil)
261 #expect(SyntaxHighlighter.language(for: "weird.xyz123") == nil)
262 }
263}