diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 2b2a39b04a..23cad1267c 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -674,7 +674,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -875,7 +875,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1148,7 +1148,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1421,7 +1421,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1465,7 +1465,7 @@ DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"CodeEdit/Preview Content\""; DEVELOPMENT_TEAM = ""; - ENABLE_APP_SANDBOX = YES; + ENABLE_APP_SANDBOX = NO; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = NO; @@ -1617,7 +1617,7 @@ 28052DF32973045C00F4F90A /* Beta */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Pre; }; B658FB2727DA9E0F00EA4DBD /* Build configuration list for PBXProject "CodeEdit" */ = { isa = XCConfigurationList; @@ -1629,7 +1629,7 @@ 28052DEF2973045C00F4F90A /* Beta */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Pre; }; B658FB5127DA9E1000EA4DBD /* Build configuration list for PBXNativeTarget "CodeEdit" */ = { isa = XCConfigurationList; @@ -1641,7 +1641,7 @@ 28052DF02973045C00F4F90A /* Beta */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Pre; }; B658FB5427DA9E1000EA4DBD /* Build configuration list for PBXNativeTarget "CodeEditTests" */ = { isa = XCConfigurationList; @@ -1653,7 +1653,7 @@ 28052DF12973045C00F4F90A /* Beta */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Pre; }; B658FB5727DA9E1000EA4DBD /* Build configuration list for PBXNativeTarget "CodeEditUITests" */ = { isa = XCConfigurationList; @@ -1665,7 +1665,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/Features/Editor/Views/EditorAreaFileView.swift b/CodeEdit/Features/Editor/Views/EditorAreaFileView.swift index e4367dcc0a..6fd4c38fca 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 @@ -9,34 +12,863 @@ 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 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 +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? + + // NEW: preview visibility toggle + @State private var showPreviewPane: Bool = true + + @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? + + // 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( + 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() + + // 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: + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + + case .preview: + if showPreviewPane { + 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: 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) { + Spacer() + PreviewBottomBar( + refresh: { refreshPreview() }, + reloadIgnoreCache: { webViewRefreshToken = UUID() }, + enableJS: $webViewAllowJS, + previewSource: $previewSource, + serverErrorMessage: serverErrorMessage + ) + } + } + // 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(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) { + // Code area + ZStack { + 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 { + // divider placed between code and preview (gesture here) + verticalDivider + .zIndex(5) + + // 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) + + 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) + } + // Constrain preview to the adjustable width + .frame(width: previewWidth) + .frame(maxHeight: .infinity) + .background(Color.white) + } + } } + } else if isMarkdown { + switch displayMode { + case .code: + CodeFileView(editorInstance: editorInstance, codeFile: codeFile) + + case .preview: + if showPreviewPane { + VStack(spacing: 0) { + ZStack { + MarkdownView(source: contentString) + .frame(maxWidth: .infinity, minHeight: 320, maxHeight: .infinity) + .padding(.top, edgeInsets.top) + .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(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) { + // Code area + 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 { + verticalDivider + .zIndex(5) + + VStack(spacing: 0) { + ZStack { + MarkdownView(source: contentString) + .frame(maxWidth: .infinity, minHeight: 320, maxHeight: .infinity) + .padding(.top, edgeInsets.top) + .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: previewWidth, + idealWidth: previewWidth, + maxWidth: previewWidth, + 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 +877,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 + } +}