Skip to content
Merged
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
64 changes: 60 additions & 4 deletions Pine/CodeEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1037,17 +1037,25 @@ struct CodeEditorView: NSViewRepresentable {
range editedRange: NSRange,
changeInLength delta: Int
) {
// Only capture character edits from user typing (not attribute-only
// changes from highlighting, and not programmatic text replacement).
if editedMask.contains(.editedCharacters), !isProgrammaticTextChange {
pendingEditedRange = editedRange
pendingChangeInLength = delta
}
}

/// True while undo/redo is in progress. Prevents syntax highlighting
/// from modifying NSTextStorage attributes concurrently with the undo
/// manager's grouped operations, which causes EXC_BAD_ACCESS (#650).
private(set) var isUndoRedoInProgress = false

func textDidChange(_ notification: Notification) {
guard let textView = notification.object as? NSTextView else { return }

// Always reset at the start of every textDidChange — prevents the flag
// from "sticking" if a previous deferred highlightWorkItem was cancelled
// before it could clear the flag (#650 review).
isUndoRedoInProgress = false

// When text was replaced programmatically by updateContentIfNeeded,
// skip highlight scheduling — updateContentIfNeeded handles its own
// full highlight. Only update caches that it doesn't handle.
Expand All @@ -1062,6 +1070,15 @@ struct CodeEditorView: NSViewRepresentable {
return
}

// Detect undo/redo in progress. When the undo manager is unwinding
// grouped operations, modifying NSTextStorage attributes (via syntax
// highlighting beginEditing/endEditing) can cause a race condition
// leading to EXC_BAD_ACCESS. We defer highlighting until the undo
// manager finishes its current operation (#650).
let undoing = textView.undoManager?.isUndoing == true
let redoing = textView.undoManager?.isRedoing == true
isUndoRedoInProgress = undoing || redoing

// Mark that this change originated from the user typing,
// so the upcoming updateNSView won't overwrite the text and reset the cursor.
didChangeFromTextView = true
Expand Down Expand Up @@ -1107,25 +1124,60 @@ struct CodeEditorView: NSViewRepresentable {
// сдвигает символьные смещения, старый диапазон некорректен
highlightedCharRange = nil

// During undo/redo, cancel any pending highlight and schedule a
// deferred full re-highlight. The undo manager may still be processing
// grouped operations — modifying textStorage attributes now would cause
// EXC_BAD_ACCESS (#650).
if isUndoRedoInProgress {
scheduleDeferredHighlight(editedRange: nil)
return
}

// Дебаунсинг: откладываем подсветку до паузы в вводе.
// Не накапливаем диапазоны — каждый textDidChange работает
// в своих координатах; union между версиями некорректен.
// При быстром вводе последовательные правки обычно смежны,
// и 20-строчный контекст в highlightEdited покрывает их.
scheduleDeferredHighlight(editedRange: editedRange)
}

/// Cancels any in-flight highlight work and schedules a new debounced
/// highlight pass. When `editedRange` is non-nil, an incremental
/// `highlightEditedAsync` is attempted first; otherwise a full
/// re-highlight runs.
///
/// Called from both normal edits and undo/redo paths to avoid
/// duplicating the scheduling logic.
private func scheduleDeferredHighlight(editedRange: NSRange?) {
highlightWorkItem?.cancel()
highlightTask?.cancel()
let isLargeFile = (textView.string as NSString).length > CodeEditorView.viewportHighlightThreshold

let isUndoRedo = isUndoRedoInProgress

let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }

// Clear the undo/redo flag now that we're past the danger zone.
if isUndoRedo {
self.isUndoRedoInProgress = false
}

guard let sv = self.scrollView,
let tv = sv.documentView as? NSTextView,
let storage = tv.textStorage else { return }

// Double-check: if an undo/redo started between scheduling and
// execution, bail out to avoid the same race condition.
if tv.undoManager?.isUndoing == true || tv.undoManager?.isRedoing == true {
return
}

self.highlightGeneration.increment()
let gen = self.highlightGeneration
let lang = self.parent.language
let name = self.parent.fileName
let font = self.parent.editorFont
let isLargeFile = storage.length > CodeEditorView.viewportHighlightThreshold

if let range = editedRange, range.location + range.length <= storage.length {
self.highlightTask = Task { @MainActor in
Expand Down Expand Up @@ -1156,7 +1208,11 @@ struct CodeEditorView: NSViewRepresentable {
}
}
highlightWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + highlightDelay, execute: workItem)
// During undo/redo, dispatch on next run loop iteration so the undo
// manager finishes its grouped operations before we touch textStorage.
// Normal edits use the standard debounce delay.
let delay: TimeInterval = isUndoRedo ? 0 : highlightDelay
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
}

func textViewDidChangeSelection(_ notification: Notification) {
Expand Down
16 changes: 16 additions & 0 deletions Pine/SyntaxHighlighter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -586,6 +586,7 @@ final class SyntaxHighlighter: @unchecked Sendable {

/// Сбрасывает атрибуты на базовый стиль (без грамматики).
/// Clamps range to textStorage.length to avoid crash if text changed.
/// Skips if undo/redo is in progress to prevent EXC_BAD_ACCESS (#650).
private func resetAttributes(textStorage: NSTextStorage, range: NSRange, font: NSFont) {
let currentLength = textStorage.length
guard currentLength > 0 else { return }
Expand All @@ -596,6 +597,12 @@ final class SyntaxHighlighter: @unchecked Sendable {
guard safeRange.length > 0 else { return }

let undoManager = textStorage.layoutManagers.first?.firstTextView?.undoManager

// Bail out if undo/redo is in progress (#650).
if undoManager?.isUndoing == true || undoManager?.isRedoing == true {
return
}

undoManager?.disableUndoRegistration()
defer { undoManager?.enableUndoRegistration() }
textStorage.beginEditing()
Expand Down Expand Up @@ -732,6 +739,8 @@ final class SyntaxHighlighter: @unchecked Sendable {
/// Applies pre-computed matches to NSTextStorage. Must be called on main thread.
/// Validates that ranges are still valid — text may have changed between
/// computation and application.
/// Skips application if the undo manager is currently undoing/redoing to
/// prevent EXC_BAD_ACCESS from concurrent NSTextStorage mutations (#650).
func applyMatches(
_ result: HighlightMatchResult,
to textStorage: NSTextStorage,
Expand All @@ -744,6 +753,13 @@ final class SyntaxHighlighter: @unchecked Sendable {
}

let undoManager = textStorage.layoutManagers.first?.firstTextView?.undoManager

// Bail out if undo/redo is in progress — modifying textStorage attributes
// during an undo group causes EXC_BAD_ACCESS (#650).
if undoManager?.isUndoing == true || undoManager?.isRedoing == true {
return
}

undoManager?.disableUndoRegistration()
defer { undoManager?.enableUndoRegistration() }

Expand Down
Loading
Loading