diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 21cd2c9cd..9a50070df 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -84,6 +84,8 @@ pub const KIND_STREAM_MESSAGE_BOOKMARKED: u32 = 40005; pub const KIND_STREAM_MESSAGE_SCHEDULED: u32 = 40006; /// A reminder attached to a stream message or time. pub const KIND_STREAM_REMINDER: u32 = 40007; +/// A diff/patch message showing file changes (unified diff format). +pub const KIND_STREAM_MESSAGE_DIFF: u32 = 40008; /// Canvas (shared document) for a channel. pub const KIND_CANVAS: u32 = 40100; /// System message for channel state changes (join, leave, rename, etc.). @@ -226,6 +228,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_REMINDER, + KIND_STREAM_MESSAGE_DIFF, KIND_CANVAS, KIND_SYSTEM_MESSAGE, KIND_DM_CREATED, diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 98d3c4120..da4b6033b 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -128,7 +128,7 @@ pub struct CreateWorkflowParams { /// UUID of the channel to own this workflow. pub channel_id: String, /// Full workflow definition in YAML format. Required fields: name (string), trigger (object with - /// 'on' field: 'message_posted', 'reaction_added', or 'webhook'), steps (array). + /// 'on' field: 'message_posted', 'diff_posted', 'reaction_added', or 'webhook'), steps (array). /// Each step needs: id (alphanumeric/underscore), action (e.g. 'send_message'), and action-specific /// fields as direct properties (NOT nested under 'params'). Example: /// ```yaml @@ -475,6 +475,130 @@ pub struct GetFeedActionsParams { pub limit: Option, } +/// Parameters for the `send_diff_message` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct SendDiffMessageParams { + /// UUID of the channel to post to. + pub channel_id: String, + /// Unified diff content (git diff format). + pub diff: String, + /// URL of the source repository (e.g. "https://github.com/org/repo"). + pub repo_url: String, + /// Full commit SHA this diff applies to. + pub commit_sha: String, + /// Optional file path within the repo (used for language inference and display). + #[serde(default)] + pub file_path: Option, + /// Optional parent commit SHA (the base of the diff). + #[serde(default)] + pub parent_commit_sha: Option, + /// Optional source branch name (e.g. "feat/my-feature"). + #[serde(default)] + pub source_branch: Option, + /// Optional target branch name (e.g. "main"). + #[serde(default)] + pub target_branch: Option, + /// Optional pull request number associated with this diff. + #[serde(default)] + pub pr_number: Option, + /// Optional language hint for syntax highlighting (e.g. "rust", "typescript"). + /// Inferred from file_path extension if omitted. + #[serde(default)] + pub language: Option, + /// Optional human-readable description of the change. + #[serde(default)] + pub description: Option, + /// Optional parent event ID. If provided, sends the diff as a threaded reply. + #[serde(default)] + pub parent_event_id: Option, +} + +// ── Diff utility functions ──────────────────────────────────────────────────── + +// Truncation notice appended when a diff is cut. This constant is used to +// reserve space so the final result never exceeds max_bytes. +// NOTE: This function is only called with max_bytes = 60 * 1024, so the +// hardcoded "60KB" in the notice is intentional and always accurate. +const TRUNCATION_NOTICE: &str = + "\n\\ Diff truncated at 60KB. Full diff available at the source repository."; + +/// Truncate a diff to at most `max_bytes` bytes, cutting at a hunk boundary +/// where possible. Returns the (possibly truncated) string and a flag indicating +/// whether truncation occurred. +/// +/// The truncation notice is included within the `max_bytes` budget — the +/// returned string is guaranteed to be `<= max_bytes` in length. +fn truncate_diff(diff: &str, max_bytes: usize) -> (String, bool) { + debug_assert!( + max_bytes >= TRUNCATION_NOTICE.len(), + "max_bytes ({max_bytes}) must be >= TRUNCATION_NOTICE length ({})", + TRUNCATION_NOTICE.len() + ); + + if diff.len() <= max_bytes { + return (diff.to_string(), false); + } + + // Reserve space for the truncation notice so the final result stays within max_bytes. + let effective_limit = max_bytes.saturating_sub(TRUNCATION_NOTICE.len()); + + // Step 1: Find the last UTF-8 char boundary at or before effective_limit + let utf8_boundary = diff + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= effective_limit) + .last() + .unwrap_or(0); + + // Step 2: Within safe prefix, find last complete hunk boundary + let safe_prefix = &diff[..utf8_boundary]; + let last_hunk_start = safe_prefix.rfind("\n@@"); + + let cut_point = match last_hunk_start { + Some(pos) if pos > 0 => pos, + _ => safe_prefix.rfind('\n').unwrap_or(utf8_boundary), + }; + + let mut result = diff[..cut_point].to_string(); + result.push_str(TRUNCATION_NOTICE); + (result, true) +} + +/// Infer a language name from a file path's extension for syntax highlighting. +/// Returns `None` if the extension is unknown or absent. +fn infer_language(file_path: &str) -> Option { + // Note: rsplit always yields at least one element (the full string if no '.' found), + // so .next() always returns Some. The ? is effectively a no-op here. + let ext = file_path.rsplit('.').next()?; + let lang = match ext { + "rs" => "rust", + "ts" | "tsx" => "typescript", + "js" | "jsx" => "javascript", + "py" => "python", + "go" => "go", + "java" => "java", + "rb" => "ruby", + "c" | "h" => "c", + "cpp" | "cc" | "cxx" | "hpp" => "cpp", + "cs" => "csharp", + "swift" => "swift", + "kt" | "kts" => "kotlin", + "scala" => "scala", + "sh" | "bash" | "zsh" => "bash", + "sql" => "sql", + "html" | "htm" => "html", + "css" | "scss" | "sass" => "css", + "json" => "json", + "yaml" | "yml" => "yaml", + "toml" => "toml", + "xml" => "xml", + "md" | "markdown" => "markdown", + "dockerfile" => "dockerfile", + _ => return None, + }; + Some(lang.to_string()) +} + /// The MCP server that exposes Sprout relay functionality as tools. #[derive(Clone)] pub struct SproutMcpServer { @@ -531,6 +655,103 @@ impl SproutMcpServer { } } + /// Send a code diff to a Sprout channel as kind:40008. + #[tool( + name = "send_diff_message", + description = "Send a code diff to a Sprout channel with syntax highlighting and structured metadata. The diff is rendered with GitHub-quality visualization in the desktop client." + )] + pub async fn send_diff_message( + &self, + Parameters(p): Parameters, + ) -> String { + let SendDiffMessageParams { + channel_id, + diff, + repo_url, + commit_sha, + file_path, + parent_commit_sha, + source_branch, + target_branch, + pr_number, + language, + description, + parent_event_id, + } = p; + + if let Err(e) = validate_uuid(&channel_id) { + return format!("Error: {e}"); + } + + // 1. Truncate diff at 60KB (UTF-8 safe) + let (diff_content, truncated) = truncate_diff(&diff, 60 * 1024); + + // 2. Infer language from file extension if not provided + let lang = language.or_else(|| file_path.as_deref().and_then(infer_language)); + + // 3. Build NIP-31 alt tag + let alt_text = match &description { + Some(desc) => format!( + "Diff: {} — {}", + file_path.as_deref().unwrap_or("diff"), + desc + ), + None => format!("Diff: {}", file_path.as_deref().unwrap_or("diff")), + }; + + // 4. Build JSON body for REST endpoint + let mut body = serde_json::json!({ + "content": diff_content, + "kind": 40008_u32, + "broadcast_to_channel": false, + "diff_repo_url": repo_url, + "diff_commit_sha": commit_sha, + "diff_alt": alt_text, + }); + if let Some(ref parent) = parent_event_id { + body["parent_event_id"] = serde_json::Value::String(parent.clone()); + } + if let Some(ref file) = file_path { + body["diff_file_path"] = serde_json::Value::String(file.clone()); + } + if let Some(ref sha) = parent_commit_sha { + body["diff_parent_commit_sha"] = serde_json::Value::String(sha.clone()); + } + // Branch metadata — both source and target must be provided together + match (&source_branch, &target_branch) { + (Some(ref src), Some(ref tgt)) => { + body["diff_source_branch"] = serde_json::Value::String(src.clone()); + body["diff_target_branch"] = serde_json::Value::String(tgt.clone()); + } + (Some(_), None) | (None, Some(_)) => { + // Warn caller that partial branch metadata is discarded + tracing::warn!("send_diff_message: only one of source_branch/target_branch provided — both required, branch metadata omitted"); + } + (None, None) => {} // Both absent — no branch tag + } + if let Some(pr) = pr_number { + body["diff_pr_number"] = serde_json::json!(pr); + } + if let Some(ref l) = lang { + body["diff_language"] = serde_json::Value::String(l.clone()); + } + if let Some(ref desc) = description { + body["diff_description"] = serde_json::Value::String(desc.clone()); + } + if truncated { + body["diff_truncated"] = serde_json::json!(true); + } + + match self + .client + .post(&format!("/api/channels/{}/messages", channel_id), &body) + .await + { + Ok(b) => b, + Err(e) => format!("Error: {e}"), + } + } + /// Get recent messages from a Sprout channel. #[tool( name = "get_channel_history", @@ -1555,3 +1776,102 @@ mod tests { assert_eq!(MAX_CONTENT_BYTES, 65_536); } } + +#[cfg(test)] +mod diff_tests { + use super::*; + + #[test] + fn truncate_diff_small_passes_through() { + let diff = "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n"; + let (result, truncated) = truncate_diff(diff, 60 * 1024); + assert_eq!(result, diff); + assert!(!truncated); + } + + #[test] + fn truncate_diff_cuts_at_hunk_boundary() { + // Build a diff large enough that truncation is meaningful. + // Repeat the first hunk many times so the total is well above any + // reasonable max_bytes, then append a second hunk we want excluded. + let hunk_unit = "--- a/file\n+++ b/file\n@@ -1,3 +1,3 @@\n context\n-old\n+new\n"; + let mut diff = hunk_unit.repeat(20); // ~1140 bytes of first-hunk content + diff.push_str("@@ -10,3 +10,3 @@\n more context\n-old2\n+new2\n"); + + // max_bytes sits inside the repeated first-hunk region (well below total) + // but above TRUNCATION_NOTICE.len() so effective_limit > 0. + // effective_limit = max_bytes - TRUNCATION_NOTICE.len() ≈ 500 - 72 = 428, + // which lands inside the repeated first-hunk block. + let max_bytes = 500; + let (result, truncated) = truncate_diff(&diff, max_bytes); + assert!(truncated); + assert!( + result.contains("context"), + "should contain first-hunk content" + ); + assert!(result.contains("Diff truncated")); + assert!( + !result.contains("@@ -10,3"), + "second hunk should be excluded" + ); + // Result must not exceed max_bytes. + assert!( + result.len() <= max_bytes, + "truncated result ({}) exceeds max_bytes ({})", + result.len(), + max_bytes + ); + } + + #[test] + fn truncate_diff_utf8_safe() { + // Create a diff with multi-byte chars near the boundary + let mut diff = String::from("--- a/file\n+++ b/file\n@@ -1,1 +1,1 @@\n-"); + // Add enough content to exceed a small limit, with multi-byte chars + for _ in 0..100 { + diff.push('日'); // 3-byte UTF-8 char + } + diff.push('\n'); + let (result, truncated) = truncate_diff(&diff, 80); + assert!(truncated); + // Must not panic and must produce valid UTF-8 + assert!(result.is_char_boundary(result.len())); + } + + #[test] + fn truncate_diff_result_within_limit() { + let mut diff = String::new(); + for i in 0..2000 { + diff.push_str(&format!( + "@@ -{i},1 +{i},1 @@\n-old line {i}\n+new line {i}\n" + )); + } + let max = 1024; + let (result, truncated) = truncate_diff(&diff, max); + assert!(truncated); + assert!( + result.len() <= max, + "truncated result ({}) exceeds max_bytes ({})", + result.len(), + max + ); + } + + #[test] + fn infer_language_known_extensions() { + assert_eq!(infer_language("src/main.rs"), Some("rust".to_string())); + assert_eq!(infer_language("app.tsx"), Some("typescript".to_string())); + assert_eq!(infer_language("script.py"), Some("python".to_string())); + assert_eq!(infer_language("Makefile"), None); + } + + #[test] + fn infer_language_no_extension() { + assert_eq!(infer_language("Dockerfile"), None); + // But "foo.dockerfile" should match + assert_eq!( + infer_language("foo.dockerfile"), + Some("dockerfile".to_string()) + ); + } +} diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs index 87192106e..acb6f0c16 100644 --- a/crates/sprout-relay/src/api/messages.rs +++ b/crates/sprout-relay/src/api/messages.rs @@ -102,6 +102,137 @@ fn reactions_to_json(reactions: &[sprout_db::reaction::ReactionSummary]) -> serd // ── POST /api/channels/:channel_id/messages ─────────────────────────────────── +/// Build Nostr tags for a kind:40008 diff message. +/// +/// Required fields: `diff_repo_url` (must be http/https) and `diff_commit_sha`. +/// All other diff fields are optional. +fn build_diff_tags( + body: &SendMessageBody, +) -> Result, (StatusCode, axum::Json)> { + // repo-url is required and must be http/https + let repo_url = body.diff_repo_url.as_deref().ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "diff_repo_url is required for kind:40008", + ) + })?; + if !repo_url.starts_with("http://") && !repo_url.starts_with("https://") { + return Err(api_error( + StatusCode::BAD_REQUEST, + "diff_repo_url must use http or https scheme", + )); + } + + // commit-sha is required and must be a valid hex string (min 7 chars for short SHA) + let commit_sha = body + .diff_commit_sha + .as_deref() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + api_error( + StatusCode::BAD_REQUEST, + "diff_commit_sha is required for kind=40008", + ) + })?; + if commit_sha.len() < 7 || !commit_sha.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "diff_commit_sha must be a hex string (min 7 chars)", + )); + } + + let mut tags: Vec = Vec::new(); + + tags.push( + Tag::parse(&["repo", repo_url]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); + tags.push( + Tag::parse(&["commit", commit_sha]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); + + if let Some(ref file_path) = body.diff_file_path { + tags.push( + Tag::parse(&["file", file_path]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); + } + + if let Some(parent) = body + .diff_parent_commit_sha + .as_deref() + .filter(|s| !s.is_empty()) + { + if parent.len() < 7 || !parent.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "diff_parent_commit_sha must be a hex string (min 7 chars)", + )); + } + tags.push( + Tag::parse(&["parent-commit", parent]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); + } + + // Both source and target branch are required together + match (&body.diff_source_branch, &body.diff_target_branch) { + (Some(src), Some(tgt)) => { + tags.push( + Tag::parse(&["branch", src, tgt]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); + } + (None, None) => {} + _ => { + return Err(api_error( + StatusCode::BAD_REQUEST, + "diff_source_branch and diff_target_branch must both be provided or both omitted", + )); + } + } + + if let Some(pr_number) = body.diff_pr_number { + let pr_str = pr_number.to_string(); + tags.push( + Tag::parse(&["pr", &pr_str]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); + } + + if let Some(ref lang) = body.diff_language { + tags.push( + Tag::parse(&["l", lang]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); + } + + if let Some(ref description) = body.diff_description { + tags.push( + Tag::parse(&["description", description]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); + } + + if let Some(truncated) = body.diff_truncated { + let val = if truncated { "true" } else { "false" }; + tags.push( + Tag::parse(&["truncated", val]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); + } + + if let Some(ref alt) = body.diff_alt { + tags.push( + Tag::parse(&["alt", alt]) + .map_err(|e| internal_error(&format!("tag build error: {e}")))?, + ); + } + + Ok(tags) +} + /// Request body for sending a channel message or thread reply. #[derive(Debug, Deserialize)] pub struct SendMessageBody { @@ -114,6 +245,29 @@ pub struct SendMessageBody { pub broadcast_to_channel: bool, /// Nostr kind for this message. Defaults to `KIND_STREAM_MESSAGE` (40001). pub kind: Option, + // Diff metadata fields (only used when kind == KIND_STREAM_MESSAGE_DIFF) + /// Repository URL for the diff (required for kind:40008; must be http/https). + pub diff_repo_url: Option, + /// File path within the repository that this diff applies to. + pub diff_file_path: Option, + /// Commit SHA that produced this diff (required for kind:40008). + pub diff_commit_sha: Option, + /// Parent commit SHA (the base of the diff). + pub diff_parent_commit_sha: Option, + /// Source branch for the diff (e.g. feature branch). Must be paired with `diff_target_branch`. + pub diff_source_branch: Option, + /// Target branch for the diff (e.g. main). Must be paired with `diff_source_branch`. + pub diff_target_branch: Option, + /// Pull request number associated with this diff. + pub diff_pr_number: Option, + /// Programming language of the diffed file (e.g. `"rust"`, `"typescript"`). + pub diff_language: Option, + /// Human-readable description of what the diff changes. + pub diff_description: Option, + /// When `true`, the diff content was truncated to fit the 60KB limit. + pub diff_truncated: Option, + /// Plain-text alternative summary for clients that cannot render diffs. + pub diff_alt: Option, } /// Send a new channel message or reply to an existing thread. @@ -149,6 +303,13 @@ pub async fn send_message( // Resolve kind — default to KIND_STREAM_MESSAGE (40001). let kind_u32 = body.kind.unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE); + + if kind_u32 == sprout_core::kind::KIND_STREAM_MESSAGE_DIFF && body.content.len() > 60 * 1024 { + return Err(api_error( + StatusCode::BAD_REQUEST, + "diff content exceeds 60KB limit; truncate before sending", + )); + } let kind = Kind::from(kind_u32 as u16); // ── Resolve thread ancestry ─────────────────────────────────────────────── @@ -274,6 +435,11 @@ pub async fn send_message( ); } + if body.kind == Some(sprout_core::kind::KIND_STREAM_MESSAGE_DIFF) { + let diff_tags = build_diff_tags(&body)?; + tags.extend(diff_tags); + } + let event = EventBuilder::new(kind, &body.content, tags) .sign_with_keys(&state.relay_keypair) .map_err(|e| internal_error(&format!("event signing error: {e}")))?; @@ -486,6 +652,7 @@ pub async fn list_messages( "kind": m.kind, "created_at": m.created_at.timestamp(), "channel_id": m.channel_id.to_string(), + "tags": m.tags, }); if let Some(ref ts) = m.thread_summary { @@ -618,11 +785,14 @@ pub async fn get_thread( let relay_pk = state.relay_keypair.public_key(); let relay_pk_bytes = relay_pk.serialize().to_vec(); let root_author = effective_author(&root_event.event, &relay_pk); + let root_tags = + serde_json::to_value(&root_event.event.tags).unwrap_or(serde_json::Value::Array(vec![])); let mut root_obj = serde_json::json!({ "event_id": root_event.event.id.to_hex(), "pubkey": nostr_hex::encode(&root_author), "content": root_event.event.content, "kind": root_event.event.kind.as_u16(), + "tags": root_tags, "created_at": root_created_at, "channel_id": channel_id.to_string(), "thread_summary": summary.as_ref().map(|s| serde_json::json!({ @@ -677,6 +847,7 @@ pub async fn get_thread( "depth": r.depth, "created_at": r.created_at.timestamp(), "broadcast": r.broadcast, + "tags": r.tags, }); if let Some(reactions) = thread_reaction_map.get(&r.event_id) { diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs index 7f2dc0608..0d0b78241 100644 --- a/crates/sprout-relay/src/handlers/event.rs +++ b/crates/sprout-relay/src/handlers/event.rs @@ -11,9 +11,9 @@ use sprout_core::event::StoredEvent; use sprout_core::kind::{ event_kind_u32, is_ephemeral, is_workflow_execution_kind, KIND_AUTH, KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_EDIT, - KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, - KIND_STREAM_REMINDER, + KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, + KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, + KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, }; use sprout_core::verification::verify_event; @@ -581,6 +581,10 @@ fn extract_channel_id(event: &Event) -> Option { None } +// NOTE: This function only validates that channel-scoped kinds include an `h` tag. +// Kind-specific metadata validation (e.g., diff_repo_url for kind:40008) is NOT +// enforced on the WebSocket path — it is handled by the REST API layer (api/messages.rs). +// This follows the Nostr protocol model where the relay is kind-agnostic for content events. fn requires_h_channel_scope(kind: u32) -> bool { matches!( kind, @@ -591,6 +595,7 @@ fn requires_h_channel_scope(kind: u32) -> bool { | KIND_STREAM_MESSAGE_BOOKMARKED | KIND_STREAM_MESSAGE_SCHEDULED | KIND_STREAM_REMINDER + | KIND_STREAM_MESSAGE_DIFF | KIND_CANVAS | KIND_FORUM_POST | KIND_FORUM_VOTE @@ -603,13 +608,14 @@ mod tests { use super::requires_h_channel_scope; use sprout_core::kind::{ KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, - KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, }; #[test] fn channel_scoped_content_kinds_require_h_tags() { for kind in [ KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_DIFF, KIND_CANVAS, KIND_FORUM_POST, KIND_FORUM_VOTE, diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs index 48a4e22a7..965a2faf7 100644 --- a/crates/sprout-test-client/tests/e2e_mcp.rs +++ b/crates/sprout-test-client/tests/e2e_mcp.rs @@ -295,8 +295,8 @@ async fn test_mcp_initialize_and_list_tools() { assert_eq!( tools.len(), - 41, - "expected exactly 41 tools, got {}. Tools: {:?}", + 42, + "expected exactly 42 tools, got {}. Tools: {:?}", tools.len(), tools .iter() diff --git a/crates/sprout-workflow/src/lib.rs b/crates/sprout-workflow/src/lib.rs index ff212548d..609282211 100644 --- a/crates/sprout-workflow/src/lib.rs +++ b/crates/sprout-workflow/src/lib.rs @@ -351,6 +351,29 @@ async fn should_fire_workflow( } } + if let TriggerDef::DiffPosted { + filter: Some(ref expr), + } = def.trigger + { + match executor::evaluate_condition(expr, trigger_ctx, &HashMap::new()).await { + Ok(true) => {} + Ok(false) => { + tracing::debug!( + workflow_id = %workflow_id, + "Trigger filter evaluated false — skipping workflow" + ); + return false; + } + Err(e) => { + tracing::warn!( + workflow_id = %workflow_id, + "Trigger filter error: {e} — skipping workflow" + ); + return false; + } + } + } + true } @@ -426,10 +449,11 @@ pub fn build_trigger_context(event: &sprout_core::StoredEvent) -> executor::Trig /// Returns `true` if the trigger type matches the given event kind. fn trigger_matches_event(trigger: &TriggerDef, kind_u32: u32) -> bool { - use sprout_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE}; + use sprout_core::kind::{KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF}; match trigger { TriggerDef::MessagePosted { .. } => kind_u32 == KIND_STREAM_MESSAGE, TriggerDef::ReactionAdded { .. } => kind_u32 == KIND_REACTION, + TriggerDef::DiffPosted { .. } => kind_u32 == KIND_STREAM_MESSAGE_DIFF, // Schedule and Webhook triggers are not fired by channel events. TriggerDef::Schedule { .. } | TriggerDef::Webhook => false, } @@ -611,6 +635,21 @@ steps: assert!(!trigger_matches_event(&webhook_trigger, 0)); } + #[test] + fn diff_posted_matches_kind_40008_only() { + let trigger = TriggerDef::DiffPosted { filter: None }; + assert!(trigger_matches_event(&trigger, 40008)); + assert!(!trigger_matches_event(&trigger, 40001)); + assert!(!trigger_matches_event(&trigger, 7)); + } + + #[test] + fn message_posted_does_not_match_kind_40008() { + let trigger = TriggerDef::MessagePosted { filter: None }; + assert!(!trigger_matches_event(&trigger, 40008)); + assert!(trigger_matches_event(&trigger, 40001)); + } + #[test] fn workflow_config_custom_values() { let cfg = WorkflowConfig { diff --git a/crates/sprout-workflow/src/schema.rs b/crates/sprout-workflow/src/schema.rs index ccf453165..54203861e 100644 --- a/crates/sprout-workflow/src/schema.rs +++ b/crates/sprout-workflow/src/schema.rs @@ -52,6 +52,12 @@ pub enum TriggerDef { #[serde(default)] emoji: Option, }, + /// Fires when a diff message (kind:40008) is posted in the workflow's channel. + DiffPosted { + /// Optional evalexpr filter expression (same variables as MessagePosted). + #[serde(default)] + filter: Option, + }, /// Fires on a cron schedule. Schedule { /// Cron expression (UTC). Mutually exclusive with `interval`. @@ -832,4 +838,25 @@ mod tests { let result = normalize_cron("* * * * *"); assert_eq!(result, "0 * * * * * *"); } + + // ── DiffPosted trigger ──────────────────────────────────────────────────── + + #[test] + fn diff_posted_trigger_roundtrips_yaml() { + let yaml = "on: diff_posted\n"; + let trigger: TriggerDef = serde_yaml::from_str(yaml).unwrap(); + assert!(matches!(trigger, TriggerDef::DiffPosted { filter: None })); + let back = serde_yaml::to_string(&trigger).unwrap(); + assert!(back.contains("diff_posted")); + } + + #[test] + fn diff_posted_trigger_with_filter_roundtrips_yaml() { + let yaml = "on: diff_posted\nfilter: 'str_contains(trigger_text, \"src/\")'\n"; + let trigger: TriggerDef = serde_yaml::from_str(yaml).unwrap(); + assert!(matches!( + trigger, + TriggerDef::DiffPosted { filter: Some(_) } + )); + } } diff --git a/desktop/package.json b/desktop/package.json index 394b56dc7..11171b272 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -19,6 +19,7 @@ "test:e2e:report": "playwright show-report" }, "dependencies": { + "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-separator": "^1.1.8", @@ -29,6 +30,8 @@ "@tauri-apps/plugin-opener": "^2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "diff2html": "^3.4.56", + "dompurify": "^3.3.3", "lucide-react": "^0.577.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index 0c10c10f5..7be916dc5 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@radix-ui/react-dialog': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -38,6 +41,12 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 + diff2html: + specifier: ^3.4.56 + version: 3.4.56 + dompurify: + specifier: ^3.3.3 + version: 3.3.3 lucide-react: specifier: ^0.577.0 version: 0.577.0(react@19.2.4) @@ -432,6 +441,16 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@noble/ciphers@2.1.1': resolution: {integrity: sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw==} engines: {node: '>= 20.19.0'} @@ -461,6 +480,10 @@ packages: engines: {node: '>=18'} hasBin: true + '@profoundlogic/hogan@3.0.4': + resolution: {integrity: sha512-pmNVGuooS30Mm7YbZd5T7E5zYVO6D5Ct91sn4T39mUvMUc3sCGridcnhAufL1/Bz2QzAtzEn0agNrdk3+5yWzw==} + hasBin: true + '@radix-ui/primitive@1.1.3': resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==} @@ -838,7 +861,6 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} @@ -849,7 +871,6 @@ packages: resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} @@ -867,7 +888,6 @@ packages: resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} @@ -884,18 +904,17 @@ packages: resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} @@ -907,6 +926,7 @@ packages: resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1070,6 +1090,9 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/unist@2.0.11': resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} @@ -1085,6 +1108,9 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + abbrev@1.1.1: + resolution: {integrity: sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==} + any-promise@1.3.0: resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} @@ -1203,9 +1229,23 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff2html@3.4.56: + resolution: {integrity: sha512-u9gfn+BlbHcyO7vItCIC4z49LJDUt31tODzOfAuJ5R1E7IdlRL6KjugcB9zOpejD+XiR+dDZbsnHSQ3g6A/u8A==} + engines: {node: '>=12'} + + diff@8.0.3: + resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + engines: {node: '>=0.3.1'} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + + dompurify@3.3.3: + resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + electron-to-chromium@1.5.307: resolution: {integrity: sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==} @@ -1290,6 +1330,10 @@ packages: hast-util-whitespace@3.0.0: resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + highlight.js@11.11.1: + resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} + engines: {node: '>=12.0.0'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} @@ -1370,6 +1414,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + mdast-util-find-and-replace@3.0.2: resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} @@ -1510,6 +1559,9 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1524,6 +1576,10 @@ packages: node-releases@2.0.36: resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + nopt@1.0.10: + resolution: {integrity: sha512-NWmpvLSqUrgrAC9HCuxEvb+PSloHpqVu+FqcO4eeF2h5qYRhA7ev6KvelyQAKtegUbC6RypJnlEOhd8vloNKYg==} + hasBin: true + normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} @@ -1731,6 +1787,9 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + stringify-entities@4.0.4: resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} @@ -2163,6 +2222,17 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + '@noble/ciphers@2.1.1': {} '@noble/curves@2.0.1': @@ -2187,6 +2257,10 @@ snapshots: dependencies: playwright: 1.58.2 + '@profoundlogic/hogan@3.0.4': + dependencies: + nopt: 1.0.10 + '@radix-ui/primitive@1.1.3': {} '@radix-ui/react-arrow@1.1.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': @@ -2702,6 +2776,9 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/trusted-types@2.0.7': + optional: true + '@types/unist@2.0.11': {} '@types/unist@3.0.3': {} @@ -2720,6 +2797,8 @@ snapshots: transitivePeerDependencies: - supports-color + abbrev@1.1.1: {} + any-promise@1.3.0: {} anymatch@3.1.3: @@ -2820,8 +2899,25 @@ snapshots: didyoumean@1.2.2: {} + diff2html@3.4.56: + dependencies: + '@profoundlogic/hogan': 3.0.4 + diff: 8.0.3 + optionalDependencies: + highlight.js: 11.11.1 + + diff@8.0.3: {} + dlv@1.1.3: {} + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + + dompurify@3.3.3: + optionalDependencies: + '@types/trusted-types': 2.0.7 + electron-to-chromium@1.5.307: {} esbuild@0.27.3: @@ -2931,6 +3027,9 @@ snapshots: dependencies: '@types/hast': 3.0.4 + highlight.js@11.11.1: + optional: true + html-url-attributes@3.0.1: {} inline-style-parser@0.2.7: {} @@ -2988,6 +3087,8 @@ snapshots: markdown-table@3.0.4: {} + marked@14.0.0: {} + mdast-util-find-and-replace@3.0.2: dependencies: '@types/mdast': 4.0.4 @@ -3344,6 +3445,11 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + ms@2.1.3: {} mz@2.7.0: @@ -3356,6 +3462,10 @@ snapshots: node-releases@2.0.36: {} + nopt@1.0.10: + dependencies: + abbrev: 1.1.1 + normalize-path@3.0.0: {} nostr-tools@2.23.3(typescript@5.8.3): @@ -3600,6 +3710,8 @@ snapshots: space-separated-tokens@2.0.2: {} + state-local@1.0.7: {} + stringify-entities@4.0.4: dependencies: character-entities-html4: 2.1.0 diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts index 8f67a70a9..e470adc2f 100644 --- a/desktop/src/features/messages/lib/formatTimelineMessages.ts +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -54,6 +54,8 @@ export function formatTimelineMessages( body: event.content, accent: currentPubkey === event.pubkey, pending: event.pending, + kind: event.kind, + tags: event.tags, })); } diff --git a/desktop/src/features/messages/lib/parseDiff.ts b/desktop/src/features/messages/lib/parseDiff.ts new file mode 100644 index 000000000..e19de19bd --- /dev/null +++ b/desktop/src/features/messages/lib/parseDiff.ts @@ -0,0 +1,52 @@ +import { parse } from "diff2html"; + +export function parseDiffToOldNew(unifiedDiff: string): { + original: string; + modified: string; +} { + try { + const files = parse(unifiedDiff); + if (!files.length) { + // diff2html couldn't parse any files — show raw diff as fallback + return { original: "", modified: unifiedDiff }; + } + + const originalLines: string[] = []; + const modifiedLines: string[] = []; + + for (const file of files) { + // Add file header separator for multi-file diffs + if (files.length > 1) { + const header = `// ── ${file.newName || file.oldName || "unknown"} ──`; + originalLines.push(header); + modifiedLines.push(header); + } + + for (const block of file.blocks) { + for (const line of block.lines) { + if (line.content.startsWith("\\ ")) continue; + if (line.type === "context" || line.type === "delete") { + originalLines.push(line.content.slice(1)); + } + if (line.type === "context" || line.type === "insert") { + modifiedLines.push(line.content.slice(1)); + } + } + } + + // Add blank line between files + if (files.length > 1) { + originalLines.push(""); + modifiedLines.push(""); + } + } + + return { + original: originalLines.join("\n"), + modified: modifiedLines.join("\n"), + }; + } catch { + // Malformed diff — return raw content as fallback + return { original: "", modified: unifiedDiff }; + } +} diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts index 304f551ce..d1c18e121 100644 --- a/desktop/src/features/messages/types.ts +++ b/desktop/src/features/messages/types.ts @@ -8,4 +8,6 @@ export type TimelineMessage = { accent?: boolean; pending?: boolean; highlighted?: boolean; + kind?: number; + tags?: string[][]; }; diff --git a/desktop/src/features/messages/ui/DiffMessage.tsx b/desktop/src/features/messages/ui/DiffMessage.tsx new file mode 100644 index 000000000..078bec71d --- /dev/null +++ b/desktop/src/features/messages/ui/DiffMessage.tsx @@ -0,0 +1,236 @@ +import { html } from "diff2html"; +import "diff2html/bundles/css/diff2html.min.css"; +import DOMPurify from "dompurify"; +import { FileDiff, Maximize2 } from "lucide-react"; +import { useMemo } from "react"; + +import { Button } from "@/shared/ui/button"; + +/** + * Override diff2html styles that break in constrained containers. + * The default CSS uses position:absolute for line numbers which causes + * overflow and misalignment in narrow message bubbles. + */ +const diffStyleOverrides = ` +.d2h-wrapper { text-align: left; } +.d2h-file-wrapper { border: none; margin: 0; border-radius: 0; } +.d2h-file-header { display: none; } +.d2h-diff-table { font-size: 12px; width: 100%; table-layout: fixed; } +.d2h-code-linenumber { + position: static; + display: table-cell; + width: 3em; + min-width: 3em; + max-width: 3em; + padding: 0 0.4em; + text-align: right; + vertical-align: top; + user-select: none; + border-right: 1px solid var(--d2h-line-border-color, #eee); + font-size: 11px; +} +.d2h-code-line { + display: table-cell; + padding: 0 0.5em; + white-space: pre-wrap; + word-break: break-all; + width: auto; +} +.d2h-code-line-ctn { + white-space: pre-wrap; + word-break: break-all; +} +.d2h-info { + padding: 0.2em 0.5em; +} +`; + +type DiffMessageProps = { + content: string; + repoUrl?: string; + filePath?: string; + commitSha?: string; + description?: string; + truncated?: boolean; + onExpand?: () => void; +}; + +function isSafeUrl(url: string | undefined): url is string { + if (!url) return false; + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function getHostname(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +const ALLOWED_TAGS = [ + "div", + "span", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "pre", + "code", + "ins", + "del", + "a", + "i", + "em", + "strong", + "small", +]; + +const ALLOWED_ATTR = [ + "class", + "id", + "data-line-number", + "href", + "title", + "aria-label", +]; + +const ALLOWED_URI_REGEXP = /^https?:\/\//i; + +export function DiffMessage({ + content, + repoUrl, + filePath, + commitSha, + description, + truncated, + onExpand, +}: DiffMessageProps) { + const { diffHtml, renderError } = useMemo(() => { + try { + const rawHtml = html(content, { + drawFileList: false, + matching: "lines", + outputFormat: "line-by-line", + }); + const sanitized = DOMPurify.sanitize(rawHtml, { + ALLOWED_TAGS, + ALLOWED_ATTR, + ALLOWED_URI_REGEXP, + }); + return { diffHtml: sanitized, renderError: false }; + } catch { + return { diffHtml: "", renderError: true }; + } + }, [content]); + + const safeRepoUrl = isSafeUrl(repoUrl) ? repoUrl : undefined; + + const commitUrl = + safeRepoUrl && commitSha ? `${safeRepoUrl}/commit/${commitSha}` : undefined; + + const shortSha = commitSha ? commitSha.slice(0, 7) : undefined; + + return ( +
+ {/* Header */} +
+ + + {filePath ?? "diff"} + + {shortSha && ( + + {commitUrl ? ( + + {shortSha} + + ) : ( + shortSha + )} + + )} + {safeRepoUrl && !commitUrl && ( + + + {getHostname(safeRepoUrl)} + + + )} + {onExpand && ( + + )} +
+ + {/* Description */} + {description && ( +
+ {description} +
+ )} + + {/* Diff content — max 400px height, scrollable */} +
+ {/* biome-ignore lint/security/noDangerouslySetInnerHtml: static CSS overrides */} +