refactor(macos): refresh skills settings layout

This commit is contained in:
Peter Steinberger
2025-12-20 20:49:23 +01:00
parent 52a2dfe08b
commit 77582ff5d4

View File

@@ -9,17 +9,12 @@ struct SkillsSettings: View {
@State private var filter: SkillsFilter = .all @State private var filter: SkillsFilter = .all
var body: some View { var body: some View {
ScrollView { VStack(alignment: .leading, spacing: 12) {
VStack(alignment: .leading, spacing: 14) { self.header
self.header self.filterBar
self.filterBar self.statusBanner
self.statusBanner self.skillsList
self.skillsList Spacer(minLength: 0)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 24)
.padding(.vertical, 18)
} }
.task { await self.model.refresh() } .task { await self.model.refresh() }
.sheet(item: self.$envEditor) { editor in .sheet(item: self.$envEditor) { editor in
@@ -36,17 +31,26 @@ struct SkillsSettings: View {
} }
private var header: some View { private var header: some View {
HStack(alignment: .top, spacing: 12) { HStack {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 4) {
Text("Skills") Text("Skills")
.font(.title3.weight(.semibold)) .font(.headline)
Text("Skills are enabled when requirements are met (binaries, env, config).") Text("Skills are enabled when requirements are met (binaries, env, config).")
.font(.callout) .font(.footnote)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
Spacer() Spacer()
Button("Refresh") { Task { await self.model.refresh() } } if self.model.isLoading {
.disabled(self.model.isLoading) ProgressView()
} else {
Button {
Task { await self.model.refresh() }
} label: {
Label("Refresh", systemImage: "arrow.clockwise")
}
.buttonStyle(.bordered)
.help("Refresh")
}
} }
} }
@@ -63,49 +67,59 @@ struct SkillsSettings: View {
} }
} }
@ViewBuilder
private var skillsList: some View { private var skillsList: some View {
VStack(spacing: 10) { if self.model.skills.isEmpty {
ForEach(self.filteredSkills) { skill in Text("No skills reported yet.")
SkillRow( .foregroundStyle(.secondary)
skill: skill, } else {
isBusy: self.model.isBusy(skill: skill), List {
onToggleEnabled: { enabled in ForEach(self.filteredSkills) { skill in
Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) } SkillRow(
}, skill: skill,
onInstall: { option in isBusy: self.model.isBusy(skill: skill),
Task { await self.model.install(skill: skill, option: option) } onToggleEnabled: { enabled in
}, Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) }
onSetEnv: { envKey, isPrimary in },
self.envEditor = EnvEditorState( onInstall: { option in
skillKey: skill.skillKey, Task { await self.model.install(skill: skill, option: option) }
skillName: skill.name, },
envKey: envKey, onSetEnv: { envKey, isPrimary in
isPrimary: isPrimary) self.envEditor = EnvEditorState(
}) skillKey: skill.skillKey,
} skillName: skill.name,
if !self.model.skills.isEmpty, self.filteredSkills.isEmpty { envKey: envKey,
Text("No skills match this filter.") isPrimary: isPrimary)
.font(.callout) })
.foregroundStyle(.secondary) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.frame(maxWidth: .infinity, alignment: .leading) }
.padding(.top, 4) if !self.model.skills.isEmpty, self.filteredSkills.isEmpty {
Text("No skills match this filter.")
.font(.callout)
.foregroundStyle(.secondary)
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
}
} }
.listStyle(.inset)
.searchable(text: self.$searchQuery, placement: .automatic, prompt: "Search skills")
} }
} }
private var filterBar: some View { private var filterBar: some View {
VStack(alignment: .leading, spacing: 10) { HStack(spacing: 10) {
TextField("Search skills", text: self.$searchQuery) Text("Filter")
.textFieldStyle(.roundedBorder) .font(.caption)
.frame(maxWidth: 320) .foregroundStyle(.secondary)
Picker("Filter", selection: self.$filter) { Picker("", selection: self.$filter) {
ForEach(SkillsFilter.allCases) { filter in ForEach(SkillsFilter.allCases) { filter in
Text(filter.title) Text(filter.title)
.tag(filter) .tag(filter)
} }
} }
.pickerStyle(.segmented) .labelsHidden()
.frame(maxWidth: 420) .pickerStyle(.menu)
.frame(width: 160, alignment: .leading)
Spacer(minLength: 0)
} }
} }
@@ -166,45 +180,44 @@ private struct SkillRow: View {
private var missingConfig: [String] { self.skill.missing.config } private var missingConfig: [String] { self.skill.missing.config }
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 10) { HStack(alignment: .top, spacing: 12) {
HStack(alignment: .top, spacing: 12) { Text(self.skill.emoji ?? "")
Text(self.skill.emoji ?? "") .font(.title2)
.font(.title2)
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) { HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(self.skill.name) Text(self.skill.name)
.font(.headline) .font(.headline)
self.statusBadge self.statusBadge
}
Text(self.skill.description)
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
self.metaRow
} }
Spacer() Text(self.skill.description)
} .font(.subheadline)
if self.skill.disabled {
Text("Disabled in config")
.font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else if !self.skill.eligible { .fixedSize(horizontal: false, vertical: true)
self.missingSummary self.metaRow
if self.skill.disabled {
Text("Disabled in config")
.font(.caption)
.foregroundStyle(.secondary)
} else if !self.skill.eligible {
self.missingSummary
}
if !self.skill.configChecks.isEmpty {
self.configChecksView
}
if !self.missingEnv.isEmpty {
self.envActionRow
}
} }
if !self.skill.configChecks.isEmpty { Spacer(minLength: 0)
self.configChecksView
}
self.actionRow self.trailingActions
} }
.padding(12) .padding(.vertical, 6)
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 10))
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(Color.secondary.opacity(0.15), lineWidth: 1))
} }
private var sourceLabel: String { private var sourceLabel: String {
@@ -248,23 +261,10 @@ private struct SkillRow: View {
} }
.buttonStyle(.link) .buttonStyle(.link)
} }
HStack(spacing: 6) {
Text(self.enabledLabel)
.font(.caption)
.foregroundStyle(.secondary)
Toggle("", isOn: self.enabledBinding)
.toggleStyle(.switch)
.labelsHidden()
.disabled(self.isBusy)
}
Spacer(minLength: 0) Spacer(minLength: 0)
} }
} }
private var enabledLabel: String {
self.skill.disabled ? "Disabled" : "Enabled"
}
private var homepageUrl: URL? { private var homepageUrl: URL? {
guard let raw = self.skill.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else { guard let raw = self.skill.homepage?.trimmingCharacters(in: .whitespacesAndNewlines) else {
return nil return nil
@@ -317,14 +317,8 @@ private struct SkillRow: View {
} }
} }
private var actionRow: some View { private var envActionRow: some View {
HStack(spacing: 8) { HStack(spacing: 8) {
ForEach(self.installOptions) { option in
Button(option.label) { self.onInstall(option) }
.buttonStyle(.borderedProminent)
.disabled(self.isBusy)
}
ForEach(self.missingEnv, id: \.self) { envKey in ForEach(self.missingEnv, id: \.self) { envKey in
let isPrimary = envKey == self.skill.primaryEnv let isPrimary = envKey == self.skill.primaryEnv
Button(isPrimary ? "Set API Key" : "Set \(envKey)") { Button(isPrimary ? "Set API Key" : "Set \(envKey)") {
@@ -333,11 +327,33 @@ private struct SkillRow: View {
.buttonStyle(.bordered) .buttonStyle(.bordered)
.disabled(self.isBusy) .disabled(self.isBusy)
} }
Spacer(minLength: 0) Spacer(minLength: 0)
} }
} }
@ViewBuilder
private var trailingActions: some View {
VStack(alignment: .trailing, spacing: 8) {
if !self.installOptions.isEmpty {
ForEach(self.installOptions) { option in
Button(option.label) { self.onInstall(option) }
.buttonStyle(.borderedProminent)
.disabled(self.isBusy)
}
} else {
Toggle("", isOn: self.enabledBinding)
.toggleStyle(.switch)
.labelsHidden()
.disabled(self.isBusy)
}
if self.isBusy {
ProgressView()
.controlSize(.small)
}
}
}
private var installOptions: [SkillInstallOption] { private var installOptions: [SkillInstallOption] {
guard !self.missingBins.isEmpty else { return [] } guard !self.missingBins.isEmpty else { return [] }
let missing = Set(self.missingBins) let missing = Set(self.missingBins)