Preview markup before sending !128

merged merged by cmc on 2026-09-23 00:23 UTC · krz/gitbay-ios:markup-preview into main

9 files changed, +146 −0

Layout: unified · split

gitbay/Views/Issues/IssueView.swift +6
@@ -172,6 +172,7 @@ struct IssueView: View {
172172 HStack {
173173 TextField("Comment", text: $commentText, axis: .vertical)
174174 .lineLimit(1...5)
175 .accessibilityIdentifier("issue-comment-field")
175176 Picker("Format", selection: $commentFormat) {
176177 Text("Markdown").tag("md")
177178 Text("Org").tag("org")
@@ -190,6 +191,11 @@ struct IssueView: View {
190191 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
191192 || model.working)
192193 }
194 if !commentText.isEmpty {
195 MarkupPreview(text: commentText, format: commentFormat,
196 identifier: "issue-comment-preview")
197 .font(.gbSans(.subheadline))
198 }
193199 }
194200 }
195201
gitbay/Views/MRs/MRListView.swift +1
@@ -131,6 +131,7 @@ private struct MRCreateSheet: View {
131131 }
132132 .pickerStyle(.segmented)
133133 .accessibilityIdentifier("mr-format")
134 MarkupPreview(text: bodyText, format: format, identifier: "mr-create-preview")
134135 }
135136 Section {
136137 Toggle("Draft", isOn: Bindable(model).draft)
gitbay/Views/MRs/MRView.swift +6
@@ -537,6 +537,7 @@ struct MRView: View {
537537 HStack {
538538 TextField("Comment", text: $commentText, axis: .vertical)
539539 .lineLimit(1...5)
540 .accessibilityIdentifier("mr-comment-field")
540541 Picker("Format", selection: $commentFormat) {
541542 Text("Markdown").tag("md")
542543 Text("Org").tag("org")
@@ -555,6 +556,11 @@ struct MRView: View {
555556 .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
556557 || model.working)
557558 }
559 if !commentText.isEmpty {
560 MarkupPreview(text: commentText, format: commentFormat,
561 identifier: "mr-comment-preview")
562 .font(.gbSans(.subheadline))
563 }
558564 }
559565 }
560566
gitbay/Views/Releases/ReleaseListView.swift +3
@@ -108,6 +108,9 @@ private struct ReleaseCreateSheet: View {
108108 TextEditor(text: $notes)
109109 .frame(minHeight: 140)
110110 .autocorrectionDisabled()
111 // release create sends no --format, and release notes
112 // render as markdown.
113 MarkupPreview(text: notes, format: "md", identifier: "release-notes-preview")
111114 }
112115 if let error = model.createError {
113116 Section {
gitbay/Views/Repos/FileEditSheet.swift +5
@@ -28,6 +28,11 @@ struct FileEditSheet: View {
2828 } footer: {
2929 Text("Commits to \(branch) as an unsigned commit.")
3030 }
31 if let format = MarkupPreview.format(forFile: model.fileName) {
32 Section {
33 MarkupPreview(text: content, format: format, identifier: "file-edit-preview")
34 }
35 }
3136 Section("Message") {
3237 TextField("edit \(model.fileName)", text: $message)
3338 .autocorrectionDisabled()
gitbay/Views/Shared/ComposeSheet.swift +1
@@ -73,6 +73,7 @@ struct ComposeSheet: View {
7373 .pickerStyle(.segmented)
7474 .accessibilityIdentifier("compose-format")
7575 }
76 MarkupPreview(text: bodyText, format: format, identifier: "compose-preview")
7677 }
7778 if let errorMessage {
7879 Section {
gitbay/Views/Shared/MarkupPreview.swift added +42
@@ -0,0 +1,42 @@
1import SwiftUI
2
3/// A collapsed "Preview" row under a markup editor. Expanded, it renders
4/// the draft with `FormattedBodyView`, the view the app's threads use, so
5/// what it shows is what the app will show once the text is sent. It sits
6/// under the editor rather than replacing it, so the draft stays in view.
7struct MarkupPreview: View {
8
9 let text: String
10 let format: String?
11 let identifier: String
12
13 @State private var expanded = false
14
15 var body: some View {
16 DisclosureGroup(isExpanded: $expanded) {
17 if text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
18 Text("Nothing to preview.")
19 .font(.gbSans(.subheadline))
20 .foregroundStyle(.secondary)
21 } else {
22 FormattedBodyView(text: text, format: format)
23 .padding(.vertical, 4)
24 }
25 } label: {
26 Text("Preview")
27 .font(.gbSans(.subheadline))
28 }
29 .accessibilityIdentifier(identifier)
30 }
31
32 /// The format a file's contents render in, or nil for a file shown as
33 /// source. The same extensions the forge renders a README or wiki
34 /// page from.
35 static func format(forFile name: String) -> String? {
36 switch (name as NSString).pathExtension.lowercased() {
37 case "org": "org"
38 case "md", "markdown": "md"
39 default: nil
40 }
41 }
42}
gitbayTests/MarkupPreviewTests.swift added +23
@@ -0,0 +1,23 @@
1import Testing
2@testable import gitbay
3
4struct MarkupPreviewFormatTests {
5
6 @Test func orgFilesRenderAsOrg() {
7 #expect(MarkupPreview.format(forFile: "README.org") == "org")
8 #expect(MarkupPreview.format(forFile: "profile/README.org") == "org")
9 }
10
11 @Test func markdownFilesRenderAsMarkdown() {
12 #expect(MarkupPreview.format(forFile: "README.md") == "md")
13 #expect(MarkupPreview.format(forFile: "notes.markdown") == "md")
14 #expect(MarkupPreview.format(forFile: "CHANGES.MD") == "md")
15 }
16
17 /// Source files show as source, so there is nothing to preview.
18 @Test func otherFilesHaveNoPreview() {
19 #expect(MarkupPreview.format(forFile: "main.swift") == nil)
20 #expect(MarkupPreview.format(forFile: "Makefile") == nil)
21 #expect(MarkupPreview.format(forFile: "notes.txt") == nil)
22 }
23}
gitbayUITests/LiveSmokeUITests.swift +59
@@ -93,6 +93,10 @@ final class LiveSmokeUITests: XCTestCase {
9393 try XCTSkipUnless(open.contains(Self.labelledTitle) && open.contains(Self.unlabelledTitle),
9494 "could not open both label fixture merge requests on \(Self.fixtureRepo)")
9595 }
96 if name.contains("testMarkupPreviewFlow") {
97 try requireFixtureRepo()
98 ensureFixtureIssue()
99 }
96100 }
97101
98102 override func tearDownWithError() throws {
@@ -1910,4 +1914,59 @@ extension LiveSmokeUITests {
19101914 remove.tap()
19111915 XCTAssertTrue(remove.waitForNonExistence(timeout: 15), "removed label still shown")
19121916 }
1917
1918 /// A comment's preview renders its markup before anything is sent:
1919 /// the bold markers are gone and the word remains. Nothing is posted.
1920 func testMarkupPreviewFlow() throws {
1921 openRepo(Self.fixtureRepo)
1922 app.staticTexts["Issues"].firstMatch.tap()
1923 let issue = app.staticTexts[Self.fixtureIssueTitle].firstMatch
1924 XCTAssertTrue(issue.waitForExistence(timeout: 15), "fixture issue not listed")
1925 issue.tap()
1926
1927 let field = app.descendants(matching: .any)
1928 .matching(identifier: "issue-comment-field").firstMatch
1929 XCTAssertTrue(scrollTo(field, swipes: 10), "no comment field on the issue")
1930 let draft = "**Bold preview**"
1931 focusAndType(field, draft)
1932
1933 let preview = app.descendants(matching: .any)
1934 .matching(identifier: "issue-comment-preview").firstMatch
1935 XCTAssertTrue(scrollTo(preview), "no preview row for a non-empty comment")
1936 // The keyboard can cover the row right after typing.
1937 for _ in 0..<3 where !preview.isHittable { app.swipeUp() }
1938 preview.tap()
1939 let rendered = app.staticTexts
1940 .containing(NSPredicate(format: "label CONTAINS 'Bold preview'")).firstMatch
1941 if !rendered.waitForExistence(timeout: 3) {
1942 // The row's identifier can land on the cell rather than the toggle.
1943 app.buttons["Preview"].firstMatch.tap()
1944 }
1945 XCTAssertTrue(rendered.waitForExistence(timeout: 5), "preview did not render the draft")
1946 XCTAssertFalse(app.staticTexts
1947 .containing(NSPredicate(format: "label CONTAINS '**Bold'")).firstMatch.exists,
1948 "preview shows the raw markup")
1949
1950 // Kept on success too: this is how the preview's look is checked
1951 // in light and dark without signing a simulator in by hand.
1952 // The rendered text sits under the keyboard, below the comment
1953 // field. A drag on the list moves the row above the keyboard for
1954 // the screenshot; the wait lets the scroll settle.
1955 if app.keyboards.firstMatch.exists {
1956 app.swipeDown()
1957 _ = app.keyboards.firstMatch.waitForNonExistence(timeout: 3)
1958 }
1959 XCTAssertTrue(scrollTo(rendered), "rendered preview scrolled away")
1960 let shot = XCTAttachment(screenshot: app.screenshot())
1961 shot.name = "markup preview"
1962 shot.lifetime = .keepAlways
1963 add(shot)
1964
1965 // Leave the fixture as found: clear the draft, send nothing.
1966 XCTAssertTrue(scrollTo(field), "comment field scrolled away")
1967 field.coordinate(withNormalizedOffset: CGVector(dx: 0.98, dy: 0.9)).tap()
1968 let typed = (field.value as? String)?.count ?? draft.count
1969 field.typeText(String(repeating: XCUIKeyboardKey.delete.rawValue, count: typed))
1970 XCTAssertTrue(preview.waitForNonExistence(timeout: 5), "preview row outlived an empty comment")
1971 }
19131972}