diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index cc77696..432675c 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "Capability for the main window", - "windows": ["welcome", "folder-*", "commit-*", "settings"], + "windows": ["welcome", "folder-*", "commit-*", "merge-*", "settings"], "permissions": [ "core:default", "core:window:default", diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index f2ffddd..3d9c15d 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -33,9 +33,17 @@ pub struct GitBranchList { pub worktree_branches: Vec, } +#[derive(Debug, Serialize)] +pub struct GitConflictInfo { + pub has_conflicts: bool, + pub conflicted_files: Vec, + pub operation: String, +} + #[derive(Debug, Serialize)] pub struct GitPullResult { pub updated_files: usize, + pub conflict: Option, } #[derive(Debug, Serialize)] @@ -47,6 +55,21 @@ pub struct GitPushResult { #[derive(Debug, Serialize)] pub struct GitMergeResult { pub merged_commits: usize, + pub conflict: Option, +} + +#[derive(Debug, Serialize)] +pub struct GitRebaseResult { + pub message: String, + pub conflict: Option, +} + +#[derive(Debug, Serialize)] +pub struct GitConflictFileVersions { + pub base: String, + pub ours: String, + pub theirs: String, + pub merged: String, } #[derive(Debug, Serialize)] @@ -160,6 +183,25 @@ fn git_command_error(operation: &str, stderr: &[u8]) -> AppCommandError { AppCommandError::external_command(format!("git {operation} failed"), stderr) } +async fn detect_conflicts(path: &str) -> Result, AppCommandError> { + let output = crate::process::tokio_command("git") + .args(["diff", "--name-only", "--diff-filter=U"]) + .current_dir(path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !output.status.success() { + return Ok(vec![]); + } + + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect()) +} + async fn get_head_hash(path: &str) -> Result, AppCommandError> { let output = crate::process::tokio_command("git") .args(["rev-parse", "HEAD"]) @@ -484,15 +526,110 @@ pub async fn git_init(path: String) -> Result<(), AppCommandError> { pub async fn git_pull(path: String) -> Result { let head_before = get_head_hash(&path).await?; - let output = crate::process::tokio_command("git") - .args(["pull"]) + // Step 1: fetch from remote + let fetch_output = crate::process::tokio_command("git") + .args(["fetch"]) .current_dir(&path) .output() .await .map_err(AppCommandError::io)?; - if !output.status.success() { - return Err(git_command_error("pull", &output.stderr)); + if !fetch_output.status.success() { + return Err(git_command_error("fetch", &fetch_output.stderr)); + } + + // Step 2: check if upstream exists + let upstream_check = crate::process::tokio_command("git") + .args(["rev-parse", "@{u}"]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !upstream_check.status.success() { + // No upstream configured, nothing to merge + return Ok(GitPullResult { + updated_files: 0, + conflict: None, + }); + } + + // Step 3: check if we can fast-forward + let merge_base = crate::process::tokio_command("git") + .args(["merge-base", "HEAD", "@{u}"]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + let head_hash = crate::process::tokio_command("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + let base_hash = String::from_utf8_lossy(&merge_base.stdout).trim().to_string(); + let current_head = String::from_utf8_lossy(&head_hash.stdout).trim().to_string(); + + if base_hash == current_head { + // Can fast-forward — just do it + let ff_output = crate::process::tokio_command("git") + .args(["merge", "--ff-only", "@{u}"]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !ff_output.status.success() { + return Err(git_command_error("merge --ff-only", &ff_output.stderr)); + } + } else { + // Non-fast-forward: try merge with --no-commit to detect conflicts + let merge_output = crate::process::tokio_command("git") + .args(["merge", "--no-commit", "@{u}"]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !merge_output.status.success() { + // Check for conflicts + let conflicted_files = detect_conflicts(&path).await?; + if !conflicted_files.is_empty() { + // Abort merge to restore working tree + let _ = crate::process::tokio_command("git") + .args(["merge", "--abort"]) + .current_dir(&path) + .output() + .await; + + return Ok(GitPullResult { + updated_files: 0, + conflict: Some(GitConflictInfo { + has_conflicts: true, + conflicted_files, + operation: "pull".to_string(), + }), + }); + } + return Err(git_command_error("merge", &merge_output.stderr)); + } + + // Merge succeeded without conflicts — commit + let commit_output = crate::process::tokio_command("git") + .args(["commit", "--no-edit"]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !commit_output.status.success() { + let stderr = String::from_utf8_lossy(&commit_output.stderr); + let stdout = String::from_utf8_lossy(&commit_output.stdout); + if !stderr.contains("nothing to commit") && !stdout.contains("nothing to commit") { + return Err(git_command_error("commit", &commit_output.stderr)); + } + } } let head_after = get_head_hash(&path).await?; @@ -504,7 +641,34 @@ pub async fn git_pull(path: String) -> Result { _ => 0, }; - Ok(GitPullResult { updated_files }) + Ok(GitPullResult { + updated_files, + conflict: None, + }) +} + +/// 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. +#[tauri::command] +pub async fn git_start_pull_merge(path: String) -> Result<(), AppCommandError> { + let output = crate::process::tokio_command("git") + .args(["merge", "--no-commit", "@{u}"]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + // It's expected to fail with conflicts — that's the point. + // We just need the merge state to be active so stage entries exist. + if !output.status.success() { + let conflicted_files = detect_conflicts(&path).await?; + if !conflicted_files.is_empty() { + return Ok(()); // Conflict state is now active — merge workspace can proceed + } + return Err(git_command_error("merge", &output.stderr)); + } + + Ok(()) } #[tauri::command] @@ -1241,13 +1405,30 @@ pub async fn git_merge( .map_err(AppCommandError::io)?; if !output.status.success() { + let conflicted_files = detect_conflicts(&path).await?; + if !conflicted_files.is_empty() { + return Ok(GitMergeResult { + merged_commits, + conflict: Some(GitConflictInfo { + has_conflicts: true, + conflicted_files, + operation: "merge".to_string(), + }), + }); + } return Err(git_command_error("merge", &output.stderr)); } - Ok(GitMergeResult { merged_commits }) + Ok(GitMergeResult { + merged_commits, + conflict: None, + }) } #[tauri::command] -pub async fn git_rebase(path: String, branch_name: String) -> Result { +pub async fn git_rebase( + path: String, + branch_name: String, +) -> Result { let output = crate::process::tokio_command("git") .args(["rebase", &branch_name]) .current_dir(&path) @@ -1256,9 +1437,23 @@ pub async fn git_rebase(path: String, branch_name: String) -> Result Result, AppCommandError> { + detect_conflicts(&path).await +} + +#[tauri::command] +pub async fn git_conflict_file_versions( + path: String, + file: String, +) -> Result { + // :1: = base (common ancestor), :2: = ours (HEAD), :3: = theirs (incoming) + let mut versions = Vec::with_capacity(3); + for stage in ["1", "2", "3"] { + let file_spec = format!(":{}:{}", stage, file); + let output = crate::process::tokio_command("git") + .args(["show", &file_spec]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !output.status.success() { + // File may not exist at this stage (e.g. newly added on one side) + versions.push(String::new()); + } else { + let bytes = &output.stdout; + if bytes.iter().take(2048).any(|b| *b == 0) { + return Err( + AppCommandError::invalid_input("Binary files are not supported") + .with_detail(file_spec), + ); + } + versions.push(String::from_utf8_lossy(bytes).to_string()); + } + } + + // Read the working tree file (contains conflict markers) + let file_path = Path::new(&path).join(&file); + let merged = std::fs::read_to_string(&file_path).unwrap_or_default(); + + Ok(GitConflictFileVersions { + base: versions.remove(0), + ours: versions.remove(0), + theirs: versions.remove(0), + merged, + }) +} + +#[tauri::command] +pub async fn git_resolve_conflict( + path: String, + file: String, + content: String, +) -> Result<(), AppCommandError> { + let file_path = Path::new(&path).join(&file); + + // Write resolved content + std::fs::write(&file_path, content).map_err(|e| { + AppCommandError::io_error(format!("Failed to write resolved file: {}", e)) + })?; + + // Stage the resolved file + let output = crate::process::tokio_command("git") + .args(["add", &file]) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !output.status.success() { + return Err(git_command_error("add", &output.stderr)); + } + + Ok(()) +} + +#[tauri::command] +pub async fn git_abort_operation( + path: String, + operation: String, +) -> Result<(), AppCommandError> { + let args = match operation.as_str() { + "merge" | "pull" => vec!["merge", "--abort"], + "rebase" => vec!["rebase", "--abort"], + _ => { + return Err(AppCommandError::invalid_input(format!( + "Unknown operation: {operation}" + ))); + } + }; + + let output = crate::process::tokio_command("git") + .args(&args) + .current_dir(&path) + .output() + .await + .map_err(AppCommandError::io)?; + + if !output.status.success() { + return Err(git_command_error( + &format!("{} --abort", operation), + &output.stderr, + )); + } + Ok(()) +} + +#[tauri::command] +pub async fn git_continue_operation( + path: String, + operation: String, +) -> Result<(), AppCommandError> { + let (program, args): (&str, Vec<&str>) = match operation.as_str() { + "merge" | "pull" => ("git", vec!["commit", "--no-edit"]), + "rebase" => ("git", vec!["rebase", "--continue"]), + _ => { + return Err(AppCommandError::invalid_input(format!( + "Unknown operation: {operation}" + ))); + } + }; + + let output = crate::process::tokio_command(program) + .args(&args) + .current_dir(&path) + .env("GIT_EDITOR", "true") + .output() + .await + .map_err(AppCommandError::io)?; + + if !output.status.success() { + return Err(git_command_error( + &format!("{} --continue", operation), + &output.stderr, + )); + } + Ok(()) +} + const WATCH_IGNORED_DIRS: &[&str] = &["__pycache__"]; const FILE_TREE_IGNORED_DIRS: &[&str] = &[".git", "__pycache__"]; diff --git a/src-tauri/src/commands/windows.rs b/src-tauri/src/commands/windows.rs index a1144d8..53bc776 100644 --- a/src-tauri/src/commands/windows.rs +++ b/src-tauri/src/commands/windows.rs @@ -391,6 +391,108 @@ pub fn restore_window_after_commit( } } +pub struct MergeWindowState { + owner_by_merge_label: Mutex>, +} + +impl MergeWindowState { + pub fn new() -> Self { + Self { + owner_by_merge_label: Mutex::new(HashMap::new()), + } + } + + fn set_owner(&self, merge_label: String, owner_label: String) { + if let Ok(mut owners) = self.owner_by_merge_label.lock() { + owners.insert(merge_label, owner_label); + } + } + + fn take_owner(&self, merge_label: &str) -> Option { + self.owner_by_merge_label + .lock() + .ok() + .and_then(|mut owners| owners.remove(merge_label)) + } +} + +#[tauri::command] +pub async fn open_merge_window( + app: AppHandle, + window: tauri::WebviewWindow, + db: tauri::State<'_, AppDatabase>, + state: tauri::State<'_, MergeWindowState>, + folder_id: i32, + operation: String, +) -> Result<(), AppCommandError> { + let owner_label = window.label().to_string(); + let label = format!("merge-{folder_id}"); + + if let Some(existing) = app.get_webview_window(&label) { + if let Some(owner_window) = app.get_webview_window(&owner_label) { + owner_window.set_enabled(false).map_err(|e| { + AppCommandError::window("Failed to disable owner window", e.to_string()) + })?; + } + state.set_owner(label.clone(), owner_label); + let _ = existing.unminimize(); + existing + .set_focus() + .map_err(|e| AppCommandError::window("Failed to focus merge window", e.to_string()))?; + return Ok(()); + } + + let folder = crate::db::service::folder_service::get_folder_by_id(&db.conn, folder_id) + .await + .map_err(AppCommandError::from)? + .ok_or_else(|| { + AppCommandError::not_found(format!("Folder {folder_id} not found")) + .with_detail(format!("folder_id={folder_id}")) + })?; + + let url = WebviewUrl::App( + format!("merge?folderId={folder_id}&operation={operation}").into(), + ); + let builder = WebviewWindowBuilder::new(&app, &label, url) + .title(format!("解决冲突 - {}", folder.name)) + .inner_size(1400.0, 900.0) + .min_inner_size(1100.0, 650.0) + .always_on_top(true) + .center(); + let merge_window = apply_platform_window_style(builder) + .build() + .map_err(|e| AppCommandError::window("Failed to open merge window", e.to_string()))?; + ensure_windows_undecorated(&merge_window); + if let Some(owner_window) = app.get_webview_window(&owner_label) { + if let Err(err) = owner_window.set_enabled(false) { + let _ = merge_window.close(); + return Err(AppCommandError::window( + "Failed to disable owner window", + err.to_string(), + )); + } + } + state.set_owner(label, owner_label); + merge_window + .set_focus() + .map_err(|e| AppCommandError::window("Failed to focus merge window", e.to_string()))?; + + Ok(()) +} + +pub fn restore_window_after_merge( + app: &AppHandle, + state: &MergeWindowState, + merge_window_label: &str, +) { + if let Some(owner_label) = state.take_owner(merge_window_label) { + if let Some(window) = app.get_webview_window(&owner_label) { + let _ = window.set_enabled(true); + let _ = window.set_focus(); + } + } +} + 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 ed16474..274a63b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -42,6 +42,7 @@ pub fn run() { .manage(TerminalManager::new()) .manage(windows::SettingsWindowState::new()) .manage(windows::CommitWindowState::new()) + .manage(windows::MergeWindowState::new()) .setup(|app| { let app_data_dir = app.path().app_data_dir()?; let app_version = env!("CARGO_PKG_VERSION"); @@ -113,6 +114,18 @@ pub fn run() { } } + if label.starts_with("merge-") + && matches!( + event, + tauri::WindowEvent::CloseRequested { .. } | tauri::WindowEvent::Destroyed + ) + { + let app = window.app_handle(); + if let Some(state) = app.try_state::() { + windows::restore_window_after_merge(app, &state, &label); + } + } + if let tauri::WindowEvent::CloseRequested { .. } = event { if label.starts_with("folder-") { let app = window.app_handle(); @@ -181,6 +194,7 @@ pub fn run() { folders::get_git_branch, folders::git_init, folders::git_pull, + folders::git_start_pull_merge, folders::git_fetch, folders::git_push, folders::git_new_branch, @@ -207,6 +221,11 @@ pub fn run() { folders::git_merge, folders::git_rebase, folders::git_delete_branch, + folders::git_list_conflicts, + folders::git_conflict_file_versions, + folders::git_resolve_conflict, + folders::git_abort_operation, + folders::git_continue_operation, folders::save_folder_opened_conversations, folders::start_file_tree_watch, folders::stop_file_tree_watch, @@ -226,6 +245,7 @@ pub fn run() { windows::open_settings_window, windows::list_open_folders, windows::focus_folder_window, + windows::open_merge_window, system_settings::get_system_proxy_settings, system_settings::update_system_proxy_settings, system_settings::get_system_language_settings, diff --git a/src/app/globals.css b/src/app/globals.css index 143401e..55ce2c9 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -347,3 +347,173 @@ background-color: rgba(248, 81, 73, 0.2); } } + +/* Merge editor: hunk type decorations (IDEA-style) */ + +/* Left pane (ours) & Right pane (theirs) — diff highlights */ +.monaco-editor .merge-hunk-added-bg { + background-color: rgba(46, 160, 67, 0.12); +} + +.monaco-editor .merge-hunk-modified-bg { + background-color: rgba(31, 111, 235, 0.12); +} + +.monaco-editor .merge-hunk-removed-bg { + background-color: rgba(128, 128, 128, 0.15); +} + +/* Center pane — conflict regions */ +.monaco-editor .merge-hunk-conflict-bg { + background-color: rgba(248, 81, 73, 0.12); +} + +/* Center pane — applied hunk */ +.monaco-editor .merge-hunk-applied-bg { + background-color: rgba(46, 160, 67, 0.08); +} + +/* Center pane — pending non-conflict hunk */ +.monaco-editor .merge-hunk-pending-bg { + background-color: rgba(234, 179, 8, 0.06); +} + +/* Dark mode overrides */ +.dark .monaco-editor .merge-hunk-added-bg { + background-color: rgba(63, 185, 80, 0.18); +} + +.dark .monaco-editor .merge-hunk-modified-bg { + background-color: rgba(56, 139, 253, 0.18); +} + +.dark .monaco-editor .merge-hunk-removed-bg { + background-color: rgba(128, 128, 128, 0.2); +} + +.dark .monaco-editor .merge-hunk-conflict-bg { + background-color: rgba(248, 81, 73, 0.18); +} + +.dark .monaco-editor .merge-hunk-applied-bg { + background-color: rgba(63, 185, 80, 0.1); +} + +.dark .monaco-editor .merge-hunk-pending-bg { + background-color: rgba(234, 179, 8, 0.08); +} + +@media (prefers-color-scheme: dark) { + :root:not(.light) .monaco-editor .merge-hunk-added-bg { + background-color: rgba(63, 185, 80, 0.18); + } + + :root:not(.light) .monaco-editor .merge-hunk-modified-bg { + background-color: rgba(56, 139, 253, 0.18); + } + + :root:not(.light) .monaco-editor .merge-hunk-removed-bg { + background-color: rgba(128, 128, 128, 0.2); + } + + :root:not(.light) .monaco-editor .merge-hunk-conflict-bg { + background-color: rgba(248, 81, 73, 0.18); + } + + :root:not(.light) .monaco-editor .merge-hunk-applied-bg { + background-color: rgba(63, 185, 80, 0.1); + } + + :root:not(.light) .monaco-editor .merge-hunk-pending-bg { + background-color: rgba(234, 179, 8, 0.08); + } +} + +/* Merge arrow gutter columns */ +.merge-gutter-column { + position: relative; + width: 24px; + min-width: 24px; + height: 100%; + overflow: hidden; + background: var(--background); +} + +.merge-gutter-arrow-btn { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 18px; + border-radius: 3px; + border: none; + cursor: pointer; + font-size: 13px; + font-weight: 600; + line-height: 1; + opacity: 0.75; + transition: opacity 0.15s, background-color 0.15s; +} + +.merge-gutter-arrow-btn:hover { + opacity: 1; +} + +.merge-gutter-arrow-accept { + background-color: rgba(46, 160, 67, 0.25); + color: #2ea043; +} + +.merge-gutter-arrow-accept:hover { + background-color: rgba(46, 160, 67, 0.45); +} + +.merge-gutter-arrow-conflict { + background-color: rgba(248, 81, 73, 0.2); + color: #f85149; +} + +.merge-gutter-arrow-conflict:hover { + background-color: rgba(248, 81, 73, 0.4); +} + +.dark .merge-gutter-arrow-accept { + background-color: rgba(63, 185, 80, 0.2); + color: #3fb950; +} + +.dark .merge-gutter-arrow-accept:hover { + background-color: rgba(63, 185, 80, 0.4); +} + +.dark .merge-gutter-arrow-conflict { + background-color: rgba(248, 81, 73, 0.15); + color: #f85149; +} + +.dark .merge-gutter-arrow-conflict:hover { + background-color: rgba(248, 81, 73, 0.35); +} + +@media (prefers-color-scheme: dark) { + :root:not(.light) .merge-gutter-arrow-accept { + background-color: rgba(63, 185, 80, 0.2); + color: #3fb950; + } + + :root:not(.light) .merge-gutter-arrow-accept:hover { + background-color: rgba(63, 185, 80, 0.4); + } + + :root:not(.light) .merge-gutter-arrow-conflict { + background-color: rgba(248, 81, 73, 0.15); + color: #f85149; + } + + :root:not(.light) .merge-gutter-arrow-conflict:hover { + background-color: rgba(248, 81, 73, 0.35); + } +} diff --git a/src/app/merge/page.tsx b/src/app/merge/page.tsx new file mode 100644 index 0000000..f262a1f --- /dev/null +++ b/src/app/merge/page.tsx @@ -0,0 +1,128 @@ +"use client" + +import { Suspense, useCallback, useEffect, useState } from "react" +import { useSearchParams } from "next/navigation" +import { useTranslations } from "next-intl" +import { getCurrentWindow } from "@tauri-apps/api/window" +import { Loader2 } from "lucide-react" +import { MergeWorkspace } from "@/components/merge/merge-workspace" +import { AppTitleBar } from "@/components/layout/app-title-bar" +import { AppToaster } from "@/components/ui/app-toaster" +import { getFolder } from "@/lib/tauri" +import type { FolderDetail } from "@/lib/types" + +const TOAST_DURATION_MS = 6000 + +interface FolderLoadState { + loadedId: number | null + folder: FolderDetail | null + error: string | null +} + +function MergePageInner() { + const t = useTranslations("MergePage") + const searchParams = useSearchParams() + const [state, setState] = useState({ + loadedId: null, + folder: null, + error: null, + }) + + const folderId = Number(searchParams.get("folderId") ?? "0") + const operation = searchParams.get("operation") ?? "merge" + const normalizedFolderId = Number.isFinite(folderId) ? folderId : 0 + const hasValidFolderId = normalizedFolderId > 0 + const loading = hasValidFolderId && state.loadedId !== normalizedFolderId + const folder = state.loadedId === normalizedFolderId ? state.folder : null + const error = state.loadedId === normalizedFolderId ? state.error : null + + const closeWindow = useCallback(() => { + getCurrentWindow() + .close() + .catch((err) => { + console.error("[MergePage] failed to close window:", err) + }) + }, []) + + useEffect(() => { + if (!hasValidFolderId) return + + let cancelled = false + + getFolder(normalizedFolderId) + .then((detail) => { + if (!cancelled) { + setState({ + loadedId: normalizedFolderId, + folder: detail, + error: null, + }) + } + }) + .catch((err) => { + if (!cancelled) { + setState({ + loadedId: normalizedFolderId, + folder: null, + error: String(err), + }) + } + }) + + return () => { + cancelled = true + } + }, [hasValidFolderId, normalizedFolderId]) + + return ( +
+ + {t("title")} + {hasValidFolderId && folder ? ` · ${folder.name}` : ""} +
+ } + /> + +
+ {!hasValidFolderId ? ( +
+ {t("invalidFolderId")} +
+ ) : loading ? ( +
+ + {t("loadingRepo")} +
+ ) : error ? ( +
+ {error} +
+ ) : folder ? ( + + ) : null} +
+ + + + ) +} + +export default function MergePage() { + return ( + + + + ) +} diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx index 1b262d8..211f37f 100644 --- a/src/components/layout/branch-dropdown.tsx +++ b/src/components/layout/branch-dropdown.tsx @@ -80,10 +80,13 @@ import { openFolderWindow, openCommitWindow, setFolderParentBranch, + gitListConflicts, } from "@/lib/tauri" import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog" +import { ConflictDialog } from "@/components/layout/conflict-dialog" import { disposeTauriListener } from "@/lib/tauri-listener" -import type { GitBranchList } from "@/lib/types" +import { toErrorMessage } from "@/lib/app-error" +import type { GitBranchList, GitConflictInfo } from "@/lib/types" import { toast } from "sonner" import { useFolderContext } from "@/contexts/folder-context" import { useTaskContext } from "@/contexts/task-context" @@ -134,6 +137,7 @@ export function BranchDropdown({ const [worktreeBranchName, setWorktreeBranchName] = useState("") const [worktreePath, setWorktreePath] = useState("") const [manageRemotesOpen, setManageRemotesOpen] = useState(false) + const [conflictInfo, setConflictInfo] = useState(null) const taskSeq = useRef(0) const worktreeBranchSet = useMemo( () => new Set(branchList.worktree_branches), @@ -184,7 +188,7 @@ export function BranchDropdown({ async function runGitTask( label: string, action: () => Promise, - getSuccessDescription?: (result: T) => string | undefined + getSuccessDescription?: (result: T) => string | false | undefined ) { const taskId = `git-${++taskSeq.current}-${Date.now()}` setLoading(true) @@ -195,19 +199,22 @@ export function BranchDropdown({ const successDescription = getSuccessDescription?.(result) updateTask(taskId, { status: "completed" }) onBranchChange() - toast.success( - t("toasts.taskCompleted", { label }), - successDescription - ? { - description: successDescription, - } - : undefined - ) + if (successDescription !== false) { + toast.success( + t("toasts.taskCompleted", { label }), + successDescription + ? { + description: successDescription, + } + : undefined + ) + } } catch (err) { removeTask(taskId) const errorTitle = t("toasts.taskFailed", { label }) - pushAlert("error", errorTitle, String(err)) - toast.error(errorTitle, { description: String(err) }) + const errorMsg = toErrorMessage(err) + pushAlert("error", errorTitle, errorMsg) + toast.error(errorTitle, { description: errorMsg }) } finally { setLoading(false) } @@ -285,6 +292,117 @@ export function BranchDropdown({ }) } + async function showMergeConflictDialog() { + try { + const remaining = await gitListConflicts(folderPath) + setConflictInfo({ + has_conflicts: true, + conflicted_files: remaining, + operation: "merge", + }) + } catch { + setConflictInfo({ + has_conflicts: true, + conflicted_files: [], + operation: "merge", + }) + } + } + + async function handlePush() { + const taskId = `git-${++taskSeq.current}-${Date.now()}` + const label = t("tasks.pushCode") + setLoading(true) + addTask(taskId, label) + updateTask(taskId, { status: "running" }) + try { + const result = await gitPush(folderPath) + updateTask(taskId, { status: "completed" }) + onBranchChange() + let description: string | undefined + if (result.upstream_set) { + description = + result.pushed_commits === 0 + ? t("toasts.upstreamSet") + : t("toasts.upstreamSetAndPushed", { + count: result.pushed_commits, + }) + } else if (result.pushed_commits === 0) { + description = t("toasts.noCommitsToPush") + } else { + description = t("toasts.pushedCommits", { + count: result.pushed_commits, + }) + } + toast.success(t("toasts.taskCompleted", { label }), { + description, + }) + } catch (err) { + const errorMsg = toErrorMessage(err) + if (/MERGE_HEAD|unfinished merge/i.test(errorMsg)) { + // Unfinished merge — show conflict dialog + removeTask(taskId) + await showMergeConflictDialog() + } else if (/rejected|fetch first/i.test(errorMsg)) { + // Remote has new commits — auto-pull then retry push + updateTask(taskId, { + status: "running", + label: t("tasks.pullCode"), + }) + try { + const pullResult = await gitPull(folderPath) + if (pullResult.conflict?.has_conflicts) { + removeTask(taskId) + onBranchChange() + setConflictInfo(pullResult.conflict) + } else { + // Pull succeeded, retry push + updateTask(taskId, { status: "running", label }) + const pushResult = await gitPush(folderPath) + updateTask(taskId, { status: "completed" }) + onBranchChange() + let description: string | undefined + if (pushResult.upstream_set) { + description = + pushResult.pushed_commits === 0 + ? t("toasts.upstreamSet") + : t("toasts.upstreamSetAndPushed", { + count: pushResult.pushed_commits, + }) + } else if (pushResult.pushed_commits === 0) { + description = t("toasts.noCommitsToPush") + } else { + description = t("toasts.pushedCommits", { + count: pushResult.pushed_commits, + }) + } + toast.success(t("toasts.taskCompleted", { label }), { + description, + }) + } + } catch (pullErr) { + const pullErrMsg = toErrorMessage(pullErr) + if (/MERGE_HEAD|unfinished merge/i.test(pullErrMsg)) { + removeTask(taskId) + await showMergeConflictDialog() + } else { + removeTask(taskId) + const pullErrTitle = t("toasts.taskFailed", { label }) + pushAlert("error", pullErrTitle, pullErrMsg) + toast.error(pullErrTitle, { description: pullErrMsg }) + } + } + } else { + removeTask(taskId) + const errorTitle = t("toasts.taskFailed", { label }) + pushAlert("error", errorTitle, errorMsg) + toast.error(errorTitle, { description: errorMsg }) + } + } finally { + setLoading(false) + } + } + function handleMergeParent() { if (!parentBranch) return setConfirmAction({ type: "merge", branchName: parentBranch }) @@ -316,6 +434,10 @@ export function BranchDropdown({ t("tasks.mergeBranch", { branchName }), () => gitMerge(folderPath, branchName), (result) => { + if (result.conflict?.has_conflicts) { + setConflictInfo(result.conflict) + return false + } if (result.merged_commits === 0) { return t("toasts.mergeNoNewCommits", { branchName }) } @@ -324,8 +446,16 @@ export function BranchDropdown({ ) break case "rebase": - await runGitTask(t("tasks.rebaseTo", { branchName }), () => - gitRebase(folderPath, branchName) + await runGitTask( + t("tasks.rebaseTo", { branchName }), + () => gitRebase(folderPath, branchName), + (result) => { + if (result.conflict?.has_conflicts) { + setConflictInfo(result.conflict) + return false + } + return undefined + } ) break case "delete": @@ -520,6 +650,10 @@ export function BranchDropdown({ t("tasks.pullCode"), () => gitPull(folderPath), (result) => { + if (result.conflict?.has_conflicts) { + setConflictInfo(result.conflict) + return false + } if (result.updated_files === 0) { return t("toasts.allFilesUpToDate") } @@ -552,39 +686,16 @@ export function BranchDropdown({ setDropdownOpen(false) openCommitWindow(folder.id).catch((err) => { const title = t("toasts.openCommitWindowFailed") - pushAlert("error", title, String(err)) - toast.error(title, { description: String(err) }) + const msg = toErrorMessage(err) + pushAlert("error", title, msg) + toast.error(title, { description: msg }) }) }} > {t("openCommitWindow")} - - runGitTask( - t("tasks.pushCode"), - () => gitPush(folderPath), - (result) => { - if (result.upstream_set) { - if (result.pushed_commits === 0) { - return t("toasts.upstreamSet") - } - return t("toasts.upstreamSetAndPushed", { - count: result.pushed_commits, - }) - } - if (result.pushed_commits === 0) { - return t("toasts.noCommitsToPush") - } - return t("toasts.pushedCommits", { - count: result.pushed_commits, - }) - } - ) - } - > + {t("pushCode")} @@ -846,6 +957,14 @@ export function BranchDropdown({ folderPath={folderPath} onSaved={() => loadAllBranches()} /> + + setConflictInfo(null)} + onResolved={onBranchChange} + /> ) } diff --git a/src/components/layout/conflict-dialog.tsx b/src/components/layout/conflict-dialog.tsx new file mode 100644 index 0000000..cc7f614 --- /dev/null +++ b/src/components/layout/conflict-dialog.tsx @@ -0,0 +1,243 @@ +"use client" + +import { useCallback, useEffect, useState } from "react" +import { listen, type UnlistenFn } from "@tauri-apps/api/event" +import { AlertTriangle, Check, FileWarning, Loader2 } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + gitListConflicts, + gitAbortOperation, + gitContinueOperation, + openMergeWindow, +} from "@/lib/tauri" +import { disposeTauriListener } from "@/lib/tauri-listener" +import type { GitConflictInfo } from "@/lib/types" + +interface ConflictDialogProps { + conflictInfo: GitConflictInfo | null + folderId: number + folderPath: string + onClose: () => void + onResolved: () => void +} + +export function ConflictDialog({ + conflictInfo, + folderId, + folderPath, + onClose, + onResolved, +}: ConflictDialogProps) { + const t = useTranslations("Folder.branchDropdown.conflict") + const [conflictedFiles, setConflictedFiles] = useState([]) + const [resolvedFiles, setResolvedFiles] = useState>(new Set()) + const [aborting, setAborting] = useState(false) + const [completing, setCompleting] = useState(false) + + const open = conflictInfo !== null + const operation = conflictInfo?.operation ?? "merge" + + // Initialize conflict files from conflictInfo + useEffect(() => { + if (conflictInfo) { + setConflictedFiles(conflictInfo.conflicted_files) + setResolvedFiles(new Set()) + } + }, [conflictInfo]) + + // Refresh conflict list to detect resolved files + const refreshConflicts = useCallback(async () => { + if (!folderPath || !open) return + try { + const remaining = await gitListConflicts(folderPath) + const nowResolved = new Set( + conflictedFiles.filter((f) => !remaining.includes(f)) + ) + setResolvedFiles(nowResolved) + } catch { + // ignore refresh errors + } + }, [folderPath, open, conflictedFiles]) + + // Listen for merge events from the merge window + useEffect(() => { + if (!open) return + + let unlistenResolved: UnlistenFn | null = null + let unlistenCompleted: UnlistenFn | null = null + + listen<{ folder_id: number; file: string }>( + "folder://merge-conflict-resolved", + (event) => { + if (event.payload.folder_id !== folderId) return + setResolvedFiles((prev) => new Set([...prev, event.payload.file])) + } + ) + .then((fn) => { + unlistenResolved = fn + }) + .catch(() => {}) + + listen<{ folder_id: number }>("folder://merge-completed", (event) => { + if (event.payload.folder_id !== folderId) return + onResolved() + onClose() + }) + .then((fn) => { + unlistenCompleted = fn + }) + .catch(() => {}) + + return () => { + disposeTauriListener( + unlistenResolved, + "ConflictDialog.mergeConflictResolved" + ) + disposeTauriListener(unlistenCompleted, "ConflictDialog.mergeCompleted") + } + }, [open, folderId, onResolved, onClose]) + + // Periodically refresh conflict status (skip for pull — merge is aborted + // until the merge tool re-starts it, so git index has no conflicts yet) + useEffect(() => { + if (!open || operation === "pull") return + const interval = setInterval(refreshConflicts, 3000) + return () => clearInterval(interval) + }, [open, operation, refreshConflicts]) + + const allResolved = + conflictedFiles.length > 0 && + conflictedFiles.every((f) => resolvedFiles.has(f)) + + async function handleOpenMergeTool() { + try { + await openMergeWindow(folderId, operation) + } catch (err) { + toast.error(String(err)) + } + } + + async function handleAbort() { + // For pull operations, the merge was already aborted during conflict + // detection, so there's nothing to abort — just close the dialog. + if (operation === "pull") { + onClose() + return + } + setAborting(true) + try { + await gitAbortOperation(folderPath, operation) + toast.success(t("abortSuccess")) + onClose() + onResolved() + } catch (err) { + toast.error(String(err)) + } finally { + setAborting(false) + } + } + + async function handleComplete() { + setCompleting(true) + try { + await gitContinueOperation(folderPath, operation) + toast.success(t("completeSuccess")) + onResolved() + onClose() + } catch (err) { + toast.error(String(err)) + } finally { + setCompleting(false) + } + } + + return ( + !v && onClose()}> + + + + + {t("title")} + + {t("description")} + + + +
+ {conflictedFiles.map((file) => { + const isResolved = resolvedFiles.has(file) + return ( +
+ {isResolved ? ( + + ) : ( + + )} + + {file} + +
+ ) + })} +
+
+ + + +
+ + {allResolved && ( + + )} +
+
+
+
+ ) +} diff --git a/src/components/merge/conflict-parser.ts b/src/components/merge/conflict-parser.ts new file mode 100644 index 0000000..75ef836 --- /dev/null +++ b/src/components/merge/conflict-parser.ts @@ -0,0 +1,102 @@ +export interface ConflictRegion { + /** Line number (1-based) of <<<<<<< marker */ + startLine: number + /** Line number (1-based) of ======= marker */ + separatorLine: number + /** Line number (1-based) of >>>>>>> marker */ + endLine: number + /** Content from the ours (local/HEAD) side */ + oursContent: string + /** Content from the theirs (remote/incoming) side */ + theirsContent: string +} + +/** + * Parse git conflict markers from file content. + * Returns an array of conflict regions sorted by line number. + */ +export function parseConflictMarkers(content: string): ConflictRegion[] { + const lines = content.split("\n") + const regions: ConflictRegion[] = [] + + let i = 0 + while (i < lines.length) { + if (lines[i].startsWith("<<<<<<<")) { + const startLine = i + 1 // 1-based + let separatorLine = -1 + let endLine = -1 + const oursLines: string[] = [] + const theirsLines: string[] = [] + let inOurs = true + + let j = i + 1 + while (j < lines.length) { + if (lines[j].startsWith("=======") && separatorLine === -1) { + separatorLine = j + 1 + inOurs = false + } else if (lines[j].startsWith(">>>>>>>")) { + endLine = j + 1 + break + } else if (inOurs) { + oursLines.push(lines[j]) + } else { + theirsLines.push(lines[j]) + } + j++ + } + + if (separatorLine !== -1 && endLine !== -1) { + regions.push({ + startLine, + separatorLine, + endLine, + oursContent: oursLines.join("\n"), + theirsContent: theirsLines.join("\n"), + }) + i = j + 1 + continue + } + } + i++ + } + + return regions +} + +/** + * Resolve a single conflict region by replacing the conflict block + * with the chosen content. + */ +export function resolveConflict( + content: string, + region: ConflictRegion, + choice: "ours" | "theirs" | "both" +): string { + const lines = content.split("\n") + const startIdx = region.startLine - 1 + const endIdx = region.endLine - 1 + + let replacement: string + switch (choice) { + case "ours": + replacement = region.oursContent + break + case "theirs": + replacement = region.theirsContent + break + case "both": + replacement = region.oursContent + "\n" + region.theirsContent + break + } + + const replacementLines = replacement === "" ? [] : replacement.split("\n") + lines.splice(startIdx, endIdx - startIdx + 1, ...replacementLines) + return lines.join("\n") +} + +/** + * Check if content still has unresolved conflict markers. + */ +export function hasConflictMarkers(content: string): boolean { + return content.includes("<<<<<<<") && content.includes(">>>>>>>") +} diff --git a/src/components/merge/merge-diff.ts b/src/components/merge/merge-diff.ts new file mode 100644 index 0000000..4e1c8ca --- /dev/null +++ b/src/components/merge/merge-diff.ts @@ -0,0 +1,355 @@ +/** + * Line-level diff engine for three-way merge. + * + * Computes diffs between base↔ours and base↔theirs, then aligns + * them into MergeHunks classified as left-only, right-only, or conflict. + */ + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export interface DiffHunk { + /** Start index in the "old" (base) array, 0-based */ + baseStart: number + /** Number of lines removed from base (0 = pure insertion) */ + baseCount: number + /** Replacement lines from the "new" side */ + newLines: string[] +} + +export type HunkStatus = "pending" | "applied" | "ignored" + +export interface MergeHunk { + id: string + /** Start index in base lines, 0-based */ + baseStart: number + /** Number of base lines covered */ + baseCount: number + /** Diff hunk from ours (left) side, null if unchanged */ + leftHunk: DiffHunk | null + /** Diff hunk from theirs (right) side, null if unchanged */ + rightHunk: DiffHunk | null + type: "left-only" | "right-only" | "conflict" +} + +// --------------------------------------------------------------------------- +// LCS-based line diff +// --------------------------------------------------------------------------- + +/** + * Compute the Longest Common Subsequence table for two string arrays. + * Returns a 2D array where dp[i][j] = LCS length for a[0..i-1], b[0..j-1]. + */ +function lcsTable(a: string[], b: string[]): number[][] { + const m = a.length + const n = b.length + const dp: number[][] = Array.from({ length: m + 1 }, () => + new Array(n + 1).fill(0) + ) + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (a[i - 1] === b[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1 + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]) + } + } + } + return dp +} + +/** + * Backtrack the LCS table to produce edit operations. + * Returns an array of { type, aIdx, bIdx } entries. + */ +interface EditOp { + type: "equal" | "delete" | "insert" + aIdx: number // index in a (-1 for insert) + bIdx: number // index in b (-1 for delete) +} + +function backtrackLCS(a: string[], b: string[], dp: number[][]): EditOp[] { + const ops: EditOp[] = [] + let i = a.length + let j = b.length + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && a[i - 1] === b[j - 1]) { + ops.push({ type: "equal", aIdx: i - 1, bIdx: j - 1 }) + i-- + j-- + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + ops.push({ type: "insert", aIdx: -1, bIdx: j - 1 }) + j-- + } else { + ops.push({ type: "delete", aIdx: i - 1, bIdx: -1 }) + i-- + } + } + + return ops.reverse() +} + +/** + * Compute line-level diff hunks between old (a) and new (b) arrays. + */ +export function computeLineDiff(a: string[], b: string[]): DiffHunk[] { + const dp = lcsTable(a, b) + const ops = backtrackLCS(a, b, dp) + + const hunks: DiffHunk[] = [] + let idx = 0 + + while (idx < ops.length) { + const op = ops[idx] + + if (op.type === "equal") { + idx++ + continue + } + + // Start of a change region + let baseStart = op.type === "delete" ? op.aIdx : -1 + let baseCount = 0 + const newLines: string[] = [] + + while (idx < ops.length && ops[idx].type !== "equal") { + const cur = ops[idx] + if (cur.type === "delete") { + if (baseStart === -1) baseStart = cur.aIdx + baseCount++ + } else { + // insert + if (baseStart === -1) { + // Pure insertion — position it at the next base line + // Find the previous equal op's aIdx + 1, or 0 + baseStart = findInsertionPoint(ops, idx) + } + newLines.push(b[cur.bIdx]) + } + idx++ + } + + hunks.push({ baseStart, baseCount, newLines }) + } + + return hunks +} + +/** + * For a pure insertion (no deletes in this hunk), determine + * where in the base array to anchor it. + */ +function findInsertionPoint(ops: EditOp[], currentIdx: number): number { + // Walk backwards to find the last "equal" or "delete" op + for (let k = currentIdx - 1; k >= 0; k--) { + if (ops[k].type === "equal" || ops[k].type === "delete") { + return ops[k].aIdx + 1 + } + } + // If nothing found, insert at start + return 0 +} + +// --------------------------------------------------------------------------- +// Three-way merge hunk computation +// --------------------------------------------------------------------------- + +interface RangedHunk { + baseStart: number + baseEnd: number // exclusive + hunk: DiffHunk + side: "left" | "right" +} + +/** + * Given diff hunks from base→ours and base→theirs, produce + * a list of MergeHunks sorted by base position. + */ +export function computeMergeHunks( + base: string, + ours: string, + theirs: string +): MergeHunk[] { + const baseLines = base.split("\n") + const oursLines = ours.split("\n") + const theirsLines = theirs.split("\n") + + const leftDiffs = computeLineDiff(baseLines, oursLines) + const rightDiffs = computeLineDiff(baseLines, theirsLines) + + // Convert to ranged hunks for overlap detection + const ranged: RangedHunk[] = [] + + for (const h of leftDiffs) { + ranged.push({ + baseStart: h.baseStart, + baseEnd: h.baseStart + Math.max(h.baseCount, 1), // at least 1 for insertions + hunk: h, + side: "left", + }) + } + for (const h of rightDiffs) { + ranged.push({ + baseStart: h.baseStart, + baseEnd: h.baseStart + Math.max(h.baseCount, 1), + hunk: h, + side: "right", + }) + } + + // Sort by baseStart, then by side (left first) + ranged.sort( + (a, b) => a.baseStart - b.baseStart || (a.side === "left" ? -1 : 1) + ) + + // Merge overlapping hunks from different sides into conflicts + const mergeHunks: MergeHunk[] = [] + const used = new Set() + + for (let i = 0; i < ranged.length; i++) { + if (used.has(i)) continue + + const r = ranged[i] + + // Check for overlapping hunk from the other side + let paired: RangedHunk | null = null + let pairedIdx = -1 + + for (let j = i + 1; j < ranged.length; j++) { + if (used.has(j)) continue + const s = ranged[j] + if (s.side === r.side) continue + // Check overlap: ranges [r.baseStart, r.baseEnd) and [s.baseStart, s.baseEnd) + if (s.baseStart < r.baseEnd && r.baseStart < s.baseEnd) { + paired = s + pairedIdx = j + break + } + // If s starts beyond r, no more overlaps possible + if (s.baseStart >= r.baseEnd) break + } + + if (paired && pairedIdx >= 0) { + used.add(pairedIdx) + + // Check if both sides made identical changes — treat as non-conflict + const leftH = r.side === "left" ? r.hunk : paired.hunk + const rightH = r.side === "right" ? r.hunk : paired.hunk + + const identical = + leftH.baseStart === rightH.baseStart && + leftH.baseCount === rightH.baseCount && + leftH.newLines.length === rightH.newLines.length && + leftH.newLines.every((line, k) => line === rightH.newLines[k]) + + if (identical) { + // Both sides made the same change — treat as left-only (auto-applicable) + const bStart = Math.min(r.baseStart, paired.baseStart) + const bEnd = Math.max(r.baseEnd, paired.baseEnd) + mergeHunks.push({ + id: `hunk-${mergeHunks.length}`, + baseStart: bStart, + baseCount: bEnd - bStart, + leftHunk: leftH, + rightHunk: null, + type: "left-only", + }) + } else { + // Conflict + const bStart = Math.min(r.baseStart, paired.baseStart) + const bEnd = Math.max(r.baseEnd, paired.baseEnd) + mergeHunks.push({ + id: `hunk-${mergeHunks.length}`, + baseStart: bStart, + baseCount: bEnd - bStart, + leftHunk: r.side === "left" ? r.hunk : paired.hunk, + rightHunk: r.side === "right" ? r.hunk : paired.hunk, + type: "conflict", + }) + } + } else { + // Single-side change + mergeHunks.push({ + id: `hunk-${mergeHunks.length}`, + baseStart: r.baseStart, + baseCount: r.hunk.baseCount, + leftHunk: r.side === "left" ? r.hunk : null, + rightHunk: r.side === "right" ? r.hunk : null, + type: r.side === "left" ? "left-only" : "right-only", + }) + } + } + + // Sort by baseStart + mergeHunks.sort((a, b) => a.baseStart - b.baseStart) + + return mergeHunks +} + +// --------------------------------------------------------------------------- +// Result builder +// --------------------------------------------------------------------------- + +export interface AppliedHunkInfo { + id: string + side: "left" | "right" +} + +/** + * Build the result content by starting from base and applying + * hunks that have been accepted. + * + * @param base Original base content + * @param hunks All merge hunks + * @param applied Map of hunk id → which side was applied + */ +export function buildResult( + base: string, + hunks: MergeHunk[], + applied: Map +): string { + const baseLines = base.split("\n") + const result: string[] = [] + let baseIdx = 0 + + // Process hunks in order of baseStart + const sorted = [...hunks].sort((a, b) => a.baseStart - b.baseStart) + + for (const hunk of sorted) { + // Copy unchanged base lines before this hunk + while (baseIdx < hunk.baseStart) { + result.push(baseLines[baseIdx]) + baseIdx++ + } + + const appliedSide = applied.get(hunk.id) + + if (appliedSide) { + // Apply the chosen side's content + const diffHunk = appliedSide === "left" ? hunk.leftHunk : hunk.rightHunk + if (diffHunk) { + result.push(...diffHunk.newLines) + } + // Skip over the base lines that were replaced + baseIdx = hunk.baseStart + hunk.baseCount + } else { + // Not applied — keep base content + for (let i = 0; i < hunk.baseCount; i++) { + if (baseIdx < baseLines.length) { + result.push(baseLines[baseIdx]) + baseIdx++ + } + } + } + } + + // Copy remaining base lines + while (baseIdx < baseLines.length) { + result.push(baseLines[baseIdx]) + baseIdx++ + } + + return result.join("\n") +} diff --git a/src/components/merge/merge-workspace.tsx b/src/components/merge/merge-workspace.tsx new file mode 100644 index 0000000..955f688 --- /dev/null +++ b/src/components/merge/merge-workspace.tsx @@ -0,0 +1,291 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import { emit } from "@tauri-apps/api/event" +import { Check, FileWarning, Loader2, X, CheckCheck } from "lucide-react" +import { useTranslations } from "next-intl" +import { toast } from "sonner" +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Button } from "@/components/ui/button" +import { + gitListConflicts, + gitConflictFileVersions, + gitResolveConflict, + gitAbortOperation, + gitContinueOperation, + gitStartPullMerge, +} from "@/lib/tauri" +import { languageFromPath } from "@/lib/language-detect" +import { toErrorMessage } from "@/lib/app-error" +import type { GitConflictFileVersions } from "@/lib/types" +import { ThreePaneMergeEditor } from "./three-pane-merge-editor" + +interface MergeWorkspaceProps { + folderId: number + folderPath: string + operation: string + onCompleted: () => void + onAborted: () => void +} + +export function MergeWorkspace({ + folderId, + folderPath, + operation, + onCompleted, + onAborted, +}: MergeWorkspaceProps) { + const t = useTranslations("MergePage") + const [files, setFiles] = useState([]) + const [resolvedFiles, setResolvedFiles] = useState>(new Set()) + const [selectedFile, setSelectedFile] = useState(null) + const [versions, setVersions] = useState(null) + const [loadingVersions, setLoadingVersions] = useState(false) + const [resolving, setResolving] = useState(false) + const [aborting, setAborting] = useState(false) + const [completing, setCompleting] = useState(false) + const currentContentRef = useRef("") + const [hasUnresolvedConflicts, setHasUnresolvedConflicts] = useState(true) + + // Load conflict files on mount + useEffect(() => { + loadConflicts() + }, [folderPath]) // eslint-disable-line react-hooks/exhaustive-deps + + async function loadConflicts() { + try { + // For pull operations, the merge was aborted during detection to keep + // working tree clean. Re-start the merge to create conflict state. + if (operation === "pull") { + await gitStartPullMerge(folderPath) + } + const conflictFiles = await gitListConflicts(folderPath) + setFiles(conflictFiles) + if (conflictFiles.length > 0 && !selectedFile) { + selectFile(conflictFiles[0]) + } + } catch (err) { + toast.error(toErrorMessage(err)) + } + } + + async function selectFile(file: string) { + setSelectedFile(file) + setLoadingVersions(true) + try { + const v = await gitConflictFileVersions(folderPath, file) + setVersions(v) + currentContentRef.current = v.base + setHasUnresolvedConflicts(true) + } catch (err) { + toast.error(toErrorMessage(err)) + setVersions(null) + } finally { + setLoadingVersions(false) + } + } + + const handleContentChange = useCallback((content: string) => { + currentContentRef.current = content + }, []) + + const handleConflictStatusChange = useCallback((hasUnresolved: boolean) => { + setHasUnresolvedConflicts(hasUnresolved) + }, []) + + async function handleResolve() { + if (!selectedFile) return + + const content = currentContentRef.current + if (hasUnresolvedConflicts) { + toast.warning(t("unresolvedConflicts")) + return + } + + setResolving(true) + try { + await gitResolveConflict(folderPath, selectedFile, content) + setResolvedFiles((prev) => new Set([...prev, selectedFile])) + + // Notify parent window + await emit("folder://merge-conflict-resolved", { + folder_id: folderId, + file: selectedFile, + }) + + // Auto-select next unresolved file + const nextUnresolved = files.find( + (f) => f !== selectedFile && !resolvedFiles.has(f) + ) + if (nextUnresolved) { + selectFile(nextUnresolved) + } + } catch (err) { + toast.error(toErrorMessage(err)) + } finally { + setResolving(false) + } + } + + async function handleAbort() { + setAborting(true) + try { + await gitAbortOperation(folderPath, operation) + toast.success(t("abortSuccess")) + await emit("folder://merge-completed", { folder_id: folderId }) + onAborted() + } catch (err) { + toast.error(toErrorMessage(err)) + } finally { + setAborting(false) + } + } + + async function handleComplete() { + setCompleting(true) + try { + await gitContinueOperation(folderPath, operation) + toast.success(t("allResolved")) + await emit("folder://merge-completed", { folder_id: folderId }) + onCompleted() + } catch (err) { + toast.error(toErrorMessage(err)) + } finally { + setCompleting(false) + } + } + + const allResolved = + files.length > 0 && files.every((f) => resolvedFiles.has(f)) + + const language = selectedFile ? languageFromPath(selectedFile) : "plaintext" + + return ( +
+ + {/* Left sidebar: conflict file list */} + +
+
+ {t("conflictFiles")} ({files.length}) +
+ +
+ {files.map((file) => { + const isResolved = resolvedFiles.has(file) + const isSelected = file === selectedFile + return ( + + ) + })} + {files.length === 0 && ( +
+ {t("noConflicts")} +
+ )} +
+
+
+
+ + + + {/* Main area: three-pane merge editor */} + + {loadingVersions ? ( +
+ + {t("loadingFile")} +
+ ) : versions && selectedFile ? ( + + ) : ( +
+ {t("selectFile")} +
+ )} +
+
+ + {/* Bottom toolbar */} +
+ + + {allResolved && ( + + )} +
+
+ ) +} diff --git a/src/components/merge/three-pane-merge-editor.tsx b/src/components/merge/three-pane-merge-editor.tsx new file mode 100644 index 0000000..3db6d27 --- /dev/null +++ b/src/components/merge/three-pane-merge-editor.tsx @@ -0,0 +1,758 @@ +"use client" + +import { useCallback, useEffect, useMemo, useRef, useState } from "react" +import dynamic from "next/dynamic" +import type { OnMount } from "@monaco-editor/react" +import type { editor as MonacoEditorNs } from "monaco-editor" +import { ArrowLeft, ArrowRight, CheckCheck } from "lucide-react" +import { useTranslations } from "next-intl" +import { defineMonacoThemes, useMonacoThemeSync } from "@/lib/monaco-themes" +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable" +import { + computeLineDiff, + computeMergeHunks, + buildResult, + type DiffHunk, + type MergeHunk, +} from "./merge-diff" +import { useSyncScroll } from "./use-sync-scroll" + +const MonacoEditor = dynamic( + async () => { + const mod = await import("@monaco-editor/react") + return { default: mod.default } + }, + { ssr: false } +) + +interface ThreePaneMergeEditorProps { + base: string + ours: string + theirs: string + merged: string + language?: string + className?: string + onContentChange?: (content: string) => void + onConflictStatusChange?: (hasUnresolved: boolean) => void +} + +export function ThreePaneMergeEditor({ + base, + ours, + theirs, + language = "plaintext", + className, + onContentChange, + onConflictStatusChange, +}: ThreePaneMergeEditorProps) { + const t = useTranslations("MergePage") + const editorTheme = useMonacoThemeSync() + const { registerEditor } = useSyncScroll() + + const leftEditorRef = useRef( + null + ) + const centerEditorRef = useRef( + null + ) + const rightEditorRef = useRef( + null + ) + + // Decorations collections + const leftDecorationsRef = + useRef(null) + const centerDecorationsRef = + useRef(null) + const rightDecorationsRef = + useRef(null) + + // Scroll tick counter — incremented on every scroll to trigger gutter re-render + const [scrollTick, setScrollTick] = useState(0) + + // Merge state + const mergeHunks = useMemo( + () => computeMergeHunks(base, ours, theirs), + [base, ours, theirs] + ) + + // Track which hunks have been applied and which side was chosen + const [appliedHunks, setAppliedHunks] = useState< + Map + >(new Map()) + + // Track ignored hunks + const [ignoredHunks, setIgnoredHunks] = useState>(new Set()) + + const onContentChangeRef = useRef(onContentChange) + const onConflictStatusChangeRef = useRef(onConflictStatusChange) + + useEffect(() => { + onContentChangeRef.current = onContentChange + }, [onContentChange]) + + useEffect(() => { + onConflictStatusChangeRef.current = onConflictStatusChange + }, [onConflictStatusChange]) + + // Compute diffs for left/right pane decorations + const baseLines = useMemo(() => base.split("\n"), [base]) + const leftDiffs = useMemo( + () => computeLineDiff(baseLines, ours.split("\n")), + [baseLines, ours] + ) + const rightDiffs = useMemo( + () => computeLineDiff(baseLines, theirs.split("\n")), + [baseLines, theirs] + ) + + // Build the result content from base + applied hunks + const resultContent = useMemo( + () => buildResult(base, mergeHunks, appliedHunks), + [base, mergeHunks, appliedHunks] + ) + + // Notify parent of content changes + useEffect(() => { + onContentChangeRef.current?.(resultContent) + }, [resultContent]) + + // Notify parent of conflict status + useEffect(() => { + const hasUnresolved = mergeHunks.some( + (h) => + h.type === "conflict" && + !appliedHunks.has(h.id) && + !ignoredHunks.has(h.id) + ) + onConflictStatusChangeRef.current?.(hasUnresolved) + }, [mergeHunks, appliedHunks, ignoredHunks]) + + // Apply hunk handler + const applyHunk = useCallback((id: string, side: "left" | "right") => { + setAppliedHunks((prev) => { + const next = new Map(prev) + next.set(id, side) + return next + }) + setIgnoredHunks((prev) => { + const next = new Set(prev) + next.delete(id) + return next + }) + }, []) + + // Sync center editor content when result changes + useEffect(() => { + const editor = centerEditorRef.current + if (!editor) return + const currentValue = editor.getValue() + if (currentValue !== resultContent) { + const pos = editor.getPosition() + editor.setValue(resultContent) + if (pos) editor.setPosition(pos) + } + }, [resultContent]) + + // --------------------------------------------------------------------------- + // Decorations for left (ours) pane + // --------------------------------------------------------------------------- + const applyLeftDecorations = useCallback( + (editor: MonacoEditorNs.IStandaloneCodeEditor) => { + const decorations: MonacoEditorNs.IModelDeltaDecoration[] = [] + const oursLines = ours.split("\n") + + for (const hunk of leftDiffs) { + const range = hunkToEditorRange(hunk, leftDiffs, oursLines.length) + if (!range) continue + + const cssClass = + hunk.baseCount === 0 + ? "merge-hunk-added-bg" + : hunk.newLines.length === 0 + ? "merge-hunk-removed-bg" + : "merge-hunk-modified-bg" + + decorations.push({ + range, + options: { isWholeLine: true, className: cssClass }, + }) + } + + if (leftDecorationsRef.current) { + leftDecorationsRef.current.set(decorations) + } else { + leftDecorationsRef.current = + editor.createDecorationsCollection(decorations) + } + }, + [leftDiffs, ours] + ) + + // --------------------------------------------------------------------------- + // Decorations for right (theirs) pane + // --------------------------------------------------------------------------- + const applyRightDecorations = useCallback( + (editor: MonacoEditorNs.IStandaloneCodeEditor) => { + const decorations: MonacoEditorNs.IModelDeltaDecoration[] = [] + const theirsLines = theirs.split("\n") + + for (const hunk of rightDiffs) { + const range = hunkToEditorRange(hunk, rightDiffs, theirsLines.length) + if (!range) continue + + const cssClass = + hunk.baseCount === 0 + ? "merge-hunk-added-bg" + : hunk.newLines.length === 0 + ? "merge-hunk-removed-bg" + : "merge-hunk-modified-bg" + + decorations.push({ + range, + options: { isWholeLine: true, className: cssClass }, + }) + } + + if (rightDecorationsRef.current) { + rightDecorationsRef.current.set(decorations) + } else { + rightDecorationsRef.current = + editor.createDecorationsCollection(decorations) + } + }, + [rightDiffs, theirs] + ) + + // --------------------------------------------------------------------------- + // Decorations for center (result) pane + // --------------------------------------------------------------------------- + const applyCenterDecorations = useCallback( + (editor: MonacoEditorNs.IStandaloneCodeEditor) => { + const decorations: MonacoEditorNs.IModelDeltaDecoration[] = [] + const currentLines = resultContent.split("\n") + + let resultOffset = 0 + const sortedHunks = [...mergeHunks].sort( + (a, b) => a.baseStart - b.baseStart + ) + let lastBaseEnd = 0 + + for (const hunk of sortedHunks) { + resultOffset += hunk.baseStart - lastBaseEnd + + const isApplied = appliedHunks.has(hunk.id) + const isIgnored = ignoredHunks.has(hunk.id) + + let lineCount: number + if (isApplied) { + const side = appliedHunks.get(hunk.id)! + const diffHunk = side === "left" ? hunk.leftHunk : hunk.rightHunk + lineCount = diffHunk ? diffHunk.newLines.length : 0 + } else { + lineCount = hunk.baseCount + } + + if (lineCount > 0) { + const startLine = resultOffset + 1 + const endLine = resultOffset + lineCount + + let cssClass: string + if (isApplied) { + cssClass = "merge-hunk-applied-bg" + } else if (isIgnored) { + cssClass = "" + } else if (hunk.type === "conflict") { + cssClass = "merge-hunk-conflict-bg" + } else { + cssClass = "merge-hunk-pending-bg" + } + + if (cssClass) { + decorations.push({ + range: { + startLineNumber: startLine, + startColumn: 1, + endLineNumber: Math.min(endLine, currentLines.length), + endColumn: 1, + }, + options: { isWholeLine: true, className: cssClass }, + }) + } + } + + resultOffset += lineCount + lastBaseEnd = hunk.baseStart + hunk.baseCount + } + + if (centerDecorationsRef.current) { + centerDecorationsRef.current.set(decorations) + } else { + centerDecorationsRef.current = + editor.createDecorationsCollection(decorations) + } + }, + [mergeHunks, appliedHunks, ignoredHunks, resultContent] + ) + + // --------------------------------------------------------------------------- + // Apply decorations when state changes + // --------------------------------------------------------------------------- + useEffect(() => { + if (leftEditorRef.current) { + applyLeftDecorations(leftEditorRef.current) + } + }, [applyLeftDecorations]) + + useEffect(() => { + if (centerEditorRef.current) { + applyCenterDecorations(centerEditorRef.current) + } + }, [applyCenterDecorations]) + + useEffect(() => { + if (rightEditorRef.current) { + applyRightDecorations(rightEditorRef.current) + } + }, [applyRightDecorations]) + + // --------------------------------------------------------------------------- + // Editor mount handlers + // --------------------------------------------------------------------------- + const handleLeftMount: OnMount = useCallback( + (editor) => { + leftEditorRef.current = editor + registerEditor(editor, 0) + applyLeftDecorations(editor) + + // Also listen to left editor scroll to update gutter + editor.onDidScrollChange(() => { + setScrollTick((n) => n + 1) + }) + + // Trigger initial gutter render after editor is ready + requestAnimationFrame(() => { + setScrollTick((n) => n + 1) + }) + }, + [registerEditor, applyLeftDecorations] + ) + + const handleCenterMount: OnMount = useCallback( + (editor) => { + centerEditorRef.current = editor + registerEditor(editor, 1) + applyCenterDecorations(editor) + + editor.onDidChangeModelContent(() => { + const value = editor.getValue() + onContentChangeRef.current?.(value) + }) + }, + [registerEditor, applyCenterDecorations] + ) + + const handleRightMount: OnMount = useCallback( + (editor) => { + rightEditorRef.current = editor + registerEditor(editor, 2) + applyRightDecorations(editor) + + // Also listen to right editor scroll to update gutter + editor.onDidScrollChange(() => { + setScrollTick((n) => n + 1) + }) + + // Trigger initial gutter render after editor is ready + requestAnimationFrame(() => { + setScrollTick((n) => n + 1) + }) + }, + [registerEditor, applyRightDecorations] + ) + + // --------------------------------------------------------------------------- + // Compute gutter arrow items (line numbers only, positions computed at render) + // --------------------------------------------------------------------------- + const leftGutterItems = useMemo(() => { + const oursLines = ours.split("\n") + const items: Array<{ + hunk: MergeHunk + lineNumber: number + }> = [] + + for (const hunk of mergeHunks) { + if (!hunk.leftHunk) continue + if (appliedHunks.has(hunk.id) || ignoredHunks.has(hunk.id)) continue + + const range = hunkToEditorRange( + hunk.leftHunk, + leftDiffs, + oursLines.length + ) + if (!range) continue + + items.push({ hunk, lineNumber: range.startLineNumber }) + } + return items + }, [mergeHunks, appliedHunks, ignoredHunks, leftDiffs, ours]) + + const rightGutterItems = useMemo(() => { + const theirsLines = theirs.split("\n") + const items: Array<{ + hunk: MergeHunk + lineNumber: number + }> = [] + + for (const hunk of mergeHunks) { + if (!hunk.rightHunk) continue + if (appliedHunks.has(hunk.id) || ignoredHunks.has(hunk.id)) continue + + const range = hunkToEditorRange( + hunk.rightHunk, + rightDiffs, + theirsLines.length + ) + if (!range) continue + + items.push({ hunk, lineNumber: range.startLineNumber }) + } + return items + }, [mergeHunks, appliedHunks, ignoredHunks, rightDiffs, theirs]) + + // --------------------------------------------------------------------------- + // Toolbar actions + // --------------------------------------------------------------------------- + const handleApplyAllNonConflicting = useCallback(() => { + setAppliedHunks((prev) => { + const next = new Map(prev) + for (const hunk of mergeHunks) { + if (hunk.type === "left-only" && hunk.leftHunk && !next.has(hunk.id)) { + next.set(hunk.id, "left") + } else if ( + hunk.type === "right-only" && + hunk.rightHunk && + !next.has(hunk.id) + ) { + next.set(hunk.id, "right") + } + } + return next + }) + }, [mergeHunks]) + + const handleApplyLeftNonConflicting = useCallback(() => { + setAppliedHunks((prev) => { + const next = new Map(prev) + for (const hunk of mergeHunks) { + if (hunk.type === "left-only" && hunk.leftHunk && !next.has(hunk.id)) { + next.set(hunk.id, "left") + } + } + return next + }) + }, [mergeHunks]) + + const handleApplyRightNonConflicting = useCallback(() => { + setAppliedHunks((prev) => { + const next = new Map(prev) + for (const hunk of mergeHunks) { + if ( + hunk.type === "right-only" && + hunk.rightHunk && + !next.has(hunk.id) + ) { + next.set(hunk.id, "right") + } + } + return next + }) + }, [mergeHunks]) + + // --------------------------------------------------------------------------- + // Statistics + // --------------------------------------------------------------------------- + const unresolvedConflicts = mergeHunks.filter( + (h) => + h.type === "conflict" && + !appliedHunks.has(h.id) && + !ignoredHunks.has(h.id) + ).length + const pendingNonConflicts = mergeHunks.filter( + (h) => + h.type !== "conflict" && + !appliedHunks.has(h.id) && + !ignoredHunks.has(h.id) + ).length + const totalChanges = mergeHunks.length + + // --------------------------------------------------------------------------- + // Editor options + // --------------------------------------------------------------------------- + const editorOptions: MonacoEditorNs.IStandaloneEditorConstructionOptions = { + fontSize: 13, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + automaticLayout: true, + lineNumbers: "on", + glyphMargin: true, + folding: false, + wordWrap: "off", + overviewRulerLanes: 0, + } + + const readonlyOptions = { + ...editorOptions, + readOnly: true, + domReadOnly: true, + } + + const loadingEl = ( +
+ Loading editor... +
+ ) + + return ( +
+ {/* Header */} +
+
+ {t("localVersion")} +
+
+
+ {t("result")} + {unresolvedConflicts > 0 && ( + + ({unresolvedConflicts}{" "} + {unresolvedConflicts === 1 ? "conflict" : "conflicts"}) + + )} + {pendingNonConflicts > 0 && ( + + ({pendingNonConflicts} pending) + + )} + {totalChanges > 0 && + unresolvedConflicts === 0 && + pendingNonConflicts === 0 && ( + + + + )} +
+ {pendingNonConflicts > 0 && ( +
+ + + +
+ )} +
+
+ {t("remoteVersion")} +
+
+ + {/* Three-panel layout: [left editor + gutter] | center editor | [gutter + right editor] */} + + {/* Left: Ours (local) + arrow gutter */} + +
+
+ +
+ applyHunk(id, "left")} + title={t("acceptLocal")} + /> +
+
+ + + + {/* Center: Result (editable) */} + + + + + + + {/* Right: arrow gutter + Theirs (remote) */} + +
+ applyHunk(id, "right")} + title={t("acceptRemote")} + /> +
+ +
+
+
+
+
+ ) +} + +// --------------------------------------------------------------------------- +// Arrow Gutter Component +// --------------------------------------------------------------------------- + +interface ArrowGutterProps { + items: Array<{ hunk: MergeHunk; lineNumber: number }> + direction: "left" | "right" + editorRef: React.RefObject + scrollTick: number // triggers re-render on scroll + onApply: (hunkId: string) => void + title: string +} + +function ArrowGutter({ + items, + direction, + editorRef, + scrollTick, + onApply, + title, +}: ArrowGutterProps) { + const editor = editorRef.current + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const _tick = scrollTick // read to establish dependency + + const positioned = useMemo(() => { + if (!editor) return [] + return items + .map(({ hunk, lineNumber }) => { + const pos = editor.getScrolledVisiblePosition({ + lineNumber, + column: 1, + }) + return pos ? { hunk, top: pos.top } : null + }) + .filter((item): item is { hunk: MergeHunk; top: number } => item !== null) + // scrollTick is included to recompute on scroll + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [editor, items, scrollTick]) + + return ( +
+ {positioned.map(({ hunk, top }) => ( + + ))} +
+ ) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Convert a DiffHunk to an editor range in the modified file, + * accounting for offset from previous hunks. + */ +function hunkToEditorRange( + hunk: DiffHunk, + allHunks: DiffHunk[], + totalLines: number +): MonacoEditorNs.IRange | null { + let offset = 0 + for (const h of allHunks) { + if (h.baseStart >= hunk.baseStart) break + offset += h.newLines.length - h.baseCount + } + + if (hunk.newLines.length > 0) { + const start = hunk.baseStart + offset + 1 + const end = start + hunk.newLines.length - 1 + return { + startLineNumber: start, + startColumn: 1, + endLineNumber: Math.min(end, totalLines), + endColumn: 1, + } + } else if (hunk.baseCount > 0) { + const line = Math.min(hunk.baseStart + offset + 1, totalLines) + return { + startLineNumber: line, + startColumn: 1, + endLineNumber: line, + endColumn: 1, + } + } + return null +} diff --git a/src/components/merge/use-sync-scroll.ts b/src/components/merge/use-sync-scroll.ts new file mode 100644 index 0000000..6326532 --- /dev/null +++ b/src/components/merge/use-sync-scroll.ts @@ -0,0 +1,44 @@ +import { useCallback, useRef } from "react" +import type { editor as MonacoEditorNs } from "monaco-editor" + +type EditorInstance = MonacoEditorNs.IStandaloneCodeEditor + +/** + * Hook to synchronize scrolling between multiple Monaco editors. + * Uses a flag to prevent infinite scroll loops. + */ +export function useSyncScroll() { + const isSyncing = useRef(false) + const editorsRef = useRef([]) + + const registerEditor = useCallback( + (editor: EditorInstance, index: number) => { + editorsRef.current[index] = editor + + editor.onDidScrollChange(() => { + if (isSyncing.current) return + isSyncing.current = true + + const scrollTop = editor.getScrollTop() + const scrollLeft = editor.getScrollLeft() + + for (let i = 0; i < editorsRef.current.length; i++) { + if (i !== index && editorsRef.current[i]) { + editorsRef.current[i].setScrollPosition({ + scrollTop, + scrollLeft, + }) + } + } + + // Use rAF to release the sync flag after all scroll events settle + requestAnimationFrame(() => { + isSyncing.current = false + }) + }) + }, + [] + ) + + return { registerEditor } +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 04690f2..fa3b587 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -540,6 +540,31 @@ "invalidFolderId": "معرّف المجلد غير صالح", "loadingRepo": "جارٍ تحميل المستودع..." }, + "MergePage": { + "title": "حل التعارضات", + "invalidFolderId": "معرّف المجلد غير صالح", + "loadingRepo": "جارٍ تحميل المستودع...", + "localVersion": "محلي (الخاص بنا)", + "result": "النتيجة", + "remoteVersion": "بعيد (الخاص بهم)", + "acceptLocal": "قبول المحلي", + "acceptRemote": "قبول البعيد", + "markResolved": "تحديد كمحلول", + "abortMerge": "إلغاء", + "completeMerge": "إتمام الدمج", + "unresolvedConflicts": "لا تزال هناك علامات تعارض غير محلولة في هذا الملف", + "fileResolved": "تم حل الملف بنجاح", + "allResolved": "تم حل جميع التعارضات", + "conflictFiles": "ملفات متعارضة", + "loadingFile": "جارٍ تحميل الملف...", + "selectFile": "اختر ملفًا لحله", + "noConflicts": "لا توجد ملفات متعارضة", + "skipFile": "تخطي", + "abortSuccess": "تم إلغاء العملية", + "applyAllNonConflicting": "تطبيق جميع التغييرات غير المتعارضة", + "applyLeftNonConflicting": "تطبيق المحلي", + "applyRightNonConflicting": "تطبيق البعيد" + }, "Folder": { "common": { "all": "الكل", @@ -815,6 +840,15 @@ "remoteUrlPlaceholder": "عنوان URL للمستودع البعيد", "addRemote": "إضافة", "savingRemotes": "جارٍ الحفظ..." + }, + "conflict": { + "title": "تعارضات الدمج", + "description": "الملفات التالية بها تعارضات تحتاج إلى حل:", + "abort": "إلغاء الدمج", + "openMergeTool": "فتح أداة الدمج", + "completeMerge": "إتمام الدمج", + "abortSuccess": "تم إلغاء الدمج بنجاح", + "completeSuccess": "تم إتمام الدمج بنجاح" } }, "commitDialog": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index c576cc2..5e55f60 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -540,6 +540,31 @@ "invalidFolderId": "Ungültige Ordner-ID", "loadingRepo": "Repository wird geladen..." }, + "MergePage": { + "title": "Konflikte lösen", + "invalidFolderId": "Ungültige Ordner-ID", + "loadingRepo": "Repository wird geladen...", + "localVersion": "Lokal (Unsere)", + "result": "Ergebnis", + "remoteVersion": "Remote (Deren)", + "acceptLocal": "Lokal übernehmen", + "acceptRemote": "Remote übernehmen", + "markResolved": "Als gelöst markieren", + "abortMerge": "Abbrechen", + "completeMerge": "Merge abschließen", + "unresolvedConflicts": "Es gibt noch ungelöste Konfliktmarkierungen in dieser Datei", + "fileResolved": "Datei erfolgreich gelöst", + "allResolved": "Alle Konflikte gelöst", + "conflictFiles": "Konfliktdateien", + "loadingFile": "Datei wird geladen...", + "selectFile": "Datei zum Lösen auswählen", + "noConflicts": "Keine Konfliktdateien", + "skipFile": "Überspringen", + "abortSuccess": "Vorgang abgebrochen", + "applyAllNonConflicting": "Alle konfliktfreien Änderungen anwenden", + "applyLeftNonConflicting": "Lokal anwenden", + "applyRightNonConflicting": "Remote anwenden" + }, "Folder": { "common": { "all": "Alle", @@ -815,6 +840,15 @@ "remoteUrlPlaceholder": "Remote-URL", "addRemote": "Hinzufügen", "savingRemotes": "Speichern..." + }, + "conflict": { + "title": "Merge-Konflikte", + "description": "Die folgenden Dateien haben Konflikte, die gelöst werden müssen:", + "abort": "Merge abbrechen", + "openMergeTool": "Merge-Tool öffnen", + "completeMerge": "Merge abschließen", + "abortSuccess": "Merge erfolgreich abgebrochen", + "completeSuccess": "Merge erfolgreich abgeschlossen" } }, "commitDialog": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index aa6d587..681ed7e 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -540,6 +540,31 @@ "invalidFolderId": "Invalid folder ID", "loadingRepo": "Loading repository..." }, + "MergePage": { + "title": "Resolve Conflicts", + "invalidFolderId": "Invalid folder ID", + "loadingRepo": "Loading repository...", + "localVersion": "Local (Ours)", + "result": "Result", + "remoteVersion": "Remote (Theirs)", + "acceptLocal": "Accept Local", + "acceptRemote": "Accept Remote", + "markResolved": "Mark Resolved", + "abortMerge": "Abort", + "completeMerge": "Complete Merge", + "unresolvedConflicts": "There are still unresolved conflict markers in this file", + "fileResolved": "File resolved successfully", + "allResolved": "All conflicts resolved", + "conflictFiles": "Conflict Files", + "loadingFile": "Loading file...", + "selectFile": "Select a file to resolve", + "noConflicts": "No conflict files", + "skipFile": "Skip", + "abortSuccess": "Operation aborted", + "applyAllNonConflicting": "Apply All Non-Conflicting", + "applyLeftNonConflicting": "Apply Local", + "applyRightNonConflicting": "Apply Remote" + }, "Folder": { "common": { "all": "All", @@ -815,6 +840,15 @@ "remoteUrlPlaceholder": "Remote URL", "addRemote": "Add", "savingRemotes": "Saving..." + }, + "conflict": { + "title": "Merge Conflicts", + "description": "The following files have conflicts that need to be resolved:", + "abort": "Abort Merge", + "openMergeTool": "Open Merge Tool", + "completeMerge": "Complete Merge", + "abortSuccess": "Merge aborted successfully", + "completeSuccess": "Merge completed successfully" } }, "commitDialog": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index cebccc1..ab6713d 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -540,6 +540,31 @@ "invalidFolderId": "ID de carpeta no válido", "loadingRepo": "Cargando repositorio..." }, + "MergePage": { + "title": "Resolver conflictos", + "invalidFolderId": "ID de carpeta no válido", + "loadingRepo": "Cargando repositorio...", + "localVersion": "Local (Nuestro)", + "result": "Resultado", + "remoteVersion": "Remoto (Suyo)", + "acceptLocal": "Aceptar local", + "acceptRemote": "Aceptar remoto", + "markResolved": "Marcar como resuelto", + "abortMerge": "Abortar", + "completeMerge": "Completar fusión", + "unresolvedConflicts": "Todavía hay marcadores de conflicto sin resolver en este archivo", + "fileResolved": "Archivo resuelto correctamente", + "allResolved": "Todos los conflictos resueltos", + "conflictFiles": "Archivos en conflicto", + "loadingFile": "Cargando archivo...", + "selectFile": "Seleccionar un archivo para resolver", + "noConflicts": "No hay archivos en conflicto", + "skipFile": "Omitir", + "abortSuccess": "Operación abortada", + "applyAllNonConflicting": "Aplicar todos los cambios sin conflicto", + "applyLeftNonConflicting": "Aplicar local", + "applyRightNonConflicting": "Aplicar remoto" + }, "Folder": { "common": { "all": "Todo", @@ -815,6 +840,15 @@ "remoteUrlPlaceholder": "URL del remoto", "addRemote": "Añadir", "savingRemotes": "Guardando..." + }, + "conflict": { + "title": "Conflictos de fusión", + "description": "Los siguientes archivos tienen conflictos que necesitan ser resueltos:", + "abort": "Abortar fusión", + "openMergeTool": "Abrir herramienta de fusión", + "completeMerge": "Completar fusión", + "abortSuccess": "Fusión abortada correctamente", + "completeSuccess": "Fusión completada correctamente" } }, "commitDialog": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 9b3689d..f16a03f 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -540,6 +540,31 @@ "invalidFolderId": "ID de dossier invalide", "loadingRepo": "Chargement du dépôt..." }, + "MergePage": { + "title": "Résoudre les conflits", + "invalidFolderId": "ID de dossier invalide", + "loadingRepo": "Chargement du dépôt...", + "localVersion": "Local (Le nôtre)", + "result": "Résultat", + "remoteVersion": "Distant (Le leur)", + "acceptLocal": "Accepter le local", + "acceptRemote": "Accepter le distant", + "markResolved": "Marquer comme résolu", + "abortMerge": "Abandonner", + "completeMerge": "Terminer la fusion", + "unresolvedConflicts": "Il reste des marqueurs de conflit non résolus dans ce fichier", + "fileResolved": "Fichier résolu avec succès", + "allResolved": "Tous les conflits sont résolus", + "conflictFiles": "Fichiers en conflit", + "loadingFile": "Chargement du fichier...", + "selectFile": "Sélectionner un fichier à résoudre", + "noConflicts": "Aucun fichier en conflit", + "skipFile": "Passer", + "abortSuccess": "Opération abandonnée", + "applyAllNonConflicting": "Appliquer tous les changements non conflictuels", + "applyLeftNonConflicting": "Appliquer local", + "applyRightNonConflicting": "Appliquer distant" + }, "Folder": { "common": { "all": "Tout", @@ -815,6 +840,15 @@ "remoteUrlPlaceholder": "URL du dépôt distant", "addRemote": "Ajouter", "savingRemotes": "Enregistrement..." + }, + "conflict": { + "title": "Conflits de fusion", + "description": "Les fichiers suivants ont des conflits qui doivent être résolus :", + "abort": "Abandonner la fusion", + "openMergeTool": "Ouvrir l'outil de fusion", + "completeMerge": "Terminer la fusion", + "abortSuccess": "Fusion abandonnée avec succès", + "completeSuccess": "Fusion terminée avec succès" } }, "commitDialog": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index c0bfa66..6519c1e 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -540,6 +540,31 @@ "invalidFolderId": "無効なフォルダID", "loadingRepo": "リポジトリを読み込み中..." }, + "MergePage": { + "title": "コンフリクトの解決", + "invalidFolderId": "無効なフォルダID", + "loadingRepo": "リポジトリを読み込み中...", + "localVersion": "ローカル(自分側)", + "result": "結果", + "remoteVersion": "リモート(相手側)", + "acceptLocal": "ローカルを採用", + "acceptRemote": "リモートを採用", + "markResolved": "解決済みにする", + "abortMerge": "中止", + "completeMerge": "マージ完了", + "unresolvedConflicts": "ファイルに未解決のコンフリクトマーカーがあります", + "fileResolved": "ファイルが解決されました", + "allResolved": "すべてのコンフリクトが解決されました", + "conflictFiles": "コンフリクトファイル", + "loadingFile": "ファイルを読み込み中...", + "selectFile": "解決するファイルを選択してください", + "noConflicts": "コンフリクトファイルなし", + "skipFile": "スキップ", + "abortSuccess": "操作が中止されました", + "applyAllNonConflicting": "競合しない変更をすべて適用", + "applyLeftNonConflicting": "ローカルを適用", + "applyRightNonConflicting": "リモートを適用" + }, "Folder": { "common": { "all": "すべて", @@ -815,6 +840,15 @@ "remoteUrlPlaceholder": "リモート URL", "addRemote": "追加", "savingRemotes": "保存中..." + }, + "conflict": { + "title": "マージコンフリクト", + "description": "以下のファイルにコンフリクトがあります。解決が必要です:", + "abort": "マージを中止", + "openMergeTool": "マージツールを開く", + "completeMerge": "マージ完了", + "abortSuccess": "マージが中止されました", + "completeSuccess": "マージが完了しました" } }, "commitDialog": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 37d2648..42e54a9 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -540,6 +540,31 @@ "invalidFolderId": "유효하지 않은 폴더 ID", "loadingRepo": "저장소를 불러오는 중..." }, + "MergePage": { + "title": "충돌 해결", + "invalidFolderId": "잘못된 폴더 ID", + "loadingRepo": "저장소 로딩 중...", + "localVersion": "로컬 (우리 쪽)", + "result": "결과", + "remoteVersion": "원격 (상대 쪽)", + "acceptLocal": "로컬 적용", + "acceptRemote": "원격 적용", + "markResolved": "해결됨으로 표시", + "abortMerge": "중단", + "completeMerge": "병합 완료", + "unresolvedConflicts": "파일에 아직 해결되지 않은 충돌 마커가 있습니다", + "fileResolved": "파일이 해결되었습니다", + "allResolved": "모든 충돌이 해결되었습니다", + "conflictFiles": "충돌 파일", + "loadingFile": "파일 로딩 중...", + "selectFile": "해결할 파일을 선택하세요", + "noConflicts": "충돌 파일 없음", + "skipFile": "건너뛰기", + "abortSuccess": "작업이 중단되었습니다", + "applyAllNonConflicting": "충돌하지 않는 모든 변경 적용", + "applyLeftNonConflicting": "로컬 적용", + "applyRightNonConflicting": "원격 적용" + }, "Folder": { "common": { "all": "전체", @@ -815,6 +840,15 @@ "remoteUrlPlaceholder": "원격 URL", "addRemote": "추가", "savingRemotes": "저장 중..." + }, + "conflict": { + "title": "병합 충돌", + "description": "다음 파일에 충돌이 있어 해결이 필요합니다:", + "abort": "병합 중단", + "openMergeTool": "병합 도구 열기", + "completeMerge": "병합 완료", + "abortSuccess": "병합이 중단되었습니다", + "completeSuccess": "병합이 완료되었습니다" } }, "commitDialog": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index a1c92fd..4762321 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -540,6 +540,31 @@ "invalidFolderId": "ID de pasta inválido", "loadingRepo": "Carregando repositório..." }, + "MergePage": { + "title": "Resolver conflitos", + "invalidFolderId": "ID de pasta inválido", + "loadingRepo": "Carregando repositório...", + "localVersion": "Local (Nosso)", + "result": "Resultado", + "remoteVersion": "Remoto (Deles)", + "acceptLocal": "Aceitar local", + "acceptRemote": "Aceitar remoto", + "markResolved": "Marcar como resolvido", + "abortMerge": "Abortar", + "completeMerge": "Concluir merge", + "unresolvedConflicts": "Ainda há marcadores de conflito não resolvidos neste arquivo", + "fileResolved": "Arquivo resolvido com sucesso", + "allResolved": "Todos os conflitos resolvidos", + "conflictFiles": "Arquivos em conflito", + "loadingFile": "Carregando arquivo...", + "selectFile": "Selecione um arquivo para resolver", + "noConflicts": "Nenhum arquivo em conflito", + "skipFile": "Pular", + "abortSuccess": "Operação abortada", + "applyAllNonConflicting": "Aplicar todas as alterações sem conflito", + "applyLeftNonConflicting": "Aplicar local", + "applyRightNonConflicting": "Aplicar remoto" + }, "Folder": { "common": { "all": "Todos", @@ -815,6 +840,15 @@ "remoteUrlPlaceholder": "URL do remoto", "addRemote": "Adicionar", "savingRemotes": "Salvando..." + }, + "conflict": { + "title": "Conflitos de merge", + "description": "Os seguintes arquivos têm conflitos que precisam ser resolvidos:", + "abort": "Abortar merge", + "openMergeTool": "Abrir ferramenta de merge", + "completeMerge": "Concluir merge", + "abortSuccess": "Merge abortado com sucesso", + "completeSuccess": "Merge concluído com sucesso" } }, "commitDialog": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 06af187..68e2730 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -540,6 +540,31 @@ "invalidFolderId": "无效的 folderId", "loadingRepo": "正在加载仓库..." }, + "MergePage": { + "title": "解决冲突", + "invalidFolderId": "无效的 folderId", + "loadingRepo": "正在加载仓库...", + "localVersion": "本地(我们的)", + "result": "结果", + "remoteVersion": "远程(他们的)", + "acceptLocal": "采用本地", + "acceptRemote": "采用远程", + "markResolved": "标记已解决", + "abortMerge": "中止", + "completeMerge": "完成合并", + "unresolvedConflicts": "文件中仍有未解决的冲突标记", + "fileResolved": "文件已解决", + "allResolved": "所有冲突已解决", + "conflictFiles": "冲突文件", + "loadingFile": "正在加载文件...", + "selectFile": "选择一个文件进行解决", + "noConflicts": "无冲突文件", + "skipFile": "跳过", + "abortSuccess": "操作已中止", + "applyAllNonConflicting": "应用所有非冲突变更", + "applyLeftNonConflicting": "应用本地", + "applyRightNonConflicting": "应用远程" + }, "Folder": { "common": { "all": "全部", @@ -815,6 +840,15 @@ "remoteUrlPlaceholder": "远程 URL", "addRemote": "添加", "savingRemotes": "保存中..." + }, + "conflict": { + "title": "合并冲突", + "description": "以下文件存在冲突,需要手动解决:", + "abort": "中止合并", + "openMergeTool": "打开合并工具", + "completeMerge": "完成合并", + "abortSuccess": "合并已中止", + "completeSuccess": "合并完成" } }, "commitDialog": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index edfb9ff..68ac3e8 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -540,6 +540,31 @@ "invalidFolderId": "無效的 folderId", "loadingRepo": "正在載入倉庫..." }, + "MergePage": { + "title": "解決衝突", + "invalidFolderId": "無效的 folderId", + "loadingRepo": "正在載入倉庫...", + "localVersion": "本地(我們的)", + "result": "結果", + "remoteVersion": "遠端(他們的)", + "acceptLocal": "採用本地", + "acceptRemote": "採用遠端", + "markResolved": "標記已解決", + "abortMerge": "中止", + "completeMerge": "完成合併", + "unresolvedConflicts": "檔案中仍有未解決的衝突標記", + "fileResolved": "檔案已解決", + "allResolved": "所有衝突已解決", + "conflictFiles": "衝突檔案", + "loadingFile": "正在載入檔案...", + "selectFile": "選擇一個檔案進行解決", + "noConflicts": "無衝突檔案", + "skipFile": "跳過", + "abortSuccess": "操作已中止", + "applyAllNonConflicting": "套用所有非衝突變更", + "applyLeftNonConflicting": "套用本地", + "applyRightNonConflicting": "套用遠端" + }, "Folder": { "common": { "all": "全部", @@ -815,6 +840,15 @@ "remoteUrlPlaceholder": "遠端 URL", "addRemote": "新增", "savingRemotes": "儲存中..." + }, + "conflict": { + "title": "合併衝突", + "description": "以下檔案存在衝突,需要手動解決:", + "abort": "中止合併", + "openMergeTool": "開啟合併工具", + "completeMerge": "完成合併", + "abortSuccess": "合併已中止", + "completeSuccess": "合併完成" } }, "commitDialog": { diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index a446671..64f9774 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -24,6 +24,8 @@ import type { GitPullResult, GitPushResult, GitMergeResult, + GitRebaseResult, + GitConflictFileVersions, GitCommitResult, GitRemote, PreflightResult, @@ -450,6 +452,10 @@ export async function gitPull(path: string): Promise { return invoke("git_pull", { path }) } +export async function gitStartPullMerge(path: string): Promise { + return invoke("git_start_pull_merge", { path }) +} + export async function gitFetch(path: string): Promise { return invoke("git_fetch", { path }) } @@ -503,7 +509,7 @@ export async function gitMerge( export async function gitRebase( path: string, branchName: string -): Promise { +): Promise { return invoke("git_rebase", { path, branchName }) } @@ -515,6 +521,46 @@ export async function gitDeleteBranch( return invoke("git_delete_branch", { path, branchName, force }) } +export async function gitListConflicts(path: string): Promise { + return invoke("git_list_conflicts", { path }) +} + +export async function gitConflictFileVersions( + path: string, + file: string +): Promise { + return invoke("git_conflict_file_versions", { path, file }) +} + +export async function gitResolveConflict( + path: string, + file: string, + content: string +): Promise { + return invoke("git_resolve_conflict", { path, file, content }) +} + +export async function gitAbortOperation( + path: string, + operation: string +): Promise { + return invoke("git_abort_operation", { path, operation }) +} + +export async function gitContinueOperation( + path: string, + operation: string +): Promise { + return invoke("git_continue_operation", { path, operation }) +} + +export async function openMergeWindow( + folderId: number, + operation: string +): Promise { + return invoke("open_merge_window", { folderId, operation }) +} + export async function gitStash(path: string): Promise { return invoke("git_stash", { path }) } diff --git a/src/lib/types.ts b/src/lib/types.ts index b70cede..45eaec8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -666,8 +666,15 @@ export interface GitBranchList { worktree_branches: string[] } +export interface GitConflictInfo { + has_conflicts: boolean + conflicted_files: string[] + operation: string +} + export interface GitPullResult { updated_files: number + conflict?: GitConflictInfo | null } export interface GitPushResult { @@ -677,6 +684,19 @@ export interface GitPushResult { export interface GitMergeResult { merged_commits: number + conflict?: GitConflictInfo | null +} + +export interface GitRebaseResult { + message: string + conflict?: GitConflictInfo | null +} + +export interface GitConflictFileVersions { + base: string + ours: string + theirs: string + merged: string } export interface GitCommitResult {