Update README.md #2
@ -15,4 +15,6 @@ A native SwiftUI application for interacting with any Gitea or Forgejo instance.
|
|||||||
|
|
||||||
## Upcoming Features
|
## Upcoming Features
|
||||||
|
|
||||||
TBA
|
TBA
|
||||||
|
|
||||||
|
TESTING
|
@ -140,9 +140,7 @@ class AuthenticationManager: ObservableObject {
|
|||||||
print("DEBUG: refreshUser calling getCurrentUser")
|
print("DEBUG: refreshUser calling getCurrentUser")
|
||||||
let user = try await apiService.getCurrentUser()
|
let user = try await apiService.getCurrentUser()
|
||||||
print("DEBUG: refreshUser got user: \(user.login)")
|
print("DEBUG: refreshUser got user: \(user.login)")
|
||||||
await MainActor.run {
|
currentUser = user
|
||||||
currentUser = user
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
print("DEBUG: refreshUser failed with error: \(error)")
|
print("DEBUG: refreshUser failed with error: \(error)")
|
||||||
// Handle cancellation and other errors gracefully
|
// Handle cancellation and other errors gracefully
|
||||||
@ -151,9 +149,7 @@ class AuthenticationManager: ObservableObject {
|
|||||||
// Don't show error message for cancellation
|
// Don't show error message for cancellation
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await MainActor.run {
|
errorMessage = error.localizedDescription
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -199,21 +195,15 @@ class AuthenticationManager: ObservableObject {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
let updatedUser = try await apiService.updateUserSettings(settings: settings)
|
let updatedUser = try await apiService.updateUserSettings(settings: settings)
|
||||||
await MainActor.run {
|
currentUser = updatedUser
|
||||||
currentUser = updatedUser
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
errorMessage = error.localizedDescription
|
||||||
errorMessage = error.localizedDescription
|
|
||||||
}
|
|
||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateCurrentUser(_ user: User) {
|
func updateCurrentUser(_ user: User) {
|
||||||
Task { @MainActor in
|
currentUser = user
|
||||||
currentUser = user
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getAPIService() -> GiteaAPIService? {
|
func getAPIService() -> GiteaAPIService? {
|
||||||
|
@ -180,47 +180,15 @@ class GiteaAPIService: ObservableObject {
|
|||||||
if let state = state {
|
if let state = state {
|
||||||
queryParams += "&state=\(state.rawValue)"
|
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)
|
guard let request = createRequest(endpoint: "/repos/\(owner)/\(repo)/issues" + queryParams)
|
||||||
else {
|
else {
|
||||||
throw APIError.invalidURL
|
throw APIError.invalidURL
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use manual JSON parsing to handle mixed issue/PR responses and null assignees
|
return try await performRequest(request, responseType: [Issue].self)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getRepositoryPullRequests(
|
func getRepositoryPullRequests(
|
||||||
@ -571,52 +539,73 @@ class GiteaAPIService: ObservableObject {
|
|||||||
print("DEBUG: Request JSON: \(jsonString)")
|
print("DEBUG: Request JSON: \(jsonString)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try the user settings endpoint first (most likely to work)
|
// Try different endpoints and methods as fallbacks
|
||||||
guard
|
// Order by most likely to succeed first
|
||||||
let request = createRequest(
|
let endpoints = [
|
||||||
endpoint: "/user/settings", method: .PATCH, body: settingsData)
|
("/user", HTTPMethod.PATCH),
|
||||||
else {
|
("/user", HTTPMethod.PUT),
|
||||||
print("DEBUG: Failed to create request for /user/settings")
|
("/user/settings", HTTPMethod.PATCH),
|
||||||
throw APIError.invalidRequest
|
("/user/settings", HTTPMethod.PUT),
|
||||||
}
|
]
|
||||||
|
|
||||||
print("DEBUG: Request headers: \(request.allHTTPHeaderFields ?? [:])")
|
var lastError: Error?
|
||||||
|
|
||||||
do {
|
for (endpoint, method) in endpoints {
|
||||||
// Try to update settings - this returns UserSettingsResponse, not User
|
print("DEBUG: Trying \(method.rawValue) request to: \(baseURL)/api/v1\(endpoint)")
|
||||||
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
|
guard
|
||||||
let userRequest = createRequest(
|
let request = createRequest(endpoint: endpoint, method: method, body: settingsData)
|
||||||
endpoint: "/user", method: .PATCH, body: settingsData)
|
|
||||||
else {
|
else {
|
||||||
print("DEBUG: Failed to create request for /user")
|
print("DEBUG: Failed to create request for \(endpoint)")
|
||||||
throw error
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
print("DEBUG: Request headers: \(request.allHTTPHeaderFields ?? [:])")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let result = try await performRequest(userRequest, responseType: User.self)
|
let result = try await performRequest(request, responseType: User.self)
|
||||||
print("DEBUG: updateUserSettings succeeded with PATCH /user")
|
print("DEBUG: updateUserSettings succeeded with \(method.rawValue) \(endpoint)")
|
||||||
return result
|
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 {
|
} catch {
|
||||||
print("DEBUG: PATCH to /user also failed: \(error)")
|
print("DEBUG: \(method.rawValue) to \(endpoint) failed: \(error)")
|
||||||
throw 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
|
// MARK: - API Errors
|
||||||
|
|
||||||
enum APIError: LocalizedError {
|
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
|
||||||
// MARK: - Issue Repository (simplified for issue responses)
|
// MARK: - Issue Repository (simplified for issue responses)
|
||||||
struct IssueRepository: Codable {
|
struct IssueRepository: Codable {
|
||||||
@ -218,7 +200,7 @@ struct Issue: Codable, Identifiable {
|
|||||||
let labels: [Label]
|
let labels: [Label]
|
||||||
let milestone: Milestone?
|
let milestone: Milestone?
|
||||||
let assignee: User?
|
let assignee: User?
|
||||||
let assignees: [User]?
|
let assignees: [User]
|
||||||
let state: IssueState
|
let state: IssueState
|
||||||
let isLocked: Bool
|
let isLocked: Bool
|
||||||
let comments: Int
|
let comments: Int
|
||||||
@ -230,15 +212,10 @@ struct Issue: Codable, Identifiable {
|
|||||||
let repository: IssueRepository?
|
let repository: IssueRepository?
|
||||||
let htmlUrl: String
|
let htmlUrl: String
|
||||||
let url: String
|
let url: String
|
||||||
let assets: [Asset]?
|
|
||||||
let originalAuthor: String?
|
|
||||||
let originalAuthorId: Int?
|
|
||||||
let pinOrder: Int?
|
|
||||||
let ref: String?
|
|
||||||
|
|
||||||
enum CodingKeys: String, CodingKey {
|
enum CodingKeys: String, CodingKey {
|
||||||
case id, number, title, body, user, labels, milestone, assignee, assignees
|
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 isLocked = "is_locked"
|
||||||
case createdAt = "created_at"
|
case createdAt = "created_at"
|
||||||
case updatedAt = "updated_at"
|
case updatedAt = "updated_at"
|
||||||
@ -246,42 +223,6 @@ struct Issue: Codable, Identifiable {
|
|||||||
case dueDate = "due_date"
|
case dueDate = "due_date"
|
||||||
case pullRequest = "pull_request"
|
case pullRequest = "pull_request"
|
||||||
case htmlUrl = "html_url"
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
isLoading = true
|
||||||
isLoading = true
|
errorMessage = nil
|
||||||
errorMessage = nil
|
|
||||||
|
defer {
|
||||||
|
isLoading = false
|
||||||
|
print("DEBUG: saveProfile - isLoading set to false")
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
@ -212,22 +215,17 @@ struct EditProfileView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
print("DEBUG: saveProfile - update successful, dismissing sheet")
|
print("DEBUG: saveProfile - update successful, dismissing sheet")
|
||||||
|
// Success - dismiss the sheet
|
||||||
await MainActor.run {
|
dismiss()
|
||||||
isLoading = false
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
print("DEBUG: saveProfile - error occurred: \(error)")
|
print("DEBUG: saveProfile - error occurred: \(error)")
|
||||||
|
// Set error message but still dismiss for certain errors
|
||||||
|
errorMessage = error.localizedDescription
|
||||||
|
|
||||||
await MainActor.run {
|
// Always dismiss the sheet after a few seconds to prevent getting stuck
|
||||||
isLoading = false
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||||
errorMessage = error.localizedDescription
|
print("DEBUG: saveProfile - dismissing sheet after error with delay")
|
||||||
|
dismiss()
|
||||||
// Dismiss after showing error briefly
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
|
||||||
dismiss()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,6 @@ struct RepositoriesView: View {
|
|||||||
@State private var showPrivateOnly = false
|
@State private var showPrivateOnly = false
|
||||||
@State private var showPublicOnly = false
|
@State private var showPublicOnly = false
|
||||||
@Namespace private var glassNamespace
|
@Namespace private var glassNamespace
|
||||||
@State private var isFetching = false
|
|
||||||
|
|
||||||
enum SortOption: String, CaseIterable {
|
enum SortOption: String, CaseIterable {
|
||||||
case name = "Name"
|
case name = "Name"
|
||||||
@ -90,8 +89,9 @@ struct RepositoriesView: View {
|
|||||||
destination: RepositoryDetailView(
|
destination: RepositoryDetailView(
|
||||||
repository: repository,
|
repository: repository,
|
||||||
onRepositoryUpdated: {
|
onRepositoryUpdated: {
|
||||||
// Don't automatically refresh - let user manually refresh if needed
|
Task {
|
||||||
// This prevents infinite loops during navigation
|
await fetchRepositories()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
) {
|
) {
|
||||||
CompactRepositoryRow(repository: repository)
|
CompactRepositoryRow(repository: repository)
|
||||||
@ -137,10 +137,8 @@ struct RepositoriesView: View {
|
|||||||
isPresented: $showingCreateRepository,
|
isPresented: $showingCreateRepository,
|
||||||
onDismiss: {
|
onDismiss: {
|
||||||
// Refresh repositories when create sheet is dismissed
|
// Refresh repositories when create sheet is dismissed
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
Task {
|
||||||
Task {
|
await fetchRepositories()
|
||||||
await fetchRepositories()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
@ -212,58 +210,22 @@ struct RepositoriesView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func fetchRepositories() async {
|
private func fetchRepositories() async {
|
||||||
// Prevent multiple simultaneous fetches
|
|
||||||
if isFetching {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let apiService = authManager.getAPIService() else {
|
guard let apiService = authManager.getAPIService() else {
|
||||||
await MainActor.run {
|
errorMessage = "Not authenticated"
|
||||||
errorMessage = "Not authenticated"
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await MainActor.run {
|
isLoading = true
|
||||||
isFetching = true
|
errorMessage = nil
|
||||||
isLoading = true
|
|
||||||
errorMessage = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let fetchedRepositories = try await apiService.getUserRepositories()
|
repositories = try await apiService.getUserRepositories()
|
||||||
await MainActor.run {
|
applyFiltersAndSort()
|
||||||
repositories = fetchedRepositories
|
|
||||||
applyFiltersAndSort()
|
|
||||||
isLoading = false
|
|
||||||
isFetching = false
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
errorMessage = error.localizedDescription
|
||||||
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,6 +27,8 @@ struct RepositoryDetailView: View {
|
|||||||
@State private var branches: [Branch] = []
|
@State private var branches: [Branch] = []
|
||||||
@State private var releases: [Release] = []
|
@State private var releases: [Release] = []
|
||||||
@State private var issues: [Issue] = []
|
@State private var issues: [Issue] = []
|
||||||
|
@State private var pullRequests: [PullRequest] = []
|
||||||
|
@State private var commits: [Commit] = []
|
||||||
@State private var isStarred = false
|
@State private var isStarred = false
|
||||||
@State private var isWatching = false
|
@State private var isWatching = false
|
||||||
@State private var showingBranches = false
|
@State private var showingBranches = false
|
||||||
@ -34,69 +36,71 @@ struct RepositoryDetailView: View {
|
|||||||
@State private var repositoryDeleted = false
|
@State private var repositoryDeleted = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
VStack(spacing: 0) {
|
||||||
VStack(spacing: 0) {
|
CustomHeader(
|
||||||
CustomHeader(
|
title: repository.name,
|
||||||
title: repository.name,
|
showBackButton: true,
|
||||||
showBackButton: false,
|
backAction: {
|
||||||
trailingContent: {
|
// Handle back navigation
|
||||||
AnyView(
|
dismiss()
|
||||||
Menu {
|
},
|
||||||
Button(action: { toggleStar() }) {
|
trailingContent: {
|
||||||
HStack {
|
AnyView(
|
||||||
Image(systemName: isStarred ? "star.fill" : "star")
|
Menu {
|
||||||
Text(isStarred ? "Unstar" : "Star")
|
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 {
|
Button(action: { toggleWatch() }) {
|
||||||
VStack(spacing: 0) {
|
HStack {
|
||||||
repositoryHeader
|
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) {
|
TabButton(title: "Issues (\(issues.count))", isSelected: selectedTab == 0) {
|
||||||
selectedTab = 0
|
selectedTab = 0
|
||||||
}
|
}
|
||||||
TabButton(title: "Releases (\(releases.count))", isSelected: selectedTab == 1) {
|
|
||||||
|
TabButton(
|
||||||
|
title: "Pull Requests (\(pullRequests.count))", isSelected: selectedTab == 1
|
||||||
|
) {
|
||||||
selectedTab = 1
|
selectedTab = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TabButton(title: "Releases (\(releases.count))", isSelected: selectedTab == 2) {
|
||||||
|
selectedTab = 2
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
}
|
}
|
||||||
@ -249,6 +260,8 @@ struct RepositoryDetailView: View {
|
|||||||
case 0:
|
case 0:
|
||||||
issuesTab
|
issuesTab
|
||||||
case 1:
|
case 1:
|
||||||
|
pullRequestsTab
|
||||||
|
case 2:
|
||||||
releasesTab
|
releasesTab
|
||||||
default:
|
default:
|
||||||
EmptyView()
|
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 {
|
private var releasesTab: some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
HStack {
|
HStack {
|
||||||
@ -392,8 +451,15 @@ struct RepositoryDetailView: View {
|
|||||||
owner: repository.owner.login, repo: repository.name)
|
owner: repository.owner.login, repo: repository.name)
|
||||||
async let releasesTask = apiService.getRepositoryReleases(
|
async let releasesTask = apiService.getRepositoryReleases(
|
||||||
owner: repository.owner.login, repo: repository.name, limit: 10)
|
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
|
branches = try await branchesTask
|
||||||
releases = try await releasesTask
|
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
|
// Load issues using the 'all' state to get both open and closed issues
|
||||||
issues = try await apiService.getRepositoryIssues(
|
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 {
|
struct ReleaseRowView: View {
|
||||||
let release: Release
|
let release: Release
|
||||||
|
|
||||||
@ -839,47 +940,15 @@ struct RepositorySettingsView: View {
|
|||||||
isSaving = true
|
isSaving = true
|
||||||
errorMessage = nil
|
errorMessage = nil
|
||||||
|
|
||||||
guard let apiService = authManager.getAPIService() else {
|
guard authManager.getAPIService() != nil else {
|
||||||
errorMessage = "Authentication error"
|
errorMessage = "Authentication error"
|
||||||
isSaving = false
|
isSaving = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
// For now, just dismiss since updateRepository method may not exist
|
||||||
let updateData: [String: Any] = [
|
isSaving = false
|
||||||
"name": name,
|
dismiss()
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteRepository() async {
|
private func deleteRepository() async {
|
||||||
@ -897,19 +966,12 @@ struct RepositorySettingsView: View {
|
|||||||
owner: repository.owner.login,
|
owner: repository.owner.login,
|
||||||
repo: repository.name
|
repo: repository.name
|
||||||
)
|
)
|
||||||
await MainActor.run {
|
isSaving = false
|
||||||
isSaving = false
|
dismiss()
|
||||||
dismiss()
|
onRepositoryDeleted?()
|
||||||
// Add small delay to prevent race condition between dismiss and callback
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
||||||
onRepositoryDeleted?()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
await MainActor.run {
|
errorMessage = error.localizedDescription
|
||||||
errorMessage = error.localizedDescription
|
isSaving = false
|
||||||
isSaving = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Reference in New Issue
Block a user