From a2abe6b8c7153ccf54fd64c7cffbd91db656e80d Mon Sep 17 00:00:00 2001 From: Gary Tokman Date: Fri, 14 Aug 2026 19:43:09 -0400 Subject: [PATCH] Expose line selection on DiffFilesViewController MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DiffView already ships the selection engine (isLineSelectionEnabled, handlers, clearLineSelection) but the sectioned diff never wired it. Setting lineSelectionHandler / lineSelectionEndedHandler on DiffFilesViewController now enables tap/drag selection over the hunks; callbacks receive the file's display path plus the LineSelectionInfo (nil on clear). A selection spans one hunk cell — the controller enforces cross-cell exclusivity by clearing the previous cell's highlight, which DiffView supports directly. Co-Authored-By: Claude Fable 5 --- README.md | 2 + .../DiffFilesViewController.swift | 62 ++++++++++++++++++- .../DiffFilesView/DiffHunkCell.swift | 27 +++++++- 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 481736f..97e2bbf 100644 --- a/README.md +++ b/README.md @@ -453,6 +453,8 @@ An expander offers an up arrow (lines above the next hunk), a down arrow (lines Expanders only appear once `contextProvider` is set. Errors it throws are reported through `expansionFailureHandler`, and tapping a file header collapses the file and calls `fileCollapseHandler`. +Line selection works like `MarkdownTextView`'s: setting `lineSelectionHandler` / `lineSelectionEndedHandler` enables tap and drag selection over the hunks, and the callbacks receive the file's display path alongside the `LineSelectionInfo` (`nil` when the selection clears). A selection spans one hunk at a time — selecting in another hunk clears the previous one. + ## Architecture The library is split into two modules: diff --git a/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift b/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift index 7c06766..b17b9ce 100644 --- a/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift +++ b/Sources/MarkdownView/Components/DiffFilesView/DiffFilesViewController.swift @@ -84,6 +84,20 @@ import Foundation /// the file. public var fileCollapseHandler: ((String, Bool) -> Void)? + /// Live line-selection updates: the display path of the file being + /// selected in, and the selection (`nil` when it clears). A selection + /// spans one hunk; selecting in another hunk clears the previous one. + /// Line selection is enabled while either selection handler is set. + public var lineSelectionHandler: ((String, LineSelectionInfo?) -> Void)? { + didSet { reloadEverything() } + } + + /// Fires once the selection gesture settles (tap completes, drag ends), + /// with the same arguments as `lineSelectionHandler`. + public var lineSelectionEndedHandler: ((String, LineSelectionInfo?) -> Void)? { + didSet { reloadEverything() } + } + public private(set) var patch: String = "" private var document = DiffPatchDocument(files: [], language: nil) @@ -95,6 +109,9 @@ import Foundation private var documentGeneration = 0 private var collapsedFileIDs: Set = [] private var loadingExpansions: Set = [] + /// The hunk cell owning the active line selection. `DiffView` clears + /// exclusively within one view; across hunk cells it's enforced here. + private weak var selectionCell: DiffHunkCell? private struct ExpansionKey: Hashable { let expander: DiffExpander @@ -126,6 +143,7 @@ import Foundation documentGeneration += 1 collapsedFileIDs = [] loadingExpansions = [] + selectionCell = nil guard isViewLoaded else { return } applySnapshot(animated: false) } @@ -214,8 +232,15 @@ import Foundation else { return } cell.configure( renderBlock: file.renderBlock(forHunkAt: hunkIndex), - theme: theme + theme: theme, + selectionEnabled: isLineSelectionEnabled ) + cell.onSelectionChanged = { [weak self] cell, info in + self?.hunkSelectionChanged(cell, fileID: fileID, info: info) + } + cell.onSelectionEnded = { [weak self] cell, info in + self?.hunkSelectionEnded(cell, fileID: fileID, info: info) + } } let expanderRegistration = UICollectionView.CellRegistration { @@ -347,6 +372,41 @@ import Foundation } } + // MARK: - Line selection + + private var isLineSelectionEnabled: Bool { + lineSelectionHandler != nil || lineSelectionEndedHandler != nil + } + + private func hunkSelectionChanged(_ cell: DiffHunkCell, fileID: Int, info: LineSelectionInfo?) { + guard let path = document.file(withID: fileID)?.displayPath else { return } + if info != nil { + if let previous = selectionCell, previous !== cell { + previous.clearSelection() + } + selectionCell = cell + lineSelectionHandler?(path, info) + return + } + // Only a clear from the active cell counts — a reused or + // reconfigured other cell must not cancel the live selection. + guard selectionCell == nil || selectionCell === cell else { return } + selectionCell = nil + lineSelectionHandler?(path, nil) + } + + private func hunkSelectionEnded(_ cell: DiffHunkCell, fileID: Int, info: LineSelectionInfo?) { + guard let path = document.file(withID: fileID)?.displayPath else { return } + if info != nil { + selectionCell = cell + lineSelectionEndedHandler?(path, info) + return + } + guard selectionCell == nil || selectionCell === cell else { return } + selectionCell = nil + lineSelectionEndedHandler?(path, nil) + } + // MARK: - Context expansion private func loadingDirections(for expander: DiffExpander) -> Set { diff --git a/Sources/MarkdownView/Components/DiffFilesView/DiffHunkCell.swift b/Sources/MarkdownView/Components/DiffFilesView/DiffHunkCell.swift index e70abdf..cbeb289 100644 --- a/Sources/MarkdownView/Components/DiffFilesView/DiffHunkCell.swift +++ b/Sources/MarkdownView/Components/DiffFilesView/DiffHunkCell.swift @@ -10,6 +10,9 @@ import Foundation private lazy var diffView: DiffView = .init() private var heightConstraint: NSLayoutConstraint? + var onSelectionChanged: ((DiffHunkCell, LineSelectionInfo?) -> Void)? + var onSelectionEnded: ((DiffHunkCell, LineSelectionInfo?) -> Void)? + override init(frame: CGRect) { super.init(frame: frame) diffView.translatesAutoresizingMaskIntoConstraints = false @@ -31,14 +34,36 @@ import Foundation fatalError("init(coder:) has not been implemented") } - func configure(renderBlock: DiffRenderBlock, theme: MarkdownTheme) { + func configure(renderBlock: DiffRenderBlock, theme: MarkdownTheme, selectionEnabled: Bool = false) { let hunkTheme = DiffFilesViewConfiguration.hunkTheme(from: theme) diffView.theme = hunkTheme + // Setting the render block clears any selection left by a reused cell. diffView.renderBlock = renderBlock heightConstraint?.constant = DiffViewConfiguration.intrinsicHeight( for: renderBlock, theme: hunkTheme ) + + diffView.isLineSelectionEnabled = selectionEnabled + if selectionEnabled { + diffView.lineSelectionHandler = { [weak self] info in + guard let self else { return } + onSelectionChanged?(self, info) + } + diffView.lineSelectionEndedHandler = { [weak self] info in + guard let self else { return } + onSelectionEnded?(self, info) + } + } else { + diffView.lineSelectionHandler = nil + diffView.lineSelectionEndedHandler = nil + } + } + + /// Drops the highlight without firing the selection handlers — used by + /// the controller to enforce one selection across hunk cells. + func clearSelection() { + diffView.clearLineSelection() } } #endif