Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/jcode-config-types/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
}
Expand Down
17 changes: 17 additions & 0 deletions src/cli/tui_launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
2 changes: 1 addition & 1 deletion src/config/default_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions src/config_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}

Expand Down
4 changes: 3 additions & 1 deletion src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Message> {
messages.iter().map(StoredMessage::to_message).collect()
Expand Down
96 changes: 96 additions & 0 deletions src/session/storage_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>,
pub missing: Vec<PathBuf>,
}

pub fn delete_session_artifacts(session_id: &str) -> Result<DeletedSessionArtifacts> {
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<std::ffi::OsString>) {
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());
}
}
49 changes: 47 additions & 2 deletions src/tui/app/inline_interactive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1486,7 +1486,9 @@ impl App {

let manual: Vec<String> = 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,
Expand All @@ -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],
Expand Down Expand Up @@ -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();
Expand Down
66 changes: 63 additions & 3 deletions src/tui/session_picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ pub enum PickerResult {
Selected(Vec<ResumeTarget>),
SelectedInCurrentTerminal(Vec<ResumeTarget>),
SelectedInNewTerminal(Vec<ResumeTarget>),
DeleteSessions(Vec<ResumeTarget>),
RestoreAllCrashed,
}

Expand Down Expand Up @@ -169,6 +170,7 @@ pub struct SessionPicker {
cached_search_refs: Vec<SessionRef>,
/// Lightweight placeholder shown while the picker list is loading.
loading_message: Option<String>,
pending_delete_targets: Option<Vec<ResumeTarget>>,
}

impl SessionPicker {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<SessionRef> {
Expand Down Expand Up @@ -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() {
Expand All @@ -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() {
Expand All @@ -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') => {
Expand Down Expand Up @@ -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)
}
}
}
Expand Down Expand Up @@ -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
Expand All @@ -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() {
Expand All @@ -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') => {
Expand Down
Loading