diff --git a/App/ComparisonModel.swift b/App/ComparisonModel.swift index d3aa8e8..684d781 100644 --- a/App/ComparisonModel.swift +++ b/App/ComparisonModel.swift @@ -32,6 +32,8 @@ final class ComparisonModel: Identifiable { /// Which folders are open. Held here rather than by List's outline so both /// sides of a row can carry a disclosure control. private var expanded: Set = [] + /// Stamps each scan so a stale one can't overwrite a newer result. + private var scanGeneration = 0 /// One side of a manual pairing, held while the other is chosen. Lets two /// files with different names be compared, which the tree alone can't express. @@ -164,6 +166,17 @@ final class ComparisonModel: Identifiable { if isReady { compare() } } + /// Sets both sides at once. Doing it with two `setSide` calls starts a + /// comparison against the *old* other side, and since scans run concurrently + /// that stale result can land last and win — which showed up as one side + /// zooming in while the other kept its previous contents. + func setBoth(left: URL, right: URL) { + leftURL = left + rightURL = right + selection = nil + compare() + } + func setSide(_ side: Side, to url: URL) { switch side { case .left: leftURL = url @@ -185,10 +198,7 @@ final class ComparisonModel: Identifiable { guard !urls.isEmpty else { return } if urls.count >= 2 { - leftURL = urls[0] - rightURL = urls[1] - selection = nil - compare() + setBoth(left: urls[0], right: urls[1]) return } @@ -235,18 +245,25 @@ final class ComparisonModel: Identifiable { errorMessage = nil selection = nil + // Scans run off the main actor and a slow one can finish after a newer + // one; stamping each run means only the latest is allowed to land. + scanGeneration &+= 1 + let generation = scanGeneration let options = DirectoryComparer.Options(mode: mode, textOptions: textOptions) + Task.detached(priority: .userInitiated) { do { let left = try TreeScanner.scan(leftURL) let right = try TreeScanner.scan(rightURL) let diff = DirectoryComparer.compare(left: left, right: right, options: options) await MainActor.run { + guard generation == self.scanGeneration else { return } self.root = diff self.isScanning = false } } catch { await MainActor.run { + guard generation == self.scanGeneration else { return } self.errorMessage = error.localizedDescription self.isScanning = false self.root = nil diff --git a/App/DiffTreeView.swift b/App/DiffTreeView.swift index 33419b4..40313cc 100644 --- a/App/DiffTreeView.swift +++ b/App/DiffTreeView.swift @@ -170,19 +170,19 @@ struct DiffTreeView: View { // other. Works for folders too, so a folder inside a JAR can be set // against one on disk. if let left = node.left { - Button("Mark left for comparison") { mark(left, path: node.path) } + Button("Set as base: mark left") { mark(left, path: node.path) } } if let right = node.right { - Button("Mark right for comparison") { mark(right, path: node.path) } + Button("Set as base: mark right") { mark(right, path: node.path) } } if let pending = model.pendingPick { Divider() if let left = node.left { - Button("Compare left with “\(pending.name)”") { compare(pending, with: left, newTab: false) } + Button("Set as base with “\(pending.name)” (left)") { compare(pending, with: left, newTab: false) } } if let right = node.right { - Button("Compare right with “\(pending.name)”") { compare(pending, with: right, newTab: false) } - Button("Compare right with “\(pending.name)” in a new tab") { + Button("Set as base with “\(pending.name)” (right)") { compare(pending, with: right, newTab: false) } + Button("Set as base with “\(pending.name)” in a new tab") { compare(pending, with: right, newTab: true) } } diff --git a/App/TabsModel.swift b/App/TabsModel.swift index 90d0b5d..cb2d16a 100644 --- a/App/TabsModel.swift +++ b/App/TabsModel.swift @@ -48,8 +48,7 @@ final class TabsModel { /// new tab, which is what makes a hand-picked pair the new base. func openPair(left: URL, right: URL, inNewTab: Bool) { let target = inNewTab ? newTab() : selected - target.setSide(.left, to: left) - target.setSide(.right, to: right) + target.setBoth(left: left, right: right) selectedID = target.id } diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index 48949c9..5a0eb89 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -110,3 +110,5 @@ "Paste something on each side to compare." = "Paste something on each side to compare."; "Clear" = "Clear"; "Binary" = "Binary"; +"Set as base: mark left" = "Set as base: mark left"; +"Set as base: mark right" = "Set as base: mark right"; diff --git a/Resources/es.lproj/Localizable.strings b/Resources/es.lproj/Localizable.strings index d909eb9..0e1ab6d 100644 --- a/Resources/es.lproj/Localizable.strings +++ b/Resources/es.lproj/Localizable.strings @@ -110,3 +110,5 @@ "Paste something on each side to compare." = "Pega algo en cada lado para comparar."; "Clear" = "Limpiar"; "Binary" = "Binario"; +"Set as base: mark left" = "Usar como base: marcar izquierda"; +"Set as base: mark right" = "Usar como base: marcar derecha";