Commit e0b57c49e8
Unsigned
Layout: unified · split
gitbay.xcodeproj/project.pbxproj +28
| @@ -6,6 +6,10 @@ | |||
| 6 | objectVersion = 77; | 6 | objectVersion = 77; |
| 7 | objects = { | 7 | objects = { |
| 8 | 8 | ||
| 9 | /* Begin PBXBuildFile section */ | ||
| 10 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; }; | ||
| 11 | /* End PBXBuildFile section */ | ||
| 12 | |||
| 9 | /* Begin PBXContainerItemProxy section */ | 13 | /* Begin PBXContainerItemProxy section */ |
| 10 | 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */ = { | 14 | 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */ = { |
| 11 | isa = PBXContainerItemProxy; | 15 | isa = PBXContainerItemProxy; |
| @@ -39,6 +43,7 @@ | |||
| 39 | isa = PBXFrameworksBuildPhase; | 43 | isa = PBXFrameworksBuildPhase; |
| 40 | buildActionMask = 2147483647; | 44 | buildActionMask = 2147483647; |
| 41 | files = ( | 45 | files = ( |
| 46 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */, | ||
| 42 | ); | 47 | ); |
| 43 | runOnlyForDeploymentPostprocessing = 0; | 48 | runOnlyForDeploymentPostprocessing = 0; |
| 44 | }; | 49 | }; |
| @@ -90,6 +95,7 @@ | |||
| 90 | ); | 95 | ); |
| 91 | name = gitbay; | 96 | name = gitbay; |
| 92 | packageProductDependencies = ( | 97 | packageProductDependencies = ( |
| 98 | 8B2898B1303FF3E5001BF44A /* Highlightr */, | ||
| 93 | ); | 99 | ); |
| 94 | productName = gitbay; | 100 | productName = gitbay; |
| 95 | productReference = 8B289812303FF3E5001BF44A /* gitbay.app */; | 101 | productReference = 8B289812303FF3E5001BF44A /* gitbay.app */; |
| @@ -146,6 +152,9 @@ | |||
| 146 | ); | 152 | ); |
| 147 | mainGroup = 8B289809303FF3E5001BF44A; | 153 | mainGroup = 8B289809303FF3E5001BF44A; |
| 148 | minimizedProjectReferenceProxies = 1; | 154 | minimizedProjectReferenceProxies = 1; |
| 155 | packageReferences = ( | ||
| 156 | 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */, | ||
| 157 | ); | ||
| 149 | preferredProjectObjectVersion = 77; | 158 | preferredProjectObjectVersion = 77; |
| 150 | productRefGroup = 8B289813303FF3E5001BF44A /* Products */; | 159 | productRefGroup = 8B289813303FF3E5001BF44A /* Products */; |
| 151 | projectDirPath = ""; | 160 | projectDirPath = ""; |
| @@ -456,6 +465,25 @@ | |||
| 456 | defaultConfigurationName = Release; | 465 | defaultConfigurationName = Release; |
| 457 | }; | 466 | }; |
| 458 | /* End XCConfigurationList section */ | 467 | /* 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 */ | ||
| 459 | }; | 487 | }; |
| 460 | rootObject = 8B28980A303FF3E5001BF44A /* Project object */; | 488 | rootObject = 8B28980A303FF3E5001BF44A /* Project object */; |
| 461 | } | 489 | } |
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 { | |||
| 6 | 6 | ||
| 7 | var body: some View { | 7 | var body: some View { |
| 8 | NavigationStack { | 8 | NavigationStack { |
| 9 | if let account = session.current { | 9 | if let client = session.client { |
| 10 | SignedInPlaceholderView(account: account) | 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 | } | ||
| 11 | } else { | 15 | } else { |
| 12 | SignInView() | 16 | SignInView() |
| 13 | } | 17 | } |
| 14 | } | 18 | } |
| 15 | } | 19 | } |
| 16 | } | ||
| 17 | |||
| 18 | /// Stands in until the repo list lands; proves sign-in, account switching | ||
| 19 | /// and sign-out end to end. | ||
| 20 | private 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 | } | ||
| 44 | 20 | ||
| 45 | Section { | 21 | @ViewBuilder |
| 46 | Button("Sign Out", role: .destructive) { | 22 | private func destination(_ route: RepoRoute, client: GitbayClient) -> some View { |
| 47 | session.remove(account) | 23 | switch route { |
| 48 | } | 24 | case .repo(let path): |
| 49 | } | 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() | ||
| 50 | } | 34 | } |
| 51 | .navigationTitle("gitbay") | ||
| 52 | } | 35 | } |
| 53 | } | 36 | } |
| 54 | 37 | ||
gitbay/Networking/GitbayClient.swift +9 −2
| @@ -207,6 +207,13 @@ nonisolated final class GitbayClient: Sendable { | |||
| 207 | 207 | ||
| 208 | private enum Surface { case read, command } | 208 | private enum Surface { case read, command } |
| 209 | 209 | ||
| 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 | |||
| 210 | private func decode<Payload: Decodable & Sendable>( | 217 | private func decode<Payload: Decodable & Sendable>( |
| 211 | _ body: Data, | 218 | _ body: Data, |
| 212 | status: Int, | 219 | status: Int, |
| @@ -218,7 +225,7 @@ nonisolated final class GitbayClient: Sendable { | |||
| 218 | // has — an error envelope never matches the expected payload. | 225 | // has — an error envelope never matches the expected payload. |
| 219 | let probe: Envelope<NoPayload> | 226 | let probe: Envelope<NoPayload> |
| 220 | do { | 227 | do { |
| 221 | probe = try JSONDecoder().decode(Envelope<NoPayload>.self, from: body) | 228 | probe = try Self.decoder().decode(Envelope<NoPayload>.self, from: body) |
| 222 | } catch { | 229 | } catch { |
| 223 | throw GitbayError.decoding(error) | 230 | throw GitbayError.decoding(error) |
| 224 | } | 231 | } |
| @@ -229,7 +236,7 @@ nonisolated final class GitbayClient: Sendable { | |||
| 229 | throw error | 236 | throw error |
| 230 | } | 237 | } |
| 231 | do { | 238 | do { |
| 232 | return try JSONDecoder().decode(Envelope<Payload>.self, from: body) | 239 | return try Self.decoder().decode(Envelope<Payload>.self, from: body) |
| 233 | } catch { | 240 | } catch { |
| 234 | throw GitbayError.decoding(error) | 241 | throw GitbayError.decoding(error) |
| 235 | } | 242 | } |
gitbay/Repos/FileViewModel.swift added +37
| @@ -0,0 +1,37 @@ | |||
| 1 | import Foundation | ||
| 2 | import 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 | ||
| 8 | final 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 @@ | |||
| 1 | import 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. | ||
| 5 | nonisolated 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 | |||
| 17 | extension 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// `repo log`: the default branch's history, signature verdict per commit. | ||
| 5 | @Observable | ||
| 6 | @MainActor | ||
| 7 | final 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import 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 | ||
| 8 | final 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | |||
| 4 | /// The `repo list` screen: everything you own or can reach, filterable. | ||
| 5 | @Observable | ||
| 6 | @MainActor | ||
| 7 | final 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// One row of `repo list`. | ||
| 4 | nonisolated 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>`. | ||
| 20 | nonisolated 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. | ||
| 40 | nonisolated 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. | ||
| 49 | nonisolated 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. | ||
| 62 | nonisolated 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. | ||
| 82 | nonisolated 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. | ||
| 116 | nonisolated 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import 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 | ||
| 8 | final 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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. | ||
| 55 | private 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | /// The three non-content states of any screen, rendered consistently. | ||
| 4 | struct 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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 | |||
| 25 | struct 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. | ||
| 54 | struct 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 @@ | |||
| 1 | import 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. | ||
| 6 | struct 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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 | |||
| 29 | private 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. | ||
| 63 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | /// Navigation destinations for the repo stack. Values, so navigation is | ||
| 4 | /// testable and deep-linkable later. | ||
| 5 | nonisolated 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Highlightr | ||
| 3 | import SwiftUI | ||
| 4 | import 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. | ||
| 9 | nonisolated 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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 | |||
| 45 | private 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 @@ | |||
| 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 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 | |||
| 23 | private 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 | |||
| 29 | private 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 | |||
| 37 | private 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 | |||
| 42 | private 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 | |||
| 47 | private 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 | ||
| 62 | struct 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 | ||
| 104 | struct 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 | ||
| 158 | struct 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 | ||
| 199 | struct 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 | ||
| 229 | struct 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 | |||
| 249 | struct 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 | } | ||