feat: add kind:40008 diff message type with structured metadata and desktop rendering (#36)

This commit is contained in:
tlongwell-block
2026-03-11 19:56:03 -04:00
committed by GitHub
parent 154abf3da9
commit 14817123fa
17 changed files with 1112 additions and 15 deletions
+3
View File
@@ -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,
+321 -1
View File
@@ -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<u32>,
}
/// 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<String>,
/// Optional parent commit SHA (the base of the diff).
#[serde(default)]
pub parent_commit_sha: Option<String>,
/// Optional source branch name (e.g. "feat/my-feature").
#[serde(default)]
pub source_branch: Option<String>,
/// Optional target branch name (e.g. "main").
#[serde(default)]
pub target_branch: Option<String>,
/// Optional pull request number associated with this diff.
#[serde(default)]
pub pr_number: Option<u32>,
/// Optional language hint for syntax highlighting (e.g. "rust", "typescript").
/// Inferred from file_path extension if omitted.
#[serde(default)]
pub language: Option<String>,
/// Optional human-readable description of the change.
#[serde(default)]
pub description: Option<String>,
/// Optional parent event ID. If provided, sends the diff as a threaded reply.
#[serde(default)]
pub parent_event_id: Option<String>,
}
// ── 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<String> {
// 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<SendDiffMessageParams>,
) -> 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())
);
}
}
+171
View File
@@ -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<Vec<Tag>, (StatusCode, axum::Json<serde_json::Value>)> {
// 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<Tag> = 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<u32>,
// 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<String>,
/// File path within the repository that this diff applies to.
pub diff_file_path: Option<String>,
/// Commit SHA that produced this diff (required for kind:40008).
pub diff_commit_sha: Option<String>,
/// Parent commit SHA (the base of the diff).
pub diff_parent_commit_sha: Option<String>,
/// Source branch for the diff (e.g. feature branch). Must be paired with `diff_target_branch`.
pub diff_source_branch: Option<String>,
/// Target branch for the diff (e.g. main). Must be paired with `diff_source_branch`.
pub diff_target_branch: Option<String>,
/// Pull request number associated with this diff.
pub diff_pr_number: Option<u32>,
/// Programming language of the diffed file (e.g. `"rust"`, `"typescript"`).
pub diff_language: Option<String>,
/// Human-readable description of what the diff changes.
pub diff_description: Option<String>,
/// When `true`, the diff content was truncated to fit the 60KB limit.
pub diff_truncated: Option<bool>,
/// Plain-text alternative summary for clients that cannot render diffs.
pub diff_alt: Option<String>,
}
/// 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) {
+10 -4
View File
@@ -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<uuid::Uuid> {
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,
+2 -2
View File
@@ -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()
+40 -1
View File
@@ -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 {
+27
View File
@@ -52,6 +52,12 @@ pub enum TriggerDef {
#[serde(default)]
emoji: Option<String>,
},
/// 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<String>,
},
/// 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(_) }
));
}
}
+3
View File
@@ -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",
+117 -5
View File
@@ -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
@@ -54,6 +54,8 @@ export function formatTimelineMessages(
body: event.content,
accent: currentPubkey === event.pubkey,
pending: event.pending,
kind: event.kind,
tags: event.tags,
}));
}
@@ -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 };
}
}
+2
View File
@@ -8,4 +8,6 @@ export type TimelineMessage = {
accent?: boolean;
pending?: boolean;
highlighted?: boolean;
kind?: number;
tags?: string[][];
};
@@ -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 (
<div className="rounded-xl border border-border/70 bg-card/60 overflow-hidden text-sm">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/50 bg-muted/40">
<FileDiff className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="flex-1 truncate font-mono text-xs text-foreground/80">
{filePath ?? "diff"}
</span>
{shortSha && (
<span className="text-xs text-muted-foreground font-mono">
{commitUrl ? (
<a
className="hover:underline"
href={commitUrl}
rel="noreferrer noopener"
target="_blank"
>
{shortSha}
</a>
) : (
shortSha
)}
</span>
)}
{safeRepoUrl && !commitUrl && (
<span className="text-xs text-muted-foreground">
<a
className="hover:underline"
href={safeRepoUrl}
rel="noreferrer noopener"
target="_blank"
>
{getHostname(safeRepoUrl)}
</a>
</span>
)}
{onExpand && (
<Button
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={onExpand}
size="sm"
title="Expand diff"
type="button"
variant="ghost"
>
<Maximize2 className="h-3.5 w-3.5" />
</Button>
)}
</div>
{/* Description */}
{description && (
<div className="px-3 py-1.5 text-xs text-muted-foreground border-b border-border/40 bg-muted/20">
{description}
</div>
)}
{/* Diff content — max 400px height, scrollable */}
<div className="max-h-[400px] overflow-auto text-xs">
{/* biome-ignore lint/security/noDangerouslySetInnerHtml: static CSS overrides */}
<style dangerouslySetInnerHTML={{ __html: diffStyleOverrides }} />
{renderError ? (
<pre className="p-3 whitespace-pre-wrap font-mono text-muted-foreground">
{content}
</pre>
) : diffHtml ? (
// biome-ignore lint/security/noDangerouslySetInnerHtml: sanitized by DOMPurify
<div dangerouslySetInnerHTML={{ __html: diffHtml }} />
) : (
<div className="p-3 text-xs text-muted-foreground italic">
No diff content
</div>
)}
</div>
{/* Truncation warning */}
{truncated && (
<div className="px-3 py-2 border-t border-border/50 bg-amber-500/10 text-xs text-amber-700 dark:text-amber-400">
Diff truncated.{" "}
{safeRepoUrl && commitUrl ? (
<a
className="underline hover:no-underline"
href={commitUrl}
rel="noreferrer noopener"
target="_blank"
>
View full diff on {getHostname(safeRepoUrl)}
</a>
) : (
"View the full diff at the source repository."
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,65 @@
import { DiffEditor } from "@monaco-editor/react";
import { useMemo } from "react";
import { parseDiffToOldNew } from "@/features/messages/lib/parseDiff";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
type DiffMessageExpandedProps = {
content: string;
language?: string;
filePath?: string;
onClose: () => void;
};
export default function DiffMessageExpanded({
content,
language,
filePath,
onClose,
}: DiffMessageExpandedProps) {
const { original, modified } = useMemo(
() => parseDiffToOldNew(content),
[content],
);
return (
<Dialog
onOpenChange={(open) => {
if (!open) onClose();
}}
open
>
<DialogContent className="max-w-5xl w-full h-[80vh] flex flex-col p-0 gap-0">
<DialogHeader className="px-4 py-3 border-b border-border/50 shrink-0">
<DialogTitle className="text-sm font-mono font-medium truncate">
{filePath ?? "Diff Viewer"}{" "}
<span className="text-muted-foreground text-xs font-normal">
(hunks-only preview shows changed regions, not full file)
</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 min-h-0">
<DiffEditor
height="100%"
language={language ?? "plaintext"}
modified={modified}
options={{
readOnly: true,
renderSideBySide: true,
minimap: { enabled: false },
scrollBeyondLastLine: false,
fontSize: 13,
}}
original={original}
theme="vs-dark"
/>
</div>
</DialogContent>
</Dialog>
);
}
@@ -2,11 +2,15 @@ import { ArrowDown } from "lucide-react";
import * as React from "react";
import type { TimelineMessage } from "@/features/messages/types";
import { KIND_STREAM_MESSAGE_DIFF } from "@/shared/constants/kinds";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
import { Markdown } from "@/shared/ui/markdown";
import { Separator } from "@/shared/ui/separator";
import { Skeleton } from "@/shared/ui/skeleton";
import { DiffMessage } from "./DiffMessage";
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
type MessageTimelineProps = {
messages: TimelineMessage[];
@@ -28,6 +32,9 @@ function isNearBottom(container: HTMLDivElement) {
function MessageRow({ message }: { message: TimelineMessage }) {
const [hasAvatarError, setHasAvatarError] = React.useState(false);
const [expandedDiffId, setExpandedDiffId] = React.useState<string | null>(
null,
);
const initials = message.author
.split(" ")
.map((part) => part[0])
@@ -35,6 +42,30 @@ function MessageRow({ message }: { message: TimelineMessage }) {
.slice(0, 2)
.toUpperCase();
const getTag = (name: string) =>
message.tags?.find((t) => t[0] === name)?.[1];
const renderBody = () => {
switch (message.kind) {
case KIND_STREAM_MESSAGE_DIFF:
return (
<DiffMessage
commitSha={getTag("commit")}
content={message.body}
description={getTag("description")}
filePath={getTag("file")}
onExpand={() => {
setExpandedDiffId(message.id);
}}
repoUrl={getTag("repo")}
truncated={getTag("truncated") === "true"}
/>
);
default:
return <Markdown className="max-w-3xl" content={message.body} tight />;
}
};
return (
<article
className={cn(
@@ -88,7 +119,25 @@ function MessageRow({ message }: { message: TimelineMessage }) {
<p className="whitespace-nowrap">{message.time}</p>
</div>
</div>
<Markdown className="max-w-3xl" content={message.body} tight />
{renderBody()}
{expandedDiffId === message.id && (
<React.Suspense
fallback={
<div className="p-4 text-sm text-muted-foreground">
Loading diff viewer
</div>
}
>
<DiffMessageExpanded
content={message.body}
filePath={getTag("file")}
language={getTag("l")}
onClose={() => {
setExpandedDiffId(null);
}}
/>
</React.Suspense>
)}
</div>
</article>
);
+2 -1
View File
@@ -6,6 +6,7 @@ import {
signRelayEvent,
} from "@/shared/api/tauri";
import type { PresenceStatus, RelayEvent } from "@/shared/api/types";
import { STREAM_MESSAGE_KINDS } from "@/shared/constants/kinds";
type RelaySubscriptionFilter = {
kinds: number[];
@@ -230,7 +231,7 @@ class RelayClient {
limit: number,
): RelaySubscriptionFilter {
return {
kinds: [40001],
kinds: [...STREAM_MESSAGE_KINDS],
"#h": [channelId],
limit,
};
+9
View File
@@ -0,0 +1,9 @@
export const KIND_STREAM_MESSAGE = 40001;
export const KIND_STREAM_MESSAGE_V2 = 40002;
export const KIND_STREAM_MESSAGE_EDIT = 40003;
export const KIND_STREAM_MESSAGE_DIFF = 40008;
export const STREAM_MESSAGE_KINDS = [
KIND_STREAM_MESSAGE, // 40001 — was in original filter
KIND_STREAM_MESSAGE_DIFF, // 40008 — new in this PR
] as const;