From 450d2d25e2c248ec4e284ff987edee71b0a58886 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 01:16:52 +0000 Subject: [PATCH] feat: add sectioned config layout in mac app --- .../Sources/Clawdbot/ConfigSettings.swift | 341 ++++++++++++++++-- 1 file changed, 317 insertions(+), 24 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/ConfigSettings.swift b/apps/macos/Sources/Clawdbot/ConfigSettings.swift index d6962a324..25e59877d 100644 --- a/apps/macos/Sources/Clawdbot/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdbot/ConfigSettings.swift @@ -6,15 +6,19 @@ struct ConfigSettings: View { private let isNixMode = ProcessInfo.processInfo.isNixMode @Bindable var store: ChannelsStore @State private var hasLoaded = false + @State private var activeSectionKey: String? + @State private var activeSubsection: SubsectionSelection? init(store: ChannelsStore = .shared) { self.store = store } var body: some View { - ScrollView { - self.content + HStack(spacing: 16) { + self.sidebar + self.detail } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .task { guard !self.hasLoaded else { return } guard !self.isPreview else { return } @@ -22,42 +26,125 @@ struct ConfigSettings: View { await self.store.loadConfigSchema() await self.store.loadConfig() } + .onAppear { self.ensureSelection() } + .onChange(of: self.store.configSchema) { _, _ in + self.ensureSelection() + } } } extension ConfigSettings { - private var content: some View { - VStack(alignment: .leading, spacing: 16) { - self.header - if let status = self.store.configStatus { - Text(status) - .font(.callout) - .foregroundStyle(.secondary) - } - self.actionRow - Group { - if self.store.configSchemaLoading { - ProgressView().controlSize(.small) - } else if let schema = self.store.configSchema { - ConfigSchemaForm(store: self.store, schema: schema, path: []) - .disabled(self.isNixMode) - } else { - Text("Schema unavailable.") + private enum SubsectionSelection: Hashable { + case all + case key(String) + } + + private struct ConfigSection: Identifiable { + let key: String + let label: String + let help: String? + let node: ConfigSchemaNode + + var id: String { self.key } + } + + private struct ConfigSubsection: Identifiable { + let key: String + let label: String + let help: String? + let node: ConfigSchemaNode + let path: ConfigPath + + var id: String { self.key } + } + + private var sections: [ConfigSection] { + guard let schema = self.store.configSchema else { return [] } + return self.resolveSections(schema) + } + + private var activeSection: ConfigSection? { + self.sections.first { $0.key == self.activeSectionKey } + } + + private var sidebar: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + if self.sections.isEmpty { + Text("No config sections available.") .font(.caption) .foregroundStyle(.secondary) + .padding(.horizontal, 6) + .padding(.vertical, 4) + } else { + ForEach(self.sections) { section in + self.sidebarRow(section) + } } } - if self.store.configDirty, !self.isNixMode { - Text("Unsaved changes") + .padding(.vertical, 10) + .padding(.horizontal, 10) + } + .frame(minWidth: 220, idealWidth: 240, maxWidth: 280, maxHeight: .infinity, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .windowBackgroundColor))) + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + + private var detail: some View { + VStack(alignment: .leading, spacing: 16) { + if self.store.configSchemaLoading { + ProgressView().controlSize(.small) + } else if let section = self.activeSection { + self.sectionDetail(section) + } else if self.store.configSchema != nil { + self.emptyDetail + } else { + Text("Schema unavailable.") .font(.caption) .foregroundStyle(.secondary) } - Spacer(minLength: 0) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(minWidth: 460, maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + private var emptyDetail: some View { + VStack(alignment: .leading, spacing: 8) { + self.header + Text("Select a config section to view settings.") + .font(.callout) + .foregroundStyle(.secondary) + } .padding(.horizontal, 24) .padding(.vertical, 18) - .groupBoxStyle(PlainSettingsGroupBoxStyle()) + } + + private func sectionDetail(_ section: ConfigSection) -> some View { + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: 16) { + self.header + if let status = self.store.configStatus { + Text(status) + .font(.callout) + .foregroundStyle(.secondary) + } + self.actionRow + self.sectionHeader(section) + self.subsectionNav(section) + self.sectionForm(section) + if self.store.configDirty, !self.isNixMode { + Text("Unsaved changes") + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.vertical, 18) + .groupBoxStyle(PlainSettingsGroupBoxStyle()) + } } @ViewBuilder @@ -71,6 +158,18 @@ extension ConfigSettings { .foregroundStyle(.secondary) } + private func sectionHeader(_ section: ConfigSection) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(section.label) + .font(.title3.weight(.semibold)) + if let help = section.help { + Text(help) + .font(.callout) + .foregroundStyle(.secondary) + } + } + } + private var actionRow: some View { HStack(spacing: 10) { Button("Reload") { @@ -85,6 +184,200 @@ extension ConfigSettings { } .buttonStyle(.bordered) } + + private func sidebarRow(_ section: ConfigSection) -> some View { + let isSelected = self.activeSectionKey == section.key + return Button { + self.selectSection(section) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(section.label) + if let help = section.help { + Text(help) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 6) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear) + .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + .background(Color.clear) + .contentShape(Rectangle()) + } + .frame(maxWidth: .infinity, alignment: .leading) + .buttonStyle(.plain) + .contentShape(Rectangle()) + } + + @ViewBuilder + private func subsectionNav(_ section: ConfigSection) -> some View { + let subsections = self.resolveSubsections(for: section) + guard !subsections.isEmpty else { return } + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + self.subsectionButton( + title: "All", + isSelected: self.activeSubsection == .all) + { + self.activeSubsection = .all + } + ForEach(subsections) { subsection in + self.subsectionButton( + title: subsection.label, + isSelected: self.activeSubsection == .key(subsection.key)) + { + self.activeSubsection = .key(subsection.key) + } + } + } + .padding(.vertical, 2) + } + } + + private func subsectionButton( + title: String, + isSelected: Bool, + action: @escaping () -> Void) -> some View + { + Button(action: action) { + Text(title) + .font(.callout.weight(.semibold)) + .foregroundStyle(isSelected ? Color.accentColor : .primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(isSelected ? Color.accentColor.opacity(0.18) : Color(nsColor: .controlBackgroundColor)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + + private func sectionForm(_ section: ConfigSection) -> some View { + let subsection = self.activeSubsection + let defaultPath: ConfigPath = [.key(section.key)] + let subsections = self.resolveSubsections(for: section) + let resolved: (ConfigSchemaNode, ConfigPath) = { + if case let .key(key) = subsection, + let match = subsections.first(where: { $0.key == key }) { + return (match.node, match.path) + } + return (self.resolvedSchemaNode(section.node), defaultPath) + }() + + return ConfigSchemaForm(store: self.store, schema: resolved.0, path: resolved.1) + .disabled(self.isNixMode) + } + + private func ensureSelection() { + guard let schema = self.store.configSchema else { return } + let sections = self.resolveSections(schema) + guard !sections.isEmpty else { return } + + let active = sections.first { $0.key == self.activeSectionKey } ?? sections[0] + if self.activeSectionKey != active.key { + self.activeSectionKey = active.key + } + self.ensureSubsection(for: active) + } + + private func ensureSubsection(for section: ConfigSection) { + let subsections = self.resolveSubsections(for: section) + guard !subsections.isEmpty else { + self.activeSubsection = nil + return + } + + switch self.activeSubsection { + case .all: + return + case let .key(key): + if subsections.contains(where: { $0.key == key }) { return } + case .none: + break + } + + if let first = subsections.first { + self.activeSubsection = .key(first.key) + } + } + + private func selectSection(_ section: ConfigSection) { + guard self.activeSectionKey != section.key else { return } + self.activeSectionKey = section.key + let subsections = self.resolveSubsections(for: section) + if let first = subsections.first { + self.activeSubsection = .key(first.key) + } else { + self.activeSubsection = nil + } + } + + private func resolveSections(_ root: ConfigSchemaNode) -> [ConfigSection] { + let node = self.resolvedSchemaNode(root) + let hints = self.store.configUiHints + let keys = node.properties.keys.sorted { lhs, rhs in + let orderA = hintForPath([.key(lhs)], hints: hints)?.order ?? 0 + let orderB = hintForPath([.key(rhs)], hints: hints)?.order ?? 0 + if orderA != orderB { return orderA < orderB } + return lhs < rhs + } + + return keys.compactMap { key in + guard let child = node.properties[key] else { return nil } + let path: ConfigPath = [.key(key)] + let hint = hintForPath(path, hints: hints) + let label = hint?.label + ?? child.title + ?? self.humanize(key) + let help = hint?.help ?? child.description + return ConfigSection(key: key, label: label, help: help, node: child) + } + } + + private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] { + let node = self.resolvedSchemaNode(section.node) + guard node.schemaType == "object" else { return [] } + let hints = self.store.configUiHints + let keys = node.properties.keys.sorted { lhs, rhs in + let orderA = hintForPath([.key(section.key), .key(lhs)], hints: hints)?.order ?? 0 + let orderB = hintForPath([.key(section.key), .key(rhs)], hints: hints)?.order ?? 0 + if orderA != orderB { return orderA < orderB } + return lhs < rhs + } + + return keys.compactMap { key in + guard let child = node.properties[key] else { return nil } + let path: ConfigPath = [.key(section.key), .key(key)] + let hint = hintForPath(path, hints: hints) + let label = hint?.label + ?? child.title + ?? self.humanize(key) + let help = hint?.help ?? child.description + return ConfigSubsection( + key: key, + label: label, + help: help, + node: child, + path: path) + } + } + + private func resolvedSchemaNode(_ node: ConfigSchemaNode) -> ConfigSchemaNode { + let variants = node.anyOf.isEmpty ? node.oneOf : node.anyOf + if !variants.isEmpty { + let nonNull = variants.filter { !$0.isNullSchema } + if nonNull.count == 1, let only = nonNull.first { return only } + } + return node + } + + private func humanize(_ key: String) -> String { + key.replacingOccurrences(of: "_", with: " ") + .replacingOccurrences(of: "-", with: " ") + .capitalized + } } struct ConfigSettings_Previews: PreviewProvider {