feat: local file watcher with inline diffs below agent turns

Backend (Rust):
- file_watcher.rs: notify crate watches per-channel project dirs
- Debounced 500ms, ignores .git/node_modules/binaries/large files
- similar crate computes unified diffs against snapshots
- Tauri event emission (file-diff) to frontend
- Per-channel project dir config persisted to project-dirs.json
- Commands: get/set_project_dir, start/stop_file_watcher, resnapshot

Frontend (React):
- useLocalFileDiffs hook listens for Tauri file-diff events
- Synthetic TimelineMessage (kind:40008) merges into timeline
- Renders through existing DiffMessage -> DiffViewer pipeline
- Project directory UI in channel management sheet
- Auto-starts watcher when opening channel with configured dir

Local-only for v1 — no relay events emitted.
This commit is contained in:
Taylor Ho
2026-04-10 22:54:55 -10:00
parent fc4f2f1351
commit 5d9ce58dd3
8 changed files with 880 additions and 5 deletions
+86
View File
@@ -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",
+2
View File
@@ -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"
+481
View File
@@ -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<String, String>,
}
/// 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<HashMap<String, WatcherRuntime>>,
/// channel_id → (file_path → last known content)
snapshots: Arc<Mutex<HashMap<String, HashMap<PathBuf, String>>>>,
}
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<PathBuf, String> {
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<ProjectDirConfig, String> {
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<PathBuf, String> {
let mut snapshots = HashMap::new();
if !project_dir.is_dir() {
return snapshots;
}
fn walk_dir(dir: &Path, project_dir: &Path, snapshots: &mut HashMap<PathBuf, String>) {
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<WatcherRuntime, String> {
let fw_state = app.state::<FileWatcherState>();
// 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<Mutex<HashMap<PathBuf, Instant>>> = 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<PathBuf> = {
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<notify::Event, _>| {
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<Option<String>, 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<String>,
) -> 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::<FileWatcherState>();
{
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::<FileWatcherState>();
{
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::<FileWatcherState>();
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::<FileWatcherState>();
let mut snapshots = fw_state.snapshots.lock().map_err(|e| e.to_string())?;
snapshots.insert(channel_id, new_snapshot);
Ok(())
}
+7
View File
@@ -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");
@@ -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<string | null>(
null,
);
const [projectDirError, setProjectDirError] = React.useState<string | null>(
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({
<Separator />
<Section
description="Watch a local directory for file changes and show inline diffs in the timeline."
title="Project directory"
>
<form
className="space-y-3"
onSubmit={(event) => {
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);
}
})();
}}
>
<div className="space-y-1.5">
<label
className="text-sm font-medium"
htmlFor="channel-project-dir"
>
Directory path
</label>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<FolderOpen className="pointer-events-none absolute left-2.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-9"
data-testid="channel-management-project-dir"
id="channel-project-dir"
onChange={(event) =>
setProjectDirDraft(event.target.value)
}
placeholder="/path/to/project"
value={projectDirDraft}
/>
</div>
{projectDirSaved ? (
<Button
className="h-9 w-9 shrink-0 p-0"
onClick={() => {
setProjectDirDraft("");
if (!channelId) return;
setProjectDirSaving(true);
setProjectDirError(null);
void (async () => {
try {
await setProjectDir(channelId, null);
await stopFileWatcher(channelId);
setProjectDirSaved(null);
} catch (error) {
setProjectDirError(
error instanceof Error
? error.message
: "Failed to clear project directory.",
);
} finally {
setProjectDirSaving(false);
}
})();
}}
size="sm"
title="Clear project directory"
type="button"
variant="ghost"
>
<X className="h-4 w-4" />
</Button>
) : null}
</div>
</div>
<div className="flex items-center gap-2">
<Button
data-testid="channel-management-save-project-dir"
disabled={projectDirSaving}
size="sm"
type="submit"
variant="outline"
>
{projectDirSaving
? "Saving..."
: projectDirSaved
? "Update"
: "Watch directory"}
</Button>
{projectDirSaved ? (
<span className="text-xs text-emerald-600">
Watching
</span>
) : null}
</div>
{projectDirError ? (
<p className="text-sm text-destructive">{projectDirError}</p>
) : null}
</form>
</Section>
<Separator />
<Section
description="Topic and purpose show the current context for the channel."
title="Context"
@@ -33,6 +33,7 @@ import {
} from "@/features/messages/lib/threading";
import { useFetchOlderMessages } from "@/features/messages/useFetchOlderMessages";
import { useChannelTyping } from "@/features/messages/useChannelTyping";
import { useLocalFileDiffs } from "@/features/messages/useLocalFileDiffs";
import { PresenceBadge } from "@/features/presence/ui/PresenceBadge";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { mergeCurrentProfileIntoLookup } from "@/features/profile/lib/identity";
@@ -104,6 +105,28 @@ export function ChannelScreen({
markChannelRead(activeChannelId, activeReadAt);
}, [activeChannelId, activeReadAt, markChannelRead]);
// Auto-start file watcher when switching to a channel with a configured project dir.
React.useEffect(() => {
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}
@@ -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<TimelineMessage[]>([]);
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<FileDiffPayload>("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 };
}
+27
View File
@@ -1070,3 +1070,30 @@ export async function probeBackendProvider(
binaryPath,
});
}
// ── File watcher (local diffs) ────────────────────────────────────────────────
export async function getProjectDir(
channelId: string,
): Promise<string | null> {
return invokeTauri<string | null>("get_project_dir", { channelId });
}
export async function setProjectDir(
channelId: string,
path: string | null,
): Promise<void> {
await invokeTauri("set_project_dir", { channelId, path });
}
export async function startFileWatcher(channelId: string): Promise<void> {
await invokeTauri("start_file_watcher", { channelId });
}
export async function stopFileWatcher(channelId: string): Promise<void> {
await invokeTauri("stop_file_watcher", { channelId });
}
export async function resnapshotProjectDir(channelId: string): Promise<void> {
await invokeTauri("resnapshot_project_dir", { channelId });
}