1959 lines
78 KiB
Swift
1959 lines
78 KiB
Swift
//
|
||
// SettingsView.swift
|
||
// oAI
|
||
//
|
||
// Settings and configuration screen
|
||
//
|
||
// 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 SettingsView: View {
|
||
@Environment(\.dismiss) var dismiss
|
||
@Bindable private var settingsService = SettingsService.shared
|
||
private var mcpService = MCPService.shared
|
||
private let gitSync = GitSyncService.shared
|
||
var chatViewModel: ChatViewModel?
|
||
|
||
init(chatViewModel: ChatViewModel? = nil) {
|
||
self.chatViewModel = chatViewModel
|
||
}
|
||
|
||
@State private var openrouterKey = ""
|
||
@State private var anthropicKey = ""
|
||
@State private var openaiKey = ""
|
||
@State private var googleKey = ""
|
||
@State private var googleEngineID = ""
|
||
@State private var showFolderPicker = false
|
||
@State private var selectedTab = 0
|
||
|
||
// Git Sync state
|
||
@State private var syncRepoURL = ""
|
||
@State private var syncLocalPath = "~/oAI-sync"
|
||
@State private var syncUsername = ""
|
||
@State private var syncPassword = ""
|
||
@State private var syncAccessToken = ""
|
||
@State private var showSyncPassword = false
|
||
@State private var showSyncToken = false
|
||
@State private var isTestingSync = false
|
||
@State private var syncTestResult: String?
|
||
@State private var isSyncing = false
|
||
|
||
// Email handler state
|
||
@State private var showEmailLog = false
|
||
@State private var showEmailModelSelector = false
|
||
@State private var emailHandlerSystemPrompt = ""
|
||
@State private var emailAvailableModels: [ModelInfo] = []
|
||
@State private var isLoadingEmailModels = false
|
||
@State private var showEmailPassword = false
|
||
@State private var isTestingEmailConnection = false
|
||
@State private var emailConnectionTestResult: String?
|
||
|
||
private let labelWidth: CGFloat = 160
|
||
|
||
// Default system prompt - generic for all models
|
||
private let defaultSystemPrompt = """
|
||
You are a helpful AI assistant. Follow these core principles:
|
||
|
||
## CORE BEHAVIOR
|
||
|
||
- **Accuracy First**: Never invent information. If unsure, say so clearly.
|
||
- **Ask for Clarification**: When ambiguous, ask questions before proceeding.
|
||
- **Be Direct**: Provide concise, relevant answers. No unnecessary preambles.
|
||
- **Show Your Work**: If you use capabilities (tools, web search, etc.), demonstrate what you did.
|
||
- **Complete Tasks Properly**: If you start something, finish it correctly.
|
||
|
||
## FORMATTING
|
||
|
||
Always use Markdown formatting:
|
||
- **Bold** for emphasis
|
||
- Code blocks with language tags: ```python
|
||
- Headings (##, ###) for structure
|
||
- Lists for organization
|
||
|
||
## HONESTY
|
||
|
||
It's better to admit "I need more information" or "I cannot do that" than to fake completion or invent answers.
|
||
"""
|
||
|
||
var body: some View {
|
||
VStack(spacing: 0) {
|
||
// Title
|
||
Text("Settings")
|
||
.font(.system(size: 22, weight: .bold))
|
||
.padding(.top, 20)
|
||
.padding(.bottom, 12)
|
||
|
||
// Tab picker
|
||
Picker("", selection: $selectedTab) {
|
||
Text("General").tag(0)
|
||
Text("MCP").tag(1)
|
||
Text("Appearance").tag(2)
|
||
Text("Advanced").tag(3)
|
||
Text("Sync").tag(4)
|
||
Text("Email").tag(5)
|
||
Text("Shortcuts").tag(6)
|
||
Text("Skills").tag(7)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.padding(.horizontal, 24)
|
||
.padding(.bottom, 12)
|
||
|
||
Divider()
|
||
|
||
ScrollView {
|
||
VStack(alignment: .leading, spacing: 20) {
|
||
switch selectedTab {
|
||
case 0:
|
||
generalTab
|
||
case 1:
|
||
mcpTab
|
||
case 2:
|
||
appearanceTab
|
||
case 3:
|
||
advancedTab
|
||
case 4:
|
||
syncTab
|
||
case 5:
|
||
emailTab
|
||
case 6:
|
||
shortcutsTab
|
||
case 7:
|
||
agentSkillsTab
|
||
default:
|
||
generalTab
|
||
}
|
||
}
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 16)
|
||
}
|
||
|
||
Divider()
|
||
|
||
// Bottom bar
|
||
HStack {
|
||
Spacer()
|
||
Button("Done") { dismiss() }
|
||
.keyboardShortcut(.return, modifiers: [])
|
||
.buttonStyle(.borderedProminent)
|
||
.controlSize(.regular)
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 24)
|
||
.padding(.vertical, 12)
|
||
}
|
||
.frame(minWidth: 700, idealWidth: 800, minHeight: 600, idealHeight: 750)
|
||
.sheet(isPresented: $showEmailLog) {
|
||
EmailLogView()
|
||
}
|
||
}
|
||
|
||
// MARK: - General Tab
|
||
|
||
@ViewBuilder
|
||
private var generalTab: some View {
|
||
// Provider
|
||
sectionHeader("Provider")
|
||
row("Default Provider") {
|
||
Picker("", selection: $settingsService.defaultProvider) {
|
||
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
|
||
Text(provider.displayName).tag(provider)
|
||
}
|
||
}
|
||
.labelsHidden()
|
||
.fixedSize()
|
||
}
|
||
|
||
divider()
|
||
|
||
// API Keys
|
||
sectionHeader("API Keys")
|
||
row("OpenRouter") {
|
||
SecureField("sk-or-...", text: $openrouterKey)
|
||
.textFieldStyle(.roundedBorder)
|
||
.font(.system(size: 13))
|
||
.frame(width: 400)
|
||
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
|
||
.onChange(of: openrouterKey) {
|
||
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
|
||
ProviderRegistry.shared.clearCache()
|
||
}
|
||
}
|
||
// Anthropic: API key
|
||
row("Anthropic") {
|
||
SecureField("sk-ant-... (API key)", text: $anthropicKey)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onAppear { anthropicKey = settingsService.anthropicAPIKey ?? "" }
|
||
.onChange(of: anthropicKey) {
|
||
settingsService.anthropicAPIKey = anthropicKey.isEmpty ? nil : anthropicKey
|
||
ProviderRegistry.shared.clearCache()
|
||
}
|
||
}
|
||
row("OpenAI") {
|
||
SecureField("sk-...", text: $openaiKey)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 400)
|
||
.onAppear { openaiKey = settingsService.openaiAPIKey ?? "" }
|
||
.onChange(of: openaiKey) {
|
||
settingsService.openaiAPIKey = openaiKey.isEmpty ? nil : openaiKey
|
||
ProviderRegistry.shared.clearCache()
|
||
}
|
||
}
|
||
row("Ollama URL") {
|
||
TextField("http://localhost:11434", text: $settingsService.ollamaBaseURL)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 400)
|
||
.help("Enter your Ollama server URL to enable the Ollama provider")
|
||
}
|
||
|
||
divider()
|
||
|
||
// Features
|
||
sectionHeader("Features")
|
||
row("Online Mode (Web Search)") {
|
||
Toggle("", isOn: $settingsService.onlineMode)
|
||
.toggleStyle(.switch)
|
||
}
|
||
row("Conversation Memory") {
|
||
Toggle("", isOn: $settingsService.memoryEnabled)
|
||
.toggleStyle(.switch)
|
||
}
|
||
row("MCP (File Access)") {
|
||
Toggle("", isOn: $settingsService.mcpEnabled)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
divider()
|
||
|
||
// Web Search
|
||
sectionHeader("Web Search")
|
||
row("Search Provider") {
|
||
Picker("", selection: $settingsService.searchProvider) {
|
||
ForEach(Settings.SearchProvider.allCases, id: \.self) { provider in
|
||
Text(provider.displayName).tag(provider)
|
||
}
|
||
}
|
||
.labelsHidden()
|
||
.fixedSize()
|
||
}
|
||
if settingsService.searchProvider == .google {
|
||
row("Google API Key") {
|
||
SecureField("", text: $googleKey)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onAppear { googleKey = settingsService.googleAPIKey ?? "" }
|
||
.onChange(of: googleKey) {
|
||
settingsService.googleAPIKey = googleKey.isEmpty ? nil : googleKey
|
||
}
|
||
}
|
||
row("Search Engine ID") {
|
||
TextField("", text: $googleEngineID)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onAppear { googleEngineID = settingsService.googleSearchEngineID ?? "" }
|
||
.onChange(of: googleEngineID) {
|
||
settingsService.googleSearchEngineID = googleEngineID.isEmpty ? nil : googleEngineID
|
||
}
|
||
}
|
||
}
|
||
|
||
divider()
|
||
|
||
// Model Settings
|
||
sectionHeader("Model Settings")
|
||
row("Default Model ID") {
|
||
TextField("e.g. anthropic/claude-sonnet-4", text: Binding(
|
||
get: { settingsService.defaultModel ?? "" },
|
||
set: { settingsService.defaultModel = $0.isEmpty ? nil : $0 }
|
||
))
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
|
||
divider()
|
||
|
||
// Logging
|
||
sectionHeader("Logging")
|
||
row("Log Level") {
|
||
Picker("", selection: Binding(
|
||
get: { FileLogger.shared.minimumLevel },
|
||
set: { FileLogger.shared.minimumLevel = $0 }
|
||
)) {
|
||
ForEach(LogLevel.allCases, id: \.self) { level in
|
||
Text(level.displayName).tag(level)
|
||
}
|
||
}
|
||
.labelsHidden()
|
||
.fixedSize()
|
||
}
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
Text("Controls which messages are written to ~/Library/Logs/oAI.log")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
// MARK: - MCP Tab
|
||
|
||
@ViewBuilder
|
||
private var mcpTab: some View {
|
||
// Description header
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "folder.badge.gearshape")
|
||
.font(.title2)
|
||
.foregroundStyle(.blue)
|
||
Text("Model Context Protocol")
|
||
.font(.system(size: 18, weight: .semibold))
|
||
}
|
||
Text("MCP gives the AI controlled access to read and optionally write files on your computer. The AI can search, read, and analyze files in allowed folders to help with coding, analysis, and other tasks.")
|
||
.font(.system(size: 14))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.padding(.horizontal, 24)
|
||
.padding(.top, 12)
|
||
.padding(.bottom, 16)
|
||
|
||
divider()
|
||
|
||
// Enable toggle with status
|
||
sectionHeader("Status")
|
||
row("Enable MCP") {
|
||
Toggle("", isOn: $settingsService.mcpEnabled)
|
||
.toggleStyle(.switch)
|
||
}
|
||
HStack(spacing: 4) {
|
||
Image(systemName: settingsService.mcpEnabled ? "checkmark.circle.fill" : "circle")
|
||
.foregroundStyle(settingsService.mcpEnabled ? .green : .secondary)
|
||
.font(.system(size: 13))
|
||
Text(settingsService.mcpEnabled ? "Active - AI can access allowed folders" : "Disabled - No file access")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
if settingsService.mcpEnabled {
|
||
divider()
|
||
|
||
// Folders
|
||
sectionHeader("Allowed Folders")
|
||
|
||
if mcpService.allowedFolders.isEmpty {
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "folder.badge.plus")
|
||
.font(.system(size: 32))
|
||
.foregroundStyle(.tertiary)
|
||
Text("No folders added yet")
|
||
.font(.system(size: 14, weight: .medium))
|
||
.foregroundStyle(.secondary)
|
||
Text("Click 'Add Folder' below to grant AI access to a folder")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.tertiary)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 24)
|
||
.background(Color.gray.opacity(0.05))
|
||
.cornerRadius(8)
|
||
.padding(.horizontal, labelWidth + 24)
|
||
} else {
|
||
ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "folder.fill")
|
||
.foregroundStyle(.blue)
|
||
.frame(width: 20)
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text((folder as NSString).lastPathComponent)
|
||
.font(.body)
|
||
Text(abbreviatePath(folder))
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
Button {
|
||
withAnimation { _ = mcpService.removeFolder(at: index) }
|
||
} label: {
|
||
Image(systemName: "trash.fill")
|
||
.foregroundStyle(.red)
|
||
.font(.system(size: 13))
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
}
|
||
|
||
HStack(spacing: 4) {
|
||
Button {
|
||
showFolderPicker = true
|
||
} label: {
|
||
HStack(spacing: 4) {
|
||
Image(systemName: "plus")
|
||
Text("Add Folder...")
|
||
}
|
||
.font(.system(size: 14))
|
||
}
|
||
.buttonStyle(.borderless)
|
||
Spacer()
|
||
}
|
||
.fileImporter(
|
||
isPresented: $showFolderPicker,
|
||
allowedContentTypes: [.folder],
|
||
allowsMultipleSelection: false
|
||
) { result in
|
||
if case .success(let urls) = result, let url = urls.first {
|
||
if url.startAccessingSecurityScopedResource() {
|
||
withAnimation { _ = mcpService.addFolder(url.path) }
|
||
url.stopAccessingSecurityScopedResource()
|
||
}
|
||
}
|
||
}
|
||
|
||
divider()
|
||
|
||
// Permissions
|
||
sectionHeader("Permissions")
|
||
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "checkmark.circle.fill")
|
||
.foregroundStyle(.green)
|
||
.font(.system(size: 12))
|
||
Text("Read access (always enabled)")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Text("The AI can read and search files in allowed folders")
|
||
.font(.system(size: 12))
|
||
.foregroundStyle(.tertiary)
|
||
.padding(.leading, 18)
|
||
}
|
||
.padding(.bottom, 12)
|
||
|
||
Text("Write Permissions (optional)")
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundStyle(.secondary)
|
||
.padding(.bottom, 8)
|
||
|
||
row("Write & Edit Files") {
|
||
Toggle("", isOn: $settingsService.mcpCanWriteFiles)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
row("Delete Files") {
|
||
Toggle("", isOn: $settingsService.mcpCanDeleteFiles)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
row("Create Directories") {
|
||
Toggle("", isOn: $settingsService.mcpCanCreateDirectories)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
row("Move & Copy Files") {
|
||
Toggle("", isOn: $settingsService.mcpCanMoveFiles)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
divider()
|
||
|
||
// Filtering
|
||
sectionHeader("Filtering")
|
||
row("Respect .gitignore") {
|
||
Toggle("", isOn: Binding(
|
||
get: { settingsService.mcpRespectGitignore },
|
||
set: { newValue in
|
||
settingsService.mcpRespectGitignore = newValue
|
||
mcpService.reloadGitignores()
|
||
}
|
||
))
|
||
.toggleStyle(.switch)
|
||
}
|
||
Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
// Anytype integration UI hidden (work in progress — see AnytypeMCPService.swift)
|
||
}
|
||
|
||
// MARK: - Appearance Tab
|
||
|
||
@ViewBuilder
|
||
private var appearanceTab: some View {
|
||
sectionHeader("Text Sizes")
|
||
row("GUI Text") {
|
||
HStack(spacing: 8) {
|
||
Slider(value: $settingsService.guiTextSize, in: 10...20, step: 1)
|
||
.frame(maxWidth: 200)
|
||
Text("\(Int(settingsService.guiTextSize)) pt")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 40)
|
||
}
|
||
}
|
||
row("Dialog Text") {
|
||
HStack(spacing: 8) {
|
||
Slider(value: $settingsService.dialogTextSize, in: 10...24, step: 1)
|
||
.frame(maxWidth: 200)
|
||
Text("\(Int(settingsService.dialogTextSize)) pt")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 40)
|
||
}
|
||
}
|
||
row("Input Text") {
|
||
HStack(spacing: 8) {
|
||
Slider(value: $settingsService.inputTextSize, in: 10...24, step: 1)
|
||
.frame(maxWidth: 200)
|
||
Text("\(Int(settingsService.inputTextSize)) pt")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 40)
|
||
}
|
||
}
|
||
|
||
sectionHeader("Toolbar")
|
||
row("Icon Size") {
|
||
HStack(spacing: 8) {
|
||
Slider(value: $settingsService.toolbarIconSize, in: 16...32, step: 2)
|
||
.frame(maxWidth: 200)
|
||
Text("\(Int(settingsService.toolbarIconSize)) pt")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 40)
|
||
}
|
||
}
|
||
row("") {
|
||
Toggle("Show Icon Labels", isOn: $settingsService.showToolbarLabels)
|
||
.toggleStyle(.switch)
|
||
}
|
||
HStack {
|
||
Text("Show text labels below toolbar icons (helpful for new users)")
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, labelWidth + 20)
|
||
.padding(.bottom, 12)
|
||
}
|
||
|
||
// MARK: - Advanced Tab
|
||
|
||
@ViewBuilder
|
||
private var advancedTab: some View {
|
||
sectionHeader("Response Generation")
|
||
row("Enable Streaming") {
|
||
Toggle("", isOn: $settingsService.streamEnabled)
|
||
.toggleStyle(.switch)
|
||
}
|
||
Text("Stream responses as they're generated. Disable for single, complete responses.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
divider()
|
||
|
||
sectionHeader("Model Parameters")
|
||
|
||
// Max Tokens
|
||
row("Max Tokens") {
|
||
HStack(spacing: 8) {
|
||
Slider(value: Binding(
|
||
get: { Double(settingsService.maxTokens) },
|
||
set: { settingsService.maxTokens = Int($0) }
|
||
), in: 0...32000, step: 256)
|
||
.frame(maxWidth: 250)
|
||
Text(settingsService.maxTokens == 0 ? "Default" : "\(settingsService.maxTokens)")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 70, alignment: .leading)
|
||
}
|
||
}
|
||
Text("Maximum tokens in the response. Set to 0 to use model default. Higher values allow longer responses but cost more.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
// Temperature
|
||
row("Temperature") {
|
||
HStack(spacing: 8) {
|
||
Slider(value: $settingsService.temperature, in: 0...2, step: 0.1)
|
||
.frame(maxWidth: 250)
|
||
Text(settingsService.temperature == 0.0 ? "Default" : String(format: "%.1f", settingsService.temperature))
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 70, alignment: .leading)
|
||
}
|
||
}
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Controls randomness. Set to 0 to use model default.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
Text("• Lower (0.0-0.7): More focused, deterministic")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
Text("• Higher (0.8-2.0): More creative, random")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
divider()
|
||
|
||
sectionHeader("System Prompts")
|
||
|
||
// Default prompt (read-only)
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
HStack(spacing: 4) {
|
||
Text("Default Prompt")
|
||
.font(.system(size: 14))
|
||
.fontWeight(.medium)
|
||
Text("(always used)")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
HStack(alignment: .top, spacing: 0) {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
ScrollView {
|
||
Text(defaultSystemPrompt)
|
||
.font(.system(size: 13, design: .monospaced))
|
||
.foregroundStyle(.secondary)
|
||
.textSelection(.enabled)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
.padding(8)
|
||
}
|
||
.frame(height: 160)
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(6)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||
)
|
||
}
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
Text("This default prompt is always included to ensure accurate, helpful responses.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
.padding(.bottom, 8)
|
||
|
||
// Custom prompt mode toggle
|
||
HStack {
|
||
Text("Use Only Your Prompt")
|
||
.frame(width: labelWidth, alignment: .trailing)
|
||
Toggle("", isOn: Binding(
|
||
get: { settingsService.customPromptMode == .replace },
|
||
set: { settingsService.customPromptMode = $0 ? .replace : .append }
|
||
))
|
||
.toggleStyle(.switch)
|
||
.labelsHidden()
|
||
|
||
Text(settingsService.customPromptMode == .replace ? "BYOP Mode" : "Default + Custom")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .secondary)
|
||
.frame(width: 140, alignment: .leading)
|
||
|
||
Spacer()
|
||
}
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
Text(settingsService.customPromptMode == .replace
|
||
? "⚠️ Only your custom prompt will be used. Default prompt and tool guidelines are disabled."
|
||
: "Your custom prompt will be added after the default prompt and tool guidelines.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.padding(.bottom, 8)
|
||
|
||
// Custom prompt (editable)
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
HStack(spacing: 4) {
|
||
Text(settingsService.customPromptMode == .replace
|
||
? "Now using only your prompt shown below"
|
||
: "Your Custom Prompt")
|
||
.font(.system(size: 14))
|
||
.fontWeight(.medium)
|
||
.foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .primary)
|
||
if settingsService.customPromptMode == .append {
|
||
Text("(optional)")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
HStack(alignment: .top, spacing: 0) {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
TextEditor(text: Binding(
|
||
get: { settingsService.systemPrompt ?? "" },
|
||
set: { settingsService.systemPrompt = $0.isEmpty ? nil : $0 }
|
||
))
|
||
.font(.system(size: 13, design: .monospaced))
|
||
.frame(height: 120)
|
||
.padding(8)
|
||
.background(Color(NSColor.textBackgroundColor))
|
||
.cornerRadius(6)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.stroke(Color.secondary.opacity(0.3), lineWidth: 1)
|
||
)
|
||
}
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
Text(settingsService.customPromptMode == .append
|
||
? "This will be added after the default prompt and tool-specific guidelines."
|
||
: "In BYOP mode, ONLY your custom prompt will be used. The default prompt and tool guidelines will be ignored.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(settingsService.customPromptMode == .replace ? .orange : .secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
divider()
|
||
|
||
sectionHeader("Memory & Context")
|
||
|
||
row("Smart Context Selection") {
|
||
Toggle("", isOn: $settingsService.contextSelectionEnabled)
|
||
.toggleStyle(.switch)
|
||
}
|
||
Text("Automatically select relevant messages instead of sending all history. Reduces token usage and improves response quality for long conversations.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
if settingsService.contextSelectionEnabled {
|
||
row("Max Context Tokens") {
|
||
HStack(spacing: 8) {
|
||
TextField("", value: $settingsService.contextMaxTokens, format: .number)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 120)
|
||
Text("tokens")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
Text("Maximum context window size. Default is 100,000 tokens. Smart selection will prioritize recent and starred messages within this limit.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
divider()
|
||
|
||
sectionHeader("Semantic Search")
|
||
|
||
row("Enable Embeddings") {
|
||
Toggle("", isOn: $settingsService.embeddingsEnabled)
|
||
.toggleStyle(.switch)
|
||
.disabled(!EmbeddingService.shared.isAvailable)
|
||
}
|
||
|
||
// Show status based on available providers
|
||
if let provider = EmbeddingService.shared.getBestAvailableProvider() {
|
||
Text("Enable AI-powered semantic search across conversations using \(provider.displayName) embeddings.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
} else {
|
||
Text("⚠️ No embedding providers available. Please configure an API key for OpenAI, OpenRouter, or Google in the General tab.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.orange)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
if settingsService.embeddingsEnabled {
|
||
row("Model") {
|
||
Picker("", selection: $settingsService.embeddingProvider) {
|
||
if settingsService.openaiAPIKey != nil && !settingsService.openaiAPIKey!.isEmpty {
|
||
Text("OpenAI (text-embedding-3-small)").tag("openai-small")
|
||
Text("OpenAI (text-embedding-3-large)").tag("openai-large")
|
||
}
|
||
if settingsService.openrouterAPIKey != nil && !settingsService.openrouterAPIKey!.isEmpty {
|
||
Text("OpenRouter (OpenAI small)").tag("openrouter-openai-small")
|
||
Text("OpenRouter (OpenAI large)").tag("openrouter-openai-large")
|
||
Text("OpenRouter (Qwen 8B)").tag("openrouter-qwen")
|
||
}
|
||
if settingsService.googleAPIKey != nil && !settingsService.googleAPIKey!.isEmpty {
|
||
Text("Google (Gemini embedding)").tag("google-gemini")
|
||
}
|
||
}
|
||
.pickerStyle(.menu)
|
||
}
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
Text("Cost: OpenAI ~$0.02-0.13/1M tokens, OpenRouter similar, Google ~$0.15/1M tokens")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
Button("Embed All Conversations") {
|
||
Task {
|
||
if let chatVM = chatViewModel {
|
||
await chatVM.batchEmbedAllConversations()
|
||
}
|
||
}
|
||
}
|
||
.help("Generate embeddings for all existing messages (one-time operation)")
|
||
}
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
Text("⚠️ This will generate embeddings for all messages in all conversations. Estimated cost: ~$0.04 for 10,000 messages.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.orange)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
divider()
|
||
|
||
sectionHeader("Progressive Summarization")
|
||
|
||
row("Enable Summarization") {
|
||
Toggle("", isOn: $settingsService.progressiveSummarizationEnabled)
|
||
.toggleStyle(.switch)
|
||
}
|
||
Text("Automatically summarize old portions of long conversations to save tokens and improve context efficiency.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
if settingsService.progressiveSummarizationEnabled {
|
||
row("Message Threshold") {
|
||
HStack(spacing: 8) {
|
||
TextField("", value: $settingsService.summarizationThreshold, format: .number)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 80)
|
||
Text("messages")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
Text("When a conversation exceeds this many messages, older messages will be summarized. Default: 50 messages.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
|
||
divider()
|
||
|
||
sectionHeader("Info")
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
Text("⚠️ These are advanced settings")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.orange)
|
||
.fontWeight(.medium)
|
||
Text("Changing these values affects how the AI generates responses. The defaults work well for most use cases. Only adjust if you know what you're doing.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Sync Tab
|
||
|
||
@ViewBuilder
|
||
private var syncTab: some View {
|
||
Group {
|
||
sectionHeader("Git Sync")
|
||
|
||
Text("Sync conversations and settings across multiple machines using Git.")
|
||
.font(.system(size: 14))
|
||
.foregroundStyle(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
row("Enable Git Sync") {
|
||
Toggle("", isOn: $settingsService.syncEnabled)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
if settingsService.syncEnabled {
|
||
VStack(alignment: .leading, spacing: 16) {
|
||
|
||
// Status indicator
|
||
HStack(spacing: 8) {
|
||
Image(systemName: syncStatusIcon)
|
||
.foregroundStyle(syncStatusColor)
|
||
Text(syncStatusText)
|
||
.font(.system(size: 14))
|
||
.foregroundStyle(syncStatusColor)
|
||
}
|
||
.padding(.leading, labelWidth + 12)
|
||
|
||
divider()
|
||
|
||
// Repository URL
|
||
sectionHeader("Repository")
|
||
|
||
row("URL") {
|
||
TextField("https://github.com/user/oai-sync.git", text: $syncRepoURL)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onChange(of: syncRepoURL) {
|
||
settingsService.syncRepoURL = syncRepoURL
|
||
}
|
||
}
|
||
|
||
Text("💡 Use HTTPS URL (e.g., https://gitlab.pm/user/repo.git) - works with all auth methods")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
|
||
row("Local Path") {
|
||
TextField("~/Library/Application Support/oAI/sync", text: $syncLocalPath)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onChange(of: syncLocalPath) {
|
||
settingsService.syncLocalPath = syncLocalPath
|
||
}
|
||
}
|
||
|
||
divider()
|
||
|
||
// Authentication
|
||
sectionHeader("Authentication")
|
||
|
||
row("Method") {
|
||
Picker("", selection: $settingsService.syncAuthMethod) {
|
||
Text("SSH Key").tag("ssh")
|
||
Text("Username + Password").tag("password")
|
||
Text("Access Token").tag("token")
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.frame(width: 400)
|
||
}
|
||
|
||
// SSH info
|
||
if settingsService.syncAuthMethod == "ssh" {
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("ℹ️ SSH Key Authentication")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
Text("• Uses your system SSH keys (~/.ssh/id_ed25519)")
|
||
Text("• Add public key to your git provider")
|
||
Text("• No credentials needed in oAI")
|
||
}
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
// Username + Password
|
||
if settingsService.syncAuthMethod == "password" {
|
||
row("Username") {
|
||
TextField("username", text: $syncUsername)
|
||
.textFieldStyle(.roundedBorder)
|
||
.onChange(of: syncUsername) {
|
||
settingsService.syncUsername = syncUsername.isEmpty ? nil : syncUsername
|
||
}
|
||
}
|
||
|
||
row("Password") {
|
||
HStack {
|
||
if showSyncPassword {
|
||
TextField("", text: $syncPassword)
|
||
.textFieldStyle(.roundedBorder)
|
||
} else {
|
||
SecureField("", text: $syncPassword)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
Button(action: { showSyncPassword.toggle() }) {
|
||
Image(systemName: showSyncPassword ? "eye.slash" : "eye")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.onChange(of: syncPassword) {
|
||
settingsService.syncPassword = syncPassword.isEmpty ? nil : syncPassword
|
||
}
|
||
}
|
||
}
|
||
|
||
Text("⚠️ Many providers (GitHub) no longer support password authentication. Use Access Token instead.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.orange)
|
||
}
|
||
|
||
// Access Token
|
||
if settingsService.syncAuthMethod == "token" {
|
||
row("Token") {
|
||
HStack {
|
||
if showSyncToken {
|
||
TextField("", text: $syncAccessToken)
|
||
.textFieldStyle(.roundedBorder)
|
||
} else {
|
||
SecureField("", text: $syncAccessToken)
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
Button(action: { showSyncToken.toggle() }) {
|
||
Image(systemName: showSyncToken ? "eye.slash" : "eye")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.onChange(of: syncAccessToken) {
|
||
settingsService.syncAccessToken = syncAccessToken.isEmpty ? nil : syncAccessToken
|
||
}
|
||
}
|
||
}
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
Text("💡 Generate Access Token:")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
|
||
if let tokenURL = tokenGenerationURL {
|
||
Link("→ Open \(extractProvider()) Settings", destination: URL(string: tokenURL)!)
|
||
.font(.system(size: 13))
|
||
} else {
|
||
Text("• GitHub: Settings > Developer > Personal Access Tokens")
|
||
Text("• GitLab: Preferences > Access Tokens")
|
||
Text("• Gitea: Settings > Applications > Generate New Token")
|
||
}
|
||
}
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
// Test connection
|
||
row("") {
|
||
HStack {
|
||
if let result = syncTestResult {
|
||
Text(result)
|
||
.font(.system(size: 14))
|
||
.foregroundStyle(result.hasPrefix("✓") ? .green : .red)
|
||
}
|
||
Button(action: { Task { await testSyncConnection() } }) {
|
||
HStack {
|
||
if isTestingSync {
|
||
ProgressView()
|
||
.scaleEffect(0.7)
|
||
.frame(width: 14, height: 14)
|
||
} else {
|
||
Image(systemName: "checkmark.circle")
|
||
}
|
||
Text("Test Connection")
|
||
}
|
||
}
|
||
.disabled(isTestingSync || !settingsService.syncConfigured)
|
||
}
|
||
}
|
||
|
||
divider()
|
||
|
||
// Sync options
|
||
sectionHeader("Sync Options")
|
||
|
||
row("Auto-export on save") {
|
||
Toggle("", isOn: $settingsService.syncAutoExport)
|
||
.toggleStyle(.switch)
|
||
}
|
||
row("Auto-pull on launch") {
|
||
Toggle("", isOn: $settingsService.syncAutoPull)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
divider()
|
||
|
||
// Auto-Save & Smart Sync
|
||
sectionHeader("Auto-Save & Smart Sync")
|
||
|
||
row("Enable Auto-Save") {
|
||
Toggle("", isOn: $settingsService.syncAutoSave)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
if settingsService.syncAutoSave {
|
||
// Warning about conflicts
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundStyle(.orange)
|
||
Text("Auto-sync can cause conflicts if running on multiple machines simultaneously.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.orange)
|
||
}
|
||
.padding(.vertical, 4)
|
||
|
||
// Minimum messages
|
||
row("Min Messages") {
|
||
HStack {
|
||
Slider(value: Binding(
|
||
get: { Double(settingsService.syncAutoSaveMinMessages) },
|
||
set: { settingsService.syncAutoSaveMinMessages = Int($0) }
|
||
), in: 3...20, step: 1)
|
||
.frame(width: 200)
|
||
Text("\(settingsService.syncAutoSaveMinMessages)")
|
||
.font(.system(size: 14))
|
||
.frame(width: 30)
|
||
}
|
||
}
|
||
|
||
Text("Only auto-save conversations with at least this many messages")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
|
||
// Trigger options
|
||
Text("Triggers")
|
||
.font(.system(size: 13, weight: .medium))
|
||
.foregroundStyle(.secondary)
|
||
.padding(.top, 8)
|
||
|
||
row("On model switch") {
|
||
Toggle("", isOn: $settingsService.syncAutoSaveOnModelSwitch)
|
||
.toggleStyle(.switch)
|
||
}
|
||
row("On app quit") {
|
||
Toggle("", isOn: $settingsService.syncAutoSaveOnAppQuit)
|
||
.toggleStyle(.switch)
|
||
}
|
||
row("After idle timeout") {
|
||
Toggle("", isOn: $settingsService.syncAutoSaveOnIdle)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
// Idle timeout
|
||
if settingsService.syncAutoSaveOnIdle {
|
||
row("Idle Timeout") {
|
||
HStack {
|
||
Slider(value: Binding(
|
||
get: { Double(settingsService.syncAutoSaveIdleMinutes) },
|
||
set: { settingsService.syncAutoSaveIdleMinutes = Int($0) }
|
||
), in: 1...30, step: 1)
|
||
.frame(width: 200)
|
||
Text("\(settingsService.syncAutoSaveIdleMinutes) min")
|
||
.font(.system(size: 14))
|
||
.frame(width: 60)
|
||
}
|
||
}
|
||
|
||
Text("Auto-save if no messages for this many minutes")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
|
||
divider()
|
||
|
||
// Manual actions
|
||
sectionHeader("Manual Sync")
|
||
|
||
row("") {
|
||
HStack(spacing: 12) {
|
||
if !gitSync.syncStatus.isCloned {
|
||
// Not cloned yet - show initialize button
|
||
Button {
|
||
Task { await cloneRepo() }
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
if isSyncing {
|
||
ProgressView()
|
||
.scaleEffect(0.7)
|
||
.frame(width: 16, height: 16)
|
||
} else {
|
||
Image(systemName: "arrow.down.circle")
|
||
}
|
||
Text("Initialize Repository")
|
||
}
|
||
.frame(minWidth: 160)
|
||
}
|
||
.disabled(!settingsService.syncConfigured || isSyncing)
|
||
} else {
|
||
// Already cloned - show sync button
|
||
Button {
|
||
Task { await syncNow() }
|
||
} label: {
|
||
HStack(spacing: 6) {
|
||
if isSyncing {
|
||
ProgressView()
|
||
.scaleEffect(0.7)
|
||
.frame(width: 16, height: 16)
|
||
} else {
|
||
Image(systemName: "arrow.triangle.2.circlepath")
|
||
}
|
||
Text("Sync Now")
|
||
}
|
||
.frame(minWidth: 160)
|
||
}
|
||
.disabled(isSyncing)
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
}
|
||
|
||
// Status
|
||
if gitSync.syncStatus.isCloned {
|
||
HStack {
|
||
Spacer().frame(width: labelWidth + 12)
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
if let lastSync = gitSync.syncStatus.lastSyncTime {
|
||
Text("Last sync: \(timeAgo(lastSync))")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
if gitSync.syncStatus.uncommittedChanges > 0 {
|
||
Text("Uncommitted changes: \(gitSync.syncStatus.uncommittedChanges)")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.orange)
|
||
}
|
||
|
||
if let branch = gitSync.syncStatus.currentBranch {
|
||
Text("Branch: \(branch)")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
|
||
if let status = gitSync.syncStatus.remoteStatus {
|
||
Text("Remote: \(status)")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
.onAppear {
|
||
syncRepoURL = settingsService.syncRepoURL
|
||
syncLocalPath = settingsService.syncLocalPath
|
||
syncUsername = settingsService.syncUsername ?? ""
|
||
syncPassword = settingsService.syncPassword ?? ""
|
||
syncAccessToken = settingsService.syncAccessToken ?? ""
|
||
|
||
// Update sync status to check if repository is already cloned
|
||
Task {
|
||
await gitSync.updateStatus()
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Email Tab
|
||
|
||
@ViewBuilder
|
||
private var emailTab: some View {
|
||
Group {
|
||
sectionHeader("AI Email Handler")
|
||
|
||
Text("Let AI automatically respond to emails sent to your designated email account. Uses IMAP IDLE for real-time monitoring and replies with AI-generated responses.")
|
||
.font(.system(size: settingsService.guiTextSize))
|
||
.foregroundColor(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
// Security recommendation box
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "shield.fill")
|
||
.foregroundColor(.orange)
|
||
Text("Security Recommendation")
|
||
.font(.system(size: settingsService.guiTextSize, weight: .semibold))
|
||
.foregroundColor(.orange)
|
||
}
|
||
|
||
Text("For security, create a dedicated email account specifically for AI handling. Do NOT use your personal email address.")
|
||
.font(.system(size: settingsService.guiTextSize))
|
||
.foregroundColor(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
Text("Example: oai-bot-x7k2m9p3@gmail.com")
|
||
.font(.system(size: settingsService.guiTextSize - 1, design: .monospaced))
|
||
.foregroundColor(.blue)
|
||
.padding(.vertical, 4)
|
||
.padding(.horizontal, 8)
|
||
.background(Color.blue.opacity(0.1))
|
||
.cornerRadius(4)
|
||
}
|
||
.padding(12)
|
||
.background(Color.orange.opacity(0.05))
|
||
.cornerRadius(8)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(Color.orange.opacity(0.3), lineWidth: 1)
|
||
)
|
||
|
||
divider()
|
||
|
||
// Enable toggle
|
||
row("Enable Email Handler") {
|
||
Toggle("", isOn: $settingsService.emailHandlerEnabled)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
if settingsService.emailHandlerEnabled {
|
||
divider()
|
||
|
||
// AI Configuration
|
||
sectionHeader("AI Configuration")
|
||
|
||
// Provider selection
|
||
row("AI Provider") {
|
||
Picker("", selection: $settingsService.emailHandlerProvider) {
|
||
ForEach(ProviderRegistry.shared.configuredProviders, id: \.self) { provider in
|
||
Text(provider.displayName).tag(provider.rawValue)
|
||
}
|
||
}
|
||
.labelsHidden()
|
||
.frame(width: 250)
|
||
.onChange(of: settingsService.emailHandlerProvider) {
|
||
Task {
|
||
await loadEmailModels()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Model selection
|
||
row("AI Model") {
|
||
if isLoadingEmailModels {
|
||
ProgressView()
|
||
.scaleEffect(0.7)
|
||
.frame(width: 250, alignment: .leading)
|
||
} else if emailAvailableModels.isEmpty {
|
||
Text("No models available")
|
||
.font(.system(size: settingsService.guiTextSize))
|
||
.foregroundColor(.secondary)
|
||
.frame(width: 250, alignment: .leading)
|
||
} else {
|
||
Button(action: { showEmailModelSelector = true }) {
|
||
HStack {
|
||
Text(emailAvailableModels.first(where: { $0.id == settingsService.emailHandlerModel })?.name ?? "Select model...")
|
||
.font(.system(size: settingsService.guiTextSize))
|
||
.foregroundColor(.primary)
|
||
Spacer()
|
||
Image(systemName: "chevron.up.chevron.down")
|
||
.font(.system(size: 10))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
.padding(.horizontal, 8)
|
||
.padding(.vertical, 4)
|
||
.frame(width: 250)
|
||
.background(Color(nsColor: .controlBackgroundColor))
|
||
.cornerRadius(6)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
}
|
||
|
||
Text("Select which AI model handles incoming emails. This runs in parallel to your main chat session.")
|
||
.font(.system(size: settingsService.guiTextSize - 1))
|
||
.foregroundColor(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
divider()
|
||
|
||
// Email Server Configuration
|
||
sectionHeader("Email Server")
|
||
|
||
row("IMAP Host") {
|
||
TextField("imap.gmail.com", text: Binding(
|
||
get: { settingsService.emailImapHost ?? "" },
|
||
set: { settingsService.emailImapHost = $0.isEmpty ? nil : $0 }
|
||
))
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 250)
|
||
}
|
||
|
||
row("SMTP Host") {
|
||
TextField("smtp.gmail.com", text: Binding(
|
||
get: { settingsService.emailSmtpHost ?? "" },
|
||
set: { settingsService.emailSmtpHost = $0.isEmpty ? nil : $0 }
|
||
))
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 250)
|
||
}
|
||
|
||
row("IMAP Port") {
|
||
TextField("993", text: Binding(
|
||
get: { String(settingsService.emailImapPort) },
|
||
set: { settingsService.emailImapPort = Int($0) ?? 993 }
|
||
))
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 100)
|
||
}
|
||
|
||
row("SMTP Port") {
|
||
TextField("587", text: Binding(
|
||
get: { String(settingsService.emailSmtpPort) },
|
||
set: { settingsService.emailSmtpPort = Int($0) ?? 587 }
|
||
))
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 100)
|
||
}
|
||
|
||
row("Username") {
|
||
TextField("your-email@gmail.com", text: Binding(
|
||
get: { settingsService.emailUsername ?? "" },
|
||
set: { settingsService.emailUsername = $0.isEmpty ? nil : $0 }
|
||
))
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 250)
|
||
}
|
||
|
||
row("Password") {
|
||
HStack {
|
||
if showEmailPassword {
|
||
TextField("", text: Binding(
|
||
get: { settingsService.emailPassword ?? "" },
|
||
set: { settingsService.emailPassword = $0.isEmpty ? nil : $0 }
|
||
))
|
||
.textFieldStyle(.roundedBorder)
|
||
} else {
|
||
SecureField("", text: Binding(
|
||
get: { settingsService.emailPassword ?? "" },
|
||
set: { settingsService.emailPassword = $0.isEmpty ? nil : $0 }
|
||
))
|
||
.textFieldStyle(.roundedBorder)
|
||
}
|
||
Button(action: { showEmailPassword.toggle() }) {
|
||
Image(systemName: showEmailPassword ? "eye.slash" : "eye")
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.buttonStyle(.plain)
|
||
}
|
||
.frame(width: 250)
|
||
}
|
||
|
||
Text("💡 For Gmail, use an App Password (not your regular password). Go to Google Account > Security > 2-Step Verification > App passwords.")
|
||
.font(.system(size: settingsService.guiTextSize - 1))
|
||
.foregroundColor(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
row("") {
|
||
HStack {
|
||
Button(action: {
|
||
Task { await testEmailConnection() }
|
||
}) {
|
||
HStack {
|
||
if isTestingEmailConnection {
|
||
ProgressView()
|
||
.scaleEffect(0.7)
|
||
.frame(width: 14, height: 14)
|
||
} else {
|
||
Image(systemName: "checkmark.circle")
|
||
}
|
||
Text("Test Connection")
|
||
}
|
||
}
|
||
.disabled(isTestingEmailConnection || !settingsService.emailServerConfigured)
|
||
|
||
if let result = emailConnectionTestResult {
|
||
Text(result)
|
||
.font(.system(size: settingsService.guiTextSize - 1))
|
||
.foregroundColor(result.hasPrefix("✓") ? .green : .red)
|
||
.padding(.leading, 8)
|
||
}
|
||
}
|
||
}
|
||
|
||
divider()
|
||
|
||
// Email Trigger
|
||
sectionHeader("Email Trigger")
|
||
|
||
row("Subject Identifier") {
|
||
TextField("", text: $settingsService.emailSubjectIdentifier)
|
||
.textFieldStyle(.roundedBorder)
|
||
.frame(width: 200)
|
||
}
|
||
|
||
Text("Only emails with this text in the subject line will be processed. Example: \"[OAIBOT] What's the weather?\"")
|
||
.font(.system(size: settingsService.guiTextSize - 1))
|
||
.foregroundColor(.secondary)
|
||
.padding(.leading, labelWidth + 12)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
divider()
|
||
|
||
// Rate Limiting
|
||
sectionHeader("Rate Limiting")
|
||
|
||
row("Enable Rate Limit") {
|
||
Toggle("", isOn: $settingsService.emailRateLimitEnabled)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
if settingsService.emailRateLimitEnabled {
|
||
row("Max Emails Per Hour") {
|
||
HStack {
|
||
Slider(value: Binding(
|
||
get: { Double(settingsService.emailRateLimitPerHour) },
|
||
set: { settingsService.emailRateLimitPerHour = Int($0) }
|
||
), in: 1...100, step: 1)
|
||
.frame(width: 200)
|
||
|
||
Text("\(settingsService.emailRateLimitPerHour)")
|
||
.font(.system(size: settingsService.guiTextSize))
|
||
.frame(width: 40, alignment: .trailing)
|
||
|
||
if settingsService.emailRateLimitPerHour == 100 {
|
||
Text("(Unlimited)")
|
||
.font(.system(size: settingsService.guiTextSize - 1))
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
Text("Prevents abuse by limiting how many emails the AI will process per hour. Set to 100 for unlimited.")
|
||
.font(.system(size: settingsService.guiTextSize - 1))
|
||
.foregroundColor(.secondary)
|
||
.padding(.leading, labelWidth + 12)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
|
||
divider()
|
||
|
||
// Response Settings
|
||
sectionHeader("Response Settings")
|
||
|
||
row("Max Response Tokens") {
|
||
HStack {
|
||
Slider(value: Binding(
|
||
get: { Double(settingsService.emailMaxTokens) },
|
||
set: { settingsService.emailMaxTokens = Int($0) }
|
||
), in: 100...8000, step: 100)
|
||
.frame(width: 200)
|
||
|
||
Text("\(settingsService.emailMaxTokens)")
|
||
.font(.system(size: settingsService.guiTextSize))
|
||
.frame(width: 60, alignment: .trailing)
|
||
}
|
||
}
|
||
|
||
Text("Limits the length of AI responses to prevent excessive API costs. ~750 tokens = ~500 words.")
|
||
.font(.system(size: settingsService.guiTextSize - 1))
|
||
.foregroundColor(.secondary)
|
||
.padding(.leading, labelWidth + 12)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
row("Enable Online Mode") {
|
||
Toggle("", isOn: $settingsService.emailOnlineMode)
|
||
.toggleStyle(.switch)
|
||
}
|
||
|
||
Text("Allow email handler to search the web for current information. Useful for weather, news, stock prices, or fact-checking. May increase response time and API costs.")
|
||
.font(.system(size: settingsService.guiTextSize - 1))
|
||
.foregroundColor(.secondary)
|
||
.padding(.leading, labelWidth + 12)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
divider()
|
||
|
||
// Custom System Prompt
|
||
sectionHeader("Custom System Prompt (Optional)")
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
// Warning box
|
||
HStack(alignment: .top, spacing: 8) {
|
||
Image(systemName: "exclamationmark.triangle.fill")
|
||
.foregroundColor(.orange)
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Prompt Isolation & Override")
|
||
.font(.system(size: settingsService.guiTextSize, weight: .semibold))
|
||
.foregroundColor(.orange)
|
||
Text("The email handler uses ONLY its own system prompt, completely isolated from your main chat settings. If you provide a custom prompt below, it will override the default email instructions. Your main chat system prompt and any Advanced settings prompts are never used for email handling.")
|
||
.font(.system(size: settingsService.guiTextSize - 1))
|
||
.foregroundColor(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
.padding(10)
|
||
.background(Color.orange.opacity(0.1))
|
||
.cornerRadius(6)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.stroke(Color.orange.opacity(0.3), lineWidth: 1)
|
||
)
|
||
|
||
// Text editor
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
HStack {
|
||
Text("Email Handler System Prompt")
|
||
.font(.system(size: settingsService.guiTextSize - 1, weight: .medium))
|
||
.foregroundColor(.secondary)
|
||
|
||
Spacer()
|
||
|
||
if !emailHandlerSystemPrompt.isEmpty {
|
||
Button("Clear") {
|
||
emailHandlerSystemPrompt = ""
|
||
settingsService.emailHandlerSystemPrompt = nil
|
||
}
|
||
.font(.system(size: settingsService.guiTextSize - 1))
|
||
}
|
||
}
|
||
|
||
TextEditor(text: $emailHandlerSystemPrompt)
|
||
.font(.system(size: settingsService.guiTextSize, design: .monospaced))
|
||
.frame(height: 120)
|
||
.padding(8)
|
||
.background(Color.secondary.opacity(0.05))
|
||
.cornerRadius(6)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 6)
|
||
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
|
||
)
|
||
.onChange(of: emailHandlerSystemPrompt) {
|
||
settingsService.emailHandlerSystemPrompt = emailHandlerSystemPrompt.isEmpty ? nil : emailHandlerSystemPrompt
|
||
}
|
||
|
||
if emailHandlerSystemPrompt.isEmpty {
|
||
Text("Leave empty to use the default email handler system prompt. The default prompt instructs the AI to be professional, use proper email etiquette, and format responses in Markdown. This is completely separate from your main chat settings.")
|
||
.font(.system(size: settingsService.guiTextSize - 2))
|
||
.foregroundColor(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
} else {
|
||
Text("⚠️ Custom email prompt active - Only this prompt will be sent to the model. All other prompts are excluded.")
|
||
.font(.system(size: settingsService.guiTextSize - 2))
|
||
.foregroundColor(.orange)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
}
|
||
}
|
||
|
||
divider()
|
||
|
||
// View Email Log
|
||
row("Email Activity") {
|
||
Button(action: {
|
||
showEmailLog = true
|
||
}) {
|
||
HStack {
|
||
Image(systemName: "envelope.badge.fill")
|
||
Text("View Email Log")
|
||
}
|
||
}
|
||
}
|
||
|
||
Text("View history of processed emails, AI responses, and any errors.")
|
||
.font(.system(size: settingsService.guiTextSize - 1))
|
||
.foregroundColor(.secondary)
|
||
.padding(.leading, labelWidth + 12)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
|
||
divider()
|
||
|
||
// MCP Access Notice
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: "info.circle.fill")
|
||
.foregroundColor(.blue)
|
||
Text("File Access Permissions")
|
||
.font(.system(size: settingsService.guiTextSize, weight: .semibold))
|
||
.foregroundColor(.blue)
|
||
}
|
||
|
||
Text("Email tasks have READ-ONLY access to MCP folders. The AI cannot write, delete, or modify files when processing emails.")
|
||
.font(.system(size: settingsService.guiTextSize))
|
||
.foregroundColor(.secondary)
|
||
.fixedSize(horizontal: false, vertical: true)
|
||
}
|
||
.padding(12)
|
||
.background(Color.blue.opacity(0.05))
|
||
.cornerRadius(8)
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.stroke(Color.blue.opacity(0.3), lineWidth: 1)
|
||
)
|
||
}
|
||
}
|
||
.onAppear {
|
||
emailHandlerSystemPrompt = settingsService.emailHandlerSystemPrompt ?? ""
|
||
Task {
|
||
await loadEmailModels()
|
||
}
|
||
}
|
||
.sheet(isPresented: $showEmailModelSelector) {
|
||
ModelSelectorView(
|
||
models: emailAvailableModels.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending },
|
||
selectedModel: emailAvailableModels.first(where: { $0.id == settingsService.emailHandlerModel })
|
||
) { selectedModel in
|
||
settingsService.emailHandlerModel = selectedModel.id
|
||
showEmailModelSelector = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Shortcuts Tab
|
||
|
||
@ViewBuilder
|
||
private var shortcutsTab: some View {
|
||
ShortcutsTabContent()
|
||
}
|
||
|
||
// MARK: - Agent Skills Tab
|
||
|
||
@ViewBuilder
|
||
private var agentSkillsTab: some View {
|
||
AgentSkillsTabContent()
|
||
}
|
||
|
||
// MARK: - Layout Helpers
|
||
|
||
private func sectionHeader(_ title: String) -> some View {
|
||
Text(title)
|
||
.font(.system(size: 14, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
.textCase(.uppercase)
|
||
}
|
||
|
||
private func row<Content: View>(_ label: String, @ViewBuilder content: () -> Content) -> some View {
|
||
HStack(alignment: .center, spacing: 12) {
|
||
Text(label)
|
||
.font(.system(size: 14))
|
||
Spacer()
|
||
content()
|
||
}
|
||
}
|
||
|
||
private func divider() -> some View {
|
||
Divider().padding(.vertical, 2)
|
||
}
|
||
|
||
private func abbreviatePath(_ path: String) -> String {
|
||
let home = NSHomeDirectory()
|
||
if path.hasPrefix(home) {
|
||
return "~" + path.dropFirst(home.count)
|
||
}
|
||
return path
|
||
}
|
||
|
||
// MARK: - Email Helpers
|
||
|
||
private func loadEmailModels() async {
|
||
guard settingsService.emailHandlerEnabled else {
|
||
emailAvailableModels = []
|
||
return
|
||
}
|
||
|
||
let providerRawValue = settingsService.emailHandlerProvider
|
||
guard let providerType = Settings.Provider(rawValue: providerRawValue),
|
||
let provider = ProviderRegistry.shared.getProvider(for: providerType) else {
|
||
emailAvailableModels = []
|
||
return
|
||
}
|
||
|
||
isLoadingEmailModels = true
|
||
defer { isLoadingEmailModels = false }
|
||
|
||
do {
|
||
let models = try await provider.listModels()
|
||
emailAvailableModels = models
|
||
|
||
// If current model is not in the list, select the first one
|
||
if !models.contains(where: { $0.id == settingsService.emailHandlerModel }) {
|
||
if let firstModel = models.first {
|
||
settingsService.emailHandlerModel = firstModel.id
|
||
}
|
||
}
|
||
} catch {
|
||
Log.ui.error("Failed to load email models: \(error.localizedDescription)")
|
||
emailAvailableModels = []
|
||
}
|
||
}
|
||
|
||
private func testEmailConnection() async {
|
||
isTestingEmailConnection = true
|
||
emailConnectionTestResult = nil
|
||
|
||
do {
|
||
let result = try await EmailService.shared.testConnection()
|
||
emailConnectionTestResult = "✓ \(result)"
|
||
} catch {
|
||
emailConnectionTestResult = "✗ \(error.localizedDescription)"
|
||
}
|
||
|
||
isTestingEmailConnection = false
|
||
}
|
||
|
||
// MARK: - Sync Helpers
|
||
|
||
private func testSyncConnection() async {
|
||
isTestingSync = true
|
||
syncTestResult = nil
|
||
|
||
do {
|
||
let result = try await gitSync.testConnection()
|
||
syncTestResult = "✓ \(result)"
|
||
} catch {
|
||
syncTestResult = "✗ \(error.localizedDescription)"
|
||
}
|
||
|
||
isTestingSync = false
|
||
}
|
||
|
||
private var syncStatusIcon: String {
|
||
guard settingsService.syncEnabled else { return "externaldrive.slash" }
|
||
guard settingsService.syncConfigured else { return "exclamationmark.triangle" }
|
||
guard gitSync.syncStatus.isCloned else { return "externaldrive.badge.questionmark" }
|
||
return "externaldrive.badge.checkmark"
|
||
}
|
||
|
||
private var syncStatusColor: Color {
|
||
guard settingsService.syncEnabled else { return .secondary }
|
||
guard settingsService.syncConfigured else { return .orange }
|
||
guard gitSync.syncStatus.isCloned else { return .orange }
|
||
return .green
|
||
}
|
||
|
||
private var syncStatusText: String {
|
||
guard settingsService.syncEnabled else { return "Disabled" }
|
||
guard settingsService.syncConfigured else { return "Not configured" }
|
||
guard gitSync.syncStatus.isCloned else { return "Not cloned" }
|
||
return "Ready"
|
||
}
|
||
|
||
private func cloneRepo() async {
|
||
do {
|
||
try await gitSync.cloneRepository()
|
||
syncTestResult = "✓ Repository cloned successfully"
|
||
} catch {
|
||
syncTestResult = "✗ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func exportConversations() async {
|
||
do {
|
||
try await gitSync.exportAllConversations()
|
||
syncTestResult = "✓ Conversations exported"
|
||
} catch {
|
||
syncTestResult = "✗ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func pushToGit() async {
|
||
do {
|
||
// First export conversations
|
||
try await gitSync.exportAllConversations()
|
||
// Then push
|
||
try await gitSync.push()
|
||
syncTestResult = "✓ Changes pushed successfully"
|
||
} catch {
|
||
syncTestResult = "✗ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func pullFromGit() async {
|
||
do {
|
||
try await gitSync.pull()
|
||
syncTestResult = "✓ Changes pulled successfully"
|
||
} catch {
|
||
syncTestResult = "✗ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func importConversations() async {
|
||
do {
|
||
let result = try await gitSync.importAllConversations()
|
||
syncTestResult = "✓ Imported \(result.imported) conversations (skipped \(result.skipped) duplicates)"
|
||
} catch {
|
||
syncTestResult = "✗ \(error.localizedDescription)"
|
||
}
|
||
}
|
||
|
||
private func syncNow() async {
|
||
isSyncing = true
|
||
syncTestResult = nil
|
||
|
||
do {
|
||
// Step 1: Export all conversations
|
||
syncTestResult = "Exporting conversations..."
|
||
try await gitSync.exportAllConversations()
|
||
|
||
// Step 2: Pull from remote
|
||
syncTestResult = "Pulling changes..."
|
||
try await gitSync.pull()
|
||
|
||
// Step 3: Import any new conversations
|
||
syncTestResult = "Importing conversations..."
|
||
let result = try await gitSync.importAllConversations()
|
||
|
||
// Step 4: Push to remote
|
||
syncTestResult = "Pushing changes..."
|
||
try await gitSync.push()
|
||
|
||
// Success
|
||
await gitSync.updateStatus()
|
||
syncTestResult = "✓ Sync complete: \(result.imported) imported, \(result.skipped) skipped"
|
||
} catch {
|
||
syncTestResult = "✗ Sync failed: \(error.localizedDescription)"
|
||
}
|
||
|
||
isSyncing = false
|
||
}
|
||
|
||
private var tokenGenerationURL: String? {
|
||
let url = settingsService.syncRepoURL.lowercased()
|
||
if url.contains("github.com") {
|
||
return "https://github.com/settings/tokens"
|
||
} else if url.contains("gitlab.com") {
|
||
return "https://gitlab.com/-/profile/personal_access_tokens"
|
||
} else if url.contains("gitea") {
|
||
return extractProvider() + "/user/settings/applications"
|
||
} else {
|
||
return nil
|
||
}
|
||
}
|
||
|
||
private func extractProvider() -> String {
|
||
let url = settingsService.syncRepoURL
|
||
if url.contains("github.com") {
|
||
return "GitHub"
|
||
} else if url.contains("gitlab.com") {
|
||
return "GitLab"
|
||
} else if url.contains("gitea") {
|
||
return "Gitea"
|
||
} else {
|
||
return "Git repository"
|
||
}
|
||
}
|
||
|
||
private func timeAgo(_ date: Date) -> String {
|
||
let seconds = Int(Date().timeIntervalSince(date))
|
||
if seconds < 60 {
|
||
return "just now"
|
||
} else if seconds < 3600 {
|
||
let minutes = seconds / 60
|
||
return "\(minutes) minute\(minutes == 1 ? "" : "s") ago"
|
||
} else if seconds < 86400 {
|
||
let hours = seconds / 3600
|
||
return "\(hours) hour\(hours == 1 ? "" : "s") ago"
|
||
} else {
|
||
let days = seconds / 86400
|
||
return "\(days) day\(days == 1 ? "" : "s") ago"
|
||
}
|
||
}
|
||
}
|
||
|
||
#Preview {
|
||
SettingsView()
|
||
}
|