// // MarkdownContentView.swift // oAI // // Renders markdown content with syntax-highlighted code blocks // import SwiftUI import MarkdownUI #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: 12) { 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) case .table(let tableText): TableView(content: tableText, fontSize: fontSize) } } } } @ViewBuilder private func markdownText(_ text: String) -> some View { Markdown(text) .markdownTextStyle { FontSize(fontSize) ForegroundColor(.primary) } .markdownBlockStyle(\.paragraph) { configuration in configuration.label .markdownMargin(top: 0, bottom: 8) } .textSelection(.enabled) } // MARK: - Parsing enum Segment { case text(String) case codeBlock(language: String?, code: String) case table(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 = "" var inTable = false var tableLines: [String] = [] for line in lines { if !inCodeBlock && line.hasPrefix("```") { // Start of code block if inTable { segments.append(.table(tableLines.joined(separator: "\n"))) tableLines = [] inTable = false } 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 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 if line.contains("|") && (line.hasPrefix("|") || line.filter({ $0 == "|" }).count >= 2) { // Markdown table line if !inTable { // Start of table if !currentText.isEmpty { segments.append(.text(currentText)) currentText = "" } inTable = true } tableLines.append(line) } else { // Regular text if inTable { // End of table segments.append(.table(tableLines.joined(separator: "\n"))) tableLines = [] inTable = false } currentText += line + "\n" } } // Handle unclosed code block if inCodeBlock { if codeContent.hasSuffix("\n") { codeContent = String(codeContent.dropLast()) } segments.append(.codeBlock(language: codeLanguage, code: codeContent)) } // Handle unclosed table if inTable { segments.append(.table(tableLines.joined(separator: "\n"))) } // Remaining text if !currentText.isEmpty { if currentText.hasSuffix("\n") { currentText = String(currentText.dropLast()) } if !currentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { segments.append(.text(currentText)) } } return segments } } // MARK: - Table View struct TableView: View { let content: String let fontSize: Double private struct TableData { let headers: [String] let alignments: [TextAlignment] let rows: [[String]] } private var tableData: TableData { parseTable(content) } var body: some View { let data = tableData guard !data.headers.isEmpty else { return AnyView(EmptyView()) } return AnyView( VStack(alignment: .leading, spacing: 0) { // Headers HStack(spacing: 0) { ForEach(data.headers.indices, id: \.self) { index in if index < data.headers.count { Text(data.headers[index].trimmingCharacters(in: .whitespaces)) .font(.system(size: fontSize, weight: .semibold)) .foregroundColor(.oaiPrimary) .frame(maxWidth: .infinity, alignment: alignmentFor( index < data.alignments.count ? data.alignments[index] : .leading )) .padding(8) .background(Color.oaiSecondary.opacity(0.1)) if index < data.headers.count - 1 { Divider() } } } } .overlay( Rectangle() .stroke(Color.oaiSecondary.opacity(0.3), lineWidth: 1) ) // Rows ForEach(data.rows.indices, id: \.self) { rowIndex in if rowIndex < data.rows.count { HStack(spacing: 0) { ForEach(0.. Alignment { switch textAlignment { case .leading: return .leading case .center: return .center case .trailing: return .trailing } } private func parseTable(_ content: String) -> TableData { let lines = content.components(separatedBy: "\n").filter { !$0.isEmpty } guard lines.count >= 2 else { return TableData(headers: [], alignments: [], rows: []) } // Parse headers (first line) let headers = lines[0].components(separatedBy: "|") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } // Parse alignment row (second line with dashes) let alignmentLine = lines[1] let alignments = alignmentLine.components(separatedBy: "|") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } .map { cell -> TextAlignment in let trimmed = cell.trimmingCharacters(in: .whitespaces) if trimmed.hasPrefix(":") && trimmed.hasSuffix(":") { return .center } else if trimmed.hasSuffix(":") { return .trailing } else { return .leading } } // Parse data rows (remaining lines) let rows = lines.dropFirst(2).map { line in line.components(separatedBy: "|") .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } } return TableData(headers: headers, alignments: alignments, rows: rows) } } // 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 } } } }