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