Files
clide/clide/services/file_watcher.py
T
Jeroen SchweitzerandClaude Opus 4.5 c955e3ad25 Add file watcher integration and IDE enhancements
- Implement file system watching with watchdog for real-time file panel updates
- Add FileEventMessage for event-driven file change handling
- Integrate Claude events parsing for IDE awareness of Claude actions
- Add extension hooks for file changes (clide_on_file_changed, clide_on_file_saved)
- Enhance editor pane with syntax highlighting service
- Improve workspace panel with action bar and file operations
- Update Claude panel with PTY terminal integration
- Add TODO.md to track long-term project items
- Update keybindings to use Alt-based shortcuts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 16:14:44 +01:00

287 lines
8.7 KiB
Python

"""File system watching service for real-time sync.
This module provides file system monitoring capabilities for the Clide IDE,
enabling reactive updates when files change on disk.
"""
from __future__ import annotations
import asyncio
from datetime import datetime
from pathlib import Path
from typing import Callable, Literal
from pydantic import BaseModel, ConfigDict
from textual.message import Message
try:
from watchdog.observers import Observer as WatchdogObserver
from watchdog.events import (
FileSystemEventHandler as WatchdogHandler,
FileCreatedEvent,
FileModifiedEvent,
FileDeletedEvent,
FileMovedEvent,
DirCreatedEvent,
DirModifiedEvent,
DirDeletedEvent,
DirMovedEvent,
)
WATCHDOG_AVAILABLE = True
except ImportError:
WATCHDOG_AVAILABLE = False
WatchdogObserver = None # type: ignore[misc, assignment]
WatchdogHandler = object # type: ignore[misc, assignment]
FileCreatedEvent = None # type: ignore[misc, assignment]
FileModifiedEvent = None # type: ignore[misc, assignment]
FileDeletedEvent = None # type: ignore[misc, assignment]
FileMovedEvent = None # type: ignore[misc, assignment]
DirCreatedEvent = None # type: ignore[misc, assignment]
DirModifiedEvent = None # type: ignore[misc, assignment]
DirDeletedEvent = None # type: ignore[misc, assignment]
DirMovedEvent = None # type: ignore[misc, assignment]
class FileEvent(BaseModel):
"""A file system event.
Attributes:
path: The path to the file/directory that changed.
event_type: The type of change that occurred.
timestamp: When the event occurred.
is_directory: Whether this is a directory event.
old_path: For move events, the original path.
"""
model_config = ConfigDict(strict=True, frozen=True)
path: Path
event_type: Literal["created", "modified", "deleted", "moved"]
timestamp: datetime
is_directory: bool = False
old_path: Path | None = None
class FileEventMessage(Message):
"""Textual message for file events."""
def __init__(self, event: FileEvent) -> None:
self.event = event
super().__init__()
class FileWatcher:
"""Watches a directory for file system changes.
Uses watchdog for efficient cross-platform file monitoring.
Emits FileEvent objects to registered handlers.
Example:
watcher = FileWatcher(Path.cwd())
watcher.register_handler(my_handler)
watcher.start()
# ... later ...
watcher.stop()
"""
def __init__(self, root: Path, ignore_patterns: list[str] | None = None) -> None:
"""Initialize the file watcher.
Args:
root: The root directory to watch.
ignore_patterns: Glob patterns to ignore (e.g., ["*.pyc", "__pycache__"]).
"""
self._root = root.resolve()
self._ignore_patterns = ignore_patterns or [
"*.pyc",
"__pycache__",
".git",
".venv",
"venv",
"node_modules",
".mypy_cache",
".ruff_cache",
".pytest_cache",
"*.egg-info",
]
self._handlers: list[Callable[[FileEvent], None]] = []
self._observer: WatchdogObserver | None = None # type: ignore[valid-type]
self._running = False
@property
def is_available(self) -> bool:
"""Check if watchdog is available."""
return WATCHDOG_AVAILABLE
@property
def is_running(self) -> bool:
"""Check if the watcher is currently running."""
return self._running
@property
def root(self) -> Path:
"""Get the root directory being watched."""
return self._root
def register_handler(self, handler: Callable[[FileEvent], None]) -> None:
"""Register a handler for file events.
Args:
handler: A callable that accepts a FileEvent.
"""
if handler not in self._handlers:
self._handlers.append(handler)
def unregister_handler(self, handler: Callable[[FileEvent], None]) -> None:
"""Unregister a handler.
Args:
handler: The handler to remove.
"""
if handler in self._handlers:
self._handlers.remove(handler)
def _should_ignore(self, path: Path) -> bool:
"""Check if a path should be ignored based on patterns."""
path_str = str(path)
for pattern in self._ignore_patterns:
# Simple pattern matching - could be enhanced with fnmatch
if pattern.startswith("*"):
if path_str.endswith(pattern[1:]):
return True
elif pattern in path_str:
return True
return False
def _emit_event(self, event: FileEvent) -> None:
"""Emit an event to all handlers."""
if self._should_ignore(event.path):
return
for handler in self._handlers:
try:
handler(event)
except Exception:
pass # Don't let handler errors affect other handlers
def start(self) -> bool:
"""Start watching for file changes.
Returns:
True if started successfully, False if watchdog is not available.
"""
if not WATCHDOG_AVAILABLE:
return False
if self._running:
return True
event_handler = _WatchdogHandler(self)
self._observer = WatchdogObserver()
self._observer.schedule(event_handler, str(self._root), recursive=True)
self._observer.start()
self._running = True
return True
def stop(self) -> None:
"""Stop watching for file changes."""
if self._observer is not None:
self._observer.stop()
self._observer.join(timeout=5)
self._observer = None
self._running = False
class _WatchdogHandler(WatchdogHandler): # type: ignore[misc, valid-type]
"""Internal handler for watchdog events."""
def __init__(self, watcher: FileWatcher) -> None:
super().__init__()
self._watcher = watcher
def _create_event(
self,
src_path: str | bytes,
event_type: Literal["created", "modified", "deleted", "moved"],
is_directory: bool,
dest_path: str | bytes | None = None,
) -> FileEvent:
"""Create a FileEvent from watchdog event data."""
# Watchdog can return bytes or str depending on platform
src = src_path.decode() if isinstance(src_path, bytes) else src_path
dest = dest_path.decode() if isinstance(dest_path, bytes) else dest_path
return FileEvent(
path=Path(dest if dest else src),
event_type=event_type,
timestamp=datetime.now(),
is_directory=is_directory,
old_path=Path(src) if dest else None,
)
def on_created(self, event) -> None: # type: ignore[no-untyped-def]
file_event = self._create_event(
event.src_path, "created", event.is_directory
)
self._watcher._emit_event(file_event)
def on_modified(self, event) -> None: # type: ignore[no-untyped-def]
file_event = self._create_event(
event.src_path, "modified", event.is_directory
)
self._watcher._emit_event(file_event)
def on_deleted(self, event) -> None: # type: ignore[no-untyped-def]
file_event = self._create_event(
event.src_path, "deleted", event.is_directory
)
self._watcher._emit_event(file_event)
def on_moved(self, event) -> None: # type: ignore[no-untyped-def]
file_event = self._create_event(
event.src_path, "moved", event.is_directory, event.dest_path
)
self._watcher._emit_event(file_event)
# Global watcher instance
_file_watcher: FileWatcher | None = None
def get_file_watcher(root: Path | None = None) -> FileWatcher:
"""Get or create the global file watcher.
Args:
root: The root directory to watch. Only used on first call.
Returns:
The FileWatcher instance.
"""
global _file_watcher
if _file_watcher is None:
_file_watcher = FileWatcher(root or Path.cwd())
return _file_watcher
def setup_file_watching(
root: Path,
handlers: list[Callable[[FileEvent], None]] | None = None,
) -> FileWatcher:
"""Set up file watching with optional initial handlers.
Args:
root: The root directory to watch.
handlers: Optional list of handlers to register.
Returns:
The configured FileWatcher.
"""
global _file_watcher
_file_watcher = FileWatcher(root)
if handlers:
for handler in handlers:
_file_watcher.register_handler(handler)
_file_watcher.start()
return _file_watcher