@@ -12,6 +12,8 @@ import SwiftUI
1212import WebKit
1313import UniformTypeIdentifiers
1414import Combine
15+ import Foundation
16+ import Darwin
1517
1618// MARK: - Display Modes
1719enum EditorDisplayMode : String , CaseIterable , Identifiable {
@@ -69,7 +71,6 @@ final class PreviewNavDelegate: NSObject, WKNavigationDelegate {
6971}
7072
7173// MARK: - WebView (Coordinator reuse, safe refresh)
72-
7374struct 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
235288struct 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: " & " )
585- . replacingOccurrences ( of: " < " , with: " < " )
586- . replacingOccurrences ( of: " > " , with: " > " )
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