feat(settings): move Outline version to sidebar footer, drop Installation section

Removed the Integrations & Installation category and its lone
Installation section entirely — Outline's server version now shows in
the settings sidebar footer instead, alongside Outpost's own version
(installation.info, fetched once when the sidebar appears). About stays
where it was, unaffected.
This commit is contained in:
2026-08-18 16:39:19 +01:00
parent adfa477ecf
commit fa22ac616b
3 changed files with 51 additions and 77 deletions
@@ -1,5 +1,6 @@
#if os(macOS) #if os(macOS)
import SwiftUI import SwiftUI
import OutlineKit
/// Swapped into the real sidebar's content slot (search field, collections /// Swapped into the real sidebar's content slot (search field, collections
/// tree, account footer) while Settings is open same sidebar, different /// tree, account footer) while Settings is open same sidebar, different
@@ -8,13 +9,23 @@ import SwiftUI
/// back. /// back.
/// ///
/// Grouped by `SettingsCategory` `general` (ours) sits under an "Outpost" /// Grouped by `SettingsCategory` `general` (ours) sits under an "Outpost"
/// header at the top, then Outline's own Account/Workspace/Integrations & /// header at the top, then Outline's own Account/Workspace groups, matching
/// Installation groups, matching the settings page structure of the /// the settings page structure of the Outline web app. Outline's own server
/// Outline web app. /// version has no dedicated section (there used to be an Integrations &
/// Installation category for just that) it's cheap enough to show
/// unconditionally in the footer here instead, alongside Outpost's own
/// version.
struct SettingsSidebarList: View { struct SettingsSidebarList: View {
@Binding var selection: SettingsSection? @Binding var selection: SettingsSection?
let onDone: () -> Void let onDone: () -> Void
@Environment(SessionStore.self) private var session
@State private var outlineVersion: String?
private var outpostVersion: String {
Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "0.0.1"
}
var body: some View { var body: some View {
VStack(spacing: 0) { VStack(spacing: 0) {
HStack { HStack {
@@ -46,12 +57,36 @@ struct SettingsSidebarList: View {
Divider() Divider()
versionFooter
Divider()
Button("Done", action: onDone) Button("Done", action: onDone)
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(12) .padding(12)
} }
.task { await refreshOutlineVersion() }
}
private var versionFooter: some View {
VStack(alignment: .leading, spacing: 2) {
Text("Outpost \(outpostVersion)")
if let outlineVersion {
Text("Outline \(outlineVersion)")
}
}
.font(.caption2)
.foregroundStyle(.tertiary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
private func refreshOutlineVersion() async {
guard let apiClient = session.apiClient else { return }
outlineVersion = try? await apiClient.installationInfo().version
} }
} }
#endif #endif
+2 -56
View File
@@ -44,9 +44,6 @@ struct SettingsView: View {
@State private var apiKeys: [OutlineAPIKey] = [] @State private var apiKeys: [OutlineAPIKey] = []
@State private var isLoadingApiKeys = false @State private var isLoadingApiKeys = false
@State private var apiKeysErrorMessage: String? @State private var apiKeysErrorMessage: String?
@State private var installationInfo: OutlineInstallationInfo?
@State private var isLoadingInstallationInfo = false
@State private var installationInfoErrorMessage: String?
/// Full Local Sync and cache-clearing both need a real connection to be /// Full Local Sync and cache-clearing both need a real connection to be
/// safe clearing while offline (or letting Full Local Sync think it /// safe clearing while offline (or letting Full Local Sync think it
@@ -90,7 +87,6 @@ struct SettingsView: View {
case .notifications: notificationsDetail case .notifications: notificationsDetail
case .passkeys: passkeysDetail case .passkeys: passkeysDetail
case .apiAccess: apiAccessDetail case .apiAccess: apiAccessDetail
case .installation: installationDetail
case .offlineSync: offlineSyncDetail case .offlineSync: offlineSyncDetail
case .advanced: advancedDetail case .advanced: advancedDetail
case .about: aboutDetail case .about: aboutDetail
@@ -98,9 +94,8 @@ struct SettingsView: View {
} }
} }
/// Everything in Account/Workspace/Integrations & Installation that /// Everything in Account/Workspace that isn't `.profile` real content
/// isn't `.profile` real content lands section by section; this is /// lands section by section; this is just the nav skeleton until then.
/// just the nav skeleton until then.
private var comingSoonDetail: some View { private var comingSoonDetail: some View {
VStack(spacing: 16) { VStack(spacing: 16) {
sectionHeader sectionHeader
@@ -824,55 +819,6 @@ struct SettingsView: View {
} }
} }
// MARK: - Installation
private var installationDetail: some View {
VStack(alignment: .leading, spacing: 16) {
sectionHeader
if isLoadingInstallationInfo && installationInfo == nil {
ProgressView().controlSize(.small)
} else if let installationInfoErrorMessage {
Text(installationInfoErrorMessage)
.font(.caption)
.foregroundStyle(.red)
} else if let installationInfo {
VStack(alignment: .leading, spacing: 6) {
labeledRow("Version", installationInfo.version)
Divider()
if installationInfo.versionsBehind > 0 {
labeledRow("Latest Version", installationInfo.latestVersion)
Divider()
Label(
"\(installationInfo.versionsBehind) version\(installationInfo.versionsBehind == 1 ? "" : "s") behind",
systemImage: "exclamationmark.triangle.fill"
)
.font(.caption)
.foregroundStyle(.orange)
} else {
Label("Up to date", systemImage: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
}
.frame(maxWidth: 420, alignment: .leading)
}
}
.task { await refreshInstallationInfo() }
}
private func refreshInstallationInfo() async {
guard let apiClient = session.apiClient else { return }
isLoadingInstallationInfo = true
defer { isLoadingInstallationInfo = false }
do {
installationInfo = try await apiClient.installationInfo()
installationInfoErrorMessage = nil
} catch {
installationInfoErrorMessage = outlineErrorMessage(error, fallback: "Couldn't load installation info.")
}
}
// MARK: - Offline & Sync // MARK: - Offline & Sync
private var offlineSyncDetail: some View { private var offlineSyncDetail: some View {
+11 -18
View File
@@ -8,7 +8,6 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
case general case general
case account case account
case workspace case workspace
case integrationsInstallation
var id: String { rawValue } var id: String { rawValue }
@@ -17,19 +16,20 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
case .general: return "Outpost" case .general: return "Outpost"
case .account: return "Account" case .account: return "Account"
case .workspace: return "Workspace" case .workspace: return "Workspace"
case .integrationsInstallation: return "Integrations & Installation"
} }
} }
} }
/// One entry in the Settings sidebar. Mirrors Outline's own settings /// One entry in the Settings sidebar. Mirrors Outline's own settings
/// categories (Account/Workspace/Integrations & Installation) so this app's /// categories (Account/Workspace) so this app's settings read as a native
/// settings read as a native counterpart to the web app's, plus a `general` /// counterpart to the web app's, plus a `general` group for things that are
/// group for things that are ours and don't map onto Outline's structure /// ours and don't map onto Outline's structure (offline/sync, advanced,
/// (offline/sync, advanced, about, appearance). /// about, appearance). Outline's own version info moved to the sidebar
/// footer (`SettingsSidebarList`) instead of a standalone
/// Integrations & Installation section.
/// ///
/// Most of the Account/Workspace/Integrations cases are navigation-only for /// Most of the Account/Workspace cases are navigation-only for now
/// now `SettingsView` renders a "Coming Soon" placeholder for anything not /// `SettingsView` renders a "Coming Soon" placeholder for anything not
/// explicitly built yet. Content lands section by section. /// explicitly built yet. Content lands section by section.
enum SettingsSection: String, CaseIterable, Identifiable, Hashable { enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// General (ours) // General (ours)
@@ -41,9 +41,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
// Workspace // Workspace
case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData case details, authentication, security, ai, members, groups, templates, emojis, applications, shared, links, webhooks, importData, exportData
// Integrations & Installation
case installation
var id: String { rawValue } var id: String { rawValue }
var category: SettingsCategory { var category: SettingsCategory {
@@ -54,8 +51,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
return .account return .account
case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData: case .details, .authentication, .security, .ai, .members, .groups, .templates, .emojis, .applications, .shared, .links, .webhooks, .importData, .exportData:
return .workspace return .workspace
case .installation:
return .integrationsInstallation
} }
} }
@@ -84,7 +79,6 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case .webhooks: return "Webhooks" case .webhooks: return "Webhooks"
case .importData: return "Import" case .importData: return "Import"
case .exportData: return "Export" case .exportData: return "Export"
case .installation: return "Installation"
} }
} }
@@ -113,16 +107,15 @@ enum SettingsSection: String, CaseIterable, Identifiable, Hashable {
case .webhooks: return "bolt.horizontal" case .webhooks: return "bolt.horizontal"
case .importData: return "square.and.arrow.down" case .importData: return "square.and.arrow.down"
case .exportData: return "square.and.arrow.up" case .exportData: return "square.and.arrow.up"
case .installation: return "shippingbox"
} }
} }
/// Everything actually built so far everything else in Account/ /// Everything actually built so far everything else in Account/
/// Workspace/Integrations & Installation renders a "Coming Soon" /// Workspace renders a "Coming Soon" placeholder until its content is
/// placeholder until its content is specified and built. /// specified and built.
var isImplemented: Bool { var isImplemented: Bool {
switch self { switch self {
case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess, .installation: case .appearance, .offlineSync, .advanced, .about, .profile, .preferences, .notifications, .passkeys, .apiAccess:
return true return true
default: default:
return false return false