Update README.md #2

Closed
atridad wants to merge 0 commits from test into main
7 changed files with 262 additions and 321 deletions

View File

@ -16,3 +16,5 @@ A native SwiftUI application for interacting with any Gitea or Forgejo instance.
## Upcoming Features
TBA
TESTING

View File

@ -140,9 +140,7 @@ class AuthenticationManager: ObservableObject {
print("DEBUG: refreshUser calling getCurrentUser")
let user = try await apiService.getCurrentUser()
print("DEBUG: refreshUser got user: \(user.login)")
await MainActor.run {
currentUser = user
}
} catch {
print("DEBUG: refreshUser failed with error: \(error)")
// Handle cancellation and other errors gracefully
@ -151,11 +149,9 @@ class AuthenticationManager: ObservableObject {
// Don't show error message for cancellation
return
}
await MainActor.run {
errorMessage = error.localizedDescription
}
}
}
func updateUserProfile(
fullName: String?, email: String?, description: String?, website: String?, location: String?
@ -199,22 +195,16 @@ class AuthenticationManager: ObservableObject {
do {
let updatedUser = try await apiService.updateUserSettings(settings: settings)
await MainActor.run {
currentUser = updatedUser
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
}
throw error
}
}
func updateCurrentUser(_ user: User) {
Task { @MainActor in
currentUser = user
}
}
func getAPIService() -> GiteaAPIService? {
return apiService

View File

@ -180,47 +180,15 @@ class GiteaAPIService: ObservableObject {
if let state = state {
queryParams += "&state=\(state.rawValue)"
}
// Add type parameter to filter only issues (not pull requests)
queryParams += "&type=issues"
guard let request = createRequest(endpoint: "/repos/\(owner)/\(repo)/issues" + queryParams)
else {
throw APIError.invalidURL
}
// Use manual JSON parsing to handle mixed issue/PR responses and null assignees
let (data, _) = try await URLSession.shared.data(for: request)
guard let jsonArray = try JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else {
throw APIError.invalidResponse
}
var issues: [Issue] = []
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
for itemDict in jsonArray {
// Skip pull requests - they have base/head fields or URL contains /pulls/
if itemDict["base"] != nil || itemDict["head"] != nil {
continue
}
if let urlString = itemDict["url"] as? String, urlString.contains("/pulls/") {
continue
}
// Try to decode as Issue
do {
let itemData = try JSONSerialization.data(withJSONObject: itemDict)
let issue = try decoder.decode(Issue.self, from: itemData)
issues.append(issue)
} catch {
// Log but don't fail - just skip this item
print("Failed to decode issue: \(error)")
continue
}
}
return issues
return try await performRequest(request, responseType: [Issue].self)
}
func getRepositoryPullRequests(
@ -571,52 +539,73 @@ class GiteaAPIService: ObservableObject {
print("DEBUG: Request JSON: \(jsonString)")
}
// Try the user settings endpoint first (most likely to work)
// Try different endpoints and methods as fallbacks
// Order by most likely to succeed first
let endpoints = [
("/user", HTTPMethod.PATCH),
("/user", HTTPMethod.PUT),
("/user/settings", HTTPMethod.PATCH),
("/user/settings", HTTPMethod.PUT),
]
var lastError: Error?
for (endpoint, method) in endpoints {
print("DEBUG: Trying \(method.rawValue) request to: \(baseURL)/api/v1\(endpoint)")
guard
let request = createRequest(
endpoint: "/user/settings", method: .PATCH, body: settingsData)
let request = createRequest(endpoint: endpoint, method: method, body: settingsData)
else {
print("DEBUG: Failed to create request for /user/settings")
throw APIError.invalidRequest
print("DEBUG: Failed to create request for \(endpoint)")
continue
}
print("DEBUG: Request headers: \(request.allHTTPHeaderFields ?? [:])")
do {
// Try to update settings - this returns UserSettingsResponse, not User
let _ = try await performRequest(request, responseType: UserSettingsResponse.self)
print("DEBUG: updateUserSettings succeeded with PATCH /user/settings")
// Now fetch the updated user data
print("DEBUG: Fetching updated user data")
let updatedUser = try await getCurrentUser()
print("DEBUG: Successfully fetched updated user data")
return updatedUser
} catch let error as APIError {
print("DEBUG: PATCH to /user/settings failed: \(error)")
// If settings update failed, try the user endpoint as fallback
guard
let userRequest = createRequest(
endpoint: "/user", method: .PATCH, body: settingsData)
else {
print("DEBUG: Failed to create request for /user")
throw error
}
do {
let result = try await performRequest(userRequest, responseType: User.self)
print("DEBUG: updateUserSettings succeeded with PATCH /user")
let result = try await performRequest(request, responseType: User.self)
print("DEBUG: updateUserSettings succeeded with \(method.rawValue) \(endpoint)")
return result
} catch {
print("DEBUG: PATCH to /user also failed: \(error)")
} catch let error as APIError {
print("DEBUG: \(method.rawValue) to \(endpoint) failed: \(error)")
lastError = error
// If it's a 405 Method Not Allowed, try the next endpoint/method
if case .httpError(405, _) = error {
continue
}
// If it's a 404 Not Found, try the next endpoint
if case .httpError(404, _) = error {
continue
}
// For authentication errors, don't retry
if case .httpError(401, _) = error {
throw error
}
} catch {
print("DEBUG: updateUserSettings failed with unexpected error: \(error)")
if case .httpError(403, _) = error {
throw error
}
// For other client errors (4xx), still try other endpoints
if case .httpError(let code, _) = error, code >= 400 && code < 500 {
continue
}
// For server errors (5xx), don't retry
if case .httpError(let code, _) = error, code >= 500 {
throw error
}
// For other errors, still continue but this might be the final error
continue
} catch {
print("DEBUG: \(method.rawValue) to \(endpoint) failed: \(error)")
lastError = error
continue
}
}
print(
"DEBUG: All update methods failed, final error: \(lastError?.localizedDescription ?? "Unknown error")"
)
throw lastError ?? APIError.invalidResponse
}
}
@ -677,9 +666,6 @@ struct UserSettingsOptions: Codable {
}
}
// UserSettingsResponse has the same structure as UserSettingsOptions for the API response
typealias UserSettingsResponse = UserSettingsOptions
// MARK: - API Errors
enum APIError: LocalizedError {

View File

@ -177,24 +177,6 @@ struct ExternalWiki: Codable, Equatable {
}
}
// MARK: - Asset
struct Asset: Codable, Identifiable {
let id: Int
let name: String
let size: Int
let downloadCount: Int
let createdAt: Date
let browserDownloadUrl: String
let uuid: String
enum CodingKeys: String, CodingKey {
case id, name, size, uuid
case downloadCount = "download_count"
case createdAt = "created_at"
case browserDownloadUrl = "browser_download_url"
}
}
// MARK: - Issue
// MARK: - Issue Repository (simplified for issue responses)
struct IssueRepository: Codable {
@ -218,7 +200,7 @@ struct Issue: Codable, Identifiable {
let labels: [Label]
let milestone: Milestone?
let assignee: User?
let assignees: [User]?
let assignees: [User]
let state: IssueState
let isLocked: Bool
let comments: Int
@ -230,15 +212,10 @@ struct Issue: Codable, Identifiable {
let repository: IssueRepository?
let htmlUrl: String
let url: String
let assets: [Asset]?
let originalAuthor: String?
let originalAuthorId: Int?
let pinOrder: Int?
let ref: String?
enum CodingKeys: String, CodingKey {
case id, number, title, body, user, labels, milestone, assignee, assignees
case state, comments, repository, url, assets, ref
case state, comments, repository, url
case isLocked = "is_locked"
case createdAt = "created_at"
case updatedAt = "updated_at"
@ -246,42 +223,6 @@ struct Issue: Codable, Identifiable {
case dueDate = "due_date"
case pullRequest = "pull_request"
case htmlUrl = "html_url"
case originalAuthor = "original_author"
case originalAuthorId = "original_author_id"
case pinOrder = "pin_order"
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
number = try container.decode(Int.self, forKey: .number)
title = try container.decode(String.self, forKey: .title)
body = try container.decodeIfPresent(String.self, forKey: .body)
user = try container.decode(User.self, forKey: .user)
labels = try container.decode([Label].self, forKey: .labels)
milestone = try container.decodeIfPresent(Milestone.self, forKey: .milestone)
assignee = try container.decodeIfPresent(User.self, forKey: .assignee)
assignees = try container.decodeIfPresent([User].self, forKey: .assignees)
// Handle state as string and convert to enum
let stateString = try container.decode(String.self, forKey: .state)
state = IssueState(rawValue: stateString) ?? .open
isLocked = try container.decode(Bool.self, forKey: .isLocked)
comments = try container.decode(Int.self, forKey: .comments)
createdAt = try container.decode(Date.self, forKey: .createdAt)
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
closedAt = try container.decodeIfPresent(Date.self, forKey: .closedAt)
dueDate = try container.decodeIfPresent(Date.self, forKey: .dueDate)
pullRequest = try container.decodeIfPresent(PullRequestMeta.self, forKey: .pullRequest)
repository = try container.decodeIfPresent(IssueRepository.self, forKey: .repository)
htmlUrl = try container.decode(String.self, forKey: .htmlUrl)
url = try container.decode(String.self, forKey: .url)
assets = try container.decodeIfPresent([Asset].self, forKey: .assets)
originalAuthor = try container.decodeIfPresent(String.self, forKey: .originalAuthor)
originalAuthorId = try container.decodeIfPresent(Int.self, forKey: .originalAuthorId)
pinOrder = try container.decodeIfPresent(Int.self, forKey: .pinOrder)
ref = try container.decodeIfPresent(String.self, forKey: .ref)
}
}

View File

@ -189,9 +189,12 @@ struct EditProfileView: View {
return
}
await MainActor.run {
isLoading = true
errorMessage = nil
defer {
isLoading = false
print("DEBUG: saveProfile - isLoading set to false")
}
do {
@ -212,25 +215,20 @@ struct EditProfileView: View {
)
print("DEBUG: saveProfile - update successful, dismissing sheet")
await MainActor.run {
isLoading = false
// Success - dismiss the sheet
dismiss()
}
} catch {
print("DEBUG: saveProfile - error occurred: \(error)")
await MainActor.run {
isLoading = false
// Set error message but still dismiss for certain errors
errorMessage = error.localizedDescription
// Dismiss after showing error briefly
// Always dismiss the sheet after a few seconds to prevent getting stuck
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
print("DEBUG: saveProfile - dismissing sheet after error with delay")
dismiss()
}
}
}
}
}
#Preview {

View File

@ -14,7 +14,6 @@ struct RepositoriesView: View {
@State private var showPrivateOnly = false
@State private var showPublicOnly = false
@Namespace private var glassNamespace
@State private var isFetching = false
enum SortOption: String, CaseIterable {
case name = "Name"
@ -90,8 +89,9 @@ struct RepositoriesView: View {
destination: RepositoryDetailView(
repository: repository,
onRepositoryUpdated: {
// Don't automatically refresh - let user manually refresh if needed
// This prevents infinite loops during navigation
Task {
await fetchRepositories()
}
})
) {
CompactRepositoryRow(repository: repository)
@ -137,12 +137,10 @@ struct RepositoriesView: View {
isPresented: $showingCreateRepository,
onDismiss: {
// Refresh repositories when create sheet is dismissed
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
Task {
await fetchRepositories()
}
}
}
) {
CreateRepositoryView()
.environmentObject(authManager)
@ -212,58 +210,22 @@ struct RepositoriesView: View {
}
private func fetchRepositories() async {
// Prevent multiple simultaneous fetches
if isFetching {
return
}
guard let apiService = authManager.getAPIService() else {
await MainActor.run {
errorMessage = "Not authenticated"
}
return
}
await MainActor.run {
isFetching = true
isLoading = true
errorMessage = nil
}
do {
let fetchedRepositories = try await apiService.getUserRepositories()
await MainActor.run {
repositories = fetchedRepositories
repositories = try await apiService.getUserRepositories()
applyFiltersAndSort()
isLoading = false
isFetching = false
}
} catch {
await MainActor.run {
isLoading = false
isFetching = false
// Handle different types of cancellation errors gracefully - don't show error or trigger retry
if error is CancellationError {
return
}
// Check for URL session cancellation
if let nsError = error as NSError? {
if nsError.domain == NSURLErrorDomain && nsError.code == NSURLErrorCancelled {
return
}
}
// Check for string-based cancellation
if error.localizedDescription.lowercased().contains("cancelled") {
return
}
// Only show error for non-cancellation errors
errorMessage = error.localizedDescription
}
}
isLoading = false
}
}

View File

@ -27,6 +27,8 @@ struct RepositoryDetailView: View {
@State private var branches: [Branch] = []
@State private var releases: [Release] = []
@State private var issues: [Issue] = []
@State private var pullRequests: [PullRequest] = []
@State private var commits: [Commit] = []
@State private var isStarred = false
@State private var isWatching = false
@State private var showingBranches = false
@ -34,11 +36,14 @@ struct RepositoryDetailView: View {
@State private var repositoryDeleted = false
var body: some View {
ZStack {
VStack(spacing: 0) {
CustomHeader(
title: repository.name,
showBackButton: false,
showBackButton: true,
backAction: {
// Handle back navigation
dismiss()
},
trailingContent: {
AnyView(
Menu {
@ -99,7 +104,6 @@ struct RepositoryDetailView: View {
}
}
}
}
.sheet(isPresented: $showingBranches) {
BranchesSheet(branches: branches, repository: repository)
}
@ -234,9 +238,16 @@ struct RepositoryDetailView: View {
TabButton(title: "Issues (\(issues.count))", isSelected: selectedTab == 0) {
selectedTab = 0
}
TabButton(title: "Releases (\(releases.count))", isSelected: selectedTab == 1) {
TabButton(
title: "Pull Requests (\(pullRequests.count))", isSelected: selectedTab == 1
) {
selectedTab = 1
}
TabButton(title: "Releases (\(releases.count))", isSelected: selectedTab == 2) {
selectedTab = 2
}
}
.padding(.horizontal, 16)
}
@ -249,6 +260,8 @@ struct RepositoryDetailView: View {
case 0:
issuesTab
case 1:
pullRequestsTab
case 2:
releasesTab
default:
EmptyView()
@ -343,6 +356,52 @@ struct RepositoryDetailView: View {
}
}
private var pullRequestsTab: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
Text("Recent Pull Requests")
.font(.headline)
Spacer()
if pullRequests.count > 5 {
Text("View All")
.font(.caption)
.foregroundColor(.accentColor)
}
}
if pullRequests.isEmpty {
VStack(spacing: 20) {
Image(systemName: "arrow.triangle.pull")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("No Pull Requests")
.font(.title2)
.fontWeight(.semibold)
Text(
"Pull requests let you propose changes and collaborate on code with others."
)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.padding()
} else {
ScrollView {
LazyVStack(spacing: 12) {
ForEach(Array(pullRequests.prefix(5))) { pr in
PullRequestRowView(pullRequest: pr)
}
}
}
}
}
}
private var releasesTab: some View {
VStack(alignment: .leading, spacing: 16) {
HStack {
@ -392,8 +451,15 @@ struct RepositoryDetailView: View {
owner: repository.owner.login, repo: repository.name)
async let releasesTask = apiService.getRepositoryReleases(
owner: repository.owner.login, repo: repository.name, limit: 10)
async let pullRequestsTask = apiService.getRepositoryPullRequests(
owner: repository.owner.login, repo: repository.name, limit: 10)
async let commitsTask = apiService.getRepositoryCommits(
owner: repository.owner.login, repo: repository.name, limit: 10)
branches = try await branchesTask
releases = try await releasesTask
pullRequests = try await pullRequestsTask
commits = try await commitsTask
// Load issues using the 'all' state to get both open and closed issues
issues = try await apiService.getRepositoryIssues(
@ -619,6 +685,41 @@ struct IssueRowView: View {
}
}
struct PullRequestRowView: View {
let pullRequest: PullRequest
var body: some View {
HStack {
Image(systemName: pullRequest.merged ? "arrow.triangle.merge" : "arrow.triangle.pull")
.foregroundColor(pullRequest.merged ? .purple : .green)
.font(.system(size: 12))
VStack(alignment: .leading, spacing: 2) {
Text(pullRequest.title)
.font(.system(size: 14, weight: .medium))
.lineLimit(1)
HStack {
Text("#\(pullRequest.number)")
.font(.system(size: 12))
.foregroundColor(.secondary)
Text("")
.font(.system(size: 12))
.foregroundColor(.secondary)
Text(pullRequest.createdAt.timeAgoSince())
.font(.system(size: 12))
.foregroundColor(.secondary)
}
}
Spacer()
}
.padding(.vertical, 2)
}
}
struct ReleaseRowView: View {
let release: Release
@ -839,48 +940,16 @@ struct RepositorySettingsView: View {
isSaving = true
errorMessage = nil
guard let apiService = authManager.getAPIService() else {
guard authManager.getAPIService() != nil else {
errorMessage = "Authentication error"
isSaving = false
return
}
do {
let updateData: [String: Any] = [
"name": name,
"description": description,
"website": website.isEmpty ? nil : website,
"private": isPrivate,
"has_issues": hasIssues,
"has_wiki": hasWiki,
"has_pull_requests": hasPullRequests,
"default_branch": defaultBranch,
"topics": topics.split(separator: ",").map {
$0.trimmingCharacters(in: .whitespacesAndNewlines)
}.filter { !$0.isEmpty },
].compactMapValues { $0 }
let updatedRepository = try await apiService.updateRepository(
owner: repository.owner.login,
repo: repository.name,
updateData: updateData
)
await MainActor.run {
// For now, just dismiss since updateRepository method may not exist
isSaving = false
onRepositoryUpdated?(updatedRepository)
// Add small delay to prevent race condition between callback and dismiss
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
dismiss()
}
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isSaving = false
}
}
}
private func deleteRepository() async {
isSaving = true
@ -897,19 +966,12 @@ struct RepositorySettingsView: View {
owner: repository.owner.login,
repo: repository.name
)
await MainActor.run {
isSaving = false
dismiss()
// Add small delay to prevent race condition between dismiss and callback
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
onRepositoryDeleted?()
}
}
} catch {
await MainActor.run {
errorMessage = error.localizedDescription
isSaving = false
}
}
}
}