Files
oai-swift/oAI/Views/Screens/SkillsView.swift
T
runeandClaude Sonnet 5 bd686873c4 Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:33:53 +02:00

430 lines
17 KiB
Swift

//
// ShortcutsView.swift
// oAI
//
// Modal for managing user-defined shortcuts (opened via /shortcuts command)
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
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() }