Files
oai-swift/oAI/Views/Screens/SkillsView.swift

432 lines
18 KiB
Swift

//
// ShortcutsView.swift
// oAI
//
// Modal for managing user-defined shortcuts (opened via /shortcuts command)
//
// SPDX-License-Identifier: AGPL-3.0-or-later
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// oAI is distributed in the hope that it will be useful, but WITHOUT
// ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
// or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General
// Public License for more details.
//
// You should have received a copy of the GNU Affero General Public
// License along with oAI. If not, see <https://www.gnu.org/licenses/>.
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() }