Update README.md #2
@ -16,3 +16,5 @@ A native SwiftUI application for interacting with any Gitea or Forgejo instance.
|
||||
## Upcoming Features
|
||||
|
||||
TBA
|
||||
|
||||
TESTING
|
@ -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
|
||||
}
|
||||
currentUser = user
|
||||
} catch {
|
||||
print("DEBUG: refreshUser failed with error: \(error)")
|
||||
// Handle cancellation and other errors gracefully
|
||||
@ -151,9 +149,7 @@ class AuthenticationManager: ObservableObject {
|
||||
// Don't show error message for cancellation
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
@ -199,21 +195,15 @@ class AuthenticationManager: ObservableObject {
|
||||
|
||||
do {
|
||||
let updatedUser = try await apiService.updateUserSettings(settings: settings)
|
||||
await MainActor.run {
|
||||
currentUser = updatedUser
|
||||
}
|
||||
currentUser = updatedUser
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func updateCurrentUser(_ user: User) {
|
||||
Task { @MainActor in
|
||||
currentUser = user
|
||||
}
|
||||
currentUser = user
|
||||
}
|
||||
|
||||
func getAPIService() -> GiteaAPIService? {
|
||||
|
@ -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)
|
||||
guard
|
||||
let request = createRequest(
|
||||
endpoint: "/user/settings", method: .PATCH, body: settingsData)
|
||||
else {
|
||||
print("DEBUG: Failed to create request for /user/settings")
|
||||
throw APIError.invalidRequest
|
||||
}
|
||||
// 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),
|
||||
]
|
||||
|
||||
print("DEBUG: Request headers: \(request.allHTTPHeaderFields ?? [:])")
|
||||
var lastError: Error?
|
||||
|
||||
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")
|
||||
for (endpoint, method) in endpoints {
|
||||
print("DEBUG: Trying \(method.rawValue) request to: \(baseURL)/api/v1\(endpoint)")
|
||||
|
||||
// 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)
|
||||
let request = createRequest(endpoint: endpoint, method: method, body: settingsData)
|
||||
else {
|
||||
print("DEBUG: Failed to create request for /user")
|
||||
throw error
|
||||
print("DEBUG: Failed to create request for \(endpoint)")
|
||||
continue
|
||||
}
|
||||
|
||||
print("DEBUG: Request headers: \(request.allHTTPHeaderFields ?? [:])")
|
||||
|
||||
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 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
|
||||
}
|
||||
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: PATCH to /user also failed: \(error)")
|
||||
throw error
|
||||
print("DEBUG: \(method.rawValue) to \(endpoint) failed: \(error)")
|
||||
lastError = error
|
||||
continue
|
||||
}
|
||||
} catch {
|
||||
print("DEBUG: updateUserSettings failed with unexpected error: \(error)")
|
||||
throw error
|
||||
}
|
||||
|
||||
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 {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -189,9 +189,12 @@ struct EditProfileView: View {
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
defer {
|
||||
isLoading = false
|
||||
print("DEBUG: saveProfile - isLoading set to false")
|
||||
}
|
||||
|
||||
do {
|
||||
@ -212,22 +215,17 @@ struct EditProfileView: View {
|
||||
)
|
||||
|
||||
print("DEBUG: saveProfile - update successful, dismissing sheet")
|
||||
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
dismiss()
|
||||
}
|
||||
// Success - dismiss the sheet
|
||||
dismiss()
|
||||
} catch {
|
||||
print("DEBUG: saveProfile - error occurred: \(error)")
|
||||
// Set error message but still dismiss for certain errors
|
||||
errorMessage = error.localizedDescription
|
||||
|
||||
await MainActor.run {
|
||||
isLoading = false
|
||||
errorMessage = error.localizedDescription
|
||||
|
||||
// Dismiss after showing error briefly
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
dismiss()
|
||||
}
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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,10 +137,8 @@ struct RepositoriesView: View {
|
||||
isPresented: $showingCreateRepository,
|
||||
onDismiss: {
|
||||
// Refresh repositories when create sheet is dismissed
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
Task {
|
||||
await fetchRepositories()
|
||||
}
|
||||
Task {
|
||||
await fetchRepositories()
|
||||
}
|
||||
}
|
||||
) {
|
||||
@ -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"
|
||||
}
|
||||
errorMessage = "Not authenticated"
|
||||
return
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
isFetching = true
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
}
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
let fetchedRepositories = try await apiService.getUserRepositories()
|
||||
await MainActor.run {
|
||||
repositories = fetchedRepositories
|
||||
applyFiltersAndSort()
|
||||
isLoading = false
|
||||
isFetching = false
|
||||
}
|
||||
repositories = try await apiService.getUserRepositories()
|
||||
applyFiltersAndSort()
|
||||
} 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
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,69 +36,71 @@ struct RepositoryDetailView: View {
|
||||
@State private var repositoryDeleted = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
VStack(spacing: 0) {
|
||||
CustomHeader(
|
||||
title: repository.name,
|
||||
showBackButton: false,
|
||||
trailingContent: {
|
||||
AnyView(
|
||||
Menu {
|
||||
Button(action: { toggleStar() }) {
|
||||
HStack {
|
||||
Image(systemName: isStarred ? "star.fill" : "star")
|
||||
Text(isStarred ? "Unstar" : "Star")
|
||||
}
|
||||
VStack(spacing: 0) {
|
||||
CustomHeader(
|
||||
title: repository.name,
|
||||
showBackButton: true,
|
||||
backAction: {
|
||||
// Handle back navigation
|
||||
dismiss()
|
||||
},
|
||||
trailingContent: {
|
||||
AnyView(
|
||||
Menu {
|
||||
Button(action: { toggleStar() }) {
|
||||
HStack {
|
||||
Image(systemName: isStarred ? "star.fill" : "star")
|
||||
Text(isStarred ? "Unstar" : "Star")
|
||||
}
|
||||
|
||||
Button(action: { toggleWatch() }) {
|
||||
HStack {
|
||||
Image(systemName: isWatching ? "eye.fill" : "eye")
|
||||
Text(isWatching ? "Unwatch" : "Watch")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(action: { showingBranches = true }) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
Text("Branches")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
Button(action: { showingRepositorySettings = true }) {
|
||||
HStack {
|
||||
Image(systemName: "gearshape")
|
||||
Text("Settings")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 44, height: 44)
|
||||
.modifier(GlassEffectModifier(shape: Circle()))
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
repositoryHeader
|
||||
Button(action: { toggleWatch() }) {
|
||||
HStack {
|
||||
Image(systemName: isWatching ? "eye.fill" : "eye")
|
||||
Text(isWatching ? "Unwatch" : "Watch")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
Divider()
|
||||
|
||||
tabSelector
|
||||
Button(action: { showingBranches = true }) {
|
||||
HStack {
|
||||
Image(systemName: "arrow.triangle.branch")
|
||||
Text("Branches")
|
||||
}
|
||||
}
|
||||
|
||||
Divider()
|
||||
Divider()
|
||||
|
||||
tabContent
|
||||
}
|
||||
Button(action: { showingRepositorySettings = true }) {
|
||||
HStack {
|
||||
Image(systemName: "gearshape")
|
||||
Text("Settings")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: "ellipsis.circle")
|
||||
.font(.system(size: 18, weight: .medium))
|
||||
.foregroundColor(.primary)
|
||||
.frame(width: 44, height: 44)
|
||||
.modifier(GlassEffectModifier(shape: Circle()))
|
||||
}
|
||||
.menuStyle(.borderlessButton)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
repositoryHeader
|
||||
|
||||
Divider()
|
||||
|
||||
tabSelector
|
||||
|
||||
Divider()
|
||||
|
||||
tabContent
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,47 +940,15 @@ 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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
// For now, just dismiss since updateRepository method may not exist
|
||||
isSaving = false
|
||||
dismiss()
|
||||
}
|
||||
|
||||
private func deleteRepository() async {
|
||||
@ -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?()
|
||||
}
|
||||
}
|
||||
isSaving = false
|
||||
dismiss()
|
||||
onRepositoryDeleted?()
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
errorMessage = error.localizedDescription
|
||||
isSaving = false
|
||||
}
|
||||
errorMessage = error.localizedDescription
|
||||
isSaving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user