// // 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 . 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() }