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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Large document safety caps for syntax highlighting (skip >5MB, throttle >50KB)

## [0.23.2] - 2026-03-24

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ class HighlightProviderState {
/// The length to chunk ranges into when passing to the highlighter.
private static let rangeChunkLimit = 4096

private static let largeDocThreshold = 50_000

// MARK: - State

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

/// Accumulates all pending ranges and calls `queryHighlights`.
func highlightInvalidRanges() {
let docLength = visibleRangeProvider?.documentRange.length ?? 0
let maxRanges = docLength > Self.largeDocThreshold ? 2 : Int.max

var ranges: [NSRange] = []
while let nextRange = getNextRange() {
while ranges.count < maxRanges, let nextRange = getNextRange() {
ranges.append(nextRange)
pendingSet.insert(range: nextRange)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ class Highlighter: NSObject {
/// Counts upwards to provide unique IDs for new highlight providers.
private var providerIdCounter: Int

public var maxHighlightableLength: Int = 5_000_000

// MARK: - Init

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

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

Expand Down Expand Up @@ -276,6 +279,7 @@ extension Highlighter: StyledRangeContainerDelegate {

extension Highlighter: VisibleRangeProviderDelegate {
func visibleSetDidUpdate(_ newIndices: IndexSet) {
guard textView?.textStorage.length ?? 0 <= maxHighlightableLength else { return }
highlightProviders.forEach { $0.highlightInvalidRanges() }
}
}
4 changes: 4 additions & 0 deletions TablePro/Theme/ResolvedThemeColors.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ struct ResolvedEditorColors {
let lineNumberSwiftUI: Color
let invisibles: NSColor
let invisiblesSwiftUI: Color
let currentStatementHighlight: NSColor
let currentStatementHighlightSwiftUI: Color

let keyword: NSColor
let keywordSwiftUI: Color
Expand Down Expand Up @@ -49,6 +51,8 @@ struct ResolvedEditorColors {
lineNumberSwiftUI = colors.lineNumber.swiftUIColor
invisibles = colors.invisibles.nsColor
invisiblesSwiftUI = colors.invisibles.swiftUIColor
currentStatementHighlight = colors.currentStatementHighlight.nsColor
currentStatementHighlightSwiftUI = colors.currentStatementHighlight.swiftUIColor

keyword = colors.syntax.keyword.nsColor
keywordSwiftUI = colors.syntax.keyword.swiftUIColor
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Theme/ThemeDefinition.swift
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable {
var selection: String
var lineNumber: String
var invisibles: String
/// Reserved for future current-statement background highlight in the query editor.
var currentStatementHighlight: String
var syntax: SyntaxColors

static let defaultLight = EditorThemeColors(
Expand All @@ -187,6 +189,7 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable {
selection: "#B4D8FD",
lineNumber: "#747478",
invisibles: "#D6D6D6",
currentStatementHighlight: "#F0F4FA",
syntax: .defaultLight
)

Expand All @@ -198,6 +201,7 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable {
selection: String,
lineNumber: String,
invisibles: String,
currentStatementHighlight: String,
syntax: SyntaxColors
) {
self.background = background
Expand All @@ -207,6 +211,7 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable {
self.selection = selection
self.lineNumber = lineNumber
self.invisibles = invisibles
self.currentStatementHighlight = currentStatementHighlight
self.syntax = syntax
}

Expand All @@ -222,6 +227,8 @@ internal struct EditorThemeColors: Codable, Equatable, Sendable {
selection = try container.decodeIfPresent(String.self, forKey: .selection) ?? fallback.selection
lineNumber = try container.decodeIfPresent(String.self, forKey: .lineNumber) ?? fallback.lineNumber
invisibles = try container.decodeIfPresent(String.self, forKey: .invisibles) ?? fallback.invisibles
currentStatementHighlight = try container.decodeIfPresent(String.self, forKey: .currentStatementHighlight)
?? fallback.currentStatementHighlight
syntax = try container.decodeIfPresent(SyntaxColors.self, forKey: .syntax) ?? fallback.syntax
}
}
Expand Down
54 changes: 27 additions & 27 deletions TableProTests/Core/Storage/TabDiskActorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,12 @@ struct TabDiskActorTests {
// MARK: - save / load round-trip

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

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

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

@Test("Load returns nil for unknown connectionId")
func loadReturnsNilForUnknown() async {
func loadReturnsNilForUnknown() async throws {
let result = await actor.load(connectionId: UUID())
#expect(result == nil)
}

// MARK: - save overwrites previous state

@Test("Save overwrites previous state")
func saveOverwritesPreviousState() async {
func saveOverwritesPreviousState() async throws {
let connectionId = UUID()
let tab1 = makeTab(title: "First")
let tab2 = makeTab(title: "Second")

await actor.save(connectionId: connectionId, tabs: [tab1], selectedTabId: tab1.id)
await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: tab2.id)
try await actor.save(connectionId: connectionId, tabs: [tab1], selectedTabId: tab1.id)
try await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: tab2.id)

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

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

@Test("Clear removes saved state")
func clearRemovesSavedState() async {
func clearRemovesSavedState() async throws {
let connectionId = UUID()
let tab = makeTab()

await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tab.id)
try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: tab.id)
await actor.clear(connectionId: connectionId)

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

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

// MARK: - Multiple connections are independent

@Test("Multiple connections are independent")
func multipleConnectionsAreIndependent() async {
func multipleConnectionsAreIndependent() async throws {
let connA = UUID()
let connB = UUID()
let tabA = makeTab(title: "Tab A")
let tabB = makeTab(title: "Tab B")

await actor.save(connectionId: connA, tabs: [tabA], selectedTabId: tabA.id)
await actor.save(connectionId: connB, tabs: [tabB], selectedTabId: tabB.id)
try await actor.save(connectionId: connA, tabs: [tabA], selectedTabId: tabA.id)
try await actor.save(connectionId: connB, tabs: [tabB], selectedTabId: tabB.id)

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

@Test("selectedTabId is preserved correctly including nil")
func selectedTabIdPreserved() async {
func selectedTabIdPreserved() async throws {
let connectionId = UUID()
let tab = makeTab()

await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: nil)
try await actor.save(connectionId: connectionId, tabs: [tab], selectedTabId: nil)
let stateNil = await actor.load(connectionId: connectionId)
#expect(stateNil?.selectedTabId == nil)
#expect(stateNil?.tabs.count == 1)

let specificId = UUID()
let tab2 = makeTab(id: specificId)
await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: specificId)
try await actor.save(connectionId: connectionId, tabs: [tab2], selectedTabId: specificId)
let stateWithId = await actor.load(connectionId: connectionId)
#expect(stateWithId?.selectedTabId == specificId)

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

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

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

@Test("loadLastQuery returns nil for unknown connectionId")
func loadLastQueryReturnsNilForUnknown() async {
func loadLastQueryReturnsNilForUnknown() async throws {
let result = await actor.loadLastQuery(for: UUID())
#expect(result == nil)
}

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

@Test("saveLastQuery with empty string removes the file")
func saveLastQueryEmptyRemovesFile() async {
func saveLastQueryEmptyRemovesFile() async throws {
let connectionId = UUID()

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

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

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

@Test("saveLastQuery skips queries exceeding 500KB")
func saveLastQuerySkipsLargeQueries() async {
func saveLastQuerySkipsLargeQueries() async throws {
let connectionId = UUID()
let smallQuery = "SELECT 1"

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

@Test("Tab with all fields including isView and databaseName round-trips")
func tabWithAllFieldsRoundTrips() async {
func tabWithAllFieldsRoundTrips() async throws {
let connectionId = UUID()
let tabId = UUID()
let tab = makeTab(
Expand All @@ -238,7 +238,7 @@ struct TabDiskActorTests {
databaseName: "production"
)

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

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

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

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

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

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

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

await actor.save(connectionId: connectionId, tabs: [], selectedTabId: nil)
try await actor.save(connectionId: connectionId, tabs: [], selectedTabId: nil)
let state = await actor.load(connectionId: connectionId)

#expect(state != nil)
Expand Down
Loading
Loading