diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index ff63c4974c..cd3ef1c94a 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -397,7 +397,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1330; - LastUpgradeCheck = 1640; + LastUpgradeCheck = 2610; TargetAttributes = { 2BE487EB28245162003F3F64 = { CreatedOnToolsVersion = 13.3.1; @@ -650,6 +650,7 @@ OTHER_SWIFT_FLAGS = "-D ALPHA"; RUN_DOCUMENTATION_COMPILER = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SYSTEM_FRAMEWORK_SEARCH_PATHS = ""; @@ -673,8 +674,21 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = CodeEdit/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; @@ -690,6 +704,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; + RUNTIME_EXCEPTION_ALLOW_JIT = YES; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; RUN_DOCUMENTATION_COMPILER = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; @@ -847,6 +863,7 @@ OTHER_SWIFT_FLAGS = "-D BETA"; RUN_DOCUMENTATION_COMPILER = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SYSTEM_FRAMEWORK_SEARCH_PATHS = ""; @@ -870,8 +887,21 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = CodeEdit/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; @@ -887,6 +917,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; + RUNTIME_EXCEPTION_ALLOW_JIT = YES; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; RUN_DOCUMENTATION_COMPILER = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; @@ -1115,6 +1147,7 @@ OTHER_SWIFT_FLAGS = "-D ALPHA"; RUN_DOCUMENTATION_COMPILER = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SYSTEM_FRAMEWORK_SEARCH_PATHS = ""; @@ -1139,8 +1172,21 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = CodeEdit/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; @@ -1156,6 +1202,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; + RUNTIME_EXCEPTION_ALLOW_JIT = YES; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; RUN_DOCUMENTATION_COMPILER = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; @@ -1319,6 +1367,7 @@ ONLY_ACTIVE_ARCH = YES; RUN_DOCUMENTATION_COMPILER = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SYSTEM_FRAMEWORK_SEARCH_PATHS = ""; @@ -1384,6 +1433,7 @@ MTL_FAST_MATH = YES; RUN_DOCUMENTATION_COMPILER = YES; SDKROOT = macosx; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; SYSTEM_FRAMEWORK_SEARCH_PATHS = ""; @@ -1407,8 +1457,21 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = CodeEdit/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; @@ -1424,6 +1487,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; + RUNTIME_EXCEPTION_ALLOW_JIT = YES; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; RUN_DOCUMENTATION_COMPILER = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; @@ -1448,8 +1513,21 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; + ENABLE_APP_SANDBOX = YES; + ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; ENABLE_HARDENED_RUNTIME = YES; + ENABLE_INCOMING_NETWORK_CONNECTIONS = YES; + ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES; ENABLE_PREVIEWS = YES; + ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = NO; + ENABLE_RESOURCE_ACCESS_BLUETOOTH = NO; + ENABLE_RESOURCE_ACCESS_CALENDARS = NO; + ENABLE_RESOURCE_ACCESS_CAMERA = NO; + ENABLE_RESOURCE_ACCESS_CONTACTS = NO; + ENABLE_RESOURCE_ACCESS_LOCATION = NO; + ENABLE_RESOURCE_ACCESS_PRINTING = NO; + ENABLE_RESOURCE_ACCESS_USB = NO; + ENABLE_USER_SELECTED_FILES = readwrite; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = CodeEdit/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; @@ -1465,6 +1543,8 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; REGISTER_APP_GROUPS = YES; + RUNTIME_EXCEPTION_ALLOW_JIT = YES; + RUNTIME_EXCEPTION_DISABLE_LIBRARY_VALIDATION = YES; RUN_DOCUMENTATION_COMPILER = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OBJC_BRIDGING_HEADER = ""; @@ -1597,7 +1677,7 @@ 28052DF32973045C00F4F90A /* Beta */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Pre; }; B658FB2727DA9E0F00EA4DBD /* Build configuration list for PBXProject "CodeEdit" */ = { isa = XCConfigurationList; @@ -1609,7 +1689,7 @@ 28052DEF2973045C00F4F90A /* Beta */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Pre; }; B658FB5127DA9E1000EA4DBD /* Build configuration list for PBXNativeTarget "CodeEdit" */ = { isa = XCConfigurationList; @@ -1621,7 +1701,7 @@ 28052DF02973045C00F4F90A /* Beta */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Pre; }; B658FB5427DA9E1000EA4DBD /* Build configuration list for PBXNativeTarget "CodeEditTests" */ = { isa = XCConfigurationList; @@ -1633,7 +1713,7 @@ 28052DF12973045C00F4F90A /* Beta */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Pre; }; B658FB5727DA9E1000EA4DBD /* Build configuration list for PBXNativeTarget "CodeEditUITests" */ = { isa = XCConfigurationList; @@ -1645,7 +1725,7 @@ 28052DF22973045C00F4F90A /* Beta */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Pre; }; /* End XCConfigurationList section */ diff --git a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 835319d36b..ebee3ff816 100644 --- a/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CodeEdit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -229,7 +229,7 @@ { "identity" : "swiftlintplugin", "kind" : "remoteSourceControl", - "location" : "https://github.com/lukepistrol/SwiftLintPlugin", + "location" : "https://github.com/lukepistrol/SwiftLintPlugin.git", "state" : { "revision" : "3780efccceaa87f17ec39638a9d263d0e742b71c", "version" : "0.59.1" @@ -258,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/siteline/SwiftUI-Introspect.git", "state" : { - "revision" : "807f73ce09a9b9723f12385e592b4e0aaebd3336", - "version" : "1.3.0" + "revision" : "668a65735751432b640260c56dfa621cec568368", + "version" : "1.2.0" } }, { diff --git a/CodeEdit.xcodeproj/xcshareddata/xcschemes/CodeEdit.xcscheme b/CodeEdit.xcodeproj/xcshareddata/xcschemes/CodeEdit.xcscheme index 2c80a13978..e0bdbb1fb8 100644 --- a/CodeEdit.xcodeproj/xcshareddata/xcschemes/CodeEdit.xcscheme +++ b/CodeEdit.xcodeproj/xcshareddata/xcschemes/CodeEdit.xcscheme @@ -1,6 +1,6 @@ app.codeedit.CodeEdit.shared $(TeamIdentifierPrefix) - com.apple.security.cs.allow-jit - - com.apple.security.cs.disable-library-validation - diff --git a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift index e4367dcc0a..ac16a36709 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift @@ -9,34 +9,651 @@ import AppKit import AVKit import CodeEditSourceEditor import SwiftUI +import WebKit +import UniformTypeIdentifiers +import Combine +import Foundation +import Darwin -struct EditorAreaFileView: View { +// MARK: - Display Modes +enum EditorDisplayMode: String, CaseIterable, Identifiable { + case code = "Code" + case split = "Split" + case preview = "Preview" + var id: String { rawValue } +} + +// MARK: - Preview Source +enum PreviewSource { + case localHTML + case serverPreview +} + +// MARK: - HTML Rendering Backend Abstraction +struct HTMLRenderer { + var render: ((String) -> String)? + var renderAsync: ((String) async -> String)? + var loggingEnabled: Bool = false + + func renderHTML(from source: String) async -> String { + if loggingEnabled { print("[Preview] Rendering start. Source length: \(source.count)") } + let output: String + if let renderAsync { + output = await renderAsync(source) + } else if let render { + output = render(source) + } else { + output = source + } + if loggingEnabled { print("[Preview] Rendering done. HTML length: \(output.count)") } + return output + } +} + +// MARK: - WebKit Crash-Aware Delegate +final class PreviewNavDelegate: NSObject, WKNavigationDelegate { + var onCrash: (() -> Void)? + + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + print("[WebView] WebContent process terminated") + onCrash?() + } + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { + print("[WebView] didFailProvisionalNavigation: \(error.localizedDescription)") + onCrash?() + } + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + print("[WebView] didFail navigation: \(error.localizedDescription)") + } + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + print("[WebView] didFinish navigation. URL: \(webView.url?.absoluteString ?? "nil")") + } +} + +// MARK: - WebView (Coordinator reuse, safe refresh) +struct WebView: NSViewRepresentable { + let html: String + let baseURL: URL? + let onCrash: () -> Void + let allowJavaScript: Bool + + class Coordinator { + let webView: WKWebView + let navDelegate = PreviewNavDelegate() + var lastHTML: String = "" + var lastLoadAt: Date = .distantPast + + init(onCrash: @escaping () -> Void, allowJavaScript: Bool) { + let config = WKWebViewConfiguration() + config.preferences.javaScriptEnabled = allowJavaScript + config.websiteDataStore = .default() + config.suppressesIncrementalRendering = true + + let wv = WKWebView(frame: .zero, configuration: config) + wv.setValue(false, forKey: "drawsBackground") + navDelegate.onCrash = onCrash + wv.navigationDelegate = navDelegate + self.webView = wv + } + + func safeLoad(html: String, baseURL: URL?) { + let now = Date() + if now.timeIntervalSince(lastLoadAt) < 1.0, lastHTML == html { return } + lastLoadAt = now + lastHTML = html + webView.stopLoading() + webView.loadHTMLString(html, baseURL: baseURL) + } + } + + func makeCoordinator() -> Coordinator { Coordinator(onCrash: onCrash, allowJavaScript: allowJavaScript) } + func makeNSView(context: Context) -> WKWebView { context.coordinator.webView } + func updateNSView(_ webView: WKWebView, context: Context) { context.coordinator.safeLoad(html: html, baseURL: baseURL) } +} + +// MARK: - Markdown Renderer +struct MarkdownView: NSViewRepresentable { + let source: String + + func makeNSView(context: Context) -> NSScrollView { + let scroll = NSScrollView() + scroll.hasVerticalScroller = true + let textView = NSTextView() + textView.isEditable = false + textView.backgroundColor = .white + textView.textContainerInset = NSSize(width: 8, height: 8) + scroll.documentView = textView + return scroll + } + + func updateNSView(_ nsView: NSScrollView, context: Context) { + guard let textView = nsView.documentView as? NSTextView else { return } + let attributed = renderMarkdown(source) + textView.textStorage?.setAttributedString(attributed) + } + + private func renderMarkdown(_ source: String) -> NSAttributedString { + if source.isEmpty { return NSAttributedString(string: "") } + if #available(macOS 12.0, *) { + if let attributed = try? NSAttributedString(markdown: source) { + return attributed + } + } + return NSAttributedString(string: source) + } +} +// MARK: - Server Preview Client +struct ServerPreviewClient { + let baseURL = URL(string: "http://localhost:3000")! + let path = "/preview" + let timeout: TimeInterval = 8 + + func postHTML(_ html: String, filename: String?) async throws -> String { + var components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)! + components.path = path + guard let url = components.url else { throw URLError(.badURL) } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.timeoutInterval = timeout + let payload: [String: Any] = [ + "html": html, + "filename": filename ?? "untitled.html", + "timestamp": Date().timeIntervalSince1970 + ] + let data = try JSONSerialization.data(withJSONObject: payload, options: []) + let configuration = URLSessionConfiguration.default + configuration.timeoutIntervalForRequest = timeout + configuration.timeoutIntervalForResource = timeout + let session = URLSession(configuration: configuration) + let (respData, resp) = try await session.upload(for: request, from: data) + guard let http = resp as? HTTPURLResponse else { throw URLError(.badServerResponse) } + if !(200...299).contains(http.statusCode) { + let bodyString = String(data: respData, encoding: .utf8) ?? "" + throw NSError(domain: "ServerPreview", code: http.statusCode, userInfo: [ + NSLocalizedDescriptionKey: "Server preview failed (\(http.statusCode)).", + "body": bodyString + ]) + } + return String(data: respData, encoding: .utf8) ?? "" + } +} + +extension Notification.Name { + static let CodeFileDocumentContentDidChange = Notification.Name("CodeFileDocumentContentDidChange") +} + +// MARK: - Bottom Controls Overlay +struct PreviewBottomBar: View { + let refresh: () -> Void + let reloadIgnoreCache: () -> Void + @Binding var enableJS: Bool + @Binding var previewSource: PreviewSource + let serverErrorMessage: String? + + var body: some View { + VStack(spacing: 0) { + Divider() + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + Button("Refresh") { refresh() } + .keyboardShortcut("r", modifiers: []) + Button("Reload (ignore cache)") { reloadIgnoreCache() } + Toggle("Enable JS", isOn: $enableJS) + Picker("Preview Source", selection: $previewSource) { + Text("Local").tag(PreviewSource.localHTML) + Text("Server").tag(PreviewSource.serverPreview) + } + .pickerStyle(.segmented) + Menu("More") { + Button("Refresh") { refresh() } + Button("Reload (ignore cache)") { reloadIgnoreCache() } + Toggle("Enable JS", isOn: $enableJS) + Picker("Preview Source", selection: $previewSource) { + Text("Local").tag(PreviewSource.localHTML) + Text("Server").tag(PreviewSource.serverPreview) + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + } + .background(Color(NSColor.windowBackgroundColor)) + if let serverErrorMessage, previewSource == .serverPreview { + Text("Server error: \(serverErrorMessage)") + .foregroundColor(.red) + .font(.caption) + .padding(.horizontal, 8) + .padding(.bottom, 6) + } + } + } +} + +// MARK: - Directory Watcher (helper) +final class DirectoryWatcher { + private var fd: CInt = -1 + private var source: DispatchSourceFileSystemObject? + private let queue = DispatchQueue(label: "codeedit.filewatch.queue") + private var lastEventAt: Date = .distantPast + private let debounceInterval: TimeInterval = 0.25 + + func startWatching(url: URL, onChange: @escaping () -> Void, onError: @escaping (Error) -> Void) { + stop() + + let dirURL = url.deletingLastPathComponent() + fd = open(dirURL.path, O_EVTONLY) + guard fd >= 0 else { + onError(NSError(domain: "DirectoryWatcher", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Failed to open directory: \(dirURL.path)" + ])) + return + } + + let mask: DispatchSource.FileSystemEvent = [.write, .rename, .delete, .attrib, .extend] + let src = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fd, eventMask: mask, queue: queue) + source = src + + src.setEventHandler { [weak self] in + guard let self else { return } + let now = Date() + if now.timeIntervalSince(self.lastEventAt) < self.debounceInterval { return } + self.lastEventAt = now + onChange() // caller hops to main if needed + } + + src.setCancelHandler { [weak self] in + guard let self else { return } + if self.fd >= 0 { close(self.fd) } + self.fd = -1 + self.source = nil + } + + src.resume() + print("[FS Watch] Started for directory: \(dirURL.path)") + } + + func stop() { + source?.cancel() + source = nil + if fd >= 0 { close(fd) } + fd = -1 + } + + deinit { stop() } +} + +// MARK: - Main View +struct EditorAreaFileView: View { @EnvironmentObject private var editorManager: EditorManager @EnvironmentObject private var editor: Editor @EnvironmentObject private var statusBarViewModel: StatusBarViewModel - - @Environment(\.edgeInsets) - private var edgeInsets + @Environment(\.edgeInsets) private var edgeInsets var editorInstance: EditorInstance var codeFile: CodeFileDocument + var htmlRenderer: HTMLRenderer = .init( + render: { source in + let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let looksHTML = trimmed.hasPrefix("", with: ">") + return """ + + + + + + + +
\(escaped)
+ + """ + }, + loggingEnabled: true + ) + var enablePreviewLogging: Bool = true + + @State private var renderedHTMLState: String = "" + @State private var contentString: String = "" + @State private var displayMode: EditorDisplayMode = .split + @State private var cancellables = Set() + @State private var renderWorkItem: DispatchWorkItem? + + @State private var webViewAllowJS: Bool = false + @State private var previewSource: PreviewSource = .localHTML + + private let serverClient = ServerPreviewClient() + @State private var serverErrorMessage: String? + + @State private var webViewRefreshToken = UUID() + + // FS watcher state + @State private var watcher = DirectoryWatcher() + @State private var lastKnownFileMTime: Date? + + // Fixed size constants + private let FIXED_PREVIEW_HEIGHT: CGFloat = 320 + private let FIXED_PREVIEW_WIDTH: CGFloat = 420 + + private func bindContent() { + NotificationCenter.default.publisher( + for: .CodeFileDocumentContentDidChange, + object: codeFile + ) + .compactMap { _ in codeFile.content?.string } + .removeDuplicates() + .debounce(for: .milliseconds(250), scheduler: RunLoop.main) + .sink { newText in + updatePreview(with: newText) + } + .store(in: &cancellables) + } + + private func scheduleRender(for source: String) { + renderWorkItem?.cancel() + let work = DispatchWorkItem { + Task { @MainActor in + if enablePreviewLogging { print("[Preview] Source changed. Rendering…") } + switch previewSource { + case .localHTML: + let html = await htmlRenderer.renderHTML(from: source) + serverErrorMessage = nil + if renderedHTMLState != html { + renderedHTMLState = html + webViewRefreshToken = UUID() + print("[Preview] Local HTML set. length: \(html.count)") + } else { + print("[Preview] No state change (same HTML)") + } + case .serverPreview: + let localHTML = await htmlRenderer.renderHTML(from: source) + if renderedHTMLState != localHTML { + renderedHTMLState = localHTML + print("[Preview] Fallback local HTML set. length: \(localHTML.count)") + } + await loadServerPreview(with: source) + } + } + } + renderWorkItem = work + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: work) + } + + @MainActor + private func loadServerPreview(with source: String) async { + serverErrorMessage = nil + do { + let filename = codeFile.fileURL?.lastPathComponent + let serverHTML = try await serverClient.postHTML(source, filename: filename) + if renderedHTMLState != serverHTML { + renderedHTMLState = serverHTML + print("[Preview] Server HTML set. length: \(serverHTML.count)") + } else { + print("[Preview] Server returned identical HTML; no state change.") + } + } catch { + let message: String + if let urlError = error as? URLError { + switch urlError.code { + case .cannotFindHost: message = "Cannot find server at localhost:3000." + case .timedOut: message = "Server preview timed out." + case .notConnectedToInternet: message = "No network connection." + default: message = "Network error: \(urlError.localizedDescription)" + } + } else { + message = error.localizedDescription + } + serverErrorMessage = message + print("[Preview] Server preview error: \(message)") + } + } + + private func updatePreview(with newText: String) { + if contentString != newText { + contentString = newText + scheduleRender(for: newText) + } + } + + private func refreshPreview() { + print("[Preview] Manual refresh triggered") + scheduleRender(for: contentString) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + webViewRefreshToken = UUID() + } + } + + // MARK: - FS Watch helpers + private func startFileWatchIfNeeded() { + guard let fileURL = codeFile.fileURL else { return } + + // Record current mtime to filter unrelated directory events + lastKnownFileMTime = (try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + + watcher.startWatching(url: fileURL) { [fileURL] in + // Compute new mtime off the main thread + let mtime = (try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate + + // Hop to main to mutate state and refresh UI + DispatchQueue.main.async { + if self.lastKnownFileMTime == mtime { return } + self.lastKnownFileMTime = mtime + + if let newText = self.codeFile.content?.string { + self.updatePreview(with: newText) + } else { + do { + let data = try Data(contentsOf: fileURL) + let newText = String(data: data, encoding: .utf8) ?? "" + self.updatePreview(with: newText) + } catch { + print("[FS Watch] Failed reading file: \(error.localizedDescription)") + } + } + + self.webViewRefreshToken = UUID() + } + } onError: { err in + DispatchQueue.main.async { + print("[FS Watch] Error: \(err.localizedDescription)") + } + } + } + + private func stopFileWatch() { + watcher.stop() + } + + // MARK: - Layout @ViewBuilder var editorAreaFileView: some View { - if let utType = codeFile.utType, utType.conforms(to: .text) { - CodeFileView( - editorInstance: editorInstance, - codeFile: codeFile - ) - } else { - NonTextFileView(fileDocument: codeFile) - .padding(.top, edgeInsets.top - 1.74) - .padding(.bottom, StatusBarView.height + 1.26) - .modifier(UpdateStatusBarInfo(with: codeFile.fileURL)) - .onDisappear { - statusBarViewModel.dimensions = nil - statusBarViewModel.fileSize = nil + let pathExt = codeFile.fileURL?.pathExtension.lowercased() ?? "" + let isHTML = (codeFile.utType?.conforms(to: .html) ?? false) || (["html", "htm"].contains(pathExt)) + let isMarkdown = (codeFile.utType?.identifier == "net.daringfireball.markdown") || (pathExt == "md" || pathExt == "markdown") + + VStack(spacing: 0) { + HStack(spacing: 8) { + Picker("Display Mode", selection: $displayMode) { + ForEach(EditorDisplayMode.allCases) { mode in + Text(mode.rawValue).tag(mode) + } + } + .pickerStyle(.segmented) + Spacer(minLength: 8) + } + .padding(.horizontal, 8) + .padding(.top, 8) + + Divider() + + if isHTML { + switch displayMode { + case .code: + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + + case .preview: + VStack(spacing: 0) { + ZStack { + WebView( + html: renderedHTMLState.isEmpty + ? "

Preview Ready

" + : renderedHTMLState, + baseURL: codeFile.fileURL?.deletingLastPathComponent(), + onCrash: { /* WebKit always on; show message if needed */ }, + allowJavaScript: webViewAllowJS + ) + .id(webViewRefreshToken) + .frame(maxWidth: .infinity, minHeight: FIXED_PREVIEW_HEIGHT, maxHeight: FIXED_PREVIEW_HEIGHT) + .background(Color.white) + + VStack(spacing: 0) { + Spacer() + PreviewBottomBar( + refresh: { refreshPreview() }, + reloadIgnoreCache: { webViewRefreshToken = UUID() }, + enableJS: $webViewAllowJS, + previewSource: $previewSource, + serverErrorMessage: serverErrorMessage + ) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .split: + HStack(spacing: 0) { + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + + VStack(spacing: 0) { + ZStack { + WebView( + html: renderedHTMLState.isEmpty + ? "

Preview Ready

" + : renderedHTMLState, + baseURL: codeFile.fileURL?.deletingLastPathComponent(), + onCrash: { /* WebKit always on */ }, + allowJavaScript: webViewAllowJS + ) + .id(webViewRefreshToken) + .frame(maxWidth: .infinity, minHeight: FIXED_PREVIEW_HEIGHT, maxHeight: FIXED_PREVIEW_HEIGHT) + .background(Color.white) + + VStack(spacing: 0) { + Spacer() + PreviewBottomBar( + refresh: { refreshPreview() }, + reloadIgnoreCache: { webViewRefreshToken = UUID() }, + enableJS: $webViewAllowJS, + previewSource: $previewSource, + serverErrorMessage: serverErrorMessage + ) + } + } + } + .frame(width: FIXED_PREVIEW_WIDTH) + .frame(maxHeight: .infinity) + .background(Color.white) + } + } + } else if isMarkdown { + switch displayMode { + case .code: + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + + case .preview: + VStack(spacing: 0) { + ZStack { + MarkdownView(source: contentString) + .frame(maxWidth: .infinity, minHeight: FIXED_PREVIEW_HEIGHT, maxHeight: FIXED_PREVIEW_HEIGHT) + .background(Color.white) + + VStack(spacing: 0) { + Spacer() + PreviewBottomBar( + refresh: { refreshPreview() }, + reloadIgnoreCache: { webViewRefreshToken = UUID() }, + enableJS: $webViewAllowJS, + previewSource: $previewSource, + serverErrorMessage: serverErrorMessage + ) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .split: + HStack(spacing: 0) { + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + VStack(spacing: 0) { + ZStack { + MarkdownView(source: contentString) + .frame(maxWidth: .infinity, minHeight: FIXED_PREVIEW_HEIGHT, maxHeight: FIXED_PREVIEW_HEIGHT) + .background(Color.white) + VStack(spacing: 0) { + Spacer() + PreviewBottomBar( + refresh: { refreshPreview() }, + reloadIgnoreCache: { webViewRefreshToken = UUID() }, + enableJS: $webViewAllowJS, + previewSource: $previewSource, + serverErrorMessage: serverErrorMessage + ) + } + } + } + .frame(minWidth: FIXED_PREVIEW_WIDTH, + idealWidth: FIXED_PREVIEW_WIDTH, + maxWidth: FIXED_PREVIEW_WIDTH, + maxHeight: .infinity, alignment: .center) + .background(Color.white) + } + } + } else if let utType = codeFile.utType, utType.conforms(to: .text) { + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + + } else { + NonTextFileView(fileDocument: codeFile) + .padding(.top, edgeInsets.top - 1.74) + .padding(.bottom, StatusBarView.height + 1.26) + .modifier(UpdateStatusBarInfo(with: codeFile.fileURL)) + .onDisappear { + statusBarViewModel.dimensions = nil + statusBarViewModel.fileSize = nil + } + } + } + .onAppear { + bindContent() + let sourceString = codeFile.content?.string ?? "" + contentString = sourceString + Task { @MainActor in + let html = await htmlRenderer.renderHTML(from: sourceString) + serverErrorMessage = nil + if renderedHTMLState != html { + renderedHTMLState = html + print("[Preview] Initial set. html length: \(html.count)") } + if previewSource == .serverPreview { + await loadServerPreview(with: sourceString) + } + } + startFileWatchIfNeeded() + } + .onChange(of: codeFile.fileURL) { _ in + stopFileWatch() + startFileWatchIfNeeded() + } + .onDisappear { + stopFileWatch() } } @@ -45,11 +662,7 @@ struct EditorAreaFileView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) .onHover { hover in DispatchQueue.main.async { - if hover { - NSCursor.iBeam.push() - } else { - NSCursor.pop() - } + if hover { NSCursor.iBeam.push() } else { NSCursor.pop() } } } } diff --git a/CodeEdit/Features/Welcome/NewFileButton.swift b/CodeEdit/Features/Welcome/NewFileButton.swift index 75261faee5..7069713a34 100644 --- a/CodeEdit/Features/Welcome/NewFileButton.swift +++ b/CodeEdit/Features/Welcome/NewFileButton.swift @@ -17,9 +17,22 @@ struct NewFileButton: View { iconName: "plus.square", title: "Create New File...", action: { - let documentController = CodeEditDocumentController() + let documentController = CodeEditDocumentControllerProvider.sharedDocumentController() documentController.createAndOpenNewDocument(onCompletion: { dismissWindow() }) } ) } } + +private enum CodeEditDocumentControllerProvider { + static func sharedDocumentController() -> CodeEditDocumentController { + if let typed = NSDocumentController.shared as? CodeEditDocumentController { + return typed + } + // Fall back to our own singleton instance without mutating the system `shared` + struct Holder { + static let instance = CodeEditDocumentController() + } + return Holder.instance + } +}