diff --git a/TablePro/Views/Components/PaginationControlsView.swift b/TablePro/Views/Components/PaginationControlsView.swift index be9c96a99..38ae40b6b 100644 --- a/TablePro/Views/Components/PaginationControlsView.swift +++ b/TablePro/Views/Components/PaginationControlsView.swift @@ -72,6 +72,11 @@ struct PaginationControlsView: View { .foregroundStyle(.secondary) .frame(minWidth: 60) + if pagination.isLoading { + ProgressView() + .controlSize(.small) + } + // Next page button Button(action: onNext) { Image(systemName: "chevron.right") diff --git a/TablePro/Views/Connection/ConnectionFormView.swift b/TablePro/Views/Connection/ConnectionFormView.swift index 6f04c9ee6..80c48a7e0 100644 --- a/TablePro/Views/Connection/ConnectionFormView.swift +++ b/TablePro/Views/Connection/ConnectionFormView.swift @@ -973,11 +973,14 @@ struct ConnectionFormView: View { // swiftlint:disable:this type_body_length if isTesting { ProgressView() .controlSize(.small) + } else if testSucceeded { + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) } else { - Image(systemName: testSucceeded ? "checkmark.circle.fill" : "bolt.horizontal") - .foregroundStyle(testSucceeded ? .green : .secondary) + Image(systemName: "bolt.horizontal") + .foregroundStyle(.secondary) } - Text("Test Connection") + Text(testSucceeded ? String(localized: "Connected") : String(localized: "Test Connection")) } } .disabled(isTesting || isInstallingPlugin || !isValid) diff --git a/TablePro/Views/Connection/OnboardingContentView.swift b/TablePro/Views/Connection/OnboardingContentView.swift index 7ad88caa8..3752a30b4 100644 --- a/TablePro/Views/Connection/OnboardingContentView.swift +++ b/TablePro/Views/Connection/OnboardingContentView.swift @@ -110,28 +110,28 @@ struct OnboardingContentView: View { VStack(alignment: .leading, spacing: 16) { featureRow( icon: "cylinder.split.1x2", - title: "MySQL, PostgreSQL & SQLite", - description: "Connect to popular databases with full feature support" + title: String(localized: "MySQL, PostgreSQL & SQLite"), + description: String(localized: "Connect to popular databases with full feature support") ) featureRow( icon: "chevron.left.forwardslash.chevron.right", - title: "Smart SQL Editor", - description: "Syntax highlighting, autocomplete, and multi-tab editing" + title: String(localized: "Smart SQL Editor"), + description: String(localized: "Syntax highlighting, autocomplete, and multi-tab editing") ) featureRow( icon: "tablecells", - title: "Interactive Data Grid", - description: "Browse, edit, and manage your data with ease" + title: String(localized: "Interactive Data Grid"), + description: String(localized: "Browse, edit, and manage your data with ease") ) featureRow( icon: "lock.shield", - title: "Secure Connections", - description: "SSH tunneling and SSL/TLS encryption support" + title: String(localized: "Secure Connections"), + description: String(localized: "SSH tunneling and SSL/TLS encryption support") ) featureRow( icon: "brain", - title: "AI-Powered Assistant", - description: "Get intelligent SQL suggestions and query assistance" + title: String(localized: "AI-Powered Assistant"), + description: String(localized: "Get intelligent SQL suggestions and query assistance") ) } .padding(.horizontal, 20) diff --git a/TablePro/Views/Connection/WelcomeLeftPanel.swift b/TablePro/Views/Connection/WelcomeLeftPanel.swift index 85b0853bb..da33b4279 100644 --- a/TablePro/Views/Connection/WelcomeLeftPanel.swift +++ b/TablePro/Views/Connection/WelcomeLeftPanel.swift @@ -17,7 +17,7 @@ struct WelcomeLeftPanel: View { 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) + .shadow(color: Color.accentColor.opacity(0.4), radius: 20, x: 0, y: 0) VStack(spacing: 6) { Text("TablePro") diff --git a/TablePro/Views/Editor/AIEditorContextMenu.swift b/TablePro/Views/Editor/AIEditorContextMenu.swift index 68a7a7ce3..c6b97cd86 100644 --- a/TablePro/Views/Editor/AIEditorContextMenu.swift +++ b/TablePro/Views/Editor/AIEditorContextMenu.swift @@ -16,6 +16,7 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { var onExplainWithAI: ((String) -> Void)? var onOptimizeWithAI: ((String) -> Void)? var onSaveAsFavorite: ((String) -> Void)? + var onFormatSQL: (() -> Void)? override init(title: String) { super.init(title: title) @@ -49,6 +50,18 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { menu.addItem(.separator()) + let formatItem = NSMenuItem( + title: String(localized: "Format SQL"), + action: #selector(handleFormatSQL), + keyEquivalent: "" + ) + formatItem.target = self + formatItem.image = NSImage(systemSymbolName: "text.alignleft", accessibilityDescription: nil) + formatItem.isEnabled = (fullText?()?.isEmpty == false) && (onFormatSQL != nil) + menu.addItem(formatItem) + + menu.addItem(.separator()) + let saveAsFavItem = NSMenuItem( title: String(localized: "Save as Favorite..."), action: #selector(handleSaveAsFavorite), @@ -95,6 +108,10 @@ final class AIEditorContextMenu: NSMenu, NSMenuDelegate { onOptimizeWithAI?(text) } + @objc private func handleFormatSQL() { + onFormatSQL?() + } + @objc private func handleSaveAsFavorite() { if let text = selectedText?(), !text.isEmpty { onSaveAsFavorite?(text) diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 2f1a67614..98be84613 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -54,7 +54,8 @@ struct QueryEditorView: View { onExecuteQuery: onExecuteQuery, onAIExplain: onAIExplain, onAIOptimize: onAIOptimize, - onSaveAsFavorite: onSaveAsFavorite + onSaveAsFavorite: onSaveAsFavorite, + onFormatSQL: formatQuery ) .frame(minHeight: 100) .clipped() diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index bbca2dce3..997c12aa4 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -47,6 +47,7 @@ final class SQLEditorCoordinator: TextViewCoordinator { @ObservationIgnored var onAIExplain: ((String) -> Void)? @ObservationIgnored var onAIOptimize: ((String) -> Void)? @ObservationIgnored var onSaveAsFavorite: ((String) -> Void)? + @ObservationIgnored var onFormatSQL: (() -> Void)? /// Whether the editor text view is currently the first responder. /// Used to guard cursor propagation — when the find panel highlights @@ -207,6 +208,7 @@ final class SQLEditorCoordinator: TextViewCoordinator { menu.onExplainWithAI = { [weak self] text in self?.onAIExplain?(text) } menu.onOptimizeWithAI = { [weak self] text in self?.onAIOptimize?(text) } menu.onSaveAsFavorite = { [weak self] text in self?.onSaveAsFavorite?(text) } + menu.onFormatSQL = { [weak self] in self?.onFormatSQL?() } contextMenu = menu } diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index c1e727931..7055ec8f1 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -26,6 +26,7 @@ struct SQLEditorView: View { var onAIExplain: ((String) -> Void)? var onAIOptimize: ((String) -> Void)? var onSaveAsFavorite: ((String) -> Void)? + var onFormatSQL: (() -> Void)? @State private var editorState = SourceEditorState() @State private var completionAdapter: SQLCompletionAdapter? @@ -104,6 +105,7 @@ struct SQLEditorView: View { coordinator.onAIExplain = onAIExplain coordinator.onAIOptimize = onAIOptimize coordinator.onSaveAsFavorite = onSaveAsFavorite + coordinator.onFormatSQL = onFormatSQL setupFavoritesObserver() } } else { @@ -118,6 +120,7 @@ struct SQLEditorView: View { coordinator.onAIExplain = onAIExplain coordinator.onAIOptimize = onAIOptimize coordinator.onSaveAsFavorite = onSaveAsFavorite + coordinator.onFormatSQL = onFormatSQL setupFavoritesObserver() editorReady = true } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 806b1b9f8..2580488b9 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -274,6 +274,13 @@ struct ExportDialog: View { Spacer() } + + let description = formatDescription(for: config.formatId) + if !description.isEmpty { + Text(description) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.secondary) + } } // Selection count or Pro gate message @@ -430,6 +437,17 @@ struct ExportDialog: View { private static let formatDisplayOrder = ["csv", "json", "sql", "xlsx", "mql"] private static let proFormatIds: Set = ["xlsx"] + private func formatDescription(for formatId: String) -> String { + switch formatId { + case "csv": return String(localized: "Comma-separated values. Compatible with Excel and most tools.") + case "json": return String(localized: "Structured data format. Ideal for APIs and web applications.") + case "sql": return String(localized: "SQL INSERT statements. Use to recreate data in another database.") + case "xlsx": return String(localized: "Excel spreadsheet with formatting support.") + case "mql": return String(localized: "MongoDB query language. Use to import into MongoDB.") + default: return "" + } + } + private func isProGatedFormat(_ formatId: String) -> Bool { Self.proFormatIds.contains(formatId) && !LicenseManager.shared.isFeatureAvailable(.xlsxExport) } diff --git a/TablePro/Views/Filter/FilterPanelView.swift b/TablePro/Views/Filter/FilterPanelView.swift index 7bb044946..9dcf0199a 100644 --- a/TablePro/Views/Filter/FilterPanelView.swift +++ b/TablePro/Views/Filter/FilterPanelView.swift @@ -98,10 +98,18 @@ struct FilterPanelView: View { TextField("Preset Name", text: $newPresetName) Button("Cancel", role: .cancel) {} Button("Save") { - if !newPresetName.isEmpty { - filterState.saveAsPreset(name: newPresetName) - loadPresets() + guard !newPresetName.isEmpty else { return } + var finalName = newPresetName + let existingNames = Set(savedPresets.map(\.name)) + if existingNames.contains(finalName) { + var counter = 2 + while existingNames.contains("\(newPresetName) (\(counter))") { + counter += 1 + } + finalName = "\(newPresetName) (\(counter))" } + filterState.saveAsPreset(name: finalName) + loadPresets() } } message: { Text("Enter a name for this filter preset") diff --git a/TablePro/Views/Import/ImportDialog.swift b/TablePro/Views/Import/ImportDialog.swift index 7a0fbc111..0b2b96b8a 100644 --- a/TablePro/Views/Import/ImportDialog.swift +++ b/TablePro/Views/Import/ImportDialog.swift @@ -33,6 +33,7 @@ struct ImportDialog: View { @State private var importResult: PluginImportResult? @State private var importError: (any Error)? + @State private var hasPreviewError = false @State private var tempPreviewURL: URL? @State private var loadFileTask: Task? @@ -276,7 +277,7 @@ struct ImportDialog: View { performImport() } .buttonStyle(.borderedProminent) - .disabled(fileURL == nil || importServiceState.isImporting || availableFormats.isEmpty) + .disabled(fileURL == nil || importServiceState.isImporting || availableFormats.isEmpty || hasPreviewError) .keyboardShortcut(.return, modifiers: []) } .padding(16) @@ -305,12 +306,14 @@ struct ImportDialog: View { @MainActor private func loadFile(_ url: URL) async { cleanupTempFiles() + hasPreviewError = false var isDirectory: ObjCBool = false guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false), isDirectory: &isDirectory), !isDirectory.boolValue else { filePreview = String(localized: "Error: Selected path is not a regular file") + hasPreviewError = true return } @@ -331,7 +334,8 @@ struct ImportDialog: View { tempPreviewURL = urlToRead } } catch { - filePreview = String(localized: "Failed to decompress file: \(error.localizedDescription)") + filePreview = "Failed to decompress file: \(error.localizedDescription)" + hasPreviewError = true return } @@ -350,11 +354,14 @@ struct ImportDialog: View { if let preview = String(data: previewData, encoding: selectedEncoding.encoding) { filePreview = preview + hasPreviewError = false } else { - filePreview = String(localized: "Failed to load preview using encoding: \(selectedEncoding.rawValue). Try selecting a different text encoding.") + filePreview = "Failed to load preview using encoding: \(selectedEncoding.rawValue). Try selecting a different text encoding." + hasPreviewError = true } } catch { - filePreview = String(localized: "Failed to load preview: \(error.localizedDescription)") + filePreview = "Failed to load preview: \(error.localizedDescription)" + hasPreviewError = true } Task { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 0db40e6af..9e57e025b 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -355,7 +355,14 @@ struct MainEditorContentView: View { Divider() } - dataGridView(tab: tab) + if tab.tabType == .query && !tab.resultColumns.isEmpty + && tab.resultRows.isEmpty && tab.lastExecutedAt != nil + && !tab.isExecuting && !filterStateManager.hasAppliedFilters + { + emptyResultView(executionTime: tab.activeResultSet?.executionTime ?? tab.executionTime) + } else { + dataGridView(tab: tab) + } } } @@ -388,6 +395,24 @@ struct MainEditorContentView: View { ) } + private func emptyResultView(executionTime: TimeInterval?) -> some View { + VStack(spacing: 12) { + Spacer() + Image(systemName: "tray") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("No rows returned") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.body, weight: .medium)) + if let time = executionTime { + Text(String(format: "%.3fs", time)) + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) + .foregroundStyle(.secondary) + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + @ViewBuilder private func dataGridView(tab: QueryTab) -> some View { DataGridView( diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 02d6d0e3c..52ee072fc 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -121,10 +121,13 @@ struct DataGridView: NSViewRepresentable { for (index, columnName) in rowProvider.columns.enumerated() { let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("col_\(index)")) column.title = columnName + if index < rowProvider.columnTypes.count { + let typeName = rowProvider.columnTypes[index].rawType ?? rowProvider.columnTypes[index].displayName + column.headerToolTip = "\(columnName) (\(typeName))" + } column.headerCell.setAccessibilityLabel( String(localized: "Column: \(columnName)") ) - // Use optimal width calculation based on both header and cell content column.width = context.coordinator.cellFactory.calculateOptimalColumnWidth( for: columnName, columnIndex: index, @@ -373,6 +376,11 @@ struct DataGridView: NSViewRepresentable { for (index, columnName) in rowProvider.columns.enumerated() { let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("col_\(index)")) column.title = columnName + if index < rowProvider.columnTypes.count { + let typeName = rowProvider.columnTypes[index].rawType + ?? rowProvider.columnTypes[index].displayName + column.headerToolTip = "\(columnName) (\(typeName))" + } column.headerCell.setAccessibilityLabel( String(localized: "Column: \(columnName)") ) @@ -394,6 +402,11 @@ struct DataGridView: NSViewRepresentable { colIndex < rowProvider.columns.count else { continue } let columnName = rowProvider.columns[colIndex] column.title = columnName + if colIndex < rowProvider.columnTypes.count { + let typeName = rowProvider.columnTypes[colIndex].rawType + ?? rowProvider.columnTypes[colIndex].displayName + column.headerToolTip = "\(columnName) (\(typeName))" + } column.width = coordinator.cellFactory.calculateOptimalColumnWidth( for: columnName, columnIndex: colIndex, diff --git a/TablePro/Views/Results/InlineErrorBanner.swift b/TablePro/Views/Results/InlineErrorBanner.swift index 96da638cc..4fa5a2d65 100644 --- a/TablePro/Views/Results/InlineErrorBanner.swift +++ b/TablePro/Views/Results/InlineErrorBanner.swift @@ -5,6 +5,7 @@ // Dismissable red error banner for query errors, displayed inline above results. // +import AppKit import SwiftUI struct InlineErrorBanner: View { @@ -20,6 +21,15 @@ struct InlineErrorBanner: View { .lineLimit(3) .textSelection(.enabled) Spacer() + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(message, forType: .string) + } label: { + Image(systemName: "doc.on.doc") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(String(localized: "Copy error message")) if let onDismiss { Button { onDismiss() } label: { Image(systemName: "xmark") diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 90400d5a5..ad9e2f3c0 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -449,9 +449,18 @@ private struct AIProviderEditorSheet: View { } if let error = modelFetchError { - Text(error) - .font(.caption) - .foregroundStyle(.red) + HStack { + Text(error) + .font(.caption) + .foregroundStyle(.red) + Button { + fetchModels() + } label: { + Label("Retry", systemImage: "arrow.clockwise") + .font(.caption) + } + .buttonStyle(.borderless) + } } } } diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 414dd0abb..46d50ed56 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -13,6 +13,7 @@ struct GeneralSettingsView: View { var updaterBridge: UpdaterBridge @Bindable private var settingsManager = AppSettingsManager.shared @State private var initialLanguage: AppLanguage? + @State private var showResetConfirmation = false private static let standardTimeouts = [10, 20, 30, 40, 50, 60, 90, 120, 180, 300, 600] @@ -82,9 +83,23 @@ struct GeneralSettingsView: View { .font(.caption) .foregroundStyle(.secondary) } + + Section { + Button(String(localized: "Reset All Settings to Defaults"), role: .destructive) { + showResetConfirmation = true + } + } } .formStyle(.grouped) .scrollContentBackground(.hidden) + .alert(String(localized: "Reset All Settings"), isPresented: $showResetConfirmation) { + Button(String(localized: "Reset"), role: .destructive) { + settingsManager.resetToDefaults() + } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { + Text("This will reset all settings across every section to their default values.") + } .onAppear { if initialLanguage == nil { initialLanguage = settings.language diff --git a/TablePro/Views/Settings/LicenseSettingsView.swift b/TablePro/Views/Settings/LicenseSettingsView.swift index 65788869e..5ecc96e28 100644 --- a/TablePro/Views/Settings/LicenseSettingsView.swift +++ b/TablePro/Views/Settings/LicenseSettingsView.swift @@ -20,6 +20,7 @@ struct LicenseSettingsView: View { @State private var maxActivations = 0 @State private var isLoadingActivations = false @State private var hasLoadedActivations = false + @State private var activationLoadError: String? var body: some View { Form { @@ -89,10 +90,16 @@ struct LicenseSettingsView: View { .controlSize(.small) Spacer() } - } else if activations.isEmpty { + } else if activations.isEmpty && activationLoadError == nil { Text("No activations found") .foregroundStyle(.secondary) - } else { + } + if let error = activationLoadError { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + if !activations.isEmpty { ForEach(activations) { activation in HStack { VStack(alignment: .leading, spacing: 2) { @@ -214,6 +221,7 @@ struct LicenseSettingsView: View { maxActivations = response.maxActivations } catch { Self.logger.debug("Failed to load activations: \(error.localizedDescription)") + activationLoadError = error.localizedDescription } } diff --git a/TablePro/Views/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift index 4bb4f4078..5eb680e69 100644 --- a/TablePro/Views/Settings/SettingsView.swift +++ b/TablePro/Views/Settings/SettingsView.swift @@ -70,7 +70,7 @@ struct SettingsView: View { SyncSettingsView() .tabItem { - Label("Sync", systemImage: "icloud") + Label("Sync (Pro)", systemImage: "icloud") } .tag(SettingsTab.sync.rawValue) .requiresPro(.iCloudSync) diff --git a/TablePro/Views/Toolbar/ExecutionIndicatorView.swift b/TablePro/Views/Toolbar/ExecutionIndicatorView.swift index c0d799a6e..9438161c1 100644 --- a/TablePro/Views/Toolbar/ExecutionIndicatorView.swift +++ b/TablePro/Views/Toolbar/ExecutionIndicatorView.swift @@ -21,11 +21,14 @@ struct ExecutionIndicatorView: View { ProgressView() .controlSize(.small) .accessibilityLabel(String(localized: "Query executing")) - .help("Query executing...") if let progress = clickHouseProgress { Text(progress.formattedLive) .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .regular, design: .monospaced)) .foregroundStyle(ThemeEngine.shared.colors.toolbar.tertiaryTextSwiftUI) + } else { + Text("Executing...") + .font(.system(size: ThemeEngine.shared.activeTheme.typography.small, weight: .regular, design: .monospaced)) + .foregroundStyle(ThemeEngine.shared.colors.toolbar.tertiaryTextSwiftUI) } } else if let chProgress = lastClickHouseProgress { Text(chProgress.formattedSummary)