mirror of
https://codeberg.org/secana/Forji.git
synced 2026-06-16 05:13:55 -07:00
Compare commits
33 commits
992c628abd
...
de5cb025b8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de5cb025b8 | ||
|
|
100d7c412c | ||
|
|
617e687e7b | ||
|
|
6c053741c5 | ||
|
|
c82dedf957 | ||
|
|
f39e9f682d | ||
|
|
f8a050e484 | ||
|
|
2a679140e6 | ||
|
|
b1942df58d | ||
|
|
78c91cd2a9 | ||
|
|
ab393d0237 | ||
|
|
d580156fa2 | ||
|
|
ad35a6f4f7 | ||
|
|
0bcb397805 | ||
|
|
330d2bde4f | ||
|
|
745b7af45b | ||
|
|
d95ec8ddc5 | ||
|
|
8e7f99b6e3 | ||
|
|
bec427d7da | ||
|
|
66fe573cb6 | ||
|
|
c2454d3444 | ||
|
|
ed2051ae5d | ||
|
|
cbd4039e40 | ||
|
|
e9b6be91f5 | ||
|
|
4f6803cc03 | ||
|
|
639812348b | ||
|
|
4ef76cf2d7 | ||
|
|
8e56b9d722 | ||
|
|
b0f50eca38 | ||
|
|
31d9aeea35 | ||
|
|
8648f40c07 | ||
|
|
89a0cd0bb2 | ||
|
|
c6dda2cd70 |
52 changed files with 967 additions and 189 deletions
41
CHANGELOG.md
Normal file
41
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# 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.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.
|
||||
77
CONTRIBUTING.md
Normal file
77
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
# Contributing to Forji
|
||||
|
||||
Thanks for your interest in contributing! Forji is a native iOS client for
|
||||
Forgejo, and contributions are welcome. These guidelines keep changes easy to
|
||||
review and the codebase easy to maintain for the next person.
|
||||
|
||||
## Pull requests
|
||||
|
||||
- **One concern per pull request.** Keep each PR focused on a single change.
|
||||
Split bug fixes out from refactors. A behavior-preserving refactor is easy to
|
||||
approve on its own; bundling it with a fix makes the actual fix hard to
|
||||
isolate and review.
|
||||
- **Keep the description accurate.** The PR description must match what the diff
|
||||
actually does. If you change the approach while iterating, update the
|
||||
description to match the final code, and explain *why* the change fixes the
|
||||
problem.
|
||||
- **Prefer small, stacked PRs** over one large change. For anything large, open
|
||||
an issue first so we can agree on the shape before you write the code.
|
||||
- Respond to review feedback by fixing it or explaining it.
|
||||
|
||||
## Reporting bugs
|
||||
|
||||
Open an issue with exact reproduction steps so the bug can actually be
|
||||
reproduced and fixed:
|
||||
|
||||
- Device and iOS version.
|
||||
- What to tap and what to watch for.
|
||||
- Expected behavior vs. what actually happens.
|
||||
|
||||
## Architecture
|
||||
|
||||
New code follows MVVM with Apple-native tools:
|
||||
|
||||
- `@Observable` view models with `async/await`.
|
||||
- **No external state-management dependency.** The project deliberately stays on
|
||||
Apple's `@Observable` + `async/await` direction rather than adopting a
|
||||
third-party architecture.
|
||||
- Keep the code easy to understand for the next contributor. Favor the simplest
|
||||
approach that fits Apple's idioms.
|
||||
- ForgejoKit holds the platform-agnostic API logic; the Forji app holds the
|
||||
SwiftUI views, authentication, and persistence. Keep that separation.
|
||||
|
||||
## Code style
|
||||
|
||||
The repository ships its own formatter and linter configs
|
||||
([`.swiftformat`](.swiftformat), [`.swiftlint.yml`](.swiftlint.yml)). Match them
|
||||
before opening a PR, and match the existing style in the files you touch:
|
||||
|
||||
```bash
|
||||
just format # SwiftFormat
|
||||
just lint # SwiftLint
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
- Include unit tests for the behavior you add or change.
|
||||
- When you fix a UI behavior that has no automated coverage, add a UI regression
|
||||
test. If a change is hard to cover, say so in the PR and offer a UI test.
|
||||
- See the [README](README.md) for `just test`, `just test-ui`, and the
|
||||
integration test setup.
|
||||
|
||||
## License and the App Store exception
|
||||
|
||||
Forji is licensed under the [GNU General Public License v3.0](LICENSE) with an
|
||||
[App Store Exception](LICENSE#app-store-exception) that permits distribution of
|
||||
the compiled app through Apple's App Store.
|
||||
|
||||
By submitting a pull request you agree your contribution is licensed under
|
||||
GPLv3 and may be distributed through the App Store under that exception. To
|
||||
confirm, include this sentence in your PR description or a comment:
|
||||
|
||||
> "I grant Stefan Hausotte an irrevocable, worldwide, royalty-free license to
|
||||
> use, sublicense, and distribute my contribution, including through Apple's App
|
||||
> Store under the project's App Store exception."
|
||||
|
||||
You keep your copyright, the project stays open source under GPLv3, and you will
|
||||
be credited as a contributor.
|
||||
|
|
@ -438,7 +438,7 @@
|
|||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1.4;
|
||||
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.4;
|
||||
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.4;
|
||||
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.4;
|
||||
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.4;
|
||||
CURRENT_PROJECT_VERSION = 1.5;
|
||||
DEVELOPMENT_TEAM = RVT2M7QTD4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.4;
|
||||
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.4;
|
||||
CURRENT_PROJECT_VERSION = 1.5;
|
||||
DEVELOPMENT_TEAM = RVT2M7QTD4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 1.4;
|
||||
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.4;
|
||||
CURRENT_PROJECT_VERSION = 1.5;
|
||||
DEVELOPMENT_TEAM = RVT2M7QTD4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.4;
|
||||
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.4;
|
||||
CURRENT_PROJECT_VERSION = 1.5;
|
||||
DEVELOPMENT_TEAM = RVT2M7QTD4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 1.4;
|
||||
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.6.1;
|
||||
version = 0.7.0;
|
||||
};
|
||||
};
|
||||
DEC49F6B2F3D00C700E7DD54 /* XCRemoteSwiftPackageReference "textual" */ = {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://codeberg.org/secana/ForgejoKit.git",
|
||||
"state" : {
|
||||
"revision" : "7352bc4b1fcfae735ba0008e1e376906c27b9844",
|
||||
"version" : "0.6.1"
|
||||
"revision" : "4d1dfc1305c0194fc74b09d15d29940a98cd9752",
|
||||
"version" : "0.7.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
|
|||
if response.actionIdentifier == "MARK_READ" {
|
||||
await markNotificationAsRead(userInfo: userInfo, serverURL: serverURL, username: username)
|
||||
} else {
|
||||
// Opening the notification clears it, the same as tapping it inside the app.
|
||||
await markNotificationAsRead(userInfo: userInfo, serverURL: serverURL, username: username)
|
||||
await MainActor.run {
|
||||
NavigationState.shared.navigateToNotifications(serverURL: serverURL, username: username)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,20 +9,29 @@ 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`.
|
||||
@ObservationIgnored var dedupeKey: ((Item) -> AnyHashable)?
|
||||
@ObservationIgnored private var seenKeys = Set<AnyHashable>()
|
||||
|
||||
init(pageSize: Int = 20) {
|
||||
self.pageSize = pageSize
|
||||
}
|
||||
|
||||
init(items: [Item], hasMore: Bool = false, pageSize: Int = 20) {
|
||||
init(items: [Item], hasMore: Bool = false, pageSize: Int = 20, notFound: Bool = false) {
|
||||
self.items = items
|
||||
self.hasMore = hasMore
|
||||
self.pageSize = pageSize
|
||||
self.notFound = notFound
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
|
|
@ -37,13 +46,16 @@ final class PaginationState<Item> {
|
|||
hasLoaded = false
|
||||
isLoading = true
|
||||
showError = false
|
||||
notFound = false
|
||||
let pageSize = pageSize
|
||||
let task = Task {
|
||||
do {
|
||||
let fetched = try await fetch(1, pageSize)
|
||||
guard !Task.isCancelled else { return }
|
||||
items = fetched
|
||||
hasMore = fetched.count >= pageSize
|
||||
if dedupeKey != nil { seenKeys.removeAll() }
|
||||
let newItems = deduped(fetched)
|
||||
items = newItems
|
||||
hasMore = computeHasMore(fetched: fetched, newItems: newItems)
|
||||
currentPage = 2
|
||||
hasLoaded = true
|
||||
} catch is CancellationError {
|
||||
|
|
@ -72,8 +84,9 @@ final class PaginationState<Item> {
|
|||
do {
|
||||
let fetched = try await fetch(currentPage, pageSize)
|
||||
guard !Task.isCancelled else { return }
|
||||
items.append(contentsOf: fetched)
|
||||
hasMore = fetched.count >= pageSize
|
||||
let newItems = deduped(fetched)
|
||||
items.append(contentsOf: newItems)
|
||||
hasMore = computeHasMore(fetched: fetched, newItems: newItems)
|
||||
self.currentPage = currentPage + 1
|
||||
} catch is CancellationError {
|
||||
// Ignore cancellation
|
||||
|
|
@ -95,6 +108,24 @@ final class PaginationState<Item> {
|
|||
func invalidate() {
|
||||
hasLoaded = false
|
||||
}
|
||||
|
||||
/// Filters out items already seen on a previous page when `dedupeKey` is set,
|
||||
/// recording the new keys. Without a `dedupeKey` the page passes through
|
||||
/// unchanged so single-source pagination keeps its existing behavior.
|
||||
private func deduped(_ fetched: [Item]) -> [Item] {
|
||||
guard let dedupeKey else { return fetched }
|
||||
return fetched.filter { seenKeys.insert(dedupeKey($0)).inserted }
|
||||
}
|
||||
|
||||
/// For merged sources the de-duplicated, multi-instance page size no longer
|
||||
/// matches `pageSize`, so "more pages exist" means "this page contributed new
|
||||
/// items." Single sources keep the page-size heuristic.
|
||||
private func computeHasMore(fetched: [Item], newItems: [Item]) -> Bool {
|
||||
if dedupeKey != nil {
|
||||
return !newItems.isEmpty
|
||||
}
|
||||
return fetched.count >= pageSize
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Disk cache
|
||||
|
|
|
|||
|
|
@ -73,8 +73,11 @@ extension PaginationState where Item: Identifiable {
|
|||
/// Replace placeholder auth services using the given connection sources.
|
||||
/// Items whose sourceKey has no matching source are removed.
|
||||
func rehydrate<T>(from sources: [ConnectionSource]) where Item == TaggedItem<T> {
|
||||
// Two connected accounts with the same server+username share a sourceKey,
|
||||
// so keep the first rather than trapping on a duplicate key.
|
||||
let lookup = Dictionary(
|
||||
uniqueKeysWithValues: sources.map { ($0.sourceKey, $0.authService) },
|
||||
sources.map { ($0.sourceKey, $0.authService) },
|
||||
uniquingKeysWith: { first, _ in first },
|
||||
)
|
||||
items = items.compactMap { item in
|
||||
guard let auth = lookup[item.sourceKey] else { return nil }
|
||||
|
|
|
|||
|
|
@ -95,15 +95,23 @@ class AuthenticationService {
|
|||
return true
|
||||
}
|
||||
|
||||
func logout(modelContext: ModelContext? = nil) async {
|
||||
func logout(modelContext: ModelContext) async {
|
||||
if let instance = currentInstance {
|
||||
let normalizedURL = ForgejoClient.normalizeServerURL(instance.serverURL)
|
||||
// Remove the instance from SwiftData first. If the save fails, keep the
|
||||
// credentials so the account stays usable instead of becoming an
|
||||
// unauthenticatable orphan, and bail out without clearing the session.
|
||||
instance.isDefault = false
|
||||
modelContext.delete(instance)
|
||||
do {
|
||||
try await KeychainManager.shared.deletePassword(
|
||||
for: normalizedURL,
|
||||
username: instance.username,
|
||||
)
|
||||
try await KeychainManager.shared.deleteToken(
|
||||
try modelContext.save()
|
||||
} catch {
|
||||
assertionFailure("SwiftData save failed during logout: \(error)")
|
||||
return
|
||||
}
|
||||
// Delete credentials only after the instance is gone from SwiftData.
|
||||
do {
|
||||
try await KeychainManager.shared.deleteCredentials(
|
||||
for: normalizedURL,
|
||||
username: instance.username,
|
||||
)
|
||||
|
|
@ -113,12 +121,6 @@ class AuthenticationService {
|
|||
print("Keychain delete failed during logout: \(error)")
|
||||
#endif
|
||||
}
|
||||
// Remove the instance from SwiftData and unset default
|
||||
if let modelContext {
|
||||
instance.isDefault = false
|
||||
modelContext.delete(instance)
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
// Clears cache for all instances since keys are hashed and cannot be scoped per-instance
|
||||
DiskCache.removeAll()
|
||||
|
|
|
|||
|
|
@ -87,7 +87,9 @@ final class BackgroundDownloadManager: NSObject, Sendable {
|
|||
for: instance.serverURL, username: instance.username,
|
||||
) else { return nil }
|
||||
|
||||
var components = URLComponents(string: "\(instance.serverURL)/api/v1/notifications")!
|
||||
guard var components = URLComponents(string: "\(instance.serverURL)/api/v1/notifications") else {
|
||||
return nil
|
||||
}
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "status-types", value: "unread"),
|
||||
URLQueryItem(name: "page", value: "1"),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,15 @@ actor KeychainManager {
|
|||
try deleteItem(forKey: key(for: server, username: username, suffix: "_token"))
|
||||
}
|
||||
|
||||
// MARK: - Account (both items)
|
||||
|
||||
/// Removes every credential stored for an account: the password and the API token.
|
||||
/// Use when an account is removed entirely (logout, delete instance) so neither item is orphaned.
|
||||
func deleteCredentials(for server: String, username: String) throws {
|
||||
try deletePassword(for: server, username: username)
|
||||
try deleteToken(for: server, username: username)
|
||||
}
|
||||
|
||||
// MARK: - Synchronous token read (no actor hop)
|
||||
|
||||
nonisolated static func getTokenSync(for server: String, username: String) -> String? {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ struct CommentView: View {
|
|||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Edit comment")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,8 +60,10 @@ struct DiffView: View {
|
|||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func diffLineRow(line: DiffLine, filePath: String) -> some View {
|
||||
HStack(spacing: 0) {
|
||||
let isCommentable = onLineTap != nil && line.type != .header && line.diffPosition != nil
|
||||
let row = HStack(spacing: 0) {
|
||||
HStack(spacing: 2) {
|
||||
Text(line.oldLineNumber.map { "\($0)" } ?? "")
|
||||
.font(.system(.caption2, design: .monospaced))
|
||||
|
|
@ -88,7 +90,7 @@ struct DiffView: View {
|
|||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
if onLineTap != nil, line.type != .header, line.diffPosition != nil {
|
||||
if isCommentable {
|
||||
Image(systemName: "plus.bubble")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
|
|
@ -100,10 +102,18 @@ struct DiffView: View {
|
|||
.padding(.vertical, 1)
|
||||
.background(line.type.backgroundColor)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
if line.type != .header, line.diffPosition != nil {
|
||||
|
||||
// A tappable line is a Button so VoiceOver announces it and can activate it.
|
||||
if isCommentable {
|
||||
Button {
|
||||
onLineTap?(line, filePath)
|
||||
} label: {
|
||||
row
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint("Add a comment on this line")
|
||||
} else {
|
||||
row
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ struct FileViewerView: View {
|
|||
} label: {
|
||||
Image(systemName: "pencil")
|
||||
}
|
||||
.accessibilityLabel("Edit file")
|
||||
.accessibilityIdentifier("file-edit-button")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ struct FloatingCreateButton: View {
|
|||
.frame(width: 56, height: 56)
|
||||
}
|
||||
.glassEffect(.regular.tint(.blue).interactive())
|
||||
.accessibilityLabel("Create")
|
||||
.padding(.trailing, 20)
|
||||
.padding(.bottom, 20)
|
||||
}
|
||||
|
|
@ -36,6 +37,7 @@ struct ExpandableActionMenu<Content: View>: View {
|
|||
.frame(width: 40, height: 40)
|
||||
}
|
||||
.buttonStyle(.glass)
|
||||
.accessibilityLabel("Actions")
|
||||
.accessibilityIdentifier("action-menu-toggle")
|
||||
.accessibilityValue(isExpanded ? "expanded" : "collapsed")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,11 +99,15 @@ struct HomeView: View {
|
|||
} else if let notificationService {
|
||||
do {
|
||||
unreadCount = try await notificationService.fetchUnreadCount()
|
||||
try? await UNUserNotificationCenter.current().setBadgeCount(unreadCount)
|
||||
} catch {
|
||||
// Silently ignore — badge is non-critical
|
||||
// Silently ignore — badge is non-critical, keep the prior count
|
||||
return
|
||||
}
|
||||
} else {
|
||||
return
|
||||
}
|
||||
// Mirror the in-app tab badge to the system app icon badge in both modes
|
||||
try? await UNUserNotificationCenter.current().setBadgeCount(unreadCount)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -263,15 +263,24 @@ struct InstanceFormView: View {
|
|||
case .add:
|
||||
try await performLogin()
|
||||
|
||||
// Resolve the account identity after login: token auth learns the
|
||||
// real username from the server, so it isn't known before this point.
|
||||
let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)
|
||||
let resolvedUsername =
|
||||
authMode == .token ? (authService.currentUser?.login ?? username) : username
|
||||
|
||||
// Two accounts sharing a server+username collide on the same sourceKey,
|
||||
// which used to crash merged overviews. Reject the duplicate up front.
|
||||
if rejectDuplicateAccount(serverURL: normalizedURL, username: resolvedUsername) {
|
||||
return
|
||||
}
|
||||
|
||||
if isDefault {
|
||||
for inst in instances where inst.isDefault {
|
||||
inst.isDefault = false
|
||||
}
|
||||
}
|
||||
|
||||
let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)
|
||||
let resolvedUsername =
|
||||
authMode == .token ? (authService.currentUser?.login ?? username) : username
|
||||
let instance = ForgejoInstance(
|
||||
serverURL: normalizedURL,
|
||||
username: resolvedUsername,
|
||||
|
|
@ -289,16 +298,30 @@ struct InstanceFormView: View {
|
|||
authService.currentInstance = instance
|
||||
|
||||
case let .edit(instance):
|
||||
// Resolve the would-be identity before mutating the instance: token
|
||||
// auth may change the username, and credentials auth may too.
|
||||
var resolvedUsername = instance.username
|
||||
if authMode == .token, !apiToken.isEmpty {
|
||||
try await performLogin()
|
||||
instance.username = authService.currentUser?.login ?? username
|
||||
resolvedUsername = authService.currentUser?.login ?? username
|
||||
} else if authMode == .credentials, !password.isEmpty {
|
||||
try await performLogin()
|
||||
instance.username = username
|
||||
resolvedUsername = username
|
||||
}
|
||||
|
||||
let normalizedURL = ForgejoClient.normalizeServerURL(serverURL)
|
||||
|
||||
// Editing must not produce a server+username that already belongs to
|
||||
// another account, since that collides on the same sourceKey.
|
||||
if rejectDuplicateAccount(
|
||||
serverURL: normalizedURL, username: resolvedUsername, excluding: instance,
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
instance.username = resolvedUsername
|
||||
instance.name = name
|
||||
instance.serverURL = ForgejoClient.normalizeServerURL(serverURL)
|
||||
instance.serverURL = normalizedURL
|
||||
instance.allowSelfSignedCertificates = allowSelfSignedCertificates
|
||||
instance.useTokenAuth = authMode == .token
|
||||
|
||||
|
|
@ -328,6 +351,36 @@ struct InstanceFormView: View {
|
|||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the duplicate-account error and returns true if another stored account
|
||||
/// already uses this server+username. `excluding` skips the instance being edited.
|
||||
private func rejectDuplicateAccount(
|
||||
serverURL normalizedURL: String,
|
||||
username: String,
|
||||
excluding: ForgejoInstance? = nil,
|
||||
) -> Bool {
|
||||
guard Self.accountAlreadyExists(
|
||||
serverURL: normalizedURL, username: username, in: instances, excluding: excluding,
|
||||
) else { return false }
|
||||
errorMessage = "An account for \"\(username)\" on \(normalizedURL) already exists."
|
||||
showError = true
|
||||
isLoading = false
|
||||
return true
|
||||
}
|
||||
|
||||
/// True if `instances` already contains an account with this server+username,
|
||||
/// ignoring `excluding` (the instance being edited). Accounts that share a
|
||||
/// server+username collide on the same sourceKey, which crashes merged overviews.
|
||||
static func accountAlreadyExists(
|
||||
serverURL normalizedURL: String,
|
||||
username: String,
|
||||
in instances: [ForgejoInstance],
|
||||
excluding: ForgejoInstance? = nil,
|
||||
) -> Bool {
|
||||
instances.contains { inst in
|
||||
inst !== excluding && inst.serverURL == normalizedURL && inst.username == username
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ struct InstanceListView: View {
|
|||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.accessibilityLabel("Add instance")
|
||||
.accessibilityIdentifier("instance-add-button")
|
||||
}
|
||||
}
|
||||
|
|
@ -129,6 +130,7 @@ struct InstanceListView: View {
|
|||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Edit instance")
|
||||
.accessibilityIdentifier("instance-edit-button")
|
||||
}
|
||||
}
|
||||
|
|
@ -223,7 +225,7 @@ struct InstanceListView: View {
|
|||
assertionFailure("SwiftData save failed: \(error)")
|
||||
}
|
||||
Task {
|
||||
try? await KeychainManager.shared.deletePassword(for: normalizedURL, username: username)
|
||||
try? await KeychainManager.shared.deleteCredentials(for: normalizedURL, username: username)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,14 @@ struct IssueEditView: View {
|
|||
onSaved(updatedIssue)
|
||||
dismiss()
|
||||
} catch {
|
||||
// The calls above run in sequence with no transaction, so an earlier
|
||||
// one may already be committed on the server. Re-fetch the issue and
|
||||
// push the authoritative state to the detail view so it does not show
|
||||
// stale data, then surface the error and keep the sheet open to retry.
|
||||
if let refreshed = try? await issueService.fetchIssue(owner: owner, repo: repo, index: issue.number) {
|
||||
NotificationCenter.default.post(name: .issuesDidChange, object: nil)
|
||||
onSaved(refreshed)
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,10 +19,7 @@ struct IssueLabelView: View {
|
|||
}
|
||||
|
||||
private var textColor: Color {
|
||||
let hex = label.color.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||
guard hex.count == 6,
|
||||
let rgb = UInt64(hex, radix: 16)
|
||||
else {
|
||||
guard let rgb = Color.rgbValue(hex: label.color) else {
|
||||
return .white
|
||||
}
|
||||
let red = Double((rgb >> 16) & 0xFF) / 255.0
|
||||
|
|
@ -35,10 +32,7 @@ struct IssueLabelView: View {
|
|||
|
||||
extension Color {
|
||||
init?(hex: String) {
|
||||
let hex = hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))
|
||||
guard hex.count == 6,
|
||||
let rgb = UInt64(hex, radix: 16)
|
||||
else {
|
||||
guard let rgb = Color.rgbValue(hex: hex) else {
|
||||
return nil
|
||||
}
|
||||
self.init(
|
||||
|
|
@ -47,6 +41,21 @@ 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,6 +38,12 @@ 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(
|
||||
|
|
@ -116,13 +122,18 @@ 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
|
||||
try await issueService.fetchIssues(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
state: stateFilter.rawValue,
|
||||
page: page,
|
||||
limit: limit,
|
||||
)
|
||||
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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -140,11 +151,12 @@ struct IssueListView: View {
|
|||
}
|
||||
|
||||
#if DEBUG
|
||||
init(preview _: Void, repository: Repository, authService: AuthenticationService, issues: [Issue]) {
|
||||
init(preview _: Void, repository: Repository, authService: AuthenticationService, issues: [Issue],
|
||||
notFound: Bool = false) {
|
||||
self.repository = repository
|
||||
self.authService = authService
|
||||
issueService = nil
|
||||
_pagination = State(initialValue: PaginationState(items: issues))
|
||||
_pagination = State(initialValue: PaginationState(items: issues, notFound: notFound))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
@ -160,6 +172,18 @@ struct IssueListView: View {
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Issues Unavailable") {
|
||||
NavigationStack {
|
||||
IssueListView(
|
||||
preview: (),
|
||||
repository: .preview,
|
||||
authService: .previewDefault,
|
||||
issues: [],
|
||||
notFound: true,
|
||||
)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
struct IssueRow: View {
|
||||
|
|
|
|||
|
|
@ -64,22 +64,23 @@ private struct MarkdownToolbar: View {
|
|||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 12) {
|
||||
toolbarButton("bold", icon: "bold") { wrap("**") }
|
||||
toolbarButton("italic", icon: "italic") { wrap("_") }
|
||||
toolbarButton("heading", icon: "number") { prefix("# ") }
|
||||
toolbarButton("code", icon: "chevron.left.forwardslash.chevron.right") { wrap("`") }
|
||||
toolbarButton("codeblock", icon: "text.page") { wrapBlock("```") }
|
||||
toolbarButton("link", icon: "link") { insertLink() }
|
||||
toolbarButton("list", icon: "list.bullet") { prefix("- ") }
|
||||
toolbarButton("quote", icon: "text.quote") { prefix("> ") }
|
||||
toolbarButton("task", icon: "checklist") { prefix("- [ ] ") }
|
||||
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, action: @escaping () -> Void) -> some View {
|
||||
private func toolbarButton(_ id: String, icon: String, label: String, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: icon)
|
||||
.font(.subheadline)
|
||||
|
|
@ -88,6 +89,7 @@ private struct MarkdownToolbar: View {
|
|||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(.primary)
|
||||
.accessibilityLabel(label)
|
||||
.accessibilityIdentifier("markdown-toolbar-\(id)")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,13 @@ 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
|
||||
let state = PaginationState<TaggedItem<NotificationThread>>()
|
||||
state.cacheName = "merged_notifications"
|
||||
state.dedupeKey = { $0.id }
|
||||
state.loadFromCache()
|
||||
state.rehydrate(from: manager)
|
||||
_pagination = State(initialValue: state)
|
||||
|
|
@ -66,14 +68,6 @@ struct MergedNotificationsOverviewView: View {
|
|||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
Task { await dismissNotification(tagged) }
|
||||
} label: {
|
||||
Label("Dismiss", systemImage: "xmark")
|
||||
}
|
||||
.tint(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
if pagination.hasMore {
|
||||
|
|
@ -102,6 +96,9 @@ struct MergedNotificationsOverviewView: View {
|
|||
.onChange(of: statusFilter) {
|
||||
reloadNotifications(clearItems: true)
|
||||
}
|
||||
.onChange(of: navigationState.notificationsRefreshTrigger) {
|
||||
reloadNotifications()
|
||||
}
|
||||
.errorAlert(message: $pagination.errorMessage, isPresented: $pagination.showError)
|
||||
}
|
||||
|
||||
|
|
@ -110,7 +107,10 @@ struct MergedNotificationsOverviewView: View {
|
|||
let notification = tagged.item
|
||||
let destination = navigationDestination(for: tagged)
|
||||
if let destination {
|
||||
NavigationLink { destination } label: {
|
||||
NavigationLink {
|
||||
destination
|
||||
.onAppear { Task { await markReadOnOpen(tagged) } }
|
||||
} label: {
|
||||
MergedNotificationRow(notification: notification, instanceName: tagged.instanceName)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -189,18 +189,22 @@ struct MergedNotificationsOverviewView: View {
|
|||
await setNotificationRead(tagged)
|
||||
}
|
||||
|
||||
private func dismissNotification(_ tagged: TaggedItem<NotificationThread>) async {
|
||||
await setNotificationRead(tagged)
|
||||
/// Marks a notification read when the user opens it, mirroring Mail and News.
|
||||
/// The row is updated in place rather than removed so navigation into the
|
||||
/// detail view is not interrupted while the list is mutating.
|
||||
private func markReadOnOpen(_ tagged: TaggedItem<NotificationThread>) async {
|
||||
guard tagged.item.unread else { return }
|
||||
await setNotificationRead(tagged, removeFromUnread: false)
|
||||
}
|
||||
|
||||
private func setNotificationRead(_ tagged: TaggedItem<NotificationThread>) async {
|
||||
private func setNotificationRead(_ tagged: TaggedItem<NotificationThread>, removeFromUnread: Bool = true) async {
|
||||
guard let client = tagged.authService.client else { return }
|
||||
let service = NotificationService(client: client)
|
||||
let notification = tagged.item
|
||||
do {
|
||||
try await service.markAsRead(id: notification.id)
|
||||
withAnimation {
|
||||
if statusFilter == "unread" {
|
||||
if removeFromUnread, statusFilter == "unread" {
|
||||
pagination.items.removeAll { $0.id == tagged.id }
|
||||
} else if let index = pagination.items.firstIndex(where: { $0.id == tagged.id }) {
|
||||
let updated = NotificationThread(
|
||||
|
|
@ -267,13 +271,17 @@ private struct MergedNotificationRow: View {
|
|||
}
|
||||
}
|
||||
|
||||
Text(notification.repository.fullName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(formatRelativeDate(notification.updatedAt))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 4) {
|
||||
Text(notification.repository.fullName)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formatRelativeDate(notification.updatedAt))
|
||||
.fixedSize()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
|
@ -282,7 +290,6 @@ private struct MergedNotificationRow: View {
|
|||
Circle()
|
||||
.fill(.blue)
|
||||
.frame(width: 10, height: 10)
|
||||
.glassEffect(.regular.tint(.blue))
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ struct MergedRepositoryListView: View {
|
|||
self.manager = manager
|
||||
let state = PaginationState<TaggedItem<Repository>>()
|
||||
state.cacheName = "merged_repos"
|
||||
state.dedupeKey = { $0.id }
|
||||
state.loadFromCache()
|
||||
state.rehydrate(from: manager)
|
||||
_pagination = State(initialValue: state)
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ struct MergedSearchableOverviewView<Row: View, Detail: View, CreateView: View>:
|
|||
|
||||
let state = PaginationState<TaggedItem<Issue>>()
|
||||
state.cacheName = "merged_\(config.issueType)"
|
||||
state.dedupeKey = { $0.id }
|
||||
state.loadFromCache()
|
||||
state.rehydrate(from: manager)
|
||||
_pagination = State(initialValue: state)
|
||||
|
|
@ -161,6 +162,14 @@ struct MergedSearchableOverviewView<Row: View, Detail: View, CreateView: View>:
|
|||
reloadItems()
|
||||
}
|
||||
}
|
||||
.onReceive(
|
||||
NotificationCenter.default.publisher(
|
||||
for: config.issueType == "pulls" ? .pullRequestsDidChange : .issuesDidChange,
|
||||
),
|
||||
) { _ in
|
||||
pagination.invalidate()
|
||||
reloadItems()
|
||||
}
|
||||
.onChange(of: stateFilter) {
|
||||
reloadItems(clearItems: true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,14 +68,6 @@ struct NotificationsOverviewView: View {
|
|||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
Button {
|
||||
Task { await dismissNotification(notification) }
|
||||
} label: {
|
||||
Label("Dismiss", systemImage: "xmark")
|
||||
}
|
||||
.tint(.gray)
|
||||
}
|
||||
}
|
||||
|
||||
if pagination.hasMore {
|
||||
|
|
@ -114,7 +106,10 @@ struct NotificationsOverviewView: View {
|
|||
private func notificationRow(_ notification: NotificationThread) -> some View {
|
||||
let destination = navigationDestination(for: notification)
|
||||
if let destination {
|
||||
NavigationLink { destination } label: {
|
||||
NavigationLink {
|
||||
destination
|
||||
.onAppear { Task { await markReadOnOpen(notification) } }
|
||||
} label: {
|
||||
NotificationRow(notification: notification)
|
||||
}
|
||||
} else {
|
||||
|
|
@ -178,16 +173,20 @@ struct NotificationsOverviewView: View {
|
|||
await setNotificationRead(notification)
|
||||
}
|
||||
|
||||
private func dismissNotification(_ notification: NotificationThread) async {
|
||||
await setNotificationRead(notification)
|
||||
/// Marks a notification read when the user opens it, mirroring Mail and News.
|
||||
/// The row is updated in place rather than removed so navigation into the
|
||||
/// detail view is not interrupted while the list is mutating.
|
||||
private func markReadOnOpen(_ notification: NotificationThread) async {
|
||||
guard notification.unread else { return }
|
||||
await setNotificationRead(notification, removeFromUnread: false)
|
||||
}
|
||||
|
||||
private func setNotificationRead(_ notification: NotificationThread) async {
|
||||
private func setNotificationRead(_ notification: NotificationThread, removeFromUnread: Bool = true) async {
|
||||
guard let notificationService else { return }
|
||||
do {
|
||||
try await notificationService.markAsRead(id: notification.id)
|
||||
withAnimation {
|
||||
if statusFilter == "unread" {
|
||||
if removeFromUnread, statusFilter == "unread" {
|
||||
pagination.items.removeAll { $0.id == notification.id }
|
||||
} else if let index = pagination.items.firstIndex(where: { $0.id == notification.id }) {
|
||||
pagination.items[index] = NotificationThread(
|
||||
|
|
@ -250,13 +249,17 @@ private struct NotificationRow: View {
|
|||
.font(.body)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(notification.repository.fullName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Text(formatRelativeDate(notification.updatedAt))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 4) {
|
||||
Text(notification.repository.fullName)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(formatRelativeDate(notification.updatedAt))
|
||||
.fixedSize()
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
|
@ -265,7 +268,6 @@ private struct NotificationRow: View {
|
|||
Circle()
|
||||
.fill(.blue)
|
||||
.frame(width: 10, height: 10)
|
||||
.glassEffect(.regular.tint(.blue))
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ struct PullRequestCreateView: View {
|
|||
@State private var selectedMilestoneID: Int?
|
||||
@State private var selectedAssigneeLogins: Set<String> = []
|
||||
@State private var selectedReviewerLogins: Set<String> = []
|
||||
@State private var createdPRNumber: Int?
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
private let prService: PullRequestService?
|
||||
|
|
@ -175,24 +176,33 @@ struct PullRequestCreateView: View {
|
|||
guard let prService else { return }
|
||||
isSubmitting = true
|
||||
do {
|
||||
let trimmedBody = bodyText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let createdPR = try await prService.createPullRequest(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
title: title.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
head: headBranch,
|
||||
base: baseBranch,
|
||||
body: trimmedBody.isEmpty ? nil : trimmedBody,
|
||||
labels: selectedLabelIDs.isEmpty ? nil : Array(selectedLabelIDs),
|
||||
milestone: selectedMilestoneID,
|
||||
assignees: selectedAssigneeLogins.isEmpty ? nil : Array(selectedAssigneeLogins),
|
||||
)
|
||||
// Reuse the already-created PR on retry so a failed reviewer request
|
||||
// doesn't open a duplicate PR when the user taps Create again.
|
||||
let prNumber: Int
|
||||
if let existingNumber = createdPRNumber {
|
||||
prNumber = existingNumber
|
||||
} else {
|
||||
let trimmedBody = bodyText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let createdPR = try await prService.createPullRequest(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
title: title.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
head: headBranch,
|
||||
base: baseBranch,
|
||||
body: trimmedBody.isEmpty ? nil : trimmedBody,
|
||||
labels: selectedLabelIDs.isEmpty ? nil : Array(selectedLabelIDs),
|
||||
milestone: selectedMilestoneID,
|
||||
assignees: selectedAssigneeLogins.isEmpty ? nil : Array(selectedAssigneeLogins),
|
||||
)
|
||||
createdPRNumber = createdPR.number
|
||||
prNumber = createdPR.number
|
||||
}
|
||||
if !selectedReviewerLogins.isEmpty {
|
||||
do {
|
||||
try await prService.requestReviewers(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
index: createdPR.number,
|
||||
index: prNumber,
|
||||
reviewers: Array(selectedReviewerLogins),
|
||||
)
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -173,6 +173,16 @@ struct PullRequestEditView: View {
|
|||
onSaved(updatedPR)
|
||||
dismiss()
|
||||
} catch {
|
||||
// The calls above run in sequence with no transaction, so an earlier
|
||||
// one may already be committed on the server. Re-fetch the PR and push
|
||||
// the authoritative state to the detail view so it does not show stale
|
||||
// data, then surface the error and keep the sheet open to retry.
|
||||
if let refreshed = try? await prService.fetchPullRequest(
|
||||
owner: owner, repo: repo, index: pullRequest.number,
|
||||
) {
|
||||
NotificationCenter.default.post(name: .pullRequestsDidChange, object: nil)
|
||||
onSaved(refreshed)
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
showError = true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,12 @@ 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(
|
||||
|
|
@ -116,13 +122,18 @@ 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
|
||||
try await prService.fetchPullRequests(
|
||||
owner: owner,
|
||||
repo: repo,
|
||||
state: stateFilter.rawValue,
|
||||
page: page,
|
||||
limit: limit,
|
||||
)
|
||||
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 []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -140,11 +151,12 @@ struct PullRequestListView: View {
|
|||
}
|
||||
|
||||
#if DEBUG
|
||||
init(preview _: Void, repository: Repository, authService: AuthenticationService, pullRequests: [PullRequest]) {
|
||||
init(preview _: Void, repository: Repository, authService: AuthenticationService, pullRequests: [PullRequest],
|
||||
notFound: Bool = false) {
|
||||
self.repository = repository
|
||||
self.authService = authService
|
||||
prService = nil
|
||||
_pagination = State(initialValue: PaginationState(items: pullRequests))
|
||||
_pagination = State(initialValue: PaginationState(items: pullRequests, notFound: notFound))
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
|
@ -160,6 +172,18 @@ 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 = "Pull Requests"
|
||||
case pulls = "PRs"
|
||||
case actions = "Actions"
|
||||
|
||||
var icon: String {
|
||||
|
|
@ -211,6 +211,7 @@ struct RepositoryDetailView: View {
|
|||
} label: {
|
||||
Image(systemName: "clock.arrow.circlepath")
|
||||
}
|
||||
.accessibilityLabel("Commit history")
|
||||
.accessibilityIdentifier("commits-button")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -280,6 +280,7 @@ struct RepositoryRow: View {
|
|||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(isStarring)
|
||||
.accessibilityLabel(isStarred ? "Unstar repository" : "Star repository")
|
||||
.accessibilityIdentifier("star-button")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ 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)
|
||||
}
|
||||
|
|
@ -144,13 +145,15 @@ struct SearchableOverviewView<Row: View, Detail: View, CreateView: View>: View {
|
|||
}
|
||||
.listStyle(.insetGrouped)
|
||||
.safeAreaInset(edge: .top) {
|
||||
Text(filterSummaryText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.accessibilityIdentifier("filter-summary")
|
||||
if hasNonDefaultFilters {
|
||||
Text(filterSummaryText)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 6)
|
||||
.accessibilityIdentifier("filter-summary")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ struct WorkflowRunDetailView: View {
|
|||
Link(destination: parsed) {
|
||||
Image(systemName: "safari")
|
||||
}
|
||||
.accessibilityLabel("Open in browser")
|
||||
.accessibilityIdentifier("workflow-run-open-browser")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
62
Forji/ForjiTests/InstanceFormViewTests.swift
Normal file
62
Forji/ForjiTests/InstanceFormViewTests.swift
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import Forji
|
||||
|
||||
@MainActor
|
||||
struct InstanceFormViewTests {
|
||||
private func makeInstance(
|
||||
server: String = "https://a.com",
|
||||
username: String = "user",
|
||||
name: String = "Account",
|
||||
) -> ForgejoInstance {
|
||||
ForgejoInstance(serverURL: server, username: username, name: name)
|
||||
}
|
||||
|
||||
// MARK: - accountAlreadyExists
|
||||
|
||||
@Test func detectsDuplicateServerAndUsername() {
|
||||
let existing = [makeInstance(server: "https://a.com", username: "user", name: "Work")]
|
||||
#expect(InstanceFormView.accountAlreadyExists(
|
||||
serverURL: "https://a.com", username: "user", in: existing,
|
||||
))
|
||||
}
|
||||
|
||||
@Test func differentServerIsNotDuplicate() {
|
||||
let existing = [makeInstance(server: "https://a.com", username: "user")]
|
||||
#expect(!InstanceFormView.accountAlreadyExists(
|
||||
serverURL: "https://b.com", username: "user", in: existing,
|
||||
))
|
||||
}
|
||||
|
||||
@Test func differentUsernameIsNotDuplicate() {
|
||||
let existing = [makeInstance(server: "https://a.com", username: "user")]
|
||||
#expect(!InstanceFormView.accountAlreadyExists(
|
||||
serverURL: "https://a.com", username: "other", in: existing,
|
||||
))
|
||||
}
|
||||
|
||||
@Test func emptyListIsNeverDuplicate() {
|
||||
#expect(!InstanceFormView.accountAlreadyExists(
|
||||
serverURL: "https://a.com", username: "user", in: [],
|
||||
))
|
||||
}
|
||||
|
||||
// MARK: - excluding (edit path)
|
||||
|
||||
@Test func editingInstanceWithoutChangingIdentityIsAllowed() {
|
||||
// Editing only the name on an existing account must not flag itself as a duplicate.
|
||||
let instance = makeInstance(server: "https://a.com", username: "user", name: "Old")
|
||||
#expect(!InstanceFormView.accountAlreadyExists(
|
||||
serverURL: "https://a.com", username: "user", in: [instance], excluding: instance,
|
||||
))
|
||||
}
|
||||
|
||||
@Test func editingIntoAnotherAccountsIdentityIsRejected() {
|
||||
// Two distinct accounts; editing the second to match the first must be caught.
|
||||
let first = makeInstance(server: "https://a.com", username: "user", name: "First")
|
||||
let second = makeInstance(server: "https://b.com", username: "user", name: "Second")
|
||||
#expect(InstanceFormView.accountAlreadyExists(
|
||||
serverURL: "https://a.com", username: "user", in: [first, second], excluding: second,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -133,4 +133,40 @@ struct KeychainManagerTests {
|
|||
// Clean up
|
||||
try await KeychainManager.shared.deletePassword(for: normalized, username: "user")
|
||||
}
|
||||
|
||||
@Test func keychainDeleteCredentialsRemovesBothPasswordAndToken() async throws {
|
||||
let server = "https://delete-credentials.example.com"
|
||||
let username = "user"
|
||||
|
||||
try await KeychainManager.shared.savePassword("mypassword", for: server, username: username)
|
||||
try await KeychainManager.shared.saveToken("mytoken", for: server, username: username)
|
||||
|
||||
try await KeychainManager.shared.deleteCredentials(for: server, username: username)
|
||||
|
||||
do {
|
||||
_ = try await KeychainManager.shared.getPassword(for: server, username: username)
|
||||
Issue.record("Expected KeychainError.notFound for password after deleteCredentials")
|
||||
} catch is KeychainError {
|
||||
// Expected
|
||||
} catch {
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
|
||||
do {
|
||||
_ = 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
|
||||
} catch {
|
||||
Issue.record("Unexpected error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func keychainDeleteCredentialsNonExistentDoesNotThrow() async throws {
|
||||
// Deleting credentials for an account with nothing stored must not throw.
|
||||
try await KeychainManager.shared.deleteCredentials(
|
||||
for: "https://nonexistent-credentials.example.com",
|
||||
username: "nobody"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
41
Forji/ForjiTests/LabelColorHexTests.swift
Normal file
41
Forji/ForjiTests/LabelColorHexTests.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
@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,3 +1,4 @@
|
|||
// swiftlint:disable file_length
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import Forji
|
||||
|
|
@ -359,6 +360,41 @@ 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 {
|
||||
|
|
@ -368,7 +404,7 @@ struct PaginationStateLoadMoreTests {
|
|||
await pagination.reload { _, _ in ["a", "b"] }.value
|
||||
#expect(pagination.hasMore)
|
||||
|
||||
await pagination.loadMore { page, limit in
|
||||
await pagination.loadMore { page, _ in
|
||||
#expect(page == 2)
|
||||
return ["c", "d"]
|
||||
}
|
||||
|
|
@ -439,3 +475,50 @@ 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"])
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -190,4 +190,33 @@ struct TaggedItemTests {
|
|||
|
||||
#expect(pagination.items.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func rehydrateHandlesDuplicateSourceKeys() {
|
||||
// Two connected accounts with the same server+username produce duplicate
|
||||
// sourceKeys; rehydrate must not trap on the duplicate key.
|
||||
let liveAuth = AuthenticationService()
|
||||
let key = "https://a.com:u"
|
||||
let sources = [
|
||||
ConnectionSource(
|
||||
sourceKey: key, name: "A",
|
||||
client: ForgejoClient(serverURL: "https://a.com", username: "u", token: "t"),
|
||||
authService: liveAuth,
|
||||
),
|
||||
ConnectionSource(
|
||||
sourceKey: key, name: "A (duplicate)",
|
||||
client: ForgejoClient(serverURL: "https://a.com", username: "u", token: "t"),
|
||||
authService: AuthenticationService(),
|
||||
),
|
||||
]
|
||||
let pagination = PaginationState<TaggedItem<FIssue>>()
|
||||
pagination.items = [
|
||||
TaggedItem(item: makeIssue(id: 1), sourceKey: key, instanceName: "A", authService: makeAuth()),
|
||||
]
|
||||
|
||||
pagination.rehydrate(from: sources)
|
||||
|
||||
#expect(pagination.items.count == 1)
|
||||
#expect(pagination.items[0].authService === liveAuth) // first source wins
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,17 +37,17 @@ final class IssueUITests: ForgejoReadOnlyUITestBase {
|
|||
// Wait for list to load
|
||||
XCTAssertTrue(app.cells.firstMatch.waitForExistence(timeout: 10), "Issues should load")
|
||||
|
||||
// Default filter summary should show "Open" (default scope is All involvement)
|
||||
// In the default state (Open + All involvement) the summary is hidden.
|
||||
let filterSummary = app.staticTexts["filter-summary"]
|
||||
XCTAssertTrue(filterSummary.waitForExistence(timeout: 5))
|
||||
XCTAssertEqual(filterSummary.label, "Open")
|
||||
XCTAssertFalse(filterSummary.exists, "Filter summary should be hidden in the default state")
|
||||
|
||||
let filterMenuButton = app.buttons["filter-menu-button"]
|
||||
|
||||
// Tap "Assigned to you" via filter menu
|
||||
// Selecting a non-default scope reveals the summary.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -125,7 +125,7 @@ final class MergedInstanceUITests: ForgejoUITestBase {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
func testMergedNotificationSwipeDismiss() throws {
|
||||
func testMergedNotificationSwipeMarkRead() throws {
|
||||
try setupTwoInstances()
|
||||
enterMergedMode()
|
||||
|
||||
|
|
@ -137,19 +137,11 @@ final class MergedInstanceUITests: ForgejoUITestBase {
|
|||
let firstCell = notificationsList.cells.firstMatch
|
||||
XCTAssertTrue(firstCell.waitForExistence(timeout: 10), "Should have at least one notification")
|
||||
|
||||
// Swipe right to mark as read
|
||||
// Swipe right to mark as read (the only notification swipe action)
|
||||
firstCell.swipeRight()
|
||||
let markReadButton = app.buttons["Mark Read"]
|
||||
XCTAssertTrue(markReadButton.waitForExistence(timeout: 3), "Mark Read swipe action should appear")
|
||||
markReadButton.tap()
|
||||
|
||||
// Swipe left to dismiss
|
||||
let nextCell = notificationsList.cells.firstMatch
|
||||
XCTAssertTrue(nextCell.waitForExistence(timeout: 5), "Should have another notification to dismiss")
|
||||
nextCell.swipeLeft()
|
||||
let dismissButton = app.buttons["Dismiss"]
|
||||
XCTAssertTrue(dismissButton.waitForExistence(timeout: 3), "Dismiss swipe action should appear")
|
||||
dismissButton.tap()
|
||||
}
|
||||
|
||||
// MARK: - Server URL Helpers
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import XCTest
|
|||
|
||||
final class NotificationsUITests: ForgejoUITestBase {
|
||||
|
||||
// MARK: - Notifications (unread, filter, swipe mark-read, swipe dismiss)
|
||||
// MARK: - Notifications (unread, filter, swipe mark-read)
|
||||
|
||||
@MainActor
|
||||
func testNotifications() throws {
|
||||
|
|
@ -38,15 +38,68 @@ final class NotificationsUITests: ForgejoUITestBase {
|
|||
if markReadButton.waitForExistence(timeout: 3) {
|
||||
markReadButton.tap()
|
||||
}
|
||||
}
|
||||
|
||||
// Swipe left to dismiss
|
||||
let nextCell = notificationsList.cells.firstMatch
|
||||
if nextCell.waitForExistence(timeout: 5) {
|
||||
nextCell.swipeLeft()
|
||||
let dismissButton = app.buttons["Dismiss"]
|
||||
if dismissButton.waitForExistence(timeout: 3) {
|
||||
dismissButton.tap()
|
||||
}
|
||||
}
|
||||
// MARK: - Open-to-clear (#32)
|
||||
|
||||
/// Opening an unread notification marks it read on the server, so it leaves
|
||||
/// the Unread filter on the next refresh.
|
||||
///
|
||||
/// Covers the in-app NotificationsOverviewView path: the tap gesture on the
|
||||
/// row calls markReadOnOpen, which marks the thread read with
|
||||
/// removeFromUnread: false so the row stays put during navigation and only
|
||||
/// drops out of Unread on the next server fetch. The multi-instance and
|
||||
/// system-notification open paths apply the same fix but aren't reachable
|
||||
/// from this single-instance harness.
|
||||
///
|
||||
/// Sorts after testNotifications and opens whichever unread row is first
|
||||
/// rather than a fixed one, so it's unaffected by the two notifications that
|
||||
/// test consumes (the seed leaves several more).
|
||||
@MainActor
|
||||
func testOpeningNotificationMarksItRead() throws {
|
||||
loginAndWaitForHome()
|
||||
app.tabBars.buttons["Notifications"].tap()
|
||||
|
||||
let notificationsList = app.collectionViews.firstMatch
|
||||
XCTAssertTrue(notificationsList.waitForExistence(timeout: 10))
|
||||
|
||||
// Make sure the Unread filter is active.
|
||||
let unreadButton = app.buttons["Unread"]
|
||||
XCTAssertTrue(unreadButton.waitForExistence(timeout: 5))
|
||||
unreadButton.tap()
|
||||
XCTAssertTrue(notificationsList.waitForExistence(timeout: 10))
|
||||
|
||||
// Remember the first unread notification's title (the row's first text)
|
||||
// so we can assert that specific one leaves the Unread list.
|
||||
let firstCell = notificationsList.cells.firstMatch
|
||||
XCTAssertTrue(firstCell.waitForExistence(timeout: 10), "Expected at least one unread notification")
|
||||
let openedTitle = firstCell.staticTexts.firstMatch.label
|
||||
XCTAssertFalse(openedTitle.isEmpty, "Could not read the unread notification's title")
|
||||
|
||||
// Open it. Navigating into the detail proves the row was tappable; the
|
||||
// nav bar title changes away from "Notifications" once it's on screen.
|
||||
firstCell.tap()
|
||||
let detailNavBar = app.navigationBars.element(boundBy: 0)
|
||||
XCTAssertTrue(detailNavBar.waitForExistence(timeout: 10), "Detail view did not open")
|
||||
XCTAssertNotEqual(detailNavBar.identifier, "Notifications", "Did not navigate into the notification detail")
|
||||
|
||||
// Back to the list.
|
||||
app.navigationBars.buttons.element(boundBy: 0).tap()
|
||||
XCTAssertTrue(notificationsList.waitForExistence(timeout: 10))
|
||||
|
||||
// Force a server refresh of Unread by toggling the filter. Opening marks
|
||||
// the thread read but keeps the row visible until the next fetch, so this
|
||||
// round-trip is what surfaces the open-to-clear behaviour.
|
||||
app.buttons["Read"].tap()
|
||||
XCTAssertTrue(notificationsList.waitForExistence(timeout: 10))
|
||||
unreadButton.tap()
|
||||
XCTAssertTrue(notificationsList.waitForExistence(timeout: 10))
|
||||
|
||||
// The opened notification should no longer be under Unread.
|
||||
let openedRow = notificationsList.staticTexts[openedTitle]
|
||||
XCTAssertFalse(
|
||||
openedRow.waitForExistence(timeout: 5),
|
||||
"Opened notification \"\(openedTitle)\" should have left the Unread filter after refresh",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -111,17 +111,17 @@ final class PullRequestUITests: ForgejoReadOnlyUITestBase {
|
|||
// Reset filters to defaults (previous test may have changed them)
|
||||
resetOverviewFilters()
|
||||
|
||||
// Default filter summary should show "Open" (default scope is All involvement)
|
||||
// In the default state (Open + All involvement) the summary is hidden.
|
||||
let filterSummary = app.staticTexts["filter-summary"]
|
||||
XCTAssertTrue(filterSummary.waitForExistence(timeout: 5))
|
||||
XCTAssertEqual(filterSummary.label, "Open")
|
||||
XCTAssertFalse(filterSummary.exists, "Filter summary should be hidden in the default state")
|
||||
|
||||
let filterMenuButton = app.buttons["filter-menu-button"]
|
||||
|
||||
// Tap "Assigned to you" via filter menu
|
||||
// Selecting a non-default scope reveals the summary.
|
||||
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
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ extension UITestNavigating {
|
|||
navigateToRepoDetail()
|
||||
let picker = app.segmentedControls["repo-detail-tab-picker"]
|
||||
XCTAssertTrue(picker.waitForExistence(timeout: 10))
|
||||
picker.buttons["Pull Requests"].tap()
|
||||
picker.buttons["PRs"].tap()
|
||||
}
|
||||
|
||||
/// Resets the overview filter to Open + All involvement (the app defaults).
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ Forji is a native iOS app for managing your Forgejo repositories, issues, pull r
|
|||
|
||||
## App Store
|
||||
|
||||
Foji is availbe on the Apple App Store for free: [Forji App Store](https://apps.apple.com/us/app/forji/id6759843639)
|
||||
Forji is available on the Apple App Store for free: [Forji App Store](https://apps.apple.com/us/app/forji/id6759843639)
|
||||
|
||||
## Features
|
||||
|
||||
|
|
|
|||
54
cliff.toml
Normal file
54
cliff.toml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
# 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,6 +18,7 @@
|
|||
pkgs.xcbeautify
|
||||
pkgs.swiftlint
|
||||
pkgs.swiftformat
|
||||
pkgs.git-cliff
|
||||
];
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
{
|
||||
"originHash" : "3de93078233e3d235402dee817c2fe74a7959fbf4120a66c390f922e560eca49",
|
||||
"originHash" : "b00b2c6720c5dd1647ef09fa552c1db4a3e6a6124bf0acfbb10670d919e23abb",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "forgejokit",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://codeberg.org/secana/ForgejoKit.git",
|
||||
"state" : {
|
||||
"revision" : "7352bc4b1fcfae735ba0008e1e376906c27b9844",
|
||||
"version" : "0.6.1"
|
||||
"revision" : "4d1dfc1305c0194fc74b09d15d29940a98cd9752",
|
||||
"version" : "0.7.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ let package = Package(
|
|||
.macOS(.v15),
|
||||
],
|
||||
dependencies: [
|
||||
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.6.1"),
|
||||
.package(url: "https://codeberg.org/secana/ForgejoKit.git", from: "0.7.0"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ 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")
|
||||
|
|
@ -16,8 +17,21 @@ enum DockerExec {
|
|||
process.standardError = stderrPipe
|
||||
process.standardOutput = FileHandle.nullDevice
|
||||
|
||||
let didExit = DispatchSemaphore(value: 0)
|
||||
process.terminationHandler = { _ in didExit.signal() }
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
// 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,
|
||||
)
|
||||
}
|
||||
|
||||
if process.terminationStatus != 0 {
|
||||
let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@ 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,6 +3,7 @@ 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? {
|
||||
|
|
@ -11,6 +12,8 @@ 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,14 +25,30 @@ struct Seeder {
|
|||
allowSelfSignedCertificates: true,
|
||||
)
|
||||
|
||||
try createAdminUser()
|
||||
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 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.")
|
||||
print("\nSeed data created successfully (\(Int(Date().timeIntervalSince(start)))s total).")
|
||||
}
|
||||
|
||||
// MARK: - Phase 1: Create users
|
||||
|
|
|
|||
48
justfile
48
justfile
|
|
@ -37,11 +37,49 @@ pbxproj := "Forji/Forji.xcodeproj/project.pbxproj"
|
|||
version:
|
||||
@grep -m1 'MARKETING_VERSION' {{pbxproj}} | sed 's/.*= *//;s/;.*//'
|
||||
|
||||
# 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}}"
|
||||
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.
|
||||
sed -i '' "s/MARKETING_VERSION = [^;]*/MARKETING_VERSION = $NEW/" {{pbxproj}}
|
||||
sed -i '' "s/CURRENT_PROJECT_VERSION = [^;]*/CURRENT_PROJECT_VERSION = $NEW/" {{pbxproj}}
|
||||
|
||||
# 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."
|
||||
|
||||
# Clean build artifacts
|
||||
clean:
|
||||
|
|
|
|||
Loading…
Reference in a new issue