diff --git a/CHANGELOG.md b/CHANGELOG.md index 41b33c2c9..b92e8e1a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Cancelling a SQLite query no longer races a disconnect happening at the same moment. (#1610) - 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) - The autocomplete popup now filters in place as you type instead of closing and reopening on every keystroke. (#1608) +- Syntax highlighting no longer disappears after formatting a query. (#1612) ## [0.49.1] - 2026-06-06 diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift index ce5cc0876..1977a8426 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Controller/TextViewController+Highlighter.swift @@ -9,6 +9,13 @@ import Foundation import SwiftTreeSitter extension TextViewController { + /// Tears down and rebuilds the highlighter for the text view's current storage. + /// + /// Ends with an explicit `invalidate()` so the rebuilt highlighter queries the + /// visible text immediately. A fresh highlighter only highlights in response to + /// triggers (an edit, a frame change, or an invalidation); after a mid-session + /// storage swap such as `setText`, none of those is guaranteed to fire, and + /// without this the document stays unstyled until the next layout change. package func setUpHighlighter() { if let highlighter { textView.removeStorageDelegate(highlighter) @@ -24,6 +31,7 @@ extension TextViewController { ) textView.addStorageDelegate(highlighter) self.highlighter = highlighter + highlighter.invalidate() } /// Sets new highlight providers. Recognizes when objects move in the array or are removed or inserted. diff --git a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift index cfba799e2..5e03a6b9d 100644 --- a/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift +++ b/LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/SourceEditor/SourceEditor+Coordinator.swift @@ -166,12 +166,17 @@ extension SourceEditor { /// fires suggestion triggers, none of which should happen for a programmatic /// whole-document replacement. `setText` clearing the undo stack matches the /// new-document semantics of that replacement. + /// + /// Must go through `TextViewController.setText`, not `textView.setText`: the + /// controller-level call rebuilds the highlighter for the replacement storage. + /// Calling the text view directly leaves tree-sitter state for the old document + /// and highlighting never recovers. func syncBindingText(_ newValue: String, controller: TextViewController) { guard !isUpdateFromTextView else { return } guard newValue != lastSyncedText else { return } textBindingTask?.cancel() isUpdatingFromRepresentable = true - controller.textView.setText(newValue) + controller.setText(newValue) isUpdatingFromRepresentable = false lastSyncedText = newValue } diff --git a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/SourceEditorBindingSyncTests.swift b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/SourceEditorBindingSyncTests.swift index 00d9b7947..c6c0184ed 100644 --- a/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/SourceEditorBindingSyncTests.swift +++ b/LocalPackages/CodeEditSourceEditor/Tests/CodeEditSourceEditorTests/SourceEditorBindingSyncTests.swift @@ -158,6 +158,49 @@ final class SourceEditorBindingSyncTests: XCTestCase { XCTAssertEqual(coordinator.lastSyncedText, "SELECT 1") } + @MainActor + func test_syncRebuildsHighlighterForReplacementStorage() { + var bound = "select * from users" + let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) + controller.textView.setText("select * from users") + controller.setUpHighlighter() + let highlighterBefore = controller.highlighter + XCTAssertNotNil(highlighterBefore) + + bound = "SELECT\n *\nFROM\n users" + coordinator.syncBindingText(bound, controller: controller) + + XCTAssertEqual(controller.textView.string, "SELECT\n *\nFROM\n users") + XCTAssertNotNil(controller.highlighter) + XCTAssertNotIdentical(controller.highlighter, highlighterBefore) + } + + @MainActor + func test_syncQueriesHighlightsForReplacementStorage() { + let provider = HighlighterTests.MockHighlightProvider() + let providerController = TextViewController( + string: "select * from users", + language: .html, + configuration: Mock.config(), + cursorPositions: [], + highlightProviders: [provider] + ) + providerController.loadView() + providerController.view.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) + providerController.view.layoutSubtreeIfNeeded() + + let setUpsBefore = provider.setUpCount + let queriesBefore = provider.queryCount + + var bound = "select * from users" + let coordinator = makeCoordinator(get: { bound }, set: { bound = $0 }) + bound = "SELECT\n *\nFROM\n users" + coordinator.syncBindingText(bound, controller: providerController) + + XCTAssertGreaterThan(provider.setUpCount, setUpsBefore) + XCTAssertGreaterThan(provider.queryCount, queriesBefore) + } + @MainActor func test_repeatedSyncWithSameValueLeavesTextViewUntouched() { var bound = "stable"