From 46fef90c9010bb1937a9dd15638b5ee00c677e65 Mon Sep 17 00:00:00 2001 From: Davide Ficano Date: Thu, 20 Aug 2026 11:03:34 +0200 Subject: [PATCH 01/24] feat(file-compare): highlight character differences inside changed lines Closes #59 --- AppDefaults/colors.json | 10 +- AppDefaults/colorsDark.json | 12 +- .../CommonPrefs/CommonPrefs+FileCompare.swift | 1 + .../Components/LineDetailTextView.swift | 26 +- .../Components/LineNumberTableCellView.swift | 81 +- .../FilesWindowController+Common.swift | 54 +- ...Controller+FilesTableViewContextMenu.swift | 6 +- .../FilesWindowController+MenuDelegate.swift | 6 +- .../FilesWindowController+Read.swift | 5 +- .../FilesWindowController+TableView.swift | 1 + .../FilesWindowController+UISetup.swift | 9 +- .../Controller/FilesWindowController.swift | 14 +- .../FilesCompare/DiffResult/DiffLine.swift | 24 +- .../DiffResult/DiffProcessor.swift | 21 +- .../DiffResult/DiffResult+Dump.swift | 1 + .../DiffResult/DiffResult+Lines.swift | 8 +- .../DiffResult/DiffResult.Options.swift | 6 + .../FilesCompare/DiffResult/DiffResult.swift | 76 +- .../DiffResult/Engine/DiffChange.swift | 27 + .../DiffResult/Engine/DiffFileData.swift | 384 +++++++++ .../DiffResult/Engine/EquivalenceTable.swift | 37 + .../DiffResult/Engine/SequenceDiff.swift | 348 +++++++++ .../FilesCompare/DiffResult/InlineDiff.swift | 135 ++++ .../DiffResult/DiffLine+Color.swift | 14 + .../Legacy/DiffEngine/UDiffScriptBuilder.h | 59 -- .../Legacy/DiffEngine/UDiffScriptBuilder.m | 104 --- Sources/Legacy/DiffEngine/UnifiedDiff.h | 79 -- Sources/Legacy/DiffEngine/UnifiedDiff.m | 739 ------------------ .../Extensions/String/String+Highlights.swift | 22 + .../Utilities/String/DisplayLine.swift | 29 + .../Utilities/String/VisibleWhitespaces.swift | 52 +- .../DiffResult/Tests/DiffResultTests.swift | 58 ++ .../DiffResult/Tests/InlineDiffTests.swift | 257 ++++++ .../DiffResult/Tests/SequenceDiffTests.swift | 128 +++ .../DiffResult/Tests/WhitespacesTests.swift | 29 +- VisualDiffer-Bridging-Header.h | 2 - VisualDiffer.xcodeproj/project.pbxproj | 65 +- 37 files changed, 1806 insertions(+), 1123 deletions(-) create mode 100644 Sources/Features/FilesCompare/DiffResult/Engine/DiffChange.swift create mode 100644 Sources/Features/FilesCompare/DiffResult/Engine/DiffFileData.swift create mode 100644 Sources/Features/FilesCompare/DiffResult/Engine/EquivalenceTable.swift create mode 100644 Sources/Features/FilesCompare/DiffResult/Engine/SequenceDiff.swift create mode 100644 Sources/Features/FilesCompare/DiffResult/InlineDiff.swift delete mode 100644 Sources/Legacy/DiffEngine/UDiffScriptBuilder.h delete mode 100644 Sources/Legacy/DiffEngine/UDiffScriptBuilder.m delete mode 100644 Sources/Legacy/DiffEngine/UnifiedDiff.h delete mode 100644 Sources/Legacy/DiffEngine/UnifiedDiff.m create mode 100644 Sources/SharedKit/Utilities/String/DisplayLine.swift create mode 100644 Tests/Sources/FileCompare/DiffResult/Tests/InlineDiffTests.swift create mode 100644 Tests/Sources/FileCompare/DiffResult/Tests/SequenceDiffTests.swift diff --git a/AppDefaults/colors.json b/AppDefaults/colors.json index 5ab5c47..a09c4c6 100644 --- a/AppDefaults/colors.json +++ b/AppDefaults/colors.json @@ -1,9 +1,4 @@ { - "menuColorsMap": { - "openWithDefaultAppContext": { - "textColor":"#0000ff" - } - }, "consoleLogColorsMap": { "info": { @@ -83,6 +78,11 @@ "backgroundColor":"#e4f1d5" }, + "changedInline": { + "textColor": "#5A1010", + "backgroundColor": "#FBE38A" + }, + "added": { "textColor": "#0000FF", "backgroundColor":"#DCE5F5" diff --git a/AppDefaults/colorsDark.json b/AppDefaults/colorsDark.json index 6f95e05..2de04fb 100644 --- a/AppDefaults/colorsDark.json +++ b/AppDefaults/colorsDark.json @@ -1,9 +1,4 @@ { - "menuColorsMap": { - "openWithDefaultAppContext": { - "textColor":"#FF9300" - } - }, "consoleLogColorsMap": { "info": { @@ -80,7 +75,12 @@ { "changed": { "textColor": "#C4EFB7", - "backgroundColor":"#5B8B6D" + "backgroundColor":"#2E4A38" + }, + + "changedInline": { + "textColor": "#2E4A38", + "backgroundColor":"#FFC24B" }, "added": { diff --git a/Sources/Core/CommonPrefs/CommonPrefs+FileCompare.swift b/Sources/Core/CommonPrefs/CommonPrefs+FileCompare.swift index b7c034e..0116f5f 100644 --- a/Sources/Core/CommonPrefs/CommonPrefs+FileCompare.swift +++ b/Sources/Core/CommonPrefs/CommonPrefs+FileCompare.swift @@ -11,6 +11,7 @@ let defaultTabWidth = 4 enum FileColorAttribute: String { case added case changed + case changedInline case deleted case lineNumber case lineNumberSeparator diff --git a/Sources/Features/FilesCompare/Components/LineDetailTextView.swift b/Sources/Features/FilesCompare/Components/LineDetailTextView.swift index 9d67c3c..2de0200 100644 --- a/Sources/Features/FilesCompare/Components/LineDetailTextView.swift +++ b/Sources/Features/FilesCompare/Components/LineDetailTextView.swift @@ -87,12 +87,16 @@ final class LineDetailTextView: NSTextView { } func updateContent( + _ diffLine: DiffLine, text: String, lineEnding: String, - textColor: NSColor?, - backgroundColor: NSColor? + highlightRanges: [Range] ) { - string = text + lineEnding + let textColor = diffLine.colors?.text + let backgroundColor = diffLine.colors?.background + let content = text + lineEnding + + string = content // use the NSString length because NSTextView selection ranges are NSRange-based protectedSuffixLength = (lineEnding as NSString).length @@ -102,6 +106,7 @@ final class LineDetailTextView: NSTextView { setTextColor(.controlTextColor, backgroundColor: .clear) } + applyInlineHighlight(in: content, ranges: highlightRanges, colors: diffLine.inlineColors) applyLineEndingStyle(textColor: textColor, backgroundColor: backgroundColor) } @@ -183,6 +188,21 @@ final class LineDetailTextView: NSTextView { return (lighterColor + Self.contrastRatioBias) / (darkerColor + Self.contrastRatioBias) } + + private func applyInlineHighlight(in content: String, ranges: [Range], colors: ColorSet?) { + guard !ranges.isEmpty, + let textStorage, + let inlineColors = colors else { + return + } + + for range in content.characterRanges(from: ranges) { + let highlightRange = NSRange(range, in: content) + + textStorage.addAttribute(.foregroundColor, value: inlineColors.text, range: highlightRange) + textStorage.addAttribute(.backgroundColor, value: inlineColors.background, range: highlightRange) + } + } } private extension NSColor { diff --git a/Sources/Features/FilesCompare/Components/LineNumberTableCellView.swift b/Sources/Features/FilesCompare/Components/LineNumberTableCellView.swift index 8b200b3..ecdce0a 100644 --- a/Sources/Features/FilesCompare/Components/LineNumberTableCellView.swift +++ b/Sources/Features/FilesCompare/Components/LineNumberTableCellView.swift @@ -87,31 +87,48 @@ class LineNumberTableCellView: NSTableCellView { } } + // character offsets relative to formattedText + var inlineRanges = [Range]() { + didSet { + if inlineRanges != oldValue { + updateContent() + } + } + } + var font: NSFont? { didSet { lineNumberTextField.font = font contentTextField.font = font - updateContent() + + // a recycled cell keeps the font of the panel, redrawing it again would + // rebuild the whole content for nothing + if font != oldValue { + updateContent() + } } } var isWordWrapEnabled: Bool = false { didSet { updateWordWrapSettings() - updateContent() + + if isWordWrapEnabled != oldValue { + updateContent() + } } } var isHighlighted = false { didSet { - updateColors() + updateColorsAndText() } } dynamic var isSelected: Bool = false { didSet { if oldValue != isSelected { - updateColors() + updateColorsAndText() } } } @@ -123,9 +140,9 @@ class LineNumberTableCellView: NSTableCellView { setupConstraints() } - @available(*, unavailable) - required init(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") + @available(*, unavailable, message: "use init(frame:)") + required init?(coder _: NSCoder) { + nil } // MARK: - Setup @@ -204,14 +221,62 @@ class LineNumberTableCellView: NSTableCellView { if diffLine.number > 0 { showLineNumberAndText() lineNumberTextField.stringValue = String(format: "%ld", diffLine.number) - contentTextField.stringValue = formattedText ?? diffLine.text updateColors() + updateText(diffLine) updateSectionSeparator() } else { showMissingLineImage() } } + /// the highlighted text has to be rebuilt together with the colors, because textColor + /// does not override the foreground attributes of an attributed string, a plain text + /// follows textColor and can be left alone + private func updateColorsAndText() { + updateColors() + + if !inlineRanges.isEmpty, let diffLine, diffLine.number > 0 { + updateText(diffLine) + } + } + + private func updateText(_ diffLine: DiffLine) { + guard let text = formattedText, + !inlineRanges.isEmpty, + let font, + let inlineColors = diffLine.inlineColors else { + contentTextField.stringValue = formattedText ?? diffLine.text + return + } + + let paragraphStyle = NSMutableParagraphStyle() + + paragraphStyle.lineBreakMode = contentTextField.lineBreakMode + + // the highlight changes colors only, a bolder font would change the glyph + // advances and break the alignment between the two panels + let normalStyle: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: diffLine.color(for: .text, isSelected: isSelected), + .paragraphStyle: paragraphStyle, + ] + // the background is painted over the selection too, whitespace has no ink to + // colour, so without the patch a difference made of spaces or tabs would show + // nothing on a selected row + let highlightStyle: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: inlineColors.text, + .paragraphStyle: paragraphStyle, + .backgroundColor: inlineColors.background, + ] + + contentTextField.attributedStringValue = text.highlights( + text.characterRanges(from: inlineRanges), + normalStyle: normalStyle, + highlightStyle: highlightStyle + ) + } + private func showLineNumberAndText() { lineNumberTextField.isHidden = false contentTextField.isHidden = false diff --git a/Sources/Features/FilesCompare/Controller/FilesWindowController+Common.swift b/Sources/Features/FilesCompare/Controller/FilesWindowController+Common.swift index 1f99c12..890c931 100644 --- a/Sources/Features/FilesCompare/Controller/FilesWindowController+Common.swift +++ b/Sources/Features/FilesCompare/Controller/FilesWindowController+Common.swift @@ -24,7 +24,7 @@ extension FilesWindowController { // refresh sections otherwise moving between differences can position to wrong line diffResult.refreshSections() differenceCounters.update(counters: DiffCountersItem.diffCounter(withResult: diffResult)) - cachedLineTextMap.removeAllObjects() + cachedLineTextMap.removeAll() let selectedRow = min( selectedRow < 0 ? lastUsedView.selectedRow : selectedRow, @@ -87,28 +87,64 @@ extension FilesWindowController { let lineEnding = scopeBar.showWhitespaces ? "" : diffLine.component.eol.visibleSymbol lineView.updateContent( + diffLine, text: getLine(diffLine), lineEnding: lineEnding, - textColor: diffLine.colors?.text, - backgroundColor: diffLine.colors?.background + highlightRanges: inlineDisplayRanges(diffLine) ) } // MARK: - Cache lines - func getLine(_ diffLine: DiffLine) -> String { - if let line = cachedLineTextMap.object(forKey: diffLine) as? String { - return line as String - } - let line = visibleWhitespaces.getString( + // only the text is cached, the offset mapping costs an array per line and is needed + // by the few changed lines carrying inline differences, so it is rebuilt on demand + func displayLine(_ diffLine: DiffLine) -> DisplayLine { + visibleWhitespaces.getString( diffLine.component, isWhitespacesVisible: scopeBar.showWhitespaces ) - cachedLineTextMap.setObject(line as NSString, forKey: diffLine) + } + + func getLine(_ diffLine: DiffLine) -> String { + if let line = cachedLineTextMap[diffLine] { + return line + } + let line = displayLine(diffLine).text + + cachedLineTextMap[diffLine] = line return line } + // the offsets are relative to the text drawn from the given column, so the highlight + // follows the horizontal scrolling of the panels + func inlineDisplayRanges(_ diffLine: DiffLine, startingColumn: Int = 0) -> [Range] { + guard diffLine.type == .changed, !diffLine.inlineRanges.isEmpty else { + return [] + } + + let display = displayLine(diffLine) + let visibleCount = display.text.count - startingColumn + + if visibleCount < 0 { + return [] + } + var ranges = [Range]() + + ranges.reserveCapacity(diffLine.inlineRanges.count) + + for range in diffLine.inlineRanges { + let lower = max(0, display.displayOffset(for: range.lowerBound) - startingColumn) + let upper = min(visibleCount, display.displayOffset(for: range.upperBound) - startingColumn) + + if lower < upper { + ranges.append(lower ..< upper) + } + } + + return ranges + } + // MARK: - Actions @objc diff --git a/Sources/Features/FilesCompare/Controller/FilesWindowController+FilesTableViewContextMenu.swift b/Sources/Features/FilesCompare/Controller/FilesWindowController+FilesTableViewContextMenu.swift index 1d54306..2b3eec9 100644 --- a/Sources/Features/FilesCompare/Controller/FilesWindowController+FilesTableViewContextMenu.swift +++ b/Sources/Features/FilesCompare/Controller/FilesWindowController+FilesTableViewContextMenu.swift @@ -26,17 +26,17 @@ extension FilesWindowController: @preconcurrency FilesTableViewContextMenu { @objc func showWhitespaces(_ sender: AnyObject?) { - cachedLineTextMap.removeAllObjects() + cachedLineTextMap.removeAll() // the scopeBar button toggles automatically but when this method is called // from the menu we set it manually if sender !== scopeBar { scopeBar.showWhitespaces(!scopeBar.showWhitespaces, informDelegate: false) } - updateDetailLines(leftView.selectedRow) - leftView.reloadData(restoreSelection: true) rightView.reloadData(restoreSelection: true) + + updateDetailLines(lastUsedView.selectedRow) } @objc diff --git a/Sources/Features/FilesCompare/Controller/FilesWindowController+MenuDelegate.swift b/Sources/Features/FilesCompare/Controller/FilesWindowController+MenuDelegate.swift index 5203dbf..be4312e 100644 --- a/Sources/Features/FilesCompare/Controller/FilesWindowController+MenuDelegate.swift +++ b/Sources/Features/FilesCompare/Controller/FilesWindowController+MenuDelegate.swift @@ -87,7 +87,11 @@ extension FilesWindowController: NSMenuDelegate, NSMenuItemValidation { item.title = NSLocalizedString("Delete Lines from Right", comment: "") return !sessionDiff.rightReadOnly && rightView.isEditAllowed } - } else if action == #selector(pasteLinesToClipboard) || action == #selector(cutToClipboard) { + } else if action == #selector(pasteLinesToClipboard) + || action == #selector(cutToClipboard) + // the Edit menu binds the public actions, they must not skip these checks + || action == #selector(paste(_:)) + || action == #selector(cut(_:)) { if scopeBar.showLinesFilter == .all { if isLeftView { return !sessionDiff.leftReadOnly && leftView.isEditAllowed diff --git a/Sources/Features/FilesCompare/Controller/FilesWindowController+Read.swift b/Sources/Features/FilesCompare/Controller/FilesWindowController+Read.swift index 628c97b..39ac508 100644 --- a/Sources/Features/FilesCompare/Controller/FilesWindowController+Read.swift +++ b/Sources/Features/FilesCompare/Controller/FilesWindowController+Read.swift @@ -41,15 +41,14 @@ extension FilesWindowController { rightLines: [DiffLineComponent], moveToFirstDifference: Bool ) { - let newDiffResult = DiffResult() + let newDiffResult = DiffResult(options: preferences.diffResultOptions) diffResult = newDiffResult currentDiffResult = nil newDiffResult.diff( leftLines: leftLines, - rightLines: rightLines, - options: preferences.diffResultOptions + rightLines: rightLines ) setSliderMaxValue() differenceCounters.update(counters: DiffCountersItem.diffCounter(withResult: newDiffResult)) diff --git a/Sources/Features/FilesCompare/Controller/FilesWindowController+TableView.swift b/Sources/Features/FilesCompare/Controller/FilesWindowController+TableView.swift index 63d0e7c..42f215f 100644 --- a/Sources/Features/FilesCompare/Controller/FilesWindowController+TableView.swift +++ b/Sources/Features/FilesCompare/Controller/FilesWindowController+TableView.swift @@ -45,6 +45,7 @@ extension FilesWindowController: NSTableViewDataSource, view.diffLine = diffLine view.font = currentFont view.isSelected = tableView.isRowSelected(row) + view.inlineRanges = inlineDisplayRanges(diffLine, startingColumn: leftPanelView.columnSlider.integerValue) view.formattedText = formattedText(diffLine) view.isWordWrapEnabled = rowHeightCalculator.isWordWrapEnabled view.lineNumberWidth = lineNumberWidth diff --git a/Sources/Features/FilesCompare/Controller/FilesWindowController+UISetup.swift b/Sources/Features/FilesCompare/Controller/FilesWindowController+UISetup.swift index 89a127f..f504d99 100644 --- a/Sources/Features/FilesCompare/Controller/FilesWindowController+UISetup.swift +++ b/Sources/Features/FilesCompare/Controller/FilesWindowController+UISetup.swift @@ -102,13 +102,13 @@ extension FilesWindowController { updateTreeViewFont() // invalidate the line text cache - cachedLineTextMap.removeAllObjects() + cachedLineTextMap.removeAll() reloadRowHeights() updateTabWidth() - updateDetailLines(leftView.selectedRow) + updateDetailLines(lastUsedView.selectedRow) updateStatusbarText() } @@ -231,14 +231,15 @@ extension FilesWindowController { @objc func appAppearanceDidChange(_: Notification) { - leftView.reloadData() - rightView.reloadData() + leftView.reloadData(restoreSelection: true) + rightView.reloadData(restoreSelection: true) guard let diffResult else { return } differenceCounters.update(counters: DiffCountersItem.diffCounter(withResult: diffResult)) + updateDetailLines(lastUsedView.selectedRow) } // MARK: - File and view scroll diff --git a/Sources/Features/FilesCompare/Controller/FilesWindowController.swift b/Sources/Features/FilesCompare/Controller/FilesWindowController.swift index 94c1446..f948572 100644 --- a/Sources/Features/FilesCompare/Controller/FilesWindowController.swift +++ b/Sources/Features/FilesCompare/Controller/FilesWindowController.swift @@ -14,7 +14,8 @@ class FilesWindowController: NSWindowController { // swiftlint:disable:next implicitly_unwrapped_optional @objc var sessionDiff: SessionDiff! - let cachedLineTextMap: NSMapTable + // keyed by line identity, the dictionary keeps the lines alive as long as they are cached + var cachedLineTextMap = [DiffLine: String]() var currentFont: NSFont var fontZoomFactor: CGFloat = 0 { @@ -76,11 +77,6 @@ class FilesWindowController: NSWindowController { visibleWhitespaces = VisibleWhitespaces() visibleWhitespaces.tabWidth = CommonPrefs.shared.tabWidth - cachedLineTextMap = NSMapTable( - keyOptions: .objectPointerPersonality, - valueOptions: .strongMemory - ) - // panels leftPanelView = FilePanelView(side: .left) rightPanelView = FilePanelView(side: .right) @@ -126,9 +122,9 @@ class FilesWindowController: NSWindowController { filePanel.setSliderChangeAction(target, action: action) } - @available(*, unavailable) - required init(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") + @available(*, unavailable, message: "use init()") + required init?(coder _: NSCoder) { + nil } // MARK: - Zoom Font diff --git a/Sources/Features/FilesCompare/DiffResult/DiffLine.swift b/Sources/Features/FilesCompare/DiffResult/DiffLine.swift index e6f0dc6..5bb26de 100644 --- a/Sources/Features/FilesCompare/DiffResult/DiffLine.swift +++ b/Sources/Features/FilesCompare/DiffResult/DiffLine.swift @@ -24,11 +24,21 @@ class DiffLine { var number: Int // mode is used by UI to determine how to draw line var mode: DisplayMode = .normal - var component: DiffLineComponent + var component: DiffLineComponent { + didSet { + // the offsets belong to the replaced text, they cannot survive it + inlineRanges = [] + } + } + var isSectionSeparator = false var filteredIndex = 0 var hasIgnoredDifferences: Bool + // character offsets, relative to component.text, that differ from the paired line, + // meaningful only when type is .changed + var inlineRanges = [Range]() + var text: String { component.text } @@ -66,3 +76,15 @@ extension DiffLine: CustomStringConvertible { String(format: "%ld %@ : %@", number, type.description, text) } } + +/// identity semantics, a line is a mutable reference and two distinct lines stay +/// distinct even while they hold the same text +extension DiffLine: Hashable { + static func == (lhs: DiffLine, rhs: DiffLine) -> Bool { + lhs === rhs + } + + func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} diff --git a/Sources/Features/FilesCompare/DiffResult/DiffProcessor.swift b/Sources/Features/FilesCompare/DiffResult/DiffProcessor.swift index b8f1008..e22fc34 100644 --- a/Sources/Features/FilesCompare/DiffResult/DiffProcessor.swift +++ b/Sources/Features/FilesCompare/DiffResult/DiffProcessor.swift @@ -29,7 +29,7 @@ struct DiffProcessor { self.options = options } - mutating func process(changes: UDiffChange?) { + mutating func process(changes: [DiffChange]) { sections.removeAll(keepingCapacity: true) summary.reset() @@ -39,22 +39,20 @@ struct DiffProcessor { leftIndex = 0 rightIndex = 0 - var script = changes - - while let localScript = script { + for change in changes { updateMatchingLines( - leftCount: Int(localScript.line0), - rightCount: Int(localScript.line1) + leftCount: change.line0, + rightCount: change.line1 ) - let commonLines = Int(min(localScript.deleted, localScript.inserted)) - let diffLines = Int(localScript.deleted - localScript.inserted) + let commonLines = min(change.deleted, change.inserted) + let diffLines = change.deleted - change.inserted var start = leftSide.lines.count // Create a new section to distinguish adjacent // .deleted from .added lines - if commonLines > 0, localScript.deleted != localScript.inserted { + if commonLines > 0, change.deleted != change.inserted { sections.append(DiffSection( start: start, end: leftSide.lines.count + commonLines - 1 @@ -80,8 +78,6 @@ struct DiffProcessor { start: start, end: leftSide.lines.count - 1 )) - - script = localScript.link } updateMatchingLines( @@ -156,6 +152,9 @@ struct DiffProcessor { component: rightLines[rightIndex] ) rightSide.add(line: rightLine) + + InlineDiff.apply(to: leftLine, and: rightLine, options: options) + leftIndex += 1 rightIndex += 1 } diff --git a/Sources/Features/FilesCompare/DiffResult/DiffResult+Dump.swift b/Sources/Features/FilesCompare/DiffResult/DiffResult+Dump.swift index 1412ff6..38a737c 100644 --- a/Sources/Features/FilesCompare/DiffResult/DiffResult+Dump.swift +++ b/Sources/Features/FilesCompare/DiffResult/DiffResult+Dump.swift @@ -84,6 +84,7 @@ DiffResult.writeLine(fileHandle, line: "type = \(type) number = \(number) mode = \(mode)") // DiffResult.writeLine(fileHandle, line: "text = \(text)") DiffResult.writeLine(fileHandle, line: "isSectionSeparator = \(isSectionSeparator) filteredIndex = \(filteredIndex)") + DiffResult.writeLine(fileHandle, line: "inlineRanges = \(inlineRanges)") } } diff --git a/Sources/Features/FilesCompare/DiffResult/DiffResult+Lines.swift b/Sources/Features/FilesCompare/DiffResult/DiffResult+Lines.swift index b118a67..e5ba906 100644 --- a/Sources/Features/FilesCompare/DiffResult/DiffResult+Lines.swift +++ b/Sources/Features/FilesCompare/DiffResult/DiffResult+Lines.swift @@ -135,7 +135,10 @@ extension LineEditOperation { extension DiffResult { static func justDifferentLines(_ result: DiffResult) -> DiffResult { - let onlyMismatches = DiffResult(sections: DiffSection.compact(sections: result.sections)) + let onlyMismatches = DiffResult( + sections: DiffSection.compact(sections: result.sections), + options: result.options + ) onlyMismatches.inheritSideMetadata(from: result) let leftSide = onlyMismatches.leftSide @@ -188,7 +191,7 @@ extension DiffResult { static func justMatchingLines(_ result: DiffResult) -> DiffResult { // difference sections are not visible - let onlyMatches = DiffResult() + let onlyMatches = DiffResult(options: result.options) onlyMatches.inheritSideMetadata(from: result) let leftSide = onlyMatches.leftSide @@ -228,7 +231,6 @@ extension DiffResult { return onlyMatches } - // a filtered result must inherit the per-side metadata of its source result private func inheritSideMetadata(from result: DiffResult) { leftSide.eol = result.leftSide.eol rightSide.eol = result.rightSide.eol diff --git a/Sources/Features/FilesCompare/DiffResult/DiffResult.Options.swift b/Sources/Features/FilesCompare/DiffResult/DiffResult.Options.swift index 8c0decd..4b9fff9 100644 --- a/Sources/Features/FilesCompare/DiffResult/DiffResult.Options.swift +++ b/Sources/Features/FilesCompare/DiffResult/DiffResult.Options.swift @@ -92,4 +92,10 @@ extension DiffResult.Options { return leftLine.text != rightLine.text } + + /// the key the comparison engine compares, every path classifying a pair of lines + /// must build it the same way or the same pair would be classified differently + func key(for component: DiffLineComponent) -> SequenceDiff.TextKey { + SequenceDiff.TextKey(applyTransformations(component: component)) + } } diff --git a/Sources/Features/FilesCompare/DiffResult/DiffResult.swift b/Sources/Features/FilesCompare/DiffResult/DiffResult.swift index 3a27d27..8491120 100644 --- a/Sources/Features/FilesCompare/DiffResult/DiffResult.swift +++ b/Sources/Features/FilesCompare/DiffResult/DiffResult.swift @@ -22,8 +22,16 @@ class DiffResult { private(set) var sections = [DiffSection]() private(set) var summary = DiffSummary() - convenience init(sections: [DiffSection]) { - self.init() + // the options the comparison ran with, the editing paths must classify a pair + // of lines with the same equality the engine used + let options: Options + + init(options: Options = []) { + self.options = options + } + + convenience init(sections: [DiffSection], options: Options) { + self.init(options: options) self.sections = sections } @@ -41,42 +49,29 @@ class DiffResult { // periphery:ignore func diff( leftText: String, - rightText: String, - options: Options = [] + rightText: String ) { diff( leftLines: DiffLineComponent.splitLines(leftText), - rightLines: DiffLineComponent.splitLines(rightText), - options: options + rightLines: DiffLineComponent.splitLines(rightText) ) } func diff( leftLines: [DiffLineComponent], - rightLines: [DiffLineComponent], - options: Options = [] + rightLines: [DiffLineComponent] ) { - let stringifier: (Any) -> String = { - guard let component = $0 as? DiffLineComponent else { - preconditionFailure("Expected a DiffLineComponent") - } - - return options.applyTransformations(component: component) - } - - let udiff = UnifiedDiff( - originalLines: leftLines, - revisedLines: rightLines, - stringifier: stringifier + let changes = SequenceDiff.changes( + left: leftLines.map { options.key(for: $0) }, + right: rightLines.map { options.key(for: $0) } ) - let script = udiff.diff_2(false) var processor = DiffProcessor( leftLines: leftLines, rightLines: rightLines, options: options ) - processor.process(changes: script) + processor.process(changes: changes) leftSide = processor.leftSide rightSide = processor.rightSide @@ -123,26 +118,36 @@ class DiffResult { } var index = startIndex - DiffLineComponent.enumerateLines(text: text) { component in + DiffLineComponent.enumerateLines(text: text) { pastedComponent in // adds missing lines to both diff sides to accommodate insertion at the specified index if index == destination.lines.count { destination.insert(DiffLine.missingLine(), at: index) otherSide.insert(DiffLine.missingLine(), at: index) } + let component = self.adoptingEOL(of: destination, for: pastedComponent) let destLine = destination.lines[index] if destLine.type == .missing { destLine.component = component let otherLine = otherSide.lines[index] - // simple comparison - if otherLine.text == component.text { + + if self.matches(otherLine.component, component) { otherLine.type = .matching destLine.type = .matching + + let hasIgnored = self.options.containsIgnoreFor( + differenceBetween: otherLine.component, + and: component + ) + otherLine.hasIgnoredDifferences = hasIgnored + destLine.hasIgnoredDifferences = hasIgnored } else { if otherLine.type == .missing { destLine.type = type } else { otherLine.type = .changed destLine.type = .changed + + InlineDiff.apply(to: otherLine, and: destLine, options: self.options) } } } else { @@ -179,4 +184,25 @@ class DiffResult { func refreshSummary() { summary.refresh(leftSide.lines) } + + // a weaker comparison than the engine one would show a pair as matching while the + // two lines differ, and the difference would appear out of nowhere on the next comparison + private func matches(_ leftLine: DiffLineComponent, _ rightLine: DiffLineComponent) -> Bool { + options.key(for: leftLine) == options.key(for: rightLine) + } + + // pasted text always carries a unix line ending, the destination one must win, + // as it does when copying lines between the two sides + private func adoptingEOL( + of destination: DiffSide, + for component: DiffLineComponent + ) -> DiffLineComponent { + guard destination.eol != .mixed, + destination.eol != .missing, + component.eol != .missing else { + return component + } + + return DiffLineComponent(text: component.text, eol: destination.eol) + } } diff --git a/Sources/Features/FilesCompare/DiffResult/Engine/DiffChange.swift b/Sources/Features/FilesCompare/DiffResult/Engine/DiffChange.swift new file mode 100644 index 0000000..d23b9ba --- /dev/null +++ b/Sources/Features/FilesCompare/DiffResult/Engine/DiffChange.swift @@ -0,0 +1,27 @@ +// +// DiffChange.swift +// VisualDiffer +// +// Created by davide ficano on 12/08/26. +// Copyright (c) 2026 visualdiffer.com +// + +/// one place where some elements are deleted and some are inserted +/// +/// `line0` and `line1` are the first affected elements in the two sequences (origin 0), +/// `deleted` is the number of elements removed from the left sequence and `inserted` +/// the number of elements added from the right one. +/// When `deleted` is 0 then `line0` is the element before which the insertion was done, +/// and vice versa for `inserted` and `line1`. +struct DiffChange: Equatable { + let line0: Int + let line1: Int + let deleted: Int + let inserted: Int +} + +extension DiffChange: CustomStringConvertible { + var description: String { + "\(line0 + 1), \(line1 + 1) inserted \(inserted), deleted \(deleted)" + } +} diff --git a/Sources/Features/FilesCompare/DiffResult/Engine/DiffFileData.swift b/Sources/Features/FilesCompare/DiffResult/Engine/DiffFileData.swift new file mode 100644 index 0000000..b692046 --- /dev/null +++ b/Sources/Features/FilesCompare/DiffResult/Engine/DiffFileData.swift @@ -0,0 +1,384 @@ +// +// DiffFileData.swift +// VisualDiffer +// +// Created by davide ficano on 12/08/26. +// Copyright (c) 2026 visualdiffer.com +// + +/// data on one input sequence being compared +final class DiffFileData { + /// how a single element is classified before the comparison runs + private enum Discard { + case keep + case discardable + case provisional + } + + private static let manyMatches = 5 + private static let matchScaleDivisor = 64 + private static let provisionalRunDivisor = 4 + private static let nonProvisionalRunLength = 3 + private static let provisionalScanLimit = 8 + + /// vector, indexed by element number, containing an equivalence code for each element, + /// it is this vector that is actually compared with that of another sequence + private let equivalences: [Int] + + /// array, indexed by real origin-1 element number, containing true for an element + /// that is an insertion or a deletion + private(set) var changedFlags = [Bool]() + + /// vector, like `equivalences` except that the elements for discarded ones + /// have been squeezed out + private(set) var undiscarded: [Int] + + /// vector mapping virtual element numbers (not counting discarded elements) to real + /// ones (counting those elements), both are origin-0 + private var realIndexes: [Int] + + /// total number of nondiscarded elements + private(set) var nonDiscardedCount = 0 + + /// when true nothing is discarded and the comparison considers the whole sequence + private let ignoresDiscards: Bool + + private let codeCount: Int + + var count: Int { + equivalences.count + } + + init( + equivalences: [Int], + codeCount: Int, + ignoresDiscards: Bool + ) { + self.equivalences = equivalences + self.codeCount = codeCount + self.ignoresDiscards = ignoresDiscards + undiscarded = Array(repeating: 0, count: equivalences.count) + realIndexes = Array(repeating: 0, count: equivalences.count) + } + + /// Discard elements that have no matches in another sequence. + /// + /// A discarded element will not be considered by the actual comparison algorithm, + /// it will be as if that element were not in the sequence. + /// The `realIndexes` table maps virtual element numbers (which don't count the discarded + /// elements) into real element numbers, this is how the actual comparison algorithm + /// produces results that are comprehensible when the discarded elements are counted. + /// + /// When an element is discarded it is also marked as a deletion or insertion + /// so that it will be reported in the edit script. + /// - Parameter other: the sequence being compared against + func discardConfusingElements(against other: DiffFileData) { + clearChangedFlags() + + // set up table of which elements are going to be discarded + var discards = discardable(counts: other.equivalenceCounts()) + + // don't really discard the provisional elements except when they occur + // in a run of discardables, with nonprovisionals at the beginning and end + filterDiscards(&discards) + + // actually discard the elements + discard(discards) + } + + /// Adjust inserts/deletes of blank elements to join changes as much as possible. + /// + /// We do something when a run of changed elements include a blank element at one end + /// and have an excluded blank element at the other. + /// We are free to choose which blank element is included, the comparison always chooses + /// the one at the beginning, but usually it is cleaner to consider the following blank + /// element to be the "change". The only exception is if the preceding blank element + /// would join this change to other changes. + /// - Parameter other: the sequence being compared against + func shiftBoundaries(against other: DiffFileData) { + let endIndex = count + var index = 0 + var otherIndex = 0 + var preceding = -1 + var otherPreceding = -1 + + while true { + // scan forwards to find beginning of another run of changes, + // also keep track of the corresponding point in the other sequence + while index < endIndex, !changedFlags[1 + index] { + while true { + let isChanged = other.changedFlags[1 + otherIndex] + + otherIndex += 1 + + if !isChanged { + break + } + // non-corresponding elements in the other sequence + // will count as the preceding batch of changes + otherPreceding = otherIndex + } + index += 1 + } + + if index == endIndex { + break + } + var start = index + let otherStart = otherIndex + + while true { + // now find the end of this run of changes + while index < endIndex, changedFlags[1 + index] { + index += 1 + } + let end = index + + // if the first changed element matches the following unchanged one, + // and this run does not follow right after a previous run, + // and there are no elements deleted from the other sequence here, + // then classify the first changed element as unchanged + // and the following element as changed in its place + + // you might ask, how could this run follow right after another? + // only because the previous run was shifted here + guard end != endIndex, + equivalences[start] == equivalences[end], + !other.changedFlags[1 + otherIndex], + !((preceding >= 0 && start == preceding) + || (otherPreceding >= 0 && otherStart == otherPreceding)) + else { + break + } + + changedFlags[1 + end] = true + changedFlags[1 + start] = false + start += 1 + index += 1 + // since one element-that-matches is now before this run instead of after, + // we must advance in the other sequence to keep in synch + otherIndex += 1 + } + + preceding = index + otherPreceding = otherIndex + } + } + + /// marks a nondiscarded element as an insertion or a deletion + func markChanged(virtualIndex: Int) { + changedFlags[1 + realIndexes[virtualIndex]] = true + } + + /// allocate a flag for each element, saying whether that element is an insertion + /// or a deletion, with an extra always false element at each end of the vector + private func clearChangedFlags() { + changedFlags = Array(repeating: false, count: count + 2) + } + + private func equivalenceCounts() -> [Int] { + var counts = Array(repeating: 0, count: codeCount) + + for code in equivalences { + counts[code] += 1 + } + + return counts + } + + /// Mark to be discarded each element that matches no element of another sequence, + /// if an element matches many elements mark it as provisionally discardable. + /// - Parameter counts: the count of each equivalence code for the other sequence + private func discardable(counts: [Int]) -> [Discard] { + var discards = Array(repeating: Discard.keep, count: count) + + // multiply many by approximate square root of number of elements, + // that is the threshold for provisionally discardable elements + let many = Self.scaled( + Self.manyMatches, + bySquareRootOf: count / Self.matchScaleDivisor + ) + + for (index, code) in equivalences.enumerated() { + let matches = counts[code] + + if matches == 0 { + discards[index] = .discardable + } else if matches > many { + discards[index] = .provisional + } + } + + return discards + } + + /// don't really discard the provisional elements except when they occur in a run + /// of discardables, with nonprovisionals at the beginning and end + private func filterDiscards(_ discards: inout [Discard]) { + var index = 0 + + while index < count { + // cancel provisional discards not in middle of run of discards + if discards[index] == .provisional { + discards[index] = .keep + } else if discards[index] != .keep { + // we have found a nonprovisional discard + index = filterRun(from: index, discards: &discards) + } + index += 1 + } + } + + /// filters a single run of discardable elements + /// - Returns: the last element index consumed by the run + private func filterRun(from start: Int, discards: inout [Discard]) -> Int { + var provisional = 0 + var runEnd = start + + // find end of this run of discardable elements, + // count how many are provisionally discardable + while runEnd < count, discards[runEnd] != .keep { + if discards[runEnd] == .provisional { + provisional += 1 + } + runEnd += 1 + } + + // cancel provisional discards at end, and shrink the run + while runEnd > start, discards[runEnd - 1] == .provisional { + runEnd -= 1 + discards[runEnd] = .keep + provisional -= 1 + } + // now we have the length of a run of discardable elements + // whose first and last are not provisional + let length = runEnd - start + + // if 1/4 of the elements in the run are provisional, + // cancel discarding of all provisional elements in the run + if provisional * Self.provisionalRunDivisor > length { + var index = runEnd + + while index > start { + index -= 1 + + if discards[index] == .provisional { + discards[index] = .keep + } + } + + return start + } + cancelProvisionalSubruns(from: start, length: length, discards: &discards) + cancelProvisionalsAtRunEdges(from: start, length: length, discards: &discards) + + return start + length - 1 + } + + /// cancel any subrun of a minimum number of provisionals within the larger run + private func cancelProvisionalSubruns( + from start: Int, + length: Int, + discards: inout [Discard] + ) { + // minimum is approximate square root of length/4, + // a subrun of two or more provisionals can stand when length is at least 16, + // a subrun of 4 or more can stand when length is at least 64 + let minimum = Self.scaled( + 1, + bySquareRootOf: length / Self.provisionalRunDivisor + ) + 1 + + var offset = 0 + var consecutive = 0 + + while offset < length { + if discards[start + offset] == .provisional { + consecutive += 1 + + if consecutive == minimum { + // back up to start of subrun, to cancel it all + offset -= consecutive + } else if consecutive > minimum { + discards[start + offset] = .keep + } + } else { + consecutive = 0 + } + offset += 1 + } + } + + /// scan from both ends of the run until 3 or more nonprovisionals in a row are found, + /// or until the first nonprovisional at least 8 elements in, cancelling any provisional + /// met until that point + private func cancelProvisionalsAtRunEdges( + from start: Int, + length: Int, + discards: inout [Discard] + ) { + cancelProvisionals(from: start, step: 1, length: length, discards: &discards) + cancelProvisionals(from: start + length - 1, step: -1, length: length, discards: &discards) + } + + private func cancelProvisionals( + from origin: Int, + step: Int, + length: Int, + discards: inout [Discard] + ) { + var consecutive = 0 + + for offset in 0 ..< length { + let index = origin + step * offset + + if offset >= Self.provisionalScanLimit, discards[index] == .discardable { + break + } + + if discards[index] == .provisional { + consecutive = 0 + discards[index] = .keep + } else if discards[index] == .keep { + consecutive = 0 + } else { + consecutive += 1 + } + + if consecutive == Self.nonProvisionalRunLength { + break + } + } + } + + /// actually discard the elements + /// - Parameter discards: flags elements to be discarded + private func discard(_ discards: [Discard]) { + var virtualIndex = 0 + + for index in 0 ..< count { + if ignoresDiscards || discards[index] == .keep { + undiscarded[virtualIndex] = equivalences[index] + realIndexes[virtualIndex] = index + virtualIndex += 1 + } else { + changedFlags[1 + index] = true + } + } + nonDiscardedCount = virtualIndex + } + + /// doubles value once for every pair of bits of total, the approximate square root + /// the original algorithm computes shifting total two bits at a time + private static func scaled(_ value: Int, bySquareRootOf total: Int) -> Int { + var result = value + var remaining = total >> 2 + + while remaining > 0 { + result *= 2 + remaining >>= 2 + } + + return result + } +} diff --git a/Sources/Features/FilesCompare/DiffResult/Engine/EquivalenceTable.swift b/Sources/Features/FilesCompare/DiffResult/Engine/EquivalenceTable.swift new file mode 100644 index 0000000..0652311 --- /dev/null +++ b/Sources/Features/FilesCompare/DiffResult/Engine/EquivalenceTable.swift @@ -0,0 +1,37 @@ +// +// EquivalenceTable.swift +// VisualDiffer +// +// Created by davide ficano on 12/08/26. +// Copyright (c) 2026 visualdiffer.com +// + +/// translates sequence elements into equivalence codes, so the comparison algorithm +/// only ever compares integers and never touches the original elements +/// +/// The same table must encode both sequences, otherwise equal elements would get +/// different codes. +struct EquivalenceTable { + private var codes = [Key: Int]() + // code 0 is never assigned, the comparison algorithm uses it as the unset marker + private var nextCode = 1 + + /// one more than the greatest assigned code + var codeCount: Int { + nextCode + } + + mutating func encode(_ keys: [Key]) -> [Int] { + keys.map { key in + if let code = codes[key] { + return code + } + let code = nextCode + + codes[key] = code + nextCode += 1 + + return code + } + } +} diff --git a/Sources/Features/FilesCompare/DiffResult/Engine/SequenceDiff.swift b/Sources/Features/FilesCompare/DiffResult/Engine/SequenceDiff.swift new file mode 100644 index 0000000..8b3d344 --- /dev/null +++ b/Sources/Features/FilesCompare/DiffResult/Engine/SequenceDiff.swift @@ -0,0 +1,348 @@ +// +// SequenceDiff.swift +// VisualDiffer +// +// Created by davide ficano on 12/08/26. +// Copyright (c) 2026 visualdiffer.com +// + +/// GNU diff comparison, it finds the differences between two sequences of hashable +/// elements and reports them as an edit script +/// +/// Ported from the legacy UnifiedDiff Objective-C engine, the algorithm is unchanged: +/// a bidirectional breadth-first search of the edit matrix, preceded by the heuristic +/// that discards the elements matching nothing or matching too many elements of the +/// other sequence, and followed by the boundary shifting that makes the hunks prettier. +final class SequenceDiff { + /// text element compared by exact code units + /// + /// String and Character consider canonically equivalent spellings equal, for example + /// "é" and "e" followed by a combining acute accent, so using them directly as keys + /// would silently hide a real difference between two files. + struct TextKey: Hashable { + let text: String + + init(_ text: String) { + self.text = text + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.text.utf8.elementsEqual(rhs.text.utf8) + } + + func hash(into hasher: inout Hasher) { + var copy = text + + copy.withUTF8 { + hasher.combine(bytes: UnsafeRawBufferPointer($0)) + } + } + } + + // the diagonals run from -rightCount to leftCount, plus a sentinel at each end + private static let diagonalPadding = 3 + + private let leftData: DiffFileData + private let rightData: DiffFileData + + /// vectors being compared, they hold the equivalence codes of the nondiscarded elements + private var leftVector = [Int]() + private var rightVector = [Int]() + + /// vector, indexed by diagonal, containing the X coordinate of the point furthest + /// along the given diagonal in the forward search of the edit matrix + private var forwardDiagonals = [Int]() + + /// vector, indexed by diagonal, containing the X coordinate of the point furthest + /// along the given diagonal in the backward search of the edit matrix + private var backwardDiagonals = [Int]() + + private var diagonalOffset = 0 + + private init( + leftData: DiffFileData, + rightData: DiffFileData + ) { + self.leftData = leftData + self.rightData = rightData + } + + /// Compares two sequences and reports their differences. + /// + /// Each element is translated to an equivalence code, the original elements are no + /// longer needed to compute the differences. + /// - Parameters: + /// - ignoresDiscards: when true the algorithm returns a guaranteed minimal set of + /// changes, skipping the heuristic that discards confusing elements. It must be + /// used on short sequences, where discarding the elements matching too many + /// others would report almost everything as changed + /// - Returns: the changes in ascending element order + static func changes( + left: [Key], + right: [Key], + ignoresDiscards: Bool = false + ) -> [DiffChange] { + var table = EquivalenceTable() + let leftCodes = table.encode(left) + let rightCodes = table.encode(right) + let codeCount = table.codeCount + + let diff = SequenceDiff( + leftData: DiffFileData( + equivalences: leftCodes, + codeCount: codeCount, + ignoresDiscards: ignoresDiscards + ), + rightData: DiffFileData( + equivalences: rightCodes, + codeCount: codeCount, + ignoresDiscards: ignoresDiscards + ) + ) + + return diff.changes() + } + + private func changes() -> [DiffChange] { + // some elements are obviously insertions or deletions because they don't match + // anything, detect them now and avoid even thinking about them + // in the main comparison algorithm + leftData.discardConfusingElements(against: rightData) + rightData.discardConfusingElements(against: leftData) + + // now do the main comparison algorithm, considering just the nondiscarded elements + leftVector = leftData.undiscarded + rightVector = rightData.undiscarded + + let diagonals = leftData.nonDiscardedCount + rightData.nonDiscardedCount + Self.diagonalPadding + + diagonalOffset = rightData.nonDiscardedCount + 1 + forwardDiagonals = Array(repeating: 0, count: diagonals) + backwardDiagonals = Array(repeating: 0, count: diagonals) + + compareSequences( + xoff: 0, + xlim: leftData.nonDiscardedCount, + yoff: 0, + ylim: rightData.nonDiscardedCount + ) + + forwardDiagonals = [] + backwardDiagonals = [] + + // modify the results slightly to make them prettier + // in cases where that can validly be done + leftData.shiftBoundaries(against: rightData) + rightData.shiftBoundaries(against: leftData) + + return buildScript() + } + + /// Compare in detail contiguous subsequences of the two sequences which are known, + /// as a whole, to match each other. + /// + /// The results are recorded by marking as changed each element that is an insertion + /// or a deletion. The subsequence of the left side is `[xoff, xlim)` and likewise + /// `[yoff, ylim)` for the right one, all element numbers are origin-0 and the + /// discarded elements are not counted. + private func compareSequences( + xoff: Int, + xlim: Int, + yoff: Int, + ylim: Int + ) { + var xoff = xoff + var xlim = xlim + var yoff = yoff + var ylim = ylim + + // slide down the bottom initial diagonal + while xoff < xlim, yoff < ylim, leftVector[xoff] == rightVector[yoff] { + xoff += 1 + yoff += 1 + } + // slide up the top initial diagonal + while xlim > xoff, ylim > yoff, leftVector[xlim - 1] == rightVector[ylim - 1] { + xlim -= 1 + ylim -= 1 + } + + // handle simple cases + if xoff == xlim { + while yoff < ylim { + rightData.markChanged(virtualIndex: yoff) + yoff += 1 + } + } else if yoff == ylim { + while xoff < xlim { + leftData.markChanged(virtualIndex: xoff) + xoff += 1 + } + } else { + // find a point of correspondence in the middle of the sequences + let diagonal = middleSnake(xoff: xoff, xlim: xlim, yoff: yoff, ylim: ylim) + let backward = backwardDiagonals[diagonalOffset + diagonal] + + // use that point to split this problem into two subproblems + compareSequences(xoff: xoff, xlim: backward, yoff: yoff, ylim: backward - diagonal) + // this used to use the forward value instead of the backward one, but that is + // incorrect, it is not necessarily the case that the diagonal has a snake + // from the backward point to the forward one + compareSequences(xoff: backward, xlim: xlim, yoff: backward - diagonal, ylim: ylim) + } + } + + /// Find the midpoint of the shortest edit script for a specified portion + /// of the two sequences. + /// + /// We scan from the beginnings of the sequences, and simultaneously from the ends, + /// doing a breadth-first search through the space of edit-sequence. + /// When the two searches meet, we have found the midpoint of the shortest edit sequence. + /// + /// This function assumes that the first elements of the specified portions of the two + /// sequences do not match, and likewise that the last elements do not match, the caller + /// must trim the matching elements from the beginning and the end of the portions it + /// is going to specify. + /// + /// Note that if we return the "wrong" diagonal value, or if the backward value at that + /// diagonal is "wrong", the worst this can do is cause suboptimal output, it cannot + /// cause incorrect output. + /// - Returns: the number of the diagonal on which the midpoint lies, the diagonal number + /// equals the number of inserted elements minus the number of deleted ones, counting + /// only the elements before the midpoint + private func middleSnake( + xoff: Int, + xlim: Int, + yoff: Int, + ylim: Int + ) -> Int { + // minimum and maximum valid diagonal + let dmin = xoff - ylim + let dmax = xlim - yoff + // center diagonal of the top-down and of the bottom-up search + let fmid = xoff - yoff + let bmid = xlim - ylim + // limits of the top-down and of the bottom-up search + var fmin = fmid + var fmax = fmid + var bmin = bmid + var bmax = bmid + // true if southeast corner is on an odd diagonal with respect to the northwest + let odd = ((fmid - bmid) & 1) != 0 + + forwardDiagonals[diagonalOffset + fmid] = xoff + backwardDiagonals[diagonalOffset + bmid] = xlim + + while true { + // extend the top-down search by an edit step in each diagonal + if fmin > dmin { + fmin -= 1 + forwardDiagonals[diagonalOffset + fmin - 1] = -1 + } else { + fmin += 1 + } + + if fmax < dmax { + fmax += 1 + forwardDiagonals[diagonalOffset + fmax + 1] = -1 + } else { + fmax -= 1 + } + var diagonal = fmax + + while diagonal >= fmin { + let low = forwardDiagonals[diagonalOffset + diagonal - 1] + let high = forwardDiagonals[diagonalOffset + diagonal + 1] + var x = low >= high ? low + 1 : high + var y = x - diagonal + + while x < xlim, y < ylim, leftVector[x] == rightVector[y] { + x += 1 + y += 1 + } + forwardDiagonals[diagonalOffset + diagonal] = x + + if odd, bmin <= diagonal, diagonal <= bmax, + backwardDiagonals[diagonalOffset + diagonal] <= x { + return diagonal + } + diagonal -= 2 + } + + // similarly extend the bottom-up search + if bmin > dmin { + bmin -= 1 + backwardDiagonals[diagonalOffset + bmin - 1] = .max + } else { + bmin += 1 + } + + if bmax < dmax { + bmax += 1 + backwardDiagonals[diagonalOffset + bmax + 1] = .max + } else { + bmax -= 1 + } + diagonal = bmax + + while diagonal >= bmin { + let low = backwardDiagonals[diagonalOffset + diagonal - 1] + let high = backwardDiagonals[diagonalOffset + diagonal + 1] + var x = low < high ? low : high - 1 + var y = x - diagonal + + while x > xoff, y > yoff, leftVector[x - 1] == rightVector[y - 1] { + x -= 1 + y -= 1 + } + backwardDiagonals[diagonalOffset + diagonal] = x + + if !odd, fmin <= diagonal, diagonal <= fmax, + x <= forwardDiagonals[diagonalOffset + diagonal] { + return diagonal + } + diagonal -= 2 + } + } + } + + /// Scan the tables of which elements are inserted and deleted, producing an edit script. + /// + /// The tables are walked backwards, so the changes are collected in reverse order + /// and reversed before being returned. + private func buildScript() -> [DiffChange] { + var changes = [DiffChange]() + // the flags are origin-1, index 0 and the last one are the always false sentinels + var index0 = leftData.count + var index1 = rightData.count + let leftChanged = leftData.changedFlags + let rightChanged = rightData.changedFlags + + while index0 >= 0 || index1 >= 0 { + if leftChanged[index0] || rightChanged[index1] { + let line0 = index0 + let line1 = index1 + + // find how many elements changed here in each sequence + while leftChanged[index0] { + index0 -= 1 + } + + while rightChanged[index1] { + index1 -= 1 + } + changes.append(DiffChange( + line0: index0, + line1: index1, + deleted: line0 - index0, + inserted: line1 - index1 + )) + } + // we have reached elements in the two sequences that match each other + index0 -= 1 + index1 -= 1 + } + + return Array(changes.reversed()) + } +} diff --git a/Sources/Features/FilesCompare/DiffResult/InlineDiff.swift b/Sources/Features/FilesCompare/DiffResult/InlineDiff.swift new file mode 100644 index 0000000..0fba29b --- /dev/null +++ b/Sources/Features/FilesCompare/DiffResult/InlineDiff.swift @@ -0,0 +1,135 @@ +// +// InlineDiff.swift +// VisualDiffer +// +// Created by davide ficano on 12/08/26. +// Copyright (c) 2026 visualdiffer.com +// + +/// character level differences between the two lines of a changed pair +/// +/// The ranges are character offsets into the line text, ascending and non overlapping. +/// A pure insertion leaves the left side without ranges, a pure deletion leaves +/// the right side without ranges. +struct InlineDiff { + /// above this length the character comparison degrades to a single range per side, + /// generated or minified lines would otherwise make the comparison too slow + private static let maxDiffableLength = 2000 + + static let empty = InlineDiff(leftRanges: [], rightRanges: []) + + let leftRanges: [Range] + let rightRanges: [Range] + + // only ignoreCharacterCase is honoured, the whitespace options drive the line + // comparison and are not applied inside a line + static func between( + _ left: DiffLineComponent, + _ right: DiffLineComponent, + options: DiffResult.Options + ) -> InlineDiff { + // the length is checked before building the keys, a very long line would + // otherwise allocate one key per character just to be degraded right after + if left.text.count > maxDiffableLength || right.text.count > maxDiffableLength { + return spanningDifference(left.text, right.text, options: options) + } + let leftKeys = keys(of: left.text, options: options) + let rightKeys = keys(of: right.text, options: options) + + if leftKeys == rightKeys { + return empty + } + // the discard heuristic must be skipped here, on a single line it would report + // every frequent character as changed + let changes = SequenceDiff.changes( + left: leftKeys, + right: rightKeys, + ignoresDiscards: true + ) + var leftRanges = [Range]() + var rightRanges = [Range]() + + for change in changes { + if change.deleted > 0 { + leftRanges.append(change.line0 ..< change.line0 + change.deleted) + } + + if change.inserted > 0 { + rightRanges.append(change.line1 ..< change.line1 + change.inserted) + } + } + + return InlineDiff(leftRanges: leftRanges, rightRanges: rightRanges) + } + + /// one key per character, so the reported offsets stay character offsets while the + /// comparison keeps the exact code unit semantics the diff engine needs + private static func keys( + of text: String, + options: DiffResult.Options + ) -> [SequenceDiff.TextKey] { + text.map { key(for: $0, options: options) } + } + + private static func key( + for character: Character, + options: DiffResult.Options + ) -> SequenceDiff.TextKey { + options.contains(.ignoreCharacterCase) + ? SequenceDiff.TextKey(character.lowercased()) + : SequenceDiff.TextKey(String(character)) + } + + // a single range per side, covering everything between the common prefix and the + // common suffix, walked without building any array + private static func spanningDifference( + _ leftText: String, + _ rightText: String, + options: DiffResult.Options + ) -> InlineDiff { + let leftCount = leftText.count + let rightCount = rightText.count + let shortest = min(leftCount, rightCount) + var prefix = 0 + + for (leftCharacter, rightCharacter) in zip(leftText, rightText) { + if key(for: leftCharacter, options: options) != key(for: rightCharacter, options: options) { + break + } + prefix += 1 + } + var suffix = 0 + + for (leftCharacter, rightCharacter) in zip(leftText.reversed(), rightText.reversed()) { + if suffix >= shortest - prefix { + break + } + + if key(for: leftCharacter, options: options) != key(for: rightCharacter, options: options) { + break + } + suffix += 1 + } + + return InlineDiff( + leftRanges: ranges(from: prefix, to: leftCount - suffix), + rightRanges: ranges(from: prefix, to: rightCount - suffix) + ) + } + + private static func ranges(from lower: Int, to upper: Int) -> [Range] { + lower < upper ? [lower ..< upper] : [] + } + + // the first line takes the ranges of the left side + static func apply( + to firstLine: DiffLine, + and secondLine: DiffLine, + options: DiffResult.Options + ) { + let inlineDiff = between(firstLine.component, secondLine.component, options: options) + + firstLine.inlineRanges = inlineDiff.leftRanges + secondLine.inlineRanges = inlineDiff.rightRanges + } +} diff --git a/Sources/Features/FilesCompare/Extensions/DiffResult/DiffLine+Color.swift b/Sources/Features/FilesCompare/Extensions/DiffResult/DiffLine+Color.swift index 0a16419..57d48a9 100644 --- a/Sources/Features/FilesCompare/Extensions/DiffResult/DiffLine+Color.swift +++ b/Sources/Features/FilesCompare/Extensions/DiffResult/DiffLine+Color.swift @@ -12,6 +12,20 @@ extension DiffLine { return CommonPrefs.shared.fileColor(color) } + // a color file without the changedInline entry falls back to the swapped line colors, + // so the highlight keeps working instead of silently disappearing, the swap contrasts + // by construction because the two colors already contrast + var inlineColors: ColorSet? { + if let inlineColors = CommonPrefs.shared.fileColor(.changedInline) { + return inlineColors + } + guard let colors else { + return nil + } + + return ColorSet(text: colors.background, background: colors.text) + } + func color(for colorKey: ColorSet.Key, isSelected: Bool) -> NSColor { var color: NSColor? diff --git a/Sources/Legacy/DiffEngine/UDiffScriptBuilder.h b/Sources/Legacy/DiffEngine/UDiffScriptBuilder.h deleted file mode 100644 index 64e8bc5..0000000 --- a/Sources/Legacy/DiffEngine/UDiffScriptBuilder.h +++ /dev/null @@ -1,59 +0,0 @@ -// -// ScriptBuilder.h -// VisualDiffer -// -// Created by davide ficano on 23/05/12. -// Copyright (c) 2012 visualdiffer.com -// - -#import - -NS_ASSUME_NONNULL_BEGIN - -/** The result of comparison is an "edit script": a chain of change objects. - Each change represents one place where some lines are deleted - and some are inserted. - - LINE0 and LINE1 are the first affected lines in the two files (origin 0). - DELETED is the number of lines deleted here from file 0. - INSERTED is the number of lines inserted here in file 1. - - If DELETED is 0 then LINE0 is the number of the line before - which the insertion was done; vice versa for INSERTED and LINE1. */ - -@interface UDiffChange : NSObject -/** Previous or next edit command. */ -@property (strong, nullable) UDiffChange* link; -/** Line number of 1st deleted line. */ -@property (assign) int line0; -/** Line number of 1st inserted line. */ -@property (assign) int line1; -/** # lines of file 1 changed here. */ -@property (assign) int inserted; -/** # lines of file 0 changed here. */ -@property (assign) int deleted; - -+ (instancetype)changeWithRange:(int)line0 line1:(int)line1 deleted:(int)deleted inserted:(int)inserted old:(nullable UDiffChange*)old; -- (instancetype)initWithRange:(int)line0 line1:(int)line1 deleted:(int)deleted inserted:(int)inserted old:(nullable UDiffChange*)old; -@end - - -@interface UDiffScriptBuilder : NSObject -/** Scan the tables of which lines are inserted and deleted, - producing an edit script. - @param changed0 true for lines in first file which do not match 2nd - @param len0 number of lines in first file - @param changed1 true for lines in 2nd file which do not match 1st - @param len1 number of lines in 2nd file - @return a linked list of changes - or null - */ -- (nullable UDiffChange*)build_script:(BOOL*)changed0 len0:(NSUInteger)len0 changed1:(BOOL*)changed1 len1:(NSUInteger)len1; -@end - -@interface UDiffReverseScript : UDiffScriptBuilder -@end; - -@interface UDiffForwardScript : UDiffScriptBuilder -@end; - -NS_ASSUME_NONNULL_END diff --git a/Sources/Legacy/DiffEngine/UDiffScriptBuilder.m b/Sources/Legacy/DiffEngine/UDiffScriptBuilder.m deleted file mode 100644 index fd77b3d..0000000 --- a/Sources/Legacy/DiffEngine/UDiffScriptBuilder.m +++ /dev/null @@ -1,104 +0,0 @@ -// -// ScriptBuilder.m -// VisualDiffer -// -// Created by davide ficano on 23/05/12. -// Copyright (c) 2012 visualdiffer.com -// - -#import "UDiffScriptBuilder.h" - -@implementation UDiffChange - -+ (instancetype)changeWithRange:(int)pline0 line1:(int)pline1 deleted:(int)pdeleted inserted:(int)pinserted old:(UDiffChange*)pold { - return [[self alloc] initWithRange:pline0 line1:pline1 deleted:pdeleted inserted:pinserted old:pold]; -} - -/** Cons an additional entry onto the front of an edit script OLD. - LINE0 and LINE1 are the first affected lines in the two files (origin 0). - DELETED is the number of lines deleted here from file 0. - INSERTED is the number of lines inserted here in file 1. - - If DELETED is 0 then LINE0 is the number of the line before - which the insertion was done; vice versa for INSERTED and LINE1. */ -- (instancetype)initWithRange:(int)pline0 line1:(int)pline1 deleted:(int)pdeleted inserted:(int)pinserted old:(UDiffChange*)pold { - self = [super init]; - - if (self) { - _line0 = pline0; - _line1 = pline1; - _inserted = pinserted; - _deleted = pdeleted; - _link = pold; - } - - return self; -} - -- (NSString *)description { - return [NSString stringWithFormat:@"%d, %d inserted %d, deleted %d", self.line0 + 1, self.line1 + 1, self.inserted, self.deleted]; -} - -@end - -@implementation UDiffScriptBuilder - -- (UDiffChange*)build_script:(BOOL*)changed0 len0:(NSUInteger)len0 changed1:(BOOL*)changed1 len1:(NSUInteger)len1 { - return nil; -} - -@end - -/** Scan the tables of which lines are inserted and deleted, - producing an edit script in reverse order. */ - -@implementation UDiffReverseScript -- (UDiffChange*)build_script:(BOOL*)changed0 len0:(NSUInteger)len0 changed1:(BOOL*)changed1 len1:(NSUInteger)len1 { - UDiffChange* script = nil; - int i0 = 0, i1 = 0; - while (i0 < (NSInteger)len0 || i1 < (NSInteger)len1) { - if (changed0[1+i0] || changed1[1+i1]) { - int line0 = i0, line1 = i1; - - /* Find # lines changed here in each file. */ - while (changed0[1+i0]) ++i0; - while (changed1[1+i1]) ++i1; - - /* Record this change. */ - script = [UDiffChange changeWithRange:line0 line1:line1 deleted:i0 - line0 inserted:i1 - line1 old:script]; - } - - /* We have reached lines in the two files that match each other. */ - i0++; i1++; - } - - return script; -} -@end; - -@implementation UDiffForwardScript -- (UDiffChange*)build_script:(BOOL*)changed0 len0:(NSUInteger)len0 changed1:(BOOL*)changed1 len1:(NSUInteger)len1 { - UDiffChange* script = nil; - int i0 = (int)len0, i1 = (int)len1; - - while (i0 >= 0 || i1 >= 0) - { - if (changed0[i0] || changed1[i1]) - { - int line0 = i0, line1 = i1; - - /* Find # lines changed here in each file. */ - while (changed0[i0]) --i0; - while (changed1[i1]) --i1; - - /* Record this change. */ - script = [UDiffChange changeWithRange:i0 line1:i1 deleted:line0 - i0 inserted:line1 - i1 old:script]; - } - - /* We have reached lines in the two files that match each other. */ - i0--; i1--; - } - - return script; -} -@end; diff --git a/Sources/Legacy/DiffEngine/UnifiedDiff.h b/Sources/Legacy/DiffEngine/UnifiedDiff.h deleted file mode 100644 index dbebf01..0000000 --- a/Sources/Legacy/DiffEngine/UnifiedDiff.h +++ /dev/null @@ -1,79 +0,0 @@ -// -// UnifiedDiff.h -// VisualDiffer -// -// Created by davide ficano on 23/05/12. -// Copyright (c) 2012 visualdiffer.com -// - -#import - -@class UDiffScriptBuilder; -@class UDiffChange; - -NS_ASSUME_NONNULL_BEGIN - -@interface UDiffFileData : NSObject { - /** Vector, indexed by line number, containing an equivalence code for - each line. It is this vector that is actually compared with that - of another file to generate differences. */ - int* equivs; - - int* pequiv_max; - BOOL* pno_discards; -} - -/** Number of elements (lines) in this file. */ -@property (assign) NSUInteger buffered_lines; -/** Array, indexed by real origin-1 line number, containing true for a line that is an insertion or a deletion. */ -@property (assign, nullable) BOOL* changed_flag; -/** Vector, like the previous one except that the elements for discarded lines have been squeezed out. */ -@property (assign) int* undiscarded; -/** Vector mapping virtual line numbers (not counting discarded lines) to real ones (counting those lines). Both are origin-0. */ -@property (assign) int* realindexes; -/** Total number of nondiscarded lines. */ -@property (assign) int nondiscarded_lines; - -+ (instancetype)fileData:(NSArray*)data h:(NSMutableDictionary*)h equivMax:(int*)ppequiv_max noDiscards:(BOOL*)ppnoDiscards - stringifier:(NSString* (^)(id obj))block; -- (instancetype)initWithData:(NSArray*)data h:(NSMutableDictionary*)h equivMax:(int*)ppequiv_max noDiscards:(BOOL*)ppnoDiscards - stringifier:(NSString* (^)(id obj))block; - -@end - -@interface UnifiedDiff : NSObject { -@private - /** 1 more than the maximum equivalence value used for this or its - sibling file. */ - int equiv_max; - - /** When set to true, the comparison uses a heuristic to speed it up. - With this heuristic, for files with a constant small density - of changes, the algorithm is linear in the file size. */ - BOOL heuristic; - - /** When set to true, the algorithm returns a guarranteed minimal - set of changes. This makes things slower, sometimes much slower. */ - BOOL no_discards; - - int* xvec, *yvec; /* Vectors being compared. */ - int* fdiag; /* Vector, indexed by diagonal, containing - the X coordinate of the point furthest - along the given diagonal in the forward - search of the edit matrix. */ - int* bdiag; /* Vector, indexed by diagonal, containing - the X coordinate of the point furthest - along the given diagonal in the backward - search of the edit matrix. */ - int fdiagoff, bdiagoff; - int cost; - - BOOL inhibit; -} - -- (instancetype)initWithOriginalLines:(NSArray*)a revisedLines:(NSArray*)b stringifier:(NSString* (^)(id obj))block; -- (nullable UDiffChange*)diff_2:(BOOL)reverse; - -@end - -NS_ASSUME_NONNULL_END diff --git a/Sources/Legacy/DiffEngine/UnifiedDiff.m b/Sources/Legacy/DiffEngine/UnifiedDiff.m deleted file mode 100644 index 38ba620..0000000 --- a/Sources/Legacy/DiffEngine/UnifiedDiff.m +++ /dev/null @@ -1,739 +0,0 @@ -// -// UnifiedDiff.m -// VisualDiffer -// -// Created by davide ficano on 23/05/12. -// Copyright (c) 2012 visualdiffer.com -// - -#import "UnifiedDiff.h" -#import "UDiffScriptBuilder.h" - -/** Snakes bigger than this are considered "big". */ -static const NSInteger SNAKE_LIMIT = 20; - -static UDiffScriptBuilder* forwardScript; -static UDiffScriptBuilder* reverseScript; - -@interface UDiffFileData() -- (char*)discardable:(int*)counts NS_RETURNS_INNER_POINTER; -- (void)filterDiscards:(char*)discards; -- (void)discard:(char*)discards; -@end - -@interface UnifiedDiff() -- (UDiffChange*)diff:(UDiffScriptBuilder*)bld; -@end - -@implementation UDiffFileData - -+ (instancetype)fileData:(NSArray*)data h:(NSMutableDictionary*)h equivMax:(int*)ppequiv_max noDiscards:(BOOL*)ppno_discards - stringifier:(NSString* (^)(id obj))block { - return [[self alloc] initWithData:data h:h equivMax:ppequiv_max noDiscards:ppno_discards stringifier:block]; -} - -- (instancetype)initWithData:(NSArray*)data h:(NSMutableDictionary*)h equivMax:(int*)ppequiv_max noDiscards:(BOOL*)ppno_discards - stringifier:(NSString* (^)(id obj))block { - self = [super init]; - - if (self) { - pequiv_max = ppequiv_max; - pno_discards = ppno_discards; - _buffered_lines = data.count; - - equivs = calloc(_buffered_lines, sizeof(int)); - _undiscarded = calloc(_buffered_lines, sizeof(int)); - _realindexes = calloc(_buffered_lines, sizeof(int)); - _changed_flag = NULL; - - int i = 0; - for (id o in data) { - NSString* str = block(o); - NSNumber* ir = h[str]; - if (ir == nil) { - h[str] = @(equivs[i] = *pequiv_max); - (*pequiv_max)++; - } else { - equivs[i] = [ir intValue]; - } - ++i; - } - } - - return self; -} - -- (void)dealloc { - free(equivs); - free(_undiscarded); - free(_realindexes); - free(_changed_flag); - -} - -/** Allocate changed array for the results of comparison. */ -- (void)clear { - /* Allocate a flag for each line of each file, saying whether that line - is an insertion or deletion. - Allocate an extra element, always zero, at each end of each vector. - */ - _changed_flag = calloc(_buffered_lines + 2, sizeof(BOOL)); -} - -/** Return equiv_count[I] as the number of lines in this file - that fall in equivalence class I. - @return the array of equivalence class counts. - */ -- (int*)equivCount { - int equiv_max = *pequiv_max; - int* equiv_count = calloc(equiv_max, sizeof(int)); - for (int i = 0; i < (NSInteger)_buffered_lines; ++i) - ++equiv_count[equivs[i]]; - return equiv_count; -} - -/** Discard lines that have no matches in another file. - - A line which is discarded will not be considered by the actual - comparison algorithm; it will be as if that line were not in the file. - The file's `realindexes' table maps virtual line numbers - (which don't count the discarded lines) into real line numbers; - this is how the actual comparison algorithm produces results - that are comprehensible when the discarded lines are counted. -

- When we discard a line, we also mark it as a deletion or insertion - so that it will be printed in the output. - @param f the other file - */ -- (void)discard_confusing_lines:(UDiffFileData*)f { - [self clear]; - /* Set up table of which lines are going to be discarded. */ - int* tempEquivCount = [f equivCount]; - char* discarded = [self discardable:tempEquivCount]; - - /* Don't really discard the provisional lines except when they occur - in a run of discardables, with nonprovisionals at the beginning - and end. */ - [self filterDiscards:discarded]; - - /* Actually discard the lines. */ - [self discard:discarded]; - - free(discarded); - free(tempEquivCount); -} - -/** Mark to be discarded each line that matches no line of another file. - If a line matches many lines, mark it as provisionally discardable. - @see equivCount() - @param counts The count of each equivalence number for the other file. - @return 0=nondiscardable, 1=discardable or 2=provisionally discardable - for each line - */ - -- (char*)discardable:(int*)counts { - NSInteger end = (NSInteger)_buffered_lines; - char* discards = calloc(end, sizeof(char)); - int many = 5; - NSUInteger tem = end / 64; - - /* Multiply MANY by approximate square root of number of lines. - That is the threshold for provisionally discardable lines. */ - while ((tem = tem >> 2) > 0) - many *= 2; - - for (int i = 0; i < end; i++) - { - int nmatch; - if (equivs[i] == 0) - continue; - nmatch = counts[equivs[i]]; - if (nmatch == 0) - discards[i] = 1; - else if (nmatch > many) - discards[i] = 2; - } - return discards; -} - -/** Don't really discard the provisional lines except when they occur - in a run of discardables, with nonprovisionals at the beginning - and end. */ - -- (void)filterDiscards:(char*)discards { - NSInteger end = (NSInteger)_buffered_lines; - - for (int i = 0; i < end; i++) - { - /* Cancel provisional discards not in middle of run of discards. */ - if (discards[i] == 2) - discards[i] = 0; - else if (discards[i] != 0) - { - /* We have found a nonprovisional discard. */ - int j; - int length; - int provisional = 0; - - /* Find end of this run of discardable lines. - Count how many are provisionally discardable. */ - for (j = i; j < end; j++) - { - if (discards[j] == 0) - break; - if (discards[j] == 2) - ++provisional; - } - - /* Cancel provisional discards at end, and shrink the run. */ - while (j > i && discards[j - 1] == 2) { - discards[--j] = 0; --provisional; - } - - /* Now we have the length of a run of discardable lines - whose first and last are not provisional. */ - length = j - i; - - /* If 1/4 of the lines in the run are provisional, - cancel discarding of all provisional lines in the run. */ - if (provisional * 4 > length) - { - while (j > i) - if (discards[--j] == 2) - discards[j] = 0; - } - else - { - int consec; - int minimum = 1; - int tem = length / 4; - - /* MINIMUM is approximate square root of LENGTH/4. - A subrun of two or more provisionals can stand - when LENGTH is at least 16. - A subrun of 4 or more can stand when LENGTH >= 64. */ - while ((tem = tem >> 2) > 0) - minimum *= 2; - minimum++; - - /* Cancel any subrun of MINIMUM or more provisionals - within the larger run. */ - for (j = 0, consec = 0; j < length; j++) - if (discards[i + j] != 2) - consec = 0; - else if (minimum == ++consec) - /* Back up to start of subrun, to cancel it all. */ - j -= consec; - else if (minimum < consec) - discards[i + j] = 0; - - /* Scan from beginning of run - until we find 3 or more nonprovisionals in a row - or until the first nonprovisional at least 8 lines in. - Until that point, cancel any provisionals. */ - for (j = 0, consec = 0; j < length; j++) - { - if (j >= 8 && discards[i + j] == 1) - break; - if (discards[i + j] == 2) { - consec = 0; discards[i + j] = 0; - } - else if (discards[i + j] == 0) - consec = 0; - else - consec++; - if (consec == 3) - break; - } - - /* I advances to the last line of the run. */ - i += length - 1; - - /* Same thing, from end. */ - for (j = 0, consec = 0; j < length; j++) - { - if (j >= 8 && discards[i - j] == 1) - break; - if (discards[i - j] == 2) { - consec = 0; discards[i - j] = 0; - } - else if (discards[i - j] == 0) - consec = 0; - else - consec++; - if (consec == 3) - break; - } - } - } - } -} - -/** Actually discard the lines. - @param discards flags lines to be discarded - */ -- (void)discard:(char*)discards { - NSInteger end = (NSInteger)_buffered_lines; - int j = 0; - for (int i = 0; i < end; ++i) - if (*pno_discards || discards[i] == 0) - { - _undiscarded[j] = equivs[i]; - _realindexes[j++] = i; - } - else - _changed_flag[1+i] = true; - _nondiscarded_lines = j; -} - -/** Adjust inserts/deletes of blank lines to join changes - as much as possible. - - We do something when a run of changed lines include a blank - line at one end and have an excluded blank line at the other. - We are free to choose which blank line is included. - `compareseq' always chooses the one at the beginning, - but usually it is cleaner to consider the following blank line - to be the "change". The only exception is if the preceding blank line - would join this change to other changes. - @param f the file being compared against - */ - -- (void)shift_boundaries:(UDiffFileData*)f { - BOOL* changed = _changed_flag; - BOOL* other_changed = f.changed_flag; - int i = 0; - int j = 0; - NSInteger i_end = (NSInteger)_buffered_lines; - int preceding = -1; - int other_preceding = -1; - - for (;;) - { - int start, end, other_start; - - /* Scan forwards to find beginning of another run of changes. - Also keep track of the corresponding point in the other file. */ - - while (i < i_end && !changed[1+i]) - { - while (other_changed[1+j++]) - /* Non-corresponding lines in the other file - will count as the preceding batch of changes. */ - other_preceding = j; - i++; - } - - if (i == i_end) - break; - - start = i; - other_start = j; - - for (;;) - { - /* Now find the end of this run of changes. */ - - while (i < i_end && changed[1+i]) i++; - end = i; - - /* If the first changed line matches the following unchanged one, - and this run does not follow right after a previous run, - and there are no lines deleted from the other file here, - then classify the first changed line as unchanged - and the following line as changed in its place. */ - - /* You might ask, how could this run follow right after another? - Only because the previous run was shifted here. */ - - if (end != i_end - && equivs[start] == equivs[end] - && !other_changed[1+j] - && end != i_end - && !((preceding >= 0 && start == preceding) - || (other_preceding >= 0 - && other_start == other_preceding))) - { - changed[1+end++] = true; - changed[1+start++] = false; - ++i; - /* Since one line-that-matches is now before this run - instead of after, we must advance in the other file - to keep in synch. */ - ++j; - } - else - break; - } - - preceding = i; - other_preceding = j; - } -} -@end - -#pragma mark - -#pragma mark UnifiedDiff methods - -@interface UnifiedDiff() -@property (strong) UDiffFileData* filevec0; -@property (strong) UDiffFileData* filevec1; -@end - -@implementation UnifiedDiff - -/** Prepare to find differences between two arrays. Each element of - the arrays is translated to an "equivalence number" based on - the result of equals. The original Object arrays - are no longer needed for computing the differences. They will - be needed again later to print the results of the comparison as - an edit script, if desired. - */ -- (instancetype)initWithOriginalLines:(NSArray*)a revisedLines:(NSArray*)b stringifier:(NSString* (^)(id obj))block { - static dispatch_once_t pred; - dispatch_once(&pred, ^{ - forwardScript = [[UDiffForwardScript alloc] init]; - reverseScript = [[UDiffReverseScript alloc] init]; - }); - self = [super init]; - - if (self) { - equiv_max = 1; - heuristic = NO; - no_discards = NO; - inhibit = NO; - NSMutableDictionary* h = [NSMutableDictionary dictionaryWithCapacity:a.count + b.count]; - _filevec0 = [UDiffFileData fileData:a h:h equivMax:&equiv_max noDiscards:&no_discards stringifier:block]; - _filevec1 = [UDiffFileData fileData:b h:h equivMax:&equiv_max noDiscards:&no_discards stringifier:block]; - } - - return self; -} - -/** Find the midpoint of the shortest edit script for a specified - portion of the two files. - - We scan from the beginnings of the files, and simultaneously from the ends, - doing a breadth-first search through the space of edit-sequence. - When the two searches meet, we have found the midpoint of the shortest - edit sequence. - - The value returned is the number of the diagonal on which the midpoint lies. - The diagonal number equals the number of inserted lines minus the number - of deleted lines (counting only lines before the midpoint). - The edit cost is stored into COST; this is the total number of - lines inserted or deleted (counting only lines before the midpoint). - - This function assumes that the first lines of the specified portions - of the two files do not match, and likewise that the last lines do not - match. The caller must trim matching lines from the beginning and end - of the portions it is going to specify. - - Note that if we return the "wrong" diagonal value, or if - the value of bdiag at that diagonal is "wrong", - the worst this can do is cause suboptimal diff output. - It cannot cause incorrect diff output. */ - -- (int)diag:(int)xoff xlim:(int)xlim yoff:(int)yoff ylim:(int)ylim { - int* fd = fdiag; // Give the compiler a chance. - int* bd = bdiag; // Additional help for the compiler. - int* xv = xvec; // Still more help for the compiler. - int* yv = yvec; // And more and more . . . - int dmin = xoff - ylim; // Minimum valid diagonal. - int dmax = xlim - yoff; // Maximum valid diagonal. - int fmid = xoff - yoff; // Center diagonal of top-down search. - int bmid = xlim - ylim; // Center diagonal of bottom-up search. - int fmin = fmid, fmax = fmid; // Limits of top-down search. - int bmin = bmid, bmax = bmid; // Limits of bottom-up search. - /* True if southeast corner is on an odd - diagonal with respect to the northwest. */ - BOOL odd = (fmid - bmid & 1) != 0; - - fd[fdiagoff + fmid] = xoff; - bd[bdiagoff + bmid] = xlim; - - for (int c = 1;; ++c) - { - int d; /* Active diagonal. */ - BOOL big_snake = NO; - - /* Extend the top-down search by an edit step in each diagonal. */ - if (fmin > dmin) - fd[fdiagoff + --fmin - 1] = -1; - else - ++fmin; - if (fmax < dmax) - fd[fdiagoff + ++fmax + 1] = -1; - else - --fmax; - for (d = fmax; d >= fmin; d -= 2) - { - int x, y, oldx, tlo = fd[fdiagoff + d - 1], thi = fd[fdiagoff + d + 1]; - - if (tlo >= thi) - x = tlo + 1; - else - x = thi; - oldx = x; - y = x - d; - while (x < xlim && y < ylim && xv[x] == yv[y]) { - ++x; ++y; - } - if (x - oldx > SNAKE_LIMIT) - big_snake = true; - fd[fdiagoff + d] = x; - if (odd && bmin <= d && d <= bmax && bd[bdiagoff + d] <= fd[fdiagoff + d]) - { - cost = 2 * c - 1; - return d; - } - } - - /* Similar extend the bottom-up search. */ - if (bmin > dmin) - bd[bdiagoff + --bmin - 1] = INT_MAX; - else - ++bmin; - if (bmax < dmax) - bd[bdiagoff + ++bmax + 1] = INT_MAX; - else - --bmax; - for (d = bmax; d >= bmin; d -= 2) - { - int x, y, oldx, tlo = bd[bdiagoff + d - 1], thi = bd[bdiagoff + d + 1]; - - if (tlo < thi) - x = tlo; - else - x = thi - 1; - oldx = x; - y = x - d; - while (x > xoff && y > yoff && xv[x - 1] == yv[y - 1]) { - --x; --y; - } - if (oldx - x > SNAKE_LIMIT) - big_snake = true; - bd[bdiagoff + d] = x; - if (!odd && fmin <= d && d <= fmax && bd[bdiagoff + d] <= fd[fdiagoff + d]) - { - cost = 2 * c; - return d; - } - } - - /* Heuristic: check occasionally for a diagonal that has made - lots of progress compared with the edit distance. - If we have any such, find the one that has made the most - progress and return it as if it had succeeded. - - With this heuristic, for files with a constant small density - of changes, the algorithm is linear in the file size. */ - - if (c > 200 && big_snake && heuristic) - { - int best = 0; - int bestpos = -1; - - for (d = fmax; d >= fmin; d -= 2) - { - int dd = d - fmid; - int x = fd[fdiagoff + d]; - int y = x - d; - int v = (x - xoff) * 2 - dd; - if (v > 12 * (c + (dd < 0 ? -dd : dd))) - { - if (v > best - && xoff + SNAKE_LIMIT <= x && x < xlim - && yoff + SNAKE_LIMIT <= y && y < ylim) - { - /* We have a good enough best diagonal; - now insist that it end with a significant snake. */ - int k; - - for (k = 1; xvec[x - k] == yvec[y - k]; k++) - if (k == SNAKE_LIMIT) - { - best = v; - bestpos = d; - break; - } - } - } - } - if (best > 0) - { - cost = 2 * c - 1; - return bestpos; - } - - best = 0; - for (d = bmax; d >= bmin; d -= 2) - { - int dd = d - bmid; - int x = bd[bdiagoff + d]; - int y = x - d; - int v = (xlim - x) * 2 + dd; - if (v > 12 * (c + (dd < 0 ? -dd : dd))) - { - if (v > best - && xoff < x && x <= xlim - SNAKE_LIMIT - && yoff < y && y <= ylim - SNAKE_LIMIT) - { - /* We have a good enough best diagonal; - now insist that it end with a significant snake. */ - int k; - - for (k = 0; xvec[x + k] == yvec[y + k]; k++) - if (k == SNAKE_LIMIT) - { - best = v; - bestpos = d; - break; - } - } - } - } - if (best > 0) - { - cost = 2 * c - 1; - return bestpos; - } - } - } -} - -/** Compare in detail contiguous subsequences of the two files - which are known, as a whole, to match each other. - - The results are recorded in the vectors filevec[N].changed_flag, by - storing a 1 in the element for each line that is an insertion or deletion. - - The subsequence of file 0 is [XOFF, XLIM) and likewise for file 1. - - Note that XLIM, YLIM are exclusive bounds. - All line numbers are origin-0 and discarded lines are not counted. */ - -- (void)compareseq:(int)xoff xlim:(int)xlim yoff:(int)yoff ylim:(int)ylim { - /* Slide down the bottom initial diagonal. */ - while (xoff < xlim && yoff < ylim && xvec[xoff] == yvec[yoff]) { - ++xoff; ++yoff; - } - /* Slide up the top initial diagonal. */ - while (xlim > xoff && ylim > yoff && xvec[xlim - 1] == yvec[ylim - 1]) { - --xlim; --ylim; - } - - /* Handle simple cases. */ - if (xoff == xlim) - while (yoff < ylim) - self.filevec1.changed_flag[1 + self.filevec1.realindexes[yoff++]] = YES; - else if (yoff == ylim) - while (xoff < xlim) - self.filevec0.changed_flag[1 + self.filevec0.realindexes[xoff++]] = YES; - else - { - /* Find a point of correspondence in the middle of the files. */ - - int d = [self diag:xoff xlim:xlim yoff:yoff ylim:ylim]; - int c = cost; -// int f = fdiag[fdiagoff + d]; - int b = bdiag[bdiagoff + d]; - - if (c == 1) - { - /* This should be impossible, because it implies that - one of the two subsequences is empty, - and that case was handled above without calling `diag'. - Let's verify that this is true. */ -// /throw new IllegalArgumentException("Empty subsequence"); - } - else - { - /* Use that point to split this problem into two subproblems. */ - [self compareseq:xoff xlim:b yoff:yoff ylim:b - d]; - /* This used to use f instead of b, - but that is incorrect! - It is not necessarily the case that diagonal d - has a snake from b to f. */ - [self compareseq:b xlim:xlim yoff:b - d ylim:ylim]; - } - } -} - -/** Discard lines from one file that have no matches in the other file. - */ - -- (void)discard_confusing_lines { - [self.filevec0 discard_confusing_lines:self.filevec1]; - [self.filevec1 discard_confusing_lines:self.filevec0]; -} - -/** Adjust inserts/deletes of blank lines to join changes - as much as possible. - */ - -- (void)shift_boundaries { - if (inhibit) - return; - [self.filevec0 shift_boundaries:self.filevec1]; - [self.filevec1 shift_boundaries:self.filevec0]; -} - -/* Report the differences of two files. */ -- (UDiffChange*)diff_2:(BOOL)reverse { - return [self diff:reverse ? reverseScript : forwardScript]; -} - -/** Get the results of comparison as an edit script. The script - is described by a list of changes. The standard ScriptBuilder - implementations provide for forward and reverse edit scripts. - Alternate implementations could, for instance, list common elements - instead of differences. - @param bld an object to build the script from change flags - @return the head of a list of changes - */ -- (UDiffChange*)diff:(UDiffScriptBuilder*)bld { - - /* Some lines are obviously insertions or deletions - because they don't match anything. Detect them now, - and avoid even thinking about them in the main comparison algorithm. */ - - [self discard_confusing_lines]; - - /* Now do the main comparison algorithm, considering just the - undiscarded lines. */ - - xvec = self.filevec0.undiscarded; - yvec = self.filevec1.undiscarded; - - int diags = self.filevec0.nondiscarded_lines + self.filevec1.nondiscarded_lines + 3; - fdiag = calloc(diags, sizeof(int)); - fdiagoff = self.filevec1.nondiscarded_lines + 1; - bdiag = calloc(diags, sizeof(int)); - bdiagoff = self.filevec1.nondiscarded_lines + 1; - - [self compareseq:0 - xlim:self.filevec0.nondiscarded_lines - yoff:0 - ylim:self.filevec1.nondiscarded_lines]; - fdiag = nil; - bdiag = nil; - - /* Modify the results slightly to make them prettier - in cases where that can validly be done. */ - - [self shift_boundaries]; - - /* Get the results of comparison in the form of a chain - of struct change's -- an edit script. */ - return [bld build_script:self.filevec0.changed_flag - len0:self.filevec0.buffered_lines - changed1:self.filevec1.changed_flag - len1:self.filevec1.buffered_lines]; -} - -/** Data on one input file being compared. - */ - - -@end diff --git a/Sources/SharedKit/Extensions/String/String+Highlights.swift b/Sources/SharedKit/Extensions/String/String+Highlights.swift index cfabb45..304e487 100644 --- a/Sources/SharedKit/Extensions/String/String+Highlights.swift +++ b/Sources/SharedKit/Extensions/String/String+Highlights.swift @@ -58,4 +58,26 @@ extension String { highlightStyle: hightlightStyle ) } + + // the offsets must be ascending and non overlapping, the string is walked once, + // the offsets starting past the end are dropped and the others are clamped + func characterRanges(from offsets: [Range]) -> [Range] { + let length = count + var ranges = [Range]() + var start = startIndex + var startOffset = 0 + + for offsetRange in offsets where offsetRange.lowerBound < length { + let clampedUpper = min(offsetRange.upperBound, length) + let lower = index(start, offsetBy: offsetRange.lowerBound - startOffset) + let upper = index(lower, offsetBy: clampedUpper - offsetRange.lowerBound) + + ranges.append(lower ..< upper) + + start = upper + startOffset = clampedUpper + } + + return ranges + } } diff --git a/Sources/SharedKit/Utilities/String/DisplayLine.swift b/Sources/SharedKit/Utilities/String/DisplayLine.swift new file mode 100644 index 0000000..97a3ebf --- /dev/null +++ b/Sources/SharedKit/Utilities/String/DisplayLine.swift @@ -0,0 +1,29 @@ +// +// DisplayLine.swift +// VisualDiffer +// +// Created by davide ficano on 12/08/26. +// Copyright (c) 2026 visualdiffer.com +// + +/// text of a line as it gets drawn, together with the mapping needed to translate +/// the character offsets of the source text into offsets of the drawn text +/// +/// Tab expansion changes the length of a line, so an offset in the source text does +/// not match the same offset in the drawn one. Everything else is replaced one +/// character at a time, therefore a line without tabs needs no mapping at all. +struct DisplayLine { + let text: String + + // one entry per source character, plus the past the end offset, + // empty when the drawn text matches the source text character by character + let offsets: [Int] + + func displayOffset(for sourceOffset: Int) -> Int { + if offsets.isEmpty { + return sourceOffset + } + + return sourceOffset < offsets.count ? offsets[sourceOffset] : text.count + } +} diff --git a/Sources/SharedKit/Utilities/String/VisibleWhitespaces.swift b/Sources/SharedKit/Utilities/String/VisibleWhitespaces.swift index 983d20f..e036c23 100644 --- a/Sources/SharedKit/Utilities/String/VisibleWhitespaces.swift +++ b/Sources/SharedKit/Utilities/String/VisibleWhitespaces.swift @@ -19,49 +19,73 @@ class VisibleWhitespaces: NSObject { return String(ch) } - func getString(_ component: DiffLineComponent, isWhitespacesVisible: Bool) -> String { + func getString(_ component: DiffLineComponent, isWhitespacesVisible: Bool) -> DisplayLine { if isWhitespacesVisible { return showWhitespaces(component.text + component.eol.visibleSymbol) } return Self.tabs2space(component.text, tabWidth: tabWidth) } - static func tabs2space(_ line: String, tabWidth: Int) -> String { - guard tabWidth > 0 else { - return line + static func tabs2space(_ line: String, tabWidth: Int) -> DisplayLine { + guard tabWidth > 0, line.contains("\t") else { + return DisplayLine(text: line, offsets: []) } var dest = "" + var destCount = 0 + var offsets = [Int]() + + offsets.reserveCapacity(line.count + 1) for ch in line { + offsets.append(destCount) + if ch == "\t" { - let spaces = tabWidth - (dest.count % tabWidth) + let spaces = tabWidth - (destCount % tabWidth) dest += String(repeating: " ", count: spaces) + destCount += spaces } else { dest.append(ch) + destCount += 1 } } - return dest + offsets.append(destCount) + + return DisplayLine(text: dest, offsets: offsets) } - func showWhitespaces(_ line: String) -> String { + func showWhitespaces(_ line: String) -> DisplayLine { guard tabWidth > 0 else { - return line + return DisplayLine(text: line, offsets: []) } let whitespaces = CharacterSet.whitespaces + // every character but a tab is replaced one by one, so the mapping is needed + // only when the line contains a tab + let needsOffsets = line.contains("\t") var dest = "" + var destCount = 0 + var offsets = [Int]() + + if needsOffsets { + offsets.reserveCapacity(line.count + 1) + } for ch in line { + if needsOffsets { + offsets.append(destCount) + } + guard let scalar = ch.unicodeScalars.first, whitespaces.contains(scalar) else { dest.append(ch) + destCount += 1 continue } if ch == "\t" { // subtract from spaces the character representing TAB - let spaces = (tabWidth - (dest.count % tabWidth)) - 1 + let spaces = (tabWidth - (destCount % tabWidth)) - 1 if spaces > 0 { let leftSpaces = spaces / 2 let rightSpaces = spaces - leftSpaces @@ -73,13 +97,21 @@ class VisibleWhitespaces: NSObject { if rightSpaces > 0 { dest += String(repeating: " ", count: rightSpaces) } + destCount += spaces + 1 } else { dest += Self.visibleTab + destCount += 1 } } else { dest += getVisibleCharFor(ch) + destCount += 1 } } - return dest + + if needsOffsets { + offsets.append(destCount) + } + + return DisplayLine(text: dest, offsets: offsets) } } diff --git a/Tests/Sources/FileCompare/DiffResult/Tests/DiffResultTests.swift b/Tests/Sources/FileCompare/DiffResult/Tests/DiffResultTests.swift index f4d46bd..7c28659 100644 --- a/Tests/Sources/FileCompare/DiffResult/Tests/DiffResultTests.swift +++ b/Tests/Sources/FileCompare/DiffResult/Tests/DiffResultTests.swift @@ -709,6 +709,64 @@ final class DiffResultTests: DiffResultBaseTests { #expect(rightLine == diffRightLine.text) } } + + @Test + func insertKeepsDestinationEOL() { + let leftText = "alpha\r\nbeta\r\ngamma" + let rightText = "alpha\r\ngamma" + + let diffResult = DiffResult() + diffResult.diff(leftText: leftText, rightText: rightText) + + assert(lines: diffResult.leftSide.lines, expectedValue: [.matching, .deleted, .matching]) + assert(lines: diffResult.rightSide.lines, expectedValue: [.matching, .missing, .matching]) + + // pasted text always carries a unix line ending, the destination one must win + diffResult.insert(text: "beta\n", at: 1, side: .right) + + assert(lines: diffResult.leftSide.lines, expectedValue: [.matching, .matching, .matching]) + assert(lines: diffResult.rightSide.lines, expectedValue: [.matching, .matching, .matching]) + #expect(diffResult.rightSide.lines[1].component.eol == .pcCRLF) + } + + @Test + func insertOfCanonicallyEquivalentLineIsChanged() { + let leftText = "caf\u{00E9}\nsecond" + let rightText = "second" + + let diffResult = DiffResult() + diffResult.diff(leftText: leftText, rightText: rightText) + + assert(lines: diffResult.leftSide.lines, expectedValue: [.deleted, .matching]) + assert(lines: diffResult.rightSide.lines, expectedValue: [.missing, .matching]) + + // String considers the two spellings equal, the paste must not + diffResult.insert(text: "cafe\u{0301}\n", at: 0, side: .right) + + assert(lines: diffResult.leftSide.lines, expectedValue: [.changed, .matching]) + assert(lines: diffResult.rightSide.lines, expectedValue: [.changed, .matching]) + #expect(diffResult.leftSide.lines[0].inlineRanges == [3 ..< 4]) + #expect(diffResult.rightSide.lines[0].inlineRanges == [3 ..< 4]) + } + + @Test + func insertHonoursTheComparisonOptions() { + let leftText = "ALPHA\nsecond" + let rightText = "second" + + let diffResult = DiffResult(options: [.ignoreCharacterCase]) + diffResult.diff(leftText: leftText, rightText: rightText) + + assert(lines: diffResult.leftSide.lines, expectedValue: [.deleted, .matching]) + assert(lines: diffResult.rightSide.lines, expectedValue: [.missing, .matching]) + + diffResult.insert(text: "alpha\n", at: 0, side: .right) + + assert(lines: diffResult.leftSide.lines, expectedValue: [.matching, .matching]) + assert(lines: diffResult.rightSide.lines, expectedValue: [.matching, .matching]) + #expect(diffResult.leftSide.lines[0].hasIgnoredDifferences) + #expect(diffResult.rightSide.lines[0].hasIgnoredDifferences) + } } // swiftlint:enable file_length line_length diff --git a/Tests/Sources/FileCompare/DiffResult/Tests/InlineDiffTests.swift b/Tests/Sources/FileCompare/DiffResult/Tests/InlineDiffTests.swift new file mode 100644 index 0000000..6f6fda1 --- /dev/null +++ b/Tests/Sources/FileCompare/DiffResult/Tests/InlineDiffTests.swift @@ -0,0 +1,257 @@ +// +// InlineDiffTests.swift +// VisualDiffer +// +// Created by davide ficano on 12/08/26. +// Copyright (c) 2026 visualdiffer.com +// + +import Testing +@testable import VisualDiffer + +final class InlineDiffTests: DiffResultBaseTests { + private static let longLineLength = 3000 + + @Test + func identicalLines() { + let diff = inlineDiff("hello", "hello") + + #expect(diff.leftRanges.isEmpty) + #expect(diff.rightRanges.isEmpty) + } + + @Test + func emptyLines() { + let diff = inlineDiff("", "") + + #expect(diff.leftRanges.isEmpty) + #expect(diff.rightRanges.isEmpty) + } + + @Test + func emptyAgainstFilledLine() { + let diff = inlineDiff("", "abc") + + #expect(diff.leftRanges.isEmpty) + #expect(diff.rightRanges == [0 ..< 3]) + } + + @Test + func oneCharacterInTheMiddle() { + let diff = inlineDiff("abcde", "abXde") + + #expect(diff.leftRanges == [2 ..< 3]) + #expect(diff.rightRanges == [2 ..< 3]) + } + + @Test + func changeAtTheStart() { + let diff = inlineDiff("Xbcde", "abcde") + + #expect(diff.leftRanges == [0 ..< 1]) + #expect(diff.rightRanges == [0 ..< 1]) + } + + @Test + func changeAtTheEnd() { + let diff = inlineDiff("abcdX", "abcde") + + #expect(diff.leftRanges == [4 ..< 5]) + #expect(diff.rightRanges == [4 ..< 5]) + } + + @Test + func pureInsertion() { + let diff = inlineDiff("abc", "abXYc") + + #expect(diff.leftRanges.isEmpty) + #expect(diff.rightRanges == [2 ..< 4]) + } + + @Test + func pureDeletion() { + let diff = inlineDiff("abXYc", "abc") + + #expect(diff.leftRanges == [2 ..< 4]) + #expect(diff.rightRanges.isEmpty) + } + + @Test + func severalDisjointChanges() { + let diff = inlineDiff("abcXefgYijk", "abcQefgRijk") + + #expect(diff.leftRanges == [3 ..< 4, 7 ..< 8]) + #expect(diff.rightRanges == [3 ..< 4, 7 ..< 8]) + } + + @Test + func changedIdentifierKeepsCommonPrefixAndSuffix() { + let diff = inlineDiff("let value = computeTotal(a, b)", "let value = computeSum(a, b)") + + #expect(diff.leftRanges == [19 ..< 24]) + #expect(diff.rightRanges == [19 ..< 22]) + } + + @Test + func characterCaseIsHonouredOnlyWhenIgnored() { + let sensitive = inlineDiff("Hello", "hello") + let insensitive = inlineDiff("Hello", "hello", options: [.ignoreCharacterCase]) + + #expect(sensitive.leftRanges == [0 ..< 1]) + #expect(sensitive.rightRanges == [0 ..< 1]) + #expect(insensitive.leftRanges.isEmpty) + #expect(insensitive.rightRanges.isEmpty) + } + + @Test + func canonicallyEquivalentCharactersAreDifferent() { + let precomposed = "caf\u{00E9}" + let decomposed = "cafe\u{0301}" + + // Character compares the two spellings as equal and both count as one character, + // the comparison must still report the difference + #expect(precomposed == decomposed) + #expect(precomposed.count == decomposed.count) + + let diff = inlineDiff(precomposed, decomposed) + + #expect(diff.leftRanges == [3 ..< 4]) + #expect(diff.rightRanges == [3 ..< 4]) + } + + @Test + func canonicallyEquivalentCharactersDifferWhileIgnoringCase() { + let diff = inlineDiff("CAF\u{00C9}", "cafe\u{0301}", options: [.ignoreCharacterCase]) + + #expect(diff.leftRanges == [3 ..< 4]) + #expect(diff.rightRanges == [3 ..< 4]) + } + + @Test + func canonicallyEquivalentLinesAreReportedAsChanged() { + let diffResult = DiffResult() + diffResult.diff(leftText: "caf\u{00E9}\nsecond", rightText: "cafe\u{0301}\nsecond") + + #expect(diffResult.leftSide.lines[0].type == .changed) + #expect(diffResult.rightSide.lines[0].type == .changed) + #expect(diffResult.leftSide.lines[0].inlineRanges == [3 ..< 4]) + #expect(diffResult.rightSide.lines[0].inlineRanges == [3 ..< 4]) + #expect(diffResult.leftSide.lines[1].type == .matching) + } + + @Test + func longLinesDegradeToASingleRange() { + let filler = String(repeating: "a", count: Self.longLineLength) + let diff = inlineDiff(filler + "X" + filler, filler + "Y" + filler) + + #expect(diff.leftRanges == [Self.longLineLength ..< Self.longLineLength + 1]) + #expect(diff.rightRanges == [Self.longLineLength ..< Self.longLineLength + 1]) + } + + @Test + func longIdenticalLinesHaveNoRanges() { + let filler = String(repeating: "a", count: Self.longLineLength) + let diff = inlineDiff(filler, filler) + + #expect(diff.leftRanges.isEmpty) + #expect(diff.rightRanges.isEmpty) + } + + @Test + func longLineAgainstItsOwnPrefix() { + let filler = String(repeating: "a", count: Self.longLineLength) + let diff = inlineDiff(filler, filler + "X") + + #expect(diff.leftRanges.isEmpty) + #expect(diff.rightRanges == [Self.longLineLength ..< Self.longLineLength + 1]) + } + + @Test + func longLineChangedAtTheStart() { + let filler = String(repeating: "a", count: Self.longLineLength) + let diff = inlineDiff("X" + filler, "Y" + filler) + + #expect(diff.leftRanges == [0 ..< 1]) + #expect(diff.rightRanges == [0 ..< 1]) + } + + @Test + func longLineChangedAtTheEnd() { + let filler = String(repeating: "a", count: Self.longLineLength) + let diff = inlineDiff(filler + "X", filler + "Y") + + #expect(diff.leftRanges == [Self.longLineLength ..< Self.longLineLength + 1]) + #expect(diff.rightRanges == [Self.longLineLength ..< Self.longLineLength + 1]) + } + + @Test + func longLinesIgnoringCharacterCase() { + let filler = String(repeating: "a", count: Self.longLineLength) + let diff = inlineDiff(filler + "X", filler.uppercased() + "y", options: [.ignoreCharacterCase]) + + #expect(diff.leftRanges == [Self.longLineLength ..< Self.longLineLength + 1]) + #expect(diff.rightRanges == [Self.longLineLength ..< Self.longLineLength + 1]) + } + + @Test + func longLinesKeepTheExactComparison() { + let filler = String(repeating: "a", count: Self.longLineLength) + let diff = inlineDiff(filler + "caf\u{00E9}" + filler, filler + "cafe\u{0301}" + filler) + let expected = [Self.longLineLength + 3 ..< Self.longLineLength + 4] + + #expect(diff.leftRanges == expected) + #expect(diff.rightRanges == expected) + } + + @Test + func changedPairCarriesRanges() { + let diffResult = DiffResult() + diffResult.diff(leftText: "alpha\nbeta1\ngamma", rightText: "alpha\nbeta2\ngamma") + + #expect(diffResult.leftSide.lines[1].type == .changed) + #expect(diffResult.leftSide.lines[1].inlineRanges == [4 ..< 5]) + #expect(diffResult.rightSide.lines[1].inlineRanges == [4 ..< 5]) + #expect(diffResult.leftSide.lines[0].inlineRanges.isEmpty) + #expect(diffResult.leftSide.lines[2].inlineRanges.isEmpty) + } + + @Test + func replacingComponentClearsRanges() { + let line = DiffLine( + with: .changed, + number: 1, + component: DiffLineComponent(text: "abc", eol: .unix) + ) + line.inlineRanges = [0 ..< 1] + + line.component = DiffLineComponent(text: "xyz", eol: .unix) + + #expect(line.inlineRanges.isEmpty) + } + + @Test + func missingLineClearsRanges() { + let line = DiffLine( + with: .changed, + number: 1, + component: DiffLineComponent(text: "abc", eol: .unix) + ) + line.inlineRanges = [0 ..< 1] + + line.makeMissing() + + #expect(line.inlineRanges.isEmpty) + } + + private func inlineDiff( + _ leftText: String, + _ rightText: String, + options: DiffResult.Options = [] + ) -> InlineDiff { + InlineDiff.between( + DiffLineComponent(text: leftText, eol: .missing), + DiffLineComponent(text: rightText, eol: .missing), + options: options + ) + } +} diff --git a/Tests/Sources/FileCompare/DiffResult/Tests/SequenceDiffTests.swift b/Tests/Sources/FileCompare/DiffResult/Tests/SequenceDiffTests.swift new file mode 100644 index 0000000..f0e5f26 --- /dev/null +++ b/Tests/Sources/FileCompare/DiffResult/Tests/SequenceDiffTests.swift @@ -0,0 +1,128 @@ +// +// SequenceDiffTests.swift +// VisualDiffer +// +// Created by davide ficano on 12/08/26. +// Copyright (c) 2026 visualdiffer.com +// + +import Testing +@testable import VisualDiffer + +final class SequenceDiffTests: DiffResultBaseTests { + @Test + func emptySequences() { + #expect(changes([], []).isEmpty) + } + + @Test + func identicalSequences() { + #expect(changes(["a", "b", "c"], ["a", "b", "c"]).isEmpty) + } + + @Test + func insertedIntoEmptySequence() { + #expect(changes([], ["a", "b"]) == [DiffChange(line0: 0, line1: 0, deleted: 0, inserted: 2)]) + } + + @Test + func deletedWholeSequence() { + #expect(changes(["a", "b"], []) == [DiffChange(line0: 0, line1: 0, deleted: 2, inserted: 0)]) + } + + @Test + func deletedFromMiddle() { + #expect(changes(["a", "b", "c"], ["a", "c"]) == [DiffChange(line0: 1, line1: 1, deleted: 1, inserted: 0)]) + } + + @Test + func replacedElement() { + #expect(changes(["a", "b", "c"], ["a", "x", "c"]) == [DiffChange(line0: 1, line1: 1, deleted: 1, inserted: 1)]) + } + + @Test + func twoSeparateHunks() { + let result = changes(["a", "b", "c", "d", "e"], ["x", "b", "c", "y", "e"]) + + #expect(result == [ + DiffChange(line0: 0, line1: 0, deleted: 1, inserted: 1), + DiffChange(line0: 3, line1: 3, deleted: 1, inserted: 1), + ]) + } + + @Test + func blankLineDeletion() { + let result = changes(["a", "", "b", "", "c"], ["a", "b", "", "c"]) + + #expect(result == [DiffChange(line0: 1, line1: 1, deleted: 1, inserted: 0)]) + } + + @Test + func repeatedRunTrimmedAtBothEnds() { + let result = changes(["x", "a", "a", "a", "y"], ["a", "a", "a"]) + + #expect(result == [ + DiffChange(line0: 0, line1: 0, deleted: 1, inserted: 0), + DiffChange(line0: 4, line1: 3, deleted: 1, inserted: 0), + ]) + } + + @Test + func insertionNextToABlankLine() { + let result = changes(["a", "b", "", "c", "d"], ["a", "b", "", "x", "", "c", "d"]) + + #expect(result == [DiffChange(line0: 3, line1: 3, deleted: 0, inserted: 2)]) + } + + @Test + func duplicateHeavySequenceKeepsTheDiscardHeuristicHonest() { + let lineCount = 80 + let distinctCount = 4 + let base = (0 ..< lineCount).map { "line \($0 % distinctCount)" } + var mutated = base + + mutated[10] = "unique A" + mutated[40] = "unique B" + mutated.insert("unique C", at: 60) + mutated.remove(at: 20) + + #expect(changes(base, mutated) == [ + DiffChange(line0: 10, line1: 10, deleted: 1, inserted: 1), + DiffChange(line0: 20, line1: 20, deleted: 1, inserted: 0), + DiffChange(line0: 40, line1: 39, deleted: 1, inserted: 1), + DiffChange(line0: 60, line1: 59, deleted: 0, inserted: 1), + ]) + } + + @Test + func canonicallyEquivalentLinesAreDifferent() { + let precomposed = "caf\u{00E9}" + let decomposed = "cafe\u{0301}" + + // String compares the two spellings as equal, the diff engine must not, + // otherwise a real file difference would be silently hidden + #expect(precomposed == decomposed) + + #expect(changes([precomposed], [decomposed]) == [DiffChange(line0: 0, line1: 0, deleted: 1, inserted: 1)]) + } + + @Test + func ignoredDiscardsKeepFrequentElements() { + // without ignoresDiscards the frequent element would be provisionally discarded + // and the whole sequence would be reported as changed + let left = Array(String(repeating: "a", count: 100) + "X" + String(repeating: "a", count: 100)) + let right = Array(String(repeating: "a", count: 100) + "Y" + String(repeating: "a", count: 100)) + + let result = SequenceDiff.changes(left: left, right: right, ignoresDiscards: true) + + #expect(result == [DiffChange(line0: 100, line1: 100, deleted: 1, inserted: 1)]) + } + + /// compares through the exact code unit key, as the file comparison does + private func changes(_ left: [String], _ right: [String]) -> [DiffChange] { + SequenceDiff.changes( + left: left.map { SequenceDiff.TextKey($0) }, + right: right.map { SequenceDiff.TextKey($0) } + ) + } +} diff --git a/Tests/Sources/FileCompare/DiffResult/Tests/WhitespacesTests.swift b/Tests/Sources/FileCompare/DiffResult/Tests/WhitespacesTests.swift index 29e1ab4..7dabdbb 100644 --- a/Tests/Sources/FileCompare/DiffResult/Tests/WhitespacesTests.swift +++ b/Tests/Sources/FileCompare/DiffResult/Tests/WhitespacesTests.swift @@ -19,8 +19,8 @@ final class WhitespacesTests: DiffResultBaseTests { " leading spaces\n" + "another line" - let diffResult = DiffResult() - diffResult.diff(leftText: leftText, rightText: rightText, options: .ignoreLeadingWhitespaces) + let diffResult = DiffResult(options: .ignoreLeadingWhitespaces) + diffResult.diff(leftText: leftText, rightText: rightText) let leftChangeType: [DiffChangeType] = [.matching, .matching] assert(lines: diffResult.leftSide.lines, expectedValue: leftChangeType) @@ -44,8 +44,8 @@ final class WhitespacesTests: DiffResultBaseTests { "line1\n" + "another text tabs and spaces" - let diffResult = DiffResult() - diffResult.diff(leftText: leftText, rightText: rightText, options: .ignoreInternalWhitespaces) + let diffResult = DiffResult(options: .ignoreInternalWhitespaces) + diffResult.diff(leftText: leftText, rightText: rightText) let leftChangeType: [DiffChangeType] = [.changed, .matching] assert(lines: diffResult.leftSide.lines, expectedValue: leftChangeType) @@ -71,8 +71,8 @@ final class WhitespacesTests: DiffResultBaseTests { "another text tabs and spaces\n" + " last line" - let diffResult = DiffResult() - diffResult.diff(leftText: leftText, rightText: rightText, options: [.allIgnoreWhitespaces, .ignoreLineEndings]) + let diffResult = DiffResult(options: [.allIgnoreWhitespaces, .ignoreLineEndings]) + diffResult.diff(leftText: leftText, rightText: rightText) let leftChangeType: [DiffChangeType] = [.matching, .matching, .matching] assert(lines: diffResult.leftSide.lines, expectedValue: leftChangeType) @@ -98,8 +98,8 @@ final class WhitespacesTests: DiffResultBaseTests { "another text tabs and spaces\n" + " last line" - let diffResult = DiffResult() - diffResult.diff(leftText: leftText, rightText: rightText, options: [.allIgnoreWhitespaces]) + let diffResult = DiffResult(options: [.allIgnoreWhitespaces]) + diffResult.diff(leftText: leftText, rightText: rightText) let leftChangeType: [DiffChangeType] = [.matching, .changed, .matching] assert(lines: diffResult.leftSide.lines, expectedValue: leftChangeType) @@ -123,8 +123,8 @@ final class WhitespacesTests: DiffResultBaseTests { "line One\n" + "another line" - let diffResult = DiffResult() - diffResult.diff(leftText: leftText, rightText: rightText, options: .ignoreCharacterCase) + let diffResult = DiffResult(options: .ignoreCharacterCase) + diffResult.diff(leftText: leftText, rightText: rightText) let leftChangeType: [DiffChangeType] = [.matching, .matching] assert(lines: diffResult.leftSide.lines, expectedValue: leftChangeType) @@ -150,8 +150,8 @@ final class WhitespacesTests: DiffResultBaseTests { "inserted line\n" + " spaced line" - let diffResult = DiffResult() - diffResult.diff(leftText: leftText, rightText: rightText, options: .ignoreLeadingWhitespaces) + let diffResult = DiffResult(options: .ignoreLeadingWhitespaces) + diffResult.diff(leftText: leftText, rightText: rightText) let leftChangeType: [DiffChangeType] = [.matching, .missing, .missing, .matching] assert(lines: diffResult.leftSide.lines, expectedValue: leftChangeType) @@ -170,11 +170,10 @@ final class WhitespacesTests: DiffResultBaseTests { @Test func refreshSummaryAfterDeletingFilteredMatch() { - let diffResult = DiffResult() + let diffResult = DiffResult(options: .ignoreLeadingWhitespaces) diffResult.diff( leftText: " leading spaces", - rightText: "leading spaces", - options: .ignoreLeadingWhitespaces + rightText: "leading spaces" ) let filtered = DiffResult.justMatchingLines(diffResult) diff --git a/VisualDiffer-Bridging-Header.h b/VisualDiffer-Bridging-Header.h index 99175c7..738ca76 100644 --- a/VisualDiffer-Bridging-Header.h +++ b/VisualDiffer-Bridging-Header.h @@ -7,5 +7,3 @@ #import "TitlePredicateEditorRowTemplate.h" #import "TOPFileSizePredicateEditorRowTemplate.h" #import "TOPTimestampPredicateEditorRowTemplate.h" -#import "UnifiedDiff.h" -#import "UDiffScriptBuilder.h" diff --git a/VisualDiffer.xcodeproj/project.pbxproj b/VisualDiffer.xcodeproj/project.pbxproj index 2d20e59..474c3dd 100644 --- a/VisualDiffer.xcodeproj/project.pbxproj +++ b/VisualDiffer.xcodeproj/project.pbxproj @@ -8,6 +8,9 @@ /* Begin PBXBuildFile section */ 2F7446AB0DB6BCF400F9684A /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 2F7446A70DB6BCF400F9684A /* MainMenu.xib */; }; + 55183967302C926400F4190C /* DisplayLine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55183966302C926400F4190C /* DisplayLine.swift */; }; + 5518396A302C928D00F4190C /* InlineDiffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55183968302C928D00F4190C /* InlineDiffTests.swift */; }; + 5518396B302C928D00F4190C /* SequenceDiffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55183969302C928D00F4190C /* SequenceDiffTests.swift */; }; 551DE39F2ED422820067AB18 /* FilesWindowController+SessionPreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551DE39E2ED422820067AB18 /* FilesWindowController+SessionPreferences.swift */; }; 551DE3A62ED4237B0067AB18 /* FileSessionPreferencesWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551DE3A42ED4237B0067AB18 /* FileSessionPreferencesWindow.swift */; }; 551DE3AA2ED428B40067AB18 /* FilePreferences.swift in Sources */ = {isa = PBXBuildFile; fileRef = 551DE3A92ED428A50067AB18 /* FilePreferences.swift */; }; @@ -55,7 +58,6 @@ 5537419F2E9372DF00AB56D0 /* ConfirmationsFoldersBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5537408B2E9372DF00AB56D0 /* ConfirmationsFoldersBox.swift */; }; 553741A02E9372DF00AB56D0 /* DifferenceNavigatorBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5537408E2E9372DF00AB56D0 /* DifferenceNavigatorBox.swift */; }; 553741A12E9372DF00AB56D0 /* FolderReaderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553740602E9372DF00AB56D0 /* FolderReaderDelegate.swift */; }; - 553741A22E9372DF00AB56D0 /* UDiffScriptBuilder.m in Sources */ = {isa = PBXBuildFile; fileRef = 553740BD2E9372DF00AB56D0 /* UDiffScriptBuilder.m */; }; 553741A32E9372DF00AB56D0 /* String+RegularExpression.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553741072E9372DF00AB56D0 /* String+RegularExpression.swift */; }; 553741A42E9372DF00AB56D0 /* FileSystemController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553740182E9372DF00AB56D0 /* FileSystemController.swift */; }; 553741A52E9372DF00AB56D0 /* ComparatorOptions+Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553740482E9372DF00AB56D0 /* ComparatorOptions+Helper.swift */; }; @@ -168,7 +170,6 @@ 553742182E9372DF00AB56D0 /* ComparatorOptions+Description.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553740472E9372DF00AB56D0 /* ComparatorOptions+Description.swift */; }; 553742192E9372DF00AB56D0 /* FoldersOutlineView+Clipboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55373FE82E9372DF00AB56D0 /* FoldersOutlineView+Clipboard.swift */; }; 5537421A2E9372DF00AB56D0 /* DatePickersStackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553740112E9372DF00AB56D0 /* DatePickersStackView.swift */; }; - 5537421B2E9372DF00AB56D0 /* UnifiedDiff.m in Sources */ = {isa = PBXBuildFile; fileRef = 553740BF2E9372DF00AB56D0 /* UnifiedDiff.m */; }; 5537421C2E9372DF00AB56D0 /* SyncOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5537400F2E9372DF00AB56D0 /* SyncOutlineView.swift */; }; 5537421D2E9372DF00AB56D0 /* CompareItem+Comparison.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553740522E9372DF00AB56D0 /* CompareItem+Comparison.swift */; }; 5537421E2E9372DF00AB56D0 /* NSError+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 553740EF2E9372DF00AB56D0 /* NSError+Format.swift */; }; @@ -406,6 +407,11 @@ 55B1CE2B2E9934E900426FEC /* DateBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B1CE2A2E9934E900426FEC /* DateBuilder.swift */; }; 55B1CE312E9934F800426FEC /* DiffResultBaseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B1CE2E2E9934F800426FEC /* DiffResultBaseTests.swift */; }; 55B1CE322E9934F800426FEC /* DiffResultTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B1CE2C2E9934F800426FEC /* DiffResultTests.swift */; }; + 55B5338A302D9C3F008A065B /* InlineDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B53389302D9C3F008A065B /* InlineDiff.swift */; }; + 55B53390302D9C52008A065B /* DiffFileData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B5338C302D9C52008A065B /* DiffFileData.swift */; }; + 55B53391302D9C52008A065B /* EquivalenceTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B5338D302D9C52008A065B /* EquivalenceTable.swift */; }; + 55B53392302D9C52008A065B /* SequenceDiff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B5338E302D9C52008A065B /* SequenceDiff.swift */; }; + 55B53393302D9C52008A065B /* DiffChange.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B5338B302D9C52008A065B /* DiffChange.swift */; }; 55B93A452FAC42950024AEC0 /* DiffProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B93A442FAC42910024AEC0 /* DiffProcessor.swift */; }; 55B9B73E2F5C26F800DB8326 /* BaseTests+CompareItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55B9B73D2F5C26F800DB8326 /* BaseTests+CompareItem.swift */; }; 55BC333E2EDAC69C004035A0 /* RowHeightCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55BC333D2EDAC69C004035A0 /* RowHeightCalculator.swift */; }; @@ -522,6 +528,9 @@ 2A37F4C5FDCFA73011CA2CEA /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = /System/Library/Frameworks/Foundation.framework; sourceTree = ""; }; 32DBCF750370BD2300C91783 /* VisualDiffer_Prefix.pch */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = VisualDiffer_Prefix.pch; sourceTree = ""; }; 550A2CC113FF973B00981A9B /* ApplicationServices.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ApplicationServices.framework; path = System/Library/Frameworks/ApplicationServices.framework; sourceTree = SDKROOT; }; + 55183966302C926400F4190C /* DisplayLine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLine.swift; sourceTree = ""; }; + 55183968302C928D00F4190C /* InlineDiffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineDiffTests.swift; sourceTree = ""; }; + 55183969302C928D00F4190C /* SequenceDiffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequenceDiffTests.swift; sourceTree = ""; }; 551DE39E2ED422820067AB18 /* FilesWindowController+SessionPreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FilesWindowController+SessionPreferences.swift"; sourceTree = ""; }; 551DE3A42ED4237B0067AB18 /* FileSessionPreferencesWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileSessionPreferencesWindow.swift; sourceTree = ""; }; 551DE3A92ED428A50067AB18 /* FilePreferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilePreferences.swift; sourceTree = ""; }; @@ -762,10 +771,6 @@ 553740B42E9372DF00AB56D0 /* SessionPreferencesFiltersBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPreferencesFiltersBox.swift; sourceTree = ""; }; 553740B52E9372DF00AB56D0 /* SessionPreferencesFiltersPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionPreferencesFiltersPanel.swift; sourceTree = ""; }; 553740B92E9372DF00AB56D0 /* PreferencesPanelDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesPanelDataSource.swift; sourceTree = ""; }; - 553740BC2E9372DF00AB56D0 /* UDiffScriptBuilder.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UDiffScriptBuilder.h; sourceTree = ""; }; - 553740BD2E9372DF00AB56D0 /* UDiffScriptBuilder.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UDiffScriptBuilder.m; sourceTree = ""; }; - 553740BE2E9372DF00AB56D0 /* UnifiedDiff.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = UnifiedDiff.h; sourceTree = ""; }; - 553740BF2E9372DF00AB56D0 /* UnifiedDiff.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = UnifiedDiff.m; sourceTree = ""; }; 553740C72E9372DF00AB56D0 /* BigFileFileOperationManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BigFileFileOperationManager.h; sourceTree = ""; }; 553740C82E9372DF00AB56D0 /* BigFileFileOperationManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BigFileFileOperationManager.m; sourceTree = ""; }; 553740D42E9372DF00AB56D0 /* NSPredicate+Objc.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSPredicate+Objc.h"; sourceTree = ""; }; @@ -941,6 +946,11 @@ 55B1CE2C2E9934F800426FEC /* DiffResultTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffResultTests.swift; sourceTree = ""; }; 55B1CE2E2E9934F800426FEC /* DiffResultBaseTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffResultBaseTests.swift; sourceTree = ""; }; 55B212092490AB3B001717DD /* en */ = {isa = PBXFileReference; lastKnownFileType = text.rtf; name = en; path = en.lproj/Credits.rtf; sourceTree = ""; }; + 55B53389302D9C3F008A065B /* InlineDiff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineDiff.swift; sourceTree = ""; }; + 55B5338B302D9C52008A065B /* DiffChange.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffChange.swift; sourceTree = ""; }; + 55B5338C302D9C52008A065B /* DiffFileData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffFileData.swift; sourceTree = ""; }; + 55B5338D302D9C52008A065B /* EquivalenceTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EquivalenceTable.swift; sourceTree = ""; }; + 55B5338E302D9C52008A065B /* SequenceDiff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SequenceDiff.swift; sourceTree = ""; }; 55B93A442FAC42910024AEC0 /* DiffProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiffProcessor.swift; sourceTree = ""; }; 55B9B73D2F5C26F800DB8326 /* BaseTests+CompareItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseTests+CompareItem.swift"; sourceTree = ""; }; 55BC333D2EDAC69C004035A0 /* RowHeightCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RowHeightCalculator.swift; sourceTree = ""; }; @@ -1920,17 +1930,6 @@ path = Features; sourceTree = ""; }; - 553740C02E9372DF00AB56D0 /* DiffEngine */ = { - isa = PBXGroup; - children = ( - 553740BC2E9372DF00AB56D0 /* UDiffScriptBuilder.h */, - 553740BD2E9372DF00AB56D0 /* UDiffScriptBuilder.m */, - 553740BE2E9372DF00AB56D0 /* UnifiedDiff.h */, - 553740BF2E9372DF00AB56D0 /* UnifiedDiff.m */, - ); - path = DiffEngine; - sourceTree = ""; - }; 553740C92E9372DF00AB56D0 /* FileManager */ = { isa = PBXGroup; children = ( @@ -1958,7 +1957,6 @@ 553740E12E9372DF00AB56D0 /* Legacy */ = { isa = PBXGroup; children = ( - 553740C02E9372DF00AB56D0 /* DiffEngine */, 553740C92E9372DF00AB56D0 /* FileManager */, 553740DC2E9372DF00AB56D0 /* Predicate */, ); @@ -2313,6 +2311,7 @@ 553741402E9372DF00AB56D0 /* String */ = { isa = PBXGroup; children = ( + 55183966302C926400F4190C /* DisplayLine.swift */, 5537413F2E9372DF00AB56D0 /* VisibleWhitespaces.swift */, ); path = String; @@ -2545,6 +2544,7 @@ 554B4A442E9B7B8F0023AAF0 /* DiffResult */ = { isa = PBXGroup; children = ( + 55B5338F302D9C52008A065B /* Engine */, 55373FDE2E9372DF00AB56D0 /* DiffChangeType.swift */, 554B4A432E9B7B8F0023AAF0 /* DiffLine.swift */, 557BBB0D2ED1BBB00016CBD4 /* DiffLineComponent.swift */, @@ -2559,6 +2559,7 @@ 554B4A402E9B7B8F0023AAF0 /* DiffSide.swift */, 559186402E9B821B00A30620 /* DiffSummary.swift */, 554B4A3A2E9B7B8F0023AAF0 /* EndOfLine.swift */, + 55B53389302D9C3F008A065B /* InlineDiff.swift */, ); path = DiffResult; sourceTree = ""; @@ -2623,6 +2624,8 @@ 55B1CE2D2E9934F800426FEC /* Tests */ = { isa = PBXGroup; children = ( + 55183968302C928D00F4190C /* InlineDiffTests.swift */, + 55183969302C928D00F4190C /* SequenceDiffTests.swift */, 55B1CE2C2E9934F800426FEC /* DiffResultTests.swift */, 55A4DF332FFD1FFF00ED11BB /* DiffSideTests.swift */, 55A4DF312E9A49A700ED11BB /* EndOfLineTests.swift */, @@ -2648,6 +2651,17 @@ path = FileCompare; sourceTree = ""; }; + 55B5338F302D9C52008A065B /* Engine */ = { + isa = PBXGroup; + children = ( + 55B5338B302D9C52008A065B /* DiffChange.swift */, + 55B5338C302D9C52008A065B /* DiffFileData.swift */, + 55B5338D302D9C52008A065B /* EquivalenceTable.swift */, + 55B5338E302D9C52008A065B /* SequenceDiff.swift */, + ); + path = Engine; + sourceTree = ""; + }; 55C1CBFC2E8BB6AD004FE322 /* visdiff */ = { isa = PBXGroup; children = ( @@ -2803,8 +2817,6 @@ 55944CCC2E5C8D9F00FB1F8C /* PBXTargetDependency */, ); name = VisualDifferTests; - packageProductDependencies = ( - ); productName = VisualDifferTests; productReference = 55944CC72E5C8D9F00FB1F8C /* VisualDifferTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; @@ -2822,8 +2834,6 @@ dependencies = ( ); name = visdiff; - packageProductDependencies = ( - ); productName = visdiff; productReference = 55C1CBE22E8BB6A2004FE322 /* visdiff */; productType = "com.apple.product-type.tool"; @@ -2889,6 +2899,7 @@ packageReferences = ( 55C165602EE444050082A24C /* XCRemoteSwiftPackageReference "Sparkle" */, ); + productRefGroup = 19C28FB0FE9D524F11CA2CBB /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -2961,6 +2972,8 @@ 55A4DF322E9A49B000ED11BB /* EndOfLineTests.swift in Sources */, 55DF3DDF2E924BB10044CC0C /* AlignmentTests.swift in Sources */, 554750C62F5C0A1100C1747A /* CommonAncestorPathTests.swift in Sources */, + 5518396A302C928D00F4190C /* InlineDiffTests.swift in Sources */, + 5518396B302C928D00F4190C /* SequenceDiffTests.swift in Sources */, 55E381C12EEFFD0500CB2F00 /* SymbolicLinkTests.swift in Sources */, 55B1CE312E9934F800426FEC /* DiffResultBaseTests.swift in Sources */, 55B1CE322E9934F800426FEC /* DiffResultTests.swift in Sources */, @@ -3048,7 +3061,6 @@ 553741A02E9372DF00AB56D0 /* DifferenceNavigatorBox.swift in Sources */, 55BC33402EDAC96F004035A0 /* TiledImageView.swift in Sources */, 553741A12E9372DF00AB56D0 /* FolderReaderDelegate.swift in Sources */, - 553741A22E9372DF00AB56D0 /* UDiffScriptBuilder.m in Sources */, 553741A32E9372DF00AB56D0 /* String+RegularExpression.swift in Sources */, 553741A42E9372DF00AB56D0 /* FileSystemController.swift in Sources */, 553741A52E9372DF00AB56D0 /* ComparatorOptions+Helper.swift in Sources */, @@ -3188,7 +3200,6 @@ 553742192E9372DF00AB56D0 /* FoldersOutlineView+Clipboard.swift in Sources */, 5536ADBA2EF169820019EFBF /* SessionDiff+ExtraData.swift in Sources */, 5537421A2E9372DF00AB56D0 /* DatePickersStackView.swift in Sources */, - 5537421B2E9372DF00AB56D0 /* UnifiedDiff.m in Sources */, 5537421C2E9372DF00AB56D0 /* SyncOutlineView.swift in Sources */, 5537421D2E9372DF00AB56D0 /* CompareItem+Comparison.swift in Sources */, 5537421E2E9372DF00AB56D0 /* NSError+Format.swift in Sources */, @@ -3326,12 +3337,14 @@ 552430412F7673F800951F1A /* FileDifferenceNavigatorBox.swift in Sources */, 5537429D2E9372DF00AB56D0 /* FolderSelectionInfo+FilterActionValidator.swift in Sources */, 5537429E2E9372DF00AB56D0 /* OpenDiffCommand.swift in Sources */, + 55B5338A302D9C3F008A065B /* InlineDiff.swift in Sources */, 5537429F2E9372DF00AB56D0 /* RenameCompareItem.swift in Sources */, 553742A02E9372DF00AB56D0 /* DisplayOptions+Helper.swift in Sources */, 553742A12E9372DF00AB56D0 /* SessionPreferencesWindow+Data.swift in Sources */, 553742A22E9372DF00AB56D0 /* FolderReader+Log.swift in Sources */, 553742A32E9372DF00AB56D0 /* NSArrayController+Paths.swift in Sources */, 553742A42E9372DF00AB56D0 /* NSPredicateEditor+Fix.swift in Sources */, + 55183967302C926400F4190C /* DisplayLine.swift in Sources */, 553742A52E9372DF00AB56D0 /* FoldersWindowController+NSToolbarDelegate.swift in Sources */, 553742A62E9372DF00AB56D0 /* NSAlert+Helper.swift in Sources */, 553742A72E9372DF00AB56D0 /* FoldersWindowController+NSWindowDelegate.swift in Sources */, @@ -3347,6 +3360,10 @@ 553742B12E9372DF00AB56D0 /* FoldersWindowController+UISetup.swift in Sources */, 5577FFE82FAF2FC30084FB06 /* PopUpButtonURL.swift in Sources */, 553742B22E9372DF00AB56D0 /* NSTableView+Font.swift in Sources */, + 55B53390302D9C52008A065B /* DiffFileData.swift in Sources */, + 55B53391302D9C52008A065B /* EquivalenceTable.swift in Sources */, + 55B53392302D9C52008A065B /* SequenceDiff.swift in Sources */, + 55B53393302D9C52008A065B /* DiffChange.swift in Sources */, 553742B32E9372DF00AB56D0 /* ActionBarView.swift in Sources */, 553742B42E9372DF00AB56D0 /* TouchCompareItem.swift in Sources */, 553742B52E9372DF00AB56D0 /* HistoryFetchedResultsControllerDelegate.swift in Sources */, From bf5432710799d57809bab7b11c8b0fa7bc2c0ead Mon Sep 17 00:00:00 2001 From: Davide Ficano Date: Thu, 20 Aug 2026 11:29:42 +0200 Subject: [PATCH 02/24] build: add `RUN_LINT` flag --- .gitignore | 2 +- Gemfile.lock | 179 +++++++++++++++++++++++------------------- fastlane/.env.default | 1 + fastlane/Fastfile | 11 ++- 4 files changed, 109 insertions(+), 84 deletions(-) diff --git a/.gitignore b/.gitignore index 61539f8..0716547 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,4 @@ fastlane/test_output fastlane/report.xml fastlane/README.md -notes.txt +.bundle/ diff --git a/Gemfile.lock b/Gemfile.lock index 2d636c3..7ae887c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -8,8 +8,8 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.4.0) - aws-partitions (1.1268.0) - aws-sdk-core (3.254.0) + aws-partitions (1.1281.0) + aws-sdk-core (3.254.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -20,8 +20,8 @@ GEM aws-sdk-kms (1.130.0) aws-sdk-core (~> 3, >= 3.254.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.227.0) - aws-sdk-core (~> 3, >= 3.254.0) + aws-sdk-s3 (1.229.0) + aws-sdk-core (~> 3, >= 3.254.1) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) @@ -35,45 +35,33 @@ GEM colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) - csv (3.3.5) + csv (3.3.6) declarative (0.0.20) digest-crc (0.7.0) rake (>= 12.0.0, < 14.0.0) domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (1.5.0) + erb (6.0.7) + excon (1.7.0) + logger + faraday (2.14.3) + faraday-net_http (>= 2.0, < 3.5) + json logger - faraday (1.10.6) - faraday-em_http (~> 1.0) - faraday-em_synchrony (~> 1.0) - faraday-excon (~> 1.1) - faraday-httpclient (~> 1.0) - faraday-multipart (~> 1.0) - faraday-net_http (~> 1.0) - faraday-net_http_persistent (~> 1.0) - faraday-patron (~> 1.0) - faraday-rack (~> 1.0) - faraday-retry (~> 1.0) - ruby2_keywords (>= 0.0.4) faraday-cookie_jar (0.0.8) faraday (>= 0.8.0) http-cookie (>= 1.0.0) - faraday-em_http (1.0.0) - faraday-em_synchrony (1.0.1) - faraday-excon (1.1.0) - faraday-httpclient (1.0.1) + faraday-follow_redirects (0.5.0) + faraday (>= 1, < 3) faraday-multipart (1.2.0) multipart-post (~> 2.0) - faraday-net_http (1.0.2) - faraday-net_http_persistent (1.2.0) - faraday-patron (1.0.0) - faraday-rack (1.0.0) - faraday-retry (1.0.4) - faraday_middleware (1.2.1) - faraday (~> 1.0) + faraday-net_http (3.4.4) + net-http (~> 0.5) + faraday-retry (2.4.0) + faraday (~> 2.0) fastimage (2.4.1) - fastlane (2.237.0) + fastlane (2.238.0) CFPropertyList (>= 2.3, < 5.0.0) abbrev (~> 0.1) addressable (>= 2.9.0, < 3.0.0) @@ -89,9 +77,11 @@ GEM dotenv (>= 2.1.1, < 3.0.0) emoji_regex (>= 0.1, < 4.0) excon (>= 0.71.0, < 2.0.0) - faraday (~> 1.0) - faraday-cookie_jar (~> 0.0.6) - faraday_middleware (~> 1.0) + faraday (~> 2.7) + faraday-cookie_jar (~> 0.0.8) + faraday-follow_redirects (~> 0.3) + faraday-multipart (~> 1.0) + faraday-retry (~> 2.0) fastimage (>= 2.1.0, < 3.0.0) fastlane-sirp (>= 1.1.0) gh_inspector (>= 1.1.2, < 2.0.0) @@ -101,6 +91,7 @@ GEM google-cloud-storage (~> 1.31) highline (~> 2.0) http-cookie (~> 1.0.5) + irb (>= 1.8) json (< 3.0.0) jwt (>= 2.10.3, < 4) logger (>= 1.6, < 2.0) @@ -117,7 +108,7 @@ GEM security (= 0.1.5) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (~> 3) + terminal-table (~> 4) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -126,21 +117,22 @@ GEM xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) fastlane-sirp (1.1.0) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.104.0) + google-apis-androidpublisher_v3 (0.106.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-core (0.18.0) - addressable (~> 2.5, >= 2.5.1) - googleauth (~> 1.9) - httpclient (>= 2.8.3, < 3.a) - mini_mime (~> 1.0) - mutex_m + google-apis-core (1.2.5) + addressable (~> 2.9) + faraday (~> 2.13) + faraday-follow_redirects (~> 0.3) + googleauth (~> 1.14) + mini_mime (~> 1.1) + multi_json (~> 1.11) representable (~> 3.0) - retriable (>= 2.0, < 4.a) + retriable (>= 3.1, < 5.0) google-apis-iamcredentials_v1 (0.28.0) google-apis-core (>= 0.15.0, < 2.a) google-apis-playcustomapp_v1 (0.18.0) google-apis-core (>= 0.15.0, < 2.a) - google-apis-storage_v1 (0.64.0) + google-apis-storage_v1 (0.66.0) google-apis-core (>= 0.15.0, < 2.a) google-cloud-core (1.9.0) google-cloud-env (>= 1.0, < 3.a) @@ -159,7 +151,7 @@ GEM googleauth (~> 1.9) mini_mime (~> 1.0) google-logging-utils (0.2.0) - googleauth (1.17.1) + googleauth (1.17.3) faraday (>= 1.0, < 3.a) google-cloud-env (~> 2.2) google-logging-utils (~> 0.1) @@ -170,10 +162,14 @@ GEM highline (2.0.3) http-cookie (1.0.8) domain_name (~> 0.5) - httpclient (2.9.0) - mutex_m + io-console (0.9.2) + irb (1.18.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) jmespath (1.6.2) - json (2.20.0) + json (2.21.2) jwt (3.2.0) base64 kramdown (2.5.1) @@ -186,22 +182,38 @@ GEM mutex_m (0.3.0) nanaimo (0.4.0) naturally (2.3.0) + net-http (0.9.1) + uri (>= 0.11.1) nkf (0.3.0) optparse (0.8.1) os (1.1.4) ostruct (0.6.3) plist (3.7.2) + pp (0.6.4) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) pstore (0.2.1) public_suffix (7.0.5) rake (13.4.2) + rbs (4.1.3) + logger + prism (>= 1.6.0) + tsort + rdoc (8.0.0) + erb + prism (>= 1.6.0) + rbs (>= 4.0.0) + tsort + reline (0.7.0) + io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.8.0) + retriable (4.2.0) rexml (3.4.4) rouge (3.28.0) - ruby2_keywords (0.0.5) rubyzip (2.4.1) security (0.1.5) signet (0.22.0) @@ -212,15 +224,19 @@ GEM CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (3.0.2) - unicode-display_width (>= 1.1.1, < 3) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) trailblazer-option (0.1.2) + tsort (0.2.0) tty-cursor (0.7.1) tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unicode-display_width (2.6.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uri (1.1.1) word_wrap (1.0.0) xcodeproj (1.28.1) CFPropertyList (>= 2.3.3, < 4.0) @@ -252,59 +268,55 @@ CHECKSUMS artifactory (3.0.17) sha256=3023d5c964c31674090d655a516f38ca75665c15084140c08b7f2841131af263 atomos (0.1.3) sha256=7d43b22f2454a36bace5532d30785b06de3711399cb1c6bf932573eda536789f aws-eventstream (1.4.0) sha256=116bf85c436200d1060811e6f5d2d40c88f65448f2125bc77ffce5121e6e183b - aws-partitions (1.1268.0) sha256=61c41c23445380425455e549dcb283f0df50aa2f3d71892f692572fed9ef6c0f - aws-sdk-core (3.254.0) sha256=ee3e3220b8468a3c9e59daba18e6ec897bf5c7ce8adcc0670cfa2f1f092112fe + aws-partitions (1.1281.0) sha256=97fcc7d6a068ab790a1efcd8c6587c08fb108b4bed7ff1409cb47e0d642782ec + aws-sdk-core (3.254.1) sha256=518089e32134c3478cd4ec63d07fb966546a45e9e4cbf5f80d2cf16d5699d29b aws-sdk-kms (1.130.0) sha256=a2e83662ca31b77a2a19c9aa2f40a98165a67270c18c718fe1c70d0cbd7cd749 - aws-sdk-s3 (1.227.0) sha256=552b23bf9a37c7db4957e9291543e61c8de886cf31d1d45ea3b429df0feea939 + aws-sdk-s3 (1.229.0) sha256=7dd11a111875b3505d2ec0a0a383a6aab6c1badb3d4e94e7fbb958a24451f596 aws-sigv4 (1.12.1) sha256=6973ff95cb0fd0dc58ba26e90e9510a2219525d07620c8babeb70ef831826c00 babosa (1.0.4) sha256=18dea450f595462ed7cb80595abd76b2e535db8c91b350f6c4b3d73986c5bc99 base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c bigdecimal (4.1.2) sha256=53d217666027eab4280346fba98e7d5b66baaae1b9c3c1c0ffe89d48188a3fbd + bundler (4.0.11) sha256=5bcec0fb78302e48d02ee46f10ee6e6942be647ba5b44a6d1ddfda9a240ce785 claide (1.1.0) sha256=6d3c5c089dde904d96aa30e73306d0d4bd444b1accb9b3125ce14a3c0183f82e colored (1.2) sha256=9d82b47ac589ce7f6cab64b1f194a2009e9fd00c326a5357321f44afab2c1d2c colored2 (3.1.2) sha256=b13c2bd7eeae2cf7356a62501d398e72fde78780bd26aec6a979578293c28b4a commander (4.6.0) sha256=7d1ddc3fccae60cc906b4131b916107e2ef0108858f485fdda30610c0f2913d9 - csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f + csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456 declarative (0.0.20) sha256=8021dd6cb17ab2b61233c56903d3f5a259c5cf43c80ff332d447d395b17d9ff9 digest-crc (0.7.0) sha256=64adc23a26a241044cbe6732477ca1b3c281d79e2240bcff275a37a5a0d78c07 domain_name (0.6.20240107) sha256=5f693b2215708476517479bf2b3802e49068ad82167bcd2286f899536a17d933 dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8 emoji_regex (3.2.3) sha256=ecd8be856b7691406c6bf3bb3a5e55d6ed683ffab98b4aa531bb90e1ddcc564b - excon (1.5.0) sha256=c503ad1d0123bc8ab2a062ff3789dc891ec368cb9e13765ab88a9c58c8bb6d50 - faraday (1.10.6) sha256=7ff4802a6b312876a2241b3e641ce0d5045e168dd871b422c35b505e5261ad4d + erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92 + excon (1.7.0) sha256=b8deec2bf978123b7e4b13a103ffec61557c6feaba34706dbc1ce4a94dec33e0 + faraday (2.14.3) sha256=1882247e6766615c8220b4392bf1d27f6ebb63d8e28267587cef1fb0bf37f278 faraday-cookie_jar (0.0.8) sha256=0140605823f8cc63c7028fccee486aaed8e54835c360cffc1f7c8c07c4299dbb - faraday-em_http (1.0.0) sha256=7a3d4c7079789121054f57e08cd4ef7e40ad1549b63101f38c7093a9d6c59689 - faraday-em_synchrony (1.0.1) sha256=bf3ce45dcf543088d319ab051f80985ea6d294930635b7a0b966563179f81750 - faraday-excon (1.1.0) sha256=b055c842376734d7f74350fe8611542ae2000c5387348d9ba9708109d6e40940 - faraday-httpclient (1.0.1) sha256=4c8ff1f0973ff835be8d043ef16aaf54f47f25b7578f6d916deee8399a04d33b + faraday-follow_redirects (0.5.0) sha256=5cde93c894b30943a5d2b93c2fe9284216a6b756f7af406a1e55f211d97d10ad faraday-multipart (1.2.0) sha256=7d89a949693714176f612323ca13746a2ded204031a6ba528adee788694ef757 - faraday-net_http (1.0.2) sha256=63992efea42c925a20818cf3c0830947948541fdcf345842755510d266e4c682 - faraday-net_http_persistent (1.2.0) sha256=0b0cbc8f03dab943c3e1cc58d8b7beb142d9df068b39c718cd83e39260348335 - faraday-patron (1.0.0) sha256=dc2cd7b340bb3cc8e36bcb9e6e7eff43d134b6d526d5f3429c7a7680ddd38fa7 - faraday-rack (1.0.0) sha256=ef60ec969a2bb95b8dbf24400155aee64a00fc8ba6c6a4d3968562bcc92328c0 - faraday-retry (1.0.4) sha256=dc659233777fabf96c69c2ffe56c0a5d2c102af90321a42cc6c90157bcd716aa - faraday_middleware (1.2.1) sha256=d45b78c8ee864c4783fbc276f845243d4a7918a67301c052647bacabec0529e9 + faraday-net_http (3.4.4) sha256=0e78af151747ed1b00f33e25973b4bc220d7f16c00c39676817c8b12331eb588 + faraday-retry (2.4.0) sha256=7b79c48fb7e56526faf247b12d94a680071ff40c9fda7cf1ec1549439ad11ebe fastimage (2.4.1) sha256=c64bebd46b6fd8943ab70c1e6e85ff728f970f2e48f92ecd249b6bc3a540ad20 - fastlane (2.237.0) sha256=bb1e867bc070fb328741b5e6e7606d5ba596941a9ecca0478f60ef22d1009db9 + fastlane (2.238.0) sha256=78e9252df2224c7e427012638e41051edfa29b133a40fda883fd2f1c13a77b98 fastlane-sirp (1.1.0) sha256=10bc94f9682efd8e1badfb31452a76dd8981f1f3a33717c765fde6d75b54d847 gh_inspector (1.1.3) sha256=04cca7171b87164e053aa43147971d3b7f500fcb58177698886b48a9fc4a1939 - google-apis-androidpublisher_v3 (0.104.0) sha256=3bf7f0dee23ae71e070e279848374834853204e304831cf6aa76fa435a4d6eff - google-apis-core (0.18.0) sha256=96b057816feeeab448139ed5b5c78eab7fc2a9d8958f0fbc8217dedffad054ee + google-apis-androidpublisher_v3 (0.106.0) sha256=b90b5312b70f8f731def88f24a2b8fb791fe1b979a7c2c14a905cb6cae19cf3f + google-apis-core (1.2.5) sha256=e21562b5627a0fc6a0c3165e429c3afed0f6a628eaa514e83f7204dda1adff69 google-apis-iamcredentials_v1 (0.28.0) sha256=0a92ffe6cc39c569554af2a77a25dfc61519ed8bbb64ab04cffdd352dc5ef106 google-apis-playcustomapp_v1 (0.18.0) sha256=44b277b9dee4a59ac5e9d98be1485edc5e382d2f9d73c79ae8908a455786a254 - google-apis-storage_v1 (0.64.0) sha256=75b11afa2edcee859b84c7a6972ee4456314eeef5f762827fd6cf5c5ffaf93f2 + google-apis-storage_v1 (0.66.0) sha256=fdf6d65d3fc77e7046b3494333a818ece9e2afd763bb161e1a7a9e70cf198351 google-cloud-core (1.9.0) sha256=ab55409f51488e8deefb6edcc1ce4771dfb5da2fe7b3bc075709a030c2b682a4 google-cloud-env (2.2.2) sha256=94bed40e05a67e9468ce1cb38389fba9a90aa8fc62fc9e173204c1dca59e21e7 google-cloud-errors (1.7.0) sha256=6e682f42d89aae08689f36f28495de629d756da076bafff0003bac7f8042ebb3 google-cloud-storage (1.62.0) sha256=e2c3c08bf8fd40d50be92304084942203314d4fc0ee52028e99f9359c3ad1330 google-logging-utils (0.2.0) sha256=675462b4ea5affa825a3442694ca2d75d0069455a1d0956127207498fca3df7b - googleauth (1.17.1) sha256=0f7e6fc70e204cee1b2d71f1e1de2d3b349d432404197fe68ebf7fa23d0821b9 + googleauth (1.17.3) sha256=9a655455885c0c17a8b70537acd2a992a7c7842c43c06c6612dfb0d93e755d58 highline (2.0.3) sha256=2ddd5c127d4692721486f91737307236fe005352d12a4202e26c48614f719479 http-cookie (1.0.8) sha256=b14fe0445cf24bf9ae098633e9b8d42e4c07c3c1f700672b09fbfe32ffd41aa6 - httpclient (2.9.0) sha256=4b645958e494b2f86c2f8a2f304c959baa273a310e77a2931ddb986d83e498c8 + io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 + irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 jmespath (1.6.2) sha256=238d774a58723d6c090494c8879b5e9918c19485f7e840f2c1c7532cf84ebcb1 - json (2.20.0) sha256=9362bc6e55a952b056abf9167cf053358181c904cb70cd6eee0808ea830fc32b + json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a jwt (3.2.0) sha256=5419b1fe37b1da0982bd07051f573a8b8789ab724c2aa7e785e4784a3ed217d7 kramdown (2.5.1) sha256=87bbb6abd9d3cebe4fc1f33e367c392b4500e6f8fa19dd61c0972cf4afe7368c logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 @@ -315,31 +327,40 @@ CHECKSUMS mutex_m (0.3.0) sha256=cfcb04ac16b69c4813777022fdceda24e9f798e48092a2b817eb4c0a782b0751 nanaimo (0.4.0) sha256=faf069551bab17f15169c1f74a1c73c220657e71b6e900919897a10d991d0723 naturally (2.3.0) sha256=459923cf76c2e6613048301742363200c3c7e4904c324097d54a67401e179e01 + net-http (0.9.1) sha256=25ba0b67c63e89df626ed8fac771d0ad24ad151a858af2cc8e6a716ca4336996 nkf (0.3.0) sha256=357a8dbeba38b727b75930f665146546076a394a1c243faf634ff176e3588895 optparse (0.8.1) sha256=42bea10d53907ccff4f080a69991441d611fbf8733b60ed1ce9ee365ce03bd1a os (1.1.4) sha256=57816d6a334e7bd6aed048f4b0308226c5fb027433b67d90a9ab435f35108d3f ostruct (0.6.3) sha256=95a2ed4a4bd1d190784e666b47b2d3f078e4a9efda2fccf18f84ddc6538ed912 plist (3.7.2) sha256=d37a4527cc1116064393df4b40e1dbbc94c65fa9ca2eec52edf9a13616718a42 + pp (0.6.4) sha256=dfcb0fce700c41456265922884f9fe195d7fbb0674a3578e6c0f69588e82b570 + prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 + prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 pstore (0.2.1) sha256=03904d0f2c66579e96d1e6704cdabc0c88df7ea8ed8782d9f3569f6f6c702c1a public_suffix (7.0.5) sha256=1a8bb08f1bbea19228d3bed6e5ed908d1cb4f7c2726d18bd9cadf60bc676f623 rake (13.4.2) sha256=cb825b2bd5f1f8e91ca37bddb4b9aaf345551b4731da62949be002fa89283701 + rbs (4.1.3) sha256=0c4474a9751cdc14364bfad0b3e53678323bbdc2c31683b0445932867dbab8c4 + rdoc (8.0.0) sha256=03bf8c08a9639658855a0cfd77c0abca8325c227693f7f33f82957811348c469 + reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d representable (3.2.0) sha256=cc29bf7eebc31653586849371a43ffe36c60b54b0a6365b5f7d95ec34d1ebace - retriable (3.8.0) sha256=9f2f1b0207594c7817f17f671587b8ec7587387ac6cebda6c941a802bb98a8e5 + retriable (4.2.0) sha256=90c3b257472a1c02f2a3dfa64dd35a420f7a624cef1a5981e20fdac5730f8cdc rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 rouge (3.28.0) sha256=0d6de482c7624000d92697772ab14e48dca35629f8ddf3f4b21c99183fd70e20 - ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef rubyzip (2.4.1) sha256=8577c88edc1fde8935eb91064c5cb1aef9ad5494b940cf19c775ee833e075615 security (0.1.5) sha256=3a977a0eca7706e804c96db0dd9619e0a94969fe3aac9680fcfc2bf9b8a833b7 signet (0.22.0) sha256=b76d495ccb07ad35dbc89f3e920665a9d8ed717141955034005d7843dcfe4780 simctl (1.6.10) sha256=b99077f4d13ad81eace9f86bf5ba4df1b0b893a4d1b368bd3ed59b5b27f9236b terminal-notifier (2.0.0) sha256=7a0d2b2212ab9835c07f4b2e22a94cff64149dba1eed203c04835f7991078cea - terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + terminal-table (4.0.0) sha256=f504793203f8251b2ea7c7068333053f0beeea26093ec9962e62ea79f94301d2 trailblazer-option (0.1.2) sha256=20e4f12ea4e1f718c8007e7944ca21a329eee4eed9e0fa5dde6e8ad8ac4344a3 + tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f tty-cursor (0.7.1) sha256=79534185e6a777888d88628b14b6a1fdf5154a603f285f80b1753e1908e0bf48 tty-screen (0.8.2) sha256=c090652115beae764336c28802d633f204fb84da93c6a968aa5d8e319e819b50 tty-spinner (0.9.3) sha256=0e036f047b4ffb61f2aa45f5a770ec00b4d04130531558a94bfc5b192b570542 uber (0.1.0) sha256=5beeb407ff807b5db994f82fa9ee07cfceaa561dad8af20be880bc67eba935dc - unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + unicode-display_width (3.2.0) sha256=0cdd96b5681a5949cdbc2c55e7b420facae74c4aaf9a9815eee1087cb1853c42 + unicode-emoji (4.2.0) sha256=519e69150f75652e40bf736106cfbc8f0f73aa3fb6a65afe62fefa7f80b0f80f + uri (1.1.1) sha256=379fa58d27ffb1387eaada68c749d1426738bd0f654d812fcc07e7568f5c57c6 word_wrap (1.0.0) sha256=f556d4224c812e371000f12a6ee8102e0daa724a314c3f246afaad76d82accc7 xcodeproj (1.28.1) sha256=6f12670f00739d9817ca27ac89d6ef01cc86050e22a0bc08a3131487e5b5cddc xcpretty (0.4.1) sha256=b14c50e721f6589ee3d6f5353e2c2cfcd8541fa1ea16d6c602807dd7327f3892 diff --git a/fastlane/.env.default b/fastlane/.env.default index f1db723..af9ed2f 100644 --- a/fastlane/.env.default +++ b/fastlane/.env.default @@ -9,6 +9,7 @@ APPLE_ID= RELEASE_NOTE_PATH=notes CHECK_VERSION=true +RUN_LINT=true # === Build === BUILD_PATH="build" diff --git a/fastlane/Fastfile b/fastlane/Fastfile index c706dd1..cfc3f90 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -76,10 +76,13 @@ platform :mac do private_lane :release_precondition do check_current_branch unless ENV["IGNORE_BUILD_BRANCH"] == "true" check_release_version if ENV["CHECK_VERSION"] == "true" - lint_app - format_app( - check: true - ) + + if ENV["LINT_ENABLED"] == "true" + lint_app + format_app( + check: true + ) + end note_file_name = File.join(ENV["BUILD_PATH"], ENV["RELEASE_NOTE_PATH"], ENV["APP_VERSION"]) notes_full_path = File.join(ENV["PROJECT_ROOT"], "#{note_file_name}.md") From fb7e7f9a6f5945d92746fd6ca5ea4e40156918f0 Mon Sep 17 00:00:00 2001 From: Davide Ficano Date: Thu, 20 Aug 2026 13:05:53 +0200 Subject: [PATCH 03/24] fix(folder-compare): avoid a too tall console pane on tall windows The pane reopened at three quarters of the window height instead of a size of its own. Collapsing also relied on isSubviewCollapsed(_:), which is true for a pane not laid out yet, so expanding collapsed it again. --- .../FoldersWindowController+UICreation.swift | 2 - .../View/DualPane/DualPaneSplitView.swift | 120 +++++++++--------- 2 files changed, 60 insertions(+), 62 deletions(-) diff --git a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+UICreation.swift b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+UICreation.swift index 4b110c4..aaa4326 100644 --- a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+UICreation.swift +++ b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+UICreation.swift @@ -84,10 +84,8 @@ extension FoldersWindowController { } func createConsoleSplitter() -> DualPaneSplitView { - let frame = window?.contentView?.bounds ?? .zero let view = DualPaneSplitView(frame: .zero) view.translatesAutoresizingMaskIntoConstraints = false - view.firstViewSize = frame.height * 3 / 4 return view } diff --git a/Sources/SharedKit/Utilities/View/DualPane/DualPaneSplitView.swift b/Sources/SharedKit/Utilities/View/DualPane/DualPaneSplitView.swift index 37da868..2685a14 100644 --- a/Sources/SharedKit/Utilities/View/DualPane/DualPaneSplitView.swift +++ b/Sources/SharedKit/Utilities/View/DualPane/DualPaneSplitView.swift @@ -6,29 +6,37 @@ // Copyright (c) 2025 visualdiffer.com // +// size used until the user resizes the collapsable pane +private let defaultCollapsablePaneSize: CGFloat = 120 + +private let userResizeKey = "NSSplitViewUserResizeKey" + class DualPaneSplitView: NSSplitView { - var isSubviewCollapsed = false + var collapsablePaneSize: CGFloat = defaultCollapsablePaneSize - @objc var firstViewSize: CGFloat = 0 + // the collapsed state is owned by the app through isHidden because + // NSSplitView.isSubviewCollapsed(_:) reports true also for a pane not laid out yet + @objc var hasSubviewCollapsed: Bool { + subviews.isEmpty || subviews.contains { $0.isHidden } + } + + // the collapsable pane is the one the delegate allows to collapse + private var collapsablePaneIndex: Int? { + subviews.firstIndex { delegate?.splitView?(self, canCollapseSubview: $0) == true } + } + + override var dividerThickness: CGFloat { + hasSubviewCollapsed ? 0.0 : super.dividerThickness + } override init(frame frameRect: NSRect) { super.init(frame: frameRect) - NotificationCenter.default.addObserver( - self, - selector: #selector(subviewResized), - name: NSSplitView.didResizeSubviewsNotification, - object: self - ) + observeUserResize() } required init?(coder: NSCoder) { super.init(coder: coder) - NotificationCenter.default.addObserver( - self, - selector: #selector(subviewResized), - name: NSSplitView.didResizeSubviewsNotification, - object: self - ) + observeUserResize() } deinit { @@ -41,30 +49,21 @@ class DualPaneSplitView: NSSplitView { @objc func subviewResized(_ notification: Notification) { - if hasSubviewCollapsed { - return - } - guard let num = notification.userInfo?["NSSplitViewUserResizeKey"] as? NSNumber else { + // only a manual drag defines the size to remember; on a window + // resize NSSplitView scales the panes proportionally and that size is not a user choice + guard let userResize = notification.userInfo?[userResizeKey] as? NSNumber, + userResize.boolValue, + !hasSubviewCollapsed, + let index = collapsablePaneIndex else { return } - let isUserResize = num.boolValue - if isUserResize { - let view = subviews[0] - firstViewSize = isVertical ? view.frame.width : view.frame.height + let currentSize = paneSize(of: subviews[index]) + if currentSize > 0 { + collapsablePaneSize = currentSize } } - @objc var hasSubviewCollapsed: Bool { - if subviews.isEmpty { - return true - } - for view in subviews where isSubviewCollapsed(view) { - return true - } - return false - } - @objc func toggleSubview(at index: Int) { if hasSubviewCollapsed { @@ -76,45 +75,46 @@ class DualPaneSplitView: NSSplitView { @objc func collapseSubview(at index: Int) { - if hasSubviewCollapsed { - return - } - if index == 0 { - let collapseView = subviews[0] - collapseView.isHidden = true - setPosition(0, ofDividerAt: 0) - } else if index == 1 { - let expandView = subviews[0] - let collapseView = subviews[1] - collapseView.isHidden = true - let position = isVertical ? expandView.frame.width : expandView.frame.height - setPosition(position, ofDividerAt: 0) - } else { + let collapseView = subviews[index] + if collapseView.isHidden { return } + collapseView.isHidden = true adjustSubviews() } @objc func expandSubview(at index: Int) { - if !hasSubviewCollapsed { - return - } - if index == 0 { - let collapseView = subviews[0] - collapseView.isHidden = false - setPosition(firstViewSize, ofDividerAt: 0) - } else if index == 1 { - let collapseView = subviews[1] - collapseView.isHidden = false - setPosition(firstViewSize, ofDividerAt: 0) - } else { + let expandView = subviews[index] + if !expandView.isHidden { return } + expandView.isHidden = false + // the position is derived from the current size and kept inside the + // range allowed by the delegate, otherwise NSSplitView collapses the pane again + let available = paneSize(of: self) + let position = index == 0 ? collapsablePaneSize : available - collapsablePaneSize - dividerThickness + setPosition(constrain(position: position, available: available), ofDividerAt: 0) adjustSubviews() } - override var dividerThickness: CGFloat { - hasSubviewCollapsed ? 0.0 : super.dividerThickness + private func observeUserResize() { + NotificationCenter.default.addObserver( + self, + selector: #selector(subviewResized), + name: NSSplitView.didResizeSubviewsNotification, + object: self + ) + } + + private func paneSize(of view: NSView) -> CGFloat { + isVertical ? view.frame.width : view.frame.height + } + + private func constrain(position: CGFloat, available: CGFloat) -> CGFloat { + let minPosition = delegate?.splitView?(self, constrainMinCoordinate: 0, ofSubviewAt: 0) ?? 0 + let maxPosition = delegate?.splitView?(self, constrainMaxCoordinate: available, ofSubviewAt: 0) ?? available + + return min(max(position, minPosition), maxPosition) } } From 427bba0f9f835866d74e382357bb0375dc7dbc13 Mon Sep 17 00:00:00 2001 From: Davide Ficano Date: Thu, 20 Aug 2026 13:06:41 +0200 Subject: [PATCH 04/24] docs: improve command line usage Closes #60 --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 6947ad5..d9edc3b 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The app helps developers, designers, and anyone managing multiple versions of pr - 🧩 **File-level diff view** — inspect detailed content changes line-by-line (for supported file types). - 🧹 **Powerful filters** — exclude version control, backup, or temporary files (e.g., `.git`, `.svn`, `.zip`, `.DS_Store`). - 🖱️ **Drag & drop support** — compare folders by simply dragging them into the app window. -- 📦 **Export and automation** — integrate comparisons into scripts or workflows using CLI tools (if available). +- 📦 **Command line & automation** — compare from the shell with the bundled [`visdiff`](https://wiki.visualdiffer.com/unixshell.html) tool, or drive the app from your own scripts with [AppleScript](https://wiki.visualdiffer.com/externalApps.html#applescript). - ⚡ **Fast comparison engine** — optimized to handle large folder structures efficiently. For more information, visit the [VisualDiffer Wiki](https://wiki.visualdiffer.com/). @@ -67,6 +67,17 @@ brew install visualdiffer Download from [releases](https://github.com/visualdiffer/visualdiffer/releases/latest), unzip, and drag the app to Applications folder +### Command line tool + +The app bundles `visdiff`, a CLI tool to start a comparison from the shell. +It is not installed in the PATH automatically (sandbox restriction), create a symlink: + +```bash +sudo ln -s /Applications/VisualDiffer.app/Contents/Helpers/visdiff /usr/local/bin/visdiff +``` + +See the [visdiff documentation](https://wiki.visualdiffer.com/unixshell.html) for the available options. + ### Updates When the app launches, it automatically checks for updates and notifies the user when a new version is available. From 539373498384934b48e0f772c4b9a23caa251eb5 Mon Sep 17 00:00:00 2001 From: Davide Ficano Date: Tue, 25 Aug 2026 11:05:03 +0200 Subject: [PATCH 05/24] refactor: merge 'Compare Files' and 'Compare Folders' menus, add 'Compare' toolbar icon Closes #62 --- Base.lproj/MainMenu.xib | 6 --- Localizable.xcstrings | 3 ++ Sources/Core/Common/VDSymbol.swift | 1 + .../FoldersOutlineView+Menu.swift | 13 ++--- .../Controller/FolderSelectionInfo.swift | 49 ++++++++++++++++++- .../FoldersWindowController+Exclude.swift | 2 +- ...roller+FoldersOutlineViewContextMenu.swift | 15 +++--- .../FoldersWindowController+Menu.swift | 9 +--- ...FoldersWindowController+MenuDelegate.swift | 15 ++++-- ...rsWindowController+NSToolbarDelegate.swift | 14 ++++++ ...SelectionInfo+CompareActionValidator.swift | 30 +++--------- ...ectionInfo+FileSystemActionValidator.swift | 16 +++--- ...rSelectionInfo+FilterActionValidator.swift | 6 +-- ...rSelectionInfo+FolderActionValidator.swift | 14 +----- ...rSelectionInfo+ViewerActionValidator.swift | 4 +- assets/Images.xcassets/Toolbar/Contents.json | 6 +-- .../compareItems.symbolset/Contents.json | 12 +++++ .../compareItems.symbolset/compareItems.svg | 46 +++++++++++++++++ mul.lproj/MainMenu.xcstrings | 12 ----- 19 files changed, 172 insertions(+), 101 deletions(-) create mode 100644 assets/Images.xcassets/Toolbar/compareItems.symbolset/Contents.json create mode 100644 assets/Images.xcassets/Toolbar/compareItems.symbolset/compareItems.svg diff --git a/Base.lproj/MainMenu.xib b/Base.lproj/MainMenu.xib index a345101..e4fb1e3 100644 --- a/Base.lproj/MainMenu.xib +++ b/Base.lproj/MainMenu.xib @@ -344,12 +344,6 @@ - - - - - - diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 24f2c74..4a397ea 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -728,6 +728,9 @@ }, "Compare Folders or Files" : { + }, + "Compare selected files or folders" : { + }, "Comparison" : { diff --git a/Sources/Core/Common/VDSymbol.swift b/Sources/Core/Common/VDSymbol.swift index 3dd105e..710e312 100644 --- a/Sources/Core/Common/VDSymbol.swift +++ b/Sources/Core/Common/VDSymbol.swift @@ -48,6 +48,7 @@ enum VDSymbol { case comparisonMethod = "list.bullet.rectangle" case wordWrapOn = "vd.wordWrapOn" case wordWrapOff = "vd.wordWrapOff" + case compareItems = "vd.compareItems" } enum Button: String, VDSymbolImage { diff --git a/Sources/Features/FoldersCompare/Components/FoldersOutlineView/FoldersOutlineView+Menu.swift b/Sources/Features/FoldersCompare/Components/FoldersOutlineView/FoldersOutlineView+Menu.swift index 6f3ab83..032da18 100644 --- a/Sources/Features/FoldersCompare/Components/FoldersOutlineView/FoldersOutlineView+Menu.swift +++ b/Sources/Features/FoldersCompare/Components/FoldersOutlineView/FoldersOutlineView+Menu.swift @@ -9,9 +9,7 @@ @objc protocol FoldersOutlineViewContextMenu: NSObjectProtocol { @MainActor - func compareFiles(_ sender: AnyObject?) - @MainActor - func compareFolders(_ sender: AnyObject?) + func compareItems(_ sender: AnyObject?) @MainActor func copyFileNames(_ sender: AnyObject?) @MainActor @@ -67,13 +65,8 @@ public extension FoldersOutlineView { private static func addCompareItems(_ menu: NSMenu) { menu.addItem( - withTitle: NSLocalizedString("Compare Files", comment: ""), - action: #selector(FoldersOutlineViewContextMenu.compareFiles), - keyEquivalent: "" - ) - menu.addItem( - withTitle: NSLocalizedString("Compare Folders", comment: ""), - action: #selector(FoldersOutlineViewContextMenu.compareFolders), + withTitle: NSLocalizedString("Compare", comment: ""), + action: #selector(FoldersOutlineViewContextMenu.compareItems), keyEquivalent: "" ) } diff --git a/Sources/Features/FoldersCompare/Controller/FolderSelectionInfo.swift b/Sources/Features/FoldersCompare/Controller/FolderSelectionInfo.swift index c023cdb..d4ef1a7 100644 --- a/Sources/Features/FoldersCompare/Controller/FolderSelectionInfo.swift +++ b/Sources/Features/FoldersCompare/Controller/FolderSelectionInfo.swift @@ -16,7 +16,7 @@ struct SelectionType: OptionSet { @MainActor struct FolderSelectionInfo: @preconcurrency CustomDebugStringConvertible { - private(set) var selType: SelectionType = [] + private var selType: SelectionType = [] private(set) var nullFilesCount = 0 private(set) var foldersCount = 0 private(set) var filesCount = 0 @@ -30,6 +30,26 @@ struct FolderSelectionInfo: @preconcurrency CustomDebugStringConvertible { // contains all valid folders and files indexes private(set) var validObjectsIndexes: [Int] + var hasFiles: Bool { + filesCount > 0 + } + + var hasFolders: Bool { + foldersCount > 0 + } + + var hasFilesOrFolders: Bool { + hasFiles || hasFolders + } + + var hasFilePair: Bool { + hasPair(of: .file) + } + + var hasFolderPair: Bool { + hasPair(of: .folder) + } + init(view: FoldersOutlineView) { self.view = view let indexes = view.selectedRowIndexes @@ -85,4 +105,31 @@ struct FolderSelectionInfo: @preconcurrency CustomDebugStringConvertible { hasMultipleSel ) } + + // two items of the same type arranged as a pair: both on one side, or one per side + private func hasPair(of type: SelectionType) -> Bool { + guard selType == type, + let linkedSelInfo = view.linkedView?.selectionInfo else { + return false + } + + if linkedSelInfo.selType.isEmpty { + return count(of: type) == 2 + } + if linkedSelInfo.selType == type { + return count(of: type) == 1 && linkedSelInfo.count(of: type) == 1 + } + return false + } + + private func count(of countType: SelectionType) -> Int { + switch countType { + case .file: + filesCount + case .folder: + foldersCount + default: + 0 + } + } } diff --git a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+Exclude.swift b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+Exclude.swift index 9aeaa09..9a02703 100644 --- a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+Exclude.swift +++ b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+Exclude.swift @@ -17,7 +17,7 @@ extension FoldersWindowController { } let fsi = lastUsedView.selectionInfo - let containsFolders = fsi.foldersCount > 0 + let containsFolders = fsi.hasFolders var predicateTemplate: NSPredicate var propertyKey: (CompareItem) -> String? diff --git a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+FoldersOutlineViewContextMenu.swift b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+FoldersOutlineViewContextMenu.swift index fb4f370..fe2b7fb 100644 --- a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+FoldersOutlineViewContextMenu.swift +++ b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+FoldersOutlineViewContextMenu.swift @@ -8,9 +8,9 @@ extension FoldersWindowController: FoldersOutlineViewContextMenu { @objc - func compareFiles(_: AnyObject?) { - let leftSelItems = leftView.selectedItems() - let rightSelItems = rightView.selectedItems() + func compareItems(_: AnyObject?) { + let leftSelItems = leftView.selectedItems(findLeafPaths: false) + let rightSelItems = rightView.selectedItems(findLeafPaths: false) var leftItem: CompareItem? var rightItem: CompareItem? @@ -22,6 +22,10 @@ extension FoldersWindowController: FoldersOutlineViewContextMenu { leftItem = leftSelItems.last rightItem = rightSelItems.last case 0: + guard rightSelItems.count == 2 else { + return + } + leftItem = rightSelItems[0] rightItem = rightSelItems[1] default: @@ -37,11 +41,6 @@ extension FoldersWindowController: FoldersOutlineViewContextMenu { } } - @objc - func compareFolders(_ sender: AnyObject?) { - compareFiles(sender) - } - @objc func copyFileNames(_: AnyObject?) { lastUsedView.copySelectedAsFileNames() diff --git a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+Menu.swift b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+Menu.swift index 2d3ed45..6ee4a35 100644 --- a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+Menu.swift +++ b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+Menu.swift @@ -189,13 +189,8 @@ extension FoldersWindowController { private static func addCompareItems(_ menu: NSMenu) { menu.addItem( - withTitle: NSLocalizedString("Compare Files", comment: ""), - action: #selector(compareFiles), - keyEquivalent: "" - ) - menu.addItem( - withTitle: NSLocalizedString("Compare Folders", comment: ""), - action: #selector(compareFolders), + withTitle: NSLocalizedString("Compare", comment: ""), + action: #selector(compareItems), keyEquivalent: "" ) } diff --git a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+MenuDelegate.swift b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+MenuDelegate.swift index f72cf35..f9167ba 100644 --- a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+MenuDelegate.swift +++ b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+MenuDelegate.swift @@ -58,8 +58,17 @@ extension FoldersWindowController: NSMenuDelegate, return fsi.validateSetAsBaseFolderOtherSide() } else if action == #selector(setAsBaseFoldersBothSides) { return fsi.validateSetAsBaseFoldersBothSides() - } else if action == #selector(compareFiles) { - return fsi.validateCompareFiles() + } else if action == #selector(compareItems) { + switch fsi.comparableType { + case .file?: + menuItem.title = NSLocalizedString("Compare Files", comment: "") + case .folder?: + menuItem.title = NSLocalizedString("Compare Folders", comment: "") + default: + menuItem.title = NSLocalizedString("Compare", comment: "") + return false + } + return true } else if action == #selector(excludeByName) { var fileName: String? = "" if !fsi.validateExclude(byName: &fileName) { @@ -116,8 +125,6 @@ extension FoldersWindowController: NSMenuDelegate, return scopeBar.findView.hasMatches } else if action == #selector(findPrevious) { return scopeBar.findView.hasMatches - } else if action == #selector(compareFolders) { - return fsi.validateCompareFolders() } else if action == #selector(moveFiles) { switch fsi.view.side { case .left: diff --git a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+NSToolbarDelegate.swift b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+NSToolbarDelegate.swift index 388f247..2eb70a7 100644 --- a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+NSToolbarDelegate.swift +++ b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+NSToolbarDelegate.swift @@ -25,6 +25,7 @@ extension NSToolbarItem.Identifier { static let differenceNavigation = NSToolbarItem.Identifier("DifferenceNavigation") static let openWith = NSToolbarItem.Identifier("OpenWith") static let showInFinder = NSToolbarItem.Identifier("ShowInFinder") + static let compareItems = NSToolbarItem.Identifier("CompareItems") } } @@ -69,6 +70,7 @@ extension FoldersWindowController: NSToolbarDelegate, NSToolbarItemValidation { .Folders.differenceNavigation, .Folders.openWith, .Folders.showInFinder, + .Folders.compareItems, ] } @@ -218,6 +220,15 @@ extension FoldersWindowController: NSToolbarDelegate, NSToolbarItemValidation { target: self, action: #selector(showInFinder) ) + } else if itemIdentifier == .Folders.compareItems { + return NSToolbarItem( + identifier: itemIdentifier, + label: NSLocalizedString("Compare", comment: ""), + tooltip: NSLocalizedString("Compare selected files or folders", comment: ""), + image: VDSymbol.Toolbar.compareItems.image(), + target: self, + action: #selector(compareItems) + ) } return nil @@ -249,6 +260,9 @@ extension FoldersWindowController: NSToolbarDelegate, NSToolbarItemValidation { var path: String? return fsi.validateOpen(withApp: &path) } + if item.itemIdentifier == .Folders.compareItems { + return fsi.comparableType != nil + } return true } diff --git a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+CompareActionValidator.swift b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+CompareActionValidator.swift index c5514e6..a457e80 100644 --- a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+CompareActionValidator.swift +++ b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+CompareActionValidator.swift @@ -8,31 +8,13 @@ @MainActor extension FolderSelectionInfo { - func validateCompareFiles() -> Bool { - guard let linkedSelInfo = view.linkedView?.selectionInfo else { - return false + var comparableType: SelectionType? { + if hasFilePair { + return .file } - - if selType == .file, linkedSelInfo.selType.isEmpty { - return filesCount == 2 - } - if selType == .file, linkedSelInfo.selType == .file { - return filesCount == 1 && linkedSelInfo.filesCount == 1 - } - return false - } - - func validateCompareFolders() -> Bool { - guard let linkedSelInfo = view.linkedView?.selectionInfo else { - return false - } - - if selType == .folder, linkedSelInfo.selType.isEmpty { - return foldersCount == 2 - } - if selType == .folder, linkedSelInfo.selType == .folder { - return foldersCount == 1 && linkedSelInfo.foldersCount == 1 + if hasFolderPair { + return .folder } - return false + return nil } } diff --git a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FileSystemActionValidator.swift b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FileSystemActionValidator.swift index 8ecfe32..4df2f4b 100644 --- a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FileSystemActionValidator.swift +++ b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FileSystemActionValidator.swift @@ -15,11 +15,11 @@ extension FolderSelectionInfo { case .right: !sessionDiff.leftReadOnly } - return isValid && (foldersCount > 0 || filesCount > 0) + return isValid && hasFilesOrFolders } func validateCopyFilesToExternal() -> Bool { - foldersCount > 0 || filesCount > 0 + hasFilesOrFolders } func validateMoveFiles(_ sessionDiff: SessionDiff) -> Bool { @@ -29,7 +29,7 @@ extension FolderSelectionInfo { case .right: !sessionDiff.leftReadOnly && !sessionDiff.rightReadOnly } - return isValid && (foldersCount > 0 || filesCount > 0) + return isValid && hasFilesOrFolders } func validateMoveFilesToExternal(_ sessionDiff: SessionDiff) -> Bool { @@ -39,7 +39,7 @@ extension FolderSelectionInfo { case .right: !sessionDiff.rightReadOnly } - return isValid && (foldersCount > 0 || filesCount > 0) + return isValid && hasFilesOrFolders } func validateSyncFiles(_ sessionDiff: SessionDiff) -> Bool { @@ -49,7 +49,7 @@ extension FolderSelectionInfo { case .right: !sessionDiff.leftReadOnly } - return isValid && (foldersCount > 0 || filesCount > 0) + return isValid && hasFilesOrFolders } func validateDeleteFiles(_ sessionDiff: SessionDiff) -> Bool { @@ -59,7 +59,7 @@ extension FolderSelectionInfo { case .right: !sessionDiff.rightReadOnly } - return isValid && (foldersCount > 0 || filesCount > 0) + return isValid && hasFilesOrFolders } func validateFileTouch(_ sessionDiff: SessionDiff) -> Bool { @@ -69,10 +69,10 @@ extension FolderSelectionInfo { case .right: !sessionDiff.rightReadOnly } - return isValid && (foldersCount > 0 || filesCount > 0) + return isValid && hasFilesOrFolders } func validateClipboardCopy() -> Bool { - !selType.isDisjoint(with: [.folder, .file]) || hasValidPaths + hasFilesOrFolders || hasValidPaths } } diff --git a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FilterActionValidator.swift b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FilterActionValidator.swift index b1e2e59..1aba490 100644 --- a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FilterActionValidator.swift +++ b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FilterActionValidator.swift @@ -9,13 +9,13 @@ @MainActor extension FolderSelectionInfo { func validateExclude(byName outExcludedFileName: inout String?) -> Bool { - if selType.isEmpty || selType == .nullfile { + if !hasFilesOrFolders { return false } if outExcludedFileName != nil { // Only one element selected if filesCount + foldersCount == 1 { - if let row = filesCount > 0 ? filesIndexes.first : foldersIndexes.first, + if let row = hasFiles ? filesIndexes.first : foldersIndexes.first, let vi = view.item(atRow: row) as? VisibleItem { let item = vi.item outExcludedFileName = foldersCount == 1 ? item.pathRelativeToRoot : item.fileName @@ -30,7 +30,7 @@ extension FolderSelectionInfo { func validateExclude(byExt outExcludedExt: inout String?) -> Bool { // Only valid for selection containing only files (null files are skipped) - if filesCount == 0 || foldersCount > 0 { + if !hasFiles || hasFolders { return false } let indexes = filesIndexes diff --git a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FolderActionValidator.swift b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FolderActionValidator.swift index 3c81868..e879bd5 100644 --- a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FolderActionValidator.swift +++ b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+FolderActionValidator.swift @@ -17,20 +17,10 @@ extension FolderSelectionInfo { } func validateSetAsBaseFoldersBothSides() -> Bool { - guard let linkedSelInfo = view.linkedView?.selectionInfo else { - return false - } - - if selType == .folder, linkedSelInfo.selType.isEmpty { - return foldersCount == 2 - } - if selType == .folder, linkedSelInfo.selType == .folder { - return foldersCount == 1 && linkedSelInfo.foldersCount == 1 - } - return false + hasFolderPair } func validateExpandSelectedSubfolders() -> Bool { - filesCount == 0 && foldersCount > 0 + !hasFiles && hasFolders } } diff --git a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+ViewerActionValidator.swift b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+ViewerActionValidator.swift index a8f25a2..e251950 100644 --- a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+ViewerActionValidator.swift +++ b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+ViewerActionValidator.swift @@ -9,7 +9,7 @@ @MainActor extension FolderSelectionInfo { func validateShowInFinder() -> Bool { - !selType.isDisjoint(with: [.folder, .file]) + hasFilesOrFolders } func validateOpen(withApp outSelectedPath: inout String?) -> Bool { @@ -27,6 +27,6 @@ extension FolderSelectionInfo { } func validatePreviewPanel() -> Bool { - foldersCount > 0 || filesCount > 0 + hasFilesOrFolders } } diff --git a/assets/Images.xcassets/Toolbar/Contents.json b/assets/Images.xcassets/Toolbar/Contents.json index 74d6a72..73c0059 100644 --- a/assets/Images.xcassets/Toolbar/Contents.json +++ b/assets/Images.xcassets/Toolbar/Contents.json @@ -1,6 +1,6 @@ { - "info": { - "author": "xcode", - "version": 1 + "info" : { + "author" : "xcode", + "version" : 1 } } diff --git a/assets/Images.xcassets/Toolbar/compareItems.symbolset/Contents.json b/assets/Images.xcassets/Toolbar/compareItems.symbolset/Contents.json new file mode 100644 index 0000000..02cdaac --- /dev/null +++ b/assets/Images.xcassets/Toolbar/compareItems.symbolset/Contents.json @@ -0,0 +1,12 @@ +{ + "info": { + "author": "xcode", + "version": 1 + }, + "symbols": [ + { + "filename": "compareItems.svg", + "idiom": "universal" + } + ] +} diff --git a/assets/Images.xcassets/Toolbar/compareItems.symbolset/compareItems.svg b/assets/Images.xcassets/Toolbar/compareItems.symbolset/compareItems.svg new file mode 100644 index 0000000..10d1608 --- /dev/null +++ b/assets/Images.xcassets/Toolbar/compareItems.symbolset/compareItems.svg @@ -0,0 +1,46 @@ + + + + + + Small + Medium + Large + + + Ultralight + Regular + Black + Template v.3.0 + + https://github.com/swhitty/SwiftDraw + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mul.lproj/MainMenu.xcstrings b/mul.lproj/MainMenu.xcstrings index f921664..37a5ef5 100644 --- a/mul.lproj/MainMenu.xcstrings +++ b/mul.lproj/MainMenu.xcstrings @@ -505,18 +505,6 @@ } } }, - "608.title" : { - "comment" : "Class = \"NSMenuItem\"; title = \"Compare Files\"; ObjectID = \"608\";", - "extractionState" : "extracted_with_value", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "Compare Files" - } - } - } - }, "609.title" : { "comment" : "Class = \"NSMenuItem\"; title = \"Copy Files...\"; ObjectID = \"609\";", "extractionState" : "extracted_with_value", From 5d38fbf96d6a2ed94d4230946466d55b474019aa Mon Sep 17 00:00:00 2001 From: Davide Ficano Date: Wed, 26 Aug 2026 07:44:12 +0200 Subject: [PATCH 06/24] chore: show compare side on menu title --- Localizable.xcstrings | 18 ++++++++++++++++ .../Controller/FolderSelectionInfo.swift | 4 ++++ ...FoldersWindowController+MenuDelegate.swift | 12 ++--------- ...SelectionInfo+CompareActionValidator.swift | 21 +++++++++++++++++++ 4 files changed, 45 insertions(+), 10 deletions(-) diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 4a397ea..905d8cc 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -716,6 +716,12 @@ }, "Compare Files" : { + }, + "Compare Files on Left" : { + + }, + "Compare Files on Right" : { + }, "Compare Finder Label" : { @@ -725,9 +731,21 @@ }, "Compare Folders" : { + }, + "Compare Folders on Left" : { + + }, + "Compare Folders on Right" : { + }, "Compare Folders or Files" : { + }, + "Compare on Left" : { + + }, + "Compare on Right" : { + }, "Compare selected files or folders" : { diff --git a/Sources/Features/FoldersCompare/Controller/FolderSelectionInfo.swift b/Sources/Features/FoldersCompare/Controller/FolderSelectionInfo.swift index d4ef1a7..89fbc0b 100644 --- a/Sources/Features/FoldersCompare/Controller/FolderSelectionInfo.swift +++ b/Sources/Features/FoldersCompare/Controller/FolderSelectionInfo.swift @@ -50,6 +50,10 @@ struct FolderSelectionInfo: @preconcurrency CustomDebugStringConvertible { hasPair(of: .folder) } + var hasPairOnSingleSide: Bool { + validObjectsIndexes.count == 2 + } + init(view: FoldersOutlineView) { self.view = view let indexes = view.selectedRowIndexes diff --git a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+MenuDelegate.swift b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+MenuDelegate.swift index f9167ba..7d1347b 100644 --- a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+MenuDelegate.swift +++ b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+MenuDelegate.swift @@ -59,16 +59,8 @@ extension FoldersWindowController: NSMenuDelegate, } else if action == #selector(setAsBaseFoldersBothSides) { return fsi.validateSetAsBaseFoldersBothSides() } else if action == #selector(compareItems) { - switch fsi.comparableType { - case .file?: - menuItem.title = NSLocalizedString("Compare Files", comment: "") - case .folder?: - menuItem.title = NSLocalizedString("Compare Folders", comment: "") - default: - menuItem.title = NSLocalizedString("Compare", comment: "") - return false - } - return true + menuItem.title = fsi.compareItemsTitle + return fsi.comparableType != nil } else if action == #selector(excludeByName) { var fileName: String? = "" if !fsi.validateExclude(byName: &fileName) { diff --git a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+CompareActionValidator.swift b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+CompareActionValidator.swift index a457e80..abfc3c7 100644 --- a/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+CompareActionValidator.swift +++ b/Sources/Features/FoldersCompare/Validators/FolderSelectionInfo+CompareActionValidator.swift @@ -17,4 +17,25 @@ extension FolderSelectionInfo { } return nil } + + var compareItemsTitle: String { + switch (comparableType, hasPairOnSingleSide, view.side) { + case (.file?, false, _): + NSLocalizedString("Compare Files", comment: "") + case (.file?, true, .left): + NSLocalizedString("Compare Files on Left", comment: "") + case (.file?, true, .right): + NSLocalizedString("Compare Files on Right", comment: "") + case (.folder?, false, _): + NSLocalizedString("Compare Folders", comment: "") + case (.folder?, true, .left): + NSLocalizedString("Compare Folders on Left", comment: "") + case (.folder?, true, .right): + NSLocalizedString("Compare Folders on Right", comment: "") + case (_, _, .left): + NSLocalizedString("Compare on Left", comment: "") + case (_, _, .right): + NSLocalizedString("Compare on Right", comment: "") + } + } } From 5f226a075ffc0b302d5e3028d014a4ea18b07dfc Mon Sep 17 00:00:00 2001 From: Davide Ficano Date: Wed, 26 Aug 2026 08:35:41 +0200 Subject: [PATCH 07/24] fix: move focus to next/prev panel with TAB is pressed --- .../Components/FilesTableView/FilesTableView.swift | 4 ++++ .../Controller/FilesWindowController+UISetup.swift | 1 - .../Components/FoldersOutlineView/FoldersOutlineView.swift | 4 ++++ .../Controller/FoldersWindowController+UISetup.swift | 2 -- 4 files changed, 8 insertions(+), 3 deletions(-) diff --git a/Sources/Features/FilesCompare/Components/FilesTableView/FilesTableView.swift b/Sources/Features/FilesCompare/Components/FilesTableView/FilesTableView.swift index e802a07..f1a40fc 100644 --- a/Sources/Features/FilesCompare/Components/FilesTableView/FilesTableView.swift +++ b/Sources/Features/FilesCompare/Components/FilesTableView/FilesTableView.swift @@ -109,6 +109,10 @@ class FilesTableView: NSTableView, @preconcurrency DisplayPositionable, ViewLink filesTableDelegate?.filesTableView(self, scrollHorizontally: true) } else if keyCode == .rightArrow, !isArrowKeyDownWithModifiers { filesTableDelegate?.filesTableView(self, scrollHorizontally: false) + } else if keyCode == .tab, let linkedView { + // tab moves the focus between the two panels + // the key view loop is not used because it walks out of the panels + window?.makeFirstResponder(linkedView) } else { super.keyDown(with: event) } diff --git a/Sources/Features/FilesCompare/Controller/FilesWindowController+UISetup.swift b/Sources/Features/FilesCompare/Controller/FilesWindowController+UISetup.swift index f504d99..c4e0798 100644 --- a/Sources/Features/FilesCompare/Controller/FilesWindowController+UISetup.swift +++ b/Sources/Features/FilesCompare/Controller/FilesWindowController+UISetup.swift @@ -12,7 +12,6 @@ extension FilesWindowController { // must be called after setting up all views setupWindow() - leftPanelView.treeView.nextKeyView = rightView } func setupWindowLayout() { diff --git a/Sources/Features/FoldersCompare/Components/FoldersOutlineView/FoldersOutlineView.swift b/Sources/Features/FoldersCompare/Components/FoldersOutlineView/FoldersOutlineView.swift index 59d2f5e..321d036 100644 --- a/Sources/Features/FoldersCompare/Components/FoldersOutlineView/FoldersOutlineView.swift +++ b/Sources/Features/FoldersCompare/Components/FoldersOutlineView/FoldersOutlineView.swift @@ -142,6 +142,10 @@ public class FoldersOutlineView: NSOutlineView, @preconcurrency DisplayPositiona (delegate as? FoldersOutlineViewContextMenu)?.deleteFiles(nil) } else if str == " " { QLPreviewPanel.toggle() + } else if event.keyCode == KeyCode.tabCharacter, let linkedView { + // tab moves the focus between the two panels + // the key view loop is not used because it walks out of the panels + window?.makeFirstResponder(linkedView) } else { super.keyDown(with: event) } diff --git a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+UISetup.swift b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+UISetup.swift index 72de2f3..594c06f 100644 --- a/Sources/Features/FoldersCompare/Controller/FoldersWindowController+UISetup.swift +++ b/Sources/Features/FoldersCompare/Controller/FoldersWindowController+UISetup.swift @@ -11,8 +11,6 @@ extension FoldersWindowController { setupWindowLayout() setupFoldersLayout() - leftPanelView.treeView.nextKeyView = rightView - updateTreeViewFont() adjustTableColumnsWidth() From 6d6cf1cc0b574d094106547799835ccd2336e895 Mon Sep 17 00:00:00 2001 From: Davide Ficano Date: Wed, 26 Aug 2026 08:55:31 +0200 Subject: [PATCH 08/24] chore: exclude `.bundle` from linting --- .swiftlint.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.swiftlint.yml b/.swiftlint.yml index d7be92d..0d510f9 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,5 +1,6 @@ excluded: - build + - .bundle opt_in_rules: - array_init From 4387b6199531adcfc7171940d4372d58ff262fd2 Mon Sep 17 00:00:00 2001 From: Davide Ficano Date: Wed, 26 Aug 2026 10:48:14 +0200 Subject: [PATCH 09/24] feat: compare items using the