Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- AI Explain, Optimize, and Fix Error now answer with a walkthrough in the chat panel: a before/after SQL diff you can switch between unified and split, numbered steps anchored to the lines they change, and a follow-up prompt on any step. Optimize and Fix add an Apply to Editor button that asks before replacing your query. (#1945)
- Create a connection from a project folder. Pick one from the welcome screen or File > Open Project Folder..., and TablePro reads the database settings it finds in `.env` files, `wp-config.php`, `prisma/schema.prisma`, `config/database.yml`, `docker-compose.yml`, `application.properties`, `application.yml`, and `appsettings.json`. A project that uses more than one engine gets a row for each. Review what it found, pick one, and the connection form opens filled in. Nothing is saved or connected until you save it. (#1959)

### Changed
Expand Down
34 changes: 34 additions & 0 deletions TablePro/Core/AI/AIPromptTemplates+Walkthrough.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// AIPromptTemplates+Walkthrough.swift
// TablePro
//

import Foundation

extension AIPromptTemplates {
static let walkthroughSystemDirective: String = """
The user's latest message asks you to explain, optimize, or fix a SQL query. \
Reply with a short plain-language explanation, then append exactly one block in this form:

\(WalkthroughEnvelopeParser.openFence)
{
"afterSQL": "the full rewritten query, or null if you did not change it",
"steps": [
{
"title": "short label",
"why": "one sentence",
"importance": "critical | normal | context",
"changeType": "addition | removal | modification | explanation",
"anchor": { "side": "before | after | both", "startLine": 1, "endLine": 1 }
}
]
}
\(WalkthroughEnvelopeParser.closeFence)

Rules:
- Output valid JSON. Put afterSQL on a single JSON string and escape newlines as \\n.
- Do not output a diff. The app builds the diff from afterSQL.
- Anchor each step to the 1-based line numbers it refers to. Omit "anchor" for a whole-query step.
- Keep each step to one short sentence. Write nothing after the closing marker.
"""
}
6 changes: 5 additions & 1 deletion TablePro/Core/AI/AnthropicProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions TablePro/Core/AI/Chat/ChatTurn.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ enum ChatContentBlockKind: Sendable, Equatable {
case attachment(ContextItem)
case reasoning(ReasoningBlock)
case image(ChatImageInput)
case sqlWalkthrough(SqlWalkthroughBlock)
}

@MainActor @Observable
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand All @@ -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)
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions TablePro/Core/AI/Cursor/CursorProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/AI/GeminiProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
9 changes: 8 additions & 1 deletion TablePro/Core/AI/OpenAICompatibleProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
6 changes: 5 additions & 1 deletion TablePro/Core/AI/OpenAIResponsesProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down Expand Up @@ -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
}
}
Expand Down
41 changes: 41 additions & 0 deletions TablePro/Core/AI/WalkthroughEnvelopeParser.swift
Original file line number Diff line number Diff line change
@@ -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..<text.endIndex)?.lowerBound ?? text.endIndex
let raw = String(text[afterOpen..<sliceEnd])
let json = stripCodeFence(raw.trimmingCharacters(in: .whitespacesAndNewlines))
guard let data = json.data(using: .utf8),
let envelope = try? JSONDecoder().decode(SqlWalkthroughEnvelope.self, from: data)
else { return nil }
return envelope
}

static func stripFence(from text: String) -> String {
guard let openRange = text.range(of: openFence) else { return text }
return String(text[text.startIndex..<openRange.lowerBound])
.trimmingCharacters(in: .whitespacesAndNewlines)
}

private static func stripCodeFence(_ input: String) -> 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..<closingFence.lowerBound])
}
return body.trimmingCharacters(in: .whitespacesAndNewlines)
}
}
27 changes: 27 additions & 0 deletions TablePro/Core/Diff/FileConflictDiff.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading
Loading