mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
Compare commits
No commits in common. "main" and "v1.5" have entirely different histories.
61 changed files with 221 additions and 1277 deletions
1
.envrc
1
.envrc
|
|
@ -1 +0,0 @@
|
|||
use flake
|
||||
|
|
@ -1,3 +1,2 @@
|
|||
--swiftversion 6.2
|
||||
--disable redundantMemberwiseInit
|
||||
--disable swiftTestingTestCaseNames
|
||||
|
|
|
|||
65
CHANGELOG.md
65
CHANGELOG.md
|
|
@ -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.
|
||||
|
|
@ -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" */ = {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
import ForgejoKit
|
||||
import SwiftUI
|
||||
|
||||
/// Markdown to insert when an attachment is uploaded. Images use embed syntax
|
||||
/// (``) 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 {
|
||||
")"
|
||||
} 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
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ struct CommitHistoryView: View {
|
|||
repo: repo,
|
||||
)
|
||||
} catch {
|
||||
// Non-critical, branch selector stays disabled
|
||||
// Non-critical — branch selector stays disabled
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ?? "")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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. ``.
|
||||
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?
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 ?? "")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) ?? "—"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 == "")
|
||||
}
|
||||
|
||||
@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 == "")
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 , 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
54
cliff.toml
54
cliff.toml
|
|
@ -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 },
|
||||
]
|
||||
|
|
@ -18,7 +18,6 @@
|
|||
pkgs.xcbeautify
|
||||
pkgs.swiftlint
|
||||
pkgs.swiftformat
|
||||
pkgs.git-cliff
|
||||
];
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
50
justfile
50
justfile
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue