diff --git a/.swiftlint.yml b/.swiftlint.yml index 6ee1e2e8e1..2a961ebde0 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -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] diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a54fb60a5..c946c45c59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. @@ -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 diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift b/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift index 6c95adc5f2..28ba20e5e6 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/Theme/EditorTheme.swift @@ -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 } @@ -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 diff --git a/Packages/TableProEditor/Sources/TableProEditorKit/TreeSitter/TreeSitterClient+Temporary.swift b/Packages/TableProEditor/Sources/TableProEditorKit/TreeSitter/TreeSitterClient+Temporary.swift index e7d906441a..200fa61faa 100644 --- a/Packages/TableProEditor/Sources/TableProEditorKit/TreeSitter/TreeSitterClient+Temporary.swift +++ b/Packages/TableProEditor/Sources/TableProEditorKit/TreeSitter/TreeSitterClient+Temporary.swift @@ -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 } @@ -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. @@ -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 } } diff --git a/TablePro/Core/AI/Images/AIImageCache.swift b/TablePro/Core/AI/Images/AIImageCache.swift index d01cef282c..15a0888a9a 100644 --- a/TablePro/Core/AI/Images/AIImageCache.swift +++ b/TablePro/Core/AI/Images/AIImageCache.swift @@ -1,5 +1,6 @@ import AppKit import Foundation +import ImageIO import os /// Disk-backed cache for chat-attached images. File operations are serialised @@ -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 = { + let cache = NSCache() + cache.countLimit = 64 + return cache + }() private init() { let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first @@ -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 { diff --git a/TablePro/ViewModels/AIChatViewModel.swift b/TablePro/ViewModels/AIChatViewModel.swift index f34717d256..2a118460af 100644 --- a/TablePro/ViewModels/AIChatViewModel.swift +++ b/TablePro/ViewModels/AIChatViewModel.swift @@ -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] = [] @@ -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: diff --git a/TablePro/ViewModels/ChatComposerDraft.swift b/TablePro/ViewModels/ChatComposerDraft.swift new file mode 100644 index 0000000000..4fb41b44a7 --- /dev/null +++ b/TablePro/ViewModels/ChatComposerDraft.swift @@ -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: View { + @ObservedObject internal var draft: ChatComposerDraft + @ViewBuilder internal let content: (Binding) -> Content + + internal var body: some View { + content($draft.text) + } +} diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index 64ae1263f1..291f07508e 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -5,56 +5,46 @@ import AppKit import SwiftUI -import TableProEditorKit import TableProGrammars +/// A fenced code block in a chat reply: selectable text in the editor's font and colors, as tall as its code. +/// +/// It holds no scroll view of its own. The HIG asks not to nest a scroll view inside another of the same +/// orientation, and an inner one that cannot show all its code takes the transcript's scroll whenever the pointer +/// crosses it. struct AIChatCodeBlockView: View, Equatable { - @ObservedObject private var settingsManager = AppSettingsManager.shared @ObservedObject private var themeEngine = ThemeEngine.shared let code: String let language: String? - var prefersLightweightRendering: Bool = false + /// False while the block is the end of a reply still arriving. It shows plain editor text until then, so a + /// half-written block is not reparsed and recolored on every flush. + var isSettled: Bool = true static func == (lhs: AIChatCodeBlockView, rhs: AIChatCodeBlockView) -> Bool { lhs.code == rhs.code && lhs.language == rhs.language - && lhs.prefersLightweightRendering == rhs.prefersLightweightRendering + && lhs.isSettled == rhs.isSettled } @State private var isCopied: Bool = false - @State private var isEditorReady = false - @State private var editorState = SourceEditorState() - @State private var editorConfiguration = makeConfiguration() - @State private var measuredWidth: CGFloat = 0 + @State private var highlighted: (key: String, text: AttributedString)? @Environment(\.commandActions) private var actions - private var usesLightweightContent: Bool { - prefersLightweightRendering || !isEditorReady - } - var body: some View { + let resolvedLanguage = Self.resolvedLanguage(hint: language, code: code) + let codeLanguage = Self.codeLanguage(for: resolvedLanguage) GroupBox { - codeContent + codeText(language: codeLanguage) } label: { - codeBlockHeader + header(resolvedLanguage: resolvedLanguage, isInsertable: codeLanguage.id != CodeLanguage.default.id) } .groupBoxStyle(CodeBlockGroupBoxStyle()) - .task(id: prefersLightweightRendering) { - guard !prefersLightweightRendering else { - isEditorReady = false - return - } - isEditorReady = true - } - .onDisappear { - isEditorReady = false - } } - private var codeBlockHeader: some View { + private func header(resolvedLanguage: String?, isInsertable: Bool) -> some View { HStack(spacing: 8) { - if let resolved = resolvedLanguage { - Text(resolved.uppercased()) + if let resolvedLanguage { + Text(resolvedLanguage.uppercased()) .font(.caption2) .fontWeight(.medium) .foregroundStyle(.secondary) @@ -105,60 +95,52 @@ struct AIChatCodeBlockView: View, Equatable { } } - @ViewBuilder - private var codeContent: some View { - Group { - if usesLightweightContent { - // Stands in for the editor while the block streams, so it wears the editor's colors and the block - // does not change color when the editor replaces it. - Text(code.isEmpty ? " " : code) - .font(Font(Self.editorFont)) - .lineSpacing(CodeBlockHeightEstimator.extraLineSpacing(for: Self.editorFont)) - .foregroundStyle(themeEngine.palette.color(.editorText)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(8) - .frame(minHeight: CodeBlockHeightEstimator.minimumHeight, alignment: .topLeading) - .background(themeEngine.palette.color(.editorBackground)) - } else { - SourceEditor( - .constant(code), - language: treeSitterLanguage, - configuration: editorConfiguration, - state: $editorState, - foldProvider: FoldProviderResolver.provider(for: treeSitterLanguage) + private func codeText(language: CodeLanguage) -> some View { + let font = themeEngine.editorFonts.font + let key = isSettled + ? CodeBlockHighlighter.key(for: code, language: language, font: font, themeEngine: themeEngine) + : nil + let marked = key.flatMap { key in + highlighted?.key == key ? highlighted?.text : CodeBlockHighlighter.cachedCode(forKey: key) + } + return Text(code.isEmpty ? AttributedString(" ") : marked ?? AttributedString(code)) + .font(Font(font)) + .foregroundStyle(themeEngine.palette.color(.editorText)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) + .background(themeEngine.palette.color(.editorBackground)) + .task(id: key) { + guard let key, marked == nil else { return } + let text = await CodeBlockHighlighter.highlightedCode( + code, + language: language, + font: font, + key: key, + themeEngine: themeEngine ) - .frame(maxWidth: .infinity) - .frame(height: contentHeight) + highlighted = (key, text) } - } - .onGeometryChange(for: CGFloat.self) { proxy in - proxy.size.width - } action: { newWidth in - measuredWidth = newWidth - } - .onReceive(AppEvents.shared.themeChanged) { _ in - editorConfiguration = Self.makeConfiguration() - } - .onChange(of: settingsManager.editor) { _ in - editorConfiguration = Self.makeConfiguration() - } } - private var resolvedLanguage: String? { - if let language, !language.isEmpty { - return language + private static func resolvedLanguage(hint: String?, code: String) -> String? { + if let hint, !hint.isEmpty { + return hint } - return Self.detectLanguage(from: code) + return detectLanguage(from: code) } + /// Reads only up to the first line that is not blank or a comment, so a long block costs no more than a short one. nonisolated static func detectLanguage(from code: String) -> String? { - let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() - guard !trimmed.isEmpty else { return nil } - let firstNonCommentLine = trimmed - .split(whereSeparator: { $0.isNewline }) - .map { $0.trimmingCharacters(in: .whitespaces) } - .first(where: { !$0.isEmpty && !$0.hasPrefix("--") && !$0.hasPrefix("/*") }) ?? trimmed + var firstStatement: String? + code.enumerateLines { line, stop in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty, !trimmed.hasPrefix("--"), !trimmed.hasPrefix("/*") else { return } + firstStatement = trimmed.uppercased() + stop = true + } + guard let firstStatement else { return nil } let sqlPrefixes = [ "SELECT ", "INSERT ", "UPDATE ", "DELETE ", "WITH ", @@ -167,16 +149,16 @@ struct AIChatCodeBlockView: View, Equatable { "REVOKE ", "ANALYZE ", "SET ", "CALL ", "LOCK ", "MERGE ", "SHOW ", "DESCRIBE ", "DESC " ] - if sqlPrefixes.contains(where: { firstNonCommentLine.hasPrefix($0) }) { + if sqlPrefixes.contains(where: { firstStatement.hasPrefix($0) }) { return "sql" } - if firstNonCommentLine.hasPrefix("DB.") { + if firstStatement.hasPrefix("DB.") { return "javascript" } return nil } - private var treeSitterLanguage: CodeLanguage { + private static func codeLanguage(for resolvedLanguage: String?) -> CodeLanguage { switch resolvedLanguage?.lowercased() { case "sql", "mysql", "postgresql", "postgres", "sqlite": return .sql @@ -188,40 +170,6 @@ struct AIChatCodeBlockView: View, Equatable { return .default } } - - private var isInsertable: Bool { - treeSitterLanguage.id != CodeLanguage.default.id - } - - private var contentHeight: CGFloat { - CodeBlockHeightEstimator.height( - for: code, - font: Self.editorFont, - availableWidth: measuredWidth - ) - } - - private static let editorFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) - - private static func makeConfiguration() -> SourceEditorConfiguration { - SourceEditorConfiguration( - appearance: .init( - theme: TableProEditorTheme.make(), - font: editorFont, - lineHeightMultiple: Double(CodeBlockHeightEstimator.lineHeightMultiple), - wrapLines: true - ), - behavior: .init( - isEditable: false - ), - layout: .init( - contentInsets: NSEdgeInsets(top: 8, left: 8, bottom: 8, right: 8) - ), - peripherals: EditorPeripherals.preview( - folding: AppSettingsManager.shared.editor.codeFoldingEnabled - ) - ) - } } private struct CodeBlockGroupBoxStyle: GroupBoxStyle { diff --git a/TablePro/Views/AIChat/AIChatMessageSpacing.swift b/TablePro/Views/AIChat/AIChatMessageSpacing.swift deleted file mode 100644 index 6baf3bc785..0000000000 --- a/TablePro/Views/AIChat/AIChatMessageSpacing.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// AIChatMessageSpacing.swift -// TablePro -// - -import Foundation - -@MainActor -enum AIChatMessageSpacing { - static func spacedMessageIDs(for messages: [ChatTurn]) -> Set { - var ids: Set = [] - for (previous, current) in zip(messages, messages.dropFirst()) - where current.role == .user && previous.role == .assistant { - ids.insert(current.id) - } - return ids - } -} diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index 4df78996f1..d8ae75da4b 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -24,11 +24,6 @@ struct AIChatPanelView: View { var contentWidth: ChatContentWidth = .pane @ObservedObject private var settingsManager = AppSettingsManager.shared @ObservedObject private var copilotService = CopilotService.shared - @State private var bottomVisibleMessageID: UUID? - @State private var pinnedToBottom: Bool = true - @State private var scrollToBottomRequest: UUID? - - private static let bottomAnchorID = "chat.bottom.anchor" @StateObject private var mentionState = MentionPopoverState() private var hasConfiguredProvider: Bool { @@ -54,7 +49,7 @@ struct AIChatPanelView: View { } else if viewModel.messages.isEmpty { emptyState } else { - messageList + AIChatTranscriptView(viewModel: viewModel, contentWidth: contentWidth) } if hasConfiguredProvider { @@ -150,114 +145,6 @@ struct AIChatPanelView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - // MARK: - Message List - - private var messageList: some View { - let visibleMessages = viewModel.messages.filter { isVisibleInMessageList($0) } - let spacedMessageIDs = AIChatMessageSpacing.spacedMessageIDs(for: visibleMessages) - - let lastMessageID = visibleMessages.last?.id - let isUserScrolledUp = !pinnedToBottom && bottomVisibleMessageID != nil - && bottomVisibleMessageID != lastMessageID - - return ZStack(alignment: .bottom) { - ScrollViewReader { proxy in - GeometryReader { viewport in - ScrollView { - LazyVStack(spacing: 0) { - ForEach(visibleMessages) { message in - if spacedMessageIDs.contains(message.id) { - Spacer() - .frame(height: 16) - } - AIChatMessageView( - message: message, - onRetry: shouldShowRetry(for: message) ? { viewModel.retry() } : nil, - onRegenerate: shouldShowRegenerate(for: message) ? { viewModel.regenerate() } : nil, - onEdit: message.role == .user && !viewModel.isStreaming - ? { viewModel.editMessage(message) } : nil, - onContinue: shouldShowContinue(for: message) - ? { viewModel.continueToolLoop() } : nil, - onAdjustToolLimit: shouldShowContinue(for: message) - ? { WindowOpener.shared.openSettings(tab: .ai) } : nil, - pausedToolCallCount: shouldShowContinue(for: message) - ? viewModel.toolLimitPauseCount : nil - ) - .equatable() - .padding(.vertical, 4) - .id(message.id) - } - /// The bottom sentinel. `scrollPosition(id:anchor:)` reported which row sat at - /// the bottom edge, which is macOS 14; measuring the last row against the - /// viewport answers the only question that read was asked: is the reader at the - /// end, or have they scrolled up. - Color.clear - .frame(height: 1) - .id(Self.bottomAnchorID) - .background( - GeometryReader { row in - Color.clear.preference( - key: ChatAtBottomKey.self, - value: row.frame(in: .global).maxY - <= viewport.frame(in: .global).maxY + 24 - ) - } - ) - } - .chatColumn(contentWidth) - .padding(.horizontal, 8) - .padding(.vertical, 8) - } - .scrollIndicators(.hidden) - .onPreferenceChange(ChatAtBottomKey.self) { atBottom in - pinnedToBottom = atBottom - bottomVisibleMessageID = atBottom ? lastMessageID : visibleMessages.dropLast().last?.id - } - .modifier(ScrollsToBottomOnFirstAppearance { - proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) - }) - .onChange(of: visibleMessages.count) { _ in - if pinnedToBottom { - proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) - } - } - .onChange(of: viewModel.activeConversationID) { _ in - pinnedToBottom = true - proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) - } - .onChange(of: viewModel.isStreaming) { newValue in - if !newValue, pinnedToBottom { - proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) - } - } - .onChange(of: scrollToBottomRequest) { _ in - proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) - } - .environmentObject(viewModel) - } - } - - if isUserScrolledUp { - Button { - pinnedToBottom = true - withMotion(.easeOut(duration: 0.2)) { - scrollToBottomRequest = UUID() - } - } label: { - Image(systemName: "arrow.down.circle.fill") - .font(.title2) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - .padding(.bottom, 8) - .transition(.opacity) - .motionAnimation(.easeInOut(duration: 0.2), value: isUserScrolledUp) - .accessibilityLabel(String(localized: "Scroll to latest message")) - } - } - } - // MARK: - Error Banner private func errorBanner(_ message: String) -> some View { @@ -300,36 +187,11 @@ struct AIChatPanelView: View { composerImageChipStrip } - ChatComposerView( - text: $viewModel.inputText, - placeholder: String(localized: "Ask about your database…"), - minLines: 1, - maxLines: 5, - mentionState: mentionState, - onTextChange: { text, caret in - updateMentionState(text: text, caret: caret) - }, - onSubmit: { - updateContext() - viewModel.sendMessage() - }, - onAttach: { item in - viewModel.attach(item) - }, - acceptsImages: viewModel.activeProviderSupportsImages, - onAttachImages: { images in - for image in images { - viewModel.attachImage(image) - } - }, - onImageAttachmentFailed: { message in - viewModel.reportImageAttachmentFailure(message) - }, - highlightEnabled: settingsManager.ai.composerHighlightEnabled, - onToggleHighlight: { - settingsManager.ai.composerHighlightEnabled.toggle() - } - ) + let acceptsImages = viewModel.activeProviderSupportsImages + let highlightEnabled = settingsManager.ai.composerHighlightEnabled + ChatComposerDraftReader(draft: viewModel.composerDraft) { text in + composer(text: text, acceptsImages: acceptsImages, highlightEnabled: highlightEnabled) + } HStack(alignment: .center, spacing: 8) { mentionMenu @@ -347,6 +209,40 @@ struct AIChatPanelView: View { } } + /// Built by the draft reader on each keystroke, so it takes what it shows rather than looking it up again. + private func composer(text: Binding, acceptsImages: Bool, highlightEnabled: Bool) -> some View { + ChatComposerView( + text: text, + placeholder: String(localized: "Ask about your database…"), + minLines: 1, + maxLines: 5, + mentionState: mentionState, + onTextChange: { text, caret in + updateMentionState(text: text, caret: caret) + }, + onSubmit: { + updateContext() + viewModel.sendMessage() + }, + onAttach: { item in + viewModel.attach(item) + }, + acceptsImages: acceptsImages, + onAttachImages: { images in + for image in images { + viewModel.attachImage(image) + } + }, + onImageAttachmentFailed: { message in + viewModel.reportImageAttachmentFailure(message) + }, + highlightEnabled: highlightEnabled, + onToggleHighlight: { + settingsManager.ai.composerHighlightEnabled.toggle() + } + ) + } + private var composerImageChipStrip: some View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 8) { @@ -406,18 +302,20 @@ struct AIChatPanelView: View { .help(String(localized: "Stop Generating")) .accessibilityLabel(String(localized: "Stop Generating")) } else { - let isEmpty = viewModel.inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - Button { - updateContext() - viewModel.sendMessage() - } label: { - Image(systemName: "arrow.up.circle.fill") - .foregroundStyle(isEmpty ? .secondary : Color.accentColor) + ChatComposerDraftReader(draft: viewModel.composerDraft) { text in + let isEmpty = text.wrappedValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + Button { + updateContext() + viewModel.sendMessage() + } label: { + Image(systemName: "arrow.up.circle.fill") + .foregroundStyle(isEmpty ? .secondary : Color.accentColor) + } + .buttonStyle(.plain) + .disabled(isEmpty) + .help(String(localized: "Send Message")) + .accessibilityLabel(String(localized: "Send Message")) } - .buttonStyle(.plain) - .disabled(isEmpty) - .help(String(localized: "Send Message")) - .accessibilityLabel(String(localized: "Send Message")) } } @@ -622,22 +520,6 @@ struct AIChatPanelView: View { viewModel.editorTarget = live.map(\.target) ?? editorTarget } - /// Hide system turns and user turns that exist only to carry tool-result - /// blocks back to the model: those are protocol plumbing, not user input. - private func isVisibleInMessageList(_ message: ChatTurn) -> Bool { - guard message.role != .system else { return false } - if message.role == .user { - let hasUserContent = message.blocks.contains { block in - switch block.kind { - case .text(let value): return !value.isEmpty - case .attachment, .image: return true - case .toolUse, .toolResult, .reasoning, .sqlWalkthrough: return false - } - } - if !hasUserContent { return false } - } - return true - } private func updateMentionState(text: String, caret: Int) { guard let match = MentionDetector.detect(in: text, caret: caret) else { @@ -714,53 +596,6 @@ struct AIChatPanelView: View { private func matchesQuery(_ label: String, _ query: String) -> Bool { query.isEmpty || label.localizedCaseInsensitiveContains(query) } - - private func shouldShowRetry(for message: ChatTurn) -> Bool { - message.role == .user - && message.id == viewModel.messages.last?.id - && viewModel.lastMessageFailed - && viewModel.canRetryLastFailure - } - - private func shouldShowContinue(for message: ChatTurn) -> Bool { - message.role == .assistant - && viewModel.isPausedAtToolLimit - && message.id == viewModel.messages.last(where: { $0.role == .assistant })?.id - } - - private func shouldShowRegenerate(for message: ChatTurn) -> Bool { - message.role == .assistant - && message.id == viewModel.messages.last?.id - && !viewModel.isStreaming - && !message.plainText.isEmpty - } -} - -/// Whether the conversation's last row is inside the viewport. Replaces reading -/// `scrollPosition(id:anchor:)`, which is macOS 14. -private struct ChatAtBottomKey: PreferenceKey { - static let defaultValue = true - - static func reduce(value: inout Bool, nextValue: () -> Bool) { - value = nextValue() - } -} - -/// Re-adding the pane to the window fires `onAppear` again while the scroll view keeps its offset, -/// so scrolling on every appearance threw a reader who had scrolled up back to the bottom. The flag -/// lives with the scroll view, so a transcript rebuilt after the empty state still opens at the end. -private struct ScrollsToBottomOnFirstAppearance: ViewModifier { - let scrollToBottom: () -> Void - - @State private var hasScrolled = false - - func body(content: Content) -> some View { - content.onAppear { - guard !hasScrolled else { return } - hasScrolled = true - scrollToBottom() - } - } } /// Copilot lists no models until its server runs, and it starts on first use rather than with the diff --git a/TablePro/Views/AIChat/AIChatReasoningBlockView.swift b/TablePro/Views/AIChat/AIChatReasoningBlockView.swift index dd6955f900..cf1e9f8d52 100644 --- a/TablePro/Views/AIChat/AIChatReasoningBlockView.swift +++ b/TablePro/Views/AIChat/AIChatReasoningBlockView.swift @@ -5,16 +5,14 @@ import SwiftUI +/// Reasoning text arrives through the same 50 ms flush as the reply, which already coalesces it, so it is shown as it +/// comes. A debounce on top of the flush, longer than the flush, restarted on every one and never fired. struct AIChatReasoningBlockView: View { let block: ReasoningBlock let isStreaming: Bool - @State private var displayedText: String = "" - @State private var debounceTask: Task? @State private var manualExpanded: Bool? - private static let debounceInterval: Duration = .milliseconds(80) - private var isExpanded: Bool { manualExpanded ?? isStreaming } @@ -28,8 +26,8 @@ struct AIChatReasoningBlockView: View { var body: some View { DisclosureGroup(isExpanded: expansionBinding) { - if !displayedText.isEmpty { - Text(displayedText) + if let text = block.text, !text.isEmpty { + Text(text) .font(.callout) .foregroundStyle(.secondary) .textSelection(.enabled) @@ -44,24 +42,5 @@ struct AIChatReasoningBlockView: View { .font(.caption) .foregroundStyle(.secondary) } - .onAppear { - displayedText = block.text ?? "" - } - .onChange(of: block.text ?? "") { newValue in - scheduleUpdate(to: newValue) - } - .onDisappear { - debounceTask?.cancel() - debounceTask = nil - } - } - - private func scheduleUpdate(to newValue: String) { - debounceTask?.cancel() - debounceTask = Task { @MainActor in - try? await Task.sleep(for: Self.debounceInterval) - guard !Task.isCancelled else { return } - displayedText = newValue - } } } diff --git a/TablePro/Views/AIChat/AIChatTranscriptView.swift b/TablePro/Views/AIChat/AIChatTranscriptView.swift new file mode 100644 index 0000000000..b8bdbaef60 --- /dev/null +++ b/TablePro/Views/AIChat/AIChatTranscriptView.swift @@ -0,0 +1,294 @@ +// +// AIChatTranscriptView.swift +// TablePro +// + +import SwiftUI + +/// The conversation in one scroll view, newest at the bottom, following the reply while the reader is at the end. +struct AIChatTranscriptView: View { + @ObservedObject var viewModel: AIChatViewModel + let contentWidth: ChatContentWidth + + @State private var tracker = ChatScrollTracker() + @State private var isPinned = true + @State private var scrollToBottomRequest = 0 + @State private var viewportHeight: CGFloat = 0 + + private static let bottomAnchorID = "chat.bottom.anchor" + private static let viewportSpace = "chat.viewport" + private static let contentSpace = "chat.content" + /// How close to the end still counts as at the end, so a reader a few points short is still followed. + private static let bottomTolerance: CGFloat = 24 + + var body: some View { + let rows = ChatTranscriptRow.rows(for: viewModel.messages) + ZStack(alignment: .bottom) { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + LazyVStack(spacing: 0) { + ForEach(rows) { row in + messageView(for: row.message) + .padding(.top, row.followsReply ? 20 : 4) + .padding(.bottom, 4) + .id(row.message.id) + } + } + bottomEdge(scrollToBottom: { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) }) + } + .coordinateSpace(name: Self.contentSpace) + .chatColumn(contentWidth) + .padding(.horizontal, 8) + .padding(.vertical, 8) + } + .coordinateSpace(name: Self.viewportSpace) + .onGeometryChange(for: CGFloat.self) { geometry in + geometry.size.height + } action: { height in + viewportHeight = height + } + .modifier(FollowsGrowthAtBottom { + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + }) + .onChange(of: viewModel.activeConversationID) { _ in + pin() + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + .onChange(of: rows.last(where: { $0.message.role == .user })?.message.id) { _ in + pin() + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + .onChange(of: scrollToBottomRequest) { _ in + withMotion(.easeOut(duration: 0.2)) { + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + } + } + + /// Its own container carries the animation, so the button fades without animating the transcript's + /// layout when the two change together. + ZStack { + if !isPinned { + Button { + pin() + scrollToBottomRequest &+= 1 + } label: { + Image(systemName: "arrow.down") + .fontWeight(.semibold) + } + .controlSize(.large) + .modifier(FloatingButtonStyle()) + .help(String(localized: "Scroll to latest message")) + .padding(.bottom, 8) + .transition(.opacity) + .accessibilityLabel(String(localized: "Scroll to latest message")) + } + } + .motionAnimation(.easeInOut(duration: 0.2), value: isPinned) + } + .environmentObject(viewModel) + .task(id: viewModel.activeConversationID) { + await MarkdownDocumentCache.shared.prewarm(Self.settledMarkdown(in: viewModel.messages)) + } + } + + /// The text every row will parse, so opening a long conversation parses it away from the main actor before the + /// reader scrolls back through it. + private static func settledMarkdown(in messages: [ChatTurn]) -> [String] { + messages.reversed().flatMap { message -> [String] in + if message.role == .user { + return [message.plainText] + } + return message.blocks.compactMap { block in + guard !block.isStreaming, case .text(let text) = block.kind, !text.isEmpty else { return nil } + return text + } + } + } + + /// The end of the transcript, outside the lazy stack so it is always built. Its geometry says whether the reader + /// is at the end and whether the content grew, and changes only when one of those does, not on every scrolled + /// frame. + private func bottomEdge(scrollToBottom: @escaping () -> Void) -> some View { + let viewportHeight = viewportHeight + return Color.clear + .frame(height: 1) + .id(Self.bottomAnchorID) + .onGeometryChange(for: ChatScrollEdge.self) { geometry in + ChatScrollEdge( + contentHeight: geometry.frame(in: .named(Self.contentSpace)).minY.rounded(), + isAtBottom: geometry.frame(in: .named(Self.viewportSpace)).minY + <= viewportHeight + Self.bottomTolerance + ) + } action: { edge in + let followsGrowth = tracker.pinning.update(with: edge) + if isPinned != tracker.pinning.isPinned { + isPinned = tracker.pinning.isPinned + } + guard followsGrowth else { return } + if #unavailable(macOS 14) { + scrollToBottom() + } + } + } + + private func pin() { + tracker.pinning.pin() + isPinned = true + } + + private func messageView(for message: ChatTurn) -> some View { + let isPausedHere = shouldShowContinue(for: message) + return AIChatMessageView( + message: message, + onRetry: shouldShowRetry(for: message) ? { viewModel.retry() } : nil, + onRegenerate: shouldShowRegenerate(for: message) ? { viewModel.regenerate() } : nil, + onEdit: message.role == .user && !viewModel.isStreaming ? { viewModel.editMessage(message) } : nil, + onContinue: isPausedHere ? { viewModel.continueToolLoop() } : nil, + onAdjustToolLimit: isPausedHere ? { WindowOpener.shared.openSettings(tab: .ai) } : nil, + pausedToolCallCount: isPausedHere ? viewModel.toolLimitPauseCount : nil + ) + .equatable() + } + + private func shouldShowRetry(for message: ChatTurn) -> Bool { + message.role == .user + && message.id == viewModel.messages.last?.id + && viewModel.lastMessageFailed + && viewModel.canRetryLastFailure + } + + private func shouldShowContinue(for message: ChatTurn) -> Bool { + message.role == .assistant + && viewModel.isPausedAtToolLimit + && message.id == viewModel.messages.last(where: { $0.role == .assistant })?.id + } + + private func shouldShowRegenerate(for message: ChatTurn) -> Bool { + message.role == .assistant + && message.id == viewModel.messages.last?.id + && !viewModel.isStreaming + && !message.plainText.isEmpty + } +} + +/// One visible turn of the transcript. +struct ChatTranscriptRow: Identifiable { + let message: ChatTurn + /// A question that follows a reply opens a new exchange, and gets more room above it. + let followsReply: Bool + + var id: UUID { message.id } + + /// System turns, and user turns that only carry tool results back to the model, are protocol plumbing rather + /// than anything the user wrote, so they are left out. + @MainActor + static func rows(for messages: [ChatTurn]) -> [ChatTranscriptRow] { + var rows: [ChatTranscriptRow] = [] + rows.reserveCapacity(messages.count) + for message in messages where isVisible(message) { + let followsReply = message.role == .user && rows.last?.message.role == .assistant + rows.append(ChatTranscriptRow(message: message, followsReply: followsReply)) + } + return rows + } + + @MainActor + private static func isVisible(_ message: ChatTurn) -> Bool { + guard message.role != .system else { return false } + guard message.role == .user else { return true } + return message.blocks.contains { block in + switch block.kind { + case .text(let value): return !value.isEmpty + case .attachment, .image: return true + case .toolUse, .toolResult, .reasoning, .sqlWalkthrough: return false + } + } + } +} + +/// Where the transcript's end sits, as its bottom edge reports it. +struct ChatScrollEdge: Equatable, Sendable { + /// The edge's offset inside the content, which grows when the content does. + let contentHeight: CGFloat + let isAtBottom: Bool +} + +/// Whether the transcript follows its end. +/// +/// The reader is followed while at the end and left alone once they scroll up. Growth is told apart from scrolling +/// by the content's height: content that grows while the reader was at the end pushes the end out of view for a +/// moment, and that must not read as the reader leaving it. +struct ChatScrollPinning: Equatable { + private(set) var isPinned = true + private var contentHeight: CGFloat? + + /// - Returns: Whether the content grew while the reader was at the end, so the view has to follow it. + mutating func update(with edge: ChatScrollEdge) -> Bool { + defer { contentHeight = edge.contentHeight } + /// The first report can come before the transcript has opened at its end. + guard let contentHeight else { return false } + if isPinned, edge.contentHeight > contentHeight { + return true + } + isPinned = edge.isAtBottom + return false + } + + mutating func pin() { + isPinned = true + } +} + +/// Holds the pin state where SwiftUI does not watch it. The content height moves on every streamed flush, and only a +/// change of `isPinned`, which the view copies into its own state, is worth redrawing the transcript for. +private final class ChatScrollTracker { + var pinning = ChatScrollPinning() +} + +/// A control floating over the transcript, so it has to read over code and tables as well as over prose. macOS 26 +/// draws such a control in glass; before that a bordered button carries its own opaque fill, round from macOS 14. +private struct FloatingButtonStyle: ViewModifier { + func body(content: Content) -> some View { + if #available(macOS 26, *) { + content + .buttonStyle(.glass) + .buttonBorderShape(.circle) + } else if #available(macOS 14, *) { + content + .buttonStyle(.bordered) + .buttonBorderShape(.circle) + } else { + content.buttonStyle(.bordered) + } + } +} + +/// The scroll anchor opens the transcript at its end and keeps it there as content grows, leaving a reader who +/// scrolled up where they are. macOS 15 applies it to those two roles alone, so a short conversation still starts at +/// the top; macOS 14 has one anchor for every role. macOS 13 has none, so the transcript opens at the end once and +/// follows growth through `ChatScrollPinning`. +private struct FollowsGrowthAtBottom: ViewModifier { + let scrollToBottom: () -> Void + + @State private var hasScrolled = false + + func body(content: Content) -> some View { + if #available(macOS 15, *) { + content + .defaultScrollAnchor(.bottom, for: .initialOffset) + .defaultScrollAnchor(.bottom, for: .sizeChanges) + } else if #available(macOS 14, *) { + content.defaultScrollAnchor(.bottom) + } else { + /// Re-adding the pane fires `onAppear` again while the scroll view keeps its offset, so it scrolls on the + /// first appearance only, or a reader who had scrolled up is thrown back to the end. + content.onAppear { + guard !hasScrolled else { return } + hasScrolled = true + scrollToBottom() + } + } + } +} diff --git a/TablePro/Views/AIChat/ChatImageThumbnailView.swift b/TablePro/Views/AIChat/ChatImageThumbnailView.swift index a1ecb03e49..fffab4273f 100644 --- a/TablePro/Views/AIChat/ChatImageThumbnailView.swift +++ b/TablePro/Views/AIChat/ChatImageThumbnailView.swift @@ -9,15 +9,28 @@ import SwiftUI struct ChatImageThumbnailView: View { let input: ChatImageInput + /// The largest frame a thumbnail is drawn in, in points. + private static let maximumPointSize: CGFloat = 96 + + @Environment(\.displayScale) private var displayScale + @State private var thumbnail: NSImage? + var body: some View { switch input.source { case .cacheFile(let filename, _): - if let nsImage = AIImageCache.shared.loadImage(filename: filename) { - Image(nsImage: nsImage) - .resizable() - .scaledToFill() - } else { - placeholder + let maxPixelSize = Int(Self.maximumPointSize * displayScale) + Group { + if let thumbnail = thumbnail + ?? AIImageCache.shared.cachedThumbnail(filename: filename, maxPixelSize: maxPixelSize) { + Image(nsImage: thumbnail) + .resizable() + .scaledToFill() + } else { + placeholder + } + } + .task(id: filename) { + thumbnail = await Self.loadThumbnail(filename: filename, maxPixelSize: maxPixelSize) } case .remoteURL(let url, _): AsyncImage(url: url) { phase in @@ -35,6 +48,12 @@ struct ChatImageThumbnailView: View { } } + /// Decoding happens off the main actor; a row that scrolls back in finds the thumbnail in the cache. + @concurrent + nonisolated private static func loadThumbnail(filename: String, maxPixelSize: Int) async -> NSImage? { + AIImageCache.shared.thumbnail(filename: filename, maxPixelSize: maxPixelSize) + } + private var placeholder: some View { Image(systemName: "photo") .foregroundStyle(.secondary) diff --git a/TablePro/Views/AIChat/CodeBlockHeightEstimator.swift b/TablePro/Views/AIChat/CodeBlockHeightEstimator.swift deleted file mode 100644 index ab286bb844..0000000000 --- a/TablePro/Views/AIChat/CodeBlockHeightEstimator.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// CodeBlockHeightEstimator.swift -// TablePro -// - -import AppKit - -enum CodeBlockHeightEstimator { - static let minimumHeight: CGFloat = 32 - static let maximumHeight: CGFloat = 400 - static let lineHeightMultiple: CGFloat = 1.2 - private static let verticalInsets: CGFloat = 18 - private static let horizontalInsets: CGFloat = 16 - - static func height(for code: String, font: NSFont, availableWidth: CGFloat) -> CGFloat { - guard availableWidth > 0 else { return fallbackHeight(for: code, font: font) } - let textWidth = max(availableWidth - horizontalInsets, 1) - let measured = (code as NSString).boundingRect( - with: CGSize(width: textWidth, height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: [.font: font] - ).height - return clamped(ceil(measured * pitchScale(for: font)) + verticalInsets) - } - - static func fallbackHeight(for code: String, font: NSFont) -> CGFloat { - let lineCount = (code as NSString).components(separatedBy: "\n").count - return clamped(CGFloat(lineCount) * editorLineHeight(for: font) + verticalInsets) - } - - static func editorLineHeight(for font: NSFont) -> CGFloat { - (font.ascender - font.descender + font.leading) * lineHeightMultiple - } - - static func extraLineSpacing(for font: NSFont) -> CGFloat { - max(editorLineHeight(for: font) - layoutLineHeight(for: font), 0) - } - - private static func layoutLineHeight(for font: NSFont) -> CGFloat { - NSLayoutManager().defaultLineHeight(for: font) - } - - private static func pitchScale(for font: NSFont) -> CGFloat { - let layoutHeight = layoutLineHeight(for: font) - guard layoutHeight > 0 else { return lineHeightMultiple } - return max(editorLineHeight(for: font) / layoutHeight, 1) - } - - private static func clamped(_ height: CGFloat) -> CGFloat { - min(max(height, minimumHeight), maximumHeight) - } -} diff --git a/TablePro/Views/AIChat/CodeBlockHighlighter.swift b/TablePro/Views/AIChat/CodeBlockHighlighter.swift new file mode 100644 index 0000000000..1a18c37f18 --- /dev/null +++ b/TablePro/Views/AIChat/CodeBlockHighlighter.swift @@ -0,0 +1,148 @@ +// +// CodeBlockHighlighter.swift +// TablePro +// + +import AppKit +import SwiftUI +import TableProEditorKit +import TableProGrammars +import TableProTextEngine + +/// Syntax colors for a chat code block from the editor's own grammar and theme, with no editor behind them. +/// +/// A code block used to embed a whole `SourceEditor`, which a transcript row built each time it scrolled in. The +/// block only ever shows code, so it needs the editor's colors, not the editor. The colors are worked out away from +/// the main actor, once per block and theme, and the block shows its plain text until they arrive. Color and weight +/// do not move a monospaced line, so the row keeps its height unless the code holds an invisible character to mark. +@MainActor +internal enum CodeBlockHighlighter { + /// Past this many UTF-16 units only the lines before it are colored, as everywhere the app highlights a string + /// that can hold a whole dump on one line. + nonisolated internal static let maximumHighlightedLength = 10_000 + + private final class Entry { + let text: AttributedString + + init(_ text: AttributedString) { + self.text = text + } + } + + private static let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 256 + cache.totalCostLimit = 8 * 1_024 * 1_024 + return cache + }() + + /// What a block's colors depend on, or nil when there is no code. The theme change is part of it because the + /// colors are the theme's objects of the moment: SwiftUI holds a `Color(nsColor:)` by identity, and a theme or + /// appearance change has to produce new ones. + internal static func key( + for code: String, + language: CodeLanguage, + font: NSFont, + themeEngine: ThemeEngine = .shared + ) -> String? { + guard !code.isEmpty else { return nil } + let change = themeEngine.change + return "\(change.revision):\(change.generation):\(font.fontName):\(font.pointSize):\(language.id)\n\(code)" + } + + internal static func cachedCode(forKey key: String) -> AttributedString? { + cache.object(forKey: key as NSString)?.text + } + + internal static func highlightedCode( + _ code: String, + language: CodeLanguage, + font: NSFont, + key: String, + themeEngine: ThemeEngine = .shared + ) async -> AttributedString { + if let cached = cachedCode(forKey: key) { + return cached + } + let markup = await markup(of: code, language: language) + let text = attributed(code, markup: markup, font: font, theme: themeEngine.makeEditorTheme()) + cache.setObject(Entry(text), forKey: key as NSString, cost: (key as NSString).length) + return text + } + + /// Captures and colors in one call, against a theme the caller chose. + internal static func highlight( + _ code: String, + language: CodeLanguage, + font: NSFont, + theme: EditorTheme + ) async -> AttributedString { + attributed(code, markup: await markup(of: code, language: language), font: font, theme: theme) + } + + /// What the code holds beyond its characters: the grammar's captures, and the invisible characters a reader has + /// to see before running it, as the editor marks them. Found away from the main actor, since both grow with the + /// code. + internal struct Markup: Sendable { + let highlights: [HighlightRange] + let specialCharacters: [ClassifiedSpecialCharacter] + } + + @concurrent + nonisolated private static func markup(of code: String, language: CodeLanguage) async -> Markup { + let nsCode = code as NSString + let highlightedLength = nsCode.length > maximumHighlightedLength + ? nsCode.lineRange(for: NSRange(location: maximumHighlightedLength, length: 0)).location + : nsCode.length + var highlights: [HighlightRange] = [] + if highlightedLength > 0, language.id != CodeLanguage.default.id { + let source = highlightedLength == nsCode.length ? code : nsCode.substring(to: highlightedLength) + highlights = TreeSitterClient.highlightRanges(in: source, language: language) ?? [] + } + var specialCharacters: [ClassifiedSpecialCharacter] = [] + var index = 0 + while index < nsCode.length { + if let classified = SpecialCharacter.classify(in: nsCode, at: index) { + specialCharacters.append(classified) + index = NSMaxRange(classified.range) + } else { + index += 1 + } + } + return Markup(highlights: highlights, specialCharacters: specialCharacters) + } + + /// The colors and bold weights the editor gives each capture, as SwiftUI attributes so `Text` draws them on + /// every macOS version, with invisible characters marked the way error messages mark them. Text no capture covers + /// takes the base color the caller sets. + internal static func attributed( + _ code: String, + markup: Markup, + font: NSFont, + theme: EditorTheme + ) -> AttributedString { + var result = AttributedString(code) + for highlight in markup.highlights { + guard let runRange = Range(highlight.range, in: result) else { continue } + result[runRange].foregroundColor = Color(nsColor: theme.colorFor(highlight.capture)) + let runFont = theme.fontFor(for: highlight.capture, from: font) + if runFont != font { + result[runRange].font = Font(runFont) + } + } + /// From the end, so each replacement leaves the ranges before it where they were. + for special in markup.specialCharacters.reversed() { + guard let range = Range(special.range, in: result) else { continue } + switch special.character { + case .marker(let label): + var mark = AttributedString("<\(label)>") + mark.foregroundColor = RevealedText.markColor + mark.backgroundColor = RevealedText.markBackground + result.replaceSubrange(range, with: mark) + case .blankSpace: + result[range].backgroundColor = RevealedText.blankSpaceBackground + } + } + return result + } +} diff --git a/TablePro/Views/AIChat/MarkdownDocument.swift b/TablePro/Views/AIChat/MarkdownDocument.swift new file mode 100644 index 0000000000..1317b23b12 --- /dev/null +++ b/TablePro/Views/AIChat/MarkdownDocument.swift @@ -0,0 +1,364 @@ +// +// MarkdownDocument.swift +// TablePro +// + +import Combine +import Foundation +import os + +/// A reply's block structure, as Foundation's CommonMark and GitHub-flavored parser reads it. +internal struct MarkdownDocument: Equatable, Sendable { + internal let blocks: [MarkdownBlock] +} + +internal struct MarkdownBlock: Identifiable, Equatable, Sendable { + /// The block's position among its siblings. A reply only grows at its end, so the blocks before the last keep + /// their identity while it streams. + internal let id: Int + internal let kind: Kind + + internal enum Kind: Equatable, Sendable { + case paragraph(AttributedString) + case header(level: Int, text: AttributedString) + case codeBlock(code: String, language: String?) + case list(MarkdownList) + case blockquote([MarkdownBlock]) + case table(MarkdownTable) + case thematicBreak + } +} + +internal struct MarkdownList: Equatable, Sendable { + /// The first item's number for an ordered list, nil for a bulleted one. + internal let start: Int? + internal let items: [MarkdownListItem] +} + +internal struct MarkdownListItem: Identifiable, Equatable, Sendable { + internal let id: Int + internal let blocks: [MarkdownBlock] +} + +internal struct MarkdownTable: Equatable, Sendable { + internal let alignments: [MarkdownTableAlignment] + internal let header: [AttributedString] + internal let rows: [[AttributedString]] +} + +internal enum MarkdownTableAlignment: Equatable, Sendable { + case left + case center + case right +} + +// MARK: - Parser + +/// Turns markdown into a `MarkdownDocument` through `AttributedString(markdown:)` with full syntax, whose runs carry +/// each block's `PresentationIntent`. Runs of one block are contiguous and share the block's identity, so the tree is +/// rebuilt by walking every run's intent from the outermost component in. +internal enum MarkdownDocumentParser { + private static let logger = Logger(subsystem: "com.TablePro", category: "MarkdownDocumentParser") + + private static let options = AttributedString.MarkdownParsingOptions( + interpretedSyntax: .full, + failurePolicy: .returnPartiallyParsedIfPossible + ) + + internal static func parse(_ source: String) -> MarkdownDocument { + guard !source.isEmpty else { return MarkdownDocument(blocks: []) } + let parsed: AttributedString + do { + parsed = try AttributedString(markdown: source, options: options) + } catch { + logger.warning("Markdown did not parse, showing it as text: \(error.publicLogShape, privacy: .public)") + return MarkdownDocument(blocks: [MarkdownBlock(id: 0, kind: .paragraph(AttributedString(source)))]) + } + return MarkdownDocument(blocks: blocks(from: tree(of: parsed).children)) + } + + /// A reply still arriving ends mid-span: `**bold` with no closing pair yet reads as literal asterisks until it + /// arrives. The last line is closed for the parse, unless it is code, where the characters are the content. + internal static func parseStreaming(_ source: String) -> MarkdownDocument { + let document = parse(source) + guard !endsInCode(document.blocks) else { return document } + let lastLineStart = source.lastIndex(of: "\n").map { source.index(after: $0) } ?? source.startIndex + let lastLine = String(source[lastLineStart...]) + let repaired = MarkdownInlineRepair.repairingDanglingSyntax(lastLine) + guard repaired != lastLine else { return document } + return parse(String(source[.. Bool { + guard let last = blocks.last else { return false } + switch last.kind { + case .codeBlock: + return true + case .list(let list): + return endsInCode(list.items.last?.blocks ?? []) + case .blockquote(let children): + return endsInCode(children) + case .paragraph, .header, .table, .thematicBreak: + return false + } + } + + // MARK: Tree + + private final class Node { + let identity: Int + /// Nil for raw HTML, which arrives with no block intent and reads as literal text. + let kind: PresentationIntent.Kind? + var children: [Node] = [] + var text = AttributedString() + + init(identity: Int, kind: PresentationIntent.Kind?) { + self.identity = identity + self.kind = kind + } + } + + private static func tree(of parsed: AttributedString) -> Node { + let root = Node(identity: Int.min, kind: nil) + var nextLooseIdentity = Int.min + 1 + for run in parsed.runs { + var node = root + if let intent = run.presentationIntent { + for component in intent.components.reversed() { + if let last = node.children.last, last.identity == component.identity { + node = last + } else { + let child = Node(identity: component.identity, kind: component.kind) + node.children.append(child) + node = child + } + } + } else if let last = root.children.last, last.kind == nil { + node = last + } else { + let loose = Node(identity: nextLooseIdentity, kind: nil) + nextLooseIdentity += 1 + root.children.append(loose) + node = loose + } + node.text.append(leafText(parsed[run.range], inline: run.inlinePresentationIntent)) + } + return root + } + + /// A soft break is the newline the author typed inside a paragraph. It stays a line break, as it always has in + /// this chat, rather than the space CommonMark allows a renderer to draw. + private static func leafText( + _ slice: AttributedSubstring, + inline: InlinePresentationIntent? + ) -> AttributedString { + if let inline, inline.contains(.softBreak) { + return AttributedString("\n") + } + var text = AttributedString(slice) + text.presentationIntent = nil + return text + } + + // MARK: Blocks + + private static func blocks(from nodes: [Node]) -> [MarkdownBlock] { + nodes.enumerated().map { index, node in MarkdownBlock(id: index, kind: kind(of: node)) } + } + + private static func kind(of node: Node) -> MarkdownBlock.Kind { + guard let kind = node.kind else { return .paragraph(node.text) } + switch kind { + case .header(let level): + return .header(level: level, text: node.text) + case .codeBlock(let languageHint): + var code = String(node.text.characters) + if code.hasSuffix("\n") { code.removeLast() } + let language = languageHint.flatMap { $0.isEmpty ? nil : $0 } + return .codeBlock(code: code, language: language) + case .orderedList, .unorderedList: + return .list(list(from: node, isOrdered: kind == .orderedList)) + case .blockQuote: + return .blockquote(blocks(from: node.children)) + case .table(let columns): + return .table(table(from: node, columns: columns)) + case .thematicBreak: + return .thematicBreak + default: + return .paragraph(node.text) + } + } + + private static func list(from node: Node, isOrdered: Bool) -> MarkdownList { + var start: Int? + if isOrdered { + if case .listItem(let ordinal)? = node.children.first?.kind { + start = ordinal + } else { + start = 1 + } + } + let items = node.children.enumerated().map { index, item in + MarkdownListItem(id: index, blocks: blocks(from: item.children)) + } + return MarkdownList(start: start, items: items) + } + + /// An empty cell produces no run, so cells are placed by their column index rather than their order. + private static func table(from node: Node, columns: [PresentationIntent.TableColumn]) -> MarkdownTable { + let alignments: [MarkdownTableAlignment] = columns.map { column in + switch column.alignment { + case .center: .center + case .right: .right + default: .left + } + } + func cells(of row: Node) -> [AttributedString] { + var cells = Array(repeating: AttributedString(), count: alignments.count) + for cell in row.children { + guard case .tableCell(let column)? = cell.kind, cells.indices.contains(column) else { continue } + cells[column] = cell.text + } + return cells + } + var header = Array(repeating: AttributedString(), count: alignments.count) + var rows: [[AttributedString]] = [] + for row in node.children { + if case .tableHeaderRow? = row.kind { + header = cells(of: row) + } else { + rows.append(cells(of: row)) + } + } + return MarkdownTable(alignments: alignments, header: header, rows: rows) + } +} + +// MARK: - Settled documents + +/// Parsed documents of settled text, keyed by the text. A transcript row is rebuilt each time it scrolls back into the +/// lazy stack, and the parse is the same every time. +internal final class MarkdownDocumentCache: @unchecked Sendable { + internal static let shared = MarkdownDocumentCache() + + private final class Entry { + let document: MarkdownDocument + + init(_ document: MarkdownDocument) { + self.document = document + } + } + + /// `NSCache` is safe to use from any thread, which is what the unchecked conformance rests on. + private let cache: NSCache = { + let cache = NSCache() + cache.countLimit = 512 + cache.totalCostLimit = 16 * 1_024 * 1_024 + return cache + }() + + internal func cachedDocument(for source: String) -> MarkdownDocument? { + cache.object(forKey: source as NSString)?.document + } + + internal func document(for source: String) -> MarkdownDocument { + if let cached = cachedDocument(for: source) { + return cached + } + let document = MarkdownDocumentParser.parse(source) + cache.setObject(Entry(document), forKey: source as NSString, cost: (source as NSString).length) + return document + } + + /// Parses a transcript's text before its rows are built, so scrolling back through a long conversation lays rows + /// out without parsing them on the main actor. + @concurrent + internal func prewarm(_ sources: [String]) async { + for source in sources where !Task.isCancelled { + _ = document(for: source) + } + } +} + +// MARK: - A row's document + +/// The document one transcript row shows, kept current as its text grows and settles. +/// +/// A parse is linear in the text, and a long reply would spend each 50 ms flush on the main actor parsing all of it, +/// then parse it once more when it finished. Every parse after the first runs away from the main actor, one at a +/// time, with the newest text winning; the row keeps showing what it has until the parse lands. The first parse runs +/// at once, because a row needs its height on the frame it appears, and for settled text it is usually a cache hit. +@MainActor +internal final class MarkdownDocumentModel: ObservableObject { + @Published internal private(set) var document: MarkdownDocument + /// Whether `document` is the parse of finished text. False while a reply streams, and until the parse of its final + /// text lands. + @Published internal private(set) var isSettled: Bool + + private struct Request: Equatable, Sendable { + let source: String + let isStreaming: Bool + } + + private var latest: Request + private var parsed: Request + private var isParsing = false + + internal init(source: String, isStreaming: Bool) { + let request = Request(source: source, isStreaming: isStreaming) + document = Self.parseNow(request) + isSettled = !isStreaming + latest = request + parsed = request + } + + internal func update(source: String, isStreaming: Bool) { + latest = Request(source: source, isStreaming: isStreaming) + if !isStreaming, let cached = MarkdownDocumentCache.shared.cachedDocument(for: source) { + parsed = latest + show(cached, isSettled: true) + return + } + guard !isParsing, latest != parsed else { return } + parseLatest() + } + + private func parseLatest() { + isParsing = true + let request = latest + parsed = request + Task { [weak self] in + let document = await Self.parseAway(request) + guard let self else { return } + self.isParsing = false + /// A cache hit for finished text can land while this parse runs; that newer document stays. + if self.parsed == request { + self.show(document, isSettled: !request.isStreaming) + } + if self.latest != self.parsed { + self.parseLatest() + } + } + } + + private func show(_ document: MarkdownDocument, isSettled: Bool) { + if self.document != document { + self.document = document + } + if self.isSettled != isSettled { + self.isSettled = isSettled + } + } + + nonisolated private static func parseNow(_ request: Request) -> MarkdownDocument { + request.isStreaming + ? MarkdownDocumentParser.parseStreaming(request.source) + : MarkdownDocumentCache.shared.document(for: request.source) + } + + /// Settled text goes through the cache, so a finished reply is parsed once and its row finds it when rebuilt. + @concurrent + nonisolated private static func parseAway(_ request: Request) async -> MarkdownDocument { + parseNow(request) + } +} diff --git a/TablePro/Views/AIChat/MarkdownView.swift b/TablePro/Views/AIChat/MarkdownView.swift index 194959f852..89b7eb594e 100644 --- a/TablePro/Views/AIChat/MarkdownView.swift +++ b/TablePro/Views/AIChat/MarkdownView.swift @@ -2,106 +2,96 @@ // MarkdownView.swift // TablePro // -// Block-level markdown renderer backed by Foundation's AttributedString(markdown:) -// for inline formatting and native SwiftUI views for block layout. -// Supports progressive (streaming) sources: an open fenced code block renders as -// lightweight code until its closing fence arrives, then switches to the editor. +// Native SwiftUI views over a `MarkdownDocument`: one `Text` per paragraph, header and table cell, and the +// chat's code block for fenced code. // import AppKit -import os import SwiftUI struct MarkdownView: View, Equatable { let source: String var isStreaming: Bool = false - @State private var cache = MarkdownDocumentCache() + var body: some View { + MarkdownContent(source: source, isStreaming: isStreaming) + } +} + +private enum MarkdownMetrics { + static let blockSpacing: CGFloat = 6 + static let listItemSpacing: CGFloat = 3 +} - static func == (lhs: MarkdownView, rhs: MarkdownView) -> Bool { - lhs.source == rhs.source && lhs.isStreaming == rhs.isStreaming +/// Owns the row's document model, which lives as long as the row does. A row the lazy stack drops takes its model +/// with it, and a parse finishing after that is discarded. +private struct MarkdownContent: View { + let source: String + let isStreaming: Bool + + @StateObject private var model: MarkdownDocumentModel + + init(source: String, isStreaming: Bool) { + self.source = source + self.isStreaming = isStreaming + _model = StateObject(wrappedValue: MarkdownDocumentModel(source: source, isStreaming: isStreaming)) } var body: some View { - let blocks = cache.blocks(for: source) - let unsettledBlockID = isStreaming ? blocks.last?.id : nil - VStack(alignment: .leading, spacing: 6) { - ForEach(blocks) { block in - MarkdownBlockView( - block: block, - prefersLightweightCode: isStreaming, - isSettled: block.id != unsettledBlockID - ) - .equatable() + MarkdownBlockStack(blocks: model.document.blocks, isSettled: model.isSettled, spacing: MarkdownMetrics.blockSpacing) + .onChange(of: source) { newSource in + model.update(source: newSource, isStreaming: isStreaming) + } + .onChange(of: isStreaming) { newValue in + model.update(source: source, isStreaming: newValue) } - } - .frame(maxWidth: .infinity, alignment: .leading) } } -@MainActor -final class MarkdownDocumentCache { - private var lastSource: String = "\u{FFFF}" - private var lastBlocks: [MarkdownBlock] = [] +/// Only the last block of a reply still arriving can change, so only the blocks on that last path render unsettled. +private struct MarkdownBlockStack: View, Equatable { + let blocks: [MarkdownBlock] + let isSettled: Bool + let spacing: CGFloat - func blocks(for source: String) -> [MarkdownBlock] { - if source == lastSource { return lastBlocks } - lastSource = source - lastBlocks = MarkdownBlockParser.parse(source) - return lastBlocks + var body: some View { + VStack(alignment: .leading, spacing: spacing) { + ForEach(blocks) { block in + MarkdownBlockView(block: block, isSettled: isSettled || block.id != blocks.last?.id) + .equatable() + } + } + .frame(maxWidth: .infinity, alignment: .leading) } } private struct MarkdownBlockView: View, Equatable { let block: MarkdownBlock - let prefersLightweightCode: Bool - var isSettled: Bool = true - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.block == rhs.block - && lhs.prefersLightweightCode == rhs.prefersLightweightCode - && lhs.isSettled == rhs.isSettled - } + let isSettled: Bool var body: some View { switch block.kind { case .paragraph(let text): - Text(MarkdownInline.parse(text, isSettled: isSettled)) + Text(text) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) case .header(let level, let text): - Text(MarkdownInline.parse(text, isSettled: isSettled)) + Text(text) .font(headerFont(for: level)) - .fontWeight(headerWeight(for: level)) + .fontWeight(level <= 2 ? .bold : .semibold) .padding(.top, level == 1 ? 6 : 4) .padding(.bottom, 2) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) - case .codeBlock(let code, let language, let isClosed): - AIChatCodeBlockView( - code: code, - language: language, - prefersLightweightRendering: prefersLightweightCode && !isClosed - ) - .equatable() - case .unorderedList(let items): - MarkdownListView( - items: items, - style: .unordered, - prefersLightweightCode: prefersLightweightCode, - isSettled: isSettled - ) - case .orderedList(let start, let items): - MarkdownListView( - items: items, - style: .ordered(start: start), - prefersLightweightCode: prefersLightweightCode, - isSettled: isSettled - ) - case .blockquote(let lines): - MarkdownBlockquoteView(lines: lines) - case .table(let headers, let alignments, let rows): - MarkdownTableView(headers: headers, alignments: alignments, rows: rows) + case .codeBlock(let code, let language): + AIChatCodeBlockView(code: code, language: language, isSettled: isSettled) + .equatable() + case .list(let list): + MarkdownListView(list: list, isSettled: isSettled) + case .blockquote(let blocks): + MarkdownBlockquoteView(blocks: blocks, isSettled: isSettled) + case .table(let table): + MarkdownTableView(table: table) case .thematicBreak: Divider() .padding(.vertical, 4) @@ -116,77 +106,51 @@ private struct MarkdownBlockView: View, Equatable { default: return .subheadline } } - - private func headerWeight(for level: Int) -> Font.Weight { - level <= 2 ? .bold : .semibold - } } // MARK: - List private struct MarkdownListView: View { - let items: [MarkdownListItem] - let style: ListStyle - let prefersLightweightCode: Bool - var isSettled: Bool = true - - enum ListStyle: Equatable { - case unordered - case ordered(start: Int) - } + let list: MarkdownList + let isSettled: Bool var body: some View { - VStack(alignment: .leading, spacing: 3) { - ForEach(Array(items.enumerated()), id: \.element.id) { index, item in + VStack(alignment: .leading, spacing: MarkdownMetrics.listItemSpacing) { + ForEach(list.items) { item in HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(marker(for: index)) + Text(marker(for: item)) .foregroundStyle(.secondary) + .monospacedDigit() .frame(minWidth: 16, alignment: .trailing) - VStack(alignment: .leading, spacing: 3) { - Text(MarkdownInline.parse(item.text, isSettled: isSettled)) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - if !item.children.isEmpty { - ForEach(item.children) { child in - MarkdownBlockView( - block: child, - prefersLightweightCode: prefersLightweightCode, - isSettled: isSettled - ) - .equatable() - } - .padding(.leading, 4) - } - } + MarkdownBlockStack( + blocks: item.blocks, + isSettled: isSettled || item.id != list.items.last?.id, + spacing: MarkdownMetrics.blockSpacing + ) } } } } - private func marker(for index: Int) -> String { - switch style { - case .unordered: - return "•" - case .ordered(let start): - return "\(start + index)." - } + private func marker(for item: MarkdownListItem) -> String { + guard let start = list.start else { return "•" } + return "\(start + item.id)." } } // MARK: - Blockquote private struct MarkdownBlockquoteView: View { - let lines: String + let blocks: [MarkdownBlock] + let isSettled: Bool var body: some View { HStack(spacing: 8) { RoundedRectangle(cornerRadius: 2) .fill(Color(nsColor: .tertiaryLabelColor)) .frame(width: 3) - Text(MarkdownInline.parse(lines)) + MarkdownBlockStack(blocks: blocks, isSettled: isSettled, spacing: MarkdownMetrics.blockSpacing) .foregroundStyle(.secondary) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) } .padding(.vertical, 2) } @@ -194,26 +158,25 @@ private struct MarkdownBlockquoteView: View { // MARK: - Table +/// Wider than the pane, a table scrolls sideways. A horizontal scroll view passes vertical scrolling on to the +/// transcript, which the HIG allows for a scroll view of the other orientation. private struct MarkdownTableView: View { - let headers: [String] - let alignments: [MarkdownTableAlignment] - let rows: [[String]] + let table: MarkdownTable var body: some View { - let columnCount = headers.count ScrollView(.horizontal) { Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 6) { GridRow { - ForEach(0.. some View { - let attributed = MarkdownInline.parse(text) - switch alignment { - case .left: - Text(attributed).frame(maxWidth: .infinity, alignment: .leading) - case .center: - Text(attributed).frame(maxWidth: .infinity, alignment: .center) - case .right: - Text(attributed).frame(maxWidth: .infinity, alignment: .trailing) + private func cell(_ text: AttributedString, column: Int) -> some View { + let alignment: Alignment + switch table.alignments.indices.contains(column) ? table.alignments[column] : .left { + case .left: alignment = .leading + case .center: alignment = .center + case .right: alignment = .trailing } - } -} - -// MARK: - Inline parsing - -enum MarkdownInline { - private static let cache: OSAllocatedUnfairLock> = { - let cache = NSCache() - cache.countLimit = 4_000 - cache.totalCostLimit = 8 * 1_024 * 1_024 - return OSAllocatedUnfairLock(uncheckedState: cache) - }() - - private static let options = AttributedString.MarkdownParsingOptions( - interpretedSyntax: .inlineOnlyPreservingWhitespace - ) - - static func parse(_ source: String, isSettled: Bool = true) -> AttributedString { - guard isSettled else { - return attributedString(from: MarkdownInlineRepair.repairingDanglingSyntax(source)) - } - let key = source as NSString - if let cached = cache.withLockUnchecked({ $0.object(forKey: key) }) { - return AttributedString(cached) - } - let attributed = attributedString(from: source) - cache.withLockUnchecked { $0.setObject(NSAttributedString(attributed), forKey: key, cost: key.length) } - return attributed - } - - private static func attributedString(from source: String) -> AttributedString { - guard let parsed = try? AttributedString(markdown: source, options: options) else { - return AttributedString(source) - } - return parsed - } -} - -// MARK: - Block model - -struct MarkdownBlock: Identifiable, Equatable { - let id: Int - let kind: Kind - - enum Kind: Equatable { - case paragraph(String) - case header(level: Int, text: String) - case codeBlock(code: String, language: String?, isClosed: Bool) - case unorderedList([MarkdownListItem]) - case orderedList(start: Int, items: [MarkdownListItem]) - case blockquote(String) - case table(headers: [String], alignments: [MarkdownTableAlignment], rows: [[String]]) - case thematicBreak - } -} - -struct MarkdownListItem: Identifiable, Equatable { - let id: Int - let text: String - let children: [MarkdownBlock] -} - -enum MarkdownTableAlignment: Equatable { - case left - case center - case right -} - -// MARK: - Parser - -enum MarkdownBlockParser { - static func parse(_ source: String) -> [MarkdownBlock] { - var lines = source.components(separatedBy: "\n") - var blocks: [MarkdownBlock] = [] - while !lines.isEmpty { - if let parsed = parseNextBlock(&lines, index: blocks.count) { - blocks.append(parsed) - } - } - return blocks - } - - private static func parseNextBlock(_ lines: inout [String], index: Int) -> MarkdownBlock? { - guard let first = lines.first else { return nil } - let trimmed = first.trimmingCharacters(in: .whitespaces) - - if trimmed.isEmpty { - lines.removeFirst() - return nil - } - - if isFencedCodeStart(trimmed) { - return parseFencedCodeBlock(&lines, index: index) - } - - if let level = headerLevel(trimmed) { - lines.removeFirst() - let stripped = String(trimmed.dropFirst(level + 1)) - .trimmingCharacters(in: .whitespaces) - return MarkdownBlock(id: index, kind: .header(level: level, text: stripped)) - } - - if isThematicBreak(trimmed) { - lines.removeFirst() - return MarkdownBlock(id: index, kind: .thematicBreak) - } - - if trimmed.hasPrefix(">") { - return parseBlockquote(&lines, index: index) - } - - if isUnorderedListMarker(trimmed) { - return parseUnorderedList(&lines, index: index) - } - - if let start = orderedListStart(trimmed) { - return parseOrderedList(&lines, startingAt: start, index: index) - } - - if let table = parseTable(&lines, index: index) { - return table - } - - return parseParagraph(&lines, index: index) - } - - private static func headerLevel(_ trimmed: String) -> Int? { - guard trimmed.hasPrefix("#") else { return nil } - let hashes = trimmed.prefix { $0 == "#" }.count - guard hashes <= 6, - trimmed.count > hashes, - trimmed[trimmed.index(trimmed.startIndex, offsetBy: hashes)] == " " - else { return nil } - return hashes - } - - private static func isThematicBreak(_ trimmed: String) -> Bool { - let collapsed = trimmed.replacingOccurrences(of: " ", with: "") - guard collapsed.count >= 3 else { return false } - return collapsed.allSatisfy { $0 == "-" } - || collapsed.allSatisfy { $0 == "*" } - || collapsed.allSatisfy { $0 == "_" } - } - - private static func isFencedCodeStart(_ trimmed: String) -> Bool { - trimmed.hasPrefix("```") || trimmed.hasPrefix("~~~") - } - - private static func isUnorderedListMarker(_ trimmed: String) -> Bool { - guard trimmed.count >= 2 else { return false } - let first = trimmed.first - let second = trimmed[trimmed.index(after: trimmed.startIndex)] - return (first == "-" || first == "*" || first == "+") && second == " " - } - - private static func orderedListStart(_ trimmed: String) -> Int? { - let scanner = Scanner(string: trimmed) - scanner.charactersToBeSkipped = nil - var value: Int = 0 - guard scanner.scanInt(&value) else { return nil } - guard scanner.scanString(".") != nil else { return nil } - guard scanner.scanString(" ") != nil else { return nil } - return value - } - - private static func parseFencedCodeBlock(_ lines: inout [String], index: Int) -> MarkdownBlock { - let opener = lines.removeFirst() - let trimmedOpener = opener.trimmingCharacters(in: .whitespaces) - let fenceChar: Character = trimmedOpener.hasPrefix("`") ? "`" : "~" - let fenceLength = trimmedOpener.prefix(while: { $0 == fenceChar }).count - let language: String? = { - let info = String(trimmedOpener.dropFirst(fenceLength)).trimmingCharacters(in: .whitespaces) - return info.isEmpty ? nil : info - }() - var bodyLines: [String] = [] - var isClosed = false - while let line = lines.first { - let trimmedLine = line.trimmingCharacters(in: .whitespaces) - if isFencedCodeClose(trimmedLine, fenceChar: fenceChar, fenceLength: fenceLength) { - lines.removeFirst() - isClosed = true - break - } - bodyLines.append(line) - lines.removeFirst() - } - return MarkdownBlock( - id: index, - kind: .codeBlock( - code: bodyLines.joined(separator: "\n"), - language: language, - isClosed: isClosed - ) - ) - } - - private static func isFencedCodeClose(_ trimmed: String, fenceChar: Character, fenceLength: Int) -> Bool { - guard !trimmed.isEmpty else { return false } - let run = trimmed.prefix(while: { $0 == fenceChar }) - guard run.count >= fenceLength else { return false } - let rest = trimmed.dropFirst(run.count) - return rest.allSatisfy { $0.isWhitespace } - } - - private static func parseBlockquote(_ lines: inout [String], index: Int) -> MarkdownBlock { - var collected: [String] = [] - while let line = lines.first { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.hasPrefix(">") else { break } - let content = trimmed.dropFirst() - .trimmingCharacters(in: .whitespaces) - collected.append(String(content)) - lines.removeFirst() - } - return MarkdownBlock(id: index, kind: .blockquote(collected.joined(separator: "\n"))) - } - - private static func parseUnorderedList(_ lines: inout [String], index: Int) -> MarkdownBlock { - var items: [MarkdownListItem] = [] - while let item = parseListItem(&lines, ordered: false, itemIndex: items.count) { - items.append(item) - } - return MarkdownBlock(id: index, kind: .unorderedList(items)) - } - - private static func parseOrderedList(_ lines: inout [String], startingAt start: Int, index: Int) -> MarkdownBlock { - var items: [MarkdownListItem] = [] - while let item = parseListItem(&lines, ordered: true, itemIndex: items.count) { - items.append(item) - } - return MarkdownBlock(id: index, kind: .orderedList(start: start, items: items)) - } - - private static func parseListItem(_ lines: inout [String], ordered: Bool, itemIndex: Int) -> MarkdownListItem? { - guard let first = lines.first else { return nil } - let trimmed = first.trimmingCharacters(in: .whitespaces) - let markerLength: Int - if ordered { - guard orderedListStart(trimmed) != nil, - let dotIndex = trimmed.firstIndex(of: ".") else { return nil } - markerLength = trimmed.distance(from: trimmed.startIndex, to: dotIndex) + 2 - } else { - guard isUnorderedListMarker(trimmed) else { return nil } - markerLength = 2 - } - let bodyStart = trimmed.index(trimmed.startIndex, offsetBy: markerLength) - var textLines: [String] = [String(trimmed[bodyStart...])] - lines.removeFirst() - - while let next = lines.first { - let nextTrimmed = next.trimmingCharacters(in: .whitespaces) - if nextTrimmed.isEmpty { break } - if isUnorderedListMarker(nextTrimmed) || orderedListStart(nextTrimmed) != nil { break } - if next.hasPrefix(" ") || next.hasPrefix("\t") { - textLines.append(next.trimmingCharacters(in: .whitespaces)) - lines.removeFirst() - continue - } - break - } - return MarkdownListItem(id: itemIndex, text: textLines.joined(separator: " "), children: []) - } - - private static func parseTable(_ lines: inout [String], index: Int) -> MarkdownBlock? { - guard lines.count >= 2 else { return nil } - let headerLine = lines[0] - let separatorLine = lines[1] - guard headerLine.contains("|"), isTableSeparator(separatorLine) else { return nil } - - let headers = splitTableRow(headerLine) - let alignments = parseTableAlignments(separatorLine) - guard !headers.isEmpty, headers.count == alignments.count else { return nil } - - lines.removeFirst(2) - var rows: [[String]] = [] - while let line = lines.first, looksLikeTableRow(line, columnCount: headers.count) { - let row = splitTableRow(line) - var padded = row - while padded.count < headers.count { padded.append("") } - rows.append(Array(padded.prefix(headers.count))) - lines.removeFirst() - } - return MarkdownBlock(id: index, kind: .table(headers: headers, alignments: alignments, rows: rows)) - } - - private static func looksLikeTableRow(_ line: String, columnCount: Int) -> Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.contains("|") else { return false } - guard trimmed.hasPrefix("|") || trimmed.hasSuffix("|") else { return false } - let segments = splitTableRow(line) - return segments.count >= max(2, columnCount - 1) - } - - private static func isTableSeparator(_ line: String) -> Bool { - let trimmed = line.trimmingCharacters(in: .whitespaces) - guard trimmed.contains("|") else { return false } - let segments = splitTableRow(line) - guard !segments.isEmpty else { return false } - return segments.allSatisfy { segment in - let inner = segment.trimmingCharacters(in: .whitespaces) - guard !inner.isEmpty else { return false } - return inner.allSatisfy { $0 == "-" || $0 == ":" } - } - } - - private static func parseTableAlignments(_ line: String) -> [MarkdownTableAlignment] { - splitTableRow(line).map { segment in - let inner = segment.trimmingCharacters(in: .whitespaces) - let leadingColon = inner.hasPrefix(":") - let trailingColon = inner.hasSuffix(":") - switch (leadingColon, trailingColon) { - case (true, true): return .center - case (false, true): return .right - default: return .left - } - } - } - - private static func splitTableRow(_ line: String) -> [String] { - var trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("|") { trimmed.removeFirst() } - if trimmed.hasSuffix("|") { trimmed.removeLast() } - return trimmed - .split(separator: "|", omittingEmptySubsequences: false) - .map { $0.trimmingCharacters(in: .whitespaces) } - } - - private static func parseParagraph(_ lines: inout [String], index: Int) -> MarkdownBlock { - var collected: [String] = [] - while let line = lines.first { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.isEmpty { break } - if isFencedCodeStart(trimmed) { break } - if headerLevel(trimmed) != nil { break } - if isThematicBreak(trimmed) { break } - if trimmed.hasPrefix(">") { break } - if isUnorderedListMarker(trimmed) { break } - if orderedListStart(trimmed) != nil { break } - collected.append(line) - lines.removeFirst() - } - return MarkdownBlock(id: index, kind: .paragraph(collected.joined(separator: "\n"))) - } -} - -private extension Array { - subscript(safe index: Int) -> Element? { - indices.contains(index) ? self[index] : nil + return Text(text).frame(maxWidth: .infinity, alignment: alignment) } } diff --git a/TablePro/Views/AIChat/MentionPopoverState.swift b/TablePro/Views/AIChat/MentionPopoverState.swift index e3e938cb55..84faf86f0c 100644 --- a/TablePro/Views/AIChat/MentionPopoverState.swift +++ b/TablePro/Views/AIChat/MentionPopoverState.swift @@ -14,7 +14,11 @@ final class MentionPopoverState: ObservableObject { @Published var query = "" @Published var anchorRange = NSRange(location: 0, length: 0) + /// Called on every keystroke without a mention in progress, so it writes only when there is something to clear: + /// each write publishes, and the composer's owner redraws on every publish. func reset() { + guard isVisible || !candidates.isEmpty || selectedIndex != 0 || !query.isEmpty || anchorRange.length != 0 + || anchorRange.location != 0 else { return } isVisible = false candidates = [] selectedIndex = 0 diff --git a/TablePro/Views/Editor/Folding/FoldPreviewMetrics.swift b/TablePro/Views/Editor/Folding/FoldPreviewMetrics.swift index efa051d1c8..690901366b 100644 --- a/TablePro/Views/Editor/Folding/FoldPreviewMetrics.swift +++ b/TablePro/Views/Editor/Folding/FoldPreviewMetrics.swift @@ -23,6 +23,9 @@ enum FoldPreviewMetrics { private static let verticalPadding: CGFloat = 16 private static let horizontalPadding: CGFloat = 24 + /// The peek's `SourceEditor` keeps the editor's default line height multiple. + private static let lineHeightMultiple: CGFloat = 1.2 + struct Layout: Equatable { /// The lines the peek shows, without a trailing newline. let text: String @@ -41,7 +44,7 @@ enum FoldPreviewMetrics { let shown = Array(lines.prefix(maxLines)) let hiddenLineCount = lines.count - shown.count - let lineHeight = CodeBlockHeightEstimator.editorLineHeight(for: font) + let lineHeight = (font.ascender - font.descender + font.leading) * lineHeightMultiple let widest = shown.reduce(CGFloat.zero) { widest, line in max(widest, (line as NSString).size(withAttributes: [.font: font]).width) } diff --git a/TableProTests/Core/AI/AIImageCacheThumbnailTests.swift b/TableProTests/Core/AI/AIImageCacheThumbnailTests.swift new file mode 100644 index 0000000000..4529af4247 --- /dev/null +++ b/TableProTests/Core/AI/AIImageCacheThumbnailTests.swift @@ -0,0 +1,44 @@ +// +// AIImageCacheThumbnailTests.swift +// TableProTests +// + +import AppKit +@testable import TablePro +import Testing + +struct AIImageCacheThumbnailTests { + private func pngData(width: Int, height: Int) throws -> Data { + let bitmap = try #require(NSBitmapImageRep( + bitmapDataPlanes: nil, + pixelsWide: width, + pixelsHigh: height, + bitsPerSample: 8, + samplesPerPixel: 4, + hasAlpha: true, + isPlanar: false, + colorSpaceName: .deviceRGB, + bytesPerRow: 0, + bitsPerPixel: 0 + )) + return try #require(bitmap.representation(using: .png, properties: [:])) + } + + @Test("A thumbnail is scaled down to the size asked for, and kept") + func thumbnailIsScaledDownAndCached() throws { + let cache = AIImageCache.shared + let filename = cache.store(data: try pngData(width: 2_000, height: 1_000), mediaType: "image/png") + defer { cache.delete(filename: filename) } + + #expect(cache.cachedThumbnail(filename: filename, maxPixelSize: 192) == nil) + let thumbnail = try #require(cache.thumbnail(filename: filename, maxPixelSize: 192)) + let image = try #require(thumbnail.cgImage(forProposedRect: nil, context: nil, hints: nil)) + #expect(max(image.width, image.height) == 192) + #expect(cache.cachedThumbnail(filename: filename, maxPixelSize: 192) != nil) + } + + @Test("A filename outside the cache gives no thumbnail") + func traversalIsRejected() { + #expect(AIImageCache.shared.thumbnail(filename: "../secret.png", maxPixelSize: 96) == nil) + } +} diff --git a/TableProTests/Core/AI/ChatTurnObservationTests.swift b/TableProTests/Core/AI/ChatTurnObservationTests.swift index 6d28bd3031..3baabdffc7 100644 --- a/TableProTests/Core/AI/ChatTurnObservationTests.swift +++ b/TableProTests/Core/AI/ChatTurnObservationTests.swift @@ -20,6 +20,29 @@ struct ChatTurnObservationTests { return (ChatTurn(role: .assistant, blocks: [block]), block) } + @Test("Typing in the composer publishes on the draft, not on the view model") + func typingLeavesTheViewModelSilent() { + let viewModel = AIChatViewModel() + let viewModelInvalidated = OSAllocatedUnfairLock(initialState: false) + let draftInvalidated = OSAllocatedUnfairLock(initialState: false) + let viewModelObservation = viewModel.objectWillChange.sink { _ in + viewModelInvalidated.withLock { $0 = true } + } + let draftObservation = viewModel.composerDraft.objectWillChange.sink { _ in + draftInvalidated.withLock { $0 = true } + } + defer { + viewModelObservation.cancel() + draftObservation.cancel() + } + + viewModel.inputText = "select" + + #expect(viewModelInvalidated.withLock { $0 } == false) + #expect(draftInvalidated.withLock { $0 } == true) + #expect(viewModel.composerDraft.text == "select") + } + @Test("Appending a streaming token leaves the messages array untouched") func tokenAppendDoesNotInvalidateMessagesArray() { let viewModel = AIChatViewModel() diff --git a/TableProTests/Core/AI/MentionPopoverStateTests.swift b/TableProTests/Core/AI/MentionPopoverStateTests.swift index ffdf5d1d2a..34b5d3bd2b 100644 --- a/TableProTests/Core/AI/MentionPopoverStateTests.swift +++ b/TableProTests/Core/AI/MentionPopoverStateTests.swift @@ -3,7 +3,9 @@ // TableProTests // +import Combine import Foundation +import os import TableProPluginKit @testable import TablePro import Testing @@ -14,6 +16,38 @@ struct MentionPopoverStateTests { MentionCandidate(item: .table(connectionId: UUID(), name: name)) } + @Test("Resetting a popover that is already reset publishes nothing") + func resetWhenResetIsSilent() { + let state = MentionPopoverState() + let published = OSAllocatedUnfairLock(initialState: false) + let observation = state.objectWillChange.sink { _ in + published.withLock { $0 = true } + } + defer { observation.cancel() } + + state.reset() + + #expect(published.withLock { $0 } == false) + } + + @Test("Resetting a visible popover clears it") + func resetClearsAVisiblePopover() { + let state = MentionPopoverState() + state.candidates = [candidate("a"), candidate("b")] + state.selectedIndex = 1 + state.query = "a" + state.anchorRange = NSRange(location: 3, length: 2) + state.isVisible = true + + state.reset() + + #expect(state.isVisible == false) + #expect(state.candidates.isEmpty) + #expect(state.selectedIndex == 0) + #expect(state.query.isEmpty) + #expect(state.anchorRange == NSRange(location: 0, length: 0)) + } + @Test("moveSelection wraps forward at end") func wrapForwardAtEnd() { let state = MentionPopoverState() diff --git a/TableProTests/Models/TrailingPaneStateTests.swift b/TableProTests/Models/TrailingPaneStateTests.swift index 50dcc0a9c6..00d5edc4cf 100644 --- a/TableProTests/Models/TrailingPaneStateTests.swift +++ b/TableProTests/Models/TrailingPaneStateTests.swift @@ -77,9 +77,9 @@ struct TrailingPaneStateTests { var announcements = 0 let subscription = state.assistant.objectWillChange.sink { announcements += 1 } - busy.inputText = "typed into the busy session" + busy.selectedModel = "chosen in the busy session" #expect(announcements == 0) - fresh.inputText = "typed into the new session" + fresh.selectedModel = "chosen in the new session" #expect(announcements > 0) subscription.cancel() } @@ -114,9 +114,9 @@ struct TrailingPaneStateTests { var announcements = 0 let subscription = second.assistant.objectWillChange.sink { announcements += 1 } - busy.inputText = "typed into the busy session" + busy.selectedModel = "chosen in the busy session" #expect(announcements == 0) - fresh.inputText = "typed into the new session" + fresh.selectedModel = "chosen in the new session" #expect(announcements > 0) #expect(second.assistant.viewModelIfActivated === fresh) subscription.cancel() diff --git a/TableProTests/Views/AIChat/AIChatMessageSpacingTests.swift b/TableProTests/Views/AIChat/AIChatMessageSpacingTests.swift deleted file mode 100644 index 4b6d138cce..0000000000 --- a/TableProTests/Views/AIChat/AIChatMessageSpacingTests.swift +++ /dev/null @@ -1,58 +0,0 @@ -// -// AIChatMessageSpacingTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -@MainActor -struct AIChatMessageSpacingTests { - private func turn(_ role: ChatRole) -> ChatTurn { - ChatTurn(role: role, blocks: [ChatContentBlock.text("body")]) - } - - @Test("An empty message list produces no spacing and does not trap") - func emptyListIsSafe() { - #expect(AIChatMessageSpacing.spacedMessageIDs(for: []).isEmpty) - } - - @Test("A single message produces no spacing") - func singleMessageIsSafe() { - #expect(AIChatMessageSpacing.spacedMessageIDs(for: [turn(.user)]).isEmpty) - } - - @Test("A user message following an assistant message is spaced") - func userAfterAssistantIsSpaced() { - let assistant = turn(.assistant) - let user = turn(.user) - - let spaced = AIChatMessageSpacing.spacedMessageIDs(for: [assistant, user]) - - #expect(spaced == [user.id]) - } - - @Test("A user message following another user message is not spaced") - func userAfterUserIsNotSpaced() { - let first = turn(.user) - let second = turn(.user) - - #expect(AIChatMessageSpacing.spacedMessageIDs(for: [first, second]).isEmpty) - } - - @Test("Only the boundary messages in a longer conversation are spaced") - func spacesEveryAssistantToUserBoundary() { - let firstUser = turn(.user) - let firstAssistant = turn(.assistant) - let secondUser = turn(.user) - let secondAssistant = turn(.assistant) - let thirdUser = turn(.user) - - let spaced = AIChatMessageSpacing.spacedMessageIDs( - for: [firstUser, firstAssistant, secondUser, secondAssistant, thirdUser] - ) - - #expect(spaced == [secondUser.id, thirdUser.id]) - } -} diff --git a/TableProTests/Views/AIChat/AIChatTranscriptTests.swift b/TableProTests/Views/AIChat/AIChatTranscriptTests.swift new file mode 100644 index 0000000000..f50eceb027 --- /dev/null +++ b/TableProTests/Views/AIChat/AIChatTranscriptTests.swift @@ -0,0 +1,98 @@ +// +// AIChatTranscriptTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@MainActor +struct AIChatTranscriptTests { + private func turn(_ role: ChatRole, _ blocks: [ChatContentBlock] = [ChatContentBlock.text("body")]) -> ChatTurn { + ChatTurn(role: role, blocks: blocks) + } + + // MARK: - Rows + + @Test("A question that follows a reply gets room above it, and nothing else does") + func onlyQuestionsAfterRepliesAreSpaced() { + let firstUser = turn(.user) + let firstAssistant = turn(.assistant) + let secondUser = turn(.user) + let thirdUser = turn(.user) + + let rows = ChatTranscriptRow.rows(for: [firstUser, firstAssistant, secondUser, thirdUser]) + + #expect(rows.map(\.followsReply) == [false, false, true, false]) + } + + @Test("System turns and turns that only carry tool results are left out") + func plumbingTurnsAreHidden() { + let question = turn(.user) + let reply = turn(.assistant) + let toolResults = turn(.user, [ChatContentBlock.toolResult(ToolResultBlock(toolUseId: "1", content: "{}"))]) + let system = turn(.system) + let followUp = turn(.user) + + let rows = ChatTranscriptRow.rows(for: [question, reply, toolResults, system, followUp]) + + #expect(rows.map(\.id) == [question.id, reply.id, followUp.id]) + #expect(rows.last?.followsReply == true) + } + + @Test("An empty conversation has no rows") + func emptyConversation() { + #expect(ChatTranscriptRow.rows(for: []).isEmpty) + } + + // MARK: - Pinning + + private func edge(_ contentHeight: CGFloat, atBottom: Bool) -> ChatScrollEdge { + ChatScrollEdge(contentHeight: contentHeight, isAtBottom: atBottom) + } + + @Test("The first report only records where the end is") + func firstReportKeepsThePin() { + var pinning = ChatScrollPinning() + #expect(pinning.update(with: edge(900, atBottom: false)) == false) + #expect(pinning.isPinned) + } + + @Test("A reply growing while the reader is at the end is followed") + func growthAtTheEndIsFollowed() { + var pinning = ChatScrollPinning() + _ = pinning.update(with: edge(900, atBottom: true)) + #expect(pinning.update(with: edge(960, atBottom: false)) == true) + #expect(pinning.isPinned) + } + + @Test("Scrolling up unpins, and growth after that is not followed") + func scrollingUpUnpins() { + var pinning = ChatScrollPinning() + _ = pinning.update(with: edge(900, atBottom: true)) + #expect(pinning.update(with: edge(900, atBottom: false)) == false) + #expect(pinning.isPinned == false) + #expect(pinning.update(with: edge(960, atBottom: false)) == false) + #expect(pinning.isPinned == false) + } + + @Test("Scrolling back to the end pins again") + func returningToTheEndPins() { + var pinning = ChatScrollPinning() + _ = pinning.update(with: edge(900, atBottom: true)) + _ = pinning.update(with: edge(900, atBottom: false)) + _ = pinning.update(with: edge(900, atBottom: true)) + #expect(pinning.isPinned) + } + + @Test("Pinning by hand follows the next growth again") + func pinningByHandFollowsGrowth() { + var pinning = ChatScrollPinning() + _ = pinning.update(with: edge(900, atBottom: true)) + _ = pinning.update(with: edge(900, atBottom: false)) + pinning.pin() + #expect(pinning.isPinned) + #expect(pinning.update(with: edge(960, atBottom: false)) == true) + } +} diff --git a/TableProTests/Views/AIChat/CodeBlockHeightEstimatorTests.swift b/TableProTests/Views/AIChat/CodeBlockHeightEstimatorTests.swift deleted file mode 100644 index f572fbb7a5..0000000000 --- a/TableProTests/Views/AIChat/CodeBlockHeightEstimatorTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -// -// CodeBlockHeightEstimatorTests.swift -// TableProTests -// - -import AppKit -@testable import TablePro -import Testing - -struct CodeBlockHeightEstimatorTests { - private static var font: NSFont { NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) } - - private func height(_ code: String, width: CGFloat = 480) -> CGFloat { - CodeBlockHeightEstimator.height(for: code, font: Self.font, availableWidth: width) - } - - @Test("A measured height stays within the clamp") - func heightStaysWithinClamp() { - let tall = String(repeating: "SELECT 1;\n", count: 200) - - #expect(height("SELECT 1;") >= CodeBlockHeightEstimator.minimumHeight) - #expect(height(tall) == CodeBlockHeightEstimator.maximumHeight) - } - - @Test("More lines never produce a shorter block") - func heightGrowsMonotonicallyWithLineCount() { - let one = height("SELECT 1;") - let three = height("SELECT 1;\nSELECT 2;\nSELECT 3;") - - #expect(three > one) - } - - @Test("A wrapping line is taller than the same text on a wide layout") - func wrappingIsAccountedFor() { - let line = String(repeating: "SELECT column_name FROM some_table ", count: 6) - - #expect(height(line, width: 200) > height(line, width: 1_200)) - } - - @Test("A zero width falls back to the line-count estimate") - func zeroWidthUsesFallback() { - let code = "SELECT 1;\nSELECT 2;" - - #expect(height(code, width: 0) == CodeBlockHeightEstimator.fallbackHeight(for: code, font: Self.font)) - } - - @Test("The estimate covers what the editor actually lays out at its line height multiple") - func estimateCoversEditorLineHeight() { - let lineCount = 10 - let code = (1...lineCount).map { "SELECT \($0);" }.joined(separator: "\n") - let editorContent = CGFloat(lineCount) * CodeBlockHeightEstimator.editorLineHeight(for: Self.font) - - #expect(height(code) >= editorContent) - } - - @Test("The editor line height applies the multiple to the font's own metrics") - func editorLineHeightAppliesTheMultiple() { - let raw: CGFloat = Self.font.ascender - Self.font.descender + Self.font.leading - - #expect(CodeBlockHeightEstimator.editorLineHeight(for: Self.font) == raw * CodeBlockHeightEstimator.lineHeightMultiple) - #expect(CodeBlockHeightEstimator.extraLineSpacing(for: Self.font) >= 0) - } - - @Test("The estimate is never shorter than a plain unscaled text measurement") - func estimateIsNeverShorterThanPlainText() { - let code = (1...12).map { "SELECT column_\($0) FROM some_table;" }.joined(separator: "\n") - let plain = (code as NSString).boundingRect( - with: CGSize(width: CGFloat(464), height: .greatestFiniteMagnitude), - options: [.usesLineFragmentOrigin, .usesFontLeading], - attributes: [.font: Self.font] - ).height - - #expect(height(code) >= plain) - } -} diff --git a/TableProTests/Views/AIChat/CodeBlockHighlighterTests.swift b/TableProTests/Views/AIChat/CodeBlockHighlighterTests.swift new file mode 100644 index 0000000000..908d23ae2a --- /dev/null +++ b/TableProTests/Views/AIChat/CodeBlockHighlighterTests.swift @@ -0,0 +1,135 @@ +// +// CodeBlockHighlighterTests.swift +// TableProTests +// + +import AppKit +import SwiftUI +@testable import TablePro +import TableProEditorKit +import TableProGrammars +import Testing + +@MainActor +struct CodeBlockHighlighterTests { + private static let font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + + /// One color per role, so a run's color names the capture it came from. + private static let theme: EditorTheme = { + let plain = EditorTheme.Attribute(color: .labelColor) + return EditorTheme( + text: plain, + invisibles: plain, + background: .textBackgroundColor, + lineHighlight: .clear, + selection: .selectedTextBackgroundColor, + keywords: EditorTheme.Attribute(color: .systemPink, bold: true), + commands: EditorTheme.Attribute(color: .systemPink, bold: true), + types: EditorTheme.Attribute(color: .systemTeal), + attributes: EditorTheme.Attribute(color: .systemBrown), + variables: EditorTheme.Attribute(color: .systemBrown), + values: EditorTheme.Attribute(color: .systemBrown), + numbers: EditorTheme.Attribute(color: .systemBlue), + strings: EditorTheme.Attribute(color: .systemRed), + characters: EditorTheme.Attribute(color: .systemRed), + comments: EditorTheme.Attribute(color: .systemGreen), + operators: plain, + functions: EditorTheme.Attribute(color: .systemPurple) + ) + }() + + private func highlight(_ code: String, _ language: CodeLanguage = .sql) async -> AttributedString { + await CodeBlockHighlighter.highlight(code, language: language, font: Self.font, theme: Self.theme) + } + + @Test("A keyword takes the theme's keyword color and weight") + func keywordTakesTheKeywordColor() async throws { + let text = await highlight("SELECT id FROM users") + let keyword = try #require(text.range(of: "SELECT")) + #expect(text[keyword].foregroundColor == Color(nsColor: .systemPink)) + #expect(text[keyword].font != nil) + } + + @Test("A string literal takes the theme's string color") + func stringTakesTheStringColor() async throws { + let text = await highlight("SELECT 'value'") + let literal = try #require(text.range(of: "'value'")) + #expect(text[literal].foregroundColor == Color(nsColor: .systemRed)) + } + + @Test("The code itself is unchanged") + func textIsUnchanged() async { + let code = "SELECT id\nFROM users\nWHERE name = 'a b';" + #expect(String(await highlight(code).characters) == code) + } + + @Test("Text no capture covers is left for the base color") + func uncapturedTextHasNoColor() async throws { + let text = await highlight("SELECT id FROM users") + let space = try #require(text.range(of: " ")) + #expect(text[space].foregroundColor == nil) + } + + @Test("Code past the cap is left plain, from the line that crosses it") + func longCodeIsColoredOnlyUpToTheCap() async throws { + let line = "SELECT id FROM users;\n" + let filler = String(repeating: line, count: CodeBlockHighlighter.maximumHighlightedLength / line.count) + let text = await highlight(filler + "SELECT tail FROM last;") + let head = try #require(text.range(of: "SELECT")) + #expect(text[head].foregroundColor != nil) + let tail = try #require(text.range(of: "SELECT tail")) + #expect(text[tail].foregroundColor == nil) + } + + @Test("A single line longer than the cap is not parsed at all") + func oneHugeLineIsPlain() async { + let code = "SELECT " + String(repeating: "x, ", count: CodeBlockHighlighter.maximumHighlightedLength) + #expect(await highlight(code).runs.allSatisfy { $0.foregroundColor == nil }) + } + + @Test("A control character shows as its name, as the editor marks it") + func controlCharacterIsNamed() async throws { + let text = await highlight("SELECT\u{0008} 1") + #expect(String(text.characters) == "SELECT 1") + let mark = try #require(text.range(of: "")) + #expect(text[mark].foregroundColor == RevealedText.markColor) + } + + @Test("A non-breaking space keeps its character and is tinted") + func specialSpaceIsTinted() async throws { + let code = "SELECT\u{00A0}1" + let text = await highlight(code) + #expect(String(text.characters) == code) + let space = try #require(text.range(of: "\u{00A0}")) + #expect(text[space].backgroundColor == RevealedText.blankSpaceBackground) + } + + @Test("Code in a language with no grammar still has its invisible characters marked") + func plainLanguageIsMarked() async { + let text = await highlight("hello\u{0008}", .default) + #expect(String(text.characters) == "hello") + } + + @Test("Only a block with no code has nothing to work out") + func emptyCodeHasNoKey() { + #expect(CodeBlockHighlighter.key(for: "", language: .sql, font: Self.font) == nil) + #expect(CodeBlockHighlighter.key(for: "hello", language: .default, font: Self.font) != nil) + } + + @Test("Colors worked out once are kept for the next time the block is built") + func colorsAreCached() async throws { + let code = "SELECT cached FROM t" + let key = try #require(CodeBlockHighlighter.key(for: code, language: .sql, font: Self.font)) + let first = await CodeBlockHighlighter.highlightedCode(code, language: .sql, font: Self.font, key: key) + #expect(CodeBlockHighlighter.cachedCode(forKey: key) == first) + } + + @Test("A different font gives a different key") + func fontIsPartOfTheKey() { + let larger = NSFont.monospacedSystemFont(ofSize: 14, weight: .regular) + #expect( + CodeBlockHighlighter.key(for: "SELECT 1", language: .sql, font: Self.font) + != CodeBlockHighlighter.key(for: "SELECT 1", language: .sql, font: larger) + ) + } +} diff --git a/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift b/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift deleted file mode 100644 index 8ccc9695d3..0000000000 --- a/TableProTests/Views/AIChat/MarkdownBlockParserTests.swift +++ /dev/null @@ -1,243 +0,0 @@ -// -// MarkdownBlockParserTests.swift -// TableProTests -// - -import Foundation -@testable import TablePro -import Testing - -struct MarkdownBlockParserTests { - @Test("Closed fenced code block is marked closed") - func closedFence() { - let source = """ - ```sql - SELECT 1 - ``` - """ - let blocks = MarkdownBlockParser.parse(source) - #expect(blocks.count == 1) - guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { - Issue.record("Expected code block") - return - } - #expect(code == "SELECT 1") - #expect(language == "sql") - #expect(isClosed == true) - } - - @Test("Unclosed fenced code block stays a code block while streaming") - func unclosedFence() { - let source = """ - ```sql - SELECT * FROM users - WHERE id = 1 - """ - let blocks = MarkdownBlockParser.parse(source) - #expect(blocks.count == 1) - guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { - Issue.record("Expected code block") - return - } - #expect(code.contains("SELECT * FROM users")) - #expect(code.contains("WHERE id = 1")) - #expect(language == "sql") - #expect(isClosed == false) - } - - @Test("Unclosed fence with only opener yields empty open code block") - func unclosedFenceOpenerOnly() { - let blocks = MarkdownBlockParser.parse("```sql") - #expect(blocks.count == 1) - guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { - Issue.record("Expected code block") - return - } - #expect(code.isEmpty) - #expect(language == "sql") - #expect(isClosed == false) - } - - @Test("Tilde fences support unclosed streaming") - func unclosedTildeFence() { - let source = """ - ~~~javascript - db.users.find({}) - """ - let blocks = MarkdownBlockParser.parse(source) - #expect(blocks.count == 1) - guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { - Issue.record("Expected code block") - return - } - #expect(code == "db.users.find({})") - #expect(language == "javascript") - #expect(isClosed == false) - } - - @Test("Paragraph before open fence remains a separate block") - func paragraphThenOpenFence() { - let source = """ - Here is the query: - - ```sql - SELECT 1 - """ - let blocks = MarkdownBlockParser.parse(source) - #expect(blocks.count == 2) - guard case .paragraph(let text) = blocks[0].kind else { - Issue.record("Expected paragraph") - return - } - #expect(text.contains("Here is the query")) - guard case .codeBlock(let code, let language, let isClosed) = blocks[1].kind else { - Issue.record("Expected code block") - return - } - #expect(code == "SELECT 1") - #expect(language == "sql") - #expect(isClosed == false) - } - - @Test("Incomplete table header without separator stays a paragraph") - func incompleteTableAsParagraph() { - let source = "| name | age |" - let blocks = MarkdownBlockParser.parse(source) - #expect(blocks.count == 1) - guard case .paragraph(let text) = blocks[0].kind else { - Issue.record("Expected paragraph for incomplete table") - return - } - #expect(text == "| name | age |") - } - - @Test("Complete table still parses with alignments") - func completeTable() { - let source = """ - | name | age | - | :--- | --: | - | Ada | 36 | - """ - let blocks = MarkdownBlockParser.parse(source) - #expect(blocks.count == 1) - guard case .table(let headers, let alignments, let rows) = blocks[0].kind else { - Issue.record("Expected table") - return - } - #expect(headers == ["name", "age"]) - #expect(alignments == [.left, .right]) - #expect(rows.count == 1) - #expect(rows[0] == ["Ada", "36"]) - } - - @Test("Headers and lists parse during partial stream") - func headersAndLists() { - let source = """ - ## Plan - - 1. Index the foreign key - 2. Rewrite the join - - also check nulls - """ - let blocks = MarkdownBlockParser.parse(source) - #expect(blocks.count >= 3) - guard case .header(let level, let text) = blocks[0].kind else { - Issue.record("Expected header") - return - } - #expect(level == 2) - #expect(text == "Plan") - guard case .orderedList(let start, let orderedItems) = blocks[1].kind else { - Issue.record("Expected ordered list") - return - } - #expect(start == 1) - #expect(orderedItems.count == 2) - guard case .unorderedList(let unorderedItems) = blocks[2].kind else { - Issue.record("Expected unordered list") - return - } - #expect(unorderedItems.count == 1) - } - - @Test("Closing a previously open fence marks the block closed") - func fenceClosesOnFinalBackticks() { - let open = MarkdownBlockParser.parse("```\nSELECT 1\n") - guard case .codeBlock(_, _, let openClosed) = open[0].kind else { - Issue.record("Expected open code block") - return - } - #expect(openClosed == false) - - let closed = MarkdownBlockParser.parse("```\nSELECT 1\n```") - guard case .codeBlock(let code, _, let isClosed) = closed[0].kind else { - Issue.record("Expected closed code block") - return - } - #expect(code == "SELECT 1") - #expect(isClosed == true) - } - - @Test("Longer closing fence matches CommonMark minimum length rule") - func longerClosingFence() { - let source = """ - ````sql - SELECT 1 - ```` - """ - let blocks = MarkdownBlockParser.parse(source) - #expect(blocks.count == 1) - guard case .codeBlock(let code, let language, let isClosed) = blocks[0].kind else { - Issue.record("Expected code block") - return - } - #expect(code == "SELECT 1") - #expect(language == "sql") - #expect(isClosed == true) - } - - @Test("Inner shorter fence does not close a longer opener") - func innerFenceDoesNotClose() { - let source = """ - ```` - ``` - still code - ```` - """ - let blocks = MarkdownBlockParser.parse(source) - #expect(blocks.count == 1) - guard case .codeBlock(let code, _, let isClosed) = blocks[0].kind else { - Issue.record("Expected code block") - return - } - #expect(code.contains("```")) - #expect(code.contains("still code")) - #expect(isClosed == true) - } - - @Test("A closed code block stays closed while a later fence streams open") - func closedBlockThenOpenBlockDuringStream() { - let source = """ - ```sql - SELECT 1 - ``` - - Then run: - - ```sql - SELECT 2 - """ - let blocks = MarkdownBlockParser.parse(source) - guard case .codeBlock(_, _, let firstClosed) = blocks.first?.kind else { - Issue.record("Expected a leading code block") - return - } - #expect(firstClosed == true) - guard case .codeBlock(let lastCode, _, let lastClosed) = blocks.last?.kind else { - Issue.record("Expected a trailing code block") - return - } - #expect(lastCode == "SELECT 2") - #expect(lastClosed == false) - } -} diff --git a/TableProTests/Views/AIChat/MarkdownDocumentTests.swift b/TableProTests/Views/AIChat/MarkdownDocumentTests.swift new file mode 100644 index 0000000000..eb71e00f31 --- /dev/null +++ b/TableProTests/Views/AIChat/MarkdownDocumentTests.swift @@ -0,0 +1,288 @@ +// +// MarkdownDocumentTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProTestSupport +import Testing + +struct MarkdownDocumentTests { + private func parse(_ source: String) -> [MarkdownBlock] { + MarkdownDocumentParser.parse(source).blocks + } + + private func plain(_ text: AttributedString) -> String { + String(text.characters) + } + + private func paragraphText(_ block: MarkdownBlock?) -> String? { + guard case .paragraph(let text)? = block?.kind else { return nil } + return plain(text) + } + + private func code(_ block: MarkdownBlock?) -> (code: String, language: String?)? { + guard case .codeBlock(let code, let language)? = block?.kind else { return nil } + return (code, language) + } + + private func list(_ block: MarkdownBlock?) -> MarkdownList? { + guard case .list(let list)? = block?.kind else { return nil } + return list + } + + private func table(_ block: MarkdownBlock?) -> MarkdownTable? { + guard case .table(let table)? = block?.kind else { return nil } + return table + } + + // MARK: - Code + + @Test("A closed fence keeps its code and language") + func closedFence() { + let blocks = parse("```sql\nSELECT 1\n```") + #expect(blocks.count == 1) + #expect(code(blocks.first)?.code == "SELECT 1") + #expect(code(blocks.first)?.language == "sql") + } + + @Test("An unclosed fence is code up to the end while it streams") + func unclosedFence() { + let blocks = parse("```sql\nSELECT * FROM users\nWHERE id = 1") + #expect(blocks.count == 1) + #expect(code(blocks.first)?.code == "SELECT * FROM users\nWHERE id = 1") + #expect(code(blocks.first)?.language == "sql") + } + + @Test("A fence with no code yet draws nothing until its first line arrives") + func openerOnly() { + #expect(parse("```sql").isEmpty) + #expect(code(parse("```sql\nS").first)?.code == "S") + } + + @Test("A tilde fence streams like a backtick one") + func unclosedTildeFence() { + let blocks = parse("~~~javascript\ndb.users.find({})") + #expect(code(blocks.first)?.code == "db.users.find({})") + #expect(code(blocks.first)?.language == "javascript") + } + + @Test("A fence with no info string has no language") + func fenceWithoutLanguage() { + #expect(code(parse("```\nSELECT 1\n```").first)?.language == nil) + } + + @Test("A paragraph before an open fence stays its own block") + func paragraphThenOpenFence() { + let blocks = parse("Here is the query:\n\n```sql\nSELECT 1") + #expect(blocks.count == 2) + #expect(paragraphText(blocks.first) == "Here is the query:") + #expect(code(blocks.last)?.code == "SELECT 1") + } + + @Test("A longer closing fence closes, and a shorter inner one does not") + func fenceLengths() { + #expect(code(parse("````sql\nSELECT 1\n````").first)?.code == "SELECT 1") + let inner = parse("````\n```\nstill code\n````") + #expect(inner.count == 1) + #expect(code(inner.first)?.code == "```\nstill code") + } + + @Test("A closed block before a fence still streaming keeps its code") + func closedThenOpen() { + let blocks = parse("```sql\nSELECT 1\n```\n\nThen run:\n\n```sql\nSELECT 2") + #expect(code(blocks.first)?.code == "SELECT 1") + #expect(code(blocks.last)?.code == "SELECT 2") + } + + // MARK: - Lists + + /// The shape of the reply in the report: a fence and a note indented under item 4, then item 5. + @Test("Content indented under a list item belongs to it, with the indentation removed") + func listItemHoldsIndentedContent() throws { + let source = """ + 3. **Quyền**: xác nhận có quyền DELETE. + 4. **Chỉ xoá chữ ký của user hiện tại**: dùng: + + ```sql + DELETE l FROM `user_signature_audit_logs` l + WHERE s.user_id IN ('', ''); + ``` + + (user_signatures.user_id là VARCHAR(191).) + + 5. **Bảng chữ ký khác không bị ảnh hưởng**. + """ + let blocks = parse(source) + #expect(blocks.count == 1) + let list = try #require(list(blocks.first)) + #expect(list.start == 3) + #expect(list.items.count == 3) + + let item = list.items[1] + #expect(item.blocks.count == 3) + #expect(paragraphText(item.blocks.first) == "Chỉ xoá chữ ký của user hiện tại: dùng:") + #expect(code(item.blocks[1])?.code == "DELETE l FROM `user_signature_audit_logs` l\nWHERE s.user_id IN ('', '');") + #expect(paragraphText(item.blocks[2]) == "(user_signatures.user_id là VARCHAR(191).)") + } + + @Test("A nested list is a block of its item") + func nestedList() throws { + let blocks = parse("- outer\n - inner a\n - inner b\n- second") + let outer = try #require(list(blocks.first)) + #expect(outer.start == nil) + #expect(outer.items.count == 2) + let inner = try #require(list(outer.items[0].blocks.last)) + #expect(inner.items.count == 2) + } + + @Test("A loose list numbered 1. 1. 1. is one list counting up") + func looseListIsOneList() throws { + let blocks = parse("1. one\n\n1. two\n\n1. three") + #expect(blocks.count == 1) + let list = try #require(list(blocks.first)) + #expect(list.start == 1) + #expect(list.items.count == 3) + } + + @Test("A heading, an ordered list and a bulleted list are three blocks") + func headerAndLists() throws { + let blocks = parse("## Plan\n\n1. Index the foreign key\n2. Rewrite the join\n- also check nulls") + #expect(blocks.count == 3) + guard case .header(let level, let text)? = blocks.first?.kind else { + Issue.record("Expected a heading") + return + } + #expect(level == 2) + #expect(plain(text) == "Plan") + #expect(try #require(list(blocks[1])).items.count == 2) + #expect(try #require(list(blocks[2])).start == nil) + } + + // MARK: - Tables + + @Test("A table keeps its header, alignments and rows") + func completeTable() throws { + let table = try #require(table(parse("| name | age |\n| :--- | --: |\n| Ada | 36 |").first)) + #expect(table.header.map(plain) == ["name", "age"]) + #expect(table.alignments == [.left, .right]) + #expect(table.rows.map { $0.map(plain) } == [["Ada", "36"]]) + } + + @Test("A table header without its separator row is a paragraph") + func incompleteTable() { + #expect(paragraphText(parse("| name | age |").first) == "| name | age |") + } + + @Test("A table right after a paragraph is a table") + func tableAfterParagraph() throws { + let blocks = parse("Results:\n| a | b |\n|---|---|\n| 1 | 2 |") + #expect(blocks.count == 2) + #expect(paragraphText(blocks.first) == "Results:") + #expect(try #require(table(blocks.last)).rows.count == 1) + } + + @Test("Rows without outer pipes are table rows") + func rowsWithoutOuterPipes() throws { + let table = try #require(table(parse("a | b\n--|--\n1 | 2\n3 | 4").first)) + #expect(table.rows.map { $0.map(plain) } == [["1", "2"], ["3", "4"]]) + } + + @Test("An empty cell keeps the cells after it in their columns") + func emptyCell() throws { + let table = try #require(table(parse("| a | b | c |\n|---|---|---|\n| 1 | | 3 |").first)) + #expect(table.rows.map { $0.map(plain) } == [["1", "", "3"]]) + } + + // MARK: - Inline + + @Test("A line break inside a paragraph stays a line break") + func softBreakIsLineBreak() { + #expect(paragraphText(parse("line one\nline two").first) == "line one\nline two") + } + + @Test("Inline styles survive the block parse") + func inlineStyles() throws { + guard case .paragraph(let text)? = parse("a **bold** and `code`").first?.kind else { + Issue.record("Expected a paragraph") + return + } + let bold = try #require(text.range(of: "bold")) + #expect(text[bold].inlinePresentationIntent == .stronglyEmphasized) + let code = try #require(text.range(of: "code")) + #expect(text[code].inlinePresentationIntent == .code) + #expect(text.presentationIntent == nil) + } + + @Test("Raw HTML shows as its text") + func rawHTML() { + #expect(paragraphText(parse("
html
").first)?.contains("
html
") == true) + } + + @Test("Empty text has no blocks") + func emptySource() { + #expect(parse("").isEmpty) + } + + // MARK: - Streaming + + @Test("A dangling bold run reads as bold while the reply streams") + func streamingRepairsDanglingBold() throws { + let blocks = MarkdownDocumentParser.parseStreaming("this is **bold").blocks + guard case .paragraph(let text)? = blocks.first?.kind else { + Issue.record("Expected a paragraph") + return + } + #expect(plain(text) == "this is bold") + let bold = try #require(text.range(of: "bold")) + #expect(text[bold].inlinePresentationIntent == .stronglyEmphasized) + } + + @Test("Code still streaming is left exactly as written") + func streamingLeavesCodeAlone() { + let blocks = MarkdownDocumentParser.parseStreaming("```sql\nSELECT **x").blocks + #expect(code(blocks.first)?.code == "SELECT **x") + } + + @Test("A row's document ends on the newest text it was given while the reply streams") + @MainActor + func streamingModelEndsOnNewestText() async throws { + let model = MarkdownDocumentModel(source: "first", isStreaming: true) + model.update(source: "first second", isStreaming: true) + model.update(source: "first second third", isStreaming: true) + try await waitUntil { + paragraphText(model.document.blocks.first) == "first second third" + } + #expect(model.isSettled == false) + } + + @Test("A finished reply keeps its last document until the final parse lands, then caches it") + @MainActor + func finishedReplySettlesAwayFromTheMainActor() async throws { + let source = "Reply \(UUID().uuidString) with **bold** text" + let model = MarkdownDocumentModel(source: String(source.dropLast(5)), isStreaming: true) + model.update(source: source, isStreaming: false) + #expect(model.isSettled == false) + try await waitUntil { model.isSettled } + #expect(model.document == MarkdownDocumentParser.parse(source)) + #expect(MarkdownDocumentCache.shared.cachedDocument(for: source) == model.document) + } + + @Test("Settled text already parsed is shown on the row's first frame") + @MainActor + func cachedTextIsShownAtOnce() { + let source = "Cached \(UUID().uuidString)" + _ = MarkdownDocumentCache.shared.document(for: source) + let model = MarkdownDocumentModel(source: source, isStreaming: false) + #expect(model.isSettled) + #expect(paragraphText(model.document.blocks.first) == source) + } + + @Test("The settled cache returns what a parse returns") + func settledCacheMatchesParse() async { + let source = "# Title\n\n- a\n- b" + await MarkdownDocumentCache.shared.prewarm([source]) + #expect(MarkdownDocumentCache.shared.document(for: source) == MarkdownDocumentParser.parse(source)) + } +}