diff --git a/CHANGELOG.md b/CHANGELOG.md index 912a90c35..7ba70c9bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Database switcher now opens as a popover anchored to the database chip in the toolbar, with a checkmark on the active row and direct row-click to switch. Refresh sits beside the search field; New Database is a menu-style footer row that appears only when the engine supports it. ⌘N and ⌘R bind globally inside the popover. Schemas use folder icons; databases keep the cylinder. +- New Database dialog uses the native sheet layout: title in the toolbar, Cancel and Create as toolbar items, wider form so the Name field no longer wraps. +- Drop database now uses the native confirmation dialog instead of a custom sheet. - Add competitive tracking docs sourced from top TablePlus issues. ### Fixed diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj index 1d578deec..8e8e5bff1 100644 --- a/TablePro.xcodeproj/project.pbxproj +++ b/TablePro.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */; }; 5A32BBFB2F9D5EAB00BAEB5F /* X509 in Frameworks */ = {isa = PBXBuildFile; productRef = 5A32BBFA2F9D5EAB00BAEB5F /* X509 */; }; 5A32BC0B2F9D659100BAEB5F /* tablepro-mcp in Copy Files */ = {isa = PBXBuildFile; fileRef = 5A32BC002F9D5F1300BAEB5F /* tablepro-mcp */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 5A3A69B82F976F38000AC5B2 /* GhosttyTerminal in Frameworks */ = {isa = PBXBuildFile; productRef = 5A3A69B72F976F38000AC5B2 /* GhosttyTerminal */; }; @@ -55,6 +54,7 @@ 5ACE00012F4F000000000005 /* CodeEditLanguages in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000003 /* CodeEditLanguages */; }; 5ACE00012F4F000000000006 /* CodeEditTextView in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000007 /* CodeEditTextView */; }; 5ACE00012F4F00000000000A /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 5ACE00012F4F000000000009 /* Sparkle */; }; + 5AD1D8C12FB5000000000001 /* TableProMSSQLCore in Frameworks */ = {isa = PBXBuildFile; productRef = 5AD1D8C12FB5000000000002 /* TableProMSSQLCore */; }; 5ADDB00100000000000000A1 /* DynamoDBConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A1 /* DynamoDBConnection.swift */; }; 5ADDB00100000000000000A2 /* DynamoDBItemFlattener.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A2 /* DynamoDBItemFlattener.swift */; }; 5ADDB00100000000000000A3 /* DynamoDBPartiQLParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ADDB00200000000000000A3 /* DynamoDBPartiQLParser.swift */; }; diff --git a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift index 28896b411..ef1d3a9c2 100644 --- a/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift +++ b/TablePro/Core/Services/Infrastructure/MainWindowToolbar+Buttons.swift @@ -22,7 +22,7 @@ struct ConnectionToolbarButton: View { } struct DatabaseToolbarButton: View { - let coordinator: MainContentCoordinator + @Bindable var coordinator: MainContentCoordinator var body: some View { let state = coordinator.toolbarState @@ -38,6 +38,9 @@ struct DatabaseToolbarButton: View { state.connectionState != .connected || PluginManager.shared.connectionMode(for: state.databaseType) == .fileBased ) + .popover(isPresented: $coordinator.isDatabaseSwitcherShown, arrowEdge: .bottom) { + DatabaseSwitcherPopoverHost(coordinator: coordinator) + } } } } diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index ccb1f0197..f095e4b7f 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -7427,9 +7427,8 @@ } } }, - "Backup Dump" : { - "comment" : "A button that triggers a backup of the current database.", - "isCommentAutoGenerated" : true + "Backup Database" : { + }, "Backup Dump Cancelled" : { "comment" : "A title for a backup result sheet that was cancelled.", @@ -9088,6 +9087,9 @@ }, "Choose AI provider and model" : { + }, + "Choose Destination…" : { + }, "Choose Dump File" : { @@ -13085,6 +13087,7 @@ }, "Create Database" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13107,6 +13110,7 @@ } }, "Create new database" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -13453,6 +13457,7 @@ } }, "current" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -14239,6 +14244,9 @@ } } } + }, + "Database name" : { + }, "Database Name" : { "extractionState" : "stale", @@ -16590,6 +16598,7 @@ } }, "Drop database '%@'?" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -16610,8 +16619,12 @@ } } } + }, + "Drop database “%@”?" : { + }, "Drop Database..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -16632,6 +16645,9 @@ } } } + }, + "Drop Database…" : { + }, "Drop Foreign Table" : { @@ -16684,6 +16700,7 @@ } }, "Drop selected database" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -16772,6 +16789,7 @@ } }, "Dropping..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -17065,6 +17083,7 @@ } }, "Each SQLite file is a separate database.\nTo open a different database, create a new connection." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -18010,6 +18029,7 @@ } }, "Enter database name" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27346,6 +27366,9 @@ } } } + }, + "Loading databases…" : { + }, "Loading keys…" : { "localizations" : { @@ -27370,6 +27393,7 @@ } }, "Loading options..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27390,6 +27414,9 @@ } } } + }, + "Loading options…" : { + }, "Loading plugins..." : { "extractionState" : "stale", @@ -27437,6 +27464,7 @@ } }, "Loading schemas..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -27457,6 +27485,9 @@ } } } + }, + "Loading schemas…" : { + }, "Loading tables..." : { "extractionState" : "stale", @@ -29530,6 +29561,15 @@ } } } + }, + "New Database" : { + + }, + "New Database (⌘N)" : { + + }, + "New Database…" : { + }, "New Favorite" : { "localizations" : { @@ -30340,8 +30380,12 @@ } } } + }, + "No databases" : { + }, "No databases found" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -30364,6 +30408,7 @@ } }, "No databases match \"%@\"" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -30384,6 +30429,9 @@ } } } + }, + "No databases match “%@”" : { + }, "No DDL available" : { "localizations" : { @@ -30683,6 +30731,7 @@ } }, "No matching databases" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -30794,6 +30843,7 @@ } }, "No matching schemas" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -31217,8 +31267,12 @@ } } } + }, + "No schemas" : { + }, "No schemas found" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -31241,6 +31295,7 @@ } }, "No schemas match \"%@\"" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -31261,6 +31316,9 @@ } } } + }, + "No schemas match “%@”" : { + }, "No selection" : { "localizations" : { @@ -32445,6 +32503,9 @@ }, "Open a connection to insert" : { + }, + "Open a different file from the Welcome window." : { + }, "Open a table tab in TablePro for the given connection." : { @@ -32885,6 +32946,7 @@ }, "Open Schema" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -37606,6 +37668,7 @@ } }, "Refresh database list" : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -38696,9 +38759,8 @@ } } }, - "Restore Dump" : { - "comment" : "A button that opens a dialog for restoring a database from a dump.", - "isCommentAutoGenerated" : true + "Restore Database" : { + }, "Restore Dump Cancelled" : { "comment" : "A title for a result sheet that indicates a restore operation was cancelled.", @@ -38741,6 +38803,9 @@ } } } + }, + "Restore…" : { + }, "Restored “%@” from %@" : { "localizations" : { @@ -40486,8 +40551,12 @@ } } } + }, + "Search databases" : { + }, "Search databases..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { @@ -40648,8 +40717,12 @@ }, "Search saved query history. Returns matching entries with execution time, row count, and outcome." : { + }, + "Search schemas" : { + }, "Search schemas..." : { + "extractionState" : "stale", "localizations" : { "tr" : { "stringUnit" : { diff --git a/TablePro/Views/Backup/RestoreDatabaseFlow.swift b/TablePro/Views/Backup/RestoreDatabaseFlow.swift index 2507a28d5..cabaccc01 100644 --- a/TablePro/Views/Backup/RestoreDatabaseFlow.swift +++ b/TablePro/Views/Backup/RestoreDatabaseFlow.swift @@ -94,7 +94,7 @@ struct RestoreDatabaseFlow: View { } .padding(.horizontal, 12) .padding(.vertical, 8) - .frame(width: 420, alignment: .leading) + .frame(width: 480, alignment: .leading) } private var serviceState: PostgresDumpState { service.state } diff --git a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift index 1babeda11..3d0f4f465 100644 --- a/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/CreateDatabaseSheet.swift @@ -5,6 +5,7 @@ struct CreateDatabaseSheet: View { let databaseType: DatabaseType let viewModel: DatabaseSwitcherViewModel + var onCreated: ((String) -> Void)? @State private var loadState: LoadState = .loading @State private var databaseName = "" @@ -21,103 +22,136 @@ struct CreateDatabaseSheet: View { } var body: some View { - VStack(spacing: 0) { - header + VStack(alignment: .leading, spacing: 0) { + titleRow + Divider() + formBody + .padding(.horizontal, 20) + .padding(.vertical, 16) + + if let error = errorMessage { + Divider() + errorBanner(error) + } + Divider() - footer + + buttonBar } - .frame(width: 420) + .frame(width: 380) .onExitCommand { - if !isCreating { - dismiss() - } + if !isCreating { dismiss() } } .task { await load() } } - private var header: some View { - Text(String(localized: "Create Database")) - .font(.body.weight(.semibold)) - .padding(.vertical, 12) + private var titleRow: some View { + HStack { + Text(String(localized: "New Database")) + .font(.headline) + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 14) } + @ViewBuilder private var formBody: some View { Form { - Section { - LabeledContent(String(localized: "Name")) { - TextField(String(localized: "Enter database name"), text: $databaseName) - } - } + TextField( + String(localized: "Name"), + text: $databaseName, + prompt: Text(String(localized: "Database name")) + ) switch loadState { case .loading: - Section { loadingView } + loadingRow case .ready(let spec): - Section { - fieldsList(spec: spec) - } footer: { - if let footnote = spec.footnote { - Text(footnote) - } - } - case .unsupported: - Section { - Text(String(localized: "This engine does not support creating databases.")) + fieldsList(spec: spec) + if let footnote = spec.footnote { + Text(footnote) + .font(.callout) .foregroundStyle(.secondary) } + case .unsupported: + Text(String(localized: "This engine does not support creating databases.")) + .foregroundStyle(.secondary) case .failed(let message): - Section { failureView(message: message) } - } - - if let error = errorMessage { - Section { - Label(error, systemImage: "exclamationmark.triangle.fill") - .foregroundStyle(Color(nsColor: .systemOrange)) - } + failureRow(message: message) } } - .formStyle(.grouped) - .scrollContentBackground(.hidden) + .formStyle(.columns) } - private var loadingView: some View { + private var loadingRow: some View { HStack(spacing: 8) { - ProgressView().scaleEffect(0.7) - Text(String(localized: "Loading options...")) - .font(.subheadline) + ProgressView().controlSize(.small) + Text(String(localized: "Loading options…")) .foregroundStyle(.secondary) } } - private func failureView(message: String) -> some View { - VStack(alignment: .leading, spacing: 8) { + private func failureRow(message: String) -> some View { + VStack(alignment: .leading, spacing: 6) { Text(String(localized: "Failed to load options")) - .font(.subheadline.weight(.medium)) + .font(.body.weight(.medium)) Text(message) - .font(.subheadline) + .font(.callout) .foregroundStyle(.secondary) Button(String(localized: "Retry")) { Task { await load() } } - .buttonStyle(.bordered) .controlSize(.small) } } + private func errorBanner(_ message: String) -> some View { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(Color(nsColor: .systemOrange)) + Text(message) + .font(.callout) + .foregroundStyle(.primary) + .lineLimit(2) + Spacer() + } + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + + private var buttonBar: some View { + HStack { + Spacer() + + Button(String(localized: "Cancel")) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(String(localized: "Create")) { + submit() + } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(!canSubmit) + } + .padding(.horizontal, 20) + .padding(.vertical, 14) + } + + @ViewBuilder private func fieldsList(spec: CreateDatabaseFormSpec) -> some View { ForEach(visibleFields(in: spec)) { field in - fieldView(field: field, spec: spec) + fieldRow(field: field, spec: spec) } } - private func fieldView(field: CreateDatabaseFormSpec.Field, spec: CreateDatabaseFormSpec) -> some View { - LabeledContent(field.label) { - picker(for: field, spec: spec) - .labelsHidden() - .pickerStyle(.menu) - } + private func fieldRow(field: CreateDatabaseFormSpec.Field, spec: CreateDatabaseFormSpec) -> some View { + picker(for: field, spec: spec) + .pickerStyle(.menu) } private func picker(for field: CreateDatabaseFormSpec.Field, spec: CreateDatabaseFormSpec) -> some View { @@ -131,31 +165,13 @@ struct CreateDatabaseSheet: View { } ) let options = filteredOptions(for: field) - return Picker("", selection: binding) { + return Picker(field.label, selection: binding) { ForEach(options, id: \.value) { option in Text(displayLabel(for: option)).tag(option.value) } } } - private var footer: some View { - HStack { - Button(String(localized: "Cancel")) { - dismiss() - } - - Spacer() - - Button(isCreating ? String(localized: "Creating...") : String(localized: "Create")) { - submit() - } - .buttonStyle(.borderedProminent) - .disabled(!canSubmit) - .keyboardShortcut(.return, modifiers: []) - } - .padding(12) - } - private var canSubmit: Bool { guard !databaseName.isEmpty, !isCreating else { return false } if case .ready = loadState { return true } @@ -260,7 +276,7 @@ struct CreateDatabaseSheet: View { Task { do { try await viewModel.createDatabase(name: name, values: submissionValues) - await viewModel.refreshDatabases() + onCreated?(name) dismiss() } catch { errorMessage = error.localizedDescription diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift new file mode 100644 index 000000000..0060d5e43 --- /dev/null +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherPopover.swift @@ -0,0 +1,447 @@ +import AppKit +import SwiftUI +import TableProPluginKit + +/// Bridges the toolbar's weak coordinator reference to a concrete `DatabaseSwitcherPopover`. +/// Resolves the current database/schema at presentation time so the popover always reflects +/// the active session, even after the user switches between tabs. +struct DatabaseSwitcherPopoverHost: View { + weak var coordinator: MainContentCoordinator? + + var body: some View { + if let coordinator { + let connection = coordinator.connection + let session = DatabaseManager.shared.session(for: connection.id) + let activeDatabase = session?.currentDatabase ?? connection.database + let activeSchema = session?.currentSchema + + DatabaseSwitcherPopover( + currentDatabase: activeDatabase, + currentSchema: activeSchema, + databaseType: connection.type, + connectionId: connection.id, + onSelect: { [weak coordinator] database in + Task { await coordinator?.switchDatabase(to: database) } + }, + onSelectSchema: PluginManager.shared.supportsSchemaSwitching(for: connection.type) + ? { [weak coordinator] schema in + Task { await coordinator?.switchSchema(to: schema) } + } + : nil, + onRequestCreate: { [weak coordinator] in + coordinator?.activeSheet = .createDatabase + }, + onRequestDrop: { [weak coordinator] name in + coordinator?.databaseToDrop = name + } + ) + } else { + EmptyView() + } + } +} + +struct DatabaseSwitcherPopover: View { + let currentDatabase: String? + let currentSchema: String? + let databaseType: DatabaseType + let connectionId: UUID + let onSelect: (String) -> Void + let onSelectSchema: ((String) -> Void)? + let onRequestCreate: () -> Void + let onRequestDrop: (String) -> Void + + @Environment(\.dismiss) private var dismiss + @State private var viewModel: DatabaseSwitcherViewModel + @State private var supportsCreateDatabase = false + + private enum FocusField { + case search + case list + } + + @FocusState private var focus: FocusField? + + /// Fixed popover dimensions. Matches the native pattern of Emoji Picker, + /// Font Panel, and Color Picker — popovers with tabs and search keep stable + /// chrome and a stable frame so switching tabs doesn't reflow the surface. + private static let popoverWidth: CGFloat = 320 + private static let popoverHeight: CGFloat = 360 + + private var isSchemaMode: Bool { viewModel.isSchemaMode } + private var activeName: String? { isSchemaMode ? currentSchema : currentDatabase } + private var supportsDropDatabase: Bool { + !isSchemaMode && PluginManager.shared.supportsDropDatabase(for: databaseType) + } + private var supportsSchemaSwitching: Bool { + PluginManager.shared.supportsSchemaSwitching(for: databaseType) + } + private var showsCreateRow: Bool { + !isSchemaMode && supportsCreateDatabase + } + + init( + currentDatabase: String?, + currentSchema: String?, + databaseType: DatabaseType, + connectionId: UUID, + onSelect: @escaping (String) -> Void, + onSelectSchema: ((String) -> Void)? = nil, + onRequestCreate: @escaping () -> Void, + onRequestDrop: @escaping (String) -> Void + ) { + self.currentDatabase = currentDatabase + self.currentSchema = currentSchema + self.databaseType = databaseType + self.connectionId = connectionId + self.onSelect = onSelect + self.onSelectSchema = onSelectSchema + self.onRequestCreate = onRequestCreate + self.onRequestDrop = onRequestDrop + self._viewModel = State( + wrappedValue: DatabaseSwitcherViewModel( + connectionId: connectionId, + currentDatabase: currentDatabase, + currentSchema: currentSchema, + databaseType: databaseType + )) + } + + var body: some View { + VStack(spacing: 0) { + if supportsSchemaSwitching { + modePicker + Divider() + } + + searchField + + Divider() + + content + + if showsCreateRow { + Divider() + createButton + } + } + .frame(width: Self.popoverWidth, height: Self.popoverHeight) + .background(refreshShortcut) + .task { await viewModel.fetchDatabases() } + .task { await refreshCreateSupport() } + .onKeyPress(.return) { + commitSelection() + return .handled + } + .onKeyPress(.upArrow) { + viewModel.moveUp() + return .handled + } + .onKeyPress(.downArrow) { + viewModel.moveDown() + return .handled + } + } + + /// Hidden ⌘R binding. Native popovers (Mail mailbox switcher, Safari tab group + /// picker) don't show a visible refresh button — they auto-refresh on open via + /// `.task`. We keep the shortcut for power users. + private var refreshShortcut: some View { + Button("") { + Task { await viewModel.refreshDatabases() } + } + .keyboardShortcut("r", modifiers: .command) + .hidden() + } + + private var modePicker: some View { + Picker("", selection: $viewModel.mode) { + Text(String(localized: "Databases")) + .tag(DatabaseSwitcherViewModel.Mode.database) + Text(String(localized: "Schemas")) + .tag(DatabaseSwitcherViewModel.Mode.schema) + } + .pickerStyle(.segmented) + .labelsHidden() + .controlSize(.small) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .onChange(of: viewModel.mode) { + Task { await viewModel.fetchDatabases() } + } + } + + private var searchField: some View { + HStack(spacing: 5) { + Image(systemName: "magnifyingglass") + .imageScale(.small) + .foregroundStyle(.secondary) + .frame(width: 14) + + TextField( + "", + text: $viewModel.searchText, + prompt: Text(searchPlaceholder) + .foregroundStyle(.tertiary) + ) + .textFieldStyle(.plain) + .font(.body) + .focused($focus, equals: .search) + .onKeyPress(.downArrow) { + viewModel.moveDown() + return .handled + } + .onKeyPress(.upArrow) { + viewModel.moveUp() + return .handled + } + .onKeyPress(.return) { + commitSelection() + return .handled + } + .onKeyPress(.escape) { + if viewModel.searchText.isEmpty { + return .ignored + } + viewModel.searchText = "" + return .handled + } + + if !viewModel.searchText.isEmpty { + Button { + viewModel.searchText = "" + } label: { + Image(systemName: "xmark.circle.fill") + .imageScale(.small) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(Color(nsColor: .quaternaryLabelColor).opacity(0.35)) + ) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .onAppear { focus = .search } + } + + private var searchPlaceholder: String { + isSchemaMode + ? String(localized: "Search schemas") + : String(localized: "Search databases") + } + + @ViewBuilder + private var content: some View { + if viewModel.isLoading { + loadingView + } else if let error = viewModel.errorMessage { + errorView(error) + } else if PluginManager.shared.connectionMode(for: databaseType) == .fileBased { + sqliteState + } else if viewModel.filteredDatabases.isEmpty { + emptyState + } else { + list + } + } + + private var list: some View { + ScrollViewReader { proxy in + List(selection: $viewModel.selectedDatabase) { + ForEach(viewModel.filteredDatabases) { db in + row(for: db) + } + } + .listStyle(.inset) + .scrollContentBackground(.hidden) + .focused($focus, equals: .list) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contextMenu(forSelectionType: String.self) { selection in + contextMenuItems(for: selection) + } primaryAction: { selection in + guard let name = selection.first else { return } + viewModel.selectedDatabase = name + commitSelection() + } + .onChange(of: viewModel.selectedDatabase) { _, newValue in + guard let item = newValue else { return } + withAnimation(.easeInOut(duration: 0.15)) { + proxy.scrollTo(item) + } + } + } + } + + private func row(for database: DatabaseMetadata) -> some View { + let isCurrent = database.name == activeName + return HStack(spacing: 8) { + Image(systemName: "checkmark") + .font(.body.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .opacity(isCurrent ? 1 : 0) + .frame(width: 14) + + Image(systemName: rowIcon(for: database)) + .font(.body) + .foregroundStyle(database.isSystemDatabase ? Color.secondary : Color.accentColor) + .frame(width: 16) + + Text(database.name) + .font(.body) + .lineLimit(1) + .truncationMode(.middle) + + Spacer(minLength: 0) + } + .padding(.vertical, 1) + .contentShape(Rectangle()) + .listRowInsets(EdgeInsets(top: 2, leading: 8, bottom: 2, trailing: 8)) + .listRowSeparator(.hidden) + .id(database.name) + .tag(database.name) + } + + private func rowIcon(for database: DatabaseMetadata) -> String { + if isSchemaMode { + return "folder.fill" + } + return database.icon + } + + @ViewBuilder + private func contextMenuItems(for selection: Set) -> some View { + if supportsDropDatabase, + let name = selection.first, + let database = viewModel.filteredDatabases.first(where: { $0.name == name }), + !database.isSystemDatabase, + database.name != activeName { + Button(role: .destructive) { + dismiss() + onRequestDrop(database.name) + } label: { + Label(String(localized: "Drop Database…"), systemImage: "trash") + } + } + } + + private var loadingView: some View { + VStack(spacing: 10) { + ProgressView().controlSize(.small) + Text(isSchemaMode + ? String(localized: "Loading schemas…") + : String(localized: "Loading databases…")) + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func errorView(_ message: String) -> some View { + VStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle") + .font(.title3) + .foregroundStyle(Color(nsColor: .systemOrange)) + Text(isSchemaMode + ? String(localized: "Failed to load schemas") + : String(localized: "Failed to load databases")) + .font(.callout.weight(.medium)) + Text(message) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + Button(String(localized: "Retry")) { + Task { await viewModel.fetchDatabases() } + } + .controlSize(.small) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 12) + } + + private var sqliteState: some View { + VStack(spacing: 10) { + Image(systemName: "doc") + .font(.title3) + .foregroundStyle(.secondary) + Text(String(localized: "SQLite is file-based")) + .font(.callout.weight(.medium)) + Text(String(localized: "Open a different file from the Welcome window.")) + .font(.caption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 12) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 12) + } + + private var emptyState: some View { + VStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.title3) + .foregroundStyle(.secondary) + if viewModel.searchText.isEmpty { + Text(isSchemaMode + ? String(localized: "No schemas") + : String(localized: "No databases")) + .font(.callout.weight(.medium)) + } else { + Text(isSchemaMode + ? String(format: String(localized: "No schemas match “%@”"), viewModel.searchText) + : String(format: String(localized: "No databases match “%@”"), viewModel.searchText)) + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.vertical, 12) + } + + private var createButton: some View { + HStack { + Button { + dismiss() + onRequestCreate() + } label: { + Label(String(localized: "New Database…"), systemImage: "plus") + } + .buttonStyle(.borderless) + .help(String(localized: "New Database (⌘N)")) + .keyboardShortcut("n", modifiers: .command) + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + private func commitSelection() { + guard let name = viewModel.selectedDatabase else { return } + if name == activeName { + dismiss() + return + } + if isSchemaMode, supportsSchemaSwitching, let onSelectSchema { + onSelectSchema(name) + } else { + onSelect(name) + } + dismiss() + } + + private func refreshCreateSupport() async { + do { + let spec = try await viewModel.loadCreateDatabaseForm() + supportsCreateDatabase = spec != nil + } catch { + supportsCreateDatabase = false + } + } +} diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index efc95ad30..1eaed3a6b 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -1,314 +1,178 @@ -// -// DatabaseSwitcherSheet.swift -// TablePro -// -// Complete redesign of the database switcher dialog. -// Features: Rich metadata, recent databases, refresh, create database, preview panel. -// - import AppKit import SwiftUI import TableProPluginKit +/// Database picker presented as a modal sheet for backup and restore flows. +/// Quick database switching from the toolbar uses `DatabaseSwitcherPopover`. struct DatabaseSwitcherSheet: View { enum Mode { - case switching case backup case restore } - /// Modes that pick a database for an out-of-band flow (backup / restore). - /// These share UI affordances: schemas tab hidden, create/drop hidden, - /// the primary button doesn't auto-dismiss. - private var isHandoffMode: Bool { - mode == .backup || mode == .restore - } - @Binding var isPresented: Bool @Environment(\.dismiss) private var dismiss let mode: Mode let currentDatabase: String? - let currentSchema: String? let databaseType: DatabaseType let connectionId: UUID let onSelect: (String) -> Void - let onSelectSchema: ((String) -> Void)? @State private var viewModel: DatabaseSwitcherViewModel - @State private var showCreateDialog = false - @State private var showDropDialog = false - @State private var databaseToDrop: String? - @State private var supportsCreateDatabase = false - private enum FocusField { - case databaseList - } + private enum FocusField { case list } @FocusState private var focus: FocusField? - private var isSchemaMode: Bool { viewModel.isSchemaMode } - - /// The active name used for current-badge comparison, depending on mode. - private var activeName: String? { - isSchemaMode ? currentSchema : currentDatabase - } - init( isPresented: Binding, - mode: Mode = .switching, + mode: Mode, currentDatabase: String?, - currentSchema: String? = nil, databaseType: DatabaseType, connectionId: UUID, - onSelect: @escaping (String) -> Void, - onSelectSchema: ((String) -> Void)? = nil + onSelect: @escaping (String) -> Void ) { self._isPresented = isPresented self.mode = mode self.currentDatabase = currentDatabase - self.currentSchema = currentSchema self.databaseType = databaseType self.connectionId = connectionId self.onSelect = onSelect - self.onSelectSchema = onSelectSchema - // Backup and restore always operate at the database level (pg_dump - // dumps a whole database). Force .database so PostgreSQL doesn't - // open the picker in schema mode. - let initialMode: DatabaseSwitcherViewModel.Mode? = (mode == .backup || mode == .restore) - ? .database - : nil self._viewModel = State( wrappedValue: DatabaseSwitcherViewModel( connectionId: connectionId, currentDatabase: currentDatabase, - currentSchema: currentSchema, + currentSchema: nil, databaseType: databaseType, - initialMode: initialMode + initialMode: .database )) } var body: some View { - VStack(spacing: 0) { - // Databases / Schemas toggle (PostgreSQL only); hidden for handoff flows. - if !isHandoffMode, PluginManager.shared.supportsSchemaSwitching(for: databaseType) { - Picker("", selection: $viewModel.mode) { - Text(String(localized: "Databases")) - .tag(DatabaseSwitcherViewModel.Mode.database) - Text(String(localized: "Schemas")) - .tag(DatabaseSwitcherViewModel.Mode.schema) + NavigationStack { + VStack(spacing: 0) { + searchField + Divider() + content + } + .navigationTitle(navigationTitle) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "Cancel")) { + dismiss() + } } - .pickerStyle(.segmented) - .labelsHidden() - .frame(width: 220) - .padding(.top, 12) - .padding(.bottom, 8) - .onChange(of: viewModel.mode) { - Task { await viewModel.fetchDatabases() } + ToolbarItem(placement: .confirmationAction) { + Button(primaryButtonLabel) { + commitSelection() + } + .disabled(primaryButtonDisabled) } } - - Divider() - - // Toolbar: Search + Refresh + Create - toolbar - - Divider() - - // Content - if viewModel.isLoading { - loadingView - } else if let error = viewModel.errorMessage { - errorView(error) - } else if PluginManager.shared.connectionMode(for: databaseType) == .fileBased { - sqliteEmptyState - } else if viewModel.filteredDatabases.isEmpty { - emptyState - } else { - databaseList - } - - Divider() - - // Footer - footer } - .frame(width: 420, height: 480) - .navigationTitle(navigationTitleString) - .background(Color(nsColor: .windowBackgroundColor)) + .frame(width: 480, height: 460) .task { await viewModel.fetchDatabases() } - .task { await refreshCreateSupport() } - .sheet(isPresented: $showCreateDialog) { - CreateDatabaseSheet(databaseType: databaseType, viewModel: viewModel) - } - .sheet(isPresented: $showDropDialog) { - if let name = databaseToDrop { - DropDatabaseSheet(databaseName: name, viewModel: viewModel) { - databaseToDrop = nil - } - } - } - .onExitCommand { - // SwiftUI handles sheet priority automatically - no nested sheets take precedence - dismiss() - } - .onKeyPress(.return) { - openSelectedDatabase() - return .handled - } - .onKeyPress(characters: .init(charactersIn: "jn"), phases: [.down, .repeat]) { keyPress in - guard keyPress.modifiers.contains(.control) else { return .ignored } - viewModel.moveDown() - return .handled - } - .onKeyPress(characters: .init(charactersIn: "kp"), phases: [.down, .repeat]) { keyPress in - guard keyPress.modifiers.contains(.control) else { return .ignored } - viewModel.moveUp() - return .handled - } - .onKeyPress(.delete) { - guard canDropSelected else { return .ignored } - initiateDropForSelected() - return .handled - } } - // MARK: - Toolbar - - private var toolbar: some View { - HStack(spacing: 8) { - NativeSearchField( - text: $viewModel.searchText, - placeholder: isSchemaMode - ? String(localized: "Search schemas...") - : String(localized: "Search databases..."), - onMoveUp: { viewModel.moveUp() }, - onMoveDown: { viewModel.moveDown() }, - focusOnAppear: true - ) + private var navigationTitle: String { + switch mode { + case .backup: return String(localized: "Backup Database") + case .restore: return String(localized: "Restore Database") + } + } - // Refresh - Button(action: { - Task { await viewModel.refreshDatabases() } - }) { - Image(systemName: "arrow.clockwise") - .frame(width: 24, height: 24) - } - .buttonStyle(.borderless) - .help(String(localized: "Refresh database list")) + private var primaryButtonLabel: String { + switch mode { + case .backup: return String(localized: "Choose Destination…") + case .restore: return String(localized: "Restore…") + } + } - if !isHandoffMode, !isSchemaMode, supportsCreateDatabase { - Button(action: { showCreateDialog = true }) { - Image(systemName: "plus") - .frame(width: 24, height: 24) - } - .buttonStyle(.borderless) - .help(String(localized: "Create new database")) - } + private var primaryButtonDisabled: Bool { + viewModel.selectedDatabase == nil + } - // Drop - if !isHandoffMode, !isSchemaMode, PluginManager.shared.supportsDropDatabase(for: databaseType) { - Button(action: { initiateDropForSelected() }) { - Image(systemName: "trash") - .frame(width: 24, height: 24) - } - .buttonStyle(.borderless) - .disabled(!canDropSelected) - .help(String(localized: "Drop selected database")) - } - } + private var searchField: some View { + NativeSearchField( + text: $viewModel.searchText, + placeholder: String(localized: "Search databases"), + onMoveUp: { viewModel.moveUp() }, + onMoveDown: { viewModel.moveDown() }, + focusOnAppear: true + ) .padding(.horizontal, 12) .padding(.vertical, 8) } - // MARK: - Database List + @ViewBuilder + private var content: some View { + if viewModel.isLoading { + loadingView + } else if let error = viewModel.errorMessage { + errorView(error) + } else if viewModel.filteredDatabases.isEmpty { + emptyState + } else { + list + } + } - private var databaseList: some View { + private var list: some View { ScrollViewReader { proxy in List(selection: $viewModel.selectedDatabase) { ForEach(viewModel.filteredDatabases) { db in - databaseRow(db) + row(for: db) } } .listStyle(.inset) .scrollContentBackground(.hidden) - .focused($focus, equals: .databaseList) - .contextMenu(forSelectionType: String.self) { selection in - contextMenuItems(for: selection) - } primaryAction: { selection in - guard let name = selection.first else { return } - viewModel.selectedDatabase = name - openSelectedDatabase() - } + .focused($focus, equals: .list) .onChange(of: viewModel.selectedDatabase) { _, newValue in - if let item = newValue { - withAnimation(.easeInOut(duration: 0.15)) { - proxy.scrollTo(item, anchor: .center) - } + guard let item = newValue else { return } + withAnimation(.easeInOut(duration: 0.15)) { + proxy.scrollTo(item, anchor: .center) } } - } - } - - @ViewBuilder - private func contextMenuItems(for selection: Set) -> some View { - if !isSchemaMode, - PluginManager.shared.supportsDropDatabase(for: databaseType), - let name = selection.first, - let database = viewModel.filteredDatabases.first(where: { $0.name == name }), - !database.isSystemDatabase, - database.name != activeName { - Button(role: .destructive) { - databaseToDrop = database.name - showDropDialog = true - } label: { - Label(String(localized: "Drop Database..."), systemImage: "trash") + .onKeyPress(.return) { + commitSelection() + return .handled } } } - private func databaseRow(_ database: DatabaseMetadata) -> some View { - let isCurrent = database.name == activeName + private func row(for database: DatabaseMetadata) -> some View { + let isCurrent = database.name == currentDatabase + return HStack(spacing: 8) { + Image(systemName: "checkmark") + .font(.body.weight(.semibold)) + .foregroundStyle(Color.accentColor) + .opacity(isCurrent ? 1 : 0) + .frame(width: 14) - return HStack(spacing: 10) { Image(systemName: database.icon) .font(.body) - .foregroundStyle(database.isSystemDatabase ? Color(nsColor: .systemOrange) : Color(nsColor: .systemBlue)) + .foregroundStyle(database.isSystemDatabase ? Color.secondary : Color.accentColor) + .frame(width: 16) Text(database.name) .font(.body) + .lineLimit(1) + .truncationMode(.middle) - Spacer() - - if isCurrent { - Text("current") - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background( - RoundedRectangle(cornerRadius: 4) - .fill(Color(nsColor: .quaternaryLabelColor)) - ) - } + Spacer(minLength: 0) } - .padding(.vertical, 4) + .padding(.vertical, 2) .contentShape(Rectangle()) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) .listRowSeparator(.hidden) .id(database.name) .tag(database.name) } - // MARK: - Empty States - private var loadingView: some View { - VStack(spacing: 12) { - ProgressView() - .scaleEffect(0.8) - Text(isSchemaMode - ? String(localized: "Loading schemas...") - : String(localized: "Loading databases...")) + VStack(spacing: 10) { + ProgressView().controlSize(.small) + Text(String(localized: "Loading databases…")) .font(.callout) .foregroundStyle(.secondary) } @@ -316,197 +180,56 @@ struct DatabaseSwitcherSheet: View { } private func errorView(_ message: String) -> some View { - VStack(spacing: 12) { + VStack(spacing: 10) { Image(systemName: "exclamationmark.triangle") - .font(.title2) + .font(.title3) .foregroundStyle(Color(nsColor: .systemOrange)) - - Text(isSchemaMode - ? String(localized: "Failed to load schemas") - : String(localized: "Failed to load databases")) - .font(.body.weight(.medium)) - + Text(String(localized: "Failed to load databases")) + .font(.callout.weight(.medium)) Text(message) - .font(.subheadline) + .font(.caption) .foregroundStyle(.secondary) .multilineTextAlignment(.center) - .padding(.horizontal) - - Button("Retry") { + .padding(.horizontal, 16) + Button(String(localized: "Retry")) { Task { await viewModel.fetchDatabases() } } - .buttonStyle(.bordered) .controlSize(.small) } .frame(maxWidth: .infinity, maxHeight: .infinity) } - private var sqliteEmptyState: some View { - VStack(spacing: 12) { - Image(systemName: "doc.fill") - .font(.title2) - .foregroundStyle(.secondary) - - Text("SQLite is file-based") - .font(.body.weight(.medium)) - - Text( - "Each SQLite file is a separate database.\nTo open a different database, create a new connection." - ) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - private var emptyState: some View { - VStack(spacing: 12) { + VStack(spacing: 10) { Image(systemName: "magnifyingglass") - .font(.title2) + .font(.title3) .foregroundStyle(.secondary) - if viewModel.searchText.isEmpty { - Text(isSchemaMode - ? String(localized: "No schemas found") - : String(localized: "No databases found")) - .font(.body.weight(.medium)) + Text(String(localized: "No databases")) + .font(.callout.weight(.medium)) } else { - Text(isSchemaMode - ? String(localized: "No matching schemas") - : String(localized: "No matching databases")) - .font(.body.weight(.medium)) - - Text(isSchemaMode - ? String(format: String(localized: "No schemas match \"%@\""), viewModel.searchText) - : String(format: String(localized: "No databases match \"%@\""), viewModel.searchText)) - .font(.subheadline) + Text(String(format: String(localized: "No databases match “%@”"), viewModel.searchText)) + .font(.callout) .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) } } .frame(maxWidth: .infinity, maxHeight: .infinity) } - // MARK: - Footer - - private var navigationTitleString: String { - switch mode { - case .switching: - return isSchemaMode - ? String(localized: "Open Schema") - : String(localized: "Open Database") - case .backup: - return String(localized: "Backup Dump") - case .restore: - return String(localized: "Restore Dump") - } - } - - private var primaryButtonLabel: String { - switch mode { - case .switching: return String(localized: "Open") - case .backup: return String(localized: "Backup Dump\u{2026}") - case .restore: return String(localized: "Restore Dump\u{2026}") - } - } - - private var primaryButtonDisabled: Bool { - guard let selected = viewModel.selectedDatabase else { return true } - if mode == .switching, selected == activeName { return true } - return false - } - - private var footer: some View { - HStack { - Button("Cancel") { - dismiss() - } - - Spacer() - - Button(primaryButtonLabel) { - openSelectedDatabase() - } - .buttonStyle(.borderedProminent) - .disabled(primaryButtonDisabled) - .keyboardShortcut(.return, modifiers: []) - } - .padding(12) - } - - // MARK: - Drop Helpers - - private var canDropSelected: Bool { - guard !isSchemaMode, - PluginManager.shared.supportsDropDatabase(for: databaseType), - let selected = viewModel.selectedDatabase, - selected != activeName - else { return false } - let isSystem = viewModel.filteredDatabases.first { $0.name == selected }?.isSystemDatabase ?? false - return !isSystem - } - - private func initiateDropForSelected() { - guard canDropSelected, let selected = viewModel.selectedDatabase else { return } - databaseToDrop = selected - showDropDialog = true - } - - // MARK: - Actions - - private func refreshCreateSupport() async { - do { - let spec = try await viewModel.loadCreateDatabaseForm() - supportsCreateDatabase = spec != nil - } catch { - supportsCreateDatabase = false - } - } - - private func openSelectedDatabase() { + private func commitSelection() { guard let database = viewModel.selectedDatabase else { return } - - // Backup/restore: hand the selection off to the parent flow without - // dismissing. The host sheet stays mounted and transitions to the - // next step (save/open panel, then progress). - if isHandoffMode { - onSelect(database) - return - } - - // Don't reopen current database/schema - if database == activeName { - dismiss() - return - } - - // Call appropriate callback - if viewModel.isSchemaMode, PluginManager.shared.supportsSchemaSwitching(for: databaseType), let onSelectSchema { - onSelectSchema(database) - } else { - onSelect(database) - } - dismiss() + onSelect(database) } } -// MARK: - Preview - -#Preview("MySQL Databases") { +#Preview("Backup") { DatabaseSwitcherSheet( isPresented: .constant(true), + mode: .backup, currentDatabase: "production", - databaseType: .mysql, - connectionId: UUID() - ) { _ in } -} - -#Preview("SQLite Empty") { - DatabaseSwitcherSheet( - isPresented: .constant(true), - currentDatabase: nil, - databaseType: .sqlite, + databaseType: .postgresql, connectionId: UUID() ) { _ in } } diff --git a/TablePro/Views/DatabaseSwitcher/DropDatabaseSheet.swift b/TablePro/Views/DatabaseSwitcher/DropDatabaseSheet.swift deleted file mode 100644 index 45602ef36..000000000 --- a/TablePro/Views/DatabaseSwitcher/DropDatabaseSheet.swift +++ /dev/null @@ -1,97 +0,0 @@ -// -// DropDatabaseSheet.swift -// TablePro -// -// Confirmation dialog for dropping a database. -// - -import SwiftUI - -struct DropDatabaseSheet: View { - @Environment(\.dismiss) private var dismiss - - let databaseName: String - let viewModel: DatabaseSwitcherViewModel - let onDropped: () -> Void - - @State private var isDropping = false - @State private var errorMessage: String? - - var body: some View { - VStack(spacing: 0) { - Form { - Section { - VStack(spacing: 12) { - Image(systemName: "exclamationmark.triangle.fill") - .font(.largeTitle) - .foregroundStyle(Color(nsColor: .systemRed)) - - Text(String(format: String(localized: "Drop database '%@'?"), databaseName)) - .font(.body.weight(.medium)) - .multilineTextAlignment(.center) - - Text(String(localized: "All tables and data will be permanently deleted.")) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - - if let error = errorMessage { - Text(error) - .font(.subheadline) - .foregroundStyle(Color(nsColor: .systemRed)) - .multilineTextAlignment(.center) - } - } - .frame(maxWidth: .infinity) - } - } - .formStyle(.grouped) - .scrollContentBackground(.hidden) - - Divider() - - HStack { - Button("Cancel") { - dismiss() - } - .keyboardShortcut(.cancelAction) - - Spacer() - - Button(role: .destructive) { - dropDatabase() - } label: { - Text(isDropping ? String(localized: "Dropping...") : String(localized: "Drop")) - } - .buttonStyle(.borderedProminent) - .tint(.red) - .disabled(isDropping) - } - .padding(12) - } - .navigationTitle(String(localized: "Drop Database")) - .frame(width: 340) - .onExitCommand { - if !isDropping { - dismiss() - } - } - } - - private func dropDatabase() { - isDropping = true - errorMessage = nil - - Task { - do { - try await viewModel.dropDatabase(name: databaseName) - await viewModel.refreshDatabases() - onDropped() - dismiss() - } catch { - errorMessage = error.localizedDescription - isDropping = false - } - } - } -} diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 839376a80..15139a580 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -478,6 +478,25 @@ extension MainContentCoordinator { } } + /// Drop a database. Called from the database switcher's confirmation dialog. + func dropDatabase(name: String) async { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { + navigationLogger.warning("dropDatabase(name: \(name, privacy: .public)) ignored: no active driver") + return + } + + do { + try await driver.dropDatabase(name: name) + } catch { + navigationLogger.error("Failed to drop database: \(error.localizedDescription, privacy: .public)") + AlertHelper.showErrorSheet( + title: String(localized: "Drop Failed"), + message: error.localizedDescription, + window: contentWindow + ) + } + } + // MARK: - Redis Database Selection /// Select a Redis database index and then run the query. diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index f2b2d3b07..db4109c10 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -828,7 +828,11 @@ final class MainContentCommandActions { // MARK: - Database Operations (Group A — Called Directly) func openDatabaseSwitcher() { - coordinator?.activeSheet = .databaseSwitcher + guard let coordinator else { return } + let type = coordinator.connection.type + guard PluginManager.shared.supportsDatabaseSwitching(for: type) else { return } + guard PluginManager.shared.connectionMode(for: type) != .fileBased else { return } + coordinator.isDatabaseSwitcherShown = true } func openQuickSwitcher() { diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 7ac30be84..3a3984195 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -42,7 +42,6 @@ struct DisplayFormatsCacheEntry { /// Represents which sheet is currently active in MainContentView. /// Uses a single `.sheet(item:)` modifier instead of multiple `.sheet(isPresented:)`. enum ActiveSheet: Identifiable { - case databaseSwitcher case quickSwitcher case connectionSwitcher case sqlPreview @@ -52,10 +51,10 @@ enum ActiveSheet: Identifiable { case backupDatabase case restoreDatabase(fileURL: URL) case maintenance(operation: String, tableName: String) + case createDatabase var id: String { switch self { - case .databaseSwitcher: "databaseSwitcher" case .quickSwitcher: "quickSwitcher" case .connectionSwitcher: "connectionSwitcher" case .sqlPreview: "sqlPreview" @@ -65,6 +64,7 @@ enum ActiveSheet: Identifiable { case .backupDatabase: "backupDatabase" case .restoreDatabase(let fileURL): "restoreDatabase-\(fileURL.path)" case .maintenance(let operation, let tableName): "maintenance-\(operation)-\(tableName)" + case .createDatabase: "createDatabase" } } } @@ -152,6 +152,8 @@ final class MainContentCoordinator { var cursorPositions: [CursorPosition] = [] var tableMetadata: TableMetadata? var activeSheet: ActiveSheet? + var isDatabaseSwitcherShown = false + var databaseToDrop: String? var importFileURL: URL? var exportPreselectedTableNames: Set? var needsLazyLoad = false diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index d40933959..a43280809 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -100,9 +100,45 @@ struct MainContentView: View { .sheet(item: Bindable(coordinator).activeSheet) { sheet in sheetContent(for: sheet) } + .confirmationDialog( + dropConfirmationTitle, + isPresented: dropConfirmationBinding, + titleVisibility: .visible, + presenting: coordinator.databaseToDrop + ) { name in + Button(String(localized: "Drop Database"), role: .destructive) { + Task { await dropDatabase(name: name) } + } + Button(String(localized: "Cancel"), role: .cancel) { + coordinator.databaseToDrop = nil + } + } message: { _ in + Text(String(localized: "All tables and data will be permanently deleted.")) + } .modifier(FocusedCommandActionsModifier(actions: commandActions)) } + private var dropConfirmationBinding: Binding { + Binding( + get: { coordinator.databaseToDrop != nil }, + set: { newValue in + if !newValue { coordinator.databaseToDrop = nil } + } + ) + } + + private var dropConfirmationTitle: String { + if let name = coordinator.databaseToDrop { + return String(format: String(localized: "Drop database “%@”?"), name) + } + return "" + } + + private func dropDatabase(name: String) async { + await coordinator.dropDatabase(name: name) + coordinator.databaseToDrop = nil + } + // MARK: - Sheet Content /// Connection with the active database from the current session, @@ -131,23 +167,19 @@ struct MainContentView: View { ) switch sheet { - case .databaseSwitcher: - let session = DatabaseManager.shared.session(for: connection.id) - let activeDatabase = session?.currentDatabase ?? connection.database - let activeSchema = session?.currentSchema - let currentSelection = - PluginManager.shared.supportsSchemaSwitching(for: connection.type) - ? (activeSchema ?? activeDatabase) - : activeDatabase - DatabaseSwitcherSheet( - isPresented: dismissBinding, - currentDatabase: currentSelection, - currentSchema: activeSchema, - databaseType: connection.type, + case .createDatabase: + let viewModel = DatabaseSwitcherViewModel( connectionId: connection.id, - onSelect: switchDatabase, - onSelectSchema: { schema in - Task { await coordinator.switchSchema(to: schema) } + currentDatabase: nil, + currentSchema: nil, + databaseType: connection.type, + initialMode: .database + ) + CreateDatabaseSheet( + databaseType: connection.type, + viewModel: viewModel, + onCreated: { newDatabaseName in + Task { await coordinator.switchDatabase(to: newDatabaseName) } } ) case .exportDialog: