diff --git a/CLAUDE.md b/AGENTS.md similarity index 100% rename from CLAUDE.md rename to AGENTS.md diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..04338cca --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: set this to true or false + msw: set this to true or false diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 17ad001d..2c41be39 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,16 @@ mod modules; -use modules::{fs, net, pty, secrets, shell, workspace}; +use modules::{fs, git, net, pty, secrets, shell, workspace}; use tauri::{Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use tauri_plugin_window_state::StateFlags; +#[tauri::command] +async fn app_current_dir() -> Result { + std::env::current_dir() + .map(|p| p.to_string_lossy().replace('\\', "/")) + .map_err(|e| e.to_string()) +} + #[tauri::command] async fn open_settings_window(app: tauri::AppHandle, tab: Option) -> Result<(), String> { let url_path = match tab.as_deref() { @@ -103,6 +110,17 @@ pub fn run() { fs::search::fs_list_files, fs::grep::fs_grep, fs::grep::fs_glob, + git::commands::git_resolve_repo, + git::commands::git_status, + git::commands::git_diff, + git::commands::git_diff_content, + git::commands::git_stage, + git::commands::git_unstage, + git::commands::git_discard, + git::commands::git_commit, + git::commands::git_fetch, + git::commands::git_pull_ff_only, + git::commands::git_push, shell::shell_run_command, shell::shell_session_open, shell::shell_session_run, @@ -114,6 +132,7 @@ pub fn run() { workspace::wsl_list_distros, workspace::wsl_default_distro, workspace::wsl_home, + app_current_dir, open_settings_window, secrets::secrets_get, secrets::secrets_set, diff --git a/src-tauri/src/modules/git/commands.rs b/src-tauri/src/modules/git/commands.rs new file mode 100644 index 00000000..8ce2fb1a --- /dev/null +++ b/src-tauri/src/modules/git/commands.rs @@ -0,0 +1,68 @@ +use crate::modules::git::operations; +use crate::modules::git::types::{ + GitCommitResult, GitDiffContentResult, GitDiffResult, GitPushResult, GitRepoInfo, + GitStatusSnapshot, +}; + +#[tauri::command] +pub async fn git_resolve_repo(cwd: String) -> Result, String> { + operations::resolve_repo(&cwd) +} + +#[tauri::command] +pub async fn git_status(repo_root: String) -> Result { + operations::status(&repo_root) +} + +#[tauri::command] +pub async fn git_diff( + repo_root: String, + path: Option, + staged: bool, +) -> Result { + operations::diff(&repo_root, path.as_deref(), staged) +} + +#[tauri::command] +pub async fn git_diff_content( + repo_root: String, + path: String, + staged: bool, +) -> Result { + operations::diff_content(&repo_root, &path, staged) +} + +#[tauri::command] +pub async fn git_stage(repo_root: String, paths: Vec) -> Result<(), String> { + operations::stage(&repo_root, &paths) +} + +#[tauri::command] +pub async fn git_unstage(repo_root: String, paths: Vec) -> Result<(), String> { + operations::unstage(&repo_root, &paths) +} + +#[tauri::command] +pub async fn git_discard(repo_root: String, paths: Vec) -> Result<(), String> { + operations::discard(&repo_root, &paths) +} + +#[tauri::command] +pub async fn git_commit(repo_root: String, message: String) -> Result { + operations::commit(&repo_root, &message) +} + +#[tauri::command] +pub async fn git_fetch(repo_root: String) -> Result<(), String> { + operations::fetch(&repo_root) +} + +#[tauri::command] +pub async fn git_pull_ff_only(repo_root: String) -> Result<(), String> { + operations::pull_ff_only(&repo_root) +} + +#[tauri::command] +pub async fn git_push(repo_root: String) -> Result { + operations::push(&repo_root) +} diff --git a/src-tauri/src/modules/git/errors.rs b/src-tauri/src/modules/git/errors.rs new file mode 100644 index 00000000..9959bda5 --- /dev/null +++ b/src-tauri/src/modules/git/errors.rs @@ -0,0 +1,46 @@ +use std::fmt::{Display, Formatter}; + +#[derive(Debug)] +pub enum GitError { + GitNotInstalled, + + RepoNotFound(String), + + CommandTimedOut, + + SpawnFailed(String), + + CommandFailed(String), + + Io(std::io::Error), +} + +#[allow(dead_code)] +pub type Result = std::result::Result; + +impl Display for GitError { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + GitError::GitNotInstalled => write!(f, "git is not available"), + GitError::RepoNotFound(path) => write!(f, "path is not a directory: {path}"), + GitError::CommandTimedOut => write!(f, "git command timed out"), + GitError::SpawnFailed(err) => write!(f, "failed to spawn git process: {err}"), + GitError::CommandFailed(err) => write!(f, "{err}"), + GitError::Io(err) => write!(f, "Input/Output error: {err}"), + } + } +} + +impl std::error::Error for GitError {} + +impl From for GitError { + fn from(value: std::io::Error) -> Self { + GitError::Io(value) + } +} + +impl From for String { + fn from(value: GitError) -> Self { + value.to_string() + } +} diff --git a/src-tauri/src/modules/git/mod.rs b/src-tauri/src/modules/git/mod.rs new file mode 100644 index 00000000..158108e5 --- /dev/null +++ b/src-tauri/src/modules/git/mod.rs @@ -0,0 +1,7 @@ +pub mod commands; +pub mod errors; +mod operations; +mod parser; +mod process; +pub mod types; +pub mod utils; diff --git a/src-tauri/src/modules/git/operations.rs b/src-tauri/src/modules/git/operations.rs new file mode 100644 index 00000000..4dd61ab9 --- /dev/null +++ b/src-tauri/src/modules/git/operations.rs @@ -0,0 +1,304 @@ +use std::ffi::OsStr; +use std::path::Path; + +use crate::modules::git::parser::{parse_branch_header, parse_changed_files}; +use crate::modules::git::process::{ + ensure_git_available, ensure_success, git_show_text, git_stdout_line, git_stdout_line_opt, + read_text_file, run_git, run_git_os, +}; +use crate::modules::git::types::{ + GitCommitResult, GitDiffContentResult, GitDiffResult, GitPushResult, GitRepoInfo, + GitStatusSnapshot, TextSource, DEFAULT_TIMEOUT_SECS, +}; +use crate::modules::git::utils::{canonical_dir, display_path, split_upstream}; + +pub fn resolve_repo(cwd: &str) -> Result, String> { + let cwd = canonical_dir(cwd)?; + repo_info_from_cwd(&cwd) +} + +pub fn status(repo_root: &str) -> Result { + let repo_root = canonical_dir(repo_root)?; + ensure_git_available()?; + let output = run_git( + Some(&repo_root), + [ + "status", + "--porcelain=v1", + "--branch", + "-z", + "--untracked-files=all", + ], + DEFAULT_TIMEOUT_SECS, + )?; + ensure_success(&output, "git status failed")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let fields: Vec<&str> = stdout.split('\0').filter(|s| !s.is_empty()).collect(); + if fields.is_empty() { + return Err("git status returned no data".into()); + } + + let (branch, upstream, ahead, behind, is_detached) = parse_branch_header(fields[0])?; + + Ok(GitStatusSnapshot { + repo_root: display_path(&repo_root), + branch, + upstream, + ahead, + behind, + is_detached, + changed_files: parse_changed_files(&fields), + }) +} + +pub fn diff(repo_root: &str, path: Option<&str>, staged: bool) -> Result { + let repo_root = canonical_dir(repo_root)?; + ensure_git_available()?; + + let mut args: Vec<&str> = vec!["diff", "--no-ext-diff"]; + if staged { + args.push("--cached"); + } + if let Some(path) = path.filter(|p| !p.is_empty()) { + args.extend(["--", path]); + } + + let output = run_git(Some(&repo_root), args, DEFAULT_TIMEOUT_SECS)?; + ensure_success(&output, "git diff failed")?; + + Ok(GitDiffResult { + diff_text: String::from_utf8_lossy(&output.stdout).into_owned(), + }) +} + +pub fn diff_content( + repo_root: &str, + path: &str, + staged: bool, +) -> Result { + let repo_root = canonical_dir(repo_root)?; + ensure_git_available()?; + let path_ref = Path::new(path); + let worktree_path = repo_root.join(path_ref); + + let original = if staged { + git_show_text(&repo_root, &format!("HEAD:{path}"))? + } else { + git_show_text(&repo_root, &format!(":{path}"))? + }; + let modified = if staged { + git_show_text(&repo_root, &format!(":{path}"))? + } else { + read_text_file(&worktree_path)? + }; + let patch = diff(&display_path(&repo_root), Some(path), staged)?.diff_text; + let is_binary = + matches!(original, TextSource::Binary) || matches!(modified, TextSource::Binary); + + Ok(GitDiffContentResult { + original_content: original.into_text(), + modified_content: modified.into_text(), + is_binary, + fallback_patch: patch, + }) +} + +pub fn stage(repo_root: &str, paths: &[String]) -> Result<(), String> { + let repo_root = canonical_dir(repo_root)?; + ensure_git_available()?; + run_git_paths(&repo_root, "add", paths) +} + +pub fn unstage(repo_root: &str, paths: &[String]) -> Result<(), String> { + let repo_root = canonical_dir(repo_root)?; + ensure_git_available()?; + let mut args: Vec<&OsStr> = vec![OsStr::new("reset"), OsStr::new("HEAD"), OsStr::new("--")]; + for path in paths { + args.push(OsStr::new(path)); + } + let output = run_git_os(Some(&repo_root), args, DEFAULT_TIMEOUT_SECS)?; + ensure_success(&output, "git reset failed") +} + +pub fn discard(repo_root: &str, paths: &[String]) -> Result<(), String> { + let repo_root = canonical_dir(repo_root)?; + ensure_git_available()?; + if paths.is_empty() { + return Ok(()); + } + + let tracked = status_paths(&repo_root, paths, false)?; + if !tracked.is_empty() { + let mut args: Vec<&OsStr> = vec![ + OsStr::new("restore"), + OsStr::new("--worktree"), + OsStr::new("--"), + ]; + for path in &tracked { + args.push(OsStr::new(path)); + } + let output = run_git_os(Some(&repo_root), args, DEFAULT_TIMEOUT_SECS)?; + ensure_success(&output, "git restore failed")?; + } + + let untracked = status_paths(&repo_root, paths, true)?; + if !untracked.is_empty() { + let mut args: Vec<&OsStr> = vec![OsStr::new("clean"), OsStr::new("-f"), OsStr::new("--")]; + for path in &untracked { + args.push(OsStr::new(path)); + } + let output = run_git_os(Some(&repo_root), args, DEFAULT_TIMEOUT_SECS)?; + ensure_success(&output, "git clean failed")?; + } + + Ok(()) +} + +pub fn commit(repo_root: &str, message: &str) -> Result { + let repo_root = canonical_dir(repo_root)?; + ensure_git_available()?; + let trimmed = message.trim(); + if trimmed.is_empty() { + return Err("commit message cannot be empty".into()); + } + + let output = run_git_os( + Some(&repo_root), + [OsStr::new("commit"), OsStr::new("-m"), OsStr::new(trimmed)], + DEFAULT_TIMEOUT_SECS, + )?; + ensure_success(&output, "git commit failed")?; + + let sha = git_stdout_line( + &repo_root, + ["rev-parse", "HEAD"], + "failed to resolve commit sha", + )?; + let summary = git_stdout_line( + &repo_root, + ["show", "-s", "--format=%s", "HEAD"], + "failed to read commit summary", + )?; + + Ok(GitCommitResult { + commit_sha: sha, + summary, + }) +} + +pub fn push(repo_root: &str) -> Result { + let repo_root = canonical_dir(repo_root)?; + ensure_git_available()?; + let output = run_git(Some(&repo_root), ["push"], DEFAULT_TIMEOUT_SECS)?; + ensure_success(&output, "git push failed")?; + + let upstream = git_stdout_line( + &repo_root, + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + "failed to resolve upstream", + )?; + let (remote, branch) = split_upstream(&upstream); + + Ok(GitPushResult { + remote, + branch, + pushed: true, + }) +} + +pub fn fetch(repo_root: &str) -> Result<(), String> { + let repo_root = canonical_dir(repo_root)?; + ensure_git_available()?; + let output = run_git(Some(&repo_root), git_fetch_args(), DEFAULT_TIMEOUT_SECS)?; + ensure_success(&output, "git fetch failed") +} + +pub fn pull_ff_only(repo_root: &str) -> Result<(), String> { + let repo_root = canonical_dir(repo_root)?; + ensure_git_available()?; + let output = run_git(Some(&repo_root), git_pull_ff_only_args(), DEFAULT_TIMEOUT_SECS)?; + ensure_success(&output, "git pull --ff-only failed") +} + +fn repo_info_from_cwd(cwd: &Path) -> Result, String> { + ensure_git_available()?; + let root = match git_stdout_line_opt(cwd, ["rev-parse", "--show-toplevel"])? { + Some(root) => root, + None => return Ok(None), + }; + let repo_root = canonical_dir(&root)?; + let head = git_stdout_line( + &repo_root, + ["rev-parse", "--abbrev-ref", "HEAD"], + "failed to resolve HEAD", + )?; + let upstream = git_stdout_line_opt( + &repo_root, + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], + )?; + + Ok(Some(GitRepoInfo { + repo_root: display_path(&repo_root), + branch: head.clone(), + upstream, + is_detached: head == "HEAD", + })) +} + +fn run_git_paths(repo_root: &Path, command: &str, paths: &[String]) -> Result<(), String> { + if paths.is_empty() { + return Ok(()); + } + let mut args: Vec<&OsStr> = vec![OsStr::new(command), OsStr::new("--")]; + for path in paths { + args.push(OsStr::new(path)); + } + let output = run_git_os(Some(repo_root), args, DEFAULT_TIMEOUT_SECS)?; + ensure_success(&output, &format!("git {command} failed")) +} + +fn status_paths(repo_root: &Path, paths: &[String], untracked: bool) -> Result, String> { + let mut filtered = Vec::new(); + for path in paths { + let mut args: Vec<&OsStr> = vec![OsStr::new("ls-files")]; + if untracked { + args.push(OsStr::new("--others")); + args.push(OsStr::new("--exclude-standard")); + } else { + args.push(OsStr::new("--modified")); + args.push(OsStr::new("--deleted")); + } + args.push(OsStr::new("--")); + args.push(OsStr::new(path)); + let output = run_git_os(Some(repo_root), args, DEFAULT_TIMEOUT_SECS)?; + ensure_success(&output, "git ls-files failed")?; + if !output.stdout.is_empty() { + filtered.push(path.clone()); + } + } + Ok(filtered) +} + +fn git_fetch_args() -> [&'static str; 2] { + ["fetch", "--prune"] +} + +fn git_pull_ff_only_args() -> [&'static str; 2] { + ["pull", "--ff-only"] +} + +#[cfg(test)] +mod tests { + use super::{git_fetch_args, git_pull_ff_only_args}; + + #[test] + fn fetch_uses_prune() { + assert_eq!(git_fetch_args(), ["fetch", "--prune"]); + } + + #[test] + fn pull_uses_fast_forward_only() { + assert_eq!(git_pull_ff_only_args(), ["pull", "--ff-only"]); + } +} diff --git a/src-tauri/src/modules/git/parser.rs b/src-tauri/src/modules/git/parser.rs new file mode 100644 index 00000000..5c3100be --- /dev/null +++ b/src-tauri/src/modules/git/parser.rs @@ -0,0 +1,151 @@ +use crate::modules::git::types::GitChangedFile; + +pub fn parse_branch_header( + header: &str, +) -> Result<(String, Option, u32, u32, bool), String> { + if !header.starts_with("## ") { + return Err("malformed git status branch header".into()); + } + + let body = &header[3..]; + let mut ahead = 0u32; + let mut behind = 0u32; + let (head_part, upstream) = match body.split_once("...") { + Some((head, rest)) => { + let (upstream, meta) = match rest.split_once(' ') { + Some((upstream, meta)) => (Some(upstream.to_string()), Some(meta)), + None => (Some(rest.to_string()), None), + }; + + if let Some(meta) = meta { + if let Some(start) = meta.find('[') { + if let Some(end) = meta[start + 1..].find(']') { + let status = &meta[start + 1..start + 1 + end]; + for part in status.split(',') { + let part = part.trim(); + if let Some(v) = part.strip_prefix("ahead ") { + ahead = v.parse::().unwrap_or(0); + } else if let Some(v) = part.strip_prefix("behind ") { + behind = v.parse::().unwrap_or(0); + } + } + } + } + } + + (head.to_string(), upstream) + } + None => (body.split(' ').next().unwrap_or("HEAD").to_string(), None), + }; + + let is_detached = head_part == "HEAD" || head_part.contains("(detached"); + Ok((head_part, upstream, ahead, behind, is_detached)) +} + +pub fn parse_changed_files(fields: &[&str]) -> Vec { + let mut files = Vec::new(); + let mut i = 1usize; + while i < fields.len() { + let entry = fields[i]; + if entry.len() < 3 { + i += 1; + continue; + } + + let xy = &entry[..2]; + let path_part = &entry[3..]; + let index_status = xy.chars().next().unwrap_or(' '); + let worktree_status = xy.chars().nth(1).unwrap_or(' '); + let original_path = if matches!(index_status, 'R' | 'C') { + let prev = fields.get(i + 1).map(|s| (*s).to_string()); + i += 1; + prev + } else { + None + }; + + files.push(GitChangedFile { + path: path_part.to_string(), + original_path, + index_status: index_status.to_string(), + worktree_status: worktree_status.to_string(), + staged: is_staged(index_status, worktree_status), + unstaged: is_unstaged(index_status, worktree_status), + untracked: index_status == '?' && worktree_status == '?', + status_label: status_label(index_status, worktree_status), + }); + i += 1; + } + + files +} + +fn is_staged(index_status: char, worktree_status: char) -> bool { + index_status != ' ' && !(index_status == '?' && worktree_status == '?') +} + +fn is_unstaged(index_status: char, worktree_status: char) -> bool { + worktree_status != ' ' || (index_status == '?' && worktree_status == '?') +} + +fn status_label(index_status: char, worktree_status: char) -> String { + match (index_status, worktree_status) { + ('?', '?') => "Untracked".into(), + ('A', _) => "Added".into(), + ('M', _) | (_, 'M') => "Modified".into(), + ('D', _) | (_, 'D') => "Deleted".into(), + ('R', _) | (_, 'R') => "Renamed".into(), + ('C', _) | (_, 'C') => "Copied".into(), + ('U', _) | (_, 'U') => "Unmerged".into(), + _ => "Changed".into(), + } +} + +#[cfg(test)] +mod tests { + use super::parse_branch_header; + + #[test] + fn parses_ahead_branch_header() { + let (branch, upstream, ahead, behind, detached) = + parse_branch_header("## main...origin/main [ahead 2]").unwrap(); + assert_eq!(branch, "main"); + assert_eq!(upstream.as_deref(), Some("origin/main")); + assert_eq!(ahead, 2); + assert_eq!(behind, 0); + assert!(!detached); + } + + #[test] + fn parses_behind_branch_header() { + let (branch, upstream, ahead, behind, detached) = + parse_branch_header("## main...origin/main [behind 3]").unwrap(); + assert_eq!(branch, "main"); + assert_eq!(upstream.as_deref(), Some("origin/main")); + assert_eq!(ahead, 0); + assert_eq!(behind, 3); + assert!(!detached); + } + + #[test] + fn parses_diverged_branch_header() { + let (branch, upstream, ahead, behind, detached) = + parse_branch_header("## main...origin/main [ahead 4, behind 1]").unwrap(); + assert_eq!(branch, "main"); + assert_eq!(upstream.as_deref(), Some("origin/main")); + assert_eq!(ahead, 4); + assert_eq!(behind, 1); + assert!(!detached); + } + + #[test] + fn parses_detached_head_header() { + let (branch, upstream, ahead, behind, detached) = + parse_branch_header("## HEAD (detached at 1a2b3c4)").unwrap(); + assert_eq!(branch, "HEAD"); + assert_eq!(upstream, None); + assert_eq!(ahead, 0); + assert_eq!(behind, 0); + assert!(detached); + } +} diff --git a/src-tauri/src/modules/git/process.rs b/src-tauri/src/modules/git/process.rs new file mode 100644 index 00000000..065eaa12 --- /dev/null +++ b/src-tauri/src/modules/git/process.rs @@ -0,0 +1,187 @@ +use std::ffi::OsStr; +use std::io::Read; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::thread; +use std::time::{Duration, Instant}; + +use crate::modules::git::types::{ + GitOutput, TextSource, DEFAULT_TIMEOUT_SECS, MAX_OUTPUT_BYTES, MAX_TIMEOUT_SECS, POLL_INTERVAL, +}; + +pub fn ensure_git_available() -> Result<(), String> { + let output = run_git(None, ["--version"], 10)?; + ensure_success(&output, "git is not available") +} + +pub fn git_show_text(repo_root: &Path, spec: &str) -> Result { + let output = run_git_os( + Some(repo_root), + [ + OsStr::new("show"), + OsStr::new("--no-textconv"), + OsStr::new(spec), + ], + DEFAULT_TIMEOUT_SECS, + )?; + if output.timed_out { + return Err("git show timed out".into()); + } + if output.exit_code != Some(0) { + return Ok(TextSource::Missing); + } + decode_text(output.stdout) +} + +pub fn git_stdout_line(cwd: P, args: I, err_prefix: &str) -> Result +where + P: AsRef, + I: IntoIterator, + S: AsRef, +{ + match git_stdout_line_opt(cwd, args)? { + Some(v) => Ok(v), + None => Err(err_prefix.into()), + } +} + +pub fn git_stdout_line_opt(cwd: P, args: I) -> Result, String> +where + P: AsRef, + I: IntoIterator, + S: AsRef, +{ + let output = run_git_os(Some(cwd.as_ref()), args, DEFAULT_TIMEOUT_SECS)?; + if output.timed_out { + return Err("git command timed out".into()); + } + if output.exit_code != Some(0) { + return Ok(None); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let line = stdout.lines().next().unwrap_or("").trim(); + if line.is_empty() { + Ok(None) + } else { + Ok(Some(line.to_string())) + } +} + +pub fn read_text_file(path: &Path) -> Result { + if !path.exists() { + return Ok(TextSource::Missing); + } + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + decode_text(bytes) +} + +pub fn run_git(cwd: Option<&Path>, args: I, timeout_secs: u64) -> Result +where + I: IntoIterator, + S: AsRef, +{ + run_git_os(cwd, args, timeout_secs) +} + +pub fn run_git_os(cwd: Option<&Path>, args: I, timeout_secs: u64) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let dur = Duration::from_secs(timeout_secs.clamp(1, MAX_TIMEOUT_SECS)); + let mut cmd = Command::new("git"); + cmd.args(args); + if let Some(dir) = cwd { + cmd.current_dir(dir); + } + cmd.stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = cmd.spawn().map_err(|e| e.to_string())?; + let mut stdout_pipe = child.stdout.take().ok_or("no stdout pipe")?; + let mut stderr_pipe = child.stderr.take().ok_or("no stderr pipe")?; + let stdout_handle = thread::spawn(move || drain(&mut stdout_pipe)); + let stderr_handle = thread::spawn(move || drain(&mut stderr_pipe)); + + let started = Instant::now(); + let mut timed_out = false; + let exit_code = loop { + match child.try_wait() { + Ok(Some(status)) => break status.code(), + Ok(None) => {} + Err(e) => return Err(e.to_string()), + } + if started.elapsed() >= dur { + let _ = child.kill(); + let _ = child.wait(); + timed_out = true; + break None; + } + thread::sleep(POLL_INTERVAL); + }; + + let (stdout, _stdout_truncated) = stdout_handle.join().unwrap_or((Vec::new(), false)); + let (stderr, _stderr_truncated) = stderr_handle.join().unwrap_or((Vec::new(), false)); + + Ok(GitOutput { + stdout, + stderr, + exit_code, + timed_out, + }) +} + +pub fn ensure_success(output: &GitOutput, context: &str) -> Result<(), String> { + if output.timed_out { + return Err(format!("{context}: timed out")); + } + if output.exit_code == Some(0) { + return Ok(()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { + stderr + } else if !stdout.is_empty() { + stdout + } else { + "unknown git error".into() + }; + Err(format!("{context}: {detail}")) +} + +fn decode_text(bytes: Vec) -> Result { + let sniff_len = bytes.len().min(8192); + if bytes[..sniff_len].contains(&0) { + return Ok(TextSource::Binary); + } + match String::from_utf8(bytes) { + Ok(text) => Ok(TextSource::Text(text)), + Err(_) => Ok(TextSource::Binary), + } +} + +fn drain(reader: &mut R) -> (Vec, bool) { + let mut out = Vec::new(); + let mut buf = [0u8; 8192]; + let mut truncated = false; + loop { + match reader.read(&mut buf) { + Ok(0) => break, + Ok(n) => { + if out.len() >= MAX_OUTPUT_BYTES { + truncated = true; + continue; + } + let take = (MAX_OUTPUT_BYTES - out.len()).min(n); + out.extend_from_slice(&buf[..take]); + if take < n { + truncated = true; + } + } + Err(_) => break, + } + } + (out, truncated) +} diff --git a/src-tauri/src/modules/git/types.rs b/src-tauri/src/modules/git/types.rs new file mode 100644 index 00000000..daa7ae63 --- /dev/null +++ b/src-tauri/src/modules/git/types.rs @@ -0,0 +1,93 @@ +use serde::Serialize; +use std::time::Duration; + +pub(crate) const DEFAULT_TIMEOUT_SECS: u64 = 30; +pub(crate) const MAX_TIMEOUT_SECS: u64 = 120; +pub(crate) const MAX_OUTPUT_BYTES: usize = 512 * 1024; +pub(crate) const POLL_INTERVAL: Duration = Duration::from_millis(50); + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitRepoInfo { + pub repo_root: String, + pub branch: String, + pub upstream: Option, + pub is_detached: bool, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitChangedFile { + pub path: String, + pub original_path: Option, + pub index_status: String, + pub worktree_status: String, + pub staged: bool, + pub unstaged: bool, + pub untracked: bool, + pub status_label: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitStatusSnapshot { + pub repo_root: String, + pub branch: String, + pub upstream: Option, + pub ahead: u32, + pub behind: u32, + pub is_detached: bool, + pub changed_files: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitDiffResult { + pub diff_text: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitDiffContentResult { + pub original_content: String, + pub modified_content: String, + pub is_binary: bool, + pub fallback_patch: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitCommitResult { + pub commit_sha: String, + pub summary: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GitPushResult { + pub remote: Option, + pub branch: Option, + pub pushed: bool, +} + +pub(crate) struct GitOutput { + pub(crate) stdout: Vec, + pub(crate) stderr: Vec, + pub(crate) exit_code: Option, + pub(crate) timed_out: bool, +} + +pub(crate) enum TextSource { + Missing, + Binary, + Text(String), +} + +impl TextSource { + pub(crate) fn into_text(self) -> String { + match self { + TextSource::Text(text) => text, + TextSource::Missing | TextSource::Binary => String::new(), + } + } +} diff --git a/src-tauri/src/modules/git/utils.rs b/src-tauri/src/modules/git/utils.rs new file mode 100644 index 00000000..45c41136 --- /dev/null +++ b/src-tauri/src/modules/git/utils.rs @@ -0,0 +1,20 @@ +use std::path::{Path, PathBuf}; + +pub fn split_upstream(upstream: &str) -> (Option, Option) { + match upstream.split_once('/') { + Some((remote, branch)) => (Some(remote.to_string()), Some(branch.to_string())), + None => (None, Some(upstream.to_string())), + } +} + +pub fn display_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} + +pub fn canonical_dir(path: &str) -> Result { + let candidate = PathBuf::from(path); + if !candidate.is_dir() { + return Err(format!("path is not a directory: {path}")); + } + std::fs::canonicalize(&candidate).map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/modules/mod.rs b/src-tauri/src/modules/mod.rs index 2eac4f49..ebf7d0b8 100644 --- a/src-tauri/src/modules/mod.rs +++ b/src-tauri/src/modules/mod.rs @@ -1,4 +1,5 @@ pub mod fs; +pub mod git; pub mod net; pub mod pty; pub mod secrets; diff --git a/src-tauri/src/modules/net.rs b/src-tauri/src/modules/net.rs index 27ab9b40..5ff1e1cd 100644 --- a/src-tauri/src/modules/net.rs +++ b/src-tauri/src/modules/net.rs @@ -191,7 +191,6 @@ pub async fn lm_ping(base_url: String) -> Result { .map(|r| r.status().as_u16()) .map_err(|e| e.to_string()) } - // AI HTTP proxy — bypasses webview CORS / Mixed-Content / PNA so local-network // model servers (LM Studio, Ollama, vLLM) work in the production bundle. diff --git a/src-tauri/src/modules/pty/shell_init.rs b/src-tauri/src/modules/pty/shell_init.rs index fe167f67..973ae6fa 100644 --- a/src-tauri/src/modules/pty/shell_init.rs +++ b/src-tauri/src/modules/pty/shell_init.rs @@ -56,8 +56,10 @@ fn apply_common(cmd: &mut CommandBuilder, cwd: Option) { let resolved_cwd = cwd .map(PathBuf::from) .filter(|p| p.is_dir()) - .or_else(|| dirs::home_dir().filter(|p| p.is_dir())) - .or_else(|| std::env::current_dir().ok()); + // In `tauri dev`, inherit the repo cwd so explorer/source-control + // point at the project the user launched from instead of `$HOME`. + .or_else(|| std::env::current_dir().ok().filter(|p| p.is_dir())) + .or_else(|| dirs::home_dir().filter(|p| p.is_dir())); if let Some(cwd) = resolved_cwd { #[cfg(windows)] let cwd = PathBuf::from(cwd.to_string_lossy().replace('/', "\\")); diff --git a/src/app/App.tsx b/src/app/App.tsx index 8eca8cff..a7a72fe8 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -27,11 +27,13 @@ import { } from "@/modules/ai"; import { AiComposerProvider } from "@/modules/ai/lib/composer"; import { redactSensitive } from "@/modules/ai/lib/redact"; +import { native } from "@/modules/ai/lib/native"; import { useAgentsStore } from "@/modules/ai/store/agentsStore"; import { useSnippetsStore } from "@/modules/ai/store/snippetsStore"; import { AiDiffStack, EditorStack, + GitDiffStack, NewEditorDialog, type EditorPaneHandle, } from "@/modules/editor"; @@ -51,10 +53,15 @@ import { useGlobalShortcuts, type ShortcutHandlers, } from "@/modules/shortcuts"; +import { + SourceControlPanel, + useSourceControl, +} from "@/modules/source-control"; import { StatusBar } from "@/modules/statusbar"; import { MAX_PANES_PER_TAB, useTabs, useWorkspaceCwd } from "@/modules/tabs"; import { disposeSession, + findLeafCwd, hasLeaf, leafIds, respawnSession, @@ -75,6 +82,41 @@ import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PanelImperativeHandle } from "react-resizable-panels"; +function dirname(path: string | null): string | null { + if (!path) return null; + const normalized = path.replace(/\\/g, "/"); + const idx = normalized.lastIndexOf("/"); + if (idx <= 0) return normalized; + return normalized.slice(0, idx); +} + +const SOURCE_CONTROL_DEFAULT_WIDTH = 300; +const SOURCE_CONTROL_MIN_WIDTH = 240; +const SOURCE_CONTROL_MAX_WIDTH = 420; +const SOURCE_CONTROL_MIN_SIZE = `${SOURCE_CONTROL_MIN_WIDTH}px`; +const SOURCE_CONTROL_MAX_SIZE = `${SOURCE_CONTROL_MAX_WIDTH}px`; +const SOURCE_CONTROL_COMFORT_WIDTH = 285; +const SOURCE_CONTROL_MAIN_MIN_SIZE = "72px"; +const SOURCE_CONTROL_WIDTH_STORAGE_KEY = "terax.sourceControl.width"; + +function clampSourceControlWidth(width: number): number { + return Math.min( + SOURCE_CONTROL_MAX_WIDTH, + Math.max(SOURCE_CONTROL_MIN_WIDTH, Math.round(width)), + ); +} + +function readSourceControlWidth(): number { + try { + const stored = window.localStorage.getItem(SOURCE_CONTROL_WIDTH_STORAGE_KEY); + const parsed = stored ? Number.parseInt(stored, 10) : NaN; + return Number.isFinite(parsed) + ? clampSourceControlWidth(parsed) + : SOURCE_CONTROL_DEFAULT_WIDTH; + } catch { + return SOURCE_CONTROL_DEFAULT_WIDTH; + } +} export default function App() { const { @@ -88,6 +130,7 @@ export default function App() { newPreviewTab, openAiDiffTab, closeAiDiffTab, + openGitDiffTab, closeTab, updateTab, selectByIndex, @@ -125,6 +168,7 @@ export default function App() { const explorerReturnFocusRef = useRef(null); const sidebarRef = useRef(null); + const sourceControlRef = useRef(null); const toggleSidebar = useCallback(() => { const p = sidebarRef.current; if (!p) return; @@ -157,6 +201,7 @@ export default function App() { const [pendingCloseTab, setPendingCloseTab] = useState(null); const workspaceEnv = useWorkspaceEnvStore((s) => s.env); const setWorkspaceEnv = useWorkspaceEnvStore((s) => s.setEnv); + const [launchCwd, setLaunchCwd] = useState(null); const [pendingDeleteTabs, setPendingDeleteTabs] = useState( null, ); @@ -207,9 +252,18 @@ export default function App() { }, [workspaceEnv, setWorkspaceEnv, resetWorkspace], ); + useEffect(() => { + native.appCurrentDir().then(setLaunchCwd).catch(() => setLaunchCwd(null)); + }, []); const [shortcutsOpen, setShortcutsOpen] = useState(false); const [newEditorOpen, setNewEditorOpen] = useState(false); + const [sourceControlOpen, setSourceControlOpen] = useState(false); + const [sourceControlPinnedContextPath, setSourceControlPinnedContextPath] = + useState(null); + const [sourceControlWidth, setSourceControlWidth] = useState( + readSourceControlWidth, + ); const miniOpen = useChatStore((s) => s.mini.open); const openMini = useChatStore((s) => s.openMini); const focusInput = useChatStore((s) => s.focusInput); @@ -277,6 +331,7 @@ export default function App() { const isEditorTab = activeTab?.kind === "editor"; const isPreviewTab = activeTab?.kind === "preview"; const isAiDiffTab = activeTab?.kind === "ai-diff"; + const isGitDiffTab = activeTab?.kind === "git-diff"; // When an AI diff is approved (write_file applied to disk), reload any // open editor tabs for that path so the user sees the new content. We @@ -300,7 +355,7 @@ export default function App() { const { explorerRoot, inheritedCwdForNewTab } = useWorkspaceCwd( activeTab, tabs, - home, + launchCwd ?? home, ); useEffect(() => { @@ -592,7 +647,92 @@ export default function App() { [tabs, disposeTab], ); - const activeFilePath = activeTab?.kind === "editor" ? activeTab.path : null; + const activeTerminalLeafCwd = + activeTab?.kind === "terminal" + ? (findLeafCwd(activeTab.paneTree, activeTab.activeLeafId) ?? + activeTab.cwd ?? + null) + : null; + + const activeFilePath = + activeTab?.kind === "editor" || activeTab?.kind === "git-diff" + ? activeTab.path + : null; + const sourceControlContextPath = + activeTab?.kind === "terminal" + ? (activeTerminalLeafCwd ?? explorerRoot ?? launchCwd ?? home ?? null) + : activeTab?.kind === "editor" + ? dirname(activeTab.path) + : activeTab?.kind === "git-diff" + ? activeTab.repoRoot + : explorerRoot ?? launchCwd ?? home ?? null; + const sourceControlTrackedContextPath = + sourceControlPinnedContextPath ?? sourceControlContextPath; + const sourceControl = useSourceControl(sourceControlTrackedContextPath); + + const rememberSourceControlWidth = useCallback((width: number) => { + if (!Number.isFinite(width) || width <= 0) return; + const next = clampSourceControlWidth(width); + setSourceControlWidth(next); + try { + window.localStorage.setItem(SOURCE_CONTROL_WIDTH_STORAGE_KEY, String(next)); + } catch { + // Ignore storage failures; the panel still works for the current session. + } + }, []); + + const openSourceControl = useCallback(() => { + setSourceControlPinnedContextPath(sourceControlContextPath); + setSourceControlOpen(true); + }, [sourceControlContextPath]); + + const closeSourceControl = useCallback(() => { + const panel = sourceControlRef.current; + if (panel && !panel.isCollapsed()) { + rememberSourceControlWidth(panel.getSize().inPixels); + panel.collapse(); + } + setSourceControlOpen(false); + setSourceControlPinnedContextPath(null); + }, [rememberSourceControlWidth]); + + const toggleSourceControl = useCallback(() => { + if (sourceControlOpen) { + closeSourceControl(); + return; + } + openSourceControl(); + }, [closeSourceControl, openSourceControl, sourceControlOpen]); + + const runSourceControlRemoteAction = useCallback(async () => { + const result = await sourceControl.runRemoteAction(); + if (!result.ok && result.error) { + setSourceControlPinnedContextPath( + sourceControlTrackedContextPath ?? sourceControlContextPath, + ); + setSourceControlOpen(true); + } + }, [ + sourceControl, + sourceControlContextPath, + sourceControlTrackedContextPath, + ]); + + useEffect(() => { + if (!sourceControlOpen) return; + const frame = window.requestAnimationFrame(() => { + const panel = sourceControlRef.current; + if (!panel) return; + if (panel.isCollapsed()) panel.expand(); + const targetSize = `${sourceControlWidth}px`; + if (Math.abs(panel.getSize().inPixels - sourceControlWidth) > 2) { + panel.resize(targetSize); + } else if (panel.getSize().inPixels < SOURCE_CONTROL_COMFORT_WIDTH) { + panel.resize(targetSize); + } + }); + return () => window.cancelAnimationFrame(frame); + }, [sourceControlOpen, sourceControlWidth]); const openPreviewTab = useCallback( (url: string) => { @@ -638,6 +778,7 @@ export default function App() { "pane.splitDown": () => splitActivePaneInActiveTab("col"), "pane.focusNext": () => focusNextPaneInTab(activeId, 1), "pane.focusPrev": () => focusNextPaneInTab(activeId, -1), + "pane.source": toggleSourceControl, "search.focus": () => searchInlineRef.current?.focus(), "ai.toggle": togglePanelAndFocus, "ai.askSelection": askFromSelection, @@ -659,6 +800,7 @@ export default function App() { selectByIndex, splitActivePaneInActiveTab, focusNextPaneInTab, + toggleSourceControl, togglePanelAndFocus, askFromSelection, toggleSidebar, @@ -751,18 +893,21 @@ export default function App() { return null; }, [isTerminalTab, isEditorTab, activeId, activeSearchAddon, activeEditorHandle]); - const activeCwd = - activeTab?.kind === "terminal" ? (activeTab.cwd ?? null) : null; + const activeCwd = activeTerminalLeafCwd; useEffect(() => { const findCwd = () => { const active = tabs.find((x) => x.id === activeId); - if (active?.kind === "terminal" && active.cwd) return active.cwd; + if (active?.kind === "terminal") { + return findLeafCwd(active.paneTree, active.activeLeafId) ?? active.cwd ?? null; + } for (let i = tabs.length - 1; i >= 0; i--) { const t = tabs[i]; - if (t.kind === "terminal" && t.cwd) return t.cwd; + if (t.kind !== "terminal") continue; + const cwd = findLeafCwd(t.paneTree, t.activeLeafId) ?? t.cwd; + if (cwd) return cwd; } - return explorerRoot ?? home ?? null; + return explorerRoot ?? launchCwd ?? home ?? null; }; setLive({ @@ -787,7 +932,7 @@ export default function App() { term.focus(); return true; }, - getWorkspaceRoot: () => explorerRoot ?? home ?? null, + getWorkspaceRoot: () => explorerRoot ?? launchCwd ?? home ?? null, getActiveFile: () => { const t = tabs.find((x) => x.id === activeId); return t?.kind === "editor" ? t.path : null; @@ -797,7 +942,81 @@ export default function App() { return true; }, }); - }, [setLive, activeId, tabs, explorerRoot, home, openPreviewTab]); + }, [setLive, activeId, tabs, explorerRoot, launchCwd, home, openPreviewTab]); + + const workspaceSurface = ( +
+
+ +
+
+ +
+
+ +
+
+ respondToApproval(id, true)} + onReject={(id) => respondToApproval(id, false)} + /> +
+
+ +
+
+ ); const shell = ( @@ -820,7 +1039,11 @@ export default function App() { leafIds(activeTerminalTab.paneTree).length < MAX_PANES_PER_TAB } onOpenShortcuts={() => setShortcutsOpen(true)} - onOpenSettings={() => void openSettingsWindow()} + onOpenSettings={() => void openSettingsWindow()} + sourceControlOpen={sourceControlOpen} + sourceControl={sourceControl} + onToggleSourceControl={toggleSourceControl} + onRunSourceControlRemoteAction={runSourceControlRemoteAction} searchTarget={searchTarget} searchRef={searchInlineRef} /> @@ -854,67 +1077,39 @@ export default function App() {
-
-
- -
-
- -
-
- -
-
- respondToApproval(id, true)} - onReject={(id) => respondToApproval(id, false)} - /> -
+
+ + + {workspaceSurface} + + {sourceControlOpen ? : null} + { + const width = sourceControlRef.current?.getSize().inPixels; + if (sourceControlOpen && width) { + rememberSourceControlWidth(width); + } + }} + > + + +
{keysLoaded ? ( diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx index e4e739e3..aba5c54d 100644 --- a/src/components/ui/tooltip.tsx +++ b/src/components/ui/tooltip.tsx @@ -48,7 +48,7 @@ function TooltipContent({ {...props} > {children} - + ) diff --git a/src/modules/ai/lib/agent.ts b/src/modules/ai/lib/agent.ts index 52ae2435..a8d16b40 100644 --- a/src/modules/ai/lib/agent.ts +++ b/src/modules/ai/lib/agent.ts @@ -174,7 +174,7 @@ export async function buildLanguageModel( return built; } -function buildModel( +export function buildConfiguredLanguageModel( modelId: ModelId, keys: ProviderKeys, lmstudioBaseURL?: string, @@ -289,7 +289,7 @@ export type RunAgentOptions = { export async function runAgentStream(opts: RunAgentOptions) { const modelId = opts.modelId ?? DEFAULT_MODEL_ID; - const model = await buildModel( + const model = await buildConfiguredLanguageModel( modelId, opts.keys, opts.lmstudioBaseURL, diff --git a/src/modules/ai/lib/native.ts b/src/modules/ai/lib/native.ts index a18e409b..16e602d1 100644 --- a/src/modules/ai/lib/native.ts +++ b/src/modules/ai/lib/native.ts @@ -37,7 +37,58 @@ export type GrepResponse = { export type GlobHit = { path: string; rel: string }; export type GlobResponse = { hits: GlobHit[]; truncated: boolean }; +export type GitRepoInfo = { + repoRoot: string; + branch: string; + upstream: string | null; + isDetached: boolean; +}; + +export type GitChangedFile = { + path: string; + originalPath: string | null; + indexStatus: string; + worktreeStatus: string; + staged: boolean; + unstaged: boolean; + untracked: boolean; + statusLabel: string; +}; + +export type GitStatusSnapshot = { + repoRoot: string; + branch: string; + upstream: string | null; + ahead: number; + behind: number; + isDetached: boolean; + changedFiles: GitChangedFile[]; +}; + +export type GitDiffResult = { + diffText: string; +}; + +export type GitDiffContentResult = { + originalContent: string; + modifiedContent: string; + isBinary: boolean; + fallbackPatch: string; +}; + +export type GitCommitResult = { + commitSha: string; + summary: string; +}; + +export type GitPushResult = { + remote: string | null; + branch: string | null; + pushed: boolean; +}; + export const native = { + appCurrentDir: () => invoke("app_current_dir"), readFile: (path: string) => invoke("fs_read_file", { path, @@ -153,4 +204,25 @@ export const native = { exit_code: number | null; }[] >("shell_bg_list"), + gitResolveRepo: (cwd: string) => + invoke("git_resolve_repo", { cwd }), + gitStatus: (repoRoot: string) => + invoke("git_status", { repoRoot }), + gitDiff: (repoRoot: string, path: string | null, staged: boolean) => + invoke("git_diff", { repoRoot, path, staged }), + gitDiffContent: (repoRoot: string, path: string, staged: boolean) => + invoke("git_diff_content", { repoRoot, path, staged }), + gitStage: (repoRoot: string, paths: string[]) => + invoke("git_stage", { repoRoot, paths }), + gitUnstage: (repoRoot: string, paths: string[]) => + invoke("git_unstage", { repoRoot, paths }), + gitDiscard: (repoRoot: string, paths: string[]) => + invoke("git_discard", { repoRoot, paths }), + gitCommit: (repoRoot: string, message: string) => + invoke("git_commit", { repoRoot, message }), + gitFetch: (repoRoot: string) => invoke("git_fetch", { repoRoot }), + gitPullFfOnly: (repoRoot: string) => + invoke("git_pull_ff_only", { repoRoot }), + gitPush: (repoRoot: string) => + invoke("git_push", { repoRoot }), }; diff --git a/src/modules/editor/GitDiffPane.tsx b/src/modules/editor/GitDiffPane.tsx new file mode 100644 index 00000000..315e0453 --- /dev/null +++ b/src/modules/editor/GitDiffPane.tsx @@ -0,0 +1,169 @@ +import { Badge } from "@/components/ui/badge"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { usePreferencesStore } from "@/modules/settings/preferences"; +import { presentableDiff, unifiedMergeView } from "@codemirror/merge"; +import { EditorState } from "@codemirror/state"; +import { EditorView } from "@codemirror/view"; +import CodeMirror, { type ReactCodeMirrorRef } from "@uiw/react-codemirror"; +import { useEffect, useMemo, useRef } from "react"; +import { buildSharedExtensions, languageCompartment } from "./lib/extensions"; +import { resolveLanguage } from "./lib/languageResolver"; +import { EDITOR_THEME_EXT } from "./lib/themes"; + +type Props = { + path: string; + repoRoot: string; + mode: "-" | "+"; + originalContent: string; + modifiedContent: string; + isBinary: boolean; + fallbackPatch: string; +}; + +const DIFF_THEME = EditorView.theme({ + ".cm-changedText": { + background: "#88ff881a !important", + }, +}); + +export function GitDiffPane({ + path, + repoRoot, + mode, + originalContent, + modifiedContent, + isBinary, + fallbackPatch, +}: Props) { + const cmRef = useRef(null); + const editorThemeId = usePreferencesStore((s) => s.editorTheme); + const themeExt = EDITOR_THEME_EXT[editorThemeId] ?? EDITOR_THEME_EXT.atomone; + + const extensions = useMemo( + () => [ + ...buildSharedExtensions(), + languageCompartment.of([]), + EditorState.readOnly.of(true), + EditorView.editable.of(false), + unifiedMergeView({ + original: originalContent, + mergeControls: false, + highlightChanges: true, + gutter: true, + syntaxHighlightDeletions: true, + collapseUnchanged: { margin: 3, minSize: 6 }, + }), + DIFF_THEME, + ], + [originalContent], + ); + + useEffect(() => { + if (isBinary) return; + let cancelled = false; + resolveLanguage(path).then((ext) => { + if (cancelled) return; + const view = cmRef.current?.view; + if (!view) return; + view.dispatch({ + effects: languageCompartment.reconfigure(ext ?? []), + }); + }); + return () => { + cancelled = true; + }; + }, [isBinary, path]); + + const stats = useMemo( + () => computeLineStats(originalContent, modifiedContent), + [originalContent, modifiedContent], + ); + + return ( +
+
+
+ + {mode} + + {isBinary ? ( + + Binary / patch fallback + + ) : null} + + {path} + +
+
+ {repoRoot} + + +{stats.added} + + + −{stats.removed} + +
+
+ +
+ {isBinary ? ( + +
+              {fallbackPatch ||
+                "Binary diff preview is not available for this file."}
+            
+
+ ) : ( + + )} +
+
+ ); +} + +function computeLineStats( + original: string, + proposed: string, +): { added: number; removed: number } { + const changes = presentableDiff(original, proposed); + let added = 0; + let removed = 0; + for (const c of changes) { + removed += countLines(original, c.fromA, c.toA); + added += countLines(proposed, c.fromB, c.toB); + } + return { added, removed }; +} + +function countLines(doc: string, from: number, to: number): number { + if (from === to) return 0; + const slice = doc.slice(from, to); + let n = 1; + for (let i = 0; i < slice.length; i++) { + if (slice.charCodeAt(i) === 10) n++; + } + if (slice.endsWith("\n")) n--; + return Math.max(n, 1); +} diff --git a/src/modules/editor/GitDiffStack.tsx b/src/modules/editor/GitDiffStack.tsx new file mode 100644 index 00000000..4ef54e23 --- /dev/null +++ b/src/modules/editor/GitDiffStack.tsx @@ -0,0 +1,40 @@ +import { cn } from "@/lib/utils"; +import type { GitDiffTab, Tab } from "@/modules/tabs"; +import { GitDiffPane } from "./GitDiffPane"; + +type Props = { + tabs: Tab[]; + activeId: number; +}; + +export function GitDiffStack({ tabs, activeId }: Props) { + const diffs = tabs.filter((t): t is GitDiffTab => t.kind === "git-diff"); + if (diffs.length === 0) return null; + return ( +
+ {diffs.map((t) => { + const visible = t.id === activeId; + return ( +
+ +
+ ); + })} +
+ ); +} diff --git a/src/modules/editor/index.ts b/src/modules/editor/index.ts index 0a079f4a..8a19c0ff 100644 --- a/src/modules/editor/index.ts +++ b/src/modules/editor/index.ts @@ -1,4 +1,6 @@ export type { EditorPaneHandle } from "./EditorPane"; export { EditorStack } from "./EditorStackLazy"; export { AiDiffStack } from "./AiDiffStackLazy"; +export { GitDiffPane } from "./GitDiffPane"; +export { GitDiffStack } from "./GitDiffStack"; export { NewEditorDialog } from "./NewEditorDialog"; diff --git a/src/modules/header/Header.tsx b/src/modules/header/Header.tsx index 628edd43..415823fd 100644 --- a/src/modules/header/Header.tsx +++ b/src/modules/header/Header.tsx @@ -1,4 +1,10 @@ import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { cn } from "@/lib/utils"; +import { + getSourceControlRemoteIndicator, + type SourceControlSummary, +} from "@/modules/source-control"; import { DropdownMenu, DropdownMenuContent, @@ -20,6 +26,8 @@ import { KeyboardIcon, LayoutTwoColumnIcon, LayoutTwoRowIcon, + Refresh01Icon, + SourceCodeCircleIcon, Settings01Icon, SidebarLeftIcon, } from "@hugeicons/core-free-icons"; @@ -48,6 +56,10 @@ type Props = { canSplit: boolean; onOpenShortcuts: () => void; onOpenSettings: () => void; + sourceControlOpen: boolean; + sourceControl: SourceControlSummary; + onToggleSourceControl: () => void; + onRunSourceControlRemoteAction: () => void; searchTarget: SearchTarget; searchRef: RefObject; }; @@ -69,6 +81,10 @@ export function Header({ canSplit, onOpenShortcuts, onOpenSettings, + sourceControlOpen, + sourceControl, + onToggleSourceControl, + onRunSourceControlRemoteAction, searchTarget, searchRef, }: Props) { @@ -92,6 +108,7 @@ export function Header({ const splitRightTokens = tokensFor("pane.splitRight"); const splitDownTokens = tokensFor("pane.splitDown"); + const remoteIndicator = getSourceControlRemoteIndicator(sourceControl); useEffect(() => { const el = rootRef.current; @@ -128,6 +145,72 @@ export function Header({ ); + const sourceControlButton = ( +
+ + {remoteIndicator.visible ? ( + + ) : null} +
+ ); + return (
+ {sourceControlButton} + {IS_MAC && ( <> {shortcutsButton} diff --git a/src/modules/shortcuts/shortcuts.ts b/src/modules/shortcuts/shortcuts.ts index 075fc721..242cd6c5 100644 --- a/src/modules/shortcuts/shortcuts.ts +++ b/src/modules/shortcuts/shortcuts.ts @@ -17,6 +17,7 @@ export type ShortcutId = | "pane.splitDown" | "pane.focusNext" | "pane.focusPrev" + | "pane.source" | "search.focus" | "explorer.search" | "explorer.focus" @@ -119,6 +120,12 @@ export const SHORTCUTS: Shortcut[] = [ label: "Focus previous pane", group: "Panes", defaultBindings: [{ [MOD_PROP]: true, key: "[" }], + }, + { + id: "pane.source", + label: "Toggle source panel", + group: "Panes", + defaultBindings: [{ [MOD_PROP]: true, key: "g" }], }, { id: "tab.next", diff --git a/src/modules/source-control/SourceControlPanel.tsx b/src/modules/source-control/SourceControlPanel.tsx new file mode 100644 index 00000000..683fa46d --- /dev/null +++ b/src/modules/source-control/SourceControlPanel.tsx @@ -0,0 +1,837 @@ +import { Button } from "@/components/ui/button"; +import { + Collapsible, + CollapsibleContent, + CollapsibleTrigger, +} from "@/components/ui/collapsible"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Separator } from "@/components/ui/separator"; +import { Spinner } from "@/components/ui/spinner"; +import { Textarea } from "@/components/ui/textarea"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { IS_MAC } from "@/lib/platform"; +import { fileIconUrl } from "@/modules/explorer/lib/iconResolver"; +import { + ArrowRight01Icon, + Cancel01Icon, + Delete01Icon, + GitBranchIcon, + MinusSignIcon, + PlusSignIcon, + Refresh01Icon, + SparklesIcon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + memo, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, + type ReactNode, +} from "react"; +import type { SourceControlSummary } from "./useSourceControl"; +import { + useSourceControlPanel, + type SourceControlEntry, +} from "./useSourceControlPanel"; + +type Props = { + open: boolean; + sourceControl: SourceControlSummary; + onClose: () => void; + onOpenDiff: (input: { + path: string; + repoRoot: string; + mode: "+" | "-"; + originalContent: string; + modifiedContent: string; + isBinary: boolean; + fallbackPatch: string; + }) => void; +}; + +const SOURCE_CONTROL_TOOLTIP_CLASS = + "border border-border/70 bg-zinc-950 text-zinc-100 shadow-lg shadow-black/30 dark:border-border/60 dark:bg-zinc-950 dark:text-zinc-100"; + +function basename(path: string): string { + const parts = path.split(/[\\/]/).filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : path; +} + +function dirname(path: string): string { + const normalized = path.replace(/\\/g, "/"); + const index = normalized.lastIndexOf("/"); + if (index <= 0) return ""; + return normalized.slice(0, index); +} + +function entryPathLabel(entry: SourceControlEntry): string { + if (entry.originalPath) { + return `${entry.originalPath} → ${entry.path}`; + } + return dirname(entry.path); +} + +function upstreamBadgeLabel(upstream: string | null | undefined): string { + if (!upstream) return "No upstream"; + const [remote] = upstream.split("/"); + return remote || upstream; +} + +function statusTone(statusCode: string): string { + switch (statusCode) { + case "A": + case "?": + return "text-emerald-700 dark:text-emerald-400"; + case "M": + return "text-amber-700 dark:text-amber-300"; + case "D": + return "text-rose-700 dark:text-rose-400"; + case "R": + return "text-sky-700 dark:text-sky-300"; + default: + return "text-muted-foreground"; + } +} + +export const SourceControlPanel = memo(function SourceControlPanel({ + open, + sourceControl, + onClose, + onOpenDiff, +}: Props) { + const rootRef = useRef(null); + const refreshAnimationRef = useRef(null); + const [panelWidth, setPanelWidth] = useState(0); + const [refreshAnimating, setRefreshAnimating] = useState(false); + const scm = useSourceControlPanel(open, sourceControl, onOpenDiff, panelWidth); + + useEffect(() => { + const node = rootRef.current; + if (!node) return; + const observer = new ResizeObserver((entries) => { + setPanelWidth(entries[0]?.contentRect.width ?? 0); + }); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + return () => { + if (refreshAnimationRef.current) { + window.clearTimeout(refreshAnimationRef.current); + } + }; + }, []); + + const isRefreshing = scm.panelState === "loading" || scm.diffLoading; + const repoLabel = useMemo(() => { + if (!scm.status) return "Source Control"; + return scm.status.isDetached ? "detached" : scm.status.branch; + }, [scm.status]); + + const headerMeta = useMemo(() => { + if (!scm.status) return null; + const parts: string[] = []; + if (scm.status.ahead > 0 || scm.status.behind > 0) { + parts.push(`↑${scm.status.ahead} ↓${scm.status.behind}`); + } + if (scm.status.isDetached) { + parts.push("Detached HEAD"); + } + return parts.join(" · "); + }, [scm.status]); + + const commitShortcut = IS_MAC ? "⌘+Enter" : "Ctrl+Enter"; + const canCommit = + scm.stagedEntries.length > 0 && + scm.commitMessage.trim().length > 0 && + !scm.actionBusy; + const commitDisabledReason = scm.actionBusy + ? "Wait for the current Git action to finish." + : scm.stagedEntries.length === 0 + ? "Stage changes to enable commit." + : scm.commitMessage.trim().length === 0 + ? "Enter a commit message to enable commit." + : null; + const commitHint = canCommit + ? `Commit with ${commitShortcut}.` + : (commitDisabledReason ?? `Commit with ${commitShortcut}.`); + const pushHint = scm.pushHint ?? "Push is unavailable right now."; + const pushDisabledReason = scm.actionBusy + ? "Wait for the current Git action to finish." + : pushHint; + const stagedCount = scm.stagedEntries.length; + const stagedCountLabel = `${stagedCount} staged`; + const commitStatusLabel = scm.actionBusy + ? "Git action in progress" + : stagedCount === 0 + ? "Stage files first" + : scm.commitMessage.trim().length === 0 + ? "Message required" + : `Ready: ${stagedCount} ${stagedCount === 1 ? "file" : "files"}`; + const pushStatusLabel = upstreamBadgeLabel(scm.status?.upstream); + const footerFeedback = useMemo(() => { + if (scm.actionError) { + return { tone: "error", message: scm.actionError } as const; + } + if (scm.remoteError) { + return { tone: "error", message: scm.remoteError } as const; + } + if (scm.actionMessage) { + return { tone: "success", message: scm.actionMessage } as const; + } + return null; + }, [scm.actionError, scm.actionMessage, scm.remoteError]); + + const handleCommitShortcut = (event: KeyboardEvent) => { + if (event.key !== "Enter" || (!event.metaKey && !event.ctrlKey)) return; + if (!canCommit) return; + event.preventDefault(); + void scm.commit(); + }; + + const handleRefresh = () => { + setRefreshAnimating(true); + if (refreshAnimationRef.current) { + window.clearTimeout(refreshAnimationRef.current); + } + void scm.refresh().finally(() => { + refreshAnimationRef.current = window.setTimeout(() => { + setRefreshAnimating(false); + refreshAnimationRef.current = null; + }, 450); + }); + }; + + if (!open) return null; + + return ( + +