diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index ae836764d..6013da19b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -1392,6 +1392,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + [[package]] name = "futf" version = "0.1.5" @@ -2205,6 +2214,26 @@ dependencies = [ "cfb", ] +[[package]] +name = "inotify" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2390,6 +2419,26 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "kqueue" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +dependencies = [ + "bitflags 1.3.2", + "libc", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -2618,6 +2667,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2767,6 +2817,25 @@ dependencies = [ "url", ] +[[package]] +name = "notify" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009" +dependencies = [ + "bitflags 2.11.0", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "walkdir", + "windows-sys 0.52.0", +] + [[package]] name = "notify-rust" version = "4.14.0" @@ -2781,6 +2850,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "notify-types" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174" +dependencies = [ + "instant", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -4436,6 +4514,12 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "siphasher" version = "0.3.11" @@ -4530,11 +4614,13 @@ dependencies = [ "infer", "libc", "nostr 0.37.0", + "notify", "png 0.18.1", "reqwest 0.13.2", "serde", "serde_json", "sha2 0.11.0", + "similar", "sprout-core", "tauri", "tauri-build", diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index b37c69a78..5b2b13e23 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -49,6 +49,8 @@ tauri-plugin-notification = "2.3.3" uuid = { version = "1", features = ["v4"] } png = "0.18" zip = "2" +notify = { version = "7", default-features = false, features = ["macos_fsevent"] } +similar = "2" [dev-dependencies] tempfile = "3" diff --git a/desktop/src-tauri/src/file_watcher.rs b/desktop/src-tauri/src/file_watcher.rs new file mode 100644 index 000000000..57daef567 --- /dev/null +++ b/desktop/src-tauri/src/file_watcher.rs @@ -0,0 +1,481 @@ +//! Local file watcher for computing diffs when agents modify files. +//! +//! Watches a per-channel project directory, debounces file changes, +//! computes unified diffs, and emits Tauri events to the frontend. + +use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, + sync::{Arc, Mutex}, + time::{Duration, Instant}, +}; + +use notify::{ + event::{CreateKind, ModifyKind}, + EventKind, RecommendedWatcher, RecursiveMode, Watcher, +}; +use serde::{Deserialize, Serialize}; +use similar::TextDiff; +use tauri::{AppHandle, Emitter, Manager}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/// Payload emitted to the frontend via the `"file-diff"` Tauri event. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileDiffEvent { + pub channel_id: String, + pub file_path: String, + pub unified_diff: String, + pub timestamp: u64, +} + +/// Per-channel project directory config, persisted to a local JSON file. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ProjectDirConfig { + /// channel_id → absolute directory path + pub channels: HashMap, +} + +/// Runtime state for an active file watcher. +struct WatcherRuntime { + _watcher: RecommendedWatcher, +} + +/// App-level state that holds all active file watchers. +pub struct FileWatcherState { + /// channel_id → watcher runtime + watchers: Mutex>, + /// channel_id → (file_path → last known content) + snapshots: Arc>>>, +} + +impl FileWatcherState { + pub fn new() -> Self { + Self { + watchers: Mutex::new(HashMap::new()), + snapshots: Arc::new(Mutex::new(HashMap::new())), + } + } +} + +// --------------------------------------------------------------------------- +// Config persistence +// --------------------------------------------------------------------------- + +fn config_path(app: &AppHandle) -> Result { + let dir = app + .path() + .app_data_dir() + .map_err(|e| format!("app data dir: {e}"))?; + fs::create_dir_all(&dir).map_err(|e| format!("create app data dir: {e}"))?; + Ok(dir.join("project-dirs.json")) +} + +fn load_config(app: &AppHandle) -> Result { + let path = config_path(app)?; + if !path.exists() { + return Ok(ProjectDirConfig::default()); + } + let content = + fs::read_to_string(&path).map_err(|e| format!("read project-dirs.json: {e}"))?; + serde_json::from_str(&content).map_err(|e| format!("parse project-dirs.json: {e}")) +} + +fn save_config(app: &AppHandle, config: &ProjectDirConfig) -> Result<(), String> { + let path = config_path(app)?; + let payload = + serde_json::to_vec_pretty(config).map_err(|e| format!("serialize config: {e}"))?; + fs::write(&path, payload).map_err(|e| format!("write project-dirs.json: {e}")) +} + +// --------------------------------------------------------------------------- +// Path filtering +// --------------------------------------------------------------------------- + +/// Directories to always ignore. +const IGNORED_DIRS: &[&str] = &[ + ".git", + "node_modules", + "target", + ".next", + "dist", + "build", + "__pycache__", + ".turbo", +]; + +/// File extensions to skip (binary / large generated files). +const IGNORED_EXTENSIONS: &[&str] = &[ + "png", "jpg", "jpeg", "gif", "ico", "webp", "svg", "bmp", "tiff", "mp4", "mov", "avi", + "mp3", "wav", "ogg", "woff", "woff2", "ttf", "eot", "otf", "zip", "tar", "gz", "bz2", + "xz", "7z", "rar", "exe", "dll", "so", "dylib", "o", "a", "class", "jar", "pyc", "pyo", + "wasm", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "db", "sqlite", "sqlite3", + "lock", +]; + +fn should_ignore_path(path: &Path, project_dir: &Path) -> bool { + // Check directory components. + if let Ok(relative) = path.strip_prefix(project_dir) { + for component in relative.components() { + if let std::path::Component::Normal(name) = component { + let name_str = name.to_string_lossy(); + if IGNORED_DIRS.iter().any(|d| *d == name_str.as_ref()) { + return true; + } + } + } + } + + // Check extension. + if let Some(ext) = path.extension().and_then(|e| e.to_str()) { + let ext_lower = ext.to_lowercase(); + if IGNORED_EXTENSIONS.iter().any(|e| *e == ext_lower.as_str()) { + return true; + } + } + + false +} + +fn is_regular_file(path: &Path) -> bool { + path.is_file() && !path.is_symlink() +} + +// --------------------------------------------------------------------------- +// Diff computation +// --------------------------------------------------------------------------- + +/// Compute a unified diff between `old` and `new` content for a given file path. +fn compute_unified_diff(file_path: &str, old: &str, new: &str) -> String { + let diff = TextDiff::from_lines(old, new); + diff.unified_diff() + .context_radius(3) + .header(&format!("a/{file_path}"), &format!("b/{file_path}")) + .to_string() +} + +// --------------------------------------------------------------------------- +// Snapshot management +// --------------------------------------------------------------------------- + +/// Take a snapshot of all text files in the project directory. +fn snapshot_directory(project_dir: &Path) -> HashMap { + let mut snapshots = HashMap::new(); + + if !project_dir.is_dir() { + return snapshots; + } + + fn walk_dir(dir: &Path, project_dir: &Path, snapshots: &mut HashMap) { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return, + }; + + for entry in entries.flatten() { + let path = entry.path(); + + if should_ignore_path(&path, project_dir) { + continue; + } + + if path.is_dir() { + walk_dir(&path, project_dir, snapshots); + } else if is_regular_file(&path) { + // Only snapshot files under 1MB to avoid memory issues. + if let Ok(meta) = path.metadata() { + if meta.len() > 1_048_576 { + continue; + } + } + if let Ok(content) = fs::read_to_string(&path) { + snapshots.insert(path, content); + } + } + } + } + + walk_dir(project_dir, project_dir, &mut snapshots); + snapshots +} + +// --------------------------------------------------------------------------- +// Watcher logic +// --------------------------------------------------------------------------- + +/// Start watching a project directory for a given channel. +fn start_watcher( + app: &AppHandle, + channel_id: &str, + project_dir: &Path, +) -> Result { + let fw_state = app.state::(); + + // Take initial snapshot. + let initial_snapshot = snapshot_directory(project_dir); + { + let mut snapshots = fw_state.snapshots.lock().map_err(|e| e.to_string())?; + snapshots.insert(channel_id.to_string(), initial_snapshot); + } + + let app_handle = app.clone(); + let channel_id_owned = channel_id.to_string(); + let project_dir_owned = project_dir.to_path_buf(); + let snapshots_ref = Arc::clone(&fw_state.snapshots); + + // Debounce state: track last event time per file. + let pending: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let pending_clone = Arc::clone(&pending); + + // Spawn a debounce processor that runs every 250ms. + let app_for_timer = app_handle.clone(); + let channel_for_timer = channel_id_owned.clone(); + let project_dir_for_timer = project_dir_owned.clone(); + let snapshots_for_timer = Arc::clone(&snapshots_ref); + + tauri::async_runtime::spawn(async move { + let debounce_delay = Duration::from_millis(500); + loop { + tokio::time::sleep(Duration::from_millis(250)).await; + + let ready_paths: Vec = { + let mut pending_guard = match pending_clone.lock() { + Ok(g) => g, + Err(_) => continue, + }; + let now = Instant::now(); + let mut ready = Vec::new(); + pending_guard.retain(|path, last_event| { + if now.duration_since(*last_event) >= debounce_delay { + ready.push(path.clone()); + false + } else { + true + } + }); + ready + }; + + for path in ready_paths { + if should_ignore_path(&path, &project_dir_for_timer) { + continue; + } + + let relative = path + .strip_prefix(&project_dir_for_timer) + .unwrap_or(&path) + .to_string_lossy() + .to_string(); + + // Get old content from snapshot. + let old_content = { + let snapshots = match snapshots_for_timer.lock() { + Ok(s) => s, + Err(_) => continue, + }; + snapshots + .get(&channel_for_timer) + .and_then(|m| m.get(&path)) + .cloned() + .unwrap_or_default() + }; + + // Read new content. + let new_content = if path.exists() && is_regular_file(&path) { + // Skip files over 1MB. + match path.metadata() { + Ok(meta) if meta.len() <= 1_048_576 => { + fs::read_to_string(&path).unwrap_or_default() + } + _ => continue, + } + } else { + // File was deleted. + String::new() + }; + + if old_content == new_content { + continue; + } + + let diff = compute_unified_diff(&relative, &old_content, &new_content); + + if diff.is_empty() { + continue; + } + + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + + let event = FileDiffEvent { + channel_id: channel_for_timer.clone(), + file_path: relative, + unified_diff: diff, + timestamp, + }; + + let _ = app_for_timer.emit("file-diff", &event); + + // Update snapshot. + if let Ok(mut snapshots) = snapshots_for_timer.lock() { + let channel_snaps = snapshots + .entry(channel_for_timer.clone()) + .or_insert_with(HashMap::new); + if new_content.is_empty() { + channel_snaps.remove(&path); + } else { + channel_snaps.insert(path.clone(), new_content); + } + } + } + } + }); + + let mut watcher = notify::recommended_watcher(move |res: Result| { + let event = match res { + Ok(e) => e, + Err(_) => return, + }; + + // Only care about creates and modifies. + match event.kind { + EventKind::Create(CreateKind::File) + | EventKind::Modify(ModifyKind::Data(_)) + | EventKind::Modify(ModifyKind::Name(_)) + | EventKind::Remove(_) => {} + _ => return, + } + + if let Ok(mut pending_guard) = pending.lock() { + for path in event.paths { + if should_ignore_path(&path, &project_dir_owned) { + continue; + } + pending_guard.insert(path, Instant::now()); + } + } + }) + .map_err(|e| format!("create file watcher: {e}"))?; + + watcher + .watch(project_dir, RecursiveMode::Recursive) + .map_err(|e| format!("watch directory: {e}"))?; + + Ok(WatcherRuntime { + _watcher: watcher, + }) +} + +// --------------------------------------------------------------------------- +// Tauri commands +// --------------------------------------------------------------------------- + +#[tauri::command] +pub async fn get_project_dir(app: AppHandle, channel_id: String) -> Result, String> { + let config = load_config(&app)?; + Ok(config.channels.get(&channel_id).cloned()) +} + +#[tauri::command] +pub async fn set_project_dir( + app: AppHandle, + channel_id: String, + path: Option, +) -> Result<(), String> { + let mut config = load_config(&app)?; + + if let Some(dir) = &path { + let dir_path = PathBuf::from(dir); + if !dir_path.is_dir() { + return Err(format!("Not a directory: {dir}")); + } + config.channels.insert(channel_id.clone(), dir.clone()); + } else { + config.channels.remove(&channel_id); + } + + save_config(&app, &config)?; + + // Stop existing watcher if any, start new one if path is set. + let fw_state = app.state::(); + { + let mut watchers = fw_state.watchers.lock().map_err(|e| e.to_string())?; + watchers.remove(&channel_id); + } + + if let Some(dir) = &path { + let runtime = start_watcher(&app, &channel_id, &PathBuf::from(dir))?; + let mut watchers = fw_state.watchers.lock().map_err(|e| e.to_string())?; + watchers.insert(channel_id, runtime); + } + + Ok(()) +} + +#[tauri::command] +pub async fn start_file_watcher(app: AppHandle, channel_id: String) -> Result<(), String> { + let config = load_config(&app)?; + let dir = config + .channels + .get(&channel_id) + .ok_or_else(|| format!("No project directory configured for channel {channel_id}"))?; + + let dir_path = PathBuf::from(dir); + if !dir_path.is_dir() { + return Err(format!("Project directory does not exist: {dir}")); + } + + let fw_state = app.state::(); + { + let watchers = fw_state.watchers.lock().map_err(|e| e.to_string())?; + if watchers.contains_key(&channel_id) { + return Ok(()); // Already watching. + } + } + + let runtime = start_watcher(&app, &channel_id, &dir_path)?; + let mut watchers = fw_state.watchers.lock().map_err(|e| e.to_string())?; + watchers.insert(channel_id, runtime); + Ok(()) +} + +#[tauri::command] +pub async fn stop_file_watcher(app: AppHandle, channel_id: String) -> Result<(), String> { + let fw_state = app.state::(); + let mut watchers = fw_state.watchers.lock().map_err(|e| e.to_string())?; + watchers.remove(&channel_id); + + // Also clear snapshots. + let mut snapshots = fw_state.snapshots.lock().map_err(|e| e.to_string())?; + snapshots.remove(&channel_id); + + Ok(()) +} + +/// Re-snapshot the project directory (e.g. after the user manually resets). +#[tauri::command] +pub async fn resnapshot_project_dir(app: AppHandle, channel_id: String) -> Result<(), String> { + let config = load_config(&app)?; + let dir = config + .channels + .get(&channel_id) + .ok_or_else(|| format!("No project directory configured for channel {channel_id}"))?; + + let dir_path = PathBuf::from(dir); + if !dir_path.is_dir() { + return Err(format!("Project directory does not exist: {dir}")); + } + + let new_snapshot = snapshot_directory(&dir_path); + let fw_state = app.state::(); + let mut snapshots = fw_state.snapshots.lock().map_err(|e| e.to_string())?; + snapshots.insert(channel_id, new_snapshot); + + Ok(()) +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 37d3987d9..a28c45484 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,6 +1,7 @@ mod app_state; mod commands; mod events; +mod file_watcher; mod managed_agents; mod migration; mod models; @@ -371,6 +372,7 @@ pub fn run() { }); }) .manage(build_app_state()) + .manage(file_watcher::FileWatcherState::new()) .setup(move |app| { let app_handle = app.handle().clone(); let shutdown_started = Arc::clone(&restore_shutdown_started); @@ -494,6 +496,11 @@ pub fn run() { trigger_workflow, grant_approval, deny_approval, + file_watcher::get_project_dir, + file_watcher::set_project_dir, + file_watcher::start_file_watcher, + file_watcher::stop_file_watcher, + file_watcher::resnapshot_project_dir, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index 8ec47403f..933a175dd 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -4,10 +4,12 @@ import { DoorClosed, DoorOpen, FileText, + FolderOpen, Hash, Lock, MessageSquare, Users, + X, } from "lucide-react"; import * as React from "react"; @@ -47,6 +49,12 @@ import { SheetTitle, } from "@/shared/ui/sheet"; import { Textarea } from "@/shared/ui/textarea"; +import { + getProjectDir, + setProjectDir, + startFileWatcher, + stopFileWatcher, +} from "@/shared/api/tauri"; import { ChannelCanvas } from "./ChannelCanvas"; type ChannelManagementSheetProps = { @@ -150,6 +158,14 @@ export function ChannelManagementSheet({ const [topicDraft, setTopicDraft] = React.useState(""); const [purposeDraft, setPurposeDraft] = React.useState(""); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); + const [projectDirDraft, setProjectDirDraft] = React.useState(""); + const [projectDirSaved, setProjectDirSaved] = React.useState( + null, + ); + const [projectDirError, setProjectDirError] = React.useState( + null, + ); + const [projectDirSaving, setProjectDirSaving] = React.useState(false); // Sync drafts from server only when the sheet opens or the channel changes — // not on every background refetch, which would clobber in-flight edits. @@ -175,6 +191,13 @@ export function ChannelManagementSheet({ setDescriptionDraft(detail.description); setTopicDraft(detail.topic ?? ""); setPurposeDraft(detail.purpose ?? ""); + + // Load project directory config. + void getProjectDir(detail.id).then((dir) => { + setProjectDirDraft(dir ?? ""); + setProjectDirSaved(dir); + setProjectDirError(null); + }); }, [detail, open]); if (!channel) { @@ -332,6 +355,123 @@ export function ChannelManagementSheet({ +
+
{ + event.preventDefault(); + if (!channelId) return; + const trimmed = projectDirDraft.trim(); + setProjectDirSaving(true); + setProjectDirError(null); + void (async () => { + try { + const dirOrNull = trimmed || null; + await setProjectDir(channelId, dirOrNull); + setProjectDirSaved(dirOrNull); + if (dirOrNull) { + await startFileWatcher(channelId); + } else { + await stopFileWatcher(channelId); + } + } catch (error) { + setProjectDirError( + error instanceof Error + ? error.message + : "Failed to set project directory.", + ); + } finally { + setProjectDirSaving(false); + } + })(); + }} + > +
+ +
+
+ + + setProjectDirDraft(event.target.value) + } + placeholder="/path/to/project" + value={projectDirDraft} + /> +
+ {projectDirSaved ? ( + + ) : null} +
+
+
+ + {projectDirSaved ? ( + + Watching + + ) : null} +
+ {projectDirError ? ( +

{projectDirError}

+ ) : null} +
+
+ + +
{ + if (!activeChannelId) return; + let cancelled = false; + void (async () => { + try { + const { getProjectDir, startFileWatcher } = await import( + "@/shared/api/tauri" + ); + const dir = await getProjectDir(activeChannelId); + if (dir && !cancelled) { + await startFileWatcher(activeChannelId); + } + } catch { + // Silently ignore — watcher will start when user configures it. + } + })(); + return () => { + cancelled = true; + }; + }, [activeChannelId]); + const { activeChannelTitle, activeDmPresenceStatus, @@ -139,6 +162,7 @@ export function ChannelScreen({ currentPubkey, latestMessageEvent, ); + const { localDiffs } = useLocalFileDiffs(activeChannelId); const messageProfilePubkeys = React.useMemo( () => [...new Set([...messageAuthorPubkeys, ...typingPubkeys])], [messageAuthorPubkeys, typingPubkeys], @@ -213,15 +237,29 @@ export function ChannelScreen({ resolvedMessages, ], ); + // Merge local file-diff events into the timeline (sorted by createdAt). + const mergedTimelineMessages = React.useMemo(() => { + if (localDiffs.length === 0) { + return timelineMessages; + } + const merged = [...timelineMessages, ...localDiffs]; + merged.sort((a, b) => a.createdAt - b.createdAt); + return merged; + }, [timelineMessages, localDiffs]); + const replyTargetMessage = React.useMemo( () => - timelineMessages.find((message) => message.id === replyTargetId) ?? null, - [replyTargetId, timelineMessages], + mergedTimelineMessages.find( + (message) => message.id === replyTargetId, + ) ?? null, + [replyTargetId, mergedTimelineMessages], ); const editTargetMessage = React.useMemo( () => - timelineMessages.find((message) => message.id === editTargetId) ?? null, - [editTargetId, timelineMessages], + mergedTimelineMessages.find( + (message) => message.id === editTargetId, + ) ?? null, + [editTargetId, mergedTimelineMessages], ); const { @@ -452,7 +490,7 @@ export function ChannelScreen({ } isSending={sendMessageMutation.isPending} isTimelineLoading={isTimelineLoading} - messages={timelineMessages} + messages={mergedTimelineMessages} onCancelEdit={handleCancelEdit} onCancelReply={handleCancelReply} onDelete={handleDelete} diff --git a/desktop/src/features/messages/useLocalFileDiffs.ts b/desktop/src/features/messages/useLocalFileDiffs.ts new file mode 100644 index 000000000..e58115d89 --- /dev/null +++ b/desktop/src/features/messages/useLocalFileDiffs.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; + +import type { TimelineMessage } from "@/features/messages/types"; +import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds"; +import { formatTime } from "@/features/messages/lib/dateFormatters"; + +/** + * Payload emitted by the Rust file watcher via Tauri events. + * Must stay in sync with `FileDiffEvent` in `file_watcher.rs`. + */ +type FileDiffPayload = { + channelId: string; + filePath: string; + unifiedDiff: string; + timestamp: number; +}; + +/** + * Listens for local `"file-diff"` Tauri events and converts them into + * synthetic `TimelineMessage` entries that render through the existing + * `DiffMessage` → `DiffViewer` pipeline. + * + * Only events matching `channelId` are kept. The hook accumulates diffs + * for the lifetime of the channel view and resets when the channel changes. + */ +export function useLocalFileDiffs(channelId: string | null) { + const [localDiffs, setLocalDiffs] = useState([]); + const channelIdRef = useRef(channelId); + + // Reset when the channel changes. + useEffect(() => { + if (channelIdRef.current !== channelId) { + channelIdRef.current = channelId; + setLocalDiffs([]); + } + }, [channelId]); + + useEffect(() => { + if (!channelId) { + return; + } + + let unlisten: UnlistenFn | null = null; + + const setup = async () => { + unlisten = await listen("file-diff", (event) => { + const payload = event.payload; + + // Only process events for the active channel. + if (payload.channelId !== channelId) { + return; + } + + const syntheticId = `local-diff-${payload.timestamp}-${payload.filePath}`; + + const message: TimelineMessage = { + id: syntheticId, + createdAt: payload.timestamp, + author: "File Change", + time: formatTime(payload.timestamp), + body: payload.unifiedDiff, + depth: 0, + kind: KIND_STREAM_MESSAGE_DIFF, + tags: [ + ["file", payload.filePath], + ["local-diff", "true"], + ], + }; + + setLocalDiffs((prev) => { + // Dedupe by file path + timestamp (in case of rapid re-fires). + const exists = prev.some((m) => m.id === syntheticId); + if (exists) { + return prev; + } + return [...prev, message]; + }); + }); + }; + + void setup(); + + return () => { + unlisten?.(); + }; + }, [channelId]); + + const clearLocalDiffs = useCallback(() => { + setLocalDiffs([]); + }, []); + + return { localDiffs, clearLocalDiffs }; +} diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 639e2bf60..600cf88a2 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -1070,3 +1070,30 @@ export async function probeBackendProvider( binaryPath, }); } + +// ── File watcher (local diffs) ──────────────────────────────────────────────── + +export async function getProjectDir( + channelId: string, +): Promise { + return invokeTauri("get_project_dir", { channelId }); +} + +export async function setProjectDir( + channelId: string, + path: string | null, +): Promise { + await invokeTauri("set_project_dir", { channelId, path }); +} + +export async function startFileWatcher(channelId: string): Promise { + await invokeTauri("start_file_watcher", { channelId }); +} + +export async function stopFileWatcher(channelId: string): Promise { + await invokeTauri("stop_file_watcher", { channelId }); +} + +export async function resnapshotProjectDir(channelId: string): Promise { + await invokeTauri("resnapshot_project_dir", { channelId }); +}