wiki: read a repository's pages !18
7 files changed, +305 −0
gitbay/ContentView.swift +4
| @@ -90,6 +90,10 @@ private struct RouteDestinations: ViewModifier { | ||
| 90 | 90 | RefsView(client: client, repo: repo) |
| 91 | 91 | case .milestones(let repo): |
| 92 | 92 | 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) | |
| 93 | 97 | case .commit(let repo, let sha): |
| 94 | 98 | CommitView(client: client, repo: repo, sha: sha) |
| 95 | 99 | case .commitDiff(let repo, let sha): |
gitbay/Repos/WikiViewModel.swift added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | import Foundation | |
| 2 | import Observation | |
| 3 | ||
| 4 | /// `wiki list` — the pages, and which one is the landing page. | |
| 5 | nonisolated 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. | |
| 12 | nonisolated 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 | |
| 28 | final 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 | |
| 56 | final 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 { | ||
| 12 | 12 | case blame(repo: String, path: String, ref: String?) |
| 13 | 13 | case refs(repo: String) |
| 14 | 14 | case milestones(repo: String) |
| 15 | case wiki(repo: String) | |
| 16 | case wikiPage(repo: String, page: String) | |
| 15 | 17 | case commit(repo: String, sha: String) |
| 16 | 18 | case commitDiff(repo: String, sha: String) |
| 17 | 19 | case profile(String) |
gitbay/Views/Repos/RepoView.swift +3
| @@ -47,6 +47,9 @@ struct RepoView: View { | ||
| 47 | 47 | NavigationLink(value: ReleaseRoute.list(repo: path)) { |
| 48 | 48 | Label("Releases", systemImage: "shippingbox") |
| 49 | 49 | } |
| 50 | NavigationLink(value: RepoRoute.wiki(repo: path)) { | |
| 51 | Label("Wiki", systemImage: "book") | |
| 52 | } | |
| 50 | 53 | NavigationLink(value: RepoRoute.grep(repo: path)) { |
| 51 | 54 | Label("Search in Files", systemImage: "text.magnifyingglass") |
| 52 | 55 | } |
gitbay/Views/Repos/WikiView.swift added +79
| @@ -0,0 +1,79 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | /// A repository's wiki pages, landing page first. | |
| 4 | struct 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. | |
| 46 | struct 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 @@ | ||
| 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 listingJSON = """ | |
| 16 | {"protocol_version":1,"data":{"path":"krz/gitbay","home":"Home",\ | |
| 17 | "pages":["API","Admin","Home","Parity"]},"exit_code":0} | |
| 18 | """ | |
| 19 | ||
| 20 | @MainActor | |
| 21 | struct 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 | |
| 67 | struct 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 | ||
| 102 | struct 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 { | ||
| 805 | 805 | "ref history rendered no commits") |
| 806 | 806 | } |
| 807 | 807 | } |
| 808 | ||
| 809 | extension 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 | } | |