Class
Sealed
public sealed class FileWatcher : IDisposable
Namespace: Moka.Docs.Serve
Watches a documentation directory and config file for changes, debouncing notifications to avoid rapid-fire rebuilds.
Inheritance
Inherits from: IDisposable
Constructors
| Name | Description |
|---|---|
FileWatcher(FileWatcher> logger, int debounceMs) |
Creates a new file watcher. |
FileWatcher(FileWatcher> logger, int debounceMs)
FileWatcher.FileWatcher(ILogger<FileWatcher> logger, int debounceMs = 300)
Creates a new file watcher.
Parameters
| Name | Type | Description |
|---|---|---|
logger | ILogger<Moka.Docs.Serve.FileWatcher> | Logger instance. |
debounceMs | int | Debounce interval in milliseconds (default: 300). |
Methods
| Name | Description |
|---|---|
Dispose() |
|
Start(string docsDirectory, string? configFilePath) |
Start watching the specified directory and optional config file path. |
Stop() |
Stop watching for changes. |
Start(string docsDirectory, string? configFilePath)
void FileWatcher.Start(string docsDirectory, string? configFilePath = null)
Start watching the specified directory and optional config file path.
Parameters
| Name | Type | Description |
|---|---|---|
docsDirectory | string | The docs directory to watch recursively. |
configFilePath | string? | Optional path to mokadocs.yaml to also watch. |
Stop()
void FileWatcher.Stop()
Stop watching for changes.
Events
| Name | Description |
|---|---|
OnChanged |
Raised when one or more files have changed (after debounce). |
OnChanged
event Func<Task>? OnChanged
Raised when one or more files have changed (after debounce).
Type Relationships
classDiagram
style FileWatcher fill:#f9f,stroke:#333,stroke-width:2px
FileWatcher --|> IDisposable : inherits
View Source
/// <summary>
/// Watches a documentation directory and config file for changes,
/// debouncing notifications to avoid rapid-fire rebuilds.
/// </summary>
public sealed class FileWatcher : IDisposable
{
private readonly TimeSpan _debounceInterval;
private readonly object _lock = new();
private readonly ILogger<FileWatcher> _logger;
private readonly List<FileSystemWatcher> _watchers = [];
private CancellationTokenSource? _debounceCts;
private bool _disposed;
/// <summary>
/// Creates a new file watcher.
/// </summary>
/// <param name = "logger">Logger instance.</param>
/// <param name = "debounceMs">Debounce interval in milliseconds (default: 300).</param>
public FileWatcher(ILogger<FileWatcher> logger, int debounceMs = 300)
{
_logger = logger;
_debounceInterval = TimeSpan.FromMilliseconds(debounceMs);
}
/// <inheritdoc/>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_debounceCts?.Cancel();
_debounceCts?.Dispose();
foreach (FileSystemWatcher watcher in _watchers)
{
watcher.EnableRaisingEvents = false;
watcher.Dispose();
}
_watchers.Clear();
}
/// <summary>
/// Raised when one or more files have changed (after debounce).
/// </summary>
public event Func<Task>? OnChanged;
/// <summary>
/// Start watching the specified directory and optional config file path.
/// </summary>
/// <param name = "docsDirectory">The docs directory to watch recursively.</param>
/// <param name = "configFilePath">Optional path to mokadocs.yaml to also watch.</param>
public void Start(string docsDirectory, string? configFilePath = null)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(FileWatcher));
}
// Watch docs directory recursively
if (Directory.Exists(docsDirectory))
{
var docsWatcher = new FileSystemWatcher(docsDirectory)
{
IncludeSubdirectories = true,
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.DirectoryName,
EnableRaisingEvents = true
};
docsWatcher.Changed += OnFileEvent;
docsWatcher.Created += OnFileEvent;
docsWatcher.Deleted += OnFileEvent;
docsWatcher.Renamed += OnRenameEvent;
_watchers.Add(docsWatcher);
_logger.LogInformation("Watching directory: {Directory}", docsDirectory);
}
else
{
_logger.LogWarning("Docs directory does not exist: {Directory}", docsDirectory);
}
// Watch config file specifically
if (configFilePath is not null && File.Exists(configFilePath))
{
string configDir = Path.GetDirectoryName(configFilePath)!;
string configName = Path.GetFileName(configFilePath);
var configWatcher = new FileSystemWatcher(configDir, configName)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.CreationTime,
EnableRaisingEvents = true
};
configWatcher.Changed += OnFileEvent;
_watchers.Add(configWatcher);
_logger.LogInformation("Watching config: {ConfigFile}", configFilePath);
}
}
/// <summary>
/// Stop watching for changes.
/// </summary>
public void Stop()
{
foreach (FileSystemWatcher watcher in _watchers)
{
watcher.EnableRaisingEvents = false;
}
_logger.LogInformation("File watching stopped");
}
private void OnFileEvent(object sender, FileSystemEventArgs e)
{
// Ignore changes in _site output directory or hidden directories
if (e.FullPath.Contains("_site") || e.FullPath.Contains("/.") || e.FullPath.Contains("\\."))
{
return;
}
_logger.LogDebug("File changed: {Path} ({ChangeType})", e.FullPath, e.ChangeType);
ScheduleDebounce();
}
private void OnRenameEvent(object sender, RenamedEventArgs e)
{
if (e.FullPath.Contains("_site") || e.FullPath.Contains("/.") || e.FullPath.Contains("\\."))
{
return;
}
_logger.LogDebug("File renamed: {OldPath} -> {Path}", e.OldFullPath, e.FullPath);
ScheduleDebounce();
}
private void ScheduleDebounce()
{
lock (_lock)
{
// Cancel any existing debounce timer
_debounceCts?.Cancel();
_debounceCts?.Dispose();
_debounceCts = new CancellationTokenSource();
CancellationToken token = _debounceCts.Token;
_ = Task.Run(async () =>
{
try
{
await Task.Delay(_debounceInterval, token);
if (!token.IsCancellationRequested)
{
_logger.LogInformation("Changes detected, triggering rebuild...");
if (OnChanged is not null)
{
await OnChanged.Invoke();
}
}
}
catch (TaskCanceledException)
{
// Debounce was reset — expected behavior
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in file change handler");
}
});
}
}
}