From 3ff40ec82132c85b9e1a0a61f5c5822675b68266 Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Mon, 2 Mar 2026 16:13:48 +0700 Subject: [PATCH 1/3] feat: add connection groups --- TablePro/Core/Storage/ConnectionStorage.swift | 15 +- TablePro/Core/Storage/GroupStorage.swift | 80 ++- TablePro/Models/ConnectionGroup.swift | 29 +- TablePro/Models/DatabaseConnection.swift | 8 +- TablePro/Resources/Localizable.xcstrings | 60 ++- .../Views/Connection/ConnectionFormView.swift | 12 +- .../Connection/ConnectionGroupEditor.swift | 141 ++++++ .../Connection/ConnectionGroupFormSheet.swift | 123 +++++ TablePro/Views/WelcomeWindowView.swift | 468 ++++++++++-------- 9 files changed, 719 insertions(+), 217 deletions(-) create mode 100644 TablePro/Views/Connection/ConnectionGroupEditor.swift create mode 100644 TablePro/Views/Connection/ConnectionGroupFormSheet.swift diff --git a/TablePro/Core/Storage/ConnectionStorage.swift b/TablePro/Core/Storage/ConnectionStorage.swift index 1ceb6801..dad76262 100644 --- a/TablePro/Core/Storage/ConnectionStorage.swift +++ b/TablePro/Core/Storage/ConnectionStorage.swift @@ -358,6 +358,10 @@ private struct StoredConnection: Codable { // AI policy let aiPolicy: String? + // Group + let groupId: String? + let sortOrder: Int + init(from connection: DatabaseConnection) { self.id = connection.id self.name = connection.name @@ -392,6 +396,10 @@ private struct StoredConnection: Codable { // AI policy self.aiPolicy = connection.aiPolicy?.rawValue + + // Group + self.groupId = connection.groupId?.uuidString + self.sortOrder = connection.sortOrder } // Custom decoder to handle migration from old format @@ -428,6 +436,8 @@ private struct StoredConnection: Codable { groupId = try container.decodeIfPresent(String.self, forKey: .groupId) isReadOnly = try container.decodeIfPresent(Bool.self, forKey: .isReadOnly) ?? false aiPolicy = try container.decodeIfPresent(String.self, forKey: .aiPolicy) + groupId = try container.decodeIfPresent(String.self, forKey: .groupId) + sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 } func toConnection() -> DatabaseConnection { @@ -452,6 +462,7 @@ private struct StoredConnection: Codable { let parsedTagId = tagId.flatMap { UUID(uuidString: $0) } let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) } let parsedAIPolicy = aiPolicy.flatMap { AIConnectionPolicy(rawValue: $0) } + let parsedGroupId = groupId.flatMap { UUID(uuidString: $0) } return DatabaseConnection( id: id, @@ -467,7 +478,9 @@ private struct StoredConnection: Codable { tagId: parsedTagId, groupId: parsedGroupId, isReadOnly: isReadOnly, - aiPolicy: parsedAIPolicy + aiPolicy: parsedAIPolicy, + groupId: parsedGroupId, + sortOrder: sortOrder ) } } diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index 4ef53f45..ef9a35b6 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -12,6 +12,7 @@ final class GroupStorage { private static let logger = Logger(subsystem: "com.TablePro", category: "GroupStorage") private let groupsKey = "com.TablePro.groups" + private let expandedGroupsKey = "com.TablePro.expandedGroups" private let defaults = UserDefaults.standard private let encoder = JSONEncoder() private let decoder = JSONDecoder() @@ -47,9 +48,6 @@ final class GroupStorage { /// Add a new group func addGroup(_ group: ConnectionGroup) { var groups = loadGroups() - guard !groups.contains(where: { $0.name.lowercased() == group.name.lowercased() }) else { - return - } groups.append(group) saveGroups(groups) } @@ -63,15 +61,87 @@ final class GroupStorage { } } - /// Delete a group + /// Delete a group and all its descendants. + /// Member connections become ungrouped. func deleteGroup(_ group: ConnectionGroup) { var groups = loadGroups() - groups.removeAll { $0.id == group.id } + let deletedIds = collectDescendantIds(of: group.id, in: groups) + let allDeletedIds = deletedIds.union([group.id]) + + // Remove deleted groups + groups.removeAll { allDeletedIds.contains($0.id) } saveGroups(groups) + + // Ungroup connections that belonged to deleted groups + let storage = ConnectionStorage.shared + var connections = storage.loadConnections() + var changed = false + for index in connections.indices { + if let gid = connections[index].groupId, allDeletedIds.contains(gid) { + connections[index].groupId = nil + changed = true + } + } + if changed { + storage.saveConnections(connections) + } } /// Get group by ID func group(for id: UUID) -> ConnectionGroup? { loadGroups().first { $0.id == id } } + + /// Get child groups of a parent, sorted by sortOrder + func childGroups(of parentId: UUID?) -> [ConnectionGroup] { + loadGroups() + .filter { $0.parentGroupId == parentId } + .sorted { $0.sortOrder < $1.sortOrder } + } + + /// Get the next sort order for a new item in a parent context + func nextSortOrder(parentId: UUID?) -> Int { + let siblings = loadGroups().filter { $0.parentGroupId == parentId } + return (siblings.map(\.sortOrder).max() ?? -1) + 1 + } + + // MARK: - Expanded State + + /// Load the set of expanded group IDs + func loadExpandedGroupIds() -> Set { + guard let data = defaults.data(forKey: expandedGroupsKey) else { + return [] + } + + do { + let ids = try decoder.decode([UUID].self, from: data) + return Set(ids) + } catch { + Self.logger.error("Failed to load expanded groups: \(error)") + return [] + } + } + + /// Save the set of expanded group IDs + func saveExpandedGroupIds(_ ids: Set) { + do { + let data = try encoder.encode(Array(ids)) + defaults.set(data, forKey: expandedGroupsKey) + } catch { + Self.logger.error("Failed to save expanded groups: \(error)") + } + } + + // MARK: - Helpers + + /// Recursively collect all descendant group IDs + private func collectDescendantIds(of groupId: UUID, in groups: [ConnectionGroup]) -> Set { + var result = Set() + let children = groups.filter { $0.parentGroupId == groupId } + for child in children { + result.insert(child.id) + result.formUnion(collectDescendantIds(of: child.id, in: groups)) + } + return result + } } diff --git a/TablePro/Models/ConnectionGroup.swift b/TablePro/Models/ConnectionGroup.swift index 99164afb..0269db41 100644 --- a/TablePro/Models/ConnectionGroup.swift +++ b/TablePro/Models/ConnectionGroup.swift @@ -5,15 +5,40 @@ import Foundation -/// A named group (folder) for organizing database connections +/// A group for organizing database connections into folders struct ConnectionGroup: Identifiable, Hashable, Codable { let id: UUID var name: String var color: ConnectionColor + var parentGroupId: UUID? + var sortOrder: Int - init(id: UUID = UUID(), name: String, color: ConnectionColor = .none) { + init( + id: UUID = UUID(), + name: String, + color: ConnectionColor = .blue, + parentGroupId: UUID? = nil, + sortOrder: Int = 0 + ) { self.id = id self.name = name self.color = color + self.parentGroupId = parentGroupId + self.sortOrder = sortOrder + } + + // MARK: - Codable (Migration Support) + + enum CodingKeys: String, CodingKey { + case id, name, color, parentGroupId, sortOrder + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(UUID.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + color = try container.decodeIfPresent(ConnectionColor.self, forKey: .color) ?? .blue + parentGroupId = try container.decodeIfPresent(UUID.self, forKey: .parentGroupId) + sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 } } diff --git a/TablePro/Models/DatabaseConnection.swift b/TablePro/Models/DatabaseConnection.swift index 917e207f..20692495 100644 --- a/TablePro/Models/DatabaseConnection.swift +++ b/TablePro/Models/DatabaseConnection.swift @@ -253,6 +253,8 @@ struct DatabaseConnection: Identifiable, Hashable { var aiPolicy: AIConnectionPolicy? var mongoReadPreference: String? var mongoWriteConcern: String? + var groupId: UUID? + var sortOrder: Int init( id: UUID = UUID(), @@ -270,7 +272,9 @@ struct DatabaseConnection: Identifiable, Hashable { isReadOnly: Bool = false, aiPolicy: AIConnectionPolicy? = nil, mongoReadPreference: String? = nil, - mongoWriteConcern: String? = nil + mongoWriteConcern: String? = nil, + groupId: UUID? = nil, + sortOrder: Int = 0 ) { self.id = id self.name = name @@ -288,6 +292,8 @@ struct DatabaseConnection: Identifiable, Hashable { self.aiPolicy = aiPolicy self.mongoReadPreference = mongoReadPreference self.mongoWriteConcern = mongoWriteConcern + self.groupId = groupId + self.sortOrder = sortOrder } /// Returns the display color (custom color or database type color) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 2f1074cb..4b483457 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -289,6 +289,18 @@ } } }, + "%@%@" : { + "comment" : "A button label that includes a dot representing the group's color, followed by the group's name", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@%2$@" + } + } + } + }, "%@ms" : { "localizations" : { "vi" : { @@ -1225,6 +1237,10 @@ } } }, + "Are you sure you want to delete \"%@\"? Connections will be ungrouped." : { + "comment" : "A confirmation dialog message asking the user to confirm the deletion of a connection group.", + "isCommentAutoGenerated" : true + }, "Are you sure you want to disconnect from this database?" : { "localizations" : { "vi" : { @@ -2396,12 +2412,10 @@ } } } - }, - "Create New Group" : { - }, "Create New Group..." : { - + "comment" : "A menu item that allows users to create a new connection group.", + "isCommentAutoGenerated" : true }, "Create New Tag" : { "localizations" : { @@ -2959,7 +2973,8 @@ } }, "Delete Group" : { - + "comment" : "A confirmation dialog title for deleting a database connection group.", + "isCommentAutoGenerated" : true }, "Delete Index" : { "extractionState" : "stale", @@ -3234,6 +3249,14 @@ } } }, + "Edit Group" : { + "comment" : "A label indicating that the view is for editing a group.", + "isCommentAutoGenerated" : true + }, + "Edit Group..." : { + "comment" : "A button label for editing an existing group.", + "isCommentAutoGenerated" : true + }, "Edit Provider" : { "localizations" : { "vi" : { @@ -4311,10 +4334,12 @@ } }, "Group" : { - + "comment" : "A label for editing the user's connection group.", + "isCommentAutoGenerated" : true }, "Group name" : { - + "comment" : "A label for the text field that lets them enter a name for the group.", + "isCommentAutoGenerated" : true }, "Help improve TablePro by sharing anonymous usage statistics (no personal data or queries)." : { "localizations" : { @@ -5061,6 +5086,10 @@ } } }, + "Manage Groups" : { + "comment" : "A menu header that allows users to manage their connection groups.", + "isCommentAutoGenerated" : true + }, "Manage Tags" : { "localizations" : { "vi" : { @@ -5229,6 +5258,10 @@ } } }, + "Move to Group" : { + "comment" : "A menu title that allows users to move a connection to a different group.", + "isCommentAutoGenerated" : true + }, "Move Up" : { "extractionState" : "stale", "localizations" : { @@ -5347,7 +5380,12 @@ } }, "New Group" : { - + "comment" : "A title for a form sheet used to create a new connection group.", + "isCommentAutoGenerated" : true + }, + "New Group..." : { + "comment" : "A menu item that allows users to create a new group.", + "isCommentAutoGenerated" : true }, "New query tab" : { "extractionState" : "stale", @@ -5381,6 +5419,10 @@ } } }, + "New Subgroup..." : { + "comment" : "A menu item that allows creating a new subgroup within an existing group.", + "isCommentAutoGenerated" : true + }, "New Tab" : { "localizations" : { "vi" : { @@ -9171,5 +9213,5 @@ } } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 0cbfea43..c9213043 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -52,7 +52,7 @@ struct ConnectionFormView: View { @State private var sslClientCertPath: String = "" @State private var sslClientKeyPath: String = "" - // Color and Tag + // Color, Tag, and Group @State private var connectionColor: ConnectionColor = .none @State private var selectedTagId: UUID? @State private var selectedGroupId: UUID? @@ -224,7 +224,7 @@ struct ConnectionFormView: View { ConnectionTagEditor(selectedTagId: $selectedTagId) } LabeledContent(String(localized: "Group")) { - ConnectionGroupPicker(selectedGroupId: $selectedGroupId) + ConnectionGroupEditor(selectedGroupId: $selectedGroupId) } Toggle(String(localized: "Read-Only"), isOn: $isReadOnly) .help("Prevent write operations (INSERT, UPDATE, DELETE, DROP, etc.)") @@ -636,11 +636,11 @@ struct ConnectionFormView: View { sslConfig: sslConfig, color: connectionColor, tagId: selectedTagId, - groupId: selectedGroupId, isReadOnly: isReadOnly, aiPolicy: aiPolicy, mongoReadPreference: mongoReadPreference.isEmpty ? nil : mongoReadPreference, - mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern + mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern, + groupId: selectedGroupId ) // Save passwords to Keychain @@ -735,9 +735,9 @@ struct ConnectionFormView: View { sslConfig: sslConfig, color: connectionColor, tagId: selectedTagId, - groupId: selectedGroupId, mongoReadPreference: mongoReadPreference.isEmpty ? nil : mongoReadPreference, - mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern + mongoWriteConcern: mongoWriteConcern.isEmpty ? nil : mongoWriteConcern, + groupId: selectedGroupId ) Task { diff --git a/TablePro/Views/Connection/ConnectionGroupEditor.swift b/TablePro/Views/Connection/ConnectionGroupEditor.swift new file mode 100644 index 00000000..abb80a2f --- /dev/null +++ b/TablePro/Views/Connection/ConnectionGroupEditor.swift @@ -0,0 +1,141 @@ +// +// ConnectionGroupEditor.swift +// TablePro +// + +import SwiftUI + +/// Group selection dropdown for the connection form +struct ConnectionGroupEditor: View { + @Binding var selectedGroupId: UUID? + @State private var allGroups: [ConnectionGroup] = [] + @State private var showingCreateSheet = false + + private let groupStorage = GroupStorage.shared + + private var selectedGroup: ConnectionGroup? { + guard let id = selectedGroupId else { return nil } + return groupStorage.group(for: id) + } + + var body: some View { + Menu { + Button { + selectedGroupId = nil + } label: { + HStack { + Text("None") + if selectedGroupId == nil { + Spacer() + Image(systemName: "checkmark") + } + } + } + + Divider() + + ForEach(sortedGroupsFlat(), id: \.group.id) { item in + Button { + selectedGroupId = item.group.id + } label: { + HStack { + Image(nsImage: colorDot(item.group.color.color)) + Text("\(item.prefix)\(item.group.name)") + if selectedGroupId == item.group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + + Divider() + + Button { + showingCreateSheet = true + } label: { + Label("Create New Group...", systemImage: "plus.circle") + } + + if allGroups.contains(where: { _ in true }) { + Divider() + + Menu("Manage Groups") { + ForEach(allGroups) { group in + Button(role: .destructive) { + deleteGroup(group) + } label: { + Label("Delete \"\(group.name)\"", systemImage: "trash") + } + } + } + } + } label: { + HStack(spacing: 6) { + if let group = selectedGroup { + Image(systemName: "folder.fill") + .foregroundStyle(group.color.color) + .font(.system(size: 10)) + Text(group.name) + .foregroundStyle(.primary) + } else { + Text("None") + .foregroundStyle(.secondary) + } + } + } + .menuStyle(.borderlessButton) + .fixedSize() + .task { allGroups = groupStorage.loadGroups() } + .sheet(isPresented: $showingCreateSheet) { + ConnectionGroupFormSheet { newGroup in + groupStorage.addGroup(newGroup) + selectedGroupId = newGroup.id + allGroups = groupStorage.loadGroups() + } + } + } + + // MARK: - Helpers + + private struct FlatGroupItem { + let group: ConnectionGroup + let prefix: String + } + + private func sortedGroupsFlat() -> [FlatGroupItem] { + var result: [FlatGroupItem] = [] + func walk(_ parentId: UUID?, depth: Int) { + let children = allGroups + .filter { $0.parentGroupId == parentId } + .sorted { $0.sortOrder < $1.sortOrder } + for child in children { + let prefix = String(repeating: " ", count: depth) + result.append(FlatGroupItem(group: child, prefix: prefix)) + walk(child.id, depth: depth + 1) + } + } + walk(nil, depth: 0) + return result + } + + /// Create a colored circle NSImage for use in menu items + private func colorDot(_ color: Color) -> NSImage { + let size = NSSize(width: 10, height: 10) + let image = NSImage(size: size, flipped: false) { rect in + NSColor(color).setFill() + NSBezierPath(ovalIn: rect).fill() + return true + } + image.isTemplate = false + return image + } + + private func deleteGroup(_ group: ConnectionGroup) { + if selectedGroupId == group.id { + selectedGroupId = nil + } + groupStorage.deleteGroup(group) + allGroups = groupStorage.loadGroups() + } +} diff --git a/TablePro/Views/Connection/ConnectionGroupFormSheet.swift b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift new file mode 100644 index 00000000..5d05688b --- /dev/null +++ b/TablePro/Views/Connection/ConnectionGroupFormSheet.swift @@ -0,0 +1,123 @@ +// +// ConnectionGroupFormSheet.swift +// TablePro +// + +import SwiftUI + +/// Sheet for creating or editing a connection group +struct ConnectionGroupFormSheet: View { + @Environment(\.dismiss) private var dismiss + + let group: ConnectionGroup? + let parentGroupId: UUID? + var onSave: ((ConnectionGroup) -> Void)? + + @State private var name: String = "" + @State private var color: ConnectionColor = .blue + + init( + group: ConnectionGroup? = nil, + parentGroupId: UUID? = nil, + onSave: ((ConnectionGroup) -> Void)? = nil + ) { + self.group = group + self.parentGroupId = parentGroupId + self.onSave = onSave + } + + var body: some View { + VStack(spacing: 16) { + Text(group == nil ? String(localized: "New Group") : String(localized: "Edit Group")) + .font(.headline) + + TextField(String(localized: "Group name"), text: $name) + .textFieldStyle(.roundedBorder) + .frame(width: 200) + + VStack(alignment: .leading, spacing: 6) { + Text("Color") + .font(.caption) + .foregroundStyle(.secondary) + GroupColorPicker(selectedColor: $color) + } + + HStack { + Button("Cancel") { + dismiss() + } + + Button(group == nil ? String(localized: "Create") : String(localized: "Save")) { + save() + } + .keyboardShortcut(.return) + .buttonStyle(.borderedProminent) + .disabled(name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .padding(20) + .frame(width: 300) + .onAppear { + if let group { + name = group.name + color = group.color + } + } + .onExitCommand { + dismiss() + } + } + + private func save() { + let trimmedName = name.trimmingCharacters(in: .whitespaces) + guard !trimmedName.isEmpty else { return } + + if var existing = group { + existing.name = trimmedName + existing.color = color + onSave?(existing) + } else { + let sortOrder = GroupStorage.shared.nextSortOrder(parentId: parentGroupId) + let newGroup = ConnectionGroup( + name: trimmedName, + color: color, + parentGroupId: parentGroupId, + sortOrder: sortOrder + ) + onSave?(newGroup) + } + dismiss() + } +} + +// MARK: - Group Color Picker + +/// Color picker for groups (excludes "none" option) +private struct GroupColorPicker: View { + @Binding var selectedColor: ConnectionColor + + private var availableColors: [ConnectionColor] { + ConnectionColor.allCases.filter { $0 != .none } + } + + var body: some View { + HStack(spacing: 6) { + ForEach(availableColors) { color in + Circle() + .fill(color.color) + .frame(width: DesignConstants.IconSize.medium, height: DesignConstants.IconSize.medium) + .overlay( + Circle() + .stroke(Color.primary, lineWidth: selectedColor == color ? 2 : 0) + .frame( + width: DesignConstants.IconSize.large, + height: DesignConstants.IconSize.large + ) + ) + .onTapGesture { + selectedColor = color + } + } + } + } +} diff --git a/TablePro/Views/WelcomeWindowView.swift b/TablePro/Views/WelcomeWindowView.swift index 8a566c20..f21724c5 100644 --- a/TablePro/Views/WelcomeWindowView.swift +++ b/TablePro/Views/WelcomeWindowView.swift @@ -15,7 +15,6 @@ import SwiftUI struct WelcomeWindowView: View { private static let logger = Logger(subsystem: "com.TablePro", category: "WelcomeWindowView") private let storage = ConnectionStorage.shared - private let groupStorage = GroupStorage.shared @ObservedObject private var dbManager = DatabaseManager.shared @State private var connections: [DatabaseConnection] = [] @@ -28,12 +27,17 @@ struct WelcomeWindowView: View { @State private var hoveredConnectionId: UUID? @State private var selectedConnectionId: UUID? // For keyboard navigation @State private var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding() + + // Group state @State private var groups: [ConnectionGroup] = [] - @State private var collapsedGroupIds: Set = { - let strings = UserDefaults.standard.stringArray(forKey: "com.TablePro.collapsedGroupIds") ?? [] - return Set(strings.compactMap { UUID(uuidString: $0) }) - }() + @State private var expandedGroups: Set = [] @State private var showNewGroupSheet = false + @State private var groupToEdit: ConnectionGroup? + @State private var groupToDelete: ConnectionGroup? + @State private var showDeleteGroupConfirmation = false + @State private var newGroupParentId: UUID? + + private let groupStorage = GroupStorage.shared @Environment(\.openWindow) private var openWindow @@ -45,28 +49,9 @@ struct WelcomeWindowView: View { connection.name.localizedCaseInsensitiveContains(searchText) || connection.host.localizedCaseInsensitiveContains(searchText) || connection.database.localizedCaseInsensitiveContains(searchText) - || groupName(for: connection.groupId)?.localizedCaseInsensitiveContains(searchText) == true } } - private func groupName(for groupId: UUID?) -> String? { - guard let groupId else { return nil } - return groups.first { $0.id == groupId }?.name - } - - private var ungroupedConnections: [DatabaseConnection] { - filteredConnections.filter { $0.groupId == nil } - } - - private var activeGroups: [ConnectionGroup] { - let groupIds = Set(filteredConnections.compactMap(\.groupId)) - return groups.filter { groupIds.contains($0.id) } - } - - private func connections(in group: ConnectionGroup) -> [DatabaseConnection] { - filteredConnections.filter { $0.groupId == group.id } - } - var body: some View { ZStack { if showOnboarding { @@ -105,11 +90,33 @@ struct WelcomeWindowView: View { .onReceive(NotificationCenter.default.publisher(for: .connectionUpdated)) { _ in loadConnections() } + .confirmationDialog( + "Delete Group", + isPresented: $showDeleteGroupConfirmation, + presenting: groupToDelete + ) { group in + Button("Delete", role: .destructive) { + deleteGroup(group) + } + Button("Cancel", role: .cancel) {} + } message: { group in + Text("Are you sure you want to delete \"\(group.name)\"? Connections will be ungrouped.") + } .sheet(isPresented: $showNewGroupSheet) { - CreateGroupSheet { name, color in - let group = ConnectionGroup(name: name, color: color) - groupStorage.addGroup(group) - groups = groupStorage.loadGroups() + ConnectionGroupFormSheet( + group: groupToEdit, + parentGroupId: newGroupParentId + ) { group in + if groupToEdit != nil { + groupStorage.updateGroup(group) + } else { + groupStorage.addGroup(group) + expandedGroups.insert(group.id) + groupStorage.saveExpandedGroupIds(expandedGroups) + } + groupToEdit = nil + newGroupParentId = nil + loadConnections() } } } @@ -201,22 +208,6 @@ struct WelcomeWindowView: View { .buttonStyle(.plain) .help("New Connection (⌘N)") - Button(action: { showNewGroupSheet = true }) { - Image(systemName: "folder.badge.plus") - .font(.system(size: DesignConstants.FontSize.medium, weight: .medium)) - .foregroundStyle(.secondary) - .frame( - width: DesignConstants.IconSize.extraLarge, - height: DesignConstants.IconSize.extraLarge - ) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color(nsColor: .quaternaryLabelColor)) - ) - } - .buttonStyle(.plain) - .help(String(localized: "New Group")) - HStack(spacing: 6) { Image(systemName: "magnifyingglass") .font(.system(size: DesignConstants.FontSize.medium)) @@ -255,40 +246,50 @@ struct WelcomeWindowView: View { Button(action: { openWindow(id: "connection-form") }) { Label("New Connection...", systemImage: "plus") } + Button(action: { + groupToEdit = nil + newGroupParentId = nil + showNewGroupSheet = true + }) { + Label("New Group...", systemImage: "folder.badge.plus") + } } // MARK: - Connection List - /// Connection list that behaves like native NSTableView: - /// - Single click: selects row (handled by List's selection binding) - /// - Double click: connects to database (via simultaneousGesture in ConnectionRow) - /// - Return key: connects to selected row - /// - Arrow keys: native keyboard navigation + /// Connection list with group hierarchy support. + /// When searching: flat filtered list. Otherwise: grouped with DisclosureGroups. private var connectionList: some View { List(selection: $selectedConnectionId) { - ForEach(ungroupedConnections) { connection in - connectionRow(for: connection) - } - .onMove { from, to in - guard searchText.isEmpty else { return } - moveUngroupedConnections(from: from, to: to) - } + if !searchText.isEmpty { + // Flat filtered list during search + ForEach(filteredConnections) { connection in + connectionRow(for: connection) + } + } else { + // Grouped hierarchical view + ForEach(rootGroups) { group in + groupSection(for: group) + } + .onMove { from, to in + moveRootGroups(from: from, to: to) + } - ForEach(activeGroups) { group in - Section { - if !collapsedGroupIds.contains(group.id) { - ForEach(connections(in: group)) { connection in - connectionRow(for: connection) - } - } - } header: { - groupHeader(for: group) + // Ungrouped connections + ForEach(ungroupedConnections) { connection in + connectionRow(for: connection) + } + .onMove { from, to in + moveUngroupedConnections(from: from, to: to) } } } .listStyle(.inset) .scrollContentBackground(.hidden) .environment(\.defaultMinListRowHeight, 44) + .dropDestination(for: String.self) { items, _ in + handleDropOnGroup(items: items, targetGroupId: nil) + } .onKeyPress(.return) { if let id = selectedConnectionId, let connection = connections.first(where: { $0.id == id }) @@ -299,104 +300,150 @@ 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) - }, - onDelete: { - connectionToDelete = connection - showDeleteConfirmation = true + // MARK: - Group Hierarchy + + private var rootGroups: [ConnectionGroup] { + groups.filter { $0.parentGroupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + } + + private var ungroupedConnections: [DatabaseConnection] { + connections.filter { $0.groupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + } + + private func childGroups(of parentId: UUID) -> [ConnectionGroup] { + groups.filter { $0.parentGroupId == parentId } + .sorted { $0.sortOrder < $1.sortOrder } + } + + private func connectionsInGroup(_ groupId: UUID) -> [DatabaseConnection] { + connections.filter { $0.groupId == groupId } + .sorted { $0.sortOrder < $1.sortOrder } + } + + private func totalConnectionCount(in group: ConnectionGroup) -> Int { + let direct = connections.filter { $0.groupId == group.id }.count + let children = childGroups(of: group.id) + let nested = children.reduce(0) { $0 + totalConnectionCount(in: $1) } + return direct + nested + } + + private func expandedBinding(_ groupId: UUID) -> Binding { + Binding( + get: { expandedGroups.contains(groupId) }, + set: { isExpanded in + if isExpanded { + expandedGroups.insert(groupId) + } else { + expandedGroups.remove(groupId) + } + groupStorage.saveExpandedGroupIds(expandedGroups) } ) - .tag(connection.id) - .listRowInsets(DesignConstants.swiftUIListRowInsets) - .listRowSeparator(.hidden) } - private func groupHeader(for group: ConnectionGroup) -> some View { - HStack(spacing: 6) { - Image(systemName: collapsedGroupIds.contains(group.id) ? "chevron.right" : "chevron.down") - .font(.system(size: DesignConstants.FontSize.small, weight: .medium)) - .foregroundStyle(.tertiary) - .frame(width: 12) - - if !group.color.isDefault { - Circle() - .fill(group.color.color) - .frame(width: 8, height: 8) + private func groupSection(for group: ConnectionGroup) -> AnyView { + AnyView( + DisclosureGroup(isExpanded: expandedBinding(group.id)) { + ForEach(childGroups(of: group.id)) { child in + groupSection(for: child) + } + ForEach(connectionsInGroup(group.id)) { connection in + connectionRow(for: connection) + } + } label: { + groupRowLabel(group) } + ) + } + + private func groupRowLabel(_ group: ConnectionGroup) -> some View { + HStack(spacing: 8) { + Image(systemName: "folder.fill") + .foregroundStyle(group.color.isDefault ? .secondary : group.color.color) + .font(.system(size: DesignConstants.FontSize.body)) Text(group.name) - .font(.system(size: DesignConstants.FontSize.small, weight: .semibold)) - .foregroundStyle(.secondary) + .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) + .foregroundStyle(.primary) - Text("\(connections(in: group).count)") - .font(.system(size: DesignConstants.FontSize.tiny)) + Text("\(totalConnectionCount(in: group))") + .font(.system(size: DesignConstants.FontSize.small)) .foregroundStyle(.tertiary) Spacer() } .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - if collapsedGroupIds.contains(group.id) { - collapsedGroupIds.remove(group.id) + .overlay( + DoubleClickView { + if expandedGroups.contains(group.id) { + expandedGroups.remove(group.id) } else { - collapsedGroupIds.insert(group.id) + expandedGroups.insert(group.id) } - UserDefaults.standard.set( - Array(collapsedGroupIds.map(\.uuidString)), - forKey: "com.TablePro.collapsedGroupIds" - ) + groupStorage.saveExpandedGroupIds(expandedGroups) } + ) + .dropDestination(for: String.self) { items, _ in + handleDropOnGroup(items: items, targetGroupId: group.id) } .contextMenu { - Button { - renameGroup(group) - } label: { - Label(String(localized: "Rename"), systemImage: "pencil") + Button(action: { openWindow(id: "connection-form") }) { + Label("New Connection...", systemImage: "plus") } - - Menu(String(localized: "Change Color")) { - ForEach(ConnectionColor.allCases) { color in - Button { - var updated = group - updated.color = color - groupStorage.updateGroup(updated) - groups = groupStorage.loadGroups() - } label: { - HStack { - if color != .none { - Image(systemName: "circle.fill") - .foregroundStyle(color.color) - } - Text(color.displayName) - if group.color == color { - Spacer() - Image(systemName: "checkmark") - } - } - } - } + Button(action: { + groupToEdit = nil + newGroupParentId = group.id + showNewGroupSheet = true + }) { + Label("New Subgroup...", systemImage: "folder.badge.plus") + } + Divider() + Button(action: { + groupToEdit = group + newGroupParentId = group.parentGroupId + showNewGroupSheet = true + }) { + Label("Edit Group...", systemImage: "pencil") } - Divider() - Button(role: .destructive) { - deleteGroup(group) + groupToDelete = group + showDeleteGroupConfirmation = true } label: { - Label(String(localized: "Delete Group"), systemImage: "trash") + Label("Delete Group", systemImage: "trash") } } } + @ViewBuilder + private func connectionRow(for connection: DatabaseConnection) -> some View { + ConnectionRow( + connection: connection, + groups: groups, + onConnect: { connectToDatabase(connection) }, + onEdit: { + openWindow(id: "connection-form", value: connection.id as UUID?) + focusConnectionFormWindow() + }, + onDuplicate: { + duplicateConnection(connection) + }, + onDelete: { + connectionToDelete = connection + showDeleteConfirmation = true + }, + onMoveToGroup: { groupId in + moveConnectionToGroup(connection, groupId: groupId) + } + ) + .tag(connection.id) + .listRowInsets(DesignConstants.swiftUIListRowInsets) + .listRowSeparator(.hidden) + .draggable(connection.id.uuidString) + } + // MARK: - Empty State private var emptyState: some View { @@ -436,7 +483,10 @@ struct WelcomeWindowView: View { } else { connections = saved } - loadGroups() + groups = groupStorage.loadGroups() + let savedExpanded = groupStorage.loadExpandedGroupIds() + // Auto-expand new groups + expandedGroups = savedExpanded.union(Set(groups.map(\.id))) } private func connectToDatabase(_ connection: DatabaseConnection) { @@ -482,61 +532,6 @@ struct WelcomeWindowView: View { focusConnectionFormWindow() } - private func loadGroups() { - groups = groupStorage.loadGroups() - } - - private func deleteGroup(_ group: ConnectionGroup) { - for i in connections.indices where connections[i].groupId == group.id { - connections[i].groupId = nil - } - storage.saveConnections(connections) - groupStorage.deleteGroup(group) - groups = groupStorage.loadGroups() - } - - private func renameGroup(_ group: ConnectionGroup) { - let alert = NSAlert() - alert.messageText = String(localized: "Rename Group") - alert.informativeText = String(localized: "Enter a new name for the group.") - alert.addButton(withTitle: String(localized: "Rename")) - alert.addButton(withTitle: String(localized: "Cancel")) - - let textField = NSTextField(frame: NSRect(x: 0, y: 0, width: 200, height: 24)) - textField.stringValue = group.name - alert.accessoryView = textField - - if alert.runModal() == .alertFirstButtonReturn { - let newName = textField.stringValue.trimmingCharacters(in: .whitespaces) - guard !newName.isEmpty else { return } - let isDuplicate = groups.contains { - $0.id != group.id && $0.name.lowercased() == newName.lowercased() - } - guard !isDuplicate else { return } - var updated = group - updated.name = newName - groupStorage.updateGroup(updated) - groups = groupStorage.loadGroups() - } - } - - private func moveUngroupedConnections(from source: IndexSet, to destination: Int) { - let ungroupedIndices = connections.indices.filter { connections[$0].groupId == nil } - - let globalSource = IndexSet(source.map { ungroupedIndices[$0] }) - let globalDestination: Int - if destination < ungroupedIndices.count { - globalDestination = ungroupedIndices[destination] - } else if let last = ungroupedIndices.last { - globalDestination = last + 1 - } else { - globalDestination = 0 - } - - connections.move(fromOffsets: globalSource, toOffset: globalDestination) - storage.saveConnections(connections) - } - /// Focus the connection form window as soon as it's available private func focusConnectionFormWindow() { // Poll rapidly until window is found (much faster than fixed delay) @@ -561,16 +556,71 @@ struct WelcomeWindowView: View { attemptFocus() } } + + private func deleteGroup(_ group: ConnectionGroup) { + groupStorage.deleteGroup(group) + expandedGroups.remove(group.id) + groupStorage.saveExpandedGroupIds(expandedGroups) + loadConnections() + } + + private func moveConnectionToGroup(_ connection: DatabaseConnection, groupId: UUID?) { + var updated = connection + updated.groupId = groupId + storage.updateConnection(updated) + loadConnections() + } + + private func moveUngroupedConnections(from: IndexSet, to: Int) { + var ungrouped = connections.filter { $0.groupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + ungrouped.move(fromOffsets: from, toOffset: to) + for (index, var conn) in ungrouped.enumerated() { + conn.sortOrder = index + storage.updateConnection(conn) + } + loadConnections() + } + + private func moveRootGroups(from: IndexSet, to: Int) { + var roots = groups.filter { $0.parentGroupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + roots.move(fromOffsets: from, toOffset: to) + for (index, var group) in roots.enumerated() { + group.sortOrder = index + groupStorage.updateGroup(group) + } + loadConnections() + } + + private func handleDropOnGroup(items: [String], targetGroupId: UUID?) -> Bool { + var changed = false + for item in items { + guard let uuid = UUID(uuidString: item) else { continue } + if var conn = connections.first(where: { $0.id == uuid }) { + guard conn.groupId != targetGroupId else { continue } + conn.groupId = targetGroupId + storage.updateConnection(conn) + changed = true + } + } + if changed { + loadConnections() + } + return changed + } } // MARK: - ConnectionRow private struct ConnectionRow: View { let connection: DatabaseConnection + var groups: [ConnectionGroup] = [] var onConnect: (() -> Void)? var onEdit: (() -> Void)? var onDuplicate: (() -> Void)? var onDelete: (() -> Void)? + var onMoveToGroup: ((UUID?) -> Void)? private var displayTag: ConnectionTag? { guard let tagId = connection.tagId else { return nil } @@ -640,6 +690,38 @@ private struct ConnectionRow: View { } } + if !groups.isEmpty, let onMoveToGroup = onMoveToGroup { + Divider() + Menu("Move to Group") { + Button { + onMoveToGroup(nil) + } label: { + HStack { + Text("None") + if connection.groupId == nil { + Spacer() + Image(systemName: "checkmark") + } + } + } + Divider() + ForEach(groups.filter { $0.parentGroupId == nil }.sorted { $0.sortOrder < $1.sortOrder }) { group in + Button { + onMoveToGroup(group.id) + } label: { + HStack { + Image(systemName: "folder.fill") + Text(group.name) + if connection.groupId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + } + } + } + if let onDelete = onDelete { Divider() Button(role: .destructive, action: onDelete) { @@ -763,7 +845,7 @@ private class PassThroughDoubleClickView: NSView { var onDoubleClick: (() -> Void)? override func mouseDown(with event: NSEvent) { - if event.clickCount == 2 { + if event.clickCount >= 2, event.clickCount % 2 == 0 { onDoubleClick?() } // Always forward to next responder for List selection From 4c5d8e2675ecfb776c1480d3e82db53b0dd03bcd Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:27:02 +0700 Subject: [PATCH 2/3] update --- .../WelcomeWindow/ConnectionOutlineView.swift | 1278 +++++++++++++++++ TablePro/Views/WelcomeWindowView.swift | 481 +------ 2 files changed, 1336 insertions(+), 423 deletions(-) create mode 100644 TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift diff --git a/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift new file mode 100644 index 00000000..5819efdb --- /dev/null +++ b/TablePro/Views/WelcomeWindow/ConnectionOutlineView.swift @@ -0,0 +1,1278 @@ +// +// ConnectionOutlineView.swift +// TablePro +// +// NSViewRepresentable wrapping NSOutlineView for hierarchical connection list +// with drag-and-drop reordering and group management. +// + +import AppKit +import os +import SwiftUI + +// MARK: - Outline Item Wrappers + +/// Reference-type wrapper for ConnectionGroup (NSOutlineView requires objects) +final class OutlineGroup: NSObject { + let group: ConnectionGroup + init(_ group: ConnectionGroup) { + self.group = group + } +} + +/// Reference-type wrapper for DatabaseConnection (NSOutlineView requires objects) +final class OutlineConnection: NSObject { + let connection: DatabaseConnection + init(_ connection: DatabaseConnection) { + self.connection = connection + } +} + +// MARK: - ConnectionOutlineView + +struct ConnectionOutlineView: NSViewRepresentable { + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionOutlineView") + + let groups: [ConnectionGroup] + let connections: [DatabaseConnection] + var expandedGroupIds: Set + var selectedItemId: UUID? + var searchText: String + + // Callbacks + var onSelectionChanged: ((UUID?) -> Void)? + var onDoubleClickConnection: ((DatabaseConnection) -> Void)? + var onToggleGroup: ((UUID) -> Void)? + var onMoveConnection: ((DatabaseConnection, UUID?) -> Void)? + var onReorderConnections: (([DatabaseConnection]) -> Void)? + var onReorderGroups: (([ConnectionGroup]) -> Void)? + var onMoveGroup: ((ConnectionGroup, UUID?) -> Void)? + + // Context menu callbacks + var onNewConnection: (() -> Void)? + var onNewGroup: ((UUID?) -> Void)? + var onEditGroup: ((ConnectionGroup) -> Void)? + var onDeleteGroup: ((ConnectionGroup) -> Void)? + var onEditConnection: ((DatabaseConnection) -> Void)? + var onDuplicateConnection: ((DatabaseConnection) -> Void)? + var onDeleteConnection: ((DatabaseConnection) -> Void)? + var onMoveConnectionToGroup: ((DatabaseConnection, UUID?) -> Void)? + + // MARK: - NSViewRepresentable + + func makeCoordinator() -> Coordinator { + Coordinator(parent: self) + } + + func makeNSView(context: Context) -> NSScrollView { + let outlineView = ConnectionNSOutlineView() + outlineView.coordinator = context.coordinator + + let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("MainColumn")) + column.title = "" + outlineView.addTableColumn(column) + outlineView.outlineTableColumn = column + + outlineView.headerView = nil + outlineView.rowHeight = DesignConstants.RowHeight.comfortable + outlineView.style = .sourceList + outlineView.selectionHighlightStyle = .regular + outlineView.allowsMultipleSelection = false + outlineView.autosaveExpandedItems = false + outlineView.floatsGroupRows = false + outlineView.rowSizeStyle = .default + outlineView.usesAutomaticRowHeights = false + outlineView.indentationPerLevel = 20 + outlineView.backgroundColor = .controlBackgroundColor + + outlineView.registerForDraggedTypes([.outlineItem]) + outlineView.setDraggingSourceOperationMask(.move, forLocal: true) + + outlineView.dataSource = context.coordinator + outlineView.delegate = context.coordinator + outlineView.target = context.coordinator + outlineView.doubleAction = #selector(Coordinator.handleDoubleClick) + + let scrollView = NSScrollView() + scrollView.documentView = outlineView + scrollView.drawsBackground = false + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.automaticallyAdjustsContentInsets = false + + context.coordinator.outlineView = outlineView + context.coordinator.rebuildData(groups: groups, connections: connections, searchText: searchText) + outlineView.reloadData() + syncExpandedState(outlineView: outlineView, coordinator: context.coordinator) + syncSelection(outlineView: outlineView, coordinator: context.coordinator) + + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let outlineView = scrollView.documentView as? NSOutlineView else { return } + + let coordinator = context.coordinator + coordinator.parent = self + + // Skip reload during active drag to avoid lag + guard !coordinator.isDragging else { return } + + let needsReload = coordinator.needsReload( + groups: groups, + connections: connections, + searchText: searchText + ) + + if needsReload { + coordinator.rebuildData(groups: groups, connections: connections, searchText: searchText) + outlineView.reloadData() + syncExpandedState(outlineView: outlineView, coordinator: coordinator) + } + + syncSelection(outlineView: outlineView, coordinator: coordinator) + } + + // MARK: - State Sync + + private func syncExpandedState(outlineView: NSOutlineView, coordinator: Coordinator) { + NSAnimationContext.beginGrouping() + NSAnimationContext.current.duration = 0 + + for item in coordinator.rootItems { + syncExpandedStateRecursive(outlineView: outlineView, item: item, coordinator: coordinator) + } + + NSAnimationContext.endGrouping() + } + + private func syncExpandedStateRecursive( + outlineView: NSOutlineView, + item: NSObject, + coordinator: Coordinator + ) { + guard let outlineGroup = item as? OutlineGroup else { return } + let shouldExpand = expandedGroupIds.contains(outlineGroup.group.id) + let isExpanded = outlineView.isItemExpanded(item) + + if shouldExpand && !isExpanded { + outlineView.expandItem(item) + } else if !shouldExpand && isExpanded { + outlineView.collapseItem(item) + } + + // Recurse into children + if let children = coordinator.childrenMap[outlineGroup.group.id] { + for child in children { + syncExpandedStateRecursive(outlineView: outlineView, item: child, coordinator: coordinator) + } + } + } + + private func syncSelection(outlineView: NSOutlineView, coordinator: Coordinator) { + guard let targetId = selectedItemId else { + if outlineView.selectedRow != -1 { + outlineView.deselectAll(nil) + } + return + } + + if let item = coordinator.itemById(targetId) { + let row = outlineView.row(forItem: item) + if row >= 0 && outlineView.selectedRow != row { + coordinator.isSyncingSelection = true + outlineView.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false) + coordinator.isSyncingSelection = false + } + } + } +} + +// MARK: - Pasteboard Type + +private extension NSPasteboard.PasteboardType { + static let outlineItem = NSPasteboard.PasteboardType("com.TablePro.outlineItem") +} + +// MARK: - Cell View Identifiers + +private extension NSUserInterfaceItemIdentifier { + static let groupCell = NSUserInterfaceItemIdentifier("GroupCell") + static let connectionCell = NSUserInterfaceItemIdentifier("ConnectionCell") +} + +// MARK: - Reusable Cell Views + +/// Cell view for group rows — subviews are created once and updated on reuse +private final class GroupCellView: NSTableCellView { + let folderIcon = NSImageView() + let nameLabel = NSTextField(labelWithString: "") + let countLabel = NSTextField(labelWithString: "") + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + identifier = .groupCell + setupViews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } + + private func setupViews() { + let iconSize = DesignConstants.IconSize.medium + + folderIcon.setContentHuggingPriority(.defaultHigh, for: .horizontal) + folderIcon.translatesAutoresizingMaskIntoConstraints = false + folderIcon.widthAnchor.constraint(equalToConstant: iconSize).isActive = true + folderIcon.heightAnchor.constraint(equalToConstant: iconSize).isActive = true + + nameLabel.font = NSFont.systemFont(ofSize: DesignConstants.FontSize.body, weight: .medium) + nameLabel.textColor = .labelColor + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + countLabel.font = NSFont.systemFont(ofSize: DesignConstants.FontSize.small) + countLabel.textColor = .tertiaryLabelColor + countLabel.setContentHuggingPriority(.defaultHigh, for: .horizontal) + + let container = NSStackView(views: [folderIcon, nameLabel, countLabel]) + container.orientation = .horizontal + container.alignment = .centerY + container.spacing = DesignConstants.Spacing.xs + container.translatesAutoresizingMaskIntoConstraints = false + + addSubview(container) + NSLayoutConstraint.activate([ + container.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DesignConstants.Spacing.xxs), + container.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -DesignConstants.Spacing.xs), + container.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + textField = nameLabel + imageView = folderIcon + } + + func configure(group: ConnectionGroup, connectionCount: Int) { + let folderImage = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil) + folderIcon.image = folderImage + folderIcon.contentTintColor = group.color.isDefault ? .secondaryLabelColor : NSColor(group.color.color) + nameLabel.stringValue = group.name + countLabel.stringValue = "\(connectionCount)" + } +} + +/// Cell view for connection rows — subviews are created once and updated on reuse +private final class ConnectionCellView: NSTableCellView { + let dbIcon = NSImageView() + let nameLabel = NSTextField(labelWithString: "") + let subtitleLabel = NSTextField(labelWithString: "") + let tagLabel = NSTextField(labelWithString: "") + let tagWrapper = NSView() + private let titleStack: NSStackView + + override init(frame frameRect: NSRect) { + titleStack = NSStackView() + super.init(frame: frameRect) + identifier = .connectionCell + setupViews() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError() + } + + private func setupViews() { + let iconSize = DesignConstants.IconSize.medium + + dbIcon.setContentHuggingPriority(.defaultHigh, for: .horizontal) + dbIcon.translatesAutoresizingMaskIntoConstraints = false + dbIcon.widthAnchor.constraint(equalToConstant: iconSize).isActive = true + dbIcon.heightAnchor.constraint(equalToConstant: iconSize).isActive = true + + nameLabel.font = NSFont.systemFont(ofSize: DesignConstants.FontSize.body, weight: .medium) + nameLabel.textColor = .labelColor + nameLabel.lineBreakMode = .byTruncatingTail + nameLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + // Tag badge setup + tagLabel.font = NSFont.systemFont(ofSize: DesignConstants.FontSize.tiny) + tagLabel.drawsBackground = false + tagLabel.isBordered = false + tagLabel.isEditable = false + tagLabel.translatesAutoresizingMaskIntoConstraints = false + + tagWrapper.wantsLayer = true + tagWrapper.layer?.cornerRadius = DesignConstants.CornerRadius.small + tagWrapper.layer?.masksToBounds = true + tagWrapper.translatesAutoresizingMaskIntoConstraints = false + tagWrapper.setContentHuggingPriority(.defaultHigh, for: .horizontal) + tagWrapper.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal) + + tagWrapper.addSubview(tagLabel) + let paddingH = DesignConstants.Spacing.xxs + let paddingV = DesignConstants.Spacing.xxxs + NSLayoutConstraint.activate([ + tagLabel.leadingAnchor.constraint(equalTo: tagWrapper.leadingAnchor, constant: paddingH), + tagLabel.trailingAnchor.constraint(equalTo: tagWrapper.trailingAnchor, constant: -paddingH), + tagLabel.topAnchor.constraint(equalTo: tagWrapper.topAnchor, constant: paddingV), + tagLabel.bottomAnchor.constraint(equalTo: tagWrapper.bottomAnchor, constant: -paddingV), + ]) + + titleStack.orientation = .horizontal + titleStack.alignment = .centerY + titleStack.spacing = DesignConstants.Spacing.xxs + 2 + titleStack.addArrangedSubview(nameLabel) + titleStack.addArrangedSubview(tagWrapper) + + subtitleLabel.font = NSFont.systemFont(ofSize: DesignConstants.FontSize.small) + subtitleLabel.textColor = .secondaryLabelColor + subtitleLabel.lineBreakMode = .byTruncatingTail + subtitleLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let textStack = NSStackView(views: [titleStack, subtitleLabel]) + textStack.orientation = .vertical + textStack.alignment = .leading + textStack.spacing = DesignConstants.Spacing.xxxs + textStack.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let container = NSStackView(views: [dbIcon, textStack]) + container.orientation = .horizontal + container.alignment = .centerY + container.spacing = DesignConstants.Spacing.sm + container.translatesAutoresizingMaskIntoConstraints = false + + addSubview(container) + NSLayoutConstraint.activate([ + container.leadingAnchor.constraint(equalTo: leadingAnchor, constant: DesignConstants.Spacing.xxs), + container.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -DesignConstants.Spacing.xs), + container.centerYAnchor.constraint(equalTo: centerYAnchor), + ]) + + textField = nameLabel + imageView = dbIcon + } + + func configure(connection: DatabaseConnection) { + // Icon + if let assetImage = NSImage(named: connection.type.iconName) { + let templateImage = assetImage.copy() as? NSImage ?? assetImage + templateImage.isTemplate = true + dbIcon.image = templateImage + dbIcon.contentTintColor = NSColor(connection.displayColor) + } + + // Name + nameLabel.stringValue = connection.name + + // Tag + if let tagId = connection.tagId, let tag = TagStorage.shared.tag(for: tagId) { + tagLabel.stringValue = tag.name + tagLabel.textColor = NSColor(tag.color.color) + tagWrapper.layer?.backgroundColor = NSColor(tag.color.color).withAlphaComponent(0.15).cgColor + tagWrapper.isHidden = false + } else { + tagWrapper.isHidden = true + } + + // Subtitle + if connection.sshConfig.enabled { + subtitleLabel.stringValue = "SSH : \(connection.sshConfig.username)@\(connection.sshConfig.host)" + } else if connection.host.isEmpty { + subtitleLabel.stringValue = connection.database.isEmpty ? connection.type.rawValue : connection.database + } else { + subtitleLabel.stringValue = connection.host + } + } +} + +// MARK: - ConnectionNSOutlineView + +/// Custom NSOutlineView subclass for context menus and keyboard handling +final class ConnectionNSOutlineView: NSOutlineView { + weak var coordinator: ConnectionOutlineView.Coordinator? + + override func drawBackground(inClipRect clipRect: NSRect) { + // Draw solid background instead of the translucent sourceList gray + backgroundColor.setFill() + clipRect.fill() + } + + override func menu(for event: NSEvent) -> NSMenu? { + let point = convert(event.locationInWindow, from: nil) + let clickedRow = row(at: point) + + if clickedRow >= 0 { + // Select the row under right-click + selectRowIndexes(IndexSet(integer: clickedRow), byExtendingSelection: false) + let item = self.item(atRow: clickedRow) + + if let outlineGroup = item as? OutlineGroup { + return coordinator?.contextMenu(for: outlineGroup) + } else if let outlineConn = item as? OutlineConnection { + return coordinator?.contextMenu(for: outlineConn) + } + } + + return coordinator?.emptySpaceContextMenu() + } + + override func keyDown(with event: NSEvent) { + // Return key on a connection triggers double-click action + if event.keyCode == 36 { + let row = selectedRow + if row >= 0, let outlineConn = item(atRow: row) as? OutlineConnection { + coordinator?.parent.onDoubleClickConnection?(outlineConn.connection) + return + } + // Return on a group toggles expand/collapse + if row >= 0, let outlineGroup = item(atRow: row) as? OutlineGroup { + coordinator?.parent.onToggleGroup?(outlineGroup.group.id) + return + } + } + super.keyDown(with: event) + } +} + +// MARK: - Coordinator + +extension ConnectionOutlineView { + final class Coordinator: NSObject, NSOutlineViewDataSource, NSOutlineViewDelegate { + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionOutlineView.Coordinator") + + var parent: ConnectionOutlineView + weak var outlineView: ConnectionNSOutlineView? + var isSyncingSelection = false + var isDragging = false + private var draggedItemId: UUID? + + // Data model + var rootItems: [NSObject] = [] + var childrenMap: [UUID: [NSObject]] = [:] + private var allGroupItems: [UUID: OutlineGroup] = [:] + private var allConnectionItems: [UUID: OutlineConnection] = [:] + private var isSearchMode = false + + // Snapshot for change detection + private var lastSnapshotHash = 0 + + init(parent: ConnectionOutlineView) { + self.parent = parent + } + + // MARK: - Data Building + + func needsReload(groups: [ConnectionGroup], connections: [DatabaseConnection], searchText: String) -> Bool { + let hash = computeSnapshotHash(groups: groups, connections: connections, searchText: searchText) + return hash != lastSnapshotHash + } + + private func computeSnapshotHash(groups: [ConnectionGroup], connections: [DatabaseConnection], searchText: String) -> Int { + var hasher = Hasher() + hasher.combine(searchText) + for g in groups { + hasher.combine(g.id) + hasher.combine(g.sortOrder) + hasher.combine(g.parentGroupId) + hasher.combine(g.name) + hasher.combine(g.color) + } + for c in connections { + hasher.combine(c.id) + hasher.combine(c.sortOrder) + hasher.combine(c.groupId) + hasher.combine(c.name) + hasher.combine(c.host) + hasher.combine(c.tagId) + } + return hasher.finalize() + } + + func rebuildData(groups: [ConnectionGroup], connections: [DatabaseConnection], searchText: String) { + rootItems.removeAll() + childrenMap.removeAll() + allGroupItems.removeAll() + allConnectionItems.removeAll() + isSearchMode = !searchText.isEmpty + + lastSnapshotHash = computeSnapshotHash(groups: groups, connections: connections, searchText: searchText) + + if isSearchMode { + // Flat filtered list of connections only + let query = searchText.lowercased() + let filtered = connections.filter { conn in + conn.name.lowercased().contains(query) + || conn.host.lowercased().contains(query) + || conn.database.lowercased().contains(query) + } + .sorted { $0.sortOrder < $1.sortOrder } + + for conn in filtered { + let item = OutlineConnection(conn) + allConnectionItems[conn.id] = item + rootItems.append(item) + } + return + } + + // Build group items + for group in groups { + let item = OutlineGroup(group) + allGroupItems[group.id] = item + } + + // Build connection items + for conn in connections { + let item = OutlineConnection(conn) + allConnectionItems[conn.id] = item + } + + // Build children map for each group + for group in groups { + var children: [NSObject] = [] + + // Child groups sorted by sortOrder + let childGroups = groups + .filter { $0.parentGroupId == group.id } + .sorted { $0.sortOrder < $1.sortOrder } + for child in childGroups { + if let item = allGroupItems[child.id] { + children.append(item) + } + } + + // Connections in this group sorted by sortOrder + let groupConns = connections + .filter { $0.groupId == group.id } + .sorted { $0.sortOrder < $1.sortOrder } + for conn in groupConns { + if let item = allConnectionItems[conn.id] { + children.append(item) + } + } + + childrenMap[group.id] = children + } + + // Root items: root groups (parentGroupId == nil) sorted by sortOrder, + // then ungrouped connections (groupId == nil) sorted by sortOrder + let rootGroups = groups + .filter { $0.parentGroupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + for group in rootGroups { + if let item = allGroupItems[group.id] { + rootItems.append(item) + } + } + + let ungroupedConns = connections + .filter { $0.groupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + for conn in ungroupedConns { + if let item = allConnectionItems[conn.id] { + rootItems.append(item) + } + } + } + + func itemById(_ id: UUID) -> NSObject? { + if let item = allGroupItems[id] { + return item + } + return allConnectionItems[id] + } + + // MARK: - NSOutlineViewDataSource + + func outlineView(_ outlineView: NSOutlineView, numberOfChildrenOfItem item: Any?) -> Int { + if item == nil { + return rootItems.count + } + if let outlineGroup = item as? OutlineGroup { + return childrenMap[outlineGroup.group.id]?.count ?? 0 + } + return 0 + } + + func outlineView(_ outlineView: NSOutlineView, child index: Int, ofItem item: Any?) -> Any { + if item == nil { + return rootItems[index] + } + if let outlineGroup = item as? OutlineGroup, + let children = childrenMap[outlineGroup.group.id] + { + return children[index] + } + return NSObject() + } + + func outlineView(_ outlineView: NSOutlineView, isItemExpandable item: Any) -> Bool { + if let outlineGroup = item as? OutlineGroup { + let children = childrenMap[outlineGroup.group.id] ?? [] + return !children.isEmpty + } + return false + } + + // MARK: - Drag Source + + func outlineView( + _ outlineView: NSOutlineView, + pasteboardWriterForItem item: Any + ) -> (any NSPasteboardWriting)? { + // Disable drag in search mode + guard !isSearchMode else { return nil } + + isDragging = true + + let pasteboardItem = NSPasteboardItem() + if let outlineGroup = item as? OutlineGroup { + draggedItemId = outlineGroup.group.id + pasteboardItem.setString(outlineGroup.group.id.uuidString, forType: .outlineItem) + } else if let outlineConn = item as? OutlineConnection { + draggedItemId = outlineConn.connection.id + pasteboardItem.setString(outlineConn.connection.id.uuidString, forType: .outlineItem) + } + return pasteboardItem + } + + func outlineView(_ outlineView: NSOutlineView, draggingSession session: NSDraggingSession, endedAt screenPoint: NSPoint, operation: NSDragOperation) { + isDragging = false + draggedItemId = nil + } + + // MARK: - Drop Validation + + func outlineView( + _ outlineView: NSOutlineView, + validateDrop info: any NSDraggingInfo, + proposedItem item: Any?, + proposedChildIndex index: Int + ) -> NSDragOperation { + guard let draggedId = draggedItemId else { return [] } + + let isDraggingGroup = allGroupItems[draggedId] != nil + let isDraggingConnection = allConnectionItems[draggedId] != nil + + if isDraggingConnection { + return validateConnectionDrop( + outlineView: outlineView, + draggedId: draggedId, + proposedItem: item, + proposedChildIndex: index + ) + } + + if isDraggingGroup { + return validateGroupDrop( + outlineView: outlineView, + draggedId: draggedId, + proposedItem: item, + proposedChildIndex: index + ) + } + + return [] + } + + private func validateConnectionDrop( + outlineView: NSOutlineView, + draggedId: UUID, + proposedItem item: Any?, + proposedChildIndex index: Int + ) -> NSDragOperation { + if item == nil { + // Dropping at root level: ungroup + return .move + } + + if item is OutlineGroup { + // Dropping into/within a group + return .move + } + + if let outlineConn = item as? OutlineConnection { + // Proposed target is a connection: retarget to its parent group + let parentGroupId = outlineConn.connection.groupId + if let parentGroupId, let parentItem = allGroupItems[parentGroupId] { + let children = childrenMap[parentGroupId] ?? [] + let childIndex = children.firstIndex(where: { ($0 as? OutlineConnection)?.connection.id == outlineConn.connection.id }) + outlineView.setDropItem(parentItem, dropChildIndex: childIndex ?? children.count) + } else { + // Connection is at root: retarget to root + let rootIndex = rootItems.firstIndex(where: { ($0 as? OutlineConnection)?.connection.id == outlineConn.connection.id }) + outlineView.setDropItem(nil, dropChildIndex: rootIndex ?? rootItems.count) + } + return .move + } + + return [] + } + + private func validateGroupDrop( + outlineView: NSOutlineView, + draggedId: UUID, + proposedItem item: Any?, + proposedChildIndex index: Int + ) -> NSDragOperation { + // Prevent dropping a group into itself or its descendants + if let targetGroup = item as? OutlineGroup { + if targetGroup.group.id == draggedId { + return [] + } + if isDescendant(groupId: targetGroup.group.id, ofGroupId: draggedId) { + return [] + } + return .move + } + + // Root drop + if item == nil { + return .move + } + + // Dropping onto a connection: retarget to the connection's parent + if let outlineConn = item as? OutlineConnection { + let parentGroupId = outlineConn.connection.groupId + if let parentGroupId, let parentItem = allGroupItems[parentGroupId] { + if parentGroupId == draggedId || isDescendant(groupId: parentGroupId, ofGroupId: draggedId) { + return [] + } + let children = childrenMap[parentGroupId] ?? [] + let childIndex = children.firstIndex(where: { ($0 as? OutlineConnection)?.connection.id == outlineConn.connection.id }) + outlineView.setDropItem(parentItem, dropChildIndex: childIndex ?? children.count) + } else { + let rootIndex = rootItems.firstIndex(where: { ($0 as? OutlineConnection)?.connection.id == outlineConn.connection.id }) + outlineView.setDropItem(nil, dropChildIndex: rootIndex ?? rootItems.count) + } + return .move + } + + return [] + } + + /// Check if a group is a descendant of another group + private func isDescendant(groupId: UUID, ofGroupId ancestorId: UUID) -> Bool { + guard let children = childrenMap[ancestorId] else { return false } + for child in children { + guard let childGroup = child as? OutlineGroup else { continue } + if childGroup.group.id == groupId { + return true + } + if isDescendant(groupId: groupId, ofGroupId: childGroup.group.id) { + return true + } + } + return false + } + + // MARK: - Accept Drop + + func outlineView( + _ outlineView: NSOutlineView, + acceptDrop info: any NSDraggingInfo, + item: Any?, + childIndex index: Int + ) -> Bool { + guard let pasteboardItem = info.draggingPasteboard.pasteboardItems?.first, + let uuidString = pasteboardItem.string(forType: .outlineItem), + let draggedId = UUID(uuidString: uuidString) + else { + return false + } + + // Clear drag state before callbacks so the subsequent + // SwiftUI state update → updateNSView is not blocked + isDragging = false + draggedItemId = nil + + if let draggedConnItem = allConnectionItems[draggedId] { + return acceptConnectionDrop( + connection: draggedConnItem.connection, + targetItem: item, + childIndex: index + ) + } + + if let draggedGroupItem = allGroupItems[draggedId] { + return acceptGroupDrop( + group: draggedGroupItem.group, + targetItem: item, + childIndex: index + ) + } + + return false + } + + private func acceptConnectionDrop( + connection: DatabaseConnection, + targetItem: Any?, + childIndex: Int + ) -> Bool { + if let targetGroup = targetItem as? OutlineGroup { + let targetGroupId = targetGroup.group.id + + if childIndex == NSOutlineViewDropOnItemIndex { + // Dropped ON the group: move to end + var movedConn = connection + movedConn.groupId = targetGroupId + var siblings = parent.connections + .filter { $0.groupId == targetGroupId && $0.id != connection.id } + .sorted { $0.sortOrder < $1.sortOrder } + movedConn.sortOrder = (siblings.last?.sortOrder ?? -1) + 1 + siblings.append(movedConn) + parent.onReorderConnections?(siblings) + } else { + // Dropped at a specific index within the group + var siblings = parent.connections + .filter { $0.groupId == targetGroupId && $0.id != connection.id } + .sorted { $0.sortOrder < $1.sortOrder } + + let childGroups = childrenMap[targetGroupId]?.compactMap { $0 as? OutlineGroup } ?? [] + let connectionIndex = max(0, childIndex - childGroups.count) + + var movedConn = connection + movedConn.groupId = targetGroupId + siblings.insert(movedConn, at: min(connectionIndex, siblings.count)) + + for (order, var conn) in siblings.enumerated() { + conn.sortOrder = order + siblings[order] = conn + } + parent.onReorderConnections?(siblings) + } + return true + } + + if targetItem == nil { + if childIndex == NSOutlineViewDropOnItemIndex { + // Dropped ON root: just ungroup, append at end + var movedConn = connection + movedConn.groupId = nil + var rootConns = parent.connections + .filter { $0.groupId == nil && $0.id != connection.id } + .sorted { $0.sortOrder < $1.sortOrder } + movedConn.sortOrder = (rootConns.last?.sortOrder ?? -1) + 1 + rootConns.append(movedConn) + parent.onReorderConnections?(rootConns) + } else { + var rootConns = parent.connections + .filter { $0.groupId == nil && $0.id != connection.id } + .sorted { $0.sortOrder < $1.sortOrder } + + let rootGroupCount = rootItems.compactMap { $0 as? OutlineGroup }.count + let connectionIndex = max(0, childIndex - rootGroupCount) + + var movedConn = connection + movedConn.groupId = nil + rootConns.insert(movedConn, at: min(connectionIndex, rootConns.count)) + + for (order, var conn) in rootConns.enumerated() { + conn.sortOrder = order + rootConns[order] = conn + } + parent.onReorderConnections?(rootConns) + } + return true + } + + return false + } + + private func acceptGroupDrop( + group: ConnectionGroup, + targetItem: Any?, + childIndex: Int + ) -> Bool { + if let targetGroup = targetItem as? OutlineGroup { + let newParentId = targetGroup.group.id + + if childIndex == NSOutlineViewDropOnItemIndex { + // Dropped ON the group: move as last child + var movedGroup = group + movedGroup.parentGroupId = newParentId + var siblings = parent.groups + .filter { $0.parentGroupId == newParentId && $0.id != group.id } + .sorted { $0.sortOrder < $1.sortOrder } + movedGroup.sortOrder = (siblings.last?.sortOrder ?? -1) + 1 + siblings.append(movedGroup) + parent.onReorderGroups?(siblings) + } else { + var siblings = parent.groups + .filter { $0.parentGroupId == newParentId && $0.id != group.id } + .sorted { $0.sortOrder < $1.sortOrder } + + var movedGroup = group + movedGroup.parentGroupId = newParentId + siblings.insert(movedGroup, at: min(childIndex, siblings.count)) + + for (order, var g) in siblings.enumerated() { + g.sortOrder = order + siblings[order] = g + } + parent.onReorderGroups?(siblings) + } + return true + } + + if targetItem == nil { + if childIndex == NSOutlineViewDropOnItemIndex { + // Dropped ON root: move as last root group + var movedGroup = group + movedGroup.parentGroupId = nil + var rootGroupSiblings = parent.groups + .filter { $0.parentGroupId == nil && $0.id != group.id } + .sorted { $0.sortOrder < $1.sortOrder } + movedGroup.sortOrder = (rootGroupSiblings.last?.sortOrder ?? -1) + 1 + rootGroupSiblings.append(movedGroup) + parent.onReorderGroups?(rootGroupSiblings) + } else { + var rootGroupSiblings = parent.groups + .filter { $0.parentGroupId == nil && $0.id != group.id } + .sorted { $0.sortOrder < $1.sortOrder } + + var movedGroup = group + movedGroup.parentGroupId = nil + rootGroupSiblings.insert(movedGroup, at: min(childIndex, rootGroupSiblings.count)) + + for (order, var g) in rootGroupSiblings.enumerated() { + g.sortOrder = order + rootGroupSiblings[order] = g + } + parent.onReorderGroups?(rootGroupSiblings) + } + return true + } + + return false + } + + // MARK: - NSOutlineViewDelegate + + func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { + DesignConstants.RowHeight.comfortable + } + + func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { + if let outlineGroup = item as? OutlineGroup { + let cellView: GroupCellView + if let reused = outlineView.makeView(withIdentifier: .groupCell, owner: self) as? GroupCellView { + cellView = reused + } else { + cellView = GroupCellView() + } + cellView.configure(group: outlineGroup.group, connectionCount: totalConnectionCount(for: outlineGroup.group.id)) + return cellView + } + if let outlineConn = item as? OutlineConnection { + let cellView: ConnectionCellView + if let reused = outlineView.makeView(withIdentifier: .connectionCell, owner: self) as? ConnectionCellView { + cellView = reused + } else { + cellView = ConnectionCellView() + } + cellView.configure(connection: outlineConn.connection) + return cellView + } + return nil + } + + func outlineViewSelectionDidChange(_ notification: Notification) { + guard !isSyncingSelection else { return } + guard let outlineView = notification.object as? NSOutlineView else { return } + + let row = outlineView.selectedRow + guard row >= 0 else { + parent.onSelectionChanged?(nil) + return + } + + let item = outlineView.item(atRow: row) + if let outlineGroup = item as? OutlineGroup { + parent.onSelectionChanged?(outlineGroup.group.id) + } else if let outlineConn = item as? OutlineConnection { + parent.onSelectionChanged?(outlineConn.connection.id) + } else { + parent.onSelectionChanged?(nil) + } + } + + func outlineViewItemDidExpand(_ notification: Notification) { + guard let outlineGroup = notification.userInfo?["NSObject"] as? OutlineGroup else { return } + let groupId = outlineGroup.group.id + if !parent.expandedGroupIds.contains(groupId) { + parent.onToggleGroup?(groupId) + } + } + + func outlineViewItemDidCollapse(_ notification: Notification) { + guard let outlineGroup = notification.userInfo?["NSObject"] as? OutlineGroup else { return } + let groupId = outlineGroup.group.id + if parent.expandedGroupIds.contains(groupId) { + parent.onToggleGroup?(groupId) + } + } + + // MARK: - Double Click + + @objc func handleDoubleClick() { + guard let outlineView else { return } + let row = outlineView.clickedRow + guard row >= 0 else { return } + + let item = outlineView.item(atRow: row) + if let outlineConn = item as? OutlineConnection { + parent.onDoubleClickConnection?(outlineConn.connection) + } else if let outlineGroup = item as? OutlineGroup { + parent.onToggleGroup?(outlineGroup.group.id) + } + } + + private func totalConnectionCount(for groupId: UUID) -> Int { + let directConns = parent.connections.filter { $0.groupId == groupId }.count + let childGroupIds = parent.groups.filter { $0.parentGroupId == groupId }.map(\.id) + let nested = childGroupIds.reduce(0) { $0 + totalConnectionCount(for: $1) } + return directConns + nested + } + + // MARK: - Context Menus + + func contextMenu(for outlineGroup: OutlineGroup) -> NSMenu { + let menu = NSMenu() + let group = outlineGroup.group + + let newConnItem = NSMenuItem( + title: String(localized: "New Connection..."), + action: #selector(contextMenuNewConnection), + keyEquivalent: "" + ) + newConnItem.target = self + menu.addItem(newConnItem) + + let newSubgroupItem = NSMenuItem( + title: String(localized: "New Subgroup..."), + action: #selector(contextMenuNewSubgroup(_:)), + keyEquivalent: "" + ) + newSubgroupItem.target = self + newSubgroupItem.representedObject = group.id + menu.addItem(newSubgroupItem) + + menu.addItem(.separator()) + + let editItem = NSMenuItem( + title: String(localized: "Edit Group..."), + action: #selector(contextMenuEditGroup(_:)), + keyEquivalent: "" + ) + editItem.target = self + editItem.representedObject = group + menu.addItem(editItem) + + menu.addItem(.separator()) + + let deleteItem = NSMenuItem( + title: String(localized: "Delete Group"), + action: #selector(contextMenuDeleteGroup(_:)), + keyEquivalent: "" + ) + deleteItem.target = self + deleteItem.representedObject = group + deleteItem.setDestructiveStyle() + menu.addItem(deleteItem) + + return menu + } + + func contextMenu(for outlineConn: OutlineConnection) -> NSMenu { + let menu = NSMenu() + let connection = outlineConn.connection + + let connectItem = NSMenuItem( + title: String(localized: "Connect"), + action: #selector(contextMenuConnect(_:)), + keyEquivalent: "" + ) + connectItem.target = self + connectItem.representedObject = connection + menu.addItem(connectItem) + + menu.addItem(.separator()) + + let editItem = NSMenuItem( + title: String(localized: "Edit"), + action: #selector(contextMenuEditConnection(_:)), + keyEquivalent: "" + ) + editItem.target = self + editItem.representedObject = connection + menu.addItem(editItem) + + let duplicateItem = NSMenuItem( + title: String(localized: "Duplicate"), + action: #selector(contextMenuDuplicateConnection(_:)), + keyEquivalent: "" + ) + duplicateItem.target = self + duplicateItem.representedObject = connection + menu.addItem(duplicateItem) + + menu.addItem(.separator()) + + // Move to Group submenu + let moveMenu = NSMenu() + + let noneItem = NSMenuItem( + title: String(localized: "None"), + action: #selector(contextMenuMoveToGroup(_:)), + keyEquivalent: "" + ) + noneItem.target = self + noneItem.representedObject = ConnectionMoveInfo(connection: connection, targetGroupId: nil) + if connection.groupId == nil { + noneItem.state = .on + } + moveMenu.addItem(noneItem) + + moveMenu.addItem(.separator()) + + let rootGroups = parent.groups + .filter { $0.parentGroupId == nil } + .sorted { $0.sortOrder < $1.sortOrder } + + for group in rootGroups { + let groupItem = NSMenuItem( + title: group.name, + action: #selector(contextMenuMoveToGroup(_:)), + keyEquivalent: "" + ) + groupItem.target = self + groupItem.representedObject = ConnectionMoveInfo(connection: connection, targetGroupId: group.id) + groupItem.image = NSImage(systemSymbolName: "folder.fill", accessibilityDescription: nil) + if connection.groupId == group.id { + groupItem.state = .on + } + moveMenu.addItem(groupItem) + } + + let moveItem = NSMenuItem(title: String(localized: "Move to Group"), action: nil, keyEquivalent: "") + moveItem.submenu = moveMenu + menu.addItem(moveItem) + + menu.addItem(.separator()) + + let deleteItem = NSMenuItem( + title: String(localized: "Delete"), + action: #selector(contextMenuDeleteConnection(_:)), + keyEquivalent: "" + ) + deleteItem.target = self + deleteItem.representedObject = connection + deleteItem.setDestructiveStyle() + menu.addItem(deleteItem) + + return menu + } + + func emptySpaceContextMenu() -> NSMenu { + let menu = NSMenu() + + let newConnItem = NSMenuItem( + title: String(localized: "New Connection..."), + action: #selector(contextMenuNewConnection), + keyEquivalent: "" + ) + newConnItem.target = self + menu.addItem(newConnItem) + + let newGroupItem = NSMenuItem( + title: String(localized: "New Group..."), + action: #selector(contextMenuNewGroupAtRoot), + keyEquivalent: "" + ) + newGroupItem.target = self + menu.addItem(newGroupItem) + + return menu + } + + // MARK: - Context Menu Actions + + @objc private func contextMenuNewConnection() { + parent.onNewConnection?() + } + + @objc private func contextMenuNewSubgroup(_ sender: NSMenuItem) { + guard let parentId = sender.representedObject as? UUID else { return } + parent.onNewGroup?(parentId) + } + + @objc private func contextMenuNewGroupAtRoot() { + parent.onNewGroup?(nil) + } + + @objc private func contextMenuEditGroup(_ sender: NSMenuItem) { + guard let group = sender.representedObject as? ConnectionGroup else { return } + parent.onEditGroup?(group) + } + + @objc private func contextMenuDeleteGroup(_ sender: NSMenuItem) { + guard let group = sender.representedObject as? ConnectionGroup else { return } + parent.onDeleteGroup?(group) + } + + @objc private func contextMenuConnect(_ sender: NSMenuItem) { + guard let connection = sender.representedObject as? DatabaseConnection else { return } + parent.onDoubleClickConnection?(connection) + } + + @objc private func contextMenuEditConnection(_ sender: NSMenuItem) { + guard let connection = sender.representedObject as? DatabaseConnection else { return } + parent.onEditConnection?(connection) + } + + @objc private func contextMenuDuplicateConnection(_ sender: NSMenuItem) { + guard let connection = sender.representedObject as? DatabaseConnection else { return } + parent.onDuplicateConnection?(connection) + } + + @objc private func contextMenuDeleteConnection(_ sender: NSMenuItem) { + guard let connection = sender.representedObject as? DatabaseConnection else { return } + parent.onDeleteConnection?(connection) + } + + @objc private func contextMenuMoveToGroup(_ sender: NSMenuItem) { + guard let moveInfo = sender.representedObject as? ConnectionMoveInfo else { return } + parent.onMoveConnectionToGroup?(moveInfo.connection, moveInfo.targetGroupId) + } + } +} + +// MARK: - ConnectionMoveInfo + +/// Helper to pass both connection and target group ID through NSMenuItem.representedObject +private final class ConnectionMoveInfo: NSObject { + let connection: DatabaseConnection + let targetGroupId: UUID? + + init(connection: DatabaseConnection, targetGroupId: UUID?) { + self.connection = connection + self.targetGroupId = targetGroupId + } +} + +// MARK: - NSMenuItem Destructive Style + +private extension NSMenuItem { + func setDestructiveStyle() { + let attributes: [NSAttributedString.Key: Any] = [ + .foregroundColor: NSColor.systemRed, + ] + attributedTitle = NSAttributedString(string: title, attributes: attributes) + } +} diff --git a/TablePro/Views/WelcomeWindowView.swift b/TablePro/Views/WelcomeWindowView.swift index f21724c5..b3b663a7 100644 --- a/TablePro/Views/WelcomeWindowView.swift +++ b/TablePro/Views/WelcomeWindowView.swift @@ -19,13 +19,9 @@ struct WelcomeWindowView: View { @State private var connections: [DatabaseConnection] = [] @State private var searchText = "" - @State private var showNewConnectionSheet = false - @State private var showEditConnectionSheet = false - @State private var connectionToEdit: DatabaseConnection? @State private var connectionToDelete: DatabaseConnection? @State private var showDeleteConfirmation = false - @State private var hoveredConnectionId: UUID? - @State private var selectedConnectionId: UUID? // For keyboard navigation + @State private var selectedConnectionId: UUID? @State private var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding() // Group state @@ -230,218 +226,88 @@ struct WelcomeWindowView: View { Divider() // Connection list - if filteredConnections.isEmpty { + if connections.isEmpty, groups.isEmpty { + emptyState + } else if !searchText.isEmpty, filteredConnections.isEmpty { emptyState } else { connectionList } } .frame(minWidth: 350) - .contentShape(Rectangle()) - .contextMenu { newConnectionContextMenu } - } - - @ViewBuilder - private var newConnectionContextMenu: some View { - Button(action: { openWindow(id: "connection-form") }) { - Label("New Connection...", systemImage: "plus") - } - Button(action: { - groupToEdit = nil - newGroupParentId = nil - showNewGroupSheet = true - }) { - Label("New Group...", systemImage: "folder.badge.plus") - } } - // MARK: - Connection List + // MARK: - Connection List (NSOutlineView) - /// Connection list with group hierarchy support. - /// When searching: flat filtered list. Otherwise: grouped with DisclosureGroups. private var connectionList: some View { - List(selection: $selectedConnectionId) { - if !searchText.isEmpty { - // Flat filtered list during search - ForEach(filteredConnections) { connection in - connectionRow(for: connection) - } - } else { - // Grouped hierarchical view - ForEach(rootGroups) { group in - groupSection(for: group) - } - .onMove { from, to in - moveRootGroups(from: from, to: to) - } - - // Ungrouped connections - ForEach(ungroupedConnections) { connection in - connectionRow(for: connection) - } - .onMove { from, to in - moveUngroupedConnections(from: from, to: to) - } - } - } - .listStyle(.inset) - .scrollContentBackground(.hidden) - .environment(\.defaultMinListRowHeight, 44) - .dropDestination(for: String.self) { items, _ in - handleDropOnGroup(items: items, targetGroupId: nil) - } - .onKeyPress(.return) { - if let id = selectedConnectionId, - let connection = connections.first(where: { $0.id == id }) - { + ConnectionOutlineView( + groups: groups, + connections: searchText.isEmpty ? connections : filteredConnections, + expandedGroupIds: expandedGroups, + selectedItemId: selectedConnectionId, + searchText: searchText, + onSelectionChanged: { id in + selectedConnectionId = id + }, + onDoubleClickConnection: { connection in connectToDatabase(connection) - } - return .handled - } - } - - // MARK: - Group Hierarchy - - private var rootGroups: [ConnectionGroup] { - groups.filter { $0.parentGroupId == nil } - .sorted { $0.sortOrder < $1.sortOrder } - } - - private var ungroupedConnections: [DatabaseConnection] { - connections.filter { $0.groupId == nil } - .sorted { $0.sortOrder < $1.sortOrder } - } - - private func childGroups(of parentId: UUID) -> [ConnectionGroup] { - groups.filter { $0.parentGroupId == parentId } - .sorted { $0.sortOrder < $1.sortOrder } - } - - private func connectionsInGroup(_ groupId: UUID) -> [DatabaseConnection] { - connections.filter { $0.groupId == groupId } - .sorted { $0.sortOrder < $1.sortOrder } - } - - private func totalConnectionCount(in group: ConnectionGroup) -> Int { - let direct = connections.filter { $0.groupId == group.id }.count - let children = childGroups(of: group.id) - let nested = children.reduce(0) { $0 + totalConnectionCount(in: $1) } - return direct + nested - } - - private func expandedBinding(_ groupId: UUID) -> Binding { - Binding( - get: { expandedGroups.contains(groupId) }, - set: { isExpanded in - if isExpanded { - expandedGroups.insert(groupId) - } else { - expandedGroups.remove(groupId) - } - groupStorage.saveExpandedGroupIds(expandedGroups) - } - ) - } - - private func groupSection(for group: ConnectionGroup) -> AnyView { - AnyView( - DisclosureGroup(isExpanded: expandedBinding(group.id)) { - ForEach(childGroups(of: group.id)) { child in - groupSection(for: child) - } - ForEach(connectionsInGroup(group.id)) { connection in - connectionRow(for: connection) + }, + onToggleGroup: { groupId in + toggleGroup(groupId) + }, + onMoveConnection: { connection, newGroupId in + moveConnectionToGroup(connection, groupId: newGroupId) + }, + onReorderConnections: { reorderedConns in + for conn in reorderedConns { + storage.updateConnection(conn) } - } label: { - groupRowLabel(group) - } - ) - } - - private func groupRowLabel(_ group: ConnectionGroup) -> some View { - HStack(spacing: 8) { - Image(systemName: "folder.fill") - .foregroundStyle(group.color.isDefault ? .secondary : group.color.color) - .font(.system(size: DesignConstants.FontSize.body)) - - Text(group.name) - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - .foregroundStyle(.primary) - - Text("\(totalConnectionCount(in: group))") - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.tertiary) - - Spacer() - } - .contentShape(Rectangle()) - .overlay( - DoubleClickView { - if expandedGroups.contains(group.id) { - expandedGroups.remove(group.id) - } else { - expandedGroups.insert(group.id) + loadConnections() + }, + onReorderGroups: { reorderedGroups in + for group in reorderedGroups { + groupStorage.updateGroup(group) } - groupStorage.saveExpandedGroupIds(expandedGroups) - } - ) - .dropDestination(for: String.self) { items, _ in - handleDropOnGroup(items: items, targetGroupId: group.id) - } - .contextMenu { - Button(action: { openWindow(id: "connection-form") }) { - Label("New Connection...", systemImage: "plus") - } - Button(action: { + loadConnections() + }, + onMoveGroup: { group, newParentId in + var updated = group + updated.parentGroupId = newParentId + groupStorage.updateGroup(updated) + loadConnections() + }, + onNewConnection: { + openWindow(id: "connection-form") + }, + onNewGroup: { parentId in groupToEdit = nil - newGroupParentId = group.id + newGroupParentId = parentId showNewGroupSheet = true - }) { - Label("New Subgroup...", systemImage: "folder.badge.plus") - } - Divider() - Button(action: { + }, + onEditGroup: { group in groupToEdit = group newGroupParentId = group.parentGroupId showNewGroupSheet = true - }) { - Label("Edit Group...", systemImage: "pencil") - } - Divider() - Button(role: .destructive) { + }, + onDeleteGroup: { group in groupToDelete = group showDeleteGroupConfirmation = true - } label: { - Label("Delete Group", systemImage: "trash") - } - } - } - - @ViewBuilder - private func connectionRow(for connection: DatabaseConnection) -> some View { - ConnectionRow( - connection: connection, - groups: groups, - onConnect: { connectToDatabase(connection) }, - onEdit: { + }, + onEditConnection: { connection in openWindow(id: "connection-form", value: connection.id as UUID?) focusConnectionFormWindow() }, - onDuplicate: { + onDuplicateConnection: { connection in duplicateConnection(connection) }, - onDelete: { + onDeleteConnection: { connection in connectionToDelete = connection showDeleteConfirmation = true }, - onMoveToGroup: { groupId in + onMoveConnectionToGroup: { connection, groupId in moveConnectionToGroup(connection, groupId: groupId) } ) - .tag(connection.id) - .listRowInsets(DesignConstants.swiftUIListRowInsets) - .listRowSeparator(.hidden) - .draggable(connection.id.uuidString) } // MARK: - Empty State @@ -571,199 +437,13 @@ struct WelcomeWindowView: View { loadConnections() } - private func moveUngroupedConnections(from: IndexSet, to: Int) { - var ungrouped = connections.filter { $0.groupId == nil } - .sorted { $0.sortOrder < $1.sortOrder } - ungrouped.move(fromOffsets: from, toOffset: to) - for (index, var conn) in ungrouped.enumerated() { - conn.sortOrder = index - storage.updateConnection(conn) - } - loadConnections() - } - - private func moveRootGroups(from: IndexSet, to: Int) { - var roots = groups.filter { $0.parentGroupId == nil } - .sorted { $0.sortOrder < $1.sortOrder } - roots.move(fromOffsets: from, toOffset: to) - for (index, var group) in roots.enumerated() { - group.sortOrder = index - groupStorage.updateGroup(group) - } - loadConnections() - } - - private func handleDropOnGroup(items: [String], targetGroupId: UUID?) -> Bool { - var changed = false - for item in items { - guard let uuid = UUID(uuidString: item) else { continue } - if var conn = connections.first(where: { $0.id == uuid }) { - guard conn.groupId != targetGroupId else { continue } - conn.groupId = targetGroupId - storage.updateConnection(conn) - changed = true - } - } - if changed { - loadConnections() - } - return changed - } -} - -// MARK: - ConnectionRow - -private struct ConnectionRow: View { - let connection: DatabaseConnection - var groups: [ConnectionGroup] = [] - var onConnect: (() -> Void)? - var onEdit: (() -> Void)? - var onDuplicate: (() -> Void)? - var onDelete: (() -> Void)? - var onMoveToGroup: ((UUID?) -> Void)? - - private var displayTag: ConnectionTag? { - guard let tagId = connection.tagId else { return nil } - return TagStorage.shared.tag(for: tagId) - } - - var body: some View { - HStack(spacing: 12) { - // Database type icon - Image(connection.type.iconName) - .renderingMode(.template) - .font(.system(size: DesignConstants.IconSize.medium)) - .foregroundStyle(connection.displayColor) - .frame( - width: DesignConstants.IconSize.medium, height: DesignConstants.IconSize.medium) - - // Connection info - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(connection.name) - .font(.system(size: DesignConstants.FontSize.body, weight: .medium)) - .foregroundStyle(.primary) - - // Tag (single) - if let tag = displayTag { - Text(tag.name) - .font(.system(size: DesignConstants.FontSize.tiny)) - .foregroundStyle(tag.color.color) - .padding(.horizontal, DesignConstants.Spacing.xxs) - .padding(.vertical, DesignConstants.Spacing.xxxs) - .background( - RoundedRectangle(cornerRadius: 4).fill( - tag.color.color.opacity(0.15))) - } - } - - Text(connectionSubtitle) - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - } - .padding(.vertical, DesignConstants.Spacing.xxs) - .contentShape(Rectangle()) - .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 !groups.isEmpty, let onMoveToGroup = onMoveToGroup { - Divider() - Menu("Move to Group") { - Button { - onMoveToGroup(nil) - } label: { - HStack { - Text("None") - if connection.groupId == nil { - Spacer() - Image(systemName: "checkmark") - } - } - } - Divider() - ForEach(groups.filter { $0.parentGroupId == nil }.sorted { $0.sortOrder < $1.sortOrder }) { group in - Button { - onMoveToGroup(group.id) - } label: { - HStack { - Image(systemName: "folder.fill") - Text(group.name) - if connection.groupId == group.id { - Spacer() - Image(systemName: "checkmark") - } - } - } - } - } - } - - if let onDelete = onDelete { - Divider() - Button(role: .destructive, action: onDelete) { - Label("Delete", systemImage: "trash") - } - } - } - } - - private var connectionSubtitle: String { - if connection.sshConfig.enabled { - return "SSH : \(connection.sshConfig.username)@\(connection.sshConfig.host)" - } - if connection.host.isEmpty { - return connection.database.isEmpty ? connection.type.rawValue : connection.database - } - return connection.host - } -} - -// 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 + private func toggleGroup(_ groupId: UUID) { + if expandedGroups.contains(groupId) { + expandedGroups.remove(groupId) + } else { + expandedGroups.insert(groupId) } - return .local - } - - var body: some View { - Text("(\(environment.rawValue.lowercased()))") - .font(.system(size: DesignConstants.FontSize.small)) - .foregroundStyle(environment.badgeColor) + groupStorage.saveExpandedGroupIds(expandedGroups) } } @@ -808,51 +488,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 { - let onDoubleClick: () -> Void - - func makeNSView(context: Context) -> NSView { - let view = PassThroughDoubleClickView() - view.onDoubleClick = onDoubleClick - return view - } - - func updateNSView(_ nsView: NSView, context: Context) { - (nsView as? PassThroughDoubleClickView)?.onDoubleClick = onDoubleClick - } -} - -private class PassThroughDoubleClickView: NSView { - var onDoubleClick: (() -> Void)? - - override func mouseDown(with event: NSEvent) { - if event.clickCount >= 2, event.clickCount % 2 == 0 { - onDoubleClick?() - } - // Always forward to next responder for List selection - super.mouseDown(with: event) - } -} - // MARK: - Preview #Preview("Welcome Window") { From e00e14e0401006143164fccca8d5ffe17e7f7304 Mon Sep 17 00:00:00 2001 From: Huy TQ <5723282+imhuytq@users.noreply.github.com> Date: Mon, 2 Mar 2026 18:30:42 +0700 Subject: [PATCH 3/3] chore: update localization strings --- TablePro/Resources/Localizable.xcstrings | 1 + 1 file changed, 1 insertion(+) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 4b483457..2bddf687 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -63,6 +63,7 @@ } }, "(%@)" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : {