From ebce43d40487bcfea72c71694504fd7371e858dd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 25 Mar 2026 00:04:07 +0700 Subject: [PATCH 1/4] feat: multi-select, bulk actions and reorder for welcome window connection list --- CHANGELOG.md | 2 +- TablePro.xcodeproj/project.pbxproj | 60 +-- TablePro/Core/Storage/ConnectionStorage.swift | 19 + TablePro/Resources/Localizable.xcstrings | 22 + .../Views/Connection/WelcomeWindowView.swift | 419 ++++++++++++------ docs/features/keyboard-shortcuts.mdx | 1 + 6 files changed, 347 insertions(+), 176 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4876bd5d..1904945a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Multi-select connections in Welcome window (Cmd+Click, Shift+Click) with bulk delete (⌘⌫), Move to Group, and multi-connect -- Drag-and-drop connections between groups, reorder within groups, and reorder groups +- Reorder connections within groups and reorder groups in Welcome window - ClickHouse, MSSQL, Redis, XLSX Export, MQL Export, and SQL Import now ship as built-in plugins - Large document safety caps for syntax highlighting (skip >5MB, throttle >50KB) - Lazy-load full values for LONGTEXT/MEDIUMTEXT/CLOB columns in the detail pane sidebar diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 24bb2624..4863a4ef 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -12,11 +12,14 @@ 5A862000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A862000D00000000 /* SQLiteDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A862000100000000 /* SQLiteDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A863000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A863000100000000 /* ClickHouseDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A864000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A864000D00000000 /* MSSQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A864000100000000 /* MSSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A865000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A865000D00000000 /* MySQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A865000100000000 /* MySQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A866000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A867000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A867000100000000 /* RedisDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A868000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A868000D00000000 /* PostgreSQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A868000100000000 /* PostgreSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A869000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; @@ -26,15 +29,12 @@ 5A86B000D00000000 /* JSONExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86B000100000000 /* JSONExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86C000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5A86C000D00000000 /* SQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86C000100000000 /* SQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A863000D00000000 /* ClickHouseDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A863000100000000 /* ClickHouseDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A864000D00000000 /* MSSQLDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A864000100000000 /* MSSQLDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A867000D00000000 /* RedisDriver.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A867000100000000 /* RedisDriver.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86D000100000000 /* XLSXExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86E000100000000 /* MQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; - 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86F000100000000 /* SQLImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86D000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86D000D00000000 /* XLSXExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86D000100000000 /* XLSXExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86E000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86E000D00000000 /* MQLExport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86E000100000000 /* MQLExport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A86F000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; + 5A86F000D00000000 /* SQLImport.tableplugin in Copy Plug-Ins */ = {isa = PBXBuildFile; fileRef = 5A86F000100000000 /* SQLImport.tableplugin */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 5A87A000A00000000 /* TableProPluginKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5A860000100000000 /* TableProPluginKit.framework */; }; 5ACE00012F4F000000000004 /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000002 /* CodeEditSourceEditor */; }; 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; @@ -90,6 +90,13 @@ remoteGlobalIDString = 5A863000000000000; remoteInfo = ClickHouseDriver; }; + 5A864000B00000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A864000000000000; + remoteInfo = MSSQLDriver; + }; 5A865000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -97,6 +104,13 @@ remoteGlobalIDString = 5A865000000000000; remoteInfo = MySQLDriver; }; + 5A867000B00000000 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; + proxyType = 1; + remoteGlobalIDString = 5A867000000000000; + remoteInfo = RedisDriver; + }; 5A868000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -132,20 +146,6 @@ remoteGlobalIDString = 5A86C000000000000; remoteInfo = SQLExport; }; - 5A864000B00000000 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5A864000000000000; - remoteInfo = MSSQLDriver; - }; - 5A867000B00000000 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; - proxyType = 1; - remoteGlobalIDString = 5A867000000000000; - remoteInfo = RedisDriver; - }; 5A86D000B00000000 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 5A1091BF2EF17EDC0055EA7C /* Project object */; @@ -1793,11 +1793,21 @@ target = 5A863000000000000 /* ClickHouseDriver */; targetProxy = 5A863000B00000000 /* PBXContainerItemProxy */; }; + 5A864000C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A864000000000000 /* MSSQLDriver */; + targetProxy = 5A864000B00000000 /* PBXContainerItemProxy */; + }; 5A865000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A865000000000000 /* MySQLDriver */; targetProxy = 5A865000B00000000 /* PBXContainerItemProxy */; }; + 5A867000C00000000 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 5A867000000000000 /* RedisDriver */; + targetProxy = 5A867000B00000000 /* PBXContainerItemProxy */; + }; 5A868000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A868000000000000 /* PostgreSQLDriver */; @@ -1823,16 +1833,6 @@ target = 5A86C000000000000 /* SQLExport */; targetProxy = 5A86C000B00000000 /* PBXContainerItemProxy */; }; - 5A864000C00000000 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5A864000000000000 /* MSSQLDriver */; - targetProxy = 5A864000B00000000 /* PBXContainerItemProxy */; - }; - 5A867000C00000000 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = 5A867000000000000 /* RedisDriver */; - targetProxy = 5A867000B00000000 /* PBXContainerItemProxy */; - }; 5A86D000C00000000 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 5A86D000000000000 /* XLSXExport */; diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 0539af32..546689d9 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -107,6 +107,25 @@ final class ConnectionStorage { deleteAllPluginSecureFields(for: connection.id, fieldIds: secureFieldIds) } + /// Batch-delete multiple connections and clean up their Keychain entries + func deleteConnections(_ connectionsToDelete: [DatabaseConnection]) { + for conn in connectionsToDelete { + SyncChangeTracker.shared.markDeleted(.connection, id: conn.id.uuidString) + } + let idsToDelete = Set(connectionsToDelete.map(\.id)) + var all = loadConnections() + all.removeAll { idsToDelete.contains($0.id) } + saveConnections(all) + for conn in connectionsToDelete { + deletePassword(for: conn.id) + deleteSSHPassword(for: conn.id) + deleteKeyPassphrase(for: conn.id) + deleteTOTPSecret(for: conn.id) + let fields = Self.secureFieldIds(for: conn.type) + deleteAllPluginSecureFields(for: conn.id, fieldIds: fields) + } + } + /// Duplicate a connection with a new UUID and "(Copy)" suffix /// Copies all passwords from source connection to the duplicate func duplicateConnection(_ connection: DatabaseConnection) -> DatabaseConnection { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 3d02370c..f5ce0350 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -179,6 +179,7 @@ } }, "(%@)" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -3919,6 +3920,9 @@ } } } + }, + "Are you sure you want to delete %lld connections? This cannot be undone." : { + }, "Are you sure you want to delete this connection? This cannot be undone." : { "localizations" : { @@ -6524,6 +6528,9 @@ } } } + }, + "Connect %lld Connections" : { + }, "Connect Anyway" : { "localizations" : { @@ -9298,6 +9305,9 @@ } } } + }, + "Delete %lld Connections" : { + }, "Delete Check Constraint" : { "extractionState" : "stale", @@ -12270,6 +12280,9 @@ } } } + }, + "Failed to load full value" : { + }, "Failed to load plugin registry" : { "extractionState" : "stale", @@ -17573,6 +17586,9 @@ } } } + }, + "Move to Group" : { + }, "Move Up" : { "extractionState" : "stale", @@ -18113,6 +18129,9 @@ } } } + }, + "New Group..." : { + }, "New Jump Host" : { "localizations" : { @@ -23568,6 +23587,9 @@ } } } + }, + "Remove from Group" : { + }, "Remove license from this machine" : { "localizations" : { diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 2c58e52d..719a66c7 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -29,10 +29,10 @@ struct WelcomeWindowView: View { @State private var showNewConnectionSheet = false @State private var showEditConnectionSheet = false @State private var connectionToEdit: DatabaseConnection? - @State private var connectionToDelete: DatabaseConnection? + @State private var connectionsToDelete: [DatabaseConnection] = [] @State private var showDeleteConfirmation = false @State private var hoveredConnectionId: UUID? - @State private var selectedConnectionId: UUID? + @State private var selectedConnectionIds: Set = [] @FocusState private var focus: FocusField? @State private var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding() @State private var groups: [ConnectionGroup] = [] @@ -41,6 +41,7 @@ struct WelcomeWindowView: View { return Set(strings.compactMap { UUID(uuidString: $0) }) }() @State private var showNewGroupSheet = false + @State private var pendingMoveToNewGroup: [DatabaseConnection] = [] @State private var showActivationSheet = false @State private var pluginInstallConnection: DatabaseConnection? @@ -88,6 +89,14 @@ struct WelcomeWindowView: View { return result } + private var selectedConnections: [DatabaseConnection] { + connections.filter { selectedConnectionIds.contains($0.id) } + } + + private var isMultipleSelection: Bool { + selectedConnectionIds.count > 1 + } + var body: some View { ZStack { if showOnboarding { @@ -109,16 +118,23 @@ struct WelcomeWindowView: View { loadConnections() } .confirmationDialog( - "Delete Connection", - isPresented: $showDeleteConfirmation, - presenting: connectionToDelete - ) { connection in - Button("Delete", role: .destructive) { - deleteConnection(connection) + connectionsToDelete.count == 1 + ? String(localized: "Delete Connection") + : String(localized: "Delete \(connectionsToDelete.count) Connections"), + isPresented: $showDeleteConfirmation + ) { + Button(String(localized: "Delete"), role: .destructive) { + deleteSelectedConnections() + } + Button(String(localized: "Cancel"), role: .cancel) { + connectionsToDelete = [] + } + } message: { + if connectionsToDelete.count == 1, let first = connectionsToDelete.first { + Text("Are you sure you want to delete \"\(first.name)\"?") + } else { + Text("Are you sure you want to delete \(connectionsToDelete.count) connections? This cannot be undone.") } - Button("Cancel", role: .cancel) {} - } message: { connection in - Text("Are you sure you want to delete \"\(connection.name)\"?") } .onReceive(NotificationCenter.default.publisher(for: .newConnection)) { _ in openWindow(id: "connection-form", value: nil as UUID?) @@ -131,6 +147,10 @@ struct WelcomeWindowView: View { let group = ConnectionGroup(name: name, color: color) groupStorage.addGroup(group) groups = groupStorage.loadGroups() + if !pendingMoveToNewGroup.isEmpty { + moveConnections(pendingMoveToNewGroup, toGroup: group.id) + pendingMoveToNewGroup = [] + } } } .sheet(isPresented: $showActivationSheet) { @@ -254,7 +274,7 @@ struct WelcomeWindowView: View { .buttonStyle(.plain) .help("New Connection (⌘N)") - Button(action: { showNewGroupSheet = true }) { + Button(action: { pendingMoveToNewGroup = []; showNewGroupSheet = true }) { Image(systemName: "folder.badge.plus") .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium, weight: .medium)) .foregroundStyle(.secondary) @@ -280,11 +300,15 @@ struct WelcomeWindowView: View { .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) .focused($focus, equals: .search) .onKeyPress(.return) { - if let id = selectedConnectionId, - let connection = connections.first(where: { $0.id == id }) - { - connectToDatabase(connection) - } + connectSelectedConnections() + return .handled + } + .onKeyPress(characters: .init(charactersIn: "\u{7F}\u{08}"), phases: .down) { keyPress in + guard keyPress.modifiers.contains(.command) else { return .ignored } + let toDelete = selectedConnections + guard !toDelete.isEmpty else { return .ignored } + connectionsToDelete = toDelete + showDeleteConfirmation = true return .handled } .onKeyPress(characters: .init(charactersIn: "jn"), phases: [.down, .repeat]) { keyPress in @@ -350,7 +374,7 @@ struct WelcomeWindowView: View { /// - Arrow keys: native keyboard navigation private var connectionList: some View { ScrollViewReader { proxy in - List(selection: $selectedConnectionId) { + List(selection: $selectedConnectionIds) { ForEach(ungroupedConnections) { connection in connectionRow(for: connection) } @@ -365,22 +389,34 @@ struct WelcomeWindowView: View { ForEach(connections(in: group)) { connection in connectionRow(for: connection) } + .onMove { from, to in + guard searchText.isEmpty else { return } + moveGroupedConnections(in: group, from: from, to: to) + } } } header: { groupHeader(for: group) } } + .onMove { from, to in + guard searchText.isEmpty else { return } + moveGroups(from: from, to: to) + } } .listStyle(.inset) .scrollContentBackground(.hidden) .focused($focus, equals: .connectionList) .environment(\.defaultMinListRowHeight, 44) .onKeyPress(.return) { - if let id = selectedConnectionId, - let connection = connections.first(where: { $0.id == id }) - { - connectToDatabase(connection) - } + connectSelectedConnections() + return .handled + } + .onKeyPress(characters: .init(charactersIn: "\u{7F}\u{08}"), phases: .down) { keyPress in + guard keyPress.modifiers.contains(.command) else { return .ignored } + let toDelete = selectedConnections + guard !toDelete.isEmpty else { return .ignored } + connectionsToDelete = toDelete + showDeleteConfirmation = true return .handled } .onKeyPress(characters: .init(charactersIn: "jn"), phases: [.down, .repeat]) { keyPress in @@ -409,30 +445,11 @@ struct WelcomeWindowView: View { } private func connectionRow(for connection: DatabaseConnection) -> some View { - ConnectionRow( - connection: connection, - onConnect: { connectToDatabase(connection) }, - onEdit: { - openWindow(id: "connection-form", value: connection.id as UUID?) - focusConnectionFormWindow() - }, - onDuplicate: { - duplicateConnection(connection) - }, - onCopyURL: { - let pw = ConnectionStorage.shared.loadPassword(for: connection.id) - let sshPw = ConnectionStorage.shared.loadSSHPassword(for: connection.id) - let url = ConnectionURLFormatter.format(connection, password: pw, sshPassword: sshPw) - ClipboardService.shared.writeText(url) - }, - onDelete: { - connectionToDelete = connection - showDeleteConfirmation = true - } - ) - .tag(connection.id) - .listRowInsets(ThemeEngine.shared.activeTheme.spacing.listRowInsets.swiftUI) - .listRowSeparator(.hidden) + ConnectionRow(connection: connection, onConnect: { connectToDatabase(connection) }) + .tag(connection.id) + .listRowInsets(ThemeEngine.shared.activeTheme.spacing.listRowInsets.swiftUI) + .listRowSeparator(.hidden) + .contextMenu { contextMenuContent(for: connection) } } private func groupHeader(for group: ConnectionGroup) -> some View { @@ -554,6 +571,134 @@ struct WelcomeWindowView: View { .frame(maxWidth: .infinity) } + // MARK: - Context Menu + + @ViewBuilder + private func contextMenuContent(for connection: DatabaseConnection) -> some View { + if isMultipleSelection, selectedConnectionIds.contains(connection.id) { + Button { connectSelectedConnections() } label: { + Label( + String(localized: "Connect \(selectedConnectionIds.count) Connections"), + systemImage: "play.fill" + ) + } + + Divider() + + moveToGroupMenu(for: selectedConnections) + + if selectedConnections.contains(where: { $0.groupId != nil }) { + Button { removeFromGroup(selectedConnections) } label: { + Label(String(localized: "Remove from Group"), systemImage: "folder.badge.minus") + } + } + + Divider() + + Button(role: .destructive) { + connectionsToDelete = selectedConnections + showDeleteConfirmation = true + } label: { + Label( + String(localized: "Delete \(selectedConnectionIds.count) Connections"), + systemImage: "trash" + ) + } + } else { + Button { connectToDatabase(connection) } label: { + Label(String(localized: "Connect"), systemImage: "play.fill") + } + + Divider() + + Button { + openWindow(id: "connection-form", value: connection.id as UUID?) + focusConnectionFormWindow() + } label: { + Label(String(localized: "Edit"), systemImage: "pencil") + } + + Button { duplicateConnection(connection) } label: { + Label(String(localized: "Duplicate"), systemImage: "doc.on.doc") + } + + Button { + let pw = ConnectionStorage.shared.loadPassword(for: connection.id) + let sshPw = ConnectionStorage.shared.loadSSHPassword(for: connection.id) + let url = ConnectionURLFormatter.format(connection, password: pw, sshPassword: sshPw) + ClipboardService.shared.writeText(url) + } label: { + Label(String(localized: "Copy as URL"), systemImage: "link") + } + + Divider() + + moveToGroupMenu(for: [connection]) + + if connection.groupId != nil { + Button { removeFromGroup([connection]) } label: { + Label(String(localized: "Remove from Group"), systemImage: "folder.badge.minus") + } + } + + Divider() + + Button(role: .destructive) { + connectionsToDelete = [connection] + showDeleteConfirmation = true + } label: { + Label(String(localized: "Delete"), systemImage: "trash") + } + } + } + + @ViewBuilder + private func moveToGroupMenu(for targets: [DatabaseConnection]) -> some View { + Menu(String(localized: "Move to Group")) { + ForEach(groups) { group in + Button { + moveConnections(targets, toGroup: group.id) + } label: { + HStack { + if !group.color.isDefault { + Circle() + .fill(group.color.color) + .frame(width: 8, height: 8) + } + Text(group.name) + } + } + } + + if !groups.isEmpty { + Divider() + } + + Button { + pendingMoveToNewGroup = targets + showNewGroupSheet = true + } label: { + Label(String(localized: "New Group..."), systemImage: "folder.badge.plus") + } + } + } + + private func moveConnections(_ targets: [DatabaseConnection], toGroup groupId: UUID) { + let ids = Set(targets.map(\.id)) + for i in connections.indices where ids.contains(connections[i].id) { + connections[i].groupId = groupId + } + storage.saveConnections(connections) + } + + private func removeFromGroup(_ targets: [DatabaseConnection]) { + let ids = Set(targets.map(\.id)) + for i in connections.indices where ids.contains(connections[i].id) { + connections[i].groupId = nil + } + storage.saveConnections(connections) + } + // MARK: - Actions private func loadConnections() { @@ -568,7 +713,8 @@ struct WelcomeWindowView: View { } private func connectToDatabase(_ connection: DatabaseConnection) { - // Open main window first, then connect in background + // Set pendingConnectionId so AppDelegate assigns the correct per-connection tabbingIdentifier + WindowOpener.shared.pendingConnectionId = connection.id openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) NSApplication.shared.closeWindows(withId: "welcome") @@ -620,10 +766,18 @@ struct WelcomeWindowView: View { } } - private func deleteConnection(_ connection: DatabaseConnection) { - connections.removeAll { $0.id == connection.id } - storage.deleteConnection(connection) - storage.saveConnections(connections) + private func connectSelectedConnections() { + for connection in selectedConnections { + connectToDatabase(connection) + } + } + + private func deleteSelectedConnections() { + let idsToDelete = Set(connectionsToDelete.map(\.id)) + storage.deleteConnections(connectionsToDelete) + connections.removeAll { idsToDelete.contains($0.id) } + selectedConnectionIds.subtract(idsToDelete) + connectionsToDelete = [] } private func duplicateConnection(_ connection: DatabaseConnection) { @@ -679,41 +833,43 @@ struct WelcomeWindowView: View { private func moveToNextConnection() { let visible = flatVisibleConnections guard !visible.isEmpty else { return } - guard let currentId = selectedConnectionId, - let index = visible.firstIndex(where: { $0.id == currentId }) else { - selectedConnectionId = visible.first?.id + let anchorId = visible.last(where: { selectedConnectionIds.contains($0.id) })?.id + guard let anchorId, + let index = visible.firstIndex(where: { $0.id == anchorId }) else { + selectedConnectionIds = Set([visible[0].id]) return } let next = min(index + 1, visible.count - 1) - selectedConnectionId = visible[next].id + selectedConnectionIds = [visible[next].id] } private func moveToPreviousConnection() { let visible = flatVisibleConnections guard !visible.isEmpty else { return } - guard let currentId = selectedConnectionId, - let index = visible.firstIndex(where: { $0.id == currentId }) else { - selectedConnectionId = visible.last?.id + let anchorId = visible.first(where: { selectedConnectionIds.contains($0.id) })?.id + guard let anchorId, + let index = visible.firstIndex(where: { $0.id == anchorId }) else { + selectedConnectionIds = Set([visible[visible.count - 1].id]) return } let prev = max(index - 1, 0) - selectedConnectionId = visible[prev].id + selectedConnectionIds = [visible[prev].id] } private func scrollToSelection(_ proxy: ScrollViewProxy) { - if let id = selectedConnectionId { + if let id = selectedConnectionIds.first { proxy.scrollTo(id, anchor: .center) } } private func collapseSelectedGroup() { - guard let id = selectedConnectionId, + guard let id = selectedConnectionIds.first, let connection = connections.first(where: { $0.id == id }), let groupId = connection.groupId, !collapsedGroupIds.contains(groupId) else { return } withAnimation(.easeInOut(duration: 0.2)) { collapsedGroupIds.insert(groupId) - // Keep selectedConnectionId so Ctrl+L can derive the groupId to expand. + // Keep selectedConnectionIds so Ctrl+L can derive the groupId to expand. // The List won't show a highlight for the hidden row. UserDefaults.standard.set( Array(collapsedGroupIds.map(\.uuidString)), @@ -723,7 +879,7 @@ struct WelcomeWindowView: View { } private func expandSelectedGroup() { - guard let id = selectedConnectionId, + guard let id = selectedConnectionIds.first, let connection = connections.first(where: { $0.id == id }), let groupId = connection.groupId, collapsedGroupIds.contains(groupId) else { return } @@ -737,7 +893,14 @@ struct WelcomeWindowView: View { } private func moveUngroupedConnections(from source: IndexSet, to destination: Int) { - let ungroupedIndices = connections.indices.filter { connections[$0].groupId == nil } + let validGroupIds = Set(groups.map(\.id)) + let ungroupedIndices = connections.indices.filter { index in + guard let groupId = connections[index].groupId else { return true } + return !validGroupIds.contains(groupId) + } + + guard source.allSatisfy({ $0 < ungroupedIndices.count }), + destination <= ungroupedIndices.count else { return } let globalSource = IndexSet(source.map { ungroupedIndices[$0] }) let globalDestination: Int @@ -753,6 +916,49 @@ struct WelcomeWindowView: View { storage.saveConnections(connections) } + private func moveGroupedConnections(in group: ConnectionGroup, from source: IndexSet, to destination: Int) { + let groupIndices = connections.indices.filter { connections[$0].groupId == group.id } + + guard source.allSatisfy({ $0 < groupIndices.count }), + destination <= groupIndices.count else { return } + + let globalSource = IndexSet(source.map { groupIndices[$0] }) + let globalDestination: Int + if destination < groupIndices.count { + globalDestination = groupIndices[destination] + } else if let last = groupIndices.last { + globalDestination = last + 1 + } else { + globalDestination = 0 + } + + connections.move(fromOffsets: globalSource, toOffset: globalDestination) + storage.saveConnections(connections) + } + + private func moveGroups(from source: IndexSet, to destination: Int) { + let active = activeGroups + guard source.allSatisfy({ $0 < active.count }), + destination <= active.count else { return } + + let activeGroupIndices = active.compactMap { activeGroup in + groups.firstIndex(where: { $0.id == activeGroup.id }) + } + + let globalSource = IndexSet(source.map { activeGroupIndices[$0] }) + let globalDestination: Int + if destination < activeGroupIndices.count { + globalDestination = activeGroupIndices[destination] + } else if let last = activeGroupIndices.last { + globalDestination = last + 1 + } else { + globalDestination = 0 + } + + groups.move(fromOffsets: globalSource, toOffset: globalDestination) + groupStorage.saveGroups(groups) + } + /// Focus the connection form window as soon as it's available private func focusConnectionFormWindow() { Task { @MainActor in @@ -773,10 +979,6 @@ struct WelcomeWindowView: View { private struct ConnectionRow: View { let connection: DatabaseConnection var onConnect: (() -> Void)? - var onEdit: (() -> Void)? - var onDuplicate: (() -> Void)? - var onCopyURL: (() -> Void)? - var onDelete: (() -> Void)? private var displayTag: ConnectionTag? { guard let tagId = connection.tagId else { return nil } @@ -791,7 +993,9 @@ private struct ConnectionRow: View { .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.medium)) .foregroundStyle(connection.displayColor) .frame( - width: ThemeEngine.shared.activeTheme.iconSizes.medium, height: ThemeEngine.shared.activeTheme.iconSizes.medium) + width: ThemeEngine.shared.activeTheme.iconSizes.medium, + height: ThemeEngine.shared.activeTheme.iconSizes.medium + ) // Connection info VStack(alignment: .leading, spacing: 2) { @@ -826,39 +1030,6 @@ private struct ConnectionRow: View { .overlay( DoubleClickView { onConnect?() } ) - .contextMenu { - if let onConnect = onConnect { - Button(action: onConnect) { - Label("Connect", systemImage: "play.fill") - } - Divider() - } - - if let onEdit = onEdit { - Button(action: onEdit) { - Label("Edit", systemImage: "pencil") - } - } - - if let onDuplicate = onDuplicate { - Button(action: onDuplicate) { - Label("Duplicate", systemImage: "doc.on.doc") - } - } - - if let onCopyURL = onCopyURL { - Button(action: onCopyURL) { - Label("Copy as URL", systemImage: "link") - } - } - - if let onDelete = onDelete { - Divider() - Button(role: .destructive, action: onDelete) { - Label("Delete", systemImage: "trash") - } - } - } } private var connectionSubtitle: String { @@ -872,31 +1043,6 @@ private struct ConnectionRow: View { } } -// MARK: - EnvironmentBadge - -private struct EnvironmentBadge: View { - let connection: DatabaseConnection - - private var environment: ConnectionEnvironment { - if connection.sshConfig.enabled { - return .ssh - } - if connection.host.contains("prod") || connection.name.lowercased().contains("prod") { - return .production - } - if connection.host.contains("staging") || connection.name.lowercased().contains("staging") { - return .staging - } - return .local - } - - var body: some View { - Text("(\(environment.rawValue.lowercased()))") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .foregroundStyle(environment.badgeColor) - } -} - // MARK: - WelcomeButtonStyle private struct WelcomeButtonStyle: ButtonStyle { @@ -938,23 +1084,6 @@ private struct KeyboardHint: View { } } -// MARK: - ConnectionEnvironment Extension - -private extension ConnectionEnvironment { - var badgeColor: Color { - switch self { - case .local: - return Color(nsColor: .systemGreen) - case .ssh: - return Color(nsColor: .systemBlue) - case .staging: - return Color(nsColor: .systemOrange) - case .production: - return Color(nsColor: .systemRed) - } - } -} - // MARK: - DoubleClickView private struct DoubleClickView: NSViewRepresentable { diff --git a/docs/features/keyboard-shortcuts.mdx b/docs/features/keyboard-shortcuts.mdx index 494879c1..bd87ce2a 100644 --- a/docs/features/keyboard-shortcuts.mdx +++ b/docs/features/keyboard-shortcuts.mdx @@ -150,6 +150,7 @@ TablePro is keyboard-driven. Most actions have shortcuts, and most menu shortcut | Switch connection | `Cmd+Option+C` | | Disconnect | `Cmd+D` | | Refresh connection | `Cmd+R` | +| Delete selected connections | `Cmd+Delete` | ### View From 053971a5f6b252e8e8bc8f3bc0513ee00ec429cd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 25 Mar 2026 00:05:46 +0700 Subject: [PATCH 2/4] fix: context menu correctness for group operations --- TablePro/Views/Connection/WelcomeWindowView.swift | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 719a66c7..45531028 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -587,7 +587,8 @@ struct WelcomeWindowView: View { moveToGroupMenu(for: selectedConnections) - if selectedConnections.contains(where: { $0.groupId != nil }) { + let validGroupIds = Set(groups.map(\.id)) + if selectedConnections.contains(where: { $0.groupId.map { validGroupIds.contains($0) } ?? false }) { Button { removeFromGroup(selectedConnections) } label: { Label(String(localized: "Remove from Group"), systemImage: "folder.badge.minus") } @@ -635,7 +636,7 @@ struct WelcomeWindowView: View { moveToGroupMenu(for: [connection]) - if connection.groupId != nil { + if let groupId = connection.groupId, groups.contains(where: { $0.id == groupId }) { Button { removeFromGroup([connection]) } label: { Label(String(localized: "Remove from Group"), systemImage: "folder.badge.minus") } @@ -654,6 +655,8 @@ struct WelcomeWindowView: View { @ViewBuilder private func moveToGroupMenu(for targets: [DatabaseConnection]) -> some View { + let isSingle = targets.count == 1 + let currentGroupId = isSingle ? targets.first?.groupId : nil Menu(String(localized: "Move to Group")) { ForEach(groups) { group in Button { @@ -666,8 +669,13 @@ struct WelcomeWindowView: View { .frame(width: 8, height: 8) } Text(group.name) + if currentGroupId == group.id { + Spacer() + Image(systemName: "checkmark") + } } } + .disabled(currentGroupId == group.id) } if !groups.isEmpty { From 07ce6d1ef60f76b13db0598454f2ef81c5e00853 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 25 Mar 2026 00:42:54 +0700 Subject: [PATCH 3/4] fix: add plural variations for count-based localized strings --- TablePro/Resources/Localizable.xcstrings | 42 ++++++++++++++++++++++-- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index f5ce0350..ecd9a6aa 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -6530,7 +6530,26 @@ } }, "Connect %lld Connections" : { - + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect %lld Connection" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connect %lld Connections" + } + } + } + } + } + } }, "Connect Anyway" : { "localizations" : { @@ -9307,7 +9326,26 @@ } }, "Delete %lld Connections" : { - + "localizations" : { + "en" : { + "variations" : { + "plural" : { + "one" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete %lld Connection" + } + }, + "other" : { + "stringUnit" : { + "state" : "translated", + "value" : "Delete %lld Connections" + } + } + } + } + } + } }, "Delete Check Constraint" : { "extractionState" : "stale", From 3c4691a69a7d79853e83ac765c4be489d24e1060 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 25 Mar 2026 00:46:26 +0700 Subject: [PATCH 4/4] fix: moveGroups bounds check and remove dead hoveredConnectionId state --- TablePro/Views/Connection/WelcomeWindowView.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 45531028..a295c41b 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -31,7 +31,6 @@ struct WelcomeWindowView: View { @State private var connectionToEdit: DatabaseConnection? @State private var connectionsToDelete: [DatabaseConnection] = [] @State private var showDeleteConfirmation = false - @State private var hoveredConnectionId: UUID? @State private var selectedConnectionIds: Set = [] @FocusState private var focus: FocusField? @State private var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding() @@ -946,13 +945,13 @@ struct WelcomeWindowView: View { private func moveGroups(from source: IndexSet, to destination: Int) { let active = activeGroups - guard source.allSatisfy({ $0 < active.count }), - destination <= active.count else { return } - let activeGroupIndices = active.compactMap { activeGroup in groups.firstIndex(where: { $0.id == activeGroup.id }) } + guard source.allSatisfy({ $0 < activeGroupIndices.count }), + destination <= activeGroupIndices.count else { return } + let globalSource = IndexSet(source.map { activeGroupIndices[$0] }) let globalDestination: Int if destination < activeGroupIndices.count {