From 3c54acd1d4c0516c3e224775d4c2d5fd5acdb9f7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 22 Jul 2026 23:36:05 +0700 Subject: [PATCH 1/9] refactor(datagrid): extract the line-diff engine into a reusable component --- TablePro/Core/Diff/SqlDiff.swift | 148 ++++++++++++++++++ .../Views/Sidebar/FileConflictDiffSheet.swift | 62 +------- TableProTests/Core/Diff/SqlDiffTests.swift | 84 ++++++++++ 3 files changed, 235 insertions(+), 59 deletions(-) create mode 100644 TablePro/Core/Diff/SqlDiff.swift create mode 100644 TableProTests/Core/Diff/SqlDiffTests.swift diff --git a/TablePro/Core/Diff/SqlDiff.swift b/TablePro/Core/Diff/SqlDiff.swift new file mode 100644 index 000000000..f637b4631 --- /dev/null +++ b/TablePro/Core/Diff/SqlDiff.swift @@ -0,0 +1,148 @@ +// +// 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 { + enum Kind { case unchanged, added, removed, changed } + let before: String? + let after: String? + let kind: Kind +} + +struct DiffUnifiedLine: Identifiable, Equatable { + enum Kind { 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] { + let pairs = computeSplit(before: before, after: after) + 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/Views/Sidebar/FileConflictDiffSheet.swift b/TablePro/Views/Sidebar/FileConflictDiffSheet.swift index b2fc3ea9b..1fd8f159d 100644 --- a/TablePro/Views/Sidebar/FileConflictDiffSheet.swift +++ b/TablePro/Views/Sidebar/FileConflictDiffSheet.swift @@ -18,7 +18,7 @@ internal struct FileConflictDiffSheet: View { 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) + return DiffComputer.computeSplit(before: mineLines, after: diskLines) } var body: some View { @@ -51,7 +51,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 +62,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 +155,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/Diff/SqlDiffTests.swift b/TableProTests/Core/Diff/SqlDiffTests.swift new file mode 100644 index 000000000..c2bf743c8 --- /dev/null +++ b/TableProTests/Core/Diff/SqlDiffTests.swift @@ -0,0 +1,84 @@ +// +// 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("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"]) + } +} From a7bcd9dd8c61aeb79c92149a1a884c81df66e185 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 22 Jul 2026 23:46:30 +0700 Subject: [PATCH 2/9] feat(ai-chat): render AI SQL walkthroughs with an anchored before/after diff --- TablePro/Core/AI/AnthropicProvider.swift | 6 +- TablePro/Core/AI/Chat/ChatTurn.swift | 18 +- TablePro/Core/AI/Cursor/CursorProvider.swift | 3 + TablePro/Core/AI/GeminiProvider.swift | 4 + .../Core/AI/OpenAICompatibleProvider.swift | 9 +- .../Core/AI/OpenAIResponsesProvider.swift | 6 +- TablePro/Models/AI/SqlWalkthroughModels.swift | 165 ++++++ TablePro/Views/AIChat/AIChatMessageView.swift | 5 + TablePro/Views/AIChat/AIChatPanelView.swift | 3 +- .../AIChat/AIChatWalkthroughBlockView.swift | 556 ++++++++++++++++++ ...ChatContentBlockWireWalkthroughTests.swift | 80 +++ .../Core/AI/SqlWalkthroughAnchorTests.swift | 48 ++ 12 files changed, 897 insertions(+), 6 deletions(-) create mode 100644 TablePro/Models/AI/SqlWalkthroughModels.swift create mode 100644 TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift create mode 100644 TableProTests/Core/AI/ChatContentBlockWireWalkthroughTests.swift create mode 100644 TableProTests/Core/AI/SqlWalkthroughAnchorTests.swift 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/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/Views/AIChat/AIChatMessageView.swift b/TablePro/Views/AIChat/AIChatMessageView.swift index bd7a792c3..6061cdcae 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 { @@ -179,6 +181,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 ed8f8f40a..0d5041d2b 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -154,6 +154,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..591ce66a5 --- /dev/null +++ b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -0,0 +1,556 @@ +// +// 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 + + private static let maxDiffLines = 500 + + 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) + } + } + + @ViewBuilder + private func content(for walkthrough: SqlWalkthroughBlock) -> some View { + let beforeLines = SqlNormalizer.lines(walkthrough.beforeSQL) + let afterLines = walkthrough.envelope.afterSQL.map(SqlNormalizer.lines) ?? [] + + GroupBox { + VStack(alignment: .leading, spacing: 8) { + header(for: walkthrough) + Divider() + if walkthrough.hasDiff { + diffSection(for: walkthrough, beforeLines: beforeLines, afterLines: afterLines) + } else { + sourceListing(beforeLines) + } + if !walkthrough.envelope.steps.isEmpty { + Divider() + stepList(walkthrough.envelope.steps, beforeLines: beforeLines, afterLines: afterLines) + } + 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, + beforeLines: [String], + afterLines: [String] + ) -> some View { + ScrollViewReader { proxy in + ScrollView { + Group { + switch walkthrough.diffStyle { + case .unified: + unifiedDiff(before: beforeLines, after: afterLines) + case .split: + splitDiff(before: beforeLines, after: afterLines) + } + } + .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(before: [String], after: [String]) -> some View { + let lines = Array(DiffComputer.computeUnified(before: before, after: after).prefix(Self.maxDiffLines)) + return LazyVStack(alignment: .leading, spacing: 0) { + ForEach(lines) { line in + unifiedRow(line) + .id(line.id) + } + truncationFooter(shown: lines.count, total: max(before.count, after.count)) + } + } + + 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(before: [String], after: [String]) -> some View { + let pairs = Array(DiffComputer.computeSplit(before: before, after: after).prefix(Self.maxDiffLines)) + return HStack(alignment: .top, spacing: 0) { + splitColumn(rows: splitRows(pairs, side: .before), side: .before) + Divider() + splitColumn(rows: splitRows(pairs, side: .after), side: .after) + } + } + + 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(_ lines: [String]) -> some View { + let capped = Array(lines.prefix(Self.maxDiffLines)) + 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(shown: capped.count, total: lines.count) + } + } + .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], + beforeLines: [String], + afterLines: [String] + ) -> some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(steps.enumerated()), id: \.element.id) { index, step in + stepRow(step, index: index, beforeLines: beforeLines, afterLines: afterLines) + } + } + } + + private func stepRow( + _ step: SqlWalkthroughStep, + index: Int, + beforeLines: [String], + afterLines: [String] + ) -> some View { + let anchor = resolvedAnchor(step.anchor, beforeCount: beforeLines.count, afterCount: afterLines.count) + 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 = anchorSnippet(anchor, beforeLines: beforeLines, afterLines: afterLines) { + 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: "Replace the editor content with the suggested SQL")) + } + .alert(String(localized: "Apply suggested SQL?"), isPresented: $showApplyConfirmation) { + Button(String(localized: "Apply")) { actions?.insertQueryFromAI(afterSQL) } + Button(String(localized: "Cancel"), role: .cancel) {} + } message: { + Text("This replaces the editor content with the AI-generated SQL.") + } + } + + @ViewBuilder + private func truncationFooter(shown: Int, total: Int) -> some View { + if total > shown { + Text(String(format: String(localized: "…and %d more lines"), total - shown)) + .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 resolvedAnchor(_ anchor: SqlWalkthroughAnchor?, beforeCount: Int, afterCount: Int) -> SqlWalkthroughAnchor? { + guard let anchor, anchor.isValid(beforeLineCount: beforeCount, afterLineCount: afterCount) else { return nil } + return anchor + } + + private func anchorSnippet(_ anchor: SqlWalkthroughAnchor, beforeLines: [String], afterLines: [String]) -> String? { + let source = anchor.side == .after ? afterLines : beforeLines + guard anchor.startLine >= 1, anchor.endLine <= source.count else { return nil } + let slice = source[(anchor.startLine - 1).. 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/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)) + } +} From 72988d7bb12dc7cd363bd8a9f432bcfbb047c0f4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 22 Jul 2026 23:46:36 +0700 Subject: [PATCH 3/9] feat(ai-chat): drive Explain, Optimize, and Fix through the walkthrough flow --- CHANGELOG.md | 1 + .../AI/AIPromptTemplates+Walkthrough.swift | 114 ++++++++++++++++++ .../Core/AI/WalkthroughEnvelopeParser.swift | 41 +++++++ .../AIChatViewModel+SlashCommands.swift | 23 ++-- .../AIChatViewModel+Streaming.swift | 26 ++++ TablePro/ViewModels/AIChatViewModel.swift | 12 +- .../AI/WalkthroughEnvelopeParserTests.swift | 112 +++++++++++++++++ docs/features/ai-assistant.mdx | 11 ++ 8 files changed, 331 insertions(+), 9 deletions(-) create mode 100644 TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift create mode 100644 TablePro/Core/AI/WalkthroughEnvelopeParser.swift create mode 100644 TableProTests/Core/AI/WalkthroughEnvelopeParserTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index bbd0f5146..d6ef6769f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - In the CSV editor, set the delimiter, quote character, encoding, and line ending by hand from Edit > Set CSV Properties…, then Reload to re-read the file with those settings. (#1469) - In the CSV editor, choose the escape character (a doubled quote or a backslash) in Set CSV Properties…, so files that escape quotes with a backslash read and save correctly. (#1469) - Add llama.cpp and MLX as local AI providers. Each preset points at the server's default local endpoint and needs no API key, alongside the existing Ollama and custom OpenAI-compatible options. (#1777) +- 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) ### Fixed diff --git a/TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift b/TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift new file mode 100644 index 000000000..4c7f61faf --- /dev/null +++ b/TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift @@ -0,0 +1,114 @@ +// +// AIPromptTemplates+Walkthrough.swift +// TablePro +// + +import Foundation +import TableProPluginKit + +extension AIPromptTemplates { + @MainActor static func explainQueryWalkthrough(_ query: String, databaseType: DatabaseType = .mysql) -> String { + let (typeName, lang) = walkthroughQueryInfo(for: databaseType) + return explainQueryWalkthrough(query, typeName: typeName, language: lang) + } + + @MainActor static func optimizeQueryWalkthrough(_ query: String, databaseType: DatabaseType = .mysql) -> String { + let (typeName, lang) = walkthroughQueryInfo(for: databaseType) + return optimizeQueryWalkthrough(query, typeName: typeName, language: lang) + } + + @MainActor static func fixErrorWalkthrough(query: String, error: String, databaseType: DatabaseType = .mysql) -> String { + let (typeName, lang) = walkthroughQueryInfo(for: databaseType) + return fixErrorWalkthrough(query: query, error: error, typeName: typeName, language: lang) + } + + static func explainQueryWalkthrough(_ query: String, typeName: String, language: String) -> String { + """ + Explain this \(typeName): + + ```\(language) + \(query) + ``` + + Write a short plain-language explanation. Then output the walkthrough block described below. + \(envelopeInstructions(hasRewrite: false)) + """ + } + + static func optimizeQueryWalkthrough(_ query: String, typeName: String, language: String) -> String { + """ + Optimize this \(typeName) for better performance and return the improved version: + + ```\(language) + \(query) + ``` + + Keep the result equivalent. Then output the walkthrough block described below. + \(envelopeInstructions(hasRewrite: true)) + """ + } + + static func fixErrorWalkthrough(query: String, error: String, typeName: String, language: String) -> String { + """ + This \(typeName) failed with an error. Fix it and return the corrected version. + + Query: + ```\(language) + \(query) + ``` + + Error: \(error) + + Then output the walkthrough block described below. + \(envelopeInstructions(hasRewrite: true)) + """ + } + + private static func envelopeInstructions(hasRewrite: Bool) -> String { + let afterField = hasRewrite + ? "Set \"afterSQL\" to the full rewritten query." + : "Set \"afterSQL\" to null (no rewrite)." + return """ + + After the explanation, output one block delimited by the exact markers below + with valid JSON between them. Do not output a diff; the app builds the diff from + your afterSQL. Anchor each step to the 1-based line numbers it refers to, and omit + "anchor" if a step is about the whole query. Keep each step to one short sentence. + + \(WalkthroughEnvelopeParser.openFence) + { + "afterSQL": null, + "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) + + \(afterField) + """ + } + + @MainActor private static func walkthroughQueryInfo(for databaseType: DatabaseType) -> (typeName: String, language: String) { + let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId) + let editorLanguage = snapshot?.editorLanguage ?? .sql + let lang = editorLanguage.codeBlockTag + let typeName: String + switch editorLanguage { + case .sql: + typeName = "\(snapshot?.queryLanguageName ?? "SQL") query" + case .bash: + typeName = "\(snapshot?.displayName ?? databaseType.rawValue) command" + case .javascript: + typeName = "\(snapshot?.displayName ?? databaseType.rawValue) query" + case .custom: + typeName = "\(snapshot?.displayName ?? databaseType.rawValue) query" + } + return (typeName, lang) + } +} 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? { diff --git a/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/TablePro/ViewModels/AIChatViewModel+Streaming.swift index bdcc5ba73..b659a9dce 100644 --- a/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -190,6 +190,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 +199,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 +225,30 @@ 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 }), + let lastBlock = messages[idx].blocks.last, + case .text(let fullText) = lastBlock.kind, + fullText.contains(WalkthroughEnvelopeParser.openFence) + else { return } + + let prose = WalkthroughEnvelopeParser.stripFence(from: fullText) + guard let envelope = WalkthroughEnvelopeParser.parse(from: fullText) else { + lastBlock.setKind(.text(prose)) + return + } + if prose.isEmpty { + messages[idx].blocks.removeLast() + } else { + lastBlock.setKind(.text(prose)) + } + let walkthrough = SqlWalkthroughBlock(beforeSQL: beforeSQL, envelope: envelope) + messages[idx].appendBlock(.sqlWalkthrough(walkthrough)) + } + private func consumeStreamRound( resolved: AIProviderFactory.ResolvedProvider, systemPrompt: String?, diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index 56de61e25..d23321a2d 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 @@ -262,8 +270,8 @@ final class AIChatViewModel { func handleFixError(query: String, error: String) { startNewConversation() let databaseType = connection?.type ?? .mysql - let prompt = AIPromptTemplates.fixError(query: query, error: error, databaseType: databaseType) - sendWithContext(prompt: prompt) + let prompt = AIPromptTemplates.fixErrorWalkthrough(query: query, error: error, databaseType: databaseType) + sendWithWalkthroughContext(prompt: prompt, beforeSQL: query) } func loadAvailableModels() async { 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/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**: From 22d7563d4ed871b734bd7ba7df8d48689bc49beb Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Jul 2026 00:27:21 +0700 Subject: [PATCH 4/9] fix(ai-chat): keep walkthrough scaffolding out of the transcript and parse it from any block --- .../AI/AIPromptTemplates+Walkthrough.swift | 122 +++--------------- .../AIChatViewModel+SlashCommands.swift | 10 +- .../AIChatViewModel+Streaming.swift | 48 +++++-- TablePro/ViewModels/AIChatViewModel.swift | 2 +- 4 files changed, 61 insertions(+), 121 deletions(-) diff --git a/TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift b/TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift index 4c7f61faf..f73c11e09 100644 --- a/TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift +++ b/TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift @@ -4,111 +4,31 @@ // import Foundation -import TableProPluginKit extension AIPromptTemplates { - @MainActor static func explainQueryWalkthrough(_ query: String, databaseType: DatabaseType = .mysql) -> String { - let (typeName, lang) = walkthroughQueryInfo(for: databaseType) - return explainQueryWalkthrough(query, typeName: typeName, language: lang) - } - - @MainActor static func optimizeQueryWalkthrough(_ query: String, databaseType: DatabaseType = .mysql) -> String { - let (typeName, lang) = walkthroughQueryInfo(for: databaseType) - return optimizeQueryWalkthrough(query, typeName: typeName, language: lang) - } - - @MainActor static func fixErrorWalkthrough(query: String, error: String, databaseType: DatabaseType = .mysql) -> String { - let (typeName, lang) = walkthroughQueryInfo(for: databaseType) - return fixErrorWalkthrough(query: query, error: error, typeName: typeName, language: lang) - } - - static func explainQueryWalkthrough(_ query: String, typeName: String, language: String) -> String { - """ - Explain this \(typeName): - - ```\(language) - \(query) - ``` - - Write a short plain-language explanation. Then output the walkthrough block described below. - \(envelopeInstructions(hasRewrite: false)) - """ - } - - static func optimizeQueryWalkthrough(_ query: String, typeName: String, language: String) -> String { - """ - Optimize this \(typeName) for better performance and return the improved version: - - ```\(language) - \(query) - ``` - - Keep the result equivalent. Then output the walkthrough block described below. - \(envelopeInstructions(hasRewrite: true)) - """ - } - - static func fixErrorWalkthrough(query: String, error: String, typeName: String, language: String) -> String { - """ - This \(typeName) failed with an error. Fix it and return the corrected version. - - Query: - ```\(language) - \(query) - ``` - - Error: \(error) - - Then output the walkthrough block described below. - \(envelopeInstructions(hasRewrite: true)) - """ - } - - private static func envelopeInstructions(hasRewrite: Bool) -> String { - let afterField = hasRewrite - ? "Set \"afterSQL\" to the full rewritten query." - : "Set \"afterSQL\" to null (no rewrite)." - return """ - - After the explanation, output one block delimited by the exact markers below - with valid JSON between them. Do not output a diff; the app builds the diff from - your afterSQL. Anchor each step to the 1-based line numbers it refers to, and omit - "anchor" if a step is about the whole query. Keep each step to one short sentence. - - \(WalkthroughEnvelopeParser.openFence) + 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": [ { - "afterSQL": null, - "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 } - } - ] + "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) - - \(afterField) - """ + ] } + \(WalkthroughEnvelopeParser.closeFence) - @MainActor private static func walkthroughQueryInfo(for databaseType: DatabaseType) -> (typeName: String, language: String) { - let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId) - let editorLanguage = snapshot?.editorLanguage ?? .sql - let lang = editorLanguage.codeBlockTag - let typeName: String - switch editorLanguage { - case .sql: - typeName = "\(snapshot?.queryLanguageName ?? "SQL") query" - case .bash: - typeName = "\(snapshot?.displayName ?? databaseType.rawValue) command" - case .javascript: - typeName = "\(snapshot?.displayName ?? databaseType.rawValue) query" - case .custom: - typeName = "\(snapshot?.displayName ?? databaseType.rawValue) query" - } - return (typeName, lang) - } + 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/ViewModels/AIChatViewModel+SlashCommands.swift b/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift index df62ec0df..8c59f6b31 100644 --- a/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift +++ b/TablePro/ViewModels/AIChatViewModel+SlashCommands.swift @@ -33,14 +33,14 @@ extension AIChatViewModel { guard let query = resolveQuery(body: body, command: command) else { return } messages.append(ChatTurn(role: .user, blocks: [.text(invocationText)])) sendWithWalkthroughContext( - prompt: AIPromptTemplates.explainQueryWalkthrough(query, databaseType: databaseType), + 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)])) sendWithWalkthroughContext( - prompt: AIPromptTemplates.optimizeQueryWalkthrough(query, databaseType: databaseType), + prompt: AIPromptTemplates.optimizeQuery(query, databaseType: databaseType), beforeSQL: query ) case .fix: @@ -48,7 +48,7 @@ extension AIChatViewModel { messages.append(ChatTurn(role: .user, blocks: [.text(invocationText)])) let lastError = queryResults ?? "" sendWithWalkthroughContext( - prompt: AIPromptTemplates.fixErrorWalkthrough(query: query, error: lastError, databaseType: databaseType), + prompt: AIPromptTemplates.fixError(query: query, error: lastError, databaseType: databaseType), beforeSQL: query ) } @@ -81,7 +81,7 @@ extension AIChatViewModel { guard !selectedText.isEmpty else { return } startNewConversation() let databaseType = connection?.type ?? .mysql - let prompt = AIPromptTemplates.explainQueryWalkthrough(selectedText, databaseType: databaseType) + let prompt = AIPromptTemplates.explainQuery(selectedText, databaseType: databaseType) sendWithWalkthroughContext(prompt: prompt, beforeSQL: selectedText) } @@ -89,7 +89,7 @@ extension AIChatViewModel { guard !selectedText.isEmpty else { return } startNewConversation() let databaseType = connection?.type ?? .mysql - let prompt = AIPromptTemplates.optimizeQueryWalkthrough(selectedText, databaseType: databaseType) + let prompt = AIPromptTemplates.optimizeQuery(selectedText, databaseType: databaseType) sendWithWalkthroughContext(prompt: prompt, beforeSQL: selectedText) } diff --git a/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/TablePro/ViewModels/AIChatViewModel+Streaming.swift index b659a9dce..6dccb907d 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, @@ -229,21 +235,23 @@ extension AIChatViewModel { func resolveWalkthroughIfNeeded(id: UUID) { guard let beforeSQL = pendingWalkthroughBeforeSQL else { return } pendingWalkthroughBeforeSQL = nil - guard let idx = messages.firstIndex(where: { $0.id == id }), - let lastBlock = messages[idx].blocks.last, - case .text(let fullText) = lastBlock.kind, - fullText.contains(WalkthroughEnvelopeParser.openFence) - else { return } + guard let idx = messages.firstIndex(where: { $0.id == id }) else { return } + guard let fenceBlock = messages[idx].blocks.last(where: { block in + if case .text(let text) = block.kind { + return text.contains(WalkthroughEnvelopeParser.openFence) + } + return false + }), case .text(let fullText) = fenceBlock.kind else { return } let prose = WalkthroughEnvelopeParser.stripFence(from: fullText) guard let envelope = WalkthroughEnvelopeParser.parse(from: fullText) else { - lastBlock.setKind(.text(prose)) + fenceBlock.setKind(.text(prose)) return } if prose.isEmpty { - messages[idx].blocks.removeLast() + messages[idx].blocks.removeAll { $0.id == fenceBlock.id } } else { - lastBlock.setKind(.text(prose)) + fenceBlock.setKind(.text(prose)) } let walkthrough = SqlWalkthroughBlock(beforeSQL: beforeSQL, envelope: envelope) messages[idx].appendBlock(.sqlWalkthrough(walkthrough)) @@ -382,7 +390,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, @@ -400,8 +412,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 d23321a2d..1bba56310 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -270,7 +270,7 @@ final class AIChatViewModel { func handleFixError(query: String, error: String) { startNewConversation() let databaseType = connection?.type ?? .mysql - let prompt = AIPromptTemplates.fixErrorWalkthrough(query: query, error: error, databaseType: databaseType) + let prompt = AIPromptTemplates.fixError(query: query, error: error, databaseType: databaseType) sendWithWalkthroughContext(prompt: prompt, beforeSQL: query) } From 2a26b3e4981f34251aa38df2fc144bc559b8573d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Jul 2026 00:37:17 +0700 Subject: [PATCH 5/9] fix(ai-chat): apply the walkthrough SQL by replacing the current query, not opening a tab --- TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index 591ce66a5..14505993d 100644 --- a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -370,7 +370,7 @@ struct AIChatWalkthroughBlockView: View { : String(localized: "Replace the editor content with the suggested SQL")) } .alert(String(localized: "Apply suggested SQL?"), isPresented: $showApplyConfirmation) { - Button(String(localized: "Apply")) { actions?.insertQueryFromAI(afterSQL) } + Button(String(localized: "Apply")) { actions?.loadQueryIntoEditor(afterSQL) } Button(String(localized: "Cancel"), role: .cancel) {} } message: { Text("This replaces the editor content with the AI-generated SQL.") From b232e8051ffc9d081ce1f6a5c1deeadb9849ad96 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Jul 2026 10:56:34 +0700 Subject: [PATCH 6/9] perf(ai-chat): cache the walkthrough diff and report every truncated row Claude-Session: https://claude.ai/code/session_01WKxR4tvL8bPU4NfsULyYNA --- TablePro/Core/Diff/SqlDiff.swift | 15 ++- .../AI/SqlWalkthroughPresentation.swift | 83 +++++++++++++ .../AIChat/AIChatWalkthroughBlockView.swift | 95 +++++++------- .../AI/SqlWalkthroughPresentationTests.swift | 116 ++++++++++++++++++ TableProTests/Core/Diff/SqlDiffTests.swift | 17 +++ 5 files changed, 267 insertions(+), 59 deletions(-) create mode 100644 TablePro/Models/AI/SqlWalkthroughPresentation.swift create mode 100644 TableProTests/Core/AI/SqlWalkthroughPresentationTests.swift diff --git a/TablePro/Core/Diff/SqlDiff.swift b/TablePro/Core/Diff/SqlDiff.swift index f637b4631..ce77b2e97 100644 --- a/TablePro/Core/Diff/SqlDiff.swift +++ b/TablePro/Core/Diff/SqlDiff.swift @@ -20,15 +20,15 @@ enum SqlNormalizer { } } -struct DiffPair: Equatable { - enum Kind { case unchanged, added, removed, changed } +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 { - enum Kind { case context, added, removed } +struct DiffUnifiedLine: Identifiable, Equatable, Sendable { + enum Kind: Sendable { case context, added, removed } let id: Int let beforeLineNumber: Int? let afterLineNumber: Int? @@ -85,7 +85,12 @@ enum DiffComputer { } static func computeUnified(before: [String], after: [String]) -> [DiffUnifiedLine] { - let pairs = computeSplit(before: before, after: after) + 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 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/Views/AIChat/AIChatWalkthroughBlockView.swift b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index 14505993d..2548096fb 100644 --- a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -20,8 +20,7 @@ struct AIChatWalkthroughBlockView: View { @State private var activeFollowUpStepID: UUID? @State private var followUpText: String = "" @State private var showApplyConfirmation = false - - private static let maxDiffLines = 500 + @State private var presentation: SqlWalkthroughPresentation? private var actions: MainContentCommandActions? { focusedActions ?? commandRegistry.current @@ -35,26 +34,28 @@ struct AIChatWalkthroughBlockView: View { 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 { - let beforeLines = SqlNormalizer.lines(walkthrough.beforeSQL) - let afterLines = walkthrough.envelope.afterSQL.map(SqlNormalizer.lines) ?? [] - GroupBox { VStack(alignment: .leading, spacing: 8) { header(for: walkthrough) Divider() - if walkthrough.hasDiff { - diffSection(for: walkthrough, beforeLines: beforeLines, afterLines: afterLines) - } else { - sourceListing(beforeLines) - } - if !walkthrough.envelope.steps.isEmpty { - Divider() - stepList(walkthrough.envelope.steps, beforeLines: beforeLines, afterLines: afterLines) + 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) @@ -96,17 +97,16 @@ struct AIChatWalkthroughBlockView: View { @ViewBuilder private func diffSection( for walkthrough: SqlWalkthroughBlock, - beforeLines: [String], - afterLines: [String] + presentation: SqlWalkthroughPresentation ) -> some View { ScrollViewReader { proxy in ScrollView { Group { switch walkthrough.diffStyle { case .unified: - unifiedDiff(before: beforeLines, after: afterLines) + unifiedDiff(presentation) case .split: - splitDiff(before: beforeLines, after: afterLines) + splitDiff(presentation) } } .padding(.vertical, 2) @@ -121,14 +121,13 @@ struct AIChatWalkthroughBlockView: View { } } - private func unifiedDiff(before: [String], after: [String]) -> some View { - let lines = Array(DiffComputer.computeUnified(before: before, after: after).prefix(Self.maxDiffLines)) - return LazyVStack(alignment: .leading, spacing: 0) { - ForEach(lines) { line in + private func unifiedDiff(_ presentation: SqlWalkthroughPresentation) -> some View { + LazyVStack(alignment: .leading, spacing: 0) { + ForEach(presentation.unifiedLines) { line in unifiedRow(line) .id(line.id) } - truncationFooter(shown: lines.count, total: max(before.count, after.count)) + truncationFooter(hidden: presentation.hiddenLines(for: .unified)) } } @@ -171,12 +170,15 @@ struct AIChatWalkthroughBlockView: View { let lineNumber: Int? } - private func splitDiff(before: [String], after: [String]) -> some View { - let pairs = Array(DiffComputer.computeSplit(before: before, after: after).prefix(Self.maxDiffLines)) - return HStack(alignment: .top, spacing: 0) { - splitColumn(rows: splitRows(pairs, side: .before), side: .before) - Divider() - splitColumn(rows: splitRows(pairs, side: .after), side: .after) + 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)) } } @@ -216,8 +218,8 @@ struct AIChatWalkthroughBlockView: View { .background(splitRowBackground(base: tint, side: side, lineNumber: row.lineNumber)) } - private func sourceListing(_ lines: [String]) -> some View { - let capped = Array(lines.prefix(Self.maxDiffLines)) + private func sourceListing(_ presentation: SqlWalkthroughPresentation) -> some View { + let capped = presentation.sourceLines return ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: 0) { @@ -236,7 +238,7 @@ struct AIChatWalkthroughBlockView: View { .padding(.vertical, 1) .background(sourceRowBackground(lineNumber: index + 1)) } - truncationFooter(shown: capped.count, total: lines.count) + truncationFooter(hidden: presentation.hiddenSourceListingLines) } } .frame(maxHeight: 220) @@ -251,12 +253,11 @@ struct AIChatWalkthroughBlockView: View { private func stepList( _ steps: [SqlWalkthroughStep], - beforeLines: [String], - afterLines: [String] + presentation: SqlWalkthroughPresentation ) -> some View { VStack(alignment: .leading, spacing: 4) { ForEach(Array(steps.enumerated()), id: \.element.id) { index, step in - stepRow(step, index: index, beforeLines: beforeLines, afterLines: afterLines) + stepRow(step, index: index, presentation: presentation) } } } @@ -264,10 +265,9 @@ struct AIChatWalkthroughBlockView: View { private func stepRow( _ step: SqlWalkthroughStep, index: Int, - beforeLines: [String], - afterLines: [String] + presentation: SqlWalkthroughPresentation ) -> some View { - let anchor = resolvedAnchor(step.anchor, beforeCount: beforeLines.count, afterCount: afterLines.count) + let anchor = presentation.resolvedAnchor(step.anchor) return DisclosureGroup(isExpanded: stepBinding(step.id)) { VStack(alignment: .leading, spacing: 6) { if !step.why.isEmpty { @@ -277,7 +277,7 @@ struct AIChatWalkthroughBlockView: View { .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } - if let anchor, let snippet = anchorSnippet(anchor, beforeLines: beforeLines, afterLines: afterLines) { + if let anchor, let snippet = presentation.anchoredSnippet(for: anchor) { anchoredSnippet(snippet) Button(String(localized: "Jump to lines")) { activateAnchor(anchor) } .buttonStyle(.link) @@ -378,9 +378,9 @@ struct AIChatWalkthroughBlockView: View { } @ViewBuilder - private func truncationFooter(shown: Int, total: Int) -> some View { - if total > shown { - Text(String(format: String(localized: "…and %d more lines"), total - shown)) + 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) @@ -414,19 +414,6 @@ struct AIChatWalkthroughBlockView: View { // MARK: - Anchor helpers - private func resolvedAnchor(_ anchor: SqlWalkthroughAnchor?, beforeCount: Int, afterCount: Int) -> SqlWalkthroughAnchor? { - guard let anchor, anchor.isValid(beforeLineCount: beforeCount, afterLineCount: afterCount) else { return nil } - return anchor - } - - private func anchorSnippet(_ anchor: SqlWalkthroughAnchor, beforeLines: [String], afterLines: [String]) -> String? { - let source = anchor.side == .after ? afterLines : beforeLines - guard anchor.startLine >= 1, anchor.endLine <= source.count else { return nil } - let slice = source[(anchor.startLine - 1).. Bool { func inRange(_ number: Int?) -> Bool { guard let number else { return false } 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/Diff/SqlDiffTests.swift b/TableProTests/Core/Diff/SqlDiffTests.swift index c2bf743c8..0f8fc93e7 100644 --- a/TableProTests/Core/Diff/SqlDiffTests.swift +++ b/TableProTests/Core/Diff/SqlDiffTests.swift @@ -66,6 +66,23 @@ struct SqlDiffTests { #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") From bfb4b83d3940be524ef54d9af1cfc27bfcb563bc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Jul 2026 10:56:48 +0700 Subject: [PATCH 7/9] fix(ai-chat): say that applying suggested SQL does not run it Claude-Session: https://claude.ai/code/session_01WKxR4tvL8bPU4NfsULyYNA --- TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index 2548096fb..24703b3fa 100644 --- a/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -367,13 +367,13 @@ struct AIChatWalkthroughBlockView: View { .disabled(actions == nil) .help(actions == nil ? String(localized: "Open a connection to apply") - : String(localized: "Replace the editor content with the suggested SQL")) + : 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 replaces the editor content with the AI-generated SQL.") + Text("This puts the AI-generated SQL into the query editor, replacing what is there. Nothing runs until you execute it.") } } From 6a62ca470fa15e55efce32ca753e8c7be10354f7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Jul 2026 10:56:48 +0700 Subject: [PATCH 8/9] fix(ai-chat): parse a walkthrough envelope that spans several text blocks Claude-Session: https://claude.ai/code/session_01WKxR4tvL8bPU4NfsULyYNA --- .../AIChatViewModel+Streaming.swift | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/TablePro/ViewModels/AIChatViewModel+Streaming.swift b/TablePro/ViewModels/AIChatViewModel+Streaming.swift index 6dccb907d..d5594efe6 100644 --- a/TablePro/ViewModels/AIChatViewModel+Streaming.swift +++ b/TablePro/ViewModels/AIChatViewModel+Streaming.swift @@ -236,23 +236,38 @@ extension AIChatViewModel { guard let beforeSQL = pendingWalkthroughBeforeSQL else { return } pendingWalkthroughBeforeSQL = nil guard let idx = messages.firstIndex(where: { $0.id == id }) else { return } - guard let fenceBlock = messages[idx].blocks.last(where: { block in + + 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 - }), case .text(let fullText) = fenceBlock.kind else { return } + }) 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) } - let prose = WalkthroughEnvelopeParser.stripFence(from: fullText) - guard let envelope = WalkthroughEnvelopeParser.parse(from: fullText) else { - fenceBlock.setKind(.text(prose)) - return - } if prose.isEmpty { - messages[idx].blocks.removeAll { $0.id == fenceBlock.id } + messages[idx].blocks.removeAll { $0.id == tail[0].id } } else { - fenceBlock.setKind(.text(prose)) + 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)) } From fdb88ae8687d943d32dde2bf40dbc52ea35bd46f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 24 Jul 2026 10:56:49 +0700 Subject: [PATCH 9/9] fix(editor): split CRLF files into lines in the conflict diff Claude-Session: https://claude.ai/code/session_01WKxR4tvL8bPU4NfsULyYNA --- TablePro/Core/Diff/FileConflictDiff.swift | 27 +++++++++ .../Views/Sidebar/FileConflictDiffSheet.swift | 4 +- .../Core/Diff/FileConflictDiffTests.swift | 55 +++++++++++++++++++ 3 files changed, 83 insertions(+), 3 deletions(-) create mode 100644 TablePro/Core/Diff/FileConflictDiff.swift create mode 100644 TableProTests/Core/Diff/FileConflictDiffTests.swift diff --git a/TablePro/Core/Diff/FileConflictDiff.swift b/TablePro/Core/Diff/FileConflictDiff.swift new file mode 100644 index 000000000..769a79d6a --- /dev/null +++ b/TablePro/Core/Diff/FileConflictDiff.swift @@ -0,0 +1,27 @@ +// +// FileConflictDiff.swift +// TablePro +// + +import Foundation + +/// Line splitting for the file-conflict sheet. This deliberately does not reuse +/// `SqlNormalizer`, which trims boundary whitespace and rewrites line endings: +/// a conflict sheet compares file bytes, so leading or trailing blank lines are a +/// real difference the user needs to see. +enum FileConflictDiff { + /// Swift treats "\r\n" as one Character, so splitting on "\n" alone leaves a + /// CRLF file as a single line. Line endings are folded before splitting, then + /// blank lines are kept. + static func lines(_ content: String) -> [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/Views/Sidebar/FileConflictDiffSheet.swift b/TablePro/Views/Sidebar/FileConflictDiffSheet.swift index 1fd8f159d..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.computeSplit(before: mineLines, after: diskLines) + FileConflictDiff.pairs(mine: mineContent, disk: diskContent) } var body: some View { 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))) + } +}