Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions macgit/App/PathComparisonWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ final class PathComparisonWindowController: NSWindowController, NSWindowDelegate
fatalError("init(coder:) has not been implemented")
}

func show(path: ComparisonPath, in repositoryURL: URL) {
func show(path: ComparisonPath, in repositoryURL: URL, target: ComparisonEndpoint = .workingTree) {
close()
let comparison = ReferenceComparisonController(
repositoryURL: repositoryURL, baseRef: "HEAD", targetRef: "",
isBranchComparison: false, title: "Compare with Revision", path: path,
pathTarget: .workingTree)
pathTarget: target)
self.comparison = comparison
let screen = NSApp.keyWindow?.screen ?? NSScreen.main
let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 800)
Expand Down
7 changes: 4 additions & 3 deletions macgit/App/ProtectedBranchCommitController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ final class ProtectedBranchCommitController: ObservableObject {
let id = UUID()
let branch: String
let remoteBranch: String
let status: BranchProtectionService.Status
}

enum Decision {
Expand Down Expand Up @@ -57,10 +56,12 @@ final class ProtectedBranchCommitController: ObservableObject {
syncState.showInfo("The current branch changed. Review your changes and commit again.")
return false
}
guard status == .protected || status == .unavailable else { return true }
// Only confirmed protection warrants a warning; a missing remote branch
// or an unavailable provider check must not interrupt a local commit.
guard status == .protected else { return true }
let decision = await withCheckedContinuation { continuation in
self.continuation = continuation
warning = Warning(branch: branch, remoteBranch: "\(target.remote)/\(target.branch)", status: status)
warning = Warning(branch: branch, remoteBranch: "\(target.remote)/\(target.branch)")
}
guard case .cancel = decision else {
guard await git.currentBranch(in: repositoryURL) == branch,
Expand Down
138 changes: 132 additions & 6 deletions macgit/App/RepositoryBookmarkController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ enum RepositoryBookmarkError: LocalizedError {
case noRemote
case unsupportedRemote
case folderDoesNotMatch
case bookmarkChanged
case remoteChanged

var errorDescription: String? {
switch self {
Expand All @@ -31,7 +33,11 @@ enum RepositoryBookmarkError: LocalizedError {
case .unsupportedRemote:
"The repository remote URL could not be recognized."
case .folderDoesNotMatch:
"The selected folder belongs to a different repository."
"None of this folder's remotes match the bookmark. If the repository was renamed or moved, update the bookmark from this folder."
case .bookmarkChanged:
"This bookmark changed or was removed. Close this window and try again."
case .remoteChanged:
"The selected remote changed. Choose the folder again to review its current URL."
}
}
}
Expand All @@ -42,10 +48,13 @@ final class RepositoryBookmarkController: ObservableObject {
@Published private(set) var localPaths: [String: String] = [:]
@Published private(set) var syncingBookmarkIDs: Set<String> = []
@Published private(set) var errorMessage: String?
@Published private(set) var mismatchedBookmarkIDs: Set<String> = []
@Published private(set) var hasPendingChanges = false
@Published private(set) var isRetryingSync = false

private let cloudStore: RepositoryBookmarkCloudStore?
private let dataStore: LocalDataStore
private var activeUID: String?
@Published private var activeUID: String?
private var observation: ObservationToken?

init(cloudStore: RepositoryBookmarkCloudStore?, dataStore: LocalDataStore? = nil) {
Expand All @@ -60,6 +69,20 @@ final class RepositoryBookmarkController: ObservableObject {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
localPaths = try dataStore.values(String.self, in: "bookmarkPaths")
mismatchedBookmarkIDs.formIntersection(Set(bookmarks.map(\.id)))
let uploads = try dataStore.values(String.self, in: "bookmarkUploads")
let deletes = try dataStore.values(String.self, in: "bookmarkDeletes")
hasPendingChanges = !uploads.isEmpty || !deletes.isEmpty
}

var canSyncPendingChanges: Bool { activeUID != nil && cloudStore != nil }

func retryPendingChanges() async {
guard hasPendingChanges, !isRetryingSync, let uid = activeUID, let cloudStore else { return }
isRetryingSync = true
defer { isRetryingSync = false }
await flushPendingChanges(uid: uid, cloudStore: cloudStore)
do { try load() } catch { errorMessage = error.localizedDescription }
}

func updateAccount(_ account: AccountSnapshot?) async {
Expand Down Expand Up @@ -102,6 +125,63 @@ final class RepositoryBookmarkController: ObservableObject {

func bookmarkID(linkedTo url: URL) -> String? { localPaths.first { $0.value == url.path }?.key }

func bookmarksNeedingAttention(at url: URL) -> [RepositoryBookmark] {
bookmarks.filter { localPaths[$0.id] == url.path && mismatchedBookmarkIDs.contains($0.id) }
}

/// Replace URL-derived identities in one local transaction, retaining upload and
/// deletion markers so an offline repair survives a restart and stale cloud data.
func updateBookmark(
_ bookmark: RepositoryBookmark,
from repositoryURL: URL,
remote: RepositoryBookmarkRemote
) async throws -> RepositoryBookmark {
let currentRemotes = try await GitStatusService.shared.repositoryBookmarkRemotes(in: repositoryURL)
guard currentRemotes.contains(remote) else { throw RepositoryBookmarkError.remoteChanged }
let result = try await dataStore.transaction { transaction in
guard let current = try transaction.value(RepositoryBookmark.self, in: "bookmarks", id: bookmark.id),
current.canonicalKey == bookmark.canonicalKey else { throw RepositoryBookmarkError.bookmarkChanged }
let identity = remote.identity
let existing = try transaction.values(RepositoryBookmark.self, in: "bookmarks").values.first {
$0.id != current.id && $0.canonicalKey == identity.canonicalKey
}
let updated = RepositoryBookmark(
id: existing?.id ?? identity.documentID,
canonicalKey: identity.canonicalKey,
name: identity.repositoryName,
provider: identity.provider,
host: identity.host,
ownerPath: identity.ownerPath,
remoteURL: identity.canonicalRemoteURL,
createdAt: existing?.createdAt ?? current.createdAt,
updatedAt: Date()
)
if updated.id != current.id {
transaction.remove(in: "bookmarks", id: current.id)
transaction.remove(in: "bookmarkPaths", id: current.id)
transaction.remove(in: "bookmarkUploads", id: current.id)
try transaction.set(UUID().uuidString, in: "bookmarkDeletes", id: current.id)
}
try transaction.set(updated, in: "bookmarks", id: updated.id)
try transaction.set(repositoryURL.path, in: "bookmarkPaths", id: updated.id)
transaction.remove(in: "bookmarkDeletes", id: updated.id)
try transaction.set(UUID().uuidString, in: "bookmarkUploads", id: updated.id)
return updated
}
mismatchedBookmarkIDs.remove(bookmark.id)
mismatchedBookmarkIDs.remove(result.id)
try load()
if let uid = activeUID, let cloudStore {
await upload(result, uid: uid, cloudStore: cloudStore)
// Keep the cloud's old bookmark until its replacement has been saved.
if result.id != bookmark.id,
try dataStore.value(String.self, in: "bookmarkUploads", id: result.id) == nil {
await deleteFromCloud(bookmark.id, uid: uid, cloudStore: cloudStore)
}
}
return result
}

func addBookmark(for repositoryURL: URL) async throws -> RepositoryBookmark {
let remoteURLString = try await bookmarkRemoteURL(in: repositoryURL)
guard let identity = RepositoryBookmarkIdentity.resolve(remoteURLString: remoteURLString) else {
Expand Down Expand Up @@ -144,12 +224,49 @@ final class RepositoryBookmarkController: ObservableObject {
}

func validateAndLink(_ bookmark: RepositoryBookmark, to repositoryURL: URL) async throws {
let remoteURLString = try await bookmarkRemoteURL(in: repositoryURL)
guard let identity = RepositoryBookmarkIdentity.resolve(remoteURLString: remoteURLString),
identity.canonicalKey == bookmark.canonicalKey else { throw RepositoryBookmarkError.folderDoesNotMatch }
let remoteURLs = await GitStatusService.shared.remoteURLs(in: repositoryURL)
guard !remoteURLs.isEmpty else { throw RepositoryBookmarkError.noRemote }
guard remoteURLs.contains(where: {
RepositoryBookmarkIdentity.resolve(remoteURLString: $0)?.canonicalKey == bookmark.canonicalKey
}) else { throw RepositoryBookmarkError.folderDoesNotMatch }
try await link(bookmark, to: repositoryURL)
}

func linkMatchingBookmarks(to repositoryURLs: [URL]) async {
var visited: Set<URL> = []
for repositoryURL in repositoryURLs where visited.insert(repositoryURL).inserted {
guard !Task.isCancelled else { return }
guard FileManager.default.fileExists(atPath: repositoryURL.appendingPathComponent(".git").path) else {
continue
}
// A Git read failure is not evidence of a renamed repository.
guard let remotes = try? await GitStatusService.shared.repositoryBookmarkRemotes(in: repositoryURL) else { continue }
let keys = Set(remotes.map(\.identity.canonicalKey))
guard !Task.isCancelled else { return }
for bookmark in bookmarks where localPaths[bookmark.id] == repositoryURL.path {
if keys.contains(bookmark.canonicalKey) {
mismatchedBookmarkIDs.remove(bookmark.id)
} else {
mismatchedBookmarkIDs.insert(bookmark.id)
}
}
let matches = bookmarks.filter { localPaths[$0.id] == nil && keys.contains($0.canonicalKey) }
guard !matches.isEmpty else { continue }
do {
try await dataStore.transaction { transaction in
for bookmark in matches {
// Recheck persisted state: a cloud update or manual link may have won the race.
guard let current = try transaction.value(RepositoryBookmark.self, in: "bookmarks", id: bookmark.id),
keys.contains(current.canonicalKey),
try transaction.value(String.self, in: "bookmarkPaths", id: bookmark.id) == nil else { continue }
try transaction.set(repositoryURL.path, in: "bookmarkPaths", id: bookmark.id)
}
}
try load()
} catch { errorMessage = error.localizedDescription }
}
}

func unlinkLocalFolder(for bookmark: RepositoryBookmark) async {
do {
try await dataStore.transaction { $0.remove(in: "bookmarkPaths", id: bookmark.id) }
Expand All @@ -175,6 +292,7 @@ final class RepositoryBookmarkController: ObservableObject {
try transaction.value(String.self, in: collection, id: id) == version else { return }
transaction.remove(in: collection, id: id)
}
try load()
}

private func upload(_ bookmark: RepositoryBookmark, uid: String, cloudStore: RepositoryBookmarkCloudStore) async {
Expand All @@ -201,10 +319,18 @@ final class RepositoryBookmarkController: ObservableObject {

private func flushPendingChanges(uid: String, cloudStore: RepositoryBookmarkCloudStore) async {
do {
for bookmark in bookmarks {
guard activeUID == uid else { return }
if syncingBookmarkIDs.contains(bookmark.id) { continue }
await upload(bookmark, uid: uid, cloudStore: cloudStore)
}
// A replacement must reach the cloud before deleting its old identity.
guard try dataStore.values(String.self, in: "bookmarkUploads").isEmpty else { return }
for id in try dataStore.values(String.self, in: "bookmarkDeletes").keys {
guard activeUID == uid else { return }
if syncingBookmarkIDs.contains(id) { continue }
await deleteFromCloud(id, uid: uid, cloudStore: cloudStore)
}
for bookmark in bookmarks { await upload(bookmark, uid: uid, cloudStore: cloudStore) }
} catch { errorMessage = error.localizedDescription }
}

Expand Down
147 changes: 147 additions & 0 deletions macgit/App/RevisionBrowserController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import Foundation
import Observation

@MainActor @Observable
final class RevisionBrowserController {
let repositoryURL: URL
let revision: String
private(set) var snapshot: RevisionBrowserSnapshot?
private(set) var children: [String: [RevisionTreeEntry]] = [:]
private(set) var expanded: Set<String> = []
private(set) var loadingFolders: Set<String> = []
private(set) var folderErrors: [String: String] = [:]
private(set) var selectedEntry: RevisionTreeEntry?
private(set) var preview: RevisionFilePreview?
private(set) var previewError: String?
private(set) var isLoadingPreview = false
private(set) var error: String?
private(set) var isLoading = false
@ObservationIgnored private let service: any RevisionBrowserServing
@ObservationIgnored private var generation = UUID()
@ObservationIgnored private var previewID = UUID()
@ObservationIgnored private var folderTasks: [String: Task<Void, Never>] = [:]
@ObservationIgnored private(set) var loadTask: Task<Void, Never>?
@ObservationIgnored private(set) var previewTask: Task<Void, Never>?

init(repositoryURL: URL, revision: String, service: any RevisionBrowserServing = GitStatusService.shared) {
self.repositoryURL = repositoryURL
self.revision = revision
self.service = service
}

var visibleEntries: [RevisionTreeEntry] {
var result: [RevisionTreeEntry] = []
func append(_ path: String) {
for entry in children[path] ?? [] {
result.append(entry)
if expanded.contains(entry.path) { append(entry.path) }
}
}
append("")
return result
}

func load() {
cancel()
children = [:]
expanded = []
folderErrors = [:]
snapshot = nil
selectedEntry = nil
preview = nil
previewError = nil
error = nil
isLoading = true
let id = generation
loadTask = Task { [weak self] in
guard let self else { return }
do {
let resolved = try await service.browserSnapshot(revision: revision, in: repositoryURL)
let entries = try await service.browserEntries(treeID: resolved.commitID, parentPath: "", in: repositoryURL)
guard generation == id, !Task.isCancelled else { return }
snapshot = resolved
children[""] = entries
isLoading = false
} catch {
guard generation == id, !Task.isCancelled else { return }
self.error = error.localizedDescription
isLoading = false
}
}
}

func toggle(_ entry: RevisionTreeEntry) {
guard entry.isDirectory else { return }
if expanded.contains(entry.path) {
expanded.remove(entry.path)
return
}
expanded.insert(entry.path)
guard children[entry.path] == nil, folderTasks[entry.path] == nil else { return }
folderErrors[entry.path] = nil
loadingFolders.insert(entry.path)
let id = generation
folderTasks[entry.path] = Task { [weak self] in
guard let self else { return }
defer {
if generation == id {
folderTasks[entry.path] = nil
loadingFolders.remove(entry.path)
}
}
do {
let entries = try await service.browserEntries(treeID: entry.objectID, parentPath: entry.path, in: repositoryURL)
guard generation == id, !Task.isCancelled else { return }
// Bound the complete window's tree cache, not just individual folders.
guard children.values.reduce(0, { $0 + $1.count }) + entries.count <= 50_000 else {
throw GitError.commandFailed("Tree limit reached (50,000 entries). Reopen the browser to browse other folders.")
}
children[entry.path] = entries
} catch {
guard generation == id, !Task.isCancelled else { return }
folderErrors[entry.path] = error.localizedDescription
}
}
}

func select(_ entry: RevisionTreeEntry) {
previewTask?.cancel()
previewID = UUID()
selectedEntry = entry
preview = nil
previewError = nil
isLoadingPreview = false
guard !entry.isDirectory else { return }
let id = previewID
let generation = generation
isLoadingPreview = true
previewTask = Task { [weak self] in
guard let self else { return }
do {
let loaded = try await service.browserPreview(entry: entry, in: repositoryURL)
guard self.generation == generation, previewID == id, !Task.isCancelled else { return }
preview = loaded
isLoadingPreview = false
} catch {
guard self.generation == generation, previewID == id, !Task.isCancelled else { return }
previewError = error.localizedDescription
isLoadingPreview = false
}
}
}

func folderTask(for path: String) -> Task<Void, Never>? { folderTasks[path] }

func cancel() {
generation = UUID()
previewID = UUID()
loadTask?.cancel()
previewTask?.cancel()
for task in folderTasks.values { task.cancel() }
folderTasks = [:]
loadingFolders = []
isLoading = false
isLoadingPreview = false
}
}
Loading
Loading