mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-04-10 17:59:43 +08:00
feat: add ecc2 completion summary notifications
This commit is contained in:
parent
a4d0a4fc14
commit
b45a6ca810
@ -3,7 +3,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::notifications::DesktopNotificationConfig;
|
||||
use crate::notifications::{CompletionSummaryConfig, DesktopNotificationConfig};
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@ -48,6 +48,7 @@ pub struct Config {
|
||||
pub auto_create_worktrees: bool,
|
||||
pub auto_merge_ready_worktrees: bool,
|
||||
pub desktop_notifications: DesktopNotificationConfig,
|
||||
pub completion_summary_notifications: CompletionSummaryConfig,
|
||||
pub cost_budget_usd: f64,
|
||||
pub token_budget: u64,
|
||||
pub budget_alert_thresholds: BudgetAlertThresholds,
|
||||
@ -111,6 +112,7 @@ impl Default for Config {
|
||||
auto_create_worktrees: true,
|
||||
auto_merge_ready_worktrees: false,
|
||||
desktop_notifications: DesktopNotificationConfig::default(),
|
||||
completion_summary_notifications: CompletionSummaryConfig::default(),
|
||||
cost_budget_usd: 10.0,
|
||||
token_budget: 500_000,
|
||||
budget_alert_thresholds: Self::BUDGET_ALERT_THRESHOLDS,
|
||||
@ -616,6 +618,24 @@ end_hour = 7
|
||||
assert_eq!(config.desktop_notifications.quiet_hours.end_hour, 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_summary_notifications_deserialize_from_toml() {
|
||||
let config: Config = toml::from_str(
|
||||
r#"
|
||||
[completion_summary_notifications]
|
||||
enabled = true
|
||||
delivery = "desktop_and_tui_popup"
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(config.completion_summary_notifications.enabled);
|
||||
assert_eq!(
|
||||
config.completion_summary_notifications.delivery,
|
||||
crate::notifications::CompletionSummaryDelivery::DesktopAndTuiPopup
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_budget_alert_thresholds_fall_back_to_defaults() {
|
||||
let config: Config = toml::from_str(
|
||||
@ -643,6 +663,8 @@ critical = 1.10
|
||||
config.auto_create_worktrees = false;
|
||||
config.auto_merge_ready_worktrees = true;
|
||||
config.desktop_notifications.session_completed = false;
|
||||
config.completion_summary_notifications.delivery =
|
||||
crate::notifications::CompletionSummaryDelivery::TuiPopup;
|
||||
config.desktop_notifications.quiet_hours.enabled = true;
|
||||
config.desktop_notifications.quiet_hours.start_hour = 21;
|
||||
config.desktop_notifications.quiet_hours.end_hour = 7;
|
||||
@ -666,6 +688,10 @@ critical = 1.10
|
||||
assert!(!loaded.auto_create_worktrees);
|
||||
assert!(loaded.auto_merge_ready_worktrees);
|
||||
assert!(!loaded.desktop_notifications.session_completed);
|
||||
assert_eq!(
|
||||
loaded.completion_summary_notifications.delivery,
|
||||
crate::notifications::CompletionSummaryDelivery::TuiPopup
|
||||
);
|
||||
assert!(loaded.desktop_notifications.quiet_hours.enabled);
|
||||
assert_eq!(loaded.desktop_notifications.quiet_hours.start_hour, 21);
|
||||
assert_eq!(loaded.desktop_notifications.quiet_hours.end_hour, 7);
|
||||
|
||||
@ -1634,7 +1634,11 @@ fn format_merge_queue_human(report: &session::manager::MergeQueueReport) -> Stri
|
||||
for entry in &report.blocked_entries {
|
||||
lines.push(format!(
|
||||
"- {} [{}] | {} / {} | {}",
|
||||
entry.session_id, entry.branch, entry.project, entry.task_group, entry.suggested_action
|
||||
entry.session_id,
|
||||
entry.branch,
|
||||
entry.project,
|
||||
entry.task_group,
|
||||
entry.suggested_action
|
||||
));
|
||||
for blocker in entry.blocked_by.iter().take(2) {
|
||||
lines.push(format!(
|
||||
@ -2781,7 +2785,9 @@ mod tests {
|
||||
state: session::SessionState::Stopped,
|
||||
conflicts: vec!["README.md".to_string()],
|
||||
summary: "merge after alpha1234 to avoid branch conflicts".to_string(),
|
||||
conflicting_patch_preview: Some("--- Branch diff vs main ---\nREADME.md".to_string()),
|
||||
conflicting_patch_preview: Some(
|
||||
"--- Branch diff vs main ---\nREADME.md".to_string(),
|
||||
),
|
||||
blocker_patch_preview: None,
|
||||
}],
|
||||
suggested_action: "merge after alpha1234".to_string(),
|
||||
|
||||
@ -32,6 +32,22 @@ pub struct DesktopNotificationConfig {
|
||||
pub quiet_hours: QuietHoursConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum CompletionSummaryDelivery {
|
||||
#[default]
|
||||
Desktop,
|
||||
TuiPopup,
|
||||
DesktopAndTuiPopup,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct CompletionSummaryConfig {
|
||||
pub enabled: bool,
|
||||
pub delivery: CompletionSummaryDelivery,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DesktopNotifier {
|
||||
config: DesktopNotificationConfig,
|
||||
@ -112,6 +128,33 @@ impl DesktopNotificationConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CompletionSummaryConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
delivery: CompletionSummaryDelivery::Desktop,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CompletionSummaryConfig {
|
||||
pub fn desktop_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
&& matches!(
|
||||
self.delivery,
|
||||
CompletionSummaryDelivery::Desktop | CompletionSummaryDelivery::DesktopAndTuiPopup
|
||||
)
|
||||
}
|
||||
|
||||
pub fn popup_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
&& matches!(
|
||||
self.delivery,
|
||||
CompletionSummaryDelivery::TuiPopup | CompletionSummaryDelivery::DesktopAndTuiPopup
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl DesktopNotifier {
|
||||
pub fn new(config: DesktopNotificationConfig) -> Self {
|
||||
Self {
|
||||
|
||||
@ -46,7 +46,16 @@ pub async fn create_session_with_grouping(
|
||||
) -> Result<String> {
|
||||
let repo_root =
|
||||
std::env::current_dir().context("Failed to resolve current working directory")?;
|
||||
queue_session_in_dir(db, cfg, task, agent_type, use_worktree, &repo_root, grouping).await
|
||||
queue_session_in_dir(
|
||||
db,
|
||||
cfg,
|
||||
task,
|
||||
agent_type,
|
||||
use_worktree,
|
||||
&repo_root,
|
||||
grouping,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub fn list_sessions(db: &StateStore) -> Result<Vec<Session>> {
|
||||
@ -219,7 +228,7 @@ pub async fn drain_inbox(
|
||||
use_worktree,
|
||||
&repo_root,
|
||||
&runner_program,
|
||||
SessionGrouping::default(),
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@ -1037,7 +1046,10 @@ pub fn build_merge_queue(db: &StateStore) -> Result<MergeQueueReport> {
|
||||
|
||||
if matches!(
|
||||
session.state,
|
||||
SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale
|
||||
SessionState::Pending
|
||||
| SessionState::Running
|
||||
| SessionState::Idle
|
||||
| SessionState::Stale
|
||||
) {
|
||||
blocked_by.push(MergeQueueBlocker {
|
||||
session_id: session.id.clone(),
|
||||
@ -1085,10 +1097,7 @@ pub fn build_merge_queue(db: &StateStore) -> Result<MergeQueueReport> {
|
||||
branch: blocker_worktree.branch.clone(),
|
||||
state: blocker.state.clone(),
|
||||
conflicts: conflict.conflicts,
|
||||
summary: format!(
|
||||
"merge after {} to avoid branch conflicts",
|
||||
blocker.id
|
||||
),
|
||||
summary: format!("merge after {} to avoid branch conflicts", blocker.id),
|
||||
conflicting_patch_preview: conflict.right_patch_preview,
|
||||
blocker_patch_preview: conflict.left_patch_preview,
|
||||
});
|
||||
@ -1107,7 +1116,10 @@ pub fn build_merge_queue(db: &StateStore) -> Result<MergeQueueReport> {
|
||||
|
||||
let suggested_action = if let Some(position) = queue_position {
|
||||
format!("merge in queue order #{position}")
|
||||
} else if blocked_by.iter().any(|blocker| blocker.session_id == session.id) {
|
||||
} else if blocked_by
|
||||
.iter()
|
||||
.any(|blocker| blocker.session_id == session.id)
|
||||
{
|
||||
blocked_by
|
||||
.first()
|
||||
.map(|blocker| blocker.summary.clone())
|
||||
@ -1369,15 +1381,8 @@ async fn queue_session_in_dir_with_runner_program(
|
||||
runner_program: &Path,
|
||||
grouping: SessionGrouping,
|
||||
) -> Result<String> {
|
||||
let session = build_session_record(
|
||||
db,
|
||||
task,
|
||||
agent_type,
|
||||
use_worktree,
|
||||
cfg,
|
||||
repo_root,
|
||||
grouping,
|
||||
)?;
|
||||
let session =
|
||||
build_session_record(db, task, agent_type, use_worktree, cfg, repo_root, grouping)?;
|
||||
db.insert_session(&session)?;
|
||||
|
||||
if use_worktree && session.worktree.is_none() {
|
||||
@ -1523,7 +1528,10 @@ fn attached_worktree_count(db: &StateStore) -> Result<usize> {
|
||||
fn merge_queue_priority(session: &Session) -> (u8, chrono::DateTime<chrono::Utc>) {
|
||||
let active_rank = match session.state {
|
||||
SessionState::Completed | SessionState::Failed | SessionState::Stopped => 0,
|
||||
SessionState::Pending | SessionState::Running | SessionState::Idle | SessionState::Stale => 1,
|
||||
SessionState::Pending
|
||||
| SessionState::Running
|
||||
| SessionState::Idle
|
||||
| SessionState::Stale => 1,
|
||||
};
|
||||
(active_rank, session.updated_at)
|
||||
}
|
||||
@ -2238,6 +2246,8 @@ mod tests {
|
||||
auto_create_worktrees: true,
|
||||
auto_merge_ready_worktrees: false,
|
||||
desktop_notifications: crate::notifications::DesktopNotificationConfig::default(),
|
||||
completion_summary_notifications:
|
||||
crate::notifications::CompletionSummaryConfig::default(),
|
||||
cost_budget_usd: 10.0,
|
||||
token_budget: 500_000,
|
||||
budget_alert_thresholds: Config::BUDGET_ALERT_THRESHOLDS,
|
||||
@ -3534,7 +3544,7 @@ mod tests {
|
||||
true,
|
||||
&repo_root,
|
||||
&fake_runner,
|
||||
SessionGrouping::default(),
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@ -3607,7 +3617,7 @@ mod tests {
|
||||
true,
|
||||
&repo_root,
|
||||
&fake_runner,
|
||||
SessionGrouping::default(),
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@ -3820,7 +3830,7 @@ mod tests {
|
||||
true,
|
||||
&repo_root,
|
||||
&fake_runner,
|
||||
SessionGrouping::default(),
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
@ -3893,7 +3903,7 @@ mod tests {
|
||||
true,
|
||||
&repo_root,
|
||||
&fake_runner,
|
||||
SessionGrouping::default(),
|
||||
SessionGrouping::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
|
||||
@ -27,6 +27,18 @@ pub async fn run(db: StateStore, cfg: Config) -> Result<()> {
|
||||
|
||||
if event::poll(Duration::from_millis(250))? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if dashboard.has_active_completion_popup() {
|
||||
match (key.modifiers, key.code) {
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
|
||||
(_, KeyCode::Esc) | (_, KeyCode::Enter) | (_, KeyCode::Char(' ')) => {
|
||||
dashboard.dismiss_completion_popup();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if dashboard.is_input_mode() {
|
||||
match (key.modifiers, key.code) {
|
||||
(KeyModifiers::CONTROL, KeyCode::Char('c')) => break,
|
||||
|
||||
@ -3,11 +3,12 @@ use crossterm::event::KeyEvent;
|
||||
use ratatui::{
|
||||
prelude::*,
|
||||
widgets::{
|
||||
Block, Borders, Cell, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs, Wrap,
|
||||
Block, Borders, Cell, Clear, HighlightSpacing, Paragraph, Row, Table, TableState, Tabs,
|
||||
Wrap,
|
||||
},
|
||||
};
|
||||
use regex::Regex;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::time::UNIX_EPOCH;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
@ -52,6 +53,28 @@ struct ThemePalette {
|
||||
help_border: Color,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SessionCompletionSummary {
|
||||
session_id: String,
|
||||
task: String,
|
||||
state: SessionState,
|
||||
files_changed: u32,
|
||||
tokens_used: u64,
|
||||
duration_secs: u64,
|
||||
cost_usd: f64,
|
||||
tests_run: usize,
|
||||
tests_passed: usize,
|
||||
recent_files: Vec<String>,
|
||||
key_decisions: Vec<String>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
struct TestRunSummary {
|
||||
total: usize,
|
||||
passed: usize,
|
||||
}
|
||||
|
||||
pub struct Dashboard {
|
||||
db: StateStore,
|
||||
cfg: Config,
|
||||
@ -112,6 +135,8 @@ pub struct Dashboard {
|
||||
search_agent_filter: SearchAgentFilter,
|
||||
search_matches: Vec<SearchMatch>,
|
||||
selected_search_match: usize,
|
||||
active_completion_popup: Option<SessionCompletionSummary>,
|
||||
queued_completion_popups: VecDeque<SessionCompletionSummary>,
|
||||
session_table_state: TableState,
|
||||
last_cost_metrics_signature: Option<(u64, u128)>,
|
||||
last_tool_activity_signature: Option<(u64, u128)>,
|
||||
@ -296,6 +321,108 @@ struct TeamSummary {
|
||||
stopped: usize,
|
||||
}
|
||||
|
||||
impl SessionCompletionSummary {
|
||||
fn title(&self) -> String {
|
||||
match self.state {
|
||||
SessionState::Completed => "ECC 2.0: Session completed".to_string(),
|
||||
SessionState::Failed => "ECC 2.0: Session failed".to_string(),
|
||||
_ => "ECC 2.0: Session summary".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn subtitle(&self) -> String {
|
||||
format!(
|
||||
"{} | {}",
|
||||
format_session_id(&self.session_id),
|
||||
truncate_for_dashboard(&self.task, 88)
|
||||
)
|
||||
}
|
||||
|
||||
fn notification_body(&self) -> String {
|
||||
let tests_line = if self.tests_run > 0 {
|
||||
format!(
|
||||
"Tests {} run / {} passed",
|
||||
self.tests_run, self.tests_passed
|
||||
)
|
||||
} else {
|
||||
"Tests not detected".to_string()
|
||||
};
|
||||
|
||||
let warnings_line = if self.warnings.is_empty() {
|
||||
"Warnings none".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Warnings {}",
|
||||
truncate_for_dashboard(&self.warnings.join("; "), 88)
|
||||
)
|
||||
};
|
||||
|
||||
[
|
||||
self.subtitle(),
|
||||
format!(
|
||||
"Files {} | Tokens {} | Duration {}",
|
||||
self.files_changed,
|
||||
format_token_count(self.tokens_used),
|
||||
format_duration(self.duration_secs)
|
||||
),
|
||||
tests_line,
|
||||
warnings_line,
|
||||
]
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
fn popup_text(&self) -> String {
|
||||
let mut lines = vec![
|
||||
self.subtitle(),
|
||||
String::new(),
|
||||
format!(
|
||||
"Files {} | Tokens {} | Cost {} | Duration {}",
|
||||
self.files_changed,
|
||||
format_token_count(self.tokens_used),
|
||||
format_currency(self.cost_usd),
|
||||
format_duration(self.duration_secs)
|
||||
),
|
||||
];
|
||||
|
||||
if self.tests_run > 0 {
|
||||
lines.push(format!(
|
||||
"Tests {} run / {} passed",
|
||||
self.tests_run, self.tests_passed
|
||||
));
|
||||
} else {
|
||||
lines.push("Tests not detected".to_string());
|
||||
}
|
||||
|
||||
if !self.recent_files.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("Recent files".to_string());
|
||||
for item in &self.recent_files {
|
||||
lines.push(format!("- {item}"));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.key_decisions.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("Key decisions".to_string());
|
||||
for item in &self.key_decisions {
|
||||
lines.push(format!("- {item}"));
|
||||
}
|
||||
}
|
||||
|
||||
if !self.warnings.is_empty() {
|
||||
lines.push(String::new());
|
||||
lines.push("Warnings".to_string());
|
||||
for item in &self.warnings {
|
||||
lines.push(format!("- {item}"));
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(String::new());
|
||||
lines.push("[Enter]/[Space]/[Esc] dismiss".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl Dashboard {
|
||||
pub fn new(db: StateStore, cfg: Config) -> Self {
|
||||
Self::with_output_store(db, cfg, SessionOutputStore::default())
|
||||
@ -394,6 +521,8 @@ impl Dashboard {
|
||||
search_agent_filter: SearchAgentFilter::AllAgents,
|
||||
search_matches: Vec::new(),
|
||||
selected_search_match: 0,
|
||||
active_completion_popup: None,
|
||||
queued_completion_popups: VecDeque::new(),
|
||||
session_table_state,
|
||||
last_cost_metrics_signature: initial_cost_metrics_signature,
|
||||
last_tool_activity_signature: initial_tool_activity_signature,
|
||||
@ -403,6 +532,7 @@ impl Dashboard {
|
||||
};
|
||||
sort_sessions_for_display(&mut dashboard.sessions);
|
||||
dashboard.unread_message_counts = dashboard.db.unread_message_counts().unwrap_or_default();
|
||||
dashboard.sync_approval_queue();
|
||||
dashboard.sync_handoff_backlog_counts();
|
||||
dashboard.sync_global_handoff_backlog();
|
||||
dashboard.sync_selected_output();
|
||||
@ -444,6 +574,10 @@ impl Dashboard {
|
||||
}
|
||||
|
||||
self.render_status_bar(frame, chunks[2]);
|
||||
|
||||
if let Some(summary) = self.active_completion_popup.as_ref() {
|
||||
self.render_completion_popup(frame, summary);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header(&self, frame: &mut Frame, area: Rect) {
|
||||
@ -1045,7 +1179,9 @@ impl Dashboard {
|
||||
self.theme_label()
|
||||
);
|
||||
|
||||
let search_prefix = if let Some(input) = self.spawn_input.as_ref() {
|
||||
let search_prefix = if self.active_completion_popup.is_some() {
|
||||
" completion summary | [Enter]/[Space]/[Esc] dismiss |".to_string()
|
||||
} else if let Some(input) = self.spawn_input.as_ref() {
|
||||
format!(" spawn>{input}_ | [Enter] queue [Esc] cancel |")
|
||||
} else if let Some(input) = self.commit_input.as_ref() {
|
||||
format!(" commit>{input}_ | [Enter] commit [Esc] cancel |")
|
||||
@ -1076,7 +1212,8 @@ impl Dashboard {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let text = if self.spawn_input.is_some()
|
||||
let text = if self.active_completion_popup.is_some()
|
||||
|| self.spawn_input.is_some()
|
||||
|| self.commit_input.is_some()
|
||||
|| self.pr_input.is_some()
|
||||
|| self.search_input.is_some()
|
||||
@ -1121,6 +1258,31 @@ impl Dashboard {
|
||||
);
|
||||
}
|
||||
|
||||
fn render_completion_popup(&self, frame: &mut Frame, summary: &SessionCompletionSummary) {
|
||||
let popup_area = centered_rect(72, 65, frame.area());
|
||||
if popup_area.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
frame.render_widget(Clear, popup_area);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(format!(" {} ", summary.title()))
|
||||
.border_style(self.pane_border_style(Pane::Output));
|
||||
let inner = block.inner(popup_area);
|
||||
frame.render_widget(block, popup_area);
|
||||
if inner.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
frame.render_widget(
|
||||
Paragraph::new(summary.popup_text())
|
||||
.wrap(Wrap { trim: true })
|
||||
.scroll((0, 0)),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_help(&self, frame: &mut Frame, area: Rect) {
|
||||
let help = vec![
|
||||
"Keyboard Shortcuts:".to_string(),
|
||||
@ -2697,6 +2859,16 @@ impl Dashboard {
|
||||
self.search_query.is_some()
|
||||
}
|
||||
|
||||
pub fn has_active_completion_popup(&self) -> bool {
|
||||
self.active_completion_popup.is_some()
|
||||
}
|
||||
|
||||
pub fn dismiss_completion_popup(&mut self) {
|
||||
if self.active_completion_popup.take().is_some() {
|
||||
self.active_completion_popup = self.queued_completion_popups.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn begin_spawn_prompt(&mut self) {
|
||||
if self.search_input.is_some() {
|
||||
self.set_operator_note(
|
||||
@ -3401,10 +3573,11 @@ impl Dashboard {
|
||||
HashMap::new()
|
||||
}
|
||||
};
|
||||
self.sync_session_state_notifications();
|
||||
self.sync_approval_notifications();
|
||||
self.sync_approval_queue();
|
||||
self.sync_handoff_backlog_counts();
|
||||
self.sync_worktree_health_by_session();
|
||||
self.sync_session_state_notifications();
|
||||
self.sync_approval_notifications();
|
||||
self.sync_global_handoff_backlog();
|
||||
self.sync_daemon_activity();
|
||||
self.sync_output_cache();
|
||||
@ -3480,6 +3653,8 @@ impl Dashboard {
|
||||
|
||||
fn sync_session_state_notifications(&mut self) {
|
||||
let mut next_states = HashMap::new();
|
||||
let mut completion_summaries = Vec::new();
|
||||
let mut failed_notifications = Vec::new();
|
||||
|
||||
for session in &self.sessions {
|
||||
let previous_state = self.last_session_states.get(&session.id);
|
||||
@ -3487,26 +3662,29 @@ impl Dashboard {
|
||||
if previous_state != &session.state {
|
||||
match session.state {
|
||||
SessionState::Completed => {
|
||||
self.notify_desktop(
|
||||
NotificationEvent::SessionCompleted,
|
||||
"ECC 2.0: Session completed",
|
||||
&format!(
|
||||
"{} | {}",
|
||||
format_session_id(&session.id),
|
||||
truncate_for_dashboard(&session.task, 96)
|
||||
),
|
||||
);
|
||||
if self.cfg.completion_summary_notifications.enabled {
|
||||
completion_summaries.push(self.build_completion_summary(session));
|
||||
} else if self.cfg.desktop_notifications.session_completed {
|
||||
self.notify_desktop(
|
||||
NotificationEvent::SessionCompleted,
|
||||
"ECC 2.0: Session completed",
|
||||
&format!(
|
||||
"{} | {}",
|
||||
format_session_id(&session.id),
|
||||
truncate_for_dashboard(&session.task, 96)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
SessionState::Failed => {
|
||||
self.notify_desktop(
|
||||
NotificationEvent::SessionFailed,
|
||||
"ECC 2.0: Session failed",
|
||||
&format!(
|
||||
failed_notifications.push((
|
||||
"ECC 2.0: Session failed".to_string(),
|
||||
format!(
|
||||
"{} | {}",
|
||||
format_session_id(&session.id),
|
||||
truncate_for_dashboard(&session.task, 96)
|
||||
),
|
||||
);
|
||||
));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@ -3516,6 +3694,16 @@ impl Dashboard {
|
||||
next_states.insert(session.id.clone(), session.state.clone());
|
||||
}
|
||||
|
||||
for summary in completion_summaries {
|
||||
self.deliver_completion_summary(summary);
|
||||
}
|
||||
|
||||
if self.cfg.desktop_notifications.session_failed {
|
||||
for (title, body) in failed_notifications {
|
||||
self.notify_desktop(NotificationEvent::SessionFailed, &title, &body);
|
||||
}
|
||||
}
|
||||
|
||||
self.last_session_states = next_states;
|
||||
}
|
||||
|
||||
@ -3554,6 +3742,90 @@ impl Dashboard {
|
||||
);
|
||||
}
|
||||
|
||||
fn deliver_completion_summary(&mut self, summary: SessionCompletionSummary) {
|
||||
if self.cfg.completion_summary_notifications.desktop_enabled()
|
||||
&& self.cfg.desktop_notifications.session_completed
|
||||
{
|
||||
self.notify_desktop(
|
||||
NotificationEvent::SessionCompleted,
|
||||
&summary.title(),
|
||||
&summary.notification_body(),
|
||||
);
|
||||
}
|
||||
|
||||
if self.cfg.completion_summary_notifications.popup_enabled() {
|
||||
if self.active_completion_popup.is_none() {
|
||||
self.active_completion_popup = Some(summary);
|
||||
} else {
|
||||
self.queued_completion_popups.push_back(summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_completion_summary(&self, session: &Session) -> SessionCompletionSummary {
|
||||
let file_activity = match self.db.list_file_activity(&session.id, 5) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"Failed to load file activity for completion summary {}: {error}",
|
||||
session.id
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let tool_logs = match self.db.list_tool_logs_for_session(&session.id) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"Failed to load tool logs for completion summary {}: {error}",
|
||||
session.id
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
let overlaps = match self.db.list_file_overlaps(&session.id, 3) {
|
||||
Ok(entries) => entries,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"Failed to load file overlaps for completion summary {}: {error}",
|
||||
session.id
|
||||
);
|
||||
Vec::new()
|
||||
}
|
||||
};
|
||||
|
||||
let tests = summarize_test_runs(&tool_logs, session.state == SessionState::Completed);
|
||||
let recent_files = recent_completion_files(&file_activity, session.metrics.files_changed);
|
||||
let key_decisions =
|
||||
summarize_completion_decisions(&tool_logs, &file_activity, &session.task);
|
||||
let warnings = summarize_completion_warnings(
|
||||
session,
|
||||
&tool_logs,
|
||||
&tests,
|
||||
self.worktree_health_by_session.get(&session.id),
|
||||
self.approval_queue_counts
|
||||
.get(&session.id)
|
||||
.copied()
|
||||
.unwrap_or(0),
|
||||
overlaps.len(),
|
||||
);
|
||||
|
||||
SessionCompletionSummary {
|
||||
session_id: session.id.clone(),
|
||||
task: session.task.clone(),
|
||||
state: session.state.clone(),
|
||||
files_changed: session.metrics.files_changed,
|
||||
tokens_used: session.metrics.tokens_used,
|
||||
duration_secs: session.metrics.duration_secs,
|
||||
cost_usd: session.metrics.cost_usd,
|
||||
tests_run: tests.total,
|
||||
tests_passed: tests.passed,
|
||||
recent_files,
|
||||
key_decisions,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_desktop(&self, event: NotificationEvent, title: &str, body: &str) {
|
||||
let _ = self.notifier.notify(event, title, body);
|
||||
}
|
||||
@ -6743,6 +7015,254 @@ fn tool_log_detail_lines(entry: &ToolLogEntry) -> Vec<String> {
|
||||
lines
|
||||
}
|
||||
|
||||
fn centered_rect(width_percent: u16, height_percent: u16, area: Rect) -> Rect {
|
||||
let vertical = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - height_percent) / 2),
|
||||
Constraint::Percentage(height_percent),
|
||||
Constraint::Percentage((100 - height_percent) / 2),
|
||||
])
|
||||
.split(area);
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - width_percent) / 2),
|
||||
Constraint::Percentage(width_percent),
|
||||
Constraint::Percentage((100 - width_percent) / 2),
|
||||
])
|
||||
.split(vertical[1])[1]
|
||||
}
|
||||
|
||||
fn summarize_test_runs(
|
||||
tool_logs: &[ToolLogEntry],
|
||||
assume_success_on_completion: bool,
|
||||
) -> TestRunSummary {
|
||||
let mut summary = TestRunSummary::default();
|
||||
|
||||
for entry in tool_logs {
|
||||
if !tool_log_looks_like_test(entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.total += 1;
|
||||
let failed = tool_log_looks_failed(entry);
|
||||
let passed = tool_log_looks_passed(entry);
|
||||
if !failed && (passed || assume_success_on_completion) {
|
||||
summary.passed += 1;
|
||||
}
|
||||
}
|
||||
|
||||
summary
|
||||
}
|
||||
|
||||
fn tool_log_looks_like_test(entry: &ToolLogEntry) -> bool {
|
||||
let haystack = format!(
|
||||
"{} {} {} {}",
|
||||
entry.tool_name,
|
||||
entry.input_summary,
|
||||
extract_tool_command(entry),
|
||||
entry.output_summary
|
||||
)
|
||||
.to_ascii_lowercase();
|
||||
const TEST_MARKERS: &[&str] = &[
|
||||
"cargo test",
|
||||
"npm test",
|
||||
"pnpm test",
|
||||
"pnpm exec vitest",
|
||||
"pnpm exec playwright",
|
||||
"yarn test",
|
||||
"bun test",
|
||||
"vitest",
|
||||
"jest",
|
||||
"pytest",
|
||||
"go test",
|
||||
"playwright test",
|
||||
"cypress",
|
||||
"rspec",
|
||||
"phpunit",
|
||||
"e2e",
|
||||
];
|
||||
|
||||
TEST_MARKERS.iter().any(|marker| haystack.contains(marker))
|
||||
}
|
||||
|
||||
fn tool_log_looks_failed(entry: &ToolLogEntry) -> bool {
|
||||
let haystack = format!(
|
||||
"{} {} {} {}",
|
||||
entry.tool_name,
|
||||
entry.input_summary,
|
||||
extract_tool_command(entry),
|
||||
entry.output_summary
|
||||
)
|
||||
.to_ascii_lowercase();
|
||||
const FAILURE_MARKERS: &[&str] = &[
|
||||
" fail",
|
||||
"failed",
|
||||
" error",
|
||||
"panic",
|
||||
"timed out",
|
||||
"non-zero",
|
||||
"exit code 1",
|
||||
"exited with",
|
||||
];
|
||||
|
||||
FAILURE_MARKERS
|
||||
.iter()
|
||||
.any(|marker| haystack.contains(marker))
|
||||
}
|
||||
|
||||
fn tool_log_looks_passed(entry: &ToolLogEntry) -> bool {
|
||||
let haystack = format!(
|
||||
"{} {} {} {}",
|
||||
entry.tool_name,
|
||||
entry.input_summary,
|
||||
extract_tool_command(entry),
|
||||
entry.output_summary
|
||||
)
|
||||
.to_ascii_lowercase();
|
||||
const SUCCESS_MARKERS: &[&str] = &[" pass", "passed", " ok", "success", "green", "completed"];
|
||||
|
||||
SUCCESS_MARKERS
|
||||
.iter()
|
||||
.any(|marker| haystack.contains(marker))
|
||||
}
|
||||
|
||||
fn extract_tool_command(entry: &ToolLogEntry) -> String {
|
||||
let Ok(value) = serde_json::from_str::<serde_json::Value>(&entry.input_params_json) else {
|
||||
return String::new();
|
||||
};
|
||||
|
||||
value
|
||||
.get("command")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn recent_completion_files(file_activity: &[FileActivityEntry], files_changed: u32) -> Vec<String> {
|
||||
if !file_activity.is_empty() {
|
||||
return file_activity
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(file_activity_summary)
|
||||
.collect();
|
||||
}
|
||||
|
||||
if files_changed > 0 {
|
||||
return vec![format!("files touched {}", files_changed)];
|
||||
}
|
||||
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
fn summarize_completion_decisions(
|
||||
tool_logs: &[ToolLogEntry],
|
||||
file_activity: &[FileActivityEntry],
|
||||
session_task: &str,
|
||||
) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut decisions = Vec::new();
|
||||
|
||||
for entry in tool_logs.iter().rev() {
|
||||
let mut candidates = Vec::new();
|
||||
if !entry.trigger_summary.trim().is_empty()
|
||||
&& entry.trigger_summary.trim() != session_task.trim()
|
||||
{
|
||||
candidates.push(format!(
|
||||
"why {}",
|
||||
truncate_for_dashboard(&entry.trigger_summary, 72)
|
||||
));
|
||||
}
|
||||
|
||||
let action = if entry.tool_name.eq_ignore_ascii_case("Bash") {
|
||||
truncate_for_dashboard(&extract_tool_command(entry), 72)
|
||||
} else if !entry.output_summary.trim().is_empty() && entry.output_summary.trim() != "ok" {
|
||||
truncate_for_dashboard(&entry.output_summary, 72)
|
||||
} else {
|
||||
truncate_for_dashboard(&entry.input_summary, 72)
|
||||
};
|
||||
|
||||
if !action.trim().is_empty() {
|
||||
candidates.push(action);
|
||||
}
|
||||
|
||||
for candidate in candidates {
|
||||
let normalized = candidate.to_ascii_lowercase();
|
||||
if seen.insert(normalized) {
|
||||
decisions.push(candidate);
|
||||
}
|
||||
if decisions.len() >= 3 {
|
||||
return decisions;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for entry in file_activity.iter().take(3) {
|
||||
let candidate = file_activity_summary(entry);
|
||||
let normalized = candidate.to_ascii_lowercase();
|
||||
if seen.insert(normalized) {
|
||||
decisions.push(candidate);
|
||||
}
|
||||
if decisions.len() >= 3 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
decisions
|
||||
}
|
||||
|
||||
fn summarize_completion_warnings(
|
||||
session: &Session,
|
||||
tool_logs: &[ToolLogEntry],
|
||||
tests: &TestRunSummary,
|
||||
worktree_health: Option<&worktree::WorktreeHealth>,
|
||||
approval_backlog: usize,
|
||||
overlap_count: usize,
|
||||
) -> Vec<String> {
|
||||
let mut warnings = Vec::new();
|
||||
let high_risk_tool_calls = tool_logs
|
||||
.iter()
|
||||
.filter(|entry| entry.risk_score >= Config::RISK_THRESHOLDS.review)
|
||||
.count();
|
||||
|
||||
if session.metrics.files_changed > 0 && tests.total == 0 {
|
||||
warnings.push("no test runs detected".to_string());
|
||||
}
|
||||
if tests.total > tests.passed {
|
||||
warnings.push(format!(
|
||||
"{} detected test run(s) were not confirmed passed",
|
||||
tests.total - tests.passed
|
||||
));
|
||||
}
|
||||
if high_risk_tool_calls > 0 {
|
||||
warnings.push(format!(
|
||||
"{high_risk_tool_calls} high-risk tool call(s) recorded"
|
||||
));
|
||||
}
|
||||
if approval_backlog > 0 {
|
||||
warnings.push(format!(
|
||||
"{approval_backlog} approval/conflict request(s) remained unread"
|
||||
));
|
||||
}
|
||||
if overlap_count > 0 {
|
||||
warnings.push(format!(
|
||||
"{overlap_count} potential file overlap(s) remained"
|
||||
));
|
||||
}
|
||||
match worktree_health {
|
||||
Some(worktree::WorktreeHealth::Conflicted) => {
|
||||
warnings.push("worktree still has unresolved conflicts".to_string());
|
||||
}
|
||||
Some(worktree::WorktreeHealth::InProgress) => {
|
||||
warnings.push("worktree still has unmerged changes".to_string());
|
||||
}
|
||||
Some(worktree::WorktreeHealth::Clear) | None => {}
|
||||
}
|
||||
|
||||
warnings
|
||||
}
|
||||
|
||||
fn file_activity_verb(action: crate::session::FileActivityAction) -> &'static str {
|
||||
match action {
|
||||
crate::session::FileActivityAction::Read => "read",
|
||||
@ -9151,6 +9671,111 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_builds_completion_summary_popup_from_metrics_activity_and_logs() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("ecc2-completion-popup-{}", Uuid::new_v4()));
|
||||
fs::create_dir_all(root.join(".claude").join("metrics"))?;
|
||||
|
||||
let mut cfg = build_config(&root.join(".claude"));
|
||||
cfg.completion_summary_notifications.delivery =
|
||||
crate::notifications::CompletionSummaryDelivery::TuiPopup;
|
||||
cfg.desktop_notifications.session_completed = false;
|
||||
|
||||
let db = StateStore::open(&cfg.db_path)?;
|
||||
let mut session = sample_session(
|
||||
"done-12345678",
|
||||
"claude",
|
||||
SessionState::Running,
|
||||
Some("ecc/done"),
|
||||
384,
|
||||
95,
|
||||
);
|
||||
session.task = "Finish session summary notifications".to_string();
|
||||
db.insert_session(&session)?;
|
||||
|
||||
let metrics_path = cfg.tool_activity_metrics_path();
|
||||
fs::create_dir_all(metrics_path.parent().unwrap())?;
|
||||
fs::write(
|
||||
&metrics_path,
|
||||
concat!(
|
||||
"{\"id\":\"evt-1\",\"session_id\":\"done-12345678\",\"tool_name\":\"Bash\",\"input_summary\":\"cargo test -q\",\"input_params_json\":\"{\\\"command\\\":\\\"cargo test -q\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:00:00Z\"}\n",
|
||||
"{\"id\":\"evt-2\",\"session_id\":\"done-12345678\",\"tool_name\":\"Write\",\"input_summary\":\"Write README.md\",\"output_summary\":\"updated readme\",\"file_events\":[{\"path\":\"README.md\",\"action\":\"create\",\"diff_preview\":\"+ session summary notifications\",\"patch_preview\":\"+ session summary notifications\"}],\"timestamp\":\"2026-04-09T00:01:00Z\"}\n",
|
||||
"{\"id\":\"evt-3\",\"session_id\":\"done-12345678\",\"tool_name\":\"Bash\",\"input_summary\":\"rm -rf build\",\"input_params_json\":\"{\\\"command\\\":\\\"rm -rf build\\\"}\",\"output_summary\":\"ok\",\"timestamp\":\"2026-04-09T00:02:00Z\"}\n"
|
||||
),
|
||||
)?;
|
||||
|
||||
let mut dashboard = Dashboard::new(db, cfg);
|
||||
dashboard
|
||||
.db
|
||||
.update_state("done-12345678", &SessionState::Completed)?;
|
||||
|
||||
dashboard.refresh();
|
||||
|
||||
let popup = dashboard
|
||||
.active_completion_popup
|
||||
.as_ref()
|
||||
.expect("completion summary popup");
|
||||
let popup_text = popup.popup_text();
|
||||
assert!(popup_text.contains("done-123"));
|
||||
assert!(popup_text.contains("Tests 1 run / 1 passed"));
|
||||
assert!(popup_text.contains("Recent files"));
|
||||
assert!(popup_text.contains("create README.md"));
|
||||
assert!(popup_text.contains("Warnings"));
|
||||
assert!(popup_text.contains("high-risk tool call"));
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dismiss_completion_popup_promotes_the_next_summary() {
|
||||
let mut dashboard = test_dashboard(Vec::new(), 0);
|
||||
dashboard.active_completion_popup = Some(SessionCompletionSummary {
|
||||
session_id: "sess-a".to_string(),
|
||||
task: "First".to_string(),
|
||||
state: SessionState::Completed,
|
||||
files_changed: 1,
|
||||
tokens_used: 10,
|
||||
duration_secs: 5,
|
||||
cost_usd: 0.01,
|
||||
tests_run: 1,
|
||||
tests_passed: 1,
|
||||
recent_files: vec!["create README.md".to_string()],
|
||||
key_decisions: vec!["cargo test -q".to_string()],
|
||||
warnings: Vec::new(),
|
||||
});
|
||||
dashboard
|
||||
.queued_completion_popups
|
||||
.push_back(SessionCompletionSummary {
|
||||
session_id: "sess-b".to_string(),
|
||||
task: "Second".to_string(),
|
||||
state: SessionState::Completed,
|
||||
files_changed: 2,
|
||||
tokens_used: 20,
|
||||
duration_secs: 8,
|
||||
cost_usd: 0.02,
|
||||
tests_run: 0,
|
||||
tests_passed: 0,
|
||||
recent_files: vec!["modify src/lib.rs".to_string()],
|
||||
key_decisions: vec!["updated lib".to_string()],
|
||||
warnings: vec!["no test runs detected".to_string()],
|
||||
});
|
||||
|
||||
dashboard.dismiss_completion_popup();
|
||||
|
||||
assert_eq!(
|
||||
dashboard
|
||||
.active_completion_popup
|
||||
.as_ref()
|
||||
.map(|summary| summary.session_id.as_str()),
|
||||
Some("sess-b")
|
||||
);
|
||||
assert!(dashboard.queued_completion_popups.is_empty());
|
||||
|
||||
dashboard.dismiss_completion_popup();
|
||||
assert!(dashboard.active_completion_popup.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_syncs_tool_activity_metrics_from_hook_file() {
|
||||
let tempdir = std::env::temp_dir().join(format!("ecc2-activity-sync-{}", Uuid::new_v4()));
|
||||
@ -11284,6 +11909,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
search_agent_filter: SearchAgentFilter::AllAgents,
|
||||
search_matches: Vec::new(),
|
||||
selected_search_match: 0,
|
||||
active_completion_popup: None,
|
||||
queued_completion_popups: VecDeque::new(),
|
||||
session_table_state,
|
||||
last_cost_metrics_signature: None,
|
||||
last_tool_activity_signature: None,
|
||||
@ -11310,6 +11937,8 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
auto_create_worktrees: true,
|
||||
auto_merge_ready_worktrees: false,
|
||||
desktop_notifications: crate::notifications::DesktopNotificationConfig::default(),
|
||||
completion_summary_notifications:
|
||||
crate::notifications::CompletionSummaryConfig::default(),
|
||||
cost_budget_usd: 10.0,
|
||||
token_budget: 500_000,
|
||||
budget_alert_thresholds: crate::config::Config::BUDGET_ALERT_THRESHOLDS,
|
||||
|
||||
@ -349,7 +349,9 @@ pub fn commit_staged(worktree: &WorktreeInfo, message: &str) -> Result<String> {
|
||||
anyhow::bail!("git rev-parse failed: {stderr}");
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&rev_parse.stdout).trim().to_string())
|
||||
Ok(String::from_utf8_lossy(&rev_parse.stdout)
|
||||
.trim()
|
||||
.to_string())
|
||||
}
|
||||
|
||||
pub fn latest_commit_subject(worktree: &WorktreeInfo) -> Result<String> {
|
||||
@ -604,7 +606,9 @@ pub fn has_uncommitted_changes(worktree: &WorktreeInfo) -> Result<bool> {
|
||||
}
|
||||
|
||||
pub fn has_staged_changes(worktree: &WorktreeInfo) -> Result<bool> {
|
||||
Ok(git_status_entries(worktree)?.iter().any(|entry| entry.staged))
|
||||
Ok(git_status_entries(worktree)?
|
||||
.iter()
|
||||
.any(|entry| entry.staged))
|
||||
}
|
||||
|
||||
pub fn merge_into_base(worktree: &WorktreeInfo) -> Result<MergeOutcome> {
|
||||
@ -925,8 +929,12 @@ fn dependency_fingerprint(root: &Path, files: &[&str]) -> Result<String> {
|
||||
let mut hasher = Sha256::new();
|
||||
for rel in files {
|
||||
let path = root.join(rel);
|
||||
let content = fs::read(&path)
|
||||
.with_context(|| format!("Failed to read dependency fingerprint input {}", path.display()))?;
|
||||
let content = fs::read(&path).with_context(|| {
|
||||
format!(
|
||||
"Failed to read dependency fingerprint input {}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
hasher.update(rel.as_bytes());
|
||||
hasher.update([0]);
|
||||
hasher.update(&content);
|
||||
@ -957,10 +965,8 @@ fn is_symlink_to(path: &Path, target: &Path) -> Result<bool> {
|
||||
fn remove_symlink(path: &Path) -> Result<()> {
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => {
|
||||
fs::remove_dir(path)
|
||||
.with_context(|| format!("Failed to remove dependency cache link {}", path.display()))
|
||||
}
|
||||
Err(error) if error.kind() == std::io::ErrorKind::IsADirectory => fs::remove_dir(path)
|
||||
.with_context(|| format!("Failed to remove dependency cache link {}", path.display())),
|
||||
Err(error) => Err(error)
|
||||
.with_context(|| format!("Failed to remove dependency cache link {}", path.display())),
|
||||
}
|
||||
@ -1072,10 +1078,7 @@ fn parse_git_status_entry(line: &str) -> Option<GitStatusEntry> {
|
||||
.to_string();
|
||||
let conflicted = matches!(
|
||||
(index_status, worktree_status),
|
||||
('U', _)
|
||||
| (_, 'U')
|
||||
| ('A', 'A')
|
||||
| ('D', 'D')
|
||||
('U', _) | (_, 'U') | ('A', 'A') | ('D', 'D')
|
||||
);
|
||||
Some(GitStatusEntry {
|
||||
path: normalized_path,
|
||||
@ -1491,8 +1494,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn branch_conflict_preview_reports_conflicting_branches() -> Result<()> {
|
||||
let root = std::env::temp_dir()
|
||||
.join(format!("ecc2-worktree-branch-conflict-preview-{}", Uuid::new_v4()));
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"ecc2-worktree-branch-conflict-preview-{}",
|
||||
Uuid::new_v4()
|
||||
));
|
||||
let repo = init_repo(&root)?;
|
||||
|
||||
let left_dir = root.join("wt-left");
|
||||
@ -1538,8 +1543,8 @@ mod tests {
|
||||
base_branch: "main".to_string(),
|
||||
};
|
||||
|
||||
let preview = branch_conflict_preview(&left, &right, 12)?
|
||||
.expect("expected branch conflict preview");
|
||||
let preview =
|
||||
branch_conflict_preview(&left, &right, 12)?.expect("expected branch conflict preview");
|
||||
assert_eq!(preview.conflicts, vec!["README.md".to_string()]);
|
||||
assert!(preview
|
||||
.left_patch_preview
|
||||
@ -1622,7 +1627,10 @@ mod tests {
|
||||
.arg(&repo)
|
||||
.args(["log", "-1", "--pretty=%s"])
|
||||
.output()?;
|
||||
assert_eq!(String::from_utf8_lossy(&output.stdout).trim(), "update readme");
|
||||
assert_eq!(
|
||||
String::from_utf8_lossy(&output.stdout).trim(),
|
||||
"update readme"
|
||||
);
|
||||
|
||||
let _ = fs::remove_dir_all(root);
|
||||
Ok(())
|
||||
@ -1652,8 +1660,19 @@ mod tests {
|
||||
let root = std::env::temp_dir().join(format!("ecc2-pr-create-{}", Uuid::new_v4()));
|
||||
let repo = init_repo(&root)?;
|
||||
let remote = root.join("remote.git");
|
||||
run_git(&root, &["init", "--bare", remote.to_str().expect("utf8 path")])?;
|
||||
run_git(&repo, &["remote", "add", "origin", remote.to_str().expect("utf8 path")])?;
|
||||
run_git(
|
||||
&root,
|
||||
&["init", "--bare", remote.to_str().expect("utf8 path")],
|
||||
)?;
|
||||
run_git(
|
||||
&repo,
|
||||
&[
|
||||
"remote",
|
||||
"add",
|
||||
"origin",
|
||||
remote.to_str().expect("utf8 path"),
|
||||
],
|
||||
)?;
|
||||
run_git(&repo, &["push", "-u", "origin", "main"])?;
|
||||
run_git(&repo, &["checkout", "-b", "feat/pr-test"])?;
|
||||
fs::write(repo.join("README.md"), "pr test\n")?;
|
||||
@ -1713,10 +1732,14 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn create_for_session_links_shared_node_modules_cache() -> Result<()> {
|
||||
let root = std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4()));
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("ecc2-worktree-node-cache-{}", Uuid::new_v4()));
|
||||
let repo = init_repo(&root)?;
|
||||
fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?;
|
||||
fs::write(repo.join("package-lock.json"), "{\n \"lockfileVersion\": 3\n}\n")?;
|
||||
fs::write(
|
||||
repo.join("package-lock.json"),
|
||||
"{\n \"lockfileVersion\": 3\n}\n",
|
||||
)?;
|
||||
fs::create_dir_all(repo.join("node_modules"))?;
|
||||
fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?;
|
||||
run_git(&repo, &["add", "package.json", "package-lock.json"])?;
|
||||
@ -1727,7 +1750,9 @@ mod tests {
|
||||
let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?;
|
||||
|
||||
let node_modules = worktree.path.join("node_modules");
|
||||
assert!(fs::symlink_metadata(&node_modules)?.file_type().is_symlink());
|
||||
assert!(fs::symlink_metadata(&node_modules)?
|
||||
.file_type()
|
||||
.is_symlink());
|
||||
assert_eq!(fs::read_link(&node_modules)?, repo.join("node_modules"));
|
||||
|
||||
remove(&worktree)?;
|
||||
@ -1741,7 +1766,10 @@ mod tests {
|
||||
std::env::temp_dir().join(format!("ecc2-worktree-node-fallback-{}", Uuid::new_v4()));
|
||||
let repo = init_repo(&root)?;
|
||||
fs::write(repo.join("package.json"), "{\n \"name\": \"repo\"\n}\n")?;
|
||||
fs::write(repo.join("package-lock.json"), "{\n \"lockfileVersion\": 3\n}\n")?;
|
||||
fs::write(
|
||||
repo.join("package-lock.json"),
|
||||
"{\n \"lockfileVersion\": 3\n}\n",
|
||||
)?;
|
||||
fs::create_dir_all(repo.join("node_modules"))?;
|
||||
fs::write(repo.join("node_modules/.cache-marker"), "shared\n")?;
|
||||
run_git(&repo, &["add", "package.json", "package-lock.json"])?;
|
||||
@ -1752,7 +1780,9 @@ mod tests {
|
||||
let worktree = create_for_session_in_repo("worker-123", &cfg, &repo)?;
|
||||
|
||||
let node_modules = worktree.path.join("node_modules");
|
||||
assert!(fs::symlink_metadata(&node_modules)?.file_type().is_symlink());
|
||||
assert!(fs::symlink_metadata(&node_modules)?
|
||||
.file_type()
|
||||
.is_symlink());
|
||||
|
||||
fs::write(
|
||||
worktree.path.join("package-lock.json"),
|
||||
@ -1761,7 +1791,9 @@ mod tests {
|
||||
let applied = sync_shared_dependency_dirs(&worktree)?;
|
||||
assert!(applied.is_empty());
|
||||
assert!(node_modules.is_dir());
|
||||
assert!(!fs::symlink_metadata(&node_modules)?.file_type().is_symlink());
|
||||
assert!(!fs::symlink_metadata(&node_modules)?
|
||||
.file_type()
|
||||
.is_symlink());
|
||||
assert!(repo.join("node_modules/.cache-marker").exists());
|
||||
|
||||
remove(&worktree)?;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user