diff --git a/macgit/App/PathComparisonWindowController.swift b/macgit/App/PathComparisonWindowController.swift index 8695819..e6455de 100644 --- a/macgit/App/PathComparisonWindowController.swift +++ b/macgit/App/PathComparisonWindowController.swift @@ -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) diff --git a/macgit/App/ProtectedBranchCommitController.swift b/macgit/App/ProtectedBranchCommitController.swift index b729593..7af3580 100644 --- a/macgit/App/ProtectedBranchCommitController.swift +++ b/macgit/App/ProtectedBranchCommitController.swift @@ -8,7 +8,6 @@ final class ProtectedBranchCommitController: ObservableObject { let id = UUID() let branch: String let remoteBranch: String - let status: BranchProtectionService.Status } enum Decision { @@ -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, diff --git a/macgit/App/RepositoryBookmarkController.swift b/macgit/App/RepositoryBookmarkController.swift index da16e03..d690bfa 100644 --- a/macgit/App/RepositoryBookmarkController.swift +++ b/macgit/App/RepositoryBookmarkController.swift @@ -23,6 +23,8 @@ enum RepositoryBookmarkError: LocalizedError { case noRemote case unsupportedRemote case folderDoesNotMatch + case bookmarkChanged + case remoteChanged var errorDescription: String? { switch self { @@ -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." } } } @@ -42,10 +48,13 @@ final class RepositoryBookmarkController: ObservableObject { @Published private(set) var localPaths: [String: String] = [:] @Published private(set) var syncingBookmarkIDs: Set = [] @Published private(set) var errorMessage: String? + @Published private(set) var mismatchedBookmarkIDs: Set = [] + @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) { @@ -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 { @@ -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 { @@ -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 = [] + 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) } @@ -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 { @@ -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 } } diff --git a/macgit/App/RevisionBrowserController.swift b/macgit/App/RevisionBrowserController.swift new file mode 100644 index 0000000..1ea5787 --- /dev/null +++ b/macgit/App/RevisionBrowserController.swift @@ -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 = [] + private(set) var loadingFolders: Set = [] + 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] = [:] + @ObservationIgnored private(set) var loadTask: Task? + @ObservationIgnored private(set) var previewTask: Task? + + 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? { 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 + } +} diff --git a/macgit/App/RevisionBrowserWindowController.swift b/macgit/App/RevisionBrowserWindowController.swift new file mode 100644 index 0000000..4c8073b --- /dev/null +++ b/macgit/App/RevisionBrowserWindowController.swift @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import AppKit +import SwiftUI + +@MainActor +final class RevisionBrowserWindowController: NSWindowController, NSWindowDelegate { + private var browser: RevisionBrowserController? + private let comparisonWindow = PathComparisonWindowController() + + init() { + super.init(window: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func show(revision: String, in repositoryURL: URL) { + close() + let browser = RevisionBrowserController(repositoryURL: repositoryURL, revision: revision) + self.browser = browser + let screen = NSApp.keyWindow?.screen ?? NSScreen.main + let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1200, height: 800) + let size = NSSize(width: min(1100, visibleFrame.width - 40), + height: min(760, visibleFrame.height - 80)) + let window = NSWindow(contentRect: NSRect(origin: .zero, size: size), + styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false) + window.title = "Repository at \(revision.prefix(8)) — \(repositoryURL.lastPathComponent)" + window.isReleasedWhenClosed = false + window.tabbingMode = .disallowed + window.contentMinSize = NSSize(width: min(760, size.width), height: min(420, size.height)) + window.delegate = self + let hostingView = NSHostingView(rootView: GeometryReader { geometry in + RevisionBrowserView(controller: browser, onCompare: { [weak self] path, sha in + self?.comparisonWindow.show(path: path, in: repositoryURL, target: .revision(sha)) + }) + .frame(width: geometry.size.width, height: geometry.size.height) + }) + // Window geometry owns the viewport; file content must never expand the window. + hostingView.sizingOptions = [] + window.contentView = hostingView + window.setContentSize(size) + window.setFrameOrigin(NSPoint(x: visibleFrame.midX - window.frame.width / 2, + y: visibleFrame.midY - window.frame.height / 2)) + self.window = window + showWindow(nil) + window.makeKeyAndOrderFront(nil) + } + + func windowWillClose(_ notification: Notification) { + browser?.cancel() + browser = nil + } +} diff --git a/macgit/Models/RepositoryBookmarkRemote.swift b/macgit/Models/RepositoryBookmarkRemote.swift new file mode 100644 index 0000000..7246623 --- /dev/null +++ b/macgit/Models/RepositoryBookmarkRemote.swift @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +struct RepositoryBookmarkRemote: Identifiable, Equatable { + let name: String + let identity: RepositoryBookmarkIdentity + + var id: String { name } +} diff --git a/macgit/Models/RevisionBrowserSnapshot.swift b/macgit/Models/RevisionBrowserSnapshot.swift new file mode 100644 index 0000000..4e3de9f --- /dev/null +++ b/macgit/Models/RevisionBrowserSnapshot.swift @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct RevisionBrowserSnapshot: Sendable { + let commitID: String + let subject: String + let changedNodePaths: Set + + init(commitID: String, subject: String, changedPaths: [String] = []) { + self.commitID = commitID + self.subject = subject + var nodes: Set = [] + for path in changedPaths { + var components = path.split(separator: "/").map(String.init) + while !components.isEmpty { + nodes.insert(components.joined(separator: "/")) + components.removeLast() + } + } + self.changedNodePaths = nodes + } +} diff --git a/macgit/Models/RevisionFilePreview.swift b/macgit/Models/RevisionFilePreview.swift new file mode 100644 index 0000000..81f8999 --- /dev/null +++ b/macgit/Models/RevisionFilePreview.swift @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct RevisionFilePreview: Sendable { + let text: String? + let lines: [DiffLine] + let message: String? + + static func notice(_ message: String) -> Self { + Self(text: nil, lines: [], message: message) + } +} diff --git a/macgit/Models/RevisionTreeEntry.swift b/macgit/Models/RevisionTreeEntry.swift new file mode 100644 index 0000000..16bb0e4 --- /dev/null +++ b/macgit/Models/RevisionTreeEntry.swift @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +nonisolated struct RevisionTreeEntry: Identifiable, Equatable, Sendable { + let path: String + let objectID: String + let mode: String + let objectType: String + let size: Int? + var id: String { path } + var name: String { String(path.split(separator: "/").last ?? "") } + var isDirectory: Bool { objectType == "tree" } + var isSymlink: Bool { mode == "120000" } + var isSubmodule: Bool { mode == "160000" } +} diff --git a/macgit/Services/GitStatusService+Remote.swift b/macgit/Services/GitStatusService+Remote.swift index a20fc4d..8e16609 100644 --- a/macgit/Services/GitStatusService+Remote.swift +++ b/macgit/Services/GitStatusService+Remote.swift @@ -396,6 +396,16 @@ extension GitStatusService { return url } + func remoteURLs(in repositoryURL: URL) async -> [String] { + let names = await remotes(in: repositoryURL) + var urls: [String] = [] + for name in names { + let url = await remoteURL(remote: name, in: repositoryURL) + if !url.isEmpty { urls.append(url) } + } + return urls + } + func defaultBranch(in repositoryURL: URL, remote: String) async -> String? { let output = try? await runGit( arguments: ["symbolic-ref", "--quiet", "--short", "refs/remotes/\(remote)/HEAD"], diff --git a/macgit/Services/GitStatusService+RepositoryBookmark.swift b/macgit/Services/GitStatusService+RepositoryBookmark.swift new file mode 100644 index 0000000..6b69fd4 --- /dev/null +++ b/macgit/Services/GitStatusService+RepositoryBookmark.swift @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +extension GitStatusService { + func repositoryBookmarkRemotes(in repositoryURL: URL) async throws -> [RepositoryBookmarkRemote] { + let output = try await runGit(arguments: ["remote"], in: repositoryURL) + var result: [RepositoryBookmarkRemote] = [] + for name in output.split(separator: "\n").map(String.init) { + let url = try await runGit(arguments: ["remote", "get-url", name], in: repositoryURL) + if let identity = RepositoryBookmarkIdentity.resolve(remoteURLString: url) { + result.append(RepositoryBookmarkRemote(name: name, identity: identity)) + } + } + return result.sorted { + if ($0.name == "origin") != ($1.name == "origin") { return $0.name == "origin" } + return $0.name < $1.name + } + } +} diff --git a/macgit/Services/GitStatusService+RevisionBrowser.swift b/macgit/Services/GitStatusService+RevisionBrowser.swift new file mode 100644 index 0000000..d5b69bd --- /dev/null +++ b/macgit/Services/GitStatusService+RevisionBrowser.swift @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +extension GitStatusService: RevisionBrowserServing { + nonisolated static let revisionPreviewByteLimit = 2_000_000 + + func browserSnapshot(revision: String, in repositoryURL: URL) async throws -> RevisionBrowserSnapshot { + let sha = try await resolveComparisonRef(revision, branchesOnly: false, in: repositoryURL) + let subject = try await runGit(arguments: ["show", "-s", "--format=%s", "--no-notes", sha, "--"], in: repositoryURL) + let changesData = try await runGitRaw(arguments: [ + "show", "--name-status", "-z", "--first-parent", "--root", "--format=", + "--find-renames", "--no-ext-diff", "--no-textconv", sha, "--" + ], in: repositoryURL, environment: ProcessInfo.processInfo.environment, outputByteLimit: 8_000_000) + let changes = try Self.parseComparisonFiles(changesData) + try Task.checkCancellation() + // Include the old path so surviving ancestors of deletions/renames are marked too. + return RevisionBrowserSnapshot(commitID: sha, subject: subject.trimmingCharacters(in: .newlines), + changedPaths: changes.flatMap { [$0.path, $0.oldPath].compactMap { $0 } }) + } + + func browserEntries(treeID: String, parentPath: String, in repositoryURL: URL) async throws -> [RevisionTreeEntry] { + try Self.validateBrowserObjectID(treeID) + let data = try await runGitRaw(arguments: ["ls-tree", "-z", "-l", "--full-tree", treeID], + in: repositoryURL, environment: ProcessInfo.processInfo.environment, outputByteLimit: 8_000_000) + return try Self.parseBrowserEntries(data, parentPath: parentPath) + } + + nonisolated static func validateBrowserObjectID(_ id: String) throws { + guard [40, 64].contains(id.count), id.utf8.allSatisfy({ (48...57).contains($0) || (97...102).contains($0) }) else { + throw GitError.commandFailed("Invalid Git object ID.") + } + } + + nonisolated static func parseBrowserEntries(_ data: Data, parentPath: String) throws -> [RevisionTreeEntry] { + guard data.isEmpty || data.last == 0 else { throw GitError.commandFailed("Incomplete repository tree.") } + var entries: [RevisionTreeEntry] = [] + for record in data.split(separator: 0) { + try Task.checkCancellation() + guard entries.count < 20_000, let tab = record.firstIndex(of: 9), + let name = String(data: record[record.index(after: tab)...], encoding: .utf8), + !name.isEmpty, !name.contains("/"), name != ".", name != ".." else { + throw GitError.commandFailed("This folder is too large or contains an unsupported filename.") + } + let fields = String(decoding: record[..= 0) else { + throw GitError.commandFailed("Invalid repository object metadata.") + } + entries.append(RevisionTreeEntry(path: parentPath.isEmpty ? name : parentPath + "/" + name, + objectID: oid, mode: String(fields[0]), objectType: String(fields[1]), size: size)) + } + return entries.sorted { + if $0.isDirectory != $1.isDirectory { return $0.isDirectory } + return $0.path < $1.path + } + } + + func browserPreview(entry: RevisionTreeEntry, in repositoryURL: URL) async throws -> RevisionFilePreview { + try Self.validateBrowserObjectID(entry.objectID) + if entry.isSubmodule { return .notice("Submodule commit: \(entry.objectID)") } + guard entry.objectType == "blob" else { return .notice("Select a file to preview its contents.") } + guard let size = entry.size, size <= Self.revisionPreviewByteLimit else { + return .notice("File exceeds the 2 MB preview limit.") + } + let data = try await runGitRaw(arguments: ["cat-file", "blob", entry.objectID], in: repositoryURL, + environment: ProcessInfo.processInfo.environment, outputByteLimit: Self.revisionPreviewByteLimit) + try Task.checkCancellation() + return try Self.decodeBrowserPreview(data, isSymlink: entry.isSymlink) + } + + nonisolated static func decodeBrowserPreview(_ data: Data, isSymlink: Bool) throws -> RevisionFilePreview { + guard !data.contains(0), let text = String(data: data, encoding: .utf8) else { + return .notice("Binary file or unsupported text encoding. UTF-8 preview is unavailable.") + } + if isSymlink { return RevisionFilePreview(text: text, lines: [], message: "Symbolic link target: \(text)") } + if text.hasPrefix("version https://git-lfs.github.com/spec/v1\n") || text.hasPrefix("version https://git-lfs.github.com/spec/v1\r\n") { + return RevisionFilePreview(text: text, lines: [], message: "Git LFS pointer (content is not downloaded):\n\(text)") + } + let rawLines = text.components(separatedBy: "\n") + guard rawLines.count <= 50_000, rawLines.allSatisfy({ $0.utf8.count <= 16_000 }) else { + return RevisionFilePreview(text: text, lines: [], message: "Text exceeds the line count or line length preview limit. You can still copy its contents.") + } + var lines: [DiffLine] = [] + for (index, line) in rawLines.enumerated() { + try Task.checkCancellation() + lines.append(DiffLine(oldLineNumber: nil, newLineNumber: index + 1, text: line, type: .context)) + } + return RevisionFilePreview(text: text, lines: text.isEmpty ? [] : lines, message: text.isEmpty ? "Empty file" : nil) + } +} diff --git a/macgit/Services/GitStatusService.swift b/macgit/Services/GitStatusService.swift index 618fe08..b873610 100644 --- a/macgit/Services/GitStatusService.swift +++ b/macgit/Services/GitStatusService.swift @@ -375,7 +375,7 @@ actor GitStatusService { ) } - func runGitRaw(arguments: [String], in directory: URL, environment: [String: String]) async throws -> Data { + func runGitRaw(arguments: [String], in directory: URL, environment: [String: String], outputByteLimit: Int? = nil) async throws -> Data { let startedAt = Date() do { let context = try await gitExecutionContext(environment: environment) @@ -383,8 +383,12 @@ actor GitStatusService { executable: context.executable, arguments: arguments, directory: directory, - environment: context.environment + environment: context.environment, + outputByteLimit: outputByteLimit ).run() + guard !result.isTruncated else { + throw GitError.commandFailed("Git object exceeds the preview limit.") + } await GitCommandLogStore.shared.record( arguments: arguments, directory: directory, diff --git a/macgit/Services/RevisionBrowserServing.swift b/macgit/Services/RevisionBrowserServing.swift new file mode 100644 index 0000000..5f1f101 --- /dev/null +++ b/macgit/Services/RevisionBrowserServing.swift @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Foundation + +protocol RevisionBrowserServing: Sendable { + func browserSnapshot(revision: String, in repositoryURL: URL) async throws -> RevisionBrowserSnapshot + func browserEntries(treeID: String, parentPath: String, in repositoryURL: URL) async throws -> [RevisionTreeEntry] + func browserPreview(entry: RevisionTreeEntry, in repositoryURL: URL) async throws -> RevisionFilePreview +} diff --git a/macgit/ViewModels/RepositoryBookmarkRepairModel.swift b/macgit/ViewModels/RepositoryBookmarkRepairModel.swift new file mode 100644 index 0000000..592211e --- /dev/null +++ b/macgit/ViewModels/RepositoryBookmarkRepairModel.swift @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import Combine +import Foundation + +@MainActor +final class RepositoryBookmarkRepairModel: ObservableObject { + let bookmark: RepositoryBookmark + @Published private(set) var repositoryURL: URL? + @Published private(set) var remotes: [RepositoryBookmarkRemote] = [] + @Published var selectedRemoteID: String? + @Published private(set) var isLoading = false + @Published private(set) var isSaving = false + @Published private(set) var errorMessage: String? + private var loadID = UUID() + + init(bookmark: RepositoryBookmark) { + self.bookmark = bookmark + } + + var selectedRemote: RepositoryBookmarkRemote? { + remotes.first { $0.id == selectedRemoteID } + } + + var canSave: Bool { + repositoryURL != nil && selectedRemote != nil && !isLoading && !isSaving + } + + func selectRepository(_ url: URL) async { + let requestID = UUID() + loadID = requestID + repositoryURL = url + remotes = [] + selectedRemoteID = nil + errorMessage = nil + isLoading = true + defer { if loadID == requestID { isLoading = false } } + guard FileManager.default.fileExists(atPath: url.appendingPathComponent(".git").path) else { + errorMessage = "Choose a Git repository folder containing a .git directory or file." + return + } + do { + let loaded = try await GitStatusService.shared.repositoryBookmarkRemotes(in: url) + guard loadID == requestID, !Task.isCancelled else { return } + remotes = loaded + selectedRemoteID = loaded.first(where: { $0.identity.canonicalKey == bookmark.canonicalKey })?.id + ?? loaded.first?.id + if loaded.isEmpty { + errorMessage = "This folder has no supported remote URL. Choose another repository, or add a remote before updating the bookmark." + } + } catch { + guard loadID == requestID else { return } + errorMessage = error.localizedDescription + } + } + + func save(using controller: RepositoryBookmarkController) async -> Bool { + guard canSave, let repositoryURL, let selectedRemote else { return false } + isSaving = true + errorMessage = nil + defer { isSaving = false } + do { + _ = try await controller.updateBookmark(bookmark, from: repositoryURL, remote: selectedRemote) + return true + } catch { + errorMessage = error.localizedDescription + return false + } + } +} diff --git a/macgit/Views/Account/AuthenticationSheet.swift b/macgit/Views/Account/AuthenticationSheet.swift index c086190..dd10178 100644 --- a/macgit/Views/Account/AuthenticationSheet.swift +++ b/macgit/Views/Account/AuthenticationSheet.swift @@ -28,6 +28,8 @@ struct AuthenticationSheet: View { @State private var showingDeviceRecovery = false @State private var showingPassword = false @State private var emailValidationMessage: String? + @State private var isEmailValid = false + @FocusState private var emailFocused: Bool @FocusState private var passwordFocused: Bool init(controller: AccountSessionController, mode: AuthenticationMode) { @@ -70,14 +72,24 @@ struct AuthenticationSheet: View { .textContentType(.emailAddress) .textFieldStyle(.roundedBorder) .controlSize(.large) + .focused($emailFocused) .disabled(controller.pendingLinkEmail != nil || controller.isLoading) .onSubmit(submit) + .onChange(of: emailFocused) { _, isFocused in + if !isFocused, controller.pendingLinkEmail == nil { + validateEmail() + } + } .onChange(of: email) { emailValidationMessage = nil + isEmailValid = false if controller.pendingLinkEmail == nil { showingPassword = false password = "" - controller.errorMessage = nil + // Avoid publishing a shared account update on every keystroke. + if controller.errorMessage != nil { + controller.errorMessage = nil + } } } @@ -256,12 +268,17 @@ struct AuthenticationSheet: View { } } - private var isEmailValid: Bool { + @discardableResult + private func validateEmail() -> Bool { let trimmedEmail = email.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmedEmail.range( + isEmailValid = trimmedEmail.range( of: #"^[A-Z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[A-Z0-9!#$%&'*+/=?^_`{|}~-]+)*@[A-Z0-9](?:[A-Z0-9-]*[A-Z0-9])?(?:\.[A-Z0-9](?:[A-Z0-9-]*[A-Z0-9])?)+$"#, options: [.regularExpression, .caseInsensitive] ) != nil + emailValidationMessage = trimmedEmail.isEmpty || isEmailValid + ? nil + : "Enter a valid email address." + return isEmailValid } private var primaryActionDisabled: Bool { @@ -282,10 +299,7 @@ struct AuthenticationSheet: View { private func submit() { guard !primaryActionDisabled else { return } - guard controller.pendingLinkEmail != nil || isEmailValid else { - emailValidationMessage = "Enter a valid email address." - return - } + guard controller.pendingLinkEmail != nil || validateEmail() else { return } if !showingPassword && controller.pendingLinkEmail == nil { showingPassword = true passwordFocused = true diff --git a/macgit/Views/Common/ProtectedBranchCommitSheet.swift b/macgit/Views/Common/ProtectedBranchCommitSheet.swift index c3cd655..8178eff 100644 --- a/macgit/Views/Common/ProtectedBranchCommitSheet.swift +++ b/macgit/Views/Common/ProtectedBranchCommitSheet.swift @@ -11,11 +11,9 @@ struct ProtectedBranchCommitSheet: View { var body: some View { VStack(alignment: .leading, spacing: 16) { - Label(warning.status == .protected ? "Commit to a protected branch?" : "Branch protection could not be checked", systemImage: "lock.trianglebadge.exclamationmark") + Label("Commit to a protected branch?", systemImage: "lock.trianglebadge.exclamationmark") .font(.headline) - Text(warning.status == .protected - ? "\(warning.remoteBranch) has branch protection rules. You can commit your changes on a new branch or continue on \(warning.branch)." - : "Commit+ could not read the rules for \(warning.remoteBranch). Check your connection and provider account access, or choose how to continue.") + Text("\(warning.remoteBranch) has branch protection rules. You can commit your changes on a new branch or continue on \(warning.branch).") .foregroundStyle(.secondary) if enteringBranchName { TextField("New branch name", text: $branchName) diff --git a/macgit/Views/History/HistoryView.swift b/macgit/Views/History/HistoryView.swift index a3e80cb..0c0529e 100644 --- a/macgit/Views/History/HistoryView.swift +++ b/macgit/Views/History/HistoryView.swift @@ -39,6 +39,7 @@ struct HistoryView: View { let onRunRepositoryOperation: RepositoryOperationRunner let onRequestCheckout: (String, Bool) -> Void let onRequestExplainCommit: (Commit) -> Void + let onRequestBrowseRevision: (Commit) -> Void @EnvironmentObject private var appState: AppState @State private var commits: [Commit] = [] @@ -112,7 +113,8 @@ struct HistoryView: View { Task { await operation() } }, onRequestCheckout: @escaping (String, Bool) -> Void = { _, _ in }, - onRequestExplainCommit: @escaping (Commit) -> Void = { _ in } + onRequestExplainCommit: @escaping (Commit) -> Void = { _ in }, + onRequestBrowseRevision: @escaping (Commit) -> Void = { _ in } ) { self.repositoryURL = repositoryURL self.selectedBranch = selectedBranch @@ -121,6 +123,7 @@ struct HistoryView: View { self.onRunRepositoryOperation = onRunRepositoryOperation self.onRequestCheckout = onRequestCheckout self.onRequestExplainCommit = onRequestExplainCommit + self.onRequestBrowseRevision = onRequestBrowseRevision let storedPageSize = UserDefaults.standard.integer(forKey: "advanced.historyLoadSize") self._paging = State( initialValue: HistoryPagingState( @@ -574,15 +577,19 @@ struct HistoryView: View { TableColumn("Message") { commit in commitDragCell(for: commit) { - HistoryCommitMessageCell( - commit: commit, - graphModel: graphModel, - isDragActive: activeDragCommitHashes.contains(commit.hash), - scrollCoordinator: tableScrollCoordinator, - onAppear: { - handleHistoryCommitCellAppearance(commit) - } - ) + GeometryReader { geometry in + HistoryCommitMessageCell( + commit: commit, + graphModel: graphModel, + isDragActive: activeDragCommitHashes.contains(commit.hash), + scrollCoordinator: tableScrollCoordinator, + onAppear: { + handleHistoryCommitCellAppearance(commit) + } + ) + .frame(width: geometry.size.width, height: geometry.size.height, alignment: .leading) + } + .clipped() } } .width( @@ -920,6 +927,12 @@ struct HistoryView: View { ) return Group { + Button("Show Repository at Revision", systemImage: "folder") { + guard let singleCommit else { return } + onRequestBrowseRevision(singleCommit) + } + .disabled(singleCommit == nil) + Button("Checkout Commit", systemImage: "arrow.right.to.line") { guard let singleCommit else { return } pendingCommit = singleCommit diff --git a/macgit/Views/History/RevisionBrowserView.swift b/macgit/Views/History/RevisionBrowserView.swift new file mode 100644 index 0000000..bf6d73f --- /dev/null +++ b/macgit/Views/History/RevisionBrowserView.swift @@ -0,0 +1,159 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import SwiftUI + +struct RevisionBrowserView: View { + let controller: RevisionBrowserController + let onCompare: (ComparisonPath, String) -> Void + + var body: some View { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(controller.snapshot?.subject ?? "Repository at Revision").font(.headline).lineLimit(2) + Text("\(controller.repositoryURL.lastPathComponent) · \((controller.snapshot?.commitID ?? controller.revision).prefix(8)) · Read-only") + .font(.caption).foregroundStyle(.secondary).textSelection(.enabled) + } + Spacer() + Button("Copy Commit SHA", systemImage: "doc.on.doc") { + copy(controller.snapshot?.commitID ?? controller.revision) + } + .pointingHandCursor() + .disabled(controller.snapshot == nil) + } + .padding(12) + Divider() + if controller.isLoading { + ProgressView("Loading repository…").frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = controller.error { + VStack { + EmptyStateView(icon: "exclamationmark.triangle", message: "Unable to browse revision", detail: error) + Button("Retry") { controller.load() } + .pointingHandCursor() + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + PersistentHSplit( + autosaveName: "RevisionBrowserDetailSplit", + left: { tree.frame(minWidth: 220, idealWidth: 300, maxWidth: 500) }, + right: { filePreview.frame(minWidth: 320, maxWidth: .infinity, maxHeight: .infinity) } + ) + } + } + .task { controller.load() } + } + + private var tree: some View { + List { + if controller.children[""]?.isEmpty == true { + Text("This revision contains no files.").foregroundStyle(.secondary) + } + ForEach(controller.visibleEntries) { entry in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + if entry.isDirectory { + Button { + controller.toggle(entry) + } label: { + Image(systemName: controller.expanded.contains(entry.path) ? "chevron.down" : "chevron.right") + .frame(width: 16, height: 22).contentShape(Rectangle()) + } + .buttonStyle(.plain) + .pointingHandCursor() + .accessibilityLabel("\(controller.expanded.contains(entry.path) ? "Collapse" : "Expand") \(entry.name)") + } else { + Color.clear.frame(width: 16, height: 1) + } + Button { + controller.select(entry) + if entry.isDirectory { controller.toggle(entry) } + } label: { + HStack { + Image(systemName: entry.isDirectory ? "folder" : entry.isSubmodule ? "shippingbox" : entry.isSymlink ? "link" : "doc.text") + Text(entry.name) + .fontWeight(isChanged(entry) ? .semibold : .regular) + .foregroundStyle(isChanged(entry) ? Color.accentColor : Color.primary) + .lineLimit(1) + Spacer(minLength: 0) + if isChanged(entry) { + Image(systemName: "circle.fill") + .font(.system(size: 6)) + .foregroundStyle(Color.accentColor) + .accessibilityLabel(entry.isDirectory ? "Contains changes in this commit" : "Changed in this commit") + } + }.contentShape(Rectangle()) + } + .buttonStyle(.plain) + .pointingHandCursor() + .accessibilityHint(entry.isDirectory ? "Expand or collapse folder" : "Preview file") + if controller.loadingFolders.contains(entry.path) { ProgressView().controlSize(.small) } + } + .padding(.leading, CGFloat(min(entry.path.split(separator: "/").count - 1, 20)) * 14) + if controller.expanded.contains(entry.path) { + if let error = controller.folderErrors[entry.path] { + Text(error + " Collapse and expand to retry.").font(.caption).foregroundStyle(.secondary) + } else if controller.children[entry.path]?.isEmpty == true { + Text("Empty folder").font(.caption).foregroundStyle(.secondary) + } + } + } + .listRowBackground(controller.selectedEntry?.id == entry.id ? Color.accentColor.opacity(0.18) : Color.clear) + .contextMenu { + Button("Copy Path") { copy(entry.path) } + Button("Copy Object ID") { copy(entry.objectID) } + Button("Compare with Revision…") { compare(entry) } + } + .help(entry.path) + } + } + .listStyle(.sidebar) + } + + @ViewBuilder private var filePreview: some View { + if let entry = controller.selectedEntry { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 6) { + Text(entry.path).font(.headline).textSelection(.enabled) + Text("\(entry.objectType) · mode \(entry.mode)\(entry.size.map { " · \($0) bytes" } ?? "")\n\(entry.objectID)") + .font(.caption.monospaced()).foregroundStyle(.secondary).textSelection(.enabled) + HStack { + Button("Copy Path") { copy(entry.path) } + .pointingHandCursor() + Button("Copy Contents") { if let text = controller.preview?.text { copy(text) } } + .pointingHandCursor() + .disabled(controller.preview?.text == nil) + Button("Compare with Revision…") { compare(entry) } + .pointingHandCursor() + } + }.padding(12) + Divider() + if controller.isLoadingPreview { + ProgressView("Loading file…").frame(maxWidth: .infinity, maxHeight: .infinity) + } else if let error = controller.previewError { + EmptyStateView(icon: "exclamationmark.triangle", message: "Unable to read object", detail: error) + } else if let message = controller.preview?.message { + ScrollView { Text(message).textSelection(.enabled).padding().frame(maxWidth: .infinity, alignment: .leading) } + } else if let preview = controller.preview { + CommitFilePreviewContent(lines: preview.lines, fileExtension: (entry.path as NSString).pathExtension.lowercased()) + .id(entry.id) + } else { + EmptyStateView(icon: "folder", message: "Expand this folder to browse its files") + } + } + } else { + EmptyStateView(icon: "doc.text.magnifyingglass", message: "Select a file to preview") + } + } + + private func isChanged(_ entry: RevisionTreeEntry) -> Bool { + controller.snapshot?.changedNodePaths.contains(entry.path) == true + } + + private func compare(_ entry: RevisionTreeEntry) { + guard let snapshot = controller.snapshot else { return } + onCompare(ComparisonPath(path: entry.path, isDirectory: entry.isDirectory), snapshot.commitID) + } + + private func copy(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } +} diff --git a/macgit/Views/MainWindow/MainWindowView.swift b/macgit/Views/MainWindow/MainWindowView.swift index 79d36de..cd55b0e 100644 --- a/macgit/Views/MainWindow/MainWindowView.swift +++ b/macgit/Views/MainWindow/MainWindowView.swift @@ -152,6 +152,7 @@ struct MainWindowView: View { @State private var remoteURLString: String = "" @State var selectedBranchName: String? = nil @State private var pathComparisonWindow = PathComparisonWindowController() + @State private var revisionBrowserWindow = RevisionBrowserWindowController() @State private var referenceDiffBase: String? @State private var referenceDiffTarget: String? @State private var referenceDiffTitle: String? @@ -1226,7 +1227,8 @@ struct MainWindowView: View { syncState: syncState, onRunRepositoryOperation: runRepositoryOperation, onRequestCheckout: checkoutRequest, - onRequestExplainCommit: explainCommitWithRepositoryAI + onRequestExplainCommit: explainCommitWithRepositoryAI, + onRequestBrowseRevision: { revisionBrowserWindow.show(revision: $0.hash, in: repositoryURL) } ) } case .branch, .worktree, .tag, .remoteBranch, .head: @@ -1237,7 +1239,8 @@ struct MainWindowView: View { syncState: syncState, onRunRepositoryOperation: runRepositoryOperation, onRequestCheckout: checkoutRequest, - onRequestExplainCommit: explainCommitWithRepositoryAI + onRequestExplainCommit: explainCommitWithRepositoryAI, + onRequestBrowseRevision: { revisionBrowserWindow.show(revision: $0.hash, in: repositoryURL) } ) case .item(.reflog): ReflogView( diff --git a/macgit/Views/MainWindow/RepoPickerView.swift b/macgit/Views/MainWindow/RepoPickerView.swift index d352223..cdef6eb 100644 --- a/macgit/Views/MainWindow/RepoPickerView.swift +++ b/macgit/Views/MainWindow/RepoPickerView.swift @@ -66,6 +66,9 @@ struct RepoPickerView: View { @ObservedObject private var store = RecentRepositoriesStore.shared @State private var showingCloneSheet = false @State private var bookmarkToClone: RepositoryBookmark? + @State private var bookmarkToRepair: RepositoryBookmark? + @State private var bookmarkRepairFolder: URL? + @State private var bookmarkRefreshGeneration = 0 @State private var errorMessage: String? @State private var showingError = false @State private var searchText = "" @@ -172,6 +175,22 @@ struct RepoPickerView: View { headerSection } controlBar + if bookmarkController.hasPendingChanges { + HStack { + Text(bookmarkController.canSyncPendingChanges + ? "Bookmark changes are saved locally and waiting to sync." + : "Bookmark changes are saved locally. Sign in to sync them.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if bookmarkController.canSyncPendingChanges { + Button("Retry Sync") { + Task { await bookmarkController.retryPendingChanges() } + } + .disabled(bookmarkController.isRetryingSync || !bookmarkController.syncingBookmarkIDs.isEmpty) + } + } + } recentRepositoriesSection if !isDashboardSidebar { Spacer(minLength: 0) } } @@ -184,6 +203,14 @@ struct RepoPickerView: View { showingCloneSheet = true } } + .task(id: [store.repositories.map(\.url.path), bookmarkController.bookmarks.map(\.canonicalKey), [String(bookmarkRefreshGeneration)]]) { + let localURLs = store.repositories.map(\.url) + + bookmarkController.bookmarks.compactMap { bookmarkController.localURL(for: $0) } + await bookmarkController.linkMatchingBookmarks(to: localURLs) + } + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + bookmarkRefreshGeneration += 1 + } .alert("Error", isPresented: $showingError, actions: { Button("OK", role: .cancel) {} }, message: { @@ -228,6 +255,12 @@ struct RepoPickerView: View { } ) } + .sheet(item: $bookmarkToRepair) { bookmark in + RepositoryBookmarkRepairSheet(bookmark: bookmark, initialRepositoryURL: bookmarkRepairFolder) { url in + store.add(url) + bookmarkRefreshGeneration += 1 + } + } .onChange(of: bookmarkController.errorMessage) { _, newValue in guard let newValue else { return } errorMessage = "Could not save or sync the repository bookmark. \(newValue)" @@ -255,7 +288,9 @@ struct RepoPickerView: View { rowStates: [URL: RepoPickerRowState] ) -> [RecentRepository] { let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + var seenURLs: Set = [] let filtered = repositories.filter { repo in + guard seenURLs.insert(repo.url).inserted else { return false } if !query.isEmpty { let haystack = [ repo.name, @@ -527,16 +562,38 @@ struct RepoPickerView: View { } private func repoRow(_ repo: RecentRepository) -> some View { - HStack(spacing: 8) { - Button(action: { - openRecentRepository(repo) - }) { - repoRowContent(repo) - } - .buttonStyle(.plain) - .sidebarPointingHandCursor() + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Button(action: { + openRecentRepository(repo) + }) { + repoRowContent(repo) + } + .buttonStyle(.plain) + .sidebarPointingHandCursor() - bookmarkButton(for: repo) + bookmarkButton(for: repo) + } + ForEach(bookmarkController.bookmarksNeedingAttention(at: repo.url)) { bookmark in + VStack(alignment: .leading, spacing: 4) { + Label("Bookmark URL no longer matches this folder's remotes.", systemImage: "exclamationmark.triangle") + .foregroundStyle(.orange) + Text(bookmark.remoteURL.absoluteString) + .foregroundStyle(.secondary) + .lineLimit(1) + .help(bookmark.remoteURL.absoluteString) + HStack { + Button("Update Bookmark…") { repairBookmark(bookmark, folder: repo.url) } + Button("Remove Bookmark") { + Task { await bookmarkController.removeBookmark(bookmark) } + } + } + .buttonStyle(.borderless) + } + .font(.caption) + .padding(.leading, 46) + .padding(.bottom, 8) + } } .background( Color.primary.opacity(hoveredRepositoryURL == repo.url ? 0.06 : 0), @@ -549,6 +606,9 @@ struct RepoPickerView: View { await loadRowPresentation(for: repo) } .contextMenu { + ForEach(bookmarkController.bookmarks.filter { bookmarkController.localURL(for: $0) == repo.url }) { bookmark in + Button("Update Bookmark: \(bookmark.name)…") { repairBookmark(bookmark, folder: repo.url) } + } Button("Remove from Recents", role: .destructive) { store.remove(repo) } @@ -592,50 +652,61 @@ struct RepoPickerView: View { } private func unlinkedBookmarkRow(_ bookmark: RepositoryBookmark) -> some View { - HStack(spacing: 12) { - Image(bookmark.provider == .generic ? "code-branch" : bookmark.provider.rawValue) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 18, height: 18) - .frame(width: 34, height: 34) - .background( - Color(nsColor: .windowBackgroundColor), - in: RoundedRectangle(cornerRadius: 10) - ) - .accessibilityLabel("Repository provider") + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Image(bookmark.provider == .generic ? "code-branch" : bookmark.provider.rawValue) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 18, height: 18) + .frame(width: 34, height: 34) + .background( + Color(nsColor: .windowBackgroundColor), + in: RoundedRectangle(cornerRadius: 10) + ) + .accessibilityLabel("Repository provider") - VStack(alignment: .leading, spacing: 3) { - Text(bookmark.name) - .font(.body.weight(.medium)) - Text(bookmark.remoteURL.absoluteString) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } + VStack(alignment: .leading, spacing: 3) { + Text(bookmark.name) + .font(.body.weight(.medium)) + Text(bookmark.remoteURL.absoluteString) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } - Spacer() + Spacer() - Button("Clone") { - bookmarkToClone = bookmark - } - .buttonStyle(.borderedProminent) - .sidebarPointingHandCursor() + Button("Clone") { + bookmarkToClone = bookmark + } + .buttonStyle(.borderedProminent) + .sidebarPointingHandCursor() - Button(isDashboardSidebar ? "Link" : "Link Folder") { - chooseFolderToLink(bookmark) - } - .buttonStyle(.bordered) - .sidebarPointingHandCursor() + Button(isDashboardSidebar ? "Link" : "Link Folder") { + chooseFolderToLink(bookmark) + } + .buttonStyle(.bordered) + .sidebarPointingHandCursor() - Button("Remove bookmark", systemImage: "star.fill") { - Task { - await bookmarkController.removeBookmark(bookmark) + Button("Remove bookmark", systemImage: "star.fill") { + Task { + await bookmarkController.removeBookmark(bookmark) + } } + .labelStyle(.iconOnly) + .buttonStyle(.borderless) + .sidebarPointingHandCursor() + .help("Remove bookmark") } - .labelStyle(.iconOnly) - .buttonStyle(.borderless) - .sidebarPointingHandCursor() - .help("Remove bookmark") + VStack(alignment: .leading, spacing: 4) { + Label("Not linked on this Mac. Renamed or moved?", systemImage: "link") + .foregroundStyle(.secondary) + Button("Update Bookmark…") { repairBookmark(bookmark) } + .buttonStyle(.borderless) + .sidebarPointingHandCursor() + } + .font(.caption) + .padding(.leading, 46) } .padding(.vertical, 12) .padding(.horizontal, 4) @@ -828,9 +899,6 @@ struct RepoPickerView: View { case .remoteURL(let remoteURL): repoIcons[repo.url] = remoteURL.isEmpty ? "code-branch" : determineRepoIconName(from: remoteURL) loadingRepoIcons.remove(repo.url) - if let bookmark = bookmarkController.bookmark(remoteURLString: remoteURL) { - linkBookmark(bookmark, to: repo.url) - } } } } @@ -913,6 +981,8 @@ struct RepoPickerView: View { try await bookmarkController.validateAndLink(bookmark, to: url) store.add(url) onRepositoryOpened(url) + } catch RepositoryBookmarkError.folderDoesNotMatch { + repairBookmark(bookmark, folder: url) } catch { errorMessage = error.localizedDescription showingError = true @@ -921,6 +991,11 @@ struct RepoPickerView: View { } } + private func repairBookmark(_ bookmark: RepositoryBookmark, folder: URL? = nil) { + bookmarkRepairFolder = folder + bookmarkToRepair = bookmark + } + private func isValidGitRepository(at url: URL) -> Bool { let gitPath = url.appendingPathComponent(".git").path return FileManager.default.fileExists(atPath: url.path) diff --git a/macgit/Views/MainWindow/RepositoryBookmarkRepairSheet.swift b/macgit/Views/MainWindow/RepositoryBookmarkRepairSheet.swift new file mode 100644 index 0000000..7b7ecdd --- /dev/null +++ b/macgit/Views/MainWindow/RepositoryBookmarkRepairSheet.swift @@ -0,0 +1,118 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import AppKit +import SwiftUI + +struct RepositoryBookmarkRepairSheet: View { + @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var bookmarkController: RepositoryBookmarkController + @StateObject private var model: RepositoryBookmarkRepairModel + let initialRepositoryURL: URL? + let onSaved: (URL) -> Void + + init(bookmark: RepositoryBookmark, initialRepositoryURL: URL?, onSaved: @escaping (URL) -> Void) { + _model = StateObject(wrappedValue: RepositoryBookmarkRepairModel(bookmark: bookmark)) + self.initialRepositoryURL = initialRepositoryURL + self.onSaved = onSaved + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + Text("Update Bookmark") + .font(.title2.bold()) + Text("If the repository was renamed or moved, choose its local folder and the remote URL to save. Review the change before updating.") + .foregroundStyle(.secondary) + + VStack(alignment: .leading, spacing: 4) { + Text("Current bookmark").font(.headline) + Text(model.bookmark.remoteURL.absoluteString) + .textSelection(.enabled) + .foregroundStyle(.secondary) + } + + HStack { + VStack(alignment: .leading, spacing: 4) { + Text("Local folder").font(.headline) + Text(model.repositoryURL?.path ?? "No folder selected") + .lineLimit(2) + .truncationMode(.middle) + .foregroundStyle(.secondary) + .help(model.repositoryURL?.path ?? "Choose the repository folder on this Mac") + } + Spacer() + Button("Choose Folder…", action: chooseFolder) + .disabled(model.isSaving) + } + + if model.isLoading { + ProgressView("Reading remotes…") + .controlSize(.small) + } else if !model.remotes.isEmpty { + Picker("Remote", selection: $model.selectedRemoteID) { + ForEach(model.remotes) { remote in + Text("\(remote.name) — \(remote.identity.host)/\(remote.identity.ownerPath)/\(remote.identity.repositoryName)") + .tag(Optional(remote.id)) + } + } + .disabled(model.isSaving) + } + + if let remote = model.selectedRemote { + VStack(alignment: .leading, spacing: 4) { + Text("New bookmark URL").font(.headline) + Text(remote.identity.canonicalRemoteURL.absoluteString) + .textSelection(.enabled) + } + Text("This replaces the saved bookmark URL and links this folder. The change will sync to your other devices when you're signed in and online. An existing bookmark for the new URL will be merged.") + .font(.callout) + .foregroundStyle(.secondary) + } + + if let error = model.errorMessage { + Label(error, systemImage: "exclamationmark.triangle") + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + + HStack { + Spacer() + Button("Cancel") { dismiss() } + .keyboardShortcut(.cancelAction) + .disabled(model.isSaving) + Button("Update Bookmark", action: save) + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!model.canSave) + } + } + .padding(24) + .frame(width: 560) + .interactiveDismissDisabled(model.isSaving) + .task { + if let initialRepositoryURL { await model.selectRepository(initialRepositoryURL) } + } + } + + private func chooseFolder() { + let panel = NSOpenPanel() + panel.canChooseFiles = false + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.message = "Choose the repository whose remote URL should replace this bookmark." + panel.prompt = "Choose Repository" + panel.directoryURL = model.repositoryURL + guard let window = NSApp.keyWindow else { return } + panel.beginSheetModal(for: window) { response in + guard response == .OK, let url = panel.url else { return } + Task { await model.selectRepository(url) } + } + } + + private func save() { + Task { + if await model.save(using: bookmarkController), let url = model.repositoryURL { + onSaved(url) + dismiss() + } + } + } +} diff --git a/macgitTests/RepoPickerViewTests.swift b/macgitTests/RepoPickerViewTests.swift index 815fc42..63712f2 100644 --- a/macgitTests/RepoPickerViewTests.swift +++ b/macgitTests/RepoPickerViewTests.swift @@ -19,6 +19,22 @@ import XCTest @testable import macgit final class RepoPickerViewTests: XCTestCase { + func testMultipleBookmarksForOneLocalFolderProduceOneRow() { + let github = makeRepository(name: "client", path: "/tmp/client", lastOpened: .distantPast) + let gitlab = makeRepository(name: "client-mirror", path: "/tmp/client", lastOpened: .distantPast) + let otherClone = makeRepository(name: "client", path: "/tmp/other-clone", lastOpened: .distantPast) + let visible = RepoPickerView.visibleRepositories( + from: [github, gitlab, otherClone], + searchText: "", + sortOption: .name, + selectedFilterTypes: [], + repoIcons: [:], + rowStates: [:] + ) + XCTAssertEqual(visible.count, 2) + XCTAssertEqual(Set(visible.map(\.url)), [github.url, otherClone.url]) + } + func testVisibleRepositoriesDefaultsToLastOpenedDescending() { let older = makeRepository(name: "Zeta", path: "/tmp/zeta", lastOpened: Date(timeIntervalSince1970: 100)) let newer = makeRepository(name: "Alpha", path: "/tmp/alpha", lastOpened: Date(timeIntervalSince1970: 200)) diff --git a/macgitTests/RepositoryBookmarkTests.swift b/macgitTests/RepositoryBookmarkTests.swift index d729a63..e770d55 100644 --- a/macgitTests/RepositoryBookmarkTests.swift +++ b/macgitTests/RepositoryBookmarkTests.swift @@ -21,6 +21,264 @@ import XCTest @testable import macgit final class RepositoryBookmarkTests: XCTestCase { + @MainActor + func testRepairReplacesOldBookmarkAndPersistsOfflineSyncMarkers() async throws { + let fixture = try LocalDataStoreTestFixture() + defer { fixture.cleanup() } + try await fixture.store.prepare() + let repo = try makeMultiRemoteRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let old = try makeBookmark("https://github.com/team/old-client.git") + try await fixture.store.transaction { transaction in + try transaction.set(old, in: "bookmarks", id: old.id) + try transaction.set(repo.path, in: "bookmarkPaths", id: old.id) + } + let controller = RepositoryBookmarkController(cloudStore: nil, dataStore: fixture.store) + try controller.load() + await controller.linkMatchingBookmarks(to: [repo]) + XCTAssertEqual(controller.bookmarksNeedingAttention(at: repo).map(\.id), [old.id]) + let remotes = try await GitStatusService.shared.repositoryBookmarkRemotes(in: repo) + let origin = try XCTUnwrap(remotes.first { $0.name == "origin" }) + let updated = try await controller.updateBookmark(old, from: repo, remote: origin) + + XCTAssertEqual(updated.canonicalKey, "github.com/team/client") + XCTAssertEqual(updated.createdAt, old.createdAt) + XCTAssertEqual(controller.bookmarks.map(\.id), [updated.id]) + XCTAssertEqual(controller.localURL(for: updated), repo) + XCTAssertNil(controller.localURL(for: old)) + XCTAssertTrue(controller.bookmarksNeedingAttention(at: repo).isEmpty) + let reopened = try await fixture.reopen() + XCTAssertEqual(try reopened.value(RepositoryBookmark.self, in: "bookmarks", id: updated.id), updated) + XCTAssertNotNil(try reopened.value(String.self, in: "bookmarkUploads", id: updated.id)) + XCTAssertNotNil(try reopened.value(String.self, in: "bookmarkDeletes", id: old.id)) + } + + @MainActor + func testRepairMergesExistingDestinationBookmarkAndKeepsUnrelatedBookmark() async throws { + let fixture = try LocalDataStoreTestFixture() + defer { fixture.cleanup() } + try await fixture.store.prepare() + let repo = try makeMultiRemoteRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let old = try makeBookmark("https://github.com/team/old-client.git") + let destination = try makeBookmark("https://github.com/team/client.git") + let unrelated = try makeBookmark("https://gitlab.com/other/repository.git") + try await fixture.store.transaction { transaction in + for bookmark in [old, destination, unrelated] { + try transaction.set(bookmark, in: "bookmarks", id: bookmark.id) + } + } + let controller = RepositoryBookmarkController(cloudStore: nil, dataStore: fixture.store) + try controller.load() + let remotes = try await GitStatusService.shared.repositoryBookmarkRemotes(in: repo) + let origin = try XCTUnwrap(remotes.first { $0.name == "origin" }) + let updated = try await controller.updateBookmark(old, from: repo, remote: origin) + XCTAssertEqual(updated.id, destination.id) + XCTAssertEqual(updated.createdAt, destination.createdAt) + XCTAssertEqual(Set(controller.bookmarks.map(\.id)), [destination.id, unrelated.id]) + XCTAssertEqual(controller.localURL(for: destination), repo) + } + + @MainActor + func testRepairRejectsRemoteChangedAfterPreview() async throws { + let fixture = try LocalDataStoreTestFixture() + defer { fixture.cleanup() } + try await fixture.store.prepare() + let repo = try makeMultiRemoteRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let old = try makeBookmark("https://github.com/team/old-client.git") + try await fixture.store.transaction { try $0.set(old, in: "bookmarks", id: old.id) } + let controller = RepositoryBookmarkController(cloudStore: nil, dataStore: fixture.store) + try controller.load() + let remotes = try await GitStatusService.shared.repositoryBookmarkRemotes(in: repo) + let origin = try XCTUnwrap(remotes.first { $0.name == "origin" }) + _ = try await GitStatusService.shared.runGit(arguments: ["remote", "set-url", "origin", "https://github.com/other/repo.git"], in: repo) + do { + _ = try await controller.updateBookmark(old, from: repo, remote: origin) + XCTFail("The user must review the new remote before saving") + } catch RepositoryBookmarkError.remoteChanged { + XCTAssertEqual(controller.bookmarks, [old]) + } + } + + @MainActor + func testFailedRepairSyncSurvivesLoginAndStaleCloudSnapshotThenRetries() async throws { + let fixture = try LocalDataStoreTestFixture() + defer { fixture.cleanup() } + try await fixture.store.prepare() + let repo = try makeMultiRemoteRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let old = try makeBookmark("https://github.com/team/old-client.git") + let cloud = BookmarkRepairTestCloud(bookmarks: [old]) + let account = AccountSnapshot(uid: "repair-user", email: nil, displayName: nil, providerIDs: []) + let controller = RepositoryBookmarkController(cloudStore: cloud, dataStore: fixture.store) + await controller.updateAccount(account) + let remotes = try await GitStatusService.shared.repositoryBookmarkRemotes(in: repo) + let origin = try XCTUnwrap(remotes.first { $0.name == "origin" }) + cloud.failSaves = true + let updated = try await controller.updateBookmark(old, from: repo, remote: origin) + XCTAssertNotNil(controller.errorMessage) + XCTAssertTrue(controller.hasPendingChanges) + XCTAssertEqual(Array(cloud.stored.values), [old], "Do not delete the cloud bookmark before saving its replacement") + + let reopened = try await fixture.reopen() + let second = RepositoryBookmarkController(cloudStore: cloud, dataStore: reopened) + await second.updateAccount(account) + XCTAssertEqual(second.bookmarks.map(\.id), [updated.id]) + XCTAssertEqual(second.localURL(for: updated), repo) + XCTAssertNil(second.bookmark(forID: old.id)) + + cloud.failSaves = false + await second.retryPendingChanges() + XCTAssertEqual(Array(cloud.stored.values), [updated]) + XCTAssertEqual(second.bookmarks.map(\.id), [updated.id]) + XCTAssertFalse(second.hasPendingChanges) + XCTAssertNil(try reopened.value(String.self, in: "bookmarkUploads", id: updated.id)) + XCTAssertNil(try reopened.value(String.self, in: "bookmarkDeletes", id: old.id)) + } + + @MainActor + func testRepairModelLetsUserSelectSecondaryRemoteAndDoesNotMutateBeforeSave() async throws { + let fixture = try LocalDataStoreTestFixture() + defer { fixture.cleanup() } + try await fixture.store.prepare() + let repo = try makeMultiRemoteRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let old = try makeBookmark("https://github.com/team/old-client.git") + try await fixture.store.transaction { try $0.set(old, in: "bookmarks", id: old.id) } + let controller = RepositoryBookmarkController(cloudStore: nil, dataStore: fixture.store) + try controller.load() + let model = RepositoryBookmarkRepairModel(bookmark: old) + await model.selectRepository(repo) + XCTAssertEqual(model.selectedRemoteID, "origin") + model.selectedRemoteID = "mirror" + XCTAssertEqual(model.selectedRemote?.identity.canonicalRemoteURL.absoluteString, "https://gitlab.com/team/client-mirror.git") + XCTAssertEqual(controller.bookmarks, [old]) + let saved = await model.save(using: controller) + XCTAssertTrue(saved) + XCTAssertEqual(controller.bookmarks.first?.canonicalKey, "gitlab.com/team/client-mirror") + } + + @MainActor + func testRepairDoesNotRecreateBookmarkRemovedWhileSheetWasOpen() async throws { + let fixture = try LocalDataStoreTestFixture() + defer { fixture.cleanup() } + try await fixture.store.prepare() + let repo = try makeMultiRemoteRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let old = try makeBookmark("https://github.com/team/old-client.git") + try await fixture.store.transaction { try $0.set(old, in: "bookmarks", id: old.id) } + let controller = RepositoryBookmarkController(cloudStore: nil, dataStore: fixture.store) + try controller.load() + let model = RepositoryBookmarkRepairModel(bookmark: old) + await model.selectRepository(repo) + await controller.removeBookmark(old) + let saved = await model.save(using: controller) + XCTAssertFalse(saved) + XCTAssertNotNil(model.errorMessage) + XCTAssertTrue(controller.bookmarks.isEmpty) + } + + @MainActor + func testRepairModelRequiresRepositoryWithSupportedRemote() async throws { + let repo = try makeMultiRemoteRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let model = RepositoryBookmarkRepairModel(bookmark: try makeBookmark("https://github.com/team/old-client.git")) + await model.selectRepository(repo) + XCTAssertTrue(model.canSave) + await model.selectRepository(repo.appendingPathComponent("missing")) + XCTAssertFalse(model.canSave) + XCTAssertNotNil(model.errorMessage) + for name in ["origin", "mirror"] { + _ = try await GitStatusService.shared.runGit(arguments: ["remote", "remove", name], in: repo) + } + await model.selectRepository(repo) + XCTAssertFalse(model.canSave) + XCTAssertTrue(model.remotes.isEmpty) + XCTAssertNotNil(model.errorMessage) + } + + @MainActor + func testAutoLinkMatchesBothRemotesOnFeatureBranchAndKeepsOtherRepositoriesSeparate() async throws { + let fixture = try LocalDataStoreTestFixture() + defer { fixture.cleanup() } + try await fixture.store.prepare() + let repo = try makeMultiRemoteRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let controller = RepositoryBookmarkController(cloudStore: nil, dataStore: fixture.store) + let github = try makeBookmark("https://github.com/team/client.git") + let gitlab = try makeBookmark("https://gitlab.com/team/client-mirror.git") + let other = try makeBookmark("https://github.com/team/other-client.git") + + // Simulate bookmarks arriving after the local list has already loaded. + await controller.linkMatchingBookmarks(to: [repo]) + try await fixture.store.transaction { transaction in + for bookmark in [github, gitlab, other] { + try transaction.set(bookmark, in: "bookmarks", id: bookmark.id) + } + } + try controller.load() + await controller.linkMatchingBookmarks(to: [repo, repo]) + + XCTAssertEqual(controller.localURL(for: github), repo) + XCTAssertEqual(controller.localURL(for: gitlab), repo) + XCTAssertNil(controller.localURL(for: other)) + + let anotherClone = URL(fileURLWithPath: "/tmp/another-clone", isDirectory: true) + try await controller.link(github, to: anotherClone) + await controller.linkMatchingBookmarks(to: [repo]) + XCTAssertEqual(controller.localURL(for: github), anotherClone) + } + + @MainActor + func testManualLinkAcceptsSecondaryRemoteAndRejectsDifferentRepository() async throws { + let fixture = try LocalDataStoreTestFixture() + defer { fixture.cleanup() } + try await fixture.store.prepare() + let repo = try makeMultiRemoteRepository() + defer { try? FileManager.default.removeItem(at: repo) } + let controller = RepositoryBookmarkController(cloudStore: nil, dataStore: fixture.store) + let gitlab = try makeBookmark("https://gitlab.com/team/client-mirror.git") + try await controller.validateAndLink(gitlab, to: repo) + XCTAssertEqual(controller.localURL(for: gitlab), repo) + + let other = try makeBookmark("https://github.com/team/other-client.git") + do { + try await controller.validateAndLink(other, to: repo) + XCTFail("A different remote repository must not be linked") + } catch RepositoryBookmarkError.folderDoesNotMatch { + XCTAssertNil(controller.localURL(for: other)) + } + } + + private func makeBookmark(_ remote: String) throws -> RepositoryBookmark { + RepositoryBookmark(identity: try XCTUnwrap(RepositoryBookmarkIdentity.resolve(remoteURLString: remote))) + } + + private func makeMultiRemoteRepository() throws -> URL { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + for arguments in [ + ["init", "-b", "feat/bookmarks"], + ["remote", "add", "origin", "git@github.com:team/client.git"], + ["remote", "add", "mirror", "git@gitlab.com:team/client-mirror.git"] + ] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.currentDirectoryURL = url + process.arguments = arguments + process.standardOutput = FileHandle.nullDevice + process.standardError = FileHandle.nullDevice + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { + try? FileManager.default.removeItem(at: url) + throw GitError.commandFailed("Could not create bookmark test repository") + } + } + return url + } + func testIdentityNormalizesHTTPSAndSSHRemotesToSameRepository() throws { let https = try XCTUnwrap( RepositoryBookmarkIdentity.resolve( @@ -109,3 +367,32 @@ final class RepositoryBookmarkTests: XCTestCase { XCTAssertFalse(bookmark.remoteURL.absoluteString.contains("/Users/test")) } } + +@MainActor +private final class BookmarkRepairTestCloud: RepositoryBookmarkCloudStore { + var stored: [String: RepositoryBookmark] + var failSaves = false + + init(bookmarks: [RepositoryBookmark]) { + stored = Dictionary(uniqueKeysWithValues: bookmarks.map { ($0.id, $0) }) + } + + func bookmarks(uid: String) async throws -> [RepositoryBookmark] { Array(stored.values) } + + func save(_ bookmark: RepositoryBookmark, uid: String) async throws { + if failSaves { throw LocalDataError.notReady } + stored[bookmark.id] = bookmark + } + + func delete(bookmarkID: String, uid: String) async throws { + stored[bookmarkID] = nil + } + + func observe(uid: String, onChange: @escaping (Result<[RepositoryBookmark], Error>) -> Void) -> ObservationToken { + BookmarkRepairTestObservation() + } +} + +private final class BookmarkRepairTestObservation: ObservationToken { + func cancel() { } +} diff --git a/macgitTests/RevisionBrowserControllerTests.swift b/macgitTests/RevisionBrowserControllerTests.swift new file mode 100644 index 0000000..4a124bc --- /dev/null +++ b/macgitTests/RevisionBrowserControllerTests.swift @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import XCTest +@testable import macgit + +@MainActor +final class RevisionBrowserControllerTests: XCTestCase { + func testLatePreviewCannotReplaceNewSelection() async throws { + let service = BrowserTestService() + let controller = RevisionBrowserController(repositoryURL: URL(fileURLWithPath: "/tmp/browser"), revision: "HEAD", service: service) + controller.load() + await controller.loadTask?.value + let entries = try XCTUnwrap(controller.children[""]) + controller.select(entries[0]) + let oldTask = controller.previewTask + await service.waitForArrival() + controller.select(entries[1]) + XCTAssertNil(controller.preview) + XCTAssertEqual(controller.selectedEntry, entries[1]) + await controller.previewTask?.value + XCTAssertEqual(controller.preview?.text, "second") + await service.release() + await oldTask?.value + XCTAssertEqual(controller.preview?.text, "second") + } + + func testCancelRejectsLatePreview() async throws { + let service = BrowserTestService() + let controller = RevisionBrowserController(repositoryURL: URL(fileURLWithPath: "/tmp/browser"), revision: "HEAD", service: service) + controller.load() + await controller.loadTask?.value + controller.select(try XCTUnwrap(controller.children[""]?.first)) + let task = controller.previewTask + await service.waitForArrival() + controller.cancel() + await service.release() + await task?.value + XCTAssertNil(controller.preview) + XCTAssertFalse(controller.isLoadingPreview) + XCTAssertNil(controller.previewError) + } + + func testTreeLoadsOnlyOnExpansionAndCachesChildren() async throws { + let service = BrowserTestService() + let controller = RevisionBrowserController(repositoryURL: URL(fileURLWithPath: "/tmp/browser"), revision: "HEAD", service: service) + controller.load() + await controller.loadTask?.value + let initialCalls = await service.treeCalls + XCTAssertEqual(initialCalls, [""]) + let folder = try XCTUnwrap(controller.children[""]?.last) + controller.toggle(folder) + await controller.folderTask(for: folder.path)?.value + let calls = await service.treeCalls + XCTAssertEqual(calls, ["", "folder"]) + XCTAssertTrue(controller.visibleEntries.contains { $0.path == "folder/child" }) + controller.toggle(folder) + XCTAssertFalse(controller.visibleEntries.contains { $0.path == "folder/child" }) + controller.toggle(folder) + let cachedCalls = await service.treeCalls + XCTAssertEqual(cachedCalls, calls) + } +} + +private actor BrowserTestService: RevisionBrowserServing { + private var arrived = false + private var arrivalWaiter: CheckedContinuation? + private var releaseWaiter: CheckedContinuation? + private(set) var treeCalls: [String] = [] + + func browserSnapshot(revision: String, in repositoryURL: URL) async throws -> RevisionBrowserSnapshot { + RevisionBrowserSnapshot(commitID: String(repeating: "a", count: 40), subject: "Snapshot") + } + + func browserEntries(treeID: String, parentPath: String, in repositoryURL: URL) async throws -> [RevisionTreeEntry] { + treeCalls.append(parentPath) + if !parentPath.isEmpty { return [entry("folder/child")] } + return [entry("first"), entry("second"), entry("folder", directory: true)] + } + + func browserPreview(entry: RevisionTreeEntry, in repositoryURL: URL) async throws -> RevisionFilePreview { + if entry.path == "first" { + arrived = true + arrivalWaiter?.resume() + arrivalWaiter = nil + // Simulate a service that finishes even after cancellation. + await withCheckedContinuation { releaseWaiter = $0 } + } + return RevisionFilePreview(text: entry.path, lines: [], message: nil) + } + + func waitForArrival() async { + if !arrived { await withCheckedContinuation { arrivalWaiter = $0 } } + } + + func release() { releaseWaiter?.resume(); releaseWaiter = nil } + + private func entry(_ path: String, directory: Bool = false) -> RevisionTreeEntry { + RevisionTreeEntry(path: path, objectID: String(repeating: "a", count: 40), mode: directory ? "040000" : "100644", objectType: directory ? "tree" : "blob", size: directory ? nil : 5) + } +} diff --git a/macgitTests/RevisionBrowserServiceTests.swift b/macgitTests/RevisionBrowserServiceTests.swift new file mode 100644 index 0000000..237624a --- /dev/null +++ b/macgitTests/RevisionBrowserServiceTests.swift @@ -0,0 +1,165 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later +import XCTest +@testable import macgit + +@MainActor +final class RevisionBrowserServiceTests: XCTestCase { + private let service = GitStatusService.shared + + func testSnapshotNestedUnusualPathsAndReadOnlyState() async throws { + let repo = try makeRepository() + let names = ["tab\tline\n日本語.txt", "space name.txt", "-option", ":(glob)*"] + try FileManager.default.createDirectory(at: repo.appendingPathComponent("nested/deep"), withIntermediateDirectories: true) + let original = "first\r\n\nlast without newline" + for name in names { try original.write(to: repo.appendingPathComponent("nested/deep/" + name), atomically: true, encoding: .utf8) } + try git(["add", "."], in: repo) + try git(["commit", "-m", "snapshot"], in: repo) + let snapshot = try await service.browserSnapshot(revision: "HEAD", in: repo) + try git(["rm", "-r", "nested"], in: repo) + try git(["commit", "-m", "delete files"], in: repo) + try "staged".write(to: repo.appendingPathComponent("dirty.txt"), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try "unstaged".write(to: repo.appendingPathComponent("dirty.txt"), atomically: true, encoding: .utf8) + let indexBefore = try Data(contentsOf: repo.appendingPathComponent(".git/index")) + let headBefore = try git(["rev-parse", "HEAD"], in: repo) + let root = try await service.browserEntries(treeID: snapshot.commitID, parentPath: "", in: repo) + XCTAssertEqual(root.map(\.path), ["nested"]) + XCTAssertTrue(snapshot.changedNodePaths.contains("nested")) + XCTAssertTrue(snapshot.changedNodePaths.contains("nested/deep")) + for name in names { XCTAssertTrue(snapshot.changedNodePaths.contains("nested/deep/" + name)) } + let nested = try await service.browserEntries(treeID: XCTUnwrap(root.first).objectID, parentPath: "nested", in: repo) + let files = try await service.browserEntries(treeID: XCTUnwrap(nested.first).objectID, parentPath: "nested/deep", in: repo) + XCTAssertEqual(Set(files.map(\.name)), Set(names)) + for file in files { + let preview = try await service.browserPreview(entry: file, in: repo) + XCTAssertEqual(preview.text, original) + XCTAssertNil(preview.message) + } + XCTAssertEqual(try Data(contentsOf: repo.appendingPathComponent(".git/index")), indexBefore) + XCTAssertEqual(try git(["rev-parse", "HEAD"], in: repo), headBefore) + XCTAssertEqual(try String(contentsOf: repo.appendingPathComponent("dirty.txt"), encoding: .utf8), "unstaged") + } + + func testSpecialObjectsAndLargeBlob() async throws { + let repo = try makeRepository() + try git(["commit", "--allow-empty", "-m", "root"], in: repo) + let sha = try git(["rev-parse", "HEAD"], in: repo) + try FileManager.default.createSymbolicLink(atPath: repo.appendingPathComponent("link").path, withDestinationPath: "missing-target") + try Data([0, 255, 1]).write(to: repo.appendingPathComponent("binary")) + try Data(repeating: 65, count: 2_000_001).write(to: repo.appendingPathComponent("large")) + try Data().write(to: repo.appendingPathComponent("empty")) + try "version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 123\n".write(to: repo.appendingPathComponent("lfs"), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["update-index", "--add", "--cacheinfo", "160000", sha, "submodule"], in: repo) + try git(["commit", "-m", "objects"], in: repo) + let snapshot = try await service.browserSnapshot(revision: "HEAD", in: repo) + let entries = try await service.browserEntries(treeID: snapshot.commitID, parentPath: "", in: repo) + var results: [String: RevisionFilePreview] = [:] + for entry in entries { results[entry.path] = try await service.browserPreview(entry: entry, in: repo) } + XCTAssertEqual(results["link"]?.text, "missing-target") + XCTAssertTrue(results["link"]?.message?.contains("Symbolic link") == true) + XCTAssertTrue(results["binary"]?.message?.contains("Binary") == true) + XCTAssertTrue(results["large"]?.message?.contains("2 MB") == true) + XCTAssertNil(results["large"]?.text) + XCTAssertEqual(results["empty"]?.text, "") + XCTAssertTrue(results["lfs"]?.message?.contains("LFS pointer") == true) + XCTAssertTrue(results["submodule"]?.message?.contains(sha) == true) + // An incorrect caller-provided size must not bypass the process output bound. + let large = try XCTUnwrap(entries.first { $0.path == "large" }) + let spoofed = RevisionTreeEntry(path: large.path, objectID: large.objectID, mode: large.mode, objectType: "blob", size: 1) + do { _ = try await service.browserPreview(entry: spoofed, in: repo); XCTFail("Expected output limit") } + catch { XCTAssertTrue(error.localizedDescription.contains("limit")) } + } + + func testEmptyRepositoryEmptyTreeInvalidRefAndMissingObject() async throws { + let repo = try makeRepository() + do { _ = try await service.browserSnapshot(revision: "HEAD", in: repo); XCTFail("Unborn HEAD") } catch {} + try git(["commit", "--allow-empty", "-m", "empty"], in: repo) + try git(["tag", "-a", "release", "-m", "release"], in: repo) + let snapshot = try await service.browserSnapshot(revision: "release", in: repo) + let entries = try await service.browserEntries(treeID: snapshot.commitID, parentPath: "", in: repo) + XCTAssertTrue(entries.isEmpty) + for ref in ["--all", "missing", "HEAD:file"] { + do { _ = try await service.browserSnapshot(revision: ref, in: repo); XCTFail("Invalid revision") } catch {} + } + do { + _ = try await service.browserPreview(entry: RevisionTreeEntry(path: "missing", objectID: String(repeating: "a", count: 40), mode: "100644", objectType: "blob", size: 1), in: repo) + XCTFail("Missing object") + } catch {} + } + + func testChangedNodesIncludeRenameAndDeletionAncestorsButNotUnchangedFiles() async throws { + let repo = try makeRepository() + for folder in ["old", "new", "deleted"] { + try FileManager.default.createDirectory(at: repo.appendingPathComponent(folder), withIntermediateDirectories: true) + try "unchanged".write(to: repo.appendingPathComponent(folder + "/keep"), atomically: true, encoding: .utf8) + } + try "rename content".write(to: repo.appendingPathComponent("old/file"), atomically: true, encoding: .utf8) + try "delete".write(to: repo.appendingPathComponent("deleted/file"), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["commit", "-m", "initial"], in: repo) + try git(["mv", "old/file", "new/file"], in: repo) + try git(["rm", "deleted/file"], in: repo) + try git(["commit", "-m", "rename and delete"], in: repo) + let snapshot = try await service.browserSnapshot(revision: "HEAD", in: repo) + XCTAssertEqual(snapshot.changedNodePaths, ["old", "old/file", "new", "new/file", "deleted", "deleted/file"]) + try git(["commit", "--allow-empty", "-m", "empty"], in: repo) + let empty = try await service.browserSnapshot(revision: "HEAD", in: repo) + XCTAssertTrue(empty.changedNodePaths.isEmpty) + } + + func testMergeHighlightsChangesRelativeToFirstParent() async throws { + let repo = try makeRepository() + try git(["commit", "--allow-empty", "-m", "initial"], in: repo) + try git(["checkout", "-b", "feature"], in: repo) + try "feature".write(to: repo.appendingPathComponent("feature.txt"), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["commit", "-m", "feature"], in: repo) + try git(["checkout", "main"], in: repo) + try "main".write(to: repo.appendingPathComponent("main.txt"), atomically: true, encoding: .utf8) + try git(["add", "."], in: repo) + try git(["commit", "-m", "main"], in: repo) + try git(["merge", "--no-ff", "feature", "-m", "merge"], in: repo) + let snapshot = try await service.browserSnapshot(revision: "HEAD", in: repo) + XCTAssertEqual(snapshot.changedNodePaths, ["feature.txt"]) + } + + func testMalformedTreeAndTextLimits() throws { + XCTAssertThrowsError(try GitStatusService.parseBrowserEntries(Data("bad record".utf8), parentPath: "")) + XCTAssertThrowsError(try GitStatusService.validateBrowserObjectID("--help")) + let invalid = try GitStatusService.decodeBrowserPreview(Data([255]), isSymlink: false) + XCTAssertNil(invalid.text) + let longLine = String(repeating: "a", count: 16_001) + let preview = try GitStatusService.decodeBrowserPreview(Data(longLine.utf8), isSymlink: false) + XCTAssertEqual(preview.text, longLine) + XCTAssertTrue(preview.lines.isEmpty) + XCTAssertNotNil(preview.message) + } + + private func makeRepository() throws -> URL { + let repo = FileManager.default.temporaryDirectory.appendingPathComponent("revision-tests-\(UUID())") + try FileManager.default.createDirectory(at: repo, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: repo) } + try git(["init", "-b", "main"], in: repo) + try git(["config", "user.name", "Tests"], in: repo) + try git(["config", "user.email", "tests@example.com"], in: repo) + try git(["config", "commit.gpgsign", "false"], in: repo) + return repo + } + + @discardableResult private func git(_ arguments: [String], in repo: URL) throws -> String { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/git") + process.arguments = arguments + process.currentDirectoryURL = repo + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + try process.run() + let data = pipe.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + let output = String(decoding: data, as: UTF8.self) + guard process.terminationStatus == 0 else { throw GitError.commandFailed(output) } + return output.trimmingCharacters(in: .whitespacesAndNewlines) + } +}