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
17 changes: 17 additions & 0 deletions .github/workflows/macos-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ on:
- "TablePro/**"
- "Plugins/**"
- "Packages/**"
- "LocalPackages/**"
- "TableProTests/**"
- "TablePro.xcodeproj/**"
- "Libs/**"
Expand All @@ -16,6 +17,7 @@ on:
- "TablePro/**"
- "Plugins/**"
- "Packages/**"
- "LocalPackages/**"
- "TableProTests/**"
- "TablePro.xcodeproj/**"
- "Libs/**"
Expand Down Expand Up @@ -49,6 +51,21 @@ jobs:
- name: Run package tests
run: swift test --package-path Packages/TableProCore

editor-tests:
name: CodeEditTextView Package Tests
runs-on: macos-26
timeout-minutes: 20
steps:
- uses: actions/checkout@v4

- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: '26.4.1'

- name: Run editor package tests
run: swift test --package-path LocalPackages/CodeEditTextView

app-tests:
name: macOS App Tests
runs-on: macos-26
Expand Down
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Autocomplete now keeps suggesting tables and columns after a connection drops and reconnects on its own. The reconnect cleared the schema it had loaded and never asked for it again, so a window that looked connected offered nothing but keywords for the rest of the session.
- The JSON view of a result now updates when you run another query. It kept showing the previous result until you switched to the data grid and back, and a query that returned the same number of rows never updated at all.
- The JSON view shows the whole result again when no row is selected. A selection left over from an earlier query made it show an empty list, and a column filter made it show the wrong rows.
- Running a query now clears the row selection from the previous result, so the row inspector and Copy act on rows you actually picked.
- Double-clicking a word in the SQL editor selects it again, including the last word in the query. It selected nothing when text was already selected, and stayed dead until you clicked once somewhere else.
- Triple-clicking selects the line under the pointer, and both now act on the word or line you clicked rather than wherever the cursor happened to be.
- Text in a read-only editor, such as the JSON view of a result, can now be clicked and selected. Only dragging worked before.
- AI chat replies now stream smoothly instead of arriving in visible jumps, and the panel no longer slows down as a reply grows. Every update used to redraw every message on screen.
- The first words of an AI reply now appear as soon as they arrive, instead of waiting for the next batch.
- Bold, italic, code, and link markers no longer flash as raw punctuation while an AI reply is still being written.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//
// NSRange+clamped.swift
// CodeEditTextView
//

import Foundation

extension NSRange {
/// Returns the range moved inside `0..<length`, keeping as much of it as still fits.
///
/// Text can shrink under a selection, when a document is replaced with a shorter one. Dropping
/// a range that no longer fits leaves the view with no selection at all, and no insertion point
/// to select from; clamping keeps a usable selection at the end of the new text.
func clamped(toLength length: Int) -> NSRange {
let start = Swift.min(Swift.max(self.location, 0), length)
let end = Swift.min(Swift.max(self.max, 0), length)
return NSRange(location: start, length: Swift.max(0, end - start))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ public class TextSelectionManager: NSObject {
/// - Parameter range: The range to set.
public func setSelectedRange(_ range: NSRange) {
textSelections.forEach { $0.view?.removeFromSuperview() }
let range = range.clamped(toLength: textStorage?.length ?? 0)
let selection = TextSelection(range: range)
selection.suggestedXPos = layoutManager?.rectForOffset(range.location)?.minX
textSelections = [selection]
Expand All @@ -88,12 +89,9 @@ public class TextSelectionManager: NSObject {
let oldRanges = textSelections.map(\.range)

textSelections.forEach { $0.view?.removeFromSuperview() }
// Remove duplicates, invalid ranges, update suggested X position.
textSelections = Set(ranges)
.filter {
(0...(textStorage?.length ?? 0)).contains($0.location)
&& (0...(textStorage?.length ?? 0)).contains($0.max)
}
// Remove duplicates, clamp out-of-bounds ranges, update suggested X position.
let storageLength = textStorage?.length ?? 0
textSelections = Set(ranges.map { $0.clamped(toLength: storageLength) })
.sorted(by: { $0.location < $1.location })
.map {
let selection = TextSelection(range: $0)
Expand Down Expand Up @@ -141,11 +139,21 @@ public class TextSelectionManager: NSObject {
/// optionally reseting the blink timer.
func updateSelectionViews(force: Bool = false, skipTimerReset: Bool = false) {
guard textView?.isFirstResponder ?? false else { return }
// A selectable but non-editable view tracks a collapsed selection so the user can extend
// it or select a word, but it must not blink an insertion point at text it cannot edit.
// This mirrors `NSTextView` with `isEditable = false` and `isSelectable = true`.
let showsInsertionPoint = textView?.isEditable ?? true
var didUpdate: Bool = false

for textSelection in textSelections {
if textSelection.range.isEmpty {
didUpdate = didUpdate || repositionCursorSelection(textSelection: textSelection)
if showsInsertionPoint {
didUpdate = didUpdate || repositionCursorSelection(textSelection: textSelection)
} else if textSelection.view != nil {
textSelection.view?.removeFromSuperview()
textSelection.view = nil
didUpdate = true
}
} else if !textSelection.range.isEmpty && textSelection.view != nil {
textSelection.view?.removeFromSuperview()
textSelection.view = nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@ extension TextView {
case 1:
handleSingleClick(event: event, offset: offset)
case 2:
handleDoubleClick(event: event)
handleDoubleClick(event: event, offset: offset)
case 3:
handleTripleClick(event: event)
handleTripleClick(event: event, offset: offset)
default:
break
}
Expand All @@ -43,43 +43,58 @@ extension TextView {
fileprivate func handleSingleClick(event: NSEvent, offset: Int) {
cursorSelectionMode = .character

guard isEditable else {
super.mouseDown(with: event)
return
}
let eventFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if eventFlags == [.control, .shift] {
guard isEditable else {
super.mouseDown(with: event)
return
}
unmarkText()
selectionManager.addSelectedRange(NSRange(location: offset, length: 0))
} else if eventFlags.contains(.shift) {
unmarkText()
if isEditable {
unmarkText()
}
shiftClickExtendSelection(to: offset)
} else {
selectionManager.setSelectedRange(NSRange(location: offset, length: 0))
unmarkTextIfNeeded()
if isEditable {
unmarkTextIfNeeded()
}
}
}

fileprivate func handleDoubleClick(event: NSEvent) {
cursorSelectionMode = .word

/// Selects the word under the pointer.
///
/// The boundary is found from the clicked offset rather than from the current selection, so a
/// double click lands on the right word no matter what was selected before. The first click of
/// the pair does not always reach ``handleSingleClick``: the drag gesture holds it back when it
/// falls inside an existing selection, and a document swap can leave the view with no selection
/// at all.
fileprivate func handleDoubleClick(event: NSEvent, offset: Int) {
guard !event.modifierFlags.contains(.shift) else {
super.mouseDown(with: event)
return
}
unmarkText()
selectWord(nil)
if isEditable {
unmarkText()
}
selectionManager.setSelectedRange(findWordBoundary(at: offset))
cursorSelectionMode = .word
needsDisplay = true
}

fileprivate func handleTripleClick(event: NSEvent) {
cursorSelectionMode = .line

fileprivate func handleTripleClick(event: NSEvent, offset: Int) {
guard !event.modifierFlags.contains(.shift) else {
super.mouseDown(with: event)
return
}
unmarkText()
selectLine(nil)
if isEditable {
unmarkText()
}
selectionManager.setSelectedRange(findLineBoundary(at: offset))
cursorSelectionMode = .line
needsDisplay = true
}

fileprivate func handleAttachmentClick(event: NSEvent, offset: Int, attachment: AnyTextAttachment) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,9 @@ extension TextView {
}

override public func selectWord(_ sender: Any?) {
let newSelections = selectionManager.textSelections.compactMap { (textSelection) -> NSRange? in
guard textSelection.range.isEmpty else {
return nil
}
return findWordBoundary(at: textSelection.range.location)
}
let newSelections = selectionManager.textSelections.map { textSelection in
findWordBoundary(at: textSelection.range.location)
}
selectionManager.setSelectedRanges(newSelections)
unmarkTextIfNeeded()
needsDisplay = true
Expand Down Expand Up @@ -63,10 +60,14 @@ extension TextView {
return NSRange(location: position, length: 0)
}

guard let start = textStorage.findPrecedingOccurrenceOfCharacter(in: characterSet.inverted, from: position),
let end = textStorage.findNextOccurrenceOfCharacter(in: characterSet.inverted, from: position) else {
return NSRange(location: position, length: 0)
}
// The scan returns nil once it runs past the end of the storage, which is what happens for
// the last word in a document: there is no character after it to end the word. The start
// and the end of the document are word boundaries too.
let start = textStorage.findPrecedingOccurrenceOfCharacter(in: characterSet.inverted, from: position) ?? 0
let end = textStorage.findNextOccurrenceOfCharacter(
in: characterSet.inverted,
from: position
) ?? textStorage.length

return NSRange(start: start, end: end)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,4 +223,29 @@ final class TextSelectionManagerTests: XCTestCase {
selectionManager.setSelectedRange(NSRange(location: 6, length: 0)) // Beyond text.length, end of doc
XCTAssertNotNil(selectionManager.textSelections.first?.suggestedXPos)
}

func test_setSelectedRangesClampsBeyondStorageInsteadOfDropping() {
let selectionManager = selectionManager("short")

selectionManager.setSelectedRanges([NSRange(location: 50, length: 0)])

XCTAssertEqual(selectionManager.textSelections.count, 1)
XCTAssertEqual(selectionManager.textSelections.first?.range, NSRange(location: 5, length: 0))
}

func test_setSelectedRangesClampsAnOverlongRangeToTheStorage() {
let selectionManager = selectionManager("short")

selectionManager.setSelectedRanges([NSRange(location: 2, length: 40)])

XCTAssertEqual(selectionManager.textSelections.first?.range, NSRange(location: 2, length: 3))
}

func test_setSelectedRangeClampsBeyondStorage() {
let selectionManager = selectionManager("short")

selectionManager.setSelectedRange(NSRange(location: 50, length: 10))

XCTAssertEqual(selectionManager.textSelections.first?.range, NSRange(location: 5, length: 0))
}
}
Loading
Loading