diff --git a/Cargo.lock b/Cargo.lock index 764202e..1ff5774 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,6 +235,7 @@ dependencies = [ "serde_json", "syntect", "syntect-tui", + "tempfile", "termbg", ] @@ -669,6 +670,12 @@ dependencies = [ "regex", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + [[package]] name = "fax" version = "0.2.6" @@ -2153,6 +2160,19 @@ dependencies = [ "syntect", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.1", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "termbg" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 848c347..9da5ea1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,6 @@ homepage = "https://github.com/cc-deck/cc-session" ratatui = "0.30" crossterm = "0.29" clap = { version = "4.5", features = ["derive"] } -arboard = { version = "3.6", features = ["wayland-data-control"] } rayon = "1.11" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" @@ -27,6 +26,9 @@ syntect = "5.3" syntect-tui = "3.0" termbg = "0.6" +[dev-dependencies] +tempfile = "3" + [profile.release] lto = true strip = true diff --git a/src/clipboard.rs b/src/clipboard.rs deleted file mode 100644 index b84f037..0000000 --- a/src/clipboard.rs +++ /dev/null @@ -1,17 +0,0 @@ -// Cross-platform clipboard with fallback - -use arboard::Clipboard; - -/// Copy the given text to the system clipboard. -pub fn copy_to_clipboard(text: &str) -> Result<(), String> { - let mut clipboard = Clipboard::new().map_err(|e| format!("failed to open clipboard: {e}"))?; - clipboard - .set_text(text.to_string()) - .map_err(|e| format!("failed to set clipboard text: {e}")) -} - -/// Check whether clipboard access is available on this system. -#[allow(dead_code)] -pub fn clipboard_available() -> bool { - Clipboard::new().is_ok() -} diff --git a/src/discovery.rs b/src/discovery.rs index 2905072..6a06cb8 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -60,22 +60,60 @@ pub fn discover_sessions(claude_home: &Path) -> Vec { sessions } +/// Decode a Claude project directory name to a real filesystem path. +/// +/// Claude encodes paths by replacing '/' with '-', e.g.: +/// `-Users-abulgu-github-repos-mabulgu-cluster-baremetal-operator` +/// becomes: +/// `/Users/abulgu/github-repos/mabulgu/cluster-baremetal-operator` +/// +/// Uses filesystem probing to handle dashes in actual directory names. +fn decode_encoded_dir(encoded: &str) -> String { + let trimmed = encoded.trim_start_matches('-'); + let parts: Vec<&str> = trimmed.split('-').collect(); + let mut path = PathBuf::from("/"); + let mut i = 0; + + while i < parts.len() { + let mut found = false; + for j in (i + 1..=parts.len()).rev() { + let candidate = parts[i..j].join("-"); + let full = path.join(&candidate); + if full.is_dir() { + path = full; + i = j; + found = true; + break; + } + } + if !found { + path = path.join(parts[i]); + i += 1; + } + } + + path.to_string_lossy().to_string() +} + /// Parse a single JSONL session file and extract the first user message. fn parse_session_file(path: &Path) -> Option { let session_id = path.file_stem()?.to_str()?.to_string(); + let encoded_dir = path.parent()?.file_name()?.to_str()?.to_string(); + let file = fs::File::open(path).ok()?; let reader = BufReader::new(file); - // Track metadata from the first user entry (for cwd, branch, timestamp) - // but keep scanning for a non-meta message to display let mut cwd = String::new(); let mut git_branch: Option = None; let mut timestamp: DateTime = Utc::now(); let mut first_message = String::new(); let mut found_metadata = false; + let mut found_message = false; + let mut custom_title: Option = None; + let mut line_count: usize = 0; - for line in reader.lines().take(50) { + for line in reader.lines() { let line = match line { Ok(l) => l, Err(_) => continue, @@ -83,6 +121,27 @@ fn parse_session_file(path: &Path) -> Option { if line.trim().is_empty() { continue; } + + if line.contains("\"custom-title\"") { + if let Ok(val) = serde_json::from_str::(&line) { + if val.get("type").and_then(|v| v.as_str()) == Some("custom-title") { + custom_title = val + .get("customTitle") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + } + } + continue; + } + + if found_message { + continue; + } + line_count += 1; + if line_count > 50 { + continue; + } + let entry: SessionFileEntry = match serde_json::from_str(&line) { Ok(e) => e, Err(_) => continue, @@ -92,7 +151,6 @@ fn parse_session_file(path: &Path) -> Option { continue; } - // Grab metadata from the first user entry if !found_metadata { cwd = entry.cwd.clone().unwrap_or_default(); git_branch = entry.git_branch.clone(); @@ -104,7 +162,6 @@ fn parse_session_file(path: &Path) -> Option { found_metadata = true; } - // Extract and clean message text let raw_text = entry.message.map(|m| m.content.text()).unwrap_or_default(); if is_meta_message(&raw_text) { continue; @@ -118,30 +175,43 @@ fn parse_session_file(path: &Path) -> Option { .chars() .take(200) .collect(); - break; + found_message = true; } if !found_metadata { return None; } - let project_name = Path::new(&cwd) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unknown") - .to_string(); + let project_name = { + let decoded = decode_encoded_dir(&encoded_dir); + let decoded_path = Path::new(&decoded); + if decoded_path.exists() { + decoded_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string() + } else { + Path::new(&cwd) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unknown") + .to_string() + } + }; let project_exists = Path::new(&cwd).exists(); Some(Session { id: session_id, - project_path: cwd.clone(), + project_path: encoded_dir, project_name, git_branch, timestamp, first_message, cwd, project_exists, + custom_title, }) } @@ -152,10 +222,9 @@ fn parse_session_file(path: &Path) -> Option { /// the same role are merged into a single message with paragraphs separated by /// blank lines. pub fn load_conversation(claude_home: &Path, session: &Session) -> Vec { - let encoded_dir = session.project_path.replace('/', "-"); let file_path = claude_home .join("projects") - .join(&encoded_dir) + .join(&session.project_path) .join(format!("{}.jsonl", session.id)); let file = match fs::File::open(&file_path) { diff --git a/src/filter.rs b/src/filter.rs index 9fab19b..254b431 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -72,9 +72,10 @@ pub fn filter_sessions(sessions: &[Session], query: &str) -> Vec { .enumerate() .filter_map(|(idx, session)| { let branch = session.git_branch.as_deref().unwrap_or(""); + let title = session.custom_title.as_deref().unwrap_or(""); let haystack = format!( - "{} {} {}", - session.project_name, branch, session.first_message + "{} {} {} {}", + session.project_name, branch, session.first_message, title ) .to_lowercase(); diff --git a/src/lib.rs b/src/lib.rs index d7d6983..39df196 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,7 @@ -pub mod clipboard; pub mod discovery; pub mod filter; pub mod search; pub mod session; pub mod theme; +pub mod titles; pub mod tui; diff --git a/src/main.rs b/src/main.rs index 42037e8..f867fe0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ -mod clipboard; mod discovery; mod filter; mod search; mod session; mod theme; +mod titles; mod tui; use clap::Parser; @@ -33,6 +33,10 @@ struct Cli { /// Force dark color theme #[arg(long = "dark", conflicts_with = "light")] dark: bool, + + /// Start in flat list view (default: grouped by project) + #[arg(long = "flat")] + flat: bool, } /// Parse a human-friendly duration string into a chrono::Duration. @@ -95,7 +99,8 @@ fn main() { }; // Interactive TUI - if let Err(e) = tui::run(sessions, theme) { + let grouped_view = !cli.flat; + if let Err(e) = tui::run(sessions, theme, grouped_view) { eprintln!("TUI error: {e}"); std::process::exit(1); } diff --git a/src/search.rs b/src/search.rs index 5cb6621..236bd7c 100644 --- a/src/search.rs +++ b/src/search.rs @@ -21,9 +21,8 @@ pub fn build_session_index(claude_home: &Path, sessions: &[Session]) -> HashMap< let mut index = HashMap::with_capacity(sessions.len()); for session in sessions { - let encoded_dir = session.project_path.replace('/', "-"); let file_path = projects_dir - .join(&encoded_dir) + .join(&session.project_path) .join(format!("{}.jsonl", session.id)); index.insert(file_path, session.clone()); } @@ -311,12 +310,13 @@ fn search_file_with_metadata(path: &Path, re: &Regex) -> Option { Some(Session { id: session_id, - project_path: cwd.clone(), + project_path: path.parent()?.file_name()?.to_str()?.to_string(), project_name, git_branch: entry.git_branch, timestamp, first_message, cwd, project_exists, + custom_title: None, }) } diff --git a/src/session.rs b/src/session.rs index fef4fa6..6f3e7d2 100644 --- a/src/session.rs +++ b/src/session.rs @@ -15,6 +15,7 @@ pub struct Session { pub first_message: String, pub cwd: String, pub project_exists: bool, + pub custom_title: Option, } impl Session { @@ -22,10 +23,16 @@ impl Session { /// /// The path is single-quoted to handle spaces and special characters. pub fn resume_command(&self) -> String { - // Single-quote the path, escaping any embedded single quotes let escaped_cwd = self.cwd.replace('\'', "'\\''"); - format!("cd '{}' && claude -r {}", escaped_cwd, self.id) + match &self.custom_title { + Some(title) if !title.is_empty() => { + let escaped_title = title.replace('\'', "'\\''"); + format!("cd '{}' && claude -r {} -n '{}'", escaped_cwd, self.id, escaped_title) + } + _ => format!("cd '{}' && claude -r {}", escaped_cwd, self.id), + } } + } /// A single line from a session JSONL file. diff --git a/src/titles.rs b/src/titles.rs new file mode 100644 index 0000000..6596773 --- /dev/null +++ b/src/titles.rs @@ -0,0 +1,41 @@ +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; + +use crate::discovery::get_claude_home; + +/// Append a custom-title entry to a session's JSONL file. +pub fn save_custom_title(project_path: &str, session_id: &str, title: &str) -> Result<(), String> { + let claude_home = get_claude_home(); + let file_path = claude_home + .join("projects") + .join(project_path) + .join(format!("{session_id}.jsonl")); + + append_custom_title(&file_path, session_id, title) +} + +fn append_custom_title(path: &Path, session_id: &str, title: &str) -> Result<(), String> { + let entry = serde_json::json!({ + "type": "custom-title", + "customTitle": title, + "sessionId": session_id, + }); + + let needs_newline = std::fs::read(path) + .map(|bytes| !bytes.is_empty() && !bytes.ends_with(b"\n")) + .unwrap_or(false); + + let mut file = OpenOptions::new() + .append(true) + .open(path) + .map_err(|e| format!("failed to open session file: {e}"))?; + + if needs_newline { + file.write_all(b"\n") + .map_err(|e| format!("failed to write newline: {e}"))?; + } + + writeln!(file, "{}", entry) + .map_err(|e| format!("failed to write title: {e}")) +} diff --git a/src/tui/input.rs b/src/tui/input.rs index 8e8a394..0d2be70 100644 --- a/src/tui/input.rs +++ b/src/tui/input.rs @@ -3,7 +3,7 @@ use std::time::Instant; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use super::{Action, App, ContentSearchState, Mode}; +use super::{Action, App, ContentSearchState, Mode, TreeRow}; /// Handle a key event and return the resulting action. pub fn handle_input(app: &mut App, key: KeyEvent) -> Action { @@ -16,31 +16,76 @@ pub fn handle_input(app: &mut App, key: KeyEvent) -> Action { Mode::Browsing => handle_browse(app, key), Mode::Conversation => handle_conversation(app, key), Mode::ConversationSearch => handle_conversation_search(app, key), + Mode::ConfirmArchive => handle_confirm_archive(app, key), + Mode::MoveSelectProject => handle_move_select(app, key), + Mode::TitleEdit => handle_title_edit(app, key), } } fn handle_browse(app: &mut App, key: KeyEvent) -> Action { match key.code { + KeyCode::Tab | KeyCode::BackTab => { + app.toggle_view(); + Action::Continue + } KeyCode::Esc => { if !app.filter_query.is_empty() || app.filter_active { - // First Escape: clear filter and deactivate app.cancel_content_search(); app.filter_query.clear(); app.filter_active = false; app.apply_filter(); Action::Continue } else { - // Second Escape (filter already empty): quit Action::Quit } } KeyCode::Enter => { - if app.selected < app.display_entries.len() { + if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Project(gi)) => { + app.toggle_project(gi); + Action::Continue + } + Some(TreeRow::Session { display_idx, .. }) => { + Action::EnterConversation(display_idx) + } + None => Action::Continue, + } + } else if app.selected < app.display_entries.len() { Action::EnterConversation(app.selected) } else { Action::Continue } } + KeyCode::Right => { + if app.grouped_view { + if let Some(TreeRow::Project(gi)) = app.selected_tree_row().cloned() { + if !app.project_groups[gi].expanded { + app.toggle_project(gi); + } + } + } + Action::Continue + } + KeyCode::Left => { + if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Project(gi)) => { + if app.project_groups[gi].expanded { + app.toggle_project(gi); + } + } + Some(TreeRow::Session { project_idx, .. }) => { + // Jump to parent project header + if let Some(pos) = app.tree_rows.iter().position(|r| *r == TreeRow::Project(project_idx)) { + app.selected = pos; + } + } + None => {} + } + } + Action::Continue + } KeyCode::Down => { app.move_down(); Action::Continue @@ -50,14 +95,12 @@ fn handle_browse(app: &mut App, key: KeyEvent) -> Action { Action::Continue } KeyCode::PageDown => { - // Jump down by a page for _ in 0..20 { app.move_down(); } Action::Continue } KeyCode::PageUp => { - // Jump up by a page for _ in 0..20 { app.move_up(); } @@ -69,8 +112,9 @@ fn handle_browse(app: &mut App, key: KeyEvent) -> Action { Action::Continue } KeyCode::End => { - if !app.display_entries.is_empty() { - app.selected = app.display_entries.len() - 1; + let count = app.visible_row_count(); + if count > 0 { + app.selected = count - 1; } Action::Continue } @@ -92,11 +136,79 @@ fn handle_browse(app: &mut App, key: KeyEvent) -> Action { Action::Continue } KeyCode::Char(c) => { - // First '/' activates filter mode visually without adding to query if c == '/' && app.filter_query.is_empty() && !app.filter_active { app.filter_active = true; return Action::Continue; } + if c == 'a' && !app.filter_active { + let display_idx = if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Session { display_idx, .. }) => Some(display_idx), + _ => None, + } + } else if app.selected < app.display_entries.len() { + Some(app.selected) + } else { + None + }; + if let Some(idx) = display_idx { + app.archive_confirm = Some(idx); + app.mode = Mode::ConfirmArchive; + } + return Action::Continue; + } + if c == 'n' && !app.filter_active { + let cwd = if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Project(gi)) => Some(app.project_groups[gi].cwd.clone()), + Some(TreeRow::Session { project_idx, .. }) => Some(app.project_groups[project_idx].cwd.clone()), + None => None, + } + } else if app.selected < app.display_entries.len() { + let entry = &app.display_entries[app.selected]; + Some(app.display_session(entry).cwd.clone()) + } else { + None + }; + if let Some(cwd) = cwd.filter(|c| !c.is_empty()) { + app.start_new_session_title(cwd); + } + return Action::Continue; + } + if c == 't' && !app.filter_active { + let display_idx = if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Session { display_idx, .. }) => Some(display_idx), + _ => None, + } + } else if app.selected < app.display_entries.len() { + Some(app.selected) + } else { + None + }; + if let Some(idx) = display_idx { + let entry = &app.display_entries[idx]; + let session_id = app.display_session(entry).id.clone(); + app.start_title_edit(session_id, Mode::Browsing); + return Action::Continue; + } + } + if c == 'm' && !app.filter_active { + let display_idx = if app.grouped_view { + match app.selected_tree_row().cloned() { + Some(TreeRow::Session { display_idx, .. }) => Some(display_idx), + _ => None, + } + } else if app.selected < app.display_entries.len() { + Some(app.selected) + } else { + None + }; + if let Some(idx) = display_idx { + app.start_move(idx); + return Action::Continue; + } + } app.filter_active = true; app.filter_query.push(c); app.cancel_flag.store(true, Ordering::Relaxed); @@ -191,6 +303,21 @@ fn handle_conversation(app: &mut App, key: KeyEvent) -> Action { jump_to_prev_match(app); Action::Continue } + KeyCode::Char('t') => { + if let Some(conv) = &app.conversation { + let session_id = conv.session.id.clone(); + app.start_title_edit(session_id, Mode::Conversation); + } + Action::Continue + } + KeyCode::Char('f') => { + if let Some(conv) = &app.conversation { + let session_id = conv.session.id.clone(); + let cwd = conv.session.cwd.clone(); + app.start_fork_title(session_id, cwd); + } + Action::Continue + } KeyCode::Char('/') => { if let Some(conv) = &mut app.conversation { conv.search_active = true; @@ -260,8 +387,13 @@ fn handle_conversation_search(app: &mut App, key: KeyEvent) -> Action { conv.search_cursor = 0; conv.search_replacing = false; } else if conv.search_cursor > 0 { - conv.search_query.remove(conv.search_cursor - 1); - conv.search_cursor -= 1; + let prev = conv.search_query[..conv.search_cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + conv.search_query.remove(prev); + conv.search_cursor = prev; } conv.rendered_width = 0; } @@ -273,7 +405,11 @@ fn handle_conversation_search(app: &mut App, key: KeyEvent) -> Action { conv.search_replacing = false; conv.search_cursor = 0; } else if conv.search_cursor > 0 { - conv.search_cursor -= 1; + conv.search_cursor = conv.search_query[..conv.search_cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); } } Action::Continue @@ -284,7 +420,11 @@ fn handle_conversation_search(app: &mut App, key: KeyEvent) -> Action { conv.search_replacing = false; conv.search_cursor = conv.search_query.len(); } else if conv.search_cursor < conv.search_query.len() { - conv.search_cursor += 1; + conv.search_cursor = conv.search_query[conv.search_cursor..] + .char_indices() + .nth(1) + .map(|(i, _)| conv.search_cursor + i) + .unwrap_or(conv.search_query.len()); } } Action::Continue @@ -297,7 +437,7 @@ fn handle_conversation_search(app: &mut App, key: KeyEvent) -> Action { conv.search_replacing = false; } conv.search_query.insert(conv.search_cursor, c); - conv.search_cursor += 1; + conv.search_cursor += c.len_utf8(); conv.rendered_width = 0; } Action::Continue @@ -306,6 +446,144 @@ fn handle_conversation_search(app: &mut App, key: KeyEvent) -> Action { } } +fn handle_move_select(app: &mut App, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc | KeyCode::Char('q') => { + app.move_state = None; + app.mode = Mode::Browsing; + Action::Continue + } + KeyCode::Enter => { + if let Some(state) = app.move_state.take() { + let (target, _, cwd) = &state.projects[state.selected]; + let target = target.clone(); + let cwd = cwd.clone(); + app.mode = Mode::Browsing; + return Action::MoveSession { + display_idx: state.display_idx, + target_project: target, + target_cwd: cwd, + }; + } + app.mode = Mode::Browsing; + Action::Continue + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(state) = &mut app.move_state { + if state.selected + 1 < state.projects.len() { + state.selected += 1; + } + } + Action::Continue + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(state) = &mut app.move_state { + state.selected = state.selected.saturating_sub(1); + } + Action::Continue + } + KeyCode::Home => { + if let Some(state) = &mut app.move_state { + state.selected = 0; + } + Action::Continue + } + KeyCode::End => { + if let Some(state) = &mut app.move_state { + if !state.projects.is_empty() { + state.selected = state.projects.len() - 1; + } + } + Action::Continue + } + _ => Action::Continue, + } +} + +fn handle_title_edit(app: &mut App, key: KeyEvent) -> Action { + match key.code { + KeyCode::Esc => { + app.cancel_title_edit(); + Action::Continue + } + KeyCode::Enter => { + match app.finish_title_edit() { + Ok(Some(action)) => action, + Ok(None) => Action::Continue, + Err(msg) => { + app.set_status(msg); + Action::Continue + } + } + } + KeyCode::Backspace => { + if let Some(state) = &mut app.title_edit { + if state.cursor > 0 { + let prev = state.query[..state.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + state.query.remove(prev); + state.cursor = prev; + } + } + Action::Continue + } + KeyCode::Left => { + if let Some(state) = &mut app.title_edit { + if state.cursor > 0 { + state.cursor = state.query[..state.cursor] + .char_indices() + .next_back() + .map(|(i, _)| i) + .unwrap_or(0); + } + } + Action::Continue + } + KeyCode::Right => { + if let Some(state) = &mut app.title_edit { + if state.cursor < state.query.len() { + let next = state.query[state.cursor..] + .char_indices() + .nth(1) + .map(|(i, _)| state.cursor + i) + .unwrap_or(state.query.len()); + state.cursor = next; + } + } + Action::Continue + } + KeyCode::Char(c) => { + if let Some(state) = &mut app.title_edit { + state.query.insert(state.cursor, c); + state.cursor += c.len_utf8(); + } + Action::Continue + } + _ => Action::Continue, + } +} + +fn handle_confirm_archive(app: &mut App, key: KeyEvent) -> Action { + match key.code { + KeyCode::Enter | KeyCode::Char('y') => { + let idx = app.archive_confirm.take(); + app.mode = Mode::Browsing; + if let Some(display_idx) = idx { + return Action::ArchiveSession(display_idx); + } + Action::Continue + } + _ => { + app.archive_confirm = None; + app.mode = Mode::Browsing; + Action::Continue + } + } +} + fn jump_to_next_match(app: &mut App) { if let Some(conv) = &mut app.conversation { if conv.match_positions.is_empty() { diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 09219d3..6c9bf23 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -18,7 +18,6 @@ use crossterm::terminal::{ }; use ratatui::prelude::*; -use crate::clipboard; use crate::discovery::{get_claude_home, load_conversation}; use crate::filter::filter_sessions; use crate::search; @@ -33,6 +32,9 @@ pub enum Mode { Browsing, Conversation, ConversationSearch, + ConfirmArchive, + MoveSelectProject, + TitleEdit, } /// Phase of the background content search. @@ -74,7 +76,126 @@ pub enum Action { Quit, EnterConversation(usize), CopyCommand(String), + NewSession(String), + ForkSession(String), BackToList, + ArchiveSession(usize), + MoveSession { display_idx: usize, target_project: String, target_cwd: String }, +} + +/// A group of sessions belonging to the same project directory. +#[derive(Debug, Clone)] +pub struct ProjectGroup { + pub name: String, + pub path: String, + pub cwd: String, + pub session_indices: Vec, + pub expanded: bool, +} + +/// A single row in the tree view: either a project header or a session under it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TreeRow { + Project(usize), + Session { project_idx: usize, display_idx: usize }, +} + +/// Build project groups from a flat list of sessions. +/// Groups are sorted by the latest session timestamp (newest group first). +/// Sessions within each group are ordered by their position in `display_entries`. +pub fn group_by_project(sessions: &[Session], display_entries: &[DisplayEntry]) -> Vec { + group_by_project_with_content(sessions, display_entries, &[]) +} + +/// Build project groups, including content-only search results. +pub fn group_by_project_with_content(sessions: &[Session], display_entries: &[DisplayEntry], content_results: &[Session]) -> Vec { + let mut group_map: std::collections::HashMap> = std::collections::HashMap::new(); + + for (di, entry) in display_entries.iter().enumerate() { + let session = match &entry.source { + DisplaySource::Sessions(idx) => &sessions[*idx], + DisplaySource::Content(idx) => &content_results[*idx], + }; + group_map + .entry(session.project_path.clone()) + .or_default() + .push(di); + } + + let mut groups: Vec = group_map + .into_iter() + .map(|(path, indices)| { + let first_session = indices.first() + .and_then(|&di| { + match &display_entries.get(di)?.source { + DisplaySource::Sessions(idx) => sessions.get(*idx), + DisplaySource::Content(idx) => content_results.get(*idx), + } + }); + let name = first_session + .map(|s| s.project_name.clone()) + .unwrap_or_else(|| "unknown".to_string()); + let cwd = first_session + .map(|s| s.cwd.clone()) + .unwrap_or_default(); + ProjectGroup { + name, + path, + cwd, + session_indices: indices, + expanded: false, + } + }) + .collect(); + + // Sort groups by latest activity (newest first) + groups.sort_by(|a, b| { + let ts_a = a.session_indices.first() + .map(|&i| display_entries[i].timestamp) + .unwrap_or_else(|| DateTime::::MIN_UTC); + let ts_b = b.session_indices.first() + .map(|&i| display_entries[i].timestamp) + .unwrap_or_else(|| DateTime::::MIN_UTC); + ts_b.cmp(&ts_a) + }); + + groups +} + +/// Build the flat list of tree rows from project groups, respecting expanded state. +pub fn build_tree_rows(groups: &[ProjectGroup]) -> Vec { + let mut rows = Vec::new(); + for (gi, group) in groups.iter().enumerate() { + rows.push(TreeRow::Project(gi)); + if group.expanded { + for &di in &group.session_indices { + rows.push(TreeRow::Session { project_idx: gi, display_idx: di }); + } + } + } + rows +} + +/// State for the project picker when moving a session. +pub struct MoveState { + pub display_idx: usize, + pub projects: Vec<(String, String, String)>, // (encoded_path, display_name, cwd) + pub selected: usize, +} + +/// What the title edit is for. +pub enum TitleEditContext { + Rename { session_id: String }, + NewSession { cwd: String }, + Fork { session_id: String, cwd: String }, +} + +/// State for title editing. +pub struct TitleEditState { + pub context: TitleEditContext, + pub query: String, + pub cursor: usize, + pub return_mode: Mode, } /// State for the conversation viewer. @@ -128,10 +249,22 @@ pub struct App { pub theme: Theme, /// Syntax highlighter for code blocks. pub syntax_highlighter: syntax::SyntaxHighlighter, + /// Project groups for tree view. + pub project_groups: Vec, + /// Flattened tree rows for the grouped view. + pub tree_rows: Vec, + /// Whether the grouped (tree) view is active. + pub grouped_view: bool, + /// Pending archive confirmation: display_idx of session to archive. + pub archive_confirm: Option, + /// State for the move-to-project picker. + pub move_state: Option, + /// Title being edited. + pub title_edit: Option, } impl App { - pub fn new(sessions: Vec, session_index: HashMap, theme: Theme) -> Self { + pub fn new(sessions: Vec, session_index: HashMap, theme: Theme, grouped_view: bool) -> Self { let filtered_indices: Vec = (0..sessions.len()).collect(); let display_entries: Vec = filtered_indices .iter() @@ -141,6 +274,8 @@ impl App { timestamp: sessions[idx].timestamp, }) .collect(); + let project_groups = group_by_project(&sessions, &display_entries); + let tree_rows = build_tree_rows(&project_groups); Self { sessions, filtered_indices, @@ -161,6 +296,12 @@ impl App { session_index: Arc::new(session_index), theme, syntax_highlighter: syntax::SyntaxHighlighter::new(), + project_groups, + tree_rows, + grouped_view, + archive_confirm: None, + move_state: None, + title_edit: None, } } @@ -213,6 +354,58 @@ impl App { entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); self.display_entries = entries; + self.rebuild_tree_rows(); + } + + /// Rebuild project groups and tree rows from current display entries. + pub fn rebuild_tree_rows(&mut self) { + let has_filter = !self.filter_query.is_empty(); + let old_expanded: HashMap = self.project_groups + .iter() + .map(|g| (g.path.clone(), g.expanded)) + .collect(); + self.project_groups = group_by_project_with_content( + &self.sessions, + &self.display_entries, + &self.content_results, + ); + for group in &mut self.project_groups { + if has_filter { + group.expanded = true; + } else if let Some(&was_expanded) = old_expanded.get(&group.path) { + group.expanded = was_expanded; + } + } + self.tree_rows = build_tree_rows(&self.project_groups); + } + + /// Toggle a project group's expanded/collapsed state. + pub fn toggle_project(&mut self, group_idx: usize) { + if group_idx < self.project_groups.len() { + self.project_groups[group_idx].expanded = !self.project_groups[group_idx].expanded; + self.tree_rows = build_tree_rows(&self.project_groups); + } + } + + /// Toggle between flat list and grouped tree view. + pub fn toggle_view(&mut self) { + self.grouped_view = !self.grouped_view; + self.selected = 0; + self.scroll_offset = 0; + } + + /// Get the currently selected tree row, if in grouped view. + pub fn selected_tree_row(&self) -> Option<&TreeRow> { + self.tree_rows.get(self.selected) + } + + /// Total number of visible rows in the current view mode. + pub fn visible_row_count(&self) -> usize { + if self.grouped_view { + self.tree_rows.len() + } else { + self.display_entries.len() + } } /// Get the session referenced by a display entry. @@ -234,8 +427,9 @@ impl App { /// Move the selection cursor down, clamped to bounds. pub fn move_down(&mut self) { - if !self.display_entries.is_empty() { - self.selected = (self.selected + 1).min(self.display_entries.len() - 1); + let count = self.visible_row_count(); + if count > 0 { + self.selected = (self.selected + 1).min(count - 1); } } @@ -296,6 +490,304 @@ impl App { self.mode = Mode::Browsing; } + /// Archive a session by moving its JSONL file to a projects-archive/ directory. + /// Returns Ok(session_name) on success. + pub fn archive_session(&mut self, display_idx: usize) -> Result { + if display_idx >= self.display_entries.len() { + return Err("invalid index".to_string()); + } + let entry = &self.display_entries[display_idx]; + let session = self.display_session(entry).clone(); + + let claude_home = get_claude_home(); + let src = claude_home + .join("projects") + .join(&session.project_path) + .join(format!("{}.jsonl", session.id)); + let archive_dir = claude_home + .join("projects-archive") + .join(&session.project_path); + + std::fs::create_dir_all(&archive_dir) + .map_err(|e| format!("failed to create archive dir: {e}"))?; + + let dst = archive_dir.join(format!("{}.jsonl", session.id)); + std::fs::rename(&src, &dst) + .map_err(|e| format!("failed to move session: {e}"))?; + + let label = session.first_message.chars().take(40).collect::(); + + match entry.source { + DisplaySource::Sessions(sidx) => { + if sidx < self.sessions.len() && self.sessions[sidx].id == session.id { + self.sessions.remove(sidx); + } + } + DisplaySource::Content(cidx) => { + if cidx < self.content_results.len() && self.content_results[cidx].id == session.id { + self.content_results.remove(cidx); + } + } + } + self.apply_filter(); + + Ok(label) + } + + /// Start the move-to-project flow for a given session. + pub fn start_move(&mut self, display_idx: usize) { + if display_idx >= self.display_entries.len() { + return; + } + let entry = &self.display_entries[display_idx]; + let current_path = self.display_session(entry).project_path.clone(); + + let mut seen = HashSet::new(); + let mut projects: Vec<(String, String, String)> = Vec::new(); + for s in &self.sessions { + if s.project_path == current_path { + continue; + } + if seen.insert(s.project_path.clone()) { + projects.push((s.project_path.clone(), s.project_name.clone(), s.cwd.clone())); + } + } + + projects.sort_by(|a, b| a.1.to_lowercase().cmp(&b.1.to_lowercase())); + + if projects.is_empty() { + self.set_status("No other projects to move to".to_string()); + return; + } + + self.move_state = Some(MoveState { + display_idx, + projects, + selected: 0, + }); + self.mode = Mode::MoveSelectProject; + } + + /// Move a session to a different project directory, updating cwd in the JSONL. + pub fn move_session(&mut self, display_idx: usize, target_encoded_dir: &str, target_cwd: &str) -> Result { + if display_idx >= self.display_entries.len() { + return Err("invalid index".to_string()); + } + let entry = &self.display_entries[display_idx]; + let session = self.display_session(entry).clone(); + + let claude_home = get_claude_home(); + let src = claude_home + .join("projects") + .join(&session.project_path) + .join(format!("{}.jsonl", session.id)); + + let dst_dir = claude_home.join("projects").join(target_encoded_dir); + std::fs::create_dir_all(&dst_dir) + .map_err(|e| format!("failed to create target dir: {e}"))?; + + let content = std::fs::read_to_string(&src) + .map_err(|e| format!("failed to read session file: {e}"))?; + let old_cwd = &session.cwd; + let mut updated: String = content + .lines() + .map(|line| { + if let Ok(mut val) = serde_json::from_str::(line) { + if let Some(obj) = val.as_object_mut() { + if obj.get("cwd").and_then(|v| v.as_str()) == Some(old_cwd) { + obj.insert("cwd".to_string(), serde_json::Value::String(target_cwd.to_string())); + } + } + serde_json::to_string(&val).unwrap_or_else(|_| line.to_string()) + } else { + line.to_string() + } + }) + .collect::>() + .join("\n"); + if !updated.is_empty() && !updated.ends_with('\n') { + updated.push('\n'); + } + + let dst = dst_dir.join(format!("{}.jsonl", session.id)); + std::fs::write(&dst, &updated) + .map_err(|e| format!("failed to write moved session: {e}"))?; + std::fs::remove_file(&src) + .map_err(|e| format!("failed to remove original session file: {e}"))?; + + let label = session.first_message.chars().take(40).collect::(); + let target_name = std::path::Path::new(target_cwd) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(target_encoded_dir); + + match entry.source { + DisplaySource::Sessions(sidx) => { + if sidx < self.sessions.len() && self.sessions[sidx].id == session.id { + self.sessions.remove(sidx); + } + } + DisplaySource::Content(cidx) => { + if cidx < self.content_results.len() && self.content_results[cidx].id == session.id { + self.content_results.remove(cidx); + } + } + } + self.apply_filter(); + + Ok(format!("{} → {}", label, target_name)) + } + + /// Start editing a title for the given session (rename). + pub fn start_title_edit(&mut self, session_id: String, return_mode: Mode) { + let existing = self.find_session(&session_id) + .and_then(|s| s.custom_title.clone()) + .unwrap_or_default(); + let cursor = existing.len(); + self.title_edit = Some(TitleEditState { + context: TitleEditContext::Rename { session_id }, + query: existing, + cursor, + return_mode, + }); + self.mode = Mode::TitleEdit; + } + + /// Start title input for a new session. + pub fn start_new_session_title(&mut self, cwd: String) { + self.title_edit = Some(TitleEditState { + context: TitleEditContext::NewSession { cwd }, + query: String::new(), + cursor: 0, + return_mode: Mode::Browsing, + }); + self.mode = Mode::TitleEdit; + } + + /// Start title input for forking a session. + pub fn start_fork_title(&mut self, session_id: String, cwd: String) { + let existing = self.find_session(&session_id) + .and_then(|s| s.custom_title.clone()); + let prefill = match existing { + Some(title) => format!("{} (fork)", title), + None => String::new(), + }; + let cursor = prefill.len(); + self.title_edit = Some(TitleEditState { + context: TitleEditContext::Fork { session_id, cwd }, + query: prefill, + cursor, + return_mode: Mode::Conversation, + }); + self.mode = Mode::TitleEdit; + } + + /// Finish title editing. Returns an Action if the caller should execute it. + pub fn finish_title_edit(&mut self) -> Result, String> { + let state = self.title_edit.take().ok_or("no title edit in progress")?; + let title = state.query.trim().to_string(); + + match state.context { + TitleEditContext::Rename { session_id } => { + if !title.is_empty() { + let duplicate = self.sessions.iter() + .chain(self.content_results.iter()) + .any(|s| s.id != session_id && s.custom_title.as_deref() == Some(&title)); + if duplicate { + let cursor = title.len(); + self.title_edit = Some(TitleEditState { + context: TitleEditContext::Rename { session_id }, + query: title, + cursor, + return_mode: state.return_mode, + }); + self.mode = Mode::TitleEdit; + return Err("title already in use".to_string()); + } + } + + let project_path = self.find_session(&session_id) + .map(|s| s.project_path.clone()) + .ok_or("session not found")?; + + crate::titles::save_custom_title(&project_path, &session_id, &title)?; + self.update_session_title(&session_id, if title.is_empty() { None } else { Some(title) }); + self.mode = state.return_mode; + Ok(None) + } + TitleEditContext::NewSession { cwd } => { + let escaped_cwd = cwd.replace('\'', "'\\''"); + + if title.is_empty() { + self.mode = Mode::Browsing; + return Ok(Some(Action::NewSession(format!("cd '{}' && claude", escaped_cwd)))); + } + + let duplicate = self.sessions.iter() + .chain(self.content_results.iter()) + .any(|s| s.custom_title.as_deref() == Some(&title)); + if duplicate { + let cursor = title.len(); + self.title_edit = Some(TitleEditState { + context: TitleEditContext::NewSession { cwd }, + query: title, + cursor, + return_mode: state.return_mode, + }); + self.mode = Mode::TitleEdit; + return Err("title already in use".to_string()); + } + + let escaped_title = title.replace('\'', "'\\''"); + self.mode = Mode::Browsing; + Ok(Some(Action::NewSession(format!("cd '{}' && claude -n '{}'", escaped_cwd, escaped_title)))) + } + TitleEditContext::Fork { session_id, cwd } => { + let escaped_cwd = cwd.replace('\'', "'\\''"); + if title.is_empty() { + self.mode = state.return_mode; + return Ok(Some(Action::ForkSession( + format!("cd '{}' && claude -r {} --fork-session", escaped_cwd, session_id) + ))); + } + let escaped_title = title.replace('\'', "'\\''"); + self.mode = state.return_mode; + Ok(Some(Action::ForkSession( + format!("cd '{}' && claude -r {} --fork-session -n '{}'", escaped_cwd, session_id, escaped_title) + ))) + } + } + } + + /// Cancel title editing. + pub fn cancel_title_edit(&mut self) { + if let Some(state) = self.title_edit.take() { + self.mode = state.return_mode; + } + } + + /// Get the display label for a session: custom_title if set, otherwise first_message. + pub fn session_display_label(&self, session: &Session) -> String { + session.custom_title.clone().unwrap_or_else(|| session.first_message.clone()) + } + + /// Find a session by ID across sessions and content_results. + fn find_session(&self, session_id: &str) -> Option<&Session> { + self.sessions.iter() + .chain(self.content_results.iter()) + .find(|s| s.id == session_id) + } + + /// Update custom_title on a session in memory. + fn update_session_title(&mut self, session_id: &str, title: Option) { + for s in self.sessions.iter_mut().chain(self.content_results.iter_mut()) { + if s.id == session_id { + s.custom_title = title; + return; + } + } + } + /// Set a status message that disappears after a few seconds. #[allow(dead_code)] pub fn set_status(&mut self, msg: String) { @@ -391,7 +883,7 @@ impl App { } /// Run the interactive TUI session picker. -pub fn run(sessions: Vec, theme: Theme) -> Result<(), Box> { +pub fn run(sessions: Vec, theme: Theme, grouped_view: bool) -> Result<(), Box> { if sessions.is_empty() { eprintln!("No sessions found."); return Ok(()); @@ -412,7 +904,7 @@ pub fn run(sessions: Vec, theme: Theme) -> Result<(), Box = None; loop { @@ -439,13 +931,36 @@ pub fn run(sessions: Vec, theme: Theme) -> Result<(), Box { app.enter_conversation(idx); } - Action::CopyCommand(cmd) => match clipboard::copy_to_clipboard(&cmd) { - Ok(()) => break, - Err(_) => { - deferred_command = Some(cmd); - break; + Action::CopyCommand(cmd) | Action::NewSession(cmd) | Action::ForkSession(cmd) => { + deferred_command = Some(cmd); + break; + } + Action::ArchiveSession(display_idx) => { + match app.archive_session(display_idx) { + Ok(label) => { + app.set_status(format!("Archived: {}", label)); + if app.selected >= app.visible_row_count() && app.selected > 0 { + app.selected -= 1; + } + } + Err(e) => { + app.set_status(format!("Archive failed: {}", e)); + } + } + } + Action::MoveSession { display_idx, target_project, target_cwd } => { + match app.move_session(display_idx, &target_project, &target_cwd) { + Ok(label) => { + app.set_status(format!("Moved: {}", label)); + if app.selected >= app.visible_row_count() && app.selected > 0 { + app.selected -= 1; + } + } + Err(e) => { + app.set_status(format!("Move failed: {}", e)); + } } - }, + } Action::BackToList => { app.leave_conversation(); } @@ -460,7 +975,19 @@ pub fn run(sessions: Vec, theme: Theme) -> Result<(), Box std::process::exit(s.code().unwrap_or(1)), + Err(e) => { + eprintln!("Failed to exec: {e}"); + println!("{cmd}"); + } + } } Ok(()) diff --git a/src/tui/view.rs b/src/tui/view.rs index 171d84a..8513a35 100644 --- a/src/tui/view.rs +++ b/src/tui/view.rs @@ -7,13 +7,16 @@ use ratatui::widgets::{Block, Borders, Paragraph, Scrollbar, ScrollbarOrientatio use crate::session::{ConversationMessage, MessageRole}; use super::table; -use super::{App, ContentSearchState, Mode}; +use super::{App, ContentSearchState, Mode, TreeRow}; /// Render the full TUI frame. pub fn render(frame: &mut Frame, app: &mut App) { let area = frame.area(); - if app.mode == Mode::Conversation || app.mode == Mode::ConversationSearch { + let in_conversation = app.mode == Mode::Conversation + || app.mode == Mode::ConversationSearch + || (app.mode == Mode::TitleEdit && app.conversation.is_some()); + if in_conversation { render_conversation(frame, app, area); return; } @@ -25,13 +28,26 @@ pub fn render(frame: &mut Frame, app: &mut App) { render_session_list(frame, app, chunks[0]); render_status_bar(frame, app, chunks[1]); + + if app.mode == Mode::MoveSelectProject { + render_move_picker(frame, app, area); + } } /// Render the session list with single-line entries. fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { - let width = area.width.saturating_sub(2) as usize; // account for left/right borders + if app.grouped_view { + render_grouped_session_list(frame, app, area); + } else { + render_flat_session_list(frame, app, area); + } +} + +/// Render the flat (ungrouped) session list. +fn render_flat_session_list(frame: &mut Frame, app: &App, area: Rect) { + let width = area.width.saturating_sub(2) as usize; let height = area.height as usize; - let visible_items = height.saturating_sub(2); // account for top/bottom borders + let visible_items = height.saturating_sub(2); let mut lines: Vec = Vec::new(); let terms = search_terms(app); @@ -48,7 +64,7 @@ fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { let delta = Utc::now().signed_duration_since(session.timestamp); let time_ago = HumanTime::from(-delta).to_text_en(Accuracy::Rough, Tense::Past); let right = format!("{} {}", session.project_name, time_ago); - let right_len = right.len(); + let right_len = right.chars().count(); let (cursor, cursor_len) = if is_selected { ("\u{27A4} ", 2) @@ -56,8 +72,9 @@ fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { (" ", 2) }; + let label = app.session_display_label(session); let max_msg_len = width.saturating_sub(cursor_len + right_len + 2); - let msg = truncate_str(&session.first_message, max_msg_len); + let msg = truncate_str(&label, max_msg_len); let msg_len = msg.chars().count(); let pad = width.saturating_sub(cursor_len + msg_len + right_len); let padding = " ".repeat(pad); @@ -69,7 +86,6 @@ fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { }; let dim = Style::default().fg(app.theme.text_dim); - let cursor_style = Style::default().fg(app.theme.cursor_color); let mut spans = vec![Span::styled(cursor, cursor_style)]; @@ -101,7 +117,6 @@ fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { let paragraph = Paragraph::new(text).block(block); frame.render_widget(paragraph, area); - // Render vertical scrollbar let total = app.display_entries.len(); let visible = area.height.saturating_sub(2) as usize; if total > visible { @@ -122,6 +137,206 @@ fn render_session_list(frame: &mut Frame, app: &App, area: Rect) { } } +/// Render the grouped (tree) session list with collapsible project headers. +fn render_grouped_session_list(frame: &mut Frame, app: &App, area: Rect) { + let width = area.width.saturating_sub(2) as usize; + let height = area.height as usize; + let visible_items = height.saturating_sub(2); + let mut lines: Vec = Vec::new(); + + let terms = search_terms(app); + let term_refs: Vec<&str> = terms.iter().map(|s| s.as_str()).collect(); + + let start = app.scroll_offset; + let end = (start + visible_items).min(app.tree_rows.len()); + + for i in start..end { + let row = &app.tree_rows[i]; + let is_selected = i == app.selected; + + match row { + TreeRow::Project(gi) => { + let group = &app.project_groups[*gi]; + let icon = if group.expanded { "\u{25BC}" } else { "\u{25B6}" }; + let count = group.session_indices.len(); + let label = format!("{} {} ({})", icon, group.name, count); + let label_len = label.chars().count(); + let pad = width.saturating_sub(label_len + 1); + let padding = " ".repeat(pad); + + let style = if is_selected { + Style::default().fg(app.theme.cursor_color).bold() + } else { + Style::default().fg(app.theme.heading).bold() + }; + + let line = Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled(label, style), + Span::raw(padding), + ]); + + if is_selected { + lines.push(line.patch_style(Style::default().bg(app.theme.selected_bg))); + } else { + lines.push(line); + } + } + TreeRow::Session { display_idx, .. } => { + let entry = &app.display_entries[*display_idx]; + let session = app.display_session(entry); + + let delta = Utc::now().signed_duration_since(session.timestamp); + let time_ago = HumanTime::from(-delta).to_text_en(Accuracy::Rough, Tense::Past); + let right = format!(" {}", time_ago); + let right_len = right.chars().count(); + + let indent = " "; + let indent_len = 4; + let (cursor, cursor_len) = if is_selected { + ("\u{27A4} ", 2) + } else { + ("- ", 2) + }; + + let label = app.session_display_label(session); + let max_msg_len = width.saturating_sub(indent_len + cursor_len + right_len + 2); + let msg = truncate_str(&label, max_msg_len); + let msg_len = msg.chars().count(); + let pad = width.saturating_sub(indent_len + cursor_len + msg_len + right_len); + let padding = " ".repeat(pad); + + let msg_style = if is_selected { + Style::default().fg(Color::White) + } else { + Style::default().fg(app.theme.text) + }; + + let dim = Style::default().fg(app.theme.text_dim); + let cursor_style = Style::default().fg(app.theme.cursor_color); + + let mut spans = vec![ + Span::raw(indent), + Span::styled(cursor, cursor_style), + ]; + spans.extend(highlight_terms(&msg, &term_refs, msg_style, &app.theme)); + spans.push(Span::raw(padding)); + spans.push(Span::styled(right, dim)); + + let line = Line::from(spans); + + if is_selected { + lines.push(line.patch_style(Style::default().bg(app.theme.selected_bg))); + } else { + lines.push(line); + } + } + } + } + + let session_count: usize = app.project_groups.iter() + .map(|g| g.session_indices.len()) + .sum(); + let text = Text::from(lines); + let border_style = Style::default().fg(app.theme.text_dim); + let block = Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(format!( + " cc-session ({}/{}) \u{2500} {} projects ", + session_count, + app.sessions.len(), + app.project_groups.len(), + )) + .title_style(Style::default().fg(app.theme.cursor_color).bold()); + + let paragraph = Paragraph::new(text).block(block); + frame.render_widget(paragraph, area); + + let total = app.tree_rows.len(); + let visible = area.height.saturating_sub(2) as usize; + if total > visible { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .thumb_style(Style::default().fg(app.theme.text_dim)) + .begin_symbol(None) + .end_symbol(None); + let mut scrollbar_state = + ScrollbarState::new(total.saturating_sub(visible)).position(app.scroll_offset); + frame.render_stateful_widget( + scrollbar, + area.inner(Margin { + vertical: 1, + horizontal: 0, + }), + &mut scrollbar_state, + ); + } +} + +/// Render the move-to-project picker as a centered overlay. +fn render_move_picker(frame: &mut Frame, app: &App, area: Rect) { + let state = match &app.move_state { + Some(s) => s, + None => return, + }; + + let max_w = 50u16.min(area.width.saturating_sub(4)); + let max_h = (state.projects.len() as u16 + 2).min(area.height.saturating_sub(4)).max(4); + let x = (area.width.saturating_sub(max_w)) / 2; + let y = (area.height.saturating_sub(max_h)) / 2; + let popup_area = Rect::new(x, y, max_w, max_h); + + let clear = ratatui::widgets::Clear; + frame.render_widget(clear, popup_area); + + let inner_w = max_w.saturating_sub(2) as usize; + let inner_h = max_h.saturating_sub(2) as usize; + + let visible_start = if state.selected >= inner_h { + state.selected - inner_h + 1 + } else { + 0 + }; + let visible_end = (visible_start + inner_h).min(state.projects.len()); + + let mut lines: Vec = Vec::new(); + for i in visible_start..visible_end { + let (_, ref name, _) = state.projects[i]; + let is_sel = i == state.selected; + let (prefix, style) = if is_sel { + ("\u{27A4} ", Style::default().fg(app.theme.cursor_color).bold()) + } else { + (" ", Style::default().fg(app.theme.text)) + }; + let label = if name.chars().count() + 2 > inner_w { + let truncated: String = name.chars().take(inner_w.saturating_sub(5)).collect(); + format!("{truncated}...") + } else { + name.clone() + }; + let pad = inner_w.saturating_sub(prefix.chars().count() + label.chars().count()); + let line = Line::from(vec![ + Span::styled(prefix, style), + Span::styled(label, style), + Span::raw(" ".repeat(pad)), + ]); + if is_sel { + lines.push(line.patch_style(Style::default().bg(app.theme.selected_bg))); + } else { + lines.push(line); + } + } + + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(app.theme.cursor_color)) + .title(" Move to project ") + .title_style(Style::default().fg(app.theme.cursor_color).bold()); + + let paragraph = Paragraph::new(Text::from(lines)).block(block); + frame.render_widget(paragraph, popup_area); +} + const MAX_CONTENT_WIDTH: u16 = 120; /// Render the conversation viewer. @@ -134,29 +349,34 @@ fn render_conversation(frame: &mut Frame, app: &mut App, area: Rect) { let full_content_area = chunks[0]; let status_area = chunks[1]; - // Build title: show match position during search, nothing otherwise - let title_extra = if let Some(conv) = &app.conversation { + // Build title bar: session name + search match info + let session_title = app.conversation.as_ref() + .and_then(|conv| conv.session.custom_title.as_deref()) + .unwrap_or(""); + + let search_info = if let Some(conv) = &app.conversation { let has_search = conv.search_confirmed || !conv.initial_search_terms.is_empty(); if has_search && !conv.match_positions.is_empty() { - format!( - " ({}/{}) ", - conv.current_match + 1, - conv.match_positions.len() - ) + format!(" ({}/{})", conv.current_match + 1, conv.match_positions.len()) } else { - String::from(" ") + String::new() } } else { - String::from(" ") + String::new() + }; + + let title_text = if session_title.is_empty() { + format!(" cc-session{search_info} ") + } else { + format!(" {session_title}{search_info} ") }; - // Draw border around the full area with title let border_style = Style::default().fg(app.theme.text_dim); let title_style = Style::default().fg(app.theme.cursor_color).bold(); let block = Block::default() .borders(Borders::ALL) .border_style(border_style) - .title(format!(" cc-session{title_extra}")) + .title(title_text) .title_style(title_style); let inner_area = block.inner(full_content_area); frame.render_widget(block, full_content_area); @@ -273,7 +493,9 @@ fn render_conversation(frame: &mut Frame, app: &mut App, area: Rect) { /// Render the conversation viewer status bar. fn render_conversation_status(frame: &mut Frame, app: &App, area: Rect) { - let content = if let Some(conv) = &app.conversation { + let content = if app.mode == Mode::TitleEdit { + render_title_edit_bar(app) + } else if let Some(conv) = &app.conversation { let dim = Style::default().fg(app.theme.text_dim); let label_style = Style::default() .fg(app.theme.status_label_fg) @@ -351,7 +573,7 @@ fn render_conversation_status(frame: &mut Frame, app: &App, area: Rect) { ), Span::raw(" "), Span::styled( - "Space/b scroll g/G top/bottom / search Enter copy & exit Esc back", + "Space/b scroll / search t title f fork Enter resume Esc back", dim, ), ]) @@ -689,13 +911,91 @@ fn wrap_line(line: &str, width: usize) -> Vec { result } +/// Render the title edit input bar. +fn render_title_edit_bar(app: &App) -> Line<'static> { + let dim = Style::default().fg(app.theme.text_dim); + let label_style = Style::default() + .fg(app.theme.status_label_fg) + .bg(app.theme.status_label_bg) + .bold(); + + if let Some(state) = &app.title_edit { + let is_new = matches!(state.context, super::TitleEditContext::NewSession { .. }); + let is_fork = matches!(state.context, super::TitleEditContext::Fork { .. }); + let label = if is_fork { " Fork title: " } else if is_new { " New session: " } else { " Title: " }; + let hint = if is_fork { + "Enter fork Esc cancel (empty = no name)" + } else if is_new { + "Enter create Esc cancel (empty = no name)" + } else { + "Enter save Esc cancel (clear to remove title)" + }; + + let mut spans = vec![ + Span::styled(label.to_string(), label_style), + Span::styled(" ", Style::default()), + ]; + + let cursor_pos = state.cursor.min(state.query.len()); + let (before, rest) = state.query.split_at(cursor_pos); + if !before.is_empty() { + spans.push(Span::styled(before.to_string(), Style::default().fg(Color::White))); + } + if let Some(ch) = rest.chars().next() { + spans.push(Span::styled( + ch.to_string(), + Style::default().fg(Color::Black).bg(Color::White), + )); + let after = &rest[ch.len_utf8()..]; + if !after.is_empty() { + spans.push(Span::styled(after.to_string(), Style::default().fg(Color::White))); + } + } else { + spans.push(Span::styled( + " ".to_string(), + Style::default().fg(Color::Black).bg(Color::White), + )); + } + + spans.push(Span::raw(" ")); + spans.push(Span::styled(hint, dim)); + + Line::from(spans) + } else { + Line::from("") + } +} + /// Render the status/help bar at the bottom. fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) { let dim = Style::default().fg(app.theme.text_dim); let content = match app.mode { Mode::Conversation | Mode::ConversationSearch => Line::from(""), - Mode::Browsing => { - if let Some((msg, _)) = &app.status_message { + Mode::TitleEdit => render_title_edit_bar(app), + Mode::MoveSelectProject => { + let key_style = Style::default().fg(Color::White).bold(); + Line::from(vec![ + Span::styled(" Select target project: ", Style::default().fg(app.theme.cursor_color).bold()), + Span::styled("↑↓", key_style), + Span::styled(" navigate ", dim), + Span::styled("Enter", key_style), + Span::styled(" confirm ", dim), + Span::styled("Esc", key_style), + Span::styled(" cancel", dim), + ]) + } + Mode::ConfirmArchive | Mode::Browsing => { + if app.archive_confirm.is_some() { + let warn_style = Style::default().fg(Color::Yellow).bold(); + let key_style = Style::default().fg(Color::White).bold(); + Line::from(vec![ + Span::styled(" Archive this session? ", warn_style), + Span::styled("y", key_style), + Span::styled(" yes ", dim), + Span::styled("any other key", key_style), + Span::styled(" cancel", dim), + ]) + } else if let Some((msg, _)) = &app.status_message { Line::from(vec![Span::styled( format!(" {msg}"), Style::default().fg(Color::Green).bold(), @@ -734,13 +1034,32 @@ fn render_status_bar(frame: &mut Frame, app: &App, area: Rect) { Span::styled("Esc clear Enter select", dim), ]) } else { + let view_hint = if app.grouped_view { + "Tab flat view" + } else { + "Tab grouped view" + }; Line::from(vec![ Span::styled(" Enter ", dim), Span::styled("detail", dim), Span::raw(" "), + Span::styled("t ", dim), + Span::styled("title", dim), + Span::raw(" "), + Span::styled("n ", dim), + Span::styled("new", dim), + Span::raw(" "), + Span::styled("a ", dim), + Span::styled("archive", dim), + Span::raw(" "), + Span::styled("m ", dim), + Span::styled("move", dim), + Span::raw(" "), Span::styled("Esc ", dim), Span::styled("quit", dim), Span::raw(" "), + Span::styled(view_hint, dim), + Span::raw(" "), Span::styled("(type to search)", dim), ]) } diff --git a/tests/grouping_test.rs b/tests/grouping_test.rs new file mode 100644 index 0000000..df06235 --- /dev/null +++ b/tests/grouping_test.rs @@ -0,0 +1,162 @@ +use std::path::PathBuf; + +use cc_session::discovery::discover_sessions; +use cc_session::filter::filter_sessions; +use cc_session::tui::{ + build_tree_rows, group_by_project, DisplayEntry, DisplaySource, MatchType, TreeRow, +}; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +fn make_display_entries(sessions: &[cc_session::session::Session]) -> Vec { + sessions + .iter() + .enumerate() + .map(|(idx, s)| DisplayEntry { + match_type: MatchType::Metadata, + source: DisplaySource::Sessions(idx), + timestamp: s.timestamp, + }) + .collect() +} + +#[test] +fn group_by_project_creates_correct_groups() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let groups = group_by_project(&sessions, &entries); + assert_eq!(groups.len(), 2, "should create 2 project groups"); + + let names: Vec<&str> = groups.iter().map(|g| g.name.as_str()).collect(); + assert!(names.contains(&"project-a")); + assert!(names.contains(&"project-b")); +} + +#[test] +fn group_by_project_correct_session_counts() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let groups = group_by_project(&sessions, &entries); + + let group_a = groups.iter().find(|g| g.name == "project-a").unwrap(); + let group_b = groups.iter().find(|g| g.name == "project-b").unwrap(); + + assert_eq!(group_a.session_indices.len(), 2, "project-a should have 2 sessions"); + assert_eq!(group_b.session_indices.len(), 1, "project-b should have 1 session"); +} + +#[test] +fn groups_sorted_by_latest_activity() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let groups = group_by_project(&sessions, &entries); + + // project-a has newer sessions (2025-02-20) than project-b (2025-02-18) + assert_eq!(groups[0].name, "project-a", "newest project should be first"); + assert_eq!(groups[1].name, "project-b", "older project should be second"); +} + +#[test] +fn groups_default_to_collapsed() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let groups = group_by_project(&sessions, &entries); + + for group in &groups { + assert!(!group.expanded, "groups should default to collapsed"); + } +} + +#[test] +fn build_tree_rows_default_collapsed() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let groups = group_by_project(&sessions, &entries); + let rows = build_tree_rows(&groups); + + // Default is collapsed: only 2 project headers + assert_eq!(rows.len(), 2, "default collapsed: only 2 project headers"); + assert!(matches!(rows[0], TreeRow::Project(0))); + assert!(matches!(rows[1], TreeRow::Project(1))); +} + +#[test] +fn build_tree_rows_all_expanded() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let mut groups = group_by_project(&sessions, &entries); + + groups[0].expanded = true; + groups[1].expanded = true; + let rows = build_tree_rows(&groups); + + // 2 project headers + 3 sessions = 5 rows + assert_eq!(rows.len(), 5, "all expanded: 2 headers + 3 sessions"); + + assert!(matches!(rows[0], TreeRow::Project(0))); + assert!(matches!(rows[1], TreeRow::Session { project_idx: 0, .. })); + assert!(matches!(rows[2], TreeRow::Session { project_idx: 0, .. })); + assert!(matches!(rows[3], TreeRow::Project(1))); + assert!(matches!(rows[4], TreeRow::Session { project_idx: 1, .. })); +} + +#[test] +fn build_tree_rows_one_expanded() { + let sessions = discover_sessions(&fixture_dir()); + let entries = make_display_entries(&sessions); + let mut groups = group_by_project(&sessions, &entries); + + // Expand only project-b (second group) + groups[1].expanded = true; + let rows = build_tree_rows(&groups); + + // 2 project headers + 1 session from project-b = 3 rows + assert_eq!(rows.len(), 3, "one expanded: 2 headers + 1 session"); + + assert!(matches!(rows[0], TreeRow::Project(0))); + assert!(matches!(rows[1], TreeRow::Project(1))); + assert!(matches!(rows[2], TreeRow::Session { project_idx: 1, .. })); +} + +#[test] +fn empty_sessions_no_groups() { + let groups = group_by_project(&[], &[]); + assert!(groups.is_empty()); + let rows = build_tree_rows(&groups); + assert!(rows.is_empty()); +} + +#[test] +fn filter_only_shows_matching_groups() { + let sessions = discover_sessions(&fixture_dir()); + let filtered = filter_sessions(&sessions, "project-b"); + + let entries: Vec = filtered + .iter() + .map(|&idx| DisplayEntry { + match_type: MatchType::Metadata, + source: DisplaySource::Sessions(idx), + timestamp: sessions[idx].timestamp, + }) + .collect(); + + let groups = group_by_project(&sessions, &entries); + assert_eq!(groups.len(), 1, "filter should leave only project-b"); + assert_eq!(groups[0].name, "project-b"); +} + +#[test] +fn tree_row_equality() { + assert_eq!(TreeRow::Project(0), TreeRow::Project(0)); + assert_ne!(TreeRow::Project(0), TreeRow::Project(1)); + assert_eq!( + TreeRow::Session { project_idx: 0, display_idx: 1 }, + TreeRow::Session { project_idx: 0, display_idx: 1 } + ); + assert_ne!( + TreeRow::Session { project_idx: 0, display_idx: 1 }, + TreeRow::Session { project_idx: 0, display_idx: 2 } + ); +} diff --git a/tests/tui_input_test.rs b/tests/tui_input_test.rs new file mode 100644 index 0000000..fcf5e31 --- /dev/null +++ b/tests/tui_input_test.rs @@ -0,0 +1,418 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + +use cc_session::discovery::discover_sessions; +use cc_session::theme::Theme; +use cc_session::tui::{input::handle_input, Action, App, Mode, TreeRow}; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures") +} + +fn make_app(grouped: bool) -> App { + let sessions = discover_sessions(&fixture_dir()); + let session_index = HashMap::new(); + let theme = Theme::dark(); + App::new(sessions, session_index, theme, grouped) +} + +fn make_app_expanded() -> App { + let mut app = make_app(true); + for i in 0..app.project_groups.len() { + if !app.project_groups[i].expanded { + app.toggle_project(i); + } + } + app.selected = 0; + app +} + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) +} + +#[test] +fn tab_toggles_grouped_view() { + let mut app = make_app(true); + assert!(app.grouped_view); + + let action = handle_input(&mut app, key(KeyCode::Tab)); + assert!(matches!(action, Action::Continue)); + assert!(!app.grouped_view, "Tab should switch to flat view"); + + let action = handle_input(&mut app, key(KeyCode::Tab)); + assert!(matches!(action, Action::Continue)); + assert!(app.grouped_view, "Tab should switch back to grouped view"); +} + +#[test] +fn enter_on_project_toggles_expand() { + let mut app = make_app(true); + assert!(matches!(app.tree_rows[0], TreeRow::Project(0))); + assert!(!app.project_groups[0].expanded, "should start collapsed"); + + app.selected = 0; + let action = handle_input(&mut app, key(KeyCode::Enter)); + assert!(matches!(action, Action::Continue)); + assert!(app.project_groups[0].expanded, "Enter should expand project"); + + let action = handle_input(&mut app, key(KeyCode::Enter)); + assert!(matches!(action, Action::Continue)); + assert!(!app.project_groups[0].expanded, "Enter should collapse project"); +} + +#[test] +fn enter_on_session_enters_conversation() { + let mut app = make_app_expanded(); + app.selected = 1; + assert!(matches!(app.tree_rows[1], TreeRow::Session { .. })); + + let action = handle_input(&mut app, key(KeyCode::Enter)); + match action { + Action::EnterConversation(idx) => { + assert!(idx < app.display_entries.len()); + } + _ => panic!("Expected EnterConversation action on session row"), + } +} + +#[test] +fn left_arrow_collapses_project() { + let mut app = make_app_expanded(); + app.selected = 0; + assert!(app.project_groups[0].expanded); + + handle_input(&mut app, key(KeyCode::Left)); + assert!(!app.project_groups[0].expanded, "Left should collapse expanded project"); +} + +#[test] +fn left_arrow_on_collapsed_project_is_noop() { + let mut app = make_app(true); + app.selected = 0; + assert!(!app.project_groups[0].expanded); + + handle_input(&mut app, key(KeyCode::Left)); + assert!(!app.project_groups[0].expanded, "Left on collapsed project should be noop"); +} + +#[test] +fn right_arrow_expands_project() { + let mut app = make_app(true); + app.selected = 0; + assert!(!app.project_groups[0].expanded); + + handle_input(&mut app, key(KeyCode::Right)); + assert!(app.project_groups[0].expanded, "Right should expand collapsed project"); +} + +#[test] +fn right_arrow_on_expanded_project_is_noop() { + let mut app = make_app_expanded(); + app.selected = 0; + assert!(app.project_groups[0].expanded); + + handle_input(&mut app, key(KeyCode::Right)); + assert!(app.project_groups[0].expanded, "Right on expanded project should be noop"); +} + +#[test] +fn left_arrow_on_session_jumps_to_parent_project() { + let mut app = make_app_expanded(); + app.selected = 2; + assert!(matches!(app.tree_rows[2], TreeRow::Session { project_idx: 0, .. })); + + handle_input(&mut app, key(KeyCode::Left)); + assert_eq!(app.selected, 0, "Left on session should jump to parent project"); + assert!(matches!(app.tree_rows[0], TreeRow::Project(0))); +} + +#[test] +fn up_down_navigates_tree_rows() { + let mut app = make_app(true); + let total = app.tree_rows.len(); + assert!(total >= 2); + + app.selected = 0; + handle_input(&mut app, key(KeyCode::Down)); + assert_eq!(app.selected, 1); + + handle_input(&mut app, key(KeyCode::Up)); + assert_eq!(app.selected, 0); + + // Up at top stays at 0 + handle_input(&mut app, key(KeyCode::Up)); + assert_eq!(app.selected, 0); +} + +#[test] +fn end_key_goes_to_last_row_in_grouped_view() { + let mut app = make_app(true); + let total = app.tree_rows.len(); + + handle_input(&mut app, key(KeyCode::End)); + assert_eq!(app.selected, total - 1); +} + +#[test] +fn flat_view_enter_enters_conversation() { + let mut app = make_app(false); + app.selected = 0; + + let action = handle_input(&mut app, key(KeyCode::Enter)); + match action { + Action::EnterConversation(idx) => { + assert_eq!(idx, 0); + } + _ => panic!("Expected EnterConversation in flat view"), + } +} + +#[test] +fn visible_row_count_reflects_view_mode() { + let mut app = make_app(true); + let grouped_count = app.visible_row_count(); + assert_eq!(grouped_count, app.tree_rows.len()); + + app.toggle_view(); + let flat_count = app.visible_row_count(); + assert_eq!(flat_count, app.display_entries.len()); +} + +// ---- Archive tests ---- + +#[test] +fn a_key_sets_archive_confirm() { + let mut app = make_app(false); + app.selected = 0; + assert!(app.archive_confirm.is_none()); + + let action = handle_input(&mut app, key(KeyCode::Char('a'))); + assert!(matches!(action, Action::Continue)); + assert_eq!(app.archive_confirm, Some(0)); +} + +#[test] +fn a_key_in_grouped_view_on_session_sets_confirm() { + let mut app = make_app_expanded(); + app.selected = 1; + assert!(matches!(app.tree_rows[1], TreeRow::Session { .. })); + + let action = handle_input(&mut app, key(KeyCode::Char('a'))); + assert!(matches!(action, Action::Continue)); + assert!(app.archive_confirm.is_some()); +} + +#[test] +fn a_key_in_grouped_view_on_project_is_noop() { + let mut app = make_app(true); + app.selected = 0; + assert!(matches!(app.tree_rows[0], TreeRow::Project(_))); + + let action = handle_input(&mut app, key(KeyCode::Char('a'))); + assert!(matches!(action, Action::Continue)); + assert!(app.archive_confirm.is_none()); +} + +#[test] +fn archive_confirm_y_triggers_archive() { + let mut app = make_app(false); + app.archive_confirm = Some(0); + app.mode = Mode::ConfirmArchive; + + let action = handle_input(&mut app, key(KeyCode::Char('y'))); + assert!(matches!(action, Action::ArchiveSession(0))); + assert!(app.archive_confirm.is_none()); + assert!(matches!(app.mode, Mode::Browsing)); +} + +#[test] +fn archive_confirm_esc_cancels() { + let mut app = make_app(false); + app.archive_confirm = Some(0); + app.mode = Mode::ConfirmArchive; + + let action = handle_input(&mut app, key(KeyCode::Esc)); + assert!(matches!(action, Action::Continue)); + assert!(app.archive_confirm.is_none()); + assert!(matches!(app.mode, Mode::Browsing)); +} + +#[test] +fn archive_confirm_other_key_cancels() { + let mut app = make_app(false); + app.archive_confirm = Some(0); + app.mode = Mode::ConfirmArchive; + + let action = handle_input(&mut app, key(KeyCode::Char('n'))); + assert!(matches!(action, Action::Continue)); + assert!(app.archive_confirm.is_none()); + assert!(matches!(app.mode, Mode::Browsing)); +} + +#[test] +fn a_key_ignored_when_filter_active() { + let mut app = make_app(false); + app.filter_active = true; + app.selected = 0; + + let action = handle_input(&mut app, key(KeyCode::Char('a'))); + assert!(matches!(action, Action::Continue)); + assert!(app.archive_confirm.is_none(), "should not trigger archive when filtering"); +} + +#[test] +fn archive_session_moves_file() { + let tmp = tempfile::tempdir().unwrap(); + let project_dir = tmp.path().join("projects").join("-Users-test-myproject"); + fs::create_dir_all(&project_dir).unwrap(); + + let session_id = "abc123"; + let session_file = project_dir.join(format!("{session_id}.jsonl")); + fs::write(&session_file, r#"{"type":"user","cwd":"/Users/test/myproject","sessionId":"abc123","message":{"role":"user","content":"hello"},"uuid":"u1","timestamp":"2025-01-01T00:00:00.000Z"} +{"type":"assistant","cwd":"/Users/test/myproject","sessionId":"abc123","message":{"role":"assistant","content":[{"type":"text","text":"hi"}]},"uuid":"u2","timestamp":"2025-01-01T00:01:00.000Z"} +"#).unwrap(); + + std::env::set_var("CLAUDE_HOME", tmp.path().to_str().unwrap()); + + let sessions = discover_sessions(tmp.path()); + assert!(!sessions.is_empty(), "should discover the test session"); + + let session_index = HashMap::new(); + let theme = Theme::dark(); + let mut app = App::new(sessions, session_index, theme, false); + assert!(!app.display_entries.is_empty()); + + let result = app.archive_session(0); + assert!(result.is_ok(), "archive should succeed: {:?}", result); + + assert!(!session_file.exists(), "original file should be moved"); + + let archive_file = tmp.path() + .join("projects-archive") + .join("-Users-test-myproject") + .join(format!("{session_id}.jsonl")); + assert!(archive_file.exists(), "file should exist in archive"); + + std::env::remove_var("CLAUDE_HOME"); +} + +#[test] +fn m_key_on_session_starts_move() { + let mut app = make_app(false); + app.selected = 0; + + let action = handle_input(&mut app, key(KeyCode::Char('m'))); + assert!(matches!(action, Action::Continue)); + assert!(matches!(app.mode, Mode::MoveSelectProject)); + assert!(app.move_state.is_some()); +} + +#[test] +fn m_key_ignored_when_filter_active() { + let mut app = make_app(false); + app.filter_active = true; + app.selected = 0; + + let action = handle_input(&mut app, key(KeyCode::Char('m'))); + assert!(matches!(action, Action::Continue)); + assert!(app.move_state.is_none(), "should not start move when filtering"); +} + +#[test] +fn m_key_in_grouped_view_on_project_is_noop() { + let mut app = make_app(true); + app.selected = 0; + assert!(matches!(app.tree_rows[0], TreeRow::Project(_))); + + let action = handle_input(&mut app, key(KeyCode::Char('m'))); + assert!(matches!(action, Action::Continue)); + assert!(app.move_state.is_none()); +} + +#[test] +fn move_picker_esc_cancels() { + let mut app = make_app(false); + app.selected = 0; + handle_input(&mut app, key(KeyCode::Char('m'))); + assert!(matches!(app.mode, Mode::MoveSelectProject)); + + let action = handle_input(&mut app, key(KeyCode::Esc)); + assert!(matches!(action, Action::Continue)); + assert!(app.move_state.is_none()); + assert!(matches!(app.mode, Mode::Browsing)); +} + +#[test] +fn move_picker_navigation() { + let mut app = make_app(false); + app.selected = 0; + handle_input(&mut app, key(KeyCode::Char('m'))); + + let project_count = app.move_state.as_ref().unwrap().projects.len(); + if project_count > 1 { + handle_input(&mut app, key(KeyCode::Down)); + assert_eq!(app.move_state.as_ref().unwrap().selected, 1); + + handle_input(&mut app, key(KeyCode::Up)); + assert_eq!(app.move_state.as_ref().unwrap().selected, 0); + } +} + +#[test] +fn move_picker_enter_triggers_move() { + let mut app = make_app(false); + app.selected = 0; + handle_input(&mut app, key(KeyCode::Char('m'))); + + if app.move_state.is_some() { + let action = handle_input(&mut app, key(KeyCode::Enter)); + assert!(matches!(action, Action::MoveSession { .. })); + assert!(matches!(app.mode, Mode::Browsing)); + } +} + +#[test] +fn move_session_moves_file() { + let tmp = tempfile::tempdir().unwrap(); + let src_dir = tmp.path().join("projects").join("-Users-test-src-project"); + let dst_dir_name = "-Users-test-dst-project"; + fs::create_dir_all(&src_dir).unwrap(); + fs::create_dir_all(tmp.path().join("projects").join(dst_dir_name)).unwrap(); + + let session_id = "move-test-123"; + let session_file = src_dir.join(format!("{session_id}.jsonl")); + fs::write(&session_file, r#"{"type":"user","cwd":"/Users/test/src-project","sessionId":"move-test-123","message":{"role":"user","content":"test move"},"uuid":"u1","timestamp":"2025-01-01T00:00:00.000Z"} +{"type":"assistant","cwd":"/Users/test/src-project","sessionId":"move-test-123","message":{"role":"assistant","content":[{"type":"text","text":"ok"}]},"uuid":"u2","timestamp":"2025-01-01T00:01:00.000Z"} +"#).unwrap(); + + std::env::set_var("CLAUDE_HOME", tmp.path().to_str().unwrap()); + + let sessions = discover_sessions(tmp.path()); + assert!(!sessions.is_empty(), "should discover the test session"); + + let session_index = HashMap::new(); + let theme = Theme::dark(); + let mut app = App::new(sessions, session_index, theme, false); + + let target_cwd = "/Users/test/dst-project"; + let result = app.move_session(0, dst_dir_name, target_cwd); + assert!(result.is_ok(), "move should succeed: {:?}", result); + + assert!(!session_file.exists(), "original file should be gone"); + + let moved_file = tmp.path() + .join("projects") + .join(dst_dir_name) + .join(format!("{session_id}.jsonl")); + assert!(moved_file.exists(), "file should exist in target project"); + + let content = fs::read_to_string(&moved_file).unwrap(); + assert!(content.contains(target_cwd), "cwd should be updated to target"); + assert!(!content.contains("/Users/test/src-project"), "old cwd should be replaced"); + + std::env::remove_var("CLAUDE_HOME"); +}