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 @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Oracle Database 11g (11.1 and 11.2) now connects. Previously only 12c and later worked, so 11g servers failed with a "Server Version Not Supported" error. (#1425)
- Oracle connections can now use a SID instead of a service name. Set Connection Type to SID in the connection form and enter the SID. (#1425)
- Cmd-click a foreign key arrow to open the referenced table in a new tab instead of the current one. The right-click menu has the same Open in New Tab option. (#1421)
- Favorite a connection from the welcome screen. Hover a row or right-click to star it, and starred connections appear in a Favorites section at the top of the list. They keep their place in their group below too, so you can still find them where you put them. (#1302)
- Cells holding JSON or PHP serialized values in text columns now open in the structured viewer automatically, without requiring the column type to be JSON.
- Add and remove buttons in the table structure editor for columns, indexes, and foreign keys, on the bottom status bar alongside the view-mode picker. Cmd+Shift+N adds and Cmd+Delete removes. An empty Indexes or Foreign Keys tab also shows a labelled add button. (#1319)

Expand Down
31 changes: 31 additions & 0 deletions TablePro/Core/Storage/ConnectionStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,28 @@ final class ConnectionStorage {
}
}

/// Update multiple connections in a single file write, marking each dirty for sync.
@discardableResult
func updateConnections(_ updates: [DatabaseConnection]) -> Bool {
guard !updates.isEmpty else { return true }
var connections = loadConnections()
let updatesById = Dictionary(updates.map { ($0.id, $0) }, uniquingKeysWith: { _, last in last })
var didMutate = false
for index in connections.indices {
if let replacement = updatesById[connections[index].id] {
connections[index] = replacement
didMutate = true
}
}
guard didMutate, saveConnections(connections) else {
return false
}
for connection in updatesById.values where !connection.localOnly && !connection.isSample {
syncTracker.markDirty(.connection, id: connection.id.uuidString)
}
return true
}

/// Delete a connection
func deleteConnection(_ connection: DatabaseConnection) {
var connections = loadConnections()
Expand Down Expand Up @@ -551,6 +573,8 @@ private struct StoredConnection: Codable {

let isSample: Bool

let isFavorite: Bool

// TOTP configuration
let totpMode: String
let totpAlgorithm: String
Expand Down Expand Up @@ -638,6 +662,9 @@ private struct StoredConnection: Codable {
// Sample marker
self.isSample = connection.isSample

// Favorite flag
self.isFavorite = connection.isFavorite

// SSH tunnel mode (v2 format preserving jump hosts, profiles, etc.)
self.sshTunnelModeJson = try? JSONEncoder().encode(connection.sshTunnelMode)

Expand Down Expand Up @@ -669,6 +696,7 @@ private struct StoredConnection: Codable {
case additionalFields
case localOnly
case isSample
case isFavorite
}

func encode(to encoder: Encoder) throws {
Expand Down Expand Up @@ -711,6 +739,7 @@ private struct StoredConnection: Codable {
try container.encodeIfPresent(additionalFields, forKey: .additionalFields)
try container.encode(localOnly, forKey: .localOnly)
try container.encode(isSample, forKey: .isSample)
try container.encode(isFavorite, forKey: .isFavorite)
}

// Custom decoder to handle migration from old format
Expand Down Expand Up @@ -779,6 +808,7 @@ private struct StoredConnection: Codable {
additionalFields = try container.decodeIfPresent([String: String].self, forKey: .additionalFields)
localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false
isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false
}

func toConnection() -> DatabaseConnection {
Expand Down Expand Up @@ -882,6 +912,7 @@ private struct StoredConnection: Codable {
sortOrder: sortOrder,
localOnly: localOnly,
isSample: isSample,
isFavorite: isFavorite,
additionalFields: mergedFields
)
}
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/Sync/SyncRecordMapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ struct SyncRecordMapper {
record["modifiedAtLocal"] = Date() as CKRecordValue
record["schemaVersion"] = schemaVersion as CKRecordValue
record["sortOrder"] = Int64(connection.sortOrder) as CKRecordValue
record["isFavorite"] = Int64(connection.isFavorite ? 1 : 0) as CKRecordValue

if let tagId = connection.tagId {
record["tagId"] = tagId.uuidString as CKRecordValue
Expand Down Expand Up @@ -163,6 +164,7 @@ struct SyncRecordMapper {
let redisDatabase = (record["redisDatabase"] as? Int64).map { Int($0) }
let startupCommands = record["startupCommands"] as? String
let sortOrder = (record["sortOrder"] as? Int64).map { Int($0) } ?? 0
let isFavorite = (record["isFavorite"] as? Int64 ?? 0) != 0
let sshProfileId = (record["sshProfileId"] as? String).flatMap { UUID(uuidString: $0) }

var sshConfig = SSHConfiguration()
Expand Down Expand Up @@ -216,6 +218,7 @@ struct SyncRecordMapper {
redisDatabase: redisDatabase,
startupCommands: startupCommands,
sortOrder: sortOrder,
isFavorite: isFavorite,
additionalFields: additionalFields
)
}
Expand Down
7 changes: 6 additions & 1 deletion TablePro/Models/Connection/DatabaseConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,7 @@ struct DatabaseConnection: Identifiable, Hashable {
var sortOrder: Int
var localOnly: Bool = false
var isSample: Bool = false
var isFavorite: Bool = false

var mongoAuthSource: String? {
get { additionalFields["mongoAuthSource"]?.nilIfEmpty }
Expand Down Expand Up @@ -428,6 +429,7 @@ struct DatabaseConnection: Identifiable, Hashable {
sortOrder: Int = 0,
localOnly: Bool = false,
isSample: Bool = false,
isFavorite: Bool = false,
additionalFields: [String: String]? = nil
) {
self.id = id
Expand Down Expand Up @@ -469,6 +471,7 @@ struct DatabaseConnection: Identifiable, Hashable {
self.sortOrder = sortOrder
self.localOnly = localOnly
self.isSample = isSample
self.isFavorite = isFavorite
if let additionalFields {
self.additionalFields = additionalFields
} else {
Expand Down Expand Up @@ -516,7 +519,7 @@ extension DatabaseConnection: Codable {
case id, name, host, port, database, username, type
case sshConfig, sslConfig, color, tagId, groupId, sshProfileId
case sshTunnelMode, cloudflareTunnelMode, safeModeLevel, aiPolicy, aiRules, aiAlwaysAllowedTools, externalAccess, additionalFields
case redisDatabase, startupCommands, sortOrder, localOnly, isSample
case redisDatabase, startupCommands, sortOrder, localOnly, isSample, isFavorite
}

init(from decoder: Decoder) throws {
Expand Down Expand Up @@ -545,6 +548,7 @@ extension DatabaseConnection: Codable {
sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0
localOnly = try container.decodeIfPresent(Bool.self, forKey: .localOnly) ?? false
isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false
isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false
cloudflareTunnelMode = try container.decodeIfPresent(CloudflareTunnelMode.self, forKey: .cloudflareTunnelMode) ?? .disabled

// Migrate from legacy fields if sshTunnelMode is not present
Expand Down Expand Up @@ -595,6 +599,7 @@ extension DatabaseConnection: Codable {
try container.encode(sortOrder, forKey: .sortOrder)
try container.encode(localOnly, forKey: .localOnly)
try container.encode(isSample, forKey: .isSample)
try container.encode(isFavorite, forKey: .isFavorite)
}
}

Expand Down
15 changes: 12 additions & 3 deletions TablePro/Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -4736,6 +4736,9 @@
}
}
}
},
"Add to Favorites" : {

},
"Add validation rules to ensure data integrity" : {
"localizations" : {
Expand Down Expand Up @@ -15164,6 +15167,9 @@
},
"Default row sort:" : {

},
"Default token" : {

},
"Default value" : {
"extractionState" : "stale",
Expand Down Expand Up @@ -21360,6 +21366,9 @@
}
}
}
},
"Favorited" : {

},
"Favorites" : {
"localizations" : {
Expand Down Expand Up @@ -38461,6 +38470,9 @@
},
"Remove Foreign Key" : {

},
"Remove from Favorites" : {

},
"Remove from Group" : {
"localizations" : {
Expand Down Expand Up @@ -40215,9 +40227,6 @@
}
}
}
},
"Sample" : {

},
"Sanitize formula-like values" : {
"extractionState" : "stale",
Expand Down
37 changes: 34 additions & 3 deletions TablePro/ViewModels/WelcomeViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,16 +97,25 @@ final class WelcomeViewModel {
// MARK: - Computed Properties

private(set) var treeItems: [ConnectionGroupTreeNode] = []
private(set) var favoriteConnections: [DatabaseConnection] = []
private(set) var connectionCountByGroup: [UUID: Int] = [:]
private(set) var depthByGroup: [UUID: Int] = [:]
private(set) var maxDescendantDepthByGroup: [UUID: Int] = [:]

func rebuildTree() {
favoriteConnections = connections
.filter(\.isFavorite)
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }

let tree = buildGroupTree(groups: groups, connections: connections, parentId: nil)
if searchText.isEmpty {
treeItems = tree
let baseItems = searchText.isEmpty ? tree : filterGroupTree(tree, searchText: searchText)
if searchText.isEmpty, !favoriteConnections.isEmpty {
treeItems = baseItems.filter { node in
if case .connection(let conn) = node, conn.isFavorite { return false }
return true
}
} else {
treeItems = filterGroupTree(tree, searchText: searchText)
treeItems = baseItems
}

var counts: [UUID: Int] = [:]
Expand Down Expand Up @@ -326,6 +335,28 @@ final class WelcomeViewModel {
WindowOpener.shared.openConnectionForm(editing: duplicate.id)
}

// MARK: - Favorites

func toggleFavorite(_ targets: [DatabaseConnection]) {
guard !targets.isEmpty else { return }
let ids = Set(targets.map(\.id))
let live = connections.filter { ids.contains($0.id) }
guard !live.isEmpty else { return }
let shouldFavorite = !live.allSatisfy(\.isFavorite)
var updated: [DatabaseConnection] = []
for index in connections.indices where ids.contains(connections[index].id) {
connections[index].isFavorite = shouldFavorite
updated.append(connections[index])
}
guard storage.updateConnections(updated) else {
connections = storage.loadConnections()
rebuildTree()
return
}
rebuildTree()
AppEvents.shared.connectionUpdated.send(targets.count == 1 ? targets.first?.id : nil)
}

// MARK: - Delete

func deleteSelectedConnections() {
Expand Down
45 changes: 42 additions & 3 deletions TablePro/Views/Connection/WelcomeConnectionRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import SwiftUI
struct WelcomeConnectionRow: View {
let connection: DatabaseConnection
let sshProfile: SSHProfile?
let isSelected: Bool
let onToggleFavorite: () -> Void
@State private var isHovering = false
private let pluginManager = PluginManager.shared

private var displayTag: ConnectionTag? {
Expand All @@ -26,6 +29,12 @@ struct WelcomeConnectionRow: View {
}
}

private var toggleFavoriteActionName: String {
connection.isFavorite
? String(localized: "Remove from Favorites")
: String(localized: "Add to Favorites")
}

var body: some View {
HStack {
connection.type.iconImage
Expand All @@ -52,7 +61,11 @@ struct WelcomeConnectionRow: View {
trailingAccessories
}
.contentShape(Rectangle())
.onHover { hovering in isHovering = hovering }
.accessibilityElement(children: .combine)
.accessibilityAction(named: Text(toggleFavoriteActionName)) {
onToggleFavorite()
}
}

@ViewBuilder
Expand Down Expand Up @@ -87,6 +100,35 @@ struct WelcomeConnectionRow: View {
.accessibilityElement(children: .combine)
.accessibilityLabel(String(format: String(localized: "Tag: %@"), tag.name))
}

favoriteButton
}
}

private var favoriteButton: some View {
let visible = connection.isFavorite || isHovering || isSelected
return Button(action: onToggleFavorite) {
favoriteStarImage
}
.buttonStyle(.borderless)
.opacity(visible ? 1 : 0)
.allowsHitTesting(visible)
.help(toggleFavoriteActionName)
.accessibilityHidden(!connection.isFavorite)
.accessibilityLabel(String(localized: "Favorited"))
.frame(width: 16, alignment: .center)
}

@ViewBuilder
private var favoriteStarImage: some View {
if connection.isFavorite {
Image(systemName: "star.fill")
.imageScale(.small)
.foregroundStyle(.yellow)
} else {
Image(systemName: "star")
.imageScale(.small)
.foregroundStyle(.tertiary)
}
}

Expand All @@ -95,9 +137,6 @@ struct WelcomeConnectionRow: View {
if let viaText = sshViaText {
components.append(viaText)
}
if connection.isSample {
components.append(String(localized: "Sample"))
}
return components.joined(separator: " · ")
}

Expand Down
23 changes: 23 additions & 0 deletions TablePro/Views/Connection/WelcomeContextMenus.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,18 @@ extension WelcomeWindowView {

Divider()

let allFavorited = connections.allSatisfy(\.isFavorite)
Button { vm.toggleFavorite(connections) } label: {
Label(
allFavorited
? String(localized: "Remove from Favorites")
: String(localized: "Add to Favorites"),
systemImage: allFavorited ? "star.slash" : "star"
)
}

Divider()

Menu(String(localized: "Share")) {
Button {
vm.exportConnections(connections)
Expand Down Expand Up @@ -109,6 +121,17 @@ extension WelcomeWindowView {

Divider()

Button { vm.toggleFavorite([connection]) } label: {
Label(
connection.isFavorite
? String(localized: "Remove from Favorites")
: String(localized: "Add to Favorites"),
systemImage: connection.isFavorite ? "star.slash" : "star"
)
}

Divider()

Menu(String(localized: "Share")) {
Button {
let pw = ConnectionStorage.shared.loadPassword(for: connection.id)
Expand Down
Loading
Loading