diff --git a/src/adapters/claudecode.rs b/src/adapters/claudecode.rs index 1085b30..a893654 100644 --- a/src/adapters/claudecode.rs +++ b/src/adapters/claudecode.rs @@ -108,7 +108,7 @@ impl ClaudeCodeAdapter { 0 }; - let mut session = Session::new(&session_id, name, self.id()); + let mut session = Session::new(&session_id, name, self.id(), created_at); session.created_at = created_at; session.updated_at = updated_at; session.message_count = message_count; @@ -189,7 +189,7 @@ impl Adapter for ClaudeCodeAdapter { let name = first_user_text.unwrap_or_else(|| id.clone()); let (created_at, updated_at) = file_times(&path).await; - let mut session = Session::new(&id, name, self.id()); + let mut session = Session::new(&id, name, self.id(), created_at); session.created_at = created_at; session.updated_at = updated_at; session.message_count = message_count; diff --git a/src/adapters/codex.rs b/src/adapters/codex.rs index e0c840d..02a7918 100644 --- a/src/adapters/codex.rs +++ b/src/adapters/codex.rs @@ -197,7 +197,7 @@ impl CodexAdapter { 0 }; - let mut session = Session::new(&session_id, name, self.id()); + let mut session = Session::new(&session_id, name, self.id(), created_at); session.created_at = created_at; session.updated_at = updated_at; session.message_count = message_count; @@ -303,7 +303,7 @@ impl Adapter for CodexAdapter { None => (updated_at, 0), }; - let mut session = Session::new(&row.id, name, self.id()); + let mut session = Session::new(&row.id, name, self.id(), created_at); session.created_at = created_at; session.updated_at = updated_at; session.message_count = message_count; diff --git a/src/adapters/cursor.rs b/src/adapters/cursor.rs index d511355..383ed36 100644 --- a/src/adapters/cursor.rs +++ b/src/adapters/cursor.rs @@ -165,6 +165,7 @@ impl Adapter for CursorAdapter { &id, title.unwrap_or_else(|| "Untitled".to_string()), "cursor", + created_at, ); session.created_at = created_at; session.updated_at = updated_at; diff --git a/src/adapters/gemini.rs b/src/adapters/gemini.rs index ec43099..aadd04b 100644 --- a/src/adapters/gemini.rs +++ b/src/adapters/gemini.rs @@ -106,7 +106,7 @@ impl Adapter for GeminiAdapter { }) .unwrap_or_else(Utc::now); - let mut session = Session::new(&session_id, session_id.clone(), self.id()); + let mut session = Session::new(&session_id, session_id.clone(), self.id(), created_at); session.created_at = created_at; session.updated_at = updated_at; diff --git a/src/adapters/opencode.rs b/src/adapters/opencode.rs index 18d5b4b..bf203dc 100644 --- a/src/adapters/opencode.rs +++ b/src/adapters/opencode.rs @@ -305,7 +305,7 @@ impl Adapter for OpenCodeAdapter { // Count messages in the session let message_count = self.count_messages(&session_id).await.unwrap_or(0); - let mut session = Session::new(&session_id, session_id.clone(), self.id()); + let mut session = Session::new(&session_id, session_id.clone(), self.id(), created_at); session.created_at = created_at; session.updated_at = updated_at; session.message_count = message_count; diff --git a/src/core/logic/intent.rs b/src/core/logic/intent.rs index d27bb53..3bf6dbf 100644 --- a/src/core/logic/intent.rs +++ b/src/core/logic/intent.rs @@ -291,18 +291,19 @@ fn parse_criterion_line(line: &str) -> Option { /// "#; /// /// let doc = parse_spec_document(content).unwrap(); -/// let intent = build_intent_from_spec(doc, PathBuf::from("auth.md")).unwrap(); +/// let intent = build_intent_from_spec(doc, PathBuf::from("auth.md"), Some("intent-abc".to_string())).unwrap(); /// /// assert_eq!(intent.title, "Add JWT Authentication"); /// assert_eq!(intent.acceptance_criteria.len(), 3); +/// // Frontmatter id takes precedence over the caller-provided fallback. +/// assert_eq!(intent.id, "intent-123"); /// ``` pub fn build_intent_from_spec( doc: SpecDocument, spec_path: PathBuf, + fallback_id: Option, ) -> Result { - let title = extract_title(&doc.content) - .or_else(|| Some("Untitled Intent".to_string())) - .unwrap(); + let title = extract_title(&doc.content).unwrap_or_else(|| "Untitled Intent".to_string()); let description = extract_description(&doc.content).unwrap_or_default(); let acceptance_criteria = extract_acceptance_criteria(&doc.content); @@ -314,11 +315,17 @@ pub fn build_intent_from_spec( .or_else(|| doc.frontmatter.created.clone()) .unwrap_or_else(|| "2026-01-01T00:00:00Z".to_string()); + // The id is resolved in priority order: frontmatter → caller-provided + // fallback → error. The Core never generates ids itself. + let id = doc + .frontmatter + .id + .clone() + .or(fallback_id) + .ok_or_else(|| IntentParseError::MissingField("id".to_string()))?; + Ok(Intent { - id: doc - .frontmatter - .id - .unwrap_or_else(|| format!("intent-{}", uuid::Uuid::new_v4())), + id, title, description, status: doc.frontmatter.status.unwrap_or(IntentStatus::Draft), @@ -348,12 +355,13 @@ pub fn build_intent_from_spec( /// ``` /// use rightclick::core::logic::intent::generate_default_spec; /// -/// let content = generate_default_spec("Add Feature X", "2026-02-14T10:00:00Z"); +/// let content = generate_default_spec("Add Feature X", "2026-02-14T10:00:00Z", "abc123"); /// assert!(content.contains("Add Feature X")); /// assert!(content.contains("## Description")); /// assert!(content.contains("## Acceptance Criteria")); +/// assert!(content.contains("id: intent-abc123")); /// ``` -pub fn generate_default_spec(title: &str, now: &str) -> String { +pub fn generate_default_spec(title: &str, now: &str, id_suffix: &str) -> String { format!( r#"--- id: intent-{id} @@ -385,7 +393,7 @@ Describe what needs to be implemented... Additional notes and considerations... "#, - id = uuid::Uuid::new_v4(), + id = id_suffix, now = now, title = title ) @@ -607,7 +615,7 @@ More content. #[test] fn test_generate_default_spec() { - let spec = generate_default_spec("Test Feature", "2026-02-14T10:00:00Z"); + let spec = generate_default_spec("Test Feature", "2026-02-14T10:00:00Z", "test-suffix"); assert!(spec.contains("# Test Feature")); assert!(spec.contains("## Description")); diff --git a/src/core/logic/mod.rs b/src/core/logic/mod.rs index 6498cfd..53af16f 100644 --- a/src/core/logic/mod.rs +++ b/src/core/logic/mod.rs @@ -7,11 +7,18 @@ pub mod guards; pub mod intent; pub mod navigation; +pub mod project; // Re-export commonly used types for convenience pub use guards::check_guard; pub use navigation::{apply_navigation, calculate_navigation}; +// Re-export project logic +pub use project::{ + ProjectPathError, build_project_config, extract_project_name, find_existing_project, + validate_project_path, +}; + // Re-export intent functions and types pub use intent::{ IntentParseError, build_intent_from_spec, extract_acceptance_criteria, diff --git a/src/core/logic/project.rs b/src/core/logic/project.rs new file mode 100644 index 0000000..501245a --- /dev/null +++ b/src/core/logic/project.rs @@ -0,0 +1,222 @@ +//! Project logic - pure functions for project validation and construction. +//! +//! These functions have no side effects (filesystem existence checks are +//! referentially transparent queries with no observable mutation) and are +//! fully deterministic, in accordance with the Functional Core pattern. + +use std::path::{Path, PathBuf}; + +use crate::core::models::config::ProjectConfig; + +/// Errors that can occur when validating a project path. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ProjectPathError { + /// The path does not exist on the filesystem. + #[error("path does not exist: {0}")] + NotFound(PathBuf), + /// The path exists but is not a directory. + #[error("path is not a directory: {0}")] + NotADirectory(PathBuf), +} + +/// Validate that the given path is suitable as a project root. +/// +/// Returns `Ok(())` only if the path exists and is a directory. +/// +/// # Examples +/// +/// ``` +/// use rightclick::core::logic::project::validate_project_path; +/// use std::path::Path; +/// +/// // A non-existent path is rejected. +/// let err = validate_project_path(Path::new("/definitely/not/here/xyz")).unwrap_err(); +/// assert!(matches!( +/// err, +/// rightclick::core::logic::project::ProjectPathError::NotFound(_) +/// )); +/// ``` +pub fn validate_project_path(path: &Path) -> Result<(), ProjectPathError> { + if !path.exists() { + return Err(ProjectPathError::NotFound(path.to_path_buf())); + } + if !path.is_dir() { + return Err(ProjectPathError::NotADirectory(path.to_path_buf())); + } + Ok(()) +} + +/// Extract a human-readable project name from the final path component. +/// +/// Returns `None` if the path has no final component (e.g. `/`). +/// +/// # Examples +/// +/// ``` +/// use rightclick::core::logic::project::extract_project_name; +/// use std::path::Path; +/// +/// assert_eq!(extract_project_name(Path::new("/home/user/my-app")), Some("my-app")); +/// ``` +pub fn extract_project_name(path: &Path) -> Option<&str> { + path.file_name().and_then(|n| n.to_str()) +} + +/// Find an existing project whose path matches the given one. +/// +/// Pure lookup over a slice of [`ProjectConfig`]. Canonical comparison is +/// delegated to `Path` equality semantics (no normalization is performed). +/// +/// # Examples +/// +/// ``` +/// use rightclick::core::logic::project::find_existing_project; +/// use rightclick::core::models::config::ProjectConfig; +/// use std::path::Path; +/// +/// let projects = vec![ +/// ProjectConfig { +/// id: "p1".to_string(), +/// name: "alpha".to_string(), +/// path: "/tmp/alpha".to_string(), +/// description: None, +/// favorite: false, +/// tags: vec![], +/// }, +/// ]; +/// let found = find_existing_project(&projects, Path::new("/tmp/alpha")); +/// assert!(found.is_some()); +/// assert_eq!(found.unwrap().id, "p1"); +/// ``` +pub fn find_existing_project<'a>( + projects: &'a [ProjectConfig], + path: &Path, +) -> Option<&'a ProjectConfig> { + projects.iter().find(|p| Path::new(&p.path) == path) +} + +/// Build a fresh [`ProjectConfig`] from a generated id and a filesystem path. +/// +/// The project name is derived from the final path component, falling back to +/// `"unnamed"` when no usable component is available. +/// +/// # Examples +/// +/// ``` +/// use rightclick::core::logic::project::build_project_config; +/// use std::path::Path; +/// +/// let cfg = build_project_config("id-123".to_string(), Path::new("/tmp/widget")); +/// assert_eq!(cfg.id, "id-123"); +/// assert_eq!(cfg.name, "widget"); +/// assert_eq!(cfg.path, "/tmp/widget"); +/// assert!(!cfg.favorite); +/// ``` +pub fn build_project_config(id: String, path: &Path) -> ProjectConfig { + let name = extract_project_name(path).unwrap_or("unnamed").to_string(); + ProjectConfig { + id, + name, + path: path.to_string_lossy().to_string(), + description: None, + favorite: false, + tags: Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn sample_config(id: &str, path: &str) -> ProjectConfig { + ProjectConfig { + id: id.to_string(), + name: format!("name-{}", id), + path: path.to_string(), + description: None, + favorite: false, + tags: Vec::new(), + } + } + + #[test] + fn validate_rejects_missing_path() { + let err = validate_project_path(Path::new("/this/should/not/exist/abczyx")).unwrap_err(); + assert!(matches!(err, ProjectPathError::NotFound(_))); + } + + #[test] + fn validate_rejects_file_for_directory() { + let tmp = TempDir::new().unwrap(); + let file_path = tmp.path().join("file.txt"); + std::fs::write(&file_path, "x").unwrap(); + let err = validate_project_path(&file_path).unwrap_err(); + assert!(matches!(err, ProjectPathError::NotADirectory(_))); + } + + #[test] + fn validate_accepts_existing_directory() { + let tmp = TempDir::new().unwrap(); + validate_project_path(tmp.path()).expect("temp dir should be valid"); + } + + #[test] + fn extract_name_from_path() { + assert_eq!( + extract_project_name(Path::new("/home/user/widget")), + Some("widget") + ); + assert_eq!( + extract_project_name(Path::new("relative")), + Some("relative") + ); + } + + #[test] + fn extract_name_handles_root() { + // On POSIX root has no file_name + assert_eq!(extract_project_name(Path::new("/")), None); + } + + #[test] + fn find_existing_returns_match() { + let projects = vec![ + sample_config("p1", "/tmp/alpha"), + sample_config("p2", "/tmp/beta"), + ]; + let found = find_existing_project(&projects, Path::new("/tmp/beta")); + assert!(found.is_some()); + assert_eq!(found.unwrap().id, "p2"); + } + + #[test] + fn find_existing_returns_none_when_absent() { + let projects = vec![sample_config("p1", "/tmp/alpha")]; + assert!(find_existing_project(&projects, Path::new("/tmp/missing")).is_none()); + } + + #[test] + fn build_config_extracts_name() { + let cfg = build_project_config("id-1".to_string(), Path::new("/var/projects/widget")); + assert_eq!(cfg.id, "id-1"); + assert_eq!(cfg.name, "widget"); + assert_eq!(cfg.path, "/var/projects/widget"); + assert!(cfg.description.is_none()); + assert!(!cfg.favorite); + assert!(cfg.tags.is_empty()); + } + + #[test] + fn build_config_falls_back_to_unnamed_for_root() { + let cfg = build_project_config("id-2".to_string(), Path::new("/")); + assert_eq!(cfg.name, "unnamed"); + } + + #[test] + fn build_config_is_deterministic() { + let a = build_project_config("id".to_string(), Path::new("/tmp/x")); + let b = build_project_config("id".to_string(), Path::new("/tmp/x")); + assert_eq!(a, b); + } +} diff --git a/src/core/models/conversation.rs b/src/core/models/conversation.rs index 2b494d3..77042d5 100644 --- a/src/core/models/conversation.rs +++ b/src/core/models/conversation.rs @@ -34,13 +34,16 @@ pub struct Session { } impl Session { - /// Create a new session with the given name and adapter + /// Create a new session with the given name and adapter. + /// + /// `now` is injected by the caller (the Shell) to keep the Core pure and + /// deterministic. The Core never reads the system clock. pub fn new( id: impl Into, name: impl Into, adapter_id: impl Into, + now: DateTime, ) -> Self { - let now = Utc::now(); Self { id: id.into(), name: name.into(), @@ -56,9 +59,9 @@ impl Session { } } - /// Update the updated_at timestamp to now - pub fn touch(&mut self) { - self.updated_at = Utc::now(); + /// Update the updated_at timestamp to `now` (provided by the Shell). + pub fn touch(&mut self, now: DateTime) { + self.updated_at = now; } /// Add a tag to the session @@ -101,13 +104,13 @@ pub struct Message { } impl Message { - /// Create a new user message - pub fn user(id: impl Into, content: impl Into) -> Self { + /// Create a new user message at `now` (injected by the Shell for purity). + pub fn user(id: impl Into, content: impl Into, now: DateTime) -> Self { Self { id: id.into(), role: Role::User, content: content.into(), - timestamp: Utc::now(), + timestamp: now, model: None, tool_uses: Vec::new(), content_blocks: Vec::new(), @@ -117,13 +120,17 @@ impl Message { } } - /// Create a new assistant message - pub fn assistant(id: impl Into, content: impl Into) -> Self { + /// Create a new assistant message at `now` (injected by the Shell for purity). + pub fn assistant( + id: impl Into, + content: impl Into, + now: DateTime, + ) -> Self { Self { id: id.into(), role: Role::Assistant, content: content.into(), - timestamp: Utc::now(), + timestamp: now, model: None, tool_uses: Vec::new(), content_blocks: Vec::new(), @@ -133,13 +140,13 @@ impl Message { } } - /// Create a new system message - pub fn system(id: impl Into, content: impl Into) -> Self { + /// Create a new system message at `now` (injected by the Shell for purity). + pub fn system(id: impl Into, content: impl Into, now: DateTime) -> Self { Self { id: id.into(), role: Role::System, content: content.into(), - timestamp: Utc::now(), + timestamp: now, model: None, tool_uses: Vec::new(), content_blocks: Vec::new(), @@ -207,8 +214,13 @@ pub struct ToolUse { } impl ToolUse { - /// Create a new pending tool use - pub fn new(id: impl Into, name: impl Into, input: impl Into) -> Self { + /// Create a new pending tool use invoked at `now` (injected by the Shell). + pub fn new( + id: impl Into, + name: impl Into, + input: impl Into, + now: DateTime, + ) -> Self { Self { id: id.into(), name: name.into(), @@ -216,23 +228,23 @@ impl ToolUse { output: None, is_success: None, error: None, - invoked_at: Utc::now(), + invoked_at: now, completed_at: None, } } - /// Mark the tool use as completed successfully - pub fn complete(&mut self, output: impl Into) { + /// Mark the tool use as completed successfully at `now`. + pub fn complete(&mut self, output: impl Into, now: DateTime) { self.output = Some(output.into()); self.is_success = Some(true); - self.completed_at = Some(Utc::now()); + self.completed_at = Some(now); } - /// Mark the tool use as failed - pub fn fail(&mut self, error: impl Into) { + /// Mark the tool use as failed at `now`. + pub fn fail(&mut self, error: impl Into, now: DateTime) { self.error = Some(error.into()); self.is_success = Some(false); - self.completed_at = Some(Utc::now()); + self.completed_at = Some(now); } /// Check if the tool use is still pending @@ -512,7 +524,7 @@ mod tests { #[test] fn test_session_new() { - let session = Session::new("uuid-123", "Test Session", "claude"); + let session = Session::new("uuid-123", "Test Session", "claude", chrono::Utc::now()); assert_eq!(session.id, "uuid-123"); assert_eq!(session.name, "Test Session"); assert_eq!(session.adapter_id, "claude"); @@ -522,7 +534,7 @@ mod tests { #[test] fn test_session_tags() { - let mut session = Session::new("id", "name", "adapter"); + let mut session = Session::new("id", "name", "adapter", chrono::Utc::now()); session.add_tag("work"); session.add_tag("urgent"); assert_eq!(session.tags.len(), 2); @@ -538,7 +550,7 @@ mod tests { #[test] fn test_message_user() { - let msg = Message::user("msg-1", "Hello, world!"); + let msg = Message::user("msg-1", "Hello, world!", chrono::Utc::now()); assert_eq!(msg.role, Role::User); assert_eq!(msg.content, "Hello, world!"); assert!(!msg.has_tool_uses()); @@ -546,7 +558,7 @@ mod tests { #[test] fn test_message_assistant() { - let msg = Message::assistant("msg-2", "Hello! How can I help?"); + let msg = Message::assistant("msg-2", "Hello! How can I help?", chrono::Utc::now()); assert_eq!(msg.role, Role::Assistant); assert_eq!(msg.content, "Hello! How can I help?"); } @@ -560,18 +572,19 @@ mod tests { #[test] fn test_tool_use_lifecycle() { - let mut tool = ToolUse::new("tool-1", "read_file", r#"{"path": "main.rs"}"#); + let now = chrono::Utc::now(); + let mut tool = ToolUse::new("tool-1", "read_file", r#"{"path": "main.rs"}"#, now); assert!(tool.is_pending()); assert!(!tool.is_success()); - tool.complete("file content here"); + tool.complete("file content here", now); assert!(!tool.is_pending()); assert!(tool.is_success()); assert!(tool.output.is_some()); assert!(tool.completed_at.is_some()); - let mut failed_tool = ToolUse::new("tool-2", "write_file", r#"{}"#); - failed_tool.fail("permission denied"); + let mut failed_tool = ToolUse::new("tool-2", "write_file", r#"{}"#, now); + failed_tool.fail("permission denied", now); assert!(!failed_tool.is_success()); assert!(failed_tool.error.is_some()); } diff --git a/src/core/models/intent.rs b/src/core/models/intent.rs index 160df96..c8f19eb 100644 --- a/src/core/models/intent.rs +++ b/src/core/models/intent.rs @@ -58,6 +58,10 @@ pub struct Intent { impl Intent { /// Create a new intent with the given title and spec path. /// + /// `id` and `now` are injected by the caller (the Shell) to keep the Core + /// pure and deterministic. The Core never generates UUIDs or reads the + /// system clock. + /// /// # Examples /// /// ``` @@ -68,15 +72,22 @@ impl Intent { /// "Add JWT authentication", /// PathBuf::from(".rightclick/intents/auth-jwt.md"), /// "2026-02-14T10:00:00Z", + /// "intent-abc", /// ); /// assert_eq!(intent.title, "Add JWT authentication"); + /// assert_eq!(intent.id, "intent-abc"); /// assert_eq!(intent.status.to_string(), "draft"); /// ``` - pub fn new(title: impl Into, spec_path: PathBuf, now: impl Into) -> Self { + pub fn new( + title: impl Into, + spec_path: PathBuf, + now: impl Into, + id: impl Into, + ) -> Self { let title = title.into(); let now = now.into(); Self { - id: format!("intent-{}", uuid::Uuid::new_v4()), + id: id.into(), title, description: String::new(), status: IntentStatus::Draft, @@ -282,6 +293,9 @@ pub struct Worker { impl Worker { /// Create a new worker. /// + /// `id` and `now` are injected by the caller (the Shell) to keep the Core + /// pure and deterministic. + /// /// # Examples /// /// ``` @@ -297,9 +311,11 @@ impl Worker { /// "claude", /// PathBuf::from("/repo/.rightclick/logs/auth.log"), /// "2026-02-14T10:00:00Z", + /// "worker-xyz", /// ); /// assert_eq!(worker.worker_type, WorkerType::Investigator); /// assert_eq!(worker.status.to_string(), "pending"); + /// assert_eq!(worker.id, "worker-xyz"); /// ``` #[allow(clippy::too_many_arguments)] pub fn new( @@ -311,9 +327,10 @@ impl Worker { agent: impl Into, output_log: PathBuf, now: impl Into, + id: impl Into, ) -> Self { Self { - id: format!("worker-{}", uuid::Uuid::new_v4()), + id: id.into(), name: name.into(), worker_type, status: WorkerStatus::Pending, @@ -613,6 +630,7 @@ mod tests { "Test Intent", PathBuf::from("test.md"), "2026-02-14T10:00:00Z", + "test-intent-id", ); assert_eq!(intent.title, "Test Intent"); assert_eq!(intent.status, IntentStatus::Draft); @@ -621,7 +639,12 @@ mod tests { #[test] fn test_intent_completion_percentage() { - let mut intent = Intent::new("Test", PathBuf::from("test.md"), "2026-02-14T10:00:00Z"); + let mut intent = Intent::new( + "Test", + PathBuf::from("test.md"), + "2026-02-14T10:00:00Z", + "test-intent-id", + ); assert_eq!(intent.completion_percentage(), 0); intent @@ -650,6 +673,7 @@ mod tests { "claude", PathBuf::from("/repo/log1"), "2026-02-14T10:00:00Z", + "worker-id-1", ); let worker2 = Worker::new( @@ -661,6 +685,7 @@ mod tests { "claude", PathBuf::from("/repo/log2"), "2026-02-14T10:00:00Z", + "worker-id-2", ) .depends_on(worker1.id.clone()); @@ -679,6 +704,7 @@ mod tests { "claude", PathBuf::from("/repo/log"), "2026-02-14T10:00:00Z", + "worker-id-test", ); assert_eq!(worker.status, WorkerStatus::Pending); diff --git a/src/core/models/mod.rs b/src/core/models/mod.rs index 7bded24..a3d43ae 100644 --- a/src/core/models/mod.rs +++ b/src/core/models/mod.rs @@ -76,8 +76,8 @@ mod tests { favorite: false, tags: vec![], }; - let _: Session = Session::new("id", "name", "adapter"); - let _: Message = Message::user("id", "content"); + let _: Session = Session::new("id", "name", "adapter", chrono::Utc::now()); + let _: Message = Message::user("id", "content", chrono::Utc::now()); let _: FileStatus = FileStatus::Modified; let _: Role = Role::User; } diff --git a/src/plugins/conversations/mod.rs b/src/plugins/conversations/mod.rs index a27e491..4374871 100644 --- a/src/plugins/conversations/mod.rs +++ b/src/plugins/conversations/mod.rs @@ -311,7 +311,7 @@ mod tests { let adapter = MockAdapter { adapter_type: AdapterType::ClaudeCode, }; - let session = Session::new("test-id", "Test Session", "claude-code"); + let session = Session::new("test-id", "Test Session", "claude-code", chrono::Utc::now()); let line = format_session_line(&session, &adapter, false); assert!(line.contains("Test Session")); @@ -326,7 +326,8 @@ mod tests { let adapter = MockAdapter { adapter_type: AdapterType::ClaudeCode, }; - let mut session = Session::new("test-id", "Test Session", "claude-code"); + let mut session = + Session::new("test-id", "Test Session", "claude-code", chrono::Utc::now()); session.message_count = 1; let line = format_session_line(&session, &adapter, false); diff --git a/src/plugins/conversations/plugin.rs b/src/plugins/conversations/plugin.rs index a24ee36..719bbbc 100644 --- a/src/plugins/conversations/plugin.rs +++ b/src/plugins/conversations/plugin.rs @@ -1141,6 +1141,7 @@ mod tests { "session-1", "Shortcut parity", "test-adapter", + chrono::Utc::now(), ), &adapter, )]); @@ -1235,8 +1236,12 @@ mod tests { let mut plugin = ConversationsPlugin::new(registry); plugin.state_mut().view = ConversationView::Conversation; plugin.state_mut().set_messages(vec![ - crate::core::models::conversation::Message::user("msg-1", "First"), - crate::core::models::conversation::Message::assistant("msg-2", "Second"), + crate::core::models::conversation::Message::user("msg-1", "First", chrono::Utc::now()), + crate::core::models::conversation::Message::assistant( + "msg-2", + "Second", + chrono::Utc::now(), + ), ]); plugin.state_mut().expand_all_messages(); @@ -1272,6 +1277,7 @@ mod tests { "session-1", "Title polish", "test-adapter", + chrono::Utc::now(), ), &adapter, )]); @@ -1306,6 +1312,7 @@ mod tests { "session-1", "Investigate render bug", "test-adapter", + chrono::Utc::now(), ), &adapter, )]); @@ -1326,6 +1333,7 @@ mod tests { "session-1", "Single message", "test-adapter", + chrono::Utc::now(), ); session.message_count = 1; session.total_tokens = Some(1); @@ -1354,6 +1362,7 @@ mod tests { "session-1", "First", "test-adapter", + chrono::Utc::now(), ), &adapter, ), @@ -1362,6 +1371,7 @@ mod tests { "session-2", "Second", "test-adapter", + chrono::Utc::now(), ), &adapter, ), @@ -1392,12 +1402,14 @@ mod tests { "session-1", "Render bug", "test-adapter", + chrono::Utc::now(), ); first.message_count = 3; let mut second = crate::core::models::conversation::Session::new( "session-2", "CLI polish", "test-adapter", + chrono::Utc::now(), ); second.message_count = 5; let mut plugin = ConversationsPlugin::new(registry); @@ -1420,11 +1432,19 @@ mod tests { fn test_status_line_names_adapter_filter() { let registry = Arc::new(RwLock::new(AdapterRegistry::new())); let adapter: Arc = Arc::new(TestAdapter); - let mut first = - crate::core::models::conversation::Session::new("session-1", "Render bug", "codex"); + let mut first = crate::core::models::conversation::Session::new( + "session-1", + "Render bug", + "codex", + chrono::Utc::now(), + ); first.message_count = 3; - let mut second = - crate::core::models::conversation::Session::new("session-2", "CLI polish", "codex"); + let mut second = crate::core::models::conversation::Session::new( + "session-2", + "CLI polish", + "codex", + chrono::Utc::now(), + ); second.message_count = 5; let mut plugin = ConversationsPlugin::new(registry); plugin.state_mut().set_sessions(vec![ @@ -1446,8 +1466,12 @@ mod tests { fn test_status_line_uses_singular_session_and_message_counts() { let registry = Arc::new(RwLock::new(AdapterRegistry::new())); let adapter: Arc = Arc::new(TestAdapter); - let mut session = - crate::core::models::conversation::Session::new("session-1", "Render bug", "codex"); + let mut session = crate::core::models::conversation::Session::new( + "session-1", + "Render bug", + "codex", + chrono::Utc::now(), + ); session.message_count = 1; let mut plugin = ConversationsPlugin::new(registry); plugin @@ -1464,8 +1488,12 @@ mod tests { fn test_status_line_omits_zero_message_count() { let registry = Arc::new(RwLock::new(AdapterRegistry::new())); let adapter: Arc = Arc::new(TestAdapter); - let session = - crate::core::models::conversation::Session::new("session-1", "Render bug", "codex"); + let session = crate::core::models::conversation::Session::new( + "session-1", + "Render bug", + "codex", + chrono::Utc::now(), + ); let mut plugin = ConversationsPlugin::new(registry); plugin .state_mut() diff --git a/src/plugins/conversations/render.rs b/src/plugins/conversations/render.rs index 29083da..411cf0c 100644 --- a/src/plugins/conversations/render.rs +++ b/src/plugins/conversations/render.rs @@ -1110,6 +1110,7 @@ mod tests { "session-1", "Render polish", "test-adapter", + chrono::Utc::now(), ); session.message_count = 2; state.set_sessions(vec![SessionInfo { @@ -1141,6 +1142,7 @@ mod tests { "session-1", "Token polish", "test-adapter", + chrono::Utc::now(), ); session.message_count = 1; session.total_tokens = Some(999); @@ -1173,6 +1175,7 @@ mod tests { "session-1", "Stats polish", "test-adapter", + chrono::Utc::now(), ); session.message_count = 1; state.set_sessions(vec![SessionInfo { @@ -1231,6 +1234,7 @@ mod tests { "session-1", "Sidebar polish", "test-adapter", + chrono::Utc::now(), ); state.set_sessions(vec![SessionInfo { session, @@ -1261,6 +1265,7 @@ mod tests { "session-1", "Search polish", "test-adapter", + chrono::Utc::now(), ); state.set_sessions(vec![SessionInfo { session, @@ -1349,6 +1354,7 @@ mod tests { "session-1", "Render polish", "test-adapter", + chrono::Utc::now(), ); state.set_sessions(vec![SessionInfo { session, @@ -1359,8 +1365,12 @@ mod tests { state.selected_session = Some(0); state.view = ConversationView::Conversation; state.messages = vec![ - crate::core::models::conversation::Message::user("msg-1", "First"), - crate::core::models::conversation::Message::assistant("msg-2", "Second"), + crate::core::models::conversation::Message::user("msg-1", "First", chrono::Utc::now()), + crate::core::models::conversation::Message::assistant( + "msg-2", + "Second", + chrono::Utc::now(), + ), ]; let theme = Theme::default(); let area = Rect::new(0, 0, 120, 30); diff --git a/src/plugins/conversations/state.rs b/src/plugins/conversations/state.rs index 45350ca..4cd9f03 100644 --- a/src/plugins/conversations/state.rs +++ b/src/plugins/conversations/state.rs @@ -501,7 +501,7 @@ mod tests { use super::*; fn create_test_session(id: &str, name: &str) -> Session { - Session::new(id, name, "test-adapter") + Session::new(id, name, "test-adapter", chrono::Utc::now()) } #[test] diff --git a/src/plugins/gitstatus/render/empty_messages.rs b/src/plugins/gitstatus/render/empty_messages.rs new file mode 100644 index 0000000..b3a6889 --- /dev/null +++ b/src/plugins/gitstatus/render/empty_messages.rs @@ -0,0 +1,212 @@ +//! Empty-state message builders for the git status plugin. +use crate::core::models::FileChange; +use crate::ui::{global_hint_message, truncate_display}; + +use super::super::state::PluginState; + +pub(super) fn git_changes_empty_message(state: &PluginState, width: u16) -> String { + if state.branch.is_empty() { + git_empty_message( + vec![ + "Git status not loaded yet".to_string(), + String::new(), + "r: Refresh git status".to_string(), + ], + width, + ) + } else { + git_empty_message( + vec![ + "Working tree clean".to_string(), + String::new(), + "B: Branches".to_string(), + "H: History".to_string(), + "r: Refresh git status".to_string(), + ], + width, + ) + } +} + +pub(super) fn git_diff_empty_message(state: &PluginState, width: u16) -> String { + if state.branch.is_empty() { + git_empty_message( + vec![ + "Git status not loaded yet".to_string(), + String::new(), + "r: Refresh git status".to_string(), + ], + width, + ) + } else if state.files.is_empty() { + git_empty_message( + vec![ + "Working tree clean".to_string(), + String::new(), + "H: History".to_string(), + "B: Branches".to_string(), + "r: Refresh git status".to_string(), + ], + width, + ) + } else { + git_empty_message( + vec![ + "No file selected".to_string(), + String::new(), + "j/k: Navigate files".to_string(), + "Tab/Shift+Tab: Switch pane".to_string(), + "S: Status".to_string(), + "H: History".to_string(), + "B: Branches".to_string(), + "r: Refresh git status".to_string(), + ], + width, + ) + } +} + +pub(super) fn git_file_no_diff_message(file: &FileChange, width: u16) -> String { + git_empty_message( + vec![ + format!( + "Diff not loaded yet for {}", + truncate_display(&file.path, 48) + ), + String::new(), + "j/k: Navigate files".to_string(), + "s: Stage".to_string(), + "u: Unstage".to_string(), + "c: Commit".to_string(), + "r: Refresh git status".to_string(), + ], + width, + ) +} + +pub(super) fn git_sidebar_empty_message(state: &PluginState, width: u16) -> String { + if state.branch.is_empty() { + git_empty_message( + vec![ + "Git status not loaded yet".to_string(), + String::new(), + "r: Refresh git status".to_string(), + ], + width, + ) + } else { + git_empty_message( + vec![ + "Working tree clean".to_string(), + String::new(), + "B: Branches".to_string(), + "H: History".to_string(), + "r: Refresh git status".to_string(), + ], + width, + ) + } +} + +pub(super) fn git_commits_empty_message(width: u16) -> String { + git_empty_message( + vec![ + "No commits".to_string(), + String::new(), + "S: Status".to_string(), + "B: Branches".to_string(), + "r: Refresh git status".to_string(), + ], + width, + ) +} + +pub(super) fn git_commit_details_empty_message(state: &PluginState, width: u16) -> String { + if state.commits.is_empty() { + git_commits_empty_message(width) + } else { + git_empty_message( + vec![ + "No commit selected".to_string(), + String::new(), + "j/k: Navigate commits".to_string(), + "Tab/Shift+Tab: Switch pane".to_string(), + "S: Status".to_string(), + "B: Branches".to_string(), + "r: Refresh git status".to_string(), + ], + width, + ) + } +} + +pub(super) fn git_branches_empty_message(width: u16) -> String { + git_empty_message( + vec![ + "No branches".to_string(), + String::new(), + "S: Status".to_string(), + "H: History".to_string(), + "r: Refresh git status".to_string(), + ], + width, + ) +} + +pub(super) fn git_branch_details_empty_message(state: &PluginState, width: u16) -> String { + if state.branches.is_empty() { + git_branches_empty_message(width) + } else { + git_empty_message( + vec![ + "No branch selected".to_string(), + String::new(), + "j/k: Navigate branches".to_string(), + "Tab/Shift+Tab: Switch pane".to_string(), + "n: New branch".to_string(), + "S: Status".to_string(), + "H: History".to_string(), + "r: Refresh git status".to_string(), + ], + width, + ) + } +} + +pub(super) fn git_stashes_empty_message(width: u16) -> String { + git_empty_message( + vec![ + "No stashes".to_string(), + String::new(), + "s: Save stash".to_string(), + "S: Status".to_string(), + "B: Branches".to_string(), + "r: Refresh git status".to_string(), + ], + width, + ) +} + +pub(super) fn git_stash_details_empty_message(state: &PluginState, width: u16) -> String { + if state.stashes.is_empty() { + git_stashes_empty_message(width) + } else { + git_empty_message( + vec![ + "No stash selected".to_string(), + String::new(), + "j/k: Navigate stashes".to_string(), + "Tab/Shift+Tab: Switch pane".to_string(), + "s: Save stash".to_string(), + "S: Status".to_string(), + "B: Branches".to_string(), + "r: Refresh git status".to_string(), + ], + width, + ) + } +} + +fn git_empty_message(lines: Vec, width: u16) -> String { + global_hint_message(lines, width) +} diff --git a/src/plugins/gitstatus/render/lines.rs b/src/plugins/gitstatus/render/lines.rs new file mode 100644 index 0000000..9f96ba5 --- /dev/null +++ b/src/plugins/gitstatus/render/lines.rs @@ -0,0 +1,108 @@ +//! Line-builder helpers (ratatui `Line<'a>`) for the git status plugin. +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; + +use crate::core::models::{FileChange, FileStatus, Theme}; +use crate::theme::{UiElement, style_for_git_status, style_for_ui_element}; + +pub(super) fn build_section_header<'a>( + name: &'a str, + _theme: &'a Theme, + color: &'a str, +) -> Line<'a> { + use ratatui::style::Color; + use std::str::FromStr; + + let color = Color::from_str(color).unwrap_or(ratatui::style::Color::Gray); + let style = Style::default().fg(color).add_modifier(Modifier::BOLD); + + Line::from(vec![Span::styled( + format!("{} ({})", name, name.to_lowercase()), + style, + )]) +} + +pub(super) fn build_file_line<'a>( + file: &'a FileChange, + is_selected: bool, + theme: &'a Theme, +) -> Line<'a> { + let status_char = match file.status { + FileStatus::Staged => "S", + FileStatus::Modified => "M", + FileStatus::Untracked => "?", + FileStatus::Deleted => "D", + FileStatus::Renamed => "R", + FileStatus::Conflicted => "!", + _ => " ", + }; + + let status_style = match file.status { + FileStatus::Staged => style_for_git_status(theme, "staged"), + FileStatus::Modified => style_for_git_status(theme, "modified"), + FileStatus::Untracked => style_for_git_status(theme, "untracked"), + FileStatus::Deleted => style_for_git_status(theme, "deleted"), + FileStatus::Renamed => style_for_git_status(theme, "modified"), + FileStatus::Conflicted => style_for_ui_element(theme, UiElement::Error), + _ => style_for_ui_element(theme, UiElement::Text), + }; + + let path_style = if is_selected { + style_for_ui_element(theme, UiElement::ActiveItem) + } else { + style_for_ui_element(theme, UiElement::Text) + }; + + let mut spans = vec![ + Span::styled(format!(" {} ", status_char), status_style), + Span::styled(file.path.clone(), path_style), + ]; + + // Add change counts if available + if let (Some(adds), Some(dels)) = (file.additions, file.deletions) { + if adds > 0 || dels > 0 { + spans.push(Span::raw(" ")); + if adds > 0 { + spans.push(Span::styled( + format!("+{}", adds), + style_for_git_status(theme, "added"), + )); + } + if dels > 0 { + spans.push(Span::styled( + format!("-{}", dels), + style_for_git_status(theme, "deleted"), + )); + } + } + } + + Line::from(spans) +} + +pub(super) fn build_commit_line<'a>( + hash: &'a str, + subject: &'a str, + is_selected: bool, + theme: &'a Theme, +) -> Line<'a> { + let hash_style = if is_selected { + style_for_ui_element(theme, UiElement::ActiveItem) + } else { + style_for_ui_element(theme, UiElement::Secondary) + }; + + let subject_style = if is_selected { + style_for_ui_element(theme, UiElement::ActiveItem) + } else { + style_for_ui_element(theme, UiElement::Text) + }; + + let arrow = if is_selected { "▸ " } else { " " }; + + Line::from(vec![ + Span::styled(arrow, hash_style), + Span::styled(format!("{} ", hash), hash_style), + Span::styled(subject.to_string(), subject_style), + ]) +} diff --git a/src/plugins/gitstatus/render.rs b/src/plugins/gitstatus/render/mod.rs similarity index 79% rename from src/plugins/gitstatus/render.rs rename to src/plugins/gitstatus/render/mod.rs index b62c06b..a55bb10 100644 --- a/src/plugins/gitstatus/render.rs +++ b/src/plugins/gitstatus/render/mod.rs @@ -12,20 +12,22 @@ use ratatui::{ }; use crate::core::models::Theme; -use crate::core::models::{ChangeType, Diff, FileChange, FileDiff, FileStatus}; +use crate::core::models::{ChangeType, Diff, FileDiff}; use crate::theme::{UiElement, style_for_git_status, style_for_ui_element}; -use crate::ui::{count_label, global_hint_message, nonzero_count_label, truncate_display}; +use crate::ui::{count_label, nonzero_count_label}; use super::state::{FocusPane, PluginState, ViewMode}; -const GIT_DELETE_BRANCH_MODAL_HINT: &str = "Enter/D: Delete | Esc: Cancel"; -const GIT_DROP_STASH_MODAL_HINT: &str = "Enter/D: Drop | Esc: Cancel"; -const GIT_CANCEL_MODAL_HINT: &str = "Esc: Cancel"; -const GIT_ERROR_MODAL_HINT: &str = "Esc: Close"; -const GIT_MODAL_WIDTH: u16 = 50; -const GIT_MODAL_HEIGHT: u16 = 7; -const MIN_GIT_MODAL_WIDTH: u16 = 20; -const MIN_GIT_MODAL_HEIGHT: u16 = 5; +mod empty_messages; +mod lines; +mod modals; +mod time_fmt; + +use empty_messages::*; +use lines::{build_commit_line, build_file_line, build_section_header}; +use modals::*; +use time_fmt::{count_title, format_relative_time}; + /// Render the git status plugin pub fn render_git_status( state: &PluginState, @@ -476,42 +478,6 @@ fn render_commit_list( } /// Format relative time (e.g., "18 mins ago") -fn format_relative_time(date: &chrono::DateTime) -> String { - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(*date); - - if duration.num_minutes() < 1 { - "just now".to_string() - } else if duration.num_minutes() < 60 { - relative_time_label(duration.num_minutes(), "min", "mins") - } else if duration.num_hours() < 24 { - relative_time_label(duration.num_hours(), "hour", "hours") - } else if duration.num_days() < 7 { - relative_time_label(duration.num_days(), "day", "days") - } else { - date.format("%Y-%m-%d").to_string() - } -} - -fn relative_time_label(count: i64, singular: &str, plural: &str) -> String { - format!("{} ago", count_label(count as usize, singular, plural)) -} - -fn count_title( - singular: &str, - plural: &str, - count: usize, - unit_singular: &str, - unit_plural: &str, -) -> String { - let title = if count == 1 { singular } else { plural }; - format!( - " {} ({}) ", - title, - count_label(count, unit_singular, unit_plural) - ) -} - /// Render commit details panel fn render_commit_details( state: &PluginState, @@ -724,101 +690,7 @@ fn render_commit_details( } /// Build a section header line -fn build_section_header<'a>(name: &'a str, _theme: &'a Theme, color: &'a str) -> Line<'a> { - use ratatui::style::Color; - use std::str::FromStr; - - let color = Color::from_str(color).unwrap_or(ratatui::style::Color::Gray); - let style = Style::default().fg(color).add_modifier(Modifier::BOLD); - - Line::from(vec![Span::styled( - format!("{} ({})", name, name.to_lowercase()), - style, - )]) -} - /// Build a line for a file entry -fn build_file_line<'a>(file: &'a FileChange, is_selected: bool, theme: &'a Theme) -> Line<'a> { - let status_char = match file.status { - FileStatus::Staged => "S", - FileStatus::Modified => "M", - FileStatus::Untracked => "?", - FileStatus::Deleted => "D", - FileStatus::Renamed => "R", - FileStatus::Conflicted => "!", - _ => " ", - }; - - let status_style = match file.status { - FileStatus::Staged => style_for_git_status(theme, "staged"), - FileStatus::Modified => style_for_git_status(theme, "modified"), - FileStatus::Untracked => style_for_git_status(theme, "untracked"), - FileStatus::Deleted => style_for_git_status(theme, "deleted"), - FileStatus::Renamed => style_for_git_status(theme, "modified"), - FileStatus::Conflicted => style_for_ui_element(theme, UiElement::Error), - _ => style_for_ui_element(theme, UiElement::Text), - }; - - let path_style = if is_selected { - style_for_ui_element(theme, UiElement::ActiveItem) - } else { - style_for_ui_element(theme, UiElement::Text) - }; - - let mut spans = vec![ - Span::styled(format!(" {} ", status_char), status_style), - Span::styled(file.path.clone(), path_style), - ]; - - // Add change counts if available - if let (Some(adds), Some(dels)) = (file.additions, file.deletions) { - if adds > 0 || dels > 0 { - spans.push(Span::raw(" ")); - if adds > 0 { - spans.push(Span::styled( - format!("+{}", adds), - style_for_git_status(theme, "added"), - )); - } - if dels > 0 { - spans.push(Span::styled( - format!("-{}", dels), - style_for_git_status(theme, "deleted"), - )); - } - } - } - - Line::from(spans) -} - -fn build_commit_line<'a>( - hash: &'a str, - subject: &'a str, - is_selected: bool, - theme: &'a Theme, -) -> Line<'a> { - let hash_style = if is_selected { - style_for_ui_element(theme, UiElement::ActiveItem) - } else { - style_for_ui_element(theme, UiElement::Secondary) - }; - - let subject_style = if is_selected { - style_for_ui_element(theme, UiElement::ActiveItem) - } else { - style_for_ui_element(theme, UiElement::Text) - }; - - let arrow = if is_selected { "▸ " } else { " " }; - - Line::from(vec![ - Span::styled(arrow, hash_style), - Span::styled(format!("{} ", hash), hash_style), - Span::styled(subject.to_string(), subject_style), - ]) -} - /// Calculate scroll offset to keep selection visible fn calculate_scroll_offset( total_lines: usize, @@ -1134,131 +1006,6 @@ fn render_stash_details( } /// Render a modal overlay -fn render_modal_overlay(state: &PluginState, area: Rect, buf: &mut Buffer, theme: &Theme) { - let Some(ref modal) = state.active_modal else { - return; - }; - - let Some(modal_area) = git_modal_area(area) else { - return; - }; - - // Clear the area - for row in modal_area.top()..modal_area.bottom() { - for col in modal_area.left()..modal_area.right() { - if let Some(cell) = buf.cell_mut((col, row)) { - cell.set_char(' '); - cell.set_style(Style::default()); - } - } - } - - let (title, body) = match modal { - super::state::GitModal::CommitMessage => ("Commit", "Enter commit message..."), - super::state::GitModal::CreateBranch => ("Create Branch", "Enter branch name..."), - super::state::GitModal::DeleteBranch { name } => { - // Can't return a reference to a temporary, so handle inline - let block = Block::default() - .title(" Delete Branch ") - .borders(Borders::ALL) - .border_style(style_for_ui_element(theme, UiElement::Error)); - let inner = block.inner(modal_area); - block.render(modal_area, buf); - - let text = Paragraph::new(vec![ - Line::styled( - format!("Delete branch '{}'?", name), - style_for_ui_element(theme, UiElement::Text), - ), - Line::raw(""), - Line::styled( - GIT_DELETE_BRANCH_MODAL_HINT, - style_for_ui_element(theme, UiElement::MutedText), - ), - ]); - text.render(inner, buf); - return; - } - super::state::GitModal::DropStash { index } => { - let block = Block::default() - .title(" Drop Stash ") - .borders(Borders::ALL) - .border_style(style_for_ui_element(theme, UiElement::Error)); - let inner = block.inner(modal_area); - block.render(modal_area, buf); - - let text = Paragraph::new(vec![ - Line::styled( - format!("Drop stash@{{{}}}?", index), - style_for_ui_element(theme, UiElement::Text), - ), - Line::raw(""), - Line::styled( - GIT_DROP_STASH_MODAL_HINT, - style_for_ui_element(theme, UiElement::MutedText), - ), - ]); - text.render(inner, buf); - return; - } - super::state::GitModal::Error { message } => { - let block = Block::default() - .title(" Error ") - .borders(Borders::ALL) - .border_style(style_for_ui_element(theme, UiElement::Error)); - let inner = block.inner(modal_area); - block.render(modal_area, buf); - - let text = Paragraph::new(vec![ - Line::styled( - message.clone(), - style_for_ui_element(theme, UiElement::Error), - ), - Line::raw(""), - Line::styled( - GIT_ERROR_MODAL_HINT, - style_for_ui_element(theme, UiElement::MutedText), - ), - ]) - .wrap(ratatui::widgets::Wrap { trim: false }); - text.render(inner, buf); - return; - } - }; - - let block = Block::default() - .title(format!(" {} ", title)) - .borders(Borders::ALL) - .border_style(style_for_ui_element(theme, UiElement::Primary)); - let inner = block.inner(modal_area); - block.render(modal_area, buf); - - let text = Paragraph::new(vec![ - Line::styled(body, style_for_ui_element(theme, UiElement::Text)), - Line::raw(""), - Line::styled( - GIT_CANCEL_MODAL_HINT, - style_for_ui_element(theme, UiElement::MutedText), - ), - ]); - text.render(inner, buf); -} - -fn git_modal_area(area: Rect) -> Option { - if area.width < MIN_GIT_MODAL_WIDTH || area.height < MIN_GIT_MODAL_HEIGHT { - return None; - } - - let width = GIT_MODAL_WIDTH.min(area.width); - let height = GIT_MODAL_HEIGHT.min(area.height); - let x = area.x.saturating_add(area.width.saturating_sub(width) / 2); - let y = area - .y - .saturating_add(area.height.saturating_sub(height) / 2); - - Some(Rect::new(x, y, width, height)) -} - /// Render the status bar info pub fn render_status_info(state: &PluginState) -> String { let mut parts = Vec::new(); @@ -1304,216 +1051,11 @@ pub fn render_status_info(state: &PluginState) -> String { parts.join(" | ") } -fn git_changes_empty_message(state: &PluginState, width: u16) -> String { - if state.branch.is_empty() { - git_empty_message( - vec![ - "Git status not loaded yet".to_string(), - String::new(), - "r: Refresh git status".to_string(), - ], - width, - ) - } else { - git_empty_message( - vec![ - "Working tree clean".to_string(), - String::new(), - "B: Branches".to_string(), - "H: History".to_string(), - "r: Refresh git status".to_string(), - ], - width, - ) - } -} - -fn git_diff_empty_message(state: &PluginState, width: u16) -> String { - if state.branch.is_empty() { - git_empty_message( - vec![ - "Git status not loaded yet".to_string(), - String::new(), - "r: Refresh git status".to_string(), - ], - width, - ) - } else if state.files.is_empty() { - git_empty_message( - vec![ - "Working tree clean".to_string(), - String::new(), - "H: History".to_string(), - "B: Branches".to_string(), - "r: Refresh git status".to_string(), - ], - width, - ) - } else { - git_empty_message( - vec![ - "No file selected".to_string(), - String::new(), - "j/k: Navigate files".to_string(), - "Tab/Shift+Tab: Switch pane".to_string(), - "S: Status".to_string(), - "H: History".to_string(), - "B: Branches".to_string(), - "r: Refresh git status".to_string(), - ], - width, - ) - } -} - -fn git_file_no_diff_message(file: &FileChange, width: u16) -> String { - git_empty_message( - vec![ - format!( - "Diff not loaded yet for {}", - truncate_display(&file.path, 48) - ), - String::new(), - "j/k: Navigate files".to_string(), - "s: Stage".to_string(), - "u: Unstage".to_string(), - "c: Commit".to_string(), - "r: Refresh git status".to_string(), - ], - width, - ) -} - -fn git_sidebar_empty_message(state: &PluginState, width: u16) -> String { - if state.branch.is_empty() { - git_empty_message( - vec![ - "Git status not loaded yet".to_string(), - String::new(), - "r: Refresh git status".to_string(), - ], - width, - ) - } else { - git_empty_message( - vec![ - "Working tree clean".to_string(), - String::new(), - "B: Branches".to_string(), - "H: History".to_string(), - "r: Refresh git status".to_string(), - ], - width, - ) - } -} - -fn git_commits_empty_message(width: u16) -> String { - git_empty_message( - vec![ - "No commits".to_string(), - String::new(), - "S: Status".to_string(), - "B: Branches".to_string(), - "r: Refresh git status".to_string(), - ], - width, - ) -} - -fn git_commit_details_empty_message(state: &PluginState, width: u16) -> String { - if state.commits.is_empty() { - git_commits_empty_message(width) - } else { - git_empty_message( - vec![ - "No commit selected".to_string(), - String::new(), - "j/k: Navigate commits".to_string(), - "Tab/Shift+Tab: Switch pane".to_string(), - "S: Status".to_string(), - "B: Branches".to_string(), - "r: Refresh git status".to_string(), - ], - width, - ) - } -} - -fn git_branches_empty_message(width: u16) -> String { - git_empty_message( - vec![ - "No branches".to_string(), - String::new(), - "S: Status".to_string(), - "H: History".to_string(), - "r: Refresh git status".to_string(), - ], - width, - ) -} - -fn git_branch_details_empty_message(state: &PluginState, width: u16) -> String { - if state.branches.is_empty() { - git_branches_empty_message(width) - } else { - git_empty_message( - vec![ - "No branch selected".to_string(), - String::new(), - "j/k: Navigate branches".to_string(), - "Tab/Shift+Tab: Switch pane".to_string(), - "n: New branch".to_string(), - "S: Status".to_string(), - "H: History".to_string(), - "r: Refresh git status".to_string(), - ], - width, - ) - } -} - -fn git_stashes_empty_message(width: u16) -> String { - git_empty_message( - vec![ - "No stashes".to_string(), - String::new(), - "s: Save stash".to_string(), - "S: Status".to_string(), - "B: Branches".to_string(), - "r: Refresh git status".to_string(), - ], - width, - ) -} - -fn git_stash_details_empty_message(state: &PluginState, width: u16) -> String { - if state.stashes.is_empty() { - git_stashes_empty_message(width) - } else { - git_empty_message( - vec![ - "No stash selected".to_string(), - String::new(), - "j/k: Navigate stashes".to_string(), - "Tab/Shift+Tab: Switch pane".to_string(), - "s: Save stash".to_string(), - "S: Status".to_string(), - "B: Branches".to_string(), - "r: Refresh git status".to_string(), - ], - width, - ) - } -} - -fn git_empty_message(lines: Vec, width: u16) -> String { - global_hint_message(lines, width) -} - #[cfg(test)] mod tests { use super::*; + use crate::core::models::{FileChange, FileStatus}; + use crate::ui::truncate_display; #[test] fn test_build_file_line() { diff --git a/src/plugins/gitstatus/render/modals.rs b/src/plugins/gitstatus/render/modals.rs new file mode 100644 index 0000000..586b364 --- /dev/null +++ b/src/plugins/gitstatus/render/modals.rs @@ -0,0 +1,150 @@ +//! Modal overlay rendering for the git status plugin. +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Style; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Paragraph, Widget}; + +use crate::core::models::Theme; +use crate::theme::{UiElement, style_for_ui_element}; + +use super::super::state::{GitModal, PluginState}; + +pub(super) const GIT_DELETE_BRANCH_MODAL_HINT: &str = "Enter/D: Delete | Esc: Cancel"; +pub(super) const GIT_DROP_STASH_MODAL_HINT: &str = "Enter/D: Drop | Esc: Cancel"; +pub(super) const GIT_CANCEL_MODAL_HINT: &str = "Esc: Cancel"; +pub(super) const GIT_ERROR_MODAL_HINT: &str = "Esc: Close"; +pub(super) const GIT_MODAL_WIDTH: u16 = 50; +pub(super) const GIT_MODAL_HEIGHT: u16 = 7; +pub(super) const MIN_GIT_MODAL_WIDTH: u16 = 20; +pub(super) const MIN_GIT_MODAL_HEIGHT: u16 = 5; + +pub(super) fn render_modal_overlay( + state: &PluginState, + area: Rect, + buf: &mut Buffer, + theme: &Theme, +) { + let Some(ref modal) = state.active_modal else { + return; + }; + + let Some(modal_area) = git_modal_area(area) else { + return; + }; + + // Clear the area + for row in modal_area.top()..modal_area.bottom() { + for col in modal_area.left()..modal_area.right() { + if let Some(cell) = buf.cell_mut((col, row)) { + cell.set_char(' '); + cell.set_style(Style::default()); + } + } + } + + let (title, body) = match modal { + GitModal::CommitMessage => ("Commit", "Enter commit message..."), + GitModal::CreateBranch => ("Create Branch", "Enter branch name..."), + GitModal::DeleteBranch { name } => { + // Can't return a reference to a temporary, so handle inline + let block = Block::default() + .title(" Delete Branch ") + .borders(Borders::ALL) + .border_style(style_for_ui_element(theme, UiElement::Error)); + let inner = block.inner(modal_area); + block.render(modal_area, buf); + + let text = Paragraph::new(vec![ + Line::styled( + format!("Delete branch '{}'?", name), + style_for_ui_element(theme, UiElement::Text), + ), + Line::raw(""), + Line::styled( + GIT_DELETE_BRANCH_MODAL_HINT, + style_for_ui_element(theme, UiElement::MutedText), + ), + ]); + text.render(inner, buf); + return; + } + GitModal::DropStash { index } => { + let block = Block::default() + .title(" Drop Stash ") + .borders(Borders::ALL) + .border_style(style_for_ui_element(theme, UiElement::Error)); + let inner = block.inner(modal_area); + block.render(modal_area, buf); + + let text = Paragraph::new(vec![ + Line::styled( + format!("Drop stash@{{{}}}?", index), + style_for_ui_element(theme, UiElement::Text), + ), + Line::raw(""), + Line::styled( + GIT_DROP_STASH_MODAL_HINT, + style_for_ui_element(theme, UiElement::MutedText), + ), + ]); + text.render(inner, buf); + return; + } + GitModal::Error { message } => { + let block = Block::default() + .title(" Error ") + .borders(Borders::ALL) + .border_style(style_for_ui_element(theme, UiElement::Error)); + let inner = block.inner(modal_area); + block.render(modal_area, buf); + + let text = Paragraph::new(vec![ + Line::styled( + message.clone(), + style_for_ui_element(theme, UiElement::Error), + ), + Line::raw(""), + Line::styled( + GIT_ERROR_MODAL_HINT, + style_for_ui_element(theme, UiElement::MutedText), + ), + ]) + .wrap(ratatui::widgets::Wrap { trim: false }); + text.render(inner, buf); + return; + } + }; + + let block = Block::default() + .title(format!(" {} ", title)) + .borders(Borders::ALL) + .border_style(style_for_ui_element(theme, UiElement::Primary)); + let inner = block.inner(modal_area); + block.render(modal_area, buf); + + let text = Paragraph::new(vec![ + Line::styled(body, style_for_ui_element(theme, UiElement::Text)), + Line::raw(""), + Line::styled( + GIT_CANCEL_MODAL_HINT, + style_for_ui_element(theme, UiElement::MutedText), + ), + ]); + text.render(inner, buf); +} + +pub(super) fn git_modal_area(area: Rect) -> Option { + if area.width < MIN_GIT_MODAL_WIDTH || area.height < MIN_GIT_MODAL_HEIGHT { + return None; + } + + let width = GIT_MODAL_WIDTH.min(area.width); + let height = GIT_MODAL_HEIGHT.min(area.height); + let x = area.x.saturating_add(area.width.saturating_sub(width) / 2); + let y = area + .y + .saturating_add(area.height.saturating_sub(height) / 2); + + Some(Rect::new(x, y, width, height)) +} diff --git a/src/plugins/gitstatus/render/time_fmt.rs b/src/plugins/gitstatus/render/time_fmt.rs new file mode 100644 index 0000000..a0c97c2 --- /dev/null +++ b/src/plugins/gitstatus/render/time_fmt.rs @@ -0,0 +1,38 @@ +//! Time-formatting helpers for the git status plugin. +use crate::ui::count_label; + +pub(super) fn format_relative_time(date: &chrono::DateTime) -> String { + let now = chrono::Utc::now(); + let duration = now.signed_duration_since(*date); + + if duration.num_minutes() < 1 { + "just now".to_string() + } else if duration.num_minutes() < 60 { + relative_time_label(duration.num_minutes(), "min", "mins") + } else if duration.num_hours() < 24 { + relative_time_label(duration.num_hours(), "hour", "hours") + } else if duration.num_days() < 7 { + relative_time_label(duration.num_days(), "day", "days") + } else { + date.format("%Y-%m-%d").to_string() + } +} + +fn relative_time_label(count: i64, singular: &str, plural: &str) -> String { + format!("{} ago", count_label(count as usize, singular, plural)) +} + +pub(super) fn count_title( + singular: &str, + plural: &str, + count: usize, + unit_singular: &str, + unit_plural: &str, +) -> String { + let title = if count == 1 { singular } else { plural }; + format!( + " {} ({}) ", + title, + count_label(count, unit_singular, unit_plural) + ) +} diff --git a/src/plugins/workers/plugin.rs b/src/plugins/workers/plugin.rs index 0654a54..599a5af 100644 --- a/src/plugins/workers/plugin.rs +++ b/src/plugins/workers/plugin.rs @@ -242,8 +242,9 @@ impl WorkersPlugin { let content = tokio::fs::read_to_string(path).await?; let doc = parse_spec_document(&content) .map_err(|e| anyhow::anyhow!("Failed to parse spec: {}", e))?; - let intent = build_intent_from_spec(doc, path.clone()) - .map_err(|e| anyhow::anyhow!("Failed to build intent: {}", e))?; + let intent = + build_intent_from_spec(doc, path.clone(), Some("test-fallback-id".to_string())) + .map_err(|e| anyhow::anyhow!("Failed to build intent: {}", e))?; Ok(intent) } @@ -278,7 +279,7 @@ impl WorkersPlugin { // Generate spec content let now = chrono::Utc::now().to_rfc3339(); - let spec_content = generate_default_spec(title, &now); + let spec_content = generate_default_spec(title, &now, "test-suffix"); // Write file tokio::fs::write(&spec_path, spec_content).await?; @@ -368,6 +369,7 @@ impl WorkersPlugin { .logs_dir .join(format!("{}-investigator.log", intent_id)), &now, + "test-worker-id-10", ); let implementer = Worker::new( @@ -381,6 +383,7 @@ impl WorkersPlugin { .logs_dir .join(format!("{}-implementer.log", intent_id)), &now, + "test-worker-id-11", ) .depends_on(investigator.id.clone()); @@ -395,6 +398,7 @@ impl WorkersPlugin { .logs_dir .join(format!("{}-verifier.log", intent_id)), &now, + "test-worker-id-12", ) .depends_on(implementer.id.clone()); @@ -1329,6 +1333,7 @@ mod tests { "Kanban open polish", PathBuf::from(".rightclick/intents/kanban-open.md"), "2026-05-26T10:00:00Z", + "test-intent-id-3", ); intent.id = "intent-kanban-open".to_string(); plugin.state.add_intent(intent); @@ -1402,6 +1407,7 @@ mod tests { "Improve search polish", PathBuf::from(".rightclick/intents/search-polish.md"), "2026-05-26T10:00:00Z", + "test-intent-id-4", ); intent.id = "intent-search-polish".to_string(); intent.status = IntentStatus::Ready; @@ -1423,12 +1429,14 @@ mod tests { "First", PathBuf::from(".rightclick/intents/first.md"), "2026-05-26T10:00:00Z", + "test-intent-id-5", ); first.id = "intent-first".to_string(); let mut second = Intent::new( "Second", PathBuf::from(".rightclick/intents/second.md"), "2026-05-26T10:00:00Z", + "test-intent-id-6", ); second.id = "intent-second".to_string(); plugin.state.add_intent(first); @@ -1482,6 +1490,7 @@ mod tests { "Ship workers polish", PathBuf::from(".rightclick/intents/workers-polish.md"), "2026-05-26T10:00:00Z", + "test-intent-id-7", ); intent.id = "intent-workers-polish".to_string(); plugin.state.add_intent(intent); @@ -1495,6 +1504,7 @@ mod tests { "claude", PathBuf::from("/repo/log1"), "2026-05-26T10:00:00Z", + "test-worker-id-13", ); running.mark_running(); let mut failed = Worker::new( @@ -1506,6 +1516,7 @@ mod tests { "claude", PathBuf::from("/repo/log2"), "2026-05-26T10:00:00Z", + "test-worker-id-14", ); failed.mark_failed("2026-05-26T11:00:00Z", 1); @@ -1525,6 +1536,7 @@ mod tests { "Ship workers polish", PathBuf::from(".rightclick/intents/workers-polish.md"), "2026-05-26T10:00:00Z", + "test-intent-id-8", ); intent.id = "intent-workers-polish".to_string(); plugin.state.add_intent(intent); @@ -1538,6 +1550,7 @@ mod tests { "claude", PathBuf::from("/repo/log1"), "2026-05-26T10:00:00Z", + "test-worker-id-15", ); worker.mark_completed("2026-05-26T11:00:00Z"); plugin.state.add_worker(worker); @@ -1555,6 +1568,7 @@ mod tests { "Ship workers polish", PathBuf::from(".rightclick/intents/workers-polish.md"), "2026-05-26T10:00:00Z", + "test-intent-id-9", ); intent.id = "intent-workers-polish".to_string(); plugin.state.add_intent(intent); @@ -1573,6 +1587,7 @@ mod tests { "claude", PathBuf::from("/repo/log1"), "2026-05-26T10:00:00Z", + "test-worker-id-16", ); failed.mark_failed("2026-05-26T11:00:00Z", 1); plugin.state.add_worker(failed); diff --git a/src/plugins/workers/render.rs b/src/plugins/workers/render.rs index 84dcefe..d4f1fcf 100644 --- a/src/plugins/workers/render.rs +++ b/src/plugins/workers/render.rs @@ -1013,6 +1013,7 @@ mod tests { "Worker status polish", PathBuf::from("intents/status-polish.md"), "2026-05-26T10:00:00Z", + "test-intent-id-3", )); let theme = Theme::default(); @@ -1043,6 +1044,7 @@ mod tests { "Remove stale worker intent", PathBuf::from("intents/remove-stale-worker-intent.md"), "2026-05-26T10:00:00Z", + "test-intent-id-4", )); state.selected_intent = Some(0); state.modal_state = ModalState::DeleteConfirm; @@ -1194,6 +1196,7 @@ mod tests { "Improve worker empty states", PathBuf::from(".rightclick/intents/empty-states.md"), "2026-02-14T10:00:00Z", + "test-intent-id-5", )); empty_state.selected_intent = None; assert_hint(&output_empty_message(&empty_state, 80)); @@ -1304,6 +1307,7 @@ mod tests { "Improve worker UX", PathBuf::from(".rightclick/intents/worker-ux.md"), "2026-02-14T10:00:00Z", + "test-intent-id-6", )); state.selected_intent = Some(0); @@ -1337,6 +1341,7 @@ mod tests { "Improve worker navigation", PathBuf::from(".rightclick/intents/worker-navigation.md"), "2026-02-14T10:00:00Z", + "test-intent-id-7", )); state.selected_intent = None; @@ -1426,6 +1431,7 @@ mod tests { "Clarify criteria navigation", PathBuf::from(".rightclick/intents/criteria-navigation.md"), "2026-02-14T10:00:00Z", + "test-intent-id-8", )); state.selected_intent = None; @@ -1463,6 +1469,7 @@ mod tests { "Clarify worker acceptance criteria", PathBuf::from(".rightclick/intents/criteria.md"), "2026-02-14T10:00:00Z", + "test-intent-id-9", )); state.selected_intent = Some(0); @@ -1501,6 +1508,7 @@ mod tests { "Improve output UX", PathBuf::from(".rightclick/intents/output-ux.md"), "2026-02-14T10:00:00Z", + "test-intent-id-10", )); state.selected_intent = None; state.preview_tab = PreviewTab::Output; @@ -1537,6 +1545,7 @@ mod tests { "Improve output run state", PathBuf::from(".rightclick/intents/output-run.md"), "2026-02-14T10:00:00Z", + "test-intent-id-11", )); state.selected_intent = Some(0); state.preview_tab = PreviewTab::Output; @@ -1578,6 +1587,7 @@ mod tests { "claude", PathBuf::from("/repo/log1"), "2026-02-14T10:00:00Z", + "test-worker-id-12", ); // w1 is Pending by default @@ -1590,6 +1600,7 @@ mod tests { "claude", PathBuf::from("/repo/log2"), "2026-02-14T10:00:00Z", + "test-worker-id-13", ); w2.mark_running(); @@ -1602,6 +1613,7 @@ mod tests { "claude", PathBuf::from("/repo/log3"), "2026-02-14T10:00:00Z", + "test-worker-id-14", ); w3.mark_completed("2026-02-14T11:00:00Z"); @@ -1646,6 +1658,7 @@ mod tests { "claude", PathBuf::from("/repo/log1"), "échéance-2026-05-26", + "test-worker-id-15", ); state .workers diff --git a/src/plugins/workers/state.rs b/src/plugins/workers/state.rs index 7bd6bf8..84ce5a9 100644 --- a/src/plugins/workers/state.rs +++ b/src/plugins/workers/state.rs @@ -578,16 +578,19 @@ mod tests { "Intent 1", PathBuf::from("i1.md"), "2026-02-14T10:00:00Z", + "test-intent-id-3", ))); state.intents.push(IntentEntry::new(Intent::new( "Intent 2", PathBuf::from("i2.md"), "2026-02-14T10:00:00Z", + "test-intent-id-4", ))); state.intents.push(IntentEntry::new(Intent::new( "Intent 3", PathBuf::from("i3.md"), "2026-02-14T10:00:00Z", + "test-intent-id-5", ))); // Initially no selection @@ -610,7 +613,12 @@ mod tests { fn test_add_and_remove_intent() { let mut state = PluginState::new(PathBuf::from("intents"), PathBuf::from("logs")); - let intent = Intent::new("Test", PathBuf::from("test.md"), "2026-02-14T10:00:00Z"); + let intent = Intent::new( + "Test", + PathBuf::from("test.md"), + "2026-02-14T10:00:00Z", + "test-intent-id-6", + ); let id = intent.id.clone(); state.add_intent(intent); @@ -632,6 +640,7 @@ mod tests { "claude", PathBuf::from("/repo/log"), "2026-02-14T10:00:00Z", + "test-worker-id-10", ); let mut entry = WorkerEntry::new(worker); @@ -649,13 +658,28 @@ mod tests { fn test_intent_groups() { let mut state = PluginState::new(PathBuf::from("intents"), PathBuf::from("logs")); - let mut intent1 = Intent::new("Draft", PathBuf::from("d.md"), "2026-02-14T10:00:00Z"); + let mut intent1 = Intent::new( + "Draft", + PathBuf::from("d.md"), + "2026-02-14T10:00:00Z", + "test-intent-id-7", + ); intent1.status = IntentStatus::Draft; - let mut intent2 = Intent::new("Ready", PathBuf::from("r.md"), "2026-02-14T10:00:00Z"); + let mut intent2 = Intent::new( + "Ready", + PathBuf::from("r.md"), + "2026-02-14T10:00:00Z", + "test-intent-id-8", + ); intent2.status = IntentStatus::Ready; - let mut intent3 = Intent::new("In Progress", PathBuf::from("p.md"), "2026-02-14T10:00:00Z"); + let mut intent3 = Intent::new( + "In Progress", + PathBuf::from("p.md"), + "2026-02-14T10:00:00Z", + "test-intent-id-9", + ); intent3.status = IntentStatus::InProgress; state.add_intent(intent1); diff --git a/src/shell/machines/mod.rs b/src/shell/machines/mod.rs index 8b39c80..e9175c1 100644 --- a/src/shell/machines/mod.rs +++ b/src/shell/machines/mod.rs @@ -40,7 +40,8 @@ mod git_state_machine; -use std::sync::{Arc, Mutex}; +use parking_lot::Mutex; +use std::sync::Arc; use crate::core::logic::guards::check_guard; use crate::core::logic::navigation::{apply_navigation, calculate_navigation}; @@ -181,7 +182,7 @@ impl StateMachineExecutor { /// /// Returns a clone of the current view state. pub fn current_state(&self) -> ViewState { - let machine = self.machine.lock().unwrap(); + let machine = self.machine.lock(); machine.current.clone() } @@ -189,7 +190,7 @@ impl StateMachineExecutor { /// /// Returns a clone of the current state context. pub fn context(&self) -> StateContext { - let machine = self.machine.lock().unwrap(); + let machine = self.machine.lock(); machine.context.clone() } @@ -212,7 +213,7 @@ impl StateMachineExecutor { where F: FnOnce(&mut StateContext), { - let mut machine = self.machine.lock().unwrap(); + let mut machine = self.machine.lock(); updater(&mut machine.context); } @@ -225,7 +226,7 @@ impl StateMachineExecutor { /// # Arguments /// * `count` - New number of items available pub fn set_item_count(&self, count: usize) { - let mut machine = self.machine.lock().unwrap(); + let mut machine = self.machine.lock(); machine.context.item_count = count; // Validate selected index is still valid @@ -248,7 +249,7 @@ impl StateMachineExecutor { /// # Arguments /// * `index` - New selection index, or None to clear selection pub fn set_selected_index(&self, index: Option) { - let mut machine = self.machine.lock().unwrap(); + let mut machine = self.machine.lock(); let old_state = machine.current.clone(); machine.context.selected_index = index; @@ -283,7 +284,7 @@ impl StateMachineExecutor { pub fn handle_navigation(&self, direction: NavDirection) -> NavigationResult { // Calculate navigation using core logic let (result, new_state, old_state, new_index) = { - let machine = self.machine.lock().unwrap(); + let machine = self.machine.lock(); let result = calculate_navigation(direction, &machine.context); let new_state = apply_navigation(machine.current.clone(), &result); @@ -301,7 +302,7 @@ impl StateMachineExecutor { // Apply changes and trigger callback if state changed if new_state != old_state { { - let mut machine = self.machine.lock().unwrap(); + let mut machine = self.machine.lock(); machine.current = new_state.clone(); // Update selected index from navigation result @@ -337,7 +338,7 @@ impl StateMachineExecutor { pub fn execute_action(&self, action: ActionId) -> ActionResult { // Build action context and check guard let guard_result = { - let machine = self.machine.lock().unwrap(); + let machine = self.machine.lock(); let action_ctx = ActionContext::new(action, machine.current.clone(), machine.context.clone()); check_guard(&action_ctx) @@ -364,7 +365,7 @@ impl StateMachineExecutor { /// Some actions cause state transitions (e.g., Select transitions to ItemSelected). fn update_state_after_action(&self, action: ActionId) { let (old_state, new_state) = { - let mut machine = self.machine.lock().unwrap(); + let mut machine = self.machine.lock(); let old_state = machine.current.clone(); let new_state = match action { @@ -416,7 +417,7 @@ impl StateMachineExecutor { /// # Returns /// true if the action is authorized, false otherwise pub fn can_execute(&self, action: ActionId) -> bool { - let machine = self.machine.lock().unwrap(); + let machine = self.machine.lock(); let action_ctx = ActionContext::new(action, machine.current.clone(), machine.context.clone()); @@ -428,7 +429,7 @@ impl StateMachineExecutor { /// Returns the list of actions that are available in the current /// view state (not accounting for guard checks). pub fn available_actions(&self) -> Vec { - let machine = self.machine.lock().unwrap(); + let machine = self.machine.lock(); machine.available_actions() } diff --git a/src/shell/usecases/mod.rs b/src/shell/usecases/mod.rs index a07d8ce..834ee7e 100644 --- a/src/shell/usecases/mod.rs +++ b/src/shell/usecases/mod.rs @@ -20,6 +20,9 @@ use std::path::Path; use std::sync::Arc; use tracing::{debug, info, instrument}; +use crate::core::logic::project::{ + build_project_config, find_existing_project, validate_project_path, +}; use crate::core::models::config::ProjectConfig; use crate::shell::repositories::{ConfigRepository, StateRepository}; use crate::shell::services::GitService; @@ -154,49 +157,27 @@ impl AppUsecase { pub async fn load_project(&self, path: &Path) -> Result { info!("Loading project"); - // Validate path - if !path.exists() { - anyhow::bail!("Path does not exist: {}", path.display()); - } - if !path.is_dir() { - anyhow::bail!("Path is not a directory: {}", path.display()); - } + // 1. Validation delegated to the Core (pure). + validate_project_path(path).map_err(|err| anyhow::anyhow!(err))?; - // Load config to check if project already exists + // 2. Load config (I/O). let config = self .config_repo .load() .await .context("Failed to load configuration")?; - // Check if project already exists by path - let existing_project = config.projects.list.iter().find(|p| { - let project_path = std::path::Path::new(&p.path); - project_path == path - }); - - let project_config = if let Some(existing) = existing_project { - debug!("Found existing project: {}", existing.id); - existing.clone() - } else { - // Create new project config - let project_name = path - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("unnamed") - .to_string(); - - let project_id = format!("{}", uuid::Uuid::new_v4()); - - ProjectConfig { - id: project_id, - name: project_name, - path: path.to_string_lossy().to_string(), - description: None, - favorite: false, - tags: Vec::new(), - } - }; + // 3. Pure lookup for an existing project by path. + let project_config = + if let Some(existing) = find_existing_project(&config.projects.list, path) { + debug!("Found existing project: {}", existing.id); + existing.clone() + } else { + // 4. ID generation belongs to the Shell (non-determinism lives here); + // building the config is delegated to the pure Core. + let project_id = format!("{}", uuid::Uuid::new_v4()); + build_project_config(project_id, path) + }; let project = Project::new(project_config);