chore: formatting and lints

This commit is contained in:
Stefan Hausotte 2026-06-15 12:19:00 +02:00
parent a7491daffc
commit 69f7923a52
34 changed files with 99 additions and 86 deletions

View file

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

View file

@ -230,7 +230,7 @@
mainGroup = DEC49F182F3CE05200E7DD54; mainGroup = DEC49F182F3CE05200E7DD54;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
packageReferences = ( packageReferences = (
DE00000000000005000000AA /* XCLocalSwiftPackageReference "../../ForgejoKit" */, DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */,
DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */, DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */,
DEC49F812F3D173F00E7DD54 /* XCRemoteSwiftPackageReference "HighlightSwift" */, DEC49F812F3D173F00E7DD54 /* XCRemoteSwiftPackageReference "HighlightSwift" */,
); );
@ -633,14 +633,15 @@
}; };
/* End XCConfigurationList section */ /* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
DE00000000000005000000AA /* XCLocalSwiftPackageReference "../../ForgejoKit" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = ../../ForgejoKit;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */
DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://codeberg.org/secana/ForgejoKit.git";
requirement = {
kind = exactVersion;
version = 0.8.0;
};
};
DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */ = { DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */ = {
isa = XCRemoteSwiftPackageReference; isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/gonzalezreal/textual"; repositoryURL = "https://github.com/gonzalezreal/textual";
@ -662,12 +663,12 @@
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
DE00000000000002000000AA /* ForgejoKit */ = { DE00000000000002000000AA /* ForgejoKit */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = DE00000000000005000000AA /* XCLocalSwiftPackageReference "../../ForgejoKit" */; package = DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */;
productName = ForgejoKit; productName = ForgejoKit;
}; };
DE00000000000004000000AA /* ForgejoKit */ = { DE00000000000004000000AA /* ForgejoKit */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = DE00000000000005000000AA /* XCLocalSwiftPackageReference "../../ForgejoKit" */; package = DE00000000000005000000AA /* XCRemoteSwiftPackageReference "ForgejoKit" */;
productName = ForgejoKit; productName = ForgejoKit;
}; };
DEC49F6D2F3D023400E7DD54 /* Textual */ = { DEC49F6D2F3D023400E7DD54 /* Textual */ = {

View file

@ -1,6 +1,15 @@
{ {
"originHash" : "931ec0beeaf4e6a5eaa0afab6f815f97bc126bda7a2c9f001c10de58585e766f", "originHash" : "931ec0beeaf4e6a5eaa0afab6f815f97bc126bda7a2c9f001c10de58585e766f",
"pins" : [ "pins" : [
{
"identity" : "forgejokit",
"kind" : "remoteSourceControl",
"location" : "https://codeberg.org/secana/ForgejoKit.git",
"state" : {
"revision" : "485c147e7f9762c06f51029de47464fb41abc4af",
"version" : "0.8.0"
}
},
{ {
"identity" : "highlightswift", "identity" : "highlightswift",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",

View file

@ -150,7 +150,7 @@ struct ContentView: View {
defaultInstance.lastUsed = Date() defaultInstance.lastUsed = Date()
try? modelContext.save() try? modelContext.save()
} catch { } 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 { if let inlinePattern = inlineHTMLPattern {
let paragraphs = splitIntoParagraphs(masked) let paragraphs = splitIntoParagraphs(masked)
for para in paragraphs { for para in paragraphs {

View file

@ -36,7 +36,7 @@ struct WorkflowStatusStyle {
let label: String let label: String
/// Maps Forgejo's single status value to a UI style. Used for runs, jobs, /// 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, /// statuses: success, failure, cancelled, skipped, running, waiting,
/// blocked, unknown. Anything else falls back to a question-mark glyph. /// blocked, unknown. Anything else falls back to a question-mark glyph.
static func forStatus(_ status: String) -> WorkflowStatusStyle { static func forStatus(_ status: String) -> WorkflowStatusStyle {
@ -122,7 +122,7 @@ extension WorkflowRun {
} }
/// Elapsed time as TimeInterval. Forgejo serialises a Go `time.Duration` /// 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). /// timestamps only when both are sane (post-2000).
var duration: TimeInterval? { var duration: TimeInterval? {
if let nanos = durationNanos, nanos > 0 { if let nanos = durationNanos, nanos > 0 {

View file

@ -116,7 +116,7 @@ class AuthenticationService {
username: instance.username, username: instance.username,
) )
} catch { } catch {
// Keychain delete failed log in debug builds // Keychain delete failed, log in debug builds
#if DEBUG #if DEBUG
print("Keychain delete failed during logout: \(error)") print("Keychain delete failed during logout: \(error)")
#endif #endif
@ -171,7 +171,7 @@ class AuthenticationService {
isAuthenticated = true isAuthenticated = true
return return
} catch { } 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 { if useTokenAuth {
throw SessionRestoreError.fromTokenValidationError(error) throw SessionRestoreError.fromTokenValidationError(error)
} }

View file

@ -2,9 +2,9 @@ import ForgejoKit
import SwiftUI import SwiftUI
/// Markdown to insert when an attachment is uploaded. Images use embed syntax /// Markdown to insert when an attachment is uploaded. Images use embed syntax
/// (`![name](url)`) so they render inline; everything else (logs, PDFs, archives, /// (`![name](url)`) so they render inline; every other file type (logs, PDFs,
/// ) uses link syntax (`[name](url)`) so it appears as a clickable link rather /// archives, and so on) uses link syntax (`[name](url)`) so it appears as a
/// than a broken inline image. /// clickable link rather than a broken inline image.
func attachmentMarkdown(for attachment: Attachment) -> String { func attachmentMarkdown(for attachment: Attachment) -> String {
if attachment.isImage { if attachment.isImage {
"![\(attachment.name)](\(attachment.browserDownloadUrl))" "![\(attachment.name)](\(attachment.browserDownloadUrl))"

View file

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

View file

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

View file

@ -55,7 +55,7 @@ struct IssueListView: View {
} description: { } description: {
Text( Text(
stateFilter == .open stateFilter == .open
? "All clear no open issues to review." ? "All clear, no open issues to review."
: "No closed issues found.", : "No closed issues found.",
) )
} }

View file

@ -106,7 +106,7 @@ struct MarkdownToolbar: View {
private func uploadTestImage() async { private func uploadTestImage() async {
guard let onUploadImage else { return } guard let onUploadImage else { return }
// 1×1 transparent PNG used in UI tests to skip the Photos picker. // 1×1 transparent PNG, used in UI tests to skip the Photos picker.
let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" let base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
guard let pngData = Data(base64Encoded: base64) else { return } guard let pngData = Data(base64Encoded: base64) else { return }
await runUpload { try await onUploadImage(pngData, "test.png", "image/png") } await runUpload { try await onUploadImage(pngData, "test.png", "image/png") }
@ -114,7 +114,7 @@ struct MarkdownToolbar: View {
private func uploadTestFile() async { private func uploadTestFile() async {
guard let onUploadImage else { return } guard let onUploadImage else { return }
// Plain-text log file used in UI tests to exercise the non-image // Plain-text log file, used in UI tests to exercise the non-image
// attachment path (link markdown + file-row display). // attachment path (link markdown + file-row display).
let logData = Data("line1 ERROR boom\nline2 WARN stuff\nline3 done\n".utf8) let logData = Data("line1 ERROR boom\nline2 WARN stuff\nline3 done\n".utf8)
await runUpload { try await onUploadImage(logData, "server.log", "text/plain") } await runUpload { try await onUploadImage(logData, "server.log", "text/plain") }

View file

@ -55,7 +55,7 @@ struct PullRequestListView: View {
} description: { } description: {
Text( Text(
stateFilter == .open stateFilter == .open
? "All clear no open pull requests." ? "All clear, no open pull requests."
: "No closed pull requests found.", : "No closed pull requests found.",
) )
} }

View file

@ -185,7 +185,7 @@ struct RepositoryDetailView: View {
repo: repository.repoName, repo: repository.repoName,
) )
} catch { } catch {
// Non-critical branch selector stays disabled // Non-critical, branch selector stays disabled
} }
} }
.toolbar { .toolbar {
@ -504,7 +504,7 @@ struct RepositoryCodeView: View {
) )
readmeContent = fileContent.decodedContent readmeContent = fileContent.decodedContent
} catch { } 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." return "No \(itemNoun) matching your search."
} }
if stateFilter == .open { 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 + " " let prefix = stateFilter == .all ? "" : stateFilter.rawValue + " "
return "No \(prefix)\(itemNoun) found." return "No \(prefix)\(itemNoun) found."
@ -85,7 +85,7 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
case .closed: "Closed" case .closed: "Closed"
case .all: "All" 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) { if !searchText.isEmpty || (stateFilter == .open && involvementScope == .involved) {
return stateLabel return stateLabel
} }

View file

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

View file

@ -196,7 +196,7 @@ struct WorkflowRunDetailView: View {
isLoading = false 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). // (e.g. older Forgejo versions without this route, or auth quirks).
if let runIndex = run?.indexInRepo { if let runIndex = run?.indexInRepo {
await loadRunView(runIndex: runIndex) await loadRunView(runIndex: runIndex)
@ -214,7 +214,7 @@ struct WorkflowRunDetailView: View {
logCursors: [], logCursors: [],
) )
} catch { } 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() let formatter = DateComponentsFormatter()
formatter.allowedUnits = [.hour, .minute, .second] formatter.allowedUnits = [.hour, .minute, .second]
formatter.unitsStyle = .abbreviated 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") _ = try await KeychainManager.shared.getPassword(for: raw, username: "user")
Issue.record("Should not find password with non-normalized key") Issue.record("Should not find password with non-normalized key")
} catch is KeychainError { } catch is KeychainError {
// Expected keys differ // Expected, keys differ
} }
// Clean up // Clean up
@ -156,7 +156,7 @@ struct KeychainManagerTests {
_ = try await KeychainManager.shared.getToken(for: server, username: username) _ = try await KeychainManager.shared.getToken(for: server, username: username)
Issue.record("Expected KeychainError.notFound for token after deleteCredentials") Issue.record("Expected KeychainError.notFound for token after deleteCredentials")
} catch is KeychainError { } 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 { } catch {
Issue.record("Unexpected error: \(error)") Issue.record("Unexpected error: \(error)")
} }

View file

@ -44,7 +44,7 @@ struct MarkdownComponentsTests {
} }
@Test func repoRelativePathReturnsNilForBranchOnly() { @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")! let url = URL(string: "https://forgejo.example.com/owner/repo/src/branch/main")!
#expect(repoRelativePath(from: url) == nil) #expect(repoRelativePath(from: url) == nil)
} }

View file

@ -71,12 +71,12 @@ struct NotificationPollingIntegrationTests {
let ids = Set(notifications.map(\.id)) let ids = Set(notifications.map(\.id))
#expect(!ids.isEmpty) #expect(!ids.isEmpty)
// Seed should record all current IDs // Seed, should record all current IDs
store.seed(ids: ids, for: normalizedURL, username: Self.username) store.seed(ids: ids, for: normalizedURL, username: Self.username)
#expect(store.isSeeded(for: normalizedURL, username: Self.username)) #expect(store.isSeeded(for: normalizedURL, username: Self.username))
#expect(store.seenIDs(for: normalizedURL, username: Self.username) == ids) #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)) let newIDs = ids.subtracting(store.seenIDs(for: normalizedURL, username: Self.username))
#expect(newIDs.isEmpty, "After seeding with current IDs, diff should be empty") #expect(newIDs.isEmpty, "After seeding with current IDs, diff should be empty")
} }
@ -93,11 +93,11 @@ struct NotificationPollingIntegrationTests {
let allIDs = Set(notifications.map(\.id)) let allIDs = Set(notifications.map(\.id))
#expect(allIDs.count >= 2, "Need at least 2 notifications for this test") #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()) let partial = Set(allIDs.dropFirst())
store.seed(ids: partial, for: normalizedURL, username: Self.username) 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 seenIDs = store.seenIDs(for: normalizedURL, username: Self.username)
let newIDs = allIDs.subtracting(seenIDs) let newIDs = allIDs.subtracting(seenIDs)
#expect(newIDs.count == 1, "Expected exactly 1 new notification after partial seed") #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) store.seed(ids: seeded, for: normalizedURL, username: Self.username)
#expect(store.seenIDs(for: normalizedURL, username: Self.username).contains(999_999)) #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) 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).contains(999_999))
#expect(store.seenIDs(for: normalizedURL, username: Self.username) == serverIDs) #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)") let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
#expect(!store.isSeeded(for: normalizedURL, username: Self.username)) #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)) let firstPollIDs = Set(notifications.map(\.id))
store.seed(ids: firstPollIDs, for: normalizedURL, username: Self.username) store.seed(ids: firstPollIDs, for: normalizedURL, username: Self.username)
@ -191,7 +191,7 @@ struct NotificationPollingIntegrationTests {
// Save with current (post-migration) accessibility // Save with current (post-migration) accessibility
try await KeychainManager.shared.saveToken(token, for: server, username: user) 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) await KeychainManager.shared.migrateAccessibility(for: server, username: user)
// Verify token is still accessible // Verify token is still accessible
@ -283,7 +283,7 @@ struct NotificationPollingIntegrationTests {
let service = NotificationService(client: client) let service = NotificationService(client: client)
let store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)") 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( let initial = try await service.fetchNotifications(
statusTypes: ["unread"], page: 1, limit: 50 statusTypes: ["unread"], page: 1, limit: 50
) )
@ -295,14 +295,14 @@ struct NotificationPollingIntegrationTests {
let markedID = initial[0].id let markedID = initial[0].id
try await service.markAsRead(id: markedID) try await service.markAsRead(id: markedID)
// Next poll fewer unreads // Next poll, fewer unreads
let afterMark = try await service.fetchNotifications( let afterMark = try await service.fetchNotifications(
statusTypes: ["unread"], page: 1, limit: 50 statusTypes: ["unread"], page: 1, limit: 50
) )
let afterIDs = Set(afterMark.map(\.id)) let afterIDs = Set(afterMark.map(\.id))
#expect(!afterIDs.contains(markedID), "Marked notification should no longer be unread") #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) store.prune(keeping: afterIDs, for: normalizedURL, username: Self.username)
#expect( #expect(
!store.seenIDs(for: normalizedURL, username: Self.username).contains(markedID), !store.seenIDs(for: normalizedURL, username: Self.username).contains(markedID),
@ -319,7 +319,7 @@ struct NotificationPollingIntegrationTests {
let server = "https://test.example.com" let server = "https://test.example.com"
let user = "testuser" 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) store.seed(ids: [1], for: server, username: user)
// Simulate a poll returning IDs [1, 2] // Simulate a poll returning IDs [1, 2]
@ -460,12 +460,12 @@ struct NotificationPollingIntegrationTests {
let allIDs = Set(notifications.map(\.id)) let allIDs = Set(notifications.map(\.id))
#expect(allIDs.count >= 2, "Need at least 2 notifications") #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 store = SeenNotificationStore(suiteName: "de.hausotte.Forji.test.\(UUID().uuidString)")
let partial = Set(allIDs.dropFirst()) let partial = Set(allIDs.dropFirst())
store.seed(ids: partial, for: normalizedURL, username: Self.username) 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 seenIDs = store.seenIDs(for: normalizedURL, username: Self.username)
let newIDs = allIDs.subtracting(seenIDs) let newIDs = allIDs.subtracting(seenIDs)
#expect(newIDs.count == 1, "Expected exactly 1 new notification") #expect(newIDs.count == 1, "Expected exactly 1 new notification")

View file

@ -147,13 +147,13 @@ struct PaginationStateConcurrentTests {
} }
await yieldUntil { fetcherA.isPending } 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 let taskB = pagination.reload { page, limit in
try await fetcherB.fetch(page: page, limit: limit) try await fetcherB.fetch(page: page, limit: limit)
} }
await yieldUntil { fetcherB.isPending } 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"]) fetcherA.complete(returning: ["stale"])
fetcherB.complete(returning: ["fresh"]) fetcherB.complete(returning: ["fresh"])
await taskB.value await taskB.value
@ -177,7 +177,7 @@ struct PaginationStateConcurrentTests {
} }
await yieldUntil { fetcherB.isPending } 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"]) fetcherA.complete(returning: ["stale"])
await yieldUntil { fetcherA.callCount == 1 } await yieldUntil { fetcherA.callCount == 1 }
@ -213,7 +213,7 @@ struct PaginationStateConcurrentTests {
} }
await yieldUntil { fetcherC.isPending } 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"]) fetcherA.complete(returning: ["a"])
fetcherB.complete(returning: ["b"]) fetcherB.complete(returning: ["b"])
fetcherC.complete(returning: ["c"]) fetcherC.complete(returning: ["c"])
@ -238,7 +238,7 @@ struct PaginationStateConcurrentTests {
} }
await yieldUntil { fetcherB.isPending } 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)) fetcherA.complete(throwing: URLError(.badServerResponse))
fetcherB.complete(returning: ["ok"]) fetcherB.complete(returning: ["ok"])
await taskB.value await taskB.value
@ -264,7 +264,7 @@ struct PaginationStateConcurrentTests {
await yieldUntil { fetcherB.isPending } await yieldUntil { fetcherB.isPending }
#expect(pagination.isLoading) #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"]) fetcherA.complete(returning: ["stale"])
await yieldUntil { fetcherA.callCount == 1 } await yieldUntil { fetcherA.callCount == 1 }
#expect(pagination.isLoading, "isLoading must stay true while latest reload (B) is pending") #expect(pagination.isLoading, "isLoading must stay true while latest reload (B) is pending")
@ -287,7 +287,7 @@ struct PaginationStateConcurrentTests {
} }
await yieldUntil { initialFetcher.isPending } 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 let filterTask = pagination.reload(clearItems: true) { page, limit in
try await filterFetcher.fetch(page: page, limit: limit) try await filterFetcher.fetch(page: page, limit: limit)
} }
@ -463,11 +463,11 @@ struct PaginationStateLoadMoreTests {
} }
await yieldUntil { freshFetcher.isPending } 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"]) moreFetcher.complete(returning: ["stale-more"])
await moreTask.value await moreTask.value
// reload completes should be applied // reload completes, should be applied
freshFetcher.complete(returning: ["fresh"]) freshFetcher.complete(returning: ["fresh"])
await reloadTask.value await reloadTask.value
@ -485,7 +485,7 @@ struct PaginationStateDedupeTests {
pagination.dedupeKey = { $0 } pagination.dedupeKey = { $0 }
await pagination.reload { _, _ in ["a", "b"] }.value 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"] } await pagination.loadMore { _, _ in ["b", "c"] }
#expect(pagination.items == ["a", "b", "c"]) #expect(pagination.items == ["a", "b", "c"])
#expect(pagination.hasMore) // the page contributed a new item #expect(pagination.hasMore) // the page contributed a new item

View file

@ -25,7 +25,7 @@ final class AttachmentUITests: ForgejoUITestBase {
XCTAssertTrue(commentButton.waitForExistence(timeout: 5)) XCTAssertTrue(commentButton.waitForExistence(timeout: 5))
commentButton.tap() commentButton.tap()
// Tap the image upload button twice in test mode each tap uploads a // Tap the image upload button twice, in test mode each tap uploads a
// hardcoded PNG instead of presenting the Photos picker. Two taps produce // hardcoded PNG instead of presenting the Photos picker. Two taps produce
// two distinct attachments and two markdown references. // two distinct attachments and two markdown references.
let imageButton = app.buttons["markdown-toolbar-image"] let imageButton = app.buttons["markdown-toolbar-image"]
@ -33,13 +33,13 @@ final class AttachmentUITests: ForgejoUITestBase {
let textEditor = app.textViews["markdown-text-editor"].firstMatch let textEditor = app.textViews["markdown-text-editor"].firstMatch
XCTAssertTrue(textEditor.waitForExistence(timeout: 5)) XCTAssertTrue(textEditor.waitForExistence(timeout: 5))
// First upload wait until one markdown reference is inserted before tapping // First upload, wait until one markdown reference is inserted before tapping
// again, so the second upload appends rather than racing the first. // again, so the second upload appends rather than racing the first.
imageButton.tap() imageButton.tap()
expectation(for: NSPredicate(format: "value MATCHES '.*!\\\\[.*'"), evaluatedWith: textEditor) expectation(for: NSPredicate(format: "value MATCHES '.*!\\\\[.*'"), evaluatedWith: textEditor)
waitForExpectations(timeout: 15, handler: nil) waitForExpectations(timeout: 15, handler: nil)
// Second upload wait until two markdown references are present. // Second upload, wait until two markdown references are present.
imageButton.tap() imageButton.tap()
let twoRefs = NSPredicate(format: "value MATCHES '(.*!\\\\[.*){2,}'") let twoRefs = NSPredicate(format: "value MATCHES '(.*!\\\\[.*){2,}'")
expectation(for: twoRefs, evaluatedWith: textEditor) expectation(for: twoRefs, evaluatedWith: textEditor)
@ -120,7 +120,7 @@ final class AttachmentUITests: ForgejoUITestBase {
XCTAssertTrue(commentButton.waitForExistence(timeout: 5)) XCTAssertTrue(commentButton.waitForExistence(timeout: 5))
commentButton.tap() commentButton.tap()
// Tap the file upload button in test mode this uploads a hardcoded server.log. // Tap the file upload button, in test mode this uploads a hardcoded server.log.
let fileButton = app.buttons["markdown-toolbar-file"] let fileButton = app.buttons["markdown-toolbar-file"]
XCTAssertTrue(fileButton.waitForExistence(timeout: 5)) XCTAssertTrue(fileButton.waitForExistence(timeout: 5))
let textEditor = app.textViews["markdown-text-editor"].firstMatch let textEditor = app.textViews["markdown-text-editor"].firstMatch
@ -132,7 +132,7 @@ final class AttachmentUITests: ForgejoUITestBase {
waitForExpectations(timeout: 15, handler: nil) waitForExpectations(timeout: 15, handler: nil)
// A non-image attachment must use link syntax [name](url), NOT image-embed // 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. // syntax ![name](url), otherwise it would render as a broken inline image.
let editorValue = textEditor.value as? String ?? "" let editorValue = textEditor.value as? String ?? ""
XCTAssertTrue( XCTAssertTrue(
editorValue.contains("[server.log]"), editorValue.contains("[server.log]"),

View file

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

View file

@ -9,7 +9,7 @@ final class ForjiUITestsLaunchTests: XCTestCase {
override func setUpWithError() throws { override func setUpWithError() throws {
continueAfterFailure = false 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") { if FileManager.default.fileExists(atPath: "/tmp/forgejo_test_url.txt") {
throw XCTSkip("Skipping launch tests during integration test run") throw XCTSkip("Skipping launch tests during integration test run")
} }

View file

@ -26,7 +26,7 @@ final class IssueMutatingUITests: ForgejoUITestBase {
openButton.tap() openButton.tap()
XCTAssertTrue(app.staticTexts["Test issue 1 from integration tests"].waitForExistence(timeout: 10)) 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 let issueCell = app.staticTexts["Test issue 1 from integration tests"].firstMatch
issueCell.tap() issueCell.tap()

View file

@ -36,7 +36,7 @@ final class MergedInstanceUITests: ForgejoUITestBase {
XCTAssertTrue(addButton.waitForExistence(timeout: 15), "Instance list did not appear") XCTAssertTrue(addButton.waitForExistence(timeout: 15), "Instance list did not appear")
addButton.tap() 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() app.swipeUp()
let loginButton = app.buttons["login-button"] let loginButton = app.buttons["login-button"]

View file

@ -2,7 +2,7 @@ import XCTest
final class OverviewCreateMutatingUITests: ForgejoUITestBase { 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 @MainActor
func testCreateIssueFromOverview() throws { func testCreateIssueFromOverview() throws {

View file

@ -13,7 +13,7 @@ final class OverviewCreateUITests: ForgejoReadOnlyUITestBase {
func testCreatePRFromOverview() throws { func testCreatePRFromOverview() throws {
app.tabBars.buttons["Pull Requests"].tap() 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)) XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10))
// Tap floating create button // Tap floating create button

View file

@ -53,7 +53,7 @@ final class PaginationUITests: ForgejoReadOnlyUITestBase {
XCTAssertTrue(closedButton.waitForExistence(timeout: 5)) XCTAssertTrue(closedButton.waitForExistence(timeout: 5))
closedButton.tap() 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)) XCTAssertTrue(app.staticTexts["Test issue 3 from integration tests"].waitForExistence(timeout: 10))
let loadMore = app.activityIndicators["load-more-indicator"] 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 @MainActor
func testIssueDetailHidesActionsForReadOnlyUser() throws { func testIssueDetailHidesActionsForReadOnlyUser() throws {
@ -43,7 +43,7 @@ final class PermissionUITests: ForgejoUITestBase {
XCTAssertFalse(toggleStateButton.exists, "Close/Reopen button should be hidden for read-only user") 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 @MainActor
func testPRDetailHidesActionsForReadOnlyUser() throws { func testPRDetailHidesActionsForReadOnlyUser() throws {

View file

@ -29,7 +29,7 @@ final class PullRequestUITests: ForgejoReadOnlyUITestBase {
openButton.tap() openButton.tap()
XCTAssertTrue(app.staticTexts["Add feature file"].waitForExistence(timeout: 10)) 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 let prCell = app.staticTexts["Add feature file"].firstMatch
prCell.tap() prCell.tap()
@ -55,7 +55,7 @@ final class PullRequestUITests: ForgejoReadOnlyUITestBase {
XCTAssertTrue(app.staticTexts["testbot"].waitForExistence(timeout: 10)) 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 @MainActor
func testDiffViewContextLineHasNoCommentButton() throws { func testDiffViewContextLineHasNoCommentButton() throws {

View file

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

View file

@ -38,7 +38,7 @@ final class RepositoryUITests: ForgejoReadOnlyUITestBase {
searchField.buttons["Clear text"].tap() searchField.buttons["Clear text"].tap()
XCTAssertTrue(app.staticTexts["test-repo"].waitForExistence(timeout: 10)) 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 let starButton = repoList.buttons["star-button"].firstMatch
XCTAssertTrue(starButton.waitForExistence(timeout: 5), "No star button found in repo list") XCTAssertTrue(starButton.waitForExistence(timeout: 5), "No star button found in repo list")
starButton.tap() starButton.tap()
@ -130,7 +130,7 @@ final class RepositoryUITests: ForgejoReadOnlyUITestBase {
// Wait for repo detail to load // Wait for repo detail to load
XCTAssertTrue(app.staticTexts["html-readme-repo"].waitForExistence(timeout: 10)) 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( XCTAssertFalse(
app.staticTexts["<details>"].waitForExistence(timeout: 5), app.staticTexts["<details>"].waitForExistence(timeout: 5),
"Raw <details> tag should not be visible as plain text") "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 - Create, edit, and close/reopen issues
- Manage labels, milestones, and assignees - Manage labels, milestones, and assignees
- Comment with Markdown support - Comment with Markdown support
- Attach and view image and file attachments in descriptions and comments
### Pull Requests ### Pull Requests
- View PRs across all repositories or per-repo - 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) - Submit reviews (comment, approve, request changes)
- Merge with merge commit, rebase, or squash - Merge with merge commit, rebase, or squash
- Close, reopen, and edit PRs - Close, reopen, and edit PRs
- Attach and view image and file attachments in descriptions and comments
### Actions ### Actions
- Browse Forgejo Actions workflows defined in a repository - 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 ### Libraries
- [ForgejoKit](https://codeberg.org/secana/ForgejoKit) Forgejo API client (MIT) - [ForgejoKit](https://codeberg.org/secana/ForgejoKit), Forgejo API client (MIT)
- [Textual](https://github.com/gonzalezreal/textual) Markdown rendering (MIT) - [Textual](https://github.com/gonzalezreal/textual), Markdown rendering (MIT)
- [HighlightSwift](https://github.com/appstefan/HighlightSwift) Code syntax highlighting (MIT) - [HighlightSwift](https://github.com/appstefan/HighlightSwift), Code syntax highlighting (MIT)
- [mermaid](https://github.com/mermaid-js/mermaid) Diagram rendering (MIT) - [mermaid](https://github.com/mermaid-js/mermaid), Diagram rendering (MIT)
- [marked](https://github.com/markedjs/marked) Markdown parser (MIT) - [marked](https://github.com/markedjs/marked), Markdown parser (MIT)
- [DOMPurify](https://github.com/cure53/DOMPurify) HTML sanitizer (Apache 2.0 / MPL 2.0) - [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) - [github-markdown-css](https://github.com/sindresorhus/github-markdown-css), GitHub-style Markdown styling (MIT)
## Contributing ## Contributing