// // ShortcutEditorSheet.swift // oAI // // Create or edit a user-defined shortcut (prompt template) // // 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 struct ShortcutEditorSheet: View { @Environment(\.dismiss) var dismiss let isNew: Bool let onSave: (Shortcut) -> Void @State private var shortcutID: UUID @State private var command: String @State private var description: String @State private var template: String @State private var createdAt: Date init(shortcut: Shortcut? = nil, onSave: @escaping (Shortcut) -> Void) { self.isNew = shortcut == nil self.onSave = onSave let s = shortcut ?? Shortcut(command: "/", description: "", template: "") _shortcutID = State(initialValue: s.id) _command = State(initialValue: s.command) _description = State(initialValue: s.description) _template = State(initialValue: s.template) _createdAt = State(initialValue: s.createdAt) } private var normalizedCommand: String { var c = command.lowercased().trimmingCharacters(in: .whitespaces) if !c.hasPrefix("/") { c = "/" + c } c = c.components(separatedBy: .whitespaces).joined() return c } private var isValid: Bool { normalizedCommand.count > 1 && !description.trimmingCharacters(in: .whitespaces).isEmpty && !template.trimmingCharacters(in: .whitespaces).isEmpty } var body: some View { VStack(spacing: 0) { HStack { Text(isNew ? "New Shortcut" : "Edit Shortcut") .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, 16) Divider() ScrollView { VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 6) { Text("Command").font(.system(size: 13, weight: .semibold)) TextField("/command", text: $command) .textFieldStyle(.roundedBorder) .font(.system(size: 13, design: .monospaced)) .onChange(of: command) { if !command.isEmpty && !command.hasPrefix("/") { command = "/" + command } } Text("Lowercase letters, numbers, and hyphens only. No spaces.") .font(.caption).foregroundStyle(.secondary) } VStack(alignment: .leading, spacing: 6) { Text("Description").font(.system(size: 13, weight: .semibold)) TextField("Brief description shown in the command dropdown", text: $description) .textFieldStyle(.roundedBorder).font(.system(size: 13)) } VStack(alignment: .leading, spacing: 6) { Text("Template").font(.system(size: 13, weight: .semibold)) TextEditor(text: $template) .font(.system(size: 13, design: .monospaced)) .frame(minHeight: 120) .scrollContentBackground(.hidden) .padding(6) .background(Color(nsColor: .textBackgroundColor)) .cornerRadius(6) .overlay(RoundedRectangle(cornerRadius: 6) .stroke(Color(nsColor: .separatorColor), lineWidth: 1)) HStack(alignment: .top, spacing: 8) { Image(systemName: "info.circle").foregroundStyle(.blue).font(.callout) VStack(alignment: .leading, spacing: 4) { Text("Use **{{input}}** to insert whatever you type after the command.") .font(.caption) if template.contains("{{input}}") { Label("Needs input — user types after the command", systemImage: "checkmark.circle.fill") .font(.caption).foregroundStyle(.green) } else { Label("Executes immediately — no extra input needed", systemImage: "bolt.fill") .font(.caption).foregroundStyle(.orange) } } } .padding(10) .background(.blue.opacity(0.06), in: RoundedRectangle(cornerRadius: 8)) } } .padding(.horizontal, 24).padding(.vertical, 16) } Divider() HStack { Button("Cancel") { dismiss() }.buttonStyle(.bordered) Spacer() Button("Save") { let shortcut = Shortcut( id: shortcutID, command: normalizedCommand, description: description.trimmingCharacters(in: .whitespaces), template: template, createdAt: createdAt, updatedAt: Date() ) onSave(shortcut) dismiss() } .buttonStyle(.borderedProminent) .disabled(!isValid) .keyboardShortcut(.return, modifiers: [.command]) } .padding(.horizontal, 24).padding(.vertical, 12) } .frame(minWidth: 480, idealWidth: 540, minHeight: 460, idealHeight: 520) } } #Preview { ShortcutEditorSheet { _ in } }