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/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index a4f5cef9..f85cbe48 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -864,6 +864,9 @@ } } } + }, + "%lld%%" : { + }, "%lldm %llds" : { "localizations" : { @@ -1904,6 +1907,9 @@ }, "Alert (Full)" : { + }, + "All" : { + }, "All %lld rows selected" : { "localizations" : { @@ -2032,8 +2038,12 @@ } } } + }, + "Also handles" : { + }, "Also handles:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2398,6 +2408,9 @@ }, "Authentication required to execute write operations" : { + }, + "Author" : { + }, "AUTO" : { "extractionState" : "stale", @@ -2641,8 +2654,12 @@ } } } + }, + "Bundle ID" : { + }, "Bundle ID:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2724,8 +2741,12 @@ } } } + }, + "Capabilities" : { + }, "Capabilities:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -2756,6 +2777,9 @@ } } } + }, + "Category" : { + }, "Cell Renderer" : { "extractionState" : "stale", @@ -4971,8 +4995,12 @@ } } } + }, + "Database Type" : { + }, "Database Type:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -5277,8 +5305,12 @@ } } } + }, + "Default Port" : { + }, "Default Port:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -5656,6 +5688,9 @@ } } } + }, + "Dismiss" : { + }, "Display" : { "localizations" : { @@ -7032,6 +7067,9 @@ } } } + }, + "Failed to Load" : { + }, "Failed to load databases" : { "localizations" : { @@ -7066,6 +7104,7 @@ } }, "Failed to load plugin registry" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -8570,6 +8609,7 @@ } }, "Install from File..." : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -8600,6 +8640,9 @@ } } } + }, + "Install plugin from file" : { + }, "Installation Failed" : { "localizations" : { @@ -8634,6 +8677,7 @@ } }, "Installed Plugins" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -8648,6 +8692,9 @@ } } } + }, + "Installing..." : { + }, "Invalid argument: %@" : { "extractionState" : "stale", @@ -9365,6 +9412,7 @@ } }, "Loading plugins..." : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -10648,6 +10696,7 @@ } }, "No plugins found" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -13346,6 +13395,9 @@ } } } + }, + "Requires" : { + }, "Reset to Defaults" : { "localizations" : { @@ -13426,6 +13478,9 @@ } } } + }, + "Retry Install" : { + }, "Reuse clean table tab" : { "extractionState" : "stale", @@ -14169,6 +14224,12 @@ } } } + }, + "Select a Plugin" : { + + }, + "Select a plugin to view details" : { + }, "Select a Query" : { "localizations" : { @@ -14806,6 +14867,7 @@ } }, "Source:" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -15313,6 +15375,9 @@ } } } + }, + "Status" : { + }, "Stop" : { "localizations" : { @@ -16699,6 +16764,9 @@ } } } + }, + "Uninstall %@" : { + }, "Uninstall Failed" : { "localizations" : { @@ -16715,6 +16783,9 @@ } } } + }, + "Uninstall plugin" : { + }, "Uninstall Plugin?" : { "localizations" : { @@ -17006,6 +17077,7 @@ } }, "User" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -17086,6 +17158,7 @@ } }, "v%@" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -17100,6 +17173,19 @@ } } } + }, + "v%@ · %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "v%1$@ · %2$@" + } + } + } + }, + "v%@+" : { + }, "Validation Failed" : { "localizations" : { @@ -17148,8 +17234,12 @@ } } } + }, + "Verified" : { + }, "Verified by TablePro" : { + "extractionState" : "stale", "localizations" : { "vi" : { "stringUnit" : { @@ -17196,6 +17286,9 @@ } } } + }, + "Version" : { + }, "Version %@" : { "localizations" : { @@ -17236,6 +17329,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..f6b8c2d6 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,140 @@ 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) + await downloadCountService.fetchCounts(for: registryClient.manifest) } } .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 +221,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..94094962 100644 --- a/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift +++ b/TablePro/Views/Settings/Plugins/InstalledPluginsView.swift @@ -18,53 +18,75 @@ 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) + .accessibilityLabel(String(localized: "Install plugin from file")) - 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) + .accessibilityLabel(selectedPlugin.map { String(localized: "Uninstall \($0.name)") } ?? String(localized: "Uninstall plugin")) + + 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 +114,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 +142,7 @@ struct InstalledPluginsView: View { } } - // MARK: - Detail Section + // MARK: - Detail Pane private var selectedPlugin: PluginEntry? { guard let id = selectedPluginId else { return nil } @@ -155,52 +150,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 +307,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..cabe1ca5 100644 --- a/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift +++ b/TablePro/Views/Settings/Plugins/RegistryPluginDetailView.swift @@ -13,62 +13,120 @@ 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) + } - HStack(spacing: 16) { - detailItem(label: "Author", value: plugin.author.name) + GridRow { + Text("Version") + .foregroundStyle(.secondary) + Text(plugin.version) + } - if let homepage = plugin.homepage, let url = URL(string: homepage) { - Link(destination: url) { - HStack(spacing: 2) { + if let minVersion = plugin.minAppVersion { + GridRow { + Text("Requires") + .foregroundStyle(.secondary) + Text("v\(minVersion)+") + } + } + + if let count = downloadCount { + GridRow { + Text("Downloads") + .foregroundStyle(.secondary) + Text(formattedCount(count)) + } + } + + if let homepage = plugin.homepage, let url = URL(string: homepage), + let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { + 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 { + GridRow { + Text("Status") + .foregroundStyle(.secondary) + Label("Verified", systemImage: "checkmark.seal.fill") + .foregroundStyle(.blue) } } } - } + .font(.callout) - if plugin.isVerified { - HStack(spacing: 4) { - Image(systemName: "checkmark.seal.fill") - .foregroundStyle(.blue) - .font(.caption) - Text("Verified by TablePro") - .font(.caption) - .foregroundStyle(.blue) + if !isInstalled { + Divider() + installActionView } } + .padding(20) + .frame(maxWidth: .infinity, alignment: .leading) + } + } - if !isInstalled, installProgress == nil { - Button("Install Plugin") { - onInstall() + @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() } - .buttonStyle(.borderedProminent) - .controlSize(.small) + 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) } - .padding(.leading, 34) - .padding(.vertical, 8) } private static let decimalFormatter: NumberFormatter = { @@ -77,19 +135,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) } } diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index e8f5fdef..68ac329c 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..cc5d9eb9 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..60552489 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** 按钮。内置插件不能卸载,只能禁用。 ### 高级设置