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,18 +9,13 @@ 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
EnvEditorView(editor: editor) { value in EnvEditorView(editor: editor) { value 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,8 +67,13 @@ struct SkillsSettings: View {
} }
} }
@ViewBuilder
private var skillsList: some View { private var skillsList: some View {
VStack(spacing: 10) { if self.model.skills.isEmpty {
Text("No skills reported yet.")
.foregroundStyle(.secondary)
} else {
List {
ForEach(self.filteredSkills) { skill in ForEach(self.filteredSkills) { skill in
SkillRow( SkillRow(
skill: skill, skill: skill,
@@ -82,30 +91,35 @@ struct SkillsSettings: View {
envKey: envKey, envKey: envKey,
isPrimary: isPrimary) isPrimary: isPrimary)
}) })
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
} }
if !self.model.skills.isEmpty, self.filteredSkills.isEmpty { if !self.model.skills.isEmpty, self.filteredSkills.isEmpty {
Text("No skills match this filter.") Text("No skills match this filter.")
.font(.callout) .font(.callout)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.padding(.top, 4)
} }
} }
.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,10 +180,10 @@ 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)
@@ -181,9 +195,6 @@ private struct SkillRow: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
self.metaRow self.metaRow
}
Spacer()
}
if self.skill.disabled { if self.skill.disabled {
Text("Disabled in config") Text("Disabled in config")
@@ -197,14 +208,16 @@ private struct SkillRow: View {
self.configChecksView self.configChecksView
} }
self.actionRow if !self.missingEnv.isEmpty {
self.envActionRow
} }
.padding(12) }
.background(Color(nsColor: .controlBackgroundColor))
.clipShape(RoundedRectangle(cornerRadius: 10)) Spacer(minLength: 0)
.overlay(
RoundedRectangle(cornerRadius: 10) self.trailingActions
.stroke(Color.secondary.opacity(0.15), lineWidth: 1)) }
.padding(.vertical, 6)
} }
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)