a native ios client for gitbay

client ios swift

https://gitbay.org

wiki: read a repository's pages !18

merged cmc wants to merge krz/gitbay-ios:wiki-screen into main

7 files changed, +305 −0

gitbay/ContentView.swift +4
@@ -90,6 +90,10 @@ private struct RouteDestinations: ViewModifier {
9090 RefsView(client: client, repo: repo)
9191 case .milestones(let repo):
9292 MilestoneListView(client: client, repo: repo)
93 case .wiki(let repo):
94 WikiView(client: client, repo: repo)
95 case .wikiPage(let repo, let page):
96 WikiPageView(client: client, repo: repo, page: page)
9397 case .commit(let repo, let sha):
9498 CommitView(client: client, repo: repo, sha: sha)
9599 case .commitDiff(let repo, let sha):
gitbay/Repos/WikiViewModel.swift added +78
@@ -0,0 +1,78 @@
1import Foundation
2import Observation
3
4/// `wiki list` the pages, and which one is the landing page.
5nonisolated struct WikiListing: Decodable, Sendable, Hashable {
6 let path: String
7 let home: String?
8 let pages: [String]
9}
10
11/// `wiki show` one page's source. Rendered by format, as a README is.
12nonisolated struct WikiPage: Decodable, Sendable, Hashable {
13 let path: String
14 let page: String
15 let file: String
16 let size: Int
17 let binary: Bool?
18 let content: String?
19
20 var isBinary: Bool { binary ?? false }
21}
22
23/// A repository's wiki. The pages live in a companion repo and are
24/// edited by pushing to it, so this is a reading surface only the
25/// same write interface every surface has.
26@Observable
27@MainActor
28final class WikiViewModel {
29
30 private(set) var state: LoadState<WikiListing> = .loading
31
32 private let client: GitbayClient
33 let repoPath: String
34
35 init(client: GitbayClient, repoPath: String) {
36 self.client = client
37 self.repoPath = repoPath
38 }
39
40 func load() async {
41 do {
42 let listing = try await client.read(
43 ["wiki", "list", repoPath], as: WikiListing.self)
44 state = listing.pages.isEmpty
45 ? .empty("This wiki has no pages yet.")
46 : .loaded(listing)
47 } catch {
48 state = .from(error)
49 }
50 }
51}
52
53/// One wiki page.
54@Observable
55@MainActor
56final class WikiPageViewModel {
57
58 private(set) var state: LoadState<WikiPage> = .loading
59
60 private let client: GitbayClient
61 let repoPath: String
62 let page: String
63
64 init(client: GitbayClient, repoPath: String, page: String) {
65 self.client = client
66 self.repoPath = repoPath
67 self.page = page
68 }
69
70 func load() async {
71 do {
72 state = .loaded(try await client.read(
73 ["wiki", "show", repoPath, page], as: WikiPage.self))
74 } catch {
75 state = .from(error)
76 }
77 }
78}
gitbay/Views/Repos/RepoRoute.swift +2
@@ -12,6 +12,8 @@ nonisolated enum RepoRoute: Hashable {
1212 case blame(repo: String, path: String, ref: String?)
1313 case refs(repo: String)
1414 case milestones(repo: String)
15 case wiki(repo: String)
16 case wikiPage(repo: String, page: String)
1517 case commit(repo: String, sha: String)
1618 case commitDiff(repo: String, sha: String)
1719 case profile(String)
gitbay/Views/Repos/RepoView.swift +3
@@ -47,6 +47,9 @@ struct RepoView: View {
4747 NavigationLink(value: ReleaseRoute.list(repo: path)) {
4848 Label("Releases", systemImage: "shippingbox")
4949 }
50 NavigationLink(value: RepoRoute.wiki(repo: path)) {
51 Label("Wiki", systemImage: "book")
52 }
5053 NavigationLink(value: RepoRoute.grep(repo: path)) {
5154 Label("Search in Files", systemImage: "text.magnifyingglass")
5255 }
gitbay/Views/Repos/WikiView.swift added +79
@@ -0,0 +1,79 @@
1import SwiftUI
2
3/// A repository's wiki pages, landing page first.
4struct WikiView: View {
5
6 @State private var model: WikiViewModel
7
8 init(client: GitbayClient, repo: String) {
9 _model = State(initialValue: WikiViewModel(client: client, repoPath: repo))
10 }
11
12 /// The landing page leads, then the rest in the order the server
13 /// gave them the same order the web's sidebar shows.
14 private var ordered: [String] {
15 guard let listing = model.state.value else { return [] }
16 guard let home = listing.home else { return listing.pages }
17 return [home] + listing.pages.filter { $0 != home }
18 }
19
20 var body: some View {
21 List {
22 ForEach(ordered, id: \.self) { page in
23 NavigationLink(value: RepoRoute.wikiPage(
24 repo: model.repoPath, page: page
25 )) {
26 HStack(spacing: 8) {
27 Label(page, systemImage: "doc.text")
28 .font(.gbSans(.subheadline))
29 if page == model.state.value?.home {
30 GBChip("home", .gbAccent)
31 }
32 }
33 }
34 }
35 }
36 .overlay { LoadStateOverlay(state: model.state) }
37 .navigationTitle("Wiki")
38 .navigationBarTitleDisplayMode(.inline)
39 .task { await model.load() }
40 .refreshable { await model.load() }
41 }
42}
43
44/// One wiki page, rendered by its format the pages here are .org as
45/// often as .md, so ReadmeView picks the renderer.
46struct WikiPageView: View {
47
48 @State private var model: WikiPageViewModel
49
50 init(client: GitbayClient, repo: String, page: String) {
51 _model = State(initialValue: WikiPageViewModel(
52 client: client, repoPath: repo, page: page
53 ))
54 }
55
56 var body: some View {
57 ScrollView {
58 if let page = model.state.value {
59 if page.isBinary {
60 ContentUnavailableView {
61 Label("Not a page", systemImage: "doc.zipper")
62 } description: {
63 Text(page.file)
64 }
65 } else if let content = page.content {
66 ReadmeView(name: page.file, content: content)
67 .padding(.horizontal, 16)
68 .padding(.vertical, 12)
69 .frame(maxWidth: .infinity, alignment: .leading)
70 }
71 }
72 }
73 .overlay { LoadStateOverlay(state: model.state) }
74 .navigationTitle(model.page)
75 .navigationBarTitleDisplayMode(.inline)
76 .task { await model.load() }
77 .refreshable { await model.load() }
78 }
79}
gitbayTests/WikiTests.swift added +110
@@ -0,0 +1,110 @@
1import Foundation
2import Testing
3@testable import gitbay
4
5private func makeClient() throws -> (GitbayClient, StubProtocol.Box) {
6 let box = StubProtocol.box()
7 let client = GitbayClient(
8 instance: try GitbayInstance(url: "https://gitbay.org"),
9 token: "test-token",
10 session: box.session()
11 )
12 return (client, box)
13}
14
15private let listingJSON = """
16 {"protocol_version":1,"data":{"path":"krz/gitbay","home":"Home",\
17 "pages":["API","Admin","Home","Parity"]},"exit_code":0}
18 """
19
20@MainActor
21struct WikiViewModelTests {
22
23 @Test func listsPagesAndNamesTheLandingPage() async throws {
24 let (client, stub) = try makeClient()
25 stub.enqueue(.init(status: 200, json: listingJSON))
26 let model = WikiViewModel(client: client, repoPath: "krz/gitbay")
27
28 await model.load()
29
30 let listing = try #require(model.state.value)
31 #expect(listing.home == "Home")
32 #expect(listing.pages == ["API", "Admin", "Home", "Parity"])
33 #expect(stub.seen.first?.url.query() == "argv=wiki&argv=list&argv=krz/gitbay")
34 }
35
36 @Test func aRepoWithoutAWikiIsAnEmptyState() async throws {
37 let (client, stub) = try makeClient()
38 stub.enqueue(.init(status: 404, json:
39 #"{"protocol_version":1,"error":"krz/quiet has no wiki","exit_code":3}"#))
40 let model = WikiViewModel(client: client, repoPath: "krz/quiet")
41
42 await model.load()
43
44 guard case .empty(let message) = model.state else {
45 Issue.record("expected .empty, got \(model.state)")
46 return
47 }
48 #expect(message.contains("no wiki"))
49 }
50
51 @Test func anEmptyWikiIsAnEmptyStateNotAList() async throws {
52 let (client, stub) = try makeClient()
53 stub.enqueue(.init(status: 200, json:
54 #"{"protocol_version":1,"data":{"path":"krz/gitbay","pages":[]},"exit_code":0}"#))
55 let model = WikiViewModel(client: client, repoPath: "krz/gitbay")
56
57 await model.load()
58
59 guard case .empty = model.state else {
60 Issue.record("expected .empty, got \(model.state)")
61 return
62 }
63 }
64}
65
66@MainActor
67struct WikiPageViewModelTests {
68
69 @Test func loadsAPageAndKeepsItsFileName() async throws {
70 let (client, stub) = try makeClient()
71 stub.enqueue(.init(status: 200, json: """
72 {"protocol_version":1,"data":{"path":"krz/gitbay","page":"Parity",\
73 "file":"Parity.org","size":11,"content":"#+title: x"},"exit_code":0}
74 """))
75 let model = WikiPageViewModel(client: client, repoPath: "krz/gitbay", page: "Parity")
76
77 await model.load()
78
79 let page = try #require(model.state.value)
80 // The file name is what picks the renderer, so it must survive.
81 #expect(page.file == "Parity.org")
82 #expect(page.content == "#+title: x")
83 #expect(stub.seen.first?.url.query() ==
84 "argv=wiki&argv=show&argv=krz/gitbay&argv=Parity")
85 }
86
87 @Test func aMissingPageIsAnEmptyState() async throws {
88 let (client, stub) = try makeClient()
89 stub.enqueue(.init(status: 404, json:
90 #"{"protocol_version":1,"error":"no wiki page \"Nope\" in krz/gitbay","exit_code":3}"#))
91 let model = WikiPageViewModel(client: client, repoPath: "krz/gitbay", page: "Nope")
92
93 await model.load()
94
95 guard case .empty = model.state else {
96 Issue.record("expected .empty, got \(model.state)")
97 return
98 }
99 }
100}
101
102struct WikiRenderingTests {
103
104 @Test func orgPagesGetTheOrgRendererAndMarkdownPagesDoNot() {
105 // Wiki pages here are .org as often as .md; the file name decides.
106 #expect(ReadmeView(name: "Parity.org", content: "* x").isOrg)
107 #expect(!ReadmeView(name: "Home.md", content: "# x").isOrg)
108 #expect(!ReadmeView(name: "Notes.markdown", content: "# x").isOrg)
109 }
110}
gitbayUITests/LiveSmokeUITests.swift +29
@@ -805,3 +805,32 @@ extension LiveSmokeUITests {
805805 "ref history rendered no commits")
806806 }
807807 }
808
809extension LiveSmokeUITests {
810
811 /// The wiki the last capability that was browser-only until
812 /// krz/gitbay#48. Read-only; editing is a push, on every surface.
813 func testWikiFlows() throws {
814 openRepo("krz/gitbay")
815 app.staticTexts["Wiki"].firstMatch.tap()
816
817 // The landing page leads and is marked.
818 let home = app.staticTexts["Home"].firstMatch
819 XCTAssertTrue(home.waitForExistence(timeout: 20), "wiki pages did not load")
820 XCTAssertTrue(app.staticTexts["home"].firstMatch.exists,
821 "the landing page is not marked")
822
823 // Parity.org is an org page, so this also proves the org renderer
824 // runs on wiki content and not just READMEs.
825 let parity = app.staticTexts["Parity"].firstMatch
826 XCTAssertTrue(parity.exists, "Parity page missing from the listing")
827 parity.tap()
828 XCTAssertTrue(app.staticTexts
829 .containing(NSPredicate(format: "label CONTAINS 'surface'")).firstMatch
830 .waitForExistence(timeout: 20), "wiki page rendered no prose")
831 // Org headings render as headings, not as raw #+title:.
832 XCTAssertFalse(app.staticTexts
833 .containing(NSPredicate(format: "label CONTAINS '#+title'")).firstMatch.exists,
834 "org markup leaked into the rendered page")
835 }
836}