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

- Auto-uppercase SQL keywords setting (#660)

## [0.30.0] - 2026-04-10

### Added
Expand Down
2 changes: 2 additions & 0 deletions TablePro/Core/Autocomplete/SQLKeywords.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import Foundation
enum SQLKeywords {
// MARK: - Keywords

static let keywordSet: Set<String> = Set(keywords.filter { !$0.contains(" ") }.map { $0.lowercased() })

/// Primary SQL keywords
static let keywords: [String] = [
// DQL
Expand Down
9 changes: 7 additions & 2 deletions TablePro/Models/Settings/EditorSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,14 +69,16 @@ struct EditorSettings: Codable, Equatable {
var autoIndent: Bool
var wordWrap: Bool
var vimModeEnabled: Bool
var uppercaseKeywords: Bool

static let `default` = EditorSettings(
showLineNumbers: true,
highlightCurrentLine: true,
tabWidth: 4,
autoIndent: true,
wordWrap: false,
vimModeEnabled: false
vimModeEnabled: false,
uppercaseKeywords: false
)

init(
Expand All @@ -85,14 +87,16 @@ struct EditorSettings: Codable, Equatable {
tabWidth: Int = 4,
autoIndent: Bool = true,
wordWrap: Bool = false,
vimModeEnabled: Bool = false
vimModeEnabled: Bool = false,
uppercaseKeywords: Bool = false
) {
self.showLineNumbers = showLineNumbers
self.highlightCurrentLine = highlightCurrentLine
self.tabWidth = tabWidth
self.autoIndent = autoIndent
self.wordWrap = wordWrap
self.vimModeEnabled = vimModeEnabled
self.uppercaseKeywords = uppercaseKeywords
}

init(from decoder: Decoder) throws {
Expand All @@ -104,6 +108,7 @@ struct EditorSettings: Codable, Equatable {
autoIndent = try container.decodeIfPresent(Bool.self, forKey: .autoIndent) ?? true
wordWrap = try container.decodeIfPresent(Bool.self, forKey: .wordWrap) ?? false
vimModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .vimModeEnabled) ?? false
uppercaseKeywords = try container.decodeIfPresent(Bool.self, forKey: .uppercaseKeywords) ?? false
}

/// Clamped tab width (1-16)
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -5282,6 +5282,9 @@
}
}
}
},
"Auto-uppercase keywords" : {

},
"Automatically check for updates" : {
"localizations" : {
Expand Down
124 changes: 117 additions & 7 deletions TablePro/Views/Editor/SQLEditorCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import os
/// Coordinator for the SQL editor — manages find panel, horizontal scrolling, and scroll-to-match
@Observable
@MainActor
final class SQLEditorCoordinator: TextViewCoordinator {
final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate {
// MARK: - Properties

private static let logger = Logger(subsystem: "com.TablePro", category: "SQLEditorCoordinator")
Expand All @@ -32,6 +32,7 @@ final class SQLEditorCoordinator: TextViewCoordinator {
/// Debounce work item for frame-change notification to avoid
/// triggering syntax highlight viewport recalculation on every keystroke.
@ObservationIgnored private var frameChangeTask: Task<Void, Never>?
@ObservationIgnored private var isUppercasing = false
@ObservationIgnored private var wasEditorFocused = false
@ObservationIgnored private var didDestroy = false

Expand Down Expand Up @@ -121,25 +122,22 @@ final class SQLEditorCoordinator: TextViewCoordinator {
}
}

func textViewDidChangeText(controller: TextViewController) {
// Invalidate Vim buffer's cached line count after text changes
func textView(_ textView: TextView, didReplaceContentsIn range: NSRange, with string: String) {
vimEngine?.invalidateLineCache()

// Notify inline suggestion manager immediately (lightweight)
Task { [weak self] in
self?.inlineSuggestionManager?.handleTextChange()
self?.vimCursorManager?.updatePosition()
}

// Throttle frame-change notification — during rapid typing, only the
// last notification matters. The highlighter recalculates the visible
// range on each notification, so coalescing saves redundant layout work.
frameChangeTask?.cancel()
frameChangeTask = Task { [weak controller] in
try? await Task.sleep(for: .milliseconds(50))
guard !Task.isCancelled, let controller, let textView = controller.textView else { return }
NotificationCenter.default.post(name: NSView.frameDidChangeNotification, object: textView)
}

uppercaseKeywordIfNeeded(textView: textView, range: range, string: string)
}

func textViewDidChangeSelection(controller: TextViewController, newPositions: [CursorPosition]) {
Expand Down Expand Up @@ -350,6 +348,118 @@ final class SQLEditorCoordinator: TextViewCoordinator {
}
}

// MARK: - Keyword Auto-Uppercase

private func uppercaseKeywordIfNeeded(textView: TextView, range: NSRange, string: String) {
guard !isUppercasing,
AppSettingsManager.shared.editor.uppercaseKeywords,
isWordBoundary(string) else { return }

let nsText = textView.textStorage.string as NSString
let wordEnd = range.location

var wordStart = wordEnd
while wordStart > 0 {
let ch = nsText.character(at: wordStart - 1)
guard isWordCharacter(ch) else { break }
wordStart -= 1
}

let wordLength = wordEnd - wordStart
guard wordLength > 0 else { return }

let word = nsText.substring(with: NSRange(location: wordStart, length: wordLength))
guard SQLKeywords.keywordSet.contains(word.lowercased()) else { return }
guard !isInsideProtectedContext(nsText, at: wordStart) else { return }

let uppercased = word.uppercased()
guard uppercased != word else { return }

// Mutate textStorage directly — we're inside beginEditing/endEditing
// so NSTextStorage consolidates this with the delimiter insertion.
// Cannot use textView.replaceCharacters here because CEUndoManager's
// registerMutation calls inverseMutation which asserts mid-edit.
let wordRange = NSRange(location: wordStart, length: wordLength)
isUppercasing = true
textView.textStorage.replaceCharacters(in: wordRange, with: uppercased)
textView.selectionManager.didReplaceCharacters(in: wordRange, replacementLength: wordLength)
isUppercasing = false
}

private func isWordBoundary(_ string: String) -> Bool {
guard (string as NSString).length == 1, let ch = string.unicodeScalars.first else { return false }
switch ch {
case " ", "\t", "\n", "\r", "(", ")", ",", ";":
return true
default:
return false
}
}

private func isWordCharacter(_ ch: unichar) -> Bool {
(ch >= 0x41 && ch <= 0x5A) || (ch >= 0x61 && ch <= 0x7A) ||
(ch >= 0x30 && ch <= 0x39) || ch == 0x5F
}

private func isInsideProtectedContext(_ text: NSString, at position: Int) -> Bool {
let scanStart = max(0, position - 2000)
var inSingleQuote = false
var inDoubleQuote = false
var inBacktick = false
var inLineComment = false
var inBlockComment = false
var i = scanStart

while i < position {
let ch = text.character(at: i)

if inBlockComment {
if ch == 0x2A && i + 1 < position && text.character(at: i + 1) == 0x2F {
inBlockComment = false
i += 2
continue
}
i += 1
continue
}
if inLineComment {
if ch == 0x0A { inLineComment = false }
i += 1
continue
}

// Skip backslash-escaped characters (e.g. \' inside strings)
if ch == 0x5C && (inSingleQuote || inDoubleQuote) {
i += 2
continue
}

switch ch {
case 0x27: if !inDoubleQuote && !inBacktick { inSingleQuote.toggle() }
case 0x22: if !inSingleQuote && !inBacktick { inDoubleQuote.toggle() }
case 0x60: if !inSingleQuote && !inDoubleQuote { inBacktick.toggle() }
case 0x2D:
if !inSingleQuote && !inDoubleQuote && !inBacktick &&
i + 1 < position && text.character(at: i + 1) == 0x2D {
inLineComment = true
i += 2
continue
}
case 0x2F:
if !inSingleQuote && !inDoubleQuote && !inBacktick &&
i + 1 < position && text.character(at: i + 1) == 0x2A {
inBlockComment = true
i += 2
continue
}
default: break
}
i += 1
}

return inSingleQuote || inDoubleQuote || inBacktick || inLineComment || inBlockComment
}

// MARK: - CodeEditSourceEditor Workarounds

/// Reorder FindViewController's subviews so the find panel is on top for hit testing.
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Settings/EditorSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ struct EditorSettingsView: View {
Text("8 spaces").tag(8)
}
Toggle("Vim mode", isOn: $settings.vimModeEnabled)
Toggle("Auto-uppercase keywords", isOn: $settings.uppercaseKeywords)
}
}
.formStyle(.grouped)
Expand Down
9 changes: 9 additions & 0 deletions docs/customization/editor-settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,15 @@ SELECT

## Editor Behavior

### Auto-Uppercase Keywords

| Option | Description |
|--------|-------------|
| **Off** | Keywords stay as typed (default) |
| **On** | SQL keywords auto-uppercase when you type a space or delimiter |

When enabled, recognized SQL keywords (`select`, `from`, `where`, `join`, etc.) are converted to uppercase as soon as you type a word boundary character (space, tab, newline, parenthesis, comma, or semicolon). Keywords inside strings, comments, and backtick-quoted identifiers are left unchanged.

### Autocomplete

Autocomplete is always on. Dismiss with `Escape`. See [Autocomplete](/features/autocomplete) for details.
Expand Down
Loading