Skip to content

Commit ad5a89c

Browse files
MarkShawn2020claude
andcommitted
perf(home): 优化 command stats 使用索引机制
- 新增 get_command_stats_path() 存储索引文件路径 - 修改 build_search_index 在扫描时同时收集 command 统计 - 简化 get_command_weekly_stats 从 JSON 索引读取 - 避免每次切换时间范围时全量扫描所有 session 文件 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 269cb5f commit ad5a89c

1 file changed

Lines changed: 121 additions & 96 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 121 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,13 @@ fn get_index_dir() -> PathBuf {
118118
.join("search-index")
119119
}
120120

121+
fn get_command_stats_path() -> PathBuf {
122+
dirs::data_local_dir()
123+
.unwrap_or_else(|| PathBuf::from("."))
124+
.join("lovcode")
125+
.join("command-stats.json")
126+
}
127+
121128
const JIEBA_TOKENIZER_NAME: &str = "jieba";
122129

123130
fn create_schema() -> Schema {
@@ -889,6 +896,61 @@ async fn build_search_index() -> Result<usize, String> {
889896
let projects_dir = get_claude_dir().join("projects");
890897
let mut indexed_count = 0;
891898

899+
// === Command stats collection ===
900+
let mut command_stats: HashMap<String, HashMap<String, usize>> = HashMap::new();
901+
let command_pattern = regex::Regex::new(r"<command-name>(/[^<]+)</command-name>")
902+
.map_err(|e| e.to_string())?;
903+
904+
// Build alias -> canonical name mapping
905+
let mut alias_map: HashMap<String, String> = HashMap::new();
906+
let commands_dir = get_claude_dir().join("commands");
907+
908+
fn scan_commands_for_aliases(dir: &std::path::Path, alias_map: &mut HashMap<String, String>, base_dir: &std::path::Path) {
909+
if let Ok(entries) = fs::read_dir(dir) {
910+
for entry in entries.filter_map(|e| e.ok()) {
911+
let path = entry.path();
912+
if path.is_dir() {
913+
scan_commands_for_aliases(&path, alias_map, base_dir);
914+
} else if path.extension().map_or(false, |e| e == "md") {
915+
let rel_path = path.strip_prefix(base_dir).unwrap_or(&path);
916+
let canonical = rel_path
917+
.with_extension("")
918+
.to_string_lossy()
919+
.replace('/', ":")
920+
.replace('\\', ":");
921+
922+
if let Ok(content) = fs::read_to_string(&path) {
923+
if content.starts_with("---") {
924+
if let Some(end) = content[3..].find("---") {
925+
let fm = &content[3..3 + end];
926+
for line in fm.lines() {
927+
if line.starts_with("aliases:") {
928+
let aliases_str = line.trim_start_matches("aliases:").trim();
929+
for alias in aliases_str.split(',') {
930+
let alias = alias.trim()
931+
.trim_matches('"')
932+
.trim_matches('\'')
933+
.trim_start_matches('/')
934+
.to_string();
935+
if !alias.is_empty() {
936+
alias_map.insert(alias, canonical.clone());
937+
}
938+
}
939+
}
940+
}
941+
}
942+
}
943+
}
944+
}
945+
}
946+
}
947+
}
948+
949+
if commands_dir.exists() {
950+
scan_commands_for_aliases(&commands_dir, &mut alias_map, &commands_dir);
951+
}
952+
// === End command stats setup ===
953+
892954
if !projects_dir.exists() {
893955
return Ok(0);
894956
}
@@ -925,7 +987,7 @@ async fn build_search_index() -> Result<usize, String> {
925987
}
926988
}
927989

928-
// Second pass: index messages
990+
// Second pass: index messages + collect command stats
929991
for line in file_content.lines() {
930992
if let Ok(parsed) = serde_json::from_str::<RawLine>(line) {
931993
let line_type = parsed.line_type.as_deref();
@@ -952,6 +1014,27 @@ async fn build_search_index() -> Result<usize, String> {
9521014
}
9531015
}
9541016
}
1017+
1018+
// Collect command stats from any line containing <command-name>
1019+
if line.contains("<command-name>") {
1020+
if let Some(ts_str) = &parsed.timestamp {
1021+
if let Ok(ts) = chrono::DateTime::parse_from_rfc3339(ts_str) {
1022+
let week_key = ts.format("%Y-W%V").to_string();
1023+
for cap in command_pattern.captures_iter(line) {
1024+
if let Some(cmd_match) = cap.get(1) {
1025+
let raw_name = cmd_match.as_str().trim_start_matches('/').to_string();
1026+
let name = alias_map.get(&raw_name).cloned().unwrap_or(raw_name);
1027+
command_stats
1028+
.entry(name)
1029+
.or_default()
1030+
.entry(week_key.clone())
1031+
.and_modify(|c| *c += 1)
1032+
.or_insert(1);
1033+
}
1034+
}
1035+
}
1036+
}
1037+
}
9551038
}
9561039
}
9571040
}
@@ -960,10 +1043,21 @@ async fn build_search_index() -> Result<usize, String> {
9601043

9611044
index_writer.commit().map_err(|e| e.to_string())?;
9621045

963-
// Store index in global state
1046+
// Store search index in global state
9641047
let mut guard = SEARCH_INDEX.lock().map_err(|e| e.to_string())?;
9651048
*guard = Some(SearchIndex { index, schema });
9661049

1050+
// Write command stats to file
1051+
let stats_path = get_command_stats_path();
1052+
if let Some(parent) = stats_path.parent() {
1053+
fs::create_dir_all(parent).ok();
1054+
}
1055+
let stats_json = serde_json::json!({
1056+
"updated_at": chrono::Utc::now().timestamp(),
1057+
"commands": command_stats,
1058+
});
1059+
fs::write(&stats_path, serde_json::to_string_pretty(&stats_json).unwrap_or_default()).ok();
1060+
9671061
Ok(indexed_count)
9681062
})
9691063
.await
@@ -3711,109 +3805,40 @@ async fn get_command_stats() -> Result<HashMap<String, usize>, String> {
37113805
Ok(new_stats)
37123806
}
37133807

3714-
/// Returns command usage counts grouped by week
3808+
/// Returns command usage counts grouped by week (from pre-built index)
37153809
/// Format: { "command_name": { "2024-W01": count, "2024-W02": count, ... } }
37163810
#[tauri::command]
3717-
async fn get_command_weekly_stats(weeks: Option<usize>) -> Result<HashMap<String, HashMap<String, usize>>, String> {
3718-
let weeks = weeks.unwrap_or(12);
3719-
3720-
tauri::async_runtime::spawn_blocking(move || {
3721-
let projects_dir = get_claude_dir().join("projects");
3722-
// command_name -> (week_key -> count)
3723-
let mut stats: HashMap<String, HashMap<String, usize>> = HashMap::new();
3724-
3725-
if !projects_dir.exists() {
3726-
return Ok(stats);
3727-
}
3728-
3729-
let command_pattern = regex::Regex::new(r"<command-name>(/[^<]+)</command-name>")
3730-
.map_err(|e| e.to_string())?;
3731-
3732-
// Calculate cutoff date (N weeks ago)
3733-
let now = chrono::Utc::now();
3734-
let cutoff = now - chrono::Duration::weeks(weeks as i64);
3811+
fn get_command_weekly_stats(_weeks: Option<usize>) -> Result<HashMap<String, HashMap<String, usize>>, String> {
3812+
// Read from pre-built index (created by build_search_index)
3813+
let stats_path = get_command_stats_path();
37353814

3736-
for project_entry in fs::read_dir(&projects_dir).map_err(|e| e.to_string())? {
3737-
let project_entry = project_entry.map_err(|e| e.to_string())?;
3738-
let project_path = project_entry.path();
3739-
3740-
if !project_path.is_dir() {
3741-
continue;
3742-
}
3743-
3744-
for session_entry in fs::read_dir(&project_path).map_err(|e| e.to_string())? {
3745-
let session_entry = session_entry.map_err(|e| e.to_string())?;
3746-
let session_path = session_entry.path();
3747-
let name = session_path
3748-
.file_name()
3749-
.unwrap()
3750-
.to_string_lossy()
3751-
.to_string();
3752-
3753-
if !name.ends_with(".jsonl") || name.starts_with("agent-") {
3754-
continue;
3755-
}
3756-
3757-
if let Ok(content) = fs::read_to_string(&session_path) {
3758-
for line in content.lines() {
3759-
// Check if line contains a command
3760-
if !line.contains("<command-name>") {
3761-
continue;
3762-
}
3763-
3764-
// Parse timestamp from the line
3765-
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(line) {
3766-
let timestamp = parsed.get("timestamp")
3767-
.and_then(|v| v.as_str())
3768-
.and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
3769-
.map(|dt| dt.with_timezone(&chrono::Utc));
3770-
3771-
if let Some(ts) = timestamp {
3772-
// Skip if before cutoff
3773-
if ts < cutoff {
3774-
continue;
3775-
}
3815+
if !stats_path.exists() {
3816+
return Ok(HashMap::new());
3817+
}
37763818

3777-
// Get week key (ISO week)
3778-
let week_key = ts.format("%Y-W%V").to_string();
3779-
3780-
// Extract command names from content
3781-
let content_str = parsed.get("message")
3782-
.and_then(|m| m.get("content"))
3783-
.and_then(|c| {
3784-
if c.is_string() {
3785-
Some(c.as_str().unwrap().to_string())
3786-
} else if c.is_array() {
3787-
// Handle array content (tool results etc)
3788-
Some(serde_json::to_string(c).unwrap_or_default())
3789-
} else {
3790-
None
3791-
}
3792-
})
3793-
.unwrap_or_default();
3819+
let content = fs::read_to_string(&stats_path).map_err(|e| e.to_string())?;
3820+
let parsed: serde_json::Value = serde_json::from_str(&content).map_err(|e| e.to_string())?;
37943821

3795-
for cap in command_pattern.captures_iter(&content_str) {
3796-
if let Some(cmd_name) = cap.get(1) {
3797-
let name = cmd_name.as_str().trim_start_matches('/').to_string();
3798-
stats
3799-
.entry(name)
3800-
.or_default()
3801-
.entry(week_key.clone())
3802-
.and_modify(|c| *c += 1)
3803-
.or_insert(1);
3804-
}
3805-
}
3806-
}
3807-
}
3808-
}
3822+
// Extract commands map
3823+
let commands = parsed
3824+
.get("commands")
3825+
.and_then(|v| v.as_object())
3826+
.ok_or("Invalid command stats format")?;
3827+
3828+
let mut stats: HashMap<String, HashMap<String, usize>> = HashMap::new();
3829+
for (cmd_name, week_data) in commands {
3830+
if let Some(weeks) = week_data.as_object() {
3831+
let mut week_map: HashMap<String, usize> = HashMap::new();
3832+
for (week_key, count) in weeks {
3833+
if let Some(n) = count.as_u64() {
3834+
week_map.insert(week_key.clone(), n as usize);
38093835
}
38103836
}
3837+
stats.insert(cmd_name.clone(), week_map);
38113838
}
3839+
}
38123840

3813-
Ok(stats)
3814-
})
3815-
.await
3816-
.map_err(|e| e.to_string())?
3841+
Ok(stats)
38173842
}
38183843

38193844
// ============================================================================

0 commit comments

Comments
 (0)