mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
Replace read-only Settings with structured config editor
Settings view now has editable form controls organized by section: Model: editable model name field, provider dropdown picker Display: personality picker (parsed from config), streaming/reasoning/verbose toggles Terminal: backend picker (local/docker/singularity/modal/daytona/ssh), max turns stepper Voice: auto TTS toggle, silence threshold stepper Memory: enabled toggle, char limit steppers, nudge interval stepper All changes write via `hermes config set key value` CLI with save confirmation feedback. Open in Editor button launches the raw YAML in the default text editor. Paths and raw config sections retained. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ struct HermesConfig: Sendable {
|
|||||||
var showReasoning: Bool
|
var showReasoning: Bool
|
||||||
var verbose: Bool
|
var verbose: Bool
|
||||||
var autoTTS: Bool
|
var autoTTS: Bool
|
||||||
|
var silenceThreshold: Int
|
||||||
|
|
||||||
static let empty = HermesConfig(
|
static let empty = HermesConfig(
|
||||||
model: "unknown",
|
model: "unknown",
|
||||||
@@ -28,7 +29,8 @@ struct HermesConfig: Sendable {
|
|||||||
streaming: true,
|
streaming: true,
|
||||||
showReasoning: false,
|
showReasoning: false,
|
||||||
verbose: false,
|
verbose: false,
|
||||||
autoTTS: true
|
autoTTS: true,
|
||||||
|
silenceThreshold: 200
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ struct HermesFileService: Sendable {
|
|||||||
streaming: values["display.streaming"] != "false",
|
streaming: values["display.streaming"] != "false",
|
||||||
showReasoning: values["display.show_reasoning"] == "true",
|
showReasoning: values["display.show_reasoning"] == "true",
|
||||||
verbose: values["agent.verbose"] == "true",
|
verbose: values["agent.verbose"] == "true",
|
||||||
autoTTS: values["voice.auto_tts"] != "false"
|
autoTTS: values["voice.auto_tts"] != "false",
|
||||||
|
silenceThreshold: Int(values["voice.silence_threshold"] ?? "") ?? 200
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import AppKit
|
||||||
|
|
||||||
@Observable
|
@Observable
|
||||||
final class SettingsViewModel {
|
final class SettingsViewModel {
|
||||||
@@ -8,11 +9,89 @@ final class SettingsViewModel {
|
|||||||
var gatewayState: GatewayState?
|
var gatewayState: GatewayState?
|
||||||
var hermesRunning = false
|
var hermesRunning = false
|
||||||
var rawConfigYAML = ""
|
var rawConfigYAML = ""
|
||||||
|
var personalities: [String] = []
|
||||||
|
var providers = ["anthropic", "openrouter", "nous", "openai-codex", "zai", "kimi-coding", "minimax"]
|
||||||
|
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh"]
|
||||||
|
var saveMessage: String?
|
||||||
|
|
||||||
func load() {
|
func load() {
|
||||||
config = fileService.loadConfig()
|
config = fileService.loadConfig()
|
||||||
gatewayState = fileService.loadGatewayState()
|
gatewayState = fileService.loadGatewayState()
|
||||||
hermesRunning = fileService.isHermesRunning()
|
hermesRunning = fileService.isHermesRunning()
|
||||||
rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
rawConfigYAML = (try? String(contentsOfFile: HermesPaths.configYAML, encoding: .utf8)) ?? ""
|
||||||
|
personalities = parsePersonalities()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSetting(_ key: String, value: String) {
|
||||||
|
let result = runHermes(["config", "set", key, value])
|
||||||
|
if result.exitCode == 0 {
|
||||||
|
saveMessage = "Saved \(key)"
|
||||||
|
config = fileService.loadConfig()
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in
|
||||||
|
self?.saveMessage = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setModel(_ value: String) { setSetting("model.default", value: value) }
|
||||||
|
func setProvider(_ value: String) { setSetting("model.provider", value: value) }
|
||||||
|
func setPersonality(_ value: String) { setSetting("display.personality", value: value) }
|
||||||
|
func setTerminalBackend(_ value: String) { setSetting("terminal.backend", value: value) }
|
||||||
|
func setMaxTurns(_ value: Int) { setSetting("agent.max_turns", value: String(value)) }
|
||||||
|
func setMemoryEnabled(_ value: Bool) { setSetting("memory.memory_enabled", value: value ? "true" : "false") }
|
||||||
|
func setMemoryCharLimit(_ value: Int) { setSetting("memory.memory_char_limit", value: String(value)) }
|
||||||
|
func setUserCharLimit(_ value: Int) { setSetting("memory.user_char_limit", value: String(value)) }
|
||||||
|
func setNudgeInterval(_ value: Int) { setSetting("memory.nudge_interval", value: String(value)) }
|
||||||
|
func setStreaming(_ value: Bool) { setSetting("display.streaming", value: value ? "true" : "false") }
|
||||||
|
func setShowReasoning(_ value: Bool) { setSetting("display.show_reasoning", value: value ? "true" : "false") }
|
||||||
|
func setVerbose(_ value: Bool) { setSetting("agent.verbose", value: value ? "true" : "false") }
|
||||||
|
func setAutoTTS(_ value: Bool) { setSetting("voice.auto_tts", value: value ? "true" : "false") }
|
||||||
|
func setSilenceThreshold(_ value: Int) { setSetting("voice.silence_threshold", value: String(value)) }
|
||||||
|
|
||||||
|
func openConfigInEditor() {
|
||||||
|
NSWorkspace.shared.open(URL(fileURLWithPath: HermesPaths.configYAML))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parsePersonalities() -> [String] {
|
||||||
|
var names: [String] = []
|
||||||
|
var inPersonalities = false
|
||||||
|
for line in rawConfigYAML.components(separatedBy: "\n") {
|
||||||
|
if line.trimmingCharacters(in: .whitespaces) == "personalities:" && line.hasPrefix(" ") {
|
||||||
|
inPersonalities = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if inPersonalities {
|
||||||
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
||||||
|
if trimmed.isEmpty { continue }
|
||||||
|
let indent = line.prefix(while: { $0 == " " }).count
|
||||||
|
if indent <= 2 && !trimmed.isEmpty {
|
||||||
|
inPersonalities = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if indent == 4 && trimmed.contains(":") {
|
||||||
|
let name = String(trimmed.split(separator: ":")[0])
|
||||||
|
names.append(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||||
|
let process = Process()
|
||||||
|
process.executableURL = URL(fileURLWithPath: HermesPaths.hermesBinary)
|
||||||
|
process.arguments = arguments
|
||||||
|
let pipe = Pipe()
|
||||||
|
process.standardOutput = pipe
|
||||||
|
process.standardError = Pipe()
|
||||||
|
do {
|
||||||
|
try process.run()
|
||||||
|
process.waitUntilExit()
|
||||||
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||||
|
return (String(data: data, encoding: .utf8) ?? "", process.terminationStatus)
|
||||||
|
} catch {
|
||||||
|
return ("", -1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
VStack(alignment: .leading, spacing: 20) {
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
configSection
|
headerBar
|
||||||
gatewaySection
|
modelSection
|
||||||
|
displaySection
|
||||||
|
terminalSection
|
||||||
|
voiceSection
|
||||||
|
memorySection
|
||||||
pathsSection
|
pathsSection
|
||||||
rawConfigSection
|
rawConfigSection
|
||||||
}
|
}
|
||||||
@@ -19,51 +23,78 @@ struct SettingsView: View {
|
|||||||
.onAppear { viewModel.load() }
|
.onAppear { viewModel.load() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private var configSection: some View {
|
private var headerBar: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
HStack {
|
||||||
Text("Configuration")
|
if let msg = viewModel.saveMessage {
|
||||||
.font(.headline)
|
Label(msg, systemImage: "checkmark.circle.fill")
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
|
.font(.caption)
|
||||||
SettingRow(label: "Model", value: viewModel.config.model)
|
.foregroundStyle(.green)
|
||||||
SettingRow(label: "Provider", value: viewModel.config.provider)
|
|
||||||
SettingRow(label: "Personality", value: viewModel.config.personality)
|
|
||||||
SettingRow(label: "Max Turns", value: "\(viewModel.config.maxTurns)")
|
|
||||||
SettingRow(label: "Terminal Backend", value: viewModel.config.terminalBackend)
|
|
||||||
SettingRow(label: "Memory Enabled", value: viewModel.config.memoryEnabled ? "Yes" : "No")
|
|
||||||
SettingRow(label: "Memory Char Limit", value: "\(viewModel.config.memoryCharLimit)")
|
|
||||||
SettingRow(label: "User Char Limit", value: "\(viewModel.config.userCharLimit)")
|
|
||||||
SettingRow(label: "Nudge Interval", value: "\(viewModel.config.nudgeInterval) turns")
|
|
||||||
SettingRow(label: "Streaming", value: viewModel.config.streaming ? "Yes" : "No")
|
|
||||||
SettingRow(label: "Show Reasoning", value: viewModel.config.showReasoning ? "Yes" : "No")
|
|
||||||
SettingRow(label: "Verbose", value: viewModel.config.verbose ? "Yes" : "No")
|
|
||||||
}
|
}
|
||||||
|
Spacer()
|
||||||
|
Button("Open in Editor") { viewModel.openConfigInEditor() }
|
||||||
|
.controlSize(.small)
|
||||||
|
Button("Reload") { viewModel.load() }
|
||||||
|
.controlSize(.small)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var gatewaySection: some View {
|
// MARK: - Model & Provider
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
|
||||||
Text("Gateway")
|
private var modelSection: some View {
|
||||||
.font(.headline)
|
SettingsSection(title: "Model", icon: "cpu") {
|
||||||
HStack(spacing: 16) {
|
EditableTextField(label: "Model", value: viewModel.config.model) { viewModel.setModel($0) }
|
||||||
Label(
|
PickerRow(label: "Provider", selection: viewModel.config.provider, options: viewModel.providers) { viewModel.setProvider($0) }
|
||||||
viewModel.gatewayState?.statusText ?? "unknown",
|
|
||||||
systemImage: viewModel.gatewayState?.isRunning == true ? "circle.fill" : "circle"
|
|
||||||
)
|
|
||||||
.foregroundStyle(viewModel.gatewayState?.isRunning == true ? .green : .secondary)
|
|
||||||
if let reason = viewModel.gatewayState?.exitReason {
|
|
||||||
Text(reason)
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Display
|
||||||
|
|
||||||
|
private var displaySection: some View {
|
||||||
|
SettingsSection(title: "Display", icon: "paintbrush") {
|
||||||
|
if !viewModel.personalities.isEmpty {
|
||||||
|
PickerRow(label: "Personality", selection: viewModel.config.personality, options: viewModel.personalities) { viewModel.setPersonality($0) }
|
||||||
|
} else {
|
||||||
|
EditableTextField(label: "Personality", value: viewModel.config.personality) { viewModel.setPersonality($0) }
|
||||||
|
}
|
||||||
|
ToggleRow(label: "Streaming", isOn: viewModel.config.streaming) { viewModel.setStreaming($0) }
|
||||||
|
ToggleRow(label: "Show Reasoning", isOn: viewModel.config.showReasoning) { viewModel.setShowReasoning($0) }
|
||||||
|
ToggleRow(label: "Verbose", isOn: viewModel.config.verbose) { viewModel.setVerbose($0) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Terminal
|
||||||
|
|
||||||
|
private var terminalSection: some View {
|
||||||
|
SettingsSection(title: "Terminal", icon: "terminal") {
|
||||||
|
PickerRow(label: "Backend", selection: viewModel.config.terminalBackend, options: viewModel.terminalBackends) { viewModel.setTerminalBackend($0) }
|
||||||
|
StepperRow(label: "Max Turns", value: viewModel.config.maxTurns, range: 1...200) { viewModel.setMaxTurns($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Voice
|
||||||
|
|
||||||
|
private var voiceSection: some View {
|
||||||
|
SettingsSection(title: "Voice", icon: "mic") {
|
||||||
|
ToggleRow(label: "Auto TTS", isOn: viewModel.config.autoTTS) { viewModel.setAutoTTS($0) }
|
||||||
|
StepperRow(label: "Silence Threshold", value: viewModel.config.silenceThreshold, range: 50...500) { viewModel.setSilenceThreshold($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Memory
|
||||||
|
|
||||||
|
private var memorySection: some View {
|
||||||
|
SettingsSection(title: "Memory", icon: "brain") {
|
||||||
|
ToggleRow(label: "Memory Enabled", isOn: viewModel.config.memoryEnabled) { viewModel.setMemoryEnabled($0) }
|
||||||
|
StepperRow(label: "Memory Char Limit", value: viewModel.config.memoryCharLimit, range: 500...10000) { viewModel.setMemoryCharLimit($0) }
|
||||||
|
StepperRow(label: "User Char Limit", value: viewModel.config.userCharLimit, range: 500...10000) { viewModel.setUserCharLimit($0) }
|
||||||
|
StepperRow(label: "Nudge Interval", value: viewModel.config.nudgeInterval, range: 1...50) { viewModel.setNudgeInterval($0) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Paths
|
||||||
|
|
||||||
private var pathsSection: some View {
|
private var pathsSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
SettingsSection(title: "Paths", icon: "folder") {
|
||||||
Text("Paths")
|
|
||||||
.font(.headline)
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
|
||||||
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
PathRow(label: "Hermes Home", path: HermesPaths.home)
|
||||||
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
PathRow(label: "State DB", path: HermesPaths.stateDB)
|
||||||
PathRow(label: "Config", path: HermesPaths.configYAML)
|
PathRow(label: "Config", path: HermesPaths.configYAML)
|
||||||
@@ -73,7 +104,8 @@ struct SettingsView: View {
|
|||||||
PathRow(label: "Logs", path: HermesPaths.errorsLog)
|
PathRow(label: "Logs", path: HermesPaths.errorsLog)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
// MARK: - Raw Config
|
||||||
|
|
||||||
private var rawConfigSection: some View {
|
private var rawConfigSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
@@ -98,19 +130,143 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SettingRow: View {
|
// MARK: - Reusable Components
|
||||||
|
|
||||||
|
struct SettingsSection<Content: View>: View {
|
||||||
|
let title: String
|
||||||
|
let icon: String
|
||||||
|
@ViewBuilder let content: Content
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Label(title, systemImage: icon)
|
||||||
|
.font(.headline)
|
||||||
|
VStack(spacing: 1) {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct EditableTextField: View {
|
||||||
let label: String
|
let label: String
|
||||||
let value: String
|
let value: String
|
||||||
|
let onCommit: (String) -> Void
|
||||||
|
@State private var text: String = ""
|
||||||
|
@State private var isEditing = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: 120, alignment: .trailing)
|
.frame(width: 130, alignment: .trailing)
|
||||||
|
if isEditing {
|
||||||
|
TextField(label, text: $text, onCommit: {
|
||||||
|
if text != value { onCommit(text) }
|
||||||
|
isEditing = false
|
||||||
|
})
|
||||||
|
.textFieldStyle(.roundedBorder)
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
Button("Cancel") { isEditing = false }
|
||||||
|
.controlSize(.mini)
|
||||||
|
} else {
|
||||||
Text(value)
|
Text(value)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
Spacer()
|
||||||
|
Button("Edit") {
|
||||||
|
text = value
|
||||||
|
isEditing = true
|
||||||
}
|
}
|
||||||
|
.controlSize(.mini)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PickerRow: View {
|
||||||
|
let label: String
|
||||||
|
let selection: String
|
||||||
|
let options: [String]
|
||||||
|
let onChange: (String) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 130, alignment: .trailing)
|
||||||
|
Picker("", selection: Binding(
|
||||||
|
get: { selection },
|
||||||
|
set: { onChange($0) }
|
||||||
|
)) {
|
||||||
|
ForEach(options, id: \.self) { option in
|
||||||
|
Text(option).tag(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: 250)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ToggleRow: View {
|
||||||
|
let label: String
|
||||||
|
let isOn: Bool
|
||||||
|
let onChange: (Bool) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 130, alignment: .trailing)
|
||||||
|
Toggle("", isOn: Binding(
|
||||||
|
get: { isOn },
|
||||||
|
set: { onChange($0) }
|
||||||
|
))
|
||||||
|
.toggleStyle(.switch)
|
||||||
|
.labelsHidden()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StepperRow: View {
|
||||||
|
let label: String
|
||||||
|
let value: Int
|
||||||
|
let range: ClosedRange<Int>
|
||||||
|
let onChange: (Int) -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack {
|
||||||
|
Text(label)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 130, alignment: .trailing)
|
||||||
|
Text("\(value)")
|
||||||
|
.font(.system(.caption, design: .monospaced))
|
||||||
|
.frame(width: 50)
|
||||||
|
Stepper("", value: Binding(
|
||||||
|
get: { value },
|
||||||
|
set: { onChange($0) }
|
||||||
|
), in: range)
|
||||||
|
.labelsHidden()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,10 +279,11 @@ struct PathRow: View {
|
|||||||
Text(label)
|
Text(label)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(width: 100, alignment: .trailing)
|
.frame(width: 130, alignment: .trailing)
|
||||||
Text(path)
|
Text(path)
|
||||||
.font(.system(.caption, design: .monospaced))
|
.font(.system(.caption, design: .monospaced))
|
||||||
.textSelection(.enabled)
|
.textSelection(.enabled)
|
||||||
|
Spacer()
|
||||||
Button {
|
Button {
|
||||||
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path)
|
||||||
} label: {
|
} label: {
|
||||||
@@ -135,5 +292,8 @@ struct PathRow: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
}
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.quaternary.opacity(0.3))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user