module IHP.IDE.FileWatcher (runFileWatcherWithDebounce, FileWatcherParams (..)) where import IHP.Prelude import Control.Exception import Control.Concurrent (threadDelay) import Control.Concurrent.MVar import Control.Monad (filterM) import System.Directory (listDirectory, doesDirectoryExist) import qualified Data.Map as Map import qualified System.FSNotify as FS import qualified Data.List as List import qualified Control.Debounce as Debounce data FileWatcherParams = FileWatcherParams { onHaskellFileChanged :: IO () , onSchemaChanged :: IO () , onAssetChanged :: IO () } runFileWatcherWithDebounce :: FileWatcherParams -> IO () runFileWatcherWithDebounce params = do debouncedParams <- applyDebounce params runFileWatcher debouncedParams applyDebounce :: FileWatcherParams -> IO FileWatcherParams applyDebounce params = do onHaskellFileChangedDebounced <- Debounce.mkDebounce Debounce.defaultDebounceSettings { Debounce.debounceAction = params.onHaskellFileChanged , Debounce.debounceFreq = 50000 -- 50ms , Debounce.debounceEdge = Debounce.leadingEdge } onSchemaChangedDebounced <- Debounce.mkDebounce Debounce.defaultDebounceSettings { Debounce.debounceAction = params.onSchemaChanged , Debounce.debounceFreq = 50000 -- 50ms , Debounce.debounceEdge = Debounce.leadingEdge } pure params { onHaskellFileChanged = onHaskellFileChangedDebounced, onSchemaChanged = onSchemaChangedDebounced } runFileWatcher :: FileWatcherParams -> IO () runFileWatcher params@FileWatcherParams { .. } = do FS.withManagerConf fileWatcherConfig \manager -> do state <- newFileWatcherState watchRootDirectoryFiles (handleFileChange params) manager state watchSubDirectories (handleFileChange params) manager state forever (threadDelay maxBound) `finally` FS.stopManager manager watchRootDirectoryFiles handleFileChange manager state = FS.watchDir manager "." shouldActOnRootFileChange (handleRootFileChange handleFileChange manager state) watchSubDirectories handleFileChange manager state = do directories <- listWatchableDirectories forM_ directories \directory -> do startWatchingSubDirectory handleFileChange manager state directory type WatchedDirectories = Map FilePath FS.StopListening type FileWatcherState = MVar WatchedDirectories newFileWatcherState :: IO FileWatcherState newFileWatcherState = newMVar mempty startWatchingSubDirectory :: (FS.Event -> IO ()) -> FS.WatchManager -> FileWatcherState -> FilePath -> IO () startWatchingSubDirectory handleFileChange manager state path = do watchedDirectories <- readMVar state case Map.lookup path watchedDirectories of Just _ -> pure () Nothing -> do stop <- FS.watchTree manager path shouldActOnFileChange handleFileChange modifyMVar_ state (\map -> pure (Map.insert path stop map)) stopWatchingSubDirectory :: FileWatcherState -> FilePath -> IO () stopWatchingSubDirectory state path = do watchedDirectories <- readMVar state case Map.lookup path watchedDirectories of Just stop -> do stop modifyMVar_ state (\map -> pure (Map.delete path map)) Nothing -> pure () listWatchableDirectories :: IO [String] listWatchableDirectories = do rootDirectoryContents <- listDirectory "." filterM shouldWatchDirectory rootDirectoryContents shouldWatchDirectory :: String -> IO Bool shouldWatchDirectory path = do isDirectory <- doesDirectoryExist path pure $ isDirectory && isDirectoryWatchable path isDirectoryWatchable :: String -> Bool isDirectoryWatchable path = path /= ".devenv" && path /= ".direnv" fileWatcherConfig :: FS.WatchConfig fileWatcherConfig = FS.defaultConfig handleFileChange :: FileWatcherParams -> FS.Event -> IO () handleFileChange params event = do let filePath = event.eventPath if isHaskellFile filePath then params.onHaskellFileChanged else if isSchemaSQL filePath then params.onSchemaChanged else if isAssetFile filePath then params.onAssetChanged else mempty handleRootFileChange :: (FS.Event -> IO ()) -> FS.WatchManager -> FileWatcherState -> FS.Event -> IO () handleRootFileChange handleFileChange manager state event = case event of FS.Added filePath _ true -> if isDirectoryWatchable filePath then do startWatchingSubDirectory handleFileChange manager state filePath else pure () FS.Removed filePath _ true -> stopWatchingSubDirectory state filePath _ -> handleFileChange event shouldActOnRootFileChange :: FS.ActionPredicate shouldActOnRootFileChange event = if FS.eventIsDirectory event == FS.IsDirectory then isDirectoryWatchable event.eventPath else shouldActOnFileChange event shouldActOnFileChange :: FS.ActionPredicate shouldActOnFileChange event = let path = event.eventPath in isHaskellFile path || isAssetFile path || isSQLFile path isHaskellFile :: String -> Bool isHaskellFile = List.isSuffixOf ".hs" isAssetFile :: String -> Bool isAssetFile = List.isSuffixOf ".css" isSQLFile :: String -> Bool isSQLFile = List.isSuffixOf ".sql" isSchemaSQL :: String -> Bool isSchemaSQL = List.isSuffixOf "Application/Schema.sql"