1use std::collections::{HashMap, HashSet};
2
3use gitgraph_core::log_parser::{build_graph_rows, parse_git_log_records};
4use gitgraph_core::{
5 ActionCatalog, ActionContext, ActionRequest, ActionScope, CommitSearchQuery, GitLgError,
6 filter_commits,
7};
8use zed_extension_api as zed;
9
10const DEFAULT_LIMIT: usize = 100;
11const MAX_LIMIT: usize = 1_000;
12
13fn is_log_command(name: &str) -> bool {
14 matches!(name, "gitgraph-log" | "gitlg-log")
15}
16
17fn is_search_command(name: &str) -> bool {
18 matches!(name, "gitgraph-search" | "gitlg-search")
19}
20
21fn is_actions_command(name: &str) -> bool {
22 matches!(name, "gitgraph-actions" | "gitlg-actions")
23}
24
25fn is_action_command(name: &str) -> bool {
26 matches!(name, "gitgraph-action" | "gitlg-action")
27}
28
29fn is_blame_command(name: &str) -> bool {
30 matches!(name, "gitgraph-blame" | "gitlg-blame")
31}
32
33fn is_tips_command(name: &str) -> bool {
34 matches!(name, "gitgraph-tips" | "gitlg-tips")
35}
36
37struct GitGraphZedExtension;
38
39impl zed::Extension for GitGraphZedExtension {
40 fn new() -> Self {
41 Self
42 }
43
44 fn complete_slash_command_argument(
45 &self,
46 command: zed::SlashCommand,
47 _args: Vec<String>,
48 ) -> zed::Result<Vec<zed::SlashCommandArgumentCompletion>, String> {
49 if is_log_command(command.name.as_str()) || is_search_command(command.name.as_str()) {
50 let mut items = vec![
51 zed::SlashCommandArgumentCompletion {
52 label: "limit=25".to_string(),
53 new_text: "limit=25".to_string(),
54 run_command: false,
55 },
56 zed::SlashCommandArgumentCompletion {
57 label: "limit=100".to_string(),
58 new_text: "limit=100".to_string(),
59 run_command: false,
60 },
61 zed::SlashCommandArgumentCompletion {
62 label: "limit=250".to_string(),
63 new_text: "limit=250".to_string(),
64 run_command: false,
65 },
66 ];
67 if is_search_command(command.name.as_str()) {
68 items.push(zed::SlashCommandArgumentCompletion {
69 label: "path=src/main.rs".to_string(),
70 new_text: "path=src/main.rs".to_string(),
71 run_command: false,
72 });
73 }
74 return Ok(items);
75 }
76 if is_action_command(command.name.as_str()) {
77 let completions = ActionCatalog::with_defaults()
78 .templates
79 .iter()
80 .map(|t| zed::SlashCommandArgumentCompletion {
81 label: format!("{} ({})", t.id, t.scope.as_str()),
82 new_text: t.id.clone(),
83 run_command: false,
84 })
85 .collect();
86 return Ok(completions);
87 }
88 if is_blame_command(command.name.as_str()) {
89 return Ok(vec![
90 zed::SlashCommandArgumentCompletion {
91 label: "README.md 1".to_string(),
92 new_text: "README.md 1".to_string(),
93 run_command: false,
94 },
95 zed::SlashCommandArgumentCompletion {
96 label: "src/main.rs 42".to_string(),
97 new_text: "src/main.rs 42".to_string(),
98 run_command: false,
99 },
100 ]);
101 }
102 Ok(Vec::new())
103 }
104
105 fn run_slash_command(
106 &self,
107 command: zed::SlashCommand,
108 args: Vec<String>,
109 worktree: Option<&zed::Worktree>,
110 ) -> zed::Result<zed::SlashCommandOutput, String> {
111 let worktree = worktree.ok_or_else(|| {
112 format!(
113 "{} requires a project worktree context (open a repository first)",
114 command.name
115 )
116 })?;
117 let root = worktree.root_path();
118 let command_name = command.name.as_str();
119 if is_log_command(command_name) {
120 return run_gitgraph_log(&root, args);
121 }
122 if is_search_command(command_name) {
123 return run_gitgraph_search(&root, args);
124 }
125 if is_actions_command(command_name) {
126 return run_gitgraph_actions();
127 }
128 if is_action_command(command_name) {
129 return run_gitgraph_action(&root, args);
130 }
131 if is_blame_command(command_name) {
132 return run_gitgraph_blame(&root, args);
133 }
134 if is_tips_command(command_name) {
135 return run_gitgraph_tips();
136 }
137 Err(format!("unsupported slash command: {}", command_name))
138 }
139}
140
141fn run_gitgraph_log(repo_root: &str, args: Vec<String>) -> Result<zed::SlashCommandOutput, String> {
142 let limit = parse_limit_arg(args.first().map(String::as_str))?;
143 let output = run_git_log(repo_root, limit)?;
144 let rows = build_graph_rows(
145 parse_git_log_records(&output).map_err(|e| format!("failed to parse git output: {e}"))?,
146 );
147 let text = render_rows(
148 repo_root,
149 &rows,
150 &format!("Showing {} commit(s)", rows.len()),
151 );
152 Ok(build_output(text, "GitGraph graph"))
153}
154
155fn run_gitgraph_search(
156 repo_root: &str,
157 args: Vec<String>,
158) -> Result<zed::SlashCommandOutput, String> {
159 let parsed = parse_search_args(args)?;
160 let output = run_git_log(repo_root, parsed.limit)?;
161 let rows = build_graph_rows(
162 parse_git_log_records(&output).map_err(|e| format!("failed to parse git output: {e}"))?,
163 );
164 let search = CommitSearchQuery {
165 text: parsed.query.clone(),
166 file_path: parsed.file_path.clone(),
167 ..CommitSearchQuery::default()
168 };
169 let filtered = if let Some(path) = parsed.file_path.as_deref() {
170 filter_rows_by_file_contents(repo_root, &rows, &search, path)?
171 } else {
172 filter_commits(&rows, &search).map_err(|e| format!("search failed: {e}"))?
173 };
174 let text = render_rows(
175 repo_root,
176 &filtered,
177 &format!(
178 "Matched {} commit(s) from {} scanned",
179 filtered.len(),
180 rows.len()
181 ),
182 );
183 Ok(build_output(text, "GitGraph search"))
184}
185
186fn filter_rows_by_file_contents(
187 repo_root: &str,
188 rows: &[gitgraph_core::GraphRow],
189 search: &CommitSearchQuery,
190 file_path: &str,
191) -> Result<Vec<gitgraph_core::GraphRow>, String> {
192 if rows.is_empty() || search.text.trim().is_empty() {
193 return Ok(rows.to_vec());
194 }
195
196 let normalized = file_path.replace('\\', "/");
197 let mut matched_hashes = HashSet::new();
198 for chunk in rows.chunks(200) {
199 let mut args = vec!["grep".to_string()];
200 if search.use_regex {
201 args.push("-E".to_string());
202 } else {
203 args.push("-F".to_string());
204 }
205 if !search.case_sensitive {
206 args.push("-i".to_string());
207 }
208 args.push("-n".to_string());
209 args.push("-e".to_string());
210 args.push(search.text.clone());
211 args.extend(chunk.iter().map(|row| row.hash.clone()));
212 args.push("--".to_string());
213 args.push(normalized.clone());
214
215 let out = run_git_command(repo_root, &args)?;
216 if !matches!(out.status, Some(0) | Some(1)) {
217 return Err(format!(
218 "git grep failed (exit {:?}): {}",
219 out.status,
220 String::from_utf8_lossy(&out.stderr)
221 ));
222 }
223 for line in String::from_utf8_lossy(&out.stdout).lines() {
224 if let Some((hash, _)) = line.split_once(':') {
225 matched_hashes.insert(hash.to_string());
226 }
227 }
228 }
229
230 Ok(rows
231 .iter()
232 .filter(|row| matched_hashes.contains(&row.hash))
233 .cloned()
234 .collect())
235}
236
237fn run_gitgraph_actions() -> Result<zed::SlashCommandOutput, String> {
238 let catalog = ActionCatalog::with_defaults();
239 let mut text = String::new();
240 text.push_str("# GitGraph actions\n\n");
241 for scope in ActionScope::all() {
242 let templates = catalog.templates_for_scope(*scope);
243 text.push_str(&format!("## {} ({})\n", scope.as_str(), templates.len()));
244 for t in templates {
245 text.push_str(&format!(
246 "- `{}`: {} -> `{}`\n",
247 t.id,
248 t.title,
249 t.args.join(" ")
250 ));
251 }
252 text.push('\n');
253 }
254 Ok(build_output(text, "GitGraph actions"))
255}
256
257fn run_gitgraph_action(
258 repo_root: &str,
259 args: Vec<String>,
260) -> Result<zed::SlashCommandOutput, String> {
261 let parsed = parse_action_args(args)?;
262 let catalog = ActionCatalog::with_defaults();
263 let request = ActionRequest {
264 template_id: parsed.template_id.clone(),
265 params: parsed.params.clone(),
266 enabled_options: parsed.enabled_options.clone(),
267 context: parsed.context.clone(),
268 };
269 let resolved = catalog
270 .resolve_with_lookup(request, |placeholder| {
271 lookup_dynamic_placeholder(repo_root, placeholder)
272 })
273 .map_err(|e| format!("resolve action failed: {e}"))?;
274
275 let output = if let Some(script) = &resolved.shell_script {
276 run_shell_command(repo_root, &format!("git {}", script))?
277 } else {
278 run_git_command(repo_root, &resolved.args)?
279 };
280 let status = output.status.unwrap_or(-1);
281 if status != 0 && !resolved.allow_non_zero_exit && !resolved.ignore_errors {
282 return Err(format!(
283 "git action failed (exit {status}):\n{}",
284 String::from_utf8_lossy(&output.stderr)
285 ));
286 }
287
288 let mut text = String::new();
289 text.push_str(&format!("# GitGraph action: `{}`\n\n", resolved.id));
290 text.push_str(&format!("Command: `git {}`\n", resolved.command_line));
291 text.push_str(&format!("Exit: `{}`\n\n", status));
292 if !output.stdout.is_empty() {
293 text.push_str("## stdout\n");
294 text.push_str("```text\n");
295 text.push_str(&String::from_utf8_lossy(&output.stdout));
296 text.push_str("\n```\n");
297 }
298 if !output.stderr.is_empty() {
299 text.push_str("## stderr\n");
300 text.push_str("```text\n");
301 text.push_str(&String::from_utf8_lossy(&output.stderr));
302 text.push_str("\n```\n");
303 }
304 if output.stdout.is_empty() && output.stderr.is_empty() {
305 text.push_str("(no output)\n");
306 }
307 Ok(build_output(text, "GitGraph action"))
308}
309
310fn run_gitgraph_blame(
311 repo_root: &str,
312 args: Vec<String>,
313) -> Result<zed::SlashCommandOutput, String> {
314 let (file, line) = parse_blame_args(args)?;
315 let out = run_git_command(
316 repo_root,
317 &[
318 "blame".to_string(),
319 format!("-L{line},{line}"),
320 "--porcelain".to_string(),
321 "--".to_string(),
322 file.clone(),
323 ],
324 )?;
325 if out.status != Some(0) {
326 return Err(format!(
327 "git blame failed (exit {:?}): {}",
328 out.status,
329 String::from_utf8_lossy(&out.stderr)
330 ));
331 }
332 let text = render_blame_text(
333 repo_root,
334 &file,
335 line,
336 &String::from_utf8_lossy(&out.stdout),
337 );
338 Ok(build_output(text, "GitGraph blame"))
339}
340
341fn run_gitgraph_tips() -> Result<zed::SlashCommandOutput, String> {
342 let text = [
343 "# GitGraph tips",
344 "",
345 "- `/gitgraph-log [limit]` - show recent graph summary",
346 "- `/gitgraph-search [limit=200] [path=src/file.rs] query` - search history",
347 "- `/gitgraph-actions` - list action ids",
348 "- `/gitgraph-action <id> KEY=VALUE +opt:<option-id>` - run action",
349 "- `/gitgraph-blame <path> <line>` - single-line blame",
350 "",
351 "For full-screen interactive graph use CLI TUI in terminal:",
352 "`gitgraph`",
353 ]
354 .join("\n");
355 Ok(build_output(text, "GitGraph tips"))
356}
357
358fn parse_limit_arg(arg: Option<&str>) -> Result<usize, String> {
359 match arg {
360 None => Ok(DEFAULT_LIMIT),
361 Some(raw) if raw.trim().is_empty() => Ok(DEFAULT_LIMIT),
362 Some(raw) => {
363 let raw = raw.trim();
364 let value = if let Some((_, rhs)) = raw.split_once("limit=") {
365 rhs
366 } else {
367 raw
368 };
369 let n = value
370 .parse::<usize>()
371 .map_err(|e| format!("invalid limit {:?}: {}", raw, e))?;
372 if n == 0 || n > MAX_LIMIT {
373 return Err(format!("limit must be between 1 and {}", MAX_LIMIT));
374 }
375 Ok(n)
376 }
377 }
378}
379
380fn parse_search_args(args: Vec<String>) -> Result<ParsedSearchArgs, String> {
381 if args.is_empty() {
382 return Err("usage: /gitgraph-search [limit=200] [path=src/file.rs] <query>".to_string());
383 }
384 let mut limit: Option<usize> = None;
385 let mut file_path = None;
386 let mut query_parts = Vec::new();
387 for arg in args {
388 if limit.is_none() && (arg.starts_with("limit=") || arg.chars().all(|c| c.is_ascii_digit()))
389 {
390 limit = Some(parse_limit_arg(Some(&arg))?);
391 continue;
392 }
393 if let Some(path) = arg.strip_prefix("path=") {
394 let path = path.trim();
395 if path.is_empty() {
396 return Err("path=... value must not be empty".to_string());
397 }
398 file_path = Some(path.replace('\\', "/"));
399 continue;
400 }
401 query_parts.push(arg);
402 }
403 let query = query_parts.join(" ").trim().to_string();
404 if query.is_empty() {
405 return Err("usage: /gitgraph-search [limit=200] [path=src/file.rs] <query>".to_string());
406 }
407 Ok(ParsedSearchArgs {
408 limit: limit.unwrap_or(DEFAULT_LIMIT),
409 file_path,
410 query,
411 })
412}
413
414#[derive(Debug)]
415struct ParsedSearchArgs {
416 limit: usize,
417 file_path: Option<String>,
418 query: String,
419}
420
421fn parse_blame_args(args: Vec<String>) -> Result<(String, usize), String> {
422 if args.len() < 2 {
423 return Err("usage: /gitgraph-blame <path> <line>".to_string());
424 }
425 let file = args[0].clone();
426 let line = args[1]
427 .parse::<usize>()
428 .map_err(|e| format!("invalid line {:?}: {}", args[1], e))?;
429 if line == 0 {
430 return Err("line must be >= 1".to_string());
431 }
432 Ok((file, line))
433}
434
435#[derive(Debug)]
436struct ParsedActionArgs {
437 template_id: String,
438 params: HashMap<String, String>,
439 enabled_options: HashSet<String>,
440 context: ActionContext,
441}
442
443fn parse_action_args(args: Vec<String>) -> Result<ParsedActionArgs, String> {
444 let Some((template_id, tail)) = args.split_first() else {
445 return Err(
446 "usage: /gitgraph-action <action-id> KEY=VALUE +opt:<option-id> (e.g. BRANCH_NAME=main)"
447 .to_string(),
448 );
449 };
450 let mut params = HashMap::new();
451 let mut enabled_options = HashSet::new();
452 let mut context = ActionContext::default();
453 context.default_remote_name = Some("origin".to_string());
454
455 for token in tail {
456 if let Some(opt) = token.strip_prefix("+opt:") {
457 enabled_options.insert(opt.to_string());
458 continue;
459 }
460 let (key, value) = token
461 .split_once('=')
462 .ok_or_else(|| format!("invalid token {:?}, expected KEY=VALUE or +opt:<id>", token))?;
463 params.insert(key.to_string(), value.to_string());
464 map_context_placeholder(&mut context, key, value);
465 }
466
467 Ok(ParsedActionArgs {
468 template_id: template_id.to_string(),
469 params,
470 enabled_options,
471 context,
472 })
473}
474
475fn map_context_placeholder(context: &mut ActionContext, key: &str, value: &str) {
476 match key {
477 "BRANCH_DISPLAY_NAME" => context.branch_display_name = Some(value.to_string()),
478 "BRANCH_NAME" => context.branch_name = Some(value.to_string()),
479 "LOCAL_BRANCH_NAME" => context.local_branch_name = Some(value.to_string()),
480 "BRANCH_ID" => context.branch_id = Some(value.to_string()),
481 "SOURCE_BRANCH_NAME" => context.source_branch_name = Some(value.to_string()),
482 "TARGET_BRANCH_NAME" => context.target_branch_name = Some(value.to_string()),
483 "COMMIT_HASH" => context.commit_hash = Some(value.to_string()),
484 "COMMIT_HASHES" => {
485 context.commit_hashes = value
486 .split([',', ' '])
487 .map(str::trim)
488 .filter(|v| !v.is_empty())
489 .map(ToString::to_string)
490 .collect()
491 }
492 "COMMIT_BODY" => context.commit_body = Some(value.to_string()),
493 "STASH_NAME" => context.stash_name = Some(value.to_string()),
494 "TAG_NAME" => context.tag_name = Some(value.to_string()),
495 "REMOTE_NAME" => context.remote_name = Some(value.to_string()),
496 "DEFAULT_REMOTE_NAME" => context.default_remote_name = Some(value.to_string()),
497 _ => {
498 context
499 .additional_placeholders
500 .insert(key.to_string(), value.to_string());
501 }
502 }
503}
504
505fn lookup_dynamic_placeholder(
506 repo_root: &str,
507 placeholder: &str,
508) -> gitgraph_core::Result<Option<String>> {
509 if let Some(key) = placeholder.strip_prefix("GIT_CONFIG:") {
510 let out = run_git_command(
511 repo_root,
512 &["config".to_string(), "--get".to_string(), key.to_string()],
513 )
514 .map_err(GitLgError::State)?;
515 return Ok(Some(
516 String::from_utf8_lossy(&out.stdout).trim().to_string(),
517 ));
518 }
519 if let Some(raw_exec) = placeholder.strip_prefix("GIT_EXEC:") {
520 let args = shlex::split(raw_exec).unwrap_or_else(|| {
521 raw_exec
522 .split_whitespace()
523 .map(ToString::to_string)
524 .collect()
525 });
526 let out = run_git_command(repo_root, &args).map_err(GitLgError::State)?;
527 return Ok(Some(
528 String::from_utf8_lossy(&out.stdout).trim().to_string(),
529 ));
530 }
531 Ok(None)
532}
533
534fn run_git_log(repo_root: &str, limit: usize) -> Result<String, String> {
535 let args = vec![
536 "-c".to_string(),
537 "color.ui=never".to_string(),
538 "log".to_string(),
539 "--date-order".to_string(),
540 "--topo-order".to_string(),
541 "--decorate=full".to_string(),
542 "--color=never".to_string(),
543 "--no-show-signature".to_string(),
544 "--no-notes".to_string(),
545 "--all".to_string(),
546 "-n".to_string(),
547 limit.to_string(),
548 "--pretty=format:%H%x1f%h%x1f%P%x1f%an%x1f%ae%x1f%at%x1f%ct%x1f%D%x1f%s%x1f%b%x1e"
549 .to_string(),
550 ];
551 let out = run_git_command(repo_root, &args)?;
552 if out.status != Some(0) {
553 return Err(format!(
554 "git log failed (exit {:?}): {}",
555 out.status,
556 String::from_utf8_lossy(&out.stderr)
557 ));
558 }
559 String::from_utf8(out.stdout).map_err(|e| format!("invalid utf-8 from git: {}", e))
560}
561
562fn run_git_command(repo_root: &str, args: &[String]) -> Result<zed::process::Output, String> {
563 let mut full_args = vec!["-C".to_string(), repo_root.to_string()];
564 full_args.extend(args.to_vec());
565 let mut cmd = zed::process::Command::new("git").args(full_args);
566 cmd.output()
567}
568
569fn run_shell_command(repo_root: &str, script: &str) -> Result<zed::process::Output, String> {
570 let (os, _arch) = zed::current_platform();
571 match os {
572 zed::Os::Windows => {
573 let mut cmd = zed::process::Command::new("cmd").args([
574 "/C".to_string(),
575 format!("cd /d \"{}\" && {}", repo_root, script),
576 ]);
577 cmd.output()
578 }
579 _ => {
580 let mut cmd = zed::process::Command::new("sh").args([
581 "-lc".to_string(),
582 format!("cd \"{}\" && {}", repo_root, script),
583 ]);
584 cmd.output()
585 }
586 }
587}
588
589fn render_rows(repo_root: &str, rows: &[gitgraph_core::GraphRow], subtitle: &str) -> String {
590 let mut out = String::new();
591 out.push_str(&format!("# GitGraph log for `{}`\n\n", repo_root));
592 out.push_str(subtitle);
593 out.push_str("\n\n");
594
595 for row in rows {
596 let graph_prefix = format!("{}*", "| ".repeat(row.lane));
597 let refs = if row.refs.is_empty() {
598 String::new()
599 } else {
600 let names = row
601 .refs
602 .iter()
603 .map(|r| r.name.as_str())
604 .collect::<Vec<_>>()
605 .join(", ");
606 format!(" ({})", names)
607 };
608 out.push_str(&format!(
609 "- {} `{}` {}{} - {}\n",
610 graph_prefix, row.short_hash, row.subject, refs, row.author_name
611 ));
612 }
613
614 if rows.is_empty() {
615 out.push_str("- (no commits found for current selection)\n");
616 }
617 out
618}
619
620fn render_blame_text(repo_root: &str, file: &str, line: usize, raw: &str) -> String {
621 let mut commit_hash = "";
622 let mut author = "";
623 let mut author_mail = "";
624 let mut summary = "";
625 let mut author_time = "";
626 for (idx, l) in raw.lines().enumerate() {
627 if idx == 0 {
628 commit_hash = l.split_whitespace().next().unwrap_or_default();
629 continue;
630 }
631 if let Some(v) = l.strip_prefix("author ") {
632 author = v;
633 continue;
634 }
635 if let Some(v) = l.strip_prefix("author-mail ") {
636 author_mail = v;
637 continue;
638 }
639 if let Some(v) = l.strip_prefix("author-time ") {
640 author_time = v;
641 continue;
642 }
643 if let Some(v) = l.strip_prefix("summary ") {
644 summary = v;
645 continue;
646 }
647 }
648 [
649 format!("# GitGraph blame for `{}`", repo_root),
650 String::new(),
651 format!("file: `{}`", file),
652 format!("line: `{}`", line),
653 format!("commit: `{}`", commit_hash),
654 format!("author: `{}` {}", author, author_mail),
655 format!("author_time_unix: `{}`", author_time),
656 format!("summary: {}", summary),
657 ]
658 .join("\n")
659}
660
661fn build_output(text: String, label: &str) -> zed::SlashCommandOutput {
662 let end = text.len() as u32;
663 zed::SlashCommandOutput {
664 text,
665 sections: vec![zed::SlashCommandOutputSection {
666 range: zed::Range { start: 0, end },
667 label: label.to_string(),
668 }],
669 }
670}
671
672zed::register_extension!(GitGraphZedExtension);