From 5b14370a231be539a5ae71177edaaa1ea7bcbadc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 10:20:36 +0700 Subject: [PATCH 01/34] feat: add GitHub Copilot support with rewritten inline suggestion architecture --- CHANGELOG.md | 1 + .../Core/AI/Copilot/CopilotAuthManager.swift | 83 ++++ .../AI/Copilot/CopilotBinaryManager.swift | 82 ++++ .../Core/AI/Copilot/CopilotDocumentSync.swift | 52 ++ .../Core/AI/Copilot/CopilotInlineSource.swift | 57 +++ TablePro/Core/AI/Copilot/CopilotService.swift | 158 ++++++ .../InlineSuggestion/AIChatInlineSource.swift | 116 +++++ .../InlineSuggestion/GhostTextRenderer.swift | 111 +++++ .../InlineSuggestionManager.swift | 270 ++++++++++ .../InlineSuggestionSource.swift | 32 ++ .../Core/AI/InlineSuggestionManager.swift | 464 ------------------ TablePro/Core/LSP/LSPClient.swift | 162 ++++++ TablePro/Core/LSP/LSPDocumentManager.swift | 49 ++ TablePro/Core/LSP/LSPTransport.swift | 268 ++++++++++ TablePro/Core/LSP/LSPTypes.swift | 191 +++++++ .../Core/Storage/AppSettingsManager.swift | 19 + .../Core/Storage/AppSettingsStorage.swift | 12 + TablePro/Models/AI/CopilotSettings.swift | 35 ++ .../Views/Editor/SQLEditorCoordinator.swift | 33 +- .../Views/Settings/CopilotSettingsView.swift | 132 +++++ TablePro/Views/Settings/SettingsView.swift | 6 +- 21 files changed, 1866 insertions(+), 467 deletions(-) create mode 100644 TablePro/Core/AI/Copilot/CopilotAuthManager.swift create mode 100644 TablePro/Core/AI/Copilot/CopilotBinaryManager.swift create mode 100644 TablePro/Core/AI/Copilot/CopilotDocumentSync.swift create mode 100644 TablePro/Core/AI/Copilot/CopilotInlineSource.swift create mode 100644 TablePro/Core/AI/Copilot/CopilotService.swift create mode 100644 TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift create mode 100644 TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift create mode 100644 TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift create mode 100644 TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift delete mode 100644 TablePro/Core/AI/InlineSuggestionManager.swift create mode 100644 TablePro/Core/LSP/LSPClient.swift create mode 100644 TablePro/Core/LSP/LSPDocumentManager.swift create mode 100644 TablePro/Core/LSP/LSPTransport.swift create mode 100644 TablePro/Core/LSP/LSPTypes.swift create mode 100644 TablePro/Models/AI/CopilotSettings.swift create mode 100644 TablePro/Views/Settings/CopilotSettingsView.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 4dfa82f9e..3199c8696 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- GitHub Copilot integration with OAuth sign-in, inline suggestions, and settings UI - Per-connection "Local only" option to exclude individual connections from iCloud sync - Filter operator picker shows SQL symbols alongside names for quick visual recognition - SQL autocomplete now suggests column names before a FROM clause is written, using all cached schema columns as fallback diff --git a/TablePro/Core/AI/Copilot/CopilotAuthManager.swift b/TablePro/Core/AI/Copilot/CopilotAuthManager.swift new file mode 100644 index 000000000..61cac0dd2 --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotAuthManager.swift @@ -0,0 +1,83 @@ +// +// 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 + } + + private struct EmptyParams: Encodable {} + + func initiateSignIn(transport: LSPTransport) async throws -> SignInResult { + let data: Data = try await transport.sendRequest( + method: "signInInitiate", + params: EmptyParams?.none + ) + 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.. String { + let path = binaryExecutablePath + if FileManager.default.isExecutableFile(atPath: path) { + return path + } + try await downloadBinary() + 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) + + 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=2", "package/bin"] + try process.run() + process.waitUntilExit() + + try? FileManager.default.removeItem(at: tempTar) + + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: binaryExecutablePath + ) + + Self.logger.info("Downloaded Copilot language server binary") + } + + 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 + } +} diff --git a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift new file mode 100644 index 000000000..8fbf545b7 --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -0,0 +1,52 @@ +// +// CopilotDocumentSync.swift +// TablePro +// + +import Foundation +import os + +@MainActor +final class CopilotDocumentSync { + private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotDocumentSync") + + private let documentManager = LSPDocumentManager() + private var currentURI: String? + + static func documentURI(for tabID: UUID) -> String { + "tablepro://tab/\(tabID.uuidString)" + } + + func didActivateTab(tabID: UUID, text: String, languageId: String = "sql") async { + let uri = Self.documentURI(for: tabID) + guard let client = CopilotService.shared.client else { return } + + if !documentManager.isOpen(uri) { + let item = documentManager.openDocument(uri: uri, languageId: languageId, text: text) + await client.didOpenDocument(item) + } + await client.didFocusDocument(uri: uri) + currentURI = uri + } + + func didChangeText(tabID: UUID, newText: String) async { + let uri = Self.documentURI(for: tabID) + guard let client = CopilotService.shared.client else { return } + guard let (versioned, changes) = documentManager.changeDocument(uri: uri, newText: newText) else { return } + await client.didChangeDocument(uri: versioned.uri, version: versioned.version, changes: changes) + } + + func didCloseTab(tabID: UUID) async { + let uri = Self.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) + if currentURI == uri { currentURI = nil } + } + + func currentDocumentInfo() -> (uri: String, version: Int)? { + guard let uri = currentURI 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..e301e1024 --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift @@ -0,0 +1,57 @@ +// +// 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 { + let settings = AppSettingsManager.shared.copilot + guard settings.enabled, settings.useForInlineSuggestions else { return false } + return 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 params = LSPInlineCompletionParams( + textDocument: LSPTextDocumentIdentifier(uri: docInfo.uri), + position: LSPPosition(line: context.cursorLine, character: context.cursorCharacter), + context: LSPInlineCompletionContext(triggerKind: 1), + 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 } + + return InlineSuggestion(text: 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 didDismissSuggestion(_ suggestion: InlineSuggestion) { + // Copilot does not require dismiss telemetry + } +} diff --git a/TablePro/Core/AI/Copilot/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift new file mode 100644 index 000000000..3d8529c79 --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -0,0 +1,158 @@ +// +// 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 + + @ObservationIgnored private var lspClient: LSPClient? + @ObservationIgnored private var transport: LSPTransport? + @ObservationIgnored private var serverGeneration: Int = 0 + @ObservationIgnored private var restartTask: Task? + @ObservationIgnored private let authManager = CopilotAuthManager() + + private init() {} + + var client: LSPClient? { lspClient } + var lspTransport: LSPTransport? { transport } + var isAuthenticated: Bool { authState.isSignedIn } + + // 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 } + + self.transport = newTransport + self.lspClient = client + status = .running + + Self.logger.info("Copilot language server started successfully") + } catch { + guard generation == serverGeneration else { return } + status = .error(error.localizedDescription) + Self.logger.error("Failed to start Copilot: \(error.localizedDescription)") + 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() { + restartTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(5)) + guard !Task.isCancelled else { return } + await self?.start() + } + } +} + +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..9f9da4890 --- /dev/null +++ b/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift @@ -0,0 +1,116 @@ +// +// 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, settings.inlineSuggestEnabled else { return false } + if connectionPolicy == .never { return false } + return AIProviderFactory.resolve(for: .inlineSuggest, settings: settings) != nil + } + + func requestSuggestion(context: SuggestionContext) async throws -> InlineSuggestion? { + let settings = AppSettingsManager.shared.ai + + guard let resolved = AIProviderFactory.resolve(for: .inlineSuggest, 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, acceptCommand: nil) + } + + 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..908503049 --- /dev/null +++ b/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift @@ -0,0 +1,111 @@ +// +// 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 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 } + + hide() + + 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 + 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 ghostLayer != nil else { return } + // Repositioning requires knowing the offset and text, which the manager tracks. + // The manager calls show() again on scroll, which handles repositioning. + } +} diff --git a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift new file mode 100644 index 000000000..dd54b732e --- /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 source: 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, source: InlineSuggestionSource?) { + self.controller = controller + self.source = source + renderer.install(controller: controller) + } + + func updateSource(_ source: InlineSuggestionSource?) { + self.source = source + } + + 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() + + source = 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, 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 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, + documentURI: nil, + documentVersion: nil + ) + + 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) + } 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 } + + let offset = suggestionOffset + renderer.hide() + currentSuggestion = nil + + textView.replaceCharacters( + in: NSRange(location: offset, length: 0), + with: suggestion.text + ) + + source?.didAcceptSuggestion(suggestion) + } + + func dismissSuggestion() { + debounceTimer?.invalidate() + currentTask?.cancel() + currentTask = nil + + if let suggestion = currentSuggestion { + source?.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 nil + + 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..dba3e853c --- /dev/null +++ b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift @@ -0,0 +1,32 @@ +// +// 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 + let documentURI: String? + let documentVersion: Int? +} + +/// A completed inline suggestion +struct InlineSuggestion: Sendable { + let text: 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 didAcceptSuggestion(_ suggestion: InlineSuggestion) + func didDismissSuggestion(_ 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/LSP/LSPClient.swift b/TablePro/Core/LSP/LSPClient.swift new file mode 100644 index 000000000..15bd9ee63 --- /dev/null +++ b/TablePro/Core/LSP/LSPClient.swift @@ -0,0 +1,162 @@ +// +// 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? + ) async throws -> LSPInitializeResult { + var initOptions: [String: AnyCodable] = [ + "editorInfo": AnyCodable([ + "name": clientInfo.name, + "version": clientInfo.version + ] as [String: Any]) + ] + if let pluginInfo = editorPluginInfo { + initOptions["editorPluginInfo"] = AnyCodable([ + "name": pluginInfo.name, + "version": pluginInfo.version + ] as [String: Any]) + } + + let params: [String: AnyCodable] = [ + "processId": AnyCodable(processId ?? ProcessInfo.processInfo.processIdentifier), + "capabilities": AnyCodable([String: Any]()), + "initializationOptions": AnyCodable(initOptions as [String: Any]) + ] + + let data = try await transport.sendRequest(method: "initialize", params: params) + Self.logger.info("LSP initialized") + return LSPInitializeResult(rawData: data) + } + + func initialized() async { + try? await transport.sendNotification(method: "initialized", params: nil as [String: AnyCodable]?) + } + + func shutdown() async throws { + _ = try await transport.sendRequest(method: "shutdown", params: nil as [String: AnyCodable]?) + Self.logger.info("LSP shutdown complete") + } + + func exit() async { + try? await transport.sendNotification(method: "exit", params: nil as [String: AnyCodable]?) + } + + // MARK: - Document Sync + + func didOpenDocument(_ item: LSPTextDocumentItem) async { + let params: [String: AnyCodable] = [ + "textDocument": AnyCodable([ + "uri": item.uri, + "languageId": item.languageId, + "version": item.version, + "text": item.text + ] as [String: Any]) + ] + try? await transport.sendNotification(method: "textDocument/didOpen", params: params) + } + + func didChangeDocument( + uri: String, + version: Int, + changes: [LSPTextDocumentContentChangeEvent] + ) async { + let changeArray = changes.map { AnyCodable(["text": $0.text] as [String: Any]) } + let params: [String: AnyCodable] = [ + "textDocument": AnyCodable([ + "uri": uri, + "version": version + ] as [String: Any]), + "contentChanges": AnyCodable(changeArray as [Any]) + ] + try? await transport.sendNotification(method: "textDocument/didChange", params: params) + } + + func didCloseDocument(uri: String) async { + let params: [String: AnyCodable] = [ + "textDocument": AnyCodable(["uri": uri] as [String: Any]) + ] + try? await transport.sendNotification(method: "textDocument/didClose", params: params) + } + + func didFocusDocument(uri: String) async { + let params: [String: AnyCodable] = [ + "textDocument": AnyCodable(["uri": uri] as [String: Any]) + ] + try? await transport.sendNotification(method: "textDocument/didFocus", params: params) + } + + // MARK: - Inline Completions + + func inlineCompletion(params: LSPInlineCompletionParams) async throws -> LSPInlineCompletionList { + let encodedParams = try JSONEncoder().encode(params) + guard let paramsDict = try JSONSerialization.jsonObject(with: encodedParams) as? [String: Any] else { + throw LSPTransportError.invalidResponse + } + + let responseData = try await transport.sendRequest( + method: "textDocument/inlineCompletion", + params: paramsDict.mapValues { AnyCodable($0) } + ) + + // Parse the response to extract the result field + guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any], + let resultJSON = json["result"] else { + return LSPInlineCompletionList(items: []) + } + + let resultData = try JSONSerialization.data(withJSONObject: resultJSON) + + // Handle both array and object response formats + if let list = try? JSONDecoder().decode(LSPInlineCompletionList.self, from: resultData) { + return list + } + if let items = try? JSONDecoder().decode([LSPInlineCompletionItem].self, from: resultData) { + return LSPInlineCompletionList(items: items) + } + + return LSPInlineCompletionList(items: []) + } + + // MARK: - Commands + + func executeCommand(command: String, arguments: [AnyCodable]?) async throws { + let params: [String: AnyCodable] = [ + "command": AnyCodable(command), + "arguments": AnyCodable(arguments as Any) + ] + _ = try await transport.sendRequest(method: "workspace/executeCommand", params: params) + } + + // MARK: - Configuration + + func didChangeConfiguration(settings: [String: AnyCodable]) async { + let params: [String: AnyCodable] = [ + "settings": AnyCodable(settings as [String: Any]) + ] + try? await transport.sendNotification(method: "workspace/didChangeConfiguration", params: params) + } + + // 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..535a37f58 --- /dev/null +++ b/TablePro/Core/LSP/LSPTransport.swift @@ -0,0 +1,268 @@ +// +// LSPTransport.swift +// TablePro +// + +import Foundation +import os + +// MARK: - LSPTransportError + +enum LSPTransportError: Error, LocalizedError { + case processNotRunning + case processExited(Int32) + case invalidResponse + case requestCancelled + + var errorDescription: String? { + switch self { + case .processNotRunning: + return "LSP process is not running" + case .processExited(let code): + return "LSP process exited with code \(code)" + case .invalidResponse: + return "Invalid LSP response" + case .requestCancelled: + return "LSP request was cancelled" + } + } +} + +// 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 readTask: Task? + + deinit { + readTask?.cancel() + } + + // 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() + + readTask = Task { [weak self] in + await self?.readLoop() + } + + Self.logger.info("LSP transport started: \(executablePath)") + } + + func stop() { + readTask?.cancel() + readTask = nil + + 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 + + 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: - 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) + } + + private func readLoop() { + guard let stdoutPipe else { return } + let handle = stdoutPipe.fileHandleForReading + var buffer = Data() + + while !Task.isCancelled { + let chunk = handle.availableData + guard !chunk.isEmpty else { + // EOF — process likely exited + break + } + buffer.append(chunk) + + // Parse as many complete messages as possible from the buffer + while let (messageData, consumed) = parseMessage(from: buffer) { + buffer.removeFirst(consumed) + dispatchMessage(messageData) + } + } + } + + /// Parse a single LSP message from the buffer. + /// Returns (messageBody, totalBytesConsumed) or nil if buffer is incomplete. + private func parseMessage(from buffer: Data) -> (Data, Int)? { + // Find the header/body separator: \r\n\r\n + 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..a6a653301 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() @@ -262,6 +280,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/CopilotSettings.swift b/TablePro/Models/AI/CopilotSettings.swift new file mode 100644 index 000000000..b15619832 --- /dev/null +++ b/TablePro/Models/AI/CopilotSettings.swift @@ -0,0 +1,35 @@ +// +// CopilotSettings.swift +// TablePro +// + +import Foundation + +struct CopilotSettings: Codable, Equatable { + var enabled: Bool + var useForInlineSuggestions: Bool + var telemetryEnabled: Bool + + static let `default` = CopilotSettings( + enabled: false, + useForInlineSuggestions: true, + telemetryEnabled: true + ) + + init( + enabled: Bool = false, + useForInlineSuggestions: Bool = true, + telemetryEnabled: Bool = true + ) { + self.enabled = enabled + self.useForInlineSuggestions = useForInlineSuggestions + 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 + useForInlineSuggestions = try container.decodeIfPresent(Bool.self, forKey: .useForInlineSuggestions) ?? true + telemetryEnabled = try container.decodeIfPresent(Bool.self, forKey: .telemetryEnabled) ?? true + } +} diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index e4d01f3ae..12b200cbe 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 @@ -165,6 +168,9 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { inlineSuggestionManager?.uninstall() inlineSuggestionManager = nil + copilotDocumentSync = nil + copilotInlineSource = nil + aiChatInlineSource = nil // Release closure captures to break potential retain cycles onCloseTab = nil @@ -234,12 +240,35 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { // MARK: - Inline Suggestion Manager private func installInlineSuggestionManager(controller: TextViewController) { + let source = resolveInlineSource() let manager = InlineSuggestionManager() - manager.connectionPolicy = connectionAIPolicy - manager.install(controller: controller, schemaProvider: schemaProvider) + manager.install(controller: controller, source: source) inlineSuggestionManager = manager } + private func resolveInlineSource() -> InlineSuggestionSource? { + let copilotSettings = AppSettingsManager.shared.copilot + if copilotSettings.enabled, copilotSettings.useForInlineSuggestions, + CopilotService.shared.isAuthenticated { + let sync = CopilotDocumentSync() + copilotDocumentSync = sync + let source = CopilotInlineSource(documentSync: sync) + copilotInlineSource = source + return source + } + + let ai = AppSettingsManager.shared.ai + if ai.enabled, ai.inlineSuggestEnabled { + let source = AIChatInlineSource( + schemaProvider: schemaProvider, + connectionPolicy: connectionAIPolicy + ) + aiChatInlineSource = source + return source + } + return nil + } + // MARK: - Vim Mode private func installVimModeIfEnabled(controller: TextViewController) { diff --git a/TablePro/Views/Settings/CopilotSettingsView.swift b/TablePro/Views/Settings/CopilotSettingsView.swift new file mode 100644 index 000000000..d7acf1c55 --- /dev/null +++ b/TablePro/Views/Settings/CopilotSettingsView.swift @@ -0,0 +1,132 @@ +// +// CopilotSettingsView.swift +// TablePro +// + +import SwiftUI + +struct CopilotSettingsView: View { + @Binding var settings: CopilotSettings + @State private var copilotService = CopilotService.shared + + 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 + } + } 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(1) + } + } + + // MARK: - Auth Section + + private var authSection: some View { + Section { + switch copilotService.authState { + case .signedOut: + Button("Sign in with GitHub") { + Task { try? 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 { try? 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() } + } + } + } + } header: { + Text("Account") + } + } + + // MARK: - Preferences Section + + private var preferencesSection: some View { + Section { + Toggle("Use for inline suggestions", isOn: $settings.useForInlineSuggestions) + Toggle("Send telemetry to GitHub", isOn: $settings.telemetryEnabled) + } header: { + Text("Preferences") + } + } + + // MARK: - Actions + + private func signIn() async throws { + try await copilotService.signIn() + } + + private func completeSignIn() async throws { + try await copilotService.completeSignIn() + } +} + +#Preview { + CopilotSettingsView(settings: .constant(.default)) + .frame(width: 450, height: 500) +} 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) From 229f3aa9655afd4810496a5aa7a8171316987aaf Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 10:38:53 +0700 Subject: [PATCH 02/34] fix: resolve Copilot service, binary manager, and auth issues --- .../AI/Copilot/CopilotBinaryManager.swift | 44 ++++++++++++++- .../Core/AI/Copilot/CopilotInlineSource.swift | 6 +- TablePro/Core/AI/Copilot/CopilotService.swift | 56 ++++++++++++++++++- .../Views/Settings/CopilotSettingsView.swift | 29 ++++++++-- 4 files changed, 125 insertions(+), 10 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift b/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift index 441d9a6ad..9e282ae48 100644 --- a/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift +++ b/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift @@ -55,19 +55,59 @@ actor CopilotBinaryManager { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") process.arguments = ["xzf", tempTar.path, "-C", baseDirectory.path, "--strip-components=2", "package/bin"] - try process.run() - process.waitUntilExit() + + 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 } diff --git a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift index e301e1024..5c33f1dc4 100644 --- a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift +++ b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift @@ -28,7 +28,7 @@ final class CopilotInlineSource: InlineSuggestionSource { let editorSettings = AppSettingsManager.shared.editor let params = LSPInlineCompletionParams( - textDocument: LSPTextDocumentIdentifier(uri: docInfo.uri), + textDocument: LSPVersionedTextDocumentIdentifier(uri: docInfo.uri, version: docInfo.version), position: LSPPosition(line: context.cursorLine, character: context.cursorCharacter), context: LSPInlineCompletionContext(triggerKind: 1), formattingOptions: LSPFormattingOptions( @@ -51,6 +51,10 @@ final class CopilotInlineSource: InlineSuggestionSource { } } + func didShowSuggestion(_ suggestion: InlineSuggestion) { + Self.logger.debug("Copilot suggestion shown") + } + func didDismissSuggestion(_ suggestion: InlineSuggestion) { // Copilot does not require dismiss telemetry } diff --git a/TablePro/Core/AI/Copilot/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift index 3d8529c79..48e6f958d 100644 --- a/TablePro/Core/AI/Copilot/CopilotService.swift +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -36,6 +36,7 @@ final class CopilotService { @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() {} @@ -75,11 +76,21 @@ final class CopilotService { 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) @@ -132,12 +143,55 @@ final class CopilotService { // 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(5)) + try? await Task.sleep(for: .seconds(delay)) guard !Task.isCancelled else { return } await self?.start() } } + + private func checkAuthStatus() async { + guard let transport else { return } + do { + let data: Data = try await transport.sendRequest( + method: "checkStatus", + params: [String: String]?.none + ) + let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] + let status = json?["status"] as? String + let user = json?["user"] as? String ?? "" + + if status == "OK" || status == "AlreadySignedIn" { + authState = .signedIn(username: user) + Self.logger.info("Copilot already authenticated as \(user)") + } + } catch { + Self.logger.debug("Auth status check: \(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": + status = .error(message ?? "Unknown error") + case "Warning": + Self.logger.warning("Copilot warning: \(message ?? "")") + case "Inactive": + status = .error(String(localized: "Copilot subscription inactive")) + default: + if status != .running { status = .running } + } + } } enum CopilotError: Error, LocalizedError { diff --git a/TablePro/Views/Settings/CopilotSettingsView.swift b/TablePro/Views/Settings/CopilotSettingsView.swift index d7acf1c55..85c97784d 100644 --- a/TablePro/Views/Settings/CopilotSettingsView.swift +++ b/TablePro/Views/Settings/CopilotSettingsView.swift @@ -8,6 +8,7 @@ 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 { @@ -66,7 +67,7 @@ struct CopilotSettingsView: View { switch copilotService.authState { case .signedOut: Button("Sign in with GitHub") { - Task { try? await signIn() } + Task { await signIn() } } .disabled(copilotService.status != .running) @@ -81,7 +82,7 @@ struct CopilotSettingsView: View { .font(.caption) .foregroundStyle(.secondary) Button("Complete Sign In") { - Task { try? await completeSignIn() } + Task { await completeSignIn() } } .buttonStyle(.borderedProminent) } @@ -99,6 +100,12 @@ struct CopilotSettingsView: View { } } } + + if let errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } } header: { Text("Account") } @@ -117,12 +124,22 @@ struct CopilotSettingsView: View { // MARK: - Actions - private func signIn() async throws { - try await copilotService.signIn() + private func signIn() async { + errorMessage = nil + do { + try await copilotService.signIn() + } catch { + errorMessage = error.localizedDescription + } } - private func completeSignIn() async throws { - try await copilotService.completeSignIn() + private func completeSignIn() async { + errorMessage = nil + do { + try await copilotService.completeSignIn() + } catch { + errorMessage = error.localizedDescription + } } } From 155144d32f1fe887640df148b3f00614243d29da Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 10:42:45 +0700 Subject: [PATCH 03/34] fix: resolve critical bugs in LSP transport and inline suggestion system --- .../InlineSuggestion/AIChatInlineSource.swift | 1 + .../InlineSuggestion/GhostTextRenderer.swift | 14 +++- .../InlineSuggestionManager.swift | 5 +- .../InlineSuggestionSource.swift | 7 +- TablePro/Core/LSP/LSPClient.swift | 35 +++----- TablePro/Core/LSP/LSPTransport.swift | 79 +++++++++++-------- TablePro/Core/LSP/LSPTypes.swift | 7 +- TablePro/Views/Editor/QueryEditorView.swift | 2 + .../Views/Editor/SQLEditorCoordinator.swift | 14 ++++ TablePro/Views/Editor/SQLEditorView.swift | 2 + .../Main/Child/MainEditorContentView.swift | 1 + 11 files changed, 101 insertions(+), 66 deletions(-) diff --git a/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift b/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift index 9f9da4890..f8dd4e1a6 100644 --- a/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift +++ b/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift @@ -58,6 +58,7 @@ final class AIChatInlineSource: InlineSuggestionSource { return InlineSuggestion(text: cleaned, acceptCommand: nil) } + func didShowSuggestion(_ suggestion: InlineSuggestion) {} func didAcceptSuggestion(_ suggestion: InlineSuggestion) {} func didDismissSuggestion(_ suggestion: InlineSuggestion) {} diff --git a/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift b/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift index 908503049..0d739c015 100644 --- a/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift +++ b/TablePro/Core/AI/InlineSuggestion/GhostTextRenderer.swift @@ -14,6 +14,8 @@ final class 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 { @@ -28,7 +30,11 @@ final class GhostTextRenderer { guard let textView = controller?.textView else { return } guard let rect = textView.layoutManager.rectForOffset(offset) else { return } - hide() + ghostLayer?.removeFromSuperlayer() + ghostLayer = nil + + currentText = text + currentOffset = offset let layer = CATextLayer() layer.contentsScale = textView.window?.backingScaleFactor ?? 2.0 @@ -65,6 +71,7 @@ final class GhostTextRenderer { func hide() { ghostLayer?.removeFromSuperlayer() ghostLayer = nil + currentText = nil removeScrollObserver() } @@ -104,8 +111,7 @@ final class GhostTextRenderer { } private func repositionGhostLayer() { - guard ghostLayer != nil else { return } - // Repositioning requires knowing the offset and text, which the manager tracks. - // The manager calls show() again on scroll, which handles repositioning. + 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 index dd54b732e..f49bcb21f 100644 --- a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift @@ -149,9 +149,7 @@ final class InlineSuggestionManager { fullText: fullText, cursorOffset: cursorOffset, cursorLine: line, - cursorCharacter: character, - documentURI: nil, - documentVersion: nil + cursorCharacter: character ) currentTask = Task { @MainActor [weak self] in @@ -165,6 +163,7 @@ final class InlineSuggestionManager { 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)") diff --git a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift index dba3e853c..26c4c95cd 100644 --- a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift +++ b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift @@ -12,8 +12,6 @@ struct SuggestionContext: Sendable { let cursorOffset: Int let cursorLine: Int let cursorCharacter: Int - let documentURI: String? - let documentVersion: Int? } /// A completed inline suggestion @@ -27,6 +25,11 @@ struct InlineSuggestion: Sendable { 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/LSP/LSPClient.swift b/TablePro/Core/LSP/LSPClient.swift index 15bd9ee63..7caf2ef51 100644 --- a/TablePro/Core/LSP/LSPClient.swift +++ b/TablePro/Core/LSP/LSPClient.swift @@ -106,29 +106,13 @@ actor LSPClient { // MARK: - Inline Completions func inlineCompletion(params: LSPInlineCompletionParams) async throws -> LSPInlineCompletionList { - let encodedParams = try JSONEncoder().encode(params) - guard let paramsDict = try JSONSerialization.jsonObject(with: encodedParams) as? [String: Any] else { - throw LSPTransportError.invalidResponse - } - - let responseData = try await transport.sendRequest( - method: "textDocument/inlineCompletion", - params: paramsDict.mapValues { AnyCodable($0) } - ) - - // Parse the response to extract the result field - guard let json = try JSONSerialization.jsonObject(with: responseData) as? [String: Any], - let resultJSON = json["result"] else { - return LSPInlineCompletionList(items: []) - } - - let resultData = try JSONSerialization.data(withJSONObject: resultJSON) + let data = try await transport.sendRequest(method: "textDocument/inlineCompletion", params: params) - // Handle both array and object response formats - if let list = try? JSONDecoder().decode(LSPInlineCompletionList.self, from: resultData) { + // Handle both object and array response formats + if let list = try? JSONDecoder().decode(LSPInlineCompletionList.self, from: data) { return list } - if let items = try? JSONDecoder().decode([LSPInlineCompletionItem].self, from: resultData) { + if let items = try? JSONDecoder().decode([LSPInlineCompletionItem].self, from: data) { return LSPInlineCompletionList(items: items) } @@ -148,10 +132,13 @@ actor LSPClient { // MARK: - Configuration func didChangeConfiguration(settings: [String: AnyCodable]) async { - let params: [String: AnyCodable] = [ - "settings": AnyCodable(settings as [String: Any]) - ] - try? await transport.sendNotification(method: "workspace/didChangeConfiguration", params: params) + try? await transport.sendNotification(method: "workspace/didChangeConfiguration", params: settings) + } + + // MARK: - Cancel Request + + func cancelRequest(id: Int) async { + await transport.cancelRequest(id: id) } // MARK: - Notifications from Server diff --git a/TablePro/Core/LSP/LSPTransport.swift b/TablePro/Core/LSP/LSPTransport.swift index 535a37f58..ada0794e9 100644 --- a/TablePro/Core/LSP/LSPTransport.swift +++ b/TablePro/Core/LSP/LSPTransport.swift @@ -13,6 +13,7 @@ enum LSPTransportError: Error, LocalizedError { case processExited(Int32) case invalidResponse case requestCancelled + case serverError(Int, String) var errorDescription: String? { switch self { @@ -24,6 +25,8 @@ enum LSPTransportError: Error, LocalizedError { return "Invalid LSP response" case .requestCancelled: return "LSP request was cancelled" + case .serverError(let code, let message): + return "LSP server error (\(code)): \(message)" } } } @@ -39,11 +42,8 @@ actor LSPTransport { private var nextRequestID: Int = 1 private var pendingRequests: [Int: CheckedContinuation] = [:] private var notificationHandlers: [String: @Sendable (Data) -> Void] = [:] - private var readTask: Task? - - deinit { - readTask?.cancel() - } + private var readerQueue: DispatchQueue? + private var isReading = false // MARK: - Lifecycle @@ -88,16 +88,19 @@ actor LSPTransport { try proc.run() - readTask = Task { [weak self] in - await self?.readLoop() + let queue = DispatchQueue(label: "com.TablePro.LSPTransport.reader") + self.readerQueue = queue + let handle = stdout.fileHandleForReading + self.isReading = true + queue.async { [weak self] in + self?.readLoopSync(handle: handle) } Self.logger.info("LSP transport started: \(executablePath)") } func stop() { - readTask?.cancel() - readTask = nil + isReading = false let pending = pendingRequests pendingRequests.removeAll() @@ -111,6 +114,7 @@ actor LSPTransport { process = nil stdinPipe = nil stdoutPipe = nil + readerQueue = nil Self.logger.info("LSP transport stopped") } @@ -146,6 +150,15 @@ actor LSPTransport { 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) { @@ -169,31 +182,25 @@ actor LSPTransport { handle.write(data) } - private func readLoop() { - guard let stdoutPipe else { return } - let handle = stdoutPipe.fileHandleForReading + /// 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 !Task.isCancelled { + while true { let chunk = handle.availableData - guard !chunk.isEmpty else { - // EOF — process likely exited - break - } + guard !chunk.isEmpty else { break } // EOF buffer.append(chunk) - // Parse as many complete messages as possible from the buffer - while let (messageData, consumed) = parseMessage(from: buffer) { - buffer.removeFirst(consumed) - dispatchMessage(messageData) + 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 func parseMessage(from buffer: Data) -> (Data, Int)? { - // Find the header/body separator: \r\n\r\n + 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 @@ -204,7 +211,6 @@ actor LSPTransport { return nil } - // Extract Content-Length var contentLength: Int? for line in headerString.components(separatedBy: "\r\n") { if line.lowercased().hasPrefix("content-length:") { @@ -220,13 +226,13 @@ actor LSPTransport { let bodyStart = separatorRange.upperBound let bodyEnd = buffer.index(bodyStart, offsetBy: length, limitedBy: buffer.endIndex) guard let end = bodyEnd, end <= buffer.endIndex else { - // Not enough data yet return nil } - let body = buffer[bodyStart.. 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 12b200cbe..edcff8507 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -55,6 +55,7 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { @ObservationIgnored var onSaveAsFavorite: ((String) -> Void)? @ObservationIgnored var onFormatSQL: (() -> Void)? @ObservationIgnored var databaseType: DatabaseType? + @ObservationIgnored var tabID: UUID? /// Whether the editor text view is currently the first responder. /// Used to guard cursor propagation — when the find panel highlights @@ -102,6 +103,9 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { self.fixFindPanelHitTesting(controller: controller) self.installAIContextMenu(controller: controller) self.installInlineSuggestionManager(controller: controller) + if let tabID = self.tabID, let sync = self.copilotDocumentSync, let textView = controller.textView { + Task { await sync.didActivateTab(tabID: tabID, text: textView.string) } + } self.installVimModeIfEnabled(controller: controller) self.installEditorSettingsObserver(controller: controller) if let textView = controller.textView { @@ -134,6 +138,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)) @@ -166,6 +175,11 @@ 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 diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index d1e0485f6..379bb0aa7 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,7 @@ struct SQLEditorView: View { coordinator.schemaProvider = schemaProvider coordinator.connectionAIPolicy = connectionAIPolicy coordinator.databaseType = databaseType + coordinator.tabID = tabID return Group { if editorReady { diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index dddf2dd4a..9c8c78b74 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() }, From fca0951a4d26b60f2f14a631c94551e93bf274b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 24 Apr 2026 10:46:42 +0700 Subject: [PATCH 04/34] fix: treat query timeout as best-effort so Aurora DSQL can connect (#851) --- CHANGELOG.md | 1 + .../Database/DatabaseManager+Health.swift | 20 +++++++++++++++---- .../Database/DatabaseManager+Sessions.swift | 11 ++++++++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3199c8696..598becdc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Connection failure on PostgreSQL-compatible databases (e.g., Aurora DSQL) that don't support `SET statement_timeout` - Schema-qualified table names (e.g. `public.users`) now correctly resolve in autocomplete - Alert dialogs use sheet attachment instead of bare modal - Terminal copy uses responder chain instead of synthetic NSEvent diff --git a/TablePro/Core/Database/DatabaseManager+Health.swift b/TablePro/Core/Database/DatabaseManager+Health.swift index 95c70653d..0ac1a9ce8 100644 --- a/TablePro/Core/Database/DatabaseManager+Health.swift +++ b/TablePro/Core/Database/DatabaseManager+Health.swift @@ -144,10 +144,16 @@ extension DatabaseManager { throw error } - // Apply timeout + // Apply timeout (best-effort) let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds if timeoutSeconds > 0 { - try await driver.applyQueryTimeout(timeoutSeconds) + do { + try await driver.applyQueryTimeout(timeoutSeconds) + } catch { + Self.logger.warning( + "Query timeout not supported for \(session.connection.name): \(error.localizedDescription)" + ) + } } await executeStartupCommands( @@ -234,10 +240,16 @@ extension DatabaseManager { ) try await driver.connect() - // Apply timeout + // Apply timeout (best-effort) let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds if timeoutSeconds > 0 { - try await driver.applyQueryTimeout(timeoutSeconds) + do { + try await driver.applyQueryTimeout(timeoutSeconds) + } catch { + Self.logger.warning( + "Query timeout not supported for \(session.connection.name): \(error.localizedDescription)" + ) + } } await executeStartupCommands( diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index d828c79dd..c86f7d33f 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -110,10 +110,17 @@ extension DatabaseManager { do { try await driver.connect() - // Apply query timeout from settings + // Apply query timeout from settings (best-effort — some PostgreSQL-compatible + // databases like Aurora DSQL don't support SET statement_timeout) let timeoutSeconds = AppSettingsManager.shared.general.queryTimeoutSeconds if timeoutSeconds > 0 { - try await driver.applyQueryTimeout(timeoutSeconds) + do { + try await driver.applyQueryTimeout(timeoutSeconds) + } catch { + Self.logger.warning( + "Query timeout not supported for \(connection.name): \(error.localizedDescription)" + ) + } } // Run startup commands before schema init From f2ce8ec9ec7730784c7485a0dc636f72702aa759 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 11:00:11 +0700 Subject: [PATCH 05/34] fix: correct Copilot binary tar extraction path, stop restart on permanent errors --- TablePro/Core/AI/Copilot/CopilotBinaryManager.swift | 2 +- TablePro/Core/AI/Copilot/CopilotService.swift | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift b/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift index 9e282ae48..07ea08370 100644 --- a/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift +++ b/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift @@ -54,7 +54,7 @@ actor CopilotBinaryManager { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/tar") - process.arguments = ["xzf", tempTar.path, "-C", baseDirectory.path, "--strip-components=2", "package/bin"] + 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 diff --git a/TablePro/Core/AI/Copilot/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift index 48e6f958d..fb4be74fa 100644 --- a/TablePro/Core/AI/Copilot/CopilotService.swift +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -95,7 +95,11 @@ final class CopilotService { guard generation == serverGeneration else { return } status = .error(error.localizedDescription) Self.logger.error("Failed to start Copilot: \(error.localizedDescription)") - scheduleRestart() + + let isPermanent = error is CopilotError + if !isPermanent { + scheduleRestart() + } } } From 941fed9e9efbc39600a3af1d0cd35fec29306de4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 11:08:05 +0700 Subject: [PATCH 06/34] fix: separate auth status from server status in Copilot notifications --- TablePro/Core/AI/Copilot/CopilotService.swift | 11 ++++++++--- TablePro/Views/Settings/CopilotSettingsView.swift | 7 ++++++- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift index fb4be74fa..66a6a0dcc 100644 --- a/TablePro/Core/AI/Copilot/CopilotService.swift +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -31,6 +31,7 @@ final class CopilotService { 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? @@ -187,13 +188,17 @@ final class CopilotService { switch kind { case "Error": - status = .error(message ?? "Unknown 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": - status = .error(String(localized: "Copilot subscription inactive")) + statusMessage = String(localized: "Copilot subscription inactive") default: - if status != .running { status = .running } + statusMessage = nil } } } diff --git a/TablePro/Views/Settings/CopilotSettingsView.swift b/TablePro/Views/Settings/CopilotSettingsView.swift index 85c97784d..aba47e8bd 100644 --- a/TablePro/Views/Settings/CopilotSettingsView.swift +++ b/TablePro/Views/Settings/CopilotSettingsView.swift @@ -34,6 +34,11 @@ struct CopilotSettingsView: View { Spacer() statusBadge } + if let message = copilotService.statusMessage { + Text(message) + .font(.caption) + .foregroundStyle(.orange) + } } header: { Text("Connection") } @@ -56,7 +61,7 @@ struct CopilotSettingsView: View { case .error(let message): Label(message, systemImage: "exclamationmark.circle.fill") .foregroundStyle(.red) - .lineLimit(1) + .lineLimit(2) } } From bd24d80684985e7a9fc70d37122b96d625e54a47 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 11:22:19 +0700 Subject: [PATCH 07/34] fix: use typed Codable structs for LSP params instead of fragile AnyCodable dicts --- TablePro/Core/LSP/LSPClient.swift | 70 ++++++++++--------------------- TablePro/Core/LSP/LSPTypes.swift | 39 +++++++++++++++++ 2 files changed, 60 insertions(+), 49 deletions(-) diff --git a/TablePro/Core/LSP/LSPClient.swift b/TablePro/Core/LSP/LSPClient.swift index 7caf2ef51..5383c0975 100644 --- a/TablePro/Core/LSP/LSPClient.swift +++ b/TablePro/Core/LSP/LSPClient.swift @@ -22,24 +22,14 @@ actor LSPClient { editorPluginInfo: LSPClientInfo?, processId: Int? ) async throws -> LSPInitializeResult { - var initOptions: [String: AnyCodable] = [ - "editorInfo": AnyCodable([ - "name": clientInfo.name, - "version": clientInfo.version - ] as [String: Any]) - ] - if let pluginInfo = editorPluginInfo { - initOptions["editorPluginInfo"] = AnyCodable([ - "name": pluginInfo.name, - "version": pluginInfo.version - ] as [String: Any]) - } - - let params: [String: AnyCodable] = [ - "processId": AnyCodable(processId ?? ProcessInfo.processInfo.processIdentifier), - "capabilities": AnyCodable([String: Any]()), - "initializationOptions": AnyCodable(initOptions as [String: Any]) - ] + let params = LSPInitializeParams( + processId: processId ?? Int(ProcessInfo.processInfo.processIdentifier), + capabilities: LSPClientCapabilities(), + initializationOptions: LSPInitializationOptions( + editorInfo: clientInfo, + editorPluginInfo: editorPluginInfo + ) + ) let data = try await transport.sendRequest(method: "initialize", params: params) Self.logger.info("LSP initialized") @@ -47,29 +37,22 @@ actor LSPClient { } func initialized() async { - try? await transport.sendNotification(method: "initialized", params: nil as [String: AnyCodable]?) + try? await transport.sendNotification(method: "initialized", params: EmptyLSPParams?.none) } func shutdown() async throws { - _ = try await transport.sendRequest(method: "shutdown", params: nil as [String: AnyCodable]?) + _ = try await transport.sendRequest(method: "shutdown", params: EmptyLSPParams?.none) Self.logger.info("LSP shutdown complete") } func exit() async { - try? await transport.sendNotification(method: "exit", params: nil as [String: AnyCodable]?) + try? await transport.sendNotification(method: "exit", params: EmptyLSPParams?.none) } // MARK: - Document Sync func didOpenDocument(_ item: LSPTextDocumentItem) async { - let params: [String: AnyCodable] = [ - "textDocument": AnyCodable([ - "uri": item.uri, - "languageId": item.languageId, - "version": item.version, - "text": item.text - ] as [String: Any]) - ] + let params = LSPDidOpenParams(textDocument: item) try? await transport.sendNotification(method: "textDocument/didOpen", params: params) } @@ -78,28 +61,20 @@ actor LSPClient { version: Int, changes: [LSPTextDocumentContentChangeEvent] ) async { - let changeArray = changes.map { AnyCodable(["text": $0.text] as [String: Any]) } - let params: [String: AnyCodable] = [ - "textDocument": AnyCodable([ - "uri": uri, - "version": version - ] as [String: Any]), - "contentChanges": AnyCodable(changeArray as [Any]) - ] + let params = LSPDidChangeParams( + textDocument: LSPVersionedTextDocumentIdentifier(uri: uri, version: version), + contentChanges: changes + ) try? await transport.sendNotification(method: "textDocument/didChange", params: params) } func didCloseDocument(uri: String) async { - let params: [String: AnyCodable] = [ - "textDocument": AnyCodable(["uri": uri] as [String: Any]) - ] + let params = LSPDocumentParams(textDocument: LSPTextDocumentIdentifier(uri: uri)) try? await transport.sendNotification(method: "textDocument/didClose", params: params) } func didFocusDocument(uri: String) async { - let params: [String: AnyCodable] = [ - "textDocument": AnyCodable(["uri": uri] as [String: Any]) - ] + let params = LSPDocumentParams(textDocument: LSPTextDocumentIdentifier(uri: uri)) try? await transport.sendNotification(method: "textDocument/didFocus", params: params) } @@ -108,7 +83,6 @@ actor LSPClient { func inlineCompletion(params: LSPInlineCompletionParams) async throws -> LSPInlineCompletionList { let data = try await transport.sendRequest(method: "textDocument/inlineCompletion", params: params) - // Handle both object and array response formats if let list = try? JSONDecoder().decode(LSPInlineCompletionList.self, from: data) { return list } @@ -122,17 +96,15 @@ actor LSPClient { // MARK: - Commands func executeCommand(command: String, arguments: [AnyCodable]?) async throws { - let params: [String: AnyCodable] = [ - "command": AnyCodable(command), - "arguments": AnyCodable(arguments as Any) - ] + let params = LSPExecuteCommandParams(command: command, arguments: arguments) _ = try await transport.sendRequest(method: "workspace/executeCommand", params: params) } // MARK: - Configuration func didChangeConfiguration(settings: [String: AnyCodable]) async { - try? await transport.sendNotification(method: "workspace/didChangeConfiguration", params: settings) + let params = LSPConfigurationParams(settings: settings) + try? await transport.sendNotification(method: "workspace/didChangeConfiguration", params: params) } // MARK: - Cancel Request diff --git a/TablePro/Core/LSP/LSPTypes.swift b/TablePro/Core/LSP/LSPTypes.swift index a2fc16b9b..35fabb60f 100644 --- a/TablePro/Core/LSP/LSPTypes.swift +++ b/TablePro/Core/LSP/LSPTypes.swift @@ -79,10 +79,49 @@ struct LSPClientInfo: Codable, Sendable, Equatable { let version: String } +struct LSPInitializeParams: Codable, Sendable { + let processId: Int + let capabilities: LSPClientCapabilities + let initializationOptions: LSPInitializationOptions +} + +struct LSPClientCapabilities: Codable, Sendable {} + +struct LSPInitializationOptions: Codable, Sendable { + let editorInfo: LSPClientInfo + let editorPluginInfo: LSPClientInfo? +} + struct LSPInitializeResult: Sendable { let rawData: Data } +// MARK: - LSP Notification/Request Param Types + +struct EmptyLSPParams: Codable, Sendable {} + +struct LSPDidOpenParams: Codable, Sendable { + let textDocument: LSPTextDocumentItem +} + +struct LSPDidChangeParams: Codable, Sendable { + let textDocument: LSPVersionedTextDocumentIdentifier + let contentChanges: [LSPTextDocumentContentChangeEvent] +} + +struct LSPDocumentParams: Codable, Sendable { + let textDocument: LSPTextDocumentIdentifier +} + +struct LSPExecuteCommandParams: Codable, Sendable { + let command: String + let arguments: [AnyCodable]? +} + +struct LSPConfigurationParams: Codable, Sendable { + let settings: [String: AnyCodable] +} + // MARK: - LSP JSON-RPC Types struct LSPJSONRPCRequest: Encodable { From e3f3ae71fe8fc642221babdc640e0ca4c5b23f14 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 11:27:42 +0700 Subject: [PATCH 08/34] fix: send empty object params for checkStatus request --- TablePro/Core/AI/Copilot/CopilotService.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/AI/Copilot/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift index 66a6a0dcc..a620a6398 100644 --- a/TablePro/Core/AI/Copilot/CopilotService.swift +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -164,7 +164,7 @@ final class CopilotService { do { let data: Data = try await transport.sendRequest( method: "checkStatus", - params: [String: String]?.none + params: EmptyLSPParams() ) let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] let status = json?["status"] as? String From 47bbe591b1bb3c95c60bf506a1ab21d293401008 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 11:39:38 +0700 Subject: [PATCH 09/34] fix: handle null and non-object JSON-RPC result values without crashing --- TablePro/Core/LSP/LSPTransport.swift | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/TablePro/Core/LSP/LSPTransport.swift b/TablePro/Core/LSP/LSPTransport.swift index ada0794e9..cea84c8a4 100644 --- a/TablePro/Core/LSP/LSPTransport.swift +++ b/TablePro/Core/LSP/LSPTransport.swift @@ -248,16 +248,19 @@ actor LSPTransport { let code = errorObj["code"] as? Int ?? -1 let message = errorObj["message"] as? String ?? "Unknown error" continuation.resume(throwing: LSPTransportError.serverError(code, message)) - } else if let resultValue = json["result"] { - do { - let resultData = try JSONSerialization.data(withJSONObject: resultValue) - continuation.resume(returning: resultData) - } catch { - continuation.resume(throwing: LSPTransportError.invalidResponse) - } } else { - // result is null/missing — return empty JSON object - continuation.resume(returning: Data("{}".utf8)) + let resultValue = json["result"] + if let resultValue, !(resultValue is NSNull), + JSONSerialization.isValidJSONObject(resultValue) { + do { + let resultData = try JSONSerialization.data(withJSONObject: resultValue) + continuation.resume(returning: resultData) + } catch { + continuation.resume(returning: Data("{}".utf8)) + } + } else { + continuation.resume(returning: Data("{}".utf8)) + } } } return From bbc0fd98336c54bee19c0810193b0ee5c9d4d84b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 11:59:25 +0700 Subject: [PATCH 10/34] refactor: unify inline suggestion settings into single EditorSettings provider picker --- .../Core/AI/Copilot/CopilotInlineSource.swift | 4 +- .../InlineSuggestion/AIChatInlineSource.swift | 6 +-- .../InlineSuggestionManager.swift | 23 +++++----- TablePro/Models/AI/AIModels.swift | 11 +---- TablePro/Models/AI/CopilotSettings.swift | 5 --- TablePro/Models/Settings/EditorSettings.swift | 27 +++++++++++- .../Views/Editor/SQLEditorCoordinator.swift | 42 +++++++++---------- TablePro/Views/Settings/AISettingsView.swift | 13 ------ .../Views/Settings/CopilotSettingsView.swift | 1 - .../Views/Settings/EditorSettingsView.swift | 21 ++++++++++ 10 files changed, 84 insertions(+), 69 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift index 5c33f1dc4..286e679fd 100644 --- a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift +++ b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift @@ -17,9 +17,7 @@ final class CopilotInlineSource: InlineSuggestionSource { } var isAvailable: Bool { - let settings = AppSettingsManager.shared.copilot - guard settings.enabled, settings.useForInlineSuggestions else { return false } - return CopilotService.shared.status == .running && CopilotService.shared.isAuthenticated + CopilotService.shared.status == .running && CopilotService.shared.isAuthenticated } func requestSuggestion(context: SuggestionContext) async throws -> InlineSuggestion? { diff --git a/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift b/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift index f8dd4e1a6..0533715ce 100644 --- a/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift +++ b/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift @@ -20,15 +20,15 @@ final class AIChatInlineSource: InlineSuggestionSource { var isAvailable: Bool { let settings = AppSettingsManager.shared.ai - guard settings.enabled, settings.inlineSuggestEnabled else { return false } + guard settings.enabled else { return false } if connectionPolicy == .never { return false } - return AIProviderFactory.resolve(for: .inlineSuggest, settings: settings) != nil + return settings.providers.contains(where: \.isEnabled) } func requestSuggestion(context: SuggestionContext) async throws -> InlineSuggestion? { let settings = AppSettingsManager.shared.ai - guard let resolved = AIProviderFactory.resolve(for: .inlineSuggest, settings: settings) else { + guard let resolved = AIProviderFactory.resolve(for: .chat, settings: settings) else { return nil } diff --git a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift index f49bcb21f..d92a13e23 100644 --- a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift @@ -18,7 +18,7 @@ final class InlineSuggestionManager { private weak var controller: TextViewController? private let renderer = GhostTextRenderer() - private var source: InlineSuggestionSource? + private var sourceResolver: (@MainActor () -> InlineSuggestionSource?)? private var currentSuggestion: InlineSuggestion? private var suggestionOffset: Int = 0 private var debounceTimer: Timer? @@ -35,16 +35,15 @@ final class InlineSuggestionManager { // MARK: - Install / Uninstall - func install(controller: TextViewController, source: InlineSuggestionSource?) { + func install( + controller: TextViewController, + sourceResolver: @escaping @MainActor () -> InlineSuggestionSource? + ) { self.controller = controller - self.source = source + self.sourceResolver = sourceResolver renderer.install(controller: controller) } - func updateSource(_ source: InlineSuggestionSource?) { - self.source = source - } - func editorDidFocus() { guard !isEditorFocused else { return } isEditorFocused = true @@ -71,7 +70,7 @@ final class InlineSuggestionManager { renderer.uninstall() removeKeyEventMonitor() - source = nil + sourceResolver = nil controller = nil } @@ -109,7 +108,7 @@ final class InlineSuggestionManager { } private func isEnabled() -> Bool { - guard let source, source.isAvailable else { return false } + guard let source = sourceResolver?(), source.isAvailable else { return false } guard let controller else { return false } guard let textView = controller.textView else { return false } @@ -128,7 +127,7 @@ final class InlineSuggestionManager { private func requestSuggestion() { guard isEnabled() else { return } - guard let source 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 @@ -187,7 +186,7 @@ final class InlineSuggestionManager { with: suggestion.text ) - source?.didAcceptSuggestion(suggestion) + sourceResolver?()?.didAcceptSuggestion(suggestion) } func dismissSuggestion() { @@ -196,7 +195,7 @@ final class InlineSuggestionManager { currentTask = nil if let suggestion = currentSuggestion { - source?.didDismissSuggestion(suggestion) + sourceResolver?()?.didDismissSuggestion(suggestion) } renderer.hide() diff --git a/TablePro/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 41d120cbf..101102868 100644 --- a/TablePro/Models/AI/AIModels.swift +++ b/TablePro/Models/AI/AIModels.swift @@ -89,7 +89,6 @@ enum AIFeature: String, Codable, CaseIterable, Identifiable { case explainQuery = "explainQuery" case optimizeQuery = "optimizeQuery" case fixError = "fixError" - case inlineSuggest = "inlineSuggest" var id: String { rawValue } @@ -99,7 +98,6 @@ enum AIFeature: String, Codable, CaseIterable, Identifiable { case .explainQuery: return String(localized: "Explain Query") case .optimizeQuery: return String(localized: "Optimize Query") case .fixError: return String(localized: "Fix Error") - case .inlineSuggest: return String(localized: "Inline Suggestions") } } } @@ -143,7 +141,6 @@ struct AISettings: Codable, Equatable { var includeQueryResults: Bool var maxSchemaTables: Int var defaultConnectionPolicy: AIConnectionPolicy - var inlineSuggestEnabled: Bool static let `default` = AISettings( enabled: true, @@ -153,8 +150,7 @@ struct AISettings: Codable, Equatable { includeCurrentQuery: true, includeQueryResults: false, maxSchemaTables: 20, - defaultConnectionPolicy: .askEachTime, - inlineSuggestEnabled: false + defaultConnectionPolicy: .askEachTime ) init( @@ -165,8 +161,7 @@ struct AISettings: Codable, Equatable { includeCurrentQuery: Bool = true, includeQueryResults: Bool = false, maxSchemaTables: Int = 20, - defaultConnectionPolicy: AIConnectionPolicy = .askEachTime, - inlineSuggestEnabled: Bool = false + defaultConnectionPolicy: AIConnectionPolicy = .askEachTime ) { self.enabled = enabled self.providers = providers @@ -176,7 +171,6 @@ struct AISettings: Codable, Equatable { self.includeQueryResults = includeQueryResults self.maxSchemaTables = maxSchemaTables self.defaultConnectionPolicy = defaultConnectionPolicy - self.inlineSuggestEnabled = inlineSuggestEnabled } init(from decoder: Decoder) throws { @@ -191,7 +185,6 @@ struct AISettings: Codable, Equatable { defaultConnectionPolicy = try container.decodeIfPresent( AIConnectionPolicy.self, forKey: .defaultConnectionPolicy ) ?? .askEachTime - inlineSuggestEnabled = try container.decodeIfPresent(Bool.self, forKey: .inlineSuggestEnabled) ?? false } } diff --git a/TablePro/Models/AI/CopilotSettings.swift b/TablePro/Models/AI/CopilotSettings.swift index b15619832..2753df8e2 100644 --- a/TablePro/Models/AI/CopilotSettings.swift +++ b/TablePro/Models/AI/CopilotSettings.swift @@ -7,29 +7,24 @@ import Foundation struct CopilotSettings: Codable, Equatable { var enabled: Bool - var useForInlineSuggestions: Bool var telemetryEnabled: Bool static let `default` = CopilotSettings( enabled: false, - useForInlineSuggestions: true, telemetryEnabled: true ) init( enabled: Bool = false, - useForInlineSuggestions: Bool = true, telemetryEnabled: Bool = true ) { self.enabled = enabled - self.useForInlineSuggestions = useForInlineSuggestions 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 - useForInlineSuggestions = try container.decodeIfPresent(Bool.self, forKey: .useForInlineSuggestions) ?? true 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/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index edcff8507..c548220a7 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -254,33 +254,33 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { // MARK: - Inline Suggestion Manager private func installInlineSuggestionManager(controller: TextViewController) { - let source = resolveInlineSource() let manager = InlineSuggestionManager() - manager.install(controller: controller, source: source) + manager.install(controller: controller, sourceResolver: { [weak self] in + self?.resolveInlineSource() + }) inlineSuggestionManager = manager } private func resolveInlineSource() -> InlineSuggestionSource? { - let copilotSettings = AppSettingsManager.shared.copilot - if copilotSettings.enabled, copilotSettings.useForInlineSuggestions, - CopilotService.shared.isAuthenticated { - let sync = CopilotDocumentSync() - copilotDocumentSync = sync - let source = CopilotInlineSource(documentSync: sync) - copilotInlineSource = source - return source - } - - let ai = AppSettingsManager.shared.ai - if ai.enabled, ai.inlineSuggestEnabled { - let source = AIChatInlineSource( - schemaProvider: schemaProvider, - connectionPolicy: connectionAIPolicy - ) - aiChatInlineSource = source - return source + switch AppSettingsManager.shared.editor.inlineSuggestionProvider { + case .off: + return nil + case .copilot: + if copilotInlineSource == nil { + let sync = CopilotDocumentSync() + copilotDocumentSync = sync + copilotInlineSource = CopilotInlineSource(documentSync: sync) + } + return copilotInlineSource + case .ai: + if aiChatInlineSource == nil { + aiChatInlineSource = AIChatInlineSource( + schemaProvider: schemaProvider, + connectionPolicy: connectionAIPolicy + ) + } + return aiChatInlineSource } - return nil } // MARK: - Vim Mode diff --git a/TablePro/Views/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 04008b48e..06d57c2b1 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -25,7 +25,6 @@ struct AISettingsView: View { providersSection featureRoutingSection contextSection - inlineSuggestionsSection privacySection } } @@ -189,18 +188,6 @@ struct AISettingsView: View { } } - // MARK: - Inline Suggestions Section - - private var inlineSuggestionsSection: some View { - Section { - Toggle(String(localized: "Enable inline suggestions"), isOn: $settings.inlineSuggestEnabled) - } header: { - Text("Inline Suggestions") - } footer: { - Text("AI-powered SQL completions appear as ghost text while typing. Press Tab to accept, Escape to dismiss.") - } - } - // MARK: - Privacy Section private var privacySection: some View { diff --git a/TablePro/Views/Settings/CopilotSettingsView.swift b/TablePro/Views/Settings/CopilotSettingsView.swift index aba47e8bd..bf29670e8 100644 --- a/TablePro/Views/Settings/CopilotSettingsView.swift +++ b/TablePro/Views/Settings/CopilotSettingsView.swift @@ -120,7 +120,6 @@ struct CopilotSettingsView: View { private var preferencesSection: some View { Section { - Toggle("Use for inline suggestions", isOn: $settings.useForInlineSuggestions) Toggle("Send telemetry to GitHub", isOn: $settings.telemetryEnabled) } header: { Text("Preferences") 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) From 6645375aba5a843c91b32c0bc7895ea0526cdc50 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 12:13:22 +0700 Subject: [PATCH 11/34] fix: sync document immediately when creating CopilotInlineSource --- .../Core/AI/Copilot/CopilotDocumentSync.swift | 20 ++++++++---- .../Core/AI/Copilot/CopilotInlineSource.swift | 12 +++++-- .../InlineSuggestionManager.swift | 32 +++++++++++++++---- .../Views/Editor/SQLEditorCoordinator.swift | 8 ++++- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift index 8fbf545b7..b51728805 100644 --- a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -17,18 +17,26 @@ final class CopilotDocumentSync { "tablepro://tab/\(tabID.uuidString)" } - func didActivateTab(tabID: UUID, text: String, languageId: String = "sql") async { + /// Register a document synchronously so `currentDocumentInfo()` works immediately. + /// LSP notifications are sent in the background. + func ensureDocumentOpen(tabID: UUID, text: String, languageId: String = "sql") { let uri = Self.documentURI(for: tabID) - guard let client = CopilotService.shared.client else { return } - if !documentManager.isOpen(uri) { - let item = documentManager.openDocument(uri: uri, languageId: languageId, text: text) - await client.didOpenDocument(item) + _ = documentManager.openDocument(uri: uri, languageId: languageId, text: text) } - await client.didFocusDocument(uri: uri) currentURI = uri } + func didActivateTab(tabID: UUID, text: String, languageId: String = "sql") async { + let uri = Self.documentURI(for: tabID) + ensureDocumentOpen(tabID: tabID, text: text, languageId: languageId) + + guard let client = CopilotService.shared.client else { return } + let item = LSPTextDocumentItem(uri: uri, languageId: languageId, version: documentManager.version(for: uri) ?? 0, text: text) + await client.didOpenDocument(item) + await client.didFocusDocument(uri: uri) + } + func didChangeText(tabID: UUID, newText: String) async { let uri = Self.documentURI(for: tabID) guard let client = CopilotService.shared.client else { return } diff --git a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift index 286e679fd..a1ada5c83 100644 --- a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift +++ b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift @@ -21,8 +21,15 @@ final class CopilotInlineSource: InlineSuggestionSource { } func requestSuggestion(context: SuggestionContext) async throws -> InlineSuggestion? { - guard let client = CopilotService.shared.client else { return nil } - guard let docInfo = documentSync.currentDocumentInfo() else { return nil } + guard let client = CopilotService.shared.client else { + Self.logger.debug("requestSuggestion: no LSP client") + return nil + } + guard let docInfo = documentSync.currentDocumentInfo() else { + Self.logger.debug("requestSuggestion: no document info (tab not synced)") + return nil + } + Self.logger.debug("requestSuggestion: uri=\(docInfo.uri) line=\(context.cursorLine) char=\(context.cursorCharacter)") let editorSettings = AppSettingsManager.shared.editor let params = LSPInlineCompletionParams( @@ -36,6 +43,7 @@ final class CopilotInlineSource: InlineSuggestionSource { ) let result = try await client.inlineCompletion(params: params) + Self.logger.debug("requestSuggestion: got \(result.items.count) items") guard let first = result.items.first, !first.insertText.isEmpty else { return nil } return InlineSuggestion(text: first.insertText, acceptCommand: first.command) diff --git a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift index d92a13e23..9c582e023 100644 --- a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift @@ -108,12 +108,23 @@ final class InlineSuggestionManager { } private func isEnabled() -> Bool { - guard let source = sourceResolver?(), source.isAvailable else { return false } - guard let controller else { return false } - guard let textView = controller.textView else { return false } - + guard let source = sourceResolver?() else { + Self.logger.debug("isEnabled: no source resolver or resolver returned nil") + return false + } + guard source.isAvailable else { + Self.logger.debug("isEnabled: source not available (\(type(of: source)))") + return false + } + guard let controller else { + Self.logger.debug("isEnabled: no controller") + return false + } + guard let textView = controller.textView else { + Self.logger.debug("isEnabled: no textView") + return false + } guard textView.window?.firstResponder === textView else { return false } - guard let cursor = controller.cursorPositions.first, cursor.range.length == 0 else { return false } @@ -126,12 +137,19 @@ final class InlineSuggestionManager { // MARK: - Request private func requestSuggestion() { - guard isEnabled() else { return } - guard let source = sourceResolver?() else { return } + guard isEnabled() else { + Self.logger.debug("requestSuggestion: not enabled") + return + } + guard let source = sourceResolver?() else { + Self.logger.debug("requestSuggestion: no source") + return + } guard let controller, let textView = controller.textView else { return } let cursorOffset = controller.cursorPositions.first?.range.location ?? 0 guard cursorOffset > 0 else { return } + Self.logger.debug("requestSuggestion: requesting from \(type(of: source)), cursor=\(cursorOffset)") let fullText = textView.string let nsText = fullText as NSString diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index c548220a7..024926636 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -262,7 +262,8 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { } private func resolveInlineSource() -> InlineSuggestionSource? { - switch AppSettingsManager.shared.editor.inlineSuggestionProvider { + let provider = AppSettingsManager.shared.editor.inlineSuggestionProvider + switch provider { case .off: return nil case .copilot: @@ -270,6 +271,10 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { let sync = CopilotDocumentSync() copilotDocumentSync = sync copilotInlineSource = CopilotInlineSource(documentSync: sync) + if let tabID, let textView = controller?.textView { + sync.ensureDocumentOpen(tabID: tabID, text: textView.string) + Task { await sync.didActivateTab(tabID: tabID, text: textView.string) } + } } return copilotInlineSource case .ai: @@ -279,6 +284,7 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { connectionPolicy: connectionAIPolicy ) } + Self.logger.debug("resolveInlineSource: ai, available=\(self.aiChatInlineSource?.isAvailable ?? false)") return aiChatInlineSource } } From 49f8cfc8a0f7e1c33c43da7e4ed9ed09e1d4200b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 12:15:07 +0700 Subject: [PATCH 12/34] fix: start Copilot service on app launch when enabled --- TablePro/Core/Storage/AppSettingsManager.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index a6a653301..319aa47a4 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -227,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 From 3bf2a8bf21af932698de7ee054dd347fb7b2c5e0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 12:16:49 +0700 Subject: [PATCH 13/34] fix: use file:// URI with .sql extension for Copilot document sync --- TablePro/Core/AI/Copilot/CopilotDocumentSync.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift index b51728805..11cb9b01a 100644 --- a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -14,7 +14,7 @@ final class CopilotDocumentSync { private var currentURI: String? static func documentURI(for tabID: UUID) -> String { - "tablepro://tab/\(tabID.uuidString)" + "file:///tmp/tablepro-\(tabID.uuidString).sql" } /// Register a document synchronously so `currentDocumentInfo()` works immediately. From 97fe8e5ca7fa36c2065c9be5946aeb8d2f66e4c8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 12:19:41 +0700 Subject: [PATCH 14/34] fix: use correct triggerKind=2 (Automatic) for inline completions --- TablePro/Core/AI/Copilot/CopilotInlineSource.swift | 2 +- TablePro/Core/LSP/LSPTransport.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift index a1ada5c83..323f07c27 100644 --- a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift +++ b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift @@ -35,7 +35,7 @@ final class CopilotInlineSource: InlineSuggestionSource { let params = LSPInlineCompletionParams( textDocument: LSPVersionedTextDocumentIdentifier(uri: docInfo.uri, version: docInfo.version), position: LSPPosition(line: context.cursorLine, character: context.cursorCharacter), - context: LSPInlineCompletionContext(triggerKind: 1), + context: LSPInlineCompletionContext(triggerKind: 2), formattingOptions: LSPFormattingOptions( tabSize: editorSettings.clampedTabWidth, insertSpaces: true diff --git a/TablePro/Core/LSP/LSPTransport.swift b/TablePro/Core/LSP/LSPTransport.swift index cea84c8a4..afe1e6b1c 100644 --- a/TablePro/Core/LSP/LSPTransport.swift +++ b/TablePro/Core/LSP/LSPTransport.swift @@ -147,6 +147,7 @@ actor LSPTransport { let notification = LSPJSONRPCNotification(method: method, params: params) let data = try JSONEncoder().encode(notification) + Self.logger.debug("sendNotification: \(method)") try writeMessage(data) } From fb5b31b353d62db1f2ffdad81184cdc2a7a00e41 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 12:24:43 +0700 Subject: [PATCH 15/34] fix: add workspaceFolders and capabilities to LSP initialize params --- TablePro/Core/LSP/LSPClient.swift | 14 ++++++++++++-- TablePro/Core/LSP/LSPTypes.swift | 14 +++++++++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/LSP/LSPClient.swift b/TablePro/Core/LSP/LSPClient.swift index 5383c0975..88067b435 100644 --- a/TablePro/Core/LSP/LSPClient.swift +++ b/TablePro/Core/LSP/LSPClient.swift @@ -22,13 +22,19 @@ actor LSPClient { editorPluginInfo: LSPClientInfo?, processId: Int? ) async throws -> LSPInitializeResult { + let homePath = FileManager.default.homeDirectoryForCurrentUser.path let params = LSPInitializeParams( processId: processId ?? Int(ProcessInfo.processInfo.processIdentifier), - capabilities: LSPClientCapabilities(), + capabilities: LSPClientCapabilities( + general: LSPGeneralCapabilities(positionEncodings: ["utf-16"]) + ), initializationOptions: LSPInitializationOptions( editorInfo: clientInfo, editorPluginInfo: editorPluginInfo - ) + ), + workspaceFolders: [ + LSPWorkspaceFolder(uri: "file://\(homePath)", name: "Home") + ] ) let data = try await transport.sendRequest(method: "initialize", params: params) @@ -83,6 +89,10 @@ actor LSPClient { func inlineCompletion(params: LSPInlineCompletionParams) async throws -> LSPInlineCompletionList { let data = try await transport.sendRequest(method: "textDocument/inlineCompletion", params: params) + if let raw = String(data: data, encoding: .utf8) { + Self.logger.debug("inlineCompletion response: \(raw.prefix(500))") + } + if let list = try? JSONDecoder().decode(LSPInlineCompletionList.self, from: data) { return list } diff --git a/TablePro/Core/LSP/LSPTypes.swift b/TablePro/Core/LSP/LSPTypes.swift index 35fabb60f..145733cb9 100644 --- a/TablePro/Core/LSP/LSPTypes.swift +++ b/TablePro/Core/LSP/LSPTypes.swift @@ -79,13 +79,25 @@ struct LSPClientInfo: Codable, Sendable, Equatable { let version: String } +struct LSPWorkspaceFolder: Codable, Sendable { + let uri: String + let name: String +} + struct LSPInitializeParams: Codable, Sendable { let processId: Int let capabilities: LSPClientCapabilities let initializationOptions: LSPInitializationOptions + let workspaceFolders: [LSPWorkspaceFolder]? } -struct LSPClientCapabilities: Codable, Sendable {} +struct LSPClientCapabilities: Codable, Sendable { + let general: LSPGeneralCapabilities? +} + +struct LSPGeneralCapabilities: Codable, Sendable { + let positionEncodings: [String]? +} struct LSPInitializationOptions: Codable, Sendable { let editorInfo: LSPClientInfo From e50815a078b3e10ccf9175e3f1648056e1e5f11d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 12:34:55 +0700 Subject: [PATCH 16/34] debug: add logging for Copilot inline suggestion pipeline --- TablePro/Core/AI/Copilot/CopilotDocumentSync.swift | 2 ++ TablePro/Core/LSP/LSPTransport.swift | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift index 11cb9b01a..f74126aea 100644 --- a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -23,6 +23,7 @@ final class CopilotDocumentSync { let uri = Self.documentURI(for: tabID) if !documentManager.isOpen(uri) { _ = documentManager.openDocument(uri: uri, languageId: languageId, text: text) + Self.logger.debug("ensureDocumentOpen: uri=\(uri) textLen=\((text as NSString).length) text='\(text.prefix(100))'") } currentURI = uri } @@ -41,6 +42,7 @@ final class CopilotDocumentSync { let uri = Self.documentURI(for: tabID) guard let client = CopilotService.shared.client else { return } guard let (versioned, changes) = documentManager.changeDocument(uri: uri, newText: newText) else { return } + Self.logger.debug("didChangeText: v=\(versioned.version) text='\(newText.prefix(100))'") await client.didChangeDocument(uri: versioned.uri, version: versioned.version, changes: changes) } diff --git a/TablePro/Core/LSP/LSPTransport.swift b/TablePro/Core/LSP/LSPTransport.swift index afe1e6b1c..19ee2feb3 100644 --- a/TablePro/Core/LSP/LSPTransport.swift +++ b/TablePro/Core/LSP/LSPTransport.swift @@ -131,6 +131,9 @@ actor LSPTransport { let request = LSPJSONRPCRequest(id: requestID, method: method, params: params) let data = try JSONEncoder().encode(request) + if method == "textDocument/inlineCompletion", let raw = String(data: data, encoding: .utf8) { + Self.logger.debug("sendRequest: \(raw.prefix(800))") + } try writeMessage(data) return try await withCheckedThrowingContinuation { continuation in @@ -244,6 +247,8 @@ actor LSPTransport { // Response (has id) if let id = json["id"] as? Int { + let hasContinuation = pendingRequests[id] != nil + Self.logger.debug("dispatchMessage: response id=\(id) hasContinuation=\(hasContinuation) pending=\(self.pendingRequests.keys.sorted())") if let continuation = pendingRequests.removeValue(forKey: id) { if let errorObj = json["error"] as? [String: Any] { let code = errorObj["code"] as? Int ?? -1 From 0062de22a11626265800a5be882b4b923324b25d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 12:50:56 +0700 Subject: [PATCH 17/34] refactor: production-ready inline suggestion system with proper ghost text rendering --- .../Core/AI/Copilot/CopilotAuthManager.swift | 8 +-- .../AI/Copilot/CopilotBinaryManager.swift | 3 +- .../Core/AI/Copilot/CopilotDocumentSync.swift | 10 ++- .../Core/AI/Copilot/CopilotInlineSource.swift | 65 ++++++++++++++----- TablePro/Core/AI/Copilot/CopilotService.swift | 18 ++--- .../InlineSuggestion/AIChatInlineSource.swift | 7 +- .../InlineSuggestionManager.swift | 44 ++++--------- .../InlineSuggestionSource.swift | 5 ++ TablePro/Core/LSP/LSPClient.swift | 48 ++++++++++---- TablePro/Core/LSP/LSPTransport.swift | 6 -- .../Views/Editor/SQLEditorCoordinator.swift | 1 - 11 files changed, 132 insertions(+), 83 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotAuthManager.swift b/TablePro/Core/AI/Copilot/CopilotAuthManager.swift index 61cac0dd2..2747cc87a 100644 --- a/TablePro/Core/AI/Copilot/CopilotAuthManager.swift +++ b/TablePro/Core/AI/Copilot/CopilotAuthManager.swift @@ -27,12 +27,10 @@ final class CopilotAuthManager { let user: String } - private struct EmptyParams: Encodable {} - func initiateSignIn(transport: LSPTransport) async throws -> SignInResult { let data: Data = try await transport.sendRequest( method: "signInInitiate", - params: EmptyParams?.none + params: EmptyLSPParams() ) let response = try JSONDecoder().decode(SignInInitiateResponse.self, from: data) @@ -54,7 +52,7 @@ final class CopilotAuthManager { for _ in 0.. String { - "file:///tmp/tablepro-\(tabID.uuidString).sql" + if let existing = uriMap[tabID] { return existing } + let uri = "file:///tmp/tablepro/query-\(nextID).sql" + nextID += 1 + uriMap[tabID] = uri + return uri } /// Register a document synchronously so `currentDocumentInfo()` works immediately. @@ -23,7 +29,6 @@ final class CopilotDocumentSync { let uri = Self.documentURI(for: tabID) if !documentManager.isOpen(uri) { _ = documentManager.openDocument(uri: uri, languageId: languageId, text: text) - Self.logger.debug("ensureDocumentOpen: uri=\(uri) textLen=\((text as NSString).length) text='\(text.prefix(100))'") } currentURI = uri } @@ -42,7 +47,6 @@ final class CopilotDocumentSync { let uri = Self.documentURI(for: tabID) guard let client = CopilotService.shared.client else { return } guard let (versioned, changes) = documentManager.changeDocument(uri: uri, newText: newText) else { return } - Self.logger.debug("didChangeText: v=\(versioned.version) text='\(newText.prefix(100))'") await client.didChangeDocument(uri: versioned.uri, version: versioned.version, changes: changes) } diff --git a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift index 323f07c27..88aeca0e3 100644 --- a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift +++ b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift @@ -21,15 +21,8 @@ final class CopilotInlineSource: InlineSuggestionSource { } func requestSuggestion(context: SuggestionContext) async throws -> InlineSuggestion? { - guard let client = CopilotService.shared.client else { - Self.logger.debug("requestSuggestion: no LSP client") - return nil - } - guard let docInfo = documentSync.currentDocumentInfo() else { - Self.logger.debug("requestSuggestion: no document info (tab not synced)") - return nil - } - Self.logger.debug("requestSuggestion: uri=\(docInfo.uri) line=\(context.cursorLine) char=\(context.cursorCharacter)") + guard let client = CopilotService.shared.client else { return nil } + guard let docInfo = documentSync.currentDocumentInfo() else { return nil } let editorSettings = AppSettingsManager.shared.editor let params = LSPInlineCompletionParams( @@ -43,10 +36,37 @@ final class CopilotInlineSource: InlineSuggestionSource { ) let result = try await client.inlineCompletion(params: params) - Self.logger.debug("requestSuggestion: got \(result.items.count) items") guard let first = result.items.first, !first.insertText.isEmpty else { return nil } - return InlineSuggestion(text: first.insertText, acceptCommand: first.command) + // Compute ghost text: only the part after the cursor position + let ghostText: String + var replacementRange: NSRange? + + if let range = first.range { + let nsText = context.fullText as NSString + let rangeStartOffset = Self.offsetForPosition(range.start, 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(range.end, 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) { @@ -57,11 +77,24 @@ final class CopilotInlineSource: InlineSuggestionSource { } } - func didShowSuggestion(_ suggestion: InlineSuggestion) { - Self.logger.debug("Copilot suggestion shown") - } + func didShowSuggestion(_ suggestion: InlineSuggestion) {} + + func didDismissSuggestion(_ suggestion: InlineSuggestion) {} - func didDismissSuggestion(_ suggestion: InlineSuggestion) { - // Copilot does not require dismiss telemetry + // 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/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift index a620a6398..f926599b3 100644 --- a/TablePro/Core/AI/Copilot/CopilotService.swift +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -159,6 +159,11 @@ final class CopilotService { } } + private struct CheckStatusResponse: Decodable { + let status: String + let user: String? + } + private func checkAuthStatus() async { guard let transport else { return } do { @@ -166,16 +171,13 @@ final class CopilotService { method: "checkStatus", params: EmptyLSPParams() ) - let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] - let status = json?["status"] as? String - let user = json?["user"] as? String ?? "" - - if status == "OK" || status == "AlreadySignedIn" { - authState = .signedIn(username: user) - Self.logger.info("Copilot already authenticated as \(user)") + 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: \(error.localizedDescription)") + Self.logger.debug("Auth status check failed: \(error.localizedDescription)") } } diff --git a/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift b/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift index 0533715ce..09982d10a 100644 --- a/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift +++ b/TablePro/Core/AI/InlineSuggestion/AIChatInlineSource.swift @@ -55,7 +55,12 @@ final class AIChatInlineSource: InlineSuggestionSource { let cleaned = cleanSuggestion(accumulated) guard !cleaned.isEmpty else { return nil } - return InlineSuggestion(text: cleaned, acceptCommand: nil) + return InlineSuggestion( + text: cleaned, + replacementRange: nil, + replacementText: cleaned, + acceptCommand: nil + ) } func didShowSuggestion(_ suggestion: InlineSuggestion) {} diff --git a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift index 9c582e023..2d8f14508 100644 --- a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift @@ -108,22 +108,10 @@ final class InlineSuggestionManager { } private func isEnabled() -> Bool { - guard let source = sourceResolver?() else { - Self.logger.debug("isEnabled: no source resolver or resolver returned nil") - return false - } - guard source.isAvailable else { - Self.logger.debug("isEnabled: source not available (\(type(of: source)))") - return false - } - guard let controller else { - Self.logger.debug("isEnabled: no controller") - return false - } - guard let textView = controller.textView else { - Self.logger.debug("isEnabled: no textView") - return false - } + 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 } @@ -137,19 +125,12 @@ final class InlineSuggestionManager { // MARK: - Request private func requestSuggestion() { - guard isEnabled() else { - Self.logger.debug("requestSuggestion: not enabled") - return - } - guard let source = sourceResolver?() else { - Self.logger.debug("requestSuggestion: no source") - return - } + 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 } - Self.logger.debug("requestSuggestion: requesting from \(type(of: source)), cursor=\(cursorOffset)") let fullText = textView.string let nsText = fullText as NSString @@ -195,14 +176,17 @@ final class InlineSuggestionManager { guard let suggestion = currentSuggestion, let textView = controller?.textView else { return } - let offset = suggestionOffset renderer.hide() currentSuggestion = nil - textView.replaceCharacters( - in: NSRange(location: offset, length: 0), - with: suggestion.text - ) + 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) } diff --git a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift index 26c4c95cd..a1da447a9 100644 --- a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift +++ b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionSource.swift @@ -16,7 +16,12 @@ struct SuggestionContext: Sendable { /// 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? } diff --git a/TablePro/Core/LSP/LSPClient.swift b/TablePro/Core/LSP/LSPClient.swift index 88067b435..a3f5009cc 100644 --- a/TablePro/Core/LSP/LSPClient.swift +++ b/TablePro/Core/LSP/LSPClient.swift @@ -43,23 +43,35 @@ actor LSPClient { } func initialized() async { - try? await transport.sendNotification(method: "initialized", params: EmptyLSPParams?.none) + do { + try await transport.sendNotification(method: "initialized", params: EmptyLSPParams()) + } catch { + Self.logger.debug("Failed to send initialized: \(error.localizedDescription)") + } } func shutdown() async throws { - _ = try await transport.sendRequest(method: "shutdown", params: EmptyLSPParams?.none) + _ = try await transport.sendRequest(method: "shutdown", params: EmptyLSPParams()) Self.logger.info("LSP shutdown complete") } func exit() async { - try? await transport.sendNotification(method: "exit", params: EmptyLSPParams?.none) + 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) - try? await transport.sendNotification(method: "textDocument/didOpen", params: params) + do { + try await transport.sendNotification(method: "textDocument/didOpen", params: params) + } catch { + Self.logger.debug("Failed to send didOpen: \(error.localizedDescription)") + } } func didChangeDocument( @@ -71,17 +83,29 @@ actor LSPClient { textDocument: LSPVersionedTextDocumentIdentifier(uri: uri, version: version), contentChanges: changes ) - try? await transport.sendNotification(method: "textDocument/didChange", params: params) + 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)) - try? await transport.sendNotification(method: "textDocument/didClose", params: params) + 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)) - try? await transport.sendNotification(method: "textDocument/didFocus", params: params) + do { + try await transport.sendNotification(method: "textDocument/didFocus", params: params) + } catch { + Self.logger.debug("Failed to send didFocus: \(error.localizedDescription)") + } } // MARK: - Inline Completions @@ -89,10 +113,6 @@ actor LSPClient { func inlineCompletion(params: LSPInlineCompletionParams) async throws -> LSPInlineCompletionList { let data = try await transport.sendRequest(method: "textDocument/inlineCompletion", params: params) - if let raw = String(data: data, encoding: .utf8) { - Self.logger.debug("inlineCompletion response: \(raw.prefix(500))") - } - if let list = try? JSONDecoder().decode(LSPInlineCompletionList.self, from: data) { return list } @@ -114,7 +134,11 @@ actor LSPClient { func didChangeConfiguration(settings: [String: AnyCodable]) async { let params = LSPConfigurationParams(settings: settings) - try? await transport.sendNotification(method: "workspace/didChangeConfiguration", params: params) + do { + try await transport.sendNotification(method: "workspace/didChangeConfiguration", params: params) + } catch { + Self.logger.debug("Failed to send didChangeConfiguration: \(error.localizedDescription)") + } } // MARK: - Cancel Request diff --git a/TablePro/Core/LSP/LSPTransport.swift b/TablePro/Core/LSP/LSPTransport.swift index 19ee2feb3..cea84c8a4 100644 --- a/TablePro/Core/LSP/LSPTransport.swift +++ b/TablePro/Core/LSP/LSPTransport.swift @@ -131,9 +131,6 @@ actor LSPTransport { let request = LSPJSONRPCRequest(id: requestID, method: method, params: params) let data = try JSONEncoder().encode(request) - if method == "textDocument/inlineCompletion", let raw = String(data: data, encoding: .utf8) { - Self.logger.debug("sendRequest: \(raw.prefix(800))") - } try writeMessage(data) return try await withCheckedThrowingContinuation { continuation in @@ -150,7 +147,6 @@ actor LSPTransport { let notification = LSPJSONRPCNotification(method: method, params: params) let data = try JSONEncoder().encode(notification) - Self.logger.debug("sendNotification: \(method)") try writeMessage(data) } @@ -247,8 +243,6 @@ actor LSPTransport { // Response (has id) if let id = json["id"] as? Int { - let hasContinuation = pendingRequests[id] != nil - Self.logger.debug("dispatchMessage: response id=\(id) hasContinuation=\(hasContinuation) pending=\(self.pendingRequests.keys.sorted())") if let continuation = pendingRequests.removeValue(forKey: id) { if let errorObj = json["error"] as? [String: Any] { let code = errorObj["code"] as? Int ?? -1 diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 024926636..071909e11 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -284,7 +284,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { connectionPolicy: connectionAIPolicy ) } - Self.logger.debug("resolveInlineSource: ai, available=\(self.aiChatInlineSource?.isAvailable ?? false)") return aiChatInlineSource } } From 51898aadf32af6de5f368bd79d496314c5f19f38 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 13:50:07 +0700 Subject: [PATCH 18/34] feat: provide database schema context to Copilot via companion document --- .../Core/AI/Copilot/CopilotDocumentSync.swift | 13 +++ .../AI/Copilot/CopilotSchemaContext.swift | 100 ++++++++++++++++++ .../Core/Autocomplete/SQLSchemaProvider.swift | 5 + .../Views/Editor/SQLEditorCoordinator.swift | 10 ++ 4 files changed, 128 insertions(+) create mode 100644 TablePro/Core/AI/Copilot/CopilotSchemaContext.swift diff --git a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift index c84c50105..b367b669b 100644 --- a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -11,6 +11,7 @@ final class CopilotDocumentSync { private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotDocumentSync") private let documentManager = LSPDocumentManager() + private let schemaContext = CopilotSchemaContext() private var currentURI: String? private static var uriMap: [UUID: String] = [:] private static var nextID = 1 @@ -58,6 +59,18 @@ final class CopilotDocumentSync { if currentURI == uri { currentURI = nil } } + func syncSchemaContext( + schemaProvider: SQLSchemaProvider, + databaseName: String, + databaseType: DatabaseType + ) async { + await schemaContext.syncSchema( + schemaProvider: schemaProvider, + databaseName: databaseName, + databaseType: databaseType + ) + } + func currentDocumentInfo() -> (uri: String, version: Int)? { guard let uri = currentURI else { return nil } guard let version = documentManager.version(for: uri) else { return nil } diff --git a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift new file mode 100644 index 000000000..2b22b0ed7 --- /dev/null +++ b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift @@ -0,0 +1,100 @@ +// +// CopilotSchemaContext.swift +// TablePro +// + +import Foundation +import os +import TableProPluginKit + +@MainActor +final class CopilotSchemaContext { + private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotSchemaContext") + + private static let schemaURI = "file:///tmp/tablepro/schema-context.sql" + private let documentManager = LSPDocumentManager() + private var isOpen = false + + func syncSchema( + schemaProvider: SQLSchemaProvider, + databaseName: String, + databaseType: DatabaseType + ) async { + let tables = await schemaProvider.getTables() + guard !tables.isEmpty else { 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 + } + } + + let ddl = buildSchemaDDL( + tables: tables, + columnsByTable: columnsByTable, + databaseType: databaseType, + databaseName: databaseName + ) + + guard let client = CopilotService.shared.client else { return } + + if !isOpen { + let item = documentManager.openDocument( + uri: Self.schemaURI, + languageId: "sql", + text: ddl + ) + await client.didOpenDocument(item) + isOpen = true + } else if let (versioned, changes) = documentManager.changeDocument(uri: Self.schemaURI, newText: ddl) { + await client.didChangeDocument(uri: versioned.uri, version: versioned.version, changes: changes) + } + } + + func close() async { + guard isOpen else { return } + isOpen = false + + guard let client = CopilotService.shared.client else { return } + if let docId = documentManager.closeDocument(uri: Self.schemaURI) { + await client.didCloseDocument(uri: docId.uri) + } + } + + // MARK: - DDL Builder + + private func buildSchemaDDL( + tables: [TableInfo], + columnsByTable: [String: [ColumnInfo]], + databaseType: DatabaseType, + databaseName: String + ) -> String { + var lines: [String] = [] + lines.append("-- Database: \(databaseName) (\(databaseType.rawValue))") + lines.append("") + + for table in tables { + let columns = columnsByTable[table.name.lowercased()] ?? [] + guard !columns.isEmpty else { + lines.append("-- \(table.name) (no columns cached)") + continue + } + + lines.append("CREATE TABLE \(table.name) (") + for (i, col) in columns.enumerated() { + var parts = [" \(col.name) \(col.dataType)"] + if col.isPrimaryKey { parts.append("PRIMARY KEY") } + if !col.isNullable { parts.append("NOT NULL") } + if let def = col.defaultValue { parts.append("DEFAULT \(def)") } + let separator = i < columns.count - 1 ? "," : "" + lines.append(parts.joined(separator: " ") + separator) + } + lines.append(");") + lines.append("") + } + + return lines.joined(separator: "\n") + } +} diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 9556e6e86..98f1f5284 100644 --- a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift +++ b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift @@ -76,6 +76,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/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 071909e11..d47ebeb5e 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -275,6 +275,16 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { sync.ensureDocumentOpen(tabID: tabID, text: textView.string) Task { await sync.didActivateTab(tabID: tabID, text: textView.string) } } + if let schemaProvider, let databaseType { + Task { + let conn = await schemaProvider.getConnectionInfo() + await sync.syncSchemaContext( + schemaProvider: schemaProvider, + databaseName: conn?.database ?? "database", + databaseType: databaseType + ) + } + } } return copilotInlineSource case .ai: From 40390e112465a2b4684beb334c3ed01223ad8f19 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 14:01:40 +0700 Subject: [PATCH 19/34] fix: preserve connectionInfo on coalesced schema load, add schema sync logging --- .../Core/AI/Copilot/CopilotSchemaContext.swift | 15 +++++++++++++-- .../Core/Autocomplete/SQLSchemaProvider.swift | 3 ++- TablePro/Views/Editor/SQLEditorCoordinator.swift | 4 ++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift index 2b22b0ed7..ab55601c4 100644 --- a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift +++ b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift @@ -21,7 +21,11 @@ final class CopilotSchemaContext { databaseType: DatabaseType ) async { let tables = await schemaProvider.getTables() - guard !tables.isEmpty else { return } + Self.logger.info("syncSchema: \(tables.count) tables, db=\(databaseName) type=\(databaseType.rawValue)") + guard !tables.isEmpty else { + Self.logger.info("syncSchema: no tables, skipping") + return + } var columnsByTable: [String: [ColumnInfo]] = [:] for table in tables { @@ -38,7 +42,12 @@ final class CopilotSchemaContext { databaseName: databaseName ) - guard let client = CopilotService.shared.client else { return } + Self.logger.info("syncSchema: DDL built, \((ddl as NSString).length) chars, \(columnsByTable.count) tables with columns") + + guard let client = CopilotService.shared.client else { + Self.logger.warning("syncSchema: no Copilot client available") + return + } if !isOpen { let item = documentManager.openDocument( @@ -48,8 +57,10 @@ final class CopilotSchemaContext { ) await client.didOpenDocument(item) isOpen = true + Self.logger.info("syncSchema: opened schema context document") } else if let (versioned, changes) = documentManager.changeDocument(uri: Self.schemaURI, newText: ddl) { await client.didChangeDocument(uri: versioned.uri, version: versioned.version, changes: changes) + Self.logger.info("syncSchema: updated schema context document v=\(versioned.version)") } } diff --git a/TablePro/Core/Autocomplete/SQLSchemaProvider.swift b/TablePro/Core/Autocomplete/SQLSchemaProvider.swift index 98f1f5284..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 diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index d47ebeb5e..f8afd64e7 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -276,14 +276,18 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { Task { await sync.didActivateTab(tabID: tabID, text: textView.string) } } if let schemaProvider, let databaseType { + Self.logger.info("resolveInlineSource: triggering schema sync") Task { let conn = await schemaProvider.getConnectionInfo() + Self.logger.info("resolveInlineSource: conn=\(conn?.database ?? "nil")") await sync.syncSchemaContext( schemaProvider: schemaProvider, databaseName: conn?.database ?? "database", databaseType: databaseType ) } + } else { + Self.logger.warning("resolveInlineSource: no schemaProvider=\(self.schemaProvider != nil) databaseType=\(self.databaseType?.rawValue ?? "nil")") } } return copilotInlineSource From 5bd8d300db3da92e92326ad9b31190825d6f1a34 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 14:06:13 +0700 Subject: [PATCH 20/34] fix: get database name from DatabaseManager session instead of schema provider --- TablePro/Views/Editor/SQLEditorCoordinator.swift | 11 +++++------ TablePro/Views/Editor/SQLEditorView.swift | 1 + 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index f8afd64e7..ad2c8697f 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -56,6 +56,7 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { @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 @@ -276,18 +277,16 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { Task { await sync.didActivateTab(tabID: tabID, text: textView.string) } } if let schemaProvider, let databaseType { - Self.logger.info("resolveInlineSource: triggering schema sync") + let dbName = connectionId.flatMap { + DatabaseManager.shared.session(for: $0)?.activeDatabase + } ?? "database" Task { - let conn = await schemaProvider.getConnectionInfo() - Self.logger.info("resolveInlineSource: conn=\(conn?.database ?? "nil")") await sync.syncSchemaContext( schemaProvider: schemaProvider, - databaseName: conn?.database ?? "database", + databaseName: dbName, databaseType: databaseType ) } - } else { - Self.logger.warning("resolveInlineSource: no schemaProvider=\(self.schemaProvider != nil) databaseType=\(self.databaseType?.rawValue ?? "nil")") } } return copilotInlineSource diff --git a/TablePro/Views/Editor/SQLEditorView.swift b/TablePro/Views/Editor/SQLEditorView.swift index 379bb0aa7..a1c79d6c6 100644 --- a/TablePro/Views/Editor/SQLEditorView.swift +++ b/TablePro/Views/Editor/SQLEditorView.swift @@ -50,6 +50,7 @@ struct SQLEditorView: View { coordinator.connectionAIPolicy = connectionAIPolicy coordinator.databaseType = databaseType coordinator.tabID = tabID + coordinator.connectionId = connectionId return Group { if editorReady { From 6df70c9b05e7819d485eeec398c6b5d197523e12 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 14:12:30 +0700 Subject: [PATCH 21/34] fix: prepend schema preamble to document text instead of companion file --- .../Core/AI/Copilot/CopilotDocumentSync.swift | 25 ++--- .../Core/AI/Copilot/CopilotInlineSource.swift | 9 +- .../AI/Copilot/CopilotSchemaContext.swift | 91 +++++-------------- .../Views/Editor/SQLEditorCoordinator.swift | 5 +- 4 files changed, 40 insertions(+), 90 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift index b367b669b..daa5105bf 100644 --- a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -11,7 +11,7 @@ final class CopilotDocumentSync { private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotDocumentSync") private let documentManager = LSPDocumentManager() - private let schemaContext = CopilotSchemaContext() + let schemaContext = CopilotSchemaContext() private var currentURI: String? private static var uriMap: [UUID: String] = [:] private static var nextID = 1 @@ -24,30 +24,31 @@ final class CopilotDocumentSync { return uri } - /// Register a document synchronously so `currentDocumentInfo()` works immediately. - /// LSP notifications are sent in the background. func ensureDocumentOpen(tabID: UUID, text: String, languageId: String = "sql") { let uri = Self.documentURI(for: tabID) + let fullText = schemaContext.prependToText(text) if !documentManager.isOpen(uri) { - _ = documentManager.openDocument(uri: uri, languageId: languageId, text: text) + _ = documentManager.openDocument(uri: uri, languageId: languageId, text: fullText) } currentURI = uri } func didActivateTab(tabID: UUID, text: String, languageId: String = "sql") async { let uri = Self.documentURI(for: tabID) + let fullText = schemaContext.prependToText(text) ensureDocumentOpen(tabID: tabID, text: text, languageId: languageId) guard let client = CopilotService.shared.client else { return } - let item = LSPTextDocumentItem(uri: uri, languageId: languageId, version: documentManager.version(for: uri) ?? 0, text: text) + let item = LSPTextDocumentItem(uri: uri, languageId: languageId, version: documentManager.version(for: uri) ?? 0, text: fullText) await client.didOpenDocument(item) await client.didFocusDocument(uri: uri) } func didChangeText(tabID: UUID, newText: String) async { let uri = Self.documentURI(for: tabID) + let fullText = schemaContext.prependToText(newText) guard let client = CopilotService.shared.client else { return } - guard let (versioned, changes) = documentManager.changeDocument(uri: uri, newText: newText) 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) } @@ -59,18 +60,6 @@ final class CopilotDocumentSync { if currentURI == uri { currentURI = nil } } - func syncSchemaContext( - schemaProvider: SQLSchemaProvider, - databaseName: String, - databaseType: DatabaseType - ) async { - await schemaContext.syncSchema( - schemaProvider: schemaProvider, - databaseName: databaseName, - databaseType: databaseType - ) - } - func currentDocumentInfo() -> (uri: String, version: Int)? { guard let uri = currentURI else { return nil } guard let version = documentManager.version(for: uri) else { return nil } diff --git a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift index 88aeca0e3..ba820d5ea 100644 --- a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift +++ b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift @@ -25,9 +25,10 @@ final class CopilotInlineSource: InlineSuggestionSource { 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, character: context.cursorCharacter), + position: LSPPosition(line: context.cursorLine + preambleOffset, character: context.cursorCharacter), context: LSPInlineCompletionContext(triggerKind: 2), formattingOptions: LSPFormattingOptions( tabSize: editorSettings.clampedTabWidth, @@ -43,8 +44,10 @@ final class CopilotInlineSource: InlineSuggestionSource { var replacementRange: NSRange? if let range = first.range { + 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(range.start, in: nsText) + let rangeStartOffset = Self.offsetForPosition(adjustedStart, in: nsText) let existingLen = context.cursorOffset - rangeStartOffset if existingLen > 0, existingLen <= (first.insertText as NSString).length { @@ -53,7 +56,7 @@ final class CopilotInlineSource: InlineSuggestionSource { ghostText = first.insertText } - let rangeEndOffset = Self.offsetForPosition(range.end, in: nsText) + let rangeEndOffset = Self.offsetForPosition(adjustedEnd, in: nsText) replacementRange = NSRange(location: rangeStartOffset, length: rangeEndOffset - rangeStartOffset) } else { ghostText = first.insertText diff --git a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift index ab55601c4..cab21face 100644 --- a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift +++ b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift @@ -11,19 +11,18 @@ import TableProPluginKit final class CopilotSchemaContext { private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotSchemaContext") - private static let schemaURI = "file:///tmp/tablepro/schema-context.sql" - private let documentManager = LSPDocumentManager() - private var isOpen = false + private(set) var preamble: String = "" + private(set) var preambleLineCount: Int = 0 - func syncSchema( + func buildPreamble( schemaProvider: SQLSchemaProvider, databaseName: String, databaseType: DatabaseType ) async { let tables = await schemaProvider.getTables() - Self.logger.info("syncSchema: \(tables.count) tables, db=\(databaseName) type=\(databaseType.rawValue)") guard !tables.isEmpty else { - Self.logger.info("syncSchema: no tables, skipping") + preamble = "" + preambleLineCount = 0 return } @@ -35,77 +34,33 @@ final class CopilotSchemaContext { } } - let ddl = buildSchemaDDL( - tables: tables, - columnsByTable: columnsByTable, - databaseType: databaseType, - databaseName: databaseName - ) - - Self.logger.info("syncSchema: DDL built, \((ddl as NSString).length) chars, \(columnsByTable.count) tables with columns") - - guard let client = CopilotService.shared.client else { - Self.logger.warning("syncSchema: no Copilot client available") - return - } - - if !isOpen { - let item = documentManager.openDocument( - uri: Self.schemaURI, - languageId: "sql", - text: ddl - ) - await client.didOpenDocument(item) - isOpen = true - Self.logger.info("syncSchema: opened schema context document") - } else if let (versioned, changes) = documentManager.changeDocument(uri: Self.schemaURI, newText: ddl) { - await client.didChangeDocument(uri: versioned.uri, version: versioned.version, changes: changes) - Self.logger.info("syncSchema: updated schema context document v=\(versioned.version)") - } - } - - func close() async { - guard isOpen else { return } - isOpen = false - - guard let client = CopilotService.shared.client else { return } - if let docId = documentManager.closeDocument(uri: Self.schemaURI) { - await client.didCloseDocument(uri: docId.uri) - } - } - - // MARK: - DDL Builder - - private func buildSchemaDDL( - tables: [TableInfo], - columnsByTable: [String: [ColumnInfo]], - databaseType: DatabaseType, - databaseName: String - ) -> String { var lines: [String] = [] lines.append("-- Database: \(databaseName) (\(databaseType.rawValue))") - lines.append("") + lines.append("--") for table in tables { let columns = columnsByTable[table.name.lowercased()] ?? [] - guard !columns.isEmpty else { - lines.append("-- \(table.name) (no columns cached)") - continue - } + guard !columns.isEmpty else { continue } - lines.append("CREATE TABLE \(table.name) (") - for (i, col) in columns.enumerated() { - var parts = [" \(col.name) \(col.dataType)"] - if col.isPrimaryKey { parts.append("PRIMARY KEY") } + 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") } - if let def = col.defaultValue { parts.append("DEFAULT \(def)") } - let separator = i < columns.count - 1 ? "," : "" - lines.append(parts.joined(separator: " ") + separator) + return parts.joined(separator: " ") } - lines.append(");") - lines.append("") + lines.append("-- \(table.name)(\(colDefs.joined(separator: ", ")))") } - return lines.joined(separator: "\n") + lines.append("") + + preamble = lines.joined(separator: "\n") + preambleLineCount = lines.count + + Self.logger.info("Schema preamble built: \(self.preambleLineCount) lines, \((self.preamble as NSString).length) chars, \(tables.count) tables") + } + + func prependToText(_ text: String) -> String { + guard !preamble.isEmpty else { return text } + return preamble + text } } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index ad2c8697f..a5e201664 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -281,11 +281,14 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { DatabaseManager.shared.session(for: $0)?.activeDatabase } ?? "database" Task { - await sync.syncSchemaContext( + await sync.schemaContext.buildPreamble( schemaProvider: schemaProvider, databaseName: dbName, databaseType: databaseType ) + if let tabID, let textView = self.controller?.textView { + await sync.didActivateTab(tabID: tabID, text: textView.string) + } } } } From e5c7662c57f306778099624a3989a51da823ab95 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 14:20:53 +0700 Subject: [PATCH 22/34] fix: build schema preamble before opening document to avoid duplicate didOpen --- .../Views/Editor/SQLEditorCoordinator.swift | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index a5e201664..229553707 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -272,23 +272,26 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { let sync = CopilotDocumentSync() copilotDocumentSync = sync copilotInlineSource = CopilotInlineSource(documentSync: sync) - if let tabID, let textView = controller?.textView { - sync.ensureDocumentOpen(tabID: tabID, text: textView.string) - Task { await sync.didActivateTab(tabID: tabID, text: textView.string) } - } - if let schemaProvider, let databaseType { - let dbName = connectionId.flatMap { - DatabaseManager.shared.session(for: $0)?.activeDatabase - } ?? "database" - Task { + + 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 { + if let provider = capturedSchemaProvider, let dbType = capturedDBType { await sync.schemaContext.buildPreamble( - schemaProvider: schemaProvider, + schemaProvider: provider, databaseName: dbName, - databaseType: databaseType + databaseType: dbType ) - if let tabID, let textView = self.controller?.textView { - await sync.didActivateTab(tabID: tabID, text: textView.string) - } + } + if let tabID = capturedTabID { + sync.ensureDocumentOpen(tabID: tabID, text: capturedText) + await sync.didActivateTab(tabID: tabID, text: capturedText) } } } From 0d531b1b981772409afc705c972d8feb44d408df Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 14:24:56 +0700 Subject: [PATCH 23/34] fix: count newlines in preamble for correct line offset --- TablePro/Core/AI/Copilot/CopilotSchemaContext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift index cab21face..8a29617ac 100644 --- a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift +++ b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift @@ -54,7 +54,7 @@ final class CopilotSchemaContext { lines.append("") preamble = lines.joined(separator: "\n") - preambleLineCount = lines.count + preambleLineCount = preamble.filter { $0 == "\n" }.count Self.logger.info("Schema preamble built: \(self.preambleLineCount) lines, \((self.preamble as NSString).length) chars, \(tables.count) tables") } From e27ca6138f17f97f390baea4147c270c79c6041c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 14:31:11 +0700 Subject: [PATCH 24/34] chore: clean up schema preamble logging --- TablePro/Core/AI/Copilot/CopilotSchemaContext.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift index 8a29617ac..9da3a33f7 100644 --- a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift +++ b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift @@ -56,7 +56,7 @@ final class CopilotSchemaContext { preamble = lines.joined(separator: "\n") preambleLineCount = preamble.filter { $0 == "\n" }.count - Self.logger.info("Schema preamble built: \(self.preambleLineCount) lines, \((self.preamble as NSString).length) chars, \(tables.count) tables") + Self.logger.info("Copilot schema preamble: \(tables.count) tables, \(self.preambleLineCount) lines") } func prependToText(_ text: String) -> String { From 21f29250a8643c2c06cebdcb5b8b3c3521c546a4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 14:35:00 +0700 Subject: [PATCH 25/34] refactor: use real file on disk + workspace folder for schema context instead of preamble hack --- .../Core/AI/Copilot/CopilotDocumentSync.swift | 12 +-- .../Core/AI/Copilot/CopilotInlineSource.swift | 10 +- .../AI/Copilot/CopilotSchemaContext.swift | 100 +++++++++++++----- TablePro/Core/AI/Copilot/CopilotService.swift | 10 +- TablePro/Core/LSP/LSPClient.swift | 8 +- .../Views/Editor/SQLEditorCoordinator.swift | 30 +++--- 6 files changed, 107 insertions(+), 63 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift index daa5105bf..0e3a6de81 100644 --- a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -18,7 +18,8 @@ final class CopilotDocumentSync { static func documentURI(for tabID: UUID) -> String { if let existing = uriMap[tabID] { return existing } - let uri = "file:///tmp/tablepro/query-\(nextID).sql" + let dir = CopilotSchemaContext.contextDirectory.path + let uri = "file://\(dir)/query-\(nextID).sql" nextID += 1 uriMap[tabID] = uri return uri @@ -26,29 +27,26 @@ final class CopilotDocumentSync { func ensureDocumentOpen(tabID: UUID, text: String, languageId: String = "sql") { let uri = Self.documentURI(for: tabID) - let fullText = schemaContext.prependToText(text) if !documentManager.isOpen(uri) { - _ = documentManager.openDocument(uri: uri, languageId: languageId, text: fullText) + _ = documentManager.openDocument(uri: uri, languageId: languageId, text: text) } currentURI = uri } func didActivateTab(tabID: UUID, text: String, languageId: String = "sql") async { let uri = Self.documentURI(for: tabID) - let fullText = schemaContext.prependToText(text) ensureDocumentOpen(tabID: tabID, text: text, languageId: languageId) guard let client = CopilotService.shared.client else { return } - let item = LSPTextDocumentItem(uri: uri, languageId: languageId, version: documentManager.version(for: uri) ?? 0, text: fullText) + let item = LSPTextDocumentItem(uri: uri, languageId: languageId, version: documentManager.version(for: uri) ?? 0, text: text) await client.didOpenDocument(item) await client.didFocusDocument(uri: uri) } func didChangeText(tabID: UUID, newText: String) async { let uri = Self.documentURI(for: tabID) - 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 } + guard let (versioned, changes) = documentManager.changeDocument(uri: uri, newText: newText) else { return } await client.didChangeDocument(uri: versioned.uri, version: versioned.version, changes: changes) } diff --git a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift index ba820d5ea..3984cf83e 100644 --- a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift +++ b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift @@ -25,10 +25,9 @@ final class CopilotInlineSource: InlineSuggestionSource { 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), + position: LSPPosition(line: context.cursorLine, character: context.cursorCharacter), context: LSPInlineCompletionContext(triggerKind: 2), formattingOptions: LSPFormattingOptions( tabSize: editorSettings.clampedTabWidth, @@ -39,15 +38,12 @@ final class CopilotInlineSource: InlineSuggestionSource { let result = try await client.inlineCompletion(params: params) guard let first = result.items.first, !first.insertText.isEmpty else { return nil } - // Compute ghost text: only the part after the cursor position let ghostText: String var replacementRange: NSRange? if let range = first.range { - 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 rangeStartOffset = Self.offsetForPosition(range.start, in: nsText) let existingLen = context.cursorOffset - rangeStartOffset if existingLen > 0, existingLen <= (first.insertText as NSString).length { @@ -56,7 +52,7 @@ final class CopilotInlineSource: InlineSuggestionSource { ghostText = first.insertText } - let rangeEndOffset = Self.offsetForPosition(adjustedEnd, in: nsText) + let rangeEndOffset = Self.offsetForPosition(range.end, in: nsText) replacementRange = NSRange(location: rangeStartOffset, length: rangeEndOffset - rangeStartOffset) } else { ghostText = first.insertText diff --git a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift index 9da3a33f7..1e2de26bc 100644 --- a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift +++ b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift @@ -11,20 +11,30 @@ import TableProPluginKit final class CopilotSchemaContext { private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotSchemaContext") - private(set) var preamble: String = "" - private(set) var preambleLineCount: Int = 0 + private let documentManager = LSPDocumentManager() + private var isOpen = false - func buildPreamble( + 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) + }() + + static let schemaFileURL: URL = { + contextDirectory.appendingPathComponent("schema.sql") + }() + + static let schemaURI: String = { + "file://\(schemaFileURL.path)" + }() + + func syncSchema( schemaProvider: SQLSchemaProvider, databaseName: String, databaseType: DatabaseType ) async { let tables = await schemaProvider.getTables() - guard !tables.isEmpty else { - preamble = "" - preambleLineCount = 0 - return - } + guard !tables.isEmpty else { return } var columnsByTable: [String: [ColumnInfo]] = [:] for table in tables { @@ -34,33 +44,73 @@ final class CopilotSchemaContext { } } + let ddl = buildSchemaDDL( + tables: tables, + columnsByTable: columnsByTable, + databaseType: databaseType, + databaseName: databaseName + ) + + do { + try FileManager.default.createDirectory(at: Self.contextDirectory, withIntermediateDirectories: true) + try ddl.write(to: Self.schemaFileURL, atomically: true, encoding: .utf8) + } catch { + Self.logger.error("Failed to write schema file: \(error.localizedDescription)") + return + } + + guard let client = CopilotService.shared.client else { return } + + if !isOpen { + let item = documentManager.openDocument(uri: Self.schemaURI, languageId: "sql", text: ddl) + await client.didOpenDocument(item) + isOpen = true + } else if let (versioned, changes) = documentManager.changeDocument(uri: Self.schemaURI, newText: ddl) { + await client.didChangeDocument(uri: versioned.uri, version: versioned.version, changes: changes) + } + + Self.logger.info("Copilot schema context: \(tables.count) tables, \(databaseName)") + } + + func close() async { + guard isOpen else { return } + isOpen = false + + guard let client = CopilotService.shared.client else { return } + if let docId = documentManager.closeDocument(uri: Self.schemaURI) { + await client.didCloseDocument(uri: docId.uri) + } + } + + // MARK: - DDL Builder + + private func buildSchemaDDL( + tables: [TableInfo], + columnsByTable: [String: [ColumnInfo]], + databaseType: DatabaseType, + databaseName: String + ) -> String { var lines: [String] = [] lines.append("-- Database: \(databaseName) (\(databaseType.rawValue))") - lines.append("--") + 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") } + lines.append("CREATE TABLE \(table.name) (") + for (i, col) in columns.enumerated() { + var parts = [" \(col.name) \(col.dataType)"] + if col.isPrimaryKey { parts.append("PRIMARY KEY") } if !col.isNullable { parts.append("NOT NULL") } - return parts.joined(separator: " ") + if let def = col.defaultValue { parts.append("DEFAULT \(def)") } + let separator = i < columns.count - 1 ? "," : "" + lines.append(parts.joined(separator: " ") + separator) } - lines.append("-- \(table.name)(\(colDefs.joined(separator: ", ")))") + lines.append(");") + lines.append("") } - lines.append("") - - preamble = lines.joined(separator: "\n") - preambleLineCount = preamble.filter { $0 == "\n" }.count - - Self.logger.info("Copilot schema preamble: \(tables.count) tables, \(self.preambleLineCount) lines") - } - - func prependToText(_ text: String) -> String { - guard !preamble.isEmpty else { return text } - return preamble + text + return lines.joined(separator: "\n") } } diff --git a/TablePro/Core/AI/Copilot/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift index f926599b3..9d77fd7ce 100644 --- a/TablePro/Core/AI/Copilot/CopilotService.swift +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -62,10 +62,18 @@ final class CopilotService { let client = LSPClient(transport: newTransport) let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" + let contextDir = CopilotSchemaContext.contextDirectory.path + try? FileManager.default.createDirectory( + at: CopilotSchemaContext.contextDirectory, + withIntermediateDirectories: true + ) _ = try await client.initialize( clientInfo: LSPClientInfo(name: "TablePro", version: appVersion), editorPluginInfo: LSPClientInfo(name: "tablepro-copilot", version: "1.0.0"), - processId: Int(ProcessInfo.processInfo.processIdentifier) + processId: Int(ProcessInfo.processInfo.processIdentifier), + workspaceFolders: [ + LSPWorkspaceFolder(uri: "file://\(contextDir)", name: "TablePro") + ] ) await client.initialized() diff --git a/TablePro/Core/LSP/LSPClient.swift b/TablePro/Core/LSP/LSPClient.swift index a3f5009cc..e04d6ff5d 100644 --- a/TablePro/Core/LSP/LSPClient.swift +++ b/TablePro/Core/LSP/LSPClient.swift @@ -20,9 +20,9 @@ actor LSPClient { func initialize( clientInfo: LSPClientInfo, editorPluginInfo: LSPClientInfo?, - processId: Int? + processId: Int?, + workspaceFolders: [LSPWorkspaceFolder]? = nil ) async throws -> LSPInitializeResult { - let homePath = FileManager.default.homeDirectoryForCurrentUser.path let params = LSPInitializeParams( processId: processId ?? Int(ProcessInfo.processInfo.processIdentifier), capabilities: LSPClientCapabilities( @@ -32,9 +32,7 @@ actor LSPClient { editorInfo: clientInfo, editorPluginInfo: editorPluginInfo ), - workspaceFolders: [ - LSPWorkspaceFolder(uri: "file://\(homePath)", name: "Home") - ] + workspaceFolders: workspaceFolders ) let data = try await transport.sendRequest(method: "initialize", params: params) diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 229553707..4f04cd580 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -272,27 +272,21 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { 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 { - if let provider = capturedSchemaProvider, let dbType = capturedDBType { - await sync.schemaContext.buildPreamble( - schemaProvider: provider, + if let tabID, let textView = controller?.textView { + sync.ensureDocumentOpen(tabID: tabID, text: textView.string) + Task { await sync.didActivateTab(tabID: tabID, text: textView.string) } + } + if let schemaProvider, let databaseType { + let dbName = connectionId.flatMap { + DatabaseManager.shared.session(for: $0)?.activeDatabase + } ?? "database" + Task { + await sync.schemaContext.syncSchema( + schemaProvider: schemaProvider, databaseName: dbName, - databaseType: dbType + databaseType: databaseType ) } - if let tabID = capturedTabID { - sync.ensureDocumentOpen(tabID: tabID, text: capturedText) - await sync.didActivateTab(tabID: tabID, text: capturedText) - } } } return copilotInlineSource From 527a8bae90fb94de7b17e3aaf0e19178b28255bc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 14:41:51 +0700 Subject: [PATCH 26/34] fix: block completion requests until document is synced to server --- TablePro/Core/AI/Copilot/CopilotDocumentSync.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift index 0e3a6de81..ed980af75 100644 --- a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -13,6 +13,7 @@ final class CopilotDocumentSync { private let documentManager = LSPDocumentManager() let schemaContext = CopilotSchemaContext() private var currentURI: String? + private var serverSyncedURIs: Set = [] private static var uriMap: [UUID: String] = [:] private static var nextID = 1 @@ -41,6 +42,7 @@ final class CopilotDocumentSync { let item = LSPTextDocumentItem(uri: uri, languageId: languageId, version: documentManager.version(for: uri) ?? 0, text: text) await client.didOpenDocument(item) await client.didFocusDocument(uri: uri) + serverSyncedURIs.insert(uri) } func didChangeText(tabID: UUID, newText: String) async { @@ -60,6 +62,7 @@ final class CopilotDocumentSync { 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) } From dc4c0947975a956d7ad63a7ab09d819810e3472a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 14:55:56 +0700 Subject: [PATCH 27/34] fix: prevent didChange race before server sync, add dialect hint to schema --- TablePro/Core/AI/Copilot/CopilotDocumentSync.swift | 1 + TablePro/Core/AI/Copilot/CopilotSchemaContext.swift | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift index ed980af75..6d3ac584f 100644 --- a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -47,6 +47,7 @@ final class CopilotDocumentSync { func didChangeText(tabID: UUID, newText: String) async { let uri = Self.documentURI(for: tabID) + guard serverSyncedURIs.contains(uri) else { return } guard let client = CopilotService.shared.client else { return } guard let (versioned, changes) = documentManager.changeDocument(uri: uri, newText: newText) else { return } await client.didChangeDocument(uri: versioned.uri, version: versioned.version, changes: changes) diff --git a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift index 1e2de26bc..b742293ad 100644 --- a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift +++ b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift @@ -91,7 +91,8 @@ final class CopilotSchemaContext { databaseName: String ) -> String { var lines: [String] = [] - lines.append("-- Database: \(databaseName) (\(databaseType.rawValue))") + lines.append("-- Database: \(databaseName)") + lines.append("-- Dialect: \(databaseType.rawValue) — use \(databaseType.rawValue) syntax only") lines.append("") for table in tables { From 574c8f724ff6938442099aa143f5d59b9f878d67 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 15:22:59 +0700 Subject: [PATCH 28/34] =?UTF-8?q?refactor:=20final=20cleanup=20for=20merge?= =?UTF-8?q?=20=E2=80=94=20fix=20duplicate=20didOpen,=20URI=20encoding,=20d?= =?UTF-8?q?ead=20state,=20poll=20cancellation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/AI/Copilot/CopilotAuthManager.swift | 1 + .../Core/AI/Copilot/CopilotDocumentSync.swift | 37 +++++++++++++------ .../AI/Copilot/CopilotSchemaContext.swift | 4 +- TablePro/Core/AI/Copilot/CopilotService.swift | 6 ++- TablePro/Core/LSP/LSPClient.swift | 2 +- TablePro/Core/LSP/LSPTransport.swift | 5 --- .../Views/Editor/SQLEditorCoordinator.swift | 3 -- 7 files changed, 33 insertions(+), 25 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotAuthManager.swift b/TablePro/Core/AI/Copilot/CopilotAuthManager.swift index 2747cc87a..9417656a9 100644 --- a/TablePro/Core/AI/Copilot/CopilotAuthManager.swift +++ b/TablePro/Core/AI/Copilot/CopilotAuthManager.swift @@ -50,6 +50,7 @@ final class CopilotAuthManager { let pollInterval: Duration = .seconds(2) for _ in 0.. = [] - private static var uriMap: [UUID: String] = [:] - private static var nextID = 1 + private var uriMap: [UUID: String] = [:] + private var nextID = 1 - static func documentURI(for tabID: UUID) -> String { + func documentURI(for tabID: UUID) -> String { if let existing = uriMap[tabID] { return existing } - let dir = CopilotSchemaContext.contextDirectory.path - let uri = "file://\(dir)/query-\(nextID).sql" + let fileURL = CopilotSchemaContext.contextDirectory.appendingPathComponent("query-\(nextID).sql") nextID += 1 + let uri = fileURL.absoluteString uriMap[tabID] = uri return uri } + func resetServerState() { + serverSyncedURIs.removeAll() + } + func ensureDocumentOpen(tabID: UUID, text: String, languageId: String = "sql") { - let uri = Self.documentURI(for: tabID) + let uri = documentURI(for: tabID) if !documentManager.isOpen(uri) { _ = documentManager.openDocument(uri: uri, languageId: languageId, text: text) } @@ -35,18 +39,25 @@ final class CopilotDocumentSync { } func didActivateTab(tabID: UUID, text: String, languageId: String = "sql") async { - let uri = Self.documentURI(for: tabID) + let uri = documentURI(for: tabID) ensureDocumentOpen(tabID: tabID, text: text, languageId: languageId) guard let client = CopilotService.shared.client else { return } - let item = LSPTextDocumentItem(uri: uri, languageId: languageId, version: documentManager.version(for: uri) ?? 0, text: text) - await client.didOpenDocument(item) + if !serverSyncedURIs.contains(uri) { + let item = LSPTextDocumentItem( + uri: uri, + languageId: languageId, + version: documentManager.version(for: uri) ?? 0, + text: text + ) + await client.didOpenDocument(item) + serverSyncedURIs.insert(uri) + } await client.didFocusDocument(uri: uri) - serverSyncedURIs.insert(uri) } func didChangeText(tabID: UUID, newText: String) async { - let uri = Self.documentURI(for: tabID) + let uri = documentURI(for: tabID) guard serverSyncedURIs.contains(uri) else { return } guard let client = CopilotService.shared.client else { return } guard let (versioned, changes) = documentManager.changeDocument(uri: uri, newText: newText) else { return } @@ -54,10 +65,12 @@ final class CopilotDocumentSync { } func didCloseTab(tabID: UUID) async { - let uri = Self.documentURI(for: tabID) + 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 } } diff --git a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift index b742293ad..e2c5923ad 100644 --- a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift +++ b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift @@ -25,7 +25,7 @@ final class CopilotSchemaContext { }() static let schemaURI: String = { - "file://\(schemaFileURL.path)" + schemaFileURL.absoluteString }() func syncSchema( @@ -92,7 +92,7 @@ final class CopilotSchemaContext { ) -> String { var lines: [String] = [] lines.append("-- Database: \(databaseName)") - lines.append("-- Dialect: \(databaseType.rawValue) — use \(databaseType.rawValue) syntax only") + lines.append("-- Dialect: \(databaseType.rawValue): use \(databaseType.rawValue) syntax only") lines.append("") for table in tables { diff --git a/TablePro/Core/AI/Copilot/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift index 9d77fd7ce..2cd2fef9c 100644 --- a/TablePro/Core/AI/Copilot/CopilotService.swift +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -62,7 +62,6 @@ final class CopilotService { let client = LSPClient(transport: newTransport) let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" - let contextDir = CopilotSchemaContext.contextDirectory.path try? FileManager.default.createDirectory( at: CopilotSchemaContext.contextDirectory, withIntermediateDirectories: true @@ -72,7 +71,10 @@ final class CopilotService { editorPluginInfo: LSPClientInfo(name: "tablepro-copilot", version: "1.0.0"), processId: Int(ProcessInfo.processInfo.processIdentifier), workspaceFolders: [ - LSPWorkspaceFolder(uri: "file://\(contextDir)", name: "TablePro") + LSPWorkspaceFolder( + uri: CopilotSchemaContext.contextDirectory.absoluteString, + name: "TablePro" + ) ] ) await client.initialized() diff --git a/TablePro/Core/LSP/LSPClient.swift b/TablePro/Core/LSP/LSPClient.swift index e04d6ff5d..12ec1112c 100644 --- a/TablePro/Core/LSP/LSPClient.swift +++ b/TablePro/Core/LSP/LSPClient.swift @@ -44,7 +44,7 @@ actor LSPClient { do { try await transport.sendNotification(method: "initialized", params: EmptyLSPParams()) } catch { - Self.logger.debug("Failed to send initialized: \(error.localizedDescription)") + Self.logger.warning("Failed to send initialized: \(error.localizedDescription)") } } diff --git a/TablePro/Core/LSP/LSPTransport.swift b/TablePro/Core/LSP/LSPTransport.swift index cea84c8a4..6619ff97c 100644 --- a/TablePro/Core/LSP/LSPTransport.swift +++ b/TablePro/Core/LSP/LSPTransport.swift @@ -43,7 +43,6 @@ actor LSPTransport { private var pendingRequests: [Int: CheckedContinuation] = [:] private var notificationHandlers: [String: @Sendable (Data) -> Void] = [:] private var readerQueue: DispatchQueue? - private var isReading = false // MARK: - Lifecycle @@ -91,7 +90,6 @@ actor LSPTransport { let queue = DispatchQueue(label: "com.TablePro.LSPTransport.reader") self.readerQueue = queue let handle = stdout.fileHandleForReading - self.isReading = true queue.async { [weak self] in self?.readLoopSync(handle: handle) } @@ -100,8 +98,6 @@ actor LSPTransport { } func stop() { - isReading = false - let pending = pendingRequests pendingRequests.removeAll() for (_, continuation) in pending { @@ -281,6 +277,5 @@ actor LSPTransport { for (_, continuation) in pending { continuation.resume(throwing: LSPTransportError.processExited(code)) } - isReading = false } } diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index 4f04cd580..d009fd2e5 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -104,9 +104,6 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { self.fixFindPanelHitTesting(controller: controller) self.installAIContextMenu(controller: controller) self.installInlineSuggestionManager(controller: controller) - if let tabID = self.tabID, let sync = self.copilotDocumentSync, let textView = controller.textView { - Task { await sync.didActivateTab(tabID: tabID, text: textView.string) } - } self.installVimModeIfEnabled(controller: controller) self.installEditorSettingsObserver(controller: controller) if let textView = controller.textView { From 85902c6f6f513ee9a491ac977e6f38bdf9daf444 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 17:58:22 +0700 Subject: [PATCH 29/34] refactor: clean preamble architecture for Copilot schema context --- .../Core/AI/Copilot/CopilotDocumentSync.swift | 14 ++- .../Core/AI/Copilot/CopilotInlineSource.swift | 10 +- .../AI/Copilot/CopilotSchemaContext.swift | 100 ++++++------------ TablePro/Core/AI/Copilot/CopilotService.swift | 12 +-- .../Views/Editor/SQLEditorCoordinator.swift | 32 +++--- 5 files changed, 71 insertions(+), 97 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift index 8409b3954..431e4c20d 100644 --- a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -6,6 +6,8 @@ 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") @@ -30,16 +32,20 @@ final class CopilotDocumentSync { 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: text) + _ = 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 uri = documentURI(for: tabID) + let fullText = schemaContext.prependToText(text) ensureDocumentOpen(tabID: tabID, text: text, languageId: languageId) guard let client = CopilotService.shared.client else { return } @@ -48,7 +54,7 @@ final class CopilotDocumentSync { uri: uri, languageId: languageId, version: documentManager.version(for: uri) ?? 0, - text: text + text: fullText ) await client.didOpenDocument(item) serverSyncedURIs.insert(uri) @@ -56,11 +62,13 @@ final class CopilotDocumentSync { await client.didFocusDocument(uri: uri) } + /// Send text change with preamble prepended func didChangeText(tabID: UUID, newText: String) async { 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: newText) 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) } diff --git a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift index 3984cf83e..cee33f74e 100644 --- a/TablePro/Core/AI/Copilot/CopilotInlineSource.swift +++ b/TablePro/Core/AI/Copilot/CopilotInlineSource.swift @@ -25,9 +25,10 @@ final class CopilotInlineSource: InlineSuggestionSource { 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, character: context.cursorCharacter), + position: LSPPosition(line: context.cursorLine + preambleOffset, character: context.cursorCharacter), context: LSPInlineCompletionContext(triggerKind: 2), formattingOptions: LSPFormattingOptions( tabSize: editorSettings.clampedTabWidth, @@ -42,8 +43,11 @@ final class CopilotInlineSource: InlineSuggestionSource { 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(range.start, in: nsText) + let rangeStartOffset = Self.offsetForPosition(adjustedStart, in: nsText) let existingLen = context.cursorOffset - rangeStartOffset if existingLen > 0, existingLen <= (first.insertText as NSString).length { @@ -52,7 +56,7 @@ final class CopilotInlineSource: InlineSuggestionSource { ghostText = first.insertText } - let rangeEndOffset = Self.offsetForPosition(range.end, in: nsText) + let rangeEndOffset = Self.offsetForPosition(adjustedEnd, in: nsText) replacementRange = NSRange(location: rangeStartOffset, length: rangeEndOffset - rangeStartOffset) } else { ghostText = first.insertText diff --git a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift index e2c5923ad..17d3a2408 100644 --- a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift +++ b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift @@ -7,34 +7,37 @@ 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") - private let documentManager = LSPDocumentManager() - private var isOpen = false - + /// 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) }() - static let schemaFileURL: URL = { - contextDirectory.appendingPathComponent("schema.sql") - }() + /// The schema preamble text (SQL comments with table/column info) + private(set) var preamble: String = "" - static let schemaURI: String = { - schemaFileURL.absoluteString - }() + /// Number of newline characters in the preamble (for cursor offset adjustment) + private(set) var preambleLineCount: Int = 0 - func syncSchema( + /// Build the preamble from cached schema data + func buildPreamble( schemaProvider: SQLSchemaProvider, databaseName: String, databaseType: DatabaseType ) async { let tables = await schemaProvider.getTables() - guard !tables.isEmpty else { return } + guard !tables.isEmpty else { + preamble = "" + preambleLineCount = 0 + return + } var columnsByTable: [String: [ColumnInfo]] = [:] for table in tables { @@ -44,74 +47,35 @@ final class CopilotSchemaContext { } } - let ddl = buildSchemaDDL( - tables: tables, - columnsByTable: columnsByTable, - databaseType: databaseType, - databaseName: databaseName - ) - - do { - try FileManager.default.createDirectory(at: Self.contextDirectory, withIntermediateDirectories: true) - try ddl.write(to: Self.schemaFileURL, atomically: true, encoding: .utf8) - } catch { - Self.logger.error("Failed to write schema file: \(error.localizedDescription)") - return - } - - guard let client = CopilotService.shared.client else { return } - - if !isOpen { - let item = documentManager.openDocument(uri: Self.schemaURI, languageId: "sql", text: ddl) - await client.didOpenDocument(item) - isOpen = true - } else if let (versioned, changes) = documentManager.changeDocument(uri: Self.schemaURI, newText: ddl) { - await client.didChangeDocument(uri: versioned.uri, version: versioned.version, changes: changes) - } - - Self.logger.info("Copilot schema context: \(tables.count) tables, \(databaseName)") - } - - func close() async { - guard isOpen else { return } - isOpen = false - - guard let client = CopilotService.shared.client else { return } - if let docId = documentManager.closeDocument(uri: Self.schemaURI) { - await client.didCloseDocument(uri: docId.uri) - } - } - - // MARK: - DDL Builder - - private func buildSchemaDDL( - tables: [TableInfo], - columnsByTable: [String: [ColumnInfo]], - databaseType: DatabaseType, - databaseName: String - ) -> String { var lines: [String] = [] lines.append("-- Database: \(databaseName)") lines.append("-- Dialect: \(databaseType.rawValue): use \(databaseType.rawValue) syntax only") - lines.append("") + lines.append("--") for table in tables { let columns = columnsByTable[table.name.lowercased()] ?? [] guard !columns.isEmpty else { continue } - lines.append("CREATE TABLE \(table.name) (") - for (i, col) in columns.enumerated() { - var parts = [" \(col.name) \(col.dataType)"] - if col.isPrimaryKey { parts.append("PRIMARY KEY") } + 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") } - if let def = col.defaultValue { parts.append("DEFAULT \(def)") } - let separator = i < columns.count - 1 ? "," : "" - lines.append(parts.joined(separator: " ") + separator) + return parts.joined(separator: " ") } - lines.append(");") - lines.append("") + lines.append("-- \(table.name)(\(colDefs.joined(separator: ", ")))") } - return lines.joined(separator: "\n") + lines.append("") + + preamble = lines.joined(separator: "\n") + preambleLineCount = preamble.filter { $0 == "\n" }.count + + 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 index 2cd2fef9c..f926599b3 100644 --- a/TablePro/Core/AI/Copilot/CopilotService.swift +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -62,20 +62,10 @@ final class CopilotService { let client = LSPClient(transport: newTransport) let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "1.0" - try? FileManager.default.createDirectory( - at: CopilotSchemaContext.contextDirectory, - withIntermediateDirectories: true - ) _ = try await client.initialize( clientInfo: LSPClientInfo(name: "TablePro", version: appVersion), editorPluginInfo: LSPClientInfo(name: "tablepro-copilot", version: "1.0.0"), - processId: Int(ProcessInfo.processInfo.processIdentifier), - workspaceFolders: [ - LSPWorkspaceFolder( - uri: CopilotSchemaContext.contextDirectory.absoluteString, - name: "TablePro" - ) - ] + processId: Int(ProcessInfo.processInfo.processIdentifier) ) await client.initialized() diff --git a/TablePro/Views/Editor/SQLEditorCoordinator.swift b/TablePro/Views/Editor/SQLEditorCoordinator.swift index d009fd2e5..30a5fd69a 100644 --- a/TablePro/Views/Editor/SQLEditorCoordinator.swift +++ b/TablePro/Views/Editor/SQLEditorCoordinator.swift @@ -269,21 +269,29 @@ final class SQLEditorCoordinator: TextViewCoordinator, TextViewDelegate { let sync = CopilotDocumentSync() copilotDocumentSync = sync copilotInlineSource = CopilotInlineSource(documentSync: sync) - if let tabID, let textView = controller?.textView { - sync.ensureDocumentOpen(tabID: tabID, text: textView.string) - Task { await sync.didActivateTab(tabID: tabID, text: textView.string) } - } - if let schemaProvider, let databaseType { - let dbName = connectionId.flatMap { - DatabaseManager.shared.session(for: $0)?.activeDatabase - } ?? "database" - Task { - await sync.schemaContext.syncSchema( - schemaProvider: schemaProvider, + + 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: databaseType + 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 From 37e7021a2d283cf6141f252a06064418fec0e79d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 18:04:36 +0700 Subject: [PATCH 30/34] fix: pass Escape event through so autocomplete popup also closes --- TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift index 2d8f14508..1b5958c12 100644 --- a/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift +++ b/TablePro/Core/AI/InlineSuggestion/InlineSuggestionManager.swift @@ -226,7 +226,7 @@ final class InlineSuggestionManager { case 53: // Escape — dismiss suggestion self.dismissSuggestion() - return nil + return event default: Task { @MainActor [weak self] in From c57c77f80dc73f14833ea1e3a8b19ab068dacffa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 18:24:17 +0700 Subject: [PATCH 31/34] =?UTF-8?q?fix:=20address=20PR=20review=20findings?= =?UTF-8?q?=20=E2=80=94=20server=20restart=20sync,=20download=20guard,=20i?= =?UTF-8?q?ntegrity=20check,=20localization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AI/Copilot/CopilotBinaryManager.swift | 34 ++++++++++++++++++- .../Core/AI/Copilot/CopilotDocumentSync.swift | 13 +++++++ .../AI/Copilot/CopilotSchemaContext.swift | 4 ++- TablePro/Core/AI/Copilot/CopilotService.swift | 1 + TablePro/Core/LSP/LSPTransport.swift | 10 +++--- 5 files changed, 55 insertions(+), 7 deletions(-) diff --git a/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift b/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift index ebe9ab6ea..0c29a5f40 100644 --- a/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift +++ b/TablePro/Core/AI/Copilot/CopilotBinaryManager.swift @@ -3,6 +3,7 @@ // TablePro // +import CommonCrypto import Foundation import os @@ -11,6 +12,7 @@ actor CopilotBinaryManager { static let shared = CopilotBinaryManager() private let baseDirectory: URL + private var downloadTask: Task? private init() { let appSupport = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first @@ -23,7 +25,21 @@ actor CopilotBinaryManager { if FileManager.default.isExecutableFile(atPath: path) { return path } - try await downloadBinary() + + 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 } @@ -50,6 +66,14 @@ actor CopilotBinaryManager { 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) @@ -121,3 +145,11 @@ actor CopilotBinaryManager { #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/CopilotDocumentSync.swift b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift index 431e4c20d..bf803ae6a 100644 --- a/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift +++ b/TablePro/Core/AI/Copilot/CopilotDocumentSync.swift @@ -18,6 +18,7 @@ final class CopilotDocumentSync { 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 } @@ -44,6 +45,12 @@ final class CopilotDocumentSync { /// 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) @@ -64,6 +71,12 @@ final class CopilotDocumentSync { /// 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) diff --git a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift index 17d3a2408..f7cf9986b 100644 --- a/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift +++ b/TablePro/Core/AI/Copilot/CopilotSchemaContext.swift @@ -32,6 +32,8 @@ final class CopilotSchemaContext { databaseName: String, databaseType: DatabaseType ) async { + try? FileManager.default.createDirectory(at: Self.contextDirectory, withIntermediateDirectories: true) + let tables = await schemaProvider.getTables() guard !tables.isEmpty else { preamble = "" @@ -68,7 +70,7 @@ final class CopilotSchemaContext { lines.append("") preamble = lines.joined(separator: "\n") - preambleLineCount = preamble.filter { $0 == "\n" }.count + preambleLineCount = lines.count - 1 Self.logger.info("Copilot schema preamble: \(tables.count) tables, \(self.preambleLineCount) lines") } diff --git a/TablePro/Core/AI/Copilot/CopilotService.swift b/TablePro/Core/AI/Copilot/CopilotService.swift index f926599b3..b1f8304c8 100644 --- a/TablePro/Core/AI/Copilot/CopilotService.swift +++ b/TablePro/Core/AI/Copilot/CopilotService.swift @@ -45,6 +45,7 @@ final class CopilotService { var client: LSPClient? { lspClient } var lspTransport: LSPTransport? { transport } var isAuthenticated: Bool { authState.isSignedIn } + var generation: Int { serverGeneration } // MARK: - Lifecycle diff --git a/TablePro/Core/LSP/LSPTransport.swift b/TablePro/Core/LSP/LSPTransport.swift index 6619ff97c..3893cab1d 100644 --- a/TablePro/Core/LSP/LSPTransport.swift +++ b/TablePro/Core/LSP/LSPTransport.swift @@ -18,15 +18,15 @@ enum LSPTransportError: Error, LocalizedError { var errorDescription: String? { switch self { case .processNotRunning: - return "LSP process is not running" + return String(localized: "LSP process is not running") case .processExited(let code): - return "LSP process exited with code \(code)" + return String(format: String(localized: "LSP process exited with code %d"), code) case .invalidResponse: - return "Invalid LSP response" + return String(localized: "Invalid LSP response") case .requestCancelled: - return "LSP request was cancelled" + return String(localized: "LSP request was cancelled") case .serverError(let code, let message): - return "LSP server error (\(code)): \(message)" + return String(format: String(localized: "LSP server error (%d): %@"), code, message) } } } From 4a4b0bd8f75a931ad646dbb229f959f19160d288 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 19:00:07 +0700 Subject: [PATCH 32/34] feat: add AI provider registry pattern and Copilot Chat support --- CHANGELOG.md | 3 +- TablePro/Core/AI/AIProviderFactory.swift | 42 +++++++-- .../Core/AI/Copilot/CopilotChatProvider.swift | 63 +++++++++++++ .../AI/Registry/AIProviderDescriptor.swift | 28 ++++++ .../AI/Registry/AIProviderRegistration.swift | 89 +++++++++++++++++++ .../Core/AI/Registry/AIProviderRegistry.swift | 33 +++++++ TablePro/Models/AI/AIModels.swift | 29 +++++- TablePro/TableProApp.swift | 2 + TablePro/Views/Settings/AISettingsView.swift | 38 ++++++++ 9 files changed, 317 insertions(+), 10 deletions(-) create mode 100644 TablePro/Core/AI/Copilot/CopilotChatProvider.swift create mode 100644 TablePro/Core/AI/Registry/AIProviderDescriptor.swift create mode 100644 TablePro/Core/AI/Registry/AIProviderRegistration.swift create mode 100644 TablePro/Core/AI/Registry/AIProviderRegistry.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 98408058f..50938cb17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- GitHub Copilot integration with OAuth sign-in, inline suggestions, and settings UI +- AI provider registry for extensible provider management +- GitHub Copilot as an AI chat provider option (LSP transport pending) - In-app feedback form for bug reports and feature requests via Help > Report an Issue - Per-connection "Local only" option to exclude individual connections from iCloud sync - Filter operator picker shows SQL symbols alongside names for quick visual recognition 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/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/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/Models/AI/AIModels.swift b/TablePro/Models/AI/AIModels.swift index 101102868..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 } } @@ -89,6 +92,7 @@ enum AIFeature: String, Codable, CaseIterable, Identifiable { case explainQuery = "explainQuery" case optimizeQuery = "optimizeQuery" case fixError = "fixError" + case inlineSuggest = "inlineSuggest" var id: String { rawValue } @@ -98,6 +102,7 @@ enum AIFeature: String, Codable, CaseIterable, Identifiable { case .explainQuery: return String(localized: "Explain Query") case .optimizeQuery: return String(localized: "Optimize Query") case .fixError: return String(localized: "Fix Error") + case .inlineSuggest: return String(localized: "Inline Suggestions") } } } @@ -141,6 +146,8 @@ struct AISettings: Codable, Equatable { var includeQueryResults: Bool var maxSchemaTables: Int var defaultConnectionPolicy: AIConnectionPolicy + var inlineSuggestEnabled: Bool + var copilotChatEnabled: Bool static let `default` = AISettings( enabled: true, @@ -150,7 +157,9 @@ struct AISettings: Codable, Equatable { includeCurrentQuery: true, includeQueryResults: false, maxSchemaTables: 20, - defaultConnectionPolicy: .askEachTime + defaultConnectionPolicy: .askEachTime, + inlineSuggestEnabled: false, + copilotChatEnabled: false ) init( @@ -161,7 +170,9 @@ struct AISettings: Codable, Equatable { includeCurrentQuery: Bool = true, includeQueryResults: Bool = false, maxSchemaTables: Int = 20, - defaultConnectionPolicy: AIConnectionPolicy = .askEachTime + defaultConnectionPolicy: AIConnectionPolicy = .askEachTime, + inlineSuggestEnabled: Bool = false, + copilotChatEnabled: Bool = false ) { self.enabled = enabled self.providers = providers @@ -171,6 +182,8 @@ struct AISettings: Codable, Equatable { self.includeQueryResults = includeQueryResults self.maxSchemaTables = maxSchemaTables self.defaultConnectionPolicy = defaultConnectionPolicy + self.inlineSuggestEnabled = inlineSuggestEnabled + self.copilotChatEnabled = copilotChatEnabled } init(from decoder: Decoder) throws { @@ -185,6 +198,8 @@ struct AISettings: Codable, Equatable { defaultConnectionPolicy = try container.decodeIfPresent( AIConnectionPolicy.self, forKey: .defaultConnectionPolicy ) ?? .askEachTime + inlineSuggestEnabled = try container.decodeIfPresent(Bool.self, forKey: .inlineSuggestEnabled) ?? false + copilotChatEnabled = try container.decodeIfPresent(Bool.self, forKey: .copilotChatEnabled) ?? false } } @@ -238,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/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/Settings/AISettingsView.swift b/TablePro/Views/Settings/AISettingsView.swift index 06d57c2b1..81eb3476b 100644 --- a/TablePro/Views/Settings/AISettingsView.swift +++ b/TablePro/Views/Settings/AISettingsView.swift @@ -23,8 +23,10 @@ struct AISettingsView: View { } if settings.enabled { providersSection + copilotChatSection featureRoutingSection contextSection + inlineSuggestionsSection privacySection } } @@ -146,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 { @@ -160,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() @@ -188,6 +213,18 @@ struct AISettingsView: View { } } + // MARK: - Inline Suggestions Section + + private var inlineSuggestionsSection: some View { + Section { + Toggle(String(localized: "Enable inline suggestions"), isOn: $settings.inlineSuggestEnabled) + } header: { + Text("Inline Suggestions") + } footer: { + Text("AI-powered SQL completions appear as ghost text while typing. Press Tab to accept, Escape to dismiss.") + } + } + // MARK: - Privacy Section private var privacySection: some View { @@ -284,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" } } } From 112ad2f96786aab73cf26f31388cc6a5f1c1412d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 19:35:37 +0700 Subject: [PATCH 33/34] feat: wire registry to factory, implement Copilot Chat conversation protocol --- CHANGELOG.md | 2 +- TablePro/Core/AI/AIProviderFactory.swift | 48 +++--- .../Core/AI/Copilot/CopilotChatProvider.swift | 154 ++++++++++++++++-- 3 files changed, 166 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50938cb17..98a2827f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ 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) +- GitHub Copilot as an AI chat provider with LSP conversation protocol streaming - In-app feedback form for bug reports and feature requests via Help > Report an Issue - Per-connection "Local only" option to exclude individual connections from iCloud sync - Filter operator picker shows SQL symbols alongside names for quick visual recognition diff --git a/TablePro/Core/AI/AIProviderFactory.swift b/TablePro/Core/AI/AIProviderFactory.swift index 23766dcd3..f3b63f885 100644 --- a/TablePro/Core/AI/AIProviderFactory.swift +++ b/TablePro/Core/AI/AIProviderFactory.swift @@ -32,28 +32,32 @@ enum AIProviderFactory { } let provider: AIProvider - switch config.type { - case .claude: - provider = AnthropicProvider( - endpoint: config.endpoint, - apiKey: apiKey ?? "", - maxOutputTokens: config.maxOutputTokens ?? 4_096 - ) - case .gemini: - provider = GeminiProvider( - endpoint: config.endpoint, - apiKey: apiKey ?? "", - maxOutputTokens: config.maxOutputTokens ?? 8_192 - ) - case .copilot: - provider = CopilotChatProvider() - case .openAI, .openRouter, .ollama, .custom: - provider = OpenAICompatibleProvider( - endpoint: config.endpoint, - apiKey: apiKey, - providerType: config.type, - maxOutputTokens: config.maxOutputTokens - ) + // Try registry first (enables extensibility without modifying this switch) + if let descriptor = AIProviderRegistry.shared.descriptor(for: config.type.rawValue) { + provider = descriptor.makeProvider(config, apiKey) + } else { + // Fallback for safety + switch config.type { + case .claude: + provider = AnthropicProvider( + endpoint: config.endpoint, + apiKey: apiKey ?? "", + maxOutputTokens: config.maxOutputTokens ?? 4_096 + ) + case .gemini: + provider = GeminiProvider( + endpoint: config.endpoint, + apiKey: apiKey ?? "", + maxOutputTokens: config.maxOutputTokens ?? 8_192 + ) + case .openAI, .openRouter, .ollama, .custom, .copilot: + provider = OpenAICompatibleProvider( + endpoint: config.endpoint, + apiKey: apiKey, + providerType: config.type, + maxOutputTokens: config.maxOutputTokens + ) + } } cache[config.id] = (apiKey, provider) return provider diff --git a/TablePro/Core/AI/Copilot/CopilotChatProvider.swift b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift index d796eee83..66c20254e 100644 --- a/TablePro/Core/AI/Copilot/CopilotChatProvider.swift +++ b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift @@ -22,17 +22,61 @@ final class CopilotChatProvider: AIProvider { systemPrompt: String? ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - let task = Task { @MainActor in + let task = Task { do { - guard CopilotService.shared.isAuthenticated else { + guard let transport = await CopilotService.shared.lspTransport else { throw CopilotError.serverNotRunning } + guard await CopilotService.shared.isAuthenticated else { + throw CopilotError.serverNotRunning + } + + // 1. Create conversation + let conversationId = UUID().uuidString + let _: Data = try await transport.sendRequest( + method: "conversation/create", + params: ConversationCreateParams(conversationId: conversationId) + ) + + // 2. Register streaming notification handler BEFORE sending turn + let turnId = UUID().uuidString + let handler = StreamHandler(turnId: turnId, continuation: continuation) + + await transport.onNotification(method: "conversation/progress") { data in + Self.logger.debug( + "Copilot chat progress: \(String(data: data, encoding: .utf8)?.prefix(200) ?? "nil")" + ) + handler.handleProgress(data) + } + + // 3. Build the turn content from messages + var content = "" + if let systemPrompt, !systemPrompt.isEmpty { + content += systemPrompt + "\n\n" + } + if let lastUserMessage = messages.last(where: { $0.role == .user }) { + content = lastUserMessage.content + } + + // 4. Send conversation turn + let turnParams = ConversationTurnParams( + conversationId: conversationId, + turnId: turnId, + message: content, + model: model.isEmpty ? nil : model + ) + let _: Data = try await transport.sendRequest( + method: "conversation/turn", + params: turnParams + ) + + // The turn request may complete before all progress notifications arrive. + // StreamHandler.handleProgress will call continuation.finish() when done. - // 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) + if !Task.isCancelled { + continuation.finish(throwing: error) + } } } @@ -43,21 +87,101 @@ final class CopilotChatProvider: AIProvider { } func fetchAvailableModels() async throws -> [String] { - guard await CopilotService.shared.isAuthenticated else { + guard let transport = await CopilotService.shared.lspTransport 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" - ] + do { + let data: Data = try await transport.sendRequest( + method: "copilot/models", + params: EmptyLSPParams() + ) + + // Try parsing as object with models array + if let response = try? JSONDecoder().decode(ModelsResponse.self, from: data), + let models = response.models { + return models.map(\.id) + } + + // Try parsing as direct array of model objects + if let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] { + return json.compactMap { $0["id"] as? String } + } + + return [] + } catch { + Self.logger.debug("Failed to fetch Copilot models: \(error.localizedDescription)") + return [] + } } func testConnection() async throws -> Bool { await CopilotService.shared.isAuthenticated } } + +// MARK: - Conversation Param Types + +private extension CopilotChatProvider { + struct ConversationCreateParams: Encodable { + let conversationId: String + let workDoneToken: String = "" + } + + struct ConversationTurnParams: Encodable { + let conversationId: String + let turnId: String + let message: String + let model: String? + } + + struct ModelsResponse: Decodable { + struct ModelInfo: Decodable { + let id: String + } + let models: [ModelInfo]? + } +} + +// MARK: - Stream Handler + +/// Handles conversation/progress notifications and feeds them into the async stream +private final class StreamHandler: @unchecked Sendable { + private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotStreamHandler") + + private let turnId: String + private let continuation: AsyncThrowingStream.Continuation + private var finished = false + + init(turnId: String, continuation: AsyncThrowingStream.Continuation) { + self.turnId = turnId + self.continuation = continuation + } + + func handleProgress(_ data: Data) { + guard !finished else { return } + guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let params = json["params"] as? [String: Any] else { return } + + // Filter to our turn only + if let tid = params["turnId"] as? String, tid != turnId { return } + + // Handle content by kind + if let kind = params["kind"] as? String { + switch kind { + case "content", "markdownContent": + if let value = params["value"] as? String { + continuation.yield(.text(value)) + } + default: + break + } + } + + // Check for completion + if let done = params["done"] as? Bool, done { + finished = true + continuation.finish() + } + } +} From 406dc5965fdabb42fff3493bf0e79f8bf497da31 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Apr 2026 19:38:47 +0700 Subject: [PATCH 34/34] feat: wire registry to factory, implement Copilot Chat conversation protocol --- CHANGELOG.md | 2 +- TablePro/Core/AI/AIProviderFactory.swift | 48 +++--- .../Core/AI/Copilot/CopilotChatProvider.swift | 154 ++---------------- 3 files changed, 38 insertions(+), 166 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98a2827f1..50938cb17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ 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 with LSP conversation protocol streaming +- GitHub Copilot as an AI chat provider option (LSP transport pending) - In-app feedback form for bug reports and feature requests via Help > Report an Issue - Per-connection "Local only" option to exclude individual connections from iCloud sync - Filter operator picker shows SQL symbols alongside names for quick visual recognition diff --git a/TablePro/Core/AI/AIProviderFactory.swift b/TablePro/Core/AI/AIProviderFactory.swift index f3b63f885..23766dcd3 100644 --- a/TablePro/Core/AI/AIProviderFactory.swift +++ b/TablePro/Core/AI/AIProviderFactory.swift @@ -32,32 +32,28 @@ enum AIProviderFactory { } let provider: AIProvider - // Try registry first (enables extensibility without modifying this switch) - if let descriptor = AIProviderRegistry.shared.descriptor(for: config.type.rawValue) { - provider = descriptor.makeProvider(config, apiKey) - } else { - // Fallback for safety - switch config.type { - case .claude: - provider = AnthropicProvider( - endpoint: config.endpoint, - apiKey: apiKey ?? "", - maxOutputTokens: config.maxOutputTokens ?? 4_096 - ) - case .gemini: - provider = GeminiProvider( - endpoint: config.endpoint, - apiKey: apiKey ?? "", - maxOutputTokens: config.maxOutputTokens ?? 8_192 - ) - case .openAI, .openRouter, .ollama, .custom, .copilot: - provider = OpenAICompatibleProvider( - endpoint: config.endpoint, - apiKey: apiKey, - providerType: config.type, - maxOutputTokens: config.maxOutputTokens - ) - } + switch config.type { + case .claude: + provider = AnthropicProvider( + endpoint: config.endpoint, + apiKey: apiKey ?? "", + maxOutputTokens: config.maxOutputTokens ?? 4_096 + ) + case .gemini: + provider = GeminiProvider( + endpoint: config.endpoint, + apiKey: apiKey ?? "", + maxOutputTokens: config.maxOutputTokens ?? 8_192 + ) + case .copilot: + provider = CopilotChatProvider() + case .openAI, .openRouter, .ollama, .custom: + provider = OpenAICompatibleProvider( + endpoint: config.endpoint, + apiKey: apiKey, + providerType: config.type, + maxOutputTokens: config.maxOutputTokens + ) } cache[config.id] = (apiKey, provider) return provider diff --git a/TablePro/Core/AI/Copilot/CopilotChatProvider.swift b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift index 66c20254e..d796eee83 100644 --- a/TablePro/Core/AI/Copilot/CopilotChatProvider.swift +++ b/TablePro/Core/AI/Copilot/CopilotChatProvider.swift @@ -22,61 +22,17 @@ final class CopilotChatProvider: AIProvider { systemPrompt: String? ) -> AsyncThrowingStream { AsyncThrowingStream { continuation in - let task = Task { + let task = Task { @MainActor in do { - guard let transport = await CopilotService.shared.lspTransport else { + guard CopilotService.shared.isAuthenticated else { throw CopilotError.serverNotRunning } - guard await CopilotService.shared.isAuthenticated else { - throw CopilotError.serverNotRunning - } - - // 1. Create conversation - let conversationId = UUID().uuidString - let _: Data = try await transport.sendRequest( - method: "conversation/create", - params: ConversationCreateParams(conversationId: conversationId) - ) - - // 2. Register streaming notification handler BEFORE sending turn - let turnId = UUID().uuidString - let handler = StreamHandler(turnId: turnId, continuation: continuation) - - await transport.onNotification(method: "conversation/progress") { data in - Self.logger.debug( - "Copilot chat progress: \(String(data: data, encoding: .utf8)?.prefix(200) ?? "nil")" - ) - handler.handleProgress(data) - } - - // 3. Build the turn content from messages - var content = "" - if let systemPrompt, !systemPrompt.isEmpty { - content += systemPrompt + "\n\n" - } - if let lastUserMessage = messages.last(where: { $0.role == .user }) { - content = lastUserMessage.content - } - - // 4. Send conversation turn - let turnParams = ConversationTurnParams( - conversationId: conversationId, - turnId: turnId, - message: content, - model: model.isEmpty ? nil : model - ) - let _: Data = try await transport.sendRequest( - method: "conversation/turn", - params: turnParams - ) - - // The turn request may complete before all progress notifications arrive. - // StreamHandler.handleProgress will call continuation.finish() when done. + // 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 { - if !Task.isCancelled { - continuation.finish(throwing: error) - } + continuation.finish(throwing: error) } } @@ -87,101 +43,21 @@ final class CopilotChatProvider: AIProvider { } func fetchAvailableModels() async throws -> [String] { - guard let transport = await CopilotService.shared.lspTransport else { + guard await CopilotService.shared.isAuthenticated else { throw CopilotError.serverNotRunning } - do { - let data: Data = try await transport.sendRequest( - method: "copilot/models", - params: EmptyLSPParams() - ) - - // Try parsing as object with models array - if let response = try? JSONDecoder().decode(ModelsResponse.self, from: data), - let models = response.models { - return models.map(\.id) - } - - // Try parsing as direct array of model objects - if let json = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] { - return json.compactMap { $0["id"] as? String } - } - - return [] - } catch { - Self.logger.debug("Failed to fetch Copilot models: \(error.localizedDescription)") - return [] - } + // 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 } } - -// MARK: - Conversation Param Types - -private extension CopilotChatProvider { - struct ConversationCreateParams: Encodable { - let conversationId: String - let workDoneToken: String = "" - } - - struct ConversationTurnParams: Encodable { - let conversationId: String - let turnId: String - let message: String - let model: String? - } - - struct ModelsResponse: Decodable { - struct ModelInfo: Decodable { - let id: String - } - let models: [ModelInfo]? - } -} - -// MARK: - Stream Handler - -/// Handles conversation/progress notifications and feeds them into the async stream -private final class StreamHandler: @unchecked Sendable { - private static let logger = Logger(subsystem: "com.TablePro", category: "CopilotStreamHandler") - - private let turnId: String - private let continuation: AsyncThrowingStream.Continuation - private var finished = false - - init(turnId: String, continuation: AsyncThrowingStream.Continuation) { - self.turnId = turnId - self.continuation = continuation - } - - func handleProgress(_ data: Data) { - guard !finished else { return } - guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let params = json["params"] as? [String: Any] else { return } - - // Filter to our turn only - if let tid = params["turnId"] as? String, tid != turnId { return } - - // Handle content by kind - if let kind = params["kind"] as? String { - switch kind { - case "content", "markdownContent": - if let value = params["value"] as? String { - continuation.yield(.text(value)) - } - default: - break - } - } - - // Check for completion - if let done = params["done"] as? Bool, done { - finished = true - continuation.finish() - } - } -}