From d17d38f95ac10b450c1e1238a59ee2242fa7bcff Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 8 Aug 2026 18:27:27 +0700 Subject: [PATCH] feat(sidebar): let object icons be hidden from a View Options menu --- CHANGELOG.md | 1 + .../Models/Settings/GeneralSettings.swift | 11 ++++-- TablePro/TableProApp.swift | 9 +++++ .../Views/Settings/GeneralSettingsView.swift | 3 ++ .../Views/Sidebar/DatabaseTreeRowView.swift | 7 +++- TablePro/Views/Sidebar/FavoritesTabView.swift | 3 ++ TablePro/Views/Sidebar/RoutineRowView.swift | 1 + TablePro/Views/Sidebar/SidebarRowIcon.swift | 24 +++++++++++++ TablePro/Views/Sidebar/SidebarTreeView.swift | 6 ++++ TablePro/Views/Sidebar/SidebarView.swift | 8 +++++ .../Sidebar/SidebarViewOptionsMenu.swift | 17 +++++++++ TablePro/Views/Sidebar/TableRowView.swift | 34 ++++++++++++++---- .../Models/GeneralSettingsTests.swift | 35 +++++++++++++++++++ TableProTests/Views/TableRowLogicTests.swift | 29 +++++++++++++++ docs/customization/settings.mdx | 1 + 15 files changed, 180 insertions(+), 9 deletions(-) create mode 100644 TablePro/Views/Sidebar/SidebarRowIcon.swift create mode 100644 TablePro/Views/Sidebar/SidebarViewOptionsMenu.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d6384cd..bcc82199c 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 ### Added +- Sidebar object icons can be turned off for a plain list of names. Right-click anywhere in the sidebar and use View Options, or toggle Show Object Icons from the View menu or Settings > General. Tables staged for truncate or delete keep their marker. - Redshift external schemas now list their tables. Spectrum, federated query, cross-database, and datashare schemas showed up empty because their tables are not in the standard catalog. - External schemas are marked in the sidebar, and their tables show an external icon. External tables open read-only, because Redshift rejects `UPDATE` and `DELETE` on them. diff --git a/TablePro/Models/Settings/GeneralSettings.swift b/TablePro/Models/Settings/GeneralSettings.swift index b64911414..f5b6849bf 100644 --- a/TablePro/Models/Settings/GeneralSettings.swift +++ b/TablePro/Models/Settings/GeneralSettings.swift @@ -69,6 +69,9 @@ struct GeneralSettings: Codable, Equatable { /// Whether to show database object comments in the sidebar and data grid headers var showObjectComments: Bool + /// Whether sidebar rows show a type icon before the object name + var showObjectIcons: Bool + static let `default` = GeneralSettings( startupBehavior: .reopenLast, language: .system, @@ -76,7 +79,8 @@ struct GeneralSettings: Codable, Equatable { queryTimeoutSeconds: 60, shareAnalytics: true, showRecentTables: false, - showObjectComments: true + showObjectComments: true, + showObjectIcons: true ) init( @@ -86,7 +90,8 @@ struct GeneralSettings: Codable, Equatable { queryTimeoutSeconds: Int = 60, shareAnalytics: Bool = true, showRecentTables: Bool = false, - showObjectComments: Bool = true + showObjectComments: Bool = true, + showObjectIcons: Bool = true ) { self.startupBehavior = startupBehavior self.language = language @@ -95,6 +100,7 @@ struct GeneralSettings: Codable, Equatable { self.shareAnalytics = shareAnalytics self.showRecentTables = showRecentTables self.showObjectComments = showObjectComments + self.showObjectIcons = showObjectIcons } init(from decoder: Decoder) throws { @@ -106,5 +112,6 @@ struct GeneralSettings: Codable, Equatable { shareAnalytics = try container.decodeIfPresent(Bool.self, forKey: .shareAnalytics) ?? true showRecentTables = try container.decodeIfPresent(Bool.self, forKey: .showRecentTables) ?? false showObjectComments = try container.decodeIfPresent(Bool.self, forKey: .showObjectComments) ?? true + showObjectIcons = try container.decodeIfPresent(Bool.self, forKey: .showObjectIcons) ?? true } } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 2cb8adf29..831915acf 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -163,6 +163,13 @@ struct AppMenuCommands: Commands { ) } + private var showObjectIconsBinding: Binding { + Binding( + get: { settingsManager.general.showObjectIcons }, + set: { settingsManager.general.showObjectIcons = $0 } + ) + } + private func shortcut(for action: ShortcutAction) -> KeyboardShortcut? { settingsManager.keyboard.keyboardShortcut(for: action) } @@ -717,6 +724,8 @@ struct AppMenuCommands: Commands { .pickerStyle(.inline) .disabled(!(actions?.canSwitchSidebarLayout ?? false)) + Toggle(String(localized: "Show Object Icons"), isOn: showObjectIconsBinding) + Toggle(String(localized: "Show Object Comments"), isOn: showObjectCommentsBinding) Divider() diff --git a/TablePro/Views/Settings/GeneralSettingsView.swift b/TablePro/Views/Settings/GeneralSettingsView.swift index 45a623dea..21bb6b3df 100644 --- a/TablePro/Views/Settings/GeneralSettingsView.swift +++ b/TablePro/Views/Settings/GeneralSettingsView.swift @@ -58,6 +58,9 @@ struct GeneralSettingsView: View { Toggle("Show recent tables", isOn: $settings.showRecentTables) .help("Adds a Recent section at the top of the Tables sidebar with the last tables you opened per connection and database.") + Toggle("Show object icons", isOn: $settings.showObjectIcons) + .help("Shows a type icon before each object name in the sidebar. Turn it off for a plain list of names.") + Toggle("Show object comments", isOn: $settings.showObjectComments) .help("Shows database object comments next to tables in the sidebar and in grid column headers.") diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index 9fabfebd0..9a9c3f816 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -48,7 +48,11 @@ struct DatabaseTreeRowView: View { var body: some View { if hasContextMenu { - row.contextMenu { menuItems } + row.contextMenu { + menuItems + Divider() + SidebarViewOptionsMenu() + } } else { row } @@ -127,6 +131,7 @@ struct DatabaseTreeRowView: View { } icon: { Image(systemName: systemImage) } + .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) .lineLimit(1) .foregroundStyle(foreground(isActive: isActive, isSystem: isSystem)) } diff --git a/TablePro/Views/Sidebar/FavoritesTabView.swift b/TablePro/Views/Sidebar/FavoritesTabView.swift index 039b2cec5..845d3ec88 100644 --- a/TablePro/Views/Sidebar/FavoritesTabView.swift +++ b/TablePro/Views/Sidebar/FavoritesTabView.swift @@ -278,7 +278,9 @@ internal struct FavoritesTabView: View { .contextMenu(forSelectionType: FavoriteSelection.self) { selection in if let selected = selection.first { contextMenu(for: selected) + Divider() } + SidebarViewOptionsMenu() } primaryAction: { selection in guard let selected = selection.first else { return } handlePrimaryAction(selected) @@ -292,6 +294,7 @@ internal struct FavoritesTabView: View { Image(systemName: TableRowLogic.iconName(for: table.type)) .sidebarTint(Color.accentColor) } + .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) .tag(FavoriteSelection.table(database: activeDatabase, schema: table.schema, name: table.name)) .accessibilityLabel( TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false) diff --git a/TablePro/Views/Sidebar/RoutineRowView.swift b/TablePro/Views/Sidebar/RoutineRowView.swift index c5e8853e5..9eacae7c2 100644 --- a/TablePro/Views/Sidebar/RoutineRowView.swift +++ b/TablePro/Views/Sidebar/RoutineRowView.swift @@ -43,6 +43,7 @@ struct RoutineRowView: View { .sidebarTint(Color.accentColor) .frame(width: 16) } + .sidebarRowIcon(visible: AppSettingsManager.shared.general.showObjectIcons) .accessibilityElement(children: .combine) .accessibilityLabel(RoutineRowLogic.accessibilityLabel(for: routine)) .help(RoutineRowLogic.tooltip(for: routine) ?? routine.name) diff --git a/TablePro/Views/Sidebar/SidebarRowIcon.swift b/TablePro/Views/Sidebar/SidebarRowIcon.swift new file mode 100644 index 000000000..f1e5c9767 --- /dev/null +++ b/TablePro/Views/Sidebar/SidebarRowIcon.swift @@ -0,0 +1,24 @@ +// +// SidebarRowIcon.swift +// TablePro +// + +import SwiftUI + +private struct SidebarRowIcon: ViewModifier { + let isVisible: Bool + + func body(content: Content) -> some View { + if isVisible { + content.labelStyle(.titleAndIcon) + } else { + content.labelStyle(.titleOnly) + } + } +} + +extension View { + func sidebarRowIcon(visible: Bool) -> some View { + modifier(SidebarRowIcon(isVisible: visible)) + } +} diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 516b9156a..242116712 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -131,6 +131,8 @@ struct SidebarTreeView: View { .tag(table) .contextMenu { tableContextMenu(table) + Divider() + SidebarViewOptionsMenu() } } @@ -174,6 +176,8 @@ struct SidebarTreeView: View { Button(String(localized: "Clear Recent Tables")) { sidebarState.clearRecentTables(inDatabase: activeDatabase) } + Divider() + SidebarViewOptionsMenu() } } } header: { @@ -195,6 +199,8 @@ struct SidebarTreeView: View { Button(String(localized: "Refresh")) { reloadTables(for: schema) } + Divider() + SidebarViewOptionsMenu() } } diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 9cb70d839..6442f021b 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -408,6 +408,8 @@ struct SidebarView: View { Button(String(localized: "Clear Recent Tables")) { sidebarState.clearRecentTables(inDatabase: activeDatabase) } + Divider() + SidebarViewOptionsMenu() } } } header: { @@ -452,6 +454,8 @@ struct SidebarView: View { onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, coordinator: coordinator ) + Divider() + SidebarViewOptionsMenu() } primaryAction: { selection in guard let table = selection.first else { return } onDoubleClick?(table) @@ -502,6 +506,8 @@ struct SidebarView: View { RoutineContextMenu(routine: routine) { selected in coordinator?.showRoutineDDL(selected) } + Divider() + SidebarViewOptionsMenu() } } } else { @@ -528,6 +534,8 @@ struct SidebarView: View { .help(helpLabel) .contextMenu { sectionHeaderMenu(for: kind, title: title) + Divider() + SidebarViewOptionsMenu() } } diff --git a/TablePro/Views/Sidebar/SidebarViewOptionsMenu.swift b/TablePro/Views/Sidebar/SidebarViewOptionsMenu.swift new file mode 100644 index 000000000..178fbfcac --- /dev/null +++ b/TablePro/Views/Sidebar/SidebarViewOptionsMenu.swift @@ -0,0 +1,17 @@ +// +// SidebarViewOptionsMenu.swift +// TablePro +// + +import SwiftUI + +struct SidebarViewOptionsMenu: View { + @State private var settingsManager = AppSettingsManager.shared + + var body: some View { + Menu(String(localized: "View Options")) { + Toggle("Icons", isOn: $settingsManager.general.showObjectIcons) + Toggle("Comments", isOn: $settingsManager.general.showObjectComments) + } + } +} diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index 2f34bb186..a9d129945 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -30,6 +30,10 @@ enum TableRowLogic { } } + static func showsLeadingIcon(showObjectIcons: Bool, isPendingTruncate: Bool, isPendingDelete: Bool) -> Bool { + showObjectIcons || isPendingTruncate || isPendingDelete + } + static func accessibilityLabel(table: TableInfo, isPendingDelete: Bool, isPendingTruncate: Bool, isFavorite: Bool = false) -> String { let kind = accessibilityKindLabel(for: table.type) var label = String(format: String(localized: "%@: %@"), kind, table.name) @@ -60,6 +64,18 @@ struct TableRow: View { return comment } + private var showsObjectIcon: Bool { + AppSettingsManager.shared.general.showObjectIcons + } + + private var showsLeadingIcon: Bool { + TableRowLogic.showsLeadingIcon( + showObjectIcons: showsObjectIcon, + isPendingTruncate: isPendingTruncate, + isPendingDelete: isPendingDelete + ) + } + @ViewBuilder private var pendingStateBadge: some View { if isPendingDelete { @@ -90,13 +106,19 @@ struct TableRow: View { } } } icon: { - Image(systemName: TableRowLogic.iconName(for: table.type)) - .sidebarTint(Color.accentColor) - .frame(width: 16) - .overlay(alignment: .bottomTrailing) { - pendingStateBadge - } + if showsObjectIcon { + Image(systemName: TableRowLogic.iconName(for: table.type)) + .sidebarTint(Color.accentColor) + .frame(width: 16) + .overlay(alignment: .bottomTrailing) { + pendingStateBadge + } + } else { + pendingStateBadge + .frame(width: 16) + } } + .sidebarRowIcon(visible: showsLeadingIcon) Spacer(minLength: 4) diff --git a/TableProTests/Models/GeneralSettingsTests.swift b/TableProTests/Models/GeneralSettingsTests.swift index 6c34af50d..b47648355 100644 --- a/TableProTests/Models/GeneralSettingsTests.swift +++ b/TableProTests/Models/GeneralSettingsTests.swift @@ -26,3 +26,38 @@ struct GeneralSettingsTests { #expect(decoded.showRecentTables == true) } } + +@Suite("GeneralSettings.showObjectIcons") +struct GeneralSettingsObjectIconsTests { + @Test("Defaults to on") + func defaultsOn() { + #expect(GeneralSettings.default.showObjectIcons == true) + #expect(GeneralSettings().showObjectIcons == true) + } + + @Test("Decoding settings saved before the key existed keeps icons on") + func decodesMissingKeyAsOn() throws { + let json = Data(#"{"startupBehavior":"showWelcome"}"#.utf8) + let decoded = try JSONDecoder().decode(GeneralSettings.self, from: json) + #expect(decoded.showObjectIcons == true) + } + + @Test("Round-trips when disabled") + func roundTripsDisabled() throws { + var settings = GeneralSettings() + settings.showObjectIcons = false + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GeneralSettings.self, from: data) + #expect(decoded.showObjectIcons == false) + } + + @Test("Icons and comments are independent") + func independentFromComments() throws { + var settings = GeneralSettings() + settings.showObjectIcons = false + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GeneralSettings.self, from: data) + #expect(decoded.showObjectIcons == false) + #expect(decoded.showObjectComments == true) + } +} diff --git a/TableProTests/Views/TableRowLogicTests.swift b/TableProTests/Views/TableRowLogicTests.swift index 5a42d5dea..250bdb009 100644 --- a/TableProTests/Views/TableRowLogicTests.swift +++ b/TableProTests/Views/TableRowLogicTests.swift @@ -108,4 +108,33 @@ struct TableRowLogicTests { func externalTableIconIsDistinct() { #expect(TableRowLogic.iconName(for: .externalTable) != TableRowLogic.iconName(for: .table)) } + + // MARK: - Leading Icon Visibility + + @Test("Leading icon shows when object icons are enabled") + func leadingIconShownWhenEnabled() { + #expect(TableRowLogic.showsLeadingIcon(showObjectIcons: true, isPendingTruncate: false, isPendingDelete: false)) + } + + @Test("Leading icon hides when object icons are disabled") + func leadingIconHiddenWhenDisabled() { + #expect(!TableRowLogic.showsLeadingIcon(showObjectIcons: false, isPendingTruncate: false, isPendingDelete: false)) + } + + @Test("Pending delete keeps the leading slot when object icons are disabled") + func leadingIconSurvivesPendingDelete() { + #expect(TableRowLogic.showsLeadingIcon(showObjectIcons: false, isPendingTruncate: false, isPendingDelete: true)) + } + + @Test("Pending truncate keeps the leading slot when object icons are disabled") + func leadingIconSurvivesPendingTruncate() { + #expect(TableRowLogic.showsLeadingIcon(showObjectIcons: false, isPendingTruncate: true, isPendingDelete: false)) + } + + @Test("Hiding icons leaves the accessibility label naming the object kind") + func accessibilityLabelIndependentOfIconVisibility() { + let view = TestFixtures.makeTableInfo(name: "active_users", type: .view) + #expect(TableRowLogic.accessibilityLabel(table: view, isPendingDelete: false, isPendingTruncate: false) == "View: active_users") + #expect(!TableRowLogic.showsLeadingIcon(showObjectIcons: false, isPendingTruncate: false, isPendingDelete: false)) + } } diff --git a/docs/customization/settings.mdx b/docs/customization/settings.mdx index 381c4e3e0..e59f28d6b 100644 --- a/docs/customization/settings.mdx +++ b/docs/customization/settings.mdx @@ -49,6 +49,7 @@ Reopened tabs restore their SQL, cursor position, sort, filters, page, and colum | Setting | Default | Description | |---------|---------|-------------| | **Show recent tables** | Off | Adds a Recent section at the top of the sidebar with the last 10 tables opened per connection and database | +| **Show object icons** | On | Shows a type icon before each object name in the sidebar. Turn it off for a plain list of names. Also on the **View** menu, and under **View Options** in the sidebar right-click menu | | **Show object comments** | On | Shows database object comments next to tables in the sidebar and in grid column headers | | **Default layout for new connections** | List | List or Tree, for servers that support a database tree. Switch the current connection from the **View** menu |