From dbf1d89553a0d896c9cbca98e8aedaae7f8403fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 09:25:58 +0700 Subject: [PATCH 1/2] fix(plugin-postgresql): rename versioned capabilities to avoid PluginCapabilities collision --- .../PostgreSQLCapabilities.swift | 1 + .../PostgreSQLPluginDriver+Columns.swift | 4 ++-- .../PostgreSQLPluginDriver.swift | 18 +++++++++--------- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift index 597f6f910..51f79fd93 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift @@ -24,4 +24,5 @@ struct PostgreSQLCapabilities: Sendable, Equatable { var hasDatabaseICULocale: Bool { serverVersion >= 150_000 } var hasDatabaseLocale: Bool { serverVersion >= 170_000 } + var hasModernICUSyntax: Bool { serverVersion >= 160_000 } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift index c0aef3054..f1d4360f6 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver+Columns.swift @@ -10,7 +10,7 @@ extension PostgreSQLPluginDriver { func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { let safeSchema = escapeLiteralForColumns(currentSchema ?? "public") let safeTable = escapeLiteralForColumns(table) - let caps = capabilities + let caps = versionedCapabilities let identityProjection = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text" let generatedProjection = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" let attributeJoin = (caps.hasIdentityColumns || caps.hasGeneratedColumns) ? """ @@ -60,7 +60,7 @@ extension PostgreSQLPluginDriver { func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] { let safeSchema = escapeLiteralForColumns(currentSchema ?? "public") - let caps = capabilities + let caps = versionedCapabilities let identityProjection = caps.hasIdentityColumns ? "a.attidentity" : "NULL::text" let generatedProjection = caps.hasGeneratedColumns ? "a.attgenerated" : "NULL::text" let attributeJoin = (caps.hasIdentityColumns || caps.hasGeneratedColumns) ? """ diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 7f97515b2..7c9a85a21 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -22,7 +22,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var supportsTransactions: Bool { true } var serverVersion: String? { libpqConnection?.serverVersion() } var serverVersionNumber: Int32 { libpqConnection?.serverVersionNumber() ?? 0 } - var capabilities: PostgreSQLCapabilities { + var versionedCapabilities: PostgreSQLCapabilities { PostgreSQLCapabilities(serverVersion: serverVersionNumber) } var parameterStyle: ParameterStyle { .dollar } @@ -234,7 +234,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchTables(schema: String?) async throws -> [PluginTableInfo] { let schemaLiteral = escapeLiteral(schema ?? _currentSchema) - let caps = capabilities + let caps = versionedCapabilities var unions: [String] = [ """ @@ -284,7 +284,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { - let columnOrdering = capabilities.hasArrayPosition + let columnOrdering = versionedCapabilities.hasArrayPosition ? "ORDER BY array_position(ix.indkey, a.attnum)" : "ORDER BY a.attnum" let query = """ @@ -433,7 +433,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { func fetchTableDDL(table: String, schema: String?) async throws -> String { let safeTable = escapeLiteral(table) let quotedTable = "\"\(table.replacingOccurrences(of: "\"", with: "\"\""))\"" - let caps = capabilities + let caps = versionedCapabilities let identityClause: String = caps.hasIdentityColumns ? """ CASE @@ -672,7 +672,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] { - guard capabilities.hasSequencesCatalog else { return [] } + guard versionedCapabilities.hasSequencesCatalog else { return [] } let safeTable = escapeLiteral(table) let query = """ SELECT s.sequencename, @@ -720,7 +720,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ] func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec? { - let supportsProvider = capabilities.hasDatabaseICULocale + let supportsProvider = versionedCapabilities.hasDatabaseICULocale async let templateDefaultsTask = fetchTemplate1Defaults() async let collationsTask = fetchCollations() @@ -813,7 +813,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { var sql = "CREATE DATABASE \"\(quotedName)\" ENCODING '\(encoding)'" - let supportsProvider = capabilities.hasDatabaseICULocale + let supportsProvider = versionedCapabilities.hasDatabaseICULocale let provider = supportsProvider ? (request.values["provider"] ?? "libc") : "libc" switch provider { @@ -873,7 +873,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { ) } let escapedIcu = escapeLiteral(icuLocale) - if let major = majorVersion, major >= 16 { + if versionedCapabilities.hasModernICUSyntax { sql += " LOCALE_PROVIDER 'icu' LOCALE '\(escapedIcu)' TEMPLATE template0" } else { sql += " LOCALE_PROVIDER 'icu' ICU_LOCALE '\(escapedIcu)' LC_COLLATE 'C' LC_CTYPE 'C' TEMPLATE template0" @@ -903,7 +903,7 @@ final class PostgreSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } private func fetchTemplate1Defaults() async -> Template1Defaults? { - let caps = capabilities + let caps = versionedCapabilities let selectColumns: String if caps.hasDatabaseLocale { selectColumns = "datcollate, datctype, datlocprovider, datlocale" From 3ce4020a9916d6b5baaea5c6e943361d1bb8ea79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 13 May 2026 09:28:52 +0700 Subject: [PATCH 2/2] fix(ai-chat): cache markdown parse, equatable code blocks, native scrollPosition (#1239) --- CHANGELOG.md | 1 + .../Views/AIChat/AIChatCodeBlockView.swift | 6 +- TablePro/Views/AIChat/AIChatPanelView.swift | 126 ++++++++---------- TablePro/Views/AIChat/MarkdownView.swift | 119 +++++++++++------ 4 files changed, 140 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6478585a1..2b85a50b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- AI Chat: scrolling no longer pegs CPU at 100% or shows blank chat after a long response. Markdown is parsed once per message and cached, code blocks reuse their editors instead of being recreated on every re-render, and scroll position uses the native `.scrollPosition` API. (#1239) - Cassandra: connection now fails fast with a clear "Cassandra 2.x is not supported" message instead of cryptic "table not found" errors during sidebar load. - MongoDB: dropped the `nameOnly: true` flag on `listDatabases` for servers older than 3.4, which previously rejected the flag. - ClickHouse: index sidebar no longer fails on ClickHouse older than 19.17 by skipping the `system.data_skipping_indices` lookup when the table doesn't exist. diff --git a/TablePro/Views/AIChat/AIChatCodeBlockView.swift b/TablePro/Views/AIChat/AIChatCodeBlockView.swift index 539486f41..1b812a951 100644 --- a/TablePro/Views/AIChat/AIChatCodeBlockView.swift +++ b/TablePro/Views/AIChat/AIChatCodeBlockView.swift @@ -8,10 +8,14 @@ import CodeEditLanguages import CodeEditSourceEditor import SwiftUI -struct AIChatCodeBlockView: View { +struct AIChatCodeBlockView: View, Equatable { let code: String let language: String? + static func == (lhs: AIChatCodeBlockView, rhs: AIChatCodeBlockView) -> Bool { + lhs.code == rhs.code && lhs.language == rhs.language + } + @State private var isCopied: Bool = false @State private var isEditorReady = false @State private var editorState = SourceEditorState() diff --git a/TablePro/Views/AIChat/AIChatPanelView.swift b/TablePro/Views/AIChat/AIChatPanelView.swift index f01b0c0c3..df6bf638d 100644 --- a/TablePro/Views/AIChat/AIChatPanelView.swift +++ b/TablePro/Views/AIChat/AIChatPanelView.swift @@ -17,7 +17,8 @@ struct AIChatPanelView: View { @Bindable var viewModel: AIChatViewModel private let settingsManager = AppSettingsManager.shared - @State private var isUserScrolledUp = false + @State private var bottomVisibleMessageID: UUID? + @State private var pinnedToBottom: Bool = true @State private var mentionState = MentionPopoverState() private var hasConfiguredProvider: Bool { @@ -106,70 +107,71 @@ struct AIChatPanelView: View { return ids }() - return ScrollViewReader { proxy in - ZStack(alignment: .bottom) { - 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 - ) - .padding(.vertical, 4) - .id(message.id) + let lastMessageID = visibleMessages.last?.id + let isUserScrolledUp = !pinnedToBottom && bottomVisibleMessageID != nil + && bottomVisibleMessageID != lastMessageID + + return ZStack(alignment: .bottom) { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(visibleMessages) { message in + if spacedMessageIDs.contains(message.id) { + Spacer() + .frame(height: 16) } - - Color.clear - .frame(height: 1) - .id("bottomAnchor") - .onAppear { isUserScrolledUp = false } - .onDisappear { isUserScrolledUp = true } + 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 + ) + .padding(.vertical, 4) + .id(message.id) } - .padding(.horizontal, 8) - .padding(.vertical, 8) - } - .defaultScrollAnchor(.bottom) - .scrollIndicators(.hidden) - .onAppear { - scrollToBottom(proxy: proxy) } - .onChange(of: viewModel.messages.count) { - isUserScrolledUp = false - scrollToBottom(proxy: proxy, animated: true) - } - .onChange(of: viewModel.activeConversationID) { - isUserScrolledUp = false - scrollToBottom(proxy: proxy, animated: true) + .padding(.horizontal, 8) + .padding(.vertical, 8) + .scrollTargetLayout() + } + .defaultScrollAnchor(.bottom) + .scrollIndicators(.hidden) + .scrollPosition(id: $bottomVisibleMessageID, anchor: .bottom) + .onChange(of: bottomVisibleMessageID) { _, newValue in + pinnedToBottom = newValue == nil || newValue == lastMessageID + } + .onChange(of: visibleMessages.count) { + if pinnedToBottom { + bottomVisibleMessageID = lastMessageID } - .onChange(of: viewModel.isStreaming) { _, newValue in - if !newValue, !isUserScrolledUp { - scrollToBottom(proxy: proxy, animated: true) - } + } + .onChange(of: viewModel.activeConversationID) { + pinnedToBottom = true + bottomVisibleMessageID = lastMessageID + } + .onChange(of: viewModel.isStreaming) { _, newValue in + if !newValue, pinnedToBottom { + bottomVisibleMessageID = lastMessageID } + } - if isUserScrolledUp { - Button { - isUserScrolledUp = false - scrollToBottom(proxy: proxy, animated: true) - } label: { - Image(systemName: "arrow.down.circle.fill") - .font(.title2) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(.secondary) + if isUserScrolledUp { + Button { + pinnedToBottom = true + withAnimation(.easeOut(duration: 0.2)) { + bottomVisibleMessageID = lastMessageID } - .buttonStyle(.plain) - .padding(.bottom, 8) - .transition(.opacity) - .animation(.easeInOut(duration: 0.2), value: isUserScrolledUp) - .accessibilityLabel(String(localized: "Scroll to latest message")) + } label: { + Image(systemName: "arrow.down.circle.fill") + .font(.title2) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(.secondary) } + .buttonStyle(.plain) + .padding(.bottom, 8) + .transition(.opacity) + .animation(.easeInOut(duration: 0.2), value: isUserScrolledUp) + .accessibilityLabel(String(localized: "Scroll to latest message")) } } } @@ -488,16 +490,6 @@ struct AIChatPanelView: View { // MARK: - Helpers - private func scrollToBottom(proxy: ScrollViewProxy, animated: Bool = false) { - if animated { - withAnimation(.easeOut(duration: 0.2)) { - proxy.scrollTo("bottomAnchor", anchor: .bottom) - } - } else { - proxy.scrollTo("bottomAnchor", anchor: .bottom) - } - } - private func updateContext() { viewModel.currentQuery = currentQuery viewModel.queryResults = queryResults diff --git a/TablePro/Views/AIChat/MarkdownView.swift b/TablePro/Views/AIChat/MarkdownView.swift index 416be61a5..f702266ba 100644 --- a/TablePro/Views/AIChat/MarkdownView.swift +++ b/TablePro/Views/AIChat/MarkdownView.swift @@ -11,28 +11,47 @@ import SwiftUI struct MarkdownView: View { let source: String + @State private var cache = MarkdownDocumentCache() var body: some View { - let blocks = MarkdownBlockParser.parse(source) + let blocks = cache.blocks(for: source) VStack(alignment: .leading, spacing: 6) { ForEach(blocks) { block in MarkdownBlockView(block: block) + .equatable() } } } } -private struct MarkdownBlockView: View { +@MainActor +final class MarkdownDocumentCache { + private var lastSource: String = "\u{FFFF}" + private var lastBlocks: [MarkdownBlock] = [] + + func blocks(for source: String) -> [MarkdownBlock] { + if source == lastSource { return lastBlocks } + lastSource = source + lastBlocks = MarkdownBlockParser.parse(source) + return lastBlocks + } +} + +private struct MarkdownBlockView: View, Equatable { let block: MarkdownBlock + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.block == rhs.block + } + var body: some View { switch block.kind { case .paragraph(let text): - Text(attributed(text)) + Text(MarkdownInline.parse(text)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) case .header(let level, let text): - Text(attributed(text)) + Text(MarkdownInline.parse(text)) .font(headerFont(for: level)) .fontWeight(headerWeight(for: level)) .padding(.top, level == 1 ? 6 : 4) @@ -41,6 +60,7 @@ private struct MarkdownBlockView: View { .frame(maxWidth: .infinity, alignment: .leading) case .codeBlock(let code, let language): AIChatCodeBlockView(code: code, language: language) + .equatable() case .unorderedList(let items): MarkdownListView(items: items, style: .unordered) case .orderedList(let start, let items): @@ -67,10 +87,6 @@ private struct MarkdownBlockView: View { private func headerWeight(for level: Int) -> Font.Weight { level <= 2 ? .bold : .semibold } - - private func attributed(_ markdown: String) -> AttributedString { - MarkdownInline.parse(markdown) - } } // MARK: - List @@ -79,7 +95,7 @@ private struct MarkdownListView: View { let items: [MarkdownListItem] let style: ListStyle - enum ListStyle { + enum ListStyle: Equatable { case unordered case ordered(start: Int) } @@ -98,6 +114,7 @@ private struct MarkdownListView: View { if !item.children.isEmpty { ForEach(item.children) { child in MarkdownBlockView(block: child) + .equatable() } .padding(.leading, 4) } @@ -187,24 +204,38 @@ private struct MarkdownTableView: View { // MARK: - Inline parsing enum MarkdownInline { + private static let cache: NSCache = { + let c = NSCache() + c.countLimit = 4_000 + return c + }() + static func parse(_ source: String) -> AttributedString { + let key = source as NSString + if let cached = cache.object(forKey: key) { + return AttributedString(cached) + } let options = AttributedString.MarkdownParsingOptions( interpretedSyntax: .inlineOnlyPreservingWhitespace ) - if let attributed = try? AttributedString(markdown: source, options: options) { - return attributed + let attributed: AttributedString + if let parsed = try? AttributedString(markdown: source, options: options) { + attributed = parsed + } else { + attributed = AttributedString(source) } - return AttributedString(source) + cache.setObject(NSAttributedString(attributed), forKey: key) + return attributed } } // MARK: - Block model -struct MarkdownBlock: Identifiable { - let id = UUID() +struct MarkdownBlock: Identifiable, Equatable { + let id: Int let kind: Kind - enum Kind { + enum Kind: Equatable { case paragraph(String) case header(level: Int, text: String) case codeBlock(code: String, language: String?) @@ -216,13 +247,13 @@ struct MarkdownBlock: Identifiable { } } -struct MarkdownListItem: Identifiable { - let id = UUID() +struct MarkdownListItem: Identifiable, Equatable { + let id: Int let text: String let children: [MarkdownBlock] } -enum MarkdownTableAlignment { +enum MarkdownTableAlignment: Equatable { case left case center case right @@ -235,14 +266,14 @@ enum MarkdownBlockParser { var lines = source.components(separatedBy: "\n") var blocks: [MarkdownBlock] = [] while !lines.isEmpty { - if let parsed = parseNextBlock(&lines) { + if let parsed = parseNextBlock(&lines, index: blocks.count) { blocks.append(parsed) } } return blocks } - private static func parseNextBlock(_ lines: inout [String]) -> MarkdownBlock? { + private static func parseNextBlock(_ lines: inout [String], index: Int) -> MarkdownBlock? { guard let first = lines.first else { return nil } let trimmed = first.trimmingCharacters(in: .whitespaces) @@ -252,38 +283,38 @@ enum MarkdownBlockParser { } if isFencedCodeStart(trimmed) { - return parseFencedCodeBlock(&lines) + return parseFencedCodeBlock(&lines, index: index) } if let level = headerLevel(trimmed) { lines.removeFirst() let stripped = String(trimmed.dropFirst(level + 1)) .trimmingCharacters(in: .whitespaces) - return MarkdownBlock(kind: .header(level: level, text: stripped)) + return MarkdownBlock(id: index, kind: .header(level: level, text: stripped)) } if isThematicBreak(trimmed) { lines.removeFirst() - return MarkdownBlock(kind: .thematicBreak) + return MarkdownBlock(id: index, kind: .thematicBreak) } if trimmed.hasPrefix(">") { - return parseBlockquote(&lines) + return parseBlockquote(&lines, index: index) } if isUnorderedListMarker(trimmed) { - return parseUnorderedList(&lines) + return parseUnorderedList(&lines, index: index) } if let start = orderedListStart(trimmed) { - return parseOrderedList(&lines, startingAt: start) + return parseOrderedList(&lines, startingAt: start, index: index) } - if let table = parseTable(&lines) { + if let table = parseTable(&lines, index: index) { return table } - return parseParagraph(&lines) + return parseParagraph(&lines, index: index) } private static func headerLevel(_ trimmed: String) -> Int? { @@ -325,7 +356,7 @@ enum MarkdownBlockParser { return value } - private static func parseFencedCodeBlock(_ lines: inout [String]) -> MarkdownBlock { + private static func parseFencedCodeBlock(_ lines: inout [String], index: Int) -> MarkdownBlock { let opener = lines.removeFirst() let trimmedOpener = opener.trimmingCharacters(in: .whitespaces) let fence = trimmedOpener.hasPrefix("```") ? "```" : "~~~" @@ -342,10 +373,10 @@ enum MarkdownBlockParser { bodyLines.append(line) lines.removeFirst() } - return MarkdownBlock(kind: .codeBlock(code: bodyLines.joined(separator: "\n"), language: language)) + return MarkdownBlock(id: index, kind: .codeBlock(code: bodyLines.joined(separator: "\n"), language: language)) } - private static func parseBlockquote(_ lines: inout [String]) -> MarkdownBlock { + private static func parseBlockquote(_ lines: inout [String], index: Int) -> MarkdownBlock { var collected: [String] = [] while let line = lines.first { let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -355,26 +386,26 @@ enum MarkdownBlockParser { collected.append(String(content)) lines.removeFirst() } - return MarkdownBlock(kind: .blockquote(collected.joined(separator: "\n"))) + return MarkdownBlock(id: index, kind: .blockquote(collected.joined(separator: "\n"))) } - private static func parseUnorderedList(_ lines: inout [String]) -> MarkdownBlock { + private static func parseUnorderedList(_ lines: inout [String], index: Int) -> MarkdownBlock { var items: [MarkdownListItem] = [] - while let item = parseListItem(&lines, ordered: false) { + while let item = parseListItem(&lines, ordered: false, itemIndex: items.count) { items.append(item) } - return MarkdownBlock(kind: .unorderedList(items)) + return MarkdownBlock(id: index, kind: .unorderedList(items)) } - private static func parseOrderedList(_ lines: inout [String], startingAt start: Int) -> MarkdownBlock { + private static func parseOrderedList(_ lines: inout [String], startingAt start: Int, index: Int) -> MarkdownBlock { var items: [MarkdownListItem] = [] - while let item = parseListItem(&lines, ordered: true) { + while let item = parseListItem(&lines, ordered: true, itemIndex: items.count) { items.append(item) } - return MarkdownBlock(kind: .orderedList(start: start, items: items)) + return MarkdownBlock(id: index, kind: .orderedList(start: start, items: items)) } - private static func parseListItem(_ lines: inout [String], ordered: Bool) -> MarkdownListItem? { + 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 @@ -401,10 +432,10 @@ enum MarkdownBlockParser { } break } - return MarkdownListItem(text: textLines.joined(separator: " "), children: []) + return MarkdownListItem(id: itemIndex, text: textLines.joined(separator: " "), children: []) } - private static func parseTable(_ lines: inout [String]) -> MarkdownBlock? { + 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] @@ -423,7 +454,7 @@ enum MarkdownBlockParser { rows.append(Array(padded.prefix(headers.count))) lines.removeFirst() } - return MarkdownBlock(kind: .table(headers: headers, alignments: alignments, rows: rows)) + return MarkdownBlock(id: index, kind: .table(headers: headers, alignments: alignments, rows: rows)) } private static func looksLikeTableRow(_ line: String, columnCount: Int) -> Bool { @@ -468,7 +499,7 @@ enum MarkdownBlockParser { .map { $0.trimmingCharacters(in: .whitespaces) } } - private static func parseParagraph(_ lines: inout [String]) -> MarkdownBlock { + private static func parseParagraph(_ lines: inout [String], index: Int) -> MarkdownBlock { var collected: [String] = [] while let line = lines.first { let trimmed = line.trimmingCharacters(in: .whitespaces) @@ -482,7 +513,7 @@ enum MarkdownBlockParser { collected.append(line) lines.removeFirst() } - return MarkdownBlock(kind: .paragraph(collected.joined(separator: "\n"))) + return MarkdownBlock(id: index, kind: .paragraph(collected.joined(separator: "\n"))) } }