Files
adolf/apps/macos/Sources/OpenClaw/ConfigSettings.swift
alvis bedb527145
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Vendor OpenClaw source as Adolf fork baseline
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
2026-07-05 09:36:54 +00:00

461 lines
16 KiB
Swift

import SwiftUI
@MainActor
struct ConfigSettings: View {
private let isPreview = ProcessInfo.processInfo.isPreview
private let isNixMode = ProcessInfo.processInfo.isNixMode
@Bindable var store: ChannelsStore
@State private var hasLoaded = false
@State private var activePath: String?
@State private var failedLookupPaths: Set<String> = []
init(store: ChannelsStore = .shared) {
self.store = store
}
var body: some View {
HStack(spacing: 16) {
self.sidebar
self.detail
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.settingsDetailContent()
.task {
guard !self.hasLoaded else { return }
guard !self.isPreview else { return }
self.hasLoaded = true
Task { await self.store.loadConfig(force: false) }
_ = await self.store.loadConfigSchemaLookup(path: ".")
self.ensureSelection()
}
.task(id: self.activePath) {
guard let activePath = self.activePath else { return }
await self.loadPath(activePath)
}
.onAppear { self.ensureSelection() }
.onChange(of: self.store.configLookupRoot?.path) { _, _ in
self.failedLookupPaths.removeAll()
self.ensureSelection()
}
}
}
extension ConfigSettings {
private struct ConfigSection: Identifiable {
let key: String
let label: String
let help: String?
let path: String
let hasChildren: Bool
var id: String {
self.path
}
}
private struct ConfigSubsection: Identifiable {
let key: String
let label: String
let help: String?
let path: String
let hasChildren: Bool
var id: String {
self.path
}
}
private var sections: [ConfigSection] {
guard let root = self.store.configLookupRoot else { return [] }
return self.resolveSections(root.children)
}
private var activeSection: ConfigSection? {
guard let activePath = self.activePath else { return nil }
return self.sections.first { activePath == $0.path || activePath.hasPrefix("\($0.path).") }
}
private var sidebar: some View {
SettingsSidebarScroll {
LazyVStack(alignment: .leading, spacing: 4) {
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.sidebarSection(section)
}
}
}
}
}
private var detail: some View {
VStack(alignment: .leading, spacing: 16) {
if self.store.configLookupRoot == nil,
!self.hasLoaded || self.store.configLookupLoadingPaths.contains(".")
{
ProgressView().controlSize(.small)
} else if let section = self.activeSection {
self.sectionDetail(section)
} else if self.store.configLookupRoot != nil {
self.emptyDetail
} else {
self.schemaUnavailableDetail
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.layoutPriority(1)
}
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, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
}
private var schemaUnavailableDetail: some View {
VStack(alignment: .leading, spacing: 8) {
self.header
Text(self.store.configStatus ?? "Schema unavailable.")
.font(.callout)
.foregroundStyle(.secondary)
self.actionRow
}
.padding(.horizontal, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
}
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.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, SettingsLayout.detailHorizontalPadding)
.padding(.vertical, SettingsLayout.detailVerticalPadding)
.groupBoxStyle(PlainSettingsGroupBoxStyle())
}
}
@ViewBuilder
private var header: some View {
Text("Config")
.font(.title3.weight(.semibold))
Text(self.isNixMode
? "This tab is read-only in Nix mode. Edit config via Nix and rebuild."
: "Edit ~/.openclaw/openclaw.json using the schema-driven form.")
.font(.callout)
.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") {
Task { await self.store.reloadConfigDraft() }
}
.disabled(!self.store.configLoaded)
Button(self.store.isSavingConfig ? "Saving…" : "Save") {
Task { await self.store.saveConfigDraft() }
}
.disabled(self.isNixMode || self.store.isSavingConfig || !self.store.configLoaded || !self.store
.configDirty)
}
.buttonStyle(.bordered)
}
private func sidebarSection(_ section: ConfigSection) -> some View {
let isExpanded = self.activePath == section.path || self.activePath?.hasPrefix("\(section.path).") == true
let subsections = isExpanded ? self.resolveSubsections(for: section) : []
return VStack(alignment: .leading, spacing: 2) {
Button {
self.selectSection(section)
} label: {
HStack(spacing: 6) {
Image(systemName: "chevron.right")
.font(.caption2.weight(.semibold))
.foregroundStyle(.tertiary)
.rotationEffect(.degrees(isExpanded ? 90 : 0))
Text(section.label)
.lineLimit(1)
}
.padding(.vertical, 5)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(self.activePath == section.path
? Color.accentColor.opacity(0.18)
: Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contentShape(Rectangle())
if isExpanded, !subsections.isEmpty {
VStack(alignment: .leading, spacing: 1) {
ForEach(subsections) { sub in
self.sidebarSubRow(sub)
}
}
.padding(.leading, 20)
.transition(.opacity.combined(with: .move(edge: .top)))
}
}
.animation(.easeInOut(duration: 0.18), value: isExpanded)
}
private func sidebarSubRow(_ subsection: ConfigSubsection) -> some View {
let isSelected = self.activePath == subsection.path
return Button {
self.selectPath(subsection.path)
} label: {
Text(subsection.label)
.font(.callout)
.lineLimit(1)
.padding(.vertical, 4)
.padding(.horizontal, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(isSelected ? Color.accentColor.opacity(0.18) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contentShape(Rectangle())
}
private func sectionForm(_ section: ConfigSection) -> some View {
let path = self.activePath ?? section.path
if self.store.configLookupLoadingPaths.contains(path) {
return AnyView(ProgressView().controlSize(.small))
}
guard let node = self.store.configLookupNode(path: path) else {
if self.failedLookupPaths.contains(path) {
return AnyView(self.lookupUnavailable(path: path))
}
return AnyView(ProgressView().controlSize(.small))
}
if !node.children.isEmpty, !Self.shouldRenderFormEditor(for: node.schema) {
return AnyView(self.lookupChildrenList(node))
}
guard self.store.configLoaded else {
return AnyView(
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Loading current values…")
.font(.caption)
.foregroundStyle(.secondary)
})
}
guard let configPath = Self.configPath(from: node.path) else {
return AnyView(
Text("Wildcard config entries are edited from their concrete key.")
.font(.caption)
.foregroundStyle(.secondary))
}
return AnyView(
ConfigSchemaForm(store: self.store, schema: node.schema, path: configPath)
.disabled(self.isNixMode))
}
private func ensureSelection() {
let sections = self.sections
guard !sections.isEmpty else { return }
if let activePath = self.activePath,
sections.contains(where: { activePath == $0.path || activePath.hasPrefix("\($0.path).") })
{
return
}
self.selectSection(sections[0])
}
private func selectSection(_ section: ConfigSection) {
self.activePath = section.path
}
private func selectPath(_ path: String) {
self.activePath = path
}
private func lookupUnavailable(path: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(self.store.configStatus ?? "Schema unavailable.")
.font(.callout)
.foregroundStyle(.secondary)
Button("Retry") {
self.failedLookupPaths.remove(path)
Task { await self.loadPath(path) }
}
.buttonStyle(.bordered)
}
}
private func lookupChildrenList(_ node: ConfigSchemaLookupNode) -> some View {
VStack(alignment: .leading, spacing: 8) {
ForEach(node.children) { child in
Button {
self.selectPath(child.path)
} label: {
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text(self.label(for: child))
.font(.callout.weight(.semibold))
if let help = child.hint?.help {
Text(help)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
} else if let type = child.typeLabel {
Text(type)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
if child.required {
Text("Required")
.font(.caption)
.foregroundStyle(.secondary)
}
Image(systemName: child.hasChildren ? "chevron.right" : "slider.horizontal.3")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
}
.padding(.vertical, 8)
.padding(.horizontal, 10)
.background(Color.primary.opacity(0.04))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
}
private func resolveSections(_ children: [ConfigSchemaLookupChild]) -> [ConfigSection] {
children
.sorted(by: self.sortLookupChildren)
.map { child in
ConfigSection(
key: child.key,
label: self.label(for: child),
help: child.hint?.help,
path: child.path,
hasChildren: child.hasChildren)
}
}
private func resolveSubsections(for section: ConfigSection) -> [ConfigSubsection] {
guard let node = self.store.configLookupNode(path: section.path) else {
return []
}
return node.children
.sorted(by: self.sortLookupChildren)
.map { child in
ConfigSubsection(
key: child.key,
label: self.label(for: child),
help: child.hint?.help,
path: child.path,
hasChildren: child.hasChildren)
}
}
private func loadPath(_ path: String) async {
guard self.store.configLookupNode(path: path) == nil else {
self.failedLookupPaths.remove(path)
return
}
guard !self.store.configLookupLoadingPaths.contains(path) else { return }
if await self.store.loadConfigSchemaLookup(path: path) == nil {
self.failedLookupPaths.insert(path)
} else {
self.failedLookupPaths.remove(path)
}
}
private func label(for child: ConfigSchemaLookupChild) -> String {
child.hint?.label
?? self.humanize(child.key)
}
private func sortLookupChildren(_ lhs: ConfigSchemaLookupChild, _ rhs: ConfigSchemaLookupChild) -> Bool {
let orderA = lhs.hint?.order ?? 0
let orderB = rhs.hint?.order ?? 0
if orderA != orderB { return orderA < orderB }
return lhs.key < rhs.key
}
private static func configPath(from lookupPath: String) -> ConfigPath? {
guard lookupPath != "." else { return [] }
let normalized = lookupPath
.replacingOccurrences(of: "[", with: ".")
.replacingOccurrences(of: "]", with: "")
let parts = normalized
.split(separator: ".")
.map(String.init)
.filter { !$0.isEmpty }
guard !parts.contains("*") else { return nil }
return parts.map { part in
if let index = Int(part) {
return .index(index)
}
return .key(part)
}
}
private static func shouldRenderFormEditor(for schema: ConfigSchemaNode) -> Bool {
if schema.schemaType == "array" { return true }
return schema.additionalProperties != nil
}
private func humanize(_ key: String) -> String {
key.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
.capitalized
}
}
struct ConfigSettings_Previews: PreviewProvider {
static var previews: some View {
ConfigSettings()
}
}