diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 074ff14..2aae101 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -12,6 +12,7 @@ "core:window:allow-show", "core:window:allow-close", "dialog:allow-open", + "dialog:allow-save", "dialog:allow-ask", "os:default", "core:menu:allow-popup", diff --git a/src-tauri/gen/schemas/capabilities.json b/src-tauri/gen/schemas/capabilities.json index 5de1780..3990ec6 100644 --- a/src-tauri/gen/schemas/capabilities.json +++ b/src-tauri/gen/schemas/capabilities.json @@ -1 +1 @@ -{"default":{"identifier":"default","description":"Default capability for invoking app commands","local":true,"windows":["main","settings","clone-repository","result-log","about","attributions"],"permissions":["core:default","core:webview:allow-create-webview-window","core:window:allow-create","core:window:allow-set-focus","core:window:allow-set-title","core:window:allow-show","core:window:allow-close","dialog:allow-open","dialog:allow-ask","os:default","core:menu:allow-popup","core:menu:allow-new","core:menu:allow-append","core:menu:allow-remove","core:window:allow-cursor-position","core:window:allow-outer-position","updater:default","shell:allow-open",{"identifier":"opener:allow-open-path","allow":[{"path":"$APPCONFIG"},{"path":"$HOME/**"},{"path":"$DESKTOP/**"},{"path":"$DOCUMENT/**"},{"path":"$DOWNLOAD/**"}]}]}} \ No newline at end of file +{"default":{"identifier":"default","description":"Default capability for invoking app commands","local":true,"windows":["main","settings","clone-repository","result-log","about","attributions"],"permissions":["core:default","core:webview:allow-create-webview-window","core:window:allow-create","core:window:allow-set-focus","core:window:allow-set-title","core:window:allow-show","core:window:allow-close","dialog:allow-open","dialog:allow-save","dialog:allow-ask","os:default","core:menu:allow-popup","core:menu:allow-new","core:menu:allow-append","core:menu:allow-remove","core:window:allow-cursor-position","core:window:allow-outer-position","updater:default","shell:allow-open",{"identifier":"opener:allow-open-path","allow":[{"path":"$APPCONFIG"},{"path":"$HOME/**"},{"path":"$DESKTOP/**"},{"path":"$DOCUMENT/**"},{"path":"$DOWNLOAD/**"}]}]}} \ No newline at end of file diff --git a/src-tauri/src/commands/repo.rs b/src-tauri/src/commands/repo.rs index 53caae1..29753d5 100644 --- a/src-tauri/src/commands/repo.rs +++ b/src-tauri/src/commands/repo.rs @@ -1,10 +1,10 @@ use crate::git::types::{ CloneRequest, CommitDetails, CommitDetailsRequest, CommitFileItem, CommitFilesRequest, - CommitMarkers, CommitRequest, DiffRequest, ExternalDiffRequest, FetchRequest, FileDiff, - FileRequest, GitIdentity, HunkStageRequest, IdentityRequest, NumstatRequest, NumstatResult, - OperationResult, PullAnalysis, PullStrategyRequest, PushRequest, PushResult, RepoRequest, - RepoStatus, SetIdentityRequest, StageFilesRequest, StashEntry, StashPushRequest, StashRequest, - SubmoduleActionRequest, + CommitMarkers, CommitRequest, DiffRequest, ExportPatchRequest, ExternalDiffRequest, + FetchRequest, FileDiff, FileRequest, GitIdentity, HunkStageRequest, IdentityRequest, + ImportPatchRequest, NumstatRequest, NumstatResult, OperationResult, PullAnalysis, + PullStrategyRequest, PushRequest, PushResult, RepoRequest, RepoStatus, SetIdentityRequest, + StageFilesRequest, StashEntry, StashPushRequest, StashRequest, SubmoduleActionRequest, }; use crate::{AppState, CloneCancelFlag, configure_command}; use serde::{Deserialize, Serialize}; @@ -290,7 +290,9 @@ pub async fn get_commit_markers( app: tauri::AppHandle, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - app.state::().git_service.get_commit_markers(request) + app.state::() + .git_service + .get_commit_markers(request) }) .await .map_err(|e| e.to_string())? @@ -303,7 +305,9 @@ pub async fn get_commit_files( app: tauri::AppHandle, ) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { - app.state::().git_service.get_commit_files(request) + app.state::() + .git_service + .get_commit_files(request) }) .await .map_err(|e| e.to_string())? @@ -316,7 +320,9 @@ pub async fn get_commit_details( app: tauri::AppHandle, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - app.state::().git_service.get_commit_details(request) + app.state::() + .git_service + .get_commit_details(request) }) .await .map_err(|e| e.to_string())? @@ -522,8 +528,7 @@ pub fn get_default_clone_dir() -> String { .or_else(|_| std::env::var("HOME")) .unwrap_or_else(|_| ".".to_string()); #[cfg(not(windows))] - let home = std::env::var("HOME") - .unwrap_or_else(|_| ".".to_string()); + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); std::path::PathBuf::from(home) .join("GitmunProjects") .to_string_lossy() @@ -552,6 +557,39 @@ pub fn open_working_tree_diff( .map_err(|error| error.to_string()) } +#[tauri::command] +pub fn check_patch_file( + request: ImportPatchRequest, + state: tauri::State<'_, AppState>, +) -> Result { + state + .git_service + .check_patch_file(request) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn import_patch_file( + request: ImportPatchRequest, + state: tauri::State<'_, AppState>, +) -> Result { + state + .git_service + .import_patch_file(request) + .map_err(|error| error.to_string()) +} + +#[tauri::command] +pub fn export_patch_file( + request: ExportPatchRequest, + state: tauri::State<'_, AppState>, +) -> Result { + state + .git_service + .export_patch_file(request) + .map_err(|error| error.to_string()) +} + #[tauri::command] pub fn get_repo_diff_tool( request: RepoRequest, @@ -651,10 +689,7 @@ pub fn commit_changes( } #[tauri::command] -pub async fn get_diff( - request: DiffRequest, - app: tauri::AppHandle, -) -> Result { +pub async fn get_diff(request: DiffRequest, app: tauri::AppHandle) -> Result { tauri::async_runtime::spawn_blocking(move || { app.state::().git_service.get_diff(request) }) diff --git a/src-tauri/src/git/cli.rs b/src-tauri/src/git/cli.rs index 01e4457..727f13b 100644 --- a/src-tauri/src/git/cli.rs +++ b/src-tauri/src/git/cli.rs @@ -16,8 +16,9 @@ use super::types::{ CommitHistoryItem, CommitHistoryRequest, CommitLogScope, CommitMarkers, CommitRequest, CommitTrailer, ConflictFileItem, CreateBranchRequest, CreateTagRequest, DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, DeleteTagRequest, DiffHunk, DiffLine, - DiffLineKind, DiffRequest, ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, - FileStatusItem, GitIdentity, HunkStageRequest, IdentityRequest, IdentityScope, LineEndingStyle, + DiffLineKind, DiffRequest, ExportPatchFileSelection, ExportPatchRequest, ExportPatchScope, + ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, FileStatusItem, GitIdentity, + HunkStageRequest, IdentityRequest, IdentityScope, ImportPatchRequest, LineEndingStyle, MergeRequest, MergeResult, NumstatRequest, NumstatResult, OperationResult, PruneRemoteRequest, PullAnalysis, PullRecommendedAction, PullState, PullStrategy, PullStrategyRequest, PushFailureKind, PushRejectionAnalysis, PushRequest, PushResult, PushTagRequest, RebaseRequest, @@ -461,6 +462,208 @@ impl CliGitHandler { } } + fn validate_repo_relative_path(path: &str) -> GitResult { + let trimmed = path.trim(); + if trimmed.is_empty() { + return Err(GitError::InvalidInput( + "File path cannot be empty".to_string(), + )); + } + if trimmed.contains('\0') { + return Err(GitError::InvalidInput( + "File path contains an invalid character".to_string(), + )); + } + + let candidate = Path::new(trimmed); + if candidate.is_absolute() + || candidate.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) + ) + }) + { + return Err(GitError::InvalidInput(format!( + "Invalid file path: {trimmed}" + ))); + } + + Ok(trimmed.replace('\\', "/")) + } + + fn validate_patch_path(patch_path: &str) -> GitResult { + let trimmed = patch_path.trim(); + if trimmed.is_empty() { + return Err(GitError::InvalidInput( + "Patch path cannot be empty".to_string(), + )); + } + if trimmed.contains('\0') { + return Err(GitError::InvalidInput( + "Patch path contains an invalid character".to_string(), + )); + } + + Ok(PathBuf::from(trimmed)) + } + + fn git_diff_for_paths(repo_path: &Path, staged: bool, paths: &[String]) -> GitResult { + if paths.is_empty() { + return Ok(String::new()); + } + + let mut args: Vec<&str> = vec!["diff"]; + if staged { + args.push("--cached"); + } + args.push("--binary"); + args.push("--"); + args.extend(paths.iter().map(String::as_str)); + Self::run_git_allow_exit_codes(&args, Some(repo_path), &[1]) + } + + fn git_diff_for_all(repo_path: &Path, staged: bool) -> GitResult { + let mut args = vec!["diff"]; + if staged { + args.push("--cached"); + } + args.push("--binary"); + args.push("--"); + Self::run_git_allow_exit_codes(&args, Some(repo_path), &[1]) + } + + fn git_untracked_patch(repo_path: &Path, path: &str) -> GitResult { + let null_device = if cfg!(windows) { "NUL" } else { "/dev/null" }; + let output = Self::run_git_allow_exit_codes( + &["diff", "--no-index", "--binary", "--", null_device, path], + Some(repo_path), + &[1], + )?; + + Ok(output + .replace("a/dev/null", "/dev/null") + .replace("a/NUL", "/dev/null")) + } + + fn tracked_paths(repo_path: &Path, paths: &[String]) -> GitResult> { + if paths.is_empty() { + return Ok(HashSet::new()); + } + + let mut args: Vec<&str> = vec!["ls-files", "--"]; + args.extend(paths.iter().map(String::as_str)); + let output = Self::run_git_allow_exit_codes(&args, Some(repo_path), &[1])?; + Ok(output.lines().map(|line| line.to_string()).collect()) + } + + fn untracked_paths(repo_path: &Path) -> GitResult> { + let output = Self::run_git_allow_exit_codes( + &["ls-files", "--others", "--exclude-standard"], + Some(repo_path), + &[1], + )?; + Ok(output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToString::to_string) + .collect()) + } + + fn changed_unstaged_paths(repo_path: &Path) -> GitResult> { + let output = + Self::run_git_allow_exit_codes(&["diff", "--name-only", "--"], Some(repo_path), &[1])?; + Ok(output + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(ToString::to_string) + .collect()) + } + + fn append_patch(target: &mut String, patch: &str) { + let patch = patch.trim_end(); + if patch.is_empty() { + return; + } + if !target.is_empty() { + target.push('\n'); + } + target.push_str(patch); + target.push('\n'); + } + + fn selected_paths(files: &[ExportPatchFileSelection], staged: bool) -> GitResult> { + let mut paths = Vec::new(); + let mut seen = HashSet::new(); + for file in files.iter().filter(|file| file.staged == staged) { + let path = Self::validate_repo_relative_path(&file.path)?; + if seen.insert(path.clone()) { + paths.push(path); + } + } + Ok(paths) + } + + fn build_unstaged_patch(repo_path: &Path, paths: &[String]) -> GitResult { + let mut output = String::new(); + let tracked = Self::tracked_paths(repo_path, paths)?; + let tracked_paths: Vec = paths + .iter() + .filter(|path| tracked.contains(*path)) + .cloned() + .collect(); + + Self::append_patch( + &mut output, + &Self::git_diff_for_paths(repo_path, false, &tracked_paths)?, + ); + + for path in paths.iter().filter(|path| !tracked.contains(*path)) { + Self::append_patch(&mut output, &Self::git_untracked_patch(repo_path, path)?); + } + + Ok(output) + } + + fn build_patch(request: &ExportPatchRequest, repo_path: &Path) -> GitResult { + let mut output = String::new(); + + match request.scope { + ExportPatchScope::Staged => { + Self::append_patch(&mut output, &Self::git_diff_for_all(repo_path, true)?); + } + ExportPatchScope::Unstaged => { + let mut paths = Self::changed_unstaged_paths(repo_path)?; + paths.extend(Self::untracked_paths(repo_path)?); + Self::append_patch(&mut output, &Self::build_unstaged_patch(repo_path, &paths)?); + } + ExportPatchScope::All => { + Self::append_patch(&mut output, &Self::git_diff_for_all(repo_path, true)?); + let mut paths = Self::changed_unstaged_paths(repo_path)?; + paths.extend(Self::untracked_paths(repo_path)?); + Self::append_patch(&mut output, &Self::build_unstaged_patch(repo_path, &paths)?); + } + ExportPatchScope::Selected => { + let staged_paths = Self::selected_paths(&request.files, true)?; + let unstaged_paths = Self::selected_paths(&request.files, false)?; + Self::append_patch( + &mut output, + &Self::git_diff_for_paths(repo_path, true, &staged_paths)?, + ); + Self::append_patch( + &mut output, + &Self::build_unstaged_patch(repo_path, &unstaged_paths)?, + ); + } + } + + Ok(output) + } + fn submodule_index_commit(repo_path: &Path, path: &str) -> Option { let output = Self::run_git_allow_exit_codes( &["ls-files", "--stage", "--", path], @@ -1944,6 +2147,59 @@ impl GitOperationHandler for CliGitHandler { }) } + fn check_patch_file(&self, request: &ImportPatchRequest) -> GitResult { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let patch_path = Self::validate_patch_path(&request.patch_path)?; + let patch_path_string = patch_path.to_string_lossy().to_string(); + let output = Self::run_git( + &["apply", "--check", "--binary", &patch_path_string], + Some(&repo_path), + )?; + + Ok(Self::operation_result( + format!("Patch file can be applied to {}", repo_path.display()), + output, + &repo_path, + )) + } + + fn import_patch_file(&self, request: &ImportPatchRequest) -> GitResult { + self.check_patch_file(request)?; + + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let patch_path = Self::validate_patch_path(&request.patch_path)?; + let patch_path_string = patch_path.to_string_lossy().to_string(); + let output = Self::run_git(&["apply", "--binary", &patch_path_string], Some(&repo_path))?; + + Ok(Self::operation_result( + format!("Applied patch file to {}", repo_path.display()), + output, + &repo_path, + )) + } + + fn export_patch_file(&self, request: &ExportPatchRequest) -> GitResult { + let repo_path = Self::normalise_repo_path(&request.repo_path)?; + let patch_path = Self::validate_patch_path(&request.patch_path)?; + let output = Self::build_patch(request, &repo_path)?; + + if output.trim().is_empty() { + return Err(GitError::InvalidInput( + "No changes available for patch export".to_string(), + )); + } + + fs::write(&patch_path, output.as_bytes())?; + + Ok(OperationResult { + message: format!("Exported patch file to {}", patch_path.display()), + output: None, + repo_path: Some(Self::path_to_string(&repo_path)), + backend_used: "git-cli".to_string(), + interpreted_error: None, + }) + } + fn get_repo_status(&self, request: &RepoRequest) -> GitResult { let repo_path = Self::normalise_repo_path(&request.repo_path)?; let output = Self::run_git( @@ -2032,7 +2288,7 @@ impl GitOperationHandler for CliGitHandler { CommitDateMode::AuthorDate => "%ad", CommitDateMode::CommitterDate => "%cd", }; - let log_format = format!("%H%x1f%h%x1f%an%x1f%ae%x1f{date_placeholder}%x1f%s"); + let log_format = format!("%H%x1f%h%x1f%aN%x1f%aE%x1f{date_placeholder}%x1f%s"); let repo_path = Self::normalise_repo_path(&request.repo_path)?; let limit = request.limit.unwrap_or(100).clamp(1, 5000).to_string(); let skip = format!("--skip={}", request.offset.unwrap_or(0)); @@ -2192,7 +2448,7 @@ impl GitOperationHandler for CliGitHandler { } // Single call: fields separated by \x1f (unit separator), record ends with \x1e - let format = "%H\x1f%an\x1f%ae\x1f%aI\x1f%cn\x1f%ce\x1f%cI\x1f%P\x1f%b\x1e"; + let format = "%H\x1f%aN\x1f%aE\x1f%aI\x1f%cN\x1f%cE\x1f%cI\x1f%P\x1f%b\x1e"; let output = Self::run_git( &["log", "-1", &format!("--format={}", format), hash], Some(&repo_path), diff --git a/src-tauri/src/git/gix_handler.rs b/src-tauri/src/git/gix_handler.rs index c8d775e..9a8414b 100644 --- a/src-tauri/src/git/gix_handler.rs +++ b/src-tauri/src/git/gix_handler.rs @@ -12,14 +12,14 @@ use super::types::{ CommitHistoryItem, CommitHistoryRequest, CommitLogScope, CommitMarkers, CommitRequest, ConflictFileItem, CreateBranchRequest, CreateTagRequest, DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, DeleteTagRequest, DiffRequest, - ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, FileStatusItem, GitIdentity, - HunkStageRequest, IdentityRequest, MergeRequest, MergeResult, NumstatRequest, NumstatResult, - OperationResult, PruneRemoteRequest, PullAnalysis, PullStrategyRequest, PushRequest, - PushResult, PushTagRequest, RebaseRequest, RebaseResult, RemoteInfo, RemoveRemoteRequest, - RenameBranchRequest, RenameRemoteRequest, RepoRequest, RepoStatus, ResetRequest, - RevertCommitRequest, SetBranchUpstreamRequest, SetIdentityRequest, SetRemoteUrlRequest, - SignatureStatus, StageFilesRequest, StashEntry, StashPushRequest, StashRequest, - SubmoduleActionRequest, TagInfo, UpstreamStatus, + ExportPatchRequest, ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, FileStatusItem, + GitIdentity, HunkStageRequest, IdentityRequest, ImportPatchRequest, MergeRequest, MergeResult, + NumstatRequest, NumstatResult, OperationResult, PruneRemoteRequest, PullAnalysis, + PullStrategyRequest, PushRequest, PushResult, PushTagRequest, RebaseRequest, RebaseResult, + RemoteInfo, RemoveRemoteRequest, RenameBranchRequest, RenameRemoteRequest, RepoRequest, + RepoStatus, ResetRequest, RevertCommitRequest, SetBranchUpstreamRequest, SetIdentityRequest, + SetRemoteUrlRequest, SignatureStatus, StageFilesRequest, StashEntry, StashPushRequest, + StashRequest, SubmoduleActionRequest, TagInfo, UpstreamStatus, }; pub struct GixGitHandler { @@ -61,6 +61,17 @@ impl GixGitHandler { String::from_utf8_lossy(value.as_ref()).to_string() } + fn mailmap_identity( + mailmap: &gix::mailmap::Snapshot, + signature: gix::actor::SignatureRef<'_>, + ) -> (String, String) { + let resolved = mailmap.resolve_cow(signature); + ( + Self::bstr_to_string(resolved.name.as_ref()), + Self::bstr_to_string(resolved.email.as_ref()), + ) + } + fn gix_error(operation: Option<&str>, error: E) -> GitError where E: std::error::Error + 'static, @@ -476,6 +487,7 @@ impl GixGitHandler { .map_err(|e| Self::gix_error(None, e))?; let mut commits = Vec::with_capacity(limit.min(256)); + let mailmap = repo.open_mailmap(); for info in walk.skip(offset).take(limit) { let info = info.map_err(|e| Self::gix_error(None, e))?; @@ -492,11 +504,8 @@ impl GixGitHandler { let short_hash = hash.chars().take(7).collect::(); // Author name and email from the author signature - let author_sig = commit - .author() - .map_err(|e| Self::gix_error(None, e))?; - let author = Self::bstr_to_string(author_sig.name); - let author_email = Self::bstr_to_string(author_sig.email); + let author_sig = commit.author().map_err(|e| Self::gix_error(None, e))?; + let (author, author_email) = Self::mailmap_identity(&mailmap, author_sig); // Date from author or committer signature depending on the setting let date_time = match commit_date_mode { CommitDateMode::AuthorDate => author_sig.time, @@ -937,6 +946,27 @@ impl GitOperationHandler for GixGitHandler { .map(Self::with_cli_fallback_backend) } + fn check_patch_file(&self, request: &ImportPatchRequest) -> GitResult { + self.validate_repo_with_gix(&request.repo_path)?; + self.cli_fallback + .check_patch_file(request) + .map(Self::with_cli_fallback_backend) + } + + fn import_patch_file(&self, request: &ImportPatchRequest) -> GitResult { + self.validate_repo_with_gix(&request.repo_path)?; + self.cli_fallback + .import_patch_file(request) + .map(Self::with_cli_fallback_backend) + } + + fn export_patch_file(&self, request: &ExportPatchRequest) -> GitResult { + self.validate_repo_with_gix(&request.repo_path)?; + self.cli_fallback + .export_patch_file(request) + .map(Self::with_cli_fallback_backend) + } + fn get_repo_status(&self, request: &RepoRequest) -> GitResult { let repo_path = Path::new(request.repo_path.trim()); let repo = gix::discover(repo_path).map_err(|error| Self::gix_error(None, error)); @@ -994,20 +1024,15 @@ impl GitOperationHandler for GixGitHandler { .try_into_commit() .map_err(|e| Self::gix_error(None, e))?; - let author_sig = commit - .author() - .map_err(|e| Self::gix_error(None, e))?; - let author = Self::bstr_to_string(author_sig.name); - let author_email = Self::bstr_to_string(author_sig.email); + let author_sig = commit.author().map_err(|e| Self::gix_error(None, e))?; + let mailmap = repo.open_mailmap(); + let (author, author_email) = Self::mailmap_identity(&mailmap, author_sig); let author_date = gix::date::parse_header(author_sig.time) .and_then(|t: gix::date::Time| t.format(gix::date::time::format::ISO8601_STRICT).ok()) .unwrap_or_else(|| author_sig.time.to_string()); - let committer_sig = commit - .committer() - .map_err(|e| Self::gix_error(None, e))?; - let committer = Self::bstr_to_string(committer_sig.name); - let committer_email = Self::bstr_to_string(committer_sig.email); + let committer_sig = commit.committer().map_err(|e| Self::gix_error(None, e))?; + let (committer, committer_email) = Self::mailmap_identity(&mailmap, committer_sig); let committer_date = gix::date::parse_header(committer_sig.time) .and_then(|t: gix::date::Time| t.format(gix::date::time::format::ISO8601_STRICT).ok()) .unwrap_or_else(|| committer_sig.time.to_string()); diff --git a/src-tauri/src/git/handler.rs b/src-tauri/src/git/handler.rs index 1f54fe1..a4f1978 100644 --- a/src-tauri/src/git/handler.rs +++ b/src-tauri/src/git/handler.rs @@ -8,15 +8,15 @@ use super::types::{ AddRemoteRequest, BackendMode, BranchInfo, BranchRequest, CherryPickRequest, CherryPickResult, CloneRequest, CommitDateMode, CommitDetails, CommitDetailsRequest, CommitFileItem, CommitFilesRequest, CommitHistoryItem, CommitHistoryRequest, CommitMarkers, - CommitPrimaryAction, CommitRequest, CreateBranchRequest, CreateTagRequest, - DeleteBranchRequest, DeleteRemoteBranchRequest, DeleteRemoteTagRequest, DeleteTagRequest, - DiffRequest, ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, GitIdentity, - HunkStageRequest, IdentityRequest, MergeRequest, MergeResult, NumstatRequest, NumstatResult, - OperationResult, PruneRemoteRequest, PullAnalysis, PullStrategyRequest, PushRequest, - PushResult, PushTagRequest, RebaseRequest, RebaseResult, RemoteInfo, RemoveRemoteRequest, - RenameBranchRequest, RenameRemoteRequest, RepoRequest, RepoStatus, ResetRequest, - RevertCommitRequest, SetBranchUpstreamRequest, SetIdentityRequest, SetRemoteUrlRequest, - Settings, StageFilesRequest, StashEntry, StashPushRequest, StashRequest, + CommitPrimaryAction, CommitRequest, CreateBranchRequest, CreateTagRequest, DeleteBranchRequest, + DeleteRemoteBranchRequest, DeleteRemoteTagRequest, DeleteTagRequest, DiffRequest, + ExportPatchRequest, ExternalDiffRequest, FetchRequest, FileDiff, FileRequest, GitIdentity, + HunkStageRequest, IdentityRequest, ImportPatchRequest, MergeRequest, MergeResult, + NumstatRequest, NumstatResult, OperationResult, PruneRemoteRequest, PullAnalysis, + PullStrategyRequest, PushRequest, PushResult, PushTagRequest, RebaseRequest, RebaseResult, + RemoteInfo, RemoveRemoteRequest, RenameBranchRequest, RenameRemoteRequest, RepoRequest, + RepoStatus, ResetRequest, RevertCommitRequest, SetBranchUpstreamRequest, SetIdentityRequest, + SetRemoteUrlRequest, Settings, StageFilesRequest, StashEntry, StashPushRequest, StashRequest, SubmoduleActionRequest, TagInfo, ThemeMode, }; @@ -34,6 +34,9 @@ pub trait GitOperationHandler: Send + Sync { fn get_configured_diff_tool(&self, request: &RepoRequest) -> GitResult>; fn open_external_diff(&self, request: &ExternalDiffRequest) -> GitResult; fn open_working_tree_diff(&self, request: &DiffRequest) -> GitResult; + fn check_patch_file(&self, request: &ImportPatchRequest) -> GitResult; + fn import_patch_file(&self, request: &ImportPatchRequest) -> GitResult; + fn export_patch_file(&self, request: &ExportPatchRequest) -> GitResult; fn get_repo_status(&self, request: &RepoRequest) -> GitResult; fn get_commit_history( &self, @@ -158,26 +161,20 @@ impl GitService { } pub fn get_config_file_path(&self) -> Option { - self.config_path - .read() - .ok() - .and_then(|path| { - path.as_ref() - .map(|p| crate::display_config_path(p).to_string_lossy().to_string()) - }) + self.config_path.read().ok().and_then(|path| { + path.as_ref() + .map(|p| crate::display_config_path(p).to_string_lossy().to_string()) + }) } pub fn get_config_folder_path(&self) -> Option { - self.config_path - .read() - .ok() - .and_then(|path| { - path.as_ref().and_then(|p| { - crate::display_config_path(p) - .parent() - .map(|folder| folder.to_string_lossy().to_string()) - }) + self.config_path.read().ok().and_then(|path| { + path.as_ref().and_then(|p| { + crate::display_config_path(p) + .parent() + .map(|folder| folder.to_string_lossy().to_string()) }) + }) } pub fn get_settings(&self) -> Settings { @@ -427,6 +424,9 @@ impl GitService { fn conflict_accept_theirs(request: FileRequest) -> GitResult; fn conflict_accept_ours(request: FileRequest) -> GitResult; fn open_merge_tool(request: FileRequest) -> GitResult; + fn check_patch_file(request: ImportPatchRequest) -> GitResult; + fn import_patch_file(request: ImportPatchRequest) -> GitResult; + fn export_patch_file(request: ExportPatchRequest) -> GitResult; } forward_read_methods! { diff --git a/src-tauri/src/git/types.rs b/src-tauri/src/git/types.rs index 289cb73..c32182f 100644 --- a/src-tauri/src/git/types.rs +++ b/src-tauri/src/git/types.rs @@ -119,6 +119,15 @@ pub enum RepoOpenBehaviour { NewWindow, } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum ExportPatchScope { + Staged, + Unstaged, + All, + Selected, +} + impl Default for RepoOpenBehaviour { fn default() -> Self { Self::Ask @@ -227,6 +236,30 @@ pub struct Settings { pub git_executable_path: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportPatchRequest { + pub repo_path: String, + pub patch_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ExportPatchFileSelection { + pub path: String, + pub staged: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportPatchRequest { + pub repo_path: String, + pub patch_path: String, + pub scope: ExportPatchScope, + #[serde(default)] + pub files: Vec, +} + impl Default for Settings { fn default() -> Self { Self { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 179e8fe..f52a4b3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -165,11 +165,9 @@ pub(crate) fn git_command() -> std::process::Command { #[cfg(test)] mod git_command_tests { fn command_env_is(command: &std::process::Command, key: &str, value: &str) -> bool { - command - .get_envs() - .any(|(env_key, env_value)| { - env_key == key && env_value == Some(std::ffi::OsStr::new(value)) - }) + command.get_envs().any(|(env_key, env_value)| { + env_key == key && env_value == Some(std::ffi::OsStr::new(value)) + }) } #[test] @@ -1506,6 +1504,9 @@ pub fn run() { commands::repo::get_default_clone_dir, commands::repo::open_external_diff, commands::repo::open_working_tree_diff, + commands::repo::check_patch_file, + commands::repo::import_patch_file, + commands::repo::export_patch_file, commands::repo::get_repo_diff_tool, commands::repo::analyze_pull, commands::repo::pull_changes, diff --git a/src-tauri/tests/git.rs b/src-tauri/tests/git.rs index b0b8bd1..9e70e61 100644 --- a/src-tauri/tests/git.rs +++ b/src-tauri/tests/git.rs @@ -9,7 +9,8 @@ use gitmun_lib::git::gix_handler::GixGitHandler; use gitmun_lib::git::handler::GitOperationHandler; use gitmun_lib::git::types::{ CommitDetailsRequest, CommitHistoryRequest, CommitLogScope, CommitRequest, CreateBranchRequest, - FileRequest, PushFailureKind, PushRequest, RepoRequest, SetBranchUpstreamRequest, + ExportPatchFileSelection, ExportPatchRequest, ExportPatchScope, FileRequest, + ImportPatchRequest, PushFailureKind, PushRequest, RepoRequest, SetBranchUpstreamRequest, StageFilesRequest, SubmoduleActionRequest, SubmoduleState, }; @@ -68,6 +69,10 @@ fn write_file(repo: &Path, name: &str, content: &str) { fs::write(repo.join(name), content).expect("write file"); } +fn read_file(repo: &Path, name: &str) -> String { + fs::read_to_string(repo.join(name)).expect("read file") +} + fn repo_request(dir: &TempDir) -> RepoRequest { RepoRequest { repo_path: dir.path().to_str().unwrap().to_string(), @@ -152,6 +157,39 @@ fn details_request(dir: &TempDir, hash: &str) -> CommitDetailsRequest { } } +fn history_request(dir: &TempDir) -> CommitHistoryRequest { + CommitHistoryRequest { + repo_path: dir.path().to_str().unwrap().to_string(), + limit: Some(10), + after_hash: None, + offset: None, + commit_date_mode: Default::default(), + scope: Default::default(), + } +} + +fn commit_with_identities( + repo: &Path, + file_name: &str, + message: &str, + author: (&str, &str), + committer: (&str, &str), +) -> String { + write_file(repo, file_name, message); + git(repo, &["add", file_name]); + git_with_env( + repo, + &["commit", "-m", message], + &[ + ("GIT_AUTHOR_NAME", author.0), + ("GIT_AUTHOR_EMAIL", author.1), + ("GIT_COMMITTER_NAME", committer.0), + ("GIT_COMMITTER_EMAIL", committer.1), + ], + ); + head_hash(repo) +} + fn push_request(repo: &TempDir) -> PushRequest { PushRequest { repo_path: repo.path().to_str().unwrap().to_string(), @@ -171,6 +209,27 @@ fn submodule_action_request(repo: &TempDir, path: &str) -> SubmoduleActionReques } } +fn import_patch_request(repo: &TempDir, patch_path: &Path) -> ImportPatchRequest { + ImportPatchRequest { + repo_path: repo.path().to_str().unwrap().to_string(), + patch_path: patch_path.to_str().unwrap().to_string(), + } +} + +fn export_patch_request( + repo: &TempDir, + patch_path: &Path, + scope: ExportPatchScope, + files: Vec, +) -> ExportPatchRequest { + ExportPatchRequest { + repo_path: repo.path().to_str().unwrap().to_string(), + patch_path: patch_path.to_str().unwrap().to_string(), + scope, + files, + } +} + #[test] fn status_clean_repo() { let dir = init_repo(); @@ -218,6 +277,189 @@ fn status_detects_modified_unstaged_file() { assert!(status.changed_files.iter().any(|f| f.path == "file.txt")); } +#[test] +fn import_patch_applies_to_working_tree() { + let dir = init_repo(); + write_file(dir.path(), "file.txt", "before\n"); + git(dir.path(), &["add", "file.txt"]); + git(dir.path(), &["commit", "-m", "add file"]); + + let patch = dir.path().join("change.patch"); + fs::write( + &patch, + "diff --git a/file.txt b/file.txt\n\ + index 624785c..172491a 100644\n\ + --- a/file.txt\n\ + +++ b/file.txt\n\ + @@ -1 +1 @@\n\ + -before\n\ + +after\n", + ) + .expect("write patch"); + + handler() + .import_patch_file(&import_patch_request(&dir, &patch)) + .expect("import patch"); + + assert_eq!(read_file(dir.path(), "file.txt"), "after\n"); + assert_eq!( + git_stdout(dir.path(), &["diff", "--cached", "--name-only"]), + "" + ); +} + +#[test] +fn import_patch_rejects_non_applicable_patch_without_modifying_files() { + let dir = init_repo(); + write_file(dir.path(), "file.txt", "different\n"); + git(dir.path(), &["add", "file.txt"]); + git(dir.path(), &["commit", "-m", "add file"]); + + let patch = dir.path().join("change.patch"); + fs::write( + &patch, + "diff --git a/file.txt b/file.txt\n\ + index 624785c..172491a 100644\n\ + --- a/file.txt\n\ + +++ b/file.txt\n\ + @@ -1 +1 @@\n\ + -before\n\ + +after\n", + ) + .expect("write patch"); + + let result = handler().import_patch_file(&import_patch_request(&dir, &patch)); + + assert!(result.is_err()); + assert_eq!(read_file(dir.path(), "file.txt"), "different\n"); +} + +#[test] +fn export_staged_patch_contains_only_staged_changes() { + let dir = init_repo(); + write_file(dir.path(), "staged.txt", "old\n"); + write_file(dir.path(), "unstaged.txt", "old\n"); + git(dir.path(), &["add", "staged.txt", "unstaged.txt"]); + git(dir.path(), &["commit", "-m", "add files"]); + write_file(dir.path(), "staged.txt", "new\n"); + write_file(dir.path(), "unstaged.txt", "new\n"); + git(dir.path(), &["add", "staged.txt"]); + + let patch = dir.path().join("staged.patch"); + handler() + .export_patch_file(&export_patch_request( + &dir, + &patch, + ExportPatchScope::Staged, + vec![], + )) + .expect("export staged patch"); + let output = fs::read_to_string(patch).expect("read patch"); + + assert!(output.contains("diff --git a/staged.txt b/staged.txt")); + assert!(!output.contains("unstaged.txt")); +} + +#[test] +fn export_unstaged_patch_contains_tracked_unstaged_and_untracked_files() { + let dir = init_repo(); + write_file(dir.path(), "tracked.txt", "old\n"); + git(dir.path(), &["add", "tracked.txt"]); + git(dir.path(), &["commit", "-m", "add tracked"]); + write_file(dir.path(), "tracked.txt", "new\n"); + write_file(dir.path(), "new.txt", "new file\n"); + + let patch = dir.path().join("unstaged.patch"); + handler() + .export_patch_file(&export_patch_request( + &dir, + &patch, + ExportPatchScope::Unstaged, + vec![], + )) + .expect("export unstaged patch"); + let output = fs::read_to_string(patch).expect("read patch"); + + assert!(output.contains("diff --git a/tracked.txt b/tracked.txt")); + assert!(output.contains("diff --git a/new.txt b/new.txt")); + assert!(output.contains("new file mode")); +} + +#[test] +fn export_all_concatenates_staged_unstaged_and_untracked_changes() { + let dir = init_repo(); + write_file(dir.path(), "staged.txt", "old\n"); + write_file(dir.path(), "unstaged.txt", "old\n"); + git(dir.path(), &["add", "staged.txt", "unstaged.txt"]); + git(dir.path(), &["commit", "-m", "add files"]); + write_file(dir.path(), "staged.txt", "new\n"); + write_file(dir.path(), "unstaged.txt", "new\n"); + write_file(dir.path(), "new.txt", "new file\n"); + git(dir.path(), &["add", "staged.txt"]); + + let patch = dir.path().join("all.patch"); + handler() + .export_patch_file(&export_patch_request( + &dir, + &patch, + ExportPatchScope::All, + vec![], + )) + .expect("export all patch"); + let output = fs::read_to_string(patch).expect("read patch"); + + assert!(output.contains("diff --git a/staged.txt b/staged.txt")); + assert!(output.contains("diff --git a/unstaged.txt b/unstaged.txt")); + assert!(output.contains("diff --git a/new.txt b/new.txt")); +} + +#[test] +fn export_selected_honours_staged_and_unstaged_file_selections() { + let dir = init_repo(); + for name in ["staged.txt", "unstaged.txt", "ignored.txt"] { + write_file(dir.path(), name, "old\n"); + } + git( + dir.path(), + &["add", "staged.txt", "unstaged.txt", "ignored.txt"], + ); + git(dir.path(), &["commit", "-m", "add files"]); + for name in ["staged.txt", "unstaged.txt", "ignored.txt"] { + write_file(dir.path(), name, "new\n"); + } + write_file(dir.path(), "new.txt", "new file\n"); + git(dir.path(), &["add", "staged.txt"]); + + let patch = dir.path().join("selected.patch"); + handler() + .export_patch_file(&export_patch_request( + &dir, + &patch, + ExportPatchScope::Selected, + vec![ + ExportPatchFileSelection { + path: "staged.txt".to_string(), + staged: true, + }, + ExportPatchFileSelection { + path: "unstaged.txt".to_string(), + staged: false, + }, + ExportPatchFileSelection { + path: "new.txt".to_string(), + staged: false, + }, + ], + )) + .expect("export selected patch"); + let output = fs::read_to_string(patch).expect("read patch"); + + assert!(output.contains("diff --git a/staged.txt b/staged.txt")); + assert!(output.contains("diff --git a/unstaged.txt b/unstaged.txt")); + assert!(output.contains("diff --git a/new.txt b/new.txt")); + assert!(!output.contains("ignored.txt")); +} + #[test] fn discard_file_removes_untracked_directory() { let dir = init_repo(); @@ -696,6 +938,75 @@ fn commit_history_returns_commits_newest_first() { assert_eq!(commits[0].message, "third"); } +fn assert_commit_history_honours_mailmap(handler: H) { + let dir = init_repo(); + commit_with_identities( + dir.path(), + "mailmap-history.txt", + "mailmap history", + ("Old Name", "old@example.test"), + ("Old Name", "old@example.test"), + ); + write_file( + dir.path(), + ".mailmap", + "Canonical Name Old Name \n", + ); + + let commits = handler + .get_commit_history(&history_request(&dir)) + .expect("get_commit_history"); + let commit = commits + .iter() + .find(|commit| commit.message == "mailmap history") + .expect("mailmap history commit"); + + assert_eq!(commit.author, "Canonical Name"); + assert_eq!(commit.author_email, "canonical@example.test"); +} + +#[test] +fn cli_commit_history_honours_mailmap() { + assert_commit_history_honours_mailmap(handler()); +} + +#[test] +fn gix_commit_history_honours_mailmap() { + assert_commit_history_honours_mailmap(gix_handler()); +} + +fn assert_commit_history_without_mailmap_keeps_raw_identity(handler: H) { + let dir = init_repo(); + commit_with_identities( + dir.path(), + "raw-history.txt", + "raw history", + ("Old Name", "old@example.test"), + ("Old Name", "old@example.test"), + ); + + let commits = handler + .get_commit_history(&history_request(&dir)) + .expect("get_commit_history"); + let commit = commits + .iter() + .find(|commit| commit.message == "raw history") + .expect("raw history commit"); + + assert_eq!(commit.author, "Old Name"); + assert_eq!(commit.author_email, "old@example.test"); +} + +#[test] +fn cli_commit_history_without_mailmap_keeps_raw_identity() { + assert_commit_history_without_mailmap_keeps_raw_identity(handler()); +} + +#[test] +fn gix_commit_history_without_mailmap_keeps_raw_identity() { + assert_commit_history_without_mailmap_keeps_raw_identity(gix_handler()); +} + #[test] fn gix_commit_history_matches_git_log_order_for_merge_commits() { let dir = init_repo(); @@ -799,6 +1110,72 @@ fn cli_commit_details_basic_fields() { assert!(details.tags.is_empty()); } +fn assert_commit_details_honours_mailmap(handler: H) { + let dir = init_repo(); + let hash = commit_with_identities( + dir.path(), + "mailmap-details.txt", + "mailmap details", + ("Old Author", "old-author@example.test"), + ("Old Committer", "old-committer@example.test"), + ); + write_file( + dir.path(), + ".mailmap", + "Canonical Author \n\ + Canonical Committer Old Committer \n", + ); + + let details = handler + .get_commit_details(&details_request(&dir, &hash)) + .expect("get_commit_details"); + + assert_eq!(details.author, "Canonical Author"); + assert_eq!(details.author_email, "canonical-author@example.test"); + assert_eq!(details.committer, "Canonical Committer"); + assert_eq!(details.committer_email, "canonical-committer@example.test"); +} + +#[test] +fn cli_commit_details_honours_mailmap() { + assert_commit_details_honours_mailmap(handler()); +} + +#[test] +fn gix_commit_details_honours_mailmap() { + assert_commit_details_honours_mailmap(gix_handler()); +} + +fn assert_commit_details_without_mailmap_keeps_raw_identity(handler: H) { + let dir = init_repo(); + let hash = commit_with_identities( + dir.path(), + "raw-details.txt", + "raw details", + ("Old Author", "old-author@example.test"), + ("Old Committer", "old-committer@example.test"), + ); + + let details = handler + .get_commit_details(&details_request(&dir, &hash)) + .expect("get_commit_details"); + + assert_eq!(details.author, "Old Author"); + assert_eq!(details.author_email, "old-author@example.test"); + assert_eq!(details.committer, "Old Committer"); + assert_eq!(details.committer_email, "old-committer@example.test"); +} + +#[test] +fn cli_commit_details_without_mailmap_keeps_raw_identity() { + assert_commit_details_without_mailmap_keeps_raw_identity(handler()); +} + +#[test] +fn gix_commit_details_without_mailmap_keeps_raw_identity() { + assert_commit_details_without_mailmap_keeps_raw_identity(gix_handler()); +} + #[test] fn cli_commit_details_parent_hash() { let dir = init_repo(); diff --git a/src/api/commands.ts b/src/api/commands.ts index e1d105f..82ce5d3 100644 --- a/src/api/commands.ts +++ b/src/api/commands.ts @@ -21,12 +21,14 @@ import type { PushTagRequest, DiffRequest, FetchRequest, + ExportPatchRequest, ExternalDiffTool, FileDiff, FileRequest, GitIdentity, HunkStageRequest, IdentityRequest, + ImportPatchRequest, MergeResult, RebaseRequest, RebaseResult, @@ -162,6 +164,18 @@ export function openWorkingTreeDiff(repoPath: string, filePath: string, staged: return invoke("open_working_tree_diff", {request: {repoPath, filePath, staged}}); } +export function checkPatchFile(request: ImportPatchRequest): Promise { + return invoke("check_patch_file", {request}); +} + +export function importPatchFile(request: ImportPatchRequest): Promise { + return invoke("import_patch_file", {request}); +} + +export function exportPatchFile(request: ExportPatchRequest): Promise { + return invoke("export_patch_file", {request}); +} + export function getRepoDiffTool(repoPath: string): Promise { return invoke("get_repo_diff_tool", {request: {repoPath}}); } diff --git a/src/components/App.css b/src/components/App.css index 3cbeb52..b0e08d5 100644 --- a/src/components/App.css +++ b/src/components/App.css @@ -102,7 +102,7 @@ } .app__empty-card { - width: min(520px, calc(100% - 48px)); + width: min(560px, calc(100% - 48px)); border: 1px solid var(--border); background: var(--bg-surface); border-radius: var(--radius-2xl); @@ -173,3 +173,68 @@ .app__empty-btn--secondary:hover { background: var(--bg-hover); } + +.app__empty-recent { + margin-top: 22px; +} + +.app__empty-recent-divider { + height: 1px; + background: var(--border-subtle); + margin-bottom: 16px; +} + +.app__empty-recent-title { + margin-bottom: 8px; + color: var(--text-muted); + font-size: var(--font-size-sm); +} + +.app__empty-recent-list { + display: grid; + gap: 6px; +} + +.app__empty-recent-item { + width: 100%; + min-width: 0; + border: 1px solid transparent; + border-radius: var(--radius-lg); + background: transparent; + color: var(--text-primary); + padding: 8px 10px; + display: grid; + grid-template-columns: auto minmax(0, 1fr); + column-gap: 9px; + row-gap: 2px; + align-items: center; + text-align: left; + cursor: pointer; +} + +.app__empty-recent-item:hover { + border-color: var(--border); + background: var(--bg-hover); +} + +.app__empty-recent-item svg { + grid-row: 1 / 3; + color: var(--text-muted); +} + +.app__empty-recent-name, +.app__empty-recent-path { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app__empty-recent-name { + font-weight: var(--font-weight-medium); +} + +.app__empty-recent-path { + color: var(--text-muted); + font-size: var(--font-size-sm); +} diff --git a/src/components/ProjectView.tsx b/src/components/ProjectView.tsx index 10f18a8..2106a4d 100644 --- a/src/components/ProjectView.tsx +++ b/src/components/ProjectView.tsx @@ -8,7 +8,7 @@ * new one. */ import React, { useState, useCallback, useEffect, useRef } from "react"; -import { ask } from "@tauri-apps/plugin-dialog"; +import { ask, open, save } from "@tauri-apps/plugin-dialog"; import { listen } from "@tauri-apps/api/event"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; @@ -45,6 +45,8 @@ import type { CommitMarkers, CommitPrimaryAction, CreateBranchRequest, + ExportPatchFileSelection, + ExportPatchScope, GitIdentity, PullAnalysis, PullStrategy, @@ -197,6 +199,7 @@ export function ProjectView({ }: ProjectViewProps) { const { t } = useTranslation("projectView"); const { t: tGitAdvice } = useTranslation("gitAdvice"); + const emptyStateRecentRepos = !repoPath ? recentRepos.slice(0, 5) : []; const collapsedRightPaneBonus = leftPaneCollapsed ? Math.max(0, leftPaneWidth + 6 - 22) : 0; @@ -211,6 +214,8 @@ export function ProjectView({ const [selectedFile, setSelectedFile] = useState(null); const [selectedFileStaged, setSelectedFileStaged] = useState(false); + const [selectedUnstagedFiles, setSelectedUnstagedFiles] = useState>({}); + const [selectedStagedFiles, setSelectedStagedFiles] = useState>({}); const [selectedSubmodulePath, setSelectedSubmodulePath] = useState(null); const [diffRefreshKey, setDiffRefreshKey] = useState(0); const [centreTab, setCentreTab] = useState("changes"); @@ -286,6 +291,18 @@ export function ProjectView({ const stagedFiles = status?.stagedFiles ?? []; const unstagedFiles = status?.changedFiles ?? []; const unversionedFiles = status?.unversionedFiles ?? []; + const selectedStagedPaths = stagedFiles + .filter(file => selectedStagedFiles[file.path]) + .map(file => file.path); + const unstagedExportPaths = [ + ...unstagedFiles.map(file => file.path), + ...unversionedFiles, + ]; + const selectedUnstagedPaths = unstagedExportPaths.filter(path => selectedUnstagedFiles[path]); + const selectedPatchFiles: ExportPatchFileSelection[] = [ + ...selectedStagedPaths.map(path => ({ path, staged: true })), + ...selectedUnstagedPaths.map(path => ({ path, staged: false })), + ]; const submodules = status?.submodules ?? []; const selectedSubmodule = submodules.find(submodule => submodule.path === selectedSubmodulePath) ?? null; const conflictedFiles = status?.conflictedFiles ?? []; @@ -367,6 +384,35 @@ export function ProjectView({ } }, [selectedFile, selectedFileStaged, status]); + useEffect(() => { + if (!status) { + setSelectedStagedFiles({}); + setSelectedUnstagedFiles({}); + return; + } + + const staged = new Set(status.stagedFiles.map(file => file.path)); + const unstaged = new Set([ + ...status.changedFiles.map(file => file.path), + ...status.unversionedFiles, + ]); + + setSelectedStagedFiles(prev => { + const next: Record = {}; + for (const path of Object.keys(prev)) { + if (prev[path] && staged.has(path)) next[path] = true; + } + return Object.keys(next).length === Object.keys(prev).length ? prev : next; + }); + setSelectedUnstagedFiles(prev => { + const next: Record = {}; + for (const path of Object.keys(prev)) { + if (prev[path] && unstaged.has(path)) next[path] = true; + } + return Object.keys(next).length === Object.keys(prev).length ? prev : next; + }); + }, [status]); + useEffect(() => { if (!selectedSubmodulePath || !status) return; const stillPresent = status.submodules.some(submodule => submodule.path === selectedSubmodulePath); @@ -1447,6 +1493,70 @@ export function ProjectView({ } catch (e) { showToast(localiseExternalToolError(e, t), "error"); } }, [repoPath, showToast, t]); + const handleImportPatch = useCallback(async () => { + if (!repoPath) return; + const selected = await open({ + multiple: false, + directory: false, + title: t("patch.importPickerTitle"), + filters: [{ name: t("patch.patchFilesFilter"), extensions: ["patch", "diff"] }], + }); + if (typeof selected !== "string") return; + + const confirmed = await ask(t("ask.importPatch.message"), { + title: t("ask.importPatch.title"), + kind: "warning", + okLabel: t("actions.importPatch"), + cancelLabel: t("actions.cancel"), + }); + if (!confirmed) return; + + try { + await api.checkPatchFile({ repoPath, patchPath: selected }); + const result = await api.importPatchFile({ repoPath, patchPath: selected }); + showToast(t("toast.patchImported"), "success"); + appendResultLog("success", result.message, result.backendUsed); + setCentreTab("changes"); + await refreshAll(); + } catch (e) { + showToast(String(e), "error"); + appendResultLog("error", t("log.importPatchFailed", { message: String(e) }), "unknown"); + } + }, [repoPath, refreshAll, showToast, t]); + + const handleExportPatch = useCallback(async (scope: ExportPatchScope) => { + if (!repoPath) return; + + const files = scope === "selected" ? selectedPatchFiles : undefined; + if (scope === "selected" && (!files || files.length === 0)) { + showToast(t("toast.noPatchChanges"), "info"); + return; + } + + const selected = await save({ + title: t("patch.exportPickerTitle"), + defaultPath: "changes.patch", + filters: [{ name: t("patch.patchFilesFilter"), extensions: ["patch"] }], + }); + if (!selected) return; + + try { + const result = await api.exportPatchFile({ repoPath, patchPath: selected, scope, files }); + const patchName = getFileName(selected); + showToast(t("toast.patchExported", { file: patchName }), "success"); + appendResultLog("success", result.message, result.backendUsed); + } catch (e) { + const message = String(e); + if (message.includes("No changes available for patch export")) { + showToast(t("toast.noPatchChanges"), "info"); + appendResultLog("info", t("log.noPatchChanges"), "git-cli"); + return; + } + showToast(message, "error"); + appendResultLog("error", t("log.exportPatchFailed", { message }), "unknown"); + } + }, [repoPath, selectedPatchFiles, showToast, t]); + const runSubmoduleAction = useCallback(async ( path: string, label: string, @@ -2043,6 +2153,9 @@ export function ProjectView({ pushDisabled={remoteActionState.disabled} pushTitle={remoteActionTitle} onStash={handleStash} + onImportPatch={handleImportPatch} + onExportPatch={handleExportPatch} + selectedPatchExportEnabled={selectedPatchFiles.length > 0} remoteOp={remoteOp} identityOpen={identityOpen} /> @@ -2169,6 +2282,10 @@ export function ProjectView({ onResetToCommit={handleResetToCommit} selectedFile={selectedFile} selectedSubmodulePath={selectedSubmodulePath} + selectedStagedFiles={selectedStagedFiles} + selectedUnstagedFiles={selectedUnstagedFiles} + onSelectedStagedFilesChange={setSelectedStagedFiles} + onSelectedUnstagedFilesChange={setSelectedUnstagedFiles} onFileSelect={handleFileSelect} onSubmoduleSelect={handleSubmoduleSelect} onSubmoduleInit={handleSubmoduleInit} @@ -2254,19 +2371,43 @@ export function ProjectView({

{t("emptyState.title")}

{t("emptyState.subtitle")}

- - -
+ {emptyStateRecentRepos.length > 0 && ( +
+
+
{t("emptyState.recentRepositories")}
+
+ {emptyStateRecentRepos.map(path => { + const name = path.split(/[\\/]/).filter(Boolean).pop() ?? path; + return ( + + ); + })} +
+
+ )}
)} diff --git a/src/components/Titlebar.css b/src/components/Titlebar.css index d4b0d1b..19d134a 100644 --- a/src/components/Titlebar.css +++ b/src/components/Titlebar.css @@ -196,9 +196,8 @@ border: 1px solid var(--border-subtle); color: var(--text-secondary); font-size: var(--font-size-sm); - width: 190px; - /* Wide enough to show "Search commits Ctrl+F" without clipping */ - min-width: 180px; + width: 210px; + min-width: 190px; flex-shrink: 0; box-sizing: border-box; cursor: text; @@ -207,7 +206,16 @@ .titlebar__search--active { border-color: var(--accent); - width: 240px; + width: 260px; +} + +.titlebar__search--disabled { + opacity: 0.35; + cursor: default; +} + +.titlebar__search--disabled .titlebar__search-input { + cursor: default; } .titlebar__search-input { @@ -226,14 +234,6 @@ color: var(--text-secondary); } -.titlebar__search-hint { - margin-left: auto; - font-size: var(--font-size-xxs); - opacity: 0.5; - font-family: var(--font-mono); - white-space: nowrap; -} - .titlebar__icon-btn { color: var(--text-secondary); cursor: pointer; @@ -285,6 +285,21 @@ box-shadow: var(--shadow-popover); } +.titlebar__open-submenu { + position: absolute; + top: -5px; + left: calc(100% + 4px); + display: none; + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 4px; + min-width: 210px; + box-sizing: border-box; + z-index: 101; + box-shadow: var(--shadow-popover); +} + .titlebar__open-menu-item { display: flex; align-items: center; @@ -297,11 +312,41 @@ transition: all 0.1s; } +.titlebar__open-menu-item--submenu { + position: relative; + justify-content: space-between; +} + +.titlebar__open-menu-item--submenu:hover .titlebar__open-submenu, +.titlebar__open-menu-item--submenu:focus-within .titlebar__open-submenu { + display: block; +} + +.titlebar__submenu-chevron { + transform: rotate(-90deg); + flex: 0 0 13px; +} + .titlebar__open-menu-item:hover { background: var(--bg-hover); color: var(--text-primary); } +.titlebar__open-menu-heading { + padding: 5px 10px 3px; + color: var(--text-muted); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); +} + +.titlebar__open-menu-item--disabled, +.titlebar__open-menu-item--disabled:hover { + background: transparent; + color: var(--text-muted); + cursor: default; + opacity: 0.55; +} + .titlebar__open-menu-icon { width: 14px; height: 14px; diff --git a/src/components/Titlebar.test.tsx b/src/components/Titlebar.test.tsx index 01cbf2c..77b20db 100644 --- a/src/components/Titlebar.test.tsx +++ b/src/components/Titlebar.test.tsx @@ -36,7 +36,14 @@ function renderTitlebar( pushLabel = "Push", repoPath: string | null = "/repo", onOpenRepoLocation = vi.fn(), + patchHandlers: { + onImportPatch?: () => void; + onExportPatch?: (scope: "staged" | "unstaged" | "all" | "selected") => void; + selectedPatchExportEnabled?: boolean; + } = {}, ) { + const onImportPatch = patchHandlers.onImportPatch ?? vi.fn(); + const onExportPatch = patchHandlers.onExportPatch ?? vi.fn(); render( , @@ -166,4 +176,49 @@ describe("Titlebar", () => { expect(screen.getByText("Git Bash")).toBeInTheDocument(); }); }); + + it("shows patch file actions when a repository is open", () => { + renderTitlebar([makeBranch()]); + + fireEvent.click(screen.getByText("More")); + + expect(screen.getByText("Patch files")).toBeInTheDocument(); + expect(screen.getByText("Import patch...")).toBeInTheDocument(); + expect(screen.getByText("Export patch")).toBeInTheDocument(); + expect(screen.getByText("Export staged patch...")).toBeInTheDocument(); + expect(screen.getByText("Export unstaged patch...")).toBeInTheDocument(); + expect(screen.getByText("Export all changes patch...")).toBeInTheDocument(); + }); + + it("disables selected patch export until files are checked", () => { + renderTitlebar([makeBranch()]); + + fireEvent.click(screen.getByText("More")); + + expect(screen.getByText("Export selected patch...").closest(".titlebar__open-menu-item")) + .toHaveAttribute("aria-disabled", "true"); + }); + + it("enables selected patch export when files are checked", () => { + const onExportPatch = vi.fn(); + renderTitlebar([makeBranch()], "Push", "/repo", vi.fn(), { + onExportPatch, + selectedPatchExportEnabled: true, + }); + + fireEvent.click(screen.getByText("More")); + fireEvent.click(screen.getByText("Export selected patch...")); + + expect(onExportPatch).toHaveBeenCalledWith("selected"); + }); + + it("calls export handlers from the export patch submenu", () => { + const onExportPatch = vi.fn(); + renderTitlebar([makeBranch()], "Push", "/repo", vi.fn(), { onExportPatch }); + + fireEvent.click(screen.getByText("More")); + fireEvent.click(screen.getByText("Export staged patch...")); + + expect(onExportPatch).toHaveBeenCalledWith("staged"); + }); }); diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx index f817bfd..361a4ac 100644 --- a/src/components/Titlebar.tsx +++ b/src/components/Titlebar.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { GitIcon, BranchIcon, FetchIcon, PullIcon, PushIcon, StashIcon, SearchIcon, SettingsIcon, FolderIcon, CopyIcon, ChevDownIcon, InfoIcon, TerminalIcon, OpenExternalIcon, + MoreIcon, } from "./icons"; import * as api from "../api/commands"; import type { PlatformType } from "../hooks/usePlatform"; @@ -37,6 +38,9 @@ type TitlebarProps = { pushDisabled?: boolean; pushTitle?: string; onStash: () => void; + onImportPatch: () => void; + onExportPatch: (scope: "staged" | "unstaged" | "all" | "selected") => void; + selectedPatchExportEnabled: boolean; remoteOp?: "fetch" | "pull" | "push" | null; identityOpen: boolean; }; @@ -59,6 +63,7 @@ export function Titlebar({ identityInitials, identityAvatarUrl, recentRepos, searchQuery, searchInputRef, onSearchChange, onAboutClick, onSettingsClick, onIdentityClick, onCloneClick, onInitRepoClick, onOpenExistingClick, onRepoSelect, onOpenRepoLocation, onFetch, onPull, onPush, pushLabel, pushDisabled = false, pushTitle, onStash, + onImportPatch, onExportPatch, selectedPatchExportEnabled, identityOpen, remoteOp, }: TitlebarProps) { const { t } = useTranslation("titlebar"); @@ -69,6 +74,8 @@ export function Titlebar({ const currentBranchInfo = branches.find(b => b.isCurrent); const ahead = currentBranchInfo?.ahead ?? 0; const behind = currentBranchInfo?.behind ?? 0; + const searchDisabled = !repoPath; + const searchShortcutLabel = platform === "macos" ? "\u2318F" : "Ctrl+F"; const { repoDir, repoName } = repoPath ? splitRepoPath(repoPath) @@ -150,6 +157,12 @@ export function Titlebar({ title={pushTitle} /> } label={t("actions.stash")} onClick={onStash} disabled={!repoPath} /> +
@@ -175,8 +188,10 @@ export function Titlebar({ {/* Search */}
searchInputRef.current?.focus()} + className={`titlebar__search${!searchDisabled && (searchQuery || searchFocused) ? " titlebar__search--active" : ""}${searchDisabled ? " titlebar__search--disabled" : ""}`} + onClick={searchDisabled ? undefined : () => searchInputRef.current?.focus()} + aria-disabled={searchDisabled} + title={searchDisabled ? undefined : t("labels.searchCommitsShortcut", { shortcut: searchShortcutLabel })} > onSearchChange(e.target.value)} onFocus={() => setSearchFocused(true)} onBlur={() => setSearchFocused(false)} @@ -191,11 +208,6 @@ export function Titlebar({ if (e.key === "Escape") { onSearchChange(""); e.currentTarget.blur(); } }} /> - {!searchQuery && ( - - {platform === "macos" ? "\u2318F" : "Ctrl+F"} - - )}
@@ -219,6 +231,85 @@ export function Titlebar({ ); } +function MoreDropdown({ repoPath, onImportPatch, onExportPatch, selectedPatchExportEnabled }: { + repoPath: string | null; + onImportPatch: () => void; + onExportPatch: (scope: "staged" | "unstaged" | "all" | "selected") => void; + selectedPatchExportEnabled: boolean; +}) { + const { t } = useTranslation("titlebar"); + const [open, setOpen] = useState(false); + const ref = useRef(null); + const disabled = !repoPath; + + useEffect(() => { + if (disabled) { + setOpen(false); + } + }, [disabled]); + + useEffect(() => { + if (!open) return; + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [open]); + + const run = (action: () => void) => { + setOpen(false); + action(); + }; + + return ( +
+
setOpen(v => !v)} + title={t("actions.more")} + aria-disabled={disabled} + > + + {t("actions.more")} + +
+ {open && !disabled && ( +
+
{t("patchFiles.heading")}
+
run(onImportPatch)}> + {t("patchFiles.import")} +
+
+ {t("patchFiles.export")} + +
+
run(() => onExportPatch("staged"))}> + {t("patchFiles.exportStaged")} +
+
run(() => onExportPatch("unstaged"))}> + {t("patchFiles.exportUnstaged")} +
+
run(() => onExportPatch("all"))}> + {t("patchFiles.exportAll")} +
+
run(() => onExportPatch("selected")) : undefined} + > + {t("patchFiles.exportSelected")} +
+
+
+
+ )} +
+ ); +} + function fallbackOpenLocations(t: ReturnType>["t"]): RepoOpenLocation[] { return [ { diff --git a/src/components/centre/CentrePanel.tsx b/src/components/centre/CentrePanel.tsx index 780c950..a06f701 100644 --- a/src/components/centre/CentrePanel.tsx +++ b/src/components/centre/CentrePanel.tsx @@ -58,6 +58,10 @@ type CentrePanelProps = { onResetToCommit?: (commitHash: string, mode: "soft" | "mixed") => void; selectedFile: string | null; selectedSubmodulePath: string | null; + selectedStagedFiles: Record; + selectedUnstagedFiles: Record; + onSelectedStagedFilesChange: React.Dispatch>>; + onSelectedUnstagedFilesChange: React.Dispatch>>; onFileSelect: (path: string, staged: boolean) => void; onSubmoduleSelect: (path: string) => void; onSubmoduleInit: (path: string) => void; @@ -208,6 +212,10 @@ export function CentrePanel(props: CentrePanelProps) { cherryPickInProgress={props.cherryPickInProgress} selectedFile={props.selectedFile} selectedSubmodulePath={props.selectedSubmodulePath} + selectedStaged={props.selectedStagedFiles} + selectedUnstaged={props.selectedUnstagedFiles} + onSelectedStagedChange={props.onSelectedStagedFilesChange} + onSelectedUnstagedChange={props.onSelectedUnstagedFilesChange} onFileSelect={props.onFileSelect} onSubmoduleSelect={props.onSubmoduleSelect} onSubmoduleInit={props.onSubmoduleInit} diff --git a/src/components/centre/StagingView.tsx b/src/components/centre/StagingView.tsx index d4d9519..7a67f60 100644 --- a/src/components/centre/StagingView.tsx +++ b/src/components/centre/StagingView.tsx @@ -18,6 +18,10 @@ type StagingViewProps = { cherryPickInProgress: boolean; selectedFile: string | null; selectedSubmodulePath: string | null; + selectedUnstaged: Record; + selectedStaged: Record; + onSelectedUnstagedChange: React.Dispatch>>; + onSelectedStagedChange: React.Dispatch>>; onFileSelect: (path: string, staged: boolean) => void; onSubmoduleSelect: (path: string) => void; onSubmoduleInit: (path: string) => void; @@ -145,7 +149,8 @@ function SubmoduleRow({ export function StagingView({ repoPath, stagedFiles, unstagedFiles, unversionedFiles, submodules, conflictedFiles, mergeInProgress, mergeMessage, rebaseInProgress, cherryPickInProgress, - selectedFile, selectedSubmodulePath, onFileSelect, onSubmoduleSelect, onSubmoduleInit, onSubmoduleUpdate, onSubmoduleSync, + selectedFile, selectedSubmodulePath, selectedUnstaged, selectedStaged, onSelectedUnstagedChange, onSelectedStagedChange, + onFileSelect, onSubmoduleSelect, onSubmoduleInit, onSubmoduleUpdate, onSubmoduleSync, onSubmoduleFetch, onSubmodulePull, onSubmoduleOpen, onStageFile, onStageFiles, onUnstageFile, onUnstageFiles, onDiscardFile, onDiscardFiles, onDiscardAll, onExternalDiff, onStageAll, onUnstageAll, selectedCommitAction, commitMessageRecommendedLength, allowCommitAndPush, onSelectCommitAction, onCommit, @@ -153,8 +158,6 @@ export function StagingView({ isCommitting, lastCommitMessage, rowStriping, }: StagingViewProps) { const { t } = useTranslation("centre"); - const [selectedUnstaged, setSelectedUnstaged] = useState>({}); - const [selectedStaged, setSelectedStaged] = useState>({}); const [numstatCache, setNumstatCache] = useState>({}); const [numstatLoading, setNumstatLoading] = useState>({}); @@ -277,23 +280,23 @@ export function StagingView({ ); const toggleUnstaged = (path: string) => { - setSelectedUnstaged(prev => ({ ...prev, [path]: !prev[path] })); + onSelectedUnstagedChange(prev => ({ ...prev, [path]: !prev[path] })); }; const toggleStaged = (path: string) => { - setSelectedStaged(prev => ({ ...prev, [path]: !prev[path] })); + onSelectedStagedChange(prev => ({ ...prev, [path]: !prev[path] })); }; const handleStageSelected = () => { if (selectedUnstagedPaths.length === 0) return; onStageFiles(selectedUnstagedPaths); - setSelectedUnstaged({}); + onSelectedUnstagedChange({}); }; const handleUnstageSelected = () => { if (selectedStagedPaths.length === 0) return; onUnstageFiles(selectedStagedPaths); - setSelectedStaged({}); + onSelectedStagedChange({}); }; const striped = (index: number): "Subtle" | "Strong" | undefined => { if (rowStriping === "Off" || index % 2 === 0) return undefined; diff --git a/src/components/icons.tsx b/src/components/icons.tsx index 0dff7ab..db20afd 100644 --- a/src/components/icons.tsx +++ b/src/components/icons.tsx @@ -28,6 +28,7 @@ import { Info, WarningCircle, GithubLogo, + DotsThree, } from "@phosphor-icons/react"; type IconProps = { size?: number; className?: string }; @@ -56,6 +57,7 @@ export const GlobeIcon = ({ size = 16, className }: IconProps) => ; export const WarningIcon = ({ size = 16, className }: IconProps) => ; export const GithubLogoIcon = ({ size = 16, className }: IconProps) => ; +export const MoreIcon = ({ size = 16, className }: IconProps) => ; export const FolderIcon = ({ size = 16, className }: IconProps) => ; export const CopyIcon = ({ size = 16, className }: IconProps) => ; export const OpenExternalIcon = ({ size = 16, className }: IconProps) => ; diff --git a/src/i18n/locales/en/projectView.json b/src/i18n/locales/en/projectView.json index cbe1b6d..75ab7cc 100644 --- a/src/i18n/locales/en/projectView.json +++ b/src/i18n/locales/en/projectView.json @@ -6,6 +6,7 @@ "delete": "Delete", "drop": "Drop", "forceDelete": "Force Delete", + "importPatch": "Apply Patch", "rebase": "Rebase", "remove": "Remove", "revert": "Revert", @@ -59,6 +60,10 @@ "message": "Force delete branch \"{{branch}}\"? This will delete it even if it has unmerged changes or is checked out in a worktree.", "title": "Force Delete Branch" }, + "importPatch": { + "message": "Apply patch file to the working tree?", + "title": "Import Patch" + }, "removeRemote": { "message": "Remove remote \"{{remote}}\"? This will also delete all remote-tracking branches for this remote.", "title": "Remove Remote" @@ -88,6 +93,7 @@ "clone": "Clone repository", "init": "Initialise repository", "openExisting": "Open existing", + "recentRepositories": "Recent repositories", "subtitle": "Clone a project, initialise a new repository, or open an existing one.", "title": "No repository open" }, @@ -125,6 +131,9 @@ "forceDeleteBranchFailed": "Force delete branch failed: {{message}}", "mergeAbortFailed": "Merge abort failed: {{message}}", "mergeFailed": "Merge failed: {{message}}", + "exportPatchFailed": "Export patch failed: {{message}}", + "importPatchFailed": "Import patch failed: {{message}}", + "noPatchChanges": "No changes available for patch export", "pruneRemoteFailed": "Prune remote failed: {{message}}", "pullAnalysisFailed": "Pull analysis failed: {{message}}", "pullFailed": "Pull failed: {{message}}", @@ -212,6 +221,9 @@ "noLocalChangesToStash": "No local changes to stash", "noRemotesConfigured": "No remotes configured", "openedDiffFor": "Opened diff for {{file}}", + "noPatchChanges": "No changes available for patch export", + "patchExported": "Exported {{file}}", + "patchImported": "Patch imported", "pullComplete": "Pull complete", "pushComplete": "Push complete", "pushDetached": "Push is unavailable while HEAD is detached.", @@ -242,5 +254,10 @@ "unstagedHunk": "Unstaged hunk", "upstreamChanged": "Upstream changed", "upstreamRepaired": "Upstream repaired" + }, + "patch": { + "exportPickerTitle": "Export patch file", + "importPickerTitle": "Import patch file", + "patchFilesFilter": "Patch files" } } diff --git a/src/i18n/locales/en/titlebar.json b/src/i18n/locales/en/titlebar.json index a3b9a70..9a743f8 100644 --- a/src/i18n/locales/en/titlebar.json +++ b/src/i18n/locales/en/titlebar.json @@ -7,6 +7,7 @@ "fetch": "Fetch", "fileManager": "File Manager", "initialiseRepository": "Initialise a repository", + "more": "More", "new": "New", "open": "Open", "openIn": "Open in...", @@ -20,6 +21,16 @@ "labels": { "copied": "Copied", "noRepositoryOpen": "No repository open", - "searchCommits": "Search commits..." + "searchCommits": "Search commits...", + "searchCommitsShortcut": "Search commits ({{shortcut}})" + }, + "patchFiles": { + "export": "Export patch", + "exportAll": "Export all changes patch...", + "exportSelected": "Export selected patch...", + "exportStaged": "Export staged patch...", + "exportUnstaged": "Export unstaged patch...", + "heading": "Patch files", + "import": "Import patch..." } } diff --git a/src/types.ts b/src/types.ts index a6ee9ea..accf57a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -10,6 +10,7 @@ export type RepoOpenBehaviour = "Ask" | "ExistingWindow" | "NewWindow"; export type RowStriping = "Off" | "Subtle" | "Strong"; export type UiTextScale = 0.9 | 1 | 1.1 | 1.2 | 1.3; export type AppUpdateChannel = "SelfManaged" | "MicrosoftStore" | "SystemManaged"; +export type ExportPatchScope = "staged" | "unstaged" | "all" | "selected"; export type GitErrorCategory = | "auth" @@ -230,6 +231,21 @@ export type RepoRequest = { repoPath: string; }; +export type ImportPatchRequest = RepoRequest & { + patchPath: string; +}; + +export type ExportPatchFileSelection = { + path: string; + staged: boolean; +}; + +export type ExportPatchRequest = RepoRequest & { + patchPath: string; + scope: ExportPatchScope; + files?: ExportPatchFileSelection[]; +}; + export type CommitRequest = RepoRequest & { message: string; amend?: boolean;