Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0594f9f
feat: redesign Plugins settings with HSplitView master-detail layout
datlechin Mar 13, 2026
1de2c0d
docs: add CHANGELOG entry and update plugin settings docs for HSplitV…
datlechin Mar 13, 2026
1972a6c
fix: address PR review feedback from CodeRabbit
datlechin Mar 13, 2026
e34177c
fix: clear needsRestart flag before early return guard in loadPending…
datlechin Mar 13, 2026
635b03b
refactor: replace HSplitView with NavigationSplitView for modern macO…
datlechin Mar 13, 2026
16a5435
fix: revert NavigationSplitView to HSplitView to fix settings layout
datlechin Mar 13, 2026
29a1eb3
refactor: move enable/disable toggle from list rows to detail pane
datlechin Mar 13, 2026
dc2134a
refactor: remove compact install button from browse list rows
datlechin Mar 13, 2026
2de2a64
feat: add search filter to installed plugins list
datlechin Mar 13, 2026
258ebb2
fix: show loading/empty/error states full-width in browse tab
datlechin Mar 13, 2026
e5bc453
fix: make empty/loading states fill available space in browse tab
datlechin Mar 13, 2026
48dd573
refactor: polish plugin settings UI layout and consistency
datlechin Mar 13, 2026
599345d
refactor: remove dead RegistryPluginRow and update docs for toggle lo…
datlechin Mar 13, 2026
4aac87a
merge: resolve conflicts with main for plugin settings hsplitview
datlechin Mar 14, 2026
818a202
feat: enrich plugin list rows with version, status badges, and downlo…
datlechin Mar 14, 2026
9d71e29
docs: update plugin settings docs for enriched rows (en, vi, zh)
datlechin Mar 14, 2026
7f76702
fix: address PR review — extract PluginIconView, fix disabled conditi…
datlechin Mar 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions TablePro/Core/Plugins/PluginManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,9 @@ final class PluginManager {
/// Load all discovered but not-yet-loaded plugin bundles.
/// Safety fallback for code paths that need plugins before the deferred Task completes.
func loadPendingPlugins(clearRestartFlag: Bool = false) {
if clearRestartFlag {
_needsRestart = false
}
guard !pendingPluginURLs.isEmpty else { return }
let pending = pendingPluginURLs
pendingPluginURLs.removeAll()
Expand All @@ -124,9 +127,6 @@ final class PluginManager {
}

validateDependencies()
if clearRestartFlag {
_needsRestart = false
}
Self.logger.info("Loaded \(self.plugins.count) plugin(s): \(self.driverPlugins.count) driver(s), \(self.exportPlugins.count) export format(s), \(self.importPlugins.count) import format(s)")
}

Expand Down
107 changes: 36 additions & 71 deletions TablePro/Views/Settings/Plugins/BrowsePluginsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,31 +24,7 @@ struct BrowsePluginsView: View {
}

var body: some View {
VStack(spacing: 0) {
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()

HSplitView {
browseLeftPane
.frame(minWidth: 200, idealWidth: 240, maxWidth: 280)

browseDetailPane
.frame(minWidth: 340)
}
}
mainContent
.task {
if registryClient.fetchState == .idle {
await registryClient.fetchManifest()
Expand All @@ -68,27 +44,49 @@ struct BrowsePluginsView: View {
}
}

// MARK: - Left Pane
// MARK: - Main Content

@ViewBuilder
private var browseLeftPane: some View {
private var mainContent: some View {
switch registryClient.fetchState {
case .idle, .loading:
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)

case .loaded:
let plugins = registryClient.search(query: searchText, category: selectedCategory)
if plugins.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
List(selection: $selectedPluginId) {
ForEach(plugins) { plugin in
browseRow(plugin)
.tag(plugin.id)
HSplitView {
VStack(spacing: 0) {
HStack(spacing: 6) {
TextField("Search...", text: $searchText)
.textFieldStyle(.roundedBorder)
Picker("", selection: $selectedCategory) {
Text("All").tag(RegistryCategory?.none)
Comment thread
datlechin marked this conversation as resolved.
ForEach(RegistryCategory.allCases) { category in
Text(category.displayName).tag(RegistryCategory?.some(category))
}
}
.labelsHidden()
.fixedSize()
}
.padding(.horizontal, 8)
.padding(.vertical, 6)

if plugins.isEmpty {
ContentUnavailableView.search(text: searchText)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(plugins, selection: $selectedPluginId) { plugin in
browseRow(plugin)
.tag(plugin.id)
}
.listStyle(.inset)
}
}
.listStyle(.inset(alternatesRowBackgrounds: true))
.frame(minWidth: 200, idealWidth: 240, maxWidth: 280)

detailContent
.frame(minWidth: 340)
}

case .failed(let message):
Expand All @@ -105,6 +103,7 @@ struct BrowsePluginsView: View {
}
.buttonStyle(.bordered)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}

Expand All @@ -123,15 +122,13 @@ struct BrowsePluginsView: View {
.foregroundStyle(.blue)
.font(.caption2)
}
Spacer()
compactActionButton(for: plugin)
}
}

// MARK: - Right Pane
// MARK: - Detail

@ViewBuilder
private var browseDetailPane: some View {
private var detailContent: some View {
if let selectedPlugin = selectedRegistryPlugin {
RegistryPluginDetailView(
plugin: selectedPlugin,
Expand All @@ -153,38 +150,6 @@ struct BrowsePluginsView: View {
}
}

// 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
Expand Down
172 changes: 103 additions & 69 deletions TablePro/Views/Settings/Plugins/InstalledPluginsView.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
//
// InstalledPluginsView.swift
// TablePro
//

import AppKit
import SwiftUI
Expand All @@ -12,76 +11,27 @@ struct InstalledPluginsView: View {
private let pluginManager = PluginManager.shared

@State private var selectedPluginId: String?
@State private var searchText = ""
@State private var showErrorAlert = false
@State private var errorAlertTitle = ""
@State private var errorAlertMessage = ""
@State private var dismissedRestartBanner = false

private var filteredPlugins: [PluginEntry] {
if searchText.isEmpty { return pluginManager.plugins }
return pluginManager.plugins.filter { $0.name.localizedCaseInsensitiveContains(searchText) }
}

var body: some View {
VStack(spacing: 0) {
if pluginManager.needsRestart && !dismissedRestartBanner {
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)
restartBanner
}

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 {
installFromFile()
} label: {
Image(systemName: "plus")
.frame(width: 24, height: 20)
}
.buttonStyle(.borderless)
.disabled(pluginManager.isInstalling)
.accessibilityLabel(String(localized: "Install plugin from file"))

Divider().frame(height: 16)

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()
pluginList
.frame(minWidth: 200, idealWidth: 240, maxWidth: 280)

if pluginManager.isInstalling {
ProgressView()
.controlSize(.small)
}
}
.padding(.horizontal, 4)
.padding(.vertical, 2)
}
.frame(minWidth: 200, idealWidth: 240, maxWidth: 280)

detailPane
.frame(minWidth: 340)
Expand Down Expand Up @@ -110,6 +60,88 @@ struct InstalledPluginsView: View {
}
}

// MARK: - Restart Banner

private var restartBanner: some View {
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)
Comment thread
datlechin marked this conversation as resolved.
.font(.callout)
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
}

// MARK: - Plugin List

private var pluginList: some View {
VStack(spacing: 0) {
TextField("Filter...", text: $searchText)
.textFieldStyle(.roundedBorder)
.padding(.horizontal, 8)
.padding(.vertical, 6)

List(selection: $selectedPluginId) {
ForEach(filteredPlugins) { plugin in
pluginRow(plugin)
.tag(plugin.id)
}
}
.listStyle(.inset)
.safeAreaInset(edge: .bottom, spacing: 0) {
listBottomBar
}
}
.onChange(of: searchText) {
if let selectedPluginId, !filteredPlugins.contains(where: { $0.id == selectedPluginId }) {
self.selectedPluginId = nil
}
}
}

private var listBottomBar: some View {
HStack(spacing: 4) {
Button {
installFromFile()
} label: {
Image(systemName: "plus")
.frame(width: 24, height: 20)
}
.buttonStyle(.borderless)
.disabled(pluginManager.isInstalling)
.accessibilityLabel(String(localized: "Install plugin from file"))

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")
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

Spacer()

if pluginManager.isInstalling {
ProgressView()
.controlSize(.small)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
}

// MARK: - Plugin Row

@ViewBuilder
Expand All @@ -121,14 +153,6 @@ struct InstalledPluginsView: View {
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()
.controlSize(.small)
}
}

Expand All @@ -154,8 +178,18 @@ struct InstalledPluginsView: View {
if let selected = selectedPlugin {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
Text(selected.name)
.font(.title3.weight(.semibold))
HStack {
Text(selected.name)
.font(.title3.weight(.semibold))
Spacer()
Toggle("", isOn: Binding(
get: { selected.isEnabled },
set: { pluginManager.setEnabled($0, pluginId: selected.id) }
))
.toggleStyle(.switch)
.labelsHidden()
.controlSize(.small)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

Text("v\(selected.version) · \(selected.source == .builtIn ? String(localized: "Built-in") : String(localized: "User-installed"))")
.font(.subheadline)
Expand Down
Loading
Loading