From 97fff6d98df0169d1fed5c0b6be85e8f3b72f406 Mon Sep 17 00:00:00 2001 From: Hunter Davis Date: Tue, 2 Dec 2025 02:40:45 -0500 Subject: [PATCH 1/5] Simple Web View --- CodeEdit.xcodeproj/project.pbxproj | 92 ++- .../xcshareddata/swiftpm/Package.resolved | 6 +- .../xcshareddata/xcschemes/CodeEdit.xcscheme | 2 +- .../xcschemes/OpenWithCodeEdit.xcscheme | 2 +- CodeEdit/CodeEdit.entitlements | 4 - .../Editor/Views/EditorAreaFileView.swift | 602 +++++++++++++++++- CodeEdit/Features/Welcome/NewFileButton.swift | 15 +- 7 files changed, 685 insertions(+), 38 deletions(-) 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..3e72ac1f55 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift @@ -9,34 +9,553 @@ import AppKit import AVKit import CodeEditSourceEditor import SwiftUI +import WebKit +import UniformTypeIdentifiers +import Combine -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() } + // WebKit is always enabled; remove toggle. + 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: - 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? + + // WebKit is always enabled; only allow JS toggle + @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() + + // Fixed size constants + private let FIXED_PREVIEW_HEIGHT: CGFloat = 320 // adjust as needed + private let FIXED_PREVIEW_WIDTH: CGFloat = 420 // used in split right pane + + 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: - Whole Editor Layout with fixed WebView below divider @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) { + // Top row: mode picker + 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() + + // Content below divider + if isHTML { + switch displayMode { + case .code: + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + + case .preview: + // Fixed-height WebView area below the divider + 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) + + // Bottom overlay bar pinned inside the fixed area + 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) + + // Right pane has fixed width; inside it, fixed-height WebView + 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, + minHeight: nil, idealHeight: nil, + 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) } + } } } @@ -45,12 +564,51 @@ 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() } } } } + + struct EditorArea: View { + let editorInstance: EditorInstance + let codeFile: CodeFileDocument + + private let renderer = HTMLRenderer( + render: { source in + func isHTML(_ sourceString: String) -> Bool { + let trimed = sourceString.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return trimed.hasPrefix("", with: ">") + return """ + + + + + + +
\(escaped)
+ + """ + }, + loggingEnabled: true + ) + + var body: some View { + EditorAreaFileView( + editorInstance: editorInstance, + codeFile: codeFile, + htmlRenderer: renderer, + enablePreviewLogging: true + ) + } + } } + 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 + } +} From 76708f5f49edec32e2cfe9e9af82749f8765e05d Mon Sep 17 00:00:00 2001 From: Hunter Davis Date: Tue, 2 Dec 2025 17:22:40 -0500 Subject: [PATCH 2/5] Live updates --- .../Editor/Views/EditorAreaFileView.swift | 167 ++++++++++++------ 1 file changed, 111 insertions(+), 56 deletions(-) diff --git a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift index 3e72ac1f55..ac16a36709 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift @@ -12,6 +12,8 @@ import SwiftUI import WebKit import UniformTypeIdentifiers import Combine +import Foundation +import Darwin // MARK: - Display Modes enum EditorDisplayMode: String, CaseIterable, Identifiable { @@ -69,7 +71,6 @@ final class PreviewNavDelegate: NSObject, WKNavigationDelegate { } // MARK: - WebView (Coordinator reuse, safe refresh) - struct WebView: NSViewRepresentable { let html: String let baseURL: URL? @@ -199,7 +200,6 @@ struct PreviewBottomBar: View { Button("Refresh") { refresh() } .keyboardShortcut("r", modifiers: []) Button("Reload (ignore cache)") { reloadIgnoreCache() } - // WebKit is always enabled; remove toggle. Toggle("Enable JS", isOn: $enableJS) Picker("Preview Source", selection: $previewSource) { Text("Local").tag(PreviewSource.localHTML) @@ -231,6 +231,59 @@ struct PreviewBottomBar: View { } } +// 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 @@ -256,7 +309,6 @@ struct EditorAreaFileView: View { - - -
\(escaped)
- - """ - }, - loggingEnabled: true - ) - - var body: some View { - EditorAreaFileView( - editorInstance: editorInstance, - codeFile: codeFile, - htmlRenderer: renderer, - enablePreviewLogging: true - ) - } - } } - From 14d0b7f4806bfb4527dc2fbacba228cb4cebc254 Mon Sep 17 00:00:00 2001 From: Hunter Davis Date: Tue, 9 Dec 2025 17:13:54 -0500 Subject: [PATCH 3/5] Web view show/hide --- .../Editor/Views/EditorAreaFileView.swift | 308 ++++++++++++++---- 1 file changed, 244 insertions(+), 64 deletions(-) diff --git a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift index ac16a36709..8975bd956f 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift @@ -1,3 +1,6 @@ +// swiftlint:disable line_length +// swiftLint:disable file_length +// swiftlint:disable type_body_length // // EditorAreaFileView.swift // CodeEdit @@ -329,6 +332,9 @@ struct EditorAreaFileView: View { @State private var cancellables = Set() @State private var renderWorkItem: DispatchWorkItem? + // NEW: preview visibility toggle + @State private var showPreviewPane: Bool = true + @State private var webViewAllowJS: Bool = false @State private var previewSource: PreviewSource = .localHTML @@ -433,7 +439,7 @@ struct EditorAreaFileView: View { } } - // MARK: - FS Watch helpers + // MARK: - FS Watch helpers private func startFileWatchIfNeeded() { guard let fileURL = codeFile.fileURL else { return } @@ -501,38 +507,7 @@ struct EditorAreaFileView: View { 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) - + if showPreviewPane { VStack(spacing: 0) { ZStack { WebView( @@ -540,7 +515,7 @@ struct EditorAreaFileView: View { ? "

Preview Ready

" : renderedHTMLState, baseURL: codeFile.fileURL?.deletingLastPathComponent(), - onCrash: { /* WebKit always on */ }, + onCrash: { /* WebKit always on; show message if needed */ }, allowJavaScript: webViewAllowJS ) .id(webViewRefreshToken) @@ -558,46 +533,144 @@ struct EditorAreaFileView: View { ) } } + // Ensure overlay respects safe area and is not clipped + .overlay(alignment: .topTrailing) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + showPreviewPane = false + } + } label: { + Image(systemName: "eye.slash") + .font(.system(size: 14, weight: .medium)) + .padding(6) + .background(Color.black.opacity(0.12)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .help("Hide Preview") + .padding(.top, edgeInsets.top + 8) + .padding(.trailing, 8) + .zIndex(10) + } } - .frame(width: FIXED_PREVIEW_WIDTH) - .frame(maxHeight: .infinity) - .background(Color.white) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // Preview hidden in Preview mode — show a center "show" button + VStack { + Spacer() + Button { + withAnimation(.easeInOut(duration: 0.18)) { + showPreviewPane = true + } + } label: { + Image(systemName: "eye") + .font(.system(size: 20, weight: .semibold)) + .padding(10) + .background(Color.black.opacity(0.08)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .help("Show Preview") + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.windowBackgroundColor)) } - } - } else if isMarkdown { - switch displayMode { - case .code: - CodeFileView(editorInstance: editorInstance, codeFile: codeFile) - case .preview: - VStack(spacing: 0) { + case .split: + HStack(spacing: 0) { + // Wrap code view so we can show the "show preview" button when preview is hidden ZStack { - MarkdownView(source: contentString) - .frame(maxWidth: .infinity, minHeight: FIXED_PREVIEW_HEIGHT, maxHeight: FIXED_PREVIEW_HEIGHT) - .background(Color.white) + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + + if !showPreviewPane { + // small overlay button at top-right of code area to restore preview + Button { + withAnimation(.easeInOut(duration: 0.18)) { + showPreviewPane = true + } + } label: { + Image(systemName: "eye") + .font(.system(size: 14, weight: .medium)) + .padding(6) + .background(Color.black.opacity(0.12)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .help("Show Preview") + .padding(.top, edgeInsets.top + 8) + .padding(.trailing, 8) + .zIndex(10) + } + } + .frame(minWidth: 200) + if showPreviewPane { VStack(spacing: 0) { - Spacer() - PreviewBottomBar( - refresh: { refreshPreview() }, - reloadIgnoreCache: { webViewRefreshToken = UUID() }, - enableJS: $webViewAllowJS, - previewSource: $previewSource, - serverErrorMessage: serverErrorMessage - ) + 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 + ) + } + + // drag / divider area (kept minimal here, you can replace with more advanced drag logic) + } + .overlay(alignment: .topTrailing) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + showPreviewPane = false + } + } label: { + Image(systemName: "eye.slash") + .font(.system(size: 14, weight: .medium)) + .padding(6) + .background(Color.black.opacity(0.12)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .help("Hide Preview") + .padding(.top, edgeInsets.top + 8) + .padding(.trailing, 8) + .zIndex(10) + } } + .frame(width: FIXED_PREVIEW_WIDTH) + .frame(maxHeight: .infinity) + .background(Color.white) } } - .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } else if isMarkdown { + switch displayMode { + case .code: + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) - case .split: - HStack(spacing: 0) { - CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + case .preview: + if showPreviewPane { 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( @@ -609,12 +682,119 @@ struct EditorAreaFileView: View { ) } } + .overlay(alignment: .topTrailing) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + showPreviewPane = false + } + } label: { + Image(systemName: "eye.slash") + .font(.system(size: 14, weight: .medium)) + .padding(6) + .background(Color.black.opacity(0.12)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .help("Hide Preview") + .padding(.top, edgeInsets.top + 8) + .padding(.trailing, 8) + .zIndex(10) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + // Preview hidden in Preview mode — show a center "show" button + VStack { + Spacer() + Button { + withAnimation(.easeInOut(duration: 0.18)) { + showPreviewPane = true + } + } label: { + Image(systemName: "eye") + .font(.system(size: 20, weight: .semibold)) + .padding(10) + .background(Color.black.opacity(0.08)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .help("Show Preview") + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(NSColor.windowBackgroundColor)) + } + + case .split: + HStack(spacing: 0) { + // Wrap code view so we can show the "show preview" button when preview is hidden + ZStack { + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + + if !showPreviewPane { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + showPreviewPane = true + } + } label: { + Image(systemName: "eye") + .font(.system(size: 14, weight: .medium)) + .padding(6) + .background(Color.black.opacity(0.12)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .help("Show Preview") + .padding(.top, edgeInsets.top + 8) + .padding(.trailing, 8) + .zIndex(10) + } + } + .frame(minWidth: 200) + + if showPreviewPane { + 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 + ) + } + } + .overlay(alignment: .topTrailing) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + showPreviewPane = false + } + } label: { + Image(systemName: "eye.slash") + .font(.system(size: 14, weight: .medium)) + .padding(6) + .background(Color.black.opacity(0.12)) + .clipShape(Circle()) + } + .buttonStyle(.plain) + .help("Hide Preview") + .padding(.top, edgeInsets.top + 8) + .padding(.trailing, 8) + .zIndex(10) + } + } + .frame(minWidth: FIXED_PREVIEW_WIDTH, + idealWidth: FIXED_PREVIEW_WIDTH, + maxWidth: FIXED_PREVIEW_WIDTH, + maxHeight: .infinity, alignment: .center) + .background(Color.white) } - .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) { From 4499fdd38adfb53b8f8618e106403898b773e323 Mon Sep 17 00:00:00 2001 From: Hunter Davis Date: Wed, 10 Dec 2025 22:59:51 -0500 Subject: [PATCH 4/5] Added Resizing --- .../Editor/Views/EditorAreaFileView.swift | 143 +++++++++++------- 1 file changed, 89 insertions(+), 54 deletions(-) diff --git a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift index 8975bd956f..6fd4c38fca 100644 --- a/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift +++ b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift @@ -53,7 +53,7 @@ struct HTMLRenderer { } } -// MARK: - WebKit Crash-Aware Delegate +// MARK: - WebKit Crash Delegate final class PreviewNavDelegate: NSObject, WKNavigationDelegate { var onCrash: (() -> Void)? @@ -73,7 +73,7 @@ final class PreviewNavDelegate: NSObject, WKNavigationDelegate { } } -// MARK: - WebView (Coordinator reuse, safe refresh) +// MARK: - WebView struct WebView: NSViewRepresentable { let html: String let baseURL: URL? @@ -347,9 +347,13 @@ struct EditorAreaFileView: View { @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 + // Width-resize state + @State private var previewWidth: CGFloat = 420 // default width for split mode + @State private var dragStartPreviewWidth: CGFloat? + + // Min/max constraints for width-only resizing + private let MIN_PREVIEW_WIDTH: CGFloat = 200 + private let MAX_PREVIEW_WIDTH: CGFloat = 1400 private func bindContent() { NotificationCenter.default.publisher( @@ -501,6 +505,26 @@ struct EditorAreaFileView: View { Divider() + // thin draggable vertical divider between code and preview (width-resize) + let verticalDivider: some View = Rectangle() + .fill(Color(NSColor.separatorColor)) + .frame(width: 2) + // give a larger transparent hit area so it is easy to grab + .padding(.horizontal, 6) + .contentShape(Rectangle()) + .gesture(DragGesture(minimumDistance: 1).onChanged { value in + if dragStartPreviewWidth == nil { dragStartPreviewWidth = previewWidth } + let start = dragStartPreviewWidth ?? previewWidth + let delta = value.location.x - value.startLocation.x + let newW = start - delta + previewWidth = max(MIN_PREVIEW_WIDTH, min(MAX_PREVIEW_WIDTH, newW)) + }.onEnded { _ in + dragStartPreviewWidth = nil + }) + .onHover { hovering in + if hovering { NSCursor.resizeLeftRight.push() } else { NSCursor.pop() } + } + if isHTML { switch displayMode { case .code: @@ -519,7 +543,9 @@ struct EditorAreaFileView: View { allowJavaScript: webViewAllowJS ) .id(webViewRefreshToken) - .frame(maxWidth: .infinity, minHeight: FIXED_PREVIEW_HEIGHT, maxHeight: FIXED_PREVIEW_HEIGHT) + .frame(maxWidth: .infinity, minHeight: 320, maxHeight: .infinity) + // ensure top safe area isn't covered — add top padding to push web content down + .padding(.top, edgeInsets.top) .background(Color.white) VStack(spacing: 0) { @@ -579,7 +605,7 @@ struct EditorAreaFileView: View { case .split: HStack(spacing: 0) { - // Wrap code view so we can show the "show preview" button when preview is hidden + // Code area ZStack { CodeFileView(editorInstance: editorInstance, codeFile: codeFile) @@ -606,53 +632,57 @@ struct EditorAreaFileView: View { .frame(minWidth: 200) if showPreviewPane { - 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) + // divider placed between code and preview (gesture here) + verticalDivider + .zIndex(5) - VStack(spacing: 0) { - Spacer() - PreviewBottomBar( - refresh: { refreshPreview() }, - reloadIgnoreCache: { webViewRefreshToken = UUID() }, - enableJS: $webViewAllowJS, - previewSource: $previewSource, - serverErrorMessage: serverErrorMessage - ) - } + // Preview container constrained to adjustable width + ZStack { + WebView( + html: renderedHTMLState.isEmpty + ? "

Preview Ready

" + : renderedHTMLState, + baseURL: codeFile.fileURL?.deletingLastPathComponent(), + onCrash: { /* WebKit always on */ }, + allowJavaScript: webViewAllowJS + ) + .id(webViewRefreshToken) + .frame(maxWidth: .infinity, minHeight: 320, maxHeight: .infinity) + // push content below top chrome so h1 isn't cut off + .padding(.top, edgeInsets.top) + .background(Color.white) - // drag / divider area (kept minimal here, you can replace with more advanced drag logic) + VStack(spacing: 0) { + Spacer() + PreviewBottomBar( + refresh: { refreshPreview() }, + reloadIgnoreCache: { webViewRefreshToken = UUID() }, + enableJS: $webViewAllowJS, + previewSource: $previewSource, + serverErrorMessage: serverErrorMessage + ) } - .overlay(alignment: .topTrailing) { - Button { - withAnimation(.easeInOut(duration: 0.18)) { - showPreviewPane = false - } - } label: { - Image(systemName: "eye.slash") - .font(.system(size: 14, weight: .medium)) - .padding(6) - .background(Color.black.opacity(0.12)) - .clipShape(Circle()) + } + .overlay(alignment: .topTrailing) { + Button { + withAnimation(.easeInOut(duration: 0.18)) { + showPreviewPane = false } - .buttonStyle(.plain) - .help("Hide Preview") - .padding(.top, edgeInsets.top + 8) - .padding(.trailing, 8) - .zIndex(10) + } label: { + Image(systemName: "eye.slash") + .font(.system(size: 14, weight: .medium)) + .padding(6) + .background(Color.black.opacity(0.12)) + .clipShape(Circle()) } + .buttonStyle(.plain) + .help("Hide Preview") + .padding(.top, edgeInsets.top + 8) + .padding(.trailing, 8) + .zIndex(10) } - .frame(width: FIXED_PREVIEW_WIDTH) + // Constrain preview to the adjustable width + .frame(width: previewWidth) .frame(maxHeight: .infinity) .background(Color.white) } @@ -668,7 +698,8 @@ struct EditorAreaFileView: View { VStack(spacing: 0) { ZStack { MarkdownView(source: contentString) - .frame(maxWidth: .infinity, minHeight: FIXED_PREVIEW_HEIGHT, maxHeight: FIXED_PREVIEW_HEIGHT) + .frame(maxWidth: .infinity, minHeight: 320, maxHeight: .infinity) + .padding(.top, edgeInsets.top) .background(Color.white) VStack(spacing: 0) { @@ -727,7 +758,7 @@ struct EditorAreaFileView: View { case .split: HStack(spacing: 0) { - // Wrap code view so we can show the "show preview" button when preview is hidden + // Code area ZStack { CodeFileView(editorInstance: editorInstance, codeFile: codeFile) @@ -753,10 +784,14 @@ struct EditorAreaFileView: View { .frame(minWidth: 200) if showPreviewPane { + verticalDivider + .zIndex(5) + VStack(spacing: 0) { ZStack { MarkdownView(source: contentString) - .frame(maxWidth: .infinity, minHeight: FIXED_PREVIEW_HEIGHT, maxHeight: FIXED_PREVIEW_HEIGHT) + .frame(maxWidth: .infinity, minHeight: 320, maxHeight: .infinity) + .padding(.top, edgeInsets.top) .background(Color.white) VStack(spacing: 0) { @@ -789,9 +824,9 @@ struct EditorAreaFileView: View { .zIndex(10) } } - .frame(minWidth: FIXED_PREVIEW_WIDTH, - idealWidth: FIXED_PREVIEW_WIDTH, - maxWidth: FIXED_PREVIEW_WIDTH, + .frame(minWidth: previewWidth, + idealWidth: previewWidth, + maxWidth: previewWidth, maxHeight: .infinity, alignment: .center) .background(Color.white) } From 78d8a16c713f8284ad98e24045789b61a97e18fb Mon Sep 17 00:00:00 2001 From: Hunter Davis Date: Thu, 11 Dec 2025 20:23:09 -0500 Subject: [PATCH 5/5] Disabling app sandbox --- CodeEdit.xcodeproj/project.pbxproj | 70 +++--------------------------- 1 file changed, 5 insertions(+), 65 deletions(-) diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index cd3ef1c94a..4b371707f7 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -674,21 +674,9 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; - ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; + ENABLE_APP_SANDBOX = NO; 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,21 +875,9 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; - ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; + ENABLE_APP_SANDBOX = NO; 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"; @@ -1172,21 +1148,9 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; - ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; + ENABLE_APP_SANDBOX = NO; 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"; @@ -1457,21 +1421,9 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; - ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; + ENABLE_APP_SANDBOX = NO; 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"; @@ -1513,21 +1465,9 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; - ENABLE_FILE_ACCESS_DOWNLOADS_FOLDER = readwrite; + ENABLE_APP_SANDBOX = NO; 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";