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 .claude/rules/data-sync-storage.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ This path holds user data. A silent wrong answer here looks exactly like an empt
| Filter defaults and presets | UserDefaults | `FilterSettingsStorage`, `FilterPresetStorage` |
| Per-table filters | JSON, one file per table | `FilterSettingsStorage` |
| Generate Rows settings per table | UserDefaults, device-local | `RowGenerationSettingsStore` |
| Row inspector field heights, per column name | UserDefaults, device-local | `InspectorFieldHeightStore` |
| Favorite tables (iCloud-synced) | UserDefaults | `FavoriteTablesStorage` |
| Sidebar database and schema filters, recent tables | UserDefaults, device-local | `DatabaseTreeFilterStorage`, `RecentTablesStore` |
| History drawer state | UserDefaults, device-local | `HistoryPanelPreferencesStorage` |
Expand Down
2 changes: 1 addition & 1 deletion apple/TablePro/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillFinishLaunching(_ notification: Notification) {
LaunchTracer.shared.mark(.willFinishLaunchingBegan)
AppSettingsStorage.shared.migrateStartupBehaviorToReopenLastIfNeeded()
AppSettingsStorage.shared.migrateJsonFieldHeightKeyIfNeeded()
AppSettingsStorage.shared.removeRetiredFieldHeightKeys()
AIProviderRegistration.registerAll()

/// Installed before any window exists, so the bar is correct from the first frame.
Expand Down
1 change: 1 addition & 0 deletions apple/TablePro/Core/Storage/AppSettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ final class AppSettingsManager: ObservableObject {
mcp = .default
typography = .default
storage.resetToDefaults()
InspectorFieldHeightStore.shared.reload()
connectionListPreferences.resetShowsRecent()
SoftwareUpdater.shared.resetUpdatePreferences()
AppModuleActivation.resetToDefaults()
Expand Down
27 changes: 13 additions & 14 deletions apple/TablePro/Core/Storage/AppSettingsStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,14 @@ final class AppSettingsStorage: Sendable {
static let typography = "com.TablePro.settings.typography"
static let hasSeenWelcomeSheet = "com.TablePro.settings.hasCompletedOnboarding"
static let startupReopenMigration = "com.TablePro.settings.didMigrateStartupToReopenLast"
static let jsonFieldHeightMigration = "com.TablePro.settings.didMigrateJsonFieldHeightKey"
static let legacyJsonFieldHeight = "rightSidebar.jsonFieldHeight"
// One height per editor kind, before a height belonged to its column.
static let retiredFieldHeights = [
"rightSidebar.jsonFieldHeight",
"com.TablePro.rightSidebar.jsonFieldHeight",
"com.TablePro.rightSidebar.textFieldHeight",
"com.TablePro.rightSidebar.geometryFieldHeight",
"com.TablePro.settings.didMigrateJsonFieldHeightKey",
]
}

/// The storage environment's defaults, not `.standard`. A UI test runs against a per-sandbox
Expand Down Expand Up @@ -71,15 +77,10 @@ final class AppSettingsStorage: Sendable {
saveGeneral(general)
}

func migrateJsonFieldHeightKeyIfNeeded() {
guard !defaults.bool(forKey: Keys.jsonFieldHeightMigration) else { return }
defaults.set(true, forKey: Keys.jsonFieldHeightMigration)

let newKey = PreferenceKeys.rowInspectorJsonFieldHeight.name
guard defaults.object(forKey: Keys.legacyJsonFieldHeight) != nil,
defaults.object(forKey: newKey) == nil else { return }
defaults.set(defaults.double(forKey: Keys.legacyJsonFieldHeight), forKey: newKey)
defaults.removeObject(forKey: Keys.legacyJsonFieldHeight)
func removeRetiredFieldHeightKeys() {
for key in Keys.retiredFieldHeights where defaults.object(forKey: key) != nil {
defaults.removeObject(forKey: key)
}
}

// MARK: - Appearance Settings
Expand Down Expand Up @@ -310,9 +311,7 @@ final class AppSettingsStorage: Sendable {
saveMCP(.default)
saveTypography(.default)
defaults.removeObject(forKey: PreferenceKeys.selectedSettingsPane.name)
defaults.removeObject(forKey: PreferenceKeys.rowInspectorJsonFieldHeight.name)
defaults.removeObject(forKey: PreferenceKeys.rowInspectorTextFieldHeight.name)
defaults.removeObject(forKey: PreferenceKeys.rowInspectorGeometryFieldHeight.name)
defaults.removeObject(forKey: PreferenceKeys.rowInspectorFieldHeights.name)
defaults.removeObject(forKey: PreferenceKeys.rowInspectorGeometryFieldMode.name)
defaults.removeObject(forKey: SidebarPersistenceKey.defaultLayout)
RoutineDebugOptionStore(defaults: { [defaults] in defaults }).removeAll()
Expand Down
83 changes: 83 additions & 0 deletions apple/TablePro/Core/Storage/InspectorFieldHeightStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//
// InspectorFieldHeightStore.swift
// TablePro
//

import Combine
import Foundation
import os

// Device-local. Keyed by column name alone, so a size holds on every row and in every result showing that column.
@MainActor
internal final class InspectorFieldHeightStore: ObservableObject {
static let shared = InspectorFieldHeightStore()
static let capacity = 200

nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "InspectorFieldHeightStore")
private static let key = PreferenceKeys.rowInspectorFieldHeights.name

@Published private(set) var heights: [String: Double] = [:]
// Least recently set first, so the oldest is dropped past `capacity`.
private var order: [String] = []
private let store: KeyValueStore

init(defaults: KeyValueStore = AppStorageEnvironment.shared.defaults) {
store = defaults
reload()
}

func height(for column: String) -> Double? {
heights[column]
}

func setHeight(_ height: Double, for column: String) {
order.removeAll { $0 == column }
order.append(column)
var updated = heights
updated[column] = height
if order.count > Self.capacity {
for evicted in order.prefix(order.count - Self.capacity) {
updated[evicted] = nil
}
order.removeFirst(order.count - Self.capacity)
}
heights = updated
persist()
}

func reload() {
var loaded: [String: Double] = [:]
var loadedOrder: [String] = []
for entry in store.dataValue(forKey: Self.key).flatMap(Self.decode) ?? [] {
if loaded.updateValue(entry.height, forKey: entry.column) != nil {
loadedOrder.removeAll { $0 == entry.column }
}
loadedOrder.append(entry.column)
}
order = loadedOrder
heights = loaded
}

private func persist() {
let entries = order.compactMap { column in heights[column].map { Entry(column: column, height: $0) } }
do {
store.setDataValue(try JSONEncoder().encode(entries), forKey: Self.key)
} catch {
Self.logger.error("Could not save field heights: \(error.publicLogShape, privacy: .public)")
}
}

private static func decode(_ data: Data) -> [Entry]? {
do {
return try JSONDecoder().decode([Entry].self, from: data)
} catch {
logger.error("Discarding unreadable field heights: \(error.publicLogShape, privacy: .public)")
return nil
}
}

private struct Entry: Codable {
let column: String
let height: Double
}
}
10 changes: 3 additions & 7 deletions apple/TablePro/Core/Storage/Preferences/PreferenceKeys.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@ enum PreferenceKeys {
static let linkedFolders = DefaultsKey<[LinkedFolder]>("com.TablePro.linkedFolders")
static let linkedSQLFolders = DefaultsKey<[LinkedSQLFolder]>("com.TablePro.linkedSQLFolders")
static let selectedSettingsPane = DefaultsKey<String>("com.TablePro.settings.selectedPane")
static let rowInspectorJsonFieldHeight = DefaultsKey<Double>("com.TablePro.rightSidebar.jsonFieldHeight")
static let rowInspectorTextFieldHeight = DefaultsKey<Double>("com.TablePro.rightSidebar.textFieldHeight")
static let rowInspectorGeometryFieldHeight = DefaultsKey<Double>("com.TablePro.rightSidebar.geometryFieldHeight")
/// Text or Map, as the field's picker last left it. Device-local, like the heights beside it.
static let rowInspectorFieldHeights = DefaultsKey<Data>("com.TablePro.rightSidebar.fieldHeights")
// Text or Map, as the field's picker last left it. Device-local, like the heights beside it.
static let rowInspectorGeometryFieldMode = DefaultsKey<String>("com.TablePro.rightSidebar.geometryFieldMode")
static let workspaceRailOrder = DefaultsKey<[WorkspaceID]>("com.TablePro.workspaceRail.order")
static let queryPlanRawFontSize = DefaultsKey<Double>("com.TablePro.queryPlan.rawFontSize")
Expand All @@ -33,9 +31,7 @@ enum PreferenceKeys {
linkedFolders.name,
linkedSQLFolders.name,
selectedSettingsPane.name,
rowInspectorJsonFieldHeight.name,
rowInspectorTextFieldHeight.name,
rowInspectorGeometryFieldHeight.name,
rowInspectorFieldHeights.name,
rowInspectorGeometryFieldMode.name,
workspaceRailOrder.name,
queryPlanRawFontSize.name,
Expand Down
8 changes: 8 additions & 0 deletions apple/TablePro/Models/UI/MultiRowEditState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,8 @@ final class MultiRowEditState: ObservableObject {
@Published private(set) var expressionCells: [Set<Int>] = []
@Published private(set) var columns: [String] = []
@Published private(set) var columnTypes: [ColumnType] = []
// By column, not field id: every selection change reissues the ids. Cleared when the columns change.
@Published private(set) var expandedColumn: Int?
private var nonNullableColumns: Set<String> = []

// Nil and .none offer no Set Value command and promote nothing.
Expand Down Expand Up @@ -180,6 +182,7 @@ final class MultiRowEditState: ObservableObject {
self.columnTypes = columnTypes
self.nonNullableColumns = nonNullableColumns
self.databaseType = databaseType
if columnsChanged { expandedColumn = nil }
self.cellWriter = cellWriter
rowsAreInserted = !rowIDs.isEmpty && rowIDs.allSatisfy { $0.isInserted }

Expand Down Expand Up @@ -576,6 +579,10 @@ final class MultiRowEditState: ObservableObject {
}
}

func toggleExpansion(ofColumn index: Int) {
expandedColumn = expandedColumn == index ? nil : index
}

func clearEdits() {
for index in fields.indices {
resetPending(at: index)
Expand All @@ -598,6 +605,7 @@ final class MultiRowEditState: ObservableObject {
columns = []
columnTypes = []
nonNullableColumns = []
expandedColumn = nil
databaseType = nil
cellWriter = .none
rowsAreInserted = false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,16 @@ internal class ResizeCursorSplitViewController: NSSplitViewController {
}

private var resizeCursor: NSCursor {
.dividerResize(isVertical: splitView.isVertical)
}
}

internal extension NSCursor {
// `isVertical` as NSSplitView means it: a vertical divider resizes left and right.
static func dividerResize(isVertical: Bool) -> NSCursor {
if #available(macOS 15.0, *) {
return splitView.isVertical
? .columnResize(directions: [.left, .right])
: .rowResize(directions: [.up, .down])
return isVertical ? .columnResize(directions: [.left, .right]) : .rowResize(directions: [.up, .down])
}
return splitView.isVertical ? .resizeLeftRight : .resizeUpDown
return isVertical ? .resizeLeftRight : .resizeUpDown
}
}
138 changes: 138 additions & 0 deletions apple/TablePro/Views/RowInspector/FieldEditors/FieldResizeHandle.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
//
// FieldResizeHandle.swift
// TablePro
//

import AppKit
import SwiftUI

// AppKit on purpose: in the inspector's List, an NSTableView, a SwiftUI DragGesture lost the mouse to the table a few points into the drag.
internal struct FieldResizeHandle: NSViewRepresentable {
let onDrag: (Double) -> Void
let onEnd: (Double) -> Void

func makeNSView(context: Context) -> FieldResizeHandleView {
FieldResizeHandleView()
}

func updateNSView(_ view: FieldResizeHandleView, context: Context) {
view.onDrag = onDrag
view.onEnd = onEnd
view.isEnabled = context.environment.isEnabled
}
}

internal final class FieldResizeHandleView: NSView {
static let gripSize = NSSize(width: 26, height: 4)

var onDrag: ((Double) -> Void)?
var onEnd: ((Double) -> Void)?

// The inspector keeps its field list mounted, disabled, behind the JSON rendering.
var isEnabled = true {
didSet {
guard isEnabled != oldValue, !isEnabled else { return }
drag = nil
isHovered = false
}
}

private(set) var isHovered = false {
didSet { if isHovered != oldValue { needsDisplay = true } }
}

// Window coordinates, which stay put while the field grows and moves this view down.
private var drag: (startY: CGFloat, hasMoved: Bool)? {
didSet { if (drag == nil) != (oldValue == nil) { needsDisplay = true } }
}

var isHighlighted: Bool { isHovered || drag != nil }

override var mouseDownCanMoveWindow: Bool { false }

override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }

override func hitTest(_ point: NSPoint) -> NSView? {
isEnabled ? super.hitTest(point) : nil
}

override func updateTrackingAreas() {
super.updateTrackingAreas()
for area in trackingAreas where area.owner === self {
removeTrackingArea(area)
}
addTrackingArea(NSTrackingArea(
rect: .zero,
options: [.mouseEnteredAndExited, .cursorUpdate, .activeInKeyWindow, .inVisibleRect],
owner: self
))
}

override func cursorUpdate(with event: NSEvent) {
guard isEnabled else {
super.cursorUpdate(with: event)
return
}
NSCursor.dividerResize(isVertical: false).set()
}

override func mouseEntered(with event: NSEvent) {
isHovered = isEnabled
}

override func mouseExited(with event: NSEvent) {
isHovered = false
}

// A handle removed mid-drag, as Expand does, gets no mouseUp it could act on.
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
guard newWindow == nil else { return }
drag = nil
isHovered = false
}

override func mouseDown(with event: NSEvent) {
guard isEnabled else { return }
drag = (event.locationInWindow.y, false)
}

override func mouseDragged(with event: NSEvent) {
guard let delta = downwardDelta(to: event) else { return }
drag?.hasMoved = true
NSCursor.dividerResize(isVertical: false).set()
onDrag?(delta)
}

override func mouseUp(with event: NSEvent) {
guard let delta = downwardDelta(to: event), let drag else { return }
self.drag = nil
if drag.hasMoved { onEnd?(delta) }
}

override func draw(_ dirtyRect: NSRect) {
guard let context = NSGraphicsContext.current?.cgContext else { return }
let size = Self.gripSize
let grip = NSRect(
x: bounds.midX - size.width / 2,
y: bounds.midY - size.height / 2,
width: size.width,
height: size.height
)
context.saveGState()
context.setAlpha(isHighlighted ? 1 : 0.5)
NSColor.tertiaryLabelColor.setFill()
NSBezierPath(roundedRect: grip, xRadius: size.height / 2, yRadius: size.height / 2).fill()
context.restoreGState()
}

override func viewDidChangeEffectiveAppearance() {
super.viewDidChangeEffectiveAppearance()
needsDisplay = true
}

private func downwardDelta(to event: NSEvent) -> Double? {
guard let drag else { return nil }
return Double(drag.startY - event.locationInWindow.y)
}
}
Loading
Loading