diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6358c4f..e00557440 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- AI provider registry for extensible provider management +- GitHub Copilot as an AI chat provider option (LSP transport pending) - MongoDB multi-host connections for replica sets - JSON results view mode with Data/Structure/JSON toggle in status bar - Import URL: dynamic placeholder, parsed preview, clipboard auto-paste, libSQL/D1 support, URL schemes for Oracle/ClickHouse/etcd/D1/libSQL diff --git a/TablePro/Core/AI/AIProviderFactory.swift b/TablePro/Core/AI/AIProviderFactory.swift index 8a67d10c8..23766dcd3 100644 --- a/TablePro/Core/AI/AIProviderFactory.swift +++ b/TablePro/Core/AI/AIProviderFactory.swift @@ -45,6 +45,8 @@ enum AIProviderFactory { apiKey: apiKey ?? "", maxOutputTokens: config.maxOutputTokens ?? 8_192 ) + case .copilot: + provider = CopilotChatProvider() case .openAI, .openRouter, .ollama, .custom: provider = OpenAICompatibleProvider( endpoint: config.endpoint, @@ -70,18 +72,46 @@ enum AIProviderFactory { for feature: AIFeature, settings: AISettings ) -> (AIProviderConfig, String?)? { - if let route = settings.featureRouting[feature.rawValue], - let config = settings.providers.first(where: { $0.id == route.providerID && $0.isEnabled }) { + // Check feature routing: explicit provider or Copilot + if let route = settings.featureRouting[feature.rawValue] { + // Routed to Copilot + if route.providerID == AIProviderConfig.copilotProviderID, settings.copilotChatEnabled { + let config = AIProviderConfig( + id: AIProviderConfig.copilotProviderID, + name: "GitHub Copilot", + type: .copilot, + model: route.model, + endpoint: "" + ) + return (config, nil) + } + + // Routed to a regular provider + if let config = settings.providers.first(where: { $0.id == route.providerID && $0.isEnabled }) { + let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) + return (config, apiKey) + } + } + + // Fallback: first enabled provider + if let config = settings.providers.first(where: { $0.isEnabled }) { let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) return (config, apiKey) } - guard let config = settings.providers.first(where: { $0.isEnabled }) else { - return nil + // Last resort: if copilotChatEnabled and no other providers, use Copilot + if settings.copilotChatEnabled { + let config = AIProviderConfig( + id: AIProviderConfig.copilotProviderID, + name: "GitHub Copilot", + type: .copilot, + model: "", + endpoint: "" + ) + return (config, nil) } - let apiKey = AIKeyStorage.shared.loadAPIKey(for: config.id) - return (config, apiKey) + return nil } static func resolveModel( diff --git a/TablePro/Core/AI/Copilot/CopilotAuthManager.swift b/TablePro/Core/AI/Copilot/CopilotAuthManager.swift new file mode 100644 index 000000000..9417656a9 --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotAuthManager.swift @@ -0,0 +1,82 @@ +// +// CopilotAuthManager.swift +// TablePro +// + +import AppKit +import Foundation +import os + +@MainActor +final class CopilotAuthManager { + private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotAuth") + + struct SignInResult { + let userCode: String + let verificationURI: String + } + + private struct SignInInitiateResponse: Decodable { + let status: String + let userCode: String + let verificationUri: String + } + + private struct SignInConfirmResponse: Decodable { + let status: String + let user: String + } + + func initiateSignIn(transport: LSPTransport) async throws -> SignInResult { + let data: Data = try await transport.sendRequest( + method: "signInInitiate", + params: EmptyLSPParams() + ) + let response = try JSONDecoder().decode(SignInInitiateResponse.self, from: data) + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(response.userCode, forType: .string) + + if let url = URL(string: response.verificationUri) { + NSWorkspace.shared.open(url) + } + + Self.logger.info("Sign-in initiated, user code copied to clipboard") + return SignInResult(userCode: response.userCode, verificationURI: response.verificationUri) + } + + func completeSignIn(transport: LSPTransport) async throws -> String { + let maxAttempts = 60 + let pollInterval: Duration = .seconds(2) + + for _ in 0..? + + private init() { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + baseDirectory = appSupport.appendingPathComponent("TablePro/copilot-language-server", isDirectory: true) + } + + func ensureBinary() async throws -> String { + let path = binaryExecutablePath + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + + if let existing = downloadTask { + try await existing.value + } else { + let task = Task { try await downloadBinary() } + downloadTask = task + do { + try await task.value + downloadTask = nil + } catch { + downloadTask = nil + throw error + } + } + + guard FileManager.default.isExecutableFile(atPath: path) else { + throw CopilotError.binaryNotFound + } + return path + } + + private func downloadBinary() async throws { + try FileManager.default.createDirectory(at: baseDirectory, withIntermediateDirectories: true) + + let platform = self.platform + let optionalDep = "@github/copilot-language-server-\(platform)" + + guard let registryURL = URL(string: "https://registry.npmjs.org/\(optionalDep)/latest") else { + throw CopilotError.binaryNotFound + } + let (data, _) = try await URLSession.shared.data(from: registryURL) + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let dist = json["dist"] as? [String: Any], + let tarballURLString = dist["tarball"] as? String, + let tarballURL = URL(string: tarballURLString) else { + throw CopilotError.binaryNotFound + } + + let (tarballData, _) = try await URLSession.shared.data(from: tarballURL) + + if let expectedShasum = dist["shasum"] as? String { + let actualHash = tarballData.sha1HexString() + if actualHash != expectedShasum { + Self.logger.error("Binary checksum mismatch: expected \(expectedShasum), got \(actualHash)") + throw CopilotError.binaryNotFound + } + } + + let tempTar = baseDirectory.appendingPathComponent("download.tar.gz") + try tarballData.write(to: tempTar) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + process.arguments = ["xzf", tempTar.path, "-C", baseDirectory.path, "--strip-components=1", "package/copilot-language-server"] + + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + process.terminationHandler = { proc in + if proc.terminationStatus == 0 { + continuation.resume() + } else { + continuation.resume(throwing: CopilotError.binaryNotFound) + } + } + do { + try process.run() + } catch { + continuation.resume(throwing: error) + } + } + + try? FileManager.default.removeItem(at: tempTar) + + // Verify extraction; try to find binary if not at expected path + if !FileManager.default.fileExists(atPath: binaryExecutablePath) { + let enumerator = FileManager.default.enumerator(at: baseDirectory, includingPropertiesForKeys: nil) + while let fileURL = enumerator?.nextObject() as? URL { + if fileURL.lastPathComponent == "copilot-language-server" { + let foundPath = fileURL.path + if foundPath != binaryExecutablePath { + try FileManager.default.moveItem(atPath: foundPath, toPath: binaryExecutablePath) + Self.logger.info("Moved binary from \(foundPath) to expected location") + } + break + } + } + } + + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: binaryExecutablePath + ) + + // Store version for future reference + if let version = json["version"] as? String { + let versionFile = baseDirectory.appendingPathComponent("version.txt") + try? version.write(to: versionFile, atomically: true, encoding: .utf8) + Self.logger.info("Installed Copilot language server version \(version)") + } + + Self.logger.info("Downloaded Copilot language server binary") + } + + func installedVersion() -> String? { + let versionFile = baseDirectory.appendingPathComponent("version.txt") + return try? String(contentsOf: versionFile, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var binaryExecutablePath: String { + baseDirectory.appendingPathComponent("copilot-language-server").path + } + + private var platform: String { + #if arch(arm64) + return "darwin-arm64" + #else + return "darwin-x64" + #endif + } +} + +private extension Data { + func sha1HexString() -> String { + var digest = [UInt8](repeating: 0, count: Int(CC_SHA1_DIGEST_LENGTH)) + withUnsafeBytes { CC_SHA1($0.baseAddress, CC_LONG(count), &digest) } + return digest.map { String(format: "%02x", $0) }.joined() + } +} diff --git a/TablePro/Core/AI/Copilot/CopilotChatProvider.swift b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift new file mode 100644 index 000000000..d796eee83 --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift @@ -0,0 +1,63 @@ +// +// CopilotChatProvider.swift +// TablePro +// +// AIProvider implementation for GitHub Copilot Chat. +// Uses the Copilot LSP conversation protocol for streaming chat completions. +// Requires an authenticated CopilotService with a running LSP server. +// + +import Foundation +import os + +/// AI provider that routes chat requests through the GitHub Copilot LSP server +final class CopilotChatProvider: AIProvider { + private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotChatProvider") + + // MARK: - AIProvider + + func streamChat( + messages: [AIChatMessage], + model: String, + systemPrompt: String? + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { @MainActor in + do { + guard CopilotService.shared.isAuthenticated else { + throw CopilotError.serverNotRunning + } + + // LSP transport not yet available. Once the Copilot LSP client ships, + // this will use conversation/create + conversation/turn to stream responses. + throw CopilotError.serverNotRunning + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + func fetchAvailableModels() async throws -> [String] { + guard await CopilotService.shared.isAuthenticated else { + throw CopilotError.serverNotRunning + } + + // Default models available through Copilot Chat. + // Once LSP transport ships, this will query copilot/models. + return [ + "gpt-4o", + "gpt-4o-mini", + "claude-3.5-sonnet", + "o3-mini" + ] + } + + func testConnection() async throws -> Bool { + await CopilotService.shared.isAuthenticated + } +} diff --git a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift new file mode 100644 index 000000000..bf803ae6a --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -0,0 +1,104 @@ +// +// CopilotDocumentSync.swift +// TablePro +// + +import Foundation +import os + +/// Manages LSP document lifecycle for Copilot. Prepends the schema preamble +/// to all document text sent to the server. +@MainActor +final class CopilotDocumentSync { + private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotDocumentSync") + + private let documentManager = LSPDocumentManager() + let schemaContext = CopilotSchemaContext() + private var currentURI: String? + private var serverSyncedURIs: Set = [] + private var uriMap: [UUID: String] = [:] + private var nextID = 1 + private var lastKnownGeneration: Int = 0 + + func documentURI(for tabID: UUID) -> String { + if let existing = uriMap[tabID] { return existing } + let fileURL = CopilotSchemaContext.contextDirectory.appendingPathComponent("query-\(nextID).sql") + nextID += 1 + let uri = fileURL.absoluteString + uriMap[tabID] = uri + return uri + } + + func resetServerState() { + serverSyncedURIs.removeAll() + } + + /// Register document locally. Does NOT send to server. + func ensureDocumentOpen(tabID: UUID, text: String, languageId: String = "sql") { + let uri = documentURI(for: tabID) + let fullText = schemaContext.prependToText(text) + if !documentManager.isOpen(uri) { + _ = documentManager.openDocument(uri: uri, languageId: languageId, text: fullText) + } + currentURI = uri + } + + /// Open document at the server with preamble-prepended text + func didActivateTab(tabID: UUID, text: String, languageId: String = "sql") async { + let currentGeneration = CopilotService.shared.generation + if currentGeneration != lastKnownGeneration { + resetServerState() + lastKnownGeneration = currentGeneration + } + + let uri = documentURI(for: tabID) + let fullText = schemaContext.prependToText(text) + ensureDocumentOpen(tabID: tabID, text: text, languageId: languageId) + + guard let client = CopilotService.shared.client else { return } + if !serverSyncedURIs.contains(uri) { + let item = LSPTextDocumentItem( + uri: uri, + languageId: languageId, + version: documentManager.version(for: uri) ?? 0, + text: fullText + ) + await client.didOpenDocument(item) + serverSyncedURIs.insert(uri) + } + await client.didFocusDocument(uri: uri) + } + + /// Send text change with preamble prepended + func didChangeText(tabID: UUID, newText: String) async { + let currentGeneration = CopilotService.shared.generation + if currentGeneration != lastKnownGeneration { + resetServerState() + lastKnownGeneration = currentGeneration + } + + let uri = documentURI(for: tabID) + guard serverSyncedURIs.contains(uri) else { return } + let fullText = schemaContext.prependToText(newText) + guard let client = CopilotService.shared.client else { return } + guard let (versioned, changes) = documentManager.changeDocument(uri: uri, newText: fullText) else { return } + await client.didChangeDocument(uri: versioned.uri, version: versioned.version, changes: changes) + } + + func didCloseTab(tabID: UUID) async { + let uri = documentURI(for: tabID) + guard let client = CopilotService.shared.client else { return } + guard let docId = documentManager.closeDocument(uri: uri) else { return } + await client.didCloseDocument(uri: docId.uri) + serverSyncedURIs.remove(uri) + uriMap.removeValue(forKey: tabID) + if currentURI == uri { currentURI = nil } + } + + func currentDocumentInfo() -> (uri: String, version: Int)? { + guard let uri = currentURI else { return nil } + guard serverSyncedURIs.contains(uri) else { return nil } + guard let version = documentManager.version(for: uri) else { return nil } + return (uri, version) + } +} diff --git a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift new file mode 100644 index 000000000..cee33f74e --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift @@ -0,0 +1,103 @@ +// +// CopilotInlineSource.swift +// TablePro +// + +import Foundation +import os + +@MainActor +final class CopilotInlineSource: InlineSuggestionSource { + private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotInlineSource") + + private let documentSync: CopilotDocumentSync + + init(documentSync: CopilotDocumentSync) { + self.documentSync = documentSync + } + + var isAvailable: Bool { + CopilotService.shared.status == .running && CopilotService.shared.isAuthenticated + } + + func requestSuggestion(context: SuggestionContext) async throws -> InlineSuggestion? { + guard let client = CopilotService.shared.client else { return nil } + guard let docInfo = documentSync.currentDocumentInfo() else { return nil } + + let editorSettings = AppSettingsManager.shared.editor + let preambleOffset = documentSync.schemaContext.preambleLineCount + let params = LSPInlineCompletionParams( + textDocument: LSPVersionedTextDocumentIdentifier(uri: docInfo.uri, version: docInfo.version), + position: LSPPosition(line: context.cursorLine + preambleOffset, character: context.cursorCharacter), + context: LSPInlineCompletionContext(triggerKind: 2), + formattingOptions: LSPFormattingOptions( + tabSize: editorSettings.clampedTabWidth, + insertSpaces: true + ) + ) + + let result = try await client.inlineCompletion(params: params) + guard let first = result.items.first, !first.insertText.isEmpty else { return nil } + + let ghostText: String + var replacementRange: NSRange? + + if let range = first.range { + // Adjust range positions back by preamble offset + let adjustedStart = LSPPosition(line: range.start.line - preambleOffset, character: range.start.character) + let adjustedEnd = LSPPosition(line: range.end.line - preambleOffset, character: range.end.character) + let nsText = context.fullText as NSString + let rangeStartOffset = Self.offsetForPosition(adjustedStart, in: nsText) + let existingLen = context.cursorOffset - rangeStartOffset + + if existingLen > 0, existingLen <= (first.insertText as NSString).length { + ghostText = (first.insertText as NSString).substring(from: existingLen) + } else { + ghostText = first.insertText + } + + let rangeEndOffset = Self.offsetForPosition(adjustedEnd, in: nsText) + replacementRange = NSRange(location: rangeStartOffset, length: rangeEndOffset - rangeStartOffset) + } else { + ghostText = first.insertText + } + + guard !ghostText.isEmpty else { return nil } + + return InlineSuggestion( + text: ghostText, + replacementRange: replacementRange, + replacementText: first.insertText, + acceptCommand: first.command + ) + } + + func didAcceptSuggestion(_ suggestion: InlineSuggestion) { + guard let command = suggestion.acceptCommand else { return } + Task { + guard let client = CopilotService.shared.client else { return } + try? await client.executeCommand(command: command.command, arguments: command.arguments) + } + } + + func didShowSuggestion(_ suggestion: InlineSuggestion) {} + + func didDismissSuggestion(_ suggestion: InlineSuggestion) {} + + // MARK: - Private + + /// Convert LSP Position (line, character) to flat character offset in text. + private static func offsetForPosition(_ position: LSPPosition, in text: NSString) -> Int { + var offset = 0 + var line = 0 + let length = text.length + + while offset < length, line < position.line { + if text.character(at: offset) == 0x0A { + line += 1 + } + offset += 1 + } + return min(offset + position.character, length) + } +} diff --git a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift new file mode 100644 index 000000000..f7cf9986b --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift @@ -0,0 +1,83 @@ +// +// CopilotSchemaContext.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +/// Builds a schema preamble (SQL comments with table/column info) to prepend +/// to document text sent to the Copilot language server. Pure data, no LSP concerns. +@MainActor +final class CopilotSchemaContext { + private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotSchemaContext") + + /// Directory for query document URIs + static let contextDirectory: URL = { + let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + return appSupport.appendingPathComponent("TablePro/copilot-context", isDirectory: true) + }() + + /// The schema preamble text (SQL comments with table/column info) + private(set) var preamble: String = "" + + /// Number of newline characters in the preamble (for cursor offset adjustment) + private(set) var preambleLineCount: Int = 0 + + /// Build the preamble from cached schema data + func buildPreamble( + schemaProvider: SQLSchemaProvider, + databaseName: String, + databaseType: DatabaseType + ) async { + try? FileManager.default.createDirectory(at: Self.contextDirectory, withIntermediateDirectories: true) + + let tables = await schemaProvider.getTables() + guard !tables.isEmpty else { + preamble = "" + preambleLineCount = 0 + return + } + + var columnsByTable: [String: [ColumnInfo]] = [:] + for table in tables { + let columns = await schemaProvider.getColumns(for: table.name) + if !columns.isEmpty { + columnsByTable[table.name.lowercased()] = columns + } + } + + var lines: [String] = [] + lines.append("-- Database: \(databaseName)") + lines.append("-- Dialect: \(databaseType.rawValue): use \(databaseType.rawValue) syntax only") + lines.append("--") + + for table in tables { + let columns = columnsByTable[table.name.lowercased()] ?? [] + guard !columns.isEmpty else { continue } + + let colDefs = columns.map { col -> String in + var parts = ["\(col.name) \(col.dataType)"] + if col.isPrimaryKey { parts.append("PK") } + if !col.isNullable { parts.append("NOT NULL") } + return parts.joined(separator: " ") + } + lines.append("-- \(table.name)(\(colDefs.joined(separator: ", ")))") + } + + lines.append("") + + preamble = lines.joined(separator: "\n") + preambleLineCount = lines.count - 1 + + Self.logger.info("Copilot schema preamble: \(tables.count) tables, \(self.preambleLineCount) lines") + } + + /// Prepend the preamble to user text for sending to Copilot + func prependToText(_ text: String) -> String { + guard !preamble.isEmpty else { return text } + return preamble + text + } +} diff --git a/TablePro/Core/AI/Copilot/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift new file mode 100644 index 000000000..b1f8304c8 --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -0,0 +1,224 @@ +// +// CopilotService.swift +// TablePro +// + +import Foundation +import os + +@MainActor @Observable +final class CopilotService { + private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotService") + static let shared = CopilotService() + + enum Status: Sendable, Equatable { + case stopped + case starting + case running + case error(String) + } + + enum AuthState: Sendable, Equatable { + case signedOut + case signingIn(userCode: String, verificationURI: String) + case signedIn(username: String) + + var isSignedIn: Bool { + if case .signedIn = self { return true } + return false + } + } + + private(set) var status: Status = .stopped + private(set) var authState: AuthState = .signedOut + private(set) var statusMessage: String? + + @ObservationIgnored private var lspClient: LSPClient? + @ObservationIgnored private var transport: LSPTransport? + @ObservationIgnored private var serverGeneration: Int = 0 + @ObservationIgnored private var restartTask: Task? + @ObservationIgnored private var restartAttempt: Int = 0 + @ObservationIgnored private let authManager = CopilotAuthManager() + + private init() {} + + var client: LSPClient? { lspClient } + var lspTransport: LSPTransport? { transport } + var isAuthenticated: Bool { authState.isSignedIn } + var generation: Int { serverGeneration } + + // MARK: - Lifecycle + + func start() async { + guard status != .starting, status != .running else { return } + serverGeneration += 1 + let generation = serverGeneration + status = .starting + + do { + let binaryPath = try await CopilotBinaryManager.shared.ensureBinary() + + let newTransport = LSPTransport() + try await newTransport.start(executablePath: binaryPath, arguments: ["--stdio"], environment: [:]) + + let client = LSPClient(transport: newTransport) + let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" + _ = try await client.initialize( + clientInfo: LSPClientInfo(name: "TablePro", version: appVersion), + editorPluginInfo: LSPClientInfo(name: "tablepro-copilot", version: "1.0.0"), + processId: Int(ProcessInfo.processInfo.processIdentifier) + ) + await client.initialized() + + let copilotSettings = AppSettingsManager.shared.copilot + let telemetryLevel: String = copilotSettings.telemetryEnabled ? "all" : "off" + await client.didChangeConfiguration(settings: [ + "telemetry": AnyCodable(["telemetryLevel": telemetryLevel]) + ]) + + guard generation == serverGeneration else { return } + + // Register notification handlers + await client.onNotification(method: "didChangeStatus") { [weak self] data in + Task { @MainActor [weak self] in + self?.handleStatusNotification(data) + } + } + + self.transport = newTransport + self.lspClient = client + status = .running + restartAttempt = 0 + + Self.logger.info("Copilot language server started successfully") + + await checkAuthStatus() + } catch { + guard generation == serverGeneration else { return } + status = .error(error.localizedDescription) + Self.logger.error("Failed to start Copilot: \(error.localizedDescription)") + + let isPermanent = error is CopilotError + if !isPermanent { + scheduleRestart() + } + } + } + + func stop() async { + restartTask?.cancel() + restartTask = nil + serverGeneration += 1 + + if let client = lspClient { + try? await client.shutdown() + await client.exit() + } + await transport?.stop() + + lspClient = nil + transport = nil + status = .stopped + Self.logger.info("Copilot language server stopped") + } + + // MARK: - Authentication + + func signIn() async throws { + guard let transport else { + throw CopilotError.serverNotRunning + } + let result = try await authManager.initiateSignIn(transport: transport) + authState = .signingIn(userCode: result.userCode, verificationURI: result.verificationURI) + } + + func completeSignIn() async throws { + guard let transport else { + throw CopilotError.serverNotRunning + } + let username = try await authManager.completeSignIn(transport: transport) + authState = .signedIn(username: username) + } + + func signOut() async { + guard let transport else { return } + await authManager.signOut(transport: transport) + authState = .signedOut + } + + // MARK: - Private + + private func scheduleRestart() { + restartAttempt += 1 + let delay = min(Double(1 << min(restartAttempt, 6)), 60.0) + Self.logger.info("Scheduling Copilot restart in \(delay)s (attempt \(self.restartAttempt))") + + restartTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + await self?.start() + } + } + + private struct CheckStatusResponse: Decodable { + let status: String + let user: String? + } + + private func checkAuthStatus() async { + guard let transport else { return } + do { + let data: Data = try await transport.sendRequest( + method: "checkStatus", + params: EmptyLSPParams() + ) + let response = try JSONDecoder().decode(CheckStatusResponse.self, from: data) + if response.status == "OK" || response.status == "AlreadySignedIn" { + authState = .signedIn(username: response.user ?? "") + Self.logger.info("Copilot already authenticated as \(response.user ?? "")") + } + } catch { + Self.logger.debug("Auth status check failed: \(error.localizedDescription)") + } + } + + private func handleStatusNotification(_ data: Data) { + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let params = json["params"] as? [String: Any] else { return } + + let kind = params["kind"] as? String ?? "Normal" + let message = params["message"] as? String + + switch kind { + case "Error": + statusMessage = message + if let message, message.lowercased().contains("sign") || message.lowercased().contains("expired") { + authState = .signedOut + } + case "Warning": + statusMessage = message + Self.logger.warning("Copilot warning: \(message ?? "")") + case "Inactive": + statusMessage = String(localized: "Copilot subscription inactive") + default: + statusMessage = nil + } + } +} + +enum CopilotError: Error, LocalizedError { + case serverNotRunning + case authenticationFailed(String) + case binaryNotFound + + var errorDescription: String? { + switch self { + case .serverNotRunning: + return String(localized: "Copilot server is not running") + case .authenticationFailed(let detail): + return String(format: String(localized: "Authentication failed: %@"), detail) + case .binaryNotFound: + return String(localized: "Copilot language server binary not found") + } + } +} diff --git a/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift b/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift new file mode 100644 index 000000000..09982d10a --- /dev/null +++ b/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift @@ -0,0 +1,122 @@ +// +// AIChatInlineSource.swift +// TablePro +// + +import Foundation +import os + +@MainActor +final class AIChatInlineSource: InlineSuggestionSource { + private static let logger = Logger(subsystem: "com.TablePro", category: "AIChatInlineSource") + + private weak var schemaProvider: SQLSchemaProvider? + var connectionPolicy: AIConnectionPolicy? + + init(schemaProvider: SQLSchemaProvider?, connectionPolicy: AIConnectionPolicy?) { + self.schemaProvider = schemaProvider + self.connectionPolicy = connectionPolicy + } + + var isAvailable: Bool { + let settings = AppSettingsManager.shared.ai + guard settings.enabled else { return false } + if connectionPolicy == .never { return false } + return settings.providers.contains(where: \.isEnabled) + } + + func requestSuggestion(context: SuggestionContext) async throws -> InlineSuggestion? { + let settings = AppSettingsManager.shared.ai + + guard let resolved = AIProviderFactory.resolve(for: .chat, settings: settings) else { + return nil + } + + let userMessage = AIPromptTemplates.inlineSuggest(textBefore: context.textBefore, fullQuery: context.fullText) + let messages = [ + AIChatMessage(role: .user, content: userMessage) + ] + + let systemPrompt = await buildSystemPrompt() + + var accumulated = "" + let stream = resolved.provider.streamChat( + messages: messages, + model: resolved.model, + systemPrompt: systemPrompt + ) + + for try await event in stream { + if case .text(let token) = event { + accumulated += token + } + } + + let cleaned = cleanSuggestion(accumulated) + guard !cleaned.isEmpty else { return nil } + + return InlineSuggestion( + text: cleaned, + replacementRange: nil, + replacementText: cleaned, + acceptCommand: nil + ) + } + + func didShowSuggestion(_ suggestion: InlineSuggestion) {} + func didAcceptSuggestion(_ suggestion: InlineSuggestion) {} + func didDismissSuggestion(_ suggestion: InlineSuggestion) {} + + // MARK: - Private + + private func buildSystemPrompt() async -> String { + let settings = AppSettingsManager.shared.ai + + guard settings.includeSchema, + let provider = schemaProvider else { + return AIPromptTemplates.inlineSuggestSystemPrompt() + } + + let schemaContext = await provider.buildSchemaContextForAI(settings: settings) + + if let schemaContext, !schemaContext.isEmpty { + return AIPromptTemplates.inlineSuggestSystemPrompt(schemaContext: schemaContext) + } + return AIPromptTemplates.inlineSuggestSystemPrompt() + } + + /// Clean the AI suggestion: strip thinking blocks, leading newlines, + /// and trailing whitespace, but preserve leading spaces. + private func cleanSuggestion(_ raw: String) -> String { + var result = raw + + result = stripThinkingBlocks(result) + + // Strip leading newlines only (preserve leading spaces) + while result.first?.isNewline == true { + result.removeFirst() + } + // Strip trailing whitespace and newlines + while result.last?.isWhitespace == true { + result.removeLast() + } + return result + } + + private static let thinkingRegex: NSRegularExpression? = try? NSRegularExpression( + pattern: ".*?|.*$", + options: [.caseInsensitive, .dotMatchesLineSeparators] + ) + + /// Remove `...` blocks (case-insensitive) from AI output. + /// Handles partial/unclosed tags too. + private func stripThinkingBlocks(_ text: String) -> String { + guard let regex = Self.thinkingRegex else { return text } + + return regex.stringByReplacingMatches( + in: text, + range: NSRange(location: 0, length: (text as NSString).length), + withTemplate: "" + ) + } +} diff --git a/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift b/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift new file mode 100644 index 000000000..0d739c015 --- /dev/null +++ b/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift @@ -0,0 +1,117 @@ +// +// GhostTextRenderer.swift +// TablePro +// + +@preconcurrency import AppKit +import CodeEditSourceEditor +import CodeEditTextView +import os + +@MainActor +final class GhostTextRenderer { + private static let logger = Logger(subsystem: "com.TablePro", category: "GhostTextRenderer") + + private weak var controller: TextViewController? + private var ghostLayer: CATextLayer? + private var currentText: String? + private var currentOffset: Int = 0 + private let _scrollObserver = OSAllocatedUnfairLock(initialState: nil) + + deinit { + if let observer = _scrollObserver.withLock({ $0 }) { NotificationCenter.default.removeObserver(observer) } + } + + func install(controller: TextViewController) { + self.controller = controller + } + + func show(_ text: String, at offset: Int) { + guard let textView = controller?.textView else { return } + guard let rect = textView.layoutManager.rectForOffset(offset) else { return } + + ghostLayer?.removeFromSuperlayer() + ghostLayer = nil + + currentText = text + currentOffset = offset + + let layer = CATextLayer() + layer.contentsScale = textView.window?.backingScaleFactor ?? 2.0 + layer.allowsFontSubpixelQuantization = true + + let font = ThemeEngine.shared.editorFonts.font + let attrs: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: NSColor.tertiaryLabelColor + ] + layer.string = NSAttributedString(string: text, attributes: attrs) + + let maxWidth = max(textView.bounds.width - rect.origin.x - 8, 200) + let boundingRect = (text as NSString).boundingRect( + with: NSSize(width: maxWidth, height: .greatestFiniteMagnitude), + options: [.usesLineFragmentOrigin, .usesFontLeading], + attributes: attrs + ) + + // isFlipped = true in CodeEditTextView, so y=0 is top — coords match layoutManager directly + layer.frame = CGRect( + x: rect.origin.x, + y: rect.origin.y, + width: ceil(boundingRect.width) + 4, + height: ceil(boundingRect.height) + 2 + ) + layer.isWrapped = true + + textView.layer?.addSublayer(layer) + ghostLayer = layer + installScrollObserver() + } + + func hide() { + ghostLayer?.removeFromSuperlayer() + ghostLayer = nil + currentText = nil + removeScrollObserver() + } + + func uninstall() { + hide() + removeScrollObserver() + controller = nil + } + + // MARK: - Scroll Observer + + private func installScrollObserver() { + guard _scrollObserver.withLock({ $0 }) == nil else { return } + guard let scrollView = controller?.scrollView else { return } + let contentView = scrollView.contentView + + _scrollObserver.withLock { + $0 = NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: contentView, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + self?.repositionGhostLayer() + } + } + } + } + + private func removeScrollObserver() { + _scrollObserver.withLock { + if let observer = $0 { + NotificationCenter.default.removeObserver(observer) + } + $0 = nil + } + } + + private func repositionGhostLayer() { + guard let text = currentText else { return } + show(text, at: currentOffset) + } +} diff --git a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift new file mode 100644 index 000000000..1b5958c12 --- /dev/null +++ b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift @@ -0,0 +1,270 @@ +// +// InlineSuggestionManager.swift +// TablePro +// + +@preconcurrency import AppKit +import CodeEditSourceEditor +import CodeEditTextView +import os + +/// Manages inline suggestions rendered as ghost text in the SQL editor. +/// Delegates actual suggestion fetching to an InlineSuggestionSource. +@MainActor +final class InlineSuggestionManager { + // MARK: - Properties + + private static let logger = Logger(subsystem: "com.TablePro", category: "InlineSuggestion") + + private weak var controller: TextViewController? + private let renderer = GhostTextRenderer() + private var sourceResolver: (@MainActor () -> InlineSuggestionSource?)? + private var currentSuggestion: InlineSuggestion? + private var suggestionOffset: Int = 0 + private var debounceTimer: Timer? + private var currentTask: Task? + private var generationID: UInt = 0 + private let debounceInterval: TimeInterval = 0.5 + private let _keyEventMonitor = OSAllocatedUnfairLock(initialState: nil) + private(set) var isEditorFocused = false + private var isUninstalled = false + + deinit { + if let monitor = _keyEventMonitor.withLock({ $0 }) { NSEvent.removeMonitor(monitor) } + } + + // MARK: - Install / Uninstall + + func install( + controller: TextViewController, + sourceResolver: @escaping @MainActor () -> InlineSuggestionSource? + ) { + self.controller = controller + self.sourceResolver = sourceResolver + renderer.install(controller: controller) + } + + func editorDidFocus() { + guard !isEditorFocused else { return } + isEditorFocused = true + installKeyEventMonitor() + } + + func editorDidBlur() { + guard isEditorFocused else { return } + isEditorFocused = false + dismissSuggestion() + removeKeyEventMonitor() + } + + func uninstall() { + guard !isUninstalled else { return } + isUninstalled = true + isEditorFocused = false + + debounceTimer?.invalidate() + debounceTimer = nil + currentTask?.cancel() + currentTask = nil + + renderer.uninstall() + removeKeyEventMonitor() + + sourceResolver = nil + controller = nil + } + + // MARK: - Text Change Handling + + func handleTextChange() { + dismissSuggestion() + scheduleSuggestion() + } + + func handleSelectionChange() { + guard currentSuggestion != nil else { return } + guard let controller else { return } + + let cursorOffset = controller.cursorPositions.first?.range.location ?? NSNotFound + if cursorOffset != suggestionOffset { + dismissSuggestion() + } + } + + // MARK: - Suggestion Scheduling + + private func scheduleSuggestion() { + debounceTimer?.invalidate() + + guard isEnabled() else { return } + + let timer = Timer(timeInterval: debounceInterval, repeats: false) { [weak self] _ in + Task { @MainActor [weak self] in + self?.requestSuggestion() + } + } + RunLoop.main.add(timer, forMode: .common) + debounceTimer = timer + } + + private func isEnabled() -> Bool { + guard let source = sourceResolver?() else { return false } + guard source.isAvailable else { return false } + guard let controller else { return false } + guard let textView = controller.textView else { return false } + guard textView.window?.firstResponder === textView else { return false } + guard let cursor = controller.cursorPositions.first, + cursor.range.length == 0 else { return false } + + let text = textView.string + guard (text as NSString).length > 0 else { return false } + + return true + } + + // MARK: - Request + + private func requestSuggestion() { + guard isEnabled() else { return } + guard let source = sourceResolver?() else { return } + guard let controller, let textView = controller.textView else { return } + + let cursorOffset = controller.cursorPositions.first?.range.location ?? 0 + guard cursorOffset > 0 else { return } + + let fullText = textView.string + let nsText = fullText as NSString + let textBefore = nsText.substring(to: min(cursorOffset, nsText.length)) + + currentTask?.cancel() + generationID &+= 1 + let myGeneration = generationID + + let (line, character) = Self.computeLineCharacter(text: nsText, offset: cursorOffset) + + let context = SuggestionContext( + textBefore: textBefore, + fullText: fullText, + cursorOffset: cursorOffset, + cursorLine: line, + cursorCharacter: character + ) + + currentTask = Task { @MainActor [weak self] in + guard let self else { return } + self.suggestionOffset = cursorOffset + + do { + guard let suggestion = try await source.requestSuggestion(context: context) else { return } + guard !Task.isCancelled, self.generationID == myGeneration else { return } + guard !suggestion.text.isEmpty else { return } + + self.currentSuggestion = suggestion + self.renderer.show(suggestion.text, at: cursorOffset) + source.didShowSuggestion(suggestion) + } catch { + if !Task.isCancelled { + Self.logger.debug("Inline suggestion failed: \(error.localizedDescription)") + } + } + } + } + + // MARK: - Accept / Dismiss + + private func acceptSuggestion() { + guard let suggestion = currentSuggestion, + let textView = controller?.textView else { return } + + renderer.hide() + currentSuggestion = nil + + if let range = suggestion.replacementRange { + textView.replaceCharacters(in: range, with: suggestion.replacementText) + } else { + textView.replaceCharacters( + in: NSRange(location: suggestionOffset, length: 0), + with: suggestion.replacementText + ) + } + + sourceResolver?()?.didAcceptSuggestion(suggestion) + } + + func dismissSuggestion() { + debounceTimer?.invalidate() + currentTask?.cancel() + currentTask = nil + + if let suggestion = currentSuggestion { + sourceResolver?()?.didDismissSuggestion(suggestion) + } + + renderer.hide() + currentSuggestion = nil + } + + // MARK: - Key Event Monitor + + private func installKeyEventMonitor() { + removeKeyEventMonitor() + _keyEventMonitor.withLock { $0 = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] nsEvent in + nonisolated(unsafe) let event = nsEvent + return MainActor.assumeIsolated { + guard let self, self.isEditorFocused else { return event } + + guard self.currentSuggestion != nil else { return event } + + guard let textView = self.controller?.textView, + event.window === textView.window, + textView.window?.firstResponder === textView else { return event } + + switch event.keyCode { + case 48: // Tab — accept suggestion + self.acceptSuggestion() + return nil + + case 53: // Escape — dismiss suggestion + self.dismissSuggestion() + return event + + default: + Task { @MainActor [weak self] in + self?.dismissSuggestion() + } + return event + } + } + } + } + } + + private func removeKeyEventMonitor() { + _keyEventMonitor.withLock { + if let monitor = $0 { NSEvent.removeMonitor(monitor) } + $0 = nil + } + } + + // MARK: - Helpers + + /// Convert a character offset to 0-based (line, character) pair. + static func computeLineCharacter(text: NSString, offset: Int) -> (Int, Int) { + var line = 0 + var lineStart = 0 + let length = text.length + let target = min(offset, length) + + var i = 0 + while i < target { + let ch = text.character(at: i) + i += 1 + if ch == 0x0A { // newline + line += 1 + lineStart = i + } + } + + return (line, target - lineStart) + } +} diff --git a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift new file mode 100644 index 000000000..a1da447a9 --- /dev/null +++ b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift @@ -0,0 +1,40 @@ +// +// InlineSuggestionSource.swift +// TablePro +// + +import Foundation + +/// Context passed to an inline suggestion source +struct SuggestionContext: Sendable { + let textBefore: String + let fullText: String + let cursorOffset: Int + let cursorLine: Int + let cursorCharacter: Int +} + +/// A completed inline suggestion +struct InlineSuggestion: Sendable { + /// Text to show as ghost text (only the part after the cursor) + let text: String + /// Range to replace on accept (nil = insert at cursor) + let replacementRange: NSRange? + /// Full text to insert when accepted (replaces range) + let replacementText: String + let acceptCommand: LSPCommand? +} + +/// Protocol for inline suggestion sources +@MainActor +protocol InlineSuggestionSource: AnyObject { + var isAvailable: Bool { get } + func requestSuggestion(context: SuggestionContext) async throws -> InlineSuggestion? + func didShowSuggestion(_ suggestion: InlineSuggestion) + func didAcceptSuggestion(_ suggestion: InlineSuggestion) + func didDismissSuggestion(_ suggestion: InlineSuggestion) +} + +extension InlineSuggestionSource { + func didShowSuggestion(_ suggestion: InlineSuggestion) {} +} diff --git a/TablePro/Core/AI/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestionManager.swift deleted file mode 100644 index ba6e4f51d..000000000 --- a/TablePro/Core/AI/InlineSuggestionManager.swift +++ /dev/null @@ -1,464 +0,0 @@ -// -// InlineSuggestionManager.swift -// TablePro -// -// Manages inline AI suggestions (ghost text) in the SQL editor. -// Debounces typing, streams completions from AI providers, and renders -// ghost text as a CATextLayer overlay on the text view. -// - -@preconcurrency import AppKit -import CodeEditSourceEditor -import CodeEditTextView -import os - -/// Manages Copilot-style inline SQL suggestions rendered as ghost text -@MainActor -final class InlineSuggestionManager { - // MARK: - Properties - - private static let logger = Logger(subsystem: "com.TablePro", category: "InlineSuggestion") - - private weak var controller: TextViewController? - private var debounceTimer: Timer? - private var currentTask: Task? - private let _keyEventMonitor = OSAllocatedUnfairLock(initialState: nil) - private let _scrollObserver = OSAllocatedUnfairLock(initialState: nil) - private(set) var isEditorFocused = false - - deinit { - if let monitor = _keyEventMonitor.withLock({ $0 }) { NSEvent.removeMonitor(monitor) } - if let observer = _scrollObserver.withLock({ $0 }) { NotificationCenter.default.removeObserver(observer) } - } - - /// The currently displayed suggestion text, nil when no suggestion is active - private(set) var currentSuggestion: String? - - /// The cursor offset where the suggestion was generated - private var suggestionOffset: Int = 0 - - /// Generation counter to detect stale completions - private var generationID: UInt = 0 - - /// The ghost text layer displaying the suggestion - private var ghostLayer: CATextLayer? - - /// Debounce interval in seconds before requesting a suggestion - private let debounceInterval: TimeInterval = 0.5 - - /// Shared schema provider (passed from coordinator, avoids duplicate schema fetches) - private var schemaProvider: SQLSchemaProvider? - - /// Connection-level AI policy — blocks suggestions when `.never` - var connectionPolicy: AIConnectionPolicy? - - /// Guard against double-uninstall (deinit + destroy can both call uninstall) - private var isUninstalled = false - - // MARK: - Install / Uninstall - - /// Install the manager on a TextViewController - func install(controller: TextViewController, schemaProvider: SQLSchemaProvider?) { - self.controller = controller - self.schemaProvider = schemaProvider - } - - func editorDidFocus() { - guard !isEditorFocused else { return } - isEditorFocused = true - installKeyEventMonitor() - } - - func editorDidBlur() { - guard isEditorFocused else { return } - isEditorFocused = false - dismissSuggestion() - removeKeyEventMonitor() - } - - /// Remove all observers and layers - func uninstall() { - guard !isUninstalled else { return } - isUninstalled = true - isEditorFocused = false - - debounceTimer?.invalidate() - debounceTimer = nil - currentTask?.cancel() - currentTask = nil - removeGhostLayer() - - removeKeyEventMonitor() - removeScrollObserver() - - schemaProvider = nil - connectionPolicy = nil - controller = nil - } - - // MARK: - Text Change Handling - - /// Called by the coordinator when text changes - func handleTextChange() { - dismissSuggestion() - scheduleSuggestion() - } - - /// Called by the coordinator when cursor selection changes - func handleSelectionChange() { - // If cursor moved away from the suggestion offset, dismiss - guard currentSuggestion != nil else { return } - guard let controller else { return } - - let cursorOffset = controller.cursorPositions.first?.range.location ?? NSNotFound - if cursorOffset != suggestionOffset { - dismissSuggestion() - } - } - - // MARK: - Suggestion Scheduling - - private func scheduleSuggestion() { - debounceTimer?.invalidate() - - guard isEnabled() else { return } - - let timer = Timer(timeInterval: debounceInterval, repeats: false) { [weak self] _ in - Task { @MainActor [weak self] in - self?.requestSuggestion() - } - } - RunLoop.main.add(timer, forMode: .common) - debounceTimer = timer - } - - private func isEnabled() -> Bool { - let settings = AppSettingsManager.shared.ai - guard settings.enabled else { return false } - guard settings.inlineSuggestEnabled else { return false } - if connectionPolicy == .never { return false } - guard let controller else { return false } - guard let textView = controller.textView else { return false } - - // Must be first responder - guard textView.window?.firstResponder === textView else { return false } - - // Must have a single cursor with no selection - guard let cursor = controller.cursorPositions.first, - cursor.range.length == 0 else { return false } - - // Must have some text - let text = textView.string - guard (text as NSString).length > 0 else { return false } - - return true - } - - // MARK: - Schema Context - - private func buildSystemPrompt() async -> String { - let settings = AppSettingsManager.shared.ai - - guard settings.includeSchema, - let provider = schemaProvider else { - return AIPromptTemplates.inlineSuggestSystemPrompt() - } - - // Build schema context from shared provider's cached data - let schemaContext = await provider.buildSchemaContextForAI(settings: settings) - - if let schemaContext, !schemaContext.isEmpty { - return AIPromptTemplates.inlineSuggestSystemPrompt(schemaContext: schemaContext) - } - return AIPromptTemplates.inlineSuggestSystemPrompt() - } - - // MARK: - AI Request - - private func requestSuggestion() { - guard isEnabled() else { return } - guard let controller, let textView = controller.textView else { return } - - let cursorOffset = controller.cursorPositions.first?.range.location ?? 0 - guard cursorOffset > 0 else { return } - - let fullText = textView.string - let nsText = fullText as NSString - let textBefore = nsText.substring(to: min(cursorOffset, nsText.length)) - - // Cancel any in-flight request - currentTask?.cancel() - generationID &+= 1 - let myGeneration = generationID - - currentTask = Task { @MainActor [weak self] in - guard let self else { return } - self.suggestionOffset = cursorOffset - - do { - let suggestion = try await self.fetchSuggestion(textBefore: textBefore, fullQuery: fullText) - - guard !Task.isCancelled, self.generationID == myGeneration else { return } - - let cleaned = self.cleanSuggestion(suggestion) - guard !cleaned.isEmpty else { return } - - self.currentSuggestion = cleaned - self.showGhostText(cleaned, at: cursorOffset) - } catch { - if !Task.isCancelled { - Self.logger.debug("Inline suggestion failed: \(error.localizedDescription)") - } - } - } - } - - private func fetchSuggestion(textBefore: String, fullQuery: String) async throws -> String { - let settings = AppSettingsManager.shared.ai - - guard let resolved = AIProviderFactory.resolve(for: .inlineSuggest, settings: settings) else { - throw AIProviderError.networkError("No AI provider configured") - } - - let userMessage = AIPromptTemplates.inlineSuggest(textBefore: textBefore, fullQuery: fullQuery) - let messages = [ - AIChatMessage(role: .user, content: userMessage) - ] - - let systemPrompt = await buildSystemPrompt() - - var accumulated = "" - let stream = resolved.provider.streamChat( - messages: messages, - model: resolved.model, - systemPrompt: systemPrompt - ) - - let flushInterval: ContinuousClock.Duration = .milliseconds(50) - var lastFlushTime: ContinuousClock.Instant = .now - - for try await event in stream { - guard !Task.isCancelled else { break } - if case .text(let token) = event { - accumulated += token - if ContinuousClock.now - lastFlushTime >= flushInterval { - let snapshot = accumulated - await MainActor.run { [weak self] in - guard let self else { return } - let cleaned = self.cleanSuggestion(snapshot) - if !cleaned.isEmpty { - self.currentSuggestion = cleaned - self.showGhostText(cleaned, at: self.suggestionOffset) - } - } - lastFlushTime = .now - } - } - } - - // Final flush - if !Task.isCancelled, !accumulated.isEmpty { - let snapshot = accumulated - await MainActor.run { [weak self] in - guard let self else { return } - let cleaned = self.cleanSuggestion(snapshot) - if !cleaned.isEmpty { - self.currentSuggestion = cleaned - self.showGhostText(cleaned, at: self.suggestionOffset) - } - } - } - - return accumulated - } - - /// Clean the AI suggestion: strip thinking blocks, leading newlines, - /// and trailing whitespace, but preserve leading spaces. - private func cleanSuggestion(_ raw: String) -> String { - var result = raw - - // Strip thinking blocks (e.g. ..., ...) - // Some models emit chain-of-thought reasoning wrapped in these tags - result = stripThinkingBlocks(result) - - // Strip leading newlines only (preserve leading spaces) - while result.first?.isNewline == true { - result.removeFirst() - } - // Strip trailing whitespace and newlines - while result.last?.isWhitespace == true { - result.removeLast() - } - return result - } - - /// Precompiled regex for stripping `...` blocks - private static let thinkingRegex: NSRegularExpression? = try? NSRegularExpression( - pattern: ".*?|.*$", - options: [.caseInsensitive, .dotMatchesLineSeparators] - ) - - /// Remove `...` blocks (case-insensitive) from AI output. - /// Handles partial/unclosed tags too — if a `` opens but never closes, - /// everything from that tag onward is stripped. - private func stripThinkingBlocks(_ text: String) -> String { - guard let regex = Self.thinkingRegex else { return text } - - return regex.stringByReplacingMatches( - in: text, - range: NSRange(location: 0, length: (text as NSString).length), - withTemplate: "" - ) - } - - // MARK: - Ghost Text Rendering - - private func showGhostText(_ text: String, at offset: Int) { - guard let textView = controller?.textView else { return } - guard let rect = textView.layoutManager.rectForOffset(offset) else { return } - - removeGhostLayer() - - let layer = CATextLayer() - layer.contentsScale = textView.window?.backingScaleFactor ?? 2.0 - layer.allowsFontSubpixelQuantization = true - - // Use the editor's font and grey color for ghost appearance - let font = ThemeEngine.shared.editorFonts.font - let attrs: [NSAttributedString.Key: Any] = [ - .font: font, - .foregroundColor: NSColor.tertiaryLabelColor - ] - layer.string = NSAttributedString(string: text, attributes: attrs) - - // Calculate the size needed for the ghost text - let maxWidth = max(textView.bounds.width - rect.origin.x - 8, 200) - let boundingRect = (text as NSString).boundingRect( - with: NSSize(width: maxWidth, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: attrs - ) - - // Position the layer at the cursor location - // isFlipped = true in CodeEditTextView, so y=0 is top — coords match layoutManager directly - layer.frame = CGRect( - x: rect.origin.x, - y: rect.origin.y, - width: ceil(boundingRect.width) + 4, - height: ceil(boundingRect.height) + 2 - ) - layer.isWrapped = true - - textView.layer?.addSublayer(layer) - ghostLayer = layer - installScrollObserver() - } - - private func removeGhostLayer() { - ghostLayer?.removeFromSuperlayer() - ghostLayer = nil - } - - // MARK: - Accept / Dismiss - - private func acceptSuggestion() { - guard let suggestion = currentSuggestion, - let textView = controller?.textView else { return } - - let offset = suggestionOffset - removeGhostLayer() - currentSuggestion = nil - removeScrollObserver() - - textView.replaceCharacters( - in: NSRange(location: offset, length: 0), - with: suggestion - ) - } - - func dismissSuggestion() { - debounceTimer?.invalidate() - currentTask?.cancel() - currentTask = nil - removeGhostLayer() - currentSuggestion = nil - removeScrollObserver() - } - - // MARK: - Key Event Monitor - - private func installKeyEventMonitor() { - removeKeyEventMonitor() - _keyEventMonitor.withLock { $0 = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] nsEvent in - nonisolated(unsafe) let event = nsEvent - return MainActor.assumeIsolated { - guard let self, self.isEditorFocused else { return event } - - guard AppSettingsManager.shared.ai.inlineSuggestEnabled else { return event } - - guard self.currentSuggestion != nil else { return event } - - guard let textView = self.controller?.textView, - event.window === textView.window, - textView.window?.firstResponder === textView else { return event } - - switch event.keyCode { - case 48: // Tab — accept suggestion - self.acceptSuggestion() - return nil - - case 53: // Escape — dismiss suggestion - self.dismissSuggestion() - return nil - - default: - // Deferred: must return the event to AppKit first, then dismiss - Task { @MainActor [weak self] in - self?.dismissSuggestion() - } - return event - } - } - } - } - } - - private func removeKeyEventMonitor() { - _keyEventMonitor.withLock { - if let monitor = $0 { NSEvent.removeMonitor(monitor) } - $0 = nil - } - } - - // MARK: - Scroll Observer - - private func installScrollObserver() { - guard _scrollObserver.withLock({ $0 }) == nil else { return } - guard let scrollView = controller?.scrollView else { return } - let contentView = scrollView.contentView - - _scrollObserver.withLock { - $0 = NotificationCenter.default.addObserver( - forName: NSView.boundsDidChangeNotification, - object: contentView, - queue: .main - ) { [weak self] _ in - Task { @MainActor [weak self] in - guard let self else { return } - if let suggestion = self.currentSuggestion { - self.showGhostText(suggestion, at: self.suggestionOffset) - } - } - } - } - } - - private func removeScrollObserver() { - _scrollObserver.withLock { - if let observer = $0 { - NotificationCenter.default.removeObserver(observer) - } - $0 = nil - } - } -} diff --git a/TablePro/Core/AI/Registry/AIProviderDescriptor.swift b/TablePro/Core/AI/Registry/AIProviderDescriptor.swift new file mode 100644 index 000000000..ef952c08a --- /dev/null +++ b/TablePro/Core/AI/Registry/AIProviderDescriptor.swift @@ -0,0 +1,28 @@ +// +// AIProviderDescriptor.swift +// TablePro +// +// Descriptor for an AI provider type, including capabilities and factory closure. +// + +import Foundation + +/// Capabilities supported by an AI provider +struct AIProviderCapabilities: OptionSet, Sendable { + let rawValue: UInt8 + + static let chat = AIProviderCapabilities(rawValue: 1 << 0) + static let inline = AIProviderCapabilities(rawValue: 1 << 1) + static let models = AIProviderCapabilities(rawValue: 1 << 2) +} + +/// Describes an AI provider type for the registry +struct AIProviderDescriptor: Sendable { + let typeID: String + let displayName: String + let defaultEndpoint: String + let requiresAPIKey: Bool + let capabilities: AIProviderCapabilities + let symbolName: String + let makeProvider: @Sendable (AIProviderConfig, String?) -> AIProvider +} diff --git a/TablePro/Core/AI/Registry/AIProviderRegistration.swift b/TablePro/Core/AI/Registry/AIProviderRegistration.swift new file mode 100644 index 000000000..a3b088bdf --- /dev/null +++ b/TablePro/Core/AI/Registry/AIProviderRegistration.swift @@ -0,0 +1,89 @@ +// +// AIProviderRegistration.swift +// TablePro +// +// Registers all built-in AI provider descriptors at app launch. +// + +import Foundation + +enum AIProviderRegistration { + static func registerAll() { + let registry = AIProviderRegistry.shared + + registry.register(AIProviderDescriptor( + typeID: AIProviderType.claude.rawValue, + displayName: "Claude", + defaultEndpoint: "https://api.anthropic.com", + requiresAPIKey: true, + capabilities: [.chat, .models], + symbolName: "brain", + makeProvider: { config, apiKey in + AnthropicProvider( + endpoint: config.endpoint, + apiKey: apiKey ?? "", + maxOutputTokens: config.maxOutputTokens ?? 4_096 + ) + } + )) + + registry.register(AIProviderDescriptor( + typeID: AIProviderType.gemini.rawValue, + displayName: "Gemini", + defaultEndpoint: "https://generativelanguage.googleapis.com", + requiresAPIKey: true, + capabilities: [.chat, .models], + symbolName: "wand.and.stars", + makeProvider: { config, apiKey in + GeminiProvider( + endpoint: config.endpoint, + apiKey: apiKey ?? "", + maxOutputTokens: config.maxOutputTokens ?? 8_192 + ) + } + )) + + // OpenAI, OpenRouter, Ollama, Custom all use OpenAICompatibleProvider + for type in [AIProviderType.openAI, .openRouter, .ollama, .custom] { + registry.register(AIProviderDescriptor( + typeID: type.rawValue, + displayName: type.displayName, + defaultEndpoint: type.defaultEndpoint, + requiresAPIKey: type.requiresAPIKey, + capabilities: [.chat, .models], + symbolName: iconForType(type), + makeProvider: { config, apiKey in + OpenAICompatibleProvider( + endpoint: config.endpoint, + apiKey: apiKey, + providerType: config.type, + maxOutputTokens: config.maxOutputTokens + ) + } + )) + } + + // Copilot: chat via LSP, no API key + registry.register(AIProviderDescriptor( + typeID: AIProviderType.copilot.rawValue, + displayName: "GitHub Copilot", + defaultEndpoint: "", + requiresAPIKey: false, + capabilities: [.chat, .models], + symbolName: "chevron.left.forwardslash.chevron.right", + makeProvider: { _, _ in + CopilotChatProvider() + } + )) + } + + private static func iconForType(_ type: AIProviderType) -> String { + switch type { + case .openAI: return "cpu" + case .openRouter: return "globe" + case .ollama: return "desktopcomputer" + case .custom: return "server.rack" + default: return "questionmark.circle" + } + } +} diff --git a/TablePro/Core/AI/Registry/AIProviderRegistry.swift b/TablePro/Core/AI/Registry/AIProviderRegistry.swift new file mode 100644 index 000000000..939781708 --- /dev/null +++ b/TablePro/Core/AI/Registry/AIProviderRegistry.swift @@ -0,0 +1,33 @@ +// +// AIProviderRegistry.swift +// TablePro +// +// Thread-safe registry of all known AI provider descriptors. +// + +import Foundation +import os + +/// Singleton registry of AI provider descriptors +final class AIProviderRegistry: @unchecked Sendable { + private static let logger = Logger(subsystem: "com.TablePro", category: "AIProviderRegistry") + + static let shared = AIProviderRegistry() + + private let lock = OSAllocatedUnfairLock(initialState: [String: AIProviderDescriptor]()) + + private init() {} + + func register(_ descriptor: AIProviderDescriptor) { + lock.withLock { $0[descriptor.typeID] = descriptor } + Self.logger.debug("Registered AI provider: \(descriptor.typeID)") + } + + func descriptor(for typeID: String) -> AIProviderDescriptor? { + lock.withLock { $0[typeID] } + } + + var allDescriptors: [AIProviderDescriptor] { + lock.withLock { Array($0.values) } + } +} diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 9556e6e86..79291d297 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -39,6 +39,7 @@ actor SQLSchemaProvider { if let existing = loadTask { Self.logger.debug("[schema] loadSchema awaiting existing in-flight task") let t0 = Date() + if let connection { self.connectionInfo = connection } await existing.value Self.logger.debug("[schema] loadSchema coalesced — awaited existing task ms=\(Int(Date().timeIntervalSince(t0) * 1_000)) tableCount=\(self.tables.count)") return @@ -47,7 +48,7 @@ actor SQLSchemaProvider { Self.logger.info("[schema] loadSchema starting new fetch") let t0 = Date() self.cachedDriver = driver - self.connectionInfo = connection + if let connection { self.connectionInfo = connection } isLoading = true lastLoadError = nil @@ -76,6 +77,11 @@ actor SQLSchemaProvider { isLoading = false } + /// Get the current connection info + func getConnectionInfo() -> DatabaseConnection? { + connectionInfo + } + /// Get all tables func getTables() -> [TableInfo] { tables diff --git a/TablePro/Core/LSP/LSPClient.swift b/TablePro/Core/LSP/LSPClient.swift new file mode 100644 index 000000000..12ec1112c --- /dev/null +++ b/TablePro/Core/LSP/LSPClient.swift @@ -0,0 +1,153 @@ +// +// LSPClient.swift +// TablePro +// + +import Foundation +import os + +actor LSPClient { + private static let logger = Logger(subsystem: "com.TablePro", category: "LSPClient") + + private let transport: LSPTransport + + init(transport: LSPTransport) { + self.transport = transport + } + + // MARK: - Lifecycle + + func initialize( + clientInfo: LSPClientInfo, + editorPluginInfo: LSPClientInfo?, + processId: Int?, + workspaceFolders: [LSPWorkspaceFolder]? = nil + ) async throws -> LSPInitializeResult { + let params = LSPInitializeParams( + processId: processId ?? Int(ProcessInfo.processInfo.processIdentifier), + capabilities: LSPClientCapabilities( + general: LSPGeneralCapabilities(positionEncodings: ["utf-16"]) + ), + initializationOptions: LSPInitializationOptions( + editorInfo: clientInfo, + editorPluginInfo: editorPluginInfo + ), + workspaceFolders: workspaceFolders + ) + + let data = try await transport.sendRequest(method: "initialize", params: params) + Self.logger.info("LSP initialized") + return LSPInitializeResult(rawData: data) + } + + func initialized() async { + do { + try await transport.sendNotification(method: "initialized", params: EmptyLSPParams()) + } catch { + Self.logger.warning("Failed to send initialized: \(error.localizedDescription)") + } + } + + func shutdown() async throws { + _ = try await transport.sendRequest(method: "shutdown", params: EmptyLSPParams()) + Self.logger.info("LSP shutdown complete") + } + + func exit() async { + do { + try await transport.sendNotification(method: "exit", params: EmptyLSPParams()) + } catch { + Self.logger.debug("Failed to send exit: \(error.localizedDescription)") + } + } + + // MARK: - Document Sync + + func didOpenDocument(_ item: LSPTextDocumentItem) async { + let params = LSPDidOpenParams(textDocument: item) + do { + try await transport.sendNotification(method: "textDocument/didOpen", params: params) + } catch { + Self.logger.debug("Failed to send didOpen: \(error.localizedDescription)") + } + } + + func didChangeDocument( + uri: String, + version: Int, + changes: [LSPTextDocumentContentChangeEvent] + ) async { + let params = LSPDidChangeParams( + textDocument: LSPVersionedTextDocumentIdentifier(uri: uri, version: version), + contentChanges: changes + ) + do { + try await transport.sendNotification(method: "textDocument/didChange", params: params) + } catch { + Self.logger.debug("Failed to send didChange: \(error.localizedDescription)") + } + } + + func didCloseDocument(uri: String) async { + let params = LSPDocumentParams(textDocument: LSPTextDocumentIdentifier(uri: uri)) + do { + try await transport.sendNotification(method: "textDocument/didClose", params: params) + } catch { + Self.logger.debug("Failed to send didClose: \(error.localizedDescription)") + } + } + + func didFocusDocument(uri: String) async { + let params = LSPDocumentParams(textDocument: LSPTextDocumentIdentifier(uri: uri)) + do { + try await transport.sendNotification(method: "textDocument/didFocus", params: params) + } catch { + Self.logger.debug("Failed to send didFocus: \(error.localizedDescription)") + } + } + + // MARK: - Inline Completions + + func inlineCompletion(params: LSPInlineCompletionParams) async throws -> LSPInlineCompletionList { + let data = try await transport.sendRequest(method: "textDocument/inlineCompletion", params: params) + + if let list = try? JSONDecoder().decode(LSPInlineCompletionList.self, from: data) { + return list + } + if let items = try? JSONDecoder().decode([LSPInlineCompletionItem].self, from: data) { + return LSPInlineCompletionList(items: items) + } + + return LSPInlineCompletionList(items: []) + } + + // MARK: - Commands + + func executeCommand(command: String, arguments: [AnyCodable]?) async throws { + let params = LSPExecuteCommandParams(command: command, arguments: arguments) + _ = try await transport.sendRequest(method: "workspace/executeCommand", params: params) + } + + // MARK: - Configuration + + func didChangeConfiguration(settings: [String: AnyCodable]) async { + let params = LSPConfigurationParams(settings: settings) + do { + try await transport.sendNotification(method: "workspace/didChangeConfiguration", params: params) + } catch { + Self.logger.debug("Failed to send didChangeConfiguration: \(error.localizedDescription)") + } + } + + // MARK: - Cancel Request + + func cancelRequest(id: Int) async { + await transport.cancelRequest(id: id) + } + + // MARK: - Notifications from Server + + func onNotification(method: String, handler: @escaping @Sendable (Data) -> Void) async { + await transport.onNotification(method: method, handler: handler) + } +} diff --git a/TablePro/Core/LSP/LSPDocumentManager.swift b/TablePro/Core/LSP/LSPDocumentManager.swift new file mode 100644 index 000000000..d79d3ccf8 --- /dev/null +++ b/TablePro/Core/LSP/LSPDocumentManager.swift @@ -0,0 +1,49 @@ +// +// LSPDocumentManager.swift +// TablePro +// + +import Foundation + +@MainActor +final class LSPDocumentManager { + struct DocumentState { + var uri: String + var version: Int + var languageId: String + } + + private var documents: [String: DocumentState] = [:] + + func openDocument(uri: String, languageId: String, text: String) -> LSPTextDocumentItem { + let state = DocumentState(uri: uri, version: 0, languageId: languageId) + documents[uri] = state + return LSPTextDocumentItem(uri: uri, languageId: languageId, version: 0, text: text) + } + + func changeDocument( + uri: String, + newText: String + ) -> (versioned: LSPVersionedTextDocumentIdentifier, changes: [LSPTextDocumentContentChangeEvent])? { + guard var state = documents[uri] else { return nil } + state.version += 1 + documents[uri] = state + return ( + versioned: LSPVersionedTextDocumentIdentifier(uri: uri, version: state.version), + changes: [LSPTextDocumentContentChangeEvent(text: newText)] + ) + } + + func closeDocument(uri: String) -> LSPTextDocumentIdentifier? { + guard documents.removeValue(forKey: uri) != nil else { return nil } + return LSPTextDocumentIdentifier(uri: uri) + } + + func version(for uri: String) -> Int? { + documents[uri]?.version + } + + func isOpen(_ uri: String) -> Bool { + documents[uri] != nil + } +} diff --git a/TablePro/Core/LSP/LSPTransport.swift b/TablePro/Core/LSP/LSPTransport.swift new file mode 100644 index 000000000..3893cab1d --- /dev/null +++ b/TablePro/Core/LSP/LSPTransport.swift @@ -0,0 +1,281 @@ +// +// LSPTransport.swift +// TablePro +// + +import Foundation +import os + +// MARK: - LSPTransportError + +enum LSPTransportError: Error, LocalizedError { + case processNotRunning + case processExited(Int32) + case invalidResponse + case requestCancelled + case serverError(Int, String) + + var errorDescription: String? { + switch self { + case .processNotRunning: + return String(localized: "LSP process is not running") + case .processExited(let code): + return String(format: String(localized: "LSP process exited with code %d"), code) + case .invalidResponse: + return String(localized: "Invalid LSP response") + case .requestCancelled: + return String(localized: "LSP request was cancelled") + case .serverError(let code, let message): + return String(format: String(localized: "LSP server error (%d): %@"), code, message) + } + } +} + +// MARK: - LSPTransport + +actor LSPTransport { + private static let logger = Logger(subsystem: "com.TablePro", category: "LSPTransport") + + private var process: Process? + private var stdinPipe: Pipe? + private var stdoutPipe: Pipe? + private var nextRequestID: Int = 1 + private var pendingRequests: [Int: CheckedContinuation] = [:] + private var notificationHandlers: [String: @Sendable (Data) -> Void] = [:] + private var readerQueue: DispatchQueue? + + // MARK: - Lifecycle + + func start(executablePath: String, arguments: [String] = [], environment: [String: String]? = nil) throws { + let proc = Process() + proc.executableURL = URL(fileURLWithPath: executablePath) + proc.arguments = arguments + + if let environment { + var env = ProcessInfo.processInfo.environment + for (key, value) in environment { + env[key] = value + } + proc.environment = env + } + + let stdin = Pipe() + let stdout = Pipe() + let stderr = Pipe() + proc.standardInput = stdin + proc.standardOutput = stdout + proc.standardError = stderr + + self.stdinPipe = stdin + self.stdoutPipe = stdout + self.process = proc + + proc.terminationHandler = { [weak self] terminatedProcess in + let code = terminatedProcess.terminationStatus + Task { [weak self] in + await self?.handleProcessExit(code: code) + } + } + + // Drain stderr to prevent pipe buffer from filling + stderr.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + if !data.isEmpty, let text = String(data: data, encoding: .utf8) { + Self.logger.debug("LSP stderr: \(text)") + } + } + + try proc.run() + + let queue = DispatchQueue(label: "com.TablePro.LSPTransport.reader") + self.readerQueue = queue + let handle = stdout.fileHandleForReading + queue.async { [weak self] in + self?.readLoopSync(handle: handle) + } + + Self.logger.info("LSP transport started: \(executablePath)") + } + + func stop() { + let pending = pendingRequests + pendingRequests.removeAll() + for (_, continuation) in pending { + continuation.resume(throwing: LSPTransportError.requestCancelled) + } + + if let process, process.isRunning { + process.terminate() + } + process = nil + stdinPipe = nil + stdoutPipe = nil + readerQueue = nil + + Self.logger.info("LSP transport stopped") + } + + // MARK: - Send Request + + func sendRequest(method: String, params: P?) async throws -> Data { + guard let process, process.isRunning else { + throw LSPTransportError.processNotRunning + } + + let requestID = nextRequestID + nextRequestID += 1 + + let request = LSPJSONRPCRequest(id: requestID, method: method, params: params) + let data = try JSONEncoder().encode(request) + try writeMessage(data) + + return try await withCheckedThrowingContinuation { continuation in + pendingRequests[requestID] = continuation + } + } + + // MARK: - Send Notification + + func sendNotification(method: String, params: P?) throws { + guard let process, process.isRunning else { + throw LSPTransportError.processNotRunning + } + + let notification = LSPJSONRPCNotification(method: method, params: params) + let data = try JSONEncoder().encode(notification) + try writeMessage(data) + } + + // MARK: - Cancel Request + + func cancelRequest(id: Int) { + let params: [String: Int] = ["id": id] + if let data = try? JSONEncoder().encode(LSPJSONRPCNotification(method: "$/cancelRequest", params: params)) { + try? writeMessage(data) + } + } + + // MARK: - Notification Handlers + + func onNotification(method: String, handler: @escaping @Sendable (Data) -> Void) { + notificationHandlers[method] = handler + } + + // MARK: - Private + + private func writeMessage(_ data: Data) throws { + guard let stdinPipe else { + throw LSPTransportError.processNotRunning + } + + let header = "Content-Length: \(data.count)\r\n\r\n" + guard let headerData = header.data(using: .utf8) else { + throw LSPTransportError.invalidResponse + } + + let handle = stdinPipe.fileHandleForWriting + handle.write(headerData) + handle.write(data) + } + + /// Blocking read loop that runs on a dedicated DispatchQueue to avoid blocking the actor executor. + private nonisolated func readLoopSync(handle: FileHandle) { + var buffer = Data() + + while true { + let chunk = handle.availableData + guard !chunk.isEmpty else { break } // EOF + buffer.append(chunk) + + while let (messageData, consumed) = Self.parseMessageFromBuffer(&buffer) { + let data = messageData + Task { [weak self] in await self?.dispatchMessage(data) } + } + } + } + + /// Parse a single LSP message from the buffer. + /// Returns (messageBody, totalBytesConsumed) or nil if buffer is incomplete. + private static func parseMessageFromBuffer(_ buffer: inout Data) -> (Data, Int)? { + let separator: [UInt8] = [0x0D, 0x0A, 0x0D, 0x0A] + guard let separatorRange = buffer.range(of: Data(separator)) else { + return nil + } + + let headerData = buffer[buffer.startIndex..: Encodable { + let jsonrpc: String = "2.0" + let id: Int + let method: String + let params: P? +} + +struct LSPJSONRPCNotification: Encodable { + let jsonrpc: String = "2.0" + let method: String + let params: P? +} + +struct LSPJSONRPCResponse: Decodable { + let id: Int? + let result: R? + let error: LSPJSONRPCError? +} + +struct LSPJSONRPCError: Decodable, Sendable { + let code: Int + let message: String +} + +// MARK: - AnyCodable + +/// Type-erased Codable wrapper for heterogeneous JSON values +struct AnyCodable: Codable, Sendable, Equatable { + let value: AnyCodableValue + + init(_ value: Any?) { + if let value { + self.value = AnyCodableValue(value) + } else { + self.value = .null + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + + if container.decodeNil() { + value = .null + } else if let boolVal = try? container.decode(Bool.self) { + value = .bool(boolVal) + } else if let intVal = try? container.decode(Int.self) { + value = .int(intVal) + } else if let doubleVal = try? container.decode(Double.self) { + value = .double(doubleVal) + } else if let stringVal = try? container.decode(String.self) { + value = .string(stringVal) + } else if let arrayVal = try? container.decode([AnyCodable].self) { + value = .array(arrayVal) + } else if let dictVal = try? container.decode([String: AnyCodable].self) { + value = .dictionary(dictVal) + } else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unsupported AnyCodable value") + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch value { + case .null: + try container.encodeNil() + case .bool(let boolVal): + try container.encode(boolVal) + case .int(let intVal): + try container.encode(intVal) + case .double(let doubleVal): + try container.encode(doubleVal) + case .string(let stringVal): + try container.encode(stringVal) + case .array(let arrayVal): + try container.encode(arrayVal) + case .dictionary(let dictVal): + try container.encode(dictVal) + } + } +} + +enum AnyCodableValue: Sendable, Equatable { + case null + case bool(Bool) + case int(Int) + case double(Double) + case string(String) + case array([AnyCodable]) + case dictionary([String: AnyCodable]) + + init(_ value: Any) { + switch value { + case let boolVal as Bool: + self = .bool(boolVal) + case let intVal as Int: + self = .int(intVal) + case let doubleVal as Double: + self = .double(doubleVal) + case let stringVal as String: + self = .string(stringVal) + case let arrayVal as [Any]: + self = .array(arrayVal.map { AnyCodable($0) }) + case let dictVal as [String: Any]: + self = .dictionary(dictVal.mapValues { AnyCodable($0) }) + default: + self = .null + } + } +} diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index 37c92ae56..319aa47a4 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -161,6 +161,23 @@ final class AppSettingsManager { } } + var copilot: CopilotSettings { + didSet { + storage.saveCopilot(copilot) + SyncChangeTracker.shared.markDirty(.settings, id: "copilot") + if copilot.enabled != oldValue.enabled { + let shouldEnable = copilot.enabled + Task { + if shouldEnable { + await CopilotService.shared.start() + } else { + await CopilotService.shared.stop() + } + } + } + } + } + @ObservationIgnored private let storage = AppSettingsStorage.shared /// Reentrancy guard for didSet validation that re-assigns the property. @ObservationIgnored private var isValidating = false @@ -185,6 +202,7 @@ final class AppSettingsManager { self.sync = storage.loadSync() self.terminal = storage.loadTerminal() self.mcp = storage.loadMCP() + self.copilot = storage.loadCopilot() // Apply language immediately general.language.apply() @@ -209,6 +227,11 @@ final class AppSettingsManager { // Observe system accessibility text size changes and re-apply editor fonts observeAccessibilityTextSizeChanges() + + // Start Copilot service if enabled (didSet doesn't fire during init) + if copilot.enabled { + Task { await CopilotService.shared.start() } + } } // MARK: - Notification Propagation @@ -262,6 +285,7 @@ final class AppSettingsManager { sync = .default terminal = .default mcp = .default + copilot = .default storage.resetToDefaults() } } diff --git a/TablePro/Core/Storage/AppSettingsStorage.swift b/TablePro/Core/Storage/AppSettingsStorage.swift index 973832e54..27780f696 100644 --- a/TablePro/Core/Storage/AppSettingsStorage.swift +++ b/TablePro/Core/Storage/AppSettingsStorage.swift @@ -32,6 +32,7 @@ final class AppSettingsStorage { static let sync = "com.TablePro.settings.sync" static let terminal = "com.TablePro.settings.terminal" static let mcp = "com.TablePro.settings.mcp" + static let copilot = "com.TablePro.settings.copilot" static let lastConnectionId = "com.TablePro.settings.lastConnectionId" static let lastOpenConnectionIds = "com.TablePro.settings.lastOpenConnectionIds" static let hasCompletedOnboarding = "com.TablePro.settings.hasCompletedOnboarding" @@ -150,6 +151,16 @@ final class AppSettingsStorage { save(settings, key: Keys.mcp) } + // MARK: - Copilot Settings + + func loadCopilot() -> CopilotSettings { + load(key: Keys.copilot, default: .default) + } + + func saveCopilot(_ settings: CopilotSettings) { + save(settings, key: Keys.copilot) + } + // MARK: - Last Connection (for Reopen Last Session) /// Load the last used connection ID @@ -243,6 +254,7 @@ final class AppSettingsStorage { saveSync(.default) saveTerminal(.default) saveMCP(.default) + saveCopilot(.default) } // MARK: - Helpers diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 41d120cbf..7c3032a18 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -17,6 +17,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable { case ollama = "ollama" case gemini = "gemini" case custom = "custom" + case copilot = "copilot" var id: String { rawValue } @@ -28,6 +29,7 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable { case .ollama: return "Ollama" case .gemini: return "Gemini" case .custom: return String(localized: "Custom") + case .copilot: return "GitHub Copilot" } } @@ -39,12 +41,13 @@ enum AIProviderType: String, Codable, CaseIterable, Identifiable { case .ollama: return "http://localhost:11434" case .gemini: return "https://generativelanguage.googleapis.com" case .custom: return "" + case .copilot: return "" } } var requiresAPIKey: Bool { switch self { - case .ollama: return false + case .ollama, .copilot: return false default: return true } } @@ -144,6 +147,7 @@ struct AISettings: Codable, Equatable { var maxSchemaTables: Int var defaultConnectionPolicy: AIConnectionPolicy var inlineSuggestEnabled: Bool + var copilotChatEnabled: Bool static let `default` = AISettings( enabled: true, @@ -154,7 +158,8 @@ struct AISettings: Codable, Equatable { includeQueryResults: false, maxSchemaTables: 20, defaultConnectionPolicy: .askEachTime, - inlineSuggestEnabled: false + inlineSuggestEnabled: false, + copilotChatEnabled: false ) init( @@ -166,7 +171,8 @@ struct AISettings: Codable, Equatable { includeQueryResults: Bool = false, maxSchemaTables: Int = 20, defaultConnectionPolicy: AIConnectionPolicy = .askEachTime, - inlineSuggestEnabled: Bool = false + inlineSuggestEnabled: Bool = false, + copilotChatEnabled: Bool = false ) { self.enabled = enabled self.providers = providers @@ -177,6 +183,7 @@ struct AISettings: Codable, Equatable { self.maxSchemaTables = maxSchemaTables self.defaultConnectionPolicy = defaultConnectionPolicy self.inlineSuggestEnabled = inlineSuggestEnabled + self.copilotChatEnabled = copilotChatEnabled } init(from decoder: Decoder) throws { @@ -192,6 +199,7 @@ struct AISettings: Codable, Equatable { AIConnectionPolicy.self, forKey: .defaultConnectionPolicy ) ?? .askEachTime inlineSuggestEnabled = try container.decodeIfPresent(Bool.self, forKey: .inlineSuggestEnabled) ?? false + copilotChatEnabled = try container.decodeIfPresent(Bool.self, forKey: .copilotChatEnabled) ?? false } } @@ -245,3 +253,11 @@ enum AIStreamEvent { case text(String) case usage(AITokenUsage) } + +// MARK: - Copilot Provider ID + +extension AIProviderConfig { + // Stable UUID for the synthetic Copilot provider config (not stored in user providers array) + // swiftlint:disable:next force_unwrapping + static let copilotProviderID = UUID(uuidString: "10000000-0000-0000-0000-000000000001")! +} diff --git a/TablePro/Models/AI/CopilotSettings.swift b/TablePro/Models/AI/CopilotSettings.swift new file mode 100644 index 000000000..2753df8e2 --- /dev/null +++ b/TablePro/Models/AI/CopilotSettings.swift @@ -0,0 +1,30 @@ +// +// CopilotSettings.swift +// TablePro +// + +import Foundation + +struct CopilotSettings: Codable, Equatable { + var enabled: Bool + var telemetryEnabled: Bool + + static let `default` = CopilotSettings( + enabled: false, + telemetryEnabled: true + ) + + init( + enabled: Bool = false, + telemetryEnabled: Bool = true + ) { + self.enabled = enabled + self.telemetryEnabled = telemetryEnabled + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + enabled = try container.decodeIfPresent(Bool.self, forKey: .enabled) ?? false + telemetryEnabled = try container.decodeIfPresent(Bool.self, forKey: .telemetryEnabled) ?? true + } +} diff --git a/TablePro/Models/Settings/EditorSettings.swift b/TablePro/Models/Settings/EditorSettings.swift index 2e433c061..154469faf 100644 --- a/TablePro/Models/Settings/EditorSettings.swift +++ b/TablePro/Models/Settings/EditorSettings.swift @@ -61,6 +61,22 @@ internal enum EditorFontResolver { } } +enum InlineSuggestionProvider: String, Codable, CaseIterable, Identifiable { + case off = "off" + case copilot = "copilot" + case ai = "ai" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .off: return String(localized: "Off") + case .copilot: return "GitHub Copilot" + case .ai: return String(localized: "AI Provider") + } + } +} + internal enum JSONViewMode: String, Codable, CaseIterable { case text case tree @@ -75,6 +91,7 @@ struct EditorSettings: Codable, Equatable { var vimModeEnabled: Bool var uppercaseKeywords: Bool var jsonViewerPreferredMode: JSONViewMode + var inlineSuggestionProvider: InlineSuggestionProvider static let `default` = EditorSettings( showLineNumbers: true, @@ -83,7 +100,8 @@ struct EditorSettings: Codable, Equatable { wordWrap: false, vimModeEnabled: false, uppercaseKeywords: false, - jsonViewerPreferredMode: .text + jsonViewerPreferredMode: .text, + inlineSuggestionProvider: .off ) init( @@ -93,7 +111,8 @@ struct EditorSettings: Codable, Equatable { wordWrap: Bool = false, vimModeEnabled: Bool = false, uppercaseKeywords: Bool = false, - jsonViewerPreferredMode: JSONViewMode = .text + jsonViewerPreferredMode: JSONViewMode = .text, + inlineSuggestionProvider: InlineSuggestionProvider = .off ) { self.showLineNumbers = showLineNumbers self.highlightCurrentLine = highlightCurrentLine @@ -102,6 +121,7 @@ struct EditorSettings: Codable, Equatable { self.vimModeEnabled = vimModeEnabled self.uppercaseKeywords = uppercaseKeywords self.jsonViewerPreferredMode = jsonViewerPreferredMode + self.inlineSuggestionProvider = inlineSuggestionProvider } init(from decoder: Decoder) throws { @@ -113,6 +133,9 @@ struct EditorSettings: Codable, Equatable { vimModeEnabled = try container.decodeIfPresent(Bool.self, forKey: .vimModeEnabled) ?? false uppercaseKeywords = try container.decodeIfPresent(Bool.self, forKey: .uppercaseKeywords) ?? false jsonViewerPreferredMode = try container.decodeIfPresent(JSONViewMode.self, forKey: .jsonViewerPreferredMode) ?? .text + inlineSuggestionProvider = try container.decodeIfPresent( + InlineSuggestionProvider.self, forKey: .inlineSuggestionProvider + ) ?? .off } /// Clamped tab width (1-16) diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 7b0494b37..d822c3d8f 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -582,6 +582,8 @@ struct TableProApp: App { @State private var commandRegistry = CommandActionsRegistry.shared init() { + AIProviderRegistration.registerAll() + // Perform startup cleanup of query history if auto-cleanup is enabled Task { QueryHistoryManager.shared.performStartupCleanup() diff --git a/TablePro/Views/Editor/QueryEditorView.swift b/TablePro/Views/Editor/QueryEditorView.swift index 02f0b44e1..46d06c391 100644 --- a/TablePro/Views/Editor/QueryEditorView.swift +++ b/TablePro/Views/Editor/QueryEditorView.swift @@ -22,6 +22,7 @@ struct QueryEditorView: View { var databaseType: DatabaseType? var connectionId: UUID? var connectionAIPolicy: AIConnectionPolicy? + var tabID: UUID? var onCloseTab: (() -> Void)? var onExecuteQuery: (() -> Void)? var onExplain: ((ClickHouseExplainVariant?) -> Void)? @@ -50,6 +51,7 @@ struct QueryEditorView: View { databaseType: databaseType, connectionId: connectionId, connectionAIPolicy: connectionAIPolicy, + tabID: tabID, vimMode: $vimMode, onCloseTab: onCloseTab, onExecuteQuery: onExecuteQuery, diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index e4d01f3ae..30a5fd69a 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -27,6 +27,9 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { @ObservationIgnored var connectionAIPolicy: AIConnectionPolicy? @ObservationIgnored private var contextMenu: AIEditorContextMenu? @ObservationIgnored private var inlineSuggestionManager: InlineSuggestionManager? + @ObservationIgnored private var aiChatInlineSource: AIChatInlineSource? + @ObservationIgnored private var copilotDocumentSync: CopilotDocumentSync? + @ObservationIgnored private var copilotInlineSource: CopilotInlineSource? @ObservationIgnored private var editorSettingsObserver: NSObjectProtocol? @ObservationIgnored private var windowKeyObserver: NSObjectProtocol? /// Debounce work item for frame-change notification to avoid @@ -52,6 +55,8 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { @ObservationIgnored var onSaveAsFavorite: ((String) -> Void)? @ObservationIgnored var onFormatSQL: (() -> Void)? @ObservationIgnored var databaseType: DatabaseType? + @ObservationIgnored var tabID: UUID? + @ObservationIgnored var connectionId: UUID? /// Whether the editor text view is currently the first responder. /// Used to guard cursor propagation — when the find panel highlights @@ -131,6 +136,11 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { self?.vimCursorManager?.updatePosition() } + if let tabID, let sync = copilotDocumentSync { + let text = textView.string + Task { await sync.didChangeText(tabID: tabID, newText: text) } + } + frameChangeTask?.cancel() frameChangeTask = Task { [weak controller] in try? await Task.sleep(for: .milliseconds(50)) @@ -163,8 +173,16 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { uninstallVimKeyInterceptor() + if let tabID, let sync = copilotDocumentSync { + let id = tabID + Task { await sync.didCloseTab(tabID: id) } + } + inlineSuggestionManager?.uninstall() inlineSuggestionManager = nil + copilotDocumentSync = nil + copilotInlineSource = nil + aiChatInlineSource = nil // Release closure captures to break potential retain cycles onCloseTab = nil @@ -235,11 +253,59 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { private func installInlineSuggestionManager(controller: TextViewController) { let manager = InlineSuggestionManager() - manager.connectionPolicy = connectionAIPolicy - manager.install(controller: controller, schemaProvider: schemaProvider) + manager.install(controller: controller, sourceResolver: { [weak self] in + self?.resolveInlineSource() + }) inlineSuggestionManager = manager } + private func resolveInlineSource() -> InlineSuggestionSource? { + let provider = AppSettingsManager.shared.editor.inlineSuggestionProvider + switch provider { + case .off: + return nil + case .copilot: + if copilotInlineSource == nil { + let sync = CopilotDocumentSync() + copilotDocumentSync = sync + copilotInlineSource = CopilotInlineSource(documentSync: sync) + + let capturedTabID = tabID + let capturedText = controller?.textView?.string ?? "" + let capturedSchemaProvider = schemaProvider + let capturedDBType = databaseType + let dbName = connectionId.flatMap { + DatabaseManager.shared.session(for: $0)?.activeDatabase + } ?? "database" + + Task { + // Build preamble first so ensureDocumentOpen includes it + if let provider = capturedSchemaProvider, let dbType = capturedDBType { + await sync.schemaContext.buildPreamble( + schemaProvider: provider, + databaseName: dbName, + databaseType: dbType + ) + } + // Then open document (with preamble already available) + if let tabID = capturedTabID { + sync.ensureDocumentOpen(tabID: tabID, text: capturedText) + await sync.didActivateTab(tabID: tabID, text: capturedText) + } + } + } + return copilotInlineSource + case .ai: + if aiChatInlineSource == nil { + aiChatInlineSource = AIChatInlineSource( + schemaProvider: schemaProvider, + connectionPolicy: connectionAIPolicy + ) + } + return aiChatInlineSource + } + } + // MARK: - Vim Mode private func installVimModeIfEnabled(controller: TextViewController) { diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index d1e0485f6..a1c79d6c6 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -21,6 +21,7 @@ struct SQLEditorView: View { var databaseType: DatabaseType? var connectionId: UUID? var connectionAIPolicy: AIConnectionPolicy? + var tabID: UUID? @Binding var vimMode: VimMode var onCloseTab: (() -> Void)? var onExecuteQuery: (() -> Void)? @@ -48,6 +49,8 @@ struct SQLEditorView: View { coordinator.schemaProvider = schemaProvider coordinator.connectionAIPolicy = connectionAIPolicy coordinator.databaseType = databaseType + coordinator.tabID = tabID + coordinator.connectionId = connectionId return Group { if editorReady { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 0f031c9cc..7ae6ac2a7 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -274,6 +274,7 @@ struct MainEditorContentView: View { databaseType: coordinator.connection.type, connectionId: coordinator.connection.id, connectionAIPolicy: coordinator.connection.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy, + tabID: tab.id, onCloseTab: { NSApp.keyWindow?.close() }, diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 04008b48e..81eb3476b 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -23,6 +23,7 @@ struct AISettingsView: View { } if settings.enabled { providersSection + copilotChatSection featureRoutingSection contextSection inlineSuggestionsSection @@ -147,6 +148,25 @@ struct AISettingsView: View { } } + // MARK: - Copilot Chat Section + + private var copilotChatSection: some View { + Section { + Toggle("Use Copilot for chat", isOn: $settings.copilotChatEnabled) + + if settings.copilotChatEnabled { + Label( + "Copilot LSP integration is not yet connected. Full support coming soon.", + systemImage: "info.circle" + ) + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text("GitHub Copilot") + } + } + // MARK: - Feature Routing Section private var featureRoutingSection: some View { @@ -161,6 +181,10 @@ struct AISettingsView: View { Text(provider.name.isEmpty ? provider.type.displayName : provider.name) .tag(UUID?.some(provider.id) as UUID?) } + if settings.copilotChatEnabled { + Text("GitHub Copilot") + .tag(UUID?.some(AIProviderConfig.copilotProviderID) as UUID?) + } } .labelsHidden() .fixedSize() @@ -297,6 +321,7 @@ struct AISettingsView: View { case .ollama: return "desktopcomputer" case .openRouter: return "globe" case .custom: return "server.rack" + case .copilot: return "chevron.left.forwardslash.chevron.right" } } } diff --git a/TablePro/Views/Settings/CopilotSettingsView.swift b/TablePro/Views/Settings/CopilotSettingsView.swift new file mode 100644 index 000000000..bf29670e8 --- /dev/null +++ b/TablePro/Views/Settings/CopilotSettingsView.swift @@ -0,0 +1,153 @@ +// +// CopilotSettingsView.swift +// TablePro +// + +import SwiftUI + +struct CopilotSettingsView: View { + @Binding var settings: CopilotSettings + @State private var copilotService = CopilotService.shared + @State private var errorMessage: String? + + var body: some View { + Form { + Section { + Toggle("Enable GitHub Copilot", isOn: $settings.enabled) + } + + if settings.enabled { + statusSection + authSection + preferencesSection + } + } + .formStyle(.grouped) + } + + // MARK: - Status Section + + private var statusSection: some View { + Section { + HStack { + Text("Status") + Spacer() + statusBadge + } + if let message = copilotService.statusMessage { + Text(message) + .font(.caption) + .foregroundStyle(.orange) + } + } header: { + Text("Connection") + } + } + + @ViewBuilder + private var statusBadge: some View { + switch copilotService.status { + case .stopped: + Label("Stopped", systemImage: "circle") + .foregroundStyle(.secondary) + case .starting: + HStack(spacing: 4) { + ProgressView().controlSize(.small) + Text("Starting...") + } + case .running: + Label("Running", systemImage: "circle.fill") + .foregroundStyle(.green) + case .error(let message): + Label(message, systemImage: "exclamationmark.circle.fill") + .foregroundStyle(.red) + .lineLimit(2) + } + } + + // MARK: - Auth Section + + private var authSection: some View { + Section { + switch copilotService.authState { + case .signedOut: + Button("Sign in with GitHub") { + Task { await signIn() } + } + .disabled(copilotService.status != .running) + + case .signingIn(let userCode, _): + VStack(alignment: .leading, spacing: 8) { + Text("Enter this code on GitHub:") + Text(userCode) + .font(.system(.title2, design: .monospaced)) + .fontWeight(.bold) + .textSelection(.enabled) + Text("The code has been copied to your clipboard.") + .font(.caption) + .foregroundStyle(.secondary) + Button("Complete Sign In") { + Task { await completeSignIn() } + } + .buttonStyle(.borderedProminent) + } + + case .signedIn(let username): + HStack { + Label( + String(format: String(localized: "Signed in as %@"), username), + systemImage: "checkmark.circle.fill" + ) + .foregroundStyle(.green) + Spacer() + Button("Sign Out") { + Task { await copilotService.signOut() } + } + } + } + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + } header: { + Text("Account") + } + } + + // MARK: - Preferences Section + + private var preferencesSection: some View { + Section { + Toggle("Send telemetry to GitHub", isOn: $settings.telemetryEnabled) + } header: { + Text("Preferences") + } + } + + // MARK: - Actions + + private func signIn() async { + errorMessage = nil + do { + try await copilotService.signIn() + } catch { + errorMessage = error.localizedDescription + } + } + + private func completeSignIn() async { + errorMessage = nil + do { + try await copilotService.completeSignIn() + } catch { + errorMessage = error.localizedDescription + } + } +} + +#Preview { + CopilotSettingsView(settings: .constant(.default)) + .frame(width: 450, height: 500) +} diff --git a/TablePro/Views/Settings/EditorSettingsView.swift b/TablePro/Views/Settings/EditorSettingsView.swift index e60ccb7a7..4548b8962 100644 --- a/TablePro/Views/Settings/EditorSettingsView.swift +++ b/TablePro/Views/Settings/EditorSettingsView.swift @@ -24,6 +24,27 @@ struct EditorSettingsView: View { Toggle("Vim mode", isOn: $settings.vimModeEnabled) } + Section { + Picker(String(localized: "Provider"), selection: $settings.inlineSuggestionProvider) { + ForEach(InlineSuggestionProvider.allCases) { provider in + Text(provider.displayName).tag(provider) + } + } + + if settings.inlineSuggestionProvider == .copilot && !CopilotService.shared.isAuthenticated { + Label( + String(localized: "Sign in to GitHub Copilot in the Copilot tab"), + systemImage: "exclamationmark.triangle" + ) + .font(.caption) + .foregroundStyle(.orange) + } + } header: { + Text("Inline Suggestions") + } footer: { + Text("Ghost text completions appear while typing. Press Tab to accept, Escape to dismiss.") + } + Section("JSON Viewer") { Picker("Default view:", selection: $settings.jsonViewerPreferredMode) { Text("Text").tag(JSONViewMode.text) diff --git a/TablePro/Views/Settings/SettingsView.swift b/TablePro/Views/Settings/SettingsView.swift index 9754db52e..d0bbeb460 100644 --- a/TablePro/Views/Settings/SettingsView.swift +++ b/TablePro/Views/Settings/SettingsView.swift @@ -6,7 +6,7 @@ import SwiftUI enum SettingsTab: String { - case general, appearance, editor, keyboard, ai, terminal, mcp, plugins, account + case general, appearance, editor, keyboard, ai, copilot, terminal, mcp, plugins, account } struct SettingsView: View { @@ -45,6 +45,10 @@ struct SettingsView: View { .tabItem { Label("AI", systemImage: "sparkles") } .tag(SettingsTab.ai.rawValue) + CopilotSettingsView(settings: $settingsManager.copilot) + .tabItem { Label("Copilot", systemImage: "chevron.left.forwardslash.chevron.right") } + .tag(SettingsTab.copilot.rawValue) + TerminalSettingsView(settings: $settingsManager.terminal) .tabItem { Label("Terminal", systemImage: "terminal") } .tag(SettingsTab.terminal.rawValue)