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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions Pine/AccessibilityIdentifiers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ nonisolated enum AccessibilityID {
static let symbolResultsList = "symbolResultsList"
static func symbolItem(_ name: String) -> String { "symbolItem_\(name)" }

// MARK: - Hunk Toolbar
static let hunkToolbar = "hunk-toolbar"
static let hunkToolbarPrevious = "hunk-toolbar-previous"
static let hunkToolbarNext = "hunk-toolbar-next"
static let hunkToolbarRestore = "hunk-toolbar-restore"
static let hunkToolbarDismiss = "hunk-toolbar-dismiss"
static let hunkToolbarSummary = "hunk-toolbar-summary"

// MARK: - Toast notifications
static let toastNotification = "toastNotification"

Expand Down
275 changes: 258 additions & 17 deletions Pine/CodeEditorView.swift

Large diffs are not rendered by default.

26 changes: 26 additions & 0 deletions Pine/HunkToolbarAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
//
// HunkToolbarAction.swift
// Pine
//
// Actions available in the inline diff hunk toolbar (#689).
//

import Foundation

/// Actions available in the hunk viewer toolbar overlay.
enum HunkToolbarAction: String, Sendable {
case previousHunk
case nextHunk
case restore
case dismiss

/// Accessibility identifier for UI testing.
var accessibilityID: String {
switch self {
case .previousHunk: return AccessibilityID.hunkToolbarPrevious
case .nextHunk: return AccessibilityID.hunkToolbarNext
case .restore: return AccessibilityID.hunkToolbarRestore
case .dismiss: return AccessibilityID.hunkToolbarDismiss
}
}
}
231 changes: 231 additions & 0 deletions Pine/HunkToolbarView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
//
// HunkToolbarView.swift
// Pine
//
// A compact AppKit toolbar overlay for expanded diff hunks (#689).
// Shows: ← Prev | ↑ summary (2/5) ↓ | Restore | ✕
// Positioned above the first line of the expanded hunk, right-aligned.
//

import AppKit

/// A compact, pill-shaped toolbar shown above an expanded inline diff hunk.
/// Contains navigation arrows, hunk summary, restore button, and dismiss button.
final class HunkToolbarView: NSView {

// MARK: - State

/// Descriptive text like "2/5 +3 -1".
var summaryText: String = "" {
didSet { summaryLabel.stringValue = summaryText }
}

/// Callback for toolbar actions.
var onAction: ((HunkToolbarAction) -> Void)?

// MARK: - Subviews

private let prevButton = NSButton()
private let nextButton = NSButton()
private let summaryLabel = NSTextField(labelWithString: "")
private let restoreButton = NSButton()
private let dismissButton = NSButton()
private let stackView = NSStackView()
private(set) var separatorViews: [NSView] = []

// MARK: - Constants

static let toolbarHeight: CGFloat = 24
private static let cornerRadius: CGFloat = 6
private static let horizontalPadding: CGFloat = 4
private static let buttonFontSize: CGFloat = 11

override var isFlipped: Bool { true }

// MARK: - Init

init() {
super.init(frame: .zero)
setupViews()
}

@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}

// MARK: - Setup

private func setupViews() {
wantsLayer = true
layer?.cornerRadius = Self.cornerRadius

// Shadow (must not use masksToBounds, otherwise shadow is clipped)
shadow = NSShadow()
layer?.shadowColor = NSColor.black.withAlphaComponent(0.15).cgColor
layer?.shadowOffset = CGSize(width: 0, height: 1)
layer?.shadowRadius = 3
layer?.shadowOpacity = 1

// Apply appearance-dependent colors
updateAppearanceColors()

// Configure buttons
configureButton(
prevButton,
symbolName: "chevron.up",
tooltip: Strings.hunkToolbarPreviousChange,
accessibilityID: AccessibilityID.hunkToolbarPrevious,
action: #selector(prevClicked)
)
configureButton(
nextButton,
symbolName: "chevron.down",
tooltip: Strings.hunkToolbarNextChange,
accessibilityID: AccessibilityID.hunkToolbarNext,
action: #selector(nextClicked)
)
configureButton(
restoreButton,
symbolName: "arrow.uturn.backward",
tooltip: Strings.hunkToolbarRestore,
accessibilityID: AccessibilityID.hunkToolbarRestore,
action: #selector(restoreClicked)
)
configureButton(
dismissButton,
symbolName: "xmark",
tooltip: Strings.hunkToolbarDismiss,
accessibilityID: AccessibilityID.hunkToolbarDismiss,
action: #selector(dismissClicked)
)

// Summary label
summaryLabel.font = NSFont.monospacedSystemFont(ofSize: Self.buttonFontSize, weight: .medium)
summaryLabel.textColor = .secondaryLabelColor
summaryLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal)
summaryLabel.setAccessibilityIdentifier(AccessibilityID.hunkToolbarSummary)

// Separator views
let sep1 = makeSeparator()
let sep2 = makeSeparator()
separatorViews = [sep1, sep2]

// Stack
stackView.orientation = .horizontal
stackView.spacing = 2
stackView.alignment = .centerY
stackView.edgeInsets = NSEdgeInsets(
top: 0,
left: Self.horizontalPadding,
bottom: 0,
right: Self.horizontalPadding
)
stackView.setViews(
[prevButton, nextButton, sep1, summaryLabel, sep2, restoreButton, dismissButton],
in: .center
)

addSubview(stackView)
stackView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
stackView.leadingAnchor.constraint(equalTo: leadingAnchor),
stackView.trailingAnchor.constraint(equalTo: trailingAnchor),
stackView.topAnchor.constraint(equalTo: topAnchor),
stackView.bottomAnchor.constraint(equalTo: bottomAnchor)
])

setAccessibilityIdentifier(AccessibilityID.hunkToolbar)
}

private func configureButton(
_ button: NSButton,
symbolName: String,
tooltip: String,
accessibilityID: String,
action: Selector
) {
let config = NSImage.SymbolConfiguration(pointSize: Self.buttonFontSize, weight: .medium)
if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: tooltip)?
.withSymbolConfiguration(config) {
button.image = image
}
button.isBordered = false
button.bezelStyle = .accessoryBarAction
button.toolTip = tooltip
button.target = self
button.action = action
button.setAccessibilityIdentifier(accessibilityID)
button.setContentHuggingPriority(.defaultHigh, for: .horizontal)
button.setContentHuggingPriority(.defaultHigh, for: .vertical)
}

private func makeSeparator() -> NSView {
let sep = NSView()
sep.wantsLayer = true
sep.layer?.backgroundColor = NSColor.separatorColor.withAlphaComponent(0.3).cgColor
sep.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
sep.widthAnchor.constraint(equalToConstant: 1),
sep.heightAnchor.constraint(equalToConstant: 14)
])
return sep
}

// MARK: - Actions

@objc private func prevClicked() {
onAction?(.previousHunk)
}

@objc private func nextClicked() {
onAction?(.nextHunk)
}

@objc private func restoreClicked() {
onAction?(.restore)
}

@objc private func dismissClicked() {
onAction?(.dismiss)
}

// MARK: - Appearance Updates

override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
updateAppearanceColors()
}

/// Recomputes background and border colors for the current appearance (Dark/Light mode).
private func updateAppearanceColors() {
let isDark = effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
let bgColor = isDark
? NSColor.controlBackgroundColor.withAlphaComponent(0.95)
: NSColor.controlBackgroundColor.withAlphaComponent(0.92)
layer?.backgroundColor = bgColor.cgColor

let border = isDark
? NSColor.separatorColor.withAlphaComponent(0.5)
: NSColor.separatorColor.withAlphaComponent(0.3)
layer?.borderColor = border.cgColor
layer?.borderWidth = 0.5

let separatorColor = NSColor.separatorColor.withAlphaComponent(0.3).cgColor
for sep in separatorViews {
sep.layer?.backgroundColor = separatorColor
}
}

// MARK: - Sizing

/// Calculates the ideal width for the toolbar based on its contents.
func idealSize() -> NSSize {
stackView.layoutSubtreeIfNeeded()
let fittingSize = stackView.fittingSize
return NSSize(
width: fittingSize.width + Self.horizontalPadding * 2,
height: Self.toolbarHeight
)
}
}
50 changes: 50 additions & 0 deletions Pine/InlineDiffProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,56 @@ enum InlineDiffProvider {
case next, previous
}

// MARK: - Hunk navigation (toolbar)

/// Returns the next hunk after the given one, wrapping around to the first.
/// Returns nil if the hunks array is empty.
/// If the current hunk is not found (stale), returns the first hunk.
static func nextHunk(after current: DiffHunk, in hunks: [DiffHunk]) -> DiffHunk? {
guard !hunks.isEmpty else { return nil }
guard let index = hunks.firstIndex(where: { $0.id == current.id }) else {
return hunks.first
}
return hunks[(index + 1) % hunks.count]
}

/// Returns the previous hunk before the given one, wrapping around to the last.
/// Returns nil if the hunks array is empty.
/// If the current hunk is not found (stale), returns the last hunk.
static func previousHunk(before current: DiffHunk, in hunks: [DiffHunk]) -> DiffHunk? {
guard !hunks.isEmpty else { return nil }
guard let index = hunks.firstIndex(where: { $0.id == current.id }) else {
return hunks.last
}
return hunks[(index - 1 + hunks.count) % hunks.count]
}

/// Returns the 1-based position and total count for a hunk in the list.
/// Returns nil if the hunk is not found.
static func hunkPositionInfo(for hunk: DiffHunk, in hunks: [DiffHunk]) -> (index: Int, total: Int)? {
guard let idx = hunks.firstIndex(where: { $0.id == hunk.id }) else { return nil }
return (idx + 1, hunks.count)
}

/// Returns a short summary string for a hunk (e.g. "+3 -2" or "+1").
static func hunkSummary(_ hunk: DiffHunk) -> String {
let added = hunk.addedLines.count
let deleted = hunk.deletedLines.count
var parts: [String] = []
if added > 0 { parts.append("+\(added)") }
if deleted > 0 { parts.append("-\(deleted)") }
return parts.joined(separator: " ")
}

/// Returns the line range covered by a hunk in the editor (1-based, inclusive).
/// For pure deletion hunks (newCount == 0), returns just the anchor line.
static func expandedLineRange(for hunk: DiffHunk) -> ClosedRange<Int> {
if hunk.newCount == 0 {
return hunk.newStart...hunk.newStart
}
return hunk.newStart...hunk.newEndLine
}

// MARK: - Hunk classification

/// Returns `true` when the hunk represents a modification (has both deleted and added lines).
Expand Down
7 changes: 7 additions & 0 deletions Pine/LineStartsCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,13 @@ struct LineStartsCache {
lineIndex(containing: charIndex) + 1
}

/// Возвращает UTF-16 символьное смещение начала строки с данным 1-based номером.
/// Для строк за пределами кэша возвращает смещение последней строки.
func charOffset(forLine line: Int) -> Int {
let index = max(0, min(line - 1, lineStarts.count - 1))
return lineStarts[index]
}

/// Инкрементально обновляет кэш после редактирования текста.
/// - Parameters:
/// - editedRange: Диапазон в новом тексте, покрывающий вставленный/изменённый контент.
Expand Down
Loading
Loading