From 6673190efc08048276f6ca4b8d53cdc283d8f029 Mon Sep 17 00:00:00 2001 From: zhnd Date: Sun, 3 May 2026 15:22:08 +0800 Subject: [PATCH 1/2] Fix Claude project path resolution --- .../src/services/claude_project_paths.rs | 117 ++++++++++++++++++ .../src/services/claude_session_service.rs | 37 +++--- src-tauri/src/services/mod.rs | 1 + src-tauri/src/services/projects_service.rs | 19 +-- 4 files changed, 139 insertions(+), 35 deletions(-) create mode 100644 src-tauri/src/services/claude_project_paths.rs diff --git a/src-tauri/src/services/claude_project_paths.rs b/src-tauri/src/services/claude_project_paths.rs new file mode 100644 index 0000000..6dde591 --- /dev/null +++ b/src-tauri/src/services/claude_project_paths.rs @@ -0,0 +1,117 @@ +//! Helpers for Claude Code project path encoding. +//! +//! Claude stores session files under `~/.claude/projects/` using directory names +//! derived from project paths by replacing `/` with `-`. That encoding is lossy +//! when a path segment itself contains `-`, so prefer the exact project paths +//! recorded in `~/.claude.json`. + +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +pub(crate) struct ClaudeProjectPaths; + +impl ClaudeProjectPaths { + fn user_prefs_path() -> Result { + let home = dirs::home_dir().context("Failed to get home directory")?; + Ok(home.join(".claude.json")) + } + + pub(crate) fn project_path_to_folder_name(project_path: &str) -> String { + project_path.replace('/', "-") + } + + fn fallback_folder_name_to_project_path(folder_name: &str) -> String { + if let Some(stripped) = folder_name.strip_prefix('-') { + format!("/{}", stripped.replace('-', "/")) + } else { + folder_name.replace('-', "/") + } + } + + pub(crate) fn load_folder_path_lookup() -> Result> { + let path = Self::user_prefs_path()?; + if !path.exists() { + return Ok(HashMap::new()); + } + + let content = match fs::read_to_string(&path) { + Ok(content) => content, + Err(error) => { + log::warn!( + "Failed to read Claude preferences at {}: {}", + path.display(), + error + ); + return Ok(HashMap::new()); + } + }; + let prefs: serde_json::Value = match serde_json::from_str(&content) { + Ok(prefs) => prefs, + Err(error) => { + log::warn!( + "Failed to parse Claude preferences JSON at {}: {}", + path.display(), + error + ); + return Ok(HashMap::new()); + } + }; + + let Some(projects) = prefs.get("projects").and_then(|value| value.as_object()) else { + return Ok(HashMap::new()); + }; + + let mut lookup = HashMap::with_capacity(projects.len()); + for project_path in projects.keys() { + lookup.insert( + Self::project_path_to_folder_name(project_path), + project_path.clone(), + ); + } + + Ok(lookup) + } + + pub(crate) fn folder_name_to_project_path( + folder_name: &str, + lookup: &HashMap, + ) -> String { + lookup + .get(folder_name) + .cloned() + .unwrap_or_else(|| Self::fallback_folder_name_to_project_path(folder_name)) + } +} + +#[cfg(test)] +mod tests { + use super::ClaudeProjectPaths; + use std::collections::HashMap; + + #[test] + fn resolves_hyphenated_project_paths_from_lookup() { + let project_path = "/Users/void/dev/projects/my-app"; + let folder_name = ClaudeProjectPaths::project_path_to_folder_name(project_path); + let lookup = HashMap::from([(folder_name.clone(), project_path.to_string())]); + + assert_eq!( + ClaudeProjectPaths::folder_name_to_project_path(&folder_name, &lookup), + project_path + ); + } + + #[test] + fn falls_back_to_legacy_directory_decoding() { + let lookup = HashMap::new(); + + assert_eq!( + ClaudeProjectPaths::folder_name_to_project_path( + "-Users-void-dev-projects-lumo", + &lookup + ), + "/Users/void/dev/projects/lumo" + ); + } +} diff --git a/src-tauri/src/services/claude_session_service.rs b/src-tauri/src/services/claude_session_service.rs index 31b5b7c..50791fe 100644 --- a/src-tauri/src/services/claude_session_service.rs +++ b/src-tauri/src/services/claude_session_service.rs @@ -8,6 +8,7 @@ use std::fs; use std::io::{BufRead, BufReader}; use std::path::{Path, PathBuf}; +use crate::services::claude_project_paths::ClaudeProjectPaths; use crate::types::{ ClaudeContentBlock, ClaudeMessage, ClaudeSession, ClaudeSessionDetail, ClaudeSessionPage, ClaudeSessionStats, ClaudeToolUse, RawClaudeMessage, @@ -54,24 +55,6 @@ impl ClaudeSessionService { Ok(Self::get_claude_dir()?.join("projects")) } - /// Convert a project path to its .claude folder name format - /// e.g., "/Users/zhnd/dev/projects/lumo" -> "-Users-zhnd-dev-projects-lumo" - fn project_path_to_folder_name(project_path: &str) -> String { - project_path.replace('/', "-") - } - - /// Decode a folder name back to a project path. - /// e.g., "-Users-zhnd-dev-projects-lumo" -> "/Users/zhnd/dev/projects/lumo" - fn folder_name_to_project_path(folder_name: &str) -> String { - if let Some(stripped) = folder_name.strip_prefix('-') { - // Unix-style: leading dash → leading slash, remaining dashes → slashes - format!("/{}", stripped.replace('-', "/")) - } else { - // Windows-style or fallback - folder_name.replace('-', "/") - } - } - fn timestamp_to_rfc3339(ms: i64) -> Option { chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.to_rfc3339()) } @@ -342,7 +325,11 @@ impl ClaudeSessionService { .parent() .and_then(|parent| parent.file_name()) .and_then(|name| name.to_str()) - .map(Self::folder_name_to_project_path) + .map(|folder_name| { + let lookup = ClaudeProjectPaths::load_folder_path_lookup() + .unwrap_or_else(|_| Default::default()); + ClaudeProjectPaths::folder_name_to_project_path(folder_name, &lookup) + }) }) .unwrap_or_default(); @@ -397,11 +384,12 @@ impl ClaudeSessionService { if !projects_dir.exists() { return Ok(vec![]); } + let project_path_lookup = ClaudeProjectPaths::load_folder_path_lookup()?; let mut files = Vec::new(); if let Some(project_path) = project_path { - let folder_name = Self::project_path_to_folder_name(project_path); + let folder_name = ClaudeProjectPaths::project_path_to_folder_name(project_path); let project_dir = projects_dir.join(folder_name); if !project_dir.exists() { return Ok(vec![]); @@ -420,7 +408,10 @@ impl ClaudeSessionService { continue; }; - let project_path = Self::folder_name_to_project_path(folder_name); + let project_path = ClaudeProjectPaths::folder_name_to_project_path( + folder_name, + &project_path_lookup, + ); files.extend(Self::list_session_files_in_project_dir(&path, &project_path)); } } @@ -502,7 +493,9 @@ impl ClaudeSessionService { let parent = path.parent().context("Invalid session path")?; let folder_name = parent.file_name().and_then(|n| n.to_str()).unwrap_or(""); - let project_path_decoded = Self::folder_name_to_project_path(folder_name); + let project_path_lookup = ClaudeProjectPaths::load_folder_path_lookup()?; + let project_path_decoded = + ClaudeProjectPaths::folder_name_to_project_path(folder_name, &project_path_lookup); // Build session metadata from the file itself let session = if let Some(meta) = Self::extract_session_meta(&path) { diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 1d51e61..3b67276 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -4,6 +4,7 @@ mod analytics_service; mod claude_config_service; +mod claude_project_paths; mod claude_session_service; pub mod notification_poller; mod notification_settings_service; diff --git a/src-tauri/src/services/projects_service.rs b/src-tauri/src/services/projects_service.rs index fe29ed8..0f286c1 100644 --- a/src-tauri/src/services/projects_service.rs +++ b/src-tauri/src/services/projects_service.rs @@ -7,6 +7,7 @@ use anyhow::{Context, Result}; use std::fs; use std::path::PathBuf; +use crate::services::claude_project_paths::ClaudeProjectPaths; use crate::services::SkillsService; use crate::types::ClaudeProjectSummary; @@ -22,18 +23,6 @@ impl ProjectsService { Ok(Self::get_claude_dir()?.join("projects")) } - /// Decode a folder name back to a project path. - /// e.g., "-Users-zhnd-dev-projects-lumo" -> "/Users/zhnd/dev/projects/lumo" - fn folder_name_to_project_path(folder_name: &str) -> String { - if let Some(stripped) = folder_name.strip_prefix('-') { - // Unix-style: leading dash → leading slash, remaining dashes → slashes - format!("/{}", stripped.replace('-', "/")) - } else { - // Windows-style or fallback - folder_name.replace('-', "/") - } - } - fn timestamp_to_rfc3339(ms: i64) -> Option { chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.to_rfc3339()) } @@ -51,6 +40,7 @@ impl ProjectsService { } let mut projects = Vec::new(); + let project_path_lookup = ClaudeProjectPaths::load_folder_path_lookup()?; for entry in fs::read_dir(&projects_dir)? { let entry = entry?; @@ -64,7 +54,10 @@ impl ProjectsService { None => continue, }; - let project_path = Self::folder_name_to_project_path(&folder_name); + let project_path = ClaudeProjectPaths::folder_name_to_project_path( + &folder_name, + &project_path_lookup, + ); let mut session_count = 0_i32; let mut latest_ms = 0_i64; From d3dd0241618960adce9ee06766185aef1b259c90 Mon Sep 17 00:00:00 2001 From: zhnd Date: Sun, 3 May 2026 15:31:06 +0800 Subject: [PATCH 2/2] Fix clippy sort warnings --- src-tauri/src/services/claude_session_service.rs | 2 +- src-tauri/src/services/skills_service.rs | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/services/claude_session_service.rs b/src-tauri/src/services/claude_session_service.rs index 50791fe..53639d4 100644 --- a/src-tauri/src/services/claude_session_service.rs +++ b/src-tauri/src/services/claude_session_service.rs @@ -416,7 +416,7 @@ impl ClaudeSessionService { } } - files.sort_by(|a, b| b.mtime_ms.cmp(&a.mtime_ms)); + files.sort_by_key(|file| std::cmp::Reverse(file.mtime_ms)); Ok(files) } diff --git a/src-tauri/src/services/skills_service.rs b/src-tauri/src/services/skills_service.rs index 248b391..6c4bd6f 100644 --- a/src-tauri/src/services/skills_service.rs +++ b/src-tauri/src/services/skills_service.rs @@ -272,7 +272,7 @@ impl SkillsService { Self::scan_skills_dir(&base_dir.join("skills"), scope, &mut skills); Self::scan_commands_dir(&base_dir.join("commands"), &mut skills); - skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + skills.sort_by_key(|skill| skill.name.to_lowercase()); Ok(skills) } @@ -624,7 +624,7 @@ Add your skill instructions here. }); } - skills.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + skills.sort_by_key(|skill| skill.name.to_lowercase()); Ok(skills) }