Skip to content

Commit f63e9cc

Browse files
authored
feat: current statement highlighting and large document safety caps (#442)
* feat: add current statement highlighting and large document safety caps - Highlight the background of the SQL statement under cursor using EmphasisManager (outline with fill). Debounced at 150ms with generation counter to prevent stale updates. - Skip highlighting for single statements (no semicolons), multi-cursor, or documents >5MB. - Add maxHighlightableLength (5MB) guard to Highlighter — documents exceeding this are shown as plain text. - Cap highlight chunks to 2 per cycle (8192 chars) for documents >50KB to keep the editor responsive on large SQL dumps. - Add currentStatementHighlight theme color (light: #F0F4FA, dark: #1A2332). * test: add tests for statement highlighting, theme colors, and located scanner - CurrentStatementHighlighterTests: 9 tests for statement detection logic (multi-statement, single statement, strings, comments, edge cases) - ThemeDefinitionTests: 7 tests for currentStatementHighlight color (defaults, round-trip, backward compatibility) - SQLStatementScannerLocatedTests: 13 tests for locatedStatementAtCursor (offsets, comments, backticks, large input, edge cases) - Fix pre-existing TabDiskActorTests compile error (save() now throws) * fix: prevent crash from stale emphasis range during text edits - Clear emphasis immediately on text change (before debounced update) to avoid drawing a stale range that extends beyond the document - Validate NSMaxRange(stmtRange) <= docLength before adding emphasis * fix: use NSTextStorage backgroundColor instead of EmphasisManager EmphasisManager creates a CATextLayer on top with black foreground color, which overwrites syntax highlighting and hides the cursor. Switch to NSTextStorage.addAttribute(.backgroundColor) which draws behind text, preserving syntax colors and cursor visibility. * fix: use CALayer behind text for statement highlight instead of text storage attribute NSTextStorage .backgroundColor conflicts with the syntax highlighting pipeline (Highlighter.setAttributes overwrites it) and stacks visually with the editor's selectedLineBackgroundColor. Using a CALayer with zPosition: -1 draws behind text, preserving syntax colors, cursor, and current line highlight independently. * fix: suppress current-line highlight when statement highlight is active The editor's selectedLineBackgroundColor stacks with our statement background, creating a double-highlight on the cursor line. Save and clear the line highlight color when statement highlighting is active, restore it when inactive (single statement or cleared). * revert: remove statement highlighting feature (keep large doc safety caps) The statement highlighting fought CodeEditSourceEditor's rendering pipeline at every turn — EmphasisManager overwrites text colors, NSTextStorage attributes conflict with the highlighter, CALayer stacks with current-line highlight. The feature needs native CESS support (TextSelectionManager background ranges) to work correctly. Kept: large document safety caps (5MB skip, 50KB throttle), theme color definition, scanner located tests, theme definition tests. * chore: remove accidentally staged files * fix: remove stale main tag from CHANGELOG and document unused theme color
1 parent d6fd7f6 commit f63e9cc

8 files changed

Lines changed: 371 additions & 28 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
12+
- Large document safety caps for syntax highlighting (skip >5MB, throttle >50KB)
13+
1014
## [0.23.2] - 2026-03-24
1115

1216
### Fixed

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/HighlightProviding/HighlightProviderState.swift

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ class HighlightProviderState {
3232
/// The length to chunk ranges into when passing to the highlighter.
3333
private static let rangeChunkLimit = 4096
3434

35+
private static let largeDocThreshold = 50_000
36+
3537
// MARK: - State
3638

3739
/// A unique identifier for this provider. Used by the delegate to determine the source of results.
@@ -120,8 +122,11 @@ class HighlightProviderState {
120122

121123
/// Accumulates all pending ranges and calls `queryHighlights`.
122124
func highlightInvalidRanges() {
125+
let docLength = visibleRangeProvider?.documentRange.length ?? 0
126+
let maxRanges = docLength > Self.largeDocThreshold ? 2 : Int.max
127+
123128
var ranges: [NSRange] = []
124-
while let nextRange = getNextRange() {
129+
while ranges.count < maxRanges, let nextRange = getNextRange() {
125130
ranges.append(nextRange)
126131
pendingSet.insert(range: nextRange)
127132
}

LocalPackages/CodeEditSourceEditor/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ class Highlighter: NSObject {
8181
/// Counts upwards to provide unique IDs for new highlight providers.
8282
private var providerIdCounter: Int
8383

84+
public var maxHighlightableLength: Int = 5_000_000
85+
8486
// MARK: - Init
8587

8688
init(
@@ -226,6 +228,7 @@ extension Highlighter: @preconcurrency NSTextStorageDelegate {
226228
// This method is called whenever attributes are updated, so to avoid re-highlighting the entire document
227229
// each time an attribute is applied, we check to make sure this is in response to an edit.
228230
guard editedMask.contains(.editedCharacters) else { return }
231+
guard textView?.textStorage.length ?? 0 <= maxHighlightableLength else { return }
229232

230233
styleContainer.storageUpdated(editedRange: editedRange, changeInLength: delta)
231234

@@ -276,6 +279,7 @@ extension Highlighter: StyledRangeContainerDelegate {
276279

277280
extension Highlighter: VisibleRangeProviderDelegate {
278281
func visibleSetDidUpdate(_ newIndices: IndexSet) {
282+
guard textView?.textStorage.length ?? 0 <= maxHighlightableLength else { return }
279283
highlightProviders.forEach { $0.highlightInvalidRanges() }
280284
}
281285
}

TablePro/Theme/ResolvedThemeColors.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ struct ResolvedEditorColors {
1616
let lineNumberSwiftUI: Color
1717
let invisibles: NSColor
1818
let invisiblesSwiftUI: Color
19+
let currentStatementHighlight: NSColor
20+
let currentStatementHighlightSwiftUI: Color
1921

2022
let keyword: NSColor
2123
let keywordSwiftUI: Color
@@ -49,6 +51,8 @@ struct ResolvedEditorColors {
4951
lineNumberSwiftUI = colors.lineNumber.swiftUIColor
5052
invisibles = colors.invisibles.nsColor
5153
invisiblesSwiftUI = colors.invisibles.swiftUIColor
54+
currentStatementHighlight = colors.currentStatementHighlight.nsColor
55+
currentStatementHighlightSwiftUI = colors.currentStatementHighlight.swiftUIColor
5256

5357
keyword = colors.syntax.keyword.nsColor
5458
keywordSwiftUI = colors.syntax.keyword.swiftUIColor

TablePro/Theme/ThemeDefinition.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,8 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable {
177177
var selection: String
178178
var lineNumber: String
179179
var invisibles: String
180+
/// Reserved for future current-statement background highlight in the query editor.
181+
var currentStatementHighlight: String
180182
var syntax: SyntaxColors
181183

182184
static let defaultLight = EditorThemeColors(
@@ -187,6 +189,7 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable {
187189
selection: "#B4D8FD",
188190
lineNumber: "#747478",
189191
invisibles: "#D6D6D6",
192+
currentStatementHighlight: "#F0F4FA",
190193
syntax: .defaultLight
191194
)
192195

@@ -198,6 +201,7 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable {
198201
selection: String,
199202
lineNumber: String,
200203
invisibles: String,
204+
currentStatementHighlight: String,
201205
syntax: SyntaxColors
202206
) {
203207
self.background = background
@@ -207,6 +211,7 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable {
207211
self.selection = selection
208212
self.lineNumber = lineNumber
209213
self.invisibles = invisibles
214+
self.currentStatementHighlight = currentStatementHighlight
210215
self.syntax = syntax
211216
}
212217

@@ -222,6 +227,8 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable {
222227
selection = try container.decodeIfPresent(String.self, forKey: .selection) ?? fallback.selection
223228
lineNumber = try container.decodeIfPresent(String.self, forKey: .lineNumber) ?? fallback.lineNumber
224229
invisibles = try container.decodeIfPresent(String.self, forKey: .invisibles) ?? fallback.invisibles
230+
currentStatementHighlight = try container.decodeIfPresent(String.self, forKey: .currentStatementHighlight)
231+
?? fallback.currentStatementHighlight
225232
syntax = try container.decodeIfPresent(SyntaxColors.self, forKey: .syntax) ?? fallback.syntax
226233
}
227234
}

TableProTests/Core/Storage/TabDiskActorTests.swift

Lines changed: 27 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -36,12 +36,12 @@ struct TabDiskActorTests {
3636
// MARK: - save / load round-trip
3737

3838
@Test("Save then load round-trips correctly")
39-
func saveAndLoadRoundTrip() async {
39+
func saveAndLoadRoundTrip() async throws {
4040
let connectionId = UUID()
4141
let tabId = UUID()
4242
let tab = makeTab(id: tabId, title: "My Tab", query: "SELECT * FROM users")
4343

44-
await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tabId)
44+
try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tabId)
4545
let state = await actor.load(connectionId: connectionId)
4646

4747
#expect(state != nil)
@@ -58,21 +58,21 @@ struct TabDiskActorTests {
5858
// MARK: - load returns nil for unknown connectionId
5959

6060
@Test("Load returns nil for unknown connectionId")
61-
func loadReturnsNilForUnknown() async {
61+
func loadReturnsNilForUnknown() async throws {
6262
let result = await actor.load(connectionId: UUID())
6363
#expect(result == nil)
6464
}
6565

6666
// MARK: - save overwrites previous state
6767

6868
@Test("Save overwrites previous state")
69-
func saveOverwritesPreviousState() async {
69+
func saveOverwritesPreviousState() async throws {
7070
let connectionId = UUID()
7171
let tab1 = makeTab(title: "First")
7272
let tab2 = makeTab(title: "Second")
7373

74-
await actor.save(connectionId: connectionId, tabs: [tab1], selectedTabId: tab1.id)
75-
await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: tab2.id)
74+
try await actor.save(connectionId: connectionId, tabs: [tab1], selectedTabId: tab1.id)
75+
try await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: tab2.id)
7676

7777
let state = await actor.load(connectionId: connectionId)
7878

@@ -86,11 +86,11 @@ struct TabDiskActorTests {
8686
// MARK: - clear removes saved state
8787

8888
@Test("Clear removes saved state")
89-
func clearRemovesSavedState() async {
89+
func clearRemovesSavedState() async throws {
9090
let connectionId = UUID()
9191
let tab = makeTab()
9292

93-
await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tab.id)
93+
try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tab.id)
9494
await actor.clear(connectionId: connectionId)
9595

9696
let state = await actor.load(connectionId: connectionId)
@@ -100,21 +100,21 @@ struct TabDiskActorTests {
100100
// MARK: - clear on non-existent connectionId does not crash
101101

102102
@Test("Clear on non-existent connectionId does not crash")
103-
func clearNonExistentDoesNotCrash() async {
103+
func clearNonExistentDoesNotCrash() async throws {
104104
await actor.clear(connectionId: UUID())
105105
}
106106

107107
// MARK: - Multiple connections are independent
108108

109109
@Test("Multiple connections are independent")
110-
func multipleConnectionsAreIndependent() async {
110+
func multipleConnectionsAreIndependent() async throws {
111111
let connA = UUID()
112112
let connB = UUID()
113113
let tabA = makeTab(title: "Tab A")
114114
let tabB = makeTab(title: "Tab B")
115115

116-
await actor.save(connectionId: connA, tabs: [tabA], selectedTabId: tabA.id)
117-
await actor.save(connectionId: connB, tabs: [tabB], selectedTabId: tabB.id)
116+
try await actor.save(connectionId: connA, tabs: [tabA], selectedTabId: tabA.id)
117+
try await actor.save(connectionId: connB, tabs: [tabB], selectedTabId: tabB.id)
118118

119119
let stateA = await actor.load(connectionId: connA)
120120
let stateB = await actor.load(connectionId: connB)
@@ -135,18 +135,18 @@ struct TabDiskActorTests {
135135
// MARK: - selectedTabId preservation
136136

137137
@Test("selectedTabId is preserved correctly including nil")
138-
func selectedTabIdPreserved() async {
138+
func selectedTabIdPreserved() async throws {
139139
let connectionId = UUID()
140140
let tab = makeTab()
141141

142-
await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: nil)
142+
try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: nil)
143143
let stateNil = await actor.load(connectionId: connectionId)
144144
#expect(stateNil?.selectedTabId == nil)
145145
#expect(stateNil?.tabs.count == 1)
146146

147147
let specificId = UUID()
148148
let tab2 = makeTab(id: specificId)
149-
await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: specificId)
149+
try await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: specificId)
150150
let stateWithId = await actor.load(connectionId: connectionId)
151151
#expect(stateWithId?.selectedTabId == specificId)
152152

@@ -156,7 +156,7 @@ struct TabDiskActorTests {
156156
// MARK: - saveLastQuery / loadLastQuery round-trip
157157

158158
@Test("saveLastQuery then loadLastQuery round-trips")
159-
func lastQueryRoundTrip() async {
159+
func lastQueryRoundTrip() async throws {
160160
let connectionId = UUID()
161161
let query = "SELECT * FROM products WHERE active = true"
162162

@@ -171,15 +171,15 @@ struct TabDiskActorTests {
171171
// MARK: - loadLastQuery returns nil for unknown connectionId
172172

173173
@Test("loadLastQuery returns nil for unknown connectionId")
174-
func loadLastQueryReturnsNilForUnknown() async {
174+
func loadLastQueryReturnsNilForUnknown() async throws {
175175
let result = await actor.loadLastQuery(for: UUID())
176176
#expect(result == nil)
177177
}
178178

179179
// MARK: - saveLastQuery with empty string removes the file
180180

181181
@Test("saveLastQuery with empty string removes the file")
182-
func saveLastQueryEmptyRemovesFile() async {
182+
func saveLastQueryEmptyRemovesFile() async throws {
183183
let connectionId = UUID()
184184

185185
await actor.saveLastQuery("SELECT 1", for: connectionId)
@@ -193,7 +193,7 @@ struct TabDiskActorTests {
193193
// MARK: - saveLastQuery with whitespace-only string removes the file
194194

195195
@Test("saveLastQuery with whitespace-only string removes the file")
196-
func saveLastQueryWhitespaceOnlyRemovesFile() async {
196+
func saveLastQueryWhitespaceOnlyRemovesFile() async throws {
197197
let connectionId = UUID()
198198

199199
await actor.saveLastQuery("SELECT 1", for: connectionId)
@@ -206,7 +206,7 @@ struct TabDiskActorTests {
206206
// MARK: - saveLastQuery skips queries exceeding 500KB
207207

208208
@Test("saveLastQuery skips queries exceeding 500KB")
209-
func saveLastQuerySkipsLargeQueries() async {
209+
func saveLastQuerySkipsLargeQueries() async throws {
210210
let connectionId = UUID()
211211
let smallQuery = "SELECT 1"
212212

@@ -225,7 +225,7 @@ struct TabDiskActorTests {
225225
// MARK: - Tab with all fields round-trips
226226

227227
@Test("Tab with all fields including isView and databaseName round-trips")
228-
func tabWithAllFieldsRoundTrips() async {
228+
func tabWithAllFieldsRoundTrips() async throws {
229229
let connectionId = UUID()
230230
let tabId = UUID()
231231
let tab = makeTab(
@@ -238,7 +238,7 @@ struct TabDiskActorTests {
238238
databaseName: "production"
239239
)
240240

241-
await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tabId)
241+
try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tabId)
242242
let state = await actor.load(connectionId: connectionId)
243243

244244
#expect(state != nil)
@@ -257,13 +257,13 @@ struct TabDiskActorTests {
257257
// MARK: - Multiple tabs in single save
258258

259259
@Test("Multiple tabs in a single save round-trip correctly")
260-
func multipleTabsRoundTrip() async {
260+
func multipleTabsRoundTrip() async throws {
261261
let connectionId = UUID()
262262
let tab1 = makeTab(title: "Tab 1", tabType: .query)
263263
let tab2 = makeTab(title: "Tab 2", tabType: .table, tableName: "orders")
264264
let tab3 = makeTab(title: "Tab 3", tabType: .query)
265265

266-
await actor.save(connectionId: connectionId, tabs: [tab1, tab2, tab3], selectedTabId: tab2.id)
266+
try await actor.save(connectionId: connectionId, tabs: [tab1, tab2, tab3], selectedTabId: tab2.id)
267267
let state = await actor.load(connectionId: connectionId)
268268

269269
#expect(state?.tabs.count == 3)
@@ -278,7 +278,7 @@ struct TabDiskActorTests {
278278
// MARK: - saveSync writes data readable by load
279279

280280
@Test("saveSync writes data that load can read back")
281-
func saveSyncWritesReadableData() async {
281+
func saveSyncWritesReadableData() async throws {
282282
let connectionId = UUID()
283283
let tabId = UUID()
284284
let tab = makeTab(id: tabId, title: "Sync Tab", query: "SELECT 42", tabType: .table, tableName: "orders")
@@ -301,10 +301,10 @@ struct TabDiskActorTests {
301301
// MARK: - Empty tabs array
302302

303303
@Test("Saving empty tabs array round-trips")
304-
func emptyTabsArrayRoundTrips() async {
304+
func emptyTabsArrayRoundTrips() async throws {
305305
let connectionId = UUID()
306306

307-
await actor.save(connectionId: connectionId, tabs: [], selectedTabId: nil)
307+
try await actor.save(connectionId: connectionId, tabs: [], selectedTabId: nil)
308308
let state = await actor.load(connectionId: connectionId)
309309

310310
#expect(state != nil)

0 commit comments

Comments
 (0)