From 2b679b5ba8f8d40b3ff3830cf0a5231a19e09f20 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Sat, 14 Mar 2026 21:17:26 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BB=A3=E7=A0=81=E5=86=B2?= =?UTF-8?q?=E7=AA=81=E8=A7=A3=E5=86=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands/folders.rs | 27 ++++++++++- src-tauri/src/commands/windows.rs | 56 +++++++++++++++++++++-- src-tauri/src/lib.rs | 8 ++++ src/app/merge/page.tsx | 2 + src/components/layout/branch-dropdown.tsx | 14 ++++++ src/components/layout/conflict-dialog.tsx | 23 +++++++++- src/components/merge/merge-workspace.tsx | 19 ++++++-- src/i18n/messages/ar.json | 1 + src/i18n/messages/de.json | 1 + src/i18n/messages/en.json | 1 + src/i18n/messages/es.json | 1 + src/i18n/messages/fr.json | 1 + src/i18n/messages/ja.json | 1 + src/i18n/messages/ko.json | 1 + src/i18n/messages/pt.json | 1 + src/i18n/messages/zh-CN.json | 1 + src/i18n/messages/zh-TW.json | 1 + src/lib/tauri.ts | 20 ++++++-- src/lib/types.ts | 1 + 19 files changed, 165 insertions(+), 15 deletions(-) diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 3d9c15d..26e2e31 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -38,6 +38,7 @@ pub struct GitConflictInfo { pub has_conflicts: bool, pub conflicted_files: Vec, pub operation: String, + pub upstream_commit: Option, } #[derive(Debug, Serialize)] @@ -553,6 +554,9 @@ pub async fn git_pull(path: String) -> Result { conflict: None, }); } + let upstream_commit = String::from_utf8_lossy(&upstream_check.stdout) + .trim() + .to_string(); // Step 3: check if we can fast-forward let merge_base = crate::process::tokio_command("git") @@ -609,6 +613,7 @@ pub async fn git_pull(path: String) -> Result { has_conflicts: true, conflicted_files, operation: "pull".to_string(), + upstream_commit: Some(upstream_commit), }), }); } @@ -649,10 +654,15 @@ pub async fn git_pull(path: String) -> Result { /// Start a merge with the upstream branch (used by merge workspace after pull conflict detection). /// This recreates the conflict state so that :1:, :2:, :3: stage entries are available. +/// If `upstream_commit` is provided, merge against that specific commit instead of `@{u}`. #[tauri::command] -pub async fn git_start_pull_merge(path: String) -> Result<(), AppCommandError> { +pub async fn git_start_pull_merge( + path: String, + upstream_commit: Option, +) -> Result<(), AppCommandError> { + let target = upstream_commit.as_deref().unwrap_or("@{u}"); let output = crate::process::tokio_command("git") - .args(["merge", "--no-commit", "@{u}"]) + .args(["merge", "--no-commit", target]) .current_dir(&path) .output() .await @@ -671,6 +681,17 @@ pub async fn git_start_pull_merge(path: String) -> Result<(), AppCommandError> { Ok(()) } +#[tauri::command] +pub async fn git_has_merge_head(path: String) -> Result { + let output = crate::process::tokio_command("git") + .args(["rev-parse", "--verify", "MERGE_HEAD"]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + Ok(output.status.success()) +} + #[tauri::command] pub async fn git_fetch(path: String) -> Result { let output = crate::process::tokio_command("git") @@ -1413,6 +1434,7 @@ pub async fn git_merge( has_conflicts: true, conflicted_files, operation: "merge".to_string(), + upstream_commit: None, }), }); } @@ -1445,6 +1467,7 @@ pub async fn git_rebase( has_conflicts: true, conflicted_files, operation: "rebase".to_string(), + upstream_commit: None, }), }); } diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index 53bc776..95249d2 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Mutex; -use tauri::{AppHandle, Manager, WebviewUrl, WebviewWindowBuilder}; +use tauri::{AppHandle, Emitter, Manager, WebviewUrl, WebviewWindowBuilder}; use crate::app_error::AppCommandError; use crate::db::AppDatabase; @@ -424,6 +424,7 @@ pub async fn open_merge_window( state: tauri::State<'_, MergeWindowState>, folder_id: i32, operation: String, + upstream_commit: Option, ) -> Result<(), AppCommandError> { let owner_label = window.label().to_string(); let label = format!("merge-{folder_id}"); @@ -450,9 +451,11 @@ pub async fn open_merge_window( .with_detail(format!("folder_id={folder_id}")) })?; - let url = WebviewUrl::App( - format!("merge?folderId={folder_id}&operation={operation}").into(), - ); + let mut url_str = format!("merge?folderId={folder_id}&operation={operation}"); + if let Some(ref commit) = upstream_commit { + url_str.push_str(&format!("&upstreamCommit={commit}")); + } + let url = WebviewUrl::App(url_str.into()); let builder = WebviewWindowBuilder::new(&app, &label, url) .title(format!("解决冲突 - {}", folder.name)) .inner_size(1400.0, 900.0) @@ -493,6 +496,51 @@ pub fn restore_window_after_merge( } } +/// Clean up dangling merge state when a merge window is closed without +/// completing or aborting. Checks if MERGE_HEAD exists, aborts the merge, +/// and notifies the parent window. +pub async fn cleanup_dangling_merge(app: &AppHandle, merge_window_label: &str) { + let folder_id: i32 = match merge_window_label + .strip_prefix("merge-") + .and_then(|s| s.parse().ok()) + { + Some(id) => id, + None => return, + }; + + let db = match app.try_state::() { + Some(db) => db, + None => return, + }; + + let folder = + match crate::db::service::folder_service::get_folder_by_id(&db.conn, folder_id).await { + Ok(Some(f)) => f, + _ => return, + }; + + // Check if MERGE_HEAD exists + let check = crate::process::tokio_command("git") + .args(["rev-parse", "--verify", "MERGE_HEAD"]) + .current_dir(&folder.path) + .output() + .await; + let has_merge_head = check.map(|o| o.status.success()).unwrap_or(false); + + if has_merge_head { + let _ = crate::process::tokio_command("git") + .args(["merge", "--abort"]) + .current_dir(&folder.path) + .output() + .await; + + let _ = app.emit( + "folder://merge-aborted", + serde_json::json!({ "folder_id": folder_id }), + ); + } +} + pub fn open_welcome_window(app: &AppHandle) -> Result<(), AppCommandError> { if let Some(existing) = app.get_webview_window("welcome") { ensure_windows_undecorated(&existing); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 274a63b..0601401 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -124,6 +124,13 @@ pub fn run() { if let Some(state) = app.try_state::() { windows::restore_window_after_merge(app, &state, &label); } + // Clean up dangling merge state (MERGE_HEAD) if window closed + // without completing or aborting the merge + let app_clone = window.app_handle().clone(); + let label_clone = label.clone(); + tauri::async_runtime::spawn(async move { + windows::cleanup_dangling_merge(&app_clone, &label_clone).await; + }); } if let tauri::WindowEvent::CloseRequested { .. } = event { @@ -195,6 +202,7 @@ pub fn run() { folders::git_init, folders::git_pull, folders::git_start_pull_merge, + folders::git_has_merge_head, folders::git_fetch, folders::git_push, folders::git_new_branch, diff --git a/src/app/merge/page.tsx b/src/app/merge/page.tsx index f262a1f..1d386cb 100644 --- a/src/app/merge/page.tsx +++ b/src/app/merge/page.tsx @@ -30,6 +30,7 @@ function MergePageInner() { const folderId = Number(searchParams.get("folderId") ?? "0") const operation = searchParams.get("operation") ?? "merge" + const upstreamCommit = searchParams.get("upstreamCommit") ?? undefined const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0 const hasValidFolderId = normalizedFolderId > 0 const loading = hasValidFolderId && state.loadedId !== normalizedFolderId @@ -104,6 +105,7 @@ function MergePageInner() { folderId={normalizedFolderId} folderPath={folder.path} operation={operation} + upstreamCommit={upstreamCommit} onCompleted={closeWindow} onAborted={closeWindow} /> diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index 211f37f..0c07165 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -81,6 +81,7 @@ import { openCommitWindow, setFolderParentBranch, gitListConflicts, + gitHasMergeHead, } from "@/lib/tauri" import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog" import { ConflictDialog } from "@/components/layout/conflict-dialog" @@ -292,6 +293,9 @@ export function BranchDropdown({ }) } + // Uses operation "merge" intentionally: MERGE_HEAD exists so merge state is + // already active. MergeWorkspace won't call gitStartPullMerge (only for "pull"), + // and ConflictDialog abort correctly runs git merge --abort. async function showMergeConflictDialog() { try { const remaining = await gitListConflicts(folderPath) @@ -310,6 +314,16 @@ export function BranchDropdown({ } async function handlePush() { + // Pre-check: if MERGE_HEAD exists, show conflict dialog immediately + try { + if (await gitHasMergeHead(folderPath)) { + await showMergeConflictDialog() + return + } + } catch { + // Pre-check failed, continue with normal push flow + } + const taskId = `git-${++taskSeq.current}-${Date.now()}` const label = t("tasks.pushCode") setLoading(true) diff --git a/src/components/layout/conflict-dialog.tsx b/src/components/layout/conflict-dialog.tsx index cc7f614..47268a3 100644 --- a/src/components/layout/conflict-dialog.tsx +++ b/src/components/layout/conflict-dialog.tsx @@ -44,6 +44,7 @@ export function ConflictDialog({ const [resolvedFiles, setResolvedFiles] = useState>(new Set()) const [aborting, setAborting] = useState(false) const [completing, setCompleting] = useState(false) + const [done, setDone] = useState(false) const open = conflictInfo !== null const operation = conflictInfo?.operation ?? "merge" @@ -53,6 +54,7 @@ export function ConflictDialog({ if (conflictInfo) { setConflictedFiles(conflictInfo.conflicted_files) setResolvedFiles(new Set()) + setDone(false) } }, [conflictInfo]) @@ -76,6 +78,7 @@ export function ConflictDialog({ let unlistenResolved: UnlistenFn | null = null let unlistenCompleted: UnlistenFn | null = null + let unlistenAborted: UnlistenFn | null = null listen<{ folder_id: number; file: string }>( "folder://merge-conflict-resolved", @@ -91,6 +94,7 @@ export function ConflictDialog({ listen<{ folder_id: number }>("folder://merge-completed", (event) => { if (event.payload.folder_id !== folderId) return + setDone(true) onResolved() onClose() }) @@ -99,12 +103,26 @@ export function ConflictDialog({ }) .catch(() => {}) + // Merge was aborted (user clicked abort in merge window, or window closed) + // Reset resolved state since abort reverts all changes + listen<{ folder_id: number }>("folder://merge-aborted", (event) => { + if (event.payload.folder_id !== folderId) return + setDone(true) + setResolvedFiles(new Set()) + onClose() + }) + .then((fn) => { + unlistenAborted = fn + }) + .catch(() => {}) + return () => { disposeTauriListener( unlistenResolved, "ConflictDialog.mergeConflictResolved" ) disposeTauriListener(unlistenCompleted, "ConflictDialog.mergeCompleted") + disposeTauriListener(unlistenAborted, "ConflictDialog.mergeAborted") } }, [open, folderId, onResolved, onClose]) @@ -122,7 +140,7 @@ export function ConflictDialog({ async function handleOpenMergeTool() { try { - await openMergeWindow(folderId, operation) + await openMergeWindow(folderId, operation, conflictInfo?.upstream_commit) } catch (err) { toast.error(String(err)) } @@ -149,6 +167,7 @@ export function ConflictDialog({ } async function handleComplete() { + if (done) return setCompleting(true) try { await gitContinueOperation(folderPath, operation) @@ -227,7 +246,7 @@ export function ConflictDialog({