Skip to content
Merged
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
- Cloudflare Tunnel: connect to a database behind Cloudflare Access by letting TablePro start and stop `cloudflared access tcp` for you, the same way it manages SSH tunnels. Configure it per connection with browser sign-in or a service token. Needs cloudflared installed (`brew install cloudflared`). (#1285)
- Fill Column: right-click a column header and choose Fill Column to set one value across all loaded rows. The change is staged like a normal edit, so you review it and Save before it applies, and one undo reverts the whole fill. Not available on primary key columns. (#1304)
- AWS IAM authentication for PostgreSQL and MySQL connections to RDS and Aurora. Pick AWS IAM in the connection's Authentication field and use an access key, a named AWS profile, or SSO. TablePro generates a fresh login token on every connect and reconnect, so you never paste an expiring token, and SSL is required automatically. (#1291)
- Date, datetime, timestamp, and time cells show a date picker from the chevron button, so you can choose a value visually. Double-clicking still edits the cell as text, and the picker keeps the value's existing format, fractional seconds, and timezone offset. (#1405)
- Pagination bar for table tabs with a rows-per-page menu (5, 10, 20, 100, 500, 1,000, All rows, or a custom size) and First, Previous, Next, and Last page buttons. (#1364)
- Click the page indicator in the pagination bar to jump to a specific page. (#1364)
- Pagination now appears for filtered tables whose total row count is unknown, so you can page through them instead of seeing only the first page. (#1364)
Expand Down
1 change: 1 addition & 0 deletions TablePro/Core/Database/DatabaseManager+Tunnel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import Foundation
import os
import TableProPluginKit

// MARK: - Shared Tunnel Helpers

Expand Down
40 changes: 40 additions & 0 deletions TablePro/Core/Services/CalendarMonth.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// CalendarMonth.swift
// TablePro
//
// Month grid layout for the data grid's date picker: leading blank cells,
// the days of the month, and weekday header symbols ordered by the calendar's
// first weekday.
//

import Foundation

struct CalendarMonth: Equatable {
let leadingBlanks: Int
let dayCount: Int
let days: [Date?]
let weekdaySymbols: [String]

init?(containing date: Date, calendar: Calendar) {
guard let monthInterval = calendar.dateInterval(of: .month, for: date),
let dayCount = calendar.range(of: .day, in: .month, for: date)?.count else {
return nil
}

let firstWeekday = calendar.component(.weekday, from: monthInterval.start)
let leadingBlanks = (firstWeekday - calendar.firstWeekday + 7) % 7

var days: [Date?] = Array(repeating: nil, count: leadingBlanks)
for offset in 0..<dayCount {
days.append(calendar.date(byAdding: .day, value: offset, to: monthInterval.start))
}

let symbols = calendar.veryShortWeekdaySymbols
let symbolOffset = calendar.firstWeekday - 1

self.leadingBlanks = leadingBlanks
self.dayCount = dayCount
self.days = days
self.weekdaySymbols = Array(symbols[symbolOffset...] + symbols[..<symbolOffset])
}
}
9 changes: 5 additions & 4 deletions TablePro/Core/Services/ColumnType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,11 @@ enum ColumnType: Equatable {

var isTimeOnly: Bool {
guard isDateType, let raw = rawType?.uppercased() else { return false }
return raw == "TIME"
|| raw == "TIMETZ"
|| raw == "TIME WITHOUT TIME ZONE"
|| raw == "TIME WITH TIME ZONE"
let base = raw.prefix { $0 != "(" }.trimmingCharacters(in: .whitespaces)
return base == "TIME"
|| base == "TIMETZ"
|| base == "TIME WITHOUT TIME ZONE"
|| base == "TIME WITH TIME ZONE"
}

/// Whether this type represents long text that should use multi-line editor
Expand Down
151 changes: 151 additions & 0 deletions TablePro/Core/Services/Formatting/DateEditingService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
//
// DateEditingService.swift
// TablePro
//
// Parses a database date/time string for editing and writes the edited value
// back in the same shape. Distinct from DateFormattingService, which formats
// for display using the user's locale and format preference.
//

import Foundation

struct TemporalLayout: Equatable {
let hasDate: Bool
let hasTime: Bool
let dateTimeSeparator: String
let fractionalSeconds: String?
let timeZoneSuffix: String?
}

struct ParsedTemporalValue: Equatable {
let date: Date
let timeZone: TimeZone
let layout: TemporalLayout
}

enum TemporalComponents: Equatable {
case dateOnly
case timeOnly
case dateAndTime
}

enum DateEditingService {
private static let pattern =
#"^(?:(\d{4})-(\d{2})-(\d{2}))?(?:([ T])?(\d{2}):(\d{2}):(\d{2})(\.\d+)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$"#

private static let matcher = try? NSRegularExpression(pattern: pattern)

private static let referenceDateComponents = (year: 2_000, month: 1, day: 1)

static func parse(_ rawValue: String?) -> ParsedTemporalValue? {
guard let matcher, let raw = rawValue?.trimmingCharacters(in: .whitespaces), !raw.isEmpty else {
return nil
}
let range = NSRange(raw.startIndex..., in: raw)
guard let match = matcher.firstMatch(in: raw, range: range) else { return nil }

func group(_ index: Int) -> String? {
let groupRange = match.range(at: index)
guard groupRange.location != NSNotFound, let swiftRange = Range(groupRange, in: raw) else {
return nil
}
return String(raw[swiftRange])
}

let year = group(1).flatMap(Int.init)
let month = group(2).flatMap(Int.init)
let day = group(3).flatMap(Int.init)
let hour = group(5).flatMap(Int.init)
let minute = group(6).flatMap(Int.init)
let second = group(7).flatMap(Int.init)

let hasDate = year != nil && month != nil && day != nil
let hasTime = hour != nil && minute != nil && second != nil
guard hasDate || hasTime else { return nil }

let timeZoneSuffix = group(9)
let timeZone = timeZoneSuffix.map(timeZone(fromSuffix:)) ?? .gmt

var components = DateComponents()
components.year = hasDate ? year : referenceDateComponents.year
components.month = hasDate ? month : referenceDateComponents.month
components.day = hasDate ? day : referenceDateComponents.day
components.hour = hasTime ? hour : 0
components.minute = hasTime ? minute : 0
components.second = hasTime ? second : 0

var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = timeZone
guard let date = calendar.date(from: components) else { return nil }

let separator = group(4) ?? (hasDate && hasTime ? " " : "")
let layout = TemporalLayout(
hasDate: hasDate,
hasTime: hasTime,
dateTimeSeparator: separator,
fractionalSeconds: group(8),
timeZoneSuffix: timeZoneSuffix
)
return ParsedTemporalValue(date: date, timeZone: timeZone, layout: layout)
}

static func string(from date: Date, like parsed: ParsedTemporalValue) -> String {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = parsed.timeZone
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)
let layout = parsed.layout

let datePart = dateString(from: components)
let timePart = timeString(from: components) + (layout.fractionalSeconds ?? "")

var result: String
if layout.hasDate && layout.hasTime {
result = datePart + layout.dateTimeSeparator + timePart
} else if layout.hasDate {
result = datePart
} else {
result = timePart
}
if let suffix = layout.timeZoneSuffix {
result += suffix
}
return result
}

static func defaultString(from date: Date, columnType: ColumnType) -> String {
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = .gmt
let components = calendar.dateComponents([.year, .month, .day, .hour, .minute, .second], from: date)

if case .date = columnType {
return dateString(from: components)
}
if columnType.isTimeOnly {
return timeString(from: components)
}
return dateString(from: components) + " " + timeString(from: components)
}

static func components(for columnType: ColumnType) -> TemporalComponents {
if case .date = columnType { return .dateOnly }
if columnType.isTimeOnly { return .timeOnly }
return .dateAndTime
}

private static func dateString(from components: DateComponents) -> String {
String(format: "%04d-%02d-%02d", components.year ?? 0, components.month ?? 0, components.day ?? 0)
}

private static func timeString(from components: DateComponents) -> String {
String(format: "%02d:%02d:%02d", components.hour ?? 0, components.minute ?? 0, components.second ?? 0)
}

private static func timeZone(fromSuffix suffix: String) -> TimeZone {
if suffix == "Z" { return .gmt }
let sign = suffix.hasPrefix("-") ? -1 : 1
let digits = suffix.dropFirst().filter(\.isNumber)
let hours = Int(digits.prefix(2)) ?? 0
let minutes = digits.count >= 4 ? (Int(digits.suffix(2)) ?? 0) : 0
return TimeZone(secondsFromGMT: sign * (hours * 3_600 + minutes * 60)) ?? .gmt
}
}
10 changes: 10 additions & 0 deletions TablePro/Views/Results/Cells/DataGridCellKind.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,14 @@ enum DataGridCellKind: Equatable {
case boolean
case json
case blob
case date

var showsChevron: Bool {
switch self {
case .dropdown, .boolean, .json, .blob, .date:
return true
case .text, .foreignKey:
return false
}
}
}
1 change: 1 addition & 0 deletions TablePro/Views/Results/Cells/DataGridCellRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ final class DataGridCellRegistry {
if type.isBooleanType { return .boolean }
if type.isJsonType { return .json }
if type.isBlobType { return .blob }
if type.isDateType { return .date }
}
return .text
}
Expand Down
55 changes: 23 additions & 32 deletions TablePro/Views/Results/Cells/DataGridCellView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,42 +283,37 @@ final class DataGridCellView: NSView {
}

private func computeAccessoryRect() -> NSRect {
switch kind {
case .text:
return .zero
case .foreignKey:
if kind == .foreignKey {
guard let raw = rawValue, !raw.isEmpty else { return .zero }
let size = NSSize(width: 16, height: 16)
let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width
let y = (bounds.height - size.height) / 2
return NSRect(x: x, y: y, width: size.width, height: size.height)
case .dropdown, .boolean, .json, .blob:
guard isEditableCell else { return .zero }
let size = NSSize(width: 12, height: 14)
let minRequired = size.width + 2 * DataGridMetrics.cellHorizontalInset
guard bounds.width >= minRequired else { return .zero }
let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width
let y = (bounds.height - size.height) / 2
return NSRect(x: x, y: y, width: size.width, height: size.height)
}
guard kind.showsChevron, isEditableCell else { return .zero }
let size = NSSize(width: 12, height: 14)
let minRequired = size.width + 2 * DataGridMetrics.cellHorizontalInset
guard bounds.width >= minRequired else { return .zero }
let x = bounds.maxX - DataGridMetrics.cellHorizontalInset - size.width
let y = (bounds.height - size.height) / 2
return NSRect(x: x, y: y, width: size.width, height: size.height)
}

private func drawAccessory(in rect: NSRect) {
guard !rect.isEmpty else { return }
let image: CGImage?
switch kind {
case .text:
return
case .foreignKey:
if kind == .foreignKey {
image = onEmphasizedSelection ? Self.fkArrowEmphasized : Self.fkArrowNormal
case .dropdown, .boolean, .json, .blob:
} else if kind.showsChevron {
if visualState.isDeleted {
image = Self.chevronDisabled
} else if onEmphasizedSelection {
image = Self.chevronEmphasized
} else {
image = Self.chevronNormal
}
} else {
return
}
guard let cgImage = image, let context = NSGraphicsContext.current?.cgContext else { return }
context.saveGState()
Expand All @@ -337,21 +332,17 @@ final class DataGridCellView: NSView {

override func mouseDown(with event: NSEvent) {
let point = convert(event.locationInWindow, from: nil)
if !accessoryHitRect.isEmpty && accessoryHitRect.contains(point) {
switch kind {
case .foreignKey:
accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex)
return
case .dropdown, .boolean, .json, .blob:
guard !visualState.isDeleted else {
super.mouseDown(with: event)
return
}
accessoryDelegate?.dataGridCellDidClickChevron(row: cellRow, columnIndex: cellColumnIndex)
return
case .text:
break
}
guard !accessoryHitRect.isEmpty, accessoryHitRect.contains(point) else {
super.mouseDown(with: event)
return
}
if kind == .foreignKey {
accessoryDelegate?.dataGridCellDidClickFKArrow(row: cellRow, columnIndex: cellColumnIndex)
return
}
if kind.showsChevron, !visualState.isDeleted {
accessoryDelegate?.dataGridCellDidClickChevron(row: cellRow, columnIndex: cellColumnIndex)
return
}
super.mouseDown(with: event)
}
Expand Down
Loading
Loading