//! Handle to communicate with HunkTrackerActor. use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{mpsc, oneshot}; use crate::commands::HunkTrackerCommand; use crate::types::{ FileContentEntry, FileHunkData, Hunk, HunkAction, HunkActionError, HunkId, HunkSourceFilter, HunkTrackerSnapshot, HunkTurnDelta, SessionSummary, TrackingMode, }; /// Handle to communicate with HunkTrackerActor. /// This is cheap to clone and can be shared across tasks. #[derive(Clone)] pub struct HunkTrackerHandle { cmd_tx: mpsc::UnboundedSender, } impl HunkTrackerHandle { /// Create a new handle with the given command sender. pub(crate) fn new(cmd_tx: mpsc::UnboundedSender) -> Self { Self { cmd_tx } } /// Create a no-op handle that discards all commands. /// Useful for tests and situations where hunk tracking is not needed. pub fn noop() -> Self { let (cmd_tx, _cmd_rx) = mpsc::unbounded_channel(); // The receiver is dropped immediately, so all sends will return Err // but since we use `let _ = send(...)` everywhere, this is fine. Self { cmd_tx } } /// Record that an agent tool wrote to a file. /// This is fire-and-forget - doesn't wait for processing. /// /// `previous_content` is the file content before this write (if known). /// It is used as a fallback baseline when the file doesn't exist in git HEAD /// (e.g., in worktrees created from dirty state). pub fn record_agent_write( &self, path: PathBuf, content: String, prompt_index: usize, previous_content: Option, ) { let _ = self.cmd_tx.send(HunkTrackerCommand::RecordAgentWrite { path, content, prompt_index, previous_content, }); } /// Notify of file change from fs_notify. pub fn handle_file_change(&self, path: PathBuf) { let _ = self .cmd_tx .send(HunkTrackerCommand::HandleFileChange { path }); } /// Notify of file deletion from fs_notify. pub fn handle_file_deleted(&self, path: PathBuf) { let _ = self .cmd_tx .send(HunkTrackerCommand::HandleFileDeleted { path }); } /// Refresh git dirty cache. pub fn refresh_git_dirty_cache(&self) { let _ = self.cmd_tx.send(HunkTrackerCommand::RefreshGitDirtyCache); } /// Reset baseline for a file (after commit). pub fn reset_baseline(&self, path: PathBuf) { let _ = self.cmd_tx.send(HunkTrackerCommand::ResetBaseline { path }); } /// Set tracking mode. pub fn set_mode(&self, mode: TrackingMode) { let _ = self.cmd_tx.send(HunkTrackerCommand::SetMode { mode }); } /// Apply action (accept/reject) to a specific hunk. pub async fn hunk_action( &self, hunk_id: HunkId, action: HunkAction, ) -> Result<(), HunkActionError> { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self.cmd_tx.send(HunkTrackerCommand::HunkAction { hunk_id: hunk_id.clone(), action, reply: reply_tx, }); reply_rx .await .unwrap_or(Err(HunkActionError::HunkNotFound(hunk_id))) } /// Apply action (accept/reject) to all hunks for a file. pub async fn file_action( &self, path: PathBuf, action: HunkAction, ) -> Result, HunkActionError> { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self.cmd_tx.send(HunkTrackerCommand::FileAction { path, action, reply: reply_tx, }); reply_rx.await.unwrap_or_else(|_| Ok(vec![])) } /// Apply action (accept/reject) to all hunks. pub async fn all_action(&self, action: HunkAction) -> Result, HunkActionError> { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self.cmd_tx.send(HunkTrackerCommand::AllAction { action, reply: reply_tx, }); reply_rx.await.unwrap_or_else(|_| Ok(vec![])) } /// Apply action (accept/reject) to all hunks for a specific turn. pub async fn turn_action( &self, prompt_index: usize, action: HunkAction, ) -> Result, HunkActionError> { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self.cmd_tx.send(HunkTrackerCommand::TurnAction { prompt_index, action, reply: reply_tx, }); reply_rx.await.unwrap_or_else(|_| Ok(vec![])) } /// Get all hunks. pub async fn get_all_hunks(&self) -> Vec> { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self .cmd_tx .send(HunkTrackerCommand::GetAllHunks { reply: reply_tx }); reply_rx.await.unwrap_or_default() } /// Get hunks for a specific path. pub async fn get_hunks_for_path(&self, path: PathBuf) -> Vec> { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self.cmd_tx.send(HunkTrackerCommand::GetHunksForPath { path, reply: reply_tx, }); reply_rx.await.unwrap_or_default() } /// Get hunks + file content for a specific path (for diff rendering). pub async fn get_file_hunk_data(&self, path: PathBuf) -> FileHunkData { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self.cmd_tx.send(HunkTrackerCommand::GetFileHunkData { path, reply: reply_tx, }); reply_rx.await.unwrap_or_default() } /// Get hunks by source. pub async fn get_hunks_by_source(&self, source: HunkSourceFilter) -> Vec> { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self.cmd_tx.send(HunkTrackerCommand::GetHunksBySource { source, reply: reply_tx, }); reply_rx.await.unwrap_or_default() } /// Get a specific hunk by ID. pub async fn get_hunk(&self, hunk_id: HunkId) -> Option> { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self.cmd_tx.send(HunkTrackerCommand::GetHunk { hunk_id, reply: reply_tx, }); reply_rx.await.ok().flatten() } /// Get all tracked file paths (agent + external), regardless of hunk state. /// /// Returns every path the hunk tracker knows about — agent writes, /// fs_notify-detected external edits, and git-dirty files (in `AllDirty` /// mode). Entries persist after the user accepts/rejects every hunk. /// /// Use this for worktree replication where ALL changes matter, not just /// agent-attributed ones (the agent may have created files via terminal /// commands like `echo`, `cp`, `mv`, etc.). pub async fn get_all_tracked_paths(&self) -> Vec { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self .cmd_tx .send(HunkTrackerCommand::GetAllTrackedPaths { reply: reply_tx }); reply_rx.await.unwrap_or_default() } /// Get staged file paths (absolute) — files with HEAD→index changes in /// git. In AllDirty mode this is repo-wide; in AgentOnly mode the /// underlying scan is scoped to tracked paths, so only their staged /// state is reported. pub async fn get_staged_files(&self) -> HashSet { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self .cmd_tx .send(HunkTrackerCommand::GetStagedFiles { reply: reply_tx }); reply_rx.await.unwrap_or_default() } /// Get baseline, current content, agent flag, and staged flag for every /// tracked file in a single call. Pure in-memory — no git I/O. pub async fn get_all_file_contents(&self) -> Vec { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self .cmd_tx .send(HunkTrackerCommand::GetAllFileContents { reply: reply_tx }); reply_rx.await.unwrap_or_default() } /// Check if path is an agent file. pub async fn is_agent_file(&self, path: PathBuf) -> bool { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self.cmd_tx.send(HunkTrackerCommand::IsAgentFile { path, reply: reply_tx, }); reply_rx.await.unwrap_or(false) } /// Get complete session summary (stats + pending turns). pub async fn get_session_summary(&self) -> SessionSummary { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self .cmd_tx .send(HunkTrackerCommand::GetSessionSummary { reply: reply_tx }); reply_rx.await.unwrap_or_default() } /// Get pending hunks for a specific turn. pub async fn get_turn_hunks(&self, prompt_index: usize) -> Vec> { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self.cmd_tx.send(HunkTrackerCommand::GetTurnHunks { prompt_index, reply: reply_tx, }); reply_rx.await.unwrap_or_default() } /// Reset session stats (e.g., after commit). pub fn reset_stats(&self) { let _ = self.cmd_tx.send(HunkTrackerCommand::ResetStats); } /// Refresh all baselines from the current git HEAD and re-read current /// content from disk. Call this after a git HEAD/index change to /// reconcile stale baselines and fix phantom "file deleted" hunks. pub fn refresh_all_baselines(&self) { let _ = self.cmd_tx.send(HunkTrackerCommand::RefreshAllBaselines); } /// Take a snapshot of all hunk tracker state for preservation across /// session kill/reload cycles (e.g., fork sync-back). /// /// Returns `None` if the actor has been shut down. pub async fn snapshot_state(&self) -> Option { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self .cmd_tx .send(HunkTrackerCommand::SnapshotState { reply: reply_tx }); reply_rx.await.ok() } /// Incremental single-turn delta for `prompt_index`: snapshots of the files /// touched that turn plus its hunk-id set. Per-prompt counterpart to /// [`snapshot_state`](Self::snapshot_state). `None` if the actor is shut down. pub async fn snapshot_turn_delta(&self, prompt_index: usize) -> Option { let (reply_tx, reply_rx) = oneshot::channel(); let _ = self.cmd_tx.send(HunkTrackerCommand::SnapshotTurnDelta { prompt_index, reply: reply_tx, }); reply_rx.await.ok() } /// Restore a previously snapshotted state. Replaces all current file /// states, turn index, and session stats in the actor. /// /// This is fire-and-forget — doesn't wait for processing. pub fn restore_state(&self, snapshot: HunkTrackerSnapshot) { let _ = self.cmd_tx.send(HunkTrackerCommand::RestoreState(snapshot)); } }