feat(macos): choose skill install target
This commit is contained in:
@@ -41,7 +41,7 @@ struct SettingsRootView: View {
|
|||||||
.tabItem { Label("Cron", systemImage: "calendar") }
|
.tabItem { Label("Cron", systemImage: "calendar") }
|
||||||
.tag(SettingsTab.cron)
|
.tag(SettingsTab.cron)
|
||||||
|
|
||||||
SkillsSettings()
|
SkillsSettings(state: self.state)
|
||||||
.tabItem { Label("Skills", systemImage: "sparkles") }
|
.tabItem { Label("Skills", systemImage: "sparkles") }
|
||||||
.tag(SettingsTab.skills)
|
.tag(SettingsTab.skills)
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,16 @@ import Observation
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SkillsSettings: View {
|
struct SkillsSettings: View {
|
||||||
|
@Bindable var state: AppState
|
||||||
@State private var model = SkillsSettingsModel()
|
@State private var model = SkillsSettingsModel()
|
||||||
@State private var envEditor: EnvEditorState?
|
@State private var envEditor: EnvEditorState?
|
||||||
@State private var searchQuery = ""
|
@State private var searchQuery = ""
|
||||||
@State private var filter: SkillsFilter = .all
|
@State private var filter: SkillsFilter = .all
|
||||||
|
|
||||||
|
init(state: AppState = AppStateStore.shared) {
|
||||||
|
self.state = state
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
self.header
|
self.header
|
||||||
@@ -78,11 +83,13 @@ struct SkillsSettings: View {
|
|||||||
SkillRow(
|
SkillRow(
|
||||||
skill: skill,
|
skill: skill,
|
||||||
isBusy: self.model.isBusy(skill: skill),
|
isBusy: self.model.isBusy(skill: skill),
|
||||||
|
canInstallLocally: self.state.connectionMode == .local,
|
||||||
|
defaultInstallTarget: self.defaultInstallTarget(for: skill),
|
||||||
onToggleEnabled: { enabled in
|
onToggleEnabled: { enabled in
|
||||||
Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) }
|
Task { await self.model.setEnabled(skillKey: skill.skillKey, enabled: enabled) }
|
||||||
},
|
},
|
||||||
onInstall: { option in
|
onInstall: { option, target in
|
||||||
Task { await self.model.install(skill: skill, option: option) }
|
Task { await self.model.install(skill: skill, option: option, target: target) }
|
||||||
},
|
},
|
||||||
onSetEnv: { envKey, isPrimary in
|
onSetEnv: { envKey, isPrimary in
|
||||||
self.envEditor = EnvEditorState(
|
self.envEditor = EnvEditorState(
|
||||||
@@ -136,6 +143,11 @@ struct SkillsSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func defaultInstallTarget(for skill: SkillStatus) -> InstallTarget {
|
||||||
|
let localPreferred = ["imsg", "peekaboo", "spotify-player"]
|
||||||
|
return localPreferred.contains(skill.skillKey) ? .local : .gateway
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum SkillsFilter: String, CaseIterable, Identifiable {
|
private enum SkillsFilter: String, CaseIterable, Identifiable {
|
||||||
@@ -160,17 +172,45 @@ private enum SkillsFilter: String, CaseIterable, Identifiable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum InstallTarget: String, CaseIterable {
|
||||||
|
case gateway
|
||||||
|
case local
|
||||||
|
}
|
||||||
|
|
||||||
private struct SkillRow: View {
|
private struct SkillRow: View {
|
||||||
let skill: SkillStatus
|
let skill: SkillStatus
|
||||||
let isBusy: Bool
|
let isBusy: Bool
|
||||||
|
let canInstallLocally: Bool
|
||||||
let onToggleEnabled: (Bool) -> Void
|
let onToggleEnabled: (Bool) -> Void
|
||||||
let onInstall: (SkillInstallOption) -> Void
|
let onInstall: (SkillInstallOption, InstallTarget) -> Void
|
||||||
let onSetEnv: (String, Bool) -> Void
|
let onSetEnv: (String, Bool) -> Void
|
||||||
|
@State private var installTarget: InstallTarget
|
||||||
|
|
||||||
private var missingBins: [String] { self.skill.missing.bins }
|
private var missingBins: [String] { self.skill.missing.bins }
|
||||||
private var missingEnv: [String] { self.skill.missing.env }
|
private var missingEnv: [String] { self.skill.missing.env }
|
||||||
private var missingConfig: [String] { self.skill.missing.config }
|
private var missingConfig: [String] { self.skill.missing.config }
|
||||||
|
|
||||||
|
init(
|
||||||
|
skill: SkillStatus,
|
||||||
|
isBusy: Bool,
|
||||||
|
canInstallLocally: Bool,
|
||||||
|
defaultInstallTarget: InstallTarget,
|
||||||
|
onToggleEnabled: @escaping (Bool) -> Void,
|
||||||
|
onInstall: @escaping (SkillInstallOption, InstallTarget) -> Void,
|
||||||
|
onSetEnv: @escaping (String, Bool) -> Void)
|
||||||
|
{
|
||||||
|
self.skill = skill
|
||||||
|
self.isBusy = isBusy
|
||||||
|
self.canInstallLocally = canInstallLocally
|
||||||
|
self.onToggleEnabled = onToggleEnabled
|
||||||
|
self.onInstall = onInstall
|
||||||
|
self.onSetEnv = onSetEnv
|
||||||
|
let initialTarget: InstallTarget = (defaultInstallTarget == .local && !canInstallLocally)
|
||||||
|
? .gateway
|
||||||
|
: defaultInstallTarget
|
||||||
|
self._installTarget = State(initialValue: initialTarget)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Text(self.skill.emoji ?? "✨")
|
Text(self.skill.emoji ?? "✨")
|
||||||
@@ -308,10 +348,28 @@ private struct SkillRow: View {
|
|||||||
private var trailingActions: some View {
|
private var trailingActions: some View {
|
||||||
VStack(alignment: .trailing, spacing: 8) {
|
VStack(alignment: .trailing, spacing: 8) {
|
||||||
if !self.installOptions.isEmpty {
|
if !self.installOptions.isEmpty {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Install on")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Picker("Install on", selection: self.$installTarget) {
|
||||||
|
Text("Gateway")
|
||||||
|
.tag(InstallTarget.gateway)
|
||||||
|
Text("This Mac")
|
||||||
|
.tag(InstallTarget.local)
|
||||||
|
.disabled(!self.canInstallLocally)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.segmented)
|
||||||
|
.frame(width: 160)
|
||||||
|
.controlSize(.small)
|
||||||
|
.help(self.canInstallLocally ? "" : "Local install requires a local gateway connection.")
|
||||||
|
}
|
||||||
ForEach(self.installOptions) { option in
|
ForEach(self.installOptions) { option in
|
||||||
Button("Install") { self.onInstall(option) }
|
Button("Install") { self.onInstall(option, self.installTarget) }
|
||||||
.buttonStyle(.borderedProminent)
|
.buttonStyle(.borderedProminent)
|
||||||
.disabled(self.isBusy)
|
.disabled(self.isBusy || self.installBlocked)
|
||||||
|
.help(self.installBlocked ? "Local install requires a local gateway connection." : "")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Toggle("", isOn: self.enabledBinding)
|
Toggle("", isOn: self.enabledBinding)
|
||||||
@@ -350,6 +408,10 @@ private struct SkillRow: View {
|
|||||||
!self.missingConfig.isEmpty
|
!self.missingConfig.isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var installBlocked: Bool {
|
||||||
|
self.installTarget == .local && !self.canInstallLocally
|
||||||
|
}
|
||||||
|
|
||||||
private func formatConfigValue(_ value: AnyCodable?) -> String {
|
private func formatConfigValue(_ value: AnyCodable?) -> String {
|
||||||
guard let value else { return "" }
|
guard let value else { return "" }
|
||||||
switch value.value {
|
switch value.value {
|
||||||
@@ -455,9 +517,13 @@ final class SkillsSettingsModel {
|
|||||||
self.isLoading = false
|
self.isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
func install(skill: SkillStatus, option: SkillInstallOption) async {
|
func install(skill: SkillStatus, option: SkillInstallOption, target: InstallTarget) async {
|
||||||
await self.withBusy(skill.skillKey) {
|
await self.withBusy(skill.skillKey) {
|
||||||
do {
|
do {
|
||||||
|
if target == .local, AppStateStore.shared.connectionMode != .local {
|
||||||
|
self.statusMessage = "Local install requires a local gateway connection"
|
||||||
|
return
|
||||||
|
}
|
||||||
let result = try await GatewayConnection.shared.skillsInstall(
|
let result = try await GatewayConnection.shared.skillsInstall(
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
installId: option.id,
|
installId: option.id,
|
||||||
@@ -515,7 +581,7 @@ final class SkillsSettingsModel {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
struct SkillsSettings_Previews: PreviewProvider {
|
struct SkillsSettings_Previews: PreviewProvider {
|
||||||
static var previews: some View {
|
static var previews: some View {
|
||||||
SkillsSettings()
|
SkillsSettings(state: .preview)
|
||||||
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
.frame(width: SettingsTab.windowWidth, height: SettingsTab.windowHeight)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,7 +145,7 @@ struct SettingsViewSmokeTests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test func skillsSettingsBuildsBody() {
|
@Test func skillsSettingsBuildsBody() {
|
||||||
let view = SkillsSettings()
|
let view = SkillsSettings(state: .preview)
|
||||||
_ = view.body
|
_ = view.body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user