Commit d98ae629c2
Verified · cmc
gitbay.xcodeproj/project.pbxproj +28
| @@ -6,6 +6,10 @@ | ||
| 6 | 6 | objectVersion = 77; |
| 7 | 7 | objects = { |
| 8 | 8 | |
| 9 | /* Begin PBXBuildFile section */ | |
| 10 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */ = {isa = PBXBuildFile; productRef = 8B2898B1303FF3E5001BF44A /* Highlightr */; }; | |
| 11 | /* End PBXBuildFile section */ | |
| 12 | ||
| 9 | 13 | /* Begin PBXContainerItemProxy section */ |
| 10 | 14 | 8B2898A7303FF3E5001BF44A /* PBXContainerItemProxy */ = { |
| 11 | 15 | isa = PBXContainerItemProxy; |
| @@ -39,6 +43,7 @@ | ||
| 39 | 43 | isa = PBXFrameworksBuildPhase; |
| 40 | 44 | buildActionMask = 2147483647; |
| 41 | 45 | files = ( |
| 46 | 8B289850303FF914001BF44A /* Highlightr in Frameworks */, | |
| 42 | 47 | ); |
| 43 | 48 | runOnlyForDeploymentPostprocessing = 0; |
| 44 | 49 | }; |
| @@ -90,6 +95,7 @@ | ||
| 90 | 95 | ); |
| 91 | 96 | name = gitbay; |
| 92 | 97 | packageProductDependencies = ( |
| 98 | 8B2898B1303FF3E5001BF44A /* Highlightr */, | |
| 93 | 99 | ); |
| 94 | 100 | productName = gitbay; |
| 95 | 101 | productReference = 8B289812303FF3E5001BF44A /* gitbay.app */; |
| @@ -146,6 +152,9 @@ | ||
| 146 | 152 | ); |
| 147 | 153 | mainGroup = 8B289809303FF3E5001BF44A; |
| 148 | 154 | minimizedProjectReferenceProxies = 1; |
| 155 | packageReferences = ( | |
| 156 | 8B2898B0303FF3E5001BF44A /* XCRemoteSwiftPackageReference "Highlightr" */, | |
| 157 | ); | |
| 149 | 158 | preferredProjectObjectVersion = 77; |
| 150 | 159 | productRefGroup = 8B289813303FF3E5001BF44A /* Products */; |
| 151 | 160 | projectDirPath = ""; |
| @@ -456,6 +465,25 @@ | ||
| 456 | 465 | defaultConfigurationName = Release; |
| 457 | 466 | }; |
| 458 | 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 | 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 | 7 | var body: some View { |
| 8 | 8 | 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 | } | |
| 11 | 15 | } else { |
| 12 | 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 { | |
| 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() | |
| 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 | 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 | 217 | private func decode<Payload: Decodable & Sendable>( |
| 211 | 218 | _ body: Data, |
| 212 | 219 | status: Int, |
| @@ -218,7 +225,7 @@ nonisolated final class GitbayClient: Sendable { | ||
| 218 | 225 | // has — an error envelope never matches the expected payload. |
| 219 | 226 | let probe: Envelope<NoPayload> |
| 220 | 227 | do { |
| 221 | probe = try JSONDecoder().decode(Envelope<NoPayload>.self, from: body) | |
| 228 | probe = try Self.decoder().decode(Envelope<NoPayload>.self, from: body) | |
| 222 | 229 | } catch { |
| 223 | 230 | throw GitbayError.decoding(error) |
| 224 | 231 | } |
| @@ -229,7 +236,7 @@ nonisolated final class GitbayClient: Sendable { | ||
| 229 | 236 | throw error |
| 230 | 237 | } |
| 231 | 238 | do { |
| 232 | return try JSONDecoder().decode(Envelope<Payload>.self, from: body) | |
| 239 | return try Self.decoder().decode(Envelope<Payload>.self, from: body) | |
| 233 | 240 | } catch { |
| 234 | 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 | } | |