// // MarkdownContentView.swift // oAI // // Renders markdown content with syntax-highlighted code blocks // import SwiftUI #if canImport(AppKit) import AppKit #endif struct MarkdownContentView: View { let content: String let fontSize: Double var body: some View { let segments = parseSegments(content) VStack(alignment: .leading, spacing: 8) { ForEach(segments.indices, id: \.self) { index in switch segments[index] { case .text(let text): if !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { markdownText(text) } case .codeBlock(let language, let code): CodeBlockView(language: language, code: code, fontSize: fontSize) } } } } @ViewBuilder private func markdownText(_ text: String) -> some View { if let attrString = try? AttributedString(markdown: text, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { Text(attrString) .font(.system(size: fontSize)) .foregroundColor(.oaiPrimary) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) } else { Text(text) .font(.system(size: fontSize)) .foregroundColor(.oaiPrimary) .frame(maxWidth: .infinity, alignment: .leading) .textSelection(.enabled) } } // MARK: - Parsing enum Segment { case text(String) case codeBlock(language: String?, code: String) } private func parseSegments(_ content: String) -> [Segment] { var segments: [Segment] = [] let lines = content.components(separatedBy: "\n") var currentText = "" var inCodeBlock = false var codeLanguage: String? = nil var codeContent = "" for line in lines { if !inCodeBlock && line.hasPrefix("```") { // Start of code block if !currentText.isEmpty { segments.append(.text(currentText)) currentText = "" } inCodeBlock = true let lang = String(line.dropFirst(3)).trimmingCharacters(in: .whitespaces) codeLanguage = lang.isEmpty ? nil : lang codeContent = "" } else if inCodeBlock && line.hasPrefix("```") { // End of code block // Remove trailing newline from code if codeContent.hasSuffix("\n") { codeContent = String(codeContent.dropLast()) } segments.append(.codeBlock(language: codeLanguage, code: codeContent)) inCodeBlock = false codeLanguage = nil codeContent = "" } else if inCodeBlock { codeContent += line + "\n" } else { currentText += line + "\n" } } // Handle unclosed code block if inCodeBlock { if codeContent.hasSuffix("\n") { codeContent = String(codeContent.dropLast()) } segments.append(.codeBlock(language: codeLanguage, code: codeContent)) } // Remaining text if !currentText.isEmpty { // Remove trailing newline if currentText.hasSuffix("\n") { currentText = String(currentText.dropLast()) } if !currentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { segments.append(.text(currentText)) } } return segments } } // MARK: - Code Block View struct CodeBlockView: View { let language: String? let code: String let fontSize: Double @State private var copied = false var body: some View { VStack(alignment: .leading, spacing: 0) { // Header bar with language label and copy button HStack { if let lang = language, !lang.isEmpty { Text(lang) .font(.system(size: 11, weight: .medium, design: .monospaced)) .foregroundColor(Color(hex: "#888888")) } Spacer() Button(action: copyCode) { HStack(spacing: 4) { Image(systemName: copied ? "checkmark" : "doc.on.doc") .font(.system(size: 11)) if copied { Text("Copied!") .font(.system(size: 11)) } } .foregroundColor(copied ? .green : Color(hex: "#888888")) } .buttonStyle(.plain) } .padding(.horizontal, 12) .padding(.vertical, 6) .background(Color(hex: "#2d2d2d")) // Code content ScrollView(.horizontal, showsIndicators: true) { Text(SyntaxHighlighter.highlight(code: code, language: language)) .font(.system(size: fontSize - 1, design: .monospaced)) .textSelection(.enabled) .padding(12) } } .background(Color(hex: "#1e1e1e")) .cornerRadius(8) .overlay( RoundedRectangle(cornerRadius: 8) .strokeBorder(Color(hex: "#3e3e3e"), lineWidth: 1) ) } private func copyCode() { #if os(macOS) let pasteboard = NSPasteboard.general pasteboard.clearContents() pasteboard.setString(code, forType: .string) #endif withAnimation { copied = true } DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { withAnimation { copied = false } } } }