Compare commits

..

2 commits

Author SHA1 Message Date
Stefan Hausotte
69f7923a52 chore: formatting and lints 2026-06-15 12:19:00 +02:00
Stefan Hausotte
a7491daffc feat: display and upload image attachments in issues, PRs, and comments
Adds `AttachmentGallery` to show image thumbnails and file rows on
issue/PR
detail views and inline in comment bodies. A `PhotosPicker` button in
the
markdown toolbar lets users attach images when composing or editing; the
selected image is uploaded via the Forgejo assets API and a markdown
reference is inserted into the editor.

Closes #79

test(ui): add AttachmentUITests — upload image and verify display

Adds a combined UI test that uploads a 1×1 PNG to an issue via the
markdown
toolbar, submits a comment, re-enters the issue, and asserts that the
Attachments section and gallery are visible.

- AttachmentGallery: add accessibilityIdentifier("attachment-gallery")
- MarkdownComponents: add -dev_testImageUpload launch-arg branch that
  bypasses PhotosPicker and uploads a hardcoded PNG, keeping the test
  deterministic without requiring photos in the simulator's library

test(attachment): fix gallery element query and image detection for
Forgejo uploads

Move the attachment-gallery accessibility identifier from the VStack
container
to the horizontal ScrollView, which XCTest reliably exposes as a scroll
view
element. Update the UI test to use
app.scrollViews["attachment-gallery"].

test(attachment): verify multiple image uploads render as distinct
thumbnails

Upload two images from the comment sheet, assert two markdown references
are
inserted, and after reload assert both render as thumbnails in the
gallery.
Confirms the ForEach(imageAttachments) gallery handles multiple
attachments.

test: add unit tests for attachmentMarkdown image vs link syntax

Cover the pure attachmentMarkdown(for:) function directly: images (by
MIME
type and by extension) produce embed syntax, non-image files (log, pdf)
produce link syntax. Faster and more focused than the UI test, which
only
exercised this indirectly.

feat: wrap markdown toolbar icons and add file attachment upload

The markdown toolbar used a horizontal scroll view, so trailing icons
(link,
list, quote, task, attach) were hidden off-screen with no visual cue.
Replace
it with a wrapping flow layout (MarkdownToolbarFlow) so every icon is
always
visible, wrapping to a second row on narrow widths.

Add general file-attachment upload: the attach button is now a menu
offering
"Photo Library" (images via PhotosPicker) and "Choose File" (any file
via
.fileImporter). Picked files are read through a security-scoped resource
and
uploaded with a MIME type derived from the extension; non-image files
insert
link markdown via the existing attachmentMarkdown(for:) path.
2026-06-15 09:52:26 +02:00
45 changed files with 769 additions and 138 deletions

View file

@ -1,2 +1,3 @@
--swiftversion 6.2
--disable redundantMemberwiseInit
--disable swiftTestingTestCaseNames

View file

@ -639,7 +639,7 @@
repositoryURL = "https://codeberg.org/secana/ForgejoKit.git";
requirement = {
kind = exactVersion;
version = 0.7.0;
version = 0.8.0;
};
};
DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */ = {

View file

@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://codeberg.org/secana/ForgejoKit.git",
"state" : {
"revision" : "4d1dfc1305c0194fc74b09d15d29940a98cd9752",
"version" : "0.7.0"
"revision" : "485c147e7f9762c06f51029de47464fb41abc4af",
"version" : "0.8.0"
}
},
{

View file

@ -150,7 +150,7 @@ struct ContentView: View {
defaultInstance.lastUsed = Date()
try? modelContext.save()
} catch {
// Auto-login failed fall through to instance list
// Auto-login failed, fall through to instance list
}
}

View file

@ -152,7 +152,7 @@ enum HTMLParser {
}
}
// 2. Inline HTML check paragraphs for inline tags
// 2. Inline HTML, check paragraphs for inline tags
if let inlinePattern = inlineHTMLPattern {
let paragraphs = splitIntoParagraphs(masked)
for para in paragraphs {

View file

@ -115,6 +115,28 @@ import Foundation
)
}
extension Attachment {
static let previewImage = Attachment(
id: 1,
uuid: "ee01801f-ffdb-4154-856b-77eab52aaad2",
name: "screenshot.png",
size: 222_208,
browserDownloadUrl: "https://codeberg.org/attachments/ee01801f-ffdb-4154-856b-77eab52aaad2",
type: "image/png",
created: Date(),
)
static let previewFile = Attachment(
id: 2,
uuid: "abc12345-0000-0000-0000-000000000000",
name: "report.pdf",
size: 51200,
browserDownloadUrl: "https://codeberg.org/attachments/abc12345-0000-0000-0000-000000000000",
type: "application/pdf",
created: Date(),
)
}
extension IssueComment {
static let preview = IssueComment(
id: 1,

View file

@ -36,7 +36,7 @@ struct WorkflowStatusStyle {
let label: String
/// Maps Forgejo's single status value to a UI style. Used for runs, jobs,
/// and steps they share the same status vocabulary. Forgejo's known
/// and steps, they share the same status vocabulary. Forgejo's known
/// statuses: success, failure, cancelled, skipped, running, waiting,
/// blocked, unknown. Anything else falls back to a question-mark glyph.
static func forStatus(_ status: String) -> WorkflowStatusStyle {
@ -122,7 +122,7 @@ extension WorkflowRun {
}
/// Elapsed time as TimeInterval. Forgejo serialises a Go `time.Duration`
/// int64 nanoseconds so divide by 1_000_000_000. Falls back to the
/// , int64 nanoseconds, so divide by 1_000_000_000. Falls back to the
/// timestamps only when both are sane (post-2000).
var duration: TimeInterval? {
if let nanos = durationNanos, nanos > 0 {

View file

@ -116,7 +116,7 @@ class AuthenticationService {
username: instance.username,
)
} catch {
// Keychain delete failed log in debug builds
// Keychain delete failed, log in debug builds
#if DEBUG
print("Keychain delete failed during logout: \(error)")
#endif
@ -171,7 +171,7 @@ class AuthenticationService {
isAuthenticated = true
return
} catch {
// Token is invalid/expired only fall through to password if this is not a token-only instance
// Token is invalid/expired, only fall through to password if this is not a token-only instance
if useTokenAuth {
throw SessionRestoreError.fromTokenValidationError(error)
}

View file

@ -0,0 +1,122 @@
import ForgejoKit
import SwiftUI
/// Markdown to insert when an attachment is uploaded. Images use embed syntax
/// (`![name](url)`) so they render inline; every other file type (logs, PDFs,
/// archives, and so on) uses link syntax (`[name](url)`) so it appears as a
/// clickable link rather than a broken inline image.
func attachmentMarkdown(for attachment: Attachment) -> String {
if attachment.isImage {
"![\(attachment.name)](\(attachment.browserDownloadUrl))"
} else {
"[\(attachment.name)](\(attachment.browserDownloadUrl))"
}
}
struct AttachmentGallery: View {
let attachments: [Attachment]
private var imageAttachments: [Attachment] {
attachments.filter(\.isImage)
}
private var fileAttachments: [Attachment] {
attachments.filter { !$0.isImage }
}
var body: some View {
VStack(alignment: .leading, spacing: 8) {
if !imageAttachments.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(imageAttachments) { attachment in
imageThumb(attachment)
}
}
}
.accessibilityIdentifier("attachment-gallery")
}
ForEach(fileAttachments) { attachment in
fileRow(attachment)
}
}
}
private func imageThumb(_ attachment: Attachment) -> some View {
Group {
if let url = URL(string: attachment.browserDownloadUrl) {
Link(destination: url) {
AsyncImage(url: url) { phase in
switch phase {
case let .success(image):
image
.resizable()
.scaledToFill()
case .failure:
brokenImagePlaceholder
default:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(width: 120, height: 90)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
}
}
private func fileRow(_ attachment: Attachment) -> some View {
Group {
if let url = URL(string: attachment.browserDownloadUrl) {
Link(destination: url) {
HStack(spacing: 8) {
Image(systemName: "paperclip")
.foregroundStyle(.secondary)
Text(attachment.name)
.font(.subheadline)
Spacer()
Text(formatFileSize(attachment.size))
.font(.caption)
.foregroundStyle(.secondary)
}
}
.tint(.primary)
.accessibilityIdentifier("attachment-file-row")
}
}
}
private var brokenImagePlaceholder: some View {
Image(systemName: "photo.badge.exclamationmark")
.font(.title2)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.tertiarySystemFill))
}
private func formatFileSize(_ bytes: Int) -> String {
let kilobytes = Double(bytes) / 1024
if kilobytes < 1024 {
return String(format: "%.1f KB", kilobytes)
}
return String(format: "%.1f MB", kilobytes / 1024)
}
}
#if DEBUG
#Preview("Images") {
List {
Section("Attachments") {
AttachmentGallery(attachments: [.previewImage, .previewFile])
}
}
.listStyle(.insetGrouped)
}
#Preview("Empty") {
AttachmentGallery(attachments: [])
}
#endif

View file

@ -3,6 +3,7 @@ import SwiftUI
struct CommentSheet: View {
let users: [User]
var onUploadImage: ((Data, String, String) async throws -> String)?
var onSubmit: (String) async throws -> Void
@State private var text = ""
@ -20,6 +21,7 @@ struct CommentSheet: View {
text: $text,
selectedTab: $selectedTab,
showToolbar: true,
onUploadImage: onUploadImage,
)
} else {
MentionableEditorField(
@ -27,6 +29,7 @@ struct CommentSheet: View {
selectedTab: $selectedTab,
users: users,
showToolbar: true,
onUploadImage: onUploadImage,
)
}
}

View file

@ -41,6 +41,11 @@ struct CommentView: View {
}
MarkdownPreview(text: displayBody)
if let assets = comment.assets, !assets.isEmpty {
AttachmentGallery(attachments: assets)
.padding(.top, 4)
}
}
.padding(.vertical, 4)
.onAppear {

View file

@ -146,7 +146,7 @@ struct CommitHistoryView: View {
repo: repo,
)
} catch {
// Non-critical branch selector stays disabled
// Non-critical, branch selector stays disabled
}
}

View file

@ -100,7 +100,7 @@ struct HomeView: View {
do {
unreadCount = try await notificationService.fetchUnreadCount()
} catch {
// Silently ignore badge is non-critical, keep the prior count
// Silently ignore, badge is non-critical, keep the prior count
return
}
} else {

View file

@ -117,6 +117,14 @@ struct IssueDetailView: View {
}
}
// Attachments
if let assets = issue.assets, !assets.isEmpty {
Section("Attachments") {
AttachmentGallery(attachments: assets)
.padding(.vertical, 4)
}
}
// Comments
if !comments.isEmpty {
Section("Comments (\(comments.count))") {
@ -187,13 +195,14 @@ struct IssueDetailView: View {
repository: repository,
issue: issue,
authService: authService,
onUploadImage: uploadIssueImage,
) { updatedIssue in
self.issue = updatedIssue
}
}
}
.sheet(isPresented: $showCommentSheet) {
CommentSheet(users: []) { body in
CommentSheet(users: [], onUploadImage: uploadIssueImage) { body in
guard let issueService else { throw URLError(.userAuthenticationRequired) }
let comment = try await issueService.createComment(
owner: owner,
@ -210,6 +219,15 @@ struct IssueDetailView: View {
.errorAlert(message: $errorMessage, isPresented: $showError)
}
private func uploadIssueImage(data: Data, fileName: String, mimeType: String) async throws -> String {
guard let issueService else { throw URLError(.userAuthenticationRequired) }
let attachment = try await issueService.uploadIssueAttachment(
owner: owner, repo: repo, index: issueNumber,
fileData: data, fileName: fileName, mimeType: mimeType,
)
return attachmentMarkdown(for: attachment)
}
private func loadData() async {
guard let issueService else { return }
isLoading = true

View file

@ -21,14 +21,20 @@ struct IssueEditView: View {
private let issueService: IssueService?
private let repositoryService: RepositoryService?
private let onUploadImage: ((Data, String, String) async throws -> String)?
private let onSaved: (Issue) -> Void
init(repository: Repository, issue: Issue, authService: AuthenticationService, onSaved: @escaping (Issue) -> Void) {
init(
repository: Repository, issue: Issue, authService: AuthenticationService,
onUploadImage: ((Data, String, String) async throws -> String)? = nil,
onSaved: @escaping (Issue) -> Void,
) {
self.repository = repository
self.issue = issue
self.authService = authService
issueService = authService.client.map { IssueService(client: $0) }
repositoryService = authService.client.map { RepositoryService(client: $0) }
self.onUploadImage = onUploadImage
self.onSaved = onSaved
_title = State(initialValue: issue.title)
_bodyText = State(initialValue: issue.body ?? "")
@ -54,7 +60,7 @@ struct IssueEditView: View {
.accessibilityIdentifier("issue-edit-title-field")
}
DescriptionEditorSection(text: $bodyText)
DescriptionEditorSection(text: $bodyText, onUploadImage: onUploadImage)
LabelPickerSection(
availableLabels: availableLabels,
@ -151,6 +157,7 @@ struct IssueEditView: View {
self.authService = authService
issueService = nil
repositoryService = nil
onUploadImage = nil
onSaved = { _ in }
_title = State(initialValue: issue.title)
_bodyText = State(initialValue: issue.body ?? "")

View file

@ -55,7 +55,7 @@ struct IssueListView: View {
} description: {
Text(
stateFilter == .open
? "All clear no open issues to review."
? "All clear, no open issues to review."
: "No closed issues found.",
)
}
@ -152,7 +152,8 @@ struct IssueListView: View {
#if DEBUG
init(preview _: Void, repository: Repository, authService: AuthenticationService, issues: [Issue],
notFound: Bool = false) {
notFound: Bool = false)
{
self.repository = repository
self.authService = authService
issueService = nil

View file

@ -1,6 +1,8 @@
import ForgejoKit
import PhotosUI
import SwiftUI
import Textual
import UniformTypeIdentifiers
enum EditPreviewTab: String, CaseIterable {
case edit = "Edit"
@ -12,6 +14,9 @@ struct MarkdownEditorField: View {
@Binding var selectedTab: EditPreviewTab
var minHeight: CGFloat = 150
var showToolbar: Bool = false
/// Called with (data, fileName, mimeType) when the user picks an image.
/// Return the markdown snippet to insert, e.g. `![name](url)`.
var onUploadImage: ((Data, String, String) async throws -> String)?
var body: some View {
VStack(spacing: 0) {
@ -26,7 +31,7 @@ struct MarkdownEditorField: View {
if selectedTab == .edit {
VStack(spacing: 0) {
if showToolbar {
MarkdownToolbar(text: $text)
MarkdownToolbar(text: $text, onUploadImage: onUploadImage)
}
TextEditor(text: $text)
.frame(minHeight: minHeight, maxHeight: .infinity)
@ -58,66 +63,6 @@ struct MarkdownEditorField: View {
}
}
private struct MarkdownToolbar: View {
@Binding var text: String
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
toolbarButton("bold", icon: "bold", label: "Bold") { wrap("**") }
toolbarButton("italic", icon: "italic", label: "Italic") { wrap("_") }
toolbarButton("heading", icon: "number", label: "Heading") { prefix("# ") }
toolbarButton("code", icon: "chevron.left.forwardslash.chevron.right",
label: "Inline code") { wrap("`") }
toolbarButton("codeblock", icon: "text.page", label: "Code block") { wrapBlock("```") }
toolbarButton("link", icon: "link", label: "Insert link") { insertLink() }
toolbarButton("list", icon: "list.bullet", label: "Bulleted list") { prefix("- ") }
toolbarButton("quote", icon: "text.quote", label: "Quote") { prefix("> ") }
toolbarButton("task", icon: "checklist", label: "Task list") { prefix("- [ ] ") }
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
}
private func toolbarButton(_ id: String, icon: String, label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon)
.font(.subheadline)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.accessibilityLabel(label)
.accessibilityIdentifier("markdown-toolbar-\(id)")
}
private func wrap(_ marker: String) {
text.append("\(marker)text\(marker)")
}
private func prefix(_ marker: String) {
if text.isEmpty || text.hasSuffix("\n") {
text.append(marker)
} else {
text.append("\n\(marker)")
}
}
private func wrapBlock(_ fence: String) {
if text.isEmpty || text.hasSuffix("\n") {
text.append("\(fence)\n\n\(fence)")
} else {
text.append("\n\(fence)\n\n\(fence)")
}
}
private func insertLink() {
text.append("[title](url)")
}
}
struct MarkdownPreview: View {
let text: String
var baseURL: URL?

View file

@ -0,0 +1,215 @@
import Foundation
import PhotosUI
import SwiftUI
import UniformTypeIdentifiers
struct MarkdownToolbar: View {
@Binding var text: String
var onUploadImage: ((Data, String, String) async throws -> String)?
@State private var photosItem: PhotosPickerItem?
@State private var showPhotosPicker = false
@State private var showFileImporter = false
@State private var isUploading = false
@State private var uploadError: String?
@State private var showUploadError = false
var body: some View {
// A wrapping flow layout keeps every icon visible: when they don't fit on
// one line they wrap to the next, instead of being hidden off-screen behind
// a horizontal scroll view.
MarkdownToolbarFlow(spacing: 10) {
toolbarButton("bold", icon: "bold", label: "Bold") { wrap("**") }
toolbarButton("italic", icon: "italic", label: "Italic") { wrap("_") }
toolbarButton("heading", icon: "number", label: "Heading") { prefix("# ") }
toolbarButton("code", icon: "chevron.left.forwardslash.chevron.right",
label: "Inline code") { wrap("`") }
toolbarButton("codeblock", icon: "text.page", label: "Code block") { wrapBlock("```") }
toolbarButton("link", icon: "link", label: "Insert link") { insertLink() }
toolbarButton("list", icon: "list.bullet", label: "Bulleted list") { prefix("- ") }
toolbarButton("quote", icon: "text.quote", label: "Quote") { prefix("> ") }
toolbarButton("task", icon: "checklist", label: "Task list") { prefix("- [ ] ") }
if onUploadImage != nil {
uploadControl
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
.frame(maxWidth: .infinity, alignment: .leading)
.alert("Upload Failed", isPresented: $showUploadError) {
Button("OK", role: .cancel) {}
} message: {
Text(uploadError ?? "")
}
}
@ViewBuilder
private var uploadControl: some View {
if isUploading {
ProgressView()
.controlSize(.small)
.frame(width: 28, height: 28)
} else if ProcessInfo.processInfo.arguments.contains("-dev_testImageUpload") {
// In UI tests, bypass the pickers and upload hardcoded files directly.
Button { Task { await uploadTestImage() } } label: {
toolbarIcon("photo")
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.accessibilityLabel("Attach image")
.accessibilityIdentifier("markdown-toolbar-image")
Button { Task { await uploadTestFile() } } label: {
toolbarIcon("doc")
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.accessibilityLabel("Attach file")
.accessibilityIdentifier("markdown-toolbar-file")
} else {
Menu {
Button { showPhotosPicker = true } label: {
Label("Photo Library", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("Choose File", systemImage: "doc")
}
} label: {
toolbarIcon("paperclip")
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.accessibilityLabel("Attach")
.accessibilityIdentifier("markdown-toolbar-attach")
.photosPicker(isPresented: $showPhotosPicker, selection: $photosItem, matching: .images)
.onChange(of: photosItem) { _, item in
guard let item else { return }
Task { await uploadPickedImage(item) }
}
.fileImporter(
isPresented: $showFileImporter,
allowedContentTypes: [.item],
allowsMultipleSelection: false,
) { result in
Task { await uploadPickedFile(result) }
}
}
}
private func toolbarIcon(_ systemName: String) -> some View {
Image(systemName: systemName)
.font(.subheadline)
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
private func uploadTestImage() async {
guard let onUploadImage else { return }
// 1×1 transparent PNG, used in UI tests to skip the Photos picker.
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
guard let pngData = Data(base64Encoded: base64) else { return }
await runUpload { try await onUploadImage(pngData, "test.png", "image/png") }
}
private func uploadTestFile() async {
guard let onUploadImage else { return }
// Plain-text log file, used in UI tests to exercise the non-image
// attachment path (link markdown + file-row display).
let logData = Data("line1 ERROR boom\nline2 WARN stuff\nline3 done\n".utf8)
await runUpload { try await onUploadImage(logData, "server.log", "text/plain") }
}
private func runUpload(_ upload: () async throws -> String) async {
isUploading = true
defer { isUploading = false }
do {
let markdown = try await upload()
if text.isEmpty || text.hasSuffix("\n") {
text.append(markdown)
} else {
text.append("\n\(markdown)")
}
} catch {
uploadError = error.localizedDescription
showUploadError = true
}
}
private func uploadPickedFile(_ result: Result<[URL], Error>) async {
guard let onUploadImage else { return }
do {
let urls = try result.get()
guard let url = urls.first else { return }
// Files returned by the document picker live outside the app sandbox and
// must be accessed through a security-scoped resource.
let didAccess = url.startAccessingSecurityScopedResource()
defer { if didAccess { url.stopAccessingSecurityScopedResource() } }
let data = try Data(contentsOf: url)
let fileName = url.lastPathComponent
let mimeType = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType
?? "application/octet-stream"
await runUpload { try await onUploadImage(data, fileName, mimeType) }
} catch {
uploadError = error.localizedDescription
showUploadError = true
}
}
private func uploadPickedImage(_ item: PhotosPickerItem) async {
guard let onUploadImage else { return }
isUploading = true
defer {
isUploading = false
photosItem = nil
}
do {
guard let data = try await item.loadTransferable(type: Data.self) else { return }
let mimeType = item.supportedContentTypes.first?.preferredMIMEType ?? "image/jpeg"
let ext = item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg"
let markdown = try await onUploadImage(data, "image.\(ext)", mimeType)
if text.isEmpty || text.hasSuffix("\n") {
text.append(markdown)
} else {
text.append("\n\(markdown)")
}
} catch {
uploadError = error.localizedDescription
showUploadError = true
}
}
private func toolbarButton(_ id: String, icon: String, label: String, action: @escaping () -> Void) -> some View {
Button(action: action) {
toolbarIcon(icon)
}
.buttonStyle(.plain)
.foregroundStyle(.primary)
.accessibilityLabel(label)
.accessibilityIdentifier("markdown-toolbar-\(id)")
}
private func wrap(_ marker: String) {
text.append("\(marker)text\(marker)")
}
private func prefix(_ marker: String) {
if text.isEmpty || text.hasSuffix("\n") {
text.append(marker)
} else {
text.append("\n\(marker)")
}
}
private func wrapBlock(_ fence: String) {
if text.isEmpty || text.hasSuffix("\n") {
text.append("\(fence)\n\n\(fence)")
} else {
text.append("\n\(fence)\n\n\(fence)")
}
}
private func insertLink() {
text.append("[title](url)")
}
}

View file

@ -0,0 +1,47 @@
import SwiftUI
/// A simple left-aligned flow layout: lays subviews out left to right, wrapping
/// to a new line when the next subview would overflow the available width. Used
/// by the markdown toolbar so all action icons stay visible (wrapping instead of
/// scrolling off-screen).
struct MarkdownToolbarFlow: Layout {
var spacing: CGFloat = 10
func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache _: inout Void) -> CGSize {
let maxWidth = proposal.width ?? .infinity
var cursorX: CGFloat = 0
var cursorY: CGFloat = 0
var rowHeight: CGFloat = 0
var widestRow: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if cursorX > 0, cursorX + size.width > maxWidth {
widestRow = max(widestRow, cursorX - spacing)
cursorX = 0
cursorY += rowHeight + spacing
rowHeight = 0
}
cursorX += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
widestRow = max(widestRow, cursorX - spacing)
return CGSize(width: min(maxWidth, widestRow), height: cursorY + rowHeight)
}
func placeSubviews(in bounds: CGRect, proposal _: ProposedViewSize, subviews: Subviews, cache _: inout Void) {
var cursorX = bounds.minX
var cursorY = bounds.minY
var rowHeight: CGFloat = 0
for subview in subviews {
let size = subview.sizeThatFits(.unspecified)
if cursorX > bounds.minX, cursorX + size.width > bounds.maxX {
cursorX = bounds.minX
cursorY += rowHeight + spacing
rowHeight = 0
}
subview.place(at: CGPoint(x: cursorX, y: cursorY), anchor: .topLeading, proposal: ProposedViewSize(size))
cursorX += size.width + spacing
rowHeight = max(rowHeight, size.height)
}
}
}

View file

@ -7,6 +7,7 @@ struct MentionableEditorField: View {
var users: [User]
var minHeight: CGFloat = 150
var showToolbar: Bool = false
var onUploadImage: ((Data, String, String) async throws -> String)?
@State private var mentionQuery: String?
@ -24,6 +25,7 @@ struct MentionableEditorField: View {
selectedTab: $selectedTab,
minHeight: minHeight,
showToolbar: showToolbar,
onUploadImage: onUploadImage,
)
if selectedTab == .edit, mentionQuery != nil, !matchingUsers.isEmpty {

View file

@ -6,6 +6,7 @@ import SwiftUI
struct DescriptionEditorSection: View {
@Binding var text: String
var title: String = "Description"
var onUploadImage: ((Data, String, String) async throws -> String)?
@State private var showingEditor = false
var body: some View {
@ -24,7 +25,7 @@ struct DescriptionEditorSection: View {
}
.tint(.primary)
.sheet(isPresented: $showingEditor) {
DescriptionEditorSheet(text: $text, title: title)
DescriptionEditorSheet(text: $text, title: title, onUploadImage: onUploadImage)
}
}
}
@ -33,6 +34,7 @@ struct DescriptionEditorSection: View {
private struct DescriptionEditorSheet: View {
@Binding var text: String
let title: String
var onUploadImage: ((Data, String, String) async throws -> String)?
@State private var selectedTab: EditPreviewTab = .edit
@Environment(\.dismiss) private var dismiss
@ -42,6 +44,7 @@ private struct DescriptionEditorSheet: View {
text: $text,
selectedTab: $selectedTab,
showToolbar: true,
onUploadImage: onUploadImage,
)
.frame(maxHeight: .infinity)
.padding()

View file

@ -31,6 +31,7 @@ struct PullRequestDetailView: View {
@State private var showActionsExpanded = false
private let prService: PullRequestService?
private let issueService: IssueService?
private let repositoryService: RepositoryService?
init(repository: Repository, prNumber: Int, authService: AuthenticationService) {
@ -38,6 +39,7 @@ struct PullRequestDetailView: View {
self.prNumber = prNumber
self.authService = authService
prService = authService.client.map { PullRequestService(client: $0) }
issueService = authService.client.map { IssueService(client: $0) }
repositoryService = authService.client.map { RepositoryService(client: $0) }
}
@ -108,6 +110,13 @@ struct PullRequestDetailView: View {
}
}
if let assets = activePR.assets, !assets.isEmpty {
Section("Attachments") {
AttachmentGallery(attachments: assets)
.padding(.vertical, 4)
}
}
reviewsSection
changesSection
conflictSection(activePR)
@ -133,6 +142,7 @@ struct PullRequestDetailView: View {
repository: repository,
pullRequest: editingPR,
authService: authService,
onUploadImage: uploadPRImage,
) { _ in
Task { await loadData() }
}
@ -174,7 +184,7 @@ struct PullRequestDetailView: View {
}
}
.sheet(isPresented: $showCommentSheet) {
CommentSheet(users: assignees) { body in
CommentSheet(users: assignees, onUploadImage: uploadPRImage) { body in
guard let prService else { throw URLError(.userAuthenticationRequired) }
let comment = try await prService.createComment(
owner: owner,
@ -436,6 +446,15 @@ struct PullRequestDetailView: View {
reviewComments.values.flatMap(\.self)
}
private func uploadPRImage(data: Data, fileName: String, mimeType: String) async throws -> String {
guard let issueService else { throw URLError(.userAuthenticationRequired) }
let attachment = try await issueService.uploadIssueAttachment(
owner: owner, repo: repo, index: prNumber,
fileData: data, fileName: fileName, mimeType: mimeType,
)
return attachmentMarkdown(for: attachment)
}
private func loadData() async {
guard let prService, let repositoryService else { return }
isLoading = true
@ -586,6 +605,7 @@ struct PullRequestDetailView: View {
self.prNumber = prNumber
self.authService = authService
prService = nil
issueService = nil
repositoryService = nil
_pullRequest = State(initialValue: pullRequest)
_comments = State(initialValue: comments)

View file

@ -22,6 +22,7 @@ struct PullRequestEditView: View {
private let prService: PullRequestService?
private let repositoryService: RepositoryService?
private let onUploadImage: ((Data, String, String) async throws -> String)?
private let onSaved: (PullRequest) -> Void
private var isMerged: Bool {
@ -31,6 +32,7 @@ struct PullRequestEditView: View {
init(
repository: Repository, pullRequest: PullRequest,
authService: AuthenticationService,
onUploadImage: ((Data, String, String) async throws -> String)? = nil,
onSaved: @escaping (PullRequest) -> Void,
) {
self.repository = repository
@ -38,6 +40,7 @@ struct PullRequestEditView: View {
self.authService = authService
prService = authService.client.map { PullRequestService(client: $0) }
repositoryService = authService.client.map { RepositoryService(client: $0) }
self.onUploadImage = onUploadImage
self.onSaved = onSaved
_title = State(initialValue: pullRequest.title)
_bodyText = State(initialValue: pullRequest.body ?? "")
@ -64,7 +67,7 @@ struct PullRequestEditView: View {
.accessibilityIdentifier("pr-edit-title-field")
}
DescriptionEditorSection(text: $bodyText)
DescriptionEditorSection(text: $bodyText, onUploadImage: onUploadImage)
LabelPickerSection(
availableLabels: availableLabels,
@ -199,6 +202,7 @@ struct PullRequestEditView: View {
self.authService = authService
prService = nil
repositoryService = nil
onUploadImage = nil
onSaved = { _ in }
_title = State(initialValue: pullRequest.title)
_bodyText = State(initialValue: pullRequest.body ?? "")

View file

@ -55,7 +55,7 @@ struct PullRequestListView: View {
} description: {
Text(
stateFilter == .open
? "All clear no open pull requests."
? "All clear, no open pull requests."
: "No closed pull requests found.",
)
}
@ -152,7 +152,8 @@ struct PullRequestListView: View {
#if DEBUG
init(preview _: Void, repository: Repository, authService: AuthenticationService, pullRequests: [PullRequest],
notFound: Bool = false) {
notFound: Bool = false)
{
self.repository = repository
self.authService = authService
prService = nil

View file

@ -185,7 +185,7 @@ struct RepositoryDetailView: View {
repo: repository.repoName,
)
} catch {
// Non-critical branch selector stays disabled
// Non-critical, branch selector stays disabled
}
}
.toolbar {
@ -504,7 +504,7 @@ struct RepositoryCodeView: View {
)
readmeContent = fileContent.decodedContent
} catch {
// README exists in listing but couldn't be fetched ignore
// README exists in listing but couldn't be fetched, ignore
}
}

View file

@ -69,7 +69,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
return "No \(itemNoun) matching your search."
}
if stateFilter == .open {
return "All clear no open \(itemNoun) to review."
return "All clear, no open \(itemNoun) to review."
}
let prefix = stateFilter == .all ? "" : stateFilter.rawValue + " "
return "No \(prefix)\(itemNoun) found."
@ -85,7 +85,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
case .closed: "Closed"
case .all: "All"
}
// Scope is ignored during search only show state
// Scope is ignored during search, only show state
if !searchText.isEmpty || (stateFilter == .open && involvementScope == .involved) {
return stateLabel
}

View file

@ -72,7 +72,7 @@ struct WorkflowJobView: View {
Label("No Steps Recorded", systemImage: "tray")
.foregroundStyle(.secondary)
} description: {
Text("This job has no recorded steps it likely never started.")
Text("This job has no recorded steps, it likely never started.")
}
} header: {
experimentalHeader

View file

@ -196,7 +196,7 @@ struct WorkflowRunDetailView: View {
isLoading = false
// Best-effort experimental fetch don't surface errors if it fails
// Best-effort experimental fetch, don't surface errors if it fails
// (e.g. older Forgejo versions without this route, or auth quirks).
if let runIndex = run?.indexInRepo {
await loadRunView(runIndex: runIndex)
@ -214,7 +214,7 @@ struct WorkflowRunDetailView: View {
logCursors: [],
)
} catch {
// Silent experimental endpoint failure shouldn't block run detail.
// Silent, experimental endpoint failure shouldn't block run detail.
}
}
@ -222,7 +222,7 @@ struct WorkflowRunDetailView: View {
let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second]
formatter.unitsStyle = .abbreviated
return formatter.string(from: seconds) ?? ""
return formatter.string(from: seconds) ?? "-"
}
}

View file

@ -127,7 +127,7 @@ struct KeychainManagerTests {
_ = try await KeychainManager.shared.getPassword(for: raw, username: "user")
Issue.record("Should not find password with non-normalized key")
} catch is KeychainError {
// Expected keys differ
// Expected, keys differ
}
// Clean up
@ -156,7 +156,7 @@ struct KeychainManagerTests {
_ = try await KeychainManager.shared.getToken(for: server, username: username)
Issue.record("Expected KeychainError.notFound for token after deleteCredentials")
} catch is KeychainError {
// Expected this is the assertion that fails under the old leak
// Expected, this is the assertion that fails under the old leak
} catch {
Issue.record("Unexpected error: \(error)")
}

View file

@ -1,16 +1,50 @@
import Foundation
import ForgejoKit
import Testing
@testable import Forji
struct MarkdownComponentsTests {
private func attachment(name: String, type: String?) -> ForgejoKit.Attachment {
ForgejoKit.Attachment(
id: 1, uuid: "u", name: name, size: 0,
browserDownloadUrl: "https://example.com/a/\(name)",
type: type, created: Date(),
)
}
// MARK: - attachmentMarkdown
@Test func imageAttachmentUsesEmbedSyntax() {
let md = attachmentMarkdown(for: attachment(name: "shot.png", type: "image/png"))
#expect(md == "![shot.png](https://example.com/a/shot.png)")
}
@Test func imageDetectedByExtensionUsesEmbedSyntax() {
// Forgejo returns the generic type "attachment", so detection falls to the extension.
let md = attachmentMarkdown(for: attachment(name: "photo.jpg", type: "attachment"))
#expect(md == "![photo.jpg](https://example.com/a/photo.jpg)")
}
@Test func nonImageFileUsesLinkSyntax() {
let md = attachmentMarkdown(for: attachment(name: "server.log", type: "text/plain"))
#expect(md == "[server.log](https://example.com/a/server.log)")
}
@Test func nonImageBinaryUsesLinkSyntax() {
let md = attachmentMarkdown(for: attachment(name: "report.pdf", type: "attachment"))
#expect(md == "[report.pdf](https://example.com/a/report.pdf)")
}
// MARK: - repoRelativePath
@Test func repoRelativePathExtractsFilePath() {
let url = URL(string: "https://forgejo.example.com/owner/repo/src/branch/main/path/to/file.swift")!
#expect(repoRelativePath(from: url) == "path/to/file.swift")
}
@Test func repoRelativePathReturnsNilForBranchOnly() {
// URL ends right after the ref no file path follows
// URL ends right after the ref, no file path follows
let url = URL(string: "https://forgejo.example.com/owner/repo/src/branch/main")!
#expect(repoRelativePath(from: url) == nil)
}

View file

@ -71,12 +71,12 @@ struct NotificationPollingIntegrationTests {
let ids = Set(notifications.map(\.id))
#expect(!ids.isEmpty)
// Seed should record all current IDs
// Seed, should record all current IDs
store.seed(ids: ids, for: normalizedURL, username: Self.username)
#expect(store.isSeeded(for: normalizedURL, username: Self.username))
#expect(store.seenIDs(for: normalizedURL, username: Self.username) == ids)
// Simulate a poll returning the same IDs no new notifications
// Simulate a poll returning the same IDs, no new notifications
let newIDs = ids.subtracting(store.seenIDs(for: normalizedURL, username: Self.username))
#expect(newIDs.isEmpty, "After seeding with current IDs, diff should be empty")
}
@ -93,11 +93,11 @@ struct NotificationPollingIntegrationTests {
let allIDs = Set(notifications.map(\.id))
#expect(allIDs.count >= 2, "Need at least 2 notifications for this test")
// Seed with only a subset simulates having seen all but one
// Seed with only a subset, simulates having seen all but one
let partial = Set(allIDs.dropFirst())
store.seed(ids: partial, for: normalizedURL, username: Self.username)
// Now diff the first ID should show as "new"
// Now diff, the first ID should show as "new"
let seenIDs = store.seenIDs(for: normalizedURL, username: Self.username)
let newIDs = allIDs.subtracting(seenIDs)
#expect(newIDs.count == 1, "Expected exactly 1 new notification after partial seed")
@ -123,7 +123,7 @@ struct NotificationPollingIntegrationTests {
store.seed(ids: seeded, for: normalizedURL, username: Self.username)
#expect(store.seenIDs(for: normalizedURL, username: Self.username).contains(999_999))
// Prune only keep IDs that the server still reports as unread
// Prune, only keep IDs that the server still reports as unread
store.prune(keeping: serverIDs, for: normalizedURL, username: Self.username)
#expect(!store.seenIDs(for: normalizedURL, username: Self.username).contains(999_999))
#expect(store.seenIDs(for: normalizedURL, username: Self.username) == serverIDs)
@ -150,7 +150,7 @@ struct NotificationPollingIntegrationTests {
let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
#expect(!store.isSeeded(for: normalizedURL, username: Self.username))
// Seed the store first poll seeds without posting notifications
// Seed the store, first poll seeds without posting notifications
let firstPollIDs = Set(notifications.map(\.id))
store.seed(ids: firstPollIDs, for: normalizedURL, username: Self.username)
@ -191,7 +191,7 @@ struct NotificationPollingIntegrationTests {
// Save with current (post-migration) accessibility
try await KeychainManager.shared.saveToken(token, for: server, username: user)
// Run migration should read + delete + re-save without losing data
// Run migration, should read + delete + re-save without losing data
await KeychainManager.shared.migrateAccessibility(for: server, username: user)
// Verify token is still accessible
@ -283,7 +283,7 @@ struct NotificationPollingIntegrationTests {
let service = NotificationService(client: client)
let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
// Initial poll seed seen store
// Initial poll, seed seen store
let initial = try await service.fetchNotifications(
statusTypes: ["unread"], page: 1, limit: 50
)
@ -295,14 +295,14 @@ struct NotificationPollingIntegrationTests {
let markedID = initial[0].id
try await service.markAsRead(id: markedID)
// Next poll fewer unreads
// Next poll, fewer unreads
let afterMark = try await service.fetchNotifications(
statusTypes: ["unread"], page: 1, limit: 50
)
let afterIDs = Set(afterMark.map(\.id))
#expect(!afterIDs.contains(markedID), "Marked notification should no longer be unread")
// Prune the marked ID should be removed from seen store
// Prune, the marked ID should be removed from seen store
store.prune(keeping: afterIDs, for: normalizedURL, username: Self.username)
#expect(
!store.seenIDs(for: normalizedURL, username: Self.username).contains(markedID),
@ -319,7 +319,7 @@ struct NotificationPollingIntegrationTests {
let server = "https://test.example.com"
let user = "testuser"
// Seed with partial IDs ID 1 is "seen", ID 2 is "new"
// Seed with partial IDs, ID 1 is "seen", ID 2 is "new"
store.seed(ids: [1], for: server, username: user)
// Simulate a poll returning IDs [1, 2]
@ -460,12 +460,12 @@ struct NotificationPollingIntegrationTests {
let allIDs = Set(notifications.map(\.id))
#expect(allIDs.count >= 2, "Need at least 2 notifications")
// Seed with a subset simulate having seen all but one
// Seed with a subset, simulate having seen all but one
let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
let partial = Set(allIDs.dropFirst())
store.seed(ids: partial, for: normalizedURL, username: Self.username)
// Diff should detect the missing ID as new
// Diff, should detect the missing ID as new
let seenIDs = store.seenIDs(for: normalizedURL, username: Self.username)
let newIDs = allIDs.subtracting(seenIDs)
#expect(newIDs.count == 1, "Expected exactly 1 new notification")

View file

@ -147,13 +147,13 @@ struct PaginationStateConcurrentTests {
}
await yieldUntil { fetcherA.isPending }
// Start reload B cancels A's internal task
// Start reload B, cancels A's internal task
let taskB = pagination.reload { page, limit in
try await fetcherB.fetch(page: page, limit: limit)
}
await yieldUntil { fetcherB.isPending }
// Complete both only B's results should be applied
// Complete both, only B's results should be applied
fetcherA.complete(returning: ["stale"])
fetcherB.complete(returning: ["fresh"])
await taskB.value
@ -177,7 +177,7 @@ struct PaginationStateConcurrentTests {
}
await yieldUntil { fetcherB.isPending }
// Complete A first (stale, its task was cancelled) must be discarded
// Complete A first (stale, its task was cancelled), must be discarded
fetcherA.complete(returning: ["stale"])
await yieldUntil { fetcherA.callCount == 1 }
@ -213,7 +213,7 @@ struct PaginationStateConcurrentTests {
}
await yieldUntil { fetcherC.isPending }
// Complete in order A, B, C only C should be applied
// Complete in order A, B, C, only C should be applied
fetcherA.complete(returning: ["a"])
fetcherB.complete(returning: ["b"])
fetcherC.complete(returning: ["c"])
@ -238,7 +238,7 @@ struct PaginationStateConcurrentTests {
}
await yieldUntil { fetcherB.isPending }
// A fails with error but its task was cancelled, so no alert
// A fails with error, but its task was cancelled, so no alert
fetcherA.complete(throwing: URLError(.badServerResponse))
fetcherB.complete(returning: ["ok"])
await taskB.value
@ -264,7 +264,7 @@ struct PaginationStateConcurrentTests {
await yieldUntil { fetcherB.isPending }
#expect(pagination.isLoading)
// Complete stale A isLoading must REMAIN true (B is still in flight)
// Complete stale A, isLoading must REMAIN true (B is still in flight)
fetcherA.complete(returning: ["stale"])
await yieldUntil { fetcherA.callCount == 1 }
#expect(pagination.isLoading, "isLoading must stay true while latest reload (B) is pending")
@ -287,7 +287,7 @@ struct PaginationStateConcurrentTests {
}
await yieldUntil { initialFetcher.isPending }
// Simulate onChange: user changed filter just call reload again
// Simulate onChange: user changed filter, just call reload again
let filterTask = pagination.reload(clearItems: true) { page, limit in
try await filterFetcher.fetch(page: page, limit: limit)
}
@ -463,11 +463,11 @@ struct PaginationStateLoadMoreTests {
}
await yieldUntil { freshFetcher.isPending }
// loadMore completes should be discarded (task was cancelled)
// loadMore completes, should be discarded (task was cancelled)
moreFetcher.complete(returning: ["stale-more"])
await moreTask.value
// reload completes should be applied
// reload completes, should be applied
freshFetcher.complete(returning: ["fresh"])
await reloadTask.value
@ -485,7 +485,7 @@ struct PaginationStateDedupeTests {
pagination.dedupeKey = { $0 }
await pagination.reload { _, _ in ["a", "b"] }.value
// "b" resurfaces on page 2 only "c" is new
// "b" resurfaces on page 2, only "c" is new
await pagination.loadMore { _, _ in ["b", "c"] }
#expect(pagination.items == ["a", "b", "c"])
#expect(pagination.hasMore) // the page contributed a new item

View file

@ -0,0 +1,179 @@
import XCTest
final class AttachmentUITests: ForgejoUITestBase {
// Tests that multiple images can be uploaded from the markdown toolbar comment
// sheet, and that both uploaded images appear in the issue's Attachments section
// on reload. The -dev_testImageUpload launch argument bypasses the PhotosPicker
// and uploads a hardcoded 1×1 PNG directly, keeping the test deterministic without
// requiring photos in the simulator's library.
@MainActor
func testUploadMultipleImagesInCommentAndVerifyDisplayed() throws {
app.launchArguments += ["-dev_testImageUpload"]
loginAndWaitForHome()
// Navigate to an existing issue
navigateToIssueTab()
let issueCell = app.staticTexts["Test issue 1 from integration tests"].firstMatch
XCTAssertTrue(issueCell.waitForExistence(timeout: 10))
issueCell.tap()
XCTAssertTrue(app.staticTexts["issue-detail-title"].waitForExistence(timeout: 10))
// Open the comment sheet
expandActionMenu()
let commentButton = app.buttons["issue-comment-button"]
XCTAssertTrue(commentButton.waitForExistence(timeout: 5))
commentButton.tap()
// Tap the image upload button twice, in test mode each tap uploads a
// hardcoded PNG instead of presenting the Photos picker. Two taps produce
// two distinct attachments and two markdown references.
let imageButton = app.buttons["markdown-toolbar-image"]
XCTAssertTrue(imageButton.waitForExistence(timeout: 5))
let textEditor = app.textViews["markdown-text-editor"].firstMatch
XCTAssertTrue(textEditor.waitForExistence(timeout: 5))
// First upload, wait until one markdown reference is inserted before tapping
// again, so the second upload appends rather than racing the first.
imageButton.tap()
expectation(for: NSPredicate(format: "value MATCHES '.*!\\\\[.*'"), evaluatedWith: textEditor)
waitForExpectations(timeout: 15, handler: nil)
// Second upload, wait until two markdown references are present.
imageButton.tap()
let twoRefs = NSPredicate(format: "value MATCHES '(.*!\\\\[.*){2,}'")
expectation(for: twoRefs, evaluatedWith: textEditor)
waitForExpectations(timeout: 15, handler: nil)
let editorValue = textEditor.value as? String ?? ""
XCTAssertEqual(
editorValue.components(separatedBy: "![").count - 1, 2,
"Expected two markdown image references in the editor after two uploads"
)
// Submit the comment
app.buttons["Submit"].tap()
// Wait for the comment sheet (NavigationStack title "New Comment") to dismiss
let commentSheetNavBar = app.navigationBars["New Comment"]
expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: commentSheetNavBar)
waitForExpectations(timeout: 10, handler: nil)
// Navigate back to the issue list
app.navigationBars.buttons.firstMatch.tap()
XCTAssertTrue(
app.staticTexts["Test issue 1 from integration tests"].waitForExistence(timeout: 10),
"Should return to the issue list after navigating back"
)
// Re-enter the issue: loadData() fetches the issue fresh,
// now including the uploaded attachment in issue.assets.
app.staticTexts["Test issue 1 from integration tests"].firstMatch.tap()
XCTAssertTrue(app.staticTexts["issue-detail-title"].waitForExistence(timeout: 10))
// Scroll past the issue header, milestone, assignees, and description
// to reach the Attachments section that appears below them.
app.swipeUp()
app.swipeUp()
// The Attachments section must now be visible with the gallery rendered.
XCTAssertTrue(
app.staticTexts["Attachments"].waitForExistence(timeout: 10),
"Attachments section header should appear after uploading images to the issue"
)
let gallery = app.scrollViews["attachment-gallery"]
XCTAssertTrue(
gallery.waitForExistence(timeout: 5),
"AttachmentGallery image scroll view should be rendered inside the Attachments section"
)
// Both uploaded images must render as thumbnails. Each thumbnail is a Link
// wrapping an AsyncImage, exposed as an image element inside the gallery.
let twoImages = NSPredicate(format: "count >= 2")
expectation(for: twoImages, evaluatedWith: gallery.images)
waitForExpectations(timeout: 10, handler: nil)
XCTAssertGreaterThanOrEqual(
gallery.images.count, 2,
"Both uploaded images should render as thumbnails in the gallery"
)
}
// Tests that a non-image attachment (a text/log file) is handled correctly:
// the inserted markdown is a plain link (not an image embed), and after reload
// the file renders as a file row (paperclip + name) rather than an image
// thumbnail. The -dev_testImageUpload launch argument also exposes a companion
// "Attach file" button that uploads a hardcoded server.log.
@MainActor
func testUploadLogFileInCommentRendersAsFileRow() throws {
app.launchArguments += ["-dev_testImageUpload"]
loginAndWaitForHome()
// Navigate to an existing issue
navigateToIssueTab()
let issueCell = app.staticTexts["Test issue 2 from integration tests"].firstMatch
XCTAssertTrue(issueCell.waitForExistence(timeout: 10))
issueCell.tap()
XCTAssertTrue(app.staticTexts["issue-detail-title"].waitForExistence(timeout: 10))
// Open the comment sheet
expandActionMenu()
let commentButton = app.buttons["issue-comment-button"]
XCTAssertTrue(commentButton.waitForExistence(timeout: 5))
commentButton.tap()
// Tap the file upload button, in test mode this uploads a hardcoded server.log.
let fileButton = app.buttons["markdown-toolbar-file"]
XCTAssertTrue(fileButton.waitForExistence(timeout: 5))
let textEditor = app.textViews["markdown-text-editor"].firstMatch
XCTAssertTrue(textEditor.waitForExistence(timeout: 5))
fileButton.tap()
// Wait for the upload to finish: a markdown reference is inserted.
expectation(for: NSPredicate(format: "value CONTAINS 'server.log'"), evaluatedWith: textEditor)
waitForExpectations(timeout: 15, handler: nil)
// A non-image attachment must use link syntax [name](url), NOT image-embed
// syntax ![name](url), otherwise it would render as a broken inline image.
let editorValue = textEditor.value as? String ?? ""
XCTAssertTrue(
editorValue.contains("[server.log]"),
"Expected a markdown link to the uploaded log file"
)
XCTAssertFalse(
editorValue.contains("!["),
"A non-image file must not use image-embed markdown syntax"
)
// Submit the comment
app.buttons["Submit"].tap()
let commentSheetNavBar = app.navigationBars["New Comment"]
expectation(for: NSPredicate(format: "exists == false"), evaluatedWith: commentSheetNavBar)
waitForExpectations(timeout: 10, handler: nil)
// Navigate back to the issue list, then re-enter to reload issue.assets.
app.navigationBars.buttons.firstMatch.tap()
let listCell = app.staticTexts["Test issue 2 from integration tests"]
XCTAssertTrue(listCell.waitForExistence(timeout: 10))
listCell.firstMatch.tap()
XCTAssertTrue(app.staticTexts["issue-detail-title"].waitForExistence(timeout: 10))
// Scroll down to the Attachments section.
app.swipeUp()
app.swipeUp()
XCTAssertTrue(
app.staticTexts["Attachments"].waitForExistence(timeout: 10),
"Attachments section header should appear after uploading a file"
)
// The log must render as a file row (not an image thumbnail), showing its name.
// A SwiftUI Link may surface as a link or a button, so match any element type.
let fileRow = app.descendants(matching: .any).matching(identifier: "attachment-file-row").firstMatch
XCTAssertTrue(
fileRow.waitForExistence(timeout: 5),
"Non-image attachment should render as a file row"
)
XCTAssertTrue(
app.staticTexts["server.log"].waitForExistence(timeout: 5),
"File row should display the attachment file name"
)
}
}

View file

@ -14,7 +14,7 @@ class ForgejoReadOnlyUITestBase: XCTestCase, UITestNavigating {
guard let url = ForgejoUITestBase.resolveTestServerURL(),
!url.isEmpty
else {
// Can't XCTSkip from class setUp individual tests will skip
// Can't XCTSkip from class setUp, individual tests will skip
return
}
sharedServerURL = url
@ -60,7 +60,7 @@ class ForgejoReadOnlyUITestBase: XCTestCase, UITestNavigating {
// Ensure we're at the home screen (Repositories tab as anchor)
let reposTab = app.tabBars.buttons["Repositories"]
if !reposTab.waitForExistence(timeout: 5) {
// Recovery: app may have crashed or navigated away relaunch
// Recovery: app may have crashed or navigated away, relaunch
let freshApp = XCUIApplication()
freshApp.launchArguments += [
"-dev_serverURL", Self.sharedServerURL,

View file

@ -9,7 +9,7 @@ final class ForjiUITestsLaunchTests: XCTestCase {
override func setUpWithError() throws {
continueAfterFailure = false
// Skip during integration test runs these tests need manual credentials
// Skip during integration test runs, these tests need manual credentials
if FileManager.default.fileExists(atPath: "/tmp/forgejo_test_url.txt") {
throw XCTSkip("Skipping launch tests during integration test run")
}

View file

@ -26,7 +26,7 @@ final class IssueMutatingUITests: ForgejoUITestBase {
openButton.tap()
XCTAssertTrue(app.staticTexts["Test issue 1 from integration tests"].waitForExistence(timeout: 10))
// Issue detail title, comments, label, milestone, assignee
// Issue detail, title, comments, label, milestone, assignee
let issueCell = app.staticTexts["Test issue 1 from integration tests"].firstMatch
issueCell.tap()

View file

@ -36,7 +36,7 @@ final class MergedInstanceUITests: ForgejoUITestBase {
XCTAssertTrue(addButton.waitForExistence(timeout: 15), "Instance list did not appear")
addButton.tap()
// Form is pre-filled from dev launch args scroll and tap login
// Form is pre-filled from dev launch args, scroll and tap login
app.swipeUp()
let loginButton = app.buttons["login-button"]

View file

@ -2,7 +2,7 @@ import XCTest
final class OverviewCreateMutatingUITests: ForgejoUITestBase {
// MARK: - Create Issue from Issues Overview (mutates creates an issue)
// MARK: - Create Issue from Issues Overview (mutates, creates an issue)
@MainActor
func testCreateIssueFromOverview() throws {

View file

@ -13,7 +13,7 @@ final class OverviewCreateUITests: ForgejoReadOnlyUITestBase {
func testCreatePRFromOverview() throws {
app.tabBars.buttons["Pull Requests"].tap()
// Default scope is "All involvement" testadmin is assigned to PR #4
// Default scope is "All involvement", testadmin is assigned to PR #4
XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10))
// Tap floating create button

View file

@ -53,7 +53,7 @@ final class PaginationUITests: ForgejoReadOnlyUITestBase {
XCTAssertTrue(closedButton.waitForExistence(timeout: 5))
closedButton.tap()
// Only 1 closed issue (#3) should not show load-more
// Only 1 closed issue (#3), should not show load-more
XCTAssertTrue(app.staticTexts["Test issue 3 from integration tests"].waitForExistence(timeout: 10))
let loadMore = app.activityIndicators["load-more-indicator"]

View file

@ -14,7 +14,7 @@ final class PermissionUITests: ForgejoUITestBase {
]
}
// MARK: - Issue Detail read-only user
// MARK: - Issue Detail, read-only user
@MainActor
func testIssueDetailHidesActionsForReadOnlyUser() throws {
@ -43,7 +43,7 @@ final class PermissionUITests: ForgejoUITestBase {
XCTAssertFalse(toggleStateButton.exists, "Close/Reopen button should be hidden for read-only user")
}
// MARK: - PR Detail read-only user
// MARK: - PR Detail, read-only user
@MainActor
func testPRDetailHidesActionsForReadOnlyUser() throws {

View file

@ -29,7 +29,7 @@ final class PullRequestUITests: ForgejoReadOnlyUITestBase {
openButton.tap()
XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10))
// PR detail title, branches
// PR detail, title, branches
let prCell = app.staticTexts["Add feature file"].firstMatch
prCell.tap()
@ -55,7 +55,7 @@ final class PullRequestUITests: ForgejoReadOnlyUITestBase {
XCTAssertTrue(app.staticTexts["testbot"].waitForExistence(timeout: 10))
}
// MARK: - Diff View context line has no comment button
// MARK: - Diff View, context line has no comment button
@MainActor
func testDiffViewContextLineHasNoCommentButton() throws {

View file

@ -33,7 +33,7 @@ final class RepositoryMutatingUITests: ForgejoUITestBase {
toggleButton.tap()
toggleButton.tap()
// File viewer tap hello.py
// File viewer, tap hello.py
let fileCell = app.staticTexts["hello.py"].firstMatch
XCTAssertTrue(fileCell.waitForExistence(timeout: 10))
fileCell.tap()

View file

@ -38,7 +38,7 @@ final class RepositoryUITests: ForgejoReadOnlyUITestBase {
searchField.buttons["Clear text"].tap()
XCTAssertTrue(app.staticTexts["test-repo"].waitForExistence(timeout: 10))
// Star toggle (last tap triggers NavigationLink, only tests tappability)
// Star toggle (last, tap triggers NavigationLink, only tests tappability)
let starButton = repoList.buttons["star-button"].firstMatch
XCTAssertTrue(starButton.waitForExistence(timeout: 5), "No star button found in repo list")
starButton.tap()
@ -130,7 +130,7 @@ final class RepositoryUITests: ForgejoReadOnlyUITestBase {
// Wait for repo detail to load
XCTAssertTrue(app.staticTexts["html-readme-repo"].waitForExistence(timeout: 10))
// Raw HTML tags should NOT appear as plain text they should be rendered
// Raw HTML tags should NOT appear as plain text, they should be rendered
XCTAssertFalse(
app.staticTexts["<details>"].waitForExistence(timeout: 5),
"Raw <details> tag should not be visible as plain text")

View file

@ -39,6 +39,7 @@ Forji is available on the Apple App Store for free: [Forji App Store](https://ap
- Create, edit, and close/reopen issues
- Manage labels, milestones, and assignees
- Comment with Markdown support
- Attach and view image and file attachments in descriptions and comments
### Pull Requests
- View PRs across all repositories or per-repo
@ -47,6 +48,7 @@ Forji is available on the Apple App Store for free: [Forji App Store](https://ap
- Submit reviews (comment, approve, request changes)
- Merge with merge commit, rebase, or squash
- Close, reopen, and edit PRs
- Attach and view image and file attachments in descriptions and comments
### Actions
- Browse Forgejo Actions workflows defined in a repository
@ -110,13 +112,13 @@ The Forji logo is based on the [Forgejo logo](https://codeberg.org/forgejo/forge
### Libraries
- [ForgejoKit](https://codeberg.org/secana/ForgejoKit) Forgejo API client (MIT)
- [Textual](https://github.com/gonzalezreal/textual) Markdown rendering (MIT)
- [HighlightSwift](https://github.com/appstefan/HighlightSwift) Code syntax highlighting (MIT)
- [mermaid](https://github.com/mermaid-js/mermaid) Diagram rendering (MIT)
- [marked](https://github.com/markedjs/marked) Markdown parser (MIT)
- [DOMPurify](https://github.com/cure53/DOMPurify) HTML sanitizer (Apache 2.0 / MPL 2.0)
- [github-markdown-css](https://github.com/sindresorhus/github-markdown-css) GitHub-style Markdown styling (MIT)
- [ForgejoKit](https://codeberg.org/secana/ForgejoKit), Forgejo API client (MIT)
- [Textual](https://github.com/gonzalezreal/textual), Markdown rendering (MIT)
- [HighlightSwift](https://github.com/appstefan/HighlightSwift), Code syntax highlighting (MIT)
- [mermaid](https://github.com/mermaid-js/mermaid), Diagram rendering (MIT)
- [marked](https://github.com/markedjs/marked), Markdown parser (MIT)
- [DOMPurify](https://github.com/cure53/DOMPurify), HTML sanitizer (Apache 2.0 / MPL 2.0)
- [github-markdown-css](https://github.com/sindresorhus/github-markdown-css), GitHub-style Markdown styling (MIT)
## Contributing