Initial release: Ironpad v0.1.0 - Local-first, file-based project and knowledge management system. Rust backend, Vue 3 frontend, Milkdown editor, Git integration, cross-platform builds. Built with AI using Open Method.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
skepsismusic
2026-02-06 00:13:31 +01:00
commit ebe3e2aa8f
97 changed files with 25033 additions and 0 deletions

161
backend/src/watcher.rs Normal file
View File

@@ -0,0 +1,161 @@
use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use notify::{RecursiveMode, Watcher};
use notify_debouncer_full::{new_debouncer, DebouncedEvent};
use tokio::sync::mpsc;
use crate::config;
use crate::websocket::{WsMessage, WsState};
/// Start the file watcher in a background task
pub async fn start_watcher(ws_state: Arc<WsState>) -> Result<(), String> {
let (tx, mut rx) = mpsc::channel::<Vec<DebouncedEvent>>(100);
// Create debouncer with 500ms debounce time
let debouncer = new_debouncer(
Duration::from_millis(500),
None,
move |result: Result<Vec<DebouncedEvent>, Vec<notify::Error>>| {
if let Ok(events) = result {
let _ = tx.blocking_send(events);
}
},
)
.map_err(|e| format!("Failed to create file watcher: {}", e))?;
// Watch the data directory
let data_path = config::data_dir();
if !data_path.exists() {
return Err(format!("Data directory does not exist: {}", data_path.display()));
}
// We need to keep the debouncer alive, so we'll store it
let debouncer = Arc::new(tokio::sync::Mutex::new(debouncer));
{
let mut d = debouncer.lock().await;
d.watcher().watch(data_path, RecursiveMode::Recursive)
.map_err(|e| format!("Failed to watch directory: {}", e))?;
}
tracing::info!("File watcher started for: {}", data_path.display());
// Spawn task to process file events
let ws_state_clone = ws_state.clone();
tokio::spawn(async move {
// Keep debouncer alive
let _debouncer = debouncer;
while let Some(events) = rx.recv().await {
for event in events {
process_event(&event, &ws_state_clone);
}
}
});
Ok(())
}
/// Track recent saves to avoid notifying about our own changes
use std::sync::Mutex;
use std::collections::HashMap;
use std::time::Instant;
lazy_static::lazy_static! {
static ref RECENT_SAVES: Mutex<HashMap<String, Instant>> = Mutex::new(HashMap::new());
}
/// Mark a file as recently saved by us (call this before saving)
pub fn mark_file_saved(path: &str) {
if let Ok(mut saves) = RECENT_SAVES.lock() {
saves.insert(path.to_string(), Instant::now());
}
}
/// Process a single debounced file event
fn process_event(event: &DebouncedEvent, ws_state: &WsState) {
use notify::EventKind;
// Only process markdown files
let paths: Vec<_> = event
.paths
.iter()
.filter(|p| {
p.extension()
.and_then(|e| e.to_str())
.map(|e| e == "md")
.unwrap_or(false)
})
.collect();
if paths.is_empty() {
return;
}
// Skip temporary files (used for atomic writes)
if paths.iter().any(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.') && n.ends_with(".tmp"))
.unwrap_or(false)
}) {
return;
}
// Skip archive and .git directories
if paths.iter().any(|p| {
let s = p.to_string_lossy();
s.contains("archive") || s.contains(".git")
}) {
return;
}
let path_str = normalize_path(&paths[0]);
// Check if this was a recent save by us (within last 2 seconds)
if let Ok(mut saves) = RECENT_SAVES.lock() {
// Clean up old entries
saves.retain(|_, t| t.elapsed().as_secs() < 5);
if let Some(saved_at) = saves.get(&path_str) {
if saved_at.elapsed().as_secs() < 2 {
return; // Skip - this was our own save
}
}
}
let msg = match &event.kind {
EventKind::Create(_) => {
tracing::info!("External file created: {}", path_str);
Some(WsMessage::FileCreated { path: path_str })
}
EventKind::Modify(_) => {
tracing::info!("External file modified: {}", path_str);
Some(WsMessage::FileModified { path: path_str })
}
EventKind::Remove(_) => {
tracing::info!("External file deleted: {}", path_str);
Some(WsMessage::FileDeleted { path: path_str })
}
_ => None,
};
if let Some(msg) = msg {
ws_state.broadcast(msg);
}
}
/// Normalize path for client consumption
fn normalize_path(path: &Path) -> String {
let path_str = path.to_string_lossy();
// Find "data" in the path and strip everything before and including it
if let Some(idx) = path_str.find("data") {
let stripped = &path_str[idx + 5..]; // Skip "data" + separator
return stripped.replace('\\', "/").trim_start_matches('/').to_string();
}
path_str.replace('\\', "/")
}