Skip to content

Commit 87cdec8

Browse files
authored
refactor(editor): explicit state machines for text binding sync and completion sessions (#1616)
* fix(editor): re-highlight the document after a programmatic text replacement (#1612) * refactor(editor): model text binding sync as an explicit origin state machine * refactor(editor): model completion as an explicit session with apply-time trigger suppression
1 parent d0153ce commit 87cdec8

11 files changed

Lines changed: 422 additions & 142 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- Cancelling a SQLite query no longer races a disconnect happening at the same moment. (#1610)
1818
- Typing in the query editor no longer erases characters or drops focus on each keystroke, a timing-dependent bug most visible on macOS 15. (#1608)
1919
- The autocomplete popup now filters in place as you type instead of closing and reopening on every keystroke. (#1608)
20+
- Syntax highlighting no longer disappears after formatting a query. (#1612)
2021

2122
## [0.49.1] - 2026-06-06
2223

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/CodeSuggestion/Model/SuggestionViewModel.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ final class SuggestionViewModel: ObservableObject {
1919

2020
var itemsRequestTask: Task<Void, Never>?
2121
weak var activeTextView: TextViewController?
22+
private(set) var isApplyingCompletion = false
2223

2324
weak var delegate: CodeSuggestionDelegate?
2425

@@ -83,6 +84,8 @@ final class SuggestionViewModel: ObservableObject {
8384
isManualTrigger: Bool = false,
8485
showWindowOnParent: @escaping @MainActor (NSWindow, NSRect) -> Void
8586
) {
87+
guard !isApplyingCompletion else { return }
88+
8689
self.activeTextView = nil
8790
self.delegate = nil
8891
itemsRequestTask?.cancel()
@@ -142,6 +145,8 @@ final class SuggestionViewModel: ObservableObject {
142145
position: CursorPosition,
143146
close: () -> Void
144147
) {
148+
guard !isApplyingCompletion else { return }
149+
145150
if activeTextView !== textView {
146151
itemsRequestTask?.cancel()
147152
itemsRequestTask = nil
@@ -173,11 +178,13 @@ final class SuggestionViewModel: ObservableObject {
173178
guard let activeTextView else {
174179
return
175180
}
181+
isApplyingCompletion = true
176182
self.delegate?.completionWindowApplyCompletion(
177183
item: item,
178184
textView: activeTextView,
179185
cursorPosition: activeTextView.cursorPositions.first
180186
)
187+
isApplyingCompletion = false
181188
onApply?()
182189
}
183190

@@ -187,6 +194,7 @@ final class SuggestionViewModel: ObservableObject {
187194
items.removeAll()
188195
selectedIndex = 0
189196
activeTextView = nil
197+
delegate?.completionWindowDidClose()
190198
delegate = nil
191199
}
192200

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ import Foundation
99
import SwiftTreeSitter
1010

1111
extension TextViewController {
12+
/// Tears down and rebuilds the highlighter for the text view's current storage.
13+
///
14+
/// Ends with an explicit `invalidate()` so the rebuilt highlighter queries the
15+
/// visible text immediately. A fresh highlighter only highlights in response to
16+
/// triggers (an edit, a frame change, or an invalidation); after a mid-session
17+
/// storage swap such as `setText`, none of those is guaranteed to fire, and
18+
/// without this the document stays unstyled until the next layout change.
1219
package func setUpHighlighter() {
1320
if let highlighter {
1421
textView.removeStorageDelegate(highlighter)
@@ -24,6 +31,7 @@ extension TextViewController {
2431
)
2532
textView.addStorageDelegate(highlighter)
2633
self.highlighter = highlighter
34+
highlighter.invalidate()
2735
}
2836

2937
/// Sets new highlight providers. Recognizes when objects move in the array or are removed or inserted.
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
//
2+
// RepresentableSyncPhase.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Tracks which side of the SwiftUI representable boundary originated the
6+
// change currently being synchronized, replacing the pair of booleans the
7+
// coordinator previously juggled. The states are mutually exclusive by
8+
// construction: an editor change can never be marked while a representable
9+
// value is being applied, and a representable value is never applied while
10+
// an editor change is pending.
11+
//
12+
13+
import Foundation
14+
15+
@MainActor
16+
final class RepresentableSyncPhase {
17+
enum Phase: Equatable {
18+
case idle
19+
case editorChangePending
20+
case applyingRepresentableValue
21+
}
22+
23+
private(set) var phase: Phase = .idle
24+
25+
var isEditorChangePending: Bool {
26+
phase == .editorChangePending
27+
}
28+
29+
var isApplyingRepresentableValue: Bool {
30+
phase == .applyingRepresentableValue
31+
}
32+
33+
/// Latches that the editor originated a change. The next representable
34+
/// update pass consumes this instead of pushing its own values down.
35+
/// Ignored while a representable value is being applied, since editor
36+
/// notifications fired during a programmatic application are echoes,
37+
/// not user edits.
38+
func markEditorChange() {
39+
guard phase != .applyingRepresentableValue else { return }
40+
phase = .editorChangePending
41+
}
42+
43+
/// Returns whether an editor change was pending and resets to idle.
44+
@discardableResult
45+
func consumePendingEditorChange() -> Bool {
46+
guard phase == .editorChangePending else { return false }
47+
phase = .idle
48+
return true
49+
}
50+
51+
/// Runs `body` with the phase marked as applying a representable value,
52+
/// so editor notifications fired by the application itself are ignored.
53+
func applyRepresentableValue(_ body: () -> Void) {
54+
let previous = phase
55+
phase = .applyingRepresentableValue
56+
body()
57+
phase = previous
58+
}
59+
}

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift

Lines changed: 6 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,18 @@ extension SourceEditor {
1414
@MainActor
1515
public class Coordinator: NSObject {
1616
private weak var controller: TextViewController?
17-
var isUpdatingFromRepresentable: Bool = false
18-
var isUpdateFromTextView: Bool = false
19-
var text: TextAPI
20-
var lastSyncedText: String?
17+
let phase = RepresentableSyncPhase()
18+
let textSync: TextBindingSync
2119
@Binding var editorState: SourceEditorState
2220

2321
private(set) var highlightProviders: [any HighlightProviding]
2422

2523
private var cancellables: Set<AnyCancellable> = []
2624

2725
init(text: TextAPI, editorState: Binding<SourceEditorState>, highlightProviders: [any HighlightProviding]?) {
28-
self.text = text
26+
self.textSync = TextBindingSync(text: text, phase: phase)
2927
self._editorState = editorState
3028
self.highlightProviders = highlightProviders ?? [TreeSitterClient()]
31-
if case .binding(let binding) = text {
32-
self.lastSyncedText = binding.wrappedValue
33-
}
3429
super.init()
3530
}
3631

@@ -123,57 +118,11 @@ extension SourceEditor {
123118
self.highlightProviders = highlightProviders
124119
}
125120

126-
private var textBindingTask: Task<Void, Never>?
127-
128121
@objc func textViewDidChangeText(_ notification: Notification) {
129122
guard let textView = notification.object as? TextView else {
130123
return
131124
}
132-
guard !isUpdatingFromRepresentable else { return }
133-
guard case .binding(let binding) = text else { return }
134-
135-
// For large documents, debounce the binding writeback to avoid
136-
// copying megabytes of text into SwiftUI on every keystroke.
137-
let docLength = textView.textStorage.length
138-
// Set flag immediately so SwiftUI's updateNSViewController knows
139-
// the text view is the source of truth during the debounce window.
140-
isUpdateFromTextView = true
141-
if docLength > 500_000 {
142-
textBindingTask?.cancel()
143-
textBindingTask = Task { @MainActor [weak self, weak textView] in
144-
try? await Task.sleep(for: .milliseconds(150))
145-
guard !Task.isCancelled, let self, let textView else { return }
146-
guard case .binding(let currentBinding) = self.text else { return }
147-
let newText = textView.string
148-
self.lastSyncedText = newText
149-
currentBinding.wrappedValue = newText
150-
}
151-
} else {
152-
let newText = textView.string
153-
lastSyncedText = newText
154-
binding.wrappedValue = newText
155-
}
156-
}
157-
158-
/// Pushes an external binding change down into the text view. The text view's
159-
/// content wins while one of its own edits is still in flight: `isUpdateFromTextView`
160-
/// is set the moment the text view mutates, before SwiftUI re-renders, so a render
161-
/// that still carries a stale binding snapshot is skipped entirely and user typing
162-
/// is never clobbered by a stale binding.
163-
///
164-
/// Uses `setText` rather than `replaceCharacters` on purpose: `replaceCharacters`
165-
/// is the user-edit path. It is gated on `isEditable`, runs mutation filters, and
166-
/// fires suggestion triggers, none of which should happen for a programmatic
167-
/// whole-document replacement. `setText` clearing the undo stack matches the
168-
/// new-document semantics of that replacement.
169-
func syncBindingText(_ newValue: String, controller: TextViewController) {
170-
guard !isUpdateFromTextView else { return }
171-
guard newValue != lastSyncedText else { return }
172-
textBindingTask?.cancel()
173-
isUpdatingFromRepresentable = true
174-
controller.textView.setText(newValue)
175-
isUpdatingFromRepresentable = false
176-
lastSyncedText = newValue
125+
textSync.editorTextDidChange(textView)
177126
}
178127

179128
@objc func textControllerCursorsDidUpdate(_ notification: Notification) {
@@ -218,8 +167,8 @@ extension SourceEditor {
218167
}
219168

220169
private func updateState(_ modifyCallback: (inout SourceEditorState) -> Void) {
221-
guard !isUpdatingFromRepresentable else { return }
222-
self.isUpdateFromTextView = true
170+
guard !phase.isApplyingRepresentableValue else { return }
171+
phase.markEditorChange()
223172
modifyCallback(&editorState)
224173
}
225174

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor.swift

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -134,18 +134,15 @@ public struct SourceEditor: NSViewControllerRepresentable {
134134

135135
context.coordinator.updateHighlightProviders(highlightProviders)
136136

137-
context.coordinator.text = text
137+
context.coordinator.textSync.text = text
138138
if case .binding(let binding) = text {
139-
context.coordinator.syncBindingText(binding.wrappedValue, controller: controller)
139+
context.coordinator.textSync.applyRepresentableText(binding.wrappedValue, controller: controller)
140140
}
141141

142-
// Prevent infinite loop of update notifications
143-
if context.coordinator.isUpdateFromTextView {
144-
context.coordinator.isUpdateFromTextView = false
145-
} else {
146-
context.coordinator.isUpdatingFromRepresentable = true
147-
updateControllerWithState(state, controller: controller)
148-
context.coordinator.isUpdatingFromRepresentable = false
142+
if !context.coordinator.phase.consumePendingEditorChange() {
143+
context.coordinator.phase.applyRepresentableValue {
144+
updateControllerWithState(state, controller: controller)
145+
}
149146
}
150147

151148
// Do manual diffing to reduce the amount of reloads.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
//
2+
// TextBindingSync.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Owns the two-way synchronization between a SwiftUI text binding and the
6+
// text view: editor edits are written back to the binding (debounced for
7+
// large documents), and external binding changes are pushed down into the
8+
// editor. The shared ``RepresentableSyncPhase`` decides which side wins
9+
// when both have changes in flight.
10+
//
11+
12+
import AppKit
13+
import CodeEditTextView
14+
import SwiftUI
15+
16+
@MainActor
17+
final class TextBindingSync {
18+
private static let writebackDebounceThreshold = 500_000
19+
private static let writebackDebounce: Duration = .milliseconds(150)
20+
21+
var text: SourceEditor.TextAPI
22+
private(set) var lastSyncedText: String?
23+
24+
private let phase: RepresentableSyncPhase
25+
private var writebackTask: Task<Void, Never>?
26+
27+
init(text: SourceEditor.TextAPI, phase: RepresentableSyncPhase) {
28+
self.text = text
29+
self.phase = phase
30+
if case .binding(let binding) = text {
31+
lastSyncedText = binding.wrappedValue
32+
}
33+
}
34+
35+
/// Writes an editor-originated text change back to the binding.
36+
///
37+
/// Marks the phase before writing so the representable update pass that
38+
/// the binding write triggers cannot clobber the editor with a stale
39+
/// snapshot. For large documents the writeback is debounced to avoid
40+
/// copying megabytes into SwiftUI on every keystroke; the phase is still
41+
/// marked immediately so the editor stays the source of truth during the
42+
/// debounce window.
43+
func editorTextDidChange(_ textView: TextView) {
44+
guard !phase.isApplyingRepresentableValue else { return }
45+
guard case .binding(let binding) = text else { return }
46+
47+
phase.markEditorChange()
48+
49+
guard textView.textStorage.length > Self.writebackDebounceThreshold else {
50+
let newText = textView.string
51+
lastSyncedText = newText
52+
binding.wrappedValue = newText
53+
return
54+
}
55+
56+
writebackTask?.cancel()
57+
writebackTask = Task { @MainActor [weak self, weak textView] in
58+
try? await Task.sleep(for: Self.writebackDebounce)
59+
guard !Task.isCancelled, let self, let textView else { return }
60+
guard case .binding(let currentBinding) = self.text else { return }
61+
let newText = textView.string
62+
self.lastSyncedText = newText
63+
currentBinding.wrappedValue = newText
64+
}
65+
}
66+
67+
/// Pushes an external binding change down into the text view. The editor's
68+
/// content wins while one of its own edits is still in flight, so user
69+
/// typing is never clobbered by a stale binding snapshot.
70+
///
71+
/// Routes through `TextViewController.setText` on purpose. The text-view
72+
/// level `replaceCharacters` is the user-edit path: it is gated on
73+
/// `isEditable`, runs mutation filters, and fires suggestion triggers,
74+
/// none of which should happen for a programmatic whole-document
75+
/// replacement. The controller-level call also rebuilds the highlighter
76+
/// for the replacement storage; calling the text view directly leaves
77+
/// tree-sitter state for the old document and highlighting never recovers.
78+
func applyRepresentableText(_ newValue: String, controller: TextViewController) {
79+
guard !phase.isEditorChangePending else { return }
80+
guard newValue != lastSyncedText else { return }
81+
82+
writebackTask?.cancel()
83+
phase.applyRepresentableValue {
84+
controller.setText(newValue)
85+
}
86+
lastSyncedText = newValue
87+
}
88+
}

0 commit comments

Comments
 (0)