From dbae83da8a678f4b3fff67b2b1eefc9de8f86504 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 28 Mar 2026 14:35:50 +0700 Subject: [PATCH] refactor: extract WelcomeViewModel and split WelcomeWindowView into focused files --- TablePro/Core/Storage/GroupStorage.swift | 10 +- TablePro/Core/Storage/TagStorage.swift | 13 +- TablePro/ViewModels/WelcomeViewModel.swift | 526 ++++++++ .../Connection/WelcomeConnectionRow.swift | 98 ++ .../Connection/WelcomeContextMenus.swift | 179 +++ .../Views/Connection/WelcomeLeftPanel.swift | 133 +++ .../Views/Connection/WelcomeWindowView.swift | 1057 ++--------------- 7 files changed, 1067 insertions(+), 949 deletions(-) create mode 100644 TablePro/ViewModels/WelcomeViewModel.swift create mode 100644 TablePro/Views/Connection/WelcomeConnectionRow.swift create mode 100644 TablePro/Views/Connection/WelcomeContextMenus.swift create mode 100644 TablePro/Views/Connection/WelcomeLeftPanel.swift diff --git a/TablePro/Core/Storage/GroupStorage.swift b/TablePro/Core/Storage/GroupStorage.swift index 2ca9fb48..782aef86 100644 --- a/TablePro/Core/Storage/GroupStorage.swift +++ b/TablePro/Core/Storage/GroupStorage.swift @@ -15,6 +15,7 @@ final class GroupStorage { private let defaults = UserDefaults.standard private let encoder = JSONEncoder() private let decoder = JSONDecoder() + private var cachedGroups: [ConnectionGroup]? private init() {} @@ -22,14 +23,20 @@ final class GroupStorage { /// Load all groups func loadGroups() -> [ConnectionGroup] { + if let cached = cachedGroups { return cached } + guard let data = defaults.data(forKey: groupsKey) else { + cachedGroups = [] return [] } do { - return try decoder.decode([ConnectionGroup].self, from: data) + let groups = try decoder.decode([ConnectionGroup].self, from: data) + cachedGroups = groups + return groups } catch { Self.logger.error("Failed to load groups: \(error)") + cachedGroups = [] return [] } } @@ -39,6 +46,7 @@ final class GroupStorage { do { let data = try encoder.encode(groups) defaults.set(data, forKey: groupsKey) + cachedGroups = nil SyncChangeTracker.shared.markDirty(.group, ids: groups.map { $0.id.uuidString }) } catch { Self.logger.error("Failed to save groups: \(error)") diff --git a/TablePro/Core/Storage/TagStorage.swift b/TablePro/Core/Storage/TagStorage.swift index 7283d45a..7b6271e4 100644 --- a/TablePro/Core/Storage/TagStorage.swift +++ b/TablePro/Core/Storage/TagStorage.swift @@ -17,6 +17,7 @@ final class TagStorage { private let defaults = UserDefaults.standard private let encoder = JSONEncoder() private let decoder = JSONDecoder() + private var cachedTags: [ConnectionTag]? private init() { // Initialize with presets on first launch @@ -29,16 +30,23 @@ final class TagStorage { /// Load all tags (presets + custom) func loadTags() -> [ConnectionTag] { + if let cached = cachedTags { return cached } + guard let data = defaults.data(forKey: tagsKey) else { - return ConnectionTag.presets + let tags = ConnectionTag.presets + cachedTags = tags + return tags } do { let tags = try decoder.decode([ConnectionTag].self, from: data) + cachedTags = tags return tags } catch { Self.logger.error("Failed to load tags: \(error)") - return ConnectionTag.presets + let tags = ConnectionTag.presets + cachedTags = tags + return tags } } @@ -47,6 +55,7 @@ final class TagStorage { do { let data = try encoder.encode(tags) defaults.set(data, forKey: tagsKey) + cachedTags = nil SyncChangeTracker.shared.markDirty(.tag, ids: tags.map { $0.id.uuidString }) } catch { Self.logger.error("Failed to save tags: \(error)") diff --git a/TablePro/ViewModels/WelcomeViewModel.swift b/TablePro/ViewModels/WelcomeViewModel.swift new file mode 100644 index 00000000..297892c0 --- /dev/null +++ b/TablePro/ViewModels/WelcomeViewModel.swift @@ -0,0 +1,526 @@ +// +// WelcomeViewModel.swift +// TablePro +// + +import AppKit +import os +import SwiftUI + +enum WelcomeActiveSheet: Identifiable { + case newGroup + case activation + case importFile(URL) + case exportConnections([DatabaseConnection]) + + var id: String { + switch self { + case .newGroup: "newGroup" + case .activation: "activation" + case .importFile(let u): "importFile-\(u.absoluteString)" + case .exportConnections: "exportConnections" + } + } +} + +@MainActor @Observable +final class WelcomeViewModel { + private static let logger = Logger(subsystem: "com.TablePro", category: "WelcomeViewModel") + + private let storage = ConnectionStorage.shared + private let groupStorage = GroupStorage.shared + private let dbManager = DatabaseManager.shared + + // MARK: - State + + var connections: [DatabaseConnection] = [] + var searchText = "" + var selectedConnectionIds: Set = [] + var groups: [ConnectionGroup] = [] + var linkedConnections: [LinkedConnection] = [] + var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding() + var connectionsToDelete: [DatabaseConnection] = [] + var showDeleteConfirmation = false + var pendingMoveToNewGroup: [DatabaseConnection] = [] + var activeSheet: WelcomeActiveSheet? + var pluginInstallConnection: DatabaseConnection? + + var renameGroupTarget: ConnectionGroup? + var renameGroupName = "" + var showRenameGroupAlert = false + + var collapsedGroupIds: Set = { + let strings = UserDefaults.standard.stringArray(forKey: "com.TablePro.collapsedGroupIds") ?? [] + return Set(strings.compactMap { UUID(uuidString: $0) }) + }() { + didSet { + UserDefaults.standard.set( + Array(collapsedGroupIds.map(\.uuidString)), + forKey: "com.TablePro.collapsedGroupIds" + ) + } + } + + // MARK: - Notification Observers + + @ObservationIgnored private var openWindow: OpenWindowAction? + @ObservationIgnored private var connectionUpdatedObserver: NSObjectProtocol? + @ObservationIgnored private var shareFileObserver: NSObjectProtocol? + @ObservationIgnored private var exportObserver: NSObjectProtocol? + @ObservationIgnored private var importObserver: NSObjectProtocol? + @ObservationIgnored private var linkedFoldersObserver: NSObjectProtocol? + @ObservationIgnored private var newConnectionObserver: NSObjectProtocol? + + // MARK: - Computed Properties + + var filteredConnections: [DatabaseConnection] { + if searchText.isEmpty { + return connections + } + return connections.filter { connection in + connection.name.localizedCaseInsensitiveContains(searchText) + || connection.host.localizedCaseInsensitiveContains(searchText) + || connection.database.localizedCaseInsensitiveContains(searchText) + || groupName(for: connection.groupId)?.localizedCaseInsensitiveContains(searchText) == true + } + } + + var ungroupedConnections: [DatabaseConnection] { + let validGroupIds = Set(groups.map(\.id)) + return filteredConnections.filter { conn in + guard let groupId = conn.groupId else { return true } + return !validGroupIds.contains(groupId) + } + } + + var activeGroups: [ConnectionGroup] { + let groupIds = Set(filteredConnections.compactMap(\.groupId)) + return groups.filter { groupIds.contains($0.id) } + } + + var flatVisibleConnections: [DatabaseConnection] { + var result = ungroupedConnections + for group in activeGroups where !collapsedGroupIds.contains(group.id) { + result.append(contentsOf: connections(in: group)) + } + return result + } + + var selectedConnections: [DatabaseConnection] { + connections.filter { selectedConnectionIds.contains($0.id) } + } + + var isMultipleSelection: Bool { + selectedConnectionIds.count > 1 + } + + func groupName(for groupId: UUID?) -> String? { + guard let groupId else { return nil } + return groups.first { $0.id == groupId }?.name + } + + func connections(in group: ConnectionGroup) -> [DatabaseConnection] { + filteredConnections.filter { $0.groupId == group.id } + } + + // MARK: - Setup & Teardown + + func setUp(openWindow: OpenWindowAction) { + self.openWindow = openWindow + guard connectionUpdatedObserver == nil else { return } + + newConnectionObserver = NotificationCenter.default.addObserver( + forName: .newConnection, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.openWindow?(id: "connection-form", value: nil as UUID?) + } + } + + connectionUpdatedObserver = NotificationCenter.default.addObserver( + forName: .connectionUpdated, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.loadConnections() + } + } + + shareFileObserver = NotificationCenter.default.addObserver( + forName: .connectionShareFileOpened, object: nil, queue: .main + ) { [weak self] notification in + Task { @MainActor [weak self] in + guard let url = notification.object as? URL else { return } + self?.activeSheet = .importFile(url) + } + } + + exportObserver = NotificationCenter.default.addObserver( + forName: .exportConnections, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, !self.connections.isEmpty else { return } + self.activeSheet = .exportConnections(self.connections) + } + } + + importObserver = NotificationCenter.default.addObserver( + forName: .importConnections, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.importConnectionsFromFile() + } + } + + linkedFoldersObserver = NotificationCenter.default.addObserver( + forName: .linkedFoldersDidUpdate, object: nil, queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.linkedConnections = LinkedFolderWatcher.shared.linkedConnections + } + } + + loadConnections() + linkedConnections = LinkedFolderWatcher.shared.linkedConnections + } + + deinit { + [connectionUpdatedObserver, shareFileObserver, exportObserver, + importObserver, linkedFoldersObserver, newConnectionObserver].forEach { + if let observer = $0 { + NotificationCenter.default.removeObserver(observer) + } + } + } + + // MARK: - Data Loading + + func loadConnections() { + connections = storage.loadConnections() + loadGroups() + } + + func loadGroups() { + groups = groupStorage.loadGroups() + } + + // MARK: - Connection Actions + + func connectToDatabase(_ connection: DatabaseConnection) { + guard let openWindow else { return } + WindowOpener.shared.pendingConnectionId = connection.id + openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) + NSApplication.shared.closeWindows(withId: "welcome") + + Task { + do { + try await dbManager.connectToSession(connection) + } catch { + if case PluginError.pluginNotInstalled = error { + Self.logger.info("Plugin not installed for \(connection.type.rawValue), prompting install") + handleMissingPlugin(connection: connection) + } else { + Self.logger.error( + "Failed to connect: \(error.localizedDescription, privacy: .public)") + handleConnectionFailure(error: error) + } + } + } + } + + func connectAfterInstall(_ connection: DatabaseConnection) { + guard let openWindow else { return } + WindowOpener.shared.pendingConnectionId = connection.id + openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) + NSApplication.shared.closeWindows(withId: "welcome") + + Task { + do { + try await dbManager.connectToSession(connection) + } catch { + Self.logger.error( + "Failed to connect after plugin install: \(error.localizedDescription, privacy: .public)") + handleConnectionFailure(error: error) + } + } + } + + func connectSelectedConnections() { + for connection in selectedConnections { + connectToDatabase(connection) + } + } + + func connectToLinkedConnection(_ linked: LinkedConnection) { + let connection = DatabaseConnection( + id: linked.id, + name: linked.connection.name, + host: linked.connection.host, + port: linked.connection.port, + database: linked.connection.database, + username: linked.connection.username, + type: DatabaseType(rawValue: linked.connection.type) + ) + connectToDatabase(connection) + } + + func duplicateConnection(_ connection: DatabaseConnection) { + let duplicate = storage.duplicateConnection(connection) + loadConnections() + openWindow?(id: "connection-form", value: duplicate.id as UUID?) + focusConnectionFormWindow() + } + + // MARK: - Delete + + func deleteSelectedConnections() { + let idsToDelete = Set(connectionsToDelete.map(\.id)) + storage.deleteConnections(connectionsToDelete) + connections.removeAll { idsToDelete.contains($0.id) } + selectedConnectionIds.subtract(idsToDelete) + connectionsToDelete = [] + } + + // MARK: - Groups + + 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() + } + + func beginRenameGroup(_ group: ConnectionGroup) { + renameGroupTarget = group + renameGroupName = group.name + showRenameGroupAlert = true + } + + func confirmRenameGroup() { + guard let target = renameGroupTarget else { return } + let newName = renameGroupName.trimmingCharacters(in: .whitespaces) + guard !newName.isEmpty else { return } + let isDuplicate = groups.contains { + $0.id != target.id && $0.name.lowercased() == newName.lowercased() + } + guard !isDuplicate else { return } + var updated = target + updated.name = newName + groupStorage.updateGroup(updated) + groups = groupStorage.loadGroups() + renameGroupTarget = nil + } + + func updateGroupColor(_ group: ConnectionGroup, color: ConnectionColor) { + var updated = group + updated.color = color + groupStorage.updateGroup(updated) + groups = groupStorage.loadGroups() + } + + func moveConnections(_ targets: [DatabaseConnection], toGroup groupId: UUID) { + let ids = Set(targets.map(\.id)) + for i in connections.indices where ids.contains(connections[i].id) { + connections[i].groupId = groupId + } + storage.saveConnections(connections) + } + + func removeFromGroup(_ targets: [DatabaseConnection]) { + let ids = Set(targets.map(\.id)) + for i in connections.indices where ids.contains(connections[i].id) { + connections[i].groupId = nil + } + storage.saveConnections(connections) + } + + // MARK: - Import / Export + + func exportConnections(_ connectionsToExport: [DatabaseConnection]) { + activeSheet = .exportConnections(connectionsToExport) + } + + func importConnectionsFromFile() { + let panel = NSOpenPanel() + panel.allowedContentTypes = [.tableproConnectionShare] + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + guard panel.runModal() == .OK, let url = panel.url else { return } + activeSheet = .importFile(url) + } + + func showImportResultAlert(count: Int) { + let alert = NSAlert() + if count > 0 { + alert.alertStyle = .informational + alert.messageText = String(localized: "Import Complete") + alert.informativeText = count == 1 + ? String(localized: "1 connection was imported.") + : String(localized: "\(count) connections were imported.") + alert.icon = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)? + .withSymbolConfiguration(.init(paletteColors: [.white, .systemGreen])) + } else { + alert.alertStyle = .informational + alert.messageText = String(localized: "No Connections Imported") + alert.informativeText = String(localized: "All selected connections were skipped.") + } + alert.addButton(withTitle: String(localized: "OK")) + if let window = NSApp.keyWindow { + alert.beginSheetModal(for: window) + } else { + alert.runModal() + } + } + + // MARK: - Keyboard Navigation + + func moveToNextConnection() { + let visible = flatVisibleConnections + guard !visible.isEmpty else { return } + let anchorId = visible.last(where: { selectedConnectionIds.contains($0.id) })?.id + guard let anchorId, + let index = visible.firstIndex(where: { $0.id == anchorId }) else { + selectedConnectionIds = Set([visible[0].id]) + return + } + let next = min(index + 1, visible.count - 1) + selectedConnectionIds = [visible[next].id] + } + + func moveToPreviousConnection() { + let visible = flatVisibleConnections + guard !visible.isEmpty else { return } + let anchorId = visible.first(where: { selectedConnectionIds.contains($0.id) })?.id + guard let anchorId, + let index = visible.firstIndex(where: { $0.id == anchorId }) else { + selectedConnectionIds = Set([visible[visible.count - 1].id]) + return + } + let prev = max(index - 1, 0) + selectedConnectionIds = [visible[prev].id] + } + + func collapseSelectedGroup() { + guard let id = selectedConnectionIds.first, + let connection = connections.first(where: { $0.id == id }), + let groupId = connection.groupId, + !collapsedGroupIds.contains(groupId) else { return } + withAnimation(.easeInOut(duration: 0.2)) { + collapsedGroupIds.insert(groupId) + } + } + + func expandSelectedGroup() { + guard let id = selectedConnectionIds.first, + let connection = connections.first(where: { $0.id == id }), + let groupId = connection.groupId, + collapsedGroupIds.contains(groupId) else { return } + withAnimation(.easeInOut(duration: 0.2)) { + collapsedGroupIds.remove(groupId) + } + } + + // MARK: - Reorder + + func moveUngroupedConnections(from source: IndexSet, to destination: Int) { + let validGroupIds = Set(groups.map(\.id)) + let ungroupedIndices = connections.indices.filter { index in + guard let groupId = connections[index].groupId else { return true } + return !validGroupIds.contains(groupId) + } + + guard source.allSatisfy({ $0 < ungroupedIndices.count }), + destination <= ungroupedIndices.count else { return } + + let globalSource = IndexSet(source.map { ungroupedIndices[$0] }) + let globalDestination: Int + 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) + } + + func moveGroupedConnections(in group: ConnectionGroup, from source: IndexSet, to destination: Int) { + let groupIndices = connections.indices.filter { connections[$0].groupId == group.id } + + guard source.allSatisfy({ $0 < groupIndices.count }), + destination <= groupIndices.count else { return } + + let globalSource = IndexSet(source.map { groupIndices[$0] }) + let globalDestination: Int + if destination < groupIndices.count { + globalDestination = groupIndices[destination] + } else if let last = groupIndices.last { + globalDestination = last + 1 + } else { + globalDestination = 0 + } + + connections.move(fromOffsets: globalSource, toOffset: globalDestination) + storage.saveConnections(connections) + } + + func moveGroups(from source: IndexSet, to destination: Int) { + let active = activeGroups + let activeGroupIndices = active.compactMap { activeGroup in + groups.firstIndex(where: { $0.id == activeGroup.id }) + } + + guard source.allSatisfy({ $0 < activeGroupIndices.count }), + destination <= activeGroupIndices.count else { return } + + let globalSource = IndexSet(source.map { activeGroupIndices[$0] }) + let globalDestination: Int + if destination < activeGroupIndices.count { + globalDestination = activeGroupIndices[destination] + } else if let last = activeGroupIndices.last { + globalDestination = last + 1 + } else { + globalDestination = 0 + } + + groups.move(fromOffsets: globalSource, toOffset: globalDestination) + groupStorage.saveGroups(groups) + } + + func focusConnectionFormWindow() { + Task { @MainActor in + for _ in 0..<10 { + for window in NSApp.windows where + window.identifier?.rawValue == "connection-form" { + window.makeKeyAndOrderFront(nil) + return + } + try? await Task.sleep(for: .milliseconds(20)) + } + } + } + + // MARK: - Private Helpers + + private func handleConnectionFailure(error: Error) { + guard let openWindow else { return } + NSApplication.shared.closeWindows(withId: "main") + openWindow(id: "welcome") + + AlertHelper.showErrorSheet( + title: String(localized: "Connection Failed"), + message: error.localizedDescription, + window: nil + ) + } + + private func handleMissingPlugin(connection: DatabaseConnection) { + guard let openWindow else { return } + NSApplication.shared.closeWindows(withId: "main") + openWindow(id: "welcome") + pluginInstallConnection = connection + } +} diff --git a/TablePro/Views/Connection/WelcomeConnectionRow.swift b/TablePro/Views/Connection/WelcomeConnectionRow.swift new file mode 100644 index 00000000..d48a8843 --- /dev/null +++ b/TablePro/Views/Connection/WelcomeConnectionRow.swift @@ -0,0 +1,98 @@ +// +// WelcomeConnectionRow.swift +// TablePro +// + +import AppKit +import SwiftUI + +struct WelcomeConnectionRow: View { + let connection: DatabaseConnection + let sshProfile: SSHProfile? + var onConnect: (() -> 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) { + connection.type.iconImage + .renderingMode(.template) + .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.medium)) + .foregroundStyle(connection.displayColor) + .frame( + width: ThemeEngine.shared.activeTheme.iconSizes.medium, + height: ThemeEngine.shared.activeTheme.iconSizes.medium + ) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(connection.name) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) + .foregroundStyle(.primary) + + if let tag = displayTag { + Text(tag.name) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) + .foregroundStyle(tag.color.color) + .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.xxs) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xxxs) + .background( + RoundedRectangle(cornerRadius: 4).fill( + tag.color.color.opacity(0.15))) + } + } + + Text(connectionSubtitle) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + } + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xxs) + .contentShape(Rectangle()) + .overlay( + DoubleClickView { onConnect?() } + ) + } + + private var connectionSubtitle: String { + let ssh = connection.effectiveSSHConfig(profile: sshProfile) + if ssh.enabled { + return "SSH : \(ssh.username)@\(ssh.host)" + } + if connection.host.isEmpty { + return connection.database.isEmpty ? connection.type.rawValue : connection.database + } + return connection.host + } +} + +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 { + onDoubleClick?() + } + super.mouseDown(with: event) + } +} diff --git a/TablePro/Views/Connection/WelcomeContextMenus.swift b/TablePro/Views/Connection/WelcomeContextMenus.swift new file mode 100644 index 00000000..fccea2e2 --- /dev/null +++ b/TablePro/Views/Connection/WelcomeContextMenus.swift @@ -0,0 +1,179 @@ +// +// WelcomeContextMenus.swift +// TablePro +// + +import SwiftUI + +extension WelcomeWindowView { + @ViewBuilder + func contextMenuContent(for connection: DatabaseConnection) -> some View { + if vm.isMultipleSelection, vm.selectedConnectionIds.contains(connection.id) { + Button { vm.connectSelectedConnections() } label: { + Label( + String(localized: "Connect \(vm.selectedConnectionIds.count) Connections"), + systemImage: "play.fill" + ) + } + + Divider() + + Button { + vm.exportConnections(Array(vm.selectedConnections)) + } label: { + Label( + String(localized: "Export \(vm.selectedConnectionIds.count) Connections..."), + systemImage: "square.and.arrow.up" + ) + } + + Divider() + + moveToGroupMenu(for: vm.selectedConnections) + + let validGroupIds = Set(vm.groups.map(\.id)) + if vm.selectedConnections.contains(where: { $0.groupId.map { validGroupIds.contains($0) } ?? false }) { + Button { vm.removeFromGroup(vm.selectedConnections) } label: { + Label(String(localized: "Remove from Group"), systemImage: "folder.badge.minus") + } + } + + Divider() + + Button(role: .destructive) { + vm.connectionsToDelete = vm.selectedConnections + vm.showDeleteConfirmation = true + } label: { + Label( + String(localized: "Delete \(vm.selectedConnectionIds.count) Connections"), + systemImage: "trash" + ) + } + } else { + Button { vm.connectToDatabase(connection) } label: { + Label(String(localized: "Connect"), systemImage: "play.fill") + } + + Divider() + + Button { + openWindow(id: "connection-form", value: connection.id as UUID?) + vm.focusConnectionFormWindow() + } label: { + Label(String(localized: "Edit"), systemImage: "pencil") + } + + Button { vm.duplicateConnection(connection) } label: { + Label(String(localized: "Duplicate"), systemImage: "doc.on.doc") + } + + Divider() + + Button { + let pw = ConnectionStorage.shared.loadPassword(for: connection.id) + let sshPw: String? + let sshProfile: SSHProfile? + if let profileId = connection.sshProfileId { + sshPw = SSHProfileStorage.shared.loadSSHPassword(for: profileId) + sshProfile = SSHProfileStorage.shared.profile(for: profileId) + } else { + sshPw = ConnectionStorage.shared.loadSSHPassword(for: connection.id) + sshProfile = nil + } + let url = ConnectionURLFormatter.format( + connection, + password: pw, + sshPassword: sshPw, + sshProfile: sshProfile + ) + ClipboardService.shared.writeText(url) + } label: { + Label(String(localized: "Copy as URL"), systemImage: "link") + } + + Button { + let link = ConnectionExportService.buildImportDeeplink(for: connection) + ClipboardService.shared.writeText(link) + } label: { + Label(String(localized: "Copy as Import Link"), systemImage: "link.badge.plus") + } + + Button { + vm.exportConnections([connection]) + } label: { + Label(String(localized: "Export..."), systemImage: "square.and.arrow.up") + } + + Divider() + + moveToGroupMenu(for: [connection]) + + if let groupId = connection.groupId, vm.groups.contains(where: { $0.id == groupId }) { + Button { vm.removeFromGroup([connection]) } label: { + Label(String(localized: "Remove from Group"), systemImage: "folder.badge.minus") + } + } + + Divider() + + Button(role: .destructive) { + vm.connectionsToDelete = [connection] + vm.showDeleteConfirmation = true + } label: { + Label(String(localized: "Delete"), systemImage: "trash") + } + } + } + + @ViewBuilder + func moveToGroupMenu(for targets: [DatabaseConnection]) -> some View { + let isSingle = targets.count == 1 + let currentGroupId = isSingle ? targets.first?.groupId : nil + Menu(String(localized: "Move to Group")) { + ForEach(vm.groups) { group in + Button { + vm.moveConnections(targets, toGroup: group.id) + } label: { + HStack { + if !group.color.isDefault { + Image(systemName: "circle.fill") + .foregroundStyle(group.color.color) + } + Text(group.name) + if currentGroupId == group.id { + Spacer() + Image(systemName: "checkmark") + } + } + } + .disabled(currentGroupId == group.id) + } + + if !vm.groups.isEmpty { + Divider() + } + + Button { + vm.pendingMoveToNewGroup = targets + vm.activeSheet = .newGroup + } label: { + Label(String(localized: "New Group..."), systemImage: "folder.badge.plus") + } + } + } + + @ViewBuilder + var newConnectionContextMenu: some View { + Button(action: { openWindow(id: "connection-form") }) { + Label("New Connection...", systemImage: "plus") + } + + Divider() + + Button { + vm.importConnectionsFromFile() + } label: { + Label(String(localized: "Import Connections..."), systemImage: "square.and.arrow.down") + } + } +} diff --git a/TablePro/Views/Connection/WelcomeLeftPanel.swift b/TablePro/Views/Connection/WelcomeLeftPanel.swift new file mode 100644 index 00000000..85b0853b --- /dev/null +++ b/TablePro/Views/Connection/WelcomeLeftPanel.swift @@ -0,0 +1,133 @@ +// +// WelcomeLeftPanel.swift +// TablePro +// + +import SwiftUI + +struct WelcomeLeftPanel: View { + let onActivateLicense: () -> Void + let onCreateConnection: () -> Void + + var body: some View { + VStack(spacing: 0) { + Spacer() + + VStack(spacing: 16) { + Image(nsImage: NSApp.applicationIconImage) + .resizable() + .frame(width: 80, height: 80) + .shadow(color: Color(red: 1.0, green: 0.576, blue: 0.0).opacity(0.4), radius: 20, x: 0, y: 0) + + VStack(spacing: 6) { + Text("TablePro") + .font( + .system( + size: ThemeEngine.shared.activeTheme.iconSizes.extraLarge, weight: .semibold, + design: .rounded)) + + Text("Version \(Bundle.main.appVersion)") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) + .foregroundStyle(.secondary) + + if LicenseManager.shared.status.isValid { + Label("Pro", systemImage: "checkmark.seal.fill") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium)) + .foregroundStyle(.green) + } else { + Button(action: onActivateLicense) { + Text("Activate License") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + } + + Spacer() + .frame(height: 48) + + VStack(spacing: 12) { + Button { + if let url = URL(string: "https://github.com/sponsors/datlechin") { + NSWorkspace.shared.open(url) + } + } label: { + Label("Sponsor TablePro", systemImage: "heart") + } + .buttonStyle(.plain) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.pink) + + Button(action: onCreateConnection) { + Label("Create connection...", systemImage: "plus.circle") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(WelcomeButtonStyle()) + } + .padding(.horizontal, 32) + + Spacer() + + ViewThatFits(in: .horizontal) { + HStack(spacing: 12) { + SyncStatusIndicator() + KeyboardHint(keys: "↵", label: "Connect") + KeyboardHint(keys: "⌘N", label: "New") + KeyboardHint(keys: "⌘,", label: "Settings") + } + HStack(spacing: 8) { + SyncStatusIndicator() + KeyboardHint(keys: "↵", label: "Connect") + KeyboardHint(keys: "⌘N", label: "New") + KeyboardHint(keys: "⌘,", label: nil) + } + } + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.tertiary) + .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.sm) + .padding(.bottom, ThemeEngine.shared.activeTheme.spacing.lg) + } + .frame(width: 260) + } +} + +struct WelcomeButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) + .foregroundStyle(.primary) + .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.md) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.sm) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill( + Color( + nsColor: configuration.isPressed + ? .controlBackgroundColor : .quaternaryLabelColor)) + ) + } +} + +struct KeyboardHint: View { + let keys: String + let label: String? + + var body: some View { + HStack(spacing: 4) { + Text(keys) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.caption, design: .monospaced)) + .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.xxs + 1) + .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xxxs) + .background( + RoundedRectangle(cornerRadius: 3) + .fill(Color(nsColor: .quaternaryLabelColor)) + ) + if let label { + Text(label) + } + } + } +} diff --git a/TablePro/Views/Connection/WelcomeWindowView.swift b/TablePro/Views/Connection/WelcomeWindowView.swift index 2b01e3af..b181f80e 100644 --- a/TablePro/Views/Connection/WelcomeWindowView.swift +++ b/TablePro/Views/Connection/WelcomeWindowView.swift @@ -2,110 +2,28 @@ // WelcomeWindowView.swift // TablePro // -// Separate welcome window with split-panel layout. -// Shows on app launch, closes when connecting to a database. -// import AppKit import os import SwiftUI import UniformTypeIdentifiers -// MARK: - WelcomeWindowView - struct WelcomeWindowView: View { - private static let logger = Logger(subsystem: "com.TablePro", category: "WelcomeWindowView") - private enum FocusField { case search case connectionList } - private let storage = ConnectionStorage.shared - private let groupStorage = GroupStorage.shared - private let dbManager = DatabaseManager.shared - - @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 connectionsToDelete: [DatabaseConnection] = [] - @State private var showDeleteConfirmation = false - @State private var selectedConnectionIds: Set = [] + @State var vm = WelcomeViewModel() @FocusState private var focus: FocusField? - @State private var showOnboarding = !AppSettingsStorage.shared.hasCompletedOnboarding() - @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 showNewGroupSheet = false - @State private var pendingMoveToNewGroup: [DatabaseConnection] = [] - @State private var showActivationSheet = false - @State private var pluginInstallConnection: DatabaseConnection? - @State private var importFileURL: IdentifiableURL? - @State private var pendingExportConnections: IdentifiableConnections? - @State private var linkedConnections: [LinkedConnection] = [] - - @Environment(\.openWindow) private var openWindow - - private var filteredConnections: [DatabaseConnection] { - if searchText.isEmpty { - return connections - } - return connections.filter { connection in - 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] { - let validGroupIds = Set(groups.map(\.id)) - return filteredConnections.filter { conn in - guard let groupId = conn.groupId else { return true } - return !validGroupIds.contains(groupId) - } - } - - 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 } - } - - private var flatVisibleConnections: [DatabaseConnection] { - var result = ungroupedConnections - for group in activeGroups where !collapsedGroupIds.contains(group.id) { - result.append(contentsOf: connections(in: group)) - } - return result - } - - private var selectedConnections: [DatabaseConnection] { - connections.filter { selectedConnectionIds.contains($0.id) } - } - - private var isMultipleSelection: Bool { - selectedConnectionIds.count > 1 - } + @Environment(\.openWindow) var openWindow var body: some View { ZStack { - if showOnboarding { + if vm.showOnboarding { OnboardingContentView { withAnimation(.easeInOut(duration: 0.45)) { - showOnboarding = false + vm.showOnboarding = false } } .transition(.move(edge: .leading)) @@ -118,187 +36,82 @@ struct WelcomeWindowView: View { .ignoresSafeArea() .frame(minWidth: 650, minHeight: 400) .onAppear { - loadConnections() + vm.setUp(openWindow: openWindow) focus = .search } .confirmationDialog( - connectionsToDelete.count == 1 + vm.connectionsToDelete.count == 1 ? String(localized: "Delete Connection") - : String(localized: "Delete \(connectionsToDelete.count) Connections"), - isPresented: $showDeleteConfirmation + : String(localized: "Delete \(vm.connectionsToDelete.count) Connections"), + isPresented: $vm.showDeleteConfirmation ) { Button(String(localized: "Delete"), role: .destructive) { - deleteSelectedConnections() + vm.deleteSelectedConnections() } Button(String(localized: "Cancel"), role: .cancel) { - connectionsToDelete = [] + vm.connectionsToDelete = [] } } message: { - if connectionsToDelete.count == 1, let first = connectionsToDelete.first { + if vm.connectionsToDelete.count == 1, let first = vm.connectionsToDelete.first { Text("Are you sure you want to delete \"\(first.name)\"?") } else { - Text("Are you sure you want to delete \(connectionsToDelete.count) connections? This cannot be undone.") - } - } - .onReceive(NotificationCenter.default.publisher(for: .newConnection)) { _ in - openWindow(id: "connection-form", value: nil as UUID?) - } - .onReceive(NotificationCenter.default.publisher(for: .connectionUpdated)) { _ in - loadConnections() - } - .sheet(isPresented: $showNewGroupSheet) { - CreateGroupSheet { name, color in - let group = ConnectionGroup(name: name, color: color) - groupStorage.addGroup(group) - groups = groupStorage.loadGroups() - if !pendingMoveToNewGroup.isEmpty { - moveConnections(pendingMoveToNewGroup, toGroup: group.id) - pendingMoveToNewGroup = [] + Text("Are you sure you want to delete \(vm.connectionsToDelete.count) connections? This cannot be undone.") + } + } + .sheet(item: $vm.activeSheet) { sheet in + switch sheet { + case .newGroup: + CreateGroupSheet { name, color in + let group = ConnectionGroup(name: name, color: color) + GroupStorage.shared.addGroup(group) + vm.groups = GroupStorage.shared.loadGroups() + if !vm.pendingMoveToNewGroup.isEmpty { + vm.moveConnections(vm.pendingMoveToNewGroup, toGroup: group.id) + vm.pendingMoveToNewGroup = [] + } } - } - } - .sheet(isPresented: $showActivationSheet) { - LicenseActivationSheet() - } - .pluginInstallPrompt(connection: $pluginInstallConnection) { connection in - connectAfterInstall(connection) - } - .sheet(item: $importFileURL) { item in - ConnectionImportSheet(fileURL: item.url) { count in - // Delay to let the sheet fully dismiss before showing alert - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - showImportResultAlert(count: count) + case .activation: + LicenseActivationSheet() + case .importFile(let url): + ConnectionImportSheet(fileURL: url) { count in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + vm.showImportResultAlert(count: count) + } } + case .exportConnections(let conns): + ConnectionExportOptionsSheet(connections: conns) } } - .sheet(item: $pendingExportConnections) { item in - ConnectionExportOptionsSheet(connections: item.connections) - } - .onReceive(NotificationCenter.default.publisher(for: .connectionShareFileOpened)) { notification in - guard let url = notification.object as? URL else { return } - importFileURL = IdentifiableURL(url: url) + .pluginInstallPrompt(connection: $vm.pluginInstallConnection) { connection in + vm.connectAfterInstall(connection) } - .onReceive(NotificationCenter.default.publisher(for: .exportConnections)) { _ in - guard !connections.isEmpty else { return } - exportConnections(connections) - } - .onReceive(NotificationCenter.default.publisher(for: .importConnections)) { _ in - importConnectionsFromFile() - } - .onReceive(NotificationCenter.default.publisher(for: .linkedFoldersDidUpdate)) { _ in - linkedConnections = LinkedFolderWatcher.shared.linkedConnections - } - .task { - linkedConnections = LinkedFolderWatcher.shared.linkedConnections + .alert(String(localized: "Rename Group"), isPresented: $vm.showRenameGroupAlert) { + TextField(String(localized: "Group name"), text: $vm.renameGroupName) + Button(String(localized: "Rename")) { vm.confirmRenameGroup() } + Button(String(localized: "Cancel"), role: .cancel) { vm.renameGroupTarget = nil } + } message: { + Text("Enter a new name for the group.") } } + // MARK: - Layout + private var welcomeContent: some View { HStack(spacing: 0) { - // Left panel - Branding - leftPanel - + WelcomeLeftPanel( + onActivateLicense: { vm.activeSheet = .activation }, + onCreateConnection: { openWindow(id: "connection-form") } + ) Divider() - - // Right panel - Connections rightPanel } .transition(.opacity) } - // MARK: - Left Panel - - private var leftPanel: some View { - VStack(spacing: 0) { - Spacer() - - // App branding - VStack(spacing: 16) { - Image(nsImage: NSApp.applicationIconImage) - .resizable() - .frame(width: 80, height: 80) - .shadow(color: Color(red: 1.0, green: 0.576, blue: 0.0).opacity(0.4), radius: 20, x: 0, y: 0) - - VStack(spacing: 6) { - Text("TablePro") - .font( - .system( - size: ThemeEngine.shared.activeTheme.iconSizes.extraLarge, weight: .semibold, - design: .rounded)) - - Text("Version \(Bundle.main.appVersion)") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) - .foregroundStyle(.secondary) - - if LicenseManager.shared.status.isValid { - Label("Pro", systemImage: "checkmark.seal.fill") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium)) - .foregroundStyle(.green) - } else { - Button(action: { showActivationSheet = true }) { - Text("Activate License") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - } - .buttonStyle(.plain) - .foregroundStyle(.secondary) - } - } - } - - Spacer() - .frame(height: 48) - - // Action button - VStack(spacing: 12) { - Button { - if let url = URL(string: "https://github.com/sponsors/datlechin") { - NSWorkspace.shared.open(url) - } - } label: { - Label("Sponsor TablePro", systemImage: "heart") - } - .buttonStyle(.plain) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .foregroundStyle(.pink) - - Button(action: { openWindow(id: "connection-form") }) { - Label("Create connection...", systemImage: "plus.circle") - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(WelcomeButtonStyle()) - } - .padding(.horizontal, 32) - - Spacer() - - // Footer hints - ViewThatFits(in: .horizontal) { - HStack(spacing: 12) { - SyncStatusIndicator() - KeyboardHint(keys: "↵", label: "Connect") - KeyboardHint(keys: "⌘N", label: "New") - KeyboardHint(keys: "⌘,", label: "Settings") - } - HStack(spacing: 8) { - SyncStatusIndicator() - KeyboardHint(keys: "↵", label: "Connect") - KeyboardHint(keys: "⌘N", label: "New") - KeyboardHint(keys: "⌘,", label: nil) - } - } - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .foregroundStyle(.tertiary) - .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.sm) - .padding(.bottom, ThemeEngine.shared.activeTheme.spacing.lg) - } - .frame(width: 260) - } - // MARK: - Right Panel private var rightPanel: some View { VStack(spacing: 0) { - // Search bar HStack(spacing: 8) { Button(action: { openWindow(id: "connection-form") }) { Image(systemName: "plus") @@ -316,7 +129,7 @@ struct WelcomeWindowView: View { .buttonStyle(.plain) .help("New Connection (⌘N)") - Button(action: { pendingMoveToNewGroup = []; showNewGroupSheet = true }) { + Button(action: { vm.pendingMoveToNewGroup = []; vm.activeSheet = .newGroup }) { Image(systemName: "folder.badge.plus") .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium, weight: .medium)) .foregroundStyle(.secondary) @@ -337,48 +150,48 @@ struct WelcomeWindowView: View { .font(.system(size: ThemeEngine.shared.activeTheme.typography.medium)) .foregroundStyle(.tertiary) - TextField("Search for connection...", text: $searchText) + TextField("Search for connection...", text: $vm.searchText) .textFieldStyle(.plain) .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) .focused($focus, equals: .search) .onKeyPress(.return) { - connectSelectedConnections() + vm.connectSelectedConnections() return .handled } .onKeyPress(.escape) { - if !searchText.isEmpty { - searchText = "" + if !vm.searchText.isEmpty { + vm.searchText = "" } focus = .connectionList return .handled } .onKeyPress(characters: .init(charactersIn: "\u{7F}\u{08}"), phases: .down) { keyPress in guard keyPress.modifiers.contains(.command) else { return .ignored } - let toDelete = selectedConnections + let toDelete = vm.selectedConnections guard !toDelete.isEmpty else { return .ignored } - connectionsToDelete = toDelete - showDeleteConfirmation = true + vm.connectionsToDelete = toDelete + vm.showDeleteConfirmation = true return .handled } .onKeyPress(characters: .init(charactersIn: "jn"), phases: [.down, .repeat]) { keyPress in guard keyPress.modifiers.contains(.control) else { return .ignored } - moveToNextConnection() + vm.moveToNextConnection() focus = .connectionList return .handled } .onKeyPress(characters: .init(charactersIn: "kp"), phases: [.down, .repeat]) { keyPress in guard keyPress.modifiers.contains(.control) else { return .ignored } - moveToPreviousConnection() + vm.moveToPreviousConnection() focus = .connectionList return .handled } .onKeyPress(.downArrow) { - moveToNextConnection() + vm.moveToNextConnection() focus = .connectionList return .handled } .onKeyPress(.upArrow) { - moveToPreviousConnection() + vm.moveToPreviousConnection() focus = .connectionList return .handled } @@ -395,8 +208,7 @@ struct WelcomeWindowView: View { Divider() - // Connection list - if filteredConnections.isEmpty { + if vm.filteredConnections.isEmpty { emptyState } else { connectionList @@ -407,48 +219,28 @@ struct WelcomeWindowView: View { .contextMenu { newConnectionContextMenu } } - @ViewBuilder - private var newConnectionContextMenu: some View { - Button(action: { openWindow(id: "connection-form") }) { - Label("New Connection...", systemImage: "plus") - } - - Divider() - - Button { - importConnectionsFromFile() - } label: { - Label(String(localized: "Import Connections..."), systemImage: "square.and.arrow.down") - } - } - // 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 private var connectionList: some View { ScrollViewReader { proxy in - List(selection: $selectedConnectionIds) { - ForEach(ungroupedConnections) { connection in + List(selection: $vm.selectedConnectionIds) { + ForEach(vm.ungroupedConnections) { connection in connectionRow(for: connection) } .onMove { from, to in - guard searchText.isEmpty else { return } - moveUngroupedConnections(from: from, to: to) + guard vm.searchText.isEmpty else { return } + vm.moveUngroupedConnections(from: from, to: to) } - ForEach(activeGroups) { group in + ForEach(vm.activeGroups) { group in Section { - if !collapsedGroupIds.contains(group.id) { - ForEach(connections(in: group)) { connection in + if !vm.collapsedGroupIds.contains(group.id) { + ForEach(vm.connections(in: group)) { connection in connectionRow(for: connection) } .onMove { from, to in - guard searchText.isEmpty else { return } - moveGroupedConnections(in: group, from: from, to: to) + guard vm.searchText.isEmpty else { return } + vm.moveGroupedConnections(in: group, from: from, to: to) } } } header: { @@ -456,13 +248,13 @@ struct WelcomeWindowView: View { } } .onMove { from, to in - guard searchText.isEmpty else { return } - moveGroups(from: from, to: to) + guard vm.searchText.isEmpty else { return } + vm.moveGroups(from: from, to: to) } - if !linkedConnections.isEmpty, LicenseManager.shared.isFeatureAvailable(.linkedFolders) { + if !vm.linkedConnections.isEmpty, LicenseManager.shared.isFeatureAvailable(.linkedFolders) { Section { - ForEach(linkedConnections) { linked in + ForEach(vm.linkedConnections) { linked in linkedConnectionRow(for: linked) } } header: { @@ -481,60 +273,66 @@ struct WelcomeWindowView: View { .focused($focus, equals: .connectionList) .environment(\.defaultMinListRowHeight, 44) .onKeyPress(.return) { - connectSelectedConnections() + vm.connectSelectedConnections() return .handled } .onKeyPress(characters: .init(charactersIn: "\u{7F}\u{08}"), phases: .down) { keyPress in guard keyPress.modifiers.contains(.command) else { return .ignored } - let toDelete = selectedConnections + let toDelete = vm.selectedConnections guard !toDelete.isEmpty else { return .ignored } - connectionsToDelete = toDelete - showDeleteConfirmation = true + vm.connectionsToDelete = toDelete + vm.showDeleteConfirmation = true return .handled } .onKeyPress(characters: .init(charactersIn: "a"), phases: .down) { keyPress in guard keyPress.modifiers.contains(.command) else { return .ignored } - selectedConnectionIds = Set(flatVisibleConnections.map(\.id)) + vm.selectedConnectionIds = Set(vm.flatVisibleConnections.map(\.id)) return .handled } .onKeyPress(.escape) { - if !selectedConnectionIds.isEmpty { - selectedConnectionIds = [] + if !vm.selectedConnectionIds.isEmpty { + vm.selectedConnectionIds = [] } return .handled } .onKeyPress(characters: .init(charactersIn: "jn"), phases: [.down, .repeat]) { keyPress in guard keyPress.modifiers.contains(.control) else { return .ignored } - moveToNextConnection() + vm.moveToNextConnection() scrollToSelection(proxy) return .handled } .onKeyPress(characters: .init(charactersIn: "kp"), phases: [.down, .repeat]) { keyPress in guard keyPress.modifiers.contains(.control) else { return .ignored } - moveToPreviousConnection() + vm.moveToPreviousConnection() scrollToSelection(proxy) return .handled } .onKeyPress(characters: .init(charactersIn: "h"), phases: .down) { keyPress in guard keyPress.modifiers.contains(.control) else { return .ignored } - collapseSelectedGroup() + vm.collapseSelectedGroup() return .handled } .onKeyPress(characters: .init(charactersIn: "l"), phases: .down) { keyPress in guard keyPress.modifiers.contains(.control) else { return .ignored } - expandSelectedGroup() + vm.expandSelectedGroup() return .handled } } } + // MARK: - Rows + private func connectionRow(for connection: DatabaseConnection) -> some View { let sshProfile = connection.sshProfileId.flatMap { SSHProfileStorage.shared.profile(for: $0) } - return ConnectionRow(connection: connection, sshProfile: sshProfile, onConnect: { connectToDatabase(connection) }) - .tag(connection.id) - .listRowInsets(ThemeEngine.shared.activeTheme.spacing.listRowInsets.swiftUI) - .listRowSeparator(.hidden) - .contextMenu { contextMenuContent(for: connection) } + return WelcomeConnectionRow( + connection: connection, + sshProfile: sshProfile, + onConnect: { vm.connectToDatabase(connection) } + ) + .tag(connection.id) + .listRowInsets(ThemeEngine.shared.activeTheme.spacing.listRowInsets.swiftUI) + .listRowSeparator(.hidden) + .contextMenu { contextMenuContent(for: connection) } } private func linkedConnectionRow(for linked: LinkedConnection) -> some View { @@ -560,47 +358,32 @@ struct WelcomeWindowView: View { .listRowInsets(ThemeEngine.shared.activeTheme.spacing.listRowInsets.swiftUI) .contentShape(Rectangle()) .simultaneousGesture(TapGesture(count: 2).onEnded { - connectToLinkedConnection(linked) + vm.connectToLinkedConnection(linked) }) .listRowSeparator(.hidden) .contextMenu { Button { - connectToLinkedConnection(linked) + vm.connectToLinkedConnection(linked) } label: { Label(String(localized: "Connect"), systemImage: "play.fill") } } } - private func connectToLinkedConnection(_ linked: LinkedConnection) { - let connection = DatabaseConnection( - id: linked.id, - name: linked.connection.name, - host: linked.connection.host, - port: linked.connection.port, - database: linked.connection.database, - username: linked.connection.username, - type: DatabaseType(rawValue: linked.connection.type) - ) - connectToDatabase(connection) - } + // MARK: - Group Header private func groupHeader(for group: ConnectionGroup) -> some View { Button(action: { withAnimation(.easeInOut(duration: 0.2)) { - if collapsedGroupIds.contains(group.id) { - collapsedGroupIds.remove(group.id) + if vm.collapsedGroupIds.contains(group.id) { + vm.collapsedGroupIds.remove(group.id) } else { - collapsedGroupIds.insert(group.id) + vm.collapsedGroupIds.insert(group.id) } - UserDefaults.standard.set( - Array(collapsedGroupIds.map(\.uuidString)), - forKey: "com.TablePro.collapsedGroupIds" - ) } }) { HStack(spacing: 6) { - Image(systemName: collapsedGroupIds.contains(group.id) ? "chevron.right" : "chevron.down") + Image(systemName: vm.collapsedGroupIds.contains(group.id) ? "chevron.right" : "chevron.down") .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .medium)) .foregroundStyle(.tertiary) .frame(width: 12) @@ -615,7 +398,7 @@ struct WelcomeWindowView: View { .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .semibold)) .foregroundStyle(.secondary) - Text("\(connections(in: group).count)") + Text("\(vm.connections(in: group).count)") .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) .foregroundStyle(.tertiary) @@ -624,10 +407,10 @@ struct WelcomeWindowView: View { .contentShape(Rectangle()) } .buttonStyle(.plain) - .accessibilityLabel(String(localized: "\(group.name), \(collapsedGroupIds.contains(group.id) ? "expand" : "collapse")")) + .accessibilityLabel(String(localized: "\(group.name), \(vm.collapsedGroupIds.contains(group.id) ? "expand" : "collapse")")) .contextMenu { Button { - renameGroup(group) + vm.beginRenameGroup(group) } label: { Label(String(localized: "Rename"), systemImage: "pencil") } @@ -635,10 +418,7 @@ struct WelcomeWindowView: View { Menu(String(localized: "Change Color")) { ForEach(ConnectionColor.allCases) { color in Button { - var updated = group - updated.color = color - groupStorage.updateGroup(updated) - groups = groupStorage.loadGroups() + vm.updateGroupColor(group, color: color) } label: { HStack { if color != .none { @@ -658,7 +438,7 @@ struct WelcomeWindowView: View { Divider() Button(role: .destructive) { - deleteGroup(group) + vm.deleteGroup(group) } label: { Label(String(localized: "Delete Group"), systemImage: "trash") } @@ -675,7 +455,7 @@ struct WelcomeWindowView: View { .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.huge)) .foregroundStyle(.tertiary) - if searchText.isEmpty { + if vm.searchText.isEmpty { Text("No Connections") .font(.system(size: ThemeEngine.shared.activeTheme.typography.title3, weight: .medium)) .foregroundStyle(.secondary) @@ -704,628 +484,13 @@ struct WelcomeWindowView: View { .frame(maxWidth: .infinity) } - // MARK: - Context Menu - - @ViewBuilder - private func contextMenuContent(for connection: DatabaseConnection) -> some View { - if isMultipleSelection, selectedConnectionIds.contains(connection.id) { - Button { connectSelectedConnections() } label: { - Label( - String(localized: "Connect \(selectedConnectionIds.count) Connections"), - systemImage: "play.fill" - ) - } - - Divider() - - Button { - exportConnections(Array(selectedConnections)) - } label: { - Label( - String(localized: "Export \(selectedConnectionIds.count) Connections..."), - systemImage: "square.and.arrow.up" - ) - } - - Divider() - - moveToGroupMenu(for: selectedConnections) - - let validGroupIds = Set(groups.map(\.id)) - if selectedConnections.contains(where: { $0.groupId.map { validGroupIds.contains($0) } ?? false }) { - Button { removeFromGroup(selectedConnections) } label: { - Label(String(localized: "Remove from Group"), systemImage: "folder.badge.minus") - } - } - - Divider() - - Button(role: .destructive) { - connectionsToDelete = selectedConnections - showDeleteConfirmation = true - } label: { - Label( - String(localized: "Delete \(selectedConnectionIds.count) Connections"), - systemImage: "trash" - ) - } - } else { - Button { connectToDatabase(connection) } label: { - Label(String(localized: "Connect"), systemImage: "play.fill") - } - - Divider() - - Button { - openWindow(id: "connection-form", value: connection.id as UUID?) - focusConnectionFormWindow() - } label: { - Label(String(localized: "Edit"), systemImage: "pencil") - } - - Button { duplicateConnection(connection) } label: { - Label(String(localized: "Duplicate"), systemImage: "doc.on.doc") - } - - Divider() - - Button { - let pw = ConnectionStorage.shared.loadPassword(for: connection.id) - let sshPw: String? - let sshProfile: SSHProfile? - if let profileId = connection.sshProfileId { - sshPw = SSHProfileStorage.shared.loadSSHPassword(for: profileId) - sshProfile = SSHProfileStorage.shared.profile(for: profileId) - } else { - sshPw = ConnectionStorage.shared.loadSSHPassword(for: connection.id) - sshProfile = nil - } - let url = ConnectionURLFormatter.format( - connection, - password: pw, - sshPassword: sshPw, - sshProfile: sshProfile - ) - ClipboardService.shared.writeText(url) - } label: { - Label(String(localized: "Copy as URL"), systemImage: "link") - } - - Button { - let link = ConnectionExportService.buildImportDeeplink(for: connection) - ClipboardService.shared.writeText(link) - } label: { - Label(String(localized: "Copy as Import Link"), systemImage: "link.badge.plus") - } - - Button { - exportConnections([connection]) - } label: { - Label(String(localized: "Export..."), systemImage: "square.and.arrow.up") - } - - Divider() - - moveToGroupMenu(for: [connection]) - - if let groupId = connection.groupId, groups.contains(where: { $0.id == groupId }) { - Button { removeFromGroup([connection]) } label: { - Label(String(localized: "Remove from Group"), systemImage: "folder.badge.minus") - } - } - - Divider() - - Button(role: .destructive) { - connectionsToDelete = [connection] - showDeleteConfirmation = true - } label: { - Label(String(localized: "Delete"), systemImage: "trash") - } - } - } - - @ViewBuilder - private func moveToGroupMenu(for targets: [DatabaseConnection]) -> some View { - let isSingle = targets.count == 1 - let currentGroupId = isSingle ? targets.first?.groupId : nil - Menu(String(localized: "Move to Group")) { - ForEach(groups) { group in - Button { - moveConnections(targets, toGroup: group.id) - } label: { - HStack { - if !group.color.isDefault { - Image(systemName: "circle.fill") - .foregroundStyle(group.color.color) - } - Text(group.name) - if currentGroupId == group.id { - Spacer() - Image(systemName: "checkmark") - } - } - } - .disabled(currentGroupId == group.id) - } - - if !groups.isEmpty { - Divider() - } - - Button { - pendingMoveToNewGroup = targets - showNewGroupSheet = true - } label: { - Label(String(localized: "New Group..."), systemImage: "folder.badge.plus") - } - } - } - - private func moveConnections(_ targets: [DatabaseConnection], toGroup groupId: UUID) { - let ids = Set(targets.map(\.id)) - for i in connections.indices where ids.contains(connections[i].id) { - connections[i].groupId = groupId - } - storage.saveConnections(connections) - } - - private func removeFromGroup(_ targets: [DatabaseConnection]) { - let ids = Set(targets.map(\.id)) - for i in connections.indices where ids.contains(connections[i].id) { - connections[i].groupId = nil - } - storage.saveConnections(connections) - } - - // MARK: - Connection Sharing - - private func exportConnections(_ connectionsToExport: [DatabaseConnection]) { - pendingExportConnections = IdentifiableConnections(connections: connectionsToExport) - } - - private func importConnectionsFromFile() { - let panel = NSOpenPanel() - panel.allowedContentTypes = [.tableproConnectionShare] - panel.allowsMultipleSelection = false - panel.canChooseDirectories = false - guard panel.runModal() == .OK, let url = panel.url else { return } - importFileURL = IdentifiableURL(url: url) - } - - private func showImportResultAlert(count: Int) { - let alert = NSAlert() - if count > 0 { - alert.alertStyle = .informational - alert.messageText = String(localized: "Import Complete") - alert.informativeText = count == 1 - ? String(localized: "1 connection was imported.") - : String(localized: "\(count) connections were imported.") - alert.icon = NSImage(systemSymbolName: "checkmark.circle.fill", accessibilityDescription: nil)? - .withSymbolConfiguration(.init(paletteColors: [.white, .systemGreen])) - } else { - alert.alertStyle = .informational - alert.messageText = String(localized: "No Connections Imported") - alert.informativeText = String(localized: "All selected connections were skipped.") - } - alert.addButton(withTitle: String(localized: "OK")) - if let window = NSApp.keyWindow { - alert.beginSheetModal(for: window) - } else { - alert.runModal() - } - } - - // MARK: - Actions - - private func loadConnections() { - connections = storage.loadConnections() - loadGroups() - } - - private func connectToDatabase(_ connection: DatabaseConnection) { - // Set pendingConnectionId so AppDelegate assigns the correct per-connection tabbingIdentifier - WindowOpener.shared.pendingConnectionId = connection.id - openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) - NSApplication.shared.closeWindows(withId: "welcome") - - Task { - do { - try await dbManager.connectToSession(connection) - } catch { - if case PluginError.pluginNotInstalled = error { - Self.logger.info("Plugin not installed for \(connection.type.rawValue), prompting install") - handleMissingPlugin(connection: connection) - } else { - Self.logger.error( - "Failed to connect: \(error.localizedDescription, privacy: .public)") - handleConnectionFailure(error: error) - } - } - } - } - - private func handleConnectionFailure(error: Error) { - NSApplication.shared.closeWindows(withId: "main") - openWindow(id: "welcome") - - AlertHelper.showErrorSheet( - title: String(localized: "Connection Failed"), - message: error.localizedDescription, - window: nil - ) - } - - private func handleMissingPlugin(connection: DatabaseConnection) { - NSApplication.shared.closeWindows(withId: "main") - openWindow(id: "welcome") - pluginInstallConnection = connection - } - - private func connectAfterInstall(_ connection: DatabaseConnection) { - WindowOpener.shared.pendingConnectionId = connection.id - openWindow(id: "main", value: EditorTabPayload(connectionId: connection.id)) - NSApplication.shared.closeWindows(withId: "welcome") - - Task { - do { - try await dbManager.connectToSession(connection) - } catch { - Self.logger.error( - "Failed to connect after plugin install: \(error.localizedDescription, privacy: .public)") - handleConnectionFailure(error: error) - } - } - } - - private func connectSelectedConnections() { - for connection in selectedConnections { - connectToDatabase(connection) - } - } - - private func deleteSelectedConnections() { - let idsToDelete = Set(connectionsToDelete.map(\.id)) - storage.deleteConnections(connectionsToDelete) - connections.removeAll { idsToDelete.contains($0.id) } - selectedConnectionIds.subtract(idsToDelete) - connectionsToDelete = [] - } - - private func duplicateConnection(_ connection: DatabaseConnection) { - // Create duplicate with new UUID and copy passwords - let duplicate = storage.duplicateConnection(connection) - - // Refresh connections list - loadConnections() - - // Open edit form for the duplicate so user can rename - openWindow(id: "connection-form", value: duplicate.id as UUID?) - 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 moveToNextConnection() { - let visible = flatVisibleConnections - guard !visible.isEmpty else { return } - let anchorId = visible.last(where: { selectedConnectionIds.contains($0.id) })?.id - guard let anchorId, - let index = visible.firstIndex(where: { $0.id == anchorId }) else { - selectedConnectionIds = Set([visible[0].id]) - return - } - let next = min(index + 1, visible.count - 1) - selectedConnectionIds = [visible[next].id] - } - - private func moveToPreviousConnection() { - let visible = flatVisibleConnections - guard !visible.isEmpty else { return } - let anchorId = visible.first(where: { selectedConnectionIds.contains($0.id) })?.id - guard let anchorId, - let index = visible.firstIndex(where: { $0.id == anchorId }) else { - selectedConnectionIds = Set([visible[visible.count - 1].id]) - return - } - let prev = max(index - 1, 0) - selectedConnectionIds = [visible[prev].id] - } + // MARK: - Helpers private func scrollToSelection(_ proxy: ScrollViewProxy) { - if let id = selectedConnectionIds.first { + if let id = vm.selectedConnectionIds.first { proxy.scrollTo(id, anchor: .center) } } - - private func collapseSelectedGroup() { - guard let id = selectedConnectionIds.first, - let connection = connections.first(where: { $0.id == id }), - let groupId = connection.groupId, - !collapsedGroupIds.contains(groupId) else { return } - withAnimation(.easeInOut(duration: 0.2)) { - collapsedGroupIds.insert(groupId) - // Keep selectedConnectionIds so Ctrl+L can derive the groupId to expand. - // The List won't show a highlight for the hidden row. - UserDefaults.standard.set( - Array(collapsedGroupIds.map(\.uuidString)), - forKey: "com.TablePro.collapsedGroupIds" - ) - } - } - - private func expandSelectedGroup() { - guard let id = selectedConnectionIds.first, - let connection = connections.first(where: { $0.id == id }), - let groupId = connection.groupId, - collapsedGroupIds.contains(groupId) else { return } - withAnimation(.easeInOut(duration: 0.2)) { - collapsedGroupIds.remove(groupId) - UserDefaults.standard.set( - Array(collapsedGroupIds.map(\.uuidString)), - forKey: "com.TablePro.collapsedGroupIds" - ) - } - } - - private func moveUngroupedConnections(from source: IndexSet, to destination: Int) { - let validGroupIds = Set(groups.map(\.id)) - let ungroupedIndices = connections.indices.filter { index in - guard let groupId = connections[index].groupId else { return true } - return !validGroupIds.contains(groupId) - } - - guard source.allSatisfy({ $0 < ungroupedIndices.count }), - destination <= ungroupedIndices.count else { return } - - let globalSource = IndexSet(source.map { ungroupedIndices[$0] }) - let globalDestination: Int - 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) - } - - private func moveGroupedConnections(in group: ConnectionGroup, from source: IndexSet, to destination: Int) { - let groupIndices = connections.indices.filter { connections[$0].groupId == group.id } - - guard source.allSatisfy({ $0 < groupIndices.count }), - destination <= groupIndices.count else { return } - - let globalSource = IndexSet(source.map { groupIndices[$0] }) - let globalDestination: Int - if destination < groupIndices.count { - globalDestination = groupIndices[destination] - } else if let last = groupIndices.last { - globalDestination = last + 1 - } else { - globalDestination = 0 - } - - connections.move(fromOffsets: globalSource, toOffset: globalDestination) - storage.saveConnections(connections) - } - - private func moveGroups(from source: IndexSet, to destination: Int) { - let active = activeGroups - let activeGroupIndices = active.compactMap { activeGroup in - groups.firstIndex(where: { $0.id == activeGroup.id }) - } - - guard source.allSatisfy({ $0 < activeGroupIndices.count }), - destination <= activeGroupIndices.count else { return } - - let globalSource = IndexSet(source.map { activeGroupIndices[$0] }) - let globalDestination: Int - if destination < activeGroupIndices.count { - globalDestination = activeGroupIndices[destination] - } else if let last = activeGroupIndices.last { - globalDestination = last + 1 - } else { - globalDestination = 0 - } - - groups.move(fromOffsets: globalSource, toOffset: globalDestination) - groupStorage.saveGroups(groups) - } - - /// Focus the connection form window as soon as it's available - private func focusConnectionFormWindow() { - Task { @MainActor in - for _ in 0..<10 { - for window in NSApp.windows where - window.identifier?.rawValue == "connection-form" { - window.makeKeyAndOrderFront(nil) - return - } - try? await Task.sleep(for: .milliseconds(20)) - } - } - } -} - -// MARK: - ConnectionRow - -private struct ConnectionRow: View { - let connection: DatabaseConnection - let sshProfile: SSHProfile? - var onConnect: (() -> 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 - connection.type.iconImage - .renderingMode(.template) - .font(.system(size: ThemeEngine.shared.activeTheme.iconSizes.medium)) - .foregroundStyle(connection.displayColor) - .frame( - width: ThemeEngine.shared.activeTheme.iconSizes.medium, - height: ThemeEngine.shared.activeTheme.iconSizes.medium - ) - - // Connection info - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text(connection.name) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) - .foregroundStyle(.primary) - - // Tag (single) - if let tag = displayTag { - Text(tag.name) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.tiny)) - .foregroundStyle(tag.color.color) - .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.xxs) - .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xxxs) - .background( - RoundedRectangle(cornerRadius: 4).fill( - tag.color.color.opacity(0.15))) - } - } - - Text(connectionSubtitle) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer() - } - .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xxs) - .contentShape(Rectangle()) - .overlay( - DoubleClickView { onConnect?() } - ) - } - - private var connectionSubtitle: String { - let ssh = connection.effectiveSSHConfig(profile: sshProfile) - if ssh.enabled { - return "SSH : \(ssh.username)@\(ssh.host)" - } - if connection.host.isEmpty { - return connection.database.isEmpty ? connection.type.rawValue : connection.database - } - return connection.host - } -} - -// MARK: - WelcomeButtonStyle - -private struct WelcomeButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) - .foregroundStyle(.primary) - .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.md) - .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.sm) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 8) - .fill( - Color( - nsColor: configuration.isPressed - ? .controlBackgroundColor : .quaternaryLabelColor)) - ) - } -} - -// MARK: - KeyboardHint - -private struct KeyboardHint: View { - let keys: String - let label: String? - - var body: some View { - HStack(spacing: 4) { - Text(keys) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.caption, design: .monospaced)) - .padding(.horizontal, ThemeEngine.shared.activeTheme.spacing.xxs + 1) - .padding(.vertical, ThemeEngine.shared.activeTheme.spacing.xxxs) - .background( - RoundedRectangle(cornerRadius: 3) - .fill(Color(nsColor: .quaternaryLabelColor)) - ) - if let label { - Text(label) - } - } - } -} - -// 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 { - onDoubleClick?() - } - // Always forward to next responder for List selection - super.mouseDown(with: event) - } } // MARK: - Preview