mod annotation;
mod app;
mod context;
mod coordinates;
mod diff_algo;
pub mod git;
mod global_search;
pub mod highlight;
mod render;
mod search;
mod state;
mod sticky_lines;
mod text_edit;
pub mod theme;
mod types;
mod watcher;
use std::collections::HashSet;
use std::io;
use std::process::{self, Command};
use std::thread;
use spinoff::{spinners, Color, Spinner};
use crate::commit_reference::CommitReference;
use crate::vcs::VcsBackend;
pub struct DiffOptions {
pub reference: Option<CommitReference>,
pub pr: Option<String>,
pub file: Option<Vec<String>>,
pub watch: bool,
pub theme: Option<String>,
pub stacked: bool,
pub focus: Option<String>,
pub origin: Option<String>,
pub wrap: bool,
}
#[derive(Clone)]
pub struct PrInfo {
pub number: u64,
pub node_id: String,
pub repo_owner: String,
pub repo_name: String,
pub base_ref: String,
pub head_ref: String,
pub base_repo_owner: String,
pub head_repo_owner: Option<String>, }
fn parse_pr_input(input: &str) -> Option<(Option<String>, Option<String>, u64)> {
if input.starts_with("http://") || input.starts_with("https://") {
let parts: Vec<&str> = input.trim_end_matches('/').split('/').collect();
if parts.len() >= 2 {
if let Some(pos) = parts.iter().position(|&p| p == "pull") {
if pos + 1 < parts.len() {
if let Ok(num) = parts[pos + 1].parse::<u64>() {
if pos >= 2 {
let owner = parts[pos - 2].to_string();
let repo = parts[pos - 1].to_string();
return Some((Some(owner), Some(repo), num));
}
return Some((None, None, num));
}
}
}
}
None
} else {
input.parse::<u64>().ok().map(|num| (None, None, num))
}
}
fn resolve_origin_repo() -> Result<String, String> {
let output = Command::new("git")
.args(["remote", "get-url", "origin"])
.output()
.map_err(|e| format!("Failed to run git: {}", e))?;
if !output.status.success() {
return Err(
"Could not determine repository. Set origin remote or use --origin owner/repo"
.to_string(),
);
}
let url = String::from_utf8_lossy(&output.stdout).trim().to_string();
let url = url.strip_suffix(".git").unwrap_or(&url);
let path = url
.split("github.com")
.nth(1)
.ok_or_else(|| format!("Origin URL is not a GitHub URL: {}", url))?;
let path = path.trim_start_matches(':').trim_start_matches('/');
let parts: Vec<&str> = path.split('/').collect();
if parts.len() >= 2 {
Ok(format!("{}/{}", parts[0], parts[1]))
} else {
Err(format!("Could not parse owner/repo from origin URL: {}", url))
}
}
fn fetch_pr_info(pr_input: &str, repo_override: Option<&str>) -> Result<PrInfo, String> {
let (owner, repo, number) = parse_pr_input(pr_input).ok_or_else(|| {
format!(
"Invalid PR reference: {}. Use a PR number or URL.",
pr_input
)
})?;
let repo_full = match (&owner, &repo, repo_override) {
(Some(o), Some(r), _) => format!("{}/{}", o, r),
(_, _, Some(r)) => r.to_string(),
_ => resolve_origin_repo()?,
};
let (repo_owner, repo_name) = {
let parts: Vec<&str> = repo_full.split('/').collect();
if parts.len() != 2 {
return Err(format!("Invalid repo format: {}", repo_full));
}
(
owner.unwrap_or_else(|| parts[0].to_string()),
repo.unwrap_or_else(|| parts[1].to_string()),
)
};
let query = format!(
r#"query {{ repository(owner: "{}", name: "{}") {{ pullRequest(number: {}) {{ id url baseRefName headRefName baseRepository {{ owner {{ login }} }} headRepository {{ owner {{ login }} }} }} }} }}"#,
repo_owner, repo_name, number
);
let output = Command::new("gh")
.args(["api", "graphql", "-f", &format!("query={}", query)])
.output()
.map_err(|e| format!("Failed to run gh api graphql: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("gh api graphql failed: {}", stderr.trim()));
}
let json_str = String::from_utf8_lossy(&output.stdout);
let node_id = extract_json_string(&json_str, "id")
.ok_or_else(|| "Could not parse PR node ID from GraphQL response".to_string())?;
let base_ref =
extract_json_string(&json_str, "baseRefName").unwrap_or_else(|| "base".to_string());
let head_ref =
extract_json_string(&json_str, "headRefName").unwrap_or_else(|| "head".to_string());
let base_repo_owner =
extract_nested_login(&json_str, "baseRepository").unwrap_or_else(|| repo_owner.clone());
let head_repo_owner = extract_nested_login(&json_str, "headRepository");
Ok(PrInfo {
number,
node_id,
repo_owner,
repo_name,
base_ref,
head_ref,
base_repo_owner,
head_repo_owner,
})
}
fn extract_json_string(json: &str, key: &str) -> Option<String> {
let pattern = format!("\"{}\":\"", key);
if let Some(start) = json.find(&pattern) {
let value_start = start + pattern.len();
if let Some(end) = json[value_start..].find('"') {
return Some(json[value_start..value_start + end].to_string());
}
}
None
}
fn extract_nested_login(json: &str, parent_key: &str) -> Option<String> {
let pattern = format!("\"{}\":", parent_key);
if let Some(start) = json.find(&pattern) {
let after_key = &json[start + pattern.len()..];
if after_key.trim_start().starts_with("null") {
return None;
}
if let Some(login_start) = after_key.find("\"login\":\"") {
let value_start = login_start + 9;
let after_login = &after_key[value_start..];
if let Some(end) = after_login.find('"') {
return Some(after_login[..end].to_string());
}
}
}
None
}
pub fn fetch_viewed_files(pr_info: &PrInfo) -> Result<HashSet<String>, String> {
let query = format!(
r#"query {{ repository(owner: "{}", name: "{}") {{ pullRequest(number: {}) {{ files(first: 100) {{ nodes {{ path viewerViewedState }} }} }} }} }}"#,
pr_info.repo_owner, pr_info.repo_name, pr_info.number
);
let output = Command::new("gh")
.args(["api", "graphql", "-f", &format!("query={}", query)])
.output()
.map_err(|e| format!("Failed to run gh api graphql: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("gh api graphql failed: {}", stderr.trim()));
}
let json_str = String::from_utf8_lossy(&output.stdout);
let mut viewed_files = HashSet::new();
let mut remaining = json_str.as_ref();
while let Some(path_start) = remaining.find("\"path\":\"") {
let path_value_start = path_start + 8;
let after_path = &remaining[path_value_start..];
if let Some(path_end) = after_path.find('"') {
let path = &after_path[..path_end];
let after_path_str = &after_path[path_end..];
if let Some(state_start) = after_path_str.find("\"viewerViewedState\":\"") {
let state_value_start = state_start + 21;
let after_state = &after_path_str[state_value_start..];
if let Some(state_end) = after_state.find('"') {
let state = &after_state[..state_end];
if state == "VIEWED" {
viewed_files.insert(path.to_string());
}
}
}
remaining = &remaining[path_value_start + path_end..];
} else {
break;
}
}
Ok(viewed_files)
}
pub fn mark_file_as_viewed_async(pr_info: &PrInfo, file_path: &str) {
let node_id = pr_info.node_id.clone();
let path = file_path.to_string();
thread::spawn(move || {
let _ = mark_file_as_viewed_sync(&node_id, &path);
});
}
pub fn unmark_file_as_viewed_async(pr_info: &PrInfo, file_path: &str) {
let node_id = pr_info.node_id.clone();
let path = file_path.to_string();
thread::spawn(move || {
let _ = unmark_file_as_viewed_sync(&node_id, &path);
});
}
fn mark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), String> {
let mutation = format!(
r#"mutation {{ markFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#,
node_id, file_path
);
let output = Command::new("gh")
.args(["api", "graphql", "-f", &format!("query={}", mutation)])
.output()
.map_err(|e| format!("Failed to run gh api graphql: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(stderr.trim().to_string());
}
Ok(())
}
fn unmark_file_as_viewed_sync(node_id: &str, file_path: &str) -> Result<(), String> {
let mutation = format!(
r#"mutation {{ unmarkFileAsViewed(input: {{ pullRequestId: "{}", path: "{}" }}) {{ clientMutationId }} }}"#,
node_id, file_path
);
let output = Command::new("gh")
.args(["api", "graphql", "-f", &format!("query={}", mutation)])
.output()
.map_err(|e| format!("Failed to run gh api graphql: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(stderr.trim().to_string());
}
Ok(())
}
pub fn run_diff_ui(options: DiffOptions, backend: &dyn VcsBackend) -> io::Result<()> {
if let Some(ref pr_input) = options.pr {
let spinner_msg = match parse_pr_input(pr_input) {
Some((Some(owner), Some(repo), number)) => {
format!("Fetching PR {}/{}#{}", owner, repo, number)
}
Some((_, _, number)) => {
format!("Fetching PR #{}", number)
}
None => "Fetching PR".to_string(),
};
let mut spinner = Spinner::new(spinners::Dots, spinner_msg, Color::Cyan);
match fetch_pr_info(pr_input, options.origin.as_deref()) {
Ok(pr_info) => {
spinner.success("Fetched PR metadata");
return app::run_app_with_pr(options, pr_info, backend);
}
Err(e) => {
spinner.fail(&e);
process::exit(1);
}
}
}
if let Some(CommitReference::Single(ref input)) = options.reference {
if input.contains("/pull/") || input.parse::<u64>().is_ok() {
let spinner_msg = match parse_pr_input(input) {
Some((Some(owner), Some(repo), number)) => {
format!("Fetching PR {}/{}#{}", owner, repo, number)
}
Some((_, _, number)) => {
format!("Fetching PR #{}", number)
}
None => "Fetching PR".to_string(),
};
let mut spinner = Spinner::new(spinners::Dots, spinner_msg, Color::Cyan);
match fetch_pr_info(input, options.origin.as_deref()) {
Ok(pr_info) => {
spinner.success("Fetched PR metadata");
return app::run_app_with_pr(options, pr_info, backend);
}
Err(e) => {
spinner.fail(&e);
process::exit(1);
}
}
}
}
if options.stacked {
if let Some(ref reference) = options.reference {
let (from, to) = match reference {
CommitReference::Range { from, to } => (from.clone(), to.clone()),
CommitReference::TripleDots { from, to } => {
let merge_base = backend
.get_merge_base(from, to)
.unwrap_or_else(|_| from.clone());
(merge_base, to.clone())
}
CommitReference::Single(_) | CommitReference::RangeToWorkingTree { .. } => {
eprintln!(
"\x1b[91merror:\x1b[0m --stacked requires a range (e.g., main..feature)"
);
process::exit(1);
}
};
let commits = match backend.get_commits_in_range(&from, &to) {
Ok(c) if c.is_empty() => {
eprintln!(
"\x1b[91merror:\x1b[0m No commits found in range {}..{}",
from, to
);
process::exit(1);
}
Ok(c) => c,
Err(e) => {
eprintln!("\x1b[91merror:\x1b[0m {}", e);
process::exit(1);
}
};
return app::run_app_stacked(options, commits, backend);
} else {
eprintln!("\x1b[91merror:\x1b[0m --stacked requires a range (e.g., main..feature)");
process::exit(1);
}
}
app::run_app(options, None, backend)
}