From f12e7a571f0df81c3a4d5ba928ab862ab0aa7a4b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 15 Oct 2026 18:12:43 +0700 Subject: [PATCH] perf(ai-chat): scroll the transcript without stutter Code blocks were each a full SourceEditor, built every time a row scrolled into the lazy stack and sized by an estimate that ignored the gutter, so they cut off lines and caught the scroll. They are now selectable text in the editor's font and colors, colored off the main actor from the editor's own grammar. Markdown comes from Foundation's parser, so content indented under a list item stays in the item and tables parse as GFM. Parses after a row's first frame run off the main actor. The end of the transcript is measured outside the lazy stack, so the reply is followed while the reader is at the end and left alone once they scroll up. Typing publishes on the draft only, reasoning text streams again, and image thumbnails are downsampled once. --- .swiftlint.yml | 2 +- CHANGELOG.md | 8 + .../TableProEditorKit/Theme/EditorTheme.swift | 4 +- .../TreeSitterClient+Temporary.swift | 52 +- TablePro/Core/AI/Images/AIImageCache.swift | 37 ++ TablePro/ViewModels/AIChatViewModel.swift | 8 +- TablePro/ViewModels/ChatComposerDraft.swift | 27 + .../Views/AIChat/AIChatCodeBlockView.swift | 168 ++---- .../Views/AIChat/AIChatMessageSpacing.swift | 18 - TablePro/Views/AIChat/AIChatPanelView.swift | 271 ++------- .../AIChat/AIChatReasoningBlockView.swift | 29 +- .../Views/AIChat/AIChatTranscriptView.swift | 294 +++++++++ .../Views/AIChat/ChatImageThumbnailView.swift | 31 +- .../AIChat/CodeBlockHeightEstimator.swift | 52 -- .../Views/AIChat/CodeBlockHighlighter.swift | 148 +++++ TablePro/Views/AIChat/MarkdownDocument.swift | 364 +++++++++++ TablePro/Views/AIChat/MarkdownView.swift | 564 +++--------------- .../Views/AIChat/MentionPopoverState.swift | 4 + .../Editor/Folding/FoldPreviewMetrics.swift | 5 +- .../Core/AI/AIImageCacheThumbnailTests.swift | 44 ++ .../Core/AI/ChatTurnObservationTests.swift | 23 + .../Core/AI/MentionPopoverStateTests.swift | 34 ++ .../Models/TrailingPaneStateTests.swift | 8 +- .../AIChat/AIChatMessageSpacingTests.swift | 58 -- .../Views/AIChat/AIChatTranscriptTests.swift | 98 +++ .../CodeBlockHeightEstimatorTests.swift | 75 --- .../AIChat/CodeBlockHighlighterTests.swift | 135 +++++ .../AIChat/MarkdownBlockParserTests.swift | 243 -------- .../Views/AIChat/MarkdownDocumentTests.swift | 288 +++++++++ 29 files changed, 1781 insertions(+), 1311 deletions(-) create mode 100644 TablePro/ViewModels/ChatComposerDraft.swift delete mode 100644 TablePro/Views/AIChat/AIChatMessageSpacing.swift create mode 100644 TablePro/Views/AIChat/AIChatTranscriptView.swift delete mode 100644 TablePro/Views/AIChat/CodeBlockHeightEstimator.swift create mode 100644 TablePro/Views/AIChat/CodeBlockHighlighter.swift create mode 100644 TablePro/Views/AIChat/MarkdownDocument.swift create mode 100644 TableProTests/Core/AI/AIImageCacheThumbnailTests.swift delete mode 100644 TableProTests/Views/AIChat/AIChatMessageSpacingTests.swift create mode 100644 TableProTests/Views/AIChat/AIChatTranscriptTests.swift delete mode 100644 TableProTests/Views/AIChat/CodeBlockHeightEstimatorTests.swift create mode 100644 TableProTests/Views/AIChat/CodeBlockHighlighterTests.swift delete mode 100644 TableProTests/Views/AIChat/MarkdownBlockParserTests.swift create mode 100644 TableProTests/Views/AIChat/MarkdownDocumentTests.swift 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 eec8d803cc..b926f35bb3 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. ### Removed @@ -115,6 +117,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. ### 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)) + } +}