Compare commits

..

No commits in common. "main" and "v1.5" have entirely different histories.
main ... v1.5

61 changed files with 221 additions and 1277 deletions

1
.envrc
View file

@ -1 +0,0 @@
use flake

View file

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

View file

@ -1,65 +0,0 @@
# Changelog
All notable changes to Forji will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.6] - 2026-06-15
### Added
- Shorten the Pull Requests tab to PRs in repository detail (#59)
- Show the overview filter summary only when filters are active (#60)
- Compact notification rows (#55)
- Display and upload image attachments in issues, PRs, and comments
### Changed
- Add a CHANGELOG.md file
### Fixed
- Refresh the merged notifications list when a push notification is opened (#71)
- Render 3-digit shorthand label colors instead of falling back to gray
- Show graceful empty state when repository has issues turned off
- Show graceful empty state when repository has issues turned off
- De-duplicate involved-scope results across pages in single-instance overviews (#70)
- Show graceful empty state when repository has pull requests turned off (#77)
- Make issue/PR description editable in edit form (#80) (#81)
- Clearer login error for security-key (passkey) accounts (#82)
## [1.5] - 2026-06-04
### Added
- VoiceOver accessibility labels for icon-only buttons (#49).
- Tappable diff lines now act as buttons for VoiceOver (#61).
- `CONTRIBUTING.md` (#42).
- `just sim-update` command for updating the simulator.
### Changed
- Updated ForgejoKit dependency from 0.6.0 to 0.7.0 (via 0.6.1).
- Refactored error handling to use ForgejoKit error categories (#31).
- Fixed a typo in the README App Store line (#43).
### Removed
- Redundant notification "Dismiss" swipe action (#46).
### Fixed
- Crash in merged overviews when two accounts shared the same `sourceKey` (#39).
- Merged pagination now settles correctly and no longer shows duplicate rows (#47).
- Merged issue/PR overview now refreshes after a mutation (#56).
- App icon badge now updates in multi-instance mode (#58).
- Multi-instance fallback is now keyed by account (#29).
- Removing an account now deletes the API token, not just the password (#40).
- Instance removal is now persisted before deleting the keychain on logout (#48).
- Added a guard that prevents editing an account into a duplicate of an existing one.
- Token restore error context is now preserved (#30).
- Issue/PR detail now refreshes after a partial edit failure (#50).
- No longer opens a duplicate PR when the reviewer request fails (#53).
- Background notification poll no longer crashes on an invalid instance URL (#54).
- Messages that were not marked as read are now handled correctly.

View file

@ -438,7 +438,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.6;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -454,7 +454,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.6;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.Forji;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@ -474,7 +474,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.6;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@ -490,7 +490,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.6;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.Forji;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@ -509,11 +509,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.6;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.6;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -532,11 +532,11 @@
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.6;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
MARKETING_VERSION = 1.6;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiTests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -554,10 +554,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.6;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.6;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -575,10 +575,10 @@
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1.6;
CURRENT_PROJECT_VERSION = 1.5;
DEVELOPMENT_TEAM = RVT2M7QTD4;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.6;
MARKETING_VERSION = 1.5;
PRODUCT_BUNDLE_IDENTIFIER = de.hausotte.ForjiUITests;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
@ -639,7 +639,7 @@
repositoryURL = "https://codeberg.org/secana/ForgejoKit.git";
requirement = {
kind = exactVersion;
version = 0.8.1;
version = 0.7.0;
};
};
DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */ = {

View file

@ -6,8 +6,8 @@
"kind" : "remoteSourceControl",
"location" : "https://codeberg.org/secana/ForgejoKit.git",
"state" : {
"revision" : "7675312f5ca302968e267aad632bec4c76414e06",
"version" : "0.8.1"
"revision" : "4d1dfc1305c0194fc74b09d15d29940a98cd9752",
"version" : "0.7.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

@ -9,17 +9,16 @@ final class PaginationState<Item> {
private(set) var hasLoaded = false
var errorMessage: String?
var showError = false
var notFound = false
private var currentPage = 1
private var loadTask: Task<Void, Never>?
let pageSize: Int
var cacheName: String?
/// Optional key extractor enabling cross-page de-duplication. Views that
/// combine several paginated sources set this (merged multi-instance lists,
/// or the per-flag involvement queries), because the same item can resurface
/// on a later page and the combined page size no longer matches `pageSize`.
/// Optional key extractor enabling cross-page de-duplication. Merged views set
/// this because they combine paginated results from several instances, where
/// the same item can resurface on a later page (overlapping involvement
/// queries) and the merged page size no longer matches `pageSize`.
@ObservationIgnored var dedupeKey: ((Item) -> AnyHashable)?
@ObservationIgnored private var seenKeys = Set<AnyHashable>()
@ -27,11 +26,10 @@ final class PaginationState<Item> {
self.pageSize = pageSize
}
init(items: [Item], hasMore: Bool = false, pageSize: Int = 20, notFound: Bool = false) {
init(items: [Item], hasMore: Bool = false, pageSize: Int = 20) {
self.items = items
self.hasMore = hasMore
self.pageSize = pageSize
self.notFound = notFound
}
@discardableResult
@ -46,7 +44,6 @@ final class PaginationState<Item> {
hasLoaded = false
isLoading = true
showError = false
notFound = false
let pageSize = pageSize
let task = Task {
do {

View file

@ -115,28 +115,6 @@ 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)
}
@ -269,7 +269,7 @@ enum SessionRestoreError: LocalizedError, Equatable {
.invalidServerResponse
case .invalidURL:
.serverNotFound
case .invalidCredentials, .otpRequired, .basicAuthBlockedBySecurityKey, .serverNotFound, .unknownError:
case .invalidCredentials, .otpRequired, .serverNotFound, .unknownError:
fromHTTPErrorCategory(error.httpErrorCategory, statusCode: error.httpStatusCode)
}
}

View file

@ -1,122 +0,0 @@
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,7 +3,6 @@ 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 = ""
@ -21,7 +20,6 @@ struct CommentSheet: View {
text: $text,
selectedTab: $selectedTab,
showToolbar: true,
onUploadImage: onUploadImage,
)
} else {
MentionableEditorField(
@ -29,7 +27,6 @@ struct CommentSheet: View {
selectedTab: $selectedTab,
users: users,
showToolbar: true,
onUploadImage: onUploadImage,
)
}
}

View file

@ -41,11 +41,6 @@ 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,14 +117,6 @@ 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))") {
@ -195,14 +187,13 @@ struct IssueDetailView: View {
repository: repository,
issue: issue,
authService: authService,
onUploadImage: uploadIssueImage,
) { updatedIssue in
self.issue = updatedIssue
}
}
}
.sheet(isPresented: $showCommentSheet) {
CommentSheet(users: [], onUploadImage: uploadIssueImage) { body in
CommentSheet(users: []) { body in
guard let issueService else { throw URLError(.userAuthenticationRequired) }
let comment = try await issueService.createComment(
owner: owner,
@ -219,15 +210,6 @@ 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,20 +21,14 @@ 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,
onUploadImage: ((Data, String, String) async throws -> String)? = nil,
onSaved: @escaping (Issue) -> Void,
) {
init(repository: Repository, issue: Issue, authService: AuthenticationService, 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 ?? "")
@ -60,7 +54,7 @@ struct IssueEditView: View {
.accessibilityIdentifier("issue-edit-title-field")
}
DescriptionEditorSection(text: $bodyText, onUploadImage: onUploadImage)
DescriptionEditorSection(text: $bodyText)
LabelPickerSection(
availableLabels: availableLabels,
@ -157,7 +151,6 @@ 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

@ -19,7 +19,10 @@ struct IssueLabelView: View {
}
private var textColor: Color {
guard let rgb = Color.rgbValue(hex: label.color) else {
let hex = label.color.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard hex.count == 6,
let rgb = UInt64(hex, radix: 16)
else {
return .white
}
let red = Double((rgb >> 16) & 0xFF) / 255.0
@ -32,7 +35,10 @@ struct IssueLabelView: View {
extension Color {
init?(hex: String) {
guard let rgb = Color.rgbValue(hex: hex) else {
let hex = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
guard hex.count == 6,
let rgb = UInt64(hex, radix: 16)
else {
return nil
}
self.init(
@ -41,21 +47,6 @@ extension Color {
blue: Double(rgb & 0xFF) / 255.0,
)
}
/// Parses a 6-digit or 3-digit shorthand hex color into its RGB value,
/// expanding the shorthand the same way Forgejo normalizes label colors
/// (#f00 -> #ff0000). Labels created before Forgejo normalized colors on
/// write can still carry the shorthand form.
static func rgbValue(hex: String) -> UInt64? {
var hex = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
if hex.count == 3 {
hex = hex.map { "\($0)\($0)" }.joined()
}
guard hex.count == 6 else {
return nil
}
return UInt64(hex, radix: 16)
}
}
#if DEBUG

View file

@ -38,12 +38,6 @@ struct IssueListView: View {
List {
if pagination.isLoading, pagination.items.isEmpty {
LoadingListSection()
} else if pagination.notFound {
ContentUnavailableView {
Label("Issues Unavailable", systemImage: "exclamationmark.circle")
} description: {
Text("Issues are not available for this repository.")
}
} else if pagination.items.isEmpty {
ContentUnavailableView {
Label(
@ -55,7 +49,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.",
)
}
@ -122,18 +116,13 @@ struct IssueListView: View {
private func reloadIssues(clearItems: Bool = false) -> Task<Void, Never> {
guard let issueService else { return Task {} }
return pagination.reload(clearItems: clearItems) { [self] page, limit in
do {
return try await issueService.fetchIssues(
owner: owner,
repo: repo,
state: stateFilter.rawValue,
page: page,
limit: limit,
)
} catch let error as ServiceError where error.httpStatusCode == 404 {
pagination.notFound = true
return []
}
try await issueService.fetchIssues(
owner: owner,
repo: repo,
state: stateFilter.rawValue,
page: page,
limit: limit,
)
}
}
@ -151,13 +140,11 @@ struct IssueListView: View {
}
#if DEBUG
init(preview _: Void, repository: Repository, authService: AuthenticationService, issues: [Issue],
notFound: Bool = false)
{
init(preview _: Void, repository: Repository, authService: AuthenticationService, issues: [Issue]) {
self.repository = repository
self.authService = authService
issueService = nil
_pagination = State(initialValue: PaginationState(items: issues, notFound: notFound))
_pagination = State(initialValue: PaginationState(items: issues))
}
#endif
}
@ -173,18 +160,6 @@ struct IssueListView: View {
)
}
}
#Preview("Issues Unavailable") {
NavigationStack {
IssueListView(
preview: (),
repository: .preview,
authService: .previewDefault,
issues: [],
notFound: true,
)
}
}
#endif
struct IssueRow: View {

View file

@ -1,8 +1,6 @@
import ForgejoKit
import PhotosUI
import SwiftUI
import Textual
import UniformTypeIdentifiers
enum EditPreviewTab: String, CaseIterable {
case edit = "Edit"
@ -14,9 +12,6 @@ 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) {
@ -31,7 +26,7 @@ struct MarkdownEditorField: View {
if selectedTab == .edit {
VStack(spacing: 0) {
if showToolbar {
MarkdownToolbar(text: $text, onUploadImage: onUploadImage)
MarkdownToolbar(text: $text)
}
TextEditor(text: $text)
.frame(minHeight: minHeight, maxHeight: .infinity)
@ -63,6 +58,66 @@ 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

@ -1,215 +0,0 @@
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

@ -1,47 +0,0 @@
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,7 +7,6 @@ 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?
@ -25,7 +24,6 @@ struct MentionableEditorField: View {
selectedTab: $selectedTab,
minHeight: minHeight,
showToolbar: showToolbar,
onUploadImage: onUploadImage,
)
if selectedTab == .edit, mentionQuery != nil, !matchingUsers.isEmpty {

View file

@ -6,7 +6,6 @@ struct MergedNotificationsOverviewView: View {
@State private var pagination: PaginationState<TaggedItem<NotificationThread>>
@State private var statusFilter: String = "unread"
@Environment(NavigationState.self) private var navigationState
init(manager: MultiInstanceManager) {
self.manager = manager
@ -96,9 +95,6 @@ struct MergedNotificationsOverviewView: View {
.onChange(of: statusFilter) {
reloadNotifications(clearItems: true)
}
.onChange(of: navigationState.notificationsRefreshTrigger) {
reloadNotifications()
}
.errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError)
}
@ -271,17 +267,13 @@ private struct MergedNotificationRow: View {
}
}
HStack(spacing: 4) {
Text(notification.repository.fullName)
.lineLimit(1)
.truncationMode(.middle)
Text("·")
.foregroundStyle(.tertiary)
Text(formatRelativeDate(notification.updatedAt))
.fixedSize()
}
.font(.caption)
.foregroundStyle(.secondary)
Text(notification.repository.fullName)
.font(.caption)
.foregroundStyle(.secondary)
Text(formatRelativeDate(notification.updatedAt))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
@ -290,6 +282,7 @@ private struct MergedNotificationRow: View {
Circle()
.fill(.blue)
.frame(width: 10, height: 10)
.glassEffect(.regular.tint(.blue))
.padding(.top, 6)
}
}

View file

@ -6,7 +6,6 @@ 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 {
@ -21,16 +20,11 @@ struct DescriptionEditorSection: View {
MarkdownPreview(text: text)
.font(.subheadline)
.lineLimit(3)
// The preview renders selectable text and tappable links. Inside a
// Button label those intercept the tap, so the editor sheet never
// opens. Disable hit testing so the tap falls through to the Button.
.allowsHitTesting(false)
}
}
.tint(.primary)
.accessibilityIdentifier("description-edit-button")
.sheet(isPresented: $showingEditor) {
DescriptionEditorSheet(text: $text, title: title, onUploadImage: onUploadImage)
DescriptionEditorSheet(text: $text, title: title)
}
}
}
@ -39,7 +33,6 @@ 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
@ -49,7 +42,6 @@ private struct DescriptionEditorSheet: View {
text: $text,
selectedTab: $selectedTab,
showToolbar: true,
onUploadImage: onUploadImage,
)
.frame(maxHeight: .infinity)
.padding()

View file

@ -249,17 +249,13 @@ private struct NotificationRow: View {
.font(.body)
.lineLimit(2)
HStack(spacing: 4) {
Text(notification.repository.fullName)
.lineLimit(1)
.truncationMode(.middle)
Text("·")
.foregroundStyle(.tertiary)
Text(formatRelativeDate(notification.updatedAt))
.fixedSize()
}
.font(.caption)
.foregroundStyle(.secondary)
Text(notification.repository.fullName)
.font(.caption)
.foregroundStyle(.secondary)
Text(formatRelativeDate(notification.updatedAt))
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
@ -268,6 +264,7 @@ private struct NotificationRow: View {
Circle()
.fill(.blue)
.frame(width: 10, height: 10)
.glassEffect(.regular.tint(.blue))
.padding(.top, 6)
}
}

View file

@ -31,7 +31,6 @@ 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) {
@ -39,7 +38,6 @@ 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) }
}
@ -110,13 +108,6 @@ struct PullRequestDetailView: View {
}
}
if let assets = activePR.assets, !assets.isEmpty {
Section("Attachments") {
AttachmentGallery(attachments: assets)
.padding(.vertical, 4)
}
}
reviewsSection
changesSection
conflictSection(activePR)
@ -142,7 +133,6 @@ struct PullRequestDetailView: View {
repository: repository,
pullRequest: editingPR,
authService: authService,
onUploadImage: uploadPRImage,
) { _ in
Task { await loadData() }
}
@ -184,7 +174,7 @@ struct PullRequestDetailView: View {
}
}
.sheet(isPresented: $showCommentSheet) {
CommentSheet(users: assignees, onUploadImage: uploadPRImage) { body in
CommentSheet(users: assignees) { body in
guard let prService else { throw URLError(.userAuthenticationRequired) }
let comment = try await prService.createComment(
owner: owner,
@ -446,15 +436,6 @@ 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
@ -605,7 +586,6 @@ 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,7 +22,6 @@ 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 {
@ -32,7 +31,6 @@ 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
@ -40,7 +38,6 @@ 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 ?? "")
@ -67,7 +64,7 @@ struct PullRequestEditView: View {
.accessibilityIdentifier("pr-edit-title-field")
}
DescriptionEditorSection(text: $bodyText, onUploadImage: onUploadImage)
DescriptionEditorSection(text: $bodyText)
LabelPickerSection(
availableLabels: availableLabels,
@ -202,7 +199,6 @@ 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

@ -38,12 +38,6 @@ struct PullRequestListView: View {
List {
if pagination.isLoading, pagination.items.isEmpty {
LoadingListSection()
} else if pagination.notFound {
ContentUnavailableView {
Label("Pull Requests Unavailable", systemImage: "exclamationmark.circle")
} description: {
Text("Pull requests are not available for this repository.")
}
} else if pagination.items.isEmpty {
ContentUnavailableView {
Label(
@ -55,7 +49,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.",
)
}
@ -122,18 +116,13 @@ struct PullRequestListView: View {
private func reloadPullRequests(clearItems: Bool = false) -> Task<Void, Never> {
guard let prService else { return Task {} }
return pagination.reload(clearItems: clearItems) { [self] page, limit in
do {
return try await prService.fetchPullRequests(
owner: owner,
repo: repo,
state: stateFilter.rawValue,
page: page,
limit: limit,
)
} catch let error as ServiceError where error.httpStatusCode == 404 {
pagination.notFound = true
return []
}
try await prService.fetchPullRequests(
owner: owner,
repo: repo,
state: stateFilter.rawValue,
page: page,
limit: limit,
)
}
}
@ -151,13 +140,11 @@ struct PullRequestListView: View {
}
#if DEBUG
init(preview _: Void, repository: Repository, authService: AuthenticationService, pullRequests: [PullRequest],
notFound: Bool = false)
{
init(preview _: Void, repository: Repository, authService: AuthenticationService, pullRequests: [PullRequest]) {
self.repository = repository
self.authService = authService
prService = nil
_pagination = State(initialValue: PaginationState(items: pullRequests, notFound: notFound))
_pagination = State(initialValue: PaginationState(items: pullRequests))
}
#endif
}
@ -173,18 +160,6 @@ struct PullRequestListView: View {
)
}
}
#Preview("Pull Requests Unavailable") {
NavigationStack {
PullRequestListView(
preview: (),
repository: .preview,
authService: .previewDefault,
pullRequests: [],
notFound: true,
)
}
}
#endif
struct PullRequestRow: View {

View file

@ -30,7 +30,7 @@ struct RepositoryDetailView: View {
enum DetailTab: String {
case code = "Code"
case issues = "Issues"
case pulls = "PRs"
case pulls = "Pull Requests"
case actions = "Actions"
var icon: String {
@ -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

@ -59,7 +59,6 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
let state = PaginationState<Issue>()
state.cacheName = issueType
state.dedupeKey = { $0.id }
state.loadFromCache()
_pagination = State(initialValue: state)
}
@ -69,7 +68,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 +84,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
}
@ -145,15 +144,13 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
}
.listStyle(.insetGrouped)
.safeAreaInset(edge: .top) {
if hasNonDefaultFilters {
Text(filterSummaryText)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.horizontal)
.padding(.vertical, 6)
.accessibilityIdentifier("filter-summary")
}
Text(filterSummaryText)
.font(.caption)
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity)
.padding(.horizontal)
.padding(.vertical, 6)
.accessibilityIdentifier("filter-summary")
}
}

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,41 +0,0 @@
@testable import Forji
import SwiftUI
import Testing
struct LabelColorHexTests {
// MARK: - Six-digit colors
@Test("six digit hex parses") func sixDigitHexParses() {
#expect(Color(hex: "#ff0000") != nil)
#expect(Color(hex: "00ff00") != nil)
}
@Test("six digit value matches") func sixDigitValueMatches() throws {
let rgb = try #require(Color.rgbValue(hex: "#3366cc"))
#expect(rgb == 0x3366CC)
}
// MARK: - Three-digit shorthand
@Test("three digit shorthand parses") func threeDigitShorthandParses() {
#expect(Color(hex: "#f00") != nil)
#expect(Color(hex: "abc") != nil)
}
@Test("three digit shorthand expands like six digit") func threeDigitShorthandExpandsLikeSixDigit() throws {
#expect(Color(hex: "#f00") == Color(hex: "#ff0000"))
#expect(Color(hex: "abc") == Color(hex: "aabbcc"))
let rgb = try #require(Color.rgbValue(hex: "#f00"))
#expect(rgb == 0xFF0000)
}
// MARK: - Invalid input
@Test("invalid hex returns nil") func invalidHexReturnsNil() {
#expect(Color(hex: "") == nil)
#expect(Color(hex: "#ff00") == nil)
#expect(Color(hex: "12345") == nil)
#expect(Color(hex: "xyzxyz") == nil)
#expect(Color(hex: "#xyz") == nil)
}
}

View file

@ -1,50 +1,16 @@
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

@ -1,4 +1,3 @@
// swiftlint:disable file_length
import Foundation
import Testing
@testable import Forji
@ -147,13 +146,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 +176,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 +212,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 +237,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 +263,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 +286,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)
}
@ -360,41 +359,6 @@ struct PaginationStateHasLoadedTests {
}
}
// MARK: - notFound
struct PaginationStateNotFoundTests {
@Test @MainActor func notFoundFalseInitially() {
let pagination = PaginationState<String>(pageSize: 5)
#expect(!pagination.notFound)
}
@Test @MainActor func notFoundSetByFetchClosureShowsNoAlert() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in
pagination.notFound = true
return []
}.value
#expect(pagination.notFound)
#expect(pagination.items.isEmpty)
#expect(!pagination.showError)
#expect(pagination.hasLoaded)
}
@Test @MainActor func reloadClearsNotFound() async {
let pagination = PaginationState<String>(pageSize: 20)
await pagination.reload { _, _ in
pagination.notFound = true
return []
}.value
#expect(pagination.notFound)
await pagination.reload { _, _ in ["ok"] }.value
#expect(!pagination.notFound)
#expect(pagination.items == ["ok"])
}
}
// MARK: - loadMore
struct PaginationStateLoadMoreTests {
@ -404,7 +368,7 @@ struct PaginationStateLoadMoreTests {
await pagination.reload { _, _ in ["a", "b"] }.value
#expect(pagination.hasMore)
await pagination.loadMore { page, _ in
await pagination.loadMore { page, limit in
#expect(page == 2)
return ["c", "d"]
}
@ -463,11 +427,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
@ -475,50 +439,3 @@ struct PaginationStateLoadMoreTests {
#expect(!pagination.isLoading)
}
}
// MARK: - Keyed de-duplication
struct PaginationStateDedupeTests {
@Test @MainActor func loadMoreFiltersKeysSeenOnEarlierPages() async {
let pagination = PaginationState<String>(pageSize: 2)
pagination.dedupeKey = { $0 }
await pagination.reload { _, _ in ["a", "b"] }.value
// "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
}
@Test @MainActor func hasMoreFollowsNewItemsNotPageSize() async {
let pagination = PaginationState<String>(pageSize: 5)
pagination.dedupeKey = { $0 }
await pagination.reload { _, _ in ["a", "b"] }.value
#expect(pagination.hasMore) // 2 < pageSize 5, but the page contributed new items
}
@Test @MainActor func hasMoreFalseWhenFullPageIsAllDuplicates() async {
let pagination = PaginationState<String>(pageSize: 2)
pagination.dedupeKey = { $0 }
await pagination.reload { _, _ in ["a", "b"] }.value
#expect(pagination.hasMore)
// 2 >= pageSize 2, but every key was already seen -> pagination ends
await pagination.loadMore { _, _ in ["a", "b"] }
#expect(pagination.items == ["a", "b"])
#expect(!pagination.hasMore)
}
@Test @MainActor func reloadResetsSeenKeys() async {
let pagination = PaginationState<String>(pageSize: 2)
pagination.dedupeKey = { $0 }
await pagination.reload { _, _ in ["a", "b"] }.value
await pagination.loadMore { _, _ in ["b", "c"] }
#expect(pagination.items == ["a", "b", "c"])
// A fresh reload must accept keys seen in the previous cycle
await pagination.reload { _, _ in ["a", "b"] }.value
#expect(pagination.items == ["a", "b"])
}
}

View file

@ -1,179 +0,0 @@
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()
@ -67,36 +67,11 @@ final class IssueMutatingUITests: ForgejoUITestBase {
editTitleField.tap(withNumberOfTaps: 3, numberOfTouches: 1)
editTitleField.typeText("Edited issue title")
// Edit the description. Tapping the description row must open the editor
// sheet, the regression from issue #80 was that the selectable markdown
// preview swallowed the tap so the sheet never appeared.
let descriptionButton = app.buttons["description-edit-button"]
XCTAssertTrue(descriptionButton.waitForExistence(timeout: 5))
descriptionButton.tap()
let descriptionEditor = app.textViews["markdown-text-editor"]
XCTAssertTrue(
descriptionEditor.waitForExistence(timeout: 5),
"Tapping the description should open the editor sheet",
)
descriptionEditor.tap()
descriptionEditor.typeText("UITESTDESC ")
app.buttons["Done"].tap()
let saveButton = app.buttons["issue-edit-save"]
XCTAssertTrue(saveButton.waitForExistence(timeout: 5))
saveButton.tap()
XCTAssertTrue(app.staticTexts["Edited issue title"].waitForExistence(timeout: 10))
// The edited description must be persisted and rendered in the detail view.
let editedDescription = app.staticTexts.element(
matching: NSPredicate(format: "label CONTAINS %@", "UITESTDESC"),
)
XCTAssertTrue(
editedDescription.waitForExistence(timeout: 10),
"Edited description should appear in the issue detail",
)
// Close and reopen
expandActionMenu()
let toggleButton = app.buttons["issue-toggle-state"]

View file

@ -37,17 +37,17 @@ final class IssueUITests: ForgejoReadOnlyUITestBase {
// Wait for list to load
XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 10), "Issues should load")
// In the default state (Open + All involvement) the summary is hidden.
// Default filter summary should show "Open" (default scope is All involvement)
let filterSummary = app.staticTexts["filter-summary"]
XCTAssertFalse(filterSummary.exists, "Filter summary should be hidden in the default state")
XCTAssertTrue(filterSummary.waitForExistence(timeout: 5))
XCTAssertEqual(filterSummary.label, "Open")
let filterMenuButton = app.buttons["filter-menu-button"]
// Selecting a non-default scope reveals the summary.
// Tap "Assigned to you" via filter menu
filterMenuButton.tap()
app.buttons["Assigned to you"].tap()
sleep(2)
XCTAssertTrue(filterSummary.waitForExistence(timeout: 5))
XCTAssertTrue(filterSummary.label.contains("Assigned to you"))
// Tap "Mentioned" via filter menu

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 {
@ -111,17 +111,17 @@ final class PullRequestUITests: ForgejoReadOnlyUITestBase {
// Reset filters to defaults (previous test may have changed them)
resetOverviewFilters()
// In the default state (Open + All involvement) the summary is hidden.
// Default filter summary should show "Open" (default scope is All involvement)
let filterSummary = app.staticTexts["filter-summary"]
XCTAssertFalse(filterSummary.exists, "Filter summary should be hidden in the default state")
XCTAssertTrue(filterSummary.waitForExistence(timeout: 5))
XCTAssertEqual(filterSummary.label, "Open")
let filterMenuButton = app.buttons["filter-menu-button"]
// Selecting a non-default scope reveals the summary.
// Tap "Assigned to you" via filter menu
filterMenuButton.tap()
app.buttons["Assigned to you"].tap()
sleep(2)
XCTAssertTrue(filterSummary.waitForExistence(timeout: 5))
XCTAssertTrue(filterSummary.label.contains("Assigned to you"))
// Tap "Mentioned" via filter menu

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

@ -25,7 +25,7 @@ extension UITestNavigating {
navigateToRepoDetail()
let picker = app.segmentedControls["repo-detail-tab-picker"]
XCTAssertTrue(picker.waitForExistence(timeout: 10))
picker.buttons["PRs"].tap()
picker.buttons["Pull Requests"].tap()
}
/// Resets the overview filter to Open + All involvement (the app defaults).

View file

@ -39,7 +39,6 @@ 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
@ -48,7 +47,6 @@ 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
@ -112,13 +110,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

View file

@ -1,54 +0,0 @@
# git-cliff configuration — generates CHANGELOG.md from conventional commits.
# Output follows the Keep a Changelog format (https://keepachangelog.com/en/1.1.0/).
# See https://git-cliff.org/docs/configuration
[changelog]
header = """
# Changelog
All notable changes to Forji will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n
"""
body = """
{% if version -%}
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else -%}
## [Unreleased]
{% endif -%}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message | split(pat="\n") | first | upper_first | trim }}\
{% endfor %}
{% endfor %}\n
"""
footer = ""
# Remove leading and trailing whitespaces from the changelog's body.
trim = true
[git]
# Parse commits according to the conventional commits specification.
conventional_commits = true
# Drop commits that are not conventional (e.g. merge commits).
filter_unconventional = true
# Drop commits that no parser assigns to a group.
filter_commits = true
# Only consider tags that look like releases (v1.5, v1.6, ...).
tag_pattern = "v[0-9]*"
sort_commits = "oldest"
# Groups render alphabetically; "Added" < "Changed" < "Fixed" already matches
# the Keep a Changelog ordering for the types we emit.
commit_parsers = [
{ message = "^feat", group = "Added" },
{ message = "^fix", group = "Fixed" },
{ message = "^refactor", group = "Changed" },
{ message = "^perf", group = "Changed" },
{ message = "^docs", group = "Changed" },
{ message = "^chore", skip = true },
{ message = "^test", skip = true },
{ message = "^style", skip = true },
{ message = "^ci", skip = true },
{ message = "^build", skip = true },
]

View file

@ -18,7 +18,6 @@
pkgs.xcbeautify
pkgs.swiftlint
pkgs.swiftformat
pkgs.git-cliff
];
};
});

View file

@ -4,7 +4,6 @@ enum DockerExec {
static func run(
composeFile: String, service: String,
command: [String],
timeout: TimeInterval = 60,
) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
@ -17,21 +16,8 @@ enum DockerExec {
process.standardError = stderrPipe
process.standardOutput = FileHandle.nullDevice
let didExit = DispatchSemaphore(value: 0)
process.terminationHandler = { _ in didExit.signal() }
try process.run()
// Enforce a timeout so a hung `docker compose exec` (a known Docker-on-macOS flake)
// fails fast and can be retried, instead of blocking the whole seed indefinitely.
if didExit.wait(timeout: .now() + timeout) == .timedOut {
process.terminate()
_ = didExit.wait(timeout: .now() + 5)
throw SeedError.dockerExecTimedOut(
command: command.joined(separator: " "),
seconds: timeout,
)
}
process.waitUntilExit()
if process.terminationStatus != 0 {
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()

View file

@ -3,10 +3,6 @@ import Foundation
@main
struct ForgejeSeed {
static func main() async throws {
// Line-buffer stdout so seed progress streams live instead of being held in a
// block buffer (and dumped all at once) when stdout is a pipe rather than a TTY.
setvbuf(stdout, nil, _IOLBF, 0)
let args = CommandLine.arguments
guard args.count >= 3 else {
throw SeedError.missingArguments

View file

@ -3,7 +3,6 @@ import Foundation
enum SeedError: LocalizedError {
case missingArguments
case dockerExecFailed(command: String, exitCode: Int32, stderr: String)
case dockerExecTimedOut(command: String, seconds: TimeInterval)
case retryExhausted(operation: String, attempts: Int)
var errorDescription: String? {
@ -12,8 +11,6 @@ enum SeedError: LocalizedError {
"Usage: forgejo-seed <base-url> <service-name> [compose-file]"
case let .dockerExecFailed(command, exitCode, stderr):
"Docker exec failed (\(exitCode)): \(command)\n\(stderr)"
case let .dockerExecTimedOut(command, seconds):
"Docker exec timed out after \(Int(seconds))s: \(command)"
case let .retryExhausted(operation, attempts):
"\(operation) failed after \(attempts) attempts"
}

View file

@ -25,30 +25,14 @@ struct Seeder {
allowSelfSignedCertificates: true,
)
let start = Date()
let total = 7
func step(_ number: Int, _ label: String) {
let elapsed = Int(Date().timeIntervalSince(start))
print("\n[\(number)/\(total)] \(label) (\(elapsed)s elapsed)")
}
step(1, "Creating admin user")
try await withRetry(maxAttempts: 3, delay: .seconds(2), operation: "create admin user") {
try createAdminUser()
}
step(2, "Creating API users")
try createAdminUser()
try await createAPIUsers(adminClient: adminClient)
step(3, "Creating repositories")
try await createRepositories(adminClient: adminClient)
step(4, "Creating issues, files, and metadata")
try await createIssuesFilesMetadata(adminClient: adminClient, testbotClient: testbotClient)
step(5, "Creating pull requests")
try await createPullRequests(testbotClient: testbotClient)
step(6, "Setting pull request metadata")
try await setPRMetadata(adminClient: adminClient, testbotClient: testbotClient)
step(7, "Creating API token")
try await createAPIToken(adminClient: adminClient)
print("\nSeed data created successfully (\(Int(Date().timeIntervalSince(start)))s total).")
print("\nSeed data created successfully.")
}
// MARK: - Phase 1: Create users

View file

@ -37,51 +37,11 @@ pbxproj := "Forji/Forji.xcodeproj/project.pbxproj"
version:
@grep -m1 'MARKETING_VERSION' {{pbxproj}} | sed 's/.*= *//;s/;.*//'
changelog := "CHANGELOG.md"
# Preview the changelog entries for unreleased commits (since the latest tag)
changelog:
@git cliff --config cliff.toml --unreleased --strip header
# Release a new version: bump version, generate the changelog, tag, commit, and push
release new_version:
#!/usr/bin/env bash
set -eo pipefail
NEW="{{new_version}}"
TAG="v$NEW"
# Refuse to release from a dirty tree so the release commit stays clean.
if [ -n "$(git status --porcelain)" ]; then
echo "Working tree is not clean. Commit or stash your changes before releasing."
exit 1
fi
# Refuse to clobber an existing tag.
if git rev-parse "$TAG" >/dev/null 2>&1; then
echo "Tag $TAG already exists."
exit 1
fi
PREV=$(grep -m1 'MARKETING_VERSION' {{pbxproj}} | sed 's/.*= *//;s/;.*//')
echo "Releasing $PREV -> $NEW"
# 1. Bump the version in the Xcode project. Use a backup suffix so the -i
# syntax works on both BSD sed (macOS) and GNU sed (nix dev shell).
sed -i.bak "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = $NEW/" {{pbxproj}}
sed -i.bak "s/CURRENT_PROJECT_VERSION = [^;]*/CURRENT_PROJECT_VERSION = $NEW/" {{pbxproj}}
rm -f {{pbxproj}}.bak
# 2. Generate the changelog section for the new version from the conventional
# commits since the last tag, prepending it under the header.
git cliff --config cliff.toml --unreleased --tag "$TAG" --prepend {{changelog}}
# 3. Commit, tag, and push.
git add {{pbxproj}} {{changelog}}
git commit -m "chore: release $NEW"
git tag -a "$TAG" -m "Release $NEW"
git push origin HEAD
git push origin "$TAG"
echo "Released $NEW and pushed $TAG."
# Set app version (updates both MARKETING_VERSION and CURRENT_PROJECT_VERSION)
set-version new_version:
sed -i '' 's/MARKETING_VERSION = [^;]*/MARKETING_VERSION = {{new_version}}/' {{pbxproj}}
sed -i '' 's/CURRENT_PROJECT_VERSION = [^;]*/CURRENT_PROJECT_VERSION = {{new_version}}/' {{pbxproj}}
@echo "Version set to {{new_version}}"
# Clean build artifacts
clean: