mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
feat(workspace): add full-text search for daily memory files
Add backend search command that performs case-insensitive matching across all daily memory files, supporting both date and content queries. Frontend includes animated search bar (⌘F), debounced input, snippet display with match count badge, and search state preservation across edits.
This commit is contained in:
@@ -147,6 +147,141 @@ pub async fn write_daily_memory_file(filename: String, content: String) -> Resul
|
||||
.map_err(|e| format!("Failed to write daily memory file {filename}: {e}"))
|
||||
}
|
||||
|
||||
/// Find the largest index `<= i` that is a valid UTF-8 char boundary.
|
||||
/// Equivalent to the unstable `str::floor_char_boundary` (stabilized in 1.91).
|
||||
fn floor_char_boundary(s: &str, mut i: usize) -> usize {
|
||||
if i >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
while !s.is_char_boundary(i) {
|
||||
i -= 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Find the smallest index `>= i` that is a valid UTF-8 char boundary.
|
||||
/// Equivalent to the unstable `str::ceil_char_boundary` (stabilized in 1.91).
|
||||
fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
|
||||
if i >= s.len() {
|
||||
return s.len();
|
||||
}
|
||||
while !s.is_char_boundary(i) {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
/// Search result for daily memory full-text search.
|
||||
#[derive(serde::Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DailyMemorySearchResult {
|
||||
pub filename: String,
|
||||
pub date: String,
|
||||
pub size_bytes: u64,
|
||||
pub modified_at: u64,
|
||||
pub snippet: String,
|
||||
pub match_count: usize,
|
||||
}
|
||||
|
||||
/// Full-text search across all daily memory files.
|
||||
///
|
||||
/// Performs case-insensitive search on both the date field and file content.
|
||||
/// Returns results sorted by filename descending (newest first), each with a
|
||||
/// snippet showing ~120 characters of context around the first match.
|
||||
#[tauri::command]
|
||||
pub async fn search_daily_memory_files(
|
||||
query: String,
|
||||
) -> Result<Vec<DailyMemorySearchResult>, String> {
|
||||
let memory_dir = get_openclaw_dir().join("workspace").join("memory");
|
||||
|
||||
if !memory_dir.exists() || query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut results: Vec<DailyMemorySearchResult> = Vec::new();
|
||||
|
||||
let entries = std::fs::read_dir(&memory_dir)
|
||||
.map_err(|e| format!("Failed to read memory directory: {e}"))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if !name.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
let meta = match entry.metadata() {
|
||||
Ok(m) if m.is_file() => m,
|
||||
_ => continue,
|
||||
};
|
||||
|
||||
let date = name.trim_end_matches(".md").to_string();
|
||||
let content = std::fs::read_to_string(entry.path()).unwrap_or_default();
|
||||
let content_lower = content.to_lowercase();
|
||||
|
||||
// Count matches in content
|
||||
let content_matches: Vec<usize> = content_lower
|
||||
.match_indices(&query_lower)
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
// Also check date field
|
||||
let date_matches = date.to_lowercase().contains(&query_lower);
|
||||
|
||||
if content_matches.is_empty() && !date_matches {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Build snippet around first content match (~120 chars of context)
|
||||
let snippet = if let Some(&first_pos) = content_matches.first() {
|
||||
let start = if first_pos > 50 {
|
||||
floor_char_boundary(&content, first_pos - 50)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let end = ceil_char_boundary(&content, (first_pos + 70).min(content.len()));
|
||||
let mut s = String::new();
|
||||
if start > 0 {
|
||||
s.push_str("...");
|
||||
}
|
||||
s.push_str(&content[start..end]);
|
||||
if end < content.len() {
|
||||
s.push_str("...");
|
||||
}
|
||||
s
|
||||
} else {
|
||||
// Date-only match — use beginning of file as preview
|
||||
let end = ceil_char_boundary(&content, 120.min(content.len()));
|
||||
let mut s = content[..end].to_string();
|
||||
if end < content.len() {
|
||||
s.push_str("...");
|
||||
}
|
||||
s
|
||||
};
|
||||
|
||||
let size_bytes = meta.len();
|
||||
let modified_at = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
|
||||
results.push(DailyMemorySearchResult {
|
||||
filename: name,
|
||||
date,
|
||||
size_bytes,
|
||||
modified_at,
|
||||
snippet,
|
||||
match_count: content_matches.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by filename descending (newest date first)
|
||||
results.sort_by(|a, b| b.filename.cmp(&a.filename));
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Delete a daily memory file (idempotent).
|
||||
#[tauri::command]
|
||||
pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> {
|
||||
|
||||
@@ -1051,6 +1051,7 @@ pub fn run() {
|
||||
commands::read_daily_memory_file,
|
||||
commands::write_daily_memory_file,
|
||||
commands::delete_daily_memory_file,
|
||||
commands::search_daily_memory_files,
|
||||
]);
|
||||
|
||||
let app = builder
|
||||
|
||||
Reference in New Issue
Block a user