Skip to content

Commit 76708f5

Browse files
committed
Live updates
1 parent 97fff6d commit 76708f5

File tree

1 file changed

+111
-56
lines changed

1 file changed

+111
-56
lines changed

CodeEdit/Features/Editor/Views/EditorAreaFileView.swift

Lines changed: 111 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import SwiftUI
1212
import WebKit
1313
import UniformTypeIdentifiers
1414
import Combine
15+
import Foundation
16+
import Darwin
1517

1618
// MARK: - Display Modes
1719
enum EditorDisplayMode: String, CaseIterable, Identifiable {
@@ -69,7 +71,6 @@ final class PreviewNavDelegate: NSObject, WKNavigationDelegate {
6971
}
7072

7173
// MARK: - WebView (Coordinator reuse, safe refresh)
72-
7374
struct WebView: NSViewRepresentable {
7475
let html: String
7576
let baseURL: URL?
@@ -199,7 +200,6 @@ struct PreviewBottomBar: View {
199200
Button("Refresh") { refresh() }
200201
.keyboardShortcut("r", modifiers: [])
201202
Button("Reload (ignore cache)") { reloadIgnoreCache() }
202-
// WebKit is always enabled; remove toggle.
203203
Toggle("Enable JS", isOn: $enableJS)
204204
Picker("Preview Source", selection: $previewSource) {
205205
Text("Local").tag(PreviewSource.localHTML)
@@ -231,6 +231,59 @@ struct PreviewBottomBar: View {
231231
}
232232
}
233233

234+
// MARK: - Directory Watcher (helper)
235+
final class DirectoryWatcher {
236+
private var fd: CInt = -1
237+
private var source: DispatchSourceFileSystemObject?
238+
private let queue = DispatchQueue(label: "codeedit.filewatch.queue")
239+
private var lastEventAt: Date = .distantPast
240+
private let debounceInterval: TimeInterval = 0.25
241+
242+
func startWatching(url: URL, onChange: @escaping () -> Void, onError: @escaping (Error) -> Void) {
243+
stop()
244+
245+
let dirURL = url.deletingLastPathComponent()
246+
fd = open(dirURL.path, O_EVTONLY)
247+
guard fd >= 0 else {
248+
onError(NSError(domain: "DirectoryWatcher", code: 1, userInfo: [
249+
NSLocalizedDescriptionKey: "Failed to open directory: \(dirURL.path)"
250+
]))
251+
return
252+
}
253+
254+
let mask: DispatchSource.FileSystemEvent = [.write, .rename, .delete, .attrib, .extend]
255+
let src = DispatchSource.makeFileSystemObjectSource(fileDescriptor: fd, eventMask: mask, queue: queue)
256+
source = src
257+
258+
src.setEventHandler { [weak self] in
259+
guard let self else { return }
260+
let now = Date()
261+
if now.timeIntervalSince(self.lastEventAt) < self.debounceInterval { return }
262+
self.lastEventAt = now
263+
onChange() // caller hops to main if needed
264+
}
265+
266+
src.setCancelHandler { [weak self] in
267+
guard let self else { return }
268+
if self.fd >= 0 { close(self.fd) }
269+
self.fd = -1
270+
self.source = nil
271+
}
272+
273+
src.resume()
274+
print("[FS Watch] Started for directory: \(dirURL.path)")
275+
}
276+
277+
func stop() {
278+
source?.cancel()
279+
source = nil
280+
if fd >= 0 { close(fd) }
281+
fd = -1
282+
}
283+
284+
deinit { stop() }
285+
}
286+
234287
// MARK: - Main View
235288
struct EditorAreaFileView: View {
236289
@EnvironmentObject private var editorManager: EditorManager
@@ -256,7 +309,6 @@ struct EditorAreaFileView: View {
256309
<head>
257310
<meta charset="utf-8">
258311
<meta name="viewport" content="width=device-width, initial-scale=1">
259-
260312
<style>
261313
html, body { margin: 0; padding: 0; background: #ffffff; color: #111; font: -apple-system-body; }
262314
pre { white-space: pre-wrap; word-wrap: break-word; margin: 0; padding: 16px; }
@@ -277,7 +329,6 @@ struct EditorAreaFileView: View {
277329
@State private var cancellables = Set<AnyCancellable>()
278330
@State private var renderWorkItem: DispatchWorkItem?
279331

280-
// WebKit is always enabled; only allow JS toggle
281332
@State private var webViewAllowJS: Bool = false
282333
@State private var previewSource: PreviewSource = .localHTML
283334

@@ -286,9 +337,13 @@ struct EditorAreaFileView: View {
286337

287338
@State private var webViewRefreshToken = UUID()
288339

340+
// FS watcher state
341+
@State private var watcher = DirectoryWatcher()
342+
@State private var lastKnownFileMTime: Date?
343+
289344
// Fixed size constants
290-
private let FIXED_PREVIEW_HEIGHT: CGFloat = 320 // adjust as needed
291-
private let FIXED_PREVIEW_WIDTH: CGFloat = 420 // used in split right pane
345+
private let FIXED_PREVIEW_HEIGHT: CGFloat = 320
346+
private let FIXED_PREVIEW_WIDTH: CGFloat = 420
292347

293348
private func bindContent() {
294349
NotificationCenter.default.publisher(
@@ -378,14 +433,54 @@ struct EditorAreaFileView: View {
378433
}
379434
}
380435

381-
// MARK: - Whole Editor Layout with fixed WebView below divider
436+
// MARK: - FS Watch helpers
437+
private func startFileWatchIfNeeded() {
438+
guard let fileURL = codeFile.fileURL else { return }
439+
440+
// Record current mtime to filter unrelated directory events
441+
lastKnownFileMTime = (try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
442+
443+
watcher.startWatching(url: fileURL) { [fileURL] in
444+
// Compute new mtime off the main thread
445+
let mtime = (try? fileURL.resourceValues(forKeys: [.contentModificationDateKey]))?.contentModificationDate
446+
447+
// Hop to main to mutate state and refresh UI
448+
DispatchQueue.main.async {
449+
if self.lastKnownFileMTime == mtime { return }
450+
self.lastKnownFileMTime = mtime
451+
452+
if let newText = self.codeFile.content?.string {
453+
self.updatePreview(with: newText)
454+
} else {
455+
do {
456+
let data = try Data(contentsOf: fileURL)
457+
let newText = String(data: data, encoding: .utf8) ?? ""
458+
self.updatePreview(with: newText)
459+
} catch {
460+
print("[FS Watch] Failed reading file: \(error.localizedDescription)")
461+
}
462+
}
463+
464+
self.webViewRefreshToken = UUID()
465+
}
466+
} onError: { err in
467+
DispatchQueue.main.async {
468+
print("[FS Watch] Error: \(err.localizedDescription)")
469+
}
470+
}
471+
}
472+
473+
private func stopFileWatch() {
474+
watcher.stop()
475+
}
476+
477+
// MARK: - Layout
382478
@ViewBuilder var editorAreaFileView: some View {
383479
let pathExt = codeFile.fileURL?.pathExtension.lowercased() ?? ""
384480
let isHTML = (codeFile.utType?.conforms(to: .html) ?? false) || (["html", "htm"].contains(pathExt))
385481
let isMarkdown = (codeFile.utType?.identifier == "net.daringfireball.markdown") || (pathExt == "md" || pathExt == "markdown")
386482

387483
VStack(spacing: 0) {
388-
// Top row: mode picker
389484
HStack(spacing: 8) {
390485
Picker("Display Mode", selection: $displayMode) {
391486
ForEach(EditorDisplayMode.allCases) { mode in
@@ -400,14 +495,12 @@ struct EditorAreaFileView: View {
400495

401496
Divider()
402497

403-
// Content below divider
404498
if isHTML {
405499
switch displayMode {
406500
case .code:
407501
CodeFileView(editorInstance: editorInstance, codeFile: codeFile)
408502

409503
case .preview:
410-
// Fixed-height WebView area below the divider
411504
VStack(spacing: 0) {
412505
ZStack {
413506
WebView(
@@ -422,7 +515,6 @@ struct EditorAreaFileView: View {
422515
.frame(maxWidth: .infinity, minHeight: FIXED_PREVIEW_HEIGHT, maxHeight: FIXED_PREVIEW_HEIGHT)
423516
.background(Color.white)
424517

425-
// Bottom overlay bar pinned inside the fixed area
426518
VStack(spacing: 0) {
427519
Spacer()
428520
PreviewBottomBar(
@@ -441,7 +533,6 @@ struct EditorAreaFileView: View {
441533
HStack(spacing: 0) {
442534
CodeFileView(editorInstance: editorInstance, codeFile: codeFile)
443535

444-
// Right pane has fixed width; inside it, fixed-height WebView
445536
VStack(spacing: 0) {
446537
ZStack {
447538
WebView(
@@ -522,7 +613,6 @@ struct EditorAreaFileView: View {
522613
.frame(minWidth: FIXED_PREVIEW_WIDTH,
523614
idealWidth: FIXED_PREVIEW_WIDTH,
524615
maxWidth: FIXED_PREVIEW_WIDTH,
525-
minHeight: nil, idealHeight: nil,
526616
maxHeight: .infinity, alignment: .center)
527617
.background(Color.white)
528618
}
@@ -556,6 +646,14 @@ struct EditorAreaFileView: View {
556646
await loadServerPreview(with: sourceString)
557647
}
558648
}
649+
startFileWatchIfNeeded()
650+
}
651+
.onChange(of: codeFile.fileURL) { _ in
652+
stopFileWatch()
653+
startFileWatchIfNeeded()
654+
}
655+
.onDisappear {
656+
stopFileWatch()
559657
}
560658
}
561659

@@ -568,47 +666,4 @@ struct EditorAreaFileView: View {
568666
}
569667
}
570668
}
571-
572-
struct EditorArea: View {
573-
let editorInstance: EditorInstance
574-
let codeFile: CodeFileDocument
575-
576-
private let renderer = HTMLRenderer(
577-
render: { source in
578-
func isHTML(_ sourceString: String) -> Bool {
579-
let trimed = sourceString.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
580-
return trimed.hasPrefix("<!doctype") || trimed.hasPrefix("<html")
581-
}
582-
if isHTML(source) { return source }
583-
let escaped = source
584-
.replacingOccurrences(of: "&", with: "&amp;")
585-
.replacingOccurrences(of: "<", with: "&lt;")
586-
.replacingOccurrences(of: ">", with: "&gt;")
587-
return """
588-
<!doctype html>
589-
<html>
590-
<head>
591-
<meta charset="utf-8">
592-
<style>
593-
body { font: -apple-system-body; padding: 16px; background: transparent; color: #111; }
594-
pre { white-space: pre-wrap; word-wrap: break-word; margin: 0; }
595-
</style>
596-
</head>
597-
<body><pre>\(escaped)</pre></body>
598-
</html>
599-
"""
600-
},
601-
loggingEnabled: true
602-
)
603-
604-
var body: some View {
605-
EditorAreaFileView(
606-
editorInstance: editorInstance,
607-
codeFile: codeFile,
608-
htmlRenderer: renderer,
609-
enablePreviewLogging: true
610-
)
611-
}
612-
}
613669
}
614-

0 commit comments

Comments
 (0)