432 lines
18 KiB
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() }
|