Commit 786da630a1
Unsigned
Layout: unified · split
gitbay/ContentView.swift +4
| @@ -90,6 +90,10 @@ private struct RouteDestinations: ViewModifier { | |||
| 90 | RefsView(client: client, repo: repo) | 90 | RefsView(client: client, repo: repo) |
| 91 | case .milestones(let repo): | 91 | case .milestones(let repo): |
| 92 | MilestoneListView(client: client, repo: repo) | 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 | case .commit(let repo, let sha): | 97 | case .commit(let repo, let sha): |
| 94 | CommitView(client: client, repo: repo, sha: sha) | 98 | CommitView(client: client, repo: repo, sha: sha) |
| 95 | case .commitDiff(let repo, let sha): | 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 | case blame(repo: String, path: String, ref: String?) | 12 | case blame(repo: String, path: String, ref: String?) |
| 13 | case refs(repo: String) | 13 | case refs(repo: String) |
| 14 | case milestones(repo: String) | 14 | case milestones(repo: String) |
| 15 | case wiki(repo: String) | ||
| 16 | case wikiPage(repo: String, page: String) | ||
| 15 | case commit(repo: String, sha: String) | 17 | case commit(repo: String, sha: String) |
| 16 | case commitDiff(repo: String, sha: String) | 18 | case commitDiff(repo: String, sha: String) |
| 17 | case profile(String) | 19 | case profile(String) |
gitbay/Views/Repos/RepoView.swift +3
| @@ -47,6 +47,9 @@ struct RepoView: View { | |||
| 47 | NavigationLink(value: ReleaseRoute.list(repo: path)) { | 47 | NavigationLink(value: ReleaseRoute.list(repo: path)) { |
| 48 | Label("Releases", systemImage: "shippingbox") | 48 | Label("Releases", systemImage: "shippingbox") |
| 49 | } | 49 | } |
| 50 | NavigationLink(value: RepoRoute.wiki(repo: path)) { | ||
| 51 | Label("Wiki", systemImage: "book") | ||
| 52 | } | ||
| 50 | NavigationLink(value: RepoRoute.grep(repo: path)) { | 53 | NavigationLink(value: RepoRoute.grep(repo: path)) { |
| 51 | Label("Search in Files", systemImage: "text.magnifyingglass") | 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 | "ref history rendered no commits") | 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 | } | ||