From dcdbd10cee27f5ece241c7a3ce0c7135d350d225 Mon Sep 17 00:00:00 2001
From: cst8t <1810150+cst8t@users.noreply.github.com>
Date: Mon, 25 May 2026 18:49:14 +0100
Subject: [PATCH 1/3] feat: improve empty repository start screen
---
src/components/App.css | 67 +++++++++++++++++++++++++++-
src/components/ProjectView.tsx | 31 +++++++++++--
src/components/Titlebar.css | 24 +++++-----
src/components/Titlebar.tsx | 15 ++++---
src/i18n/locales/en/projectView.json | 1 +
src/i18n/locales/en/titlebar.json | 3 +-
6 files changed, 117 insertions(+), 24 deletions(-)
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..7d6cae8 100644
--- a/src/components/ProjectView.tsx
+++ b/src/components/ProjectView.tsx
@@ -197,6 +197,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;
@@ -2254,19 +2255,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 (
+ onRepoSelect(path)}
+ title={path}
+ >
+
+ {name}
+ {path}
+
+ );
+ })}
+
+
+ )}
)}
diff --git a/src/components/Titlebar.css b/src/components/Titlebar.css
index d4b0d1b..64a10ff 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;
diff --git a/src/components/Titlebar.tsx b/src/components/Titlebar.tsx
index f817bfd..cbb36b4 100644
--- a/src/components/Titlebar.tsx
+++ b/src/components/Titlebar.tsx
@@ -69,6 +69,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)
@@ -175,8 +177,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 +197,6 @@ export function Titlebar({
if (e.key === "Escape") { onSearchChange(""); e.currentTarget.blur(); }
}}
/>
- {!searchQuery && (
-
- {platform === "macos" ? "\u2318F" : "Ctrl+F"}
-
- )}
diff --git a/src/i18n/locales/en/projectView.json b/src/i18n/locales/en/projectView.json
index cbe1b6d..d0d1185 100644
--- a/src/i18n/locales/en/projectView.json
+++ b/src/i18n/locales/en/projectView.json
@@ -88,6 +88,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"
},
diff --git a/src/i18n/locales/en/titlebar.json b/src/i18n/locales/en/titlebar.json
index a3b9a70..8cbbf93 100644
--- a/src/i18n/locales/en/titlebar.json
+++ b/src/i18n/locales/en/titlebar.json
@@ -20,6 +20,7 @@
"labels": {
"copied": "Copied",
"noRepositoryOpen": "No repository open",
- "searchCommits": "Search commits..."
+ "searchCommits": "Search commits...",
+ "searchCommitsShortcut": "Search commits ({{shortcut}})"
}
}
From 43585231419318fc3add8e4be3f6051989880dee Mon Sep 17 00:00:00 2001
From: cst8t <1810150+cst8t@users.noreply.github.com>
Date: Mon, 25 May 2026 23:21:47 +0100
Subject: [PATCH 2/3] fix: respect mailmap in commit identities/names
---
src-tauri/src/git/cli.rs | 4 +-
src-tauri/src/git/gix_handler.rs | 34 ++++---
src-tauri/tests/git.rs | 168 +++++++++++++++++++++++++++++++
3 files changed, 189 insertions(+), 17 deletions(-)
diff --git a/src-tauri/src/git/cli.rs b/src-tauri/src/git/cli.rs
index 01e4457..56bd975 100644
--- a/src-tauri/src/git/cli.rs
+++ b/src-tauri/src/git/cli.rs
@@ -2032,7 +2032,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 +2192,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..e97f37c 100644
--- a/src-tauri/src/git/gix_handler.rs
+++ b/src-tauri/src/git/gix_handler.rs
@@ -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,
@@ -994,20 +1003,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/tests/git.rs b/src-tauri/tests/git.rs
index b0b8bd1..7112aa2 100644
--- a/src-tauri/tests/git.rs
+++ b/src-tauri/tests/git.rs
@@ -152,6 +152,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(),
@@ -696,6 +729,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 +901,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();
From 70c6f7785284d12e7ff306fb40cea0afc2e5879e Mon Sep 17 00:00:00 2001
From: cst8t <1810150+cst8t@users.noreply.github.com>
Date: Wed, 27 May 2026 23:11:44 +0100
Subject: [PATCH 3/3] feat: add patch import and export
---
src-tauri/capabilities/default.json | 1 +
src-tauri/gen/schemas/capabilities.json | 2 +-
src-tauri/src/commands/repo.rs | 63 ++++--
src-tauri/src/git/cli.rs | 260 +++++++++++++++++++++++-
src-tauri/src/git/gix_handler.rs | 37 +++-
src-tauri/src/git/handler.rs | 50 ++---
src-tauri/src/git/types.rs | 33 +++
src-tauri/src/lib.rs | 11 +-
src-tauri/tests/git.rs | 211 ++++++++++++++++++-
src/api/commands.ts | 14 ++
src/components/ProjectView.tsx | 118 ++++++++++-
src/components/Titlebar.css | 45 ++++
src/components/Titlebar.test.tsx | 55 +++++
src/components/Titlebar.tsx | 90 ++++++++
src/components/centre/CentrePanel.tsx | 8 +
src/components/centre/StagingView.tsx | 17 +-
src/components/icons.tsx | 2 +
src/i18n/locales/en/projectView.json | 16 ++
src/i18n/locales/en/titlebar.json | 10 +
src/types.ts | 16 ++
20 files changed, 995 insertions(+), 64 deletions(-)
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 56bd975..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(
diff --git a/src-tauri/src/git/gix_handler.rs b/src-tauri/src/git/gix_handler.rs
index e97f37c..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 {
@@ -946,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));
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
@@ -220,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 d0d1185..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"
@@ -126,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}}",
@@ -213,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.",
@@ -243,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 8cbbf93..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...",
@@ -22,5 +23,14 @@
"noRepositoryOpen": "No repository open",
"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;