diff --git a/crates/jcode-config-types/src/lib.rs b/crates/jcode-config-types/src/lib.rs index 7f8963d9f..2765cfa40 100644 --- a/crates/jcode-config-types/src/lib.rs +++ b/crates/jcode-config-types/src/lib.rs @@ -449,7 +449,7 @@ impl Default for KeybindingsConfig { workspace_down: "alt+j".to_string(), workspace_up: "alt+k".to_string(), workspace_right: "alt+l".to_string(), - session_picker_enter: SessionPickerResumeAction::NewTerminal, + session_picker_enter: SessionPickerResumeAction::CurrentTerminal, } } } diff --git a/src/cli/tui_launch.rs b/src/cli/tui_launch.rs index d4ad56c20..c83a8f675 100644 --- a/src/cli/tui_launch.rs +++ b/src/cli/tui_launch.rs @@ -991,6 +991,23 @@ pub fn list_sessions() -> Result<()> { Ok(()) } + Some(tui::session_picker::PickerResult::DeleteSessions(targets)) => { + let mut deleted = 0usize; + for target in targets { + let Some(session_id) = crate::import::imported_session_id_for_target(&target) + else { + continue; + }; + match session::delete_session_artifacts(&session_id) { + Ok(result) if !result.removed.is_empty() => deleted += 1, + Ok(_) => eprintln!("No local jcode artifacts found for {session_id}."), + Err(err) => eprintln!("Failed to delete {session_id}: {err}"), + } + } + tui::session_picker::invalidate_session_list_cache(); + eprintln!("Deleted {deleted} session(s)."); + Ok(()) + } Some(tui::session_picker::PickerResult::RestoreAllCrashed) => { let recovered = session::recover_crashed_sessions()?; if recovered.is_empty() { diff --git a/src/config/default_file.rs b/src/config/default_file.rs index 7d84e618c..1f4ad36de 100644 --- a/src/config/default_file.rs +++ b/src/config/default_file.rs @@ -58,7 +58,7 @@ workspace_right = "alt+l" # /resume picker behavior. Options: "new-terminal" or "current-terminal". # Ctrl+Enter performs the alternate action. -session_picker_enter = "new-terminal" +session_picker_enter = "current-terminal" [dictation] # External speech-to-text command. diff --git a/src/config_tests.rs b/src/config_tests.rs index c0ff4d748..6d6e48203 100644 --- a/src/config_tests.rs +++ b/src/config_tests.rs @@ -166,14 +166,14 @@ fn test_native_scrollbars_default_to_enabled() { } #[test] -fn test_session_picker_resume_action_defaults_to_new_terminal() { +fn test_session_picker_resume_action_defaults_to_current_terminal() { assert_eq!( Config::default().keybindings.session_picker_enter, - SessionPickerResumeAction::NewTerminal + SessionPickerResumeAction::CurrentTerminal ); assert_eq!( - SessionPickerResumeAction::NewTerminal.alternate(), - SessionPickerResumeAction::CurrentTerminal + SessionPickerResumeAction::CurrentTerminal.alternate(), + SessionPickerResumeAction::NewTerminal ); } diff --git a/src/session.rs b/src/session.rs index 9fa6c955d..4925f3acb 100644 --- a/src/session.rs +++ b/src/session.rs @@ -39,7 +39,9 @@ pub(crate) use storage_paths::session_journal_path_from_snapshot; #[cfg(test)] pub(crate) use storage_paths::session_path_in_dir; use storage_paths::{estimate_json_bytes, persist_vector_mode_label}; -pub use storage_paths::{session_exists, session_journal_path, session_path}; +pub use storage_paths::{ + delete_session_artifacts, session_exists, session_journal_path, session_path, +}; fn stored_messages_to_messages(messages: &[StoredMessage]) -> Vec { messages.iter().map(StoredMessage::to_message).collect() diff --git a/src/session/storage_paths.rs b/src/session/storage_paths.rs index c2b8e1fa3..f42a8d047 100644 --- a/src/session/storage_paths.rs +++ b/src/session/storage_paths.rs @@ -52,3 +52,99 @@ pub fn session_exists(session_id: &str) -> bool { .map(|path| path.exists()) .unwrap_or(false) } + +#[derive(Debug, Default)] +pub struct DeletedSessionArtifacts { + pub removed: Vec, + pub missing: Vec, +} + +pub fn delete_session_artifacts(session_id: &str) -> Result { + if session_id.trim().is_empty() + || session_id + .chars() + .any(|ch| ch == '/' || ch == '\\' || ch == std::path::MAIN_SEPARATOR) + { + anyhow::bail!("Refusing to delete invalid session id: {session_id:?}"); + } + + let base = storage::jcode_dir()?; + let snapshot = session_path_in_dir(&base, session_id); + let paths = [ + snapshot.clone(), + session_journal_path_from_snapshot(&snapshot), + snapshot.with_extension("json.bak"), + base.join("active_pids").join(session_id), + base.join("todos").join(format!("{session_id}.json")), + base.join("side_panel").join(format!("{session_id}.json")), + base.join(format!("client-input-{session_id}")), + ]; + + let mut result = DeletedSessionArtifacts::default(); + for path in paths { + if path.exists() { + std::fs::remove_file(&path)?; + result.removed.push(path); + } else { + result.missing.push(path); + } + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn restore_env(previous: Option) { + if let Some(previous) = previous { + crate::env::set_var("JCODE_HOME", previous); + } else { + crate::env::remove_var("JCODE_HOME"); + } + } + + #[test] + fn delete_session_artifacts_removes_only_session_files() { + let _guard = crate::storage::lock_test_env(); + let previous_home = std::env::var_os("JCODE_HOME"); + let temp = tempfile::TempDir::new().unwrap(); + crate::env::set_var("JCODE_HOME", temp.path()); + + let base = storage::jcode_dir().unwrap(); + let session_id = "session_delete_test"; + let paths = [ + base.join("sessions").join(format!("{session_id}.json")), + base.join("sessions") + .join(format!("{session_id}.journal.jsonl")), + base.join("sessions").join(format!("{session_id}.json.bak")), + base.join("active_pids").join(session_id), + base.join("todos").join(format!("{session_id}.json")), + base.join("side_panel").join(format!("{session_id}.json")), + base.join(format!("client-input-{session_id}")), + ]; + for path in &paths { + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, "x").unwrap(); + } + let unrelated = base.join("sessions").join("session_keep.json"); + std::fs::write(&unrelated, "keep").unwrap(); + + let deleted = delete_session_artifacts(session_id).unwrap(); + + assert_eq!(deleted.removed.len(), paths.len()); + for path in &paths { + assert!(!path.exists(), "{} should be removed", path.display()); + } + assert!(unrelated.exists(), "unrelated session must not be removed"); + + restore_env(previous_home); + } + + #[test] + fn delete_session_artifacts_rejects_path_like_ids() { + assert!(delete_session_artifacts("../bad").is_err()); + assert!(delete_session_artifacts("bad/name").is_err()); + assert!(delete_session_artifacts("").is_err()); + } +} diff --git a/src/tui/app/inline_interactive.rs b/src/tui/app/inline_interactive.rs index eb5f88bd7..cfbc35df0 100644 --- a/src/tui/app/inline_interactive.rs +++ b/src/tui/app/inline_interactive.rs @@ -1486,7 +1486,9 @@ impl App { let manual: Vec = failed.iter().map(|cmd| format!(" {}", cmd)).collect(); - if spawned > 0 { + if spawned == 0 && !targets.is_empty() { + self.handle_session_picker_current_terminal_selection(targets); + } else if spawned > 0 { self.push_display_message(DisplayMessage::system(format!( "Resumed **{} session(s)** in new windows. {} failed:\n```\n{}\n```", spawned, @@ -1502,6 +1504,42 @@ impl App { } } + pub(super) fn handle_session_picker_delete(&mut self, targets: &[ResumeTarget]) { + if targets.is_empty() { + return; + } + + let mut deleted = 0usize; + let mut failed = Vec::new(); + for target in targets { + let Some(session_id) = crate::import::imported_session_id_for_target(target) else { + continue; + }; + match crate::session::delete_session_artifacts(&session_id) { + Ok(result) if !result.removed.is_empty() => deleted += 1, + Ok(_) => {} + Err(err) => failed.push(format!("{session_id}: {err}")), + } + } + + session_picker::invalidate_session_list_cache(); + self.session_picker_overlay = None; + self.session_picker_mode = SessionPickerMode::Resume; + if failed.is_empty() { + self.push_display_message(DisplayMessage::system(format!( + "Deleted **{} session(s)**.", + deleted + ))); + } else { + self.push_display_message(DisplayMessage::error(format!( + "Deleted {} session(s); failed: {}", + deleted, + failed.join("; ") + ))); + } + self.set_status_notice(format!("Deleted {} session(s)", deleted)); + } + pub(super) fn handle_session_picker_current_terminal_selection( &mut self, targets: &[ResumeTarget], @@ -1666,7 +1704,14 @@ impl App { } } OverlayAction::Selected(PickerResult::SelectedInCurrentTerminal(ids)) => { - self.handle_session_picker_current_terminal_selection(&ids); + if self.session_picker_mode == SessionPickerMode::CatchUp { + self.handle_session_picker_selection(&ids); + } else { + self.handle_session_picker_current_terminal_selection(&ids); + } + } + OverlayAction::Selected(PickerResult::DeleteSessions(targets)) => { + self.handle_session_picker_delete(&targets); } OverlayAction::Selected(PickerResult::RestoreAllCrashed) => { self.handle_batch_crash_restore(); diff --git a/src/tui/session_picker.rs b/src/tui/session_picker.rs index 99323dd5e..c79760f12 100644 --- a/src/tui/session_picker.rs +++ b/src/tui/session_picker.rs @@ -48,6 +48,7 @@ pub enum PickerResult { Selected(Vec), SelectedInCurrentTerminal(Vec), SelectedInNewTerminal(Vec), + DeleteSessions(Vec), RestoreAllCrashed, } @@ -169,6 +170,7 @@ pub struct SessionPicker { cached_search_refs: Vec, /// Lightweight placeholder shown while the picker list is loading. loading_message: Option, + pending_delete_targets: Option>, } impl SessionPicker { @@ -206,6 +208,7 @@ impl SessionPicker { cached_search_query: String::new(), cached_search_refs: Vec::new(), loading_message: None, + pending_delete_targets: None, }; picker.rebuild_items(); picker @@ -239,6 +242,7 @@ impl SessionPicker { cached_search_query: String::new(), cached_search_refs: Vec::new(), loading_message: Some("Loading sessions…".to_string()), + pending_delete_targets: None, } } @@ -305,6 +309,7 @@ impl SessionPicker { cached_search_query: String::new(), cached_search_refs: Vec::new(), loading_message: None, + pending_delete_targets: None, }; picker.rebuild_items(); picker @@ -365,6 +370,18 @@ impl SessionPicker { pub fn clear_selected_sessions(&mut self) { self.selected_session_ids.clear(); + self.pending_delete_targets = None; + } + + fn is_delete_key(code: KeyCode) -> bool { + matches!( + code, + KeyCode::Char('d') | KeyCode::Char('D') | KeyCode::Delete + ) + } + + fn is_delete_confirm_key(code: KeyCode) -> bool { + Self::is_delete_key(code) || matches!(code, KeyCode::Enter) } fn selected_session_ref(&self) -> Option { @@ -579,6 +596,20 @@ impl SessionPicker { return Ok(OverlayAction::Continue); } + if let Some(targets) = self.pending_delete_targets.clone() { + if Self::is_delete_confirm_key(code) { + self.pending_delete_targets = None; + return Ok(OverlayAction::Selected(PickerResult::DeleteSessions( + targets, + ))); + } + match code { + KeyCode::Esc | KeyCode::Char('q') => self.pending_delete_targets = None, + _ => {} + } + return Ok(OverlayAction::Continue); + } + match code { KeyCode::Esc => { if !self.search_query.is_empty() { @@ -592,6 +623,12 @@ impl SessionPicker { KeyCode::Char(' ') => { self.toggle_selected_session(); } + code if Self::is_delete_key(code) => { + let targets = self.selection_or_current_targets(); + if !targets.is_empty() { + self.pending_delete_targets = Some(targets); + } + } KeyCode::Enter => { let targets = self.selection_or_current_targets(); if !targets.is_empty() { @@ -608,7 +645,7 @@ impl SessionPicker { KeyCode::Char('/') => { self.search_active = true; } - KeyCode::Char('d') => { + KeyCode::Char('t') | KeyCode::Char('T') => { self.toggle_test_sessions(); } KeyCode::Char('s') => { @@ -644,7 +681,12 @@ impl SessionPicker { PickerResult::SelectedInNewTerminal(targets) } crate::config::SessionPickerResumeAction::CurrentTerminal => { - PickerResult::SelectedInCurrentTerminal(targets) + let target = self + .selected_session() + .map(|session| session.resume_target.clone()) + .into_iter() + .collect(); + PickerResult::SelectedInCurrentTerminal(target) } } } @@ -1225,6 +1267,18 @@ impl SessionPicker { // Normal mode match key.code { + code if self.pending_delete_targets.is_some() + && Self::is_delete_confirm_key(code) => + { + let targets = self.pending_delete_targets.take().unwrap(); + break Ok(Some(PickerResult::DeleteSessions(targets))); + } + KeyCode::Esc | KeyCode::Char('q') + if self.pending_delete_targets.is_some() => + { + self.pending_delete_targets = None; + continue; + } KeyCode::Esc => { if !self.search_query.is_empty() { // Clear active search filter first @@ -1240,6 +1294,12 @@ impl SessionPicker { KeyCode::Char(' ') => { self.toggle_selected_session(); } + code if Self::is_delete_key(code) => { + let targets = self.selection_or_current_targets(); + if !targets.is_empty() { + self.pending_delete_targets = Some(targets); + } + } KeyCode::Enter => { let targets = self.selection_or_current_targets(); if targets.is_empty() { @@ -1257,7 +1317,7 @@ impl SessionPicker { KeyCode::Char('/') => { self.search_active = true; } - KeyCode::Char('d') => { + KeyCode::Char('t') | KeyCode::Char('T') => { self.toggle_test_sessions(); } KeyCode::Char('s') => { diff --git a/src/tui/session_picker/render.rs b/src/tui/session_picker/render.rs index 727309e83..9cb9dcd00 100644 --- a/src/tui/session_picker/render.rs +++ b/src/tui/session_picker/render.rs @@ -408,8 +408,10 @@ impl SessionPicker { " Esc cancel " } else if self.search_active { " type to filter, Esc cancel " + } else if self.pending_delete_targets.is_some() { + " Confirm delete: d/D/Delete/Enter · Esc cancel " } else { - " Space select · Enter resume · s next filter · S prev · d debug · / search · h/l focus · ↑↓ · q " + " Space select · Enter resume · d/D/Delete delete · s next filter · S prev · t tests · / search · h/l focus · ↑↓ · q " }; let border_dim: Color = rgb(70, 70, 70); diff --git a/src/tui/session_picker_tests.rs b/src/tui/session_picker_tests.rs index 8226cb516..d300b40b6 100644 --- a/src/tui/session_picker_tests.rs +++ b/src/tui/session_picker_tests.rs @@ -726,23 +726,83 @@ fn test_space_selects_multiple_sessions_and_enter_returns_them() { .unwrap(); match action { - OverlayAction::Selected(PickerResult::SelectedInNewTerminal(ids)) => { + OverlayAction::Selected(PickerResult::SelectedInCurrentTerminal(ids)) => { assert_eq!( ids, - vec![ - ResumeTarget::JcodeSession { - session_id: "session_newer".to_string(), - }, - ResumeTarget::JcodeSession { - session_id: "session_older".to_string(), - } - ] + vec![ResumeTarget::JcodeSession { + session_id: "session_older".to_string(), + }] ); } other => panic!("expected selected sessions, got {other:?}"), } } +#[test] +fn test_delete_requires_confirmation_and_returns_selected_sessions() { + let newer = make_session("session_newer", "newer", false, SessionStatus::Closed); + let older = make_session("session_older", "older", false, SessionStatus::Closed); + let mut picker = SessionPicker::new(vec![older, newer]); + + picker + .handle_overlay_key(KeyCode::Char(' '), KeyModifiers::empty()) + .unwrap(); + let first = picker + .handle_overlay_key(KeyCode::Char('D'), KeyModifiers::empty()) + .unwrap(); + assert!(matches!(first, OverlayAction::Continue)); + + let confirmed = picker + .handle_overlay_key(KeyCode::Char('D'), KeyModifiers::empty()) + .unwrap(); + match confirmed { + OverlayAction::Selected(PickerResult::DeleteSessions(targets)) => { + assert_eq!( + targets, + vec![ResumeTarget::JcodeSession { + session_id: "session_older".to_string(), + }] + ); + } + other => panic!("expected delete selection, got {other:?}"), + } +} + +#[test] +fn test_delete_accepts_lowercase_d_and_delete_key() { + let newer = make_session("session_newer", "newer", false, SessionStatus::Closed); + let older = make_session("session_older", "older", false, SessionStatus::Closed); + let mut picker = SessionPicker::new(vec![older, newer]); + + picker + .handle_overlay_key(KeyCode::Char('d'), KeyModifiers::empty()) + .unwrap(); + let cancelled = picker + .handle_overlay_key(KeyCode::Esc, KeyModifiers::empty()) + .unwrap(); + assert!(matches!(cancelled, OverlayAction::Continue)); + + let first = picker + .handle_overlay_key(KeyCode::Delete, KeyModifiers::empty()) + .unwrap(); + assert!(matches!(first, OverlayAction::Continue)); + + let confirmed = picker + .handle_overlay_key(KeyCode::Char('d'), KeyModifiers::empty()) + .unwrap(); + match confirmed { + OverlayAction::Selected(PickerResult::DeleteSessions(targets)) => { + assert_eq!( + targets, + vec![ResumeTarget::JcodeSession { + session_id: "session_older".to_string(), + }] + ); + } + other => panic!("expected delete selection, got {other:?}"), + } +} + #[test] fn test_rebuild_items_prunes_selected_sessions_hidden_by_filter() { let mut saved = make_session("session_saved", "saved", false, SessionStatus::Closed);