How Quarkdown's Directory Watcher Detects File Changes for Live Preview

Quarkdown uses a DirectoryWatcher wrapper around the io.methvin.watcher library to monitor source directories for changes, filter out excluded paths, and trigger automatic recompilation when files are modified.

Quarkdown's live preview feature relies on a robust file system monitoring system that detects changes in real time and reloads the compiled output without manual intervention. The implementation centers on a Kotlin-based directory watcher that integrates with the CLI's command execution pipeline. This article examines the source code of the iamgio/quarkdown repository to explain how file change detection works, from the low-level watcher implementation to the browser auto-reload mechanism.

Core Architecture of the Directory Watcher

The DirectoryWatcher Wrapper Class

The monitoring capability is encapsulated in com.quarkdown.cli.watcher.DirectoryWatcher, located at quarkdown-cli/src/main/kotlin/com/quarkdown/cli/watcher/DirectoryWatcher.kt. This class provides a thin abstraction over the third-party io.methvin.watcher.DirectoryWatcher library, handling Kotlin-specific concurrency patterns and path filtering.

The companion object exposes a create() factory method that accepts three parameters: the target directory to watch, an optional exclude predicate (defaulting to a filter that skips temporary files ending with ~), and an onChange listener that receives a DirectoryChangeEvent. The wrapper constructs the underlying native watcher and attaches a custom listener that performs preliminary filtering before invoking the user-supplied callback.

Event Filtering and Exclusion Logic

Before forwarding any filesystem event, the watcher validates the changed path against two exclusion criteria. As implemented in lines 58-63 of DirectoryWatcher.kt, the logic checks both a path-based predicate and a list of excluded file objects:

val acceptByPath = !exclude(it.path())
val acceptByFiles = excludeFiles.none { file -> it.path().startsWith(file.absolutePath) }
if (acceptByPath && acceptByFiles) onChange(it)

This dual-filter approach ensures that temporary editor files and the output directory do not trigger unnecessary recompilation cycles. The excludeFiles list specifically prevents the watcher from reacting to changes in the directory where Quarkdown writes its generated HTML, avoiding infinite loops where compilation output would trigger further compilation.

Running Modes and Threading

The wrapper provides two execution modes for different consumption patterns. The watchBlocking() method occupies the current thread indefinitely, directly delegating to the underlying library's watch() method. Alternatively, watch() spawns a background thread using kotlin.concurrent.thread that internally calls watchBlocking(), allowing the CLI to continue with other initialization tasks while monitoring proceeds asynchronously. Cleanup is handled through the stop() method, which simply closes the native watcher instance.

CLI Integration and Live Preview Triggering

The --watch Flag in ExecuteCommand

The decision to enable file watching occurs in ExecuteCommand, the abstract base class for CompileCommand and ReplCommand found at quarkdown-cli/src/main/kotlin/com/quarkdown/cli/exec/ExecuteCommand.kt. The CLI exposes the -w/--watch flag as a Boolean property (lines 65-68), which the run() method checks after building the CLI options.

When the watch flag is active, the command identifies the parent directory of the source file and initializes a watcher that excludes the output directory:

cliOptions.takeIf { watch }?.source?.absoluteFile?.parentFile?.let { sourceDirectory ->
    Log.info("Watching for file changes in source directory: $sourceDirectory")
    DirectoryWatcher
        .create(sourceDirectory, exclude = cliOptions.outputDirectory) { event ->
            Log.info("File changed: ${event.path()}. Launching.")
            execute(cliOptions, pipelineOptions)
        }.watch()
}

This code (lines 65-74) watches the sourceDirectory (the parent of the input .qd file), excludes cliOptions.outputDirectory from monitoring, and invokes execute()—which runs the full compilation pipeline—whenever a valid change event occurs.

Preview Mode and Auto-Reload Mechanism

When the -p/--preview flag accompanies --watch, the pipeline executes with isPreview set to true, causing the generated HTML to be served by an embedded web server. The preview system uses a fixed resource name derived from a hash of the source path (see resolveResourceName, lines 19-23), meaning each recompilation overwrites the same URL. The browser client, implemented in live-preview.ts, receives server-sent events or polling updates and automatically refreshes the page when the resource changes, completing the live-reload cycle without requiring manual browser interaction.

End-to-End File Change Detection Flow

The complete workflow from file modification to browser refresh follows these steps:

  1. Initialization: User executes quarkdown compile -p -w mydoc.qd, triggering ExecuteCommand.run().
  2. Watcher Setup: The system creates a DirectoryWatcher for the directory containing mydoc.qd, configured to ignore the quarkdown-output directory.
  3. Monitoring: A background thread begins listening for filesystem events via the native io.methvin.watcher implementation.
  4. Change Detection: When the user saves changes to mydoc.qd, the OS notifies the watcher, which passes the path through the exclusion filters.
  5. Recompilation: The listener callback logs the change and invokes execute(cliOptions, pipelineOptions), re-running the document compilation.
  6. Browser Update: The new HTML overwrites the preview resource, and the frontend TypeScript client detects the update and reloads the browser tab.

Testing the Watcher Implementation

The reliability of the directory watcher is verified by WatcherTest.kt in quarkdown-cli/src/test/kotlin/com/quarkdown/cli/WatcherTest.kt. These unit tests instantiate the DirectoryWatcher with temporary directories, programmatically create and modify files, and assert that the callback listener receives the expected events. The test suite specifically validates the exclusion logic (ensuring filtered paths do not trigger callbacks) and confirms proper asynchronous start/stop behavior, preventing resource leaks during CLI operation.

Summary

  • Quarkdown's directory watcher is a Kotlin wrapper around io.methvin.watcher.DirectoryWatcher that handles threading and path filtering.
  • Dual exclusion logic prevents temporary files and the output directory from triggering recompilation, avoiding infinite loops.
  • The --watch flag in ExecuteCommand activates the watcher on the source file's parent directory, calling execute() on every valid change.
  • Live preview integration relies on fixed resource naming and an embedded server, allowing the browser to auto-reload when the pipeline regenerates HTML.
  • Unit tests in WatcherTest.kt verify the watcher's event handling and exclusion capabilities.

Frequently Asked Questions

How does Quarkdown avoid infinite loops when the output directory changes?

The watcher explicitly excludes cliOptions.outputDirectory from monitoring by passing it as an excludeFiles parameter during initialization. When the compilation pipeline writes new HTML files, the exclusion logic in DirectoryWatcher.kt (lines 58-63) checks excludeFiles.none { file -> it.path().startsWith(file.absolutePath) }, preventing these write operations from triggering additional compilation cycles.

What library does Quarkdown use for file system monitoring?

Quarkdown utilizes the io.methvin.watcher.DirectoryWatcher library, a high-performance native file system watcher for the JVM. The com.quarkdown.cli.watcher.DirectoryWatcher class serves as a Kotlin-friendly wrapper that manages threading, event filtering, and lifecycle management while delegating the actual OS-level monitoring to this underlying library.

Can I exclude specific files from triggering a reload?

Yes, the DirectoryWatcher.create() method accepts a customizable exclude predicate function that filters paths based on custom logic. By default, it ignores files ending with ~ (common temporary file patterns), but you can supply your own lambda—such as { path -> path.extension == "tmp" }—to ignore specific file extensions or patterns during initialization.

How does the browser know when to refresh during live preview?

The preview system uses a fixed resource name generated from a hash of the source path, causing each recompilation to overwrite the same URL. The TypeScript client in live-preview.ts establishes a connection to the embedded server and listens for update notifications. When ExecuteCommand triggers execute() after detecting a file change, the regenerated HTML replaces the previous version, and the frontend client automatically reloads the browser to display the updated content.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →