Skip to main content

semantic_diff/preview/
mermaid.rs

1//! Mermaid code block extraction, rendering via mmdc, and content-hash caching.
2//!
3//! Renders mermaid diagrams as halfblock images when:
4//! 1. mmdc is installed
5//! 2. Terminal supports inline images (Ghostty, iTerm2, Kitty)
6//! 3. NOT inside a multiplexer (tmux) which strips graphics escape sequences
7//!
8//! Falls back to styled source code otherwise.
9
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::{Arc, Mutex};
13
14/// A mermaid code block extracted from markdown.
15#[derive(Debug, Clone)]
16pub struct MermaidBlock {
17    /// Raw mermaid source code.
18    pub source: String,
19    /// Blake3 content hash of the source.
20    pub hash: String,
21}
22
23impl MermaidBlock {
24    pub fn new(source: String) -> Self {
25        let hash = blake3::hash(source.as_bytes()).to_hex().to_string();
26        Self { source, hash }
27    }
28}
29
30/// State of a mermaid diagram render.
31#[derive(Debug, Clone)]
32pub enum MermaidRenderState {
33    Pending,
34    Rendering,
35    Ready(PathBuf),
36    Failed(String),
37}
38
39/// Whether mermaid image rendering is supported in the current environment.
40#[derive(Debug, Clone, PartialEq)]
41pub enum ImageSupport {
42    /// Terminal supports inline images via native protocol.
43    Supported(ImageProtocol),
44    /// Inside a multiplexer or unsupported terminal — show styled source.
45    Multiplexer,
46    /// mmdc is not installed — show styled source.
47    NoMmdc,
48}
49
50/// Which inline image protocol the terminal supports.
51#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum ImageProtocol {
53    /// iTerm2 inline image protocol (OSC 1337).
54    Iterm2,
55    /// Kitty graphics protocol.
56    Kitty,
57}
58
59/// Detect whether the current terminal environment supports inline image rendering.
60///
61/// Checks are ordered: multiplexer detection first (these strip graphics escape
62/// sequences), then terminal capability, then mmdc availability.
63///
64/// Key distinction: we check for **active session** env vars, not just installed software.
65/// - `TMUX` is only set inside a tmux session (not just because tmux is installed)
66/// - `STY` is only set inside a screen session
67pub fn detect_image_support() -> ImageSupport {
68    // 1. Check for active multiplexer session — these strip graphics escape sequences.
69    if std::env::var("TMUX").is_ok() {
70        tracing::info!("Inside tmux session — mermaid images disabled (no graphics passthrough)");
71        return ImageSupport::Multiplexer;
72    }
73    if std::env::var("STY").is_ok() {
74        tracing::info!("Inside screen session — mermaid images disabled");
75        return ImageSupport::Multiplexer;
76    }
77
78    // 2. Check for terminals known to support inline images
79    let term_program = std::env::var("TERM_PROGRAM").unwrap_or_default().to_lowercase();
80    let ghostty = std::env::var("GHOSTTY_RESOURCES_DIR").is_ok();
81    let supported_terminal = ghostty
82        || term_program.contains("iterm")
83        || term_program.contains("kitty")
84        || term_program.contains("wezterm");
85
86    if !supported_terminal {
87        tracing::info!(term_program = %term_program, "Unknown terminal — mermaid images disabled");
88        return ImageSupport::Multiplexer;
89    }
90
91    // 3. Check if mmdc is available (only matters if terminal supports images)
92    if which::which("mmdc").is_err() {
93        tracing::info!("mmdc not found — mermaid diagrams will show as source code");
94        return ImageSupport::NoMmdc;
95    }
96
97    // 4. Determine protocol from terminal type
98    let protocol = if term_program.contains("iterm") {
99        ImageProtocol::Iterm2
100    } else if ghostty || term_program.contains("kitty") || term_program.contains("wezterm") {
101        ImageProtocol::Kitty
102    } else {
103        tracing::info!(term_program = %term_program, "No known image protocol");
104        return ImageSupport::Multiplexer;
105    };
106
107    tracing::info!(term_program = %term_program, ?protocol, "Image rendering enabled");
108    ImageSupport::Supported(protocol)
109}
110
111/// Cache and state manager for mermaid diagram rendering.
112pub struct MermaidCache {
113    pub states: Arc<Mutex<HashMap<String, MermaidRenderState>>>,
114    cache_dir: Option<PathBuf>,
115    mmdc_available: bool,
116}
117
118impl Default for MermaidCache {
119    fn default() -> Self {
120        Self::new()
121    }
122}
123
124impl MermaidCache {
125    pub fn new() -> Self {
126        let cache_dir = find_cache_dir();
127        let mmdc_available = which::which("mmdc").is_ok();
128
129        let mut initial_states = HashMap::new();
130
131        // Pre-load existing cached PNGs
132        if let Some(ref dir) = cache_dir {
133            if dir.exists() {
134                if let Ok(entries) = std::fs::read_dir(dir) {
135                    for entry in entries.flatten() {
136                        let path = entry.path();
137                        if path.extension().is_some_and(|e| e == "png") {
138                            if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
139                                initial_states.insert(
140                                    stem.to_string(),
141                                    MermaidRenderState::Ready(path.clone()),
142                                );
143                            }
144                        }
145                    }
146                }
147            }
148        }
149
150        Self {
151            states: Arc::new(Mutex::new(initial_states)),
152            cache_dir,
153            mmdc_available,
154        }
155    }
156
157    /// Get the render state synchronously.
158    pub fn get_state_blocking(&self, hash: &str) -> MermaidRenderState {
159        let states = self.states.lock().unwrap();
160        states
161            .get(hash)
162            .cloned()
163            .unwrap_or(MermaidRenderState::Pending)
164    }
165
166    /// Spawn async rendering of a mermaid block via mmdc.
167    pub fn render_async(
168        &self,
169        block: MermaidBlock,
170        tx: tokio::sync::mpsc::Sender<crate::app::Message>,
171    ) {
172        if !self.mmdc_available {
173            let mut states = self.states.lock().unwrap();
174            states.insert(
175                block.hash.clone(),
176                MermaidRenderState::Failed("mmdc not installed".to_string()),
177            );
178            return;
179        }
180
181        // Check if already rendering or ready
182        {
183            let states = self.states.lock().unwrap();
184            match states.get(&block.hash) {
185                Some(MermaidRenderState::Ready(_))
186                | Some(MermaidRenderState::Rendering) => return,
187                _ => {}
188            }
189        }
190
191        // Mark as rendering
192        {
193            let mut states = self.states.lock().unwrap();
194            states.insert(block.hash.clone(), MermaidRenderState::Rendering);
195        }
196
197        let cache_dir = match &self.cache_dir {
198            Some(d) => d.clone(),
199            None => {
200                let mut states = self.states.lock().unwrap();
201                states.insert(
202                    block.hash.clone(),
203                    MermaidRenderState::Failed("No git directory found".to_string()),
204                );
205                return;
206            }
207        };
208
209        let states = self.states.clone();
210        let hash = block.hash.clone();
211
212        tokio::spawn(async move {
213            let output_path = cache_dir.join(format!("{hash}.png"));
214
215            if let Err(e) = tokio::fs::create_dir_all(&cache_dir).await {
216                let mut s = states.lock().unwrap();
217                s.insert(hash, MermaidRenderState::Failed(e.to_string()));
218                return;
219            }
220
221            let input_path = cache_dir.join(format!("{hash}.mmd"));
222            if let Err(e) = tokio::fs::write(&input_path, &block.source).await {
223                let mut s = states.lock().unwrap();
224                s.insert(hash, MermaidRenderState::Failed(e.to_string()));
225                return;
226            }
227
228            let result = tokio::time::timeout(
229                std::time::Duration::from_secs(15),
230                tokio::process::Command::new("mmdc")
231                    .arg("-i")
232                    .arg(&input_path)
233                    .arg("-o")
234                    .arg(&output_path)
235                    .arg("-b")
236                    .arg("transparent")
237                    .arg("-w")
238                    .arg("800")
239                    .arg("--quiet")
240                    .output(),
241            )
242            .await;
243
244            let _ = tokio::fs::remove_file(&input_path).await;
245
246            match result {
247                Ok(Ok(output)) if output.status.success() && output_path.exists() => {
248                    let mut s = states.lock().unwrap();
249                    s.insert(hash, MermaidRenderState::Ready(output_path));
250                }
251                Ok(Ok(output)) => {
252                    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
253                    let mut s = states.lock().unwrap();
254                    s.insert(hash, MermaidRenderState::Failed(format!("mmdc failed: {stderr}")));
255                }
256                Ok(Err(e)) => {
257                    let mut s = states.lock().unwrap();
258                    s.insert(hash, MermaidRenderState::Failed(e.to_string()));
259                }
260                Err(_) => {
261                    let mut s = states.lock().unwrap();
262                    s.insert(hash, MermaidRenderState::Failed("mmdc timed out (15s)".to_string()));
263                }
264            }
265
266            let _ = tx.send(crate::app::Message::MermaidReady).await;
267        });
268    }
269}
270
271fn find_cache_dir() -> Option<PathBuf> {
272    let output = std::process::Command::new("git")
273        .args(["rev-parse", "--git-dir"])
274        .output()
275        .ok()?;
276    if !output.status.success() {
277        return None;
278    }
279    let git_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
280    Some(PathBuf::from(git_dir).join("semantic-diff-cache").join("mermaid"))
281}