Skip to content

Commit b3e38f9

Browse files
Jose Monteiroclaude
andcommitted
feat: add global cross-project session search to projects view
Add a search bar to the ProjectList that filters projects by name (instant, client-side) and searches across all projects' sessions (debounced backend call). Results are grouped by project with expandable accordion snippets, highlighting, and action buttons. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5464dd5 commit b3e38f9

File tree

8 files changed

+638
-150
lines changed

8 files changed

+638
-150
lines changed

src-tauri/src/commands/claude.rs

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,153 @@ pub async fn search_project_sessions(
997997
.map_err(|e| format!("Search task failed: {}", e))?
998998
}
999999

1000+
/// Searches through all projects' session JSONL files for messages containing the query
1001+
#[tauri::command]
1002+
pub async fn search_all_sessions(query: String) -> Result<Vec<SessionSearchResult>, String> {
1003+
let query = query.trim().to_string();
1004+
if query.is_empty() || query.len() < 2 {
1005+
return Ok(Vec::new());
1006+
}
1007+
if query.len() > 256 {
1008+
return Err("Query is too long".to_string());
1009+
}
1010+
1011+
log::info!(
1012+
"Searching all sessions across all projects (query length: {})",
1013+
query.len()
1014+
);
1015+
1016+
let parsed = parse_query(&query);
1017+
if parsed.and_terms.is_empty() && parsed.or_groups.is_empty() && parsed.not_terms.is_empty() {
1018+
return Ok(Vec::new());
1019+
}
1020+
1021+
let claude_dir = get_claude_dir().map_err(|e| e.to_string())?;
1022+
let projects_dir = claude_dir.join("projects");
1023+
1024+
if !projects_dir.exists() {
1025+
return Ok(Vec::new());
1026+
}
1027+
1028+
let todos_dir = claude_dir.join("todos");
1029+
1030+
tokio::task::spawn_blocking(move || {
1031+
const MAX_RESULTS: usize = 100;
1032+
1033+
let mut results: Vec<SessionSearchResult> = Vec::new();
1034+
1035+
let project_entries = fs::read_dir(&projects_dir)
1036+
.map_err(|e| format!("Failed to read projects directory: {}", e))?;
1037+
1038+
for project_entry in project_entries {
1039+
if results.len() >= MAX_RESULTS {
1040+
break;
1041+
}
1042+
1043+
let project_entry = match project_entry {
1044+
Ok(e) => e,
1045+
Err(_) => continue,
1046+
};
1047+
let project_dir = project_entry.path();
1048+
if !project_dir.is_dir() {
1049+
continue;
1050+
}
1051+
1052+
let project_id = match project_dir.file_name().and_then(|n| n.to_str()) {
1053+
Some(name) => name.to_string(),
1054+
None => continue,
1055+
};
1056+
1057+
let project_path = match get_project_path_from_sessions(&project_dir) {
1058+
Ok(path) => path,
1059+
Err(_) => decode_project_path(&project_id),
1060+
};
1061+
1062+
let session_entries = match fs::read_dir(&project_dir) {
1063+
Ok(entries) => entries,
1064+
Err(_) => continue,
1065+
};
1066+
1067+
for entry in session_entries {
1068+
if results.len() >= MAX_RESULTS {
1069+
break;
1070+
}
1071+
1072+
let entry = match entry {
1073+
Ok(e) => e,
1074+
Err(_) => continue,
1075+
};
1076+
let file_type = match entry.file_type() {
1077+
Ok(ft) => ft,
1078+
Err(_) => continue,
1079+
};
1080+
if file_type.is_symlink() || !file_type.is_file() {
1081+
continue;
1082+
}
1083+
let path = entry.path();
1084+
1085+
if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
1086+
if let Some(session_id) = path.file_stem().and_then(|s| s.to_str()) {
1087+
if !session_matches(&path, &parsed) {
1088+
continue;
1089+
}
1090+
let snippets = extract_snippets_for_terms(&path, &parsed.highlight_terms);
1091+
1092+
let metadata = match fs::metadata(&path) {
1093+
Ok(m) => m,
1094+
Err(_) => continue,
1095+
};
1096+
1097+
let created_at = metadata
1098+
.created()
1099+
.or_else(|_| metadata.modified())
1100+
.unwrap_or(SystemTime::UNIX_EPOCH)
1101+
.duration_since(SystemTime::UNIX_EPOCH)
1102+
.unwrap_or_default()
1103+
.as_secs();
1104+
1105+
let (first_message, message_timestamp) = extract_first_user_message(&path);
1106+
1107+
let todo_path = todos_dir.join(format!("{}.json", session_id));
1108+
let todo_data = if todo_path.exists() {
1109+
fs::read_to_string(&todo_path)
1110+
.ok()
1111+
.and_then(|content| serde_json::from_str(&content).ok())
1112+
} else {
1113+
None
1114+
};
1115+
1116+
results.push(SessionSearchResult {
1117+
session: Session {
1118+
id: session_id.to_string(),
1119+
project_id: project_id.clone(),
1120+
project_path: project_path.clone(),
1121+
todo_data,
1122+
created_at,
1123+
first_message,
1124+
message_timestamp,
1125+
},
1126+
snippets,
1127+
highlight_terms: parsed.highlight_terms.clone(),
1128+
});
1129+
}
1130+
}
1131+
}
1132+
}
1133+
1134+
results.sort_by(|a, b| b.session.created_at.cmp(&a.session.created_at));
1135+
results.truncate(MAX_RESULTS);
1136+
1137+
log::info!(
1138+
"Found {} matching sessions across all projects",
1139+
results.len()
1140+
);
1141+
Ok(results)
1142+
})
1143+
.await
1144+
.map_err(|e| format!("Search task failed: {}", e))?
1145+
}
1146+
10001147
/// Reads the Claude settings file
10011148
#[tauri::command]
10021149
pub async fn get_claude_settings() -> Result<ClaudeSettings, String> {

src-tauri/src/main.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ use commands::claude::{
2626
get_recently_modified_files, get_session_timeline, get_system_prompt, list_checkpoints,
2727
list_directory_contents, list_projects, list_running_claude_sessions, load_session_history,
2828
open_new_session, read_claude_md_file, restore_checkpoint, resume_claude_code,
29-
save_claude_md_file, save_claude_settings, save_system_prompt, search_files,
30-
search_project_sessions, track_checkpoint_message, track_session_messages,
29+
save_claude_md_file, save_claude_settings, save_system_prompt, search_all_sessions,
30+
search_files, search_project_sessions, track_checkpoint_message, track_session_messages,
3131
update_checkpoint_settings, update_hooks_config, validate_hook_command, ClaudeProcessState,
3232
};
3333
use commands::mcp::{
@@ -189,6 +189,7 @@ fn main() {
189189
create_project,
190190
get_project_sessions,
191191
search_project_sessions,
192+
search_all_sessions,
192193
get_home_directory,
193194
get_claude_settings,
194195
open_new_session,

src-tauri/src/web_server.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,20 @@ async fn search_sessions(
150150
}
151151
}
152152

153+
/// API endpoint to search sessions across all projects
154+
async fn search_all_sessions_handler(
155+
Query(params): Query<SearchQuery>,
156+
) -> Json<ApiResponse<Vec<commands::claude::SessionSearchResult>>> {
157+
let query = params.query.trim().to_string();
158+
if query.is_empty() {
159+
return Json(ApiResponse::success(Vec::new()));
160+
}
161+
match commands::claude::search_all_sessions(query).await {
162+
Ok(sessions) => Json(ApiResponse::success(sessions)),
163+
Err(e) => Json(ApiResponse::error(e.to_string())),
164+
}
165+
}
166+
153167
/// Simple agents endpoint - return empty for now (needs DB state)
154168
async fn get_agents() -> Json<ApiResponse<Vec<serde_json::Value>>> {
155169
Json(ApiResponse::success(vec![]))
@@ -811,6 +825,10 @@ pub async fn create_web_server(port: u16) -> Result<(), Box<dyn std::error::Erro
811825
"/api/projects/{project_id}/sessions/search",
812826
get(search_sessions),
813827
)
828+
.route(
829+
"/api/sessions/search/global",
830+
get(search_all_sessions_handler),
831+
)
814832
.route("/api/agents", get(get_agents))
815833
.route("/api/usage", get(get_usage))
816834
// Settings and configuration

src/components/HighlightedText.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import React from "react";
2+
3+
/** Highlights all occurrences of `terms` in `text` (case-insensitive, Unicode-safe) */
4+
export function HighlightedText({ text, terms }: { text: string; terms: string[] }) {
5+
if (!terms.length) return <>{text}</>;
6+
const escaped = terms
7+
.filter(t => t.trim())
8+
.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
9+
if (!escaped.length) return <>{text}</>;
10+
const re = new RegExp(escaped.join('|'), 'gi');
11+
const parts: React.ReactNode[] = [];
12+
let lastIndex = 0;
13+
let match: RegExpExecArray | null;
14+
15+
while ((match = re.exec(text)) !== null) {
16+
if (match.index > lastIndex) {
17+
parts.push(text.slice(lastIndex, match.index));
18+
}
19+
parts.push(
20+
<mark key={match.index} className="bg-primary/30 text-foreground rounded-sm px-0.5">
21+
{match[0]}
22+
</mark>
23+
);
24+
lastIndex = match.index + match[0].length;
25+
}
26+
27+
if (lastIndex < text.length) {
28+
parts.push(text.slice(lastIndex));
29+
}
30+
31+
return <>{parts}</>;
32+
}

0 commit comments

Comments
 (0)