semantic_diff/preview/
mermaid.rs1use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::{Arc, Mutex};
13
14#[derive(Debug, Clone)]
16pub struct MermaidBlock {
17 pub source: String,
19 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#[derive(Debug, Clone)]
32pub enum MermaidRenderState {
33 Pending,
34 Rendering,
35 Ready(PathBuf),
36 Failed(String),
37}
38
39#[derive(Debug, Clone, PartialEq)]
41pub enum ImageSupport {
42 Supported(ImageProtocol),
44 Multiplexer,
46 NoMmdc,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq)]
52pub enum ImageProtocol {
53 Iterm2,
55 Kitty,
57}
58
59pub fn detect_image_support() -> ImageSupport {
68 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 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 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 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
111pub 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 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 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 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 {
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 {
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}