From 0594f9fa683ee1252dfb2754a8ef34c20411a80a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 20:24:37 +0700 Subject: [PATCH 1/3] feat: redesign Plugins settings with HSplitView master-detail layout --- TablePro/Resources/Localizable.xcstrings | 76 +++++ .../Settings/Plugins/BrowsePluginsView.swift | 274 +++++++++--------- .../Plugins/InstalledPluginsView.swift | 248 +++++++++------- .../Plugins/RegistryPluginDetailView.swift | 126 ++++---- .../Settings/Plugins/RegistryPluginRow.swift | 45 +-- TablePro/Views/Settings/SettingsView.swift | 2 +- 6 files changed, 432 insertions(+), 339 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index a4f5cef9..ff4ca1bc 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -1904,6 +1904,9 @@ }, "Alert (Full)" : { + }, + "All" : { + }, "All %lld rows selected" : { "localizations" : { @@ -2032,8 +2035,12 @@ } } } + }, + "Also handles" : { + }, "Also handles:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2398,6 +2405,9 @@ }, "Authentication required to execute write operations" : { + }, + "Author" : { + }, "AUTO" : { "extractionState" : "stale", @@ -2641,8 +2651,12 @@ } } } + }, + "Bundle ID" : { + }, "Bundle ID:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2724,8 +2738,12 @@ } } } + }, + "Capabilities" : { + }, "Capabilities:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2756,6 +2774,9 @@ } } } + }, + "Category" : { + }, "Cell Renderer" : { "extractionState" : "stale", @@ -4971,8 +4992,12 @@ } } } + }, + "Database Type" : { + }, "Database Type:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -5277,8 +5302,12 @@ } } } + }, + "Default Port" : { + }, "Default Port:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -5656,6 +5685,9 @@ } } } + }, + "Dismiss" : { + }, "Display" : { "localizations" : { @@ -7032,6 +7064,9 @@ } } } + }, + "Failed to Load" : { + }, "Failed to load databases" : { "localizations" : { @@ -7066,6 +7101,7 @@ } }, "Failed to load plugin registry" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -8570,6 +8606,7 @@ } }, "Install from File..." : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -8634,6 +8671,7 @@ } }, "Installed Plugins" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -9365,6 +9403,7 @@ } }, "Loading plugins..." : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -10648,6 +10687,7 @@ } }, "No plugins found" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -13346,6 +13386,9 @@ } } } + }, + "Requires" : { + }, "Reset to Defaults" : { "localizations" : { @@ -14169,6 +14212,12 @@ } } } + }, + "Select a Plugin" : { + + }, + "Select a plugin to view details" : { + }, "Select a Query" : { "localizations" : { @@ -14806,6 +14855,7 @@ } }, "Source:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -15313,6 +15363,9 @@ } } } + }, + "Status" : { + }, "Stop" : { "localizations" : { @@ -17006,6 +17059,7 @@ } }, "User" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -17086,6 +17140,7 @@ } }, "v%@" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -17100,6 +17155,19 @@ } } } + }, + "v%@ · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "v%1$@ · %2$@" + } + } + } + }, + "v%@+" : { + }, "Validation Failed" : { "localizations" : { @@ -17148,8 +17216,12 @@ } } } + }, + "Verified" : { + }, "Verified by TablePro" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -17196,6 +17268,9 @@ } } } + }, + "Version" : { + }, "Version %@" : { "localizations" : { @@ -17236,6 +17311,7 @@ } }, "Version:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { diff --git a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift index ff15cc88..0fcd13f3 100644 --- a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift +++ b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift @@ -3,6 +3,7 @@ // TablePro // +import AppKit import SwiftUI struct BrowsePluginsView: View { @@ -17,16 +18,36 @@ struct BrowsePluginsView: View { @State private var showErrorAlert = false @State private var errorMessage = "" + private var selectedRegistryPlugin: RegistryPlugin? { + guard let selectedPluginId else { return nil } + return registryClient.manifest?.plugins.first { $0.id == selectedPluginId } + } + var body: some View { VStack(spacing: 0) { - searchAndFilterBar - .padding(.horizontal, 16) - .padding(.top, 8) + HStack { + TextField("Search plugins...", text: $searchText) + .textFieldStyle(.roundedBorder) + Picker("Category", selection: $selectedCategory) { + Text("All").tag(RegistryCategory?.none) + ForEach(RegistryCategory.allCases) { category in + Text(category.displayName).tag(RegistryCategory?.some(category)) + } + } + .fixedSize() + } + .padding(.horizontal, 12) + .padding(.vertical, 8) Divider() - .padding(.top, 8) - contentView + HSplitView { + browseLeftPane + .frame(minWidth: 200, idealWidth: 240, maxWidth: 280) + + browseDetailPane + .frame(minWidth: 340) + } } .task { if registryClient.fetchState == .idle { @@ -39,133 +60,137 @@ struct BrowsePluginsView: View { } message: { Text(errorMessage) } - } - - // MARK: - Search & Filter - - @ViewBuilder - private var searchAndFilterBar: some View { - VStack(spacing: 8) { - HStack { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - TextField("Search plugins...", text: $searchText) - .textFieldStyle(.plain) - - if !searchText.isEmpty { - Button { - searchText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - } - .padding(8) - .background(.quaternary, in: RoundedRectangle(cornerRadius: 8)) - - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - FilterChip(title: "All", isSelected: selectedCategory == nil) { - selectedCategory = nil - } - - ForEach(RegistryCategory.allCases) { category in - FilterChip( - title: category.displayName, - isSelected: selectedCategory == category - ) { - selectedCategory = category - } - } - } - } + .onChange(of: searchText) { + clearSelectionIfNeeded() + } + .onChange(of: selectedCategory) { + clearSelectionIfNeeded() } } - // MARK: - Content + // MARK: - Left Pane @ViewBuilder - private var contentView: some View { + private var browseLeftPane: some View { switch registryClient.fetchState { case .idle, .loading: - VStack { - Spacer() - ProgressView("Loading plugins...") - Spacer() - } - .frame(maxWidth: .infinity) + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) case .loaded: let plugins = registryClient.search(query: searchText, category: selectedCategory) if plugins.isEmpty { - VStack(spacing: 8) { - Spacer() - Image(systemName: "puzzlepiece.extension") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("No plugins found") - .foregroundStyle(.secondary) - Spacer() - } - .frame(maxWidth: .infinity) + ContentUnavailableView.search(text: searchText) } else { - ScrollView { - LazyVStack(spacing: 0) { - ForEach(plugins) { plugin in - VStack(spacing: 0) { - RegistryPluginRow( - plugin: plugin, - isInstalled: isPluginInstalled(plugin.id), - installProgress: installTracker.state(for: plugin.id), - downloadCount: downloadCountService.downloadCount(for: plugin.id), - onInstall: { installPlugin(plugin) }, - onToggleDetail: { - withAnimation(.easeInOut(duration: 0.2)) { - selectedPluginId = selectedPluginId == plugin.id ? nil : plugin.id - } - } - ) - - if selectedPluginId == plugin.id { - RegistryPluginDetailView( - plugin: plugin, - isInstalled: isPluginInstalled(plugin.id), - installProgress: installTracker.state(for: plugin.id), - downloadCount: downloadCountService.downloadCount(for: plugin.id), - onInstall: { installPlugin(plugin) } - ) - } - - Divider() - } - } + List(selection: $selectedPluginId) { + ForEach(plugins) { plugin in + browseRow(plugin) + .tag(plugin.id) } - .padding(.horizontal, 16) } + .listStyle(.inset(alternatesRowBackgrounds: true)) } case .failed(let message): - VStack(spacing: 12) { - Spacer() - Image(systemName: "wifi.slash") - .font(.largeTitle) - .foregroundStyle(.secondary) - Text("Failed to load plugin registry") - .font(.headline) + ContentUnavailableView { + Label("Failed to Load", systemImage: "wifi.slash") + } description: { Text(message) - .font(.caption) - .foregroundStyle(.secondary) + } actions: { Button("Try Again") { - Task { - await registryClient.fetchManifest(forceRefresh: true) - } + Task { await registryClient.fetchManifest(forceRefresh: true) } } .buttonStyle(.bordered) - Spacer() } - .frame(maxWidth: .infinity) + } + } + + // MARK: - Browse Row + + @ViewBuilder + private func browseRow(_ plugin: RegistryPlugin) -> some View { + HStack(spacing: 6) { + pluginIcon(plugin.iconName ?? "puzzlepiece") + .frame(width: 16) + .foregroundStyle(.secondary) + Text(plugin.name) + .lineLimit(1) + if plugin.isVerified { + Image(systemName: "checkmark.seal.fill") + .foregroundStyle(.blue) + .font(.caption2) + } + Spacer() + compactActionButton(for: plugin) + } + } + + // MARK: - Right Pane + + @ViewBuilder + private var browseDetailPane: some View { + if let selectedPlugin = selectedRegistryPlugin { + RegistryPluginDetailView( + plugin: selectedPlugin, + isInstalled: isPluginInstalled(selectedPlugin.id), + installProgress: installTracker.state(for: selectedPlugin.id), + downloadCount: downloadCountService.downloadCount(for: selectedPlugin.id), + onInstall: { installPlugin(selectedPlugin) } + ) + } else { + VStack(spacing: 8) { + Image(systemName: "puzzlepiece.extension") + .font(.system(size: 32)) + .foregroundStyle(.tertiary) + Text("Select a plugin to view details") + .font(.headline) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: - Compact Action Button + + @ViewBuilder + private func compactActionButton(for plugin: RegistryPlugin) -> some View { + if isPluginInstalled(plugin.id) { + Text("Installed") + .font(.caption2) + .foregroundStyle(.secondary) + } else if let progress = installTracker.state(for: plugin.id) { + switch progress.phase { + case .downloading(let fraction): + ProgressView(value: fraction) + .frame(width: 40) + .progressViewStyle(.linear) + case .installing: + ProgressView() + .controlSize(.mini) + case .completed: + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.caption) + case .failed: + Button("Retry") { installPlugin(plugin) } + .controlSize(.mini) + } + } else { + Button("Install") { installPlugin(plugin) } + .buttonStyle(.bordered) + .controlSize(.mini) + } + } + + // MARK: - Plugin Icon + + @ViewBuilder + private func pluginIcon(_ name: String) -> some View { + if NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil { + Image(systemName: name) + } else { + Image(name) + .renderingMode(.template) } } @@ -193,31 +218,12 @@ struct BrowsePluginsView: View { } } } -} - -// MARK: - Filter Chip -private struct FilterChip: View { - let title: String - let isSelected: Bool - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(title) - .font(.caption) - .padding(.horizontal, 10) - .padding(.vertical, 4) - .background( - isSelected ? Color.accentColor.opacity(0.15) : Color.clear, - in: RoundedRectangle(cornerRadius: 6) - ) - .overlay( - RoundedRectangle(cornerRadius: 6) - .stroke(isSelected ? Color.accentColor : Color.secondary.opacity(0.3), lineWidth: 1) - ) + private func clearSelectionIfNeeded() { + guard let selectedPluginId else { return } + let plugins = registryClient.search(query: searchText, category: selectedCategory) + if !plugins.contains(where: { $0.id == selectedPluginId }) { + self.selectedPluginId = nil } - .buttonStyle(.plain) - .foregroundStyle(isSelected ? .primary : .secondary) } } diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 307581a6..1861ee3e 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -18,53 +18,73 @@ struct InstalledPluginsView: View { @State private var dismissedRestartBanner = false var body: some View { - Form { + VStack(spacing: 0) { if pluginManager.needsRestart && !dismissedRestartBanner { - Section { - HStack(spacing: 8) { - Image(systemName: "arrow.clockwise.circle.fill") - .foregroundStyle(.orange) - Text("Restart TablePro to fully unload removed plugins.") - .font(.callout) - .foregroundStyle(.secondary) - Spacer() + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.yellow) + Text("Restart TablePro to fully unload removed plugins.") + .font(.callout) + Spacer() + Button("Dismiss") { dismissedRestartBanner = true } + .buttonStyle(.borderless) + .font(.callout) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + } + + HSplitView { + VStack(spacing: 0) { + List(selection: $selectedPluginId) { + ForEach(pluginManager.plugins) { plugin in + pluginRow(plugin) + .tag(plugin.id) + } + } + .listStyle(.inset(alternatesRowBackgrounds: true)) + + Divider() + + HStack(spacing: 0) { Button { - dismissedRestartBanner = true + installFromFile() } label: { - Image(systemName: "xmark") - .foregroundStyle(.secondary) + Image(systemName: "plus") + .frame(width: 24, height: 20) } - .buttonStyle(.plain) - } - } - } + .buttonStyle(.borderless) + .disabled(pluginManager.isInstalling) - Section("Installed Plugins") { - ForEach(pluginManager.plugins) { plugin in - pluginRow(plugin) - } - } + Divider().frame(height: 16) - Section { - HStack { - Button("Install from File...") { - installFromFile() - } - .disabled(pluginManager.isInstalling) + Button { + if let plugin = selectedPlugin { + uninstallPlugin(plugin) + } + } label: { + Image(systemName: "minus") + .frame(width: 24, height: 20) + } + .buttonStyle(.borderless) + .disabled(selectedPluginId == nil || selectedPlugin?.source == .builtIn) + + Spacer() - if pluginManager.isInstalling { - ProgressView() - .controlSize(.small) + if pluginManager.isInstalling { + ProgressView() + .controlSize(.small) + } } + .padding(.horizontal, 4) + .padding(.vertical, 2) } - } + .frame(minWidth: 200, idealWidth: 240, maxWidth: 280) - if let selected = selectedPlugin { - pluginDetailSection(selected) + detailPane + .frame(minWidth: 340) } } - .formStyle(.grouped) - .scrollContentBackground(.hidden) .onDrop(of: [.fileURL], isTargeted: nil) { providers in guard let provider = providers.first, provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) else { @@ -92,48 +112,21 @@ struct InstalledPluginsView: View { @ViewBuilder private func pluginRow(_ plugin: PluginEntry) -> some View { - HStack { + HStack(spacing: 6) { pluginIcon(plugin.iconName) - .frame(width: 20) + .frame(width: 16) .foregroundStyle(plugin.isEnabled ? .primary : .tertiary) - - VStack(alignment: .leading, spacing: 2) { - Text(plugin.name) - .foregroundStyle(plugin.isEnabled ? .primary : .secondary) - - HStack(spacing: 4) { - Text("v\(plugin.version)") - .font(.caption) - .foregroundStyle(.secondary) - - Text(plugin.source == .builtIn ? "Built-in" : "User") - .font(.caption) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background( - plugin.source == .builtIn - ? Color.blue.opacity(0.15) - : Color.green.opacity(0.15), - in: RoundedRectangle(cornerRadius: 3) - ) - .foregroundStyle(plugin.source == .builtIn ? .blue : .green) - } - } - + Text(plugin.name) + .lineLimit(1) + .foregroundStyle(plugin.isEnabled ? .primary : .secondary) Spacer() - Toggle("", isOn: Binding( get: { plugin.isEnabled }, set: { pluginManager.setEnabled($0, pluginId: plugin.id) } )) .toggleStyle(.switch) .labelsHidden() - } - .contentShape(Rectangle()) - .onTapGesture { - withAnimation(.easeInOut(duration: 0.2)) { - selectedPluginId = selectedPluginId == plugin.id ? nil : plugin.id - } + .controlSize(.small) } } @@ -147,7 +140,7 @@ struct InstalledPluginsView: View { } } - // MARK: - Detail Section + // MARK: - Detail Pane private var selectedPlugin: PluginEntry? { guard let id = selectedPluginId else { return nil } @@ -155,52 +148,95 @@ struct InstalledPluginsView: View { } @ViewBuilder - private func pluginDetailSection(_ plugin: PluginEntry) -> some View { - Section(plugin.name) { - LabeledContent("Version:", value: plugin.version) - LabeledContent("Bundle ID:", value: plugin.id) - LabeledContent("Source:", value: plugin.source == .builtIn - ? String(localized: "Built-in") - : String(localized: "User-installed")) - - if !plugin.capabilities.isEmpty { - LabeledContent("Capabilities:") { - Text(plugin.capabilities.map(\.displayName).joined(separator: ", ")) - } - } + private var detailPane: some View { + if let selected = selectedPlugin { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(selected.name) + .font(.title3.weight(.semibold)) + + Text("v\(selected.version) · \(selected.source == .builtIn ? String(localized: "Built-in") : String(localized: "User-installed"))") + .font(.subheadline) + .foregroundStyle(.secondary) - if let typeId = plugin.databaseTypeId { - LabeledContent("Database Type:", value: typeId) + if !selected.pluginDescription.isEmpty { + Text(selected.pluginDescription) + .font(.callout) + .foregroundStyle(.secondary) + } - if !plugin.additionalTypeIds.isEmpty { - LabeledContent("Also handles:", value: plugin.additionalTypeIds.joined(separator: ", ")) - } + Divider() - if let port = plugin.defaultPort { - LabeledContent("Default Port:", value: "\(port)") - } - } + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 10) { + GridRow { + Text("Bundle ID") + .foregroundStyle(.secondary) + .gridColumnAlignment(.leading) + Text(selected.id) + .textSelection(.enabled) + .gridColumnAlignment(.leading) + } + + if !selected.capabilities.isEmpty { + GridRow { + Text("Capabilities") + .foregroundStyle(.secondary) + Text(selected.capabilities.map(\.displayName).joined(separator: ", ")) + } + } - if !plugin.pluginDescription.isEmpty { - Text(plugin.pluginDescription) + if let typeId = selected.databaseTypeId { + GridRow { + Text("Database Type") + .foregroundStyle(.secondary) + Text(typeId) + } + + if !selected.additionalTypeIds.isEmpty { + GridRow { + Text("Also handles") + .foregroundStyle(.secondary) + Text(selected.additionalTypeIds.joined(separator: ", ")) + } + } + + if let port = selected.defaultPort { + GridRow { + Text("Default Port") + .foregroundStyle(.secondary) + Text("\(port)") + } + } + } + } .font(.callout) - .foregroundStyle(.secondary) - } - if let settable = pluginManager.pluginInstances[plugin.id] as? any SettablePluginDiscoverable, - let pluginSettings = settable.settingsView() { - Divider() - pluginSettings - } + if let settable = pluginManager.pluginInstances[selected.id] as? any SettablePluginDiscoverable, + let pluginSettings = settable.settingsView() { + Divider() + pluginSettings + } - if plugin.source == .userInstalled { - HStack { - Spacer() - Button("Uninstall", role: .destructive) { - uninstallPlugin(plugin) + if selected.source == .userInstalled { + Divider() + Button("Uninstall", role: .destructive) { + uninstallPlugin(selected) + } } } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + } else { + VStack(spacing: 8) { + Image(systemName: "puzzlepiece.extension") + .font(.system(size: 32)) + .foregroundStyle(.tertiary) + Text("Select a Plugin") + .font(.headline) + .foregroundStyle(.secondary) } + .frame(maxWidth: .infinity, maxHeight: .infinity) } } @@ -269,5 +305,5 @@ private extension PluginCapability { #Preview { InstalledPluginsView() - .frame(width: 550, height: 500) + .frame(width: 650, height: 500) } diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift index 16e11041..409529ac 100644 --- a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift +++ b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift @@ -13,62 +13,85 @@ struct RegistryPluginDetailView: View { let onInstall: () -> Void var body: some View { - VStack(alignment: .leading, spacing: 10) { - Text(plugin.summary) - .font(.callout) - .foregroundStyle(.secondary) + ScrollView { + VStack(alignment: .leading, spacing: 16) { + Text(plugin.name) + .font(.title3.weight(.semibold)) - HStack(spacing: 16) { - detailItem(label: "Category", value: plugin.category.displayName) + Text(plugin.summary) + .font(.callout) + .foregroundStyle(.secondary) - if let minVersion = plugin.minAppVersion { - detailItem(label: "Requires", value: "v\(minVersion)+") - } + Divider() - if let downloadCount { - detailItem( - label: String(localized: "Downloads"), - value: formattedDownloadCount(downloadCount) - ) - } - } + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 10) { + GridRow { + Text("Category") + .foregroundStyle(.secondary) + .gridColumnAlignment(.leading) + Text(plugin.category.displayName) + .gridColumnAlignment(.leading) + } + + GridRow { + Text("Author") + .foregroundStyle(.secondary) + Text(plugin.author.name) + } + + GridRow { + Text("Version") + .foregroundStyle(.secondary) + Text(plugin.version) + } - HStack(spacing: 16) { - detailItem(label: "Author", value: plugin.author.name) + if let minVersion = plugin.minAppVersion { + GridRow { + Text("Requires") + .foregroundStyle(.secondary) + Text("v\(minVersion)+") + } + } - if let homepage = plugin.homepage, let url = URL(string: homepage) { - Link(destination: url) { - HStack(spacing: 2) { + if let count = downloadCount { + GridRow { + Text("Downloads") + .foregroundStyle(.secondary) + Text(formattedCount(count)) + } + } + + if let homepage = plugin.homepage, let url = URL(string: homepage) { + GridRow { Text("Homepage") - .font(.caption) - Image(systemName: "arrow.up.right.square") - .font(.caption2) + .foregroundStyle(.secondary) + Link(homepage, destination: url) + .lineLimit(1) + .truncationMode(.middle) } } - } - } - if plugin.isVerified { - HStack(spacing: 4) { - Image(systemName: "checkmark.seal.fill") - .foregroundStyle(.blue) - .font(.caption) - Text("Verified by TablePro") - .font(.caption) - .foregroundStyle(.blue) + if plugin.isVerified { + GridRow { + Text("Status") + .foregroundStyle(.secondary) + Label("Verified", systemImage: "checkmark.seal.fill") + .foregroundStyle(.blue) + } + } } - } + .font(.callout) - if !isInstalled, installProgress == nil { - Button("Install Plugin") { - onInstall() + if !isInstalled, installProgress == nil { + Divider() + Button("Install Plugin") { onInstall() } + .buttonStyle(.borderedProminent) + .controlSize(.regular) } - .buttonStyle(.borderedProminent) - .controlSize(.small) } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) } - .padding(.leading, 34) - .padding(.vertical, 8) } private static let decimalFormatter: NumberFormatter = { @@ -77,19 +100,10 @@ struct RegistryPluginDetailView: View { return formatter }() - private func formattedDownloadCount(_ count: Int) -> String { - Self.decimalFormatter.string(from: NSNumber(value: count)) ?? "\(count)" - } - - @ViewBuilder - private func detailItem(label: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.caption2) - .foregroundStyle(.tertiary) - .textCase(.uppercase) - Text(value) - .font(.caption) - } + private func formattedCount(_ count: Int) -> String { + let formatted = Self.decimalFormatter.string(from: NSNumber(value: count)) ?? "\(count)" + return count == 1 + ? String(localized: "\(formatted) download") + : String(localized: "\(formatted) downloads") } } diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift b/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift index e103ba9a..9ef21093 100644 --- a/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift +++ b/TablePro/Views/Settings/Plugins/RegistryPluginRow.swift @@ -9,9 +9,7 @@ struct RegistryPluginRow: View { let plugin: RegistryPlugin let isInstalled: Bool let installProgress: InstallProgress? - let downloadCount: Int? let onInstall: () -> Void - let onToggleDetail: () -> Void var body: some View { HStack(spacing: 10) { @@ -31,29 +29,9 @@ struct RegistryPluginRow: View { } } - HStack(spacing: 6) { - Text("v\(plugin.version)") - .font(.caption) - .foregroundStyle(.secondary) - - Text("\u{2022}") - .font(.caption2) - .foregroundStyle(.quaternary) - - Text(plugin.author.name) - .font(.caption) - .foregroundStyle(.secondary) - - if let downloadCount { - Text("\u{2022}") - .font(.caption2) - .foregroundStyle(.quaternary) - - Text(formattedCount(downloadCount)) - .font(.caption) - .foregroundStyle(.secondary) - } - } + Text("v\(plugin.version) · \(plugin.author.name)") + .font(.caption) + .foregroundStyle(.secondary) } Spacer() @@ -61,10 +39,6 @@ struct RegistryPluginRow: View { actionButton } .padding(.vertical, 8) - .contentShape(Rectangle()) - .onTapGesture { - onToggleDetail() - } } @ViewBuilder @@ -77,19 +51,6 @@ struct RegistryPluginRow: View { } } - private static let decimalFormatter: NumberFormatter = { - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter - }() - - private func formattedCount(_ count: Int) -> String { - let formatted = Self.decimalFormatter.string(from: NSNumber(value: count)) ?? "\(count)" - return count == 1 - ? String(localized: "\(formatted) download") - : String(localized: "\(formatted) downloads") - } - @ViewBuilder private var actionButton: some View { if isInstalled { diff --git a/TablePro/Views/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift index fe0c8882..355aa5f7 100644 --- a/TablePro/Views/Settings/SettingsView.swift +++ b/TablePro/Views/Settings/SettingsView.swift @@ -74,7 +74,7 @@ struct SettingsView: View { } .tag(SettingsTab.license.rawValue) } - .frame(width: 620, height: 500) + .frame(width: 720, height: 500) } } From 1de2c0ddc6248f2e00ad813322b684627edc416c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 20:36:51 +0700 Subject: [PATCH 2/3] docs: add CHANGELOG entry and update plugin settings docs for HSplitView layout --- CHANGELOG.md | 1 + docs/customization/settings.mdx | 14 +++++++++++--- docs/vi/customization/settings.mdx | 14 +++++++++++--- docs/zh/customization/settings.mdx | 14 +++++++++++--- 4 files changed, 34 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e5c2d3..5f1d95b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Redesigned Plugins settings tab with HSplitView master-detail layout: plugin list on the left, detail pane on the right, matching macOS conventions - Replaced ~40 hardcoded `DatabaseType` switches across ~20 UI files with dynamic plugin property lookups via `PluginManager`, so third-party plugins get correct UI behavior (colors, labels, editor language, feature toggles) automatically - ConnectionFormView now fully dynamic: pgpass toggle, password visibility, and SSH/SSL tab visibility all driven by plugin metadata (`FieldSection`, `hidesPassword`, `supportsSSH`/`supportsSSL`) instead of hardcoded type checks - Replaced `AppState.isMongoDB`/`isRedis` booleans with `AppState.editorLanguage: EditorLanguage` for extensible editor language detection diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index e8f5fdef..3f3d99c3 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -372,6 +372,8 @@ Manage database driver plugins from the **Plugins** tab in Settings. ### Installed Plugins +The Installed tab uses a split-view layout: the plugin list on the left, and the detail pane on the right. + TablePro ships with 8 built-in database driver plugins: | Plugin | Database Types | Default Port | @@ -389,21 +391,27 @@ Each plugin has a toggle to enable or disable it. Disabled plugins hide their da ### Plugin Details -Click a plugin to view its details: version, bundle ID, supported capabilities, database type, and default port. Driver plugins can provide custom settings panels that appear in this detail view. +Select a plugin in the list to view its details in the right pane: version, bundle ID, supported capabilities, database type, and default port. Driver plugins can provide custom settings panels that appear in this detail view. + +### Browse Plugins + +The Browse tab also uses a split-view layout. Search and filter plugins by category using the controls at the top. Select a plugin in the list to view its full details and install it from the right pane. ### Installing Third-Party Plugins -1. Click **Install from File...** +1. Click the **+** button below the plugin list 2. Select a `.zip` archive containing a `.tableplugin` bundle 3. TablePro verifies the code signature and installs the plugin +You can also drag and drop a `.tableplugin` or `.zip` file onto the Plugins settings view to install it. + Only install plugins from sources you trust. TablePro checks the code signature of sideloaded plugins but cannot guarantee their behavior. ### Uninstalling Plugins -User-installed plugins show an **Uninstall** button in the plugin details view. Built-in plugins cannot be uninstalled, only disabled. +Select a user-installed plugin and click the **-** button below the list, or use the **Uninstall** button in the detail pane. Built-in plugins cannot be uninstalled, only disabled. ### Advanced Settings diff --git a/docs/vi/customization/settings.mdx b/docs/vi/customization/settings.mdx index a4d589ba..6ffa91eb 100644 --- a/docs/vi/customization/settings.mdx +++ b/docs/vi/customization/settings.mdx @@ -370,6 +370,8 @@ Quản lý plugin driver database từ tab **Plugins** trong Settings. ### Plugin Đã cài +Tab Installed sử dụng bố cục chia đôi: danh sách plugin bên trái và chi tiết bên phải. + TablePro đi kèm 8 plugin driver database tích hợp sẵn: | Plugin | Loại Database | Cổng Mặc định | @@ -387,21 +389,27 @@ Mỗi plugin có nút bật/tắt. Plugin bị tắt sẽ ẩn loại database k ### Chi tiết Plugin -Click vào plugin để xem chi tiết: phiên bản, bundle ID, khả năng hỗ trợ, loại database và cổng mặc định. Driver plugin có thể cung cấp panel cài đặt tùy chỉnh riêng, hiển thị trong phần chi tiết plugin. +Chọn plugin trong danh sách để xem chi tiết ở khung bên phải: phiên bản, bundle ID, khả năng hỗ trợ, loại database và cổng mặc định. Driver plugin có thể cung cấp panel cài đặt tùy chỉnh riêng, hiển thị trong phần chi tiết plugin. + +### Duyệt Plugin + +Tab Browse cũng sử dụng bố cục chia đôi. Tìm kiếm và lọc plugin theo danh mục bằng các điều khiển ở phía trên. Chọn plugin trong danh sách để xem chi tiết đầy đủ và cài đặt từ khung bên phải. ### Cài đặt Plugin Bên thứ ba -1. Nhấp **Install from File...** +1. Nhấp nút **+** bên dưới danh sách plugin 2. Chọn file `.zip` chứa bundle `.tableplugin` 3. TablePro xác minh chữ ký mã và cài đặt plugin +Bạn cũng có thể kéo thả file `.tableplugin` hoặc `.zip` vào cửa sổ cài đặt Plugins để cài đặt. + Chỉ cài plugin từ nguồn đáng tin cậy. TablePro kiểm tra chữ ký mã của plugin sideload nhưng không đảm bảo hành vi của chúng. ### Gỡ cài đặt Plugin -Plugin do người dùng cài hiện nút **Uninstall** trong chi tiết plugin. Plugin tích hợp sẵn không thể gỡ, chỉ có thể tắt. +Chọn plugin do người dùng cài và nhấp nút **-** bên dưới danh sách, hoặc dùng nút **Uninstall** trong khung chi tiết. Plugin tích hợp sẵn không thể gỡ, chỉ có thể tắt. ### Cài Đặt Nâng Cao diff --git a/docs/zh/customization/settings.mdx b/docs/zh/customization/settings.mdx index e714a56b..d1f39665 100644 --- a/docs/zh/customization/settings.mdx +++ b/docs/zh/customization/settings.mdx @@ -359,6 +359,8 @@ TablePro 使用 [Sparkle](https://sparkle-project.org/) 进行更新。当有可 ### 已安装插件 +"已安装"标签页使用分栏布局:左侧为插件列表,右侧为详情面板。 + TablePro 内置 8 个数据库驱动插件: | 插件 | 数据库类型 | 默认端口 | @@ -376,21 +378,27 @@ TablePro 内置 8 个数据库驱动插件: ### 插件详情 -点击插件查看详情:版本、Bundle ID、支持的功能、数据库类型和默认端口。驱动插件还可以提供自定义设置面板,用于配置特定于该驱动的选项。 +在列表中选择插件即可在右侧面板查看详情:版本、Bundle ID、支持的功能、数据库类型和默认端口。驱动插件还可以提供自定义设置面板,用于配置特定于该驱动的选项。 + +### 浏览插件 + +"浏览"标签页同样使用分栏布局。使用顶部的搜索和分类筛选控件查找插件。在列表中选择插件即可查看完整详情并从右侧面板安装。 ### 安装第三方插件 -1. 点击 **Install from File...** +1. 点击插件列表下方的 **+** 按钮 2. 选择包含 `.tableplugin` 包的 `.zip` 压缩文件 3. TablePro 验证代码签名并安装插件 +也可以将 `.tableplugin` 或 `.zip` 文件拖放到插件设置窗口进行安装。 + 只安装来自你信任的来源的插件。TablePro 会检查侧载插件的代码签名,但无法保证其行为。 ### 卸载插件 -用户安装的插件在插件详情视图中显示 **Uninstall** 按钮。内置插件不能卸载,只能禁用。 +选择用户安装的插件后点击列表下方的 **-** 按钮,或使用详情面板中的 **Uninstall** 按钮。内置插件不能卸载,只能禁用。 ### 高级设置 From 1972a6ce7f900259e5390a66e812957ac6f44c16 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 13 Mar 2026 21:19:34 +0700 Subject: [PATCH 3/3] fix: address PR review feedback from CodeRabbit --- TablePro/Resources/Localizable.xcstrings | 18 ++++++++ .../Settings/Plugins/BrowsePluginsView.swift | 5 ++- .../Plugins/InstalledPluginsView.swift | 2 + .../Plugins/RegistryPluginDetailView.swift | 45 ++++++++++++++++--- docs/customization/settings.mdx | 6 +-- docs/vi/customization/settings.mdx | 6 +-- docs/zh/customization/settings.mdx | 4 +- 7 files changed, 72 insertions(+), 14 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index ff4ca1bc..f85cbe48 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -864,6 +864,9 @@ } } } + }, + "%lld%%" : { + }, "%lldm %llds" : { "localizations" : { @@ -8637,6 +8640,9 @@ } } } + }, + "Install plugin from file" : { + }, "Installation Failed" : { "localizations" : { @@ -8686,6 +8692,9 @@ } } } + }, + "Installing..." : { + }, "Invalid argument: %@" : { "extractionState" : "stale", @@ -13469,6 +13478,9 @@ } } } + }, + "Retry Install" : { + }, "Reuse clean table tab" : { "extractionState" : "stale", @@ -16752,6 +16764,9 @@ } } } + }, + "Uninstall %@" : { + }, "Uninstall Failed" : { "localizations" : { @@ -16768,6 +16783,9 @@ } } } + }, + "Uninstall plugin" : { + }, "Uninstall Plugin?" : { "localizations" : { diff --git a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift index 0fcd13f3..f6b8c2d6 100644 --- a/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift +++ b/TablePro/Views/Settings/Plugins/BrowsePluginsView.swift @@ -98,7 +98,10 @@ struct BrowsePluginsView: View { Text(message) } actions: { Button("Try Again") { - Task { await registryClient.fetchManifest(forceRefresh: true) } + Task { + await registryClient.fetchManifest(forceRefresh: true) + await downloadCountService.fetchCounts(for: registryClient.manifest) + } } .buttonStyle(.bordered) } diff --git a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift index 1861ee3e..94094962 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -55,6 +55,7 @@ struct InstalledPluginsView: View { } .buttonStyle(.borderless) .disabled(pluginManager.isInstalling) + .accessibilityLabel(String(localized: "Install plugin from file")) Divider().frame(height: 16) @@ -68,6 +69,7 @@ struct InstalledPluginsView: View { } .buttonStyle(.borderless) .disabled(selectedPluginId == nil || selectedPlugin?.source == .builtIn) + .accessibilityLabel(selectedPlugin.map { String(localized: "Uninstall \($0.name)") } ?? String(localized: "Uninstall plugin")) Spacer() diff --git a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift index 409529ac..cabe1ca5 100644 --- a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift +++ b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift @@ -61,7 +61,8 @@ struct RegistryPluginDetailView: View { } } - if let homepage = plugin.homepage, let url = URL(string: homepage) { + if let homepage = plugin.homepage, let url = URL(string: homepage), + let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { GridRow { Text("Homepage") .foregroundStyle(.secondary) @@ -82,11 +83,9 @@ struct RegistryPluginDetailView: View { } .font(.callout) - if !isInstalled, installProgress == nil { + if !isInstalled { Divider() - Button("Install Plugin") { onInstall() } - .buttonStyle(.borderedProminent) - .controlSize(.regular) + installActionView } } .padding(20) @@ -94,6 +93,42 @@ struct RegistryPluginDetailView: View { } } + @ViewBuilder + private var installActionView: some View { + if let progress = installProgress { + switch progress.phase { + case .downloading(let fraction): + HStack(spacing: 8) { + ProgressView(value: fraction) + Text("\(Int(fraction * 100))%") + .font(.callout) + .foregroundStyle(.secondary) + .monospacedDigit() + } + case .installing: + HStack(spacing: 8) { + ProgressView() + .controlSize(.small) + Text("Installing...") + .font(.callout) + .foregroundStyle(.secondary) + } + case .completed: + Label("Installed", systemImage: "checkmark.circle.fill") + .foregroundStyle(.green) + .font(.callout) + case .failed: + Button("Retry Install") { onInstall() } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + } + } else { + Button("Install Plugin") { onInstall() } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + } + } + private static let decimalFormatter: NumberFormatter = { let formatter = NumberFormatter() formatter.numberStyle = .decimal diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 3f3d99c3..68ac329c 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -372,7 +372,7 @@ Manage database driver plugins from the **Plugins** tab in Settings. ### Installed Plugins -The Installed tab uses a split-view layout: the plugin list on the left, and the detail pane on the right. +The **Installed** tab uses a split-view layout: the plugin list on the left, and the detail pane on the right. TablePro ships with 8 built-in database driver plugins: @@ -393,9 +393,9 @@ Each plugin has a toggle to enable or disable it. Disabled plugins hide their da Select a plugin in the list to view its details in the right pane: version, bundle ID, supported capabilities, database type, and default port. Driver plugins can provide custom settings panels that appear in this detail view. -### Browse Plugins +### Browse plugins -The Browse tab also uses a split-view layout. Search and filter plugins by category using the controls at the top. Select a plugin in the list to view its full details and install it from the right pane. +The **Browse** tab also uses a split-view layout. Search and filter plugins by category using the controls at the top. Select a plugin in the list to view its full details and install it from the right pane. ### Installing Third-Party Plugins diff --git a/docs/vi/customization/settings.mdx b/docs/vi/customization/settings.mdx index 6ffa91eb..cc5d9eb9 100644 --- a/docs/vi/customization/settings.mdx +++ b/docs/vi/customization/settings.mdx @@ -370,7 +370,7 @@ Quản lý plugin driver database từ tab **Plugins** trong Settings. ### Plugin Đã cài -Tab Installed sử dụng bố cục chia đôi: danh sách plugin bên trái và chi tiết bên phải. +Tab **Installed** sử dụng bố cục chia đôi: danh sách plugin bên trái và chi tiết bên phải. TablePro đi kèm 8 plugin driver database tích hợp sẵn: @@ -391,9 +391,9 @@ Mỗi plugin có nút bật/tắt. Plugin bị tắt sẽ ẩn loại database k Chọn plugin trong danh sách để xem chi tiết ở khung bên phải: phiên bản, bundle ID, khả năng hỗ trợ, loại database và cổng mặc định. Driver plugin có thể cung cấp panel cài đặt tùy chỉnh riêng, hiển thị trong phần chi tiết plugin. -### Duyệt Plugin +### Duyệt plugin -Tab Browse cũng sử dụng bố cục chia đôi. Tìm kiếm và lọc plugin theo danh mục bằng các điều khiển ở phía trên. Chọn plugin trong danh sách để xem chi tiết đầy đủ và cài đặt từ khung bên phải. +Tab **Browse** cũng sử dụng bố cục chia đôi. Tìm kiếm và lọc plugin theo danh mục bằng các điều khiển ở phía trên. Chọn plugin trong danh sách để xem chi tiết đầy đủ và cài đặt từ khung bên phải. ### Cài đặt Plugin Bên thứ ba diff --git a/docs/zh/customization/settings.mdx b/docs/zh/customization/settings.mdx index d1f39665..60552489 100644 --- a/docs/zh/customization/settings.mdx +++ b/docs/zh/customization/settings.mdx @@ -359,7 +359,7 @@ TablePro 使用 [Sparkle](https://sparkle-project.org/) 进行更新。当有可 ### 已安装插件 -"已安装"标签页使用分栏布局:左侧为插件列表,右侧为详情面板。 +**已安装**标签页使用分栏布局:左侧为插件列表,右侧为详情面板。 TablePro 内置 8 个数据库驱动插件: @@ -382,7 +382,7 @@ TablePro 内置 8 个数据库驱动插件: ### 浏览插件 -"浏览"标签页同样使用分栏布局。使用顶部的搜索和分类筛选控件查找插件。在列表中选择插件即可查看完整详情并从右侧面板安装。 +**浏览**标签页同样使用分栏布局。使用顶部的搜索和分类筛选控件查找插件。在列表中选择插件即可查看完整详情并从右侧面板安装。 ### 安装第三方插件