diff --git a/bun.lock b/bun.lock index 0152c93a4..ba0c14740 100644 --- a/bun.lock +++ b/bun.lock @@ -58,6 +58,10 @@ "typescript": "~5.6.2", "vite": "^6.0.3", }, + "optionalDependencies": { + "@esbuild/linux-x64": "^0.25.6", + "@rollup/rollup-linux-x64-gnu": "^4.45.1", + }, }, }, "trustedDependencies": [ @@ -139,7 +143,7 @@ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.5", "", { "os": "none", "cpu": "arm64" }, "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw=="], @@ -357,7 +361,7 @@ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.43.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ=="], @@ -1061,6 +1065,8 @@ "decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="], + "esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.5", "", { "os": "linux", "cpu": "x64" }, "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw=="], + "hast-util-from-parse5/hastscript": ["hastscript@9.0.1", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w=="], "hast-util-to-parse5/property-information": ["property-information@6.5.0", "", {}, "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig=="], @@ -1087,6 +1093,8 @@ "rehype-prism-plus/refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="], + "rollup/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.43.0", "", { "os": "linux", "cpu": "x64" }, "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ=="], + "stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], "@uiw/react-markdown-preview/rehype-prism-plus/refractor": ["refractor@4.9.0", "", { "dependencies": { "@types/hast": "^2.0.0", "@types/prismjs": "^1.0.0", "hastscript": "^7.0.0", "parse-entities": "^4.0.0" } }, "sha512-nEG1SPXFoGGx+dcjftjv8cAjEusIh6ED1xhf5DG3C0x/k+rmZ2duKnc3QLpt6qeHv5fPb8uwN3VWN2BT7fr3Og=="], diff --git a/src-tauri/src/commands/claude.rs b/src-tauri/src/commands/claude.rs index 529eb1a2a..e1a3c14d8 100644 --- a/src-tauri/src/commands/claude.rs +++ b/src-tauri/src/commands/claude.rs @@ -57,6 +57,166 @@ pub struct Session { pub message_timestamp: Option, } +/// Represents a session search result with matching snippets +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SessionSearchResult { + #[serde(flatten)] + pub session: Session, + /// Matching text snippets from the session + pub snippets: Vec, + /// Positive search terms for frontend highlighting + pub highlight_terms: Vec, +} + +/// Parsed search query with AND, OR, NOT operators +#[derive(Debug, Clone)] +struct ParsedQuery { + /// Terms that must ALL be present (AND) + and_terms: Vec, + /// Groups where at least one term must match + or_groups: Vec>, + /// Terms that must NOT be present + not_terms: Vec, + /// All positive terms for snippet extraction and highlighting + highlight_terms: Vec, +} + +/// Parses a search query supporting AND (default), OR, NOT (-), and "exact phrase". +/// +/// Examples: +/// `alpha beta` → AND: [alpha, beta] +/// `alpha OR beta` → OR: [[alpha, beta]] +/// `-test` → NOT: [test] +/// `"exact phrase"` → AND: ["exact phrase"] +/// `"fix bug" refactor OR cleanup -test` → AND: [fix bug], OR: [[refactor, cleanup]], NOT: [test] +fn parse_query(raw: &str) -> ParsedQuery { + // Step 1: Tokenize respecting quotes + let mut tokens: Vec<(String, bool)> = Vec::new(); // (term, is_negated) + let mut chars = raw.chars().peekable(); + let mut current = String::new(); + let mut in_quotes = false; + let mut is_negated = false; + + while let Some(&ch) = chars.peek() { + if in_quotes { + chars.next(); + if ch == '"' { + in_quotes = false; + if !current.is_empty() { + tokens.push((current.clone(), is_negated)); + current.clear(); + is_negated = false; + } + } else { + current.push(ch); + } + } else if ch == '"' { + chars.next(); + if !current.is_empty() { + tokens.push((current.clone(), is_negated)); + current.clear(); + is_negated = false; + } + in_quotes = true; + } else if ch.is_whitespace() { + chars.next(); + if !current.is_empty() { + tokens.push((current.clone(), is_negated)); + current.clear(); + is_negated = false; + } + } else if ch == '-' && current.is_empty() { + chars.next(); + is_negated = true; + } else { + chars.next(); + current.push(ch); + } + } + // Handle unclosed quotes or trailing token + if !current.is_empty() { + tokens.push((current, is_negated)); + } + + // Step 2: Build ParsedQuery by processing OR groups + let mut and_terms: Vec = Vec::new(); + let mut or_groups: Vec> = Vec::new(); + let mut not_terms: Vec = Vec::new(); + let mut pending_or_group: Vec = Vec::new(); + + let mut i = 0; + while i < tokens.len() { + let (ref term, negated) = tokens[i]; + let term_lower = term.to_lowercase(); + + if negated { + not_terms.push(term_lower); + i += 1; + continue; + } + + // Skip explicit AND operator (it's the default behavior) + if term == "AND" && !negated { + i += 1; + continue; + } + + // Check if next token is OR + let next_is_or = i + 1 < tokens.len() && tokens[i + 1].0 == "OR" && !tokens[i + 1].1; + + if term == "OR" && !negated { + // OR without left context — treat as literal + if pending_or_group.is_empty() { + and_terms.push(term_lower); + } + // If pending_or_group is non-empty, this OR was already handled + i += 1; + continue; + } + + if !pending_or_group.is_empty() { + // We're continuing an OR chain + pending_or_group.push(term_lower); + if !next_is_or { + // End of OR chain + or_groups.push(pending_or_group.clone()); + pending_or_group.clear(); + } else { + i += 2; // skip term + OR + continue; + } + } else if next_is_or { + // Start a new OR chain + pending_or_group.push(term_lower); + i += 2; // skip term + OR + continue; + } else { + and_terms.push(term_lower); + } + + i += 1; + } + + // Flush any remaining OR group + if !pending_or_group.is_empty() { + or_groups.push(pending_or_group); + } + + // Build highlight_terms from all positive terms + let mut highlight_terms: Vec = Vec::new(); + highlight_terms.extend(and_terms.iter().cloned()); + for group in &or_groups { + highlight_terms.extend(group.iter().cloned()); + } + + ParsedQuery { + and_terms, + or_groups, + not_terms, + highlight_terms, + } +} + /// Represents a message entry in the JSONL file #[derive(Debug, Deserialize)] struct JsonlEntry { @@ -555,6 +715,435 @@ pub async fn get_project_sessions(project_id: String) -> Result, St Ok(sessions) } +/// Checks if a session matches the parsed query (AND, OR, NOT logic). +/// Reads all message content and checks term presence. +fn session_matches(jsonl_path: &PathBuf, query: &ParsedQuery) -> bool { + let file = match fs::File::open(jsonl_path) { + Ok(file) => file, + Err(_) => return false, + }; + + let reader = BufReader::new(file); + let mut all_content = String::new(); + + for line in reader.lines() { + if let Ok(line) = line { + if let Ok(entry) = serde_json::from_str::(&line) { + if let Some(message) = entry.message { + if let Some(content) = message.content { + all_content.push(' '); + all_content.push_str(&content); + } + } + } + } + } + + let content_lower = all_content.to_lowercase(); + + // Check NOT terms first (early exit) + for term in &query.not_terms { + if content_lower.contains(term.as_str()) { + return false; + } + } + + // Check AND terms — all must be present + for term in &query.and_terms { + if !content_lower.contains(term.as_str()) { + return false; + } + } + + // Check OR groups — at least one term per group + for group in &query.or_groups { + if !group + .iter() + .any(|term| content_lower.contains(term.as_str())) + { + return false; + } + } + + true +} + +/// Extracts matching text snippets for multiple search terms. +/// Returns up to MAX_SNIPPETS snippets with context around each match. +fn extract_snippets_for_terms(jsonl_path: &PathBuf, terms: &[String]) -> Vec { + const MAX_SNIPPETS: usize = 20; + const CONTEXT_CHARS: usize = 100; + + let file = match fs::File::open(jsonl_path) { + Ok(file) => file, + Err(_) => return Vec::new(), + }; + + if terms.is_empty() { + return Vec::new(); + } + + let reader = BufReader::new(file); + let mut snippets = Vec::new(); + + // Pre-compute query chars for each term + let term_chars: Vec> = terms.iter().map(|t| t.chars().collect()).collect(); + + for line in reader.lines() { + if snippets.len() >= MAX_SNIPPETS { + break; + } + if let Ok(line) = line { + if let Ok(entry) = serde_json::from_str::(&line) { + if let Some(message) = entry.message { + if let Some(content) = message.content { + let chars_orig: Vec<(usize, char)> = content.char_indices().collect(); + + // Search for each term in this message + for query_chars in &term_chars { + if snippets.len() >= MAX_SNIPPETS { + break; + } + let mut ci = 0; + while ci + query_chars.len() <= chars_orig.len() { + if snippets.len() >= MAX_SNIPPETS { + break; + } + let matched = chars_orig[ci..ci + query_chars.len()] + .iter() + .zip(query_chars) + .all(|((_, c), q)| c.to_lowercase().eq(q.to_lowercase())); + if matched { + let ctx_start_ci = ci.saturating_sub(CONTEXT_CHARS); + let ctx_end_ci = (ci + query_chars.len() + CONTEXT_CHARS) + .min(chars_orig.len()); + let start = chars_orig[ctx_start_ci].0; + let end = if ctx_end_ci < chars_orig.len() { + chars_orig[ctx_end_ci].0 + } else { + content.len() + }; + let raw = &content[start..end]; + let mut snippet = + raw.split_whitespace().collect::>().join(" "); + if start > 0 { + snippet = format!("...{}", snippet); + } + if end < content.len() { + snippet = format!("{}...", snippet); + } + if !snippet.is_empty() { + snippets.push(snippet); + } + ci += query_chars.len(); + } else { + ci += 1; + } + } + } + } + } + } + } + } + + snippets +} + +/// Searches through all session JSONL files in a project for messages containing the query +#[tauri::command] +pub async fn search_project_sessions( + project_id: String, + query: String, +) -> Result, String> { + let query = query.trim().to_string(); + if query.is_empty() || query.len() < 2 { + return Ok(Vec::new()); + } + if query.len() > 256 { + return Err("Query is too long".to_string()); + } + + log::info!( + "Searching sessions for project: {} (query length: {})", + project_id, + query.len() + ); + + if project_id.is_empty() { + return Err("Invalid project id".to_string()); + } + + let parsed = parse_query(&query); + if parsed.and_terms.is_empty() && parsed.or_groups.is_empty() && parsed.not_terms.is_empty() { + return Ok(Vec::new()); + } + + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; + let projects_dir = claude_dir.join("projects"); + let project_dir = projects_dir.join(&project_id); + + // Verify resolved path stays under the projects root + let canonical_projects_dir = projects_dir.canonicalize().map_err(|e| e.to_string())?; + let canonical_project_dir = project_dir + .canonicalize() + .map_err(|_| format!("Project directory not found: {}", project_id))?; + if !canonical_project_dir.starts_with(&canonical_projects_dir) { + return Err("Invalid project id".to_string()); + } + // Use canonical path for all subsequent operations to prevent TOCTOU + let project_dir = canonical_project_dir; + let todos_dir = claude_dir.join("todos"); + + tokio::task::spawn_blocking(move || { + const MAX_RESULTS: usize = 100; + + let project_path = match get_project_path_from_sessions(&project_dir) { + Ok(path) => path, + Err(_) => decode_project_path(&project_id), + }; + + let mut results: Vec = Vec::new(); + + let entries = fs::read_dir(&project_dir) + .map_err(|e| format!("Failed to read project directory: {}", e))?; + + for entry in entries { + let entry = match entry { + Ok(e) => e, + Err(e) => { + log::warn!("Skipping unreadable directory entry: {}", e); + continue; + } + }; + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(e) => { + log::warn!( + "Skipping entry with unreadable type {:?}: {}", + entry.path(), + e + ); + continue; + } + }; + if file_type.is_symlink() || !file_type.is_file() { + continue; + } + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("jsonl") { + if let Some(session_id) = path.file_stem().and_then(|s| s.to_str()) { + if !session_matches(&path, &parsed) { + continue; + } + let snippets = extract_snippets_for_terms(&path, &parsed.highlight_terms); + + let metadata = match fs::metadata(&path) { + Ok(m) => m, + Err(e) => { + log::warn!("Skipping unreadable session file {:?}: {}", path, e); + continue; + } + }; + + let created_at = metadata + .created() + .or_else(|_| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let (first_message, message_timestamp) = extract_first_user_message(&path); + + let todo_path = todos_dir.join(format!("{}.json", session_id)); + let todo_data = if todo_path.exists() { + fs::read_to_string(&todo_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + } else { + None + }; + + results.push(SessionSearchResult { + session: Session { + id: session_id.to_string(), + project_id: project_id.clone(), + project_path: project_path.clone(), + todo_data, + created_at, + first_message, + message_timestamp, + }, + snippets, + highlight_terms: parsed.highlight_terms.clone(), + }); + } + } + } + + results.sort_by(|a, b| b.session.created_at.cmp(&a.session.created_at)); + results.truncate(MAX_RESULTS); + + log::info!( + "Found {} matching sessions for project {}", + results.len(), + project_id + ); + Ok(results) + }) + .await + .map_err(|e| format!("Search task failed: {}", e))? +} + +/// Searches through all projects' session JSONL files for messages containing the query +#[tauri::command] +pub async fn search_all_sessions(query: String) -> Result, String> { + let query = query.trim().to_string(); + if query.is_empty() || query.len() < 2 { + return Ok(Vec::new()); + } + if query.len() > 256 { + return Err("Query is too long".to_string()); + } + + log::info!( + "Searching all sessions across all projects (query length: {})", + query.len() + ); + + let parsed = parse_query(&query); + if parsed.and_terms.is_empty() && parsed.or_groups.is_empty() && parsed.not_terms.is_empty() { + return Ok(Vec::new()); + } + + let claude_dir = get_claude_dir().map_err(|e| e.to_string())?; + let projects_dir = claude_dir.join("projects"); + + if !projects_dir.exists() { + return Ok(Vec::new()); + } + + let todos_dir = claude_dir.join("todos"); + + tokio::task::spawn_blocking(move || { + const MAX_RESULTS: usize = 100; + + let mut results: Vec = Vec::new(); + + let project_entries = fs::read_dir(&projects_dir) + .map_err(|e| format!("Failed to read projects directory: {}", e))?; + + for project_entry in project_entries { + if results.len() >= MAX_RESULTS { + break; + } + + let project_entry = match project_entry { + Ok(e) => e, + Err(_) => continue, + }; + let project_dir = project_entry.path(); + if !project_dir.is_dir() { + continue; + } + + let project_id = match project_dir.file_name().and_then(|n| n.to_str()) { + Some(name) => name.to_string(), + None => continue, + }; + + let project_path = match get_project_path_from_sessions(&project_dir) { + Ok(path) => path, + Err(_) => decode_project_path(&project_id), + }; + + let session_entries = match fs::read_dir(&project_dir) { + Ok(entries) => entries, + Err(_) => continue, + }; + + for entry in session_entries { + if results.len() >= MAX_RESULTS { + break; + } + + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + let file_type = match entry.file_type() { + Ok(ft) => ft, + Err(_) => continue, + }; + if file_type.is_symlink() || !file_type.is_file() { + continue; + } + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("jsonl") { + if let Some(session_id) = path.file_stem().and_then(|s| s.to_str()) { + if !session_matches(&path, &parsed) { + continue; + } + let snippets = extract_snippets_for_terms(&path, &parsed.highlight_terms); + + let metadata = match fs::metadata(&path) { + Ok(m) => m, + Err(_) => continue, + }; + + let created_at = metadata + .created() + .or_else(|_| metadata.modified()) + .unwrap_or(SystemTime::UNIX_EPOCH) + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let (first_message, message_timestamp) = extract_first_user_message(&path); + + let todo_path = todos_dir.join(format!("{}.json", session_id)); + let todo_data = if todo_path.exists() { + fs::read_to_string(&todo_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + } else { + None + }; + + results.push(SessionSearchResult { + session: Session { + id: session_id.to_string(), + project_id: project_id.clone(), + project_path: project_path.clone(), + todo_data, + created_at, + first_message, + message_timestamp, + }, + snippets, + highlight_terms: parsed.highlight_terms.clone(), + }); + } + } + } + } + + results.sort_by(|a, b| b.session.created_at.cmp(&a.session.created_at)); + results.truncate(MAX_RESULTS); + + log::info!( + "Found {} matching sessions across all projects", + results.len() + ); + Ok(results) + }) + .await + .map_err(|e| format!("Search task failed: {}", e))? +} + /// Reads the Claude settings file #[tauri::command] pub async fn get_claude_settings() -> Result { @@ -2206,6 +2795,91 @@ mod tests { Ok(()) } + #[test] + fn test_parse_query_single_term() { + let q = parse_query("hello"); + assert_eq!(q.and_terms, vec!["hello"]); + assert!(q.or_groups.is_empty()); + assert!(q.not_terms.is_empty()); + assert_eq!(q.highlight_terms, vec!["hello"]); + } + + #[test] + fn test_parse_query_and_terms() { + let q = parse_query("alpha beta gamma"); + assert_eq!(q.and_terms, vec!["alpha", "beta", "gamma"]); + assert!(q.or_groups.is_empty()); + assert!(q.not_terms.is_empty()); + } + + #[test] + fn test_parse_query_or_group() { + let q = parse_query("alpha OR beta"); + assert!(q.and_terms.is_empty()); + assert_eq!(q.or_groups, vec![vec!["alpha", "beta"]]); + assert!(q.not_terms.is_empty()); + assert_eq!(q.highlight_terms, vec!["alpha", "beta"]); + } + + #[test] + fn test_parse_query_chained_or() { + let q = parse_query("alpha OR beta OR gamma"); + assert!(q.and_terms.is_empty()); + assert_eq!(q.or_groups, vec![vec!["alpha", "beta", "gamma"]]); + } + + #[test] + fn test_parse_query_not_term() { + let q = parse_query("alpha -beta"); + assert_eq!(q.and_terms, vec!["alpha"]); + assert_eq!(q.not_terms, vec!["beta"]); + assert_eq!(q.highlight_terms, vec!["alpha"]); + } + + #[test] + fn test_parse_query_quoted_phrase() { + let q = parse_query("\"hello world\" test"); + assert_eq!(q.and_terms, vec!["hello world", "test"]); + assert!(q.or_groups.is_empty()); + } + + #[test] + fn test_parse_query_mixed() { + let q = parse_query("\"fix bug\" refactor OR cleanup -test"); + assert_eq!(q.and_terms, vec!["fix bug"]); + assert_eq!(q.or_groups, vec![vec!["refactor", "cleanup"]]); + assert_eq!(q.not_terms, vec!["test"]); + assert_eq!(q.highlight_terms, vec!["fix bug", "refactor", "cleanup"]); + } + + #[test] + fn test_parse_query_standalone_or() { + let q = parse_query("OR"); + assert_eq!(q.and_terms, vec!["or"]); + assert!(q.or_groups.is_empty()); + } + + #[test] + fn test_parse_query_negated_phrase() { + let q = parse_query("-\"bad phrase\""); + assert!(q.and_terms.is_empty()); + assert_eq!(q.not_terms, vec!["bad phrase"]); + } + + #[test] + fn test_parse_query_explicit_and() { + let q = parse_query("Altera AND outages"); + assert_eq!(q.and_terms, vec!["altera", "outages"]); + assert!(q.or_groups.is_empty()); + assert_eq!(q.highlight_terms, vec!["altera", "outages"]); + } + + #[test] + fn test_parse_query_case_preservation() { + let q = parse_query("Hello WORLD"); + assert_eq!(q.and_terms, vec!["hello", "world"]); + } + #[test] fn test_get_project_path_from_sessions_normal_case() { let temp_dir = TempDir::new().unwrap(); diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index fc93adbcf..f11d676f3 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -26,9 +26,9 @@ use commands::claude::{ get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints, list_directory_contents, list_projects, list_running_claude_sessions, load_session_history, open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code, - save_claude_md_file, save_claude_settings, save_system_prompt, search_files, - track_checkpoint_message, track_session_messages, update_checkpoint_settings, - update_hooks_config, validate_hook_command, ClaudeProcessState, + save_claude_md_file, save_claude_settings, save_system_prompt, search_all_sessions, + search_files, search_project_sessions, track_checkpoint_message, track_session_messages, + update_checkpoint_settings, update_hooks_config, validate_hook_command, ClaudeProcessState, }; use commands::mcp::{ mcp_add, mcp_add_from_claude_desktop, mcp_add_json, mcp_get, mcp_get_server_status, mcp_list, @@ -188,6 +188,8 @@ fn main() { list_projects, create_project, get_project_sessions, + search_project_sessions, + search_all_sessions, get_home_directory, get_claude_settings, open_new_session, diff --git a/src-tauri/src/web_server.rs b/src-tauri/src/web_server.rs index 01a28436f..b3270c547 100644 --- a/src-tauri/src/web_server.rs +++ b/src-tauri/src/web_server.rs @@ -1,7 +1,7 @@ use axum::extract::ws::{Message, WebSocket}; use axum::http::Method; use axum::{ - extract::{Path, State as AxumState, WebSocketUpgrade}, + extract::{Path, Query, State as AxumState, WebSocketUpgrade}, response::{Html, Json, Response}, routing::get, Router, @@ -129,6 +129,41 @@ async fn get_sessions( } } +#[derive(Deserialize)] +struct SearchQuery { + #[serde(default)] + query: String, +} + +/// API endpoint to search sessions for a project (local-only web server) +async fn search_sessions( + Path(project_id): Path, + Query(params): Query, +) -> Json>> { + let query = params.query.trim().to_string(); + if query.is_empty() { + return Json(ApiResponse::success(Vec::new())); + } + match commands::claude::search_project_sessions(project_id, query).await { + Ok(sessions) => Json(ApiResponse::success(sessions)), + Err(e) => Json(ApiResponse::error(e.to_string())), + } +} + +/// API endpoint to search sessions across all projects +async fn search_all_sessions_handler( + Query(params): Query, +) -> Json>> { + let query = params.query.trim().to_string(); + if query.is_empty() { + return Json(ApiResponse::success(Vec::new())); + } + match commands::claude::search_all_sessions(query).await { + Ok(sessions) => Json(ApiResponse::success(sessions)), + Err(e) => Json(ApiResponse::error(e.to_string())), + } +} + /// Simple agents endpoint - return empty for now (needs DB state) async fn get_agents() -> Json>> { Json(ApiResponse::success(vec![])) @@ -786,6 +821,14 @@ pub async fn create_web_server(port: u16) -> Result<(), Box diff --git a/src/components/HighlightedText.tsx b/src/components/HighlightedText.tsx new file mode 100644 index 000000000..39dc7c27c --- /dev/null +++ b/src/components/HighlightedText.tsx @@ -0,0 +1,32 @@ +import React from "react"; + +/** Highlights all occurrences of `terms` in `text` (case-insensitive, Unicode-safe) */ +export function HighlightedText({ text, terms }: { text: string; terms: string[] }) { + if (!terms.length) return <>{text}; + const escaped = terms + .filter(t => t.trim()) + .map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + if (!escaped.length) return <>{text}; + const re = new RegExp(escaped.join('|'), 'gi'); + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + + while ((match = re.exec(text)) !== null) { + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + parts.push( + + {match[0]} + + ); + lastIndex = match.index + match[0].length; + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return <>{parts}; +} diff --git a/src/components/ProjectList.tsx b/src/components/ProjectList.tsx index 8d8ab7995..260f3be39 100644 --- a/src/components/ProjectList.tsx +++ b/src/components/ProjectList.tsx @@ -1,14 +1,27 @@ -import React, { useState } from "react"; -import { motion } from "framer-motion"; -import { +import React, { useState, useEffect, useCallback, useRef, useMemo } from "react"; +import { motion, AnimatePresence } from "framer-motion"; +import { FolderOpen, ChevronLeft, - ChevronRight + ChevronRight, + Search, + Loader2, + X, + ChevronDown, + ExternalLink, + Copy, + Check, + Clock, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import type { Project } from "@/lib/api"; +import { Input } from "@/components/ui/input"; +import { HighlightedText } from "@/components/HighlightedText"; +import type { Project, SessionSearchResult } from "@/lib/api"; +import { api } from "@/lib/api"; import { cn } from "@/lib/utils"; +import { truncateText, getFirstLine } from "@/lib/date-utils"; +import { useDebounce } from "@/hooks/useDebounce"; interface ProjectListProps { /** @@ -52,7 +65,7 @@ const getDisplayPath = (path: string, maxLength: number = 30): string => { for (const indicator of homeIndicators) { if (path.includes(indicator)) { const parts = path.split('/'); - const userIndex = parts.findIndex((_part, i) => + const userIndex = parts.findIndex((_part, i) => i > 0 && parts[i - 1] === indicator.split('/')[1] ); if (userIndex > 0) { @@ -62,27 +75,28 @@ const getDisplayPath = (path: string, maxLength: number = 30): string => { } } } - + // Truncate if too long if (displayPath.length > maxLength) { const start = displayPath.substring(0, Math.floor(maxLength / 2) - 2); const end = displayPath.substring(displayPath.length - Math.floor(maxLength / 2) + 2); return `${start}...${end}`; } - + return displayPath; }; -/** - * ProjectList component - Displays recent projects in a Cursor-like interface - * - * @example - * console.log('Selected:', project)} - * onOpenProject={() => console.log('Open project')} - * /> - */ +function formatResultDate(result: SessionSearchResult) { + const date = result.message_timestamp + ? new Date(result.message_timestamp) + : new Date(result.created_at * 1000); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + export const ProjectList: React.FC = ({ projects, onProjectClick, @@ -91,26 +105,153 @@ export const ProjectList: React.FC = ({ }) => { const [showAll, setShowAll] = useState(false); const [currentPage, setCurrentPage] = useState(1); - + const [searchQuery, setSearchQuery] = useState(""); + const [globalResults, setGlobalResults] = useState(null); + const [searching, setSearching] = useState(false); + const [searchError, setSearchError] = useState(null); + const [expandedSessions, setExpandedSessions] = useState>(new Set()); + const [copiedId, setCopiedId] = useState(null); + const debouncedQuery = useDebounce(searchQuery, 300); + const normalizedQuery = debouncedQuery.trim(); + const searchRequestId = useRef(0); + + // Client-side project name filtering + const filteredProjects = useMemo(() => { + const q = searchQuery.trim().toLowerCase(); + if (!q) return projects; + return projects.filter(p => { + const name = getProjectName(p.path).toLowerCase(); + const path = p.path.toLowerCase(); + return name.includes(q) || path.includes(q); + }); + }, [projects, searchQuery]); + + // Global session search + useEffect(() => { + searchRequestId.current += 1; + if (normalizedQuery.length < 2) { + setGlobalResults(null); + setSearchError(null); + setSearching(false); + setExpandedSessions(new Set()); + return; + } + + const requestId = searchRequestId.current; + setSearching(true); + setSearchError(null); + + api.searchAllSessions(normalizedQuery) + .then((results) => { + if (searchRequestId.current === requestId) { + setGlobalResults(results); + setExpandedSessions(new Set()); + } + }) + .catch((err) => { + console.error("Global search failed:", err); + if (searchRequestId.current === requestId) { + setGlobalResults(null); + setSearchError("Search failed. Please try again."); + } + }) + .finally(() => { + if (searchRequestId.current === requestId) { + setSearching(false); + } + }); + }, [normalizedQuery]); + + // Group global results by project + const groupedResults = useMemo(() => { + if (!globalResults) return null; + const groups: Map = new Map(); + for (const result of globalResults) { + const key = result.project_id; + if (!groups.has(key)) { + groups.set(key, { projectPath: result.project_path, results: [] }); + } + groups.get(key)!.results.push(result); + } + return groups; + }, [globalResults]); + // Determine how many projects to show const projectsPerPage = showAll ? 10 : 5; - const totalPages = Math.ceil(projects.length / projectsPerPage); - + const totalPages = Math.ceil(filteredProjects.length / projectsPerPage); + // Calculate which projects to display const startIndex = showAll ? (currentPage - 1) * projectsPerPage : 0; const endIndex = startIndex + projectsPerPage; - const displayedProjects = projects.slice(startIndex, endIndex); - + const displayedProjects = filteredProjects.slice(startIndex, endIndex); + const handleViewAll = () => { setShowAll(true); setCurrentPage(1); }; - + const handleViewLess = () => { setShowAll(false); setCurrentPage(1); }; + const clearSearch = useCallback(() => { + setSearchQuery(""); + setGlobalResults(null); + setSearchError(null); + setExpandedSessions(new Set()); + }, []); + + const toggleExpanded = useCallback((sessionId: string) => { + setExpandedSessions(prev => { + const next = new Set(prev); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + return next; + }); + }, []); + + const copyResumeCommand = useCallback((sessionId: string) => { + if (!navigator.clipboard) return; + navigator.clipboard.writeText(`claude --resume ${sessionId}`) + .then(() => { + setCopiedId(sessionId); + setTimeout(() => setCopiedId(null), 2000); + }) + .catch((err) => console.error("Failed to copy:", err)); + }, []); + + const handleOpenSession = useCallback((result: SessionSearchResult) => { + // Find the matching project and navigate into it, then select the session + const project = projects.find(p => p.id === result.project_id); + if (project) { + onProjectClick(project); + // After navigating to the project, select the session via CustomEvent + setTimeout(() => { + window.dispatchEvent(new CustomEvent('claude-session-selected', { + detail: { + session: { + id: result.id, + project_id: result.project_id, + project_path: result.project_path, + created_at: result.created_at, + first_message: result.first_message, + message_timestamp: result.message_timestamp, + todo_data: result.todo_data, + }, + projectPath: result.project_path, + } + })); + }, 100); + } + }, [projects, onProjectClick]); + + const isSearchActive = searchQuery.trim().length > 0; + const hasGlobalResults = globalResults !== null && globalResults.length > 0; + return (
@@ -137,107 +278,139 @@ export const ProjectList: React.FC = ({
+ + {/* Search bar */} +
+ + { + setSearchQuery(e.target.value); + setCurrentPage(1); + }} + aria-label="Search projects and sessions" + className="pl-9 pr-9 h-9" + /> + {searchQuery && ( + + )} +
{/* Content */} -
- {/* Recent projects section */} +
+ {/* Projects section */} {displayedProjects.length > 0 ? (
-

Recent Projects

- {!showAll ? ( - - ) : ( - - )} -
- -
- {displayedProjects.map((project, index) => ( - - onProjectClick(project)} - whileTap={{ scale: 0.97 }} - transition={{ duration: 0.15 }} - className="w-full text-left px-3 py-2 rounded-md hover:bg-accent/50 transition-colors flex items-center justify-between" - > - - {getProjectName(project.path)} - - - {getDisplayPath(project.path, 35)} - - - - ))} -
- - {/* Pagination controls */} - {showAll && totalPages > 1 && ( -
- - - - -
- {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => ( - + ) : ( + + ) + )} +
+ +
+ {displayedProjects.map((project, index) => ( + - {page} - + onProjectClick(project)} + whileTap={{ scale: 0.97 }} + transition={{ duration: 0.15 }} + className="w-full text-left px-3 py-2 rounded-md hover:bg-accent/50 transition-colors flex items-center justify-between" + > + + {getProjectName(project.path)} + + + {getDisplayPath(project.path, 35)} + + + ))}
- - - - -
- )} + + {/* Pagination controls */} + {!isSearchActive && showAll && totalPages > 1 && ( +
+ + + + +
+ {Array.from({ length: totalPages }, (_, i) => i + 1).map(page => ( + + ))} +
+ + + + +
+ )} +
+ ) : isSearchActive ? ( + +

No projects matching "{searchQuery.trim()}"

) : ( @@ -265,8 +438,140 @@ export const ProjectList: React.FC = ({
)} + + {/* Global session search results */} + {isSearchActive && ( +
+
+ {searching && ( +
+ + Searching sessions across all projects... +
+ )} + {searchError && !searching && ( +

{searchError}

+ )} + {globalResults !== null && !searching && !searchError && ( +

+ {globalResults.length === 0 + ? "No sessions found across projects" + : `Sessions matching "${normalizedQuery}" — ${globalResults.length} result${globalResults.length !== 1 ? 's' : ''}`} +

+ )} +
+ + {hasGlobalResults && groupedResults && ( +
+ {Array.from(groupedResults.entries()).map(([projectId, group]) => ( + +

+ + {getProjectName(group.projectPath)} + {getDisplayPath(group.projectPath, 40)} +

+
+ {group.results.map((result) => { + const snippets = result.snippets || []; + const isExpanded = expandedSessions.has(result.id); + + return ( +
+ {/* Accordion header */} +
+ +
+ + +
+
+ + {/* Accordion content */} + + {isExpanded && snippets.length > 0 && ( + +
+
+ {snippets.map((snippet, i) => ( +
+ +
+ ))} +
+
+
+ )} +
+
+ ); + })} +
+
+ ))} +
+ )} +
+ )}
); -}; +}; diff --git a/src/components/SessionList.tsx b/src/components/SessionList.tsx index 9575c015c..8c3476f8c 100644 --- a/src/components/SessionList.tsx +++ b/src/components/SessionList.tsx @@ -1,77 +1,224 @@ -import React, { useState } from "react"; +import React, { useState, useEffect, useCallback, useRef } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { Clock, MessageSquare } from "lucide-react"; +import { Clock, MessageSquare, Search, Loader2, X, ChevronDown, ExternalLink, Copy, Check } from "lucide-react"; import { Card } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; import { Pagination } from "@/components/ui/pagination"; import { ClaudeMemoriesDropdown } from "@/components/ClaudeMemoriesDropdown"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { HighlightedText } from "@/components/HighlightedText"; import { cn } from "@/lib/utils"; import { truncateText, getFirstLine } from "@/lib/date-utils"; -import type { Session, ClaudeMdFile } from "@/lib/api"; +import { useDebounce } from "@/hooks/useDebounce"; +import { api } from "@/lib/api"; +import type { Session, SessionSearchResult, ClaudeMdFile } from "@/lib/api"; interface SessionListProps { - /** - * Array of sessions to display - */ sessions: Session[]; - /** - * The current project path being viewed - */ + projectId: string; projectPath: string; - /** - * Optional callback to go back to project list (deprecated - use tabs instead) - */ onBack?: () => void; - /** - * Callback when a session is clicked - */ onSessionClick?: (session: Session) => void; - /** - * Callback when a CLAUDE.md file should be edited - */ onEditClaudeFile?: (file: ClaudeMdFile) => void; - /** - * Optional className for styling - */ className?: string; } const ITEMS_PER_PAGE = 12; -/** - * SessionList component - Displays paginated sessions for a specific project - * - * @example - * setSelectedProject(null)} - * onSessionClick={(session) => console.log('Selected session:', session)} - * /> - */ +function formatSessionDate(session: Session) { + const date = session.message_timestamp + ? new Date(session.message_timestamp) + : new Date(session.created_at * 1000); + return date.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); +} + export const SessionList: React.FC = ({ sessions, + projectId, projectPath, onSessionClick, onEditClaudeFile, className, }) => { const [currentPage, setCurrentPage] = useState(1); - + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState(null); + const [searchError, setSearchError] = useState(null); + const [searching, setSearching] = useState(false); + const [expandedSessions, setExpandedSessions] = useState>(new Set()); + const [copiedId, setCopiedId] = useState(null); + const debouncedQuery = useDebounce(searchQuery, 300); + const normalizedQuery = debouncedQuery.trim(); + const searchRequestId = useRef(0); + const skipNextSearch = useRef(false); + + // Hard reset when project context changes + useEffect(() => { + skipNextSearch.current = true; + searchRequestId.current += 1; + setSearchQuery(""); + setSearchResults(null); + setSearchError(null); + setSearching(false); + setCurrentPage(1); + setExpandedSessions(new Set()); + }, [projectId]); + + // Invalidate when query is cleared + useEffect(() => { + searchRequestId.current += 1; + if (!searchQuery.trim()) { + setSearchResults(null); + setSearchError(null); + setSearching(false); + setCurrentPage(1); + setExpandedSessions(new Set()); + } + }, [searchQuery]); + + // Perform search when debounced query changes + useEffect(() => { + if (skipNextSearch.current) { + skipNextSearch.current = false; + return; + } + if (normalizedQuery.length < 2) { + setSearchResults(null); + setSearchError(null); + setSearching(false); + return; + } + + const requestId = ++searchRequestId.current; + setSearching(true); + setSearchError(null); + + api.searchProjectSessions(projectId, normalizedQuery) + .then((results) => { + if (searchRequestId.current === requestId) { + setSearchResults(results); + setCurrentPage(1); + setExpandedSessions(new Set()); + } + }) + .catch((err) => { + console.error("Search failed:", err); + if (searchRequestId.current === requestId) { + setSearchResults(null); + setSearchError("Search failed. Please try again."); + } + }) + .finally(() => { + if (searchRequestId.current === requestId) { + setSearching(false); + } + }); + }, [debouncedQuery, projectId]); + + const isSearchActive = + searchQuery.trim().length >= 2 && + (normalizedQuery.length >= 2 || searching || searchResults !== null); + const displayedSessions: (Session | SessionSearchResult)[] = isSearchActive ? (searchResults ?? []) : sessions; + // Calculate pagination - const totalPages = Math.ceil(sessions.length / ITEMS_PER_PAGE); + const totalPages = Math.max(1, Math.ceil(displayedSessions.length / ITEMS_PER_PAGE)); const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; const endIndex = startIndex + ITEMS_PER_PAGE; - const currentSessions = sessions.slice(startIndex, endIndex); - - // Reset to page 1 if sessions change - React.useEffect(() => { + const currentSessions = displayedSessions.slice(startIndex, endIndex); + + // Reset to page 1 if sessions dataset changes + useEffect(() => { + setCurrentPage(1); + }, [sessions, projectId]); + + const clearSearch = useCallback(() => { + setSearchQuery(""); + setSearchResults(null); + setSearchError(null); setCurrentPage(1); - }, [sessions.length]); - + setExpandedSessions(new Set()); + }, []); + + const toggleExpanded = useCallback((sessionId: string) => { + setExpandedSessions(prev => { + const next = new Set(prev); + if (next.has(sessionId)) { + next.delete(sessionId); + } else { + next.add(sessionId); + } + return next; + }); + }, []); + + const copyResumeCommand = useCallback((sessionId: string) => { + if (!navigator.clipboard) return; + navigator.clipboard.writeText(`claude --resume ${sessionId}`) + .then(() => { + setCopiedId(sessionId); + setTimeout(() => setCopiedId(null), 2000); + }) + .catch((err) => console.error("Failed to copy:", err)); + }, []); + + const handleSessionClick = useCallback((session: Session) => { + if (onSessionClick) { + onSessionClick(session); + return; + } + window.dispatchEvent(new CustomEvent('claude-session-selected', { + detail: { session, projectPath } + })); + }, [projectPath, onSessionClick]); + return (
+ {/* Search bar */} +
+ + setSearchQuery(e.target.value)} + aria-label="Search sessions" + className="pl-9 pr-9 h-9" + /> + {searchQuery && ( + + )} +
+ + {/* Search status */} +
+ {searching && ( +
+ + Searching sessions... +
+ )} + {searchError && !searching && ( +

{searchError}

+ )} + {searchResults !== null && !searching && !searchError && ( +

+ {searchResults.length === 0 + ? "No sessions found" + : `Showing ${searchResults.length} result${searchResults.length !== 1 ? 's' : ''}`} +

+ )} +
+ {/* CLAUDE.md Memories Dropdown */} {onEditClaudeFile && ( = ({ )} -
- {currentSessions.map((session, index) => ( - - { - // Emit a special event for Claude Code session navigation - const event = new CustomEvent('claude-session-selected', { - detail: { session, projectPath } - }); - window.dispatchEvent(event); - onSessionClick?.(session); + {isSearchActive ? ( + /* Search results: single-column accordion */ +
+ {currentSessions.map((item, index) => { + const result = item as SessionSearchResult; + const session = result; + const snippets = result.snippets || []; + const isExpanded = expandedSessions.has(session.id); + + return ( + + + {/* Accordion header */} +
+ +
+ + +
+
+ + {/* Accordion content: matching snippets */} + + {isExpanded && snippets.length > 0 && ( + +
+
+ {snippets.map((snippet, i) => ( +
+ +
+ ))} +
+
+
+ )} +
+
+
+ ); + })} +
+ ) : ( + /* Default view: grid layout */ +
+ {currentSessions.map((session, index) => ( + -
-
- {/* Session header */} -
-
- -
-

- Session on {session.message_timestamp - ? new Date(session.message_timestamp).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - }) - : new Date(session.created_at * 1000).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric' - }) - } -

+ handleSessionClick(session)} + > +
+
+
+
+ +
+

+ Session on {formatSessionDate(session)} +

+
+ {session.todo_data && ( + + Todo + + )}
- {session.todo_data && ( - - Todo - + {session.first_message ? ( +

+ {truncateText(getFirstLine(session.first_message), 120)} +

+ ) : ( +

+ No messages yet +

)}
- - {/* First message preview */} - {session.first_message ? ( -

- {truncateText(getFirstLine(session.first_message), 120)} +

+

+ {session.id.slice(-8)}

- ) : ( -

- No messages yet -

- )} -
- - {/* Metadata footer */} -
-

- {session.id.slice(-8)} -

- {session.todo_data && ( - - )} + {session.todo_data && ( + + )} +
-
- - - ))} -
+ + + ))} +
+ )} - + = ({
); -}; \ No newline at end of file +}; diff --git a/src/components/TabContent.tsx b/src/components/TabContent.tsx index e306324ed..13cdfedce 100644 --- a/src/components/TabContent.tsx +++ b/src/components/TabContent.tsx @@ -212,6 +212,7 @@ const TabPanel: React.FC = ({ tab, isActive }) => { {!loading && ( { // Update current tab to show the selected session diff --git a/src/lib/api.ts b/src/lib/api.ts index eb76da821..945da7510 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -53,6 +53,13 @@ export interface Session { message_timestamp?: string; } +export interface SessionSearchResult extends Session { + /** Matching text snippets from the session */ + snippets: string[]; + /** Positive search terms for frontend highlighting */ + highlight_terms: string[]; +} + /** * Represents the settings from ~/.claude/settings.json */ @@ -502,6 +509,35 @@ export const api = { } }, + /** + * Searches through all session messages for a project + * @param projectId - The ID of the project + * @param query - The search query + * @returns Promise resolving to an array of matching sessions + */ + async searchProjectSessions(projectId: string, query: string): Promise { + try { + return await apiCall('search_project_sessions', { projectId, query }); + } catch (error) { + console.error("Failed to search project sessions:", error); + throw error; + } + }, + + /** + * Search across all projects' sessions for messages matching the query + * @param query - The search query (supports AND, OR, NOT, "exact phrase") + * @returns Promise resolving to an array of matching sessions across all projects + */ + async searchAllSessions(query: string): Promise { + try { + return await apiCall('search_all_sessions', { query }); + } catch (error) { + console.error("Failed to search all sessions:", error); + throw error; + } + }, + /** * Fetch list of agents from GitHub repository * @returns Promise resolving to list of available agents on GitHub diff --git a/src/lib/apiAdapter.ts b/src/lib/apiAdapter.ts index ece52c491..db96a0631 100644 --- a/src/lib/apiAdapter.ts +++ b/src/lib/apiAdapter.ts @@ -167,6 +167,8 @@ function mapCommandToEndpoint(command: string, _params?: any): string { // Project and session commands 'list_projects': '/api/projects', 'get_project_sessions': '/api/projects/{projectId}/sessions', + 'search_project_sessions': '/api/projects/{projectId}/sessions/search', + 'search_all_sessions': '/api/sessions/search/global', // Agent commands 'list_agents': '/api/agents',