Added skills, shortcuts, and bugifixes++
This commit is contained in:
413
oAI/Views/Screens/SkillsView.swift
Normal file
413
oAI/Views/Screens/SkillsView.swift
Normal file
@@ -0,0 +1,413 @@
|
||||
//
|
||||
// ShortcutsView.swift
|
||||
// oAI
|
||||
//
|
||||
// Modal for managing user-defined shortcuts (opened via /shortcuts command)
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct ShortcutsView: View {
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@Bindable private var settings = SettingsService.shared
|
||||
|
||||
@State private var showEditor = false
|
||||
@State private var editingShortcut: Shortcut? = nil
|
||||
@State private var importConflicts: [ShortcutConflict] = []
|
||||
@State private var showConflictAlert = false
|
||||
@State private var pendingImport: [Shortcut] = []
|
||||
@State private var statusMessage: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Header
|
||||
HStack {
|
||||
Label("Your Shortcuts", systemImage: "bolt.fill")
|
||||
.font(.system(size: 18, weight: .bold))
|
||||
Spacer()
|
||||
Button { dismiss() } label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.keyboardShortcut(.escape, modifiers: [])
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.top, 20)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
// Toolbar
|
||||
HStack(spacing: 10) {
|
||||
Button { editingShortcut = nil; showEditor = true } label: {
|
||||
Label("New Shortcut", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button { importShortcuts() } label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button { exportAll() } label: {
|
||||
Label("Export All", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(settings.userShortcuts.isEmpty)
|
||||
|
||||
Spacer()
|
||||
|
||||
if let msg = statusMessage {
|
||||
Text(msg).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.bottom, 12)
|
||||
|
||||
Divider()
|
||||
|
||||
if settings.userShortcuts.isEmpty {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "bolt.slash")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No shortcuts yet")
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Create a shortcut to save a reusable prompt template accessible from the / command dropdown.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 340)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
} else {
|
||||
List {
|
||||
ForEach(settings.userShortcuts) { shortcut in
|
||||
ShortcutRow(
|
||||
shortcut: shortcut,
|
||||
onEdit: { editingShortcut = shortcut; showEditor = true },
|
||||
onExport: { exportOne(shortcut) },
|
||||
onDelete: { settings.deleteShortcut(id: shortcut.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset(alternatesRowBackgrounds: true))
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "lightbulb")
|
||||
.foregroundStyle(.yellow)
|
||||
.font(.callout)
|
||||
Text("Use **{{input}}** in the template to insert whatever you type after the command.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button("Done") { dismiss() }
|
||||
.buttonStyle(.borderedProminent)
|
||||
.keyboardShortcut(.return, modifiers: [])
|
||||
}
|
||||
.padding(.horizontal, 24)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(minWidth: 560, idealWidth: 640, minHeight: 440, idealHeight: 560)
|
||||
.sheet(isPresented: $showEditor) {
|
||||
ShortcutEditorSheet(shortcut: editingShortcut) { saved in
|
||||
if editingShortcut != nil { settings.updateShortcut(saved) }
|
||||
else { settings.addShortcut(saved) }
|
||||
editingShortcut = nil
|
||||
}
|
||||
}
|
||||
.alert("Duplicate Shortcut", isPresented: $showConflictAlert, presenting: importConflicts.first) { c in
|
||||
Button("Replace") { resolveConflict(c, action: .replace) }
|
||||
Button("Keep Both") { resolveConflict(c, action: .keepBoth) }
|
||||
Button("Skip") { resolveConflict(c, action: .skip) }
|
||||
} message: { c in
|
||||
Text("A shortcut with command \(c.incoming.command) already exists.")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Import / Export
|
||||
|
||||
private func importShortcuts() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = true
|
||||
panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false
|
||||
panel.allowedContentTypes = [.json]
|
||||
panel.message = "Select shortcut JSON files to import"
|
||||
guard panel.runModal() == .OK else { return }
|
||||
|
||||
var incoming: [Shortcut] = []
|
||||
for url in panel.urls {
|
||||
guard let data = try? Data(contentsOf: url) else { continue }
|
||||
if let pack = try? JSONDecoder().decode([Shortcut].self, from: data) {
|
||||
incoming.append(contentsOf: pack)
|
||||
} else if let single = try? JSONDecoder().decode(Shortcut.self, from: data) {
|
||||
incoming.append(single)
|
||||
}
|
||||
}
|
||||
guard !incoming.isEmpty else { show("No valid shortcuts found"); return }
|
||||
pendingImport = incoming
|
||||
processNext()
|
||||
}
|
||||
|
||||
private func processNext() {
|
||||
guard !pendingImport.isEmpty else { importConflicts = []; return }
|
||||
let item = pendingImport.removeFirst()
|
||||
if settings.userShortcuts.first(where: { $0.command == item.command }) != nil {
|
||||
importConflicts.append(ShortcutConflict(incoming: item))
|
||||
showConflictAlert = true
|
||||
} else {
|
||||
settings.addShortcut(item)
|
||||
show("Imported \(item.command)")
|
||||
processNext()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveConflict(_ c: ShortcutConflict, action: ConflictAction) {
|
||||
importConflicts.removeFirst()
|
||||
switch action {
|
||||
case .replace:
|
||||
if let ex = settings.userShortcuts.first(where: { $0.command == c.incoming.command }) {
|
||||
var u = c.incoming; u.id = ex.id; settings.updateShortcut(u)
|
||||
}
|
||||
case .keepBoth:
|
||||
var copy = c.incoming; copy.id = UUID(); copy.command += "-copy"; settings.addShortcut(copy)
|
||||
case .skip: break
|
||||
}
|
||||
processNext()
|
||||
}
|
||||
|
||||
private func exportAll() {
|
||||
let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601
|
||||
enc.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
guard let data = try? enc.encode(settings.userShortcuts) else { return }
|
||||
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
|
||||
.appendingPathComponent("shortcuts.json")
|
||||
try? data.write(to: url)
|
||||
show("Exported to Downloads/shortcuts.json")
|
||||
}
|
||||
|
||||
private func exportOne(_ shortcut: Shortcut) {
|
||||
let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601
|
||||
enc.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
guard let data = try? enc.encode(shortcut) else { return }
|
||||
let filename = String(shortcut.command.dropFirst()) + ".json"
|
||||
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
|
||||
.appendingPathComponent(filename)
|
||||
try? data.write(to: url)
|
||||
show("Exported \(filename)")
|
||||
}
|
||||
|
||||
private func show(_ text: String) {
|
||||
statusMessage = text
|
||||
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
struct ShortcutConflict { let incoming: Shortcut }
|
||||
enum ConflictAction { case replace, keepBoth, skip }
|
||||
}
|
||||
|
||||
// MARK: - Shortcut Row
|
||||
|
||||
private struct ShortcutRow: View {
|
||||
let shortcut: Shortcut
|
||||
let onEdit: () -> Void
|
||||
let onExport: () -> Void
|
||||
let onDelete: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text(shortcut.command)
|
||||
.font(.system(size: 13, weight: .semibold, design: .monospaced))
|
||||
Text(shortcut.description)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
Spacer()
|
||||
if shortcut.needsInput {
|
||||
Label("needs input", systemImage: "text.cursor")
|
||||
.font(.caption2).foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.background(.secondary.opacity(0.12), in: Capsule())
|
||||
} else {
|
||||
Label("immediate", systemImage: "bolt")
|
||||
.font(.caption2).foregroundStyle(.orange)
|
||||
.padding(.horizontal, 6).padding(.vertical, 3)
|
||||
.background(.orange.opacity(0.12), in: Capsule())
|
||||
}
|
||||
Button(action: onEdit) { Text("Edit").font(.caption) }
|
||||
.buttonStyle(.bordered).controlSize(.small)
|
||||
Button(action: onExport) { Image(systemName: "square.and.arrow.up") }
|
||||
.buttonStyle(.bordered).controlSize(.small).help("Export shortcut")
|
||||
Button(role: .destructive, action: onDelete) { Image(systemName: "trash") }
|
||||
.buttonStyle(.bordered).controlSize(.small).help("Delete shortcut")
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shortcuts Tab Content (embedded in SettingsView)
|
||||
|
||||
struct ShortcutsTabContent: View {
|
||||
@Bindable private var settings = SettingsService.shared
|
||||
|
||||
@State private var showEditor = false
|
||||
@State private var editingShortcut: Shortcut? = nil
|
||||
@State private var importConflicts: [ShortcutsView.ShortcutConflict] = []
|
||||
@State private var showConflictAlert = false
|
||||
@State private var pendingImport: [Shortcut] = []
|
||||
@State private var statusMessage: String? = nil
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
HStack(spacing: 10) {
|
||||
Button { editingShortcut = nil; showEditor = true } label: {
|
||||
Label("New Shortcut", systemImage: "plus")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button { importShortcuts() } label: {
|
||||
Label("Import", systemImage: "square.and.arrow.down")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
|
||||
Button { exportAll() } label: {
|
||||
Label("Export All", systemImage: "square.and.arrow.up")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.disabled(settings.userShortcuts.isEmpty)
|
||||
|
||||
Spacer()
|
||||
if let msg = statusMessage {
|
||||
Text(msg).font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
if settings.userShortcuts.isEmpty {
|
||||
HStack {
|
||||
Spacer()
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "bolt.slash")
|
||||
.font(.system(size: 32)).foregroundStyle(.tertiary)
|
||||
Text("No shortcuts yet — click New Shortcut to create one.")
|
||||
.font(.callout).foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 32)
|
||||
} else {
|
||||
VStack(spacing: 0) {
|
||||
ForEach(Array(settings.userShortcuts.enumerated()), id: \.element.id) { idx, shortcut in
|
||||
ShortcutRow(
|
||||
shortcut: shortcut,
|
||||
onEdit: { editingShortcut = shortcut; showEditor = true },
|
||||
onExport: { exportOne(shortcut) },
|
||||
onDelete: { settings.deleteShortcut(id: shortcut.id) }
|
||||
)
|
||||
if idx < settings.userShortcuts.count - 1 { Divider() }
|
||||
}
|
||||
}
|
||||
.background(.background, in: RoundedRectangle(cornerRadius: 10))
|
||||
.overlay(RoundedRectangle(cornerRadius: 10).stroke(.quaternary))
|
||||
}
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "lightbulb").foregroundStyle(.yellow).font(.callout)
|
||||
Text("Use **{{input}}** in the template to insert whatever you type after the command.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(.blue.opacity(0.06), in: RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
.sheet(isPresented: $showEditor) {
|
||||
ShortcutEditorSheet(shortcut: editingShortcut) { saved in
|
||||
if editingShortcut != nil { settings.updateShortcut(saved) }
|
||||
else { settings.addShortcut(saved) }
|
||||
editingShortcut = nil
|
||||
}
|
||||
}
|
||||
.alert("Duplicate Shortcut", isPresented: $showConflictAlert, presenting: importConflicts.first) { c in
|
||||
Button("Replace") { resolveConflict(c, action: .replace) }
|
||||
Button("Keep Both") { resolveConflict(c, action: .keepBoth) }
|
||||
Button("Skip") { resolveConflict(c, action: .skip) }
|
||||
} message: { c in
|
||||
Text("A shortcut with command \(c.incoming.command) already exists.")
|
||||
}
|
||||
}
|
||||
|
||||
private func importShortcuts() {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = true; panel.canChooseFiles = true
|
||||
panel.canChooseDirectories = false; panel.allowedContentTypes = [.json]
|
||||
panel.message = "Select shortcut JSON files to import"
|
||||
guard panel.runModal() == .OK else { return }
|
||||
var incoming: [Shortcut] = []
|
||||
for url in panel.urls {
|
||||
guard let data = try? Data(contentsOf: url) else { continue }
|
||||
if let pack = try? JSONDecoder().decode([Shortcut].self, from: data) { incoming.append(contentsOf: pack) }
|
||||
else if let single = try? JSONDecoder().decode(Shortcut.self, from: data) { incoming.append(single) }
|
||||
}
|
||||
guard !incoming.isEmpty else { show("No valid shortcuts found"); return }
|
||||
pendingImport = incoming; processNext()
|
||||
}
|
||||
|
||||
private func processNext() {
|
||||
guard !pendingImport.isEmpty else { importConflicts = []; return }
|
||||
let item = pendingImport.removeFirst()
|
||||
if settings.userShortcuts.first(where: { $0.command == item.command }) != nil {
|
||||
importConflicts.append(ShortcutsView.ShortcutConflict(incoming: item)); showConflictAlert = true
|
||||
} else {
|
||||
settings.addShortcut(item); show("Imported \(item.command)"); processNext()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveConflict(_ c: ShortcutsView.ShortcutConflict, action: ShortcutsView.ConflictAction) {
|
||||
importConflicts.removeFirst()
|
||||
switch action {
|
||||
case .replace:
|
||||
if let ex = settings.userShortcuts.first(where: { $0.command == c.incoming.command }) {
|
||||
var u = c.incoming; u.id = ex.id; settings.updateShortcut(u)
|
||||
}
|
||||
case .keepBoth:
|
||||
var copy = c.incoming; copy.id = UUID(); copy.command += "-copy"; settings.addShortcut(copy)
|
||||
case .skip: break
|
||||
}
|
||||
processNext()
|
||||
}
|
||||
|
||||
private func exportAll() {
|
||||
let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601
|
||||
enc.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
guard let data = try? enc.encode(settings.userShortcuts) else { return }
|
||||
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
|
||||
.appendingPathComponent("shortcuts.json")
|
||||
try? data.write(to: url); show("Exported to Downloads/shortcuts.json")
|
||||
}
|
||||
|
||||
private func exportOne(_ shortcut: Shortcut) {
|
||||
let enc = JSONEncoder(); enc.dateEncodingStrategy = .iso8601
|
||||
enc.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
guard let data = try? enc.encode(shortcut) else { return }
|
||||
let filename = String(shortcut.command.dropFirst()) + ".json"
|
||||
let url = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first!
|
||||
.appendingPathComponent(filename)
|
||||
try? data.write(to: url); show("Exported \(filename)")
|
||||
}
|
||||
|
||||
private func show(_ text: String) {
|
||||
statusMessage = text
|
||||
Task { try? await Task.sleep(for: .seconds(3)); statusMessage = nil }
|
||||
}
|
||||
}
|
||||
|
||||
#Preview { ShortcutsView() }
|
||||
Reference in New Issue
Block a user