Initial commit

This commit is contained in:
2026-02-11 22:22:55 +01:00
commit 42f54954c1
58 changed files with 10639 additions and 0 deletions

View File

@@ -0,0 +1,500 @@
//
// SettingsView.swift
// oAI
//
// Settings and configuration screen
//
import SwiftUI
import UniformTypeIdentifiers
struct SettingsView: View {
@Environment(\.dismiss) var dismiss
@Bindable private var settingsService = SettingsService.shared
private var mcpService = MCPService.shared
@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
// OAuth state
@State private var oauthCode = ""
@State private var oauthError: String?
@State private var showOAuthCodeField = false
private var oauthService = AnthropicOAuthService.shared
private let labelWidth: CGFloat = 140
var body: some View {
VStack(spacing: 0) {
// Title
Text("Settings")
.font(.system(size: 18, 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)
}
.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
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: 550, idealWidth: 600, minHeight: 500, idealHeight: 650)
}
// 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)
.onAppear { openrouterKey = settingsService.openrouterAPIKey ?? "" }
.onChange(of: openrouterKey) {
settingsService.openrouterAPIKey = openrouterKey.isEmpty ? nil : openrouterKey
ProviderRegistry.shared.clearCache()
}
}
// Anthropic: OAuth or API key
row("Anthropic") {
VStack(alignment: .leading, spacing: 8) {
if oauthService.isAuthenticated {
// Logged in via OAuth
HStack(spacing: 8) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Logged in via Claude Pro/Max")
.font(.subheadline)
Spacer()
Button("Logout") {
oauthService.logout()
ProviderRegistry.shared.clearCache()
}
.font(.subheadline)
.foregroundStyle(.red)
}
} else if showOAuthCodeField {
// Waiting for code paste
HStack(spacing: 8) {
TextField("Paste authorization code...", text: $oauthCode)
.textFieldStyle(.roundedBorder)
Button("Submit") {
Task { await submitOAuthCode() }
}
.disabled(oauthCode.isEmpty || oauthService.isLoggingIn)
Button("Cancel") {
showOAuthCodeField = false
oauthCode = ""
oauthError = nil
}
.font(.subheadline)
}
if let error = oauthError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
}
} else {
// Login button + API key field
HStack(spacing: 8) {
Button {
startOAuthLogin()
} label: {
HStack(spacing: 4) {
Image(systemName: "person.circle")
Text("Login with Claude Pro/Max")
}
.font(.subheadline)
}
.buttonStyle(.borderedProminent)
.controlSize(.small)
Text("or")
.font(.caption)
.foregroundStyle(.secondary)
}
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)
.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)
.help("Enter your Ollama server URL to enable the Ollama provider")
}
divider()
// Features
sectionHeader("Features")
row("") {
VStack(alignment: .leading, spacing: 6) {
Toggle("Online Mode (Web Search)", isOn: $settingsService.onlineMode)
Toggle("Conversation Memory", isOn: $settingsService.memoryEnabled)
Toggle("MCP (File Access)", isOn: $settingsService.mcpEnabled)
}
}
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)
}
row("") {
Toggle("Streaming Responses", isOn: $settingsService.streamEnabled)
}
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(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
// MARK: - MCP Tab
@ViewBuilder
private var mcpTab: some View {
// Enable toggle
sectionHeader("MCP")
row("") {
Toggle("MCP Enabled (File Access)", isOn: $settingsService.mcpEnabled)
}
if settingsService.mcpEnabled {
divider()
// Folders
sectionHeader("Allowed Folders")
if mcpService.allowedFolders.isEmpty {
HStack {
Spacer().frame(width: labelWidth + 12)
Text("No folders added")
.foregroundStyle(.secondary)
.font(.subheadline)
}
} else {
ForEach(Array(mcpService.allowedFolders.enumerated()), id: \.offset) { index, folder in
HStack(spacing: 0) {
Spacer().frame(width: labelWidth + 12)
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(.caption)
.foregroundStyle(.secondary)
}
.padding(.leading, 6)
Spacer()
Button {
withAnimation { _ = mcpService.removeFolder(at: index) }
} label: {
Image(systemName: "trash.fill")
.foregroundStyle(.red)
.font(.caption)
}
.buttonStyle(.plain)
}
}
}
HStack(spacing: 0) {
Spacer().frame(width: labelWidth + 12)
Button {
showFolderPicker = true
} label: {
HStack(spacing: 4) {
Image(systemName: "plus")
Text("Add Folder...")
}
.font(.subheadline)
}
.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")
row("") {
VStack(alignment: .leading, spacing: 6) {
Toggle("Write & Edit Files", isOn: $settingsService.mcpCanWriteFiles)
Toggle("Delete Files", isOn: $settingsService.mcpCanDeleteFiles)
Toggle("Create Directories", isOn: $settingsService.mcpCanCreateDirectories)
Toggle("Move & Copy Files", isOn: $settingsService.mcpCanMoveFiles)
}
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("Read access is always available when MCP is enabled. Write permissions must be explicitly enabled.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
divider()
// Filtering
sectionHeader("Filtering")
row("") {
Toggle("Respect .gitignore", isOn: Binding(
get: { settingsService.mcpRespectGitignore },
set: { newValue in
settingsService.mcpRespectGitignore = newValue
mcpService.reloadGitignores()
}
))
}
HStack {
Spacer().frame(width: labelWidth + 12)
Text("When enabled, listing and searching skip gitignored files. Write operations always ignore .gitignore.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
// 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(.caption)
.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(.caption)
.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(.caption)
.foregroundStyle(.secondary)
.frame(width: 40)
}
}
}
// MARK: - Layout Helpers
private func sectionHeader(_ title: String) -> some View {
Text(title)
.font(.system(size: 13, 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(.body)
.frame(width: labelWidth, alignment: .trailing)
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: - OAuth Helpers
private func startOAuthLogin() {
let url = oauthService.generateAuthorizationURL()
#if os(macOS)
NSWorkspace.shared.open(url)
#endif
showOAuthCodeField = true
oauthError = nil
oauthCode = ""
}
private func submitOAuthCode() async {
oauthService.isLoggingIn = true
oauthError = nil
do {
try await oauthService.exchangeCode(oauthCode)
showOAuthCodeField = false
oauthCode = ""
ProviderRegistry.shared.clearCache()
} catch {
oauthError = error.localizedDescription
}
oauthService.isLoggingIn = false
}
}
#Preview {
SettingsView()
}