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 @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- iOS: SQL Server (MSSQL) connections via FreeTDS over TDS 7.4. Uses the shared `SSLConfiguration` model from connection settings. Supports connect, query, streaming results, schema browsing (tables, columns, indexes, foreign keys), database and schema switching, and explicit transactions.
- iOS: data browser, search, filter, and pagination now render correct SQL Server syntax (bracket-quoted identifiers, `OFFSET ... ROWS FETCH NEXT ... ROWS ONLY` pagination, `SELECT TOP 1` for cell value fetch).
- iOS: Settings > Sync now shows last sync time, a Sync Now button, and a Refresh from iCloud action that re-downloads every connection, group, and tag when items are missing on this device but visible on another.
- Settings > Data Grid > Default row sort: opt in to sort tables by Primary key or First column on open. Defaults to No sorting (engine order). Click any column header to override. (#1284)

### Changed

Expand Down
11 changes: 11 additions & 0 deletions Plugins/TableProPluginKit/PluginDefaultSortProvider.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import Foundation

public enum DefaultSortHint: Sendable, Equatable {
case useAppDefault
case suppress
case forceColumns([String])
}

public protocol PluginDefaultSortProvider: AnyObject, Sendable {
func defaultSortHint(forTable table: String) -> DefaultSortHint
}
51 changes: 51 additions & 0 deletions TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,15 @@ extension QueryExecutionCoordinator {
parent.tabManager.mutate(at: idx) { $0.tableContext.primaryKeyColumns = resolvedPKs }
}

applyDefaultSortIfPending(
tabId: tabId,
tabIndex: idx,
tableName: tableName,
columns: columns,
resolvedPKs: resolvedPKs,
connectionType: conn.type
)

if parent.tabManager.selectedTabId == tabId {
parent.changeManager.configureForTable(
tableName: tableName ?? "",
Expand All @@ -199,6 +208,48 @@ extension QueryExecutionCoordinator {
}
}

private func applyDefaultSortIfPending(
tabId: UUID,
tabIndex: Int,
tableName: String?,
columns: [String],
resolvedPKs: [String],
connectionType: DatabaseType
) {
guard tabIndex < parent.tabManager.tabs.count else { return }
let tab = parent.tabManager.tabs[tabIndex]
guard !tab.execution.didEvaluateDefaultSort,
tab.tabType == .table,
!tab.sortState.isSorting,
!columns.isEmpty,
let tableName, !tableName.isEmpty,
parent.tabManager.selectedTabId == tabId else {
return
}

let behavior = AppSettingsManager.shared.dataGrid.defaultSortBehavior
let hint = PluginManager.shared.defaultSortHint(for: connectionType, table: tableName)
let resolved = DefaultSortResolver.resolveSortState(
behavior: behavior,
pluginHint: hint,
primaryKeyColumns: resolvedPKs,
allColumns: columns
)

guard resolved.isSorting else {
parent.tabManager.mutate(at: tabIndex) { $0.execution.didEvaluateDefaultSort = true }
return
}

parent.tabManager.mutate(at: tabIndex) { tab in
tab.execution.didEvaluateDefaultSort = true
tab.sortState = resolved
tab.pagination.reset()
}
parent.filterCoordinator.rebuildTableQuery(at: tabIndex)
parent.runQuery()
}

func launchPhase2Work(
tableName: String,
tabId: UUID,
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -727,6 +727,12 @@ final class PluginManager {
return provider.diagnose(error: error)
}

func defaultSortHint(for type: DatabaseType, table: String) -> DefaultSortHint {
guard let driver = driverPlugins[type.pluginTypeId] else { return .useAppDefault }
guard let provider = driver as? PluginDefaultSortProvider else { return .useAppDefault }
return provider.defaultSortHint(forTable: table)
}

func replaceExistingPlugin(bundleId: String) {
guard let existingIndex = plugins.firstIndex(where: { $0.id == bundleId }) else { return }
unregisterCapabilities(pluginId: bundleId)
Expand Down
36 changes: 36 additions & 0 deletions TablePro/Core/Services/Query/DefaultSortResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import Foundation
import TableProPluginKit

enum DefaultSortResolver {
static func resolveSortState(
behavior: DefaultSortBehavior,
pluginHint: DefaultSortHint,
primaryKeyColumns: [String],
allColumns: [String]
) -> SortState {
let names: [String]
switch pluginHint {
case .suppress:
return SortState()
case .forceColumns(let cols):
names = cols
case .useAppDefault:
switch behavior {
case .none:
return SortState()
case .primaryKey:
names = primaryKeyColumns
case .firstColumn:
names = allColumns.first.map { [$0] } ?? []
}
}

var columnsOut: [SortColumn] = []
for name in names {
guard let index = allColumns.firstIndex(of: name) else { continue }
columnsOut.append(SortColumn(columnIndex: index, direction: .ascending))
}
guard !columnsOut.isEmpty else { return SortState() }
return SortState(columns: columnsOut, source: .defaultSort)
}
}
4 changes: 4 additions & 0 deletions TablePro/Models/Query/QueryTab.swift
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ struct QueryTab: Identifiable, Equatable {
}
}

var hasUserActiveSort: Bool {
sortState.isSorting && sortState.source == .user
}

func toPersistedTab() -> PersistedTab {
let persistedQuery: String
if (content.query as NSString).length > TabQueryContent.maxPersistableQuerySize {
Expand Down
1 change: 1 addition & 0 deletions TablePro/Models/Query/QueryTabManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ final class QueryTabManager {
tab.execution.statusMessage = nil
tab.execution.errorMessage = nil
tab.execution.lastExecutedAt = nil
tab.execution.didEvaluateDefaultSort = false
tab.display.resultsViewMode = .data
tab.sortState = SortState()
tab.selectedRowIndices = []
Expand Down
12 changes: 11 additions & 1 deletion TablePro/Models/Query/QueryTabState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,20 @@ struct SortColumn: Equatable {
var direction: SortDirection
}

enum SortSource: Equatable {
case user
case defaultSort
}

/// Tracks sorting state for a table (supports multi-column sort)
struct SortState: Equatable {
var columns: [SortColumn] = []
var source: SortSource = .user

init() {}
init(columns: [SortColumn] = [], source: SortSource = .user) {
self.columns = columns
self.source = source
}

var isSorting: Bool { !columns.isEmpty }

Expand Down Expand Up @@ -232,6 +241,7 @@ struct TabExecutionState: Equatable {
var errorMessage: String?
var rowsAffected: Int = 0
var lastExecutedAt: Date?
var didEvaluateDefaultSort: Bool = false

static func == (lhs: TabExecutionState, rhs: TabExecutionState) -> Bool {
lhs.isExecuting == rhs.isExecuting
Expand Down
25 changes: 23 additions & 2 deletions TablePro/Models/Settings/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,22 @@ enum DateFormatOption: String, Codable, CaseIterable, Identifiable {
}
}

enum DefaultSortBehavior: String, Codable, CaseIterable, Identifiable, Equatable {
case none
case primaryKey
case firstColumn

var id: String { rawValue }

var displayName: String {
switch self {
case .none: return String(localized: "No sorting (engine order)")
case .primaryKey: return String(localized: "Primary key")
case .firstColumn: return String(localized: "First column")
}
}
}

/// Data grid settings
struct DataGridSettings: Codable, Equatable {
var rowHeight: DataGridRowHeight
Expand All @@ -139,6 +155,7 @@ struct DataGridSettings: Codable, Equatable {
var countRowsIfEstimateLessThan: Int
var queryResultRowCap: Int
var truncateQueryResults: Bool
var defaultSortBehavior: DefaultSortBehavior

static let `default` = DataGridSettings(
rowHeight: .normal,
Expand All @@ -151,7 +168,8 @@ struct DataGridSettings: Codable, Equatable {
enableSmartValueDetection: true,
countRowsIfEstimateLessThan: 100_000,
queryResultRowCap: 10_000,
truncateQueryResults: true
truncateQueryResults: true,
defaultSortBehavior: .none
)

init(
Expand All @@ -165,7 +183,8 @@ struct DataGridSettings: Codable, Equatable {
enableSmartValueDetection: Bool = true,
countRowsIfEstimateLessThan: Int = 100_000,
queryResultRowCap: Int = 10_000,
truncateQueryResults: Bool = true
truncateQueryResults: Bool = true,
defaultSortBehavior: DefaultSortBehavior = .none
) {
self.rowHeight = rowHeight
self.dateFormat = dateFormat
Expand All @@ -178,6 +197,7 @@ struct DataGridSettings: Codable, Equatable {
self.countRowsIfEstimateLessThan = countRowsIfEstimateLessThan
self.queryResultRowCap = queryResultRowCap
self.truncateQueryResults = truncateQueryResults
self.defaultSortBehavior = defaultSortBehavior
}

init(from decoder: Decoder) throws {
Expand All @@ -193,6 +213,7 @@ struct DataGridSettings: Codable, Equatable {
countRowsIfEstimateLessThan = try container.decodeIfPresent(Int.self, forKey: .countRowsIfEstimateLessThan) ?? 100_000
queryResultRowCap = try container.decodeIfPresent(Int.self, forKey: .queryResultRowCap) ?? 10_000
truncateQueryResults = try container.decodeIfPresent(Bool.self, forKey: .truncateQueryResults) ?? true
defaultSortBehavior = try container.decodeIfPresent(DefaultSortBehavior.self, forKey: .defaultSortBehavior) ?? .none
}

// MARK: - Validated Properties
Expand Down
18 changes: 18 additions & 0 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -6244,6 +6244,9 @@
}
}
}
},
"Applied when opening a table. Click a column header to override." : {

},
"Apply" : {
"localizations" : {
Expand Down Expand Up @@ -11302,6 +11305,9 @@
}
}
}
},
"Connection is read-only. Destructive operations are not permitted." : {

},
"Connection is read-only. Set safe mode to Confirm Writes or higher to allow this tool." : {

Expand Down Expand Up @@ -15000,6 +15006,9 @@
}
}
}
},
"Default row sort:" : {

},
"Default value" : {
"extractionState" : "stale",
Expand Down Expand Up @@ -21929,6 +21938,9 @@
}
}
}
},
"First column" : {

},
"Fit to Window" : {
"localizations" : {
Expand Down Expand Up @@ -31388,6 +31400,9 @@
}
}
}
},
"No sorting (engine order)" : {

},
"No SSL encryption" : {
"localizations" : {
Expand Down Expand Up @@ -35625,6 +35640,9 @@
}
}
}
},
"Primary key" : {

},
"Primary Key" : {
"localizations" : {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ extension MainContentCoordinator {
// If current tab has unsaved changes, active filters, or sorting, open in a new native tab
let hasActiveWork = changeManager.hasChanges
|| selectedTabFilterState.hasAppliedFilters
|| (tabManager.selectedTab?.sortState.isSorting ?? false)
|| (tabManager.selectedTab?.hasUserActiveSort ?? false)
if hasActiveWork {
let payload = EditorTabPayload(
connectionId: connection.id,
Expand Down Expand Up @@ -250,7 +250,7 @@ extension MainContentCoordinator {
if tab.isPreview { return true }
// Table tab with no active work
if tab.tabType == .table && !changeManager.hasChanges
&& !selectedTabFilterState.hasAppliedFilters && !tab.sortState.isSorting {
&& !selectedTabFilterState.hasAppliedFilters && !tab.hasUserActiveSort {
return true
}
// Empty/default query tab (no user content, no results, never executed)
Expand All @@ -271,7 +271,7 @@ extension MainContentCoordinator {
} ?? false
let previewHasWork = changeManager.hasChanges
|| selectedTabFilterState.hasAppliedFilters
|| selectedTab.sortState.isSorting
|| selectedTab.hasUserActiveSort
|| hasUnsavedQuery
if previewHasWork {
promotePreviewTab()
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Views/Settings/Sections/DataGridSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ struct DataGridSection: View {
Toggle("Show row numbers", isOn: $settings.showRowNumbers)
Toggle("Auto-show inspector on row select", isOn: $settings.autoShowInspector)
Toggle("Smart value detection", isOn: $settings.enableSmartValueDetection)

Picker("Default row sort:", selection: $settings.defaultSortBehavior) {
ForEach(DefaultSortBehavior.allCases) { behavior in
Text(behavior.displayName).tag(behavior)
}
}
.help(String(localized: "Applied when opening a table. Click a column header to override."))
}

Section("Pagination") {
Expand Down
Loading
Loading