use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use serde_json::Value;
use tokio::sync::{mpsc, watch};
use tokio_util::sync::CancellationToken;
use super::ahead_behind::AheadBehindCache;
use super::client::WorktreesClient;
use super::local_state::OpenTabs;
use super::row_colors::{RowColorKey, RowColorStore};
use super::supervisor::{self, FeedFrame};
use super::view_model::{self, FeedStatus, WorktreesViewModel};
use super::wire::{SessionsListWire, TreeSnapshotWire};
use crate::daemon::protocol::DaemonEnvelope;
const POLL_INTERVAL: Duration = Duration::from_secs(5);
struct WorktreeOids {
path: PathBuf,
head_sha: Option<String>,
upstream_sha: Option<String>,
}
#[derive(Debug, Clone)]
pub enum HubCommand {
SetOpenTab(PathBuf),
ClearOpenTab(PathBuf),
SetRowColor(RowColorKey, String),
ClearRowColor(RowColorKey),
#[allow(dead_code)] ClearAllRowColors,
#[allow(dead_code)] SetVisibleRows(Vec<PathBuf>),
}
pub struct ViewModelHandle {
pub view: watch::Receiver<Arc<WorktreesViewModel>>,
pub commands: mpsc::UnboundedSender<HubCommand>,
}
pub fn spawn(socket: PathBuf, cancel: CancellationToken) -> ViewModelHandle {
let (tree_rx, _tree_task) = supervisor::spawn_subscription::<TreeSnapshotWire>(
socket.clone(),
DaemonEnvelope::service("worktrees", "subscribe", Value::Null),
DaemonEnvelope::service("worktrees", "tree", Value::Null),
POLL_INTERVAL,
cancel.clone(),
);
let (sessions_rx, _sessions_task) = supervisor::spawn_subscription::<SessionsListWire>(
socket.clone(),
DaemonEnvelope::service("sessions", "subscribe", Value::Null),
DaemonEnvelope::service("sessions", "list", Value::Null),
POLL_INTERVAL,
cancel.clone(),
);
let row_colors = RowColorStore::load(None).unwrap_or_default();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let (out_tx, out_rx) = watch::channel(Arc::new(WorktreesViewModel::default()));
let hub = Hub {
tree_rx,
sessions_rx,
ahead_behind: AheadBehindCache::new(WorktreesClient::new(socket)),
row_colors,
open_tabs: OpenTabs::default(),
cmd_rx,
out_tx,
generation: 0,
visible_override: None,
last_seen_oids: HashMap::new(),
cancel,
};
tokio::spawn(hub.run());
ViewModelHandle {
view: out_rx,
commands: cmd_tx,
}
}
struct Hub {
tree_rx: watch::Receiver<FeedFrame<TreeSnapshotWire>>,
sessions_rx: watch::Receiver<FeedFrame<SessionsListWire>>,
ahead_behind: AheadBehindCache,
row_colors: RowColorStore,
open_tabs: OpenTabs,
cmd_rx: mpsc::UnboundedReceiver<HubCommand>,
out_tx: watch::Sender<Arc<WorktreesViewModel>>,
generation: u64,
visible_override: Option<Vec<PathBuf>>,
last_seen_oids: HashMap<PathBuf, (Option<String>, Option<String>)>,
cancel: CancellationToken,
}
impl Hub {
async fn run(mut self) {
self.publish();
loop {
tokio::select! {
changed = self.tree_rx.changed() => {
if changed.is_err() {
tracing::warn!("worktrees ui: tree feed supervisor task ended; hub stopping");
return;
}
self.on_tree_changed();
}
changed = self.sessions_rx.changed() => {
if changed.is_err() {
tracing::warn!("worktrees ui: sessions feed supervisor task ended; hub stopping");
return;
}
}
Some(cmd) = self.cmd_rx.recv() => self.apply(cmd),
() = self.ahead_behind.changed() => {}
() = self.cancel.cancelled() => return,
}
self.publish();
}
}
fn on_tree_changed(&mut self) {
let rows: Option<Vec<WorktreeOids>> = {
let guard = self.tree_rx.borrow_and_update();
match &*guard {
FeedFrame::Live(snapshot) => Some(
snapshot
.repos
.iter()
.flat_map(|repo| repo.worktrees.iter())
.map(|wt| WorktreeOids {
path: PathBuf::from(&wt.path),
head_sha: wt.head_sha.clone(),
upstream_sha: wt.upstream_sha.clone(),
})
.collect(),
),
_ => None,
}
};
let Some(rows) = rows else { return };
for row in &rows {
let oids = (row.head_sha.clone(), row.upstream_sha.clone());
if self.last_seen_oids.get(&row.path) != Some(&oids) {
self.ahead_behind.invalidate(&row.path);
self.last_seen_oids.insert(row.path.clone(), oids);
}
}
self.last_seen_oids
.retain(|path, _| rows.iter().any(|row| &row.path == path));
let all_paths: Vec<PathBuf> = rows.into_iter().map(|row| row.path).collect();
let visible = self.visible_override.clone().unwrap_or(all_paths);
self.ahead_behind.set_visible(&visible);
}
fn apply(&mut self, cmd: HubCommand) {
match cmd {
HubCommand::SetOpenTab(path) => self.open_tabs.set(path),
HubCommand::ClearOpenTab(path) => self.open_tabs.clear(&path),
HubCommand::SetRowColor(key, color) => {
if let Err(e) = self.row_colors.set(key, color) {
tracing::warn!("worktrees ui: failed to set row colour: {e:#}");
}
}
HubCommand::ClearRowColor(key) => {
if let Err(e) = self.row_colors.clear(&key) {
tracing::warn!("worktrees ui: failed to clear row colour: {e:#}");
}
}
HubCommand::ClearAllRowColors => {
if let Err(e) = self.row_colors.clear_all() {
tracing::warn!("worktrees ui: failed to clear row colours: {e:#}");
}
}
HubCommand::SetVisibleRows(paths) => {
self.ahead_behind.set_visible(&paths);
self.visible_override = Some(paths);
}
}
}
fn publish(&mut self) {
self.generation += 1;
let (tree, worktrees_status) = {
let guard = self.tree_rx.borrow();
let status = feed_status(&guard);
let tree = match &*guard {
FeedFrame::Live(snapshot) => Some(snapshot.clone()),
_ => None,
};
(tree, status)
};
let (sessions, sessions_status) = {
let guard = self.sessions_rx.borrow();
let status = feed_status(&guard);
let sessions = match &*guard {
FeedFrame::Live(list) => list.sessions.clone(),
_ => Vec::new(),
};
(sessions, status)
};
let view = view_model::merge(
tree.as_ref(),
&sessions,
&self.ahead_behind,
&self.row_colors,
&self.open_tabs,
worktrees_status,
sessions_status,
self.generation,
);
let _ = self.out_tx.send(Arc::new(view));
}
}
fn feed_status<T>(frame: &FeedFrame<T>) -> FeedStatus {
match frame {
FeedFrame::Connecting => FeedStatus::Connecting,
FeedFrame::Live(_) => FeedStatus::Live,
FeedFrame::Reconnecting { attempt, retry_in } => FeedStatus::Reconnecting {
attempt: *attempt,
retry_in: *retry_in,
},
FeedFrame::Polling => FeedStatus::Polling,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::super::wire::{TreeRepoWire, TreeWorktreeWire};
use super::*;
fn test_hub() -> (Hub, watch::Sender<FeedFrame<TreeSnapshotWire>>) {
let (tree_tx, tree_rx) = watch::channel(FeedFrame::Connecting);
let (_sessions_tx, sessions_rx) = watch::channel(FeedFrame::Connecting);
let (_cmd_tx, cmd_rx) = mpsc::unbounded_channel();
let (out_tx, _out_rx) = watch::channel(Arc::new(WorktreesViewModel::default()));
let hub = Hub {
tree_rx,
sessions_rx,
ahead_behind: AheadBehindCache::new(WorktreesClient::new(
"/tmp/nonexistent-omni-dev-hub-test.sock",
)),
row_colors: RowColorStore::default(),
open_tabs: OpenTabs::default(),
cmd_rx,
out_tx,
generation: 0,
visible_override: None,
last_seen_oids: HashMap::new(),
cancel: CancellationToken::new(),
};
(hub, tree_tx)
}
fn worktree(path: &str, head_sha: Option<&str>) -> TreeWorktreeWire {
TreeWorktreeWire {
path: path.to_string(),
branch: None,
head_sha: head_sha.map(str::to_string),
upstream_sha: None,
is_main: false,
open: false,
window_key: None,
pr: None,
pr_none: false,
operation: None,
rebasing: false,
pushing: false,
}
}
fn snapshot(wt: TreeWorktreeWire) -> TreeSnapshotWire {
TreeSnapshotWire {
repos: vec![TreeRepoWire {
main_repo: "repo".to_string(),
github: None,
root: "/repo".to_string(),
polling_enabled: false,
worktrees: vec![wt],
}],
show_closed: false,
}
}
#[tokio::test]
async fn on_tree_changed_records_each_paths_current_oids() {
let (mut hub, tree_tx) = test_hub();
tree_tx
.send(FeedFrame::Live(snapshot(worktree("/repo/wt", Some("aaa")))))
.unwrap();
hub.on_tree_changed();
assert_eq!(
hub.last_seen_oids.get(&PathBuf::from("/repo/wt")),
Some(&(Some("aaa".to_string()), None))
);
}
#[tokio::test]
async fn on_tree_changed_invalidates_the_ahead_behind_cache_when_head_sha_moves() {
let (mut hub, tree_tx) = test_hub();
let path = PathBuf::from("/repo/wt");
tree_tx
.send(FeedFrame::Live(snapshot(worktree("/repo/wt", Some("aaa")))))
.unwrap();
hub.on_tree_changed();
assert_ne!(
hub.ahead_behind.get(&path),
super::super::view_model::AheadBehindState::Unknown
);
tree_tx
.send(FeedFrame::Live(snapshot(worktree("/repo/wt", Some("bbb")))))
.unwrap();
hub.on_tree_changed();
assert_eq!(
hub.last_seen_oids.get(&path),
Some(&(Some("bbb".to_string()), None))
);
}
#[tokio::test]
async fn on_tree_changed_forgets_oids_for_worktrees_no_longer_in_the_snapshot() {
let (mut hub, tree_tx) = test_hub();
tree_tx
.send(FeedFrame::Live(snapshot(worktree("/repo/wt", Some("aaa")))))
.unwrap();
hub.on_tree_changed();
assert!(hub.last_seen_oids.contains_key(&PathBuf::from("/repo/wt")));
tree_tx
.send(FeedFrame::Live(snapshot(worktree(
"/repo/other-wt",
Some("ccc"),
))))
.unwrap();
hub.on_tree_changed();
assert!(!hub.last_seen_oids.contains_key(&PathBuf::from("/repo/wt")));
assert!(hub
.last_seen_oids
.contains_key(&PathBuf::from("/repo/other-wt")));
}
}