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
2 changes: 1 addition & 1 deletion .swiftlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -480,7 +480,7 @@ custom_rules:
- '.*/(Packages|Plugins)/'
- '.*/TablePro/(Theme|Views/Settings/Appearance|Views/Results/Cells|Views/Results/Selection)/'
- '.*/TablePro/Views/Results/(DataGrid|SortableHeader|CellOverlay)[^/]*\.swift$'
- '.*/(SQLEditorView|TableProEditorTheme|HighlightedSQLTextView|SQLStatementPreview|SQLCodePreview|JSONCodeEditor|AIChatCodeBlockView|AIChatToolUseBlockView|AIChatToolResultBlockView|DDLTextView|TriggerEditorView|RewindReviewSheet|AgentProposedStatementsView|VimCursorManager|FoldPreviewView|CodeWell|ValueWell|TextValueEditor|ValueSourceEditor|TextViewerWindowController|SvgViewerContentView|CellImageWindowController|ServerOutputTextView|HexEditorContentView|PhpViewerView|FavoriteEditDialog|OptionsPaneView|SQLPreviewSheet|MaintenanceSheet|HistoryDetailPane)(\+[^/]*)?\.swift$'
- '.*/(SQLEditorView|TableProEditorTheme|HighlightedSQLTextView|SQLStatementPreview|SQLCodePreview|JSONCodeEditor|AIChatCodeBlockView|CodeBlockHighlighter|AIChatToolUseBlockView|AIChatToolResultBlockView|DDLTextView|TriggerEditorView|RewindReviewSheet|AgentProposedStatementsView|VimCursorManager|FoldPreviewView|CodeWell|ValueWell|TextValueEditor|ValueSourceEditor|TextViewerWindowController|SvgViewerContentView|CellImageWindowController|ServerOutputTextView|HexEditorContentView|PhpViewerView|FavoriteEditDialog|OptionsPaneView|SQLPreviewSheet|MaintenanceSheet|HistoryDetailPane)(\+[^/]*)?\.swift$'
- '.*/(TabWindowController|DataFileWindowController|CompareSyncWindowController|ValueViewerWindowController)(\+[^/]*)?\.swift$'
- '.*/(MainEditorContentView|GridStandInBackground|EditorTabStrip|EditorTabStripSurfaces|VersionHistoryTabView|ObjectSourceTabView|DataFileContentView|FilterPanelView|TableStructureView|TriggerDetailView|ClickHousePartsView|PrivilegeScopeOutlineView|TreeOutlineView|TreeOutlineCoordinator|TreeOutlineCellView|TreeValueStyle|TreeValueTone\+Theme|JSONRowInspectorView|JSONNodeRowView|JSONRowColors|SingleLineEditorView|FieldEditorContent|MultiLineEditorView|JsonEditorView|ImageFieldView|PhpSerializedFieldView|CompareStatusStyle|TextDiffView|FileConflictDiffSheet|AIChatWalkthroughBlockView|ERDiagramPalette|ERDiagramNodeRenderer|ERDiagramScene|ERDiagramSceneView|MagnifiableCanvasView|QueryPlanDiagramView|QueryPlanDetailPane|QueryPlanOutlineCoordinator|ResultChartCanvas|ThemePreviewCard)(\+[^/]*)?\.swift$'
excluded_match_kinds: [comment, doccomment]
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Smaller updates: about 6 MB from any of the last five releases and 21 MB from older ones, down from 34 MB.
- Several statements run together on PostgreSQL from iPhone and iPad commit one at a time, and a failure keeps the ones before it. (#3399)
- MySQL dumps write TIMESTAMP values in UTC and turn off foreign key checks while they load, as mysqldump does.
- AI chat code blocks show all their code in the editor font and colors, without line numbers.
- AI chat shows its scroll bar as set in System Settings.
- Set Value > Empty and Set EMPTY offered only on columns that can hold an empty value, not on dates, numbers or booleans.
- Clearing a date, number or boolean cell in a NOT NULL column keeps the stored value instead of saving an empty string.
- Clearing a Redis key's TTL in the grid removes its expiry.
Expand Down Expand Up @@ -118,6 +120,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Oracle view DDL losing the view's column names, and cut off after 4000 bytes without saying so.
- Trino materialized views left out of SQL exports.
- Return in Switch Connection reopening the current connection after typing another connection's name.
- AI chat stuttering while scrolling, and code blocks cutting off their last lines and catching the scroll.
- AI chat showing code and notes indented under a list item outside the list, with extra indentation.
- AI chat tables after a line of text, or without outer pipes, shown as plain text.
- AI chat not following a reply as it streams, and jumping to the end after scrolling up.
- Scroll to latest message button in AI chat never appearing.
- AI reasoning text not updating while it streams.
- Clearing a date, number or boolean field on iPhone and iPad saving an empty string, stored as `0000-00-00 00:00:00` on MySQL, instead of NULL.

### Security
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ public struct EditorTheme: Equatable {
/// Get the color from ``theme`` for the specified capture name.
/// - Parameter capture: The capture name
/// - Returns: A `NSColor`
func colorFor(_ capture: CaptureName?) -> NSColor {
public func colorFor(_ capture: CaptureName?) -> NSColor {
mapCapture(capture).color
}

Expand All @@ -133,7 +133,7 @@ public struct EditorTheme: Equatable {
/// - capture: The capture name.
/// - font: The font to add attributes to.
/// - Returns: A new font that has the correct attributes for the capture.
func fontFor(for capture: CaptureName?, from font: NSFont) -> NSFont {
public func fontFor(for capture: CaptureName?, from font: NSFont) -> NSFont {
let attributes = mapCapture(capture)
guard attributes.bold || attributes.italic else {
return font
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,11 @@ import AppKit
import SwiftTreeSitter
import TableProGrammars

extension TreeSitterClient {
static func quickHighlight(
string: String,
theme: EditorTheme,
font: NSFont,
language: CodeLanguage
) -> NSAttributedString? {
public extension TreeSitterClient {
/// The captures the editor's grammar finds in `string`, for a view that shows code it never edits and applies
/// its own attributes. Safe away from the main thread: each call makes its own parser, and the compiled queries
/// are behind a lock.
static func highlightRanges(in string: String, language: CodeLanguage) -> [HighlightRange]? {
guard let parserLanguage = language.language, let query = HighlightQueries.shared.query(for: language.id) else {
return nil
}
Expand All @@ -28,7 +26,7 @@ extension TreeSitterClient {
}
let queryCursor = query.execute(in: syntaxTree)
var ranges: [NSRange: Int] = [:]
let highlights: [HighlightRange] = queryCursor
return queryCursor
.resolve(with: .init(string: string))
.flatMap { $0.captures }
.reversed() // SwiftTreeSitter returns captures in the reverse order of what we need to filter with.
Expand All @@ -50,22 +48,34 @@ extension TreeSitterClient {

return HighlightRange(range: range, capture: captureName)
}
} catch {
return nil
}
}

let string = NSMutableAttributedString(string: string)
/// `string` with the theme's color and weight on each range the grammar captures.
static func quickHighlight(
string: String,
theme: EditorTheme,
font: NSFont,
language: CodeLanguage
) -> NSAttributedString? {
guard let highlights = highlightRanges(in: string, language: language) else {
return nil
}

for highlight in highlights {
string.setAttributes(
[
.font: theme.fontFor(for: highlight.capture, from: font),
.foregroundColor: theme.colorFor(highlight.capture)
],
range: highlight.range
)
}
let string = NSMutableAttributedString(string: string)

return string
} catch {
return nil
for highlight in highlights {
string.setAttributes(
[
.font: theme.fontFor(for: highlight.capture, from: font),
.foregroundColor: theme.colorFor(highlight.capture)
],
range: highlight.range
)
}

return string
}
}
37 changes: 37 additions & 0 deletions TablePro/Core/AI/Images/AIImageCache.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import AppKit
import Foundation
import ImageIO
import os

/// Disk-backed cache for chat-attached images. File operations are serialised
Expand All @@ -13,6 +14,12 @@ final class AIImageCache: @unchecked Sendable {

private let cacheDirectory: URL
private let queue = DispatchQueue(label: "com.TablePro.AIImageCache", qos: .utility)
/// `NSCache` is safe to use from any thread.
private let thumbnails: NSCache<NSString, NSImage> = {
let cache = NSCache<NSString, NSImage>()
cache.countLimit = 64
return cache
}()

private init() {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
Expand Down Expand Up @@ -47,6 +54,36 @@ final class AIImageCache: @unchecked Sendable {
return NSImage(data: data)
}

/// The image scaled down to `maxPixelSize` on its longer side by ImageIO, which never keeps the full-size bitmap.
/// A pasted screenshot is up to 2000 px, and drawing it into a 96 pt thumbnail decoded all of it each time its
/// transcript row came back into view.
func thumbnail(filename: String, maxPixelSize: Int) -> NSImage? {
if let cached = cachedThumbnail(filename: filename, maxPixelSize: maxPixelSize) {
return cached
}
guard let url = safeURL(for: filename),
let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
let options: [CFString: Any] = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
]
guard let image = CGImageSourceCreateThumbnailAtIndex(source, 0, options as CFDictionary) else { return nil }
let thumbnail = NSImage(cgImage: image, size: .zero)
thumbnails.setObject(thumbnail, forKey: thumbnailKey(filename: filename, maxPixelSize: maxPixelSize))
return thumbnail
}

/// A thumbnail already made, without touching the disk, so a row that scrolls back in draws it on its first frame.
func cachedThumbnail(filename: String, maxPixelSize: Int) -> NSImage? {
thumbnails.object(forKey: thumbnailKey(filename: filename, maxPixelSize: maxPixelSize))
}

private func thumbnailKey(filename: String, maxPixelSize: Int) -> NSString {
"\(maxPixelSize)/\(filename)" as NSString
}

func delete(filename: String) {
guard let url = safeURL(for: filename) else { return }
queue.sync {
Expand Down
8 changes: 7 additions & 1 deletion TablePro/ViewModels/AIChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ final class AIChatViewModel: ObservableObject {
}

@Published var messages: [ChatTurn] = []
@Published var inputText: String = ""
let composerDraft = ChatComposerDraft()
@Published var streamingState: StreamingState = .idle
@Published var errorMessage: String?
@Published var conversations: [AIConversation] = []
Expand Down Expand Up @@ -52,6 +52,12 @@ final class AIChatViewModel: ObservableObject {
@Published var queryResults: String?
var editorTarget: AssistantEditorTarget?

/// The composer's text. It lives on `composerDraft`, which publishes it apart from the conversation.
var inputText: String {
get { composerDraft.text }
set { composerDraft.text = newValue }
}

var isStreaming: Bool {
switch streamingState {
case .loading, .streaming:
Expand Down
27 changes: 27 additions & 0 deletions TablePro/ViewModels/ChatComposerDraft.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// ChatComposerDraft.swift
// TablePro
//

import Combine
import SwiftUI

/// The text in the chat composer, published on its own rather than by `AIChatViewModel`.
///
/// Everything that shows the conversation observes the view model: the transcript, the pane around it, the agent
/// session and its rail. A keystroke published there redrew all of them; published here it redraws the field and
/// the send button.
@MainActor
internal final class ChatComposerDraft: ObservableObject {
@Published internal var text: String = ""
}

/// Hands the draft's text to a view built for it, so that view is the only one a keystroke redraws.
internal struct ChatComposerDraftReader<Content: View>: View {
@ObservedObject internal var draft: ChatComposerDraft
@ViewBuilder internal let content: (Binding<String>) -> Content

internal var body: some View {
content($draft.text)
}
}
Loading
Loading