diff --git a/CHANGELOG.md b/CHANGELOG.md index d9607f26c..f2e86f113 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 +- AI Explain, Optimize, and Fix Error now answer with a walkthrough in the chat panel: a before/after SQL diff you can switch between unified and split, numbered steps anchored to the lines they change, and a follow-up prompt on any step. Optimize and Fix add an Apply to Editor button that asks before replacing your query. (#1945) - Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959) ### Changed diff --git a/TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift b/TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift new file mode 100644 index 000000000..f73c11e09 --- /dev/null +++ b/TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift @@ -0,0 +1,34 @@ +// +// AIPromptTemplates+Walkthrough.swift +// TablePro +// + +import Foundation + +extension AIPromptTemplates { + static let walkthroughSystemDirective: String = """ + The user's latest message asks you to explain, optimize, or fix a SQL query. \ + Reply with a short plain-language explanation, then append exactly one block in this form: + + \(WalkthroughEnvelopeParser.openFence) + { + "afterSQL": "the full rewritten query, or null if you did not change it", + "steps": [ + { + "title": "short label", + "why": "one sentence", + "importance": "critical | normal | context", + "changeType": "addition | removal | modification | explanation", + "anchor": { "side": "before | after | both", "startLine": 1, "endLine": 1 } + } + ] + } + \(WalkthroughEnvelopeParser.closeFence) + + Rules: + - Output valid JSON. Put afterSQL on a single JSON string and escape newlines as \\n. + - Do not output a diff. The app builds the diff from afterSQL. + - Anchor each step to the 1-based line numbers it refers to. Omit "anchor" for a whole-query step. + - Keep each step to one short sentence. Write nothing after the closing marker. + """ +} diff --git a/TablePro/Core/AI/AnthropicProvider.swift b/TablePro/Core/AI/AnthropicProvider.swift index abe508652..12a3139ec 100644 --- a/TablePro/Core/AI/AnthropicProvider.swift +++ b/TablePro/Core/AI/AnthropicProvider.swift @@ -307,7 +307,7 @@ final class AnthropicProvider: ChatTransport { let blocks = turn.blocks let needsTypedBlocks = blocks.contains { block in switch block.kind { - case .toolUse, .toolResult, .reasoning, .image: + case .toolUse, .toolResult, .reasoning, .image, .sqlWalkthrough: return true case .text, .attachment: return false @@ -330,6 +330,10 @@ final class AnthropicProvider: ChatTransport { case .text(let text): guard !text.isEmpty else { return nil } return ["type": "text", "text": text] + case .sqlWalkthrough(let walkthrough): + let text = walkthrough.transcriptText + guard !text.isEmpty else { return nil } + return ["type": "text", "text": text] case .toolUse(let toolUse): return [ "type": "tool_use", diff --git a/TablePro/Core/AI/Chat/ChatTurn.swift b/TablePro/Core/AI/Chat/ChatTurn.swift index 075988739..9601ee2ca 100644 --- a/TablePro/Core/AI/Chat/ChatTurn.swift +++ b/TablePro/Core/AI/Chat/ChatTurn.swift @@ -19,6 +19,7 @@ enum ChatContentBlockKind: Sendable, Equatable { case attachment(ContextItem) case reasoning(ReasoningBlock) case image(ChatImageInput) + case sqlWalkthrough(SqlWalkthroughBlock) } @MainActor @Observable @@ -87,6 +88,10 @@ extension ChatContentBlock { static func image(_ input: ChatImageInput) -> ChatContentBlock { ChatContentBlock(kind: .image(input)) } + + static func sqlWalkthrough(_ block: SqlWalkthroughBlock) -> ChatContentBlock { + ChatContentBlock(kind: .sqlWalkthrough(block)) + } } @MainActor @@ -257,12 +262,16 @@ struct ChatContentBlockWire: Codable, Equatable, Sendable, Identifiable { ChatContentBlockWire(kind: .image(input)) } + static func sqlWalkthrough(_ block: SqlWalkthroughBlock) -> ChatContentBlockWire { + ChatContentBlockWire(kind: .sqlWalkthrough(block)) + } + private enum CodingKeys: String, CodingKey { - case blockId, kind, text, toolUse, toolResult, attachment, reasoning, image + case blockId, kind, text, toolUse, toolResult, attachment, reasoning, image, sqlWalkthrough } private enum KindMarker: String, Codable { - case text, toolUse, toolResult, attachment, reasoning, image + case text, toolUse, toolResult, attachment, reasoning, image, sqlWalkthrough } init(from decoder: Decoder) throws { @@ -283,6 +292,8 @@ struct ChatContentBlockWire: Codable, Equatable, Sendable, Identifiable { resolvedKind = .reasoning(try container.decode(ReasoningBlock.self, forKey: .reasoning)) case .image: resolvedKind = .image(try container.decode(ChatImageInput.self, forKey: .image)) + case .sqlWalkthrough: + resolvedKind = .sqlWalkthrough(try container.decode(SqlWalkthroughBlock.self, forKey: .sqlWalkthrough)) } self.init(id: resolvedID, kind: resolvedKind) } @@ -309,6 +320,9 @@ struct ChatContentBlockWire: Codable, Equatable, Sendable, Identifiable { case .image(let input): try container.encode(KindMarker.image, forKey: .kind) try container.encode(input, forKey: .image) + case .sqlWalkthrough(let block): + try container.encode(KindMarker.sqlWalkthrough, forKey: .kind) + try container.encode(block, forKey: .sqlWalkthrough) } } } diff --git a/TablePro/Core/AI/Cursor/CursorProvider.swift b/TablePro/Core/AI/Cursor/CursorProvider.swift index 7de6a1ced..06becd84d 100644 --- a/TablePro/Core/AI/Cursor/CursorProvider.swift +++ b/TablePro/Core/AI/Cursor/CursorProvider.swift @@ -173,6 +173,9 @@ final class CursorProvider: ChatTransport { switch block.kind { case .text(let text): if !text.isEmpty { parts.append(text) } + case .sqlWalkthrough(let walkthrough): + let text = walkthrough.transcriptText + if !text.isEmpty { parts.append(text) } case .toolResult(let result): if !result.content.isEmpty { parts.append("Result: \(result.content)") } case .toolUse, .attachment, .reasoning, .image: diff --git a/TablePro/Core/AI/GeminiProvider.swift b/TablePro/Core/AI/GeminiProvider.swift index 56215a8f6..c0557a3b5 100644 --- a/TablePro/Core/AI/GeminiProvider.swift +++ b/TablePro/Core/AI/GeminiProvider.swift @@ -181,6 +181,10 @@ final class GeminiProvider: ChatTransport { case .text(let text): guard !text.isEmpty else { continue } parts.append(["text": text]) + case .sqlWalkthrough(let walkthrough): + let text = walkthrough.transcriptText + guard !text.isEmpty else { continue } + parts.append(["text": text]) case .attachment, .reasoning, .image: continue case .toolUse(let useBlock): diff --git a/TablePro/Core/AI/OpenAICompatibleProvider.swift b/TablePro/Core/AI/OpenAICompatibleProvider.swift index cfa397e5b..e0e6905ef 100644 --- a/TablePro/Core/AI/OpenAICompatibleProvider.swift +++ b/TablePro/Core/AI/OpenAICompatibleProvider.swift @@ -339,7 +339,14 @@ final class OpenAICompatibleProvider: ChatTransport { if case .image(let input) = block.kind { return input } return nil } - let textContent = turn.plainText + let walkthroughText = turn.blocks.compactMap { block -> String? in + guard case .sqlWalkthrough(let walkthrough) = block.kind else { return nil } + let text = walkthrough.transcriptText + return text.isEmpty ? nil : text + }.joined(separator: "\n") + let textContent = [turn.plainText, walkthroughText] + .filter { !$0.isEmpty } + .joined(separator: "\n") if turn.role == .assistant, !toolUseBlocks.isEmpty { var message: [String: Any] = ["role": "assistant"] diff --git a/TablePro/Core/AI/OpenAIResponsesProvider.swift b/TablePro/Core/AI/OpenAIResponsesProvider.swift index 2197e5206..10d0b648f 100644 --- a/TablePro/Core/AI/OpenAIResponsesProvider.swift +++ b/TablePro/Core/AI/OpenAIResponsesProvider.swift @@ -182,6 +182,10 @@ final class OpenAIResponsesProvider: ChatTransport { case .text(let text): guard !text.isEmpty else { continue } messageParts.append(["type": "output_text", "text": text]) + case .sqlWalkthrough(let walkthrough): + let text = walkthrough.transcriptText + guard !text.isEmpty else { continue } + messageParts.append(["type": "output_text", "text": text]) case .toolUse(let useBlock): flushAssistantMessage(parts: &messageParts, into: &items) items.append([ @@ -227,7 +231,7 @@ final class OpenAIResponsesProvider: ChatTransport { if let part = inputImagePart(input) { userParts.append(part) } - case .toolUse, .toolResult, .attachment, .reasoning: + case .toolUse, .toolResult, .attachment, .reasoning, .sqlWalkthrough: continue } } diff --git a/TablePro/Core/AI/WalkthroughEnvelopeParser.swift b/TablePro/Core/AI/WalkthroughEnvelopeParser.swift new file mode 100644 index 000000000..31920c25e --- /dev/null +++ b/TablePro/Core/AI/WalkthroughEnvelopeParser.swift @@ -0,0 +1,41 @@ +// +// WalkthroughEnvelopeParser.swift +// TablePro +// + +import Foundation + +enum WalkthroughEnvelopeParser { + static let openFence = "---WALKTHROUGH_JSON---" + static let closeFence = "---WALKTHROUGH_JSON_END---" + + static func parse(from text: String) -> SqlWalkthroughEnvelope? { + guard let openRange = text.range(of: openFence) else { return nil } + let afterOpen = openRange.upperBound + let sliceEnd = text.range(of: closeFence, range: afterOpen.. String { + guard let openRange = text.range(of: openFence) else { return text } + return String(text[text.startIndex.. String { + guard input.hasPrefix("```") else { return input } + var body = input + if let firstNewline = body.firstIndex(of: "\n") { + body = String(body[body.index(after: firstNewline)...]) + } + if let closingFence = body.range(of: "```", options: .backwards) { + body = String(body[body.startIndex.. [String] { + content + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + } + + static func pairs(mine: String, disk: String) -> [DiffPair] { + DiffComputer.computeSplit(before: lines(mine), after: lines(disk)) + } +} diff --git a/TablePro/Core/Diff/SqlDiff.swift b/TablePro/Core/Diff/SqlDiff.swift new file mode 100644 index 000000000..ce77b2e97 --- /dev/null +++ b/TablePro/Core/Diff/SqlDiff.swift @@ -0,0 +1,153 @@ +// +// SqlDiff.swift +// TablePro +// + +import Foundation + +enum SqlNormalizer { + static func normalize(_ sql: String) -> String { + sql + .replacingOccurrences(of: "\r\n", with: "\n") + .replacingOccurrences(of: "\r", with: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + static func lines(_ sql: String) -> [String] { + normalize(sql) + .split(separator: "\n", omittingEmptySubsequences: false) + .map(String.init) + } +} + +struct DiffPair: Equatable, Sendable { + enum Kind: Sendable { case unchanged, added, removed, changed } + let before: String? + let after: String? + let kind: Kind +} + +struct DiffUnifiedLine: Identifiable, Equatable, Sendable { + enum Kind: Sendable { case context, added, removed } + let id: Int + let beforeLineNumber: Int? + let afterLineNumber: Int? + let text: String + let kind: Kind +} + +enum DiffComputer { + static func computeSplit(before: [String], after: [String]) -> [DiffPair] { + let difference = after.difference(from: before) + + var removals: [Int: String] = [:] + var insertions: [Int: String] = [:] + for change in difference { + switch change { + case .remove(let offset, let element, _): + removals[offset] = element + case .insert(let offset, let element, _): + insertions[offset] = element + } + } + + var pairs: [DiffPair] = [] + var beforeIndex = 0 + var afterIndex = 0 + + while beforeIndex < before.count || afterIndex < after.count { + let removed = removals[beforeIndex] + let inserted = insertions[afterIndex] + + switch (removed, inserted) { + case (let removed?, let inserted?): + pairs.append(DiffPair(before: removed, after: inserted, kind: .changed)) + beforeIndex += 1 + afterIndex += 1 + case (let removed?, nil): + pairs.append(DiffPair(before: removed, after: nil, kind: .removed)) + beforeIndex += 1 + case (nil, let inserted?): + pairs.append(DiffPair(before: nil, after: inserted, kind: .added)) + afterIndex += 1 + case (nil, nil): + if beforeIndex < before.count, afterIndex < after.count { + pairs.append(DiffPair(before: before[beforeIndex], after: after[afterIndex], kind: .unchanged)) + beforeIndex += 1 + afterIndex += 1 + } else { + return pairs + } + } + } + + return pairs + } + + static func computeUnified(before: [String], after: [String]) -> [DiffUnifiedLine] { + computeUnified(from: computeSplit(before: before, after: after)) + } + + /// Derives unified rows from already-computed pairs so a caller that needs both + /// layouts runs the underlying diff once instead of twice. + static func computeUnified(from pairs: [DiffPair]) -> [DiffUnifiedLine] { + var result: [DiffUnifiedLine] = [] + var beforeNumber = 1 + var afterNumber = 1 + var id = 0 + + for pair in pairs { + switch pair.kind { + case .unchanged: + result.append(DiffUnifiedLine( + id: id, + beforeLineNumber: beforeNumber, + afterLineNumber: afterNumber, + text: pair.before ?? "", + kind: .context + )) + beforeNumber += 1 + afterNumber += 1 + case .removed: + result.append(DiffUnifiedLine( + id: id, + beforeLineNumber: beforeNumber, + afterLineNumber: nil, + text: pair.before ?? "", + kind: .removed + )) + beforeNumber += 1 + case .added: + result.append(DiffUnifiedLine( + id: id, + beforeLineNumber: nil, + afterLineNumber: afterNumber, + text: pair.after ?? "", + kind: .added + )) + afterNumber += 1 + case .changed: + result.append(DiffUnifiedLine( + id: id, + beforeLineNumber: beforeNumber, + afterLineNumber: nil, + text: pair.before ?? "", + kind: .removed + )) + id += 1 + result.append(DiffUnifiedLine( + id: id, + beforeLineNumber: nil, + afterLineNumber: afterNumber, + text: pair.after ?? "", + kind: .added + )) + beforeNumber += 1 + afterNumber += 1 + } + id += 1 + } + + return result + } +} diff --git a/TablePro/Models/AI/SqlWalkthroughModels.swift b/TablePro/Models/AI/SqlWalkthroughModels.swift new file mode 100644 index 000000000..69953c2f4 --- /dev/null +++ b/TablePro/Models/AI/SqlWalkthroughModels.swift @@ -0,0 +1,165 @@ +// +// SqlWalkthroughModels.swift +// TablePro +// + +import Foundation + +enum SqlWalkthroughImportance: String, Codable, Sendable { + case critical + case normal + case context +} + +enum SqlWalkthroughChangeType: String, Codable, Sendable { + case addition + case removal + case modification + case explanation +} + +enum SqlWalkthroughDiffStyle: String, Codable, Sendable { + case unified + case split +} + +struct SqlWalkthroughAnchor: Codable, Equatable, Sendable { + enum Side: String, Codable, Sendable { + case before + case after + case both + } + + let side: Side + let startLine: Int + let endLine: Int + + func isValid(beforeLineCount: Int, afterLineCount: Int) -> Bool { + guard startLine >= 1, endLine >= startLine else { return false } + switch side { + case .before: + return endLine <= beforeLineCount + case .after: + return endLine <= afterLineCount + case .both: + return endLine <= beforeLineCount && endLine <= afterLineCount + } + } +} + +struct SqlWalkthroughStep: Identifiable, Codable, Sendable { + let id: UUID + let title: String + let why: String + let importance: SqlWalkthroughImportance + let changeType: SqlWalkthroughChangeType + let anchor: SqlWalkthroughAnchor? + + init( + id: UUID = UUID(), + title: String, + why: String, + importance: SqlWalkthroughImportance, + changeType: SqlWalkthroughChangeType, + anchor: SqlWalkthroughAnchor? + ) { + self.id = id + self.title = title + self.why = why + self.importance = importance + self.changeType = changeType + self.anchor = anchor + } + + private enum CodingKeys: String, CodingKey { + case title + case why + case importance + case changeType + case anchor + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = UUID() + title = try container.decode(String.self, forKey: .title) + why = (try container.decodeIfPresent(String.self, forKey: .why)) ?? "" + importance = (try container.decodeIfPresent(SqlWalkthroughImportance.self, forKey: .importance)) ?? .normal + changeType = (try container.decodeIfPresent(SqlWalkthroughChangeType.self, forKey: .changeType)) ?? .explanation + anchor = try container.decodeIfPresent(SqlWalkthroughAnchor.self, forKey: .anchor) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(title, forKey: .title) + try container.encode(why, forKey: .why) + try container.encode(importance, forKey: .importance) + try container.encode(changeType, forKey: .changeType) + try container.encodeIfPresent(anchor, forKey: .anchor) + } +} + +extension SqlWalkthroughStep: Equatable { + static func == (lhs: SqlWalkthroughStep, rhs: SqlWalkthroughStep) -> Bool { + lhs.title == rhs.title + && lhs.why == rhs.why + && lhs.importance == rhs.importance + && lhs.changeType == rhs.changeType + && lhs.anchor == rhs.anchor + } +} + +struct SqlWalkthroughEnvelope: Codable, Equatable, Sendable { + var afterSQL: String? + var steps: [SqlWalkthroughStep] + + init(afterSQL: String?, steps: [SqlWalkthroughStep]) { + self.afterSQL = afterSQL + self.steps = steps + } + + private enum CodingKeys: String, CodingKey { + case afterSQL + case steps + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + afterSQL = try container.decodeIfPresent(String.self, forKey: .afterSQL) + steps = (try container.decodeIfPresent([SqlWalkthroughStep].self, forKey: .steps)) ?? [] + } +} + +struct SqlWalkthroughBlock: Codable, Equatable, Sendable { + let beforeSQL: String + var envelope: SqlWalkthroughEnvelope + var diffStyle: SqlWalkthroughDiffStyle + + init(beforeSQL: String, envelope: SqlWalkthroughEnvelope, diffStyle: SqlWalkthroughDiffStyle = .unified) { + self.beforeSQL = beforeSQL + self.envelope = envelope + self.diffStyle = diffStyle + } + + private enum CodingKeys: String, CodingKey { + case beforeSQL + case envelope + case diffStyle + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + beforeSQL = try container.decode(String.self, forKey: .beforeSQL) + envelope = try container.decode(SqlWalkthroughEnvelope.self, forKey: .envelope) + diffStyle = (try container.decodeIfPresent(SqlWalkthroughDiffStyle.self, forKey: .diffStyle)) ?? .unified + } + + var hasDiff: Bool { + envelope.afterSQL != nil + } + + var transcriptText: String { + guard let afterSQL = envelope.afterSQL, !afterSQL.isEmpty else { return "" } + return "Proposed SQL:\n\(afterSQL)" + } +} diff --git a/TablePro/Models/AI/SqlWalkthroughPresentation.swift b/TablePro/Models/AI/SqlWalkthroughPresentation.swift new file mode 100644 index 000000000..ac7c7a6c2 --- /dev/null +++ b/TablePro/Models/AI/SqlWalkthroughPresentation.swift @@ -0,0 +1,83 @@ +// +// SqlWalkthroughPresentation.swift +// TablePro +// + +import Foundation + +/// Diff geometry for one walkthrough block, computed once and cached by the view. +/// Building this inside a SwiftUI body would re-run the line diff on every state +/// change, including each keystroke in a follow-up field. +struct SqlWalkthroughPresentation: Equatable, Sendable { + /// Caps the work the line diff can be asked to do. A query pasted from a dump can + /// be enormous, and the diff cost grows with the input, not with what is displayed. + static let maxInputLines = 2_000 + static let maxDisplayedLines = 500 + + let beforeLines: [String] + let afterLines: [String] + let droppedInputLines: Int + + let unifiedLines: [DiffUnifiedLine] + let hiddenUnifiedLines: Int + + let splitPairs: [DiffPair] + let hiddenSplitPairs: Int + + let sourceLines: [String] + let hiddenSourceLines: Int + + init(beforeSQL: String, afterSQL: String?) { + let allBefore = SqlNormalizer.lines(beforeSQL) + let allAfter = afterSQL.map(SqlNormalizer.lines) ?? [] + + let before = Array(allBefore.prefix(Self.maxInputLines)) + let after = Array(allAfter.prefix(Self.maxInputLines)) + beforeLines = before + afterLines = after + droppedInputLines = (allBefore.count - before.count) + (allAfter.count - after.count) + + let pairs = afterSQL == nil ? [] : DiffComputer.computeSplit(before: before, after: after) + let unified = DiffComputer.computeUnified(from: pairs) + + splitPairs = Array(pairs.prefix(Self.maxDisplayedLines)) + hiddenSplitPairs = pairs.count - min(pairs.count, Self.maxDisplayedLines) + + unifiedLines = Array(unified.prefix(Self.maxDisplayedLines)) + hiddenUnifiedLines = unified.count - min(unified.count, Self.maxDisplayedLines) + + sourceLines = Array(before.prefix(Self.maxDisplayedLines)) + hiddenSourceLines = before.count - min(before.count, Self.maxDisplayedLines) + } + + init(block: SqlWalkthroughBlock) { + self.init(beforeSQL: block.beforeSQL, afterSQL: block.envelope.afterSQL) + } + + func hiddenLines(for style: SqlWalkthroughDiffStyle) -> Int { + switch style { + case .unified: return hiddenUnifiedLines + droppedInputLines + case .split: return hiddenSplitPairs + droppedInputLines + } + } + + var hiddenSourceListingLines: Int { + hiddenSourceLines + droppedInputLines + } + + func anchoredSnippet(for anchor: SqlWalkthroughAnchor) -> String? { + let source = anchor.side == .after ? afterLines : beforeLines + guard anchor.startLine >= 1, anchor.endLine >= anchor.startLine, anchor.endLine <= source.count else { + return nil + } + let joined = source[(anchor.startLine - 1).. SqlWalkthroughAnchor? { + guard let anchor, + anchor.isValid(beforeLineCount: beforeLines.count, afterLineCount: afterLines.count) + else { return nil } + return anchor + } +} diff --git a/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift b/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift index 60aeab06a..8c59f6b31 100644 --- a/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift +++ b/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift @@ -32,16 +32,25 @@ extension AIChatViewModel { case .explain: guard let query = resolveQuery(body: body, command: command) else { return } messages.append(ChatTurn(role: .user, blocks: [.text(invocationText)])) - sendWithContext(prompt: AIPromptTemplates.explainQuery(query, databaseType: databaseType)) + sendWithWalkthroughContext( + prompt: AIPromptTemplates.explainQuery(query, databaseType: databaseType), + beforeSQL: query + ) case .optimize: guard let query = resolveQuery(body: body, command: command) else { return } messages.append(ChatTurn(role: .user, blocks: [.text(invocationText)])) - sendWithContext(prompt: AIPromptTemplates.optimizeQuery(query, databaseType: databaseType)) + sendWithWalkthroughContext( + prompt: AIPromptTemplates.optimizeQuery(query, databaseType: databaseType), + beforeSQL: query + ) case .fix: guard let query = resolveQuery(body: body, command: command) else { return } messages.append(ChatTurn(role: .user, blocks: [.text(invocationText)])) let lastError = queryResults ?? "" - sendWithContext(prompt: AIPromptTemplates.fixError(query: query, error: lastError, databaseType: databaseType)) + sendWithWalkthroughContext( + prompt: AIPromptTemplates.fixError(query: query, error: lastError, databaseType: databaseType), + beforeSQL: query + ) } } @@ -73,7 +82,7 @@ extension AIChatViewModel { startNewConversation() let databaseType = connection?.type ?? .mysql let prompt = AIPromptTemplates.explainQuery(selectedText, databaseType: databaseType) - sendWithContext(prompt: prompt) + sendWithWalkthroughContext(prompt: prompt, beforeSQL: selectedText) } func handleOptimizeSelection(_ selectedText: String) { @@ -81,7 +90,7 @@ extension AIChatViewModel { startNewConversation() let databaseType = connection?.type ?? .mysql let prompt = AIPromptTemplates.optimizeQuery(selectedText, databaseType: databaseType) - sendWithContext(prompt: prompt) + sendWithWalkthroughContext(prompt: prompt, beforeSQL: selectedText) } private func resolveQuery(body: String, command: SlashCommand) -> String? { diff --git a/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/TablePro/ViewModels/AIChatViewModel+Streaming.swift index bdcc5ba73..d5594efe6 100644 --- a/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -95,7 +95,8 @@ extension AIChatViewModel { promptContext: promptContext, resolved: resolved, assistantID: assistantID, - settings: settings + settings: settings, + includeWalkthroughDirective: self.pendingWalkthroughBeforeSQL != nil ) self.prepTask = nil } @@ -107,13 +108,18 @@ extension AIChatViewModel { promptContext: PromptContext?, resolved: AIProviderFactory.ResolvedProvider, assistantID: UUID, - settings: AISettings + settings: AISettings, + includeWalkthroughDirective: Bool = false ) { let chatMode = settings.chatMode streamingTask = Task.detached(priority: .userInitiated) { [weak self] in var currentAssistantID = assistantID do { - let systemPrompt = Self.buildSystemPrompt(promptContext, mode: chatMode) + let systemPrompt = Self.buildSystemPrompt( + promptContext, + mode: chatMode, + includeWalkthroughDirective: includeWalkthroughDirective + ) guard let self else { return } let preflightOK = await self.preflightCheck( systemPrompt: systemPrompt, @@ -190,6 +196,7 @@ extension AIChatViewModel { await MainActor.run { [weak self] in guard let self else { return } self.finalizeStreamingMessage(id: finalAssistantID) + self.resolveWalkthroughIfNeeded(id: finalAssistantID) self.streamingState = .idle self.streamingTask = nil self.persistCurrentConversation() @@ -198,6 +205,7 @@ extension AIChatViewModel { let failedAssistantID = currentAssistantID await MainActor.run { [weak self] in guard let self else { return } + self.pendingWalkthroughBeforeSQL = nil if !Task.isCancelled { Self.logger.error("Streaming failed: \(error.localizedDescription)") self.errorMessage = error.localizedDescription @@ -223,6 +231,47 @@ extension AIChatViewModel { messages[idx].finishStreamingTextBlock() } + @MainActor + func resolveWalkthroughIfNeeded(id: UUID) { + guard let beforeSQL = pendingWalkthroughBeforeSQL else { return } + pendingWalkthroughBeforeSQL = nil + guard let idx = messages.firstIndex(where: { $0.id == id }) else { return } + + let textBlocks = messages[idx].blocks.filter { block in + if case .text = block.kind { return true } + return false + } + guard let openOffset = textBlocks.firstIndex(where: { block in + if case .text(let text) = block.kind { + return text.contains(WalkthroughEnvelopeParser.openFence) + } + return false + }) else { return } + + // A provider can split the envelope across text blocks, so parse the joined tail + // rather than only the block that happens to carry the opening fence. + let tail = Array(textBlocks[openOffset...]) + let joined = tail.compactMap { block -> String? in + if case .text(let text) = block.kind { return text } + return nil + }.joined() + + guard case .text(let openText) = tail[0].kind else { return } + let prose = WalkthroughEnvelopeParser.stripFence(from: openText) + let consumedIDs = Set(tail.dropFirst().map(\.id)) + messages[idx].blocks.removeAll { consumedIDs.contains($0.id) } + + if prose.isEmpty { + messages[idx].blocks.removeAll { $0.id == tail[0].id } + } else { + tail[0].setKind(.text(prose)) + } + + guard let envelope = WalkthroughEnvelopeParser.parse(from: joined) else { return } + let walkthrough = SqlWalkthroughBlock(beforeSQL: beforeSQL, envelope: envelope) + messages[idx].appendBlock(.sqlWalkthrough(walkthrough)) + } + private func consumeStreamRound( resolved: AIProviderFactory.ResolvedProvider, systemPrompt: String?, @@ -356,7 +405,11 @@ extension AIChatViewModel { idMap = updated } - nonisolated static func buildSystemPrompt(_ promptContext: PromptContext?, mode: AIChatMode) -> String? { + nonisolated static func buildSystemPrompt( + _ promptContext: PromptContext?, + mode: AIChatMode, + includeWalkthroughDirective: Bool = false + ) -> String? { let schemaPrompt = promptContext.map { AISchemaContext.buildSystemPrompt( databaseType: $0.databaseType, @@ -374,8 +427,16 @@ extension AIChatViewModel { ) } let modeNote = mode.systemPromptNote - guard let schemaPrompt, !schemaPrompt.isEmpty else { return modeNote } - return "\(schemaPrompt)\n\n\(modeNote)" + let base: String? + if let schemaPrompt, !schemaPrompt.isEmpty { + base = "\(schemaPrompt)\n\n\(modeNote)" + } else { + base = modeNote + } + guard includeWalkthroughDirective else { return base } + let directive = AIPromptTemplates.walkthroughSystemDirective + guard let base, !base.isEmpty else { return directive } + return "\(base)\n\n\(directive)" } private func failTooManyRoundtrips(assistantID: UUID) async { diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 56de61e25..1bba56310 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -70,6 +70,7 @@ final class AIChatViewModel { lastError?.isRetryable ?? true } + @ObservationIgnored var pendingWalkthroughBeforeSQL: String? @ObservationIgnored var inFlightColumnFetches: [String: Task] = [:] @ObservationIgnored var inFlightSchemaLoad: Task? @ObservationIgnored nonisolated(unsafe) var streamingTask: Task? @@ -151,6 +152,11 @@ final class AIChatViewModel { startStreaming() } + func sendWithWalkthroughContext(prompt: String, beforeSQL: String) { + pendingWalkthroughBeforeSQL = beforeSQL + sendWithContext(prompt: prompt) + } + func attach(_ item: ContextItem) { guard !attachedContext.contains(where: { $0.stableKey == item.stableKey }) else { return } attachedContext.append(item) @@ -162,6 +168,7 @@ final class AIChatViewModel { } func cancelStream() { + pendingWalkthroughBeforeSQL = nil prepTask?.cancel() prepTask = nil streamingTask?.cancel() @@ -246,6 +253,7 @@ final class AIChatViewModel { inFlightSchemaLoad = nil currentQuery = nil queryResults = nil + pendingWalkthroughBeforeSQL = nil messages = [] errorMessage = nil activeConversationID = nil @@ -263,7 +271,7 @@ final class AIChatViewModel { startNewConversation() let databaseType = connection?.type ?? .mysql let prompt = AIPromptTemplates.fixError(query: query, error: error, databaseType: databaseType) - sendWithContext(prompt: prompt) + sendWithWalkthroughContext(prompt: prompt, beforeSQL: query) } func loadAvailableModels() async { diff --git a/TablePro/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index e08798071..7a8117d9a 100644 --- a/TablePro/Views/AIChat/AIChatMessageView.swift +++ b/TablePro/Views/AIChat/AIChatMessageView.swift @@ -142,6 +142,8 @@ struct AIChatMessageView: View { return block.isStreaming || (reasoning.text?.isEmpty == false) case .image: return true + case .sqlWalkthrough: + return true } } if visibleBlocks.isEmpty { @@ -180,6 +182,9 @@ private struct AIChatBlockView: View { case .image(let input): AIChatImageBlockView(input: input) .padding(.horizontal, 8) + case .sqlWalkthrough: + AIChatWalkthroughBlockView(block: block) + .padding(.horizontal, 8) } } } diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 2269833f3..3d261b7e8 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -155,6 +155,7 @@ struct AIChatPanelView: View { bottomVisibleMessageID = lastMessageID } } + .environment(viewModel) if isUserScrolledUp { Button { @@ -530,7 +531,7 @@ struct AIChatPanelView: View { switch block.kind { case .text(let value): return !value.isEmpty case .attachment, .image: return true - case .toolUse, .toolResult, .reasoning: return false + case .toolUse, .toolResult, .reasoning, .sqlWalkthrough: return false } } if !hasUserContent { return false } diff --git a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift new file mode 100644 index 000000000..24703b3fa --- /dev/null +++ b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -0,0 +1,543 @@ +// +// AIChatWalkthroughBlockView.swift +// TablePro +// + +import SwiftUI + +struct AIChatWalkthroughBlockView: View { + @Bindable var block: ChatContentBlock + + @Environment(AIChatViewModel.self) private var viewModel + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @FocusedValue(\.commandActions) private var focusedActions + @Bindable private var commandRegistry = CommandActionsRegistry.shared + + @State private var expandedStepIDs: Set = [] + @State private var activeAnchor: SqlWalkthroughAnchor? + @State private var scrollTarget: Int? + @State private var highlightClearTask: Task? + @State private var activeFollowUpStepID: UUID? + @State private var followUpText: String = "" + @State private var showApplyConfirmation = false + @State private var presentation: SqlWalkthroughPresentation? + + private var actions: MainContentCommandActions? { + focusedActions ?? commandRegistry.current + } + + private var walkthrough: SqlWalkthroughBlock? { + if case .sqlWalkthrough(let value) = block.kind { return value } + return nil + } + + var body: some View { + if let walkthrough { + content(for: walkthrough) + .task(id: block.id) { + presentation = SqlWalkthroughPresentation(block: walkthrough) + } + } + } + + @ViewBuilder + private func content(for walkthrough: SqlWalkthroughBlock) -> some View { + GroupBox { + VStack(alignment: .leading, spacing: 8) { + header(for: walkthrough) + Divider() + if let presentation { + if walkthrough.hasDiff { + diffSection(for: walkthrough, presentation: presentation) + } else { + sourceListing(presentation) + } + if !walkthrough.envelope.steps.isEmpty { + Divider() + stepList(walkthrough.envelope.steps, presentation: presentation) + } + } + if let afterSQL = walkthrough.envelope.afterSQL, !afterSQL.isEmpty { + applyControls(afterSQL: afterSQL) + } + } + .padding(10) + } + .onAppear { autoExpandFirstStep(walkthrough.envelope.steps) } + .onDisappear { + highlightClearTask?.cancel() + highlightClearTask = nil + } + } + + private func header(for walkthrough: SqlWalkthroughBlock) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Label(String(localized: "AI Walkthrough"), systemImage: "sparkles") + .font(.caption) + .foregroundStyle(.secondary) + Text("AI-generated. Review before applying.") + .font(.caption2) + .foregroundStyle(.tertiary) + } + Spacer() + if walkthrough.hasDiff { + Picker(String(localized: "Diff layout"), selection: diffStyleBinding) { + Text("Unified").tag(SqlWalkthroughDiffStyle.unified) + Text("Split").tag(SqlWalkthroughDiffStyle.split) + } + .pickerStyle(.segmented) + .labelsHidden() + .fixedSize() + .accessibilityLabel(String(localized: "Diff layout")) + } + } + } + + @ViewBuilder + private func diffSection( + for walkthrough: SqlWalkthroughBlock, + presentation: SqlWalkthroughPresentation + ) -> some View { + ScrollViewReader { proxy in + ScrollView { + Group { + switch walkthrough.diffStyle { + case .unified: + unifiedDiff(presentation) + case .split: + splitDiff(presentation) + } + } + .padding(.vertical, 2) + } + .frame(maxHeight: 260) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .onChange(of: scrollTarget) { _, target in + guard let target else { return } + withMotion { proxy.scrollTo(target, anchor: .center) } + } + } + } + + private func unifiedDiff(_ presentation: SqlWalkthroughPresentation) -> some View { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(presentation.unifiedLines) { line in + unifiedRow(line) + .id(line.id) + } + truncationFooter(hidden: presentation.hiddenLines(for: .unified)) + } + } + + private func unifiedRow(_ line: DiffUnifiedLine) -> some View { + let marker: String = switch line.kind { + case .added: "+" + case .removed: "-" + case .context: " " + } + let tint: Color? = switch line.kind { + case .added: .green.opacity(0.16) + case .removed: .red.opacity(0.16) + case .context: nil + } + let statusLabel: String = switch line.kind { + case .added: String(localized: "Added") + case .removed: String(localized: "Removed") + case .context: String(localized: "Unchanged") + } + return HStack(alignment: .top, spacing: 8) { + Text(verbatim: marker) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 10) + Text(line.text.isEmpty ? " " : line.text) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 8) + .padding(.vertical, 1) + .background(rowBackground(base: tint, unified: line)) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: "\(statusLabel): \(line.text)")) + } + + private struct SplitRow: Identifiable { + let id: Int + let text: String? + let kind: DiffPair.Kind + let lineNumber: Int? + } + + private func splitDiff(_ presentation: SqlWalkthroughPresentation) -> some View { + let pairs = presentation.splitPairs + return VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top, spacing: 0) { + splitColumn(rows: splitRows(pairs, side: .before), side: .before) + Divider() + splitColumn(rows: splitRows(pairs, side: .after), side: .after) + } + truncationFooter(hidden: presentation.hiddenLines(for: .split)) + } + } + + private func splitRows(_ pairs: [DiffPair], side: SqlWalkthroughAnchor.Side) -> [SplitRow] { + var rows: [SplitRow] = [] + var lineNumber = 0 + for (index, pair) in pairs.enumerated() { + let text = side == .before ? pair.before : pair.after + if text != nil { lineNumber += 1 } + rows.append(SplitRow(id: index, text: text, kind: pair.kind, lineNumber: text == nil ? nil : lineNumber)) + } + return rows + } + + private func splitColumn(rows: [SplitRow], side: SqlWalkthroughAnchor.Side) -> some View { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(rows) { row in + splitRow(row, side: side) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private func splitRow(_ row: SplitRow, side: SqlWalkthroughAnchor.Side) -> some View { + let tint: Color? = switch (row.kind, side) { + case (.removed, .before), (.changed, .before): .red.opacity(0.16) + case (.added, .after), (.changed, .after): .green.opacity(0.16) + default: nil + } + return HStack(alignment: .top, spacing: 6) { + Text(row.text ?? " ") + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, 8) + .padding(.vertical, 1) + .background(splitRowBackground(base: tint, side: side, lineNumber: row.lineNumber)) + } + + private func sourceListing(_ presentation: SqlWalkthroughPresentation) -> some View { + let capped = presentation.sourceLines + return ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(Array(capped.enumerated()), id: \.offset) { index, text in + HStack(alignment: .top, spacing: 8) { + Text(verbatim: "\(index + 1)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.tertiary) + .frame(width: 28, alignment: .trailing) + Text(text.isEmpty ? " " : text) + .font(.system(.body, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .id(index + 1) + .padding(.horizontal, 8) + .padding(.vertical, 1) + .background(sourceRowBackground(lineNumber: index + 1)) + } + truncationFooter(hidden: presentation.hiddenSourceListingLines) + } + } + .frame(maxHeight: 220) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .onChange(of: scrollTarget) { _, target in + guard let target else { return } + withMotion { proxy.scrollTo(target, anchor: .center) } + } + } + } + + private func stepList( + _ steps: [SqlWalkthroughStep], + presentation: SqlWalkthroughPresentation + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(steps.enumerated()), id: \.element.id) { index, step in + stepRow(step, index: index, presentation: presentation) + } + } + } + + private func stepRow( + _ step: SqlWalkthroughStep, + index: Int, + presentation: SqlWalkthroughPresentation + ) -> some View { + let anchor = presentation.resolvedAnchor(step.anchor) + return DisclosureGroup(isExpanded: stepBinding(step.id)) { + VStack(alignment: .leading, spacing: 6) { + if !step.why.isEmpty { + Text(step.why) + .font(.callout) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + if let anchor, let snippet = presentation.anchoredSnippet(for: anchor) { + anchoredSnippet(snippet) + Button(String(localized: "Jump to lines")) { activateAnchor(anchor) } + .buttonStyle(.link) + .font(.caption) + } + followUpControls(for: step, index: index) + } + .padding(.top, 4) + } label: { + stepLabel(step, index: index, anchored: anchor != nil) + } + } + + private func stepLabel(_ step: SqlWalkthroughStep, index: Int, anchored: Bool) -> some View { + HStack(spacing: 6) { + Image(systemName: "\(min(index + 1, 50)).circle.fill") + .foregroundStyle(.secondary) + ImportancePillView(importance: step.importance) + Text(step.title) + .font(.caption) + .fontWeight(.medium) + .lineLimit(2) + Spacer() + if anchored { + Image(systemName: "text.line.first.and.arrowtriangle.forward") + .font(.caption2) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(Text(verbatim: "\(String(localized: "Step")) \(index + 1): \(step.title)")) + } + + private func anchoredSnippet(_ snippet: String) -> some View { + Text(snippet) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } + + @ViewBuilder + private func followUpControls(for step: SqlWalkthroughStep, index: Int) -> some View { + Button { + followUpText = "" + activeFollowUpStepID = step.id + } label: { + Label(String(localized: "Ask about this change"), systemImage: "bubble.and.pencil") + .font(.caption) + } + .buttonStyle(.link) + .disabled(viewModel.isStreaming) + .popover(isPresented: followUpBinding(step.id), arrowEdge: .leading) { + followUpPopover(for: step, index: index) + } + } + + private func followUpPopover(for step: SqlWalkthroughStep, index: Int) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(String(format: String(localized: "Ask about step %d"), index + 1)) + .font(.caption) + .foregroundStyle(.secondary) + TextField(String(localized: "Ask about this change…"), text: $followUpText, axis: .vertical) + .textFieldStyle(.roundedBorder) + .lineLimit(1...4) + .frame(width: 240) + .onSubmit { submitFollowUp(for: step, index: index) } + HStack { + Spacer() + Button(String(localized: "Ask")) { submitFollowUp(for: step, index: index) } + .buttonStyle(.borderedProminent) + .disabled(followUpText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(12) + } + + private func applyControls(afterSQL: String) -> some View { + HStack { + Spacer() + Button(String(localized: "Apply to Editor")) { showApplyConfirmation = true } + .controlSize(.small) + .disabled(actions == nil) + .help(actions == nil + ? String(localized: "Open a connection to apply") + : String(localized: "Put the suggested SQL into the query editor")) + } + .alert(String(localized: "Apply suggested SQL?"), isPresented: $showApplyConfirmation) { + Button(String(localized: "Apply")) { actions?.loadQueryIntoEditor(afterSQL) } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { + Text("This puts the AI-generated SQL into the query editor, replacing what is there. Nothing runs until you execute it.") + } + } + + @ViewBuilder + private func truncationFooter(hidden: Int) -> some View { + if hidden > 0 { + Text(String(format: String(localized: "…and %d more lines"), hidden)) + .font(.caption2) + .foregroundStyle(.tertiary) + .padding(.horizontal, 8) + .padding(.vertical, 4) + } + } + + // MARK: - Backgrounds + + private func rowBackground(base: Color?, unified line: DiffUnifiedLine) -> Color { + if let anchor = activeAnchor, unifiedLineMatches(line, anchor: anchor) { + return .yellow.opacity(0.28) + } + return base ?? .clear + } + + private func splitRowBackground(base: Color?, side: SqlWalkthroughAnchor.Side, lineNumber: Int?) -> Color { + if let anchor = activeAnchor, let lineNumber, splitLineMatches(side: side, lineNumber: lineNumber, anchor: anchor) { + return .yellow.opacity(0.28) + } + return base ?? .clear + } + + private func sourceRowBackground(lineNumber: Int) -> Color { + if let anchor = activeAnchor, anchor.side != .after, + lineNumber >= anchor.startLine, lineNumber <= anchor.endLine { + return .yellow.opacity(0.28) + } + return .clear + } + + // MARK: - Anchor helpers + + private func unifiedLineMatches(_ line: DiffUnifiedLine, anchor: SqlWalkthroughAnchor) -> Bool { + func inRange(_ number: Int?) -> Bool { + guard let number else { return false } + return number >= anchor.startLine && number <= anchor.endLine + } + switch anchor.side { + case .before: return inRange(line.beforeLineNumber) + case .after: return inRange(line.afterLineNumber) + case .both: return inRange(line.beforeLineNumber) || inRange(line.afterLineNumber) + } + } + + private func splitLineMatches(side: SqlWalkthroughAnchor.Side, lineNumber: Int, anchor: SqlWalkthroughAnchor) -> Bool { + let sideMatches = anchor.side == .both || anchor.side == side + guard sideMatches else { return false } + return lineNumber >= anchor.startLine && lineNumber <= anchor.endLine + } + + private func activateAnchor(_ anchor: SqlWalkthroughAnchor) { + activeAnchor = anchor + scrollTarget = unifiedScrollTarget(for: anchor) + highlightClearTask?.cancel() + highlightClearTask = Task { @MainActor in + try? await Task.sleep(for: .seconds(1.5)) + guard !Task.isCancelled else { return } + withMotion { activeAnchor = nil } + } + } + + private func unifiedScrollTarget(for anchor: SqlWalkthroughAnchor) -> Int? { + guard let walkthrough, walkthrough.hasDiff else { return anchor.startLine } + let before = SqlNormalizer.lines(walkthrough.beforeSQL) + let after = walkthrough.envelope.afterSQL.map(SqlNormalizer.lines) ?? [] + let lines = DiffComputer.computeUnified(before: before, after: after) + return lines.first { unifiedLineMatches($0, anchor: anchor) }?.id + } + + // MARK: - Bindings & actions + + private var diffStyleBinding: Binding { + Binding( + get: { walkthrough?.diffStyle ?? .unified }, + set: { newValue in + guard var updated = walkthrough else { return } + updated.diffStyle = newValue + block.setKind(.sqlWalkthrough(updated)) + } + ) + } + + private func stepBinding(_ id: UUID) -> Binding { + Binding( + get: { expandedStepIDs.contains(id) }, + set: { isOpen in + if isOpen { expandedStepIDs.insert(id) } else { expandedStepIDs.remove(id) } + } + ) + } + + private func followUpBinding(_ id: UUID) -> Binding { + Binding( + get: { activeFollowUpStepID == id }, + set: { isShown in + if isShown { activeFollowUpStepID = id } else if activeFollowUpStepID == id { activeFollowUpStepID = nil } + } + ) + } + + private func submitFollowUp(for step: SqlWalkthroughStep, index: Int) { + let trimmed = followUpText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + activeFollowUpStepID = nil + followUpText = "" + let prompt = String( + format: String(localized: "Follow-up on step %d \"%@\" (%@): %@"), + index + 1, + step.title, + step.why, + trimmed + ) + viewModel.sendWithContext(prompt: prompt) + } + + private func autoExpandFirstStep(_ steps: [SqlWalkthroughStep]) { + guard expandedStepIDs.isEmpty, let first = steps.first else { return } + expandedStepIDs.insert(first.id) + } + + private func withMotion(_ change: () -> Void) { + if reduceMotion { + change() + } else { + withAnimation(.easeInOut(duration: 0.25)) { change() } + } + } +} + +private struct ImportancePillView: View { + let importance: SqlWalkthroughImportance + + var body: some View { + Text(label) + .font(.caption2) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(color.opacity(0.15)) + .foregroundStyle(color) + .clipShape(Capsule()) + .accessibilityLabel(Text(verbatim: "\(String(localized: "Importance")): \(label)")) + } + + private var label: String { + switch importance { + case .critical: String(localized: "Critical") + case .normal: String(localized: "Change") + case .context: String(localized: "Context") + } + } + + private var color: Color { + switch importance { + case .critical: .red + case .normal: .accentColor + case .context: .secondary + } + } +} diff --git a/TablePro/Views/Sidebar/FileConflictDiffSheet.swift b/TablePro/Views/Sidebar/FileConflictDiffSheet.swift index b2fc3ea9b..49fd075cc 100644 --- a/TablePro/Views/Sidebar/FileConflictDiffSheet.swift +++ b/TablePro/Views/Sidebar/FileConflictDiffSheet.swift @@ -16,9 +16,7 @@ internal struct FileConflictDiffSheet: View { @Environment(\.dismiss) private var dismiss private var diffLines: [DiffPair] { - let mineLines = mineContent.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - let diskLines = diskContent.split(separator: "\n", omittingEmptySubsequences: false).map(String.init) - return DiffComputer.compute(mine: mineLines, disk: diskLines) + FileConflictDiff.pairs(mine: mineContent, disk: diskContent) } var body: some View { @@ -51,7 +49,7 @@ internal struct FileConflictDiffSheet: View { title: String(localized: "Your Changes"), lines: diffLines.map { DiffColumnLine( - text: $0.mine, + text: $0.before, tint: tint(for: $0.kind, side: .mine) ) } @@ -62,7 +60,7 @@ internal struct FileConflictDiffSheet: View { title: String(localized: "On Disk"), lines: diffLines.map { DiffColumnLine( - text: $0.disk, + text: $0.after, tint: tint(for: $0.kind, side: .disk) ) } @@ -155,59 +153,3 @@ private struct DiffColumnView: View { } } } - -internal struct DiffPair { - enum Kind { case unchanged, added, removed, changed } - let mine: String? - let disk: String? - let kind: Kind -} - -internal enum DiffComputer { - static func compute(mine: [String], disk: [String]) -> [DiffPair] { - let difference = disk.difference(from: mine) - - var removals: [Int: String] = [:] - var insertions: [Int: String] = [:] - for change in difference { - switch change { - case .remove(let offset, let element, _): - removals[offset] = element - case .insert(let offset, let element, _): - insertions[offset] = element - } - } - - var pairs: [DiffPair] = [] - var mineIndex = 0 - var diskIndex = 0 - - while mineIndex < mine.count || diskIndex < disk.count { - let removed = removals[mineIndex] - let inserted = insertions[diskIndex] - - switch (removed, inserted) { - case (let removed?, let inserted?): - pairs.append(DiffPair(mine: removed, disk: inserted, kind: .changed)) - mineIndex += 1 - diskIndex += 1 - case (let removed?, nil): - pairs.append(DiffPair(mine: removed, disk: nil, kind: .removed)) - mineIndex += 1 - case (nil, let inserted?): - pairs.append(DiffPair(mine: nil, disk: inserted, kind: .added)) - diskIndex += 1 - case (nil, nil): - if mineIndex < mine.count, diskIndex < disk.count { - pairs.append(DiffPair(mine: mine[mineIndex], disk: disk[diskIndex], kind: .unchanged)) - mineIndex += 1 - diskIndex += 1 - } else { - return pairs - } - } - } - - return pairs - } -} diff --git a/TableProTests/Core/AI/ChatContentBlockWireWalkthroughTests.swift b/TableProTests/Core/AI/ChatContentBlockWireWalkthroughTests.swift new file mode 100644 index 000000000..8dde4cb2a --- /dev/null +++ b/TableProTests/Core/AI/ChatContentBlockWireWalkthroughTests.swift @@ -0,0 +1,80 @@ +// +// ChatContentBlockWireWalkthroughTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("ChatContentBlockWire SQL walkthrough") +struct ChatContentBlockWireWalkthroughTests { + private func sampleBlock() -> SqlWalkthroughBlock { + SqlWalkthroughBlock( + beforeSQL: "SELECT * FROM users", + envelope: SqlWalkthroughEnvelope( + afterSQL: "SELECT id FROM users", + steps: [ + SqlWalkthroughStep( + title: "Select only the columns you need", + why: "Avoids reading unused data off disk", + importance: .critical, + changeType: .modification, + anchor: SqlWalkthroughAnchor(side: .after, startLine: 1, endLine: 1) + ) + ] + ), + diffStyle: .split + ) + } + + @Test("A walkthrough block round-trips through the wire encoding") + func roundTrips() throws { + let wire = ChatContentBlockWire.sqlWalkthrough(sampleBlock()) + let data = try JSONEncoder().encode(wire) + let decoded = try JSONDecoder().decode(ChatContentBlockWire.self, from: data) + #expect(decoded.kind == wire.kind) + } + + @Test("Diff style survives the round trip") + func diffStylePersists() throws { + let wire = ChatContentBlockWire.sqlWalkthrough(sampleBlock()) + let data = try JSONEncoder().encode(wire) + let decoded = try JSONDecoder().decode(ChatContentBlockWire.self, from: data) + guard case .sqlWalkthrough(let block) = decoded.kind else { + Issue.record("Expected a sqlWalkthrough block") + return + } + #expect(block.diffStyle == .split) + #expect(block.envelope.afterSQL == "SELECT id FROM users") + #expect(block.envelope.steps.count == 1) + } + + @Test("A legacy text block without the walkthrough key still decodes") + func legacyTextDecodes() throws { + let json = Data(#"{"kind":"text","text":"hello"}"#.utf8) + let decoded = try JSONDecoder().decode(ChatContentBlockWire.self, from: json) + guard case .text(let value) = decoded.kind else { + Issue.record("Expected a text block") + return + } + #expect(value == "hello") + } + + @Test("A walkthrough with a null afterSQL decodes as no diff") + func explainStyleDecodes() throws { + let block = SqlWalkthroughBlock( + beforeSQL: "SELECT 1", + envelope: SqlWalkthroughEnvelope(afterSQL: nil, steps: []), + diffStyle: .unified + ) + let wire = ChatContentBlockWire.sqlWalkthrough(block) + let data = try JSONEncoder().encode(wire) + let decoded = try JSONDecoder().decode(ChatContentBlockWire.self, from: data) + guard case .sqlWalkthrough(let value) = decoded.kind else { + Issue.record("Expected a sqlWalkthrough block") + return + } + #expect(value.hasDiff == false) + } +} diff --git a/TableProTests/Core/AI/SqlWalkthroughAnchorTests.swift b/TableProTests/Core/AI/SqlWalkthroughAnchorTests.swift new file mode 100644 index 000000000..584ae815e --- /dev/null +++ b/TableProTests/Core/AI/SqlWalkthroughAnchorTests.swift @@ -0,0 +1,48 @@ +// +// SqlWalkthroughAnchorTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SqlWalkthroughAnchor") +struct SqlWalkthroughAnchorTests { + @Test("An in-range before anchor is valid") + func validBefore() { + let anchor = SqlWalkthroughAnchor(side: .before, startLine: 1, endLine: 3) + #expect(anchor.isValid(beforeLineCount: 5, afterLineCount: 0)) + } + + @Test("A zero start line is invalid") + func invalidZeroStart() { + let anchor = SqlWalkthroughAnchor(side: .before, startLine: 0, endLine: 2) + #expect(!anchor.isValid(beforeLineCount: 5, afterLineCount: 5)) + } + + @Test("An end line beyond the line count is invalid") + func invalidEndBeyondCount() { + let anchor = SqlWalkthroughAnchor(side: .after, startLine: 1, endLine: 10) + #expect(!anchor.isValid(beforeLineCount: 5, afterLineCount: 5)) + } + + @Test("An end line before the start line is invalid") + func invalidReversedRange() { + let anchor = SqlWalkthroughAnchor(side: .after, startLine: 3, endLine: 2) + #expect(!anchor.isValid(beforeLineCount: 5, afterLineCount: 5)) + } + + @Test("A both-sided anchor requires both sides in range") + func bothRequiresBothSides() { + let anchor = SqlWalkthroughAnchor(side: .both, startLine: 1, endLine: 4) + #expect(anchor.isValid(beforeLineCount: 4, afterLineCount: 6)) + #expect(!anchor.isValid(beforeLineCount: 3, afterLineCount: 6)) + } + + @Test("An after anchor ignores the before line count") + func afterIgnoresBeforeCount() { + let anchor = SqlWalkthroughAnchor(side: .after, startLine: 1, endLine: 2) + #expect(anchor.isValid(beforeLineCount: 0, afterLineCount: 2)) + } +} diff --git a/TableProTests/Core/AI/SqlWalkthroughPresentationTests.swift b/TableProTests/Core/AI/SqlWalkthroughPresentationTests.swift new file mode 100644 index 000000000..026931277 --- /dev/null +++ b/TableProTests/Core/AI/SqlWalkthroughPresentationTests.swift @@ -0,0 +1,116 @@ +// +// SqlWalkthroughPresentationTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SqlWalkthroughPresentation") +struct SqlWalkthroughPresentationTests { + private func sql(lines count: Int, prefix: String) -> String { + (1...count).map { "\(prefix)\($0)" }.joined(separator: "\n") + } + + @Test("a short walkthrough reports nothing hidden in either layout") + func shortWalkthroughHidesNothing() { + let presentation = SqlWalkthroughPresentation(beforeSQL: "SELECT *\nFROM t", afterSQL: "SELECT id\nFROM t") + + #expect(presentation.hiddenLines(for: .unified) == 0) + #expect(presentation.hiddenLines(for: .split) == 0) + #expect(presentation.droppedInputLines == 0) + #expect(presentation.unifiedLines.contains { $0.kind == .removed }) + #expect(presentation.unifiedLines.contains { $0.kind == .added }) + } + + @Test("an all-changed diff reports the unified rows it truncated") + func allChangedDiffReportsHiddenUnifiedRows() { + let limit = SqlWalkthroughPresentation.maxDisplayedLines + let lineCount = limit - 50 + let presentation = SqlWalkthroughPresentation( + beforeSQL: sql(lines: lineCount, prefix: "before"), + afterSQL: sql(lines: lineCount, prefix: "after") + ) + + #expect(presentation.unifiedLines.count == limit) + #expect(presentation.hiddenLines(for: .unified) == lineCount * 2 - limit) + } + + @Test("split truncation is reported, not silent") + func splitTruncationIsReported() { + let limit = SqlWalkthroughPresentation.maxDisplayedLines + let lineCount = limit + 120 + let presentation = SqlWalkthroughPresentation( + beforeSQL: sql(lines: lineCount, prefix: "line"), + afterSQL: sql(lines: lineCount, prefix: "line") + ) + + #expect(presentation.splitPairs.count == limit) + #expect(presentation.hiddenLines(for: .split) == lineCount - limit) + } + + @Test("an oversized query is capped before diffing and the drop is reported") + func oversizedInputIsCappedAndReported() { + let overage = 40 + let lineCount = SqlWalkthroughPresentation.maxInputLines + overage + let presentation = SqlWalkthroughPresentation( + beforeSQL: sql(lines: lineCount, prefix: "line"), + afterSQL: sql(lines: lineCount, prefix: "line") + ) + + #expect(presentation.beforeLines.count == SqlWalkthroughPresentation.maxInputLines) + #expect(presentation.afterLines.count == SqlWalkthroughPresentation.maxInputLines) + #expect(presentation.droppedInputLines == overage * 2) + #expect(presentation.hiddenLines(for: .unified) > 0) + } + + @Test("a source listing without a rewrite reports its own truncation") + func sourceListingReportsTruncation() { + let limit = SqlWalkthroughPresentation.maxDisplayedLines + let lineCount = limit + 25 + let presentation = SqlWalkthroughPresentation(beforeSQL: sql(lines: lineCount, prefix: "line"), afterSQL: nil) + + #expect(presentation.sourceLines.count == limit) + #expect(presentation.hiddenSourceListingLines == lineCount - limit) + #expect(presentation.splitPairs.isEmpty) + #expect(presentation.unifiedLines.isEmpty) + } + + @Test("an out-of-range anchor resolves to nil instead of highlighting a wrong line") + func outOfRangeAnchorResolvesToNil() { + let presentation = SqlWalkthroughPresentation(beforeSQL: "SELECT 1", afterSQL: "SELECT 2") + let valid = SqlWalkthroughAnchor(side: .before, startLine: 1, endLine: 1) + let tooFar = SqlWalkthroughAnchor(side: .before, startLine: 5, endLine: 9) + + #expect(presentation.resolvedAnchor(valid) == valid) + #expect(presentation.resolvedAnchor(tooFar) == nil) + #expect(presentation.resolvedAnchor(nil) == nil) + } + + @Test("an anchor snippet reads from the side it names") + func anchorSnippetReadsNamedSide() { + let presentation = SqlWalkthroughPresentation(beforeSQL: "old one\nold two", afterSQL: "new one\nnew two") + + let before = SqlWalkthroughAnchor(side: .before, startLine: 1, endLine: 2) + let after = SqlWalkthroughAnchor(side: .after, startLine: 2, endLine: 2) + let both = SqlWalkthroughAnchor(side: .both, startLine: 1, endLine: 1) + + #expect(presentation.anchoredSnippet(for: before) == "old one\nold two") + #expect(presentation.anchoredSnippet(for: after) == "new two") + #expect(presentation.anchoredSnippet(for: both) == "old one") + } + + @Test("building the presentation twice from the same block yields the same value") + func presentationIsDeterministic() { + let block = SqlWalkthroughBlock( + beforeSQL: "SELECT *\nFROM orders", + envelope: SqlWalkthroughEnvelope(afterSQL: "SELECT id\nFROM orders", steps: []) + ) + + let first = SqlWalkthroughPresentation(block: block) + let second = SqlWalkthroughPresentation(block: block) + + #expect(first == second) + } +} diff --git a/TableProTests/Core/AI/WalkthroughEnvelopeParserTests.swift b/TableProTests/Core/AI/WalkthroughEnvelopeParserTests.swift new file mode 100644 index 000000000..393aadc21 --- /dev/null +++ b/TableProTests/Core/AI/WalkthroughEnvelopeParserTests.swift @@ -0,0 +1,112 @@ +// +// WalkthroughEnvelopeParserTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("WalkthroughEnvelopeParser") +struct WalkthroughEnvelopeParserTests { + private let open = WalkthroughEnvelopeParser.openFence + private let close = WalkthroughEnvelopeParser.closeFence + + private func wrap(_ json: String, prose: String = "Here is the change.", includeClose: Bool = true) -> String { + var text = "\(prose)\n\n\(open)\n\(json)" + if includeClose { text += "\n\(close)" } + return text + } + + @Test("Parses a well-formed envelope") + func parsesWellFormed() { + let json = #""" + { + "afterSQL": "SELECT id FROM t", + "steps": [ + { + "title": "Trim columns", "why": "Less IO", + "importance": "critical", "changeType": "modification", + "anchor": { "side": "after", "startLine": 1, "endLine": 1 } + } + ] + } + """# + let envelope = WalkthroughEnvelopeParser.parse(from: wrap(json)) + #expect(envelope?.afterSQL == "SELECT id FROM t") + #expect(envelope?.steps.count == 1) + #expect(envelope?.steps.first?.importance == .critical) + #expect(envelope?.steps.first?.anchor?.side == .after) + } + + @Test("Parses when the JSON is wrapped in a code fence") + func parsesCodeFencedJSON() { + let json = "```json\n{\"afterSQL\":null,\"steps\":[]}\n```" + let envelope = WalkthroughEnvelopeParser.parse(from: wrap(json)) + #expect(envelope != nil) + #expect(envelope?.afterSQL == nil) + #expect(envelope?.steps.isEmpty == true) + } + + @Test("Parses even without a closing fence when the JSON is valid") + func parsesWithoutClosingFence() { + let json = #"{"afterSQL":"SELECT 1","steps":[]}"# + let envelope = WalkthroughEnvelopeParser.parse(from: wrap(json, includeClose: false)) + #expect(envelope?.afterSQL == "SELECT 1") + } + + @Test("Malformed JSON between fences returns nil") + func malformedReturnsNil() { + let envelope = WalkthroughEnvelopeParser.parse(from: wrap("{ not valid json")) + #expect(envelope == nil) + } + + @Test("Text with no open fence returns nil") + func noFenceReturnsNil() { + #expect(WalkthroughEnvelopeParser.parse(from: "Just a plain explanation, no JSON.") == nil) + } + + @Test("A null afterSQL decodes to nil") + func nullAfterSQL() { + let json = #""" + { + "afterSQL": null, + "steps": [ + { "title": "a", "why": "b", "importance": "normal", "changeType": "explanation" } + ] + } + """# + let envelope = WalkthroughEnvelopeParser.parse(from: wrap(json)) + #expect(envelope?.afterSQL == nil) + #expect(envelope?.steps.count == 1) + #expect(envelope?.steps.first?.anchor == nil) + } + + @Test("Steps receive unique synthesized ids") + func uniqueStepIDs() { + let json = #""" + { + "afterSQL": null, + "steps": [ + { "title": "a", "why": "x", "importance": "normal", "changeType": "explanation" }, + { "title": "b", "why": "y", "importance": "context", "changeType": "explanation" } + ] + } + """# + let envelope = WalkthroughEnvelopeParser.parse(from: wrap(json)) + let ids = envelope?.steps.map(\.id) ?? [] + #expect(ids.count == 2) + #expect(Set(ids).count == 2) + } + + @Test("stripFence removes the fenced block and trims prose") + func stripFenceRemovesBlock() { + let text = wrap(#"{"afterSQL":null,"steps":[]}"#, prose: "Explanation text.") + #expect(WalkthroughEnvelopeParser.stripFence(from: text) == "Explanation text.") + } + + @Test("stripFence returns the input unchanged when there is no fence") + func stripFenceNoFence() { + #expect(WalkthroughEnvelopeParser.stripFence(from: "no fence here") == "no fence here") + } +} diff --git a/TableProTests/Core/Diff/FileConflictDiffTests.swift b/TableProTests/Core/Diff/FileConflictDiffTests.swift new file mode 100644 index 000000000..515ee8dde --- /dev/null +++ b/TableProTests/Core/Diff/FileConflictDiffTests.swift @@ -0,0 +1,55 @@ +// +// FileConflictDiffTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("FileConflictDiff") +struct FileConflictDiffTests { + @Test("identical content produces only unchanged pairs") + func identicalContentIsUnchanged() { + let pairs = FileConflictDiff.pairs(mine: "a\nb", disk: "a\nb") + + #expect(pairs.count == 2) + #expect(pairs.allSatisfy { $0.kind == .unchanged }) + } + + @Test("a replaced line is reported as changed with both sides") + func replacedLineIsChanged() { + let pairs = FileConflictDiff.pairs(mine: "a\nb", disk: "a\nc") + + #expect(pairs.contains(DiffPair(before: "b", after: "c", kind: .changed))) + } + + @Test("conflict lines keep boundary whitespace that SQL normalization would trim") + func conflictLinesKeepBoundaryWhitespace() { + let content = "\nSELECT 1\n" + + #expect(FileConflictDiff.lines(content) == ["", "SELECT 1", ""]) + #expect(SqlNormalizer.lines(content) == ["SELECT 1"]) + } + + @Test("a file that gained a trailing blank line reads as a real difference") + func trailingBlankLineIsADifference() { + let pairs = FileConflictDiff.pairs(mine: "a", disk: "a\n") + + #expect(pairs.contains { $0.kind != .unchanged }) + } + + @Test("a CRLF file splits into lines instead of collapsing into one") + func crlfSplitsIntoLines() { + #expect(FileConflictDiff.lines("a\r\nb") == ["a", "b"]) + #expect(FileConflictDiff.lines("a\rb") == ["a", "b"]) + } + + @Test("a CRLF file diffs line by line against its LF twin") + func crlfDiffsLineByLine() { + let pairs = FileConflictDiff.pairs(mine: "a\r\nb\r\nc", disk: "a\nx\nc") + + #expect(pairs.count == 3) + #expect(pairs.contains(DiffPair(before: "b", after: "x", kind: .changed))) + } +} diff --git a/TableProTests/Core/Diff/SqlDiffTests.swift b/TableProTests/Core/Diff/SqlDiffTests.swift new file mode 100644 index 000000000..0f8fc93e7 --- /dev/null +++ b/TableProTests/Core/Diff/SqlDiffTests.swift @@ -0,0 +1,101 @@ +// +// SqlDiffTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("SqlDiff") +struct SqlDiffTests { + @Test("computeSplit marks unchanged lines") + func splitUnchanged() { + let pairs = DiffComputer.computeSplit(before: ["a", "b"], after: ["a", "b"]) + #expect(pairs.count == 2) + #expect(pairs.allSatisfy { $0.kind == .unchanged }) + #expect(pairs[0].before == "a") + #expect(pairs[0].after == "a") + } + + @Test("computeSplit detects a replaced line as changed") + func splitChanged() { + let pairs = DiffComputer.computeSplit(before: ["SELECT *"], after: ["SELECT id"]) + #expect(pairs.count == 1) + #expect(pairs[0].kind == .changed) + #expect(pairs[0].before == "SELECT *") + #expect(pairs[0].after == "SELECT id") + } + + @Test("computeSplit detects an added line") + func splitAdded() { + let pairs = DiffComputer.computeSplit(before: ["a"], after: ["a", "b"]) + #expect(pairs.contains(DiffPair(before: nil, after: "b", kind: .added))) + } + + @Test("computeSplit detects a removed line") + func splitRemoved() { + let pairs = DiffComputer.computeSplit(before: ["a", "b"], after: ["a"]) + #expect(pairs.contains(DiffPair(before: "b", after: nil, kind: .removed))) + } + + @Test("computeUnified splits a changed pair into a removed then added line") + func unifiedChanged() { + let lines = DiffComputer.computeUnified(before: ["SELECT *"], after: ["SELECT id"]) + #expect(lines.count == 2) + #expect(lines[0].kind == .removed) + #expect(lines[0].beforeLineNumber == 1) + #expect(lines[0].afterLineNumber == nil) + #expect(lines[1].kind == .added) + #expect(lines[1].beforeLineNumber == nil) + #expect(lines[1].afterLineNumber == 1) + } + + @Test("computeUnified numbers context lines on both sides") + func unifiedContext() { + let lines = DiffComputer.computeUnified(before: ["a", "b"], after: ["a", "c"]) + let context = lines.first { $0.kind == .context } + #expect(context?.beforeLineNumber == 1) + #expect(context?.afterLineNumber == 1) + } + + @Test("computeUnified assigns unique ids") + func unifiedUniqueIds() { + let lines = DiffComputer.computeUnified(before: ["a", "b", "c"], after: ["a", "x", "c"]) + let ids = lines.map(\.id) + #expect(Set(ids).count == ids.count) + } + + @Test("computeUnified from pairs matches computing it from the inputs") + func unifiedFromPairsMatchesDirect() { + let before = ["a", "b", "c", "d"] + let after = ["a", "x", "d", "e"] + let pairs = DiffComputer.computeSplit(before: before, after: after) + + #expect(DiffComputer.computeUnified(from: pairs) == DiffComputer.computeUnified(before: before, after: after)) + } + + @Test("computeUnified from pairs keeps ids unique across changed rows") + func unifiedFromPairsKeepsIdsUnique() { + let pairs = DiffComputer.computeSplit(before: ["a", "b"], after: ["x", "y"]) + let ids = DiffComputer.computeUnified(from: pairs).map(\.id) + + #expect(Set(ids).count == ids.count) + } + + @Test("normalize collapses CRLF and trims boundary whitespace") + func normalizeWhitespace() { + #expect(SqlNormalizer.normalize(" SELECT 1\r\nFROM t ") == "SELECT 1\nFROM t") + #expect(SqlNormalizer.normalize("a\rb") == "a\nb") + } + + @Test("lines on empty string yields one empty line") + func linesEmpty() { + #expect(SqlNormalizer.lines("") == [""]) + } + + @Test("lines preserves interior blank lines") + func linesInterior() { + #expect(SqlNormalizer.lines("a\n\nb") == ["a", "", "b"]) + } +} diff --git a/docs/features/ai-assistant.mdx b/docs/features/ai-assistant.mdx index 4131a9b81..0daccf85e 100644 --- a/docs/features/ai-assistant.mdx +++ b/docs/features/ai-assistant.mdx @@ -107,6 +107,17 @@ Right-click SQL in the editor for **Explain with AI** (`Cmd+L`) and **Optimize w When a query fails, the inline error banner above the results shows a **Fix with AI** button. It opens chat with the failed query and error pre-filled. +## SQL Walkthroughs + +Explain, Optimize, and Fix answer with a walkthrough in the chat panel instead of plain text, so you can review the change before trusting it: + +- **Before/after diff.** Optimize and Fix show a diff between your query and the AI's version. Switch between **Unified** and **Split** with the toggle in the walkthrough header. Explain shows the query with the steps anchored to it. +- **Numbered steps.** Each step has a one-line reason and an importance tag (**Critical**, **Change**, or **Context**). Expand a step to see the exact lines it refers to, and **Show in diff** scrolls to and highlights them. +- **Ask about a step.** Use **Ask about this change** on any step to send a follow-up question that stays anchored to that step. +- **Apply to Editor.** When the AI rewrote your query, **Apply to Editor** replaces the editor content after you confirm. Nothing is applied automatically. + +The AI writes the steps and the rewritten query; TablePro computes the diff itself, so the diff always reflects the real change. If a model returns plain prose instead, the response renders as normal text. + ## Context Under **Settings** > **AI** > **Context**: