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 @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Value picker on a foreign key cell, listing rows from the referenced table with a label beside the key. (#2511)
- Breakdown of a query's time into server, first row and transfer, behind the toolbar's duration readout. (#2503)
- Exclude the AUTO_INCREMENT counter and Exclude DEFINER clauses in the SQL export, both on by default. (#2516)
- Jump to Column in the grid, a fuzzy search over the result's columns with their type and position. (#2495)

### Changed

Expand Down
7 changes: 7 additions & 0 deletions TablePro/Core/Menu/EditMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@ enum EditMenuBuilder {
action: #selector(MainSplitViewController.findPrevious(_:)),
shortcut: .findPrevious,
keyboard: keyboard
),
MenuItemFactory.separator,
MenuItemFactory.item(
String(localized: "Jump to Column…"),
action: #selector(MainSplitViewController.jumpToColumn(_:)),
shortcut: .jumpToColumn,
keyboard: keyboard
)
])
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@ extension MainSplitViewController {
commandActions?.stepFindBackward()
}

@objc func jumpToColumn(_ sender: Any?) {
commandActions?.showColumnJump()
}

@objc func addRow(_ sender: Any?) {
commandActions?.addNewRow()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ struct MenuValidationContext: Equatable {
var isReadOnly = false
var canUseTableResultCommands = false
var canUseGridFindCommands = false
/// Jump to Column reads the mounted data grid, so it needs one on screen with columns to list.
var canJumpToColumn = false
/// Save As writes the selected tab's SQL, so it needs a query tab and not merely a connection.
var isQueryTab = false
/// Export Results exports the selected tab's rows, so an empty grid has nothing to offer.
Expand Down Expand Up @@ -176,6 +178,8 @@ extension MainSplitViewController: NSMenuItemValidation {
return context.hasEditorForFind || (context.isConnected && context.canUseGridFindCommands)
case #selector(findNext(_:)), #selector(findPrevious(_:)):
return context.hasEditorForFind || context.hasActiveGridFind
case #selector(jumpToColumn(_:)):
return context.isConnected && context.canJumpToColumn
case #selector(undo(_:)):
return context.canUndo
case #selector(redo(_:)):
Expand Down Expand Up @@ -259,6 +263,7 @@ extension MainSplitViewController: NSMenuItemValidation {
isReadOnly: actions.isReadOnly,
canUseTableResultCommands: actions.canUseTableResultCommands,
canUseGridFindCommands: actions.canUseGridFindCommands,
canJumpToColumn: actions.canJumpToColumn,
isQueryTab: actions.isQueryTab,
hasResultRows: actions.hasResultRows,
isCurrentTabEditable: actions.isCurrentTabEditable,
Expand Down
99 changes: 99 additions & 0 deletions TablePro/Models/UI/GridColumnEntry.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//
// GridColumnEntry.swift
// TablePro
//

import Foundation

/// One column as the Columns popover and Jump to Column both list it.
///
/// `dataIndex` is nil for a column the user hid on a table tab, because a hidden column is not
/// fetched and so has no place in the result; it is still listed so it can be shown again.
/// `position` is the column's 1-based place among the columns the grid presents, in the order the
/// grid presents them, so a reordered column reports where the reader sees it rather than where the
/// result put it.
struct GridColumnEntry: Identifiable, Hashable, Sendable {
let id: String
let name: String
let dataIndex: Int?
let typeName: String?
let position: Int?
let isHidden: Bool

init(name: String, dataIndex: Int?, typeName: String?, position: Int?, isHidden: Bool) {
self.id = dataIndex.map { "column-\($0)" } ?? "hidden-\(name)"
self.name = name
self.dataIndex = dataIndex
self.typeName = typeName
self.position = position
self.isHidden = isHidden
}
}

enum GridColumnCatalog {
/// The catalog keeps one entry per physical column, and a join routinely carries two columns
/// with one name. Visibility is kept by name, so anything that counts or toggles visibility
/// reads this projection: the first entry of each name, in catalog order.
static func uniqueByName(_ entries: [GridColumnEntry]) -> [GridColumnEntry] {
var seen = Set<String>()
return entries.filter { seen.insert($0.name).inserted }
}

/// - Parameters:
/// - displayOrder: data indices of the presented columns in the order the grid shows them,
/// or nil when no grid is mounted, in which case the result's own order stands in.
/// - pickerColumns: every column the Columns popover offers, which on a table tab includes
/// the schema's columns the result left out because they are hidden.
static func entries(
resultColumns: [String],
columnTypes: [ColumnType],
hiddenColumns: Set<String>,
displayOrder: [Int]?,
pickerColumns: [String]
) -> [GridColumnEntry] {
let presented = displayOrder
?? resultColumns.indices.filter { !hiddenColumns.contains(resultColumns[$0]) }
var positionByDataIndex: [Int: Int] = [:]
positionByDataIndex.reserveCapacity(presented.count)
for (offset, dataIndex) in presented.enumerated() {
positionByDataIndex[dataIndex] = offset + 1
}

var dataIndicesByName: [String: [Int]] = [:]
for (dataIndex, name) in resultColumns.enumerated() {
dataIndicesByName[name, default: []].append(dataIndex)
}

func resultEntry(_ dataIndex: Int) -> GridColumnEntry {
let name = resultColumns[dataIndex]
let type = dataIndex < columnTypes.count ? columnTypes[dataIndex] : nil
let isHidden = hiddenColumns.contains(name)
return GridColumnEntry(
name: name,
dataIndex: dataIndex,
typeName: type.map { $0.rawType ?? $0.displayName },
position: isHidden ? nil : positionByDataIndex[dataIndex],
isHidden: isHidden
)
}

var entries: [GridColumnEntry] = []
entries.reserveCapacity(max(pickerColumns.count, resultColumns.count))
var listedNames = Set<String>()
for name in pickerColumns {
guard listedNames.insert(name).inserted else { continue }
guard let dataIndices = dataIndicesByName.removeValue(forKey: name) else {
entries.append(GridColumnEntry(name: name, dataIndex: nil, typeName: nil, position: nil, isHidden: true))
continue
}
for dataIndex in dataIndices {
entries.append(resultEntry(dataIndex))
}
}
let unlisted = dataIndicesByName.values.flatMap { $0 }.sorted()
for dataIndex in unlisted {
entries.append(resultEntry(dataIndex))
}
return entries
}
}
7 changes: 5 additions & 2 deletions TablePro/Models/UI/KeyboardShortcutModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case refresh
case export
case importData
case jumpToColumn

// Navigation
case navigateBack
Expand Down Expand Up @@ -155,7 +156,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case .undo, .redo, .cut, .copy, .copyRowsExplicit, .copyWithHeaders, .copyAsJson,
.paste, .delete, .selectAll, .clearSelection, .addRow, .duplicateRow,
.truncateTable, .toggleHeaderRow, .previewFKReference, .saveAsFavorite, .previousPage,
.nextPage, .firstPage, .lastPage, .refresh, .export, .importData:
.nextPage, .firstPage, .lastPage, .refresh, .export, .importData, .jumpToColumn:
return .dataGrid
case .navigateBack, .navigateForward,
.newTab, .closeTab, .closeOtherTabs, .closeTabsForOtherDatabases, .closeAllTabs,
Expand All @@ -182,7 +183,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
return .editor
case .previousPage, .nextPage, .firstPage, .lastPage, .addRow, .duplicateRow,
.delete, .truncateTable, .previewFKReference, .saveAsFavorite,
.copyRowsExplicit, .copyWithHeaders, .copyAsJson, .toggleFilters:
.copyRowsExplicit, .copyWithHeaders, .copyAsJson, .toggleFilters, .jumpToColumn:
return .dataGrid
default:
return .global
Expand Down Expand Up @@ -234,6 +235,7 @@ enum ShortcutAction: String, Codable, CaseIterable, Identifiable {
case .findPrevious: return String(localized: "Find Previous")
case .export: return String(localized: "Export")
case .importData: return String(localized: "Import")
case .jumpToColumn: return String(localized: "Jump to Column")
case .quickSwitcher: return String(localized: "Open Quickly")
case .previousPage: return String(localized: "Previous Page")
case .nextPage: return String(localized: "Next Page")
Expand Down Expand Up @@ -561,6 +563,7 @@ struct KeyboardSettings: Codable, Equatable {
.firstPage: .special(.upArrow, command: true, option: true),
.lastPage: .special(.downArrow, command: true, option: true),
.refresh: .character("r", command: true),
.jumpToColumn: .character("j", command: true, shift: true),

// Navigation
/// Not the Safari chord. Command+[ and Command+] are Previous/Next Page and are claimed
Expand Down
106 changes: 106 additions & 0 deletions TablePro/ViewModels/ColumnJumpViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//
// ColumnJumpViewModel.swift
// TablePro
//

import Foundation
import Observation

@MainActor @Observable
final class ColumnJumpViewModel {
struct Match: Identifiable, Equatable {
let entry: GridColumnEntry
let matchedIndices: [Int]

var id: String { entry.id }
}

let entries: [GridColumnEntry]
private(set) var matches: [Match] = []
var selectedId: String?
var searchText: String {
didSet { refilter() }
}

@ObservationIgnored private var rankedQuery: String

/// - Parameter cursorColumnIndex: the data index under the grid's cell cursor, which the empty
/// list opens on so Return with nothing typed goes nowhere the reader is not already looking.
init(entries: [GridColumnEntry], initialQuery: String = "", cursorColumnIndex: Int? = nil) {
self.entries = entries
let query = initialQuery.trimmingCharacters(in: .whitespaces)
self.searchText = initialQuery
self.rankedQuery = query
let ranked = Self.rank(entries, query: query)
self.matches = ranked
let cursorMatch = cursorColumnIndex.flatMap { index in
ranked.first { $0.entry.dataIndex == index && !$0.entry.isHidden }
}
self.selectedId = (cursorMatch ?? ranked.first)?.id
}

var presentedColumnCount: Int {
entries.filter { !$0.isHidden }.count
}

var selectedEntry: GridColumnEntry? {
matches.first { $0.id == selectedId }?.entry
}

func moveSelection(by offset: Int) {
guard !matches.isEmpty else { return }
let current = selectedId.flatMap { id in matches.firstIndex { $0.id == id } } ?? 0
let next = min(max(current + offset, 0), matches.count - 1)
selectedId = matches[next].id
}

func listHeight(rowHeight: CGFloat, maxVisibleRows: Int) -> CGFloat {
CGFloat(min(max(matches.count, 1), maxVisibleRows)) * rowHeight
}

/// A query that ranks the same rows keeps the selection; a new query moves it to the best
/// match, because the row the reader had was chosen against a list that no longer exists.
private func refilter() {
let query = searchText.trimmingCharacters(in: .whitespaces)
guard query != rankedQuery else { return }
rankedQuery = query
matches = Self.rank(entries, query: query)
selectedId = matches.first?.id
}

private static func rank(_ entries: [GridColumnEntry], query: String) -> [Match] {
let ordered = entries.enumerated().map { (entry: $0.element, catalogOrder: $0.offset) }
guard !query.isEmpty else {
return ordered
.sorted { precedes($0, $1) }
.map { Match(entry: $0.entry, matchedIndices: []) }
}
return ordered
.compactMap { candidate -> (match: Match, score: Int, candidate: (entry: GridColumnEntry, catalogOrder: Int))? in
guard let fuzzy = FuzzyMatcher.match(query: query, candidate: candidate.entry.name) else { return nil }
return (Match(entry: candidate.entry, matchedIndices: fuzzy.matchedIndices), fuzzy.score, candidate)
}
.sorted { lhs, rhs in
if lhs.score != rhs.score { return lhs.score > rhs.score }
return precedes(lhs.candidate, rhs.candidate)
}
.map { $0.match }
}

/// Presented columns first, in the order the grid shows them; hidden ones after, in catalog order.
private static func precedes(
_ lhs: (entry: GridColumnEntry, catalogOrder: Int),
_ rhs: (entry: GridColumnEntry, catalogOrder: Int)
) -> Bool {
switch (lhs.entry.position, rhs.entry.position) {
case let (left?, right?):
return left < right
case (.some, nil):
return true
case (nil, .some):
return false
case (nil, nil):
return lhs.catalogOrder < rhs.catalogOrder
}
}
}
21 changes: 15 additions & 6 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -763,10 +763,7 @@ struct MainEditorContentView: View {
Divider()
}

if tab.tabType == .query && !resolvedRows.columns.isEmpty
&& resolvedRows.rows.isEmpty && tab.execution.lastExecutedAt != nil
&& !coordinator.tabExecution.isExecuting(tab.id) && !tab.filterState.hasAppliedFilters
{
if showsEmptyResultView(tab: tab, rows: resolvedRows) {
emptyResultView(executionTime: tab.display.activeResultSet?.executionTime ?? tab.execution.executionTime)
} else {
dataGridView(tab: tab)
Expand Down Expand Up @@ -841,6 +838,14 @@ struct MainEditorContentView: View {
)
}

/// A query that came back with columns and no rows shows this instead of a grid, so anything
/// that offers a jump into the grid reads the same condition.
private func showsEmptyResultView(tab: QueryTab, rows: TableRows) -> Bool {
tab.tabType == .query && !rows.columns.isEmpty
&& rows.rows.isEmpty && tab.execution.lastExecutedAt != nil
&& !coordinator.tabExecution.isExecuting(tab.id) && !tab.filterState.hasAppliedFilters
}

private func emptyResultView(executionTime: TimeInterval?) -> some View {
let description: String? = executionTime.map { String(format: "%.3fs", $0) }
return ContentUnavailableView {
Expand Down Expand Up @@ -973,11 +978,15 @@ struct MainEditorContentView: View {
filterState: tab.filterState,
columnState: StatusBarColumnState(
hidden: tab.columnLayout.hiddenColumns,
all: coordinator.columnsForVisibilityPicker(for: tab, resultColumns: resolvedRows.columns),
columns: coordinator.columnCatalog(for: tab, resultRows: resolvedRows),
onToggle: { coordinator.toggleColumnVisibility($0) },
onShowAll: { coordinator.showAllColumns() },
onHideAll: { coordinator.hideAllColumns($0) },
onReset: { coordinator.resetColumns() }
onReset: { coordinator.resetColumns() },
onJumpToColumn: tab.display.resultsViewMode == .data && !tab.display.isResultsCollapsed
&& !showsEmptyResultView(tab: tab, rows: resolvedRows)
? { coordinator.showColumnJump(seededWith: $0) }
: nil
),
paginationCallbacks: PaginationCallbacks(
onFirst: onFirstPage,
Expand Down
Loading
Loading