From 98e7664e2af303da24e72a36e64d69d690dafa25 Mon Sep 17 00:00:00 2001 From: AryanRogye Date: Mon, 4 May 2026 18:45:06 -0500 Subject: [PATCH] Add experimental SwiftUI preview runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Adds SwiftUI preview pipeline (compile → load → render) - Integrates preview UI into editor - Adds SwiftSyntax/SwiftParser for source processing - Updates project configuration for preview support Note: This is a proof of concept and currently requires sandboxing to be disabled for dynamic preview execution. --- CodeEdit.xcodeproj/project.pbxproj | 75 +++--- CodeEdit/CodeEdit.entitlements | 10 +- .../Editor/Views/EditorAreaFileView.swift | 43 +++- .../SwiftUIPreviewCanvasView.swift | 63 +++++ .../SwiftUIPreviewHostView.swift | 38 +++ .../SwiftUIPreview/SwiftUIPreviewParser.swift | 78 ++++++ .../SwiftUIPreview/SwiftUIPreviewRunner.swift | 226 ++++++++++++++++++ 7 files changed, 481 insertions(+), 52 deletions(-) create mode 100644 CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewCanvasView.swift create mode 100644 CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewHostView.swift create mode 100644 CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewParser.swift create mode 100644 CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewRunner.swift diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 2b2a39b04a..ed8383d147 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -46,6 +46,8 @@ 6CD3CA552C8B508200D83DCD /* CodeEditSourceEditor in Frameworks */ = {isa = PBXBuildFile; productRef = 6CD3CA542C8B508200D83DCD /* CodeEditSourceEditor */; }; 6CE21E872C650D2C0031B056 /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 6CE21E862C650D2C0031B056 /* SwiftTerm */; }; B6FF04782B6C08AC002C2C78 /* DefaultThemes in Resources */ = {isa = PBXBuildFile; fileRef = B6FF04772B6C08AC002C2C78 /* DefaultThemes */; }; + CE5F10032EBA000100000001 /* SwiftSyntax in Frameworks */ = {isa = PBXBuildFile; productRef = CE5F10022EBA000100000001 /* SwiftSyntax */; }; + CE5F10052EBA000100000001 /* SwiftParser in Frameworks */ = {isa = PBXBuildFile; productRef = CE5F10042EBA000100000001 /* SwiftParser */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -188,6 +190,8 @@ 5E4485612DF600D9008BBE69 /* AboutWindow in Frameworks */, 6C6BD6F429CD142C00235D17 /* CollectionConcurrencyKit in Frameworks */, 6C85BB442C210EFD00EB5DEF /* SwiftUIIntrospect in Frameworks */, + CE5F10032EBA000100000001 /* SwiftSyntax in Frameworks */, + CE5F10052EBA000100000001 /* SwiftParser in Frameworks */, 6CB446402B6DFF3A00539ED0 /* CodeEditSourceEditor in Frameworks */, 6C73A6D32D4F1E550012D95C /* CodeEditSourceEditor in Frameworks */, 2816F594280CF50500DD548B /* CodeEditSymbols in Frameworks */, @@ -295,7 +299,6 @@ B658FB2827DA9E0F00EA4DBD /* Sources */, B658FB2927DA9E0F00EA4DBD /* Frameworks */, B658FB2A27DA9E0F00EA4DBD /* Resources */, - 2B18499A27F8A7A0005119F0 /* Mark // swiftlint:disable:all as errors | Run Script */, 04ADA0CC27E6043B00BF00B2 /* Add TODO/FIXME as warnings | Run Script */, 04C3255A2801B43A00C8DA2D /* Embed Frameworks */, 2BE487F528245162003F3F64 /* Embed Foundation Extensions */, @@ -304,7 +307,6 @@ buildRules = ( ); dependencies = ( - 6C7B1C762A1D57CE005CBBFC /* PBXTargetDependency */, 2BE487F328245162003F3F64 /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( @@ -337,6 +339,8 @@ 6C76D6D32E15B91E00EF52C3 /* CodeEditSourceEditor */, 6CCF6DD22E26D48F00B94F75 /* SwiftTerm */, 6CCF73CF2E26DE3200B94F75 /* SwiftTerm */, + CE5F10022EBA000100000001 /* SwiftSyntax */, + CE5F10042EBA000100000001 /* SwiftParser */, ); productName = CodeEdit; productReference = B658FB2C27DA9E0F00EA4DBD /* CodeEdit.app */; @@ -443,6 +447,7 @@ 5E44855F2DF600D9008BBE69 /* XCRemoteSwiftPackageReference "AboutWindow" */, 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */, 6CCF73CE2E26DE3200B94F75 /* XCRemoteSwiftPackageReference "SwiftTerm" */, + CE5F10012EBA000100000001 /* XCRemoteSwiftPackageReference "swift-syntax" */, ); preferredProjectObjectVersion = 55; productRefGroup = B658FB2D27DA9E0F00EA4DBD /* Products */; @@ -511,25 +516,6 @@ shellPath = /bin/sh; shellScript = "TAGS=\"TODO:|FIXME:\"\necho \"searching ${SRCROOT} for ${TAGS}\"\nfind \"${SRCROOT}\" \\( -name \"*.swift\" \\) -print0 | xargs -0 egrep --with-filename --line-number --only-matching \"($TAGS).*\\$\" | perl -p -e \"s/($TAGS)/ warning: \\$1/\"\n"; }; - 2B18499A27F8A7A0005119F0 /* Mark // swiftlint:disable:all as errors | Run Script */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Mark // swiftlint:disable:all as errors | Run Script"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "TAGS=\"\\/\\/ swiftlint:disable all\"\necho \"searching ${SRCROOT} for ${TAGS}\"\nfind \"${SRCROOT}\" \\( -name \"*.swift\" \\) -print0 | xargs -0 egrep --with-filename --line-number --only-matching \"($TAGS).*\\$\" | perl -p -e \"s/($TAGS)/ error: Usage of \\$1 is prohibited/\"\n"; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -574,10 +560,6 @@ target = 2BE487EB28245162003F3F64 /* OpenWithCodeEdit */; targetProxy = 2BE487F228245162003F3F64 /* PBXContainerItemProxy */; }; - 6C7B1C762A1D57CE005CBBFC /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - productRef = 6C7B1C752A1D57CE005CBBFC /* SwiftLint */; - }; B658FB3F27DA9E1000EA4DBD /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = B658FB2B27DA9E0F00EA4DBD /* CodeEdit */; @@ -673,8 +655,8 @@ CURRENT_PROJECT_VERSION = 47; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; - DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + DEVELOPMENT_TEAM = B9P888266K; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -874,8 +856,8 @@ CURRENT_PROJECT_VERSION = 47; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; - DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + DEVELOPMENT_TEAM = B9P888266K; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1147,8 +1129,8 @@ CURRENT_PROJECT_VERSION = 47; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; - DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + DEVELOPMENT_TEAM = B9P888266K; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1420,8 +1402,8 @@ CURRENT_PROJECT_VERSION = 47; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; - DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + DEVELOPMENT_TEAM = B9P888266K; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1464,8 +1446,8 @@ CURRENT_PROJECT_VERSION = 47; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; - DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + DEVELOPMENT_TEAM = B9P888266K; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1846,6 +1828,14 @@ kind = branch; }; }; + CE5F10012EBA000100000001 /* XCRemoteSwiftPackageReference "swift-syntax" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-syntax.git"; + requirement = { + kind = exactVersion; + version = 509.1.1; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1930,11 +1920,6 @@ package = 6C76D6D22E15B91E00EF52C3 /* XCRemoteSwiftPackageReference "CodeEditSourceEditor" */; productName = CodeEditSourceEditor; }; - 6C7B1C752A1D57CE005CBBFC /* SwiftLint */ = { - isa = XCSwiftPackageProductDependency; - package = 287136B1292A407E00E9F5F4 /* XCRemoteSwiftPackageReference "SwiftLintPlugin" */; - productName = "plugin:SwiftLint"; - }; 6C81916A29B41DD300B75C92 /* DequeModule */ = { isa = XCSwiftPackageProductDependency; package = 6C147C4329A329350089B630 /* XCRemoteSwiftPackageReference "swift-collections" */; @@ -1989,6 +1974,16 @@ isa = XCSwiftPackageProductDependency; productName = SwiftTerm; }; + CE5F10022EBA000100000001 /* SwiftSyntax */ = { + isa = XCSwiftPackageProductDependency; + package = CE5F10012EBA000100000001 /* XCRemoteSwiftPackageReference "swift-syntax" */; + productName = SwiftSyntax; + }; + CE5F10042EBA000100000001 /* SwiftParser */ = { + isa = XCSwiftPackageProductDependency; + package = CE5F10012EBA000100000001 /* XCRemoteSwiftPackageReference "swift-syntax" */; + productName = SwiftParser; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = B658FB2427DA9E0F00EA4DBD /* Project object */; diff --git a/CodeEdit/CodeEdit.entitlements b/CodeEdit/CodeEdit.entitlements index 5c1489ef3b..4b18e592aa 100644 --- a/CodeEdit/CodeEdit.entitlements +++ b/CodeEdit/CodeEdit.entitlements @@ -2,18 +2,12 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-write - - com.apple.security.files.bookmarks.app-scope - - com.apple.security.network.client - com.apple.security.application-groups app.codeedit.CodeEdit.shared $(TeamIdentifierPrefix) + com.apple.security.files.bookmarks.app-scope + diff --git a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift index e4367dcc0a..27dc9b54f6 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift @@ -21,13 +21,26 @@ struct EditorAreaFileView: View { var editorInstance: EditorInstance var codeFile: CodeFileDocument + @StateObject private var previewRunner = SwiftUIPreviewRunner() + @State private var hasSwiftUIPreview = false @ViewBuilder var editorAreaFileView: some View { if let utType = codeFile.utType, utType.conforms(to: .text) { - CodeFileView( - editorInstance: editorInstance, - codeFile: codeFile - ) + if hasSwiftUIPreview { + HSplitView { + CodeFileView( + editorInstance: editorInstance, + codeFile: codeFile + ) + + SwiftUIPreviewCanvasView(runner: previewRunner) + } + } else { + CodeFileView( + editorInstance: editorInstance, + codeFile: codeFile + ) + } } else { NonTextFileView(fileDocument: codeFile) .padding(.top, edgeInsets.top - 1.74) @@ -43,6 +56,15 @@ struct EditorAreaFileView: View { var body: some View { editorAreaFileView .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + updateSwiftUIPreview() + } + .onChange(of: codeFile.fileURL) { _, _ in + updateSwiftUIPreview() + } + .onReceive(codeFile.contentCoordinator.textUpdatePublisher) { _ in + updateSwiftUIPreview() + } .onHover { hover in DispatchQueue.main.async { if hover { @@ -53,4 +75,17 @@ struct EditorAreaFileView: View { } } } + + private func updateSwiftUIPreview() { + guard codeFile.fileURL?.pathExtension.lowercased() == "swift", + let source = codeFile.content?.string, + SwiftUIPreviewParser.firstPreview(in: source) != nil else { + hasSwiftUIPreview = false + previewRunner.clear() + return + } + + hasSwiftUIPreview = true + previewRunner.compile(source: source, fileURL: codeFile.fileURL) + } } diff --git a/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewCanvasView.swift b/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewCanvasView.swift new file mode 100644 index 0000000000..1250ff4242 --- /dev/null +++ b/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewCanvasView.swift @@ -0,0 +1,63 @@ +// +// SwiftUIPreviewCanvasView.swift +// CodeEdit +// +// Created by Aryan Rogye on 5/4/26. +// + +import SwiftUI + +struct SwiftUIPreviewCanvasView: View { + @ObservedObject var runner: SwiftUIPreviewRunner + + var body: some View { + VStack(spacing: 0) { + HStack { + Label("Preview", systemImage: "play.rectangle") + .font(.headline) + + Spacer() + + if runner.isCompiling { + ProgressView() + .controlSize(.small) + } + } + .padding(.horizontal, 12) + .frame(height: 34) + .background(EffectView(.headerView)) + + Divider() + + SwiftUIPreviewHostView(previewView: runner.previewView) + .overlay { + if runner.previewView == nil { + CEContentUnavailableView( + runner.isCompiling ? "Compiling Preview" : "No Preview Loaded" + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + if !runner.logs.isEmpty { + Divider() + + ScrollView { + LazyVStack(alignment: .leading, spacing: 4) { + ForEach(Array(runner.logs.enumerated()), id: \.offset) { _, log in + Text(log) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(8) + } + .frame(minHeight: 96, idealHeight: 140, maxHeight: 180) + } + } + .frame(minWidth: 360) + .background(Color(nsColor: .windowBackgroundColor)) + } +} diff --git a/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewHostView.swift b/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewHostView.swift new file mode 100644 index 0000000000..e61c6fcbdd --- /dev/null +++ b/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewHostView.swift @@ -0,0 +1,38 @@ +// +// SwiftUIPreviewHostView.swift +// CodeEdit +// +// Created by Aryan Rogye on 5/4/26. +// + +import AppKit +import SwiftUI + +struct SwiftUIPreviewHostView: NSViewRepresentable { + let previewView: NSView? + + func makeNSView(context: Context) -> NSView { + let container = NSView() + container.wantsLayer = true + container.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor + return container + } + + func updateNSView(_ nsView: NSView, context: Context) { + nsView.subviews.forEach { $0.removeFromSuperview() } + + guard let previewView else { + return + } + + previewView.translatesAutoresizingMaskIntoConstraints = false + nsView.addSubview(previewView) + + NSLayoutConstraint.activate([ + previewView.leadingAnchor.constraint(equalTo: nsView.leadingAnchor), + previewView.trailingAnchor.constraint(equalTo: nsView.trailingAnchor), + previewView.topAnchor.constraint(equalTo: nsView.topAnchor), + previewView.bottomAnchor.constraint(equalTo: nsView.bottomAnchor) + ]) + } +} diff --git a/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewParser.swift b/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewParser.swift new file mode 100644 index 0000000000..7caafa3606 --- /dev/null +++ b/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewParser.swift @@ -0,0 +1,78 @@ +// +// SwiftUIPreviewParser.swift +// CodeEdit +// +// Created by Aryan Rogye on 5/4/26. +// + +import Foundation +import SwiftParser +import SwiftSyntax + +struct SwiftUIPreviewSource { + let sourceWithoutPreviews: String + let previewBody: String +} + +enum SwiftUIPreviewParser { + static func firstPreview(in source: String) -> SwiftUIPreviewSource? { + let tree = Parser.parse(source: source) + let visitor = PreviewVisitor(viewMode: .sourceAccurate) + visitor.walk(tree) + + guard let preview = visitor.previews.first else { + return nil + } + + let sourceWithoutPreviews = source.removingUTF8Ranges( + visitor.previews.map(\.removalRange) + ) + + return SwiftUIPreviewSource( + sourceWithoutPreviews: sourceWithoutPreviews, + previewBody: preview.body.trimmingCharacters(in: .whitespacesAndNewlines) + ) + } +} + +private final class PreviewVisitor: SyntaxVisitor { + struct Preview { + let body: String + let removalRange: Range + } + + private(set) var previews: [Preview] = [] + + override func visit(_ node: MacroExpansionExprSyntax) -> SyntaxVisitorContinueKind { + guard node.macroName.trimmedDescription == "Preview", + let closure = node.trailingClosure else { + return .skipChildren + } + + previews.append( + Preview( + body: closure.statements.description, + removalRange: node.position.utf8Offset..]) -> String { + guard !ranges.isEmpty else { + return self + } + + var bytes = Array(utf8) + for range in ranges.sorted(by: { $0.lowerBound > $1.lowerBound }) { + guard range.lowerBound >= bytes.startIndex, range.upperBound <= bytes.endIndex else { + continue + } + bytes.removeSubrange(range) + } + + return String(bytes: bytes, encoding: .utf8) ?? self + } +} diff --git a/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewRunner.swift b/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewRunner.swift new file mode 100644 index 0000000000..30ed31dfa4 --- /dev/null +++ b/CodeEdit/Features/SwiftUIPreview/SwiftUIPreviewRunner.swift @@ -0,0 +1,226 @@ +// +// SwiftUIPreviewRunner.swift +// CodeEdit +// +// Created by Aryan Rogye on 5/4/26. +// + +import AppKit +import Darwin +import Foundation + +@MainActor +final class SwiftUIPreviewRunner: ObservableObject { + @Published var previewView: NSView? + @Published var logs: [String] = [] + @Published var isCompiling = false + + private var loadedLibraryHandles: [UnsafeMutableRawPointer] = [] + private var compileTask: Task? + + func compile(source: String, fileURL: URL?) { + compileTask?.cancel() + compileTask = Task { await compilePreview(source: source, fileURL: fileURL) } + } + + func clear() { + compileTask?.cancel() + previewView = nil + logs.removeAll() + isCompiling = false + } + + private func compilePreview(source: String, fileURL: URL?) async { + guard let previewSource = SwiftUIPreviewParser.firstPreview(in: source) else { + clear() + return + } + + isCompiling = true + previewView = nil + logs = [] + defer { isCompiling = false } + + logs.append("Found #Preview in \(fileURL?.lastPathComponent ?? "current file")") + + let packageURL = FileManager.default.temporaryDirectory + .appendingPathComponent("CodeEditPreview-\(UUID().uuidString)") + + do { + try writePreviewPackage(previewSource, to: packageURL) + } catch { + logs.append("Failed to write preview package: \(error.localizedDescription)") + return + } + + logs.append("Preview package path: \(packageURL.path)") + + guard await buildPreviewPackage(at: packageURL) else { + return + } + + let dylibURL = packageURL + .appendingPathComponent(".build") + .appendingPathComponent("debug") + .appendingPathComponent("libCodeEditPreview.dylib") + + loadPreview(from: dylibURL) + } + + private func writePreviewPackage(_ previewSource: SwiftUIPreviewSource, to packageURL: URL) throws { + let sourcesURL = packageURL + .appendingPathComponent("Sources") + .appendingPathComponent("CodeEditPreview") + + try FileManager.default.createDirectory( + at: sourcesURL, + withIntermediateDirectories: true + ) + + try packageFile.write( + to: packageURL.appendingPathComponent("Package.swift"), + atomically: true, + encoding: .utf8 + ) + + try previewSource.sourceWithoutPreviews.write( + to: sourcesURL.appendingPathComponent("PreviewSource.swift"), + atomically: true, + encoding: .utf8 + ) + + try previewFactory(previewBody: previewSource.previewBody).write( + to: sourcesURL.appendingPathComponent("PreviewFactory.swift"), + atomically: true, + encoding: .utf8 + ) + } + + private func buildPreviewPackage(at packageURL: URL) async -> Bool { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/swift") + process.arguments = ["build", "--package-path", packageURL.path] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = pipe + + let outputStream = AsyncStream { continuation in + pipe.fileHandleForReading.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty else { + continuation.finish() + return + } + if let output = String(data: data, encoding: .utf8), !output.isEmpty { + continuation.yield(output) + } + } + + process.terminationHandler = { _ in + pipe.fileHandleForReading.readabilityHandler = nil + continuation.finish() + } + } + + do { + try process.run() + } catch { + logs.append("Failed to start swift build: \(error.localizedDescription)") + return false + } + + for await output in outputStream { + guard !Task.isCancelled else { + process.terminate() + return false + } + logs.append(output) + } + + guard process.terminationStatus == 0 else { + logs.append("swift build failed with exit code \(process.terminationStatus)") + return false + } + + logs.append("swift build succeeded") + return true + } + + private func loadPreview(from dylibURL: URL) { + guard FileManager.default.fileExists(atPath: dylibURL.path) else { + logs.append("Built dylib not found at: \(dylibURL.path)") + return + } + + guard let handle = dlopen(dylibURL.path, RTLD_NOW | RTLD_LOCAL) else { + logs.append("dlopen failed: \(String(cString: dlerror()))") + return + } + + guard let symbol = dlsym(handle, "makeCodeEditPreviewView") else { + logs.append("Could not find makeCodeEditPreviewView symbol") + return + } + + typealias MakePreviewView = @convention(c) () -> UnsafeMutableRawPointer + let makePreviewView = unsafeBitCast(symbol, to: MakePreviewView.self) + let pointer = makePreviewView() + + previewView = Unmanaged.fromOpaque(pointer).takeRetainedValue() + loadedLibraryHandles.append(handle) + logs.append("Loaded preview dylib") + } + + private var packageFile: String { + """ + // swift-tools-version: 5.9 + import PackageDescription + + let package = Package( + name: "CodeEditPreview", + platforms: [ + .macOS(.v14) + ], + products: [ + .library( + name: "CodeEditPreview", + type: .dynamic, + targets: ["CodeEditPreview"] + ) + ], + targets: [ + .target(name: "CodeEditPreview") + ] + ) + """ + } + + private func previewFactory(previewBody: String) -> String { + """ + import AppKit + import SwiftUI + + private enum __CodeEditPreviewFactory { + @ViewBuilder + static func makePreview() -> some View { + \(previewBody.indentingPreviewBody()) + } + } + + @_cdecl("makeCodeEditPreviewView") + public func makeCodeEditPreviewView() -> UnsafeMutableRawPointer { + let view = NSHostingView(rootView: __CodeEditPreviewFactory.makePreview()) + return Unmanaged.passRetained(view).toOpaque() + } + """ + } +} + +private extension String { + func indentingPreviewBody() -> String { + split(separator: "\n", omittingEmptySubsequences: false) + .map { " \($0)" } + .joined(separator: "\n") + } +}