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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- SQL Server Edit View Definition template creating the view in the default schema instead of the view's own.
- Oracle view DDL losing the view's column names, and cut off after 4000 bytes without saying so.
- Trino materialized views left out of SQL exports.
- Return in Switch Connection reopening the current connection after typing another connection's name.

### Security

Expand Down
40 changes: 37 additions & 3 deletions TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,26 @@ import TableProConnectionLibrary
import TableProPluginKit

enum ConnectionSwitcherFilter {
// A file connection's database is a whole path, which a short query matches letter by letter.
private static let hostOrDatabaseWeight = 0.6

static func matches(_ connection: DatabaseConnection, query: String) -> Bool {
let trimmed = query.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return true }
return FuzzyMatcher.matches(query: trimmed, candidate: connection.name)
|| FuzzyMatcher.matches(query: trimmed, candidate: connection.host)
|| FuzzyMatcher.matches(query: trimmed, candidate: connection.database)
}

static func score(_ connection: DatabaseConnection, query: String) -> Double? {
let trimmed = query.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty else { return nil }
let nameScore = FuzzyMatcher.match(query: trimmed, candidate: connection.name).map { Double($0.score) }
let otherScores = [connection.host, connection.database].compactMap { candidate in
FuzzyMatcher.match(query: trimmed, candidate: candidate).map { Double($0.score) * hostOrDatabaseWeight }
}
return ([nameScore].compactMap { $0 } + otherScores).max()
}
}

enum ConnectionSwitcherSelection {
Expand All @@ -26,6 +39,20 @@ enum ConnectionSwitcherSelection {
let newIndex = max(0, min(ids.count - 1, currentIndex + offset))
return ids[newIndex]
}

// Never the row selected before the query: it can still match through its database path.
static func preferred(in entries: [ConnectionSwitcherEntry], query: String, current: UUID?) -> UUID? {
guard !query.trimmingCharacters(in: .whitespaces).isEmpty else {
return entries.first { $0.id == current }?.id ?? entries.first?.id
}
var best: (id: UUID, score: Double)?
for entry in entries {
guard let score = ConnectionSwitcherFilter.score(entry.connection, query: query),
score > (best?.score ?? -.infinity) else { continue }
best = (entry.id, score)
}
return best?.id ?? entries.first?.id
}
}

/// The two sections list different things, a live session and a saved record, but the list shows one
Expand Down Expand Up @@ -160,8 +187,7 @@ struct ConnectionSwitcherPopover: View {
.onAppear {
reload()
if selectedConnectionId == nil {
selectedConnectionId = currentConnectionId.flatMap { id in orderedIds.contains(id) ? id : nil }
?? orderedIds.first
selectPreferred()
}
}
/// The subject itself, not a `receive(on:)` wrapper: that builds a new publisher on every
Expand All @@ -183,7 +209,7 @@ struct ConnectionSwitcherPopover: View {
settleSelection()
}
.onChange(of: searchText) { _ in
settleSelection()
selectPreferred()
}
}

Expand Down Expand Up @@ -370,6 +396,14 @@ struct ConnectionSwitcherPopover: View {
selectedConnectionId = ids.first
}

private func selectPreferred() {
selectedConnectionId = ConnectionSwitcherSelection.preferred(
in: sections.flatMap(\.items),
query: searchText,
current: currentConnectionId
)
}

private func moveSelection(by offset: Int) {
if let next = ConnectionSwitcherSelection.moved(in: orderedIds, from: selectedConnectionId, by: offset) {
selectedConnectionId = next
Expand Down
55 changes: 55 additions & 0 deletions TableProTests/Views/Toolbar/ConnectionSwitcherFilterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,61 @@ struct ConnectionSwitcherSelectionTests {
let (a, b, c) = (UUID(), UUID(), UUID())
#expect(ConnectionSwitcherSelection.moved(in: [a, b, c], from: c, by: 1) == c)
}

// MARK: - A new query

private func entry(_ connection: DatabaseConnection, isActive: Bool = false) -> ConnectionSwitcherEntry {
ConnectionSwitcherEntry(id: connection.id, connection: connection, isActive: isActive, isConnected: isActive)
}

@Test("A query selects the connection it names over the open one it matches only through a file path")
func aNameMatchBeatsAPathMatch() {
let folder = "/var/folders/5n/5t77mfb93jd70000gn/T/TableProUITests/c6da0d7486"
let open = TestFixtures.makeConnection(name: "Fixture", database: "\(folder)/seeded-0.sqlite", type: .sqlite)
let saved = TestFixtures.makeConnection(name: "Ledger", database: "\(folder)/seeded-1.sqlite", type: .sqlite)
let entries = [entry(open, isActive: true), entry(saved)]

#expect(ConnectionSwitcherFilter.matches(open, query: "Ledger"), "The open connection must still be listed")
for query in ["L", "Led", "Ledger"] {
#expect(ConnectionSwitcherSelection.preferred(in: entries, query: query, current: open.id) == saved.id)
}
}

@Test("A connection matched only through its database is still selected when no name matches")
func aDatabaseMatchIsSelectedWhenNoNameMatches() {
let open = TestFixtures.makeConnection(name: "Replica", database: "orders")
let saved = TestFixtures.makeConnection(name: "Primary", database: "analytics")

let selected = ConnectionSwitcherSelection.preferred(
in: [entry(open, isActive: true), entry(saved)], query: "analy", current: open.id
)

#expect(selected == saved.id)
}

@Test("Equal matches keep the order the list draws")
func equalMatchesKeepListOrder() {
let first = TestFixtures.makeConnection(name: "Replica")
let second = TestFixtures.makeConnection(name: "Replica")

let selected = ConnectionSwitcherSelection.preferred(
in: [entry(first), entry(second)], query: "rep", current: nil
)

#expect(selected == first.id)
}

@Test("A blank query selects the current connection, or the first row when it is not listed")
func blankQuerySelectsTheCurrentConnection() {
let open = TestFixtures.makeConnection(name: "Fixture")
let saved = TestFixtures.makeConnection(name: "Ledger")
let entries = [entry(saved), entry(open, isActive: true)]

#expect(ConnectionSwitcherSelection.preferred(in: entries, query: "", current: open.id) == open.id)
#expect(ConnectionSwitcherSelection.preferred(in: entries, query: " ", current: open.id) == open.id)
#expect(ConnectionSwitcherSelection.preferred(in: entries, query: "", current: UUID()) == saved.id)
#expect(ConnectionSwitcherSelection.preferred(in: [], query: "Ledger", current: open.id) == nil)
}
}

struct ConnectionSwitcherSectionsTests {
Expand Down
16 changes: 15 additions & 1 deletion TableProUITests/AppearanceSettingsUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ final class AppearanceSettingsUITests: UITestCase {
let reset = sheet.buttons["theme-color-reset-editor.background"]
XCTAssertTrue(reset.waitToExist(timeout: 10), "Every color of a Dracula copy starts overridden")
XCTAssertEqual(reset.label, "Use Default")
XCTAssertTrue(scrollUntilHittable(reset, in: sheet, timeout: 10))
XCTAssertTrue(
scrollIntoView(reset, in: sheet.scrollViews.firstMatch, timeout: 10),
"The Editor Background row must scroll into the editor's list"
)
reset.click()
XCTAssertTrue(waitForPredicate(timeout: 5) { !reset.exists }, "Use Default should clear the override")
XCTAssertTrue(
Expand Down Expand Up @@ -142,6 +145,17 @@ final class AppearanceSettingsUITests: UITestCase {
item.click()
}

// The list shows about 180pt of rows on the runner, so a fixed 400pt step skips the row; aim it at the middle.
private func scrollIntoView(_ element: XCUIElement, in list: XCUIElement, timeout: TimeInterval) -> Bool {
let deadline = Date(timeIntervalSinceNow: timeout)
while !(element.exists && element.isHittable) {
guard Date() < deadline, list.exists, element.exists else { return false }
list.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5))
.scroll(byDeltaX: 0, deltaY: list.frame.midY - element.frame.midY)
}
return true
}

private func element(_ identifier: String, in container: XCUIElement) -> XCUIElement {
container.descendants(matching: .any).matching(identifier: identifier).firstMatch
}
Expand Down
7 changes: 4 additions & 3 deletions TableProUITests/ConnectionImportReviewUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
message.waitToExist(timeout: 15),
"The result must count the connection and only the query still checked"
)
app.buttons["OK"].firstMatch.click()

Check failure on line 79 in TableProUITests/ConnectionImportReviewUITests.swift

View workflow job for this annotation

GitHub Actions / macOS Tests

testImportReportsConnectionsAndSavedQueries, -[XCUIElementMacOSEventTarget clickWithError:]_block_invoke cannot be called with Touch Bar elements, not valid for "OK" Button.

Check failure on line 79 in TableProUITests/ConnectionImportReviewUITests.swift

View workflow job for this annotation

GitHub Actions / macOS Tests

testImportReportsConnectionsAndSavedQueries, -[XCUIElementMacOSEventTarget clickWithError:]_block_invoke cannot be called with Touch Bar elements, not valid for "OK" Button.

let list = welcomeConnectionList(in: welcome)
XCTAssertTrue(
Expand All @@ -85,10 +85,11 @@
)
}

// XCUITest reports a mixed checkbox as NSControl.StateValue.mixed (-1), not the AXValue of 2.
// Runner images have reported a mixed checkbox as NSControl.StateValue.mixed (-1) and as its raw AXValue (2).
private func isMixed(_ checkbox: XCUIElement) -> Bool {
if let number = checkbox.value as? Int { return number == NSControl.StateValue.mixed.rawValue }
return (checkbox.value as? String) == String(NSControl.StateValue.mixed.rawValue)
let mixedValues = [NSControl.StateValue.mixed.rawValue, 2]
if let number = checkbox.value as? Int { return mixedValues.contains(number) }
return mixedValues.map(String.init).contains((checkbox.value as? String) ?? "")
}

private static func text(_ string: String) -> NSPredicate {
Expand Down
3 changes: 2 additions & 1 deletion TableProUITests/InspectorEditReachesGridUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ final class InspectorEditReachesGridUITests: UITestCase {
grid.clickCell(row: 1, column: 2)
app.typeShortcut(.toggleInspector)

let labelField = window.textFields.matching(NSPredicate(format: "value == %@", "First")).firstMatch
// A SQLite TEXT column opens the multi-line editor, which is a text view.
let labelField = window.textViews.matching(NSPredicate(format: "value == %@", "First")).firstMatch
XCTAssertTrue(waitUntilHittable(labelField, timeout: 30), "The inspector must show row 1's label field")
labelField.click()
app.typeText("Zq7")
Expand Down
3 changes: 1 addition & 2 deletions TableProUITests/SQLiteFirstTableLoadUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,9 @@ final class SQLiteFirstTableLoadUITests: UITestCase {
"The first table opened after connecting must finish loading"
)

// The strip lists its tabs only while there are two or more.
launch.app.typeShortcut(.closeTab)
XCTAssertTrue(
waitForPredicate(timeout: 20) { self.editorTabCount(in: window) == 0 && editor.exists },
waitForPredicate(timeout: 20) { self.tabsTitled("album", in: window).count == 0 && editor.exists },
"Command W must close the table tab and leave the query tab in front"
)
launch.app.typeShortcut(.closeTab)
Expand Down
3 changes: 2 additions & 1 deletion TableProUITests/StructureSaveConfirmationUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ final class StructureSaveConfirmationUITests: UITestCase {
let remove = window.buttons["structure-footer-remove"].firstMatch
XCTAssertTrue(remove.waitToExist(timeout: 20), "The Columns tab must offer a remove button")
XCTAssertTrue(waitForPredicate(timeout: 10) { remove.isEnabled }, "Removing the selected column must be offered")
remove.click()
// On the 1024pt runner the structure editor overflows its pane and this button ends past the window edge.
app.typeShortcut(.deleteRow)

app.typeShortcut(.save)
let execute = gateExecuteButton(in: window)
Expand Down
5 changes: 3 additions & 2 deletions TableProUITests/Support/UITestCase+FixtureGrid.swift
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import SQLite3
import XCTest

// A plain cell publishes as static text and a cell with a picker as a combo box, so a cell is found by
// the position its label opens with, whatever its type.
// AppKit wraps each cell view in an AXCell that repeats its label and carries no value, so a cell is the
// cell view, matched by its identifier whatever its type: static text, a combo box for a picker.
internal struct FixtureGrid {
internal let element: XCUIElement
internal let app: XCUIApplication
internal let objectBrowser: XCUIElement

internal func cell(row: Int, column: Int) -> XCUIElement {
element.descendants(matching: .any)
.matching(identifier: "DataGridCellAccessibilityView")
.matching(NSPredicate(format: "label BEGINSWITH %@", "Row \(row), column \(column): "))
.firstMatch
}
Expand Down
Loading