diff --git a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift index 4ea8584..3f5fb5e 100644 --- a/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift +++ b/NucleicRemote/NucleicRemote/Views/Transcript/MarkdownText.swift @@ -40,14 +40,19 @@ struct MarkdownText: View { /// (scrolling, a sibling row streaming) and on every chat reopen, yet structural parsing /// is independent of `bodySize` — so the source string is a complete key. Bounded; /// `NSCache` also evicts under memory pressure. + /// + /// The whole parse pipeline (both caches and the statics below) is `nonisolated`: `prewarm` + /// runs it from a detached background task by design, and as `View` statics they'd otherwise + /// be implicitly MainActor (a Swift 6 error for that call). `(unsafe)` on the caches is + /// sound because `NSCache` is thread-safe and the boxed values are immutable. private final class ParsedBlocks { let blocks: [Block]; init(_ b: [Block]) { self.blocks = b } } - private static let blockCache: NSCache = { + nonisolated(unsafe) private static let blockCache: NSCache = { let cache = NSCache() cache.countLimit = 2048 return cache }() - private static func parse(_ markdown: String) -> [Block] { + nonisolated private static func parse(_ markdown: String) -> [Block] { let key = markdown as NSString if let hit = blockCache.object(forKey: key) { return hit.blocks } let blocks = parseUncached(markdown) @@ -55,7 +60,7 @@ struct MarkdownText: View { return blocks } - private static func parseUncached(_ markdown: String) -> [Block] { + nonisolated private static func parseUncached(_ markdown: String) -> [Block] { var blocks: [Block] = [] var textBuffer: [String] = [] func flush() { @@ -100,12 +105,12 @@ struct MarkdownText: View { /// A GitHub-style table: a `|`-bearing header line immediately followed by a /// `|---|:--:|` separator line. - private static func isTableStart(_ lines: [String], _ index: Int) -> Bool { + nonisolated private static func isTableStart(_ lines: [String], _ index: Int) -> Bool { guard lines[index].contains("|"), index + 1 < lines.count else { return false } return isSeparatorRow(lines[index + 1]) } - private static func isSeparatorRow(_ line: String) -> Bool { + nonisolated private static func isSeparatorRow(_ line: String) -> Bool { let cells = tableCells(line) guard !cells.isEmpty else { return false } return cells.allSatisfy { cell in @@ -115,7 +120,7 @@ struct MarkdownText: View { /// Split a table row into trimmed cells, dropping the empties created by the /// leading/trailing pipes. - private static func tableCells(_ line: String) -> [String] { + nonisolated private static func tableCells(_ line: String) -> [String] { var trimmed = line.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix("|") { trimmed.removeFirst() } if trimmed.hasSuffix("|") { trimmed.removeLast() } @@ -189,7 +194,7 @@ struct MarkdownText: View { /// Classify one raw prose line. Headings are checked before bullets (a heading marker wins), /// and the plain case keeps the *raw* line (not the trimmed one) exactly as the old cascade /// did — the inline parser preserves leading whitespace under `.inlineOnlyPreservingWhitespace`. - private static func classify(_ raw: String) -> LineStyle { + nonisolated private static func classify(_ raw: String) -> LineStyle { let trimmed = raw.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { return .blank } // Headings scale relative to the base prose size so the hierarchy holds at any base and @@ -219,7 +224,7 @@ struct MarkdownText: View { } /// Returns the content after a `- `, `* `, `+ ` or `N. ` list marker, else nil. - private static func bulletContent(_ trimmed: String) -> String? { + nonisolated private static func bulletContent(_ trimmed: String) -> String? { for marker in ["- ", "* ", "+ "] where trimmed.hasPrefix(marker) { return String(trimmed.dropFirst(marker.count)) } @@ -241,13 +246,13 @@ struct MarkdownText: View { /// recur across re-renders and reopens, so memoize the parsed result. Independent of /// `bodySize` (callers apply the font), so the source string is a complete key. private final class InlineBox { let value: AttributedString; init(_ v: AttributedString) { self.value = v } } - private static let inlineCache: NSCache = { + nonisolated(unsafe) private static let inlineCache: NSCache = { let cache = NSCache() cache.countLimit = 16384 return cache }() - private static func attributedInline(_ string: String) -> AttributedString { + nonisolated private static func attributedInline(_ string: String) -> AttributedString { let key = string as NSString if let hit = inlineCache.object(forKey: key) { return hit.value } let options = AttributedString.MarkdownParsingOptions( @@ -272,7 +277,7 @@ struct MarkdownText: View { /// keyed only by the source string (independent of `bodySize`), so a warm value equals what /// the main thread would compute, and a repeat call is a cheap cache hit. Cooperatively /// cancellable — a huge transcript's warm loop bails the moment its owning task is cancelled. - static func prewarm(_ markdown: String) { + nonisolated static func prewarm(_ markdown: String) { for block in parse(markdown) { // also warms the block cache if Task.isCancelled { return } switch block {