Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e2e2134
feat: add full-text session search to project view
Mar 13, 2026
3cb95d6
fix: address code review findings for session search
Mar 13, 2026
f732b22
fix: address review pass 2 - spawn_blocking, path validation, stale r…
Mar 13, 2026
a31ff9e
fix: address review pass 3 - input validation and error fallback
Mar 13, 2026
ca3282b
fix: address review pass 4 - a11y, project switch, page reset, API en…
Mar 13, 2026
5a25655
fix: address review pass 5 - a11y, state reset, cross-platform valida…
Mar 13, 2026
2eafdc2
fix: address review pass 6 - result cap, canonical path, skip stale s…
Mar 13, 2026
11db48e
fix: address review pass 7 - canonical validation, truncate after sort
Mar 13, 2026
9b4d22f
fix: address review pass 8 - TOCTOU, pagination clamp, blank query pa…
Mar 13, 2026
9e60ba3
fix: wrap search status in aria-live region for screen readers
Mar 13, 2026
8575f75
feat: add accordion search results with highlighted matching snippets
Mar 13, 2026
de5724f
fix: accordion accessibility and symlink hardening
Mar 13, 2026
5a16c37
fix: Unicode-safe highlighting, deduplicate click paths
Mar 13, 2026
3070670
fix: preserve original casing in search snippets
Mar 13, 2026
f4fda21
fix: prevent search view flicker during query transitions
Mar 13, 2026
a0c3796
fix: use debouncedQuery for search-active state to prevent blank-out
Mar 13, 2026
7215007
fix: require minimum 2-char query to avoid expensive single-char scans
Mar 13, 2026
571bf76
fix: match frontend 2-char minimum with backend to prevent blank grid
Mar 13, 2026
8f5fdbe
feat: add open session and copy resume command buttons to accordion h…
Mar 13, 2026
a2db8eb
fix: debounce-aware search mode toggle and async clipboard write
Mar 13, 2026
46d52d8
fix: skip bad session files gracefully instead of aborting search
Mar 13, 2026
3f19b57
fix: prevent blank flash when clearing search or switching projects
Mar 13, 2026
f2a21b0
fix: update search placeholder to reflect project scope
Mar 13, 2026
3a0e40b
fix: normalize search query once and reuse for search and highlighting
Mar 13, 2026
5a9b73c
fix: use non-absolute result count wording for search results
Mar 13, 2026
15c1fda
style: apply cargo fmt formatting
Mar 13, 2026
0ef6d23
feat: add search operators (AND, OR, NOT, exact phrase)
Mar 13, 2026
5464dd5
fix: treat AND as explicit operator instead of literal search term
Mar 13, 2026
b3e38f9
feat: add global cross-project session search to projects view
Mar 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
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>
  • Loading branch information
Jose Monteiro and claude committed Mar 13, 2026
commit b3e38f9112958e82ecbe19c874f042af69738f2c
147 changes: 147 additions & 0 deletions src-tauri/src/commands/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,153 @@ pub async fn search_project_sessions(
.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<Vec<SessionSearchResult>, 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<SessionSearchResult> = 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<ClaudeSettings, String> {
Expand Down
5 changes: 3 additions & 2 deletions src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ 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,
search_project_sessions, track_checkpoint_message, track_session_messages,
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::{
Expand Down Expand Up @@ -189,6 +189,7 @@ fn main() {
create_project,
get_project_sessions,
search_project_sessions,
search_all_sessions,
get_home_directory,
get_claude_settings,
open_new_session,
Expand Down
18 changes: 18 additions & 0 deletions src-tauri/src/web_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,20 @@ async fn search_sessions(
}
}

/// API endpoint to search sessions across all projects
async fn search_all_sessions_handler(
Query(params): Query<SearchQuery>,
) -> Json<ApiResponse<Vec<commands::claude::SessionSearchResult>>> {
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<ApiResponse<Vec<serde_json::Value>>> {
Json(ApiResponse::success(vec![]))
Expand Down Expand Up @@ -811,6 +825,10 @@ pub async fn create_web_server(port: u16) -> Result<(), Box<dyn std::error::Erro
"/api/projects/{project_id}/sessions/search",
get(search_sessions),
)
.route(
"/api/sessions/search/global",
get(search_all_sessions_handler),
)
.route("/api/agents", get(get_agents))
.route("/api/usage", get(get_usage))
// Settings and configuration
Expand Down
32 changes: 32 additions & 0 deletions src/components/HighlightedText.tsx
Original file line number Diff line number Diff line change
@@ -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(
<mark key={match.index} className="bg-primary/30 text-foreground rounded-sm px-0.5">
{match[0]}
</mark>
);
lastIndex = match.index + match[0].length;
}

if (lastIndex < text.length) {
parts.push(text.slice(lastIndex));
}

return <>{parts}</>;
}
Loading