支持git冲突时弹出窗口合并代码解决冲突
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "../gen/schemas/desktop-schema.json",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "Capability for the main window",
|
"description": "Capability for the main window",
|
||||||
"windows": ["welcome", "folder-*", "commit-*", "settings"],
|
"windows": ["welcome", "folder-*", "commit-*", "merge-*", "settings"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
|
|||||||
@@ -33,9 +33,17 @@ pub struct GitBranchList {
|
|||||||
pub worktree_branches: Vec<String>,
|
pub worktree_branches: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct GitConflictInfo {
|
||||||
|
pub has_conflicts: bool,
|
||||||
|
pub conflicted_files: Vec<String>,
|
||||||
|
pub operation: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct GitPullResult {
|
pub struct GitPullResult {
|
||||||
pub updated_files: usize,
|
pub updated_files: usize,
|
||||||
|
pub conflict: Option<GitConflictInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -47,6 +55,21 @@ pub struct GitPushResult {
|
|||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct GitMergeResult {
|
pub struct GitMergeResult {
|
||||||
pub merged_commits: usize,
|
pub merged_commits: usize,
|
||||||
|
pub conflict: Option<GitConflictInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct GitRebaseResult {
|
||||||
|
pub message: String,
|
||||||
|
pub conflict: Option<GitConflictInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct GitConflictFileVersions {
|
||||||
|
pub base: String,
|
||||||
|
pub ours: String,
|
||||||
|
pub theirs: String,
|
||||||
|
pub merged: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
@@ -160,6 +183,25 @@ fn git_command_error(operation: &str, stderr: &[u8]) -> AppCommandError {
|
|||||||
AppCommandError::external_command(format!("git {operation} failed"), stderr)
|
AppCommandError::external_command(format!("git {operation} failed"), stderr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn detect_conflicts(path: &str) -> Result<Vec<String>, 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<Option<String>, AppCommandError> {
|
async fn get_head_hash(path: &str) -> Result<Option<String>, AppCommandError> {
|
||||||
let output = crate::process::tokio_command("git")
|
let output = crate::process::tokio_command("git")
|
||||||
.args(["rev-parse", "HEAD"])
|
.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<GitPullResult, AppCommandError> {
|
pub async fn git_pull(path: String) -> Result<GitPullResult, AppCommandError> {
|
||||||
let head_before = get_head_hash(&path).await?;
|
let head_before = get_head_hash(&path).await?;
|
||||||
|
|
||||||
let output = crate::process::tokio_command("git")
|
// Step 1: fetch from remote
|
||||||
.args(["pull"])
|
let fetch_output = crate::process::tokio_command("git")
|
||||||
|
.args(["fetch"])
|
||||||
.current_dir(&path)
|
.current_dir(&path)
|
||||||
.output()
|
.output()
|
||||||
.await
|
.await
|
||||||
.map_err(AppCommandError::io)?;
|
.map_err(AppCommandError::io)?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !fetch_output.status.success() {
|
||||||
return Err(git_command_error("pull", &output.stderr));
|
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?;
|
let head_after = get_head_hash(&path).await?;
|
||||||
@@ -504,7 +641,34 @@ pub async fn git_pull(path: String) -> Result<GitPullResult, AppCommandError> {
|
|||||||
_ => 0,
|
_ => 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]
|
#[tauri::command]
|
||||||
@@ -1241,13 +1405,30 @@ pub async fn git_merge(
|
|||||||
.map_err(AppCommandError::io)?;
|
.map_err(AppCommandError::io)?;
|
||||||
|
|
||||||
if !output.status.success() {
|
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));
|
return Err(git_command_error("merge", &output.stderr));
|
||||||
}
|
}
|
||||||
Ok(GitMergeResult { merged_commits })
|
Ok(GitMergeResult {
|
||||||
|
merged_commits,
|
||||||
|
conflict: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn git_rebase(path: String, branch_name: String) -> Result<String, AppCommandError> {
|
pub async fn git_rebase(
|
||||||
|
path: String,
|
||||||
|
branch_name: String,
|
||||||
|
) -> Result<GitRebaseResult, AppCommandError> {
|
||||||
let output = crate::process::tokio_command("git")
|
let output = crate::process::tokio_command("git")
|
||||||
.args(["rebase", &branch_name])
|
.args(["rebase", &branch_name])
|
||||||
.current_dir(&path)
|
.current_dir(&path)
|
||||||
@@ -1256,9 +1437,23 @@ pub async fn git_rebase(path: String, branch_name: String) -> Result<String, App
|
|||||||
.map_err(AppCommandError::io)?;
|
.map_err(AppCommandError::io)?;
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
|
let conflicted_files = detect_conflicts(&path).await?;
|
||||||
|
if !conflicted_files.is_empty() {
|
||||||
|
return Ok(GitRebaseResult {
|
||||||
|
message: String::from_utf8_lossy(&output.stdout).trim().to_string(),
|
||||||
|
conflict: Some(GitConflictInfo {
|
||||||
|
has_conflicts: true,
|
||||||
|
conflicted_files,
|
||||||
|
operation: "rebase".to_string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
return Err(git_command_error("rebase", &output.stderr));
|
return Err(git_command_error("rebase", &output.stderr));
|
||||||
}
|
}
|
||||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
Ok(GitRebaseResult {
|
||||||
|
message: String::from_utf8_lossy(&output.stdout).trim().to_string(),
|
||||||
|
conflict: None,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1281,6 +1476,145 @@ pub async fn git_delete_branch(
|
|||||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn git_list_conflicts(path: String) -> Result<Vec<String>, AppCommandError> {
|
||||||
|
detect_conflicts(&path).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn git_conflict_file_versions(
|
||||||
|
path: String,
|
||||||
|
file: String,
|
||||||
|
) -> Result<GitConflictFileVersions, AppCommandError> {
|
||||||
|
// :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 WATCH_IGNORED_DIRS: &[&str] = &["__pycache__"];
|
||||||
const FILE_TREE_IGNORED_DIRS: &[&str] = &[".git", "__pycache__"];
|
const FILE_TREE_IGNORED_DIRS: &[&str] = &[".git", "__pycache__"];
|
||||||
|
|
||||||
|
|||||||
@@ -391,6 +391,108 @@ pub fn restore_window_after_commit(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct MergeWindowState {
|
||||||
|
owner_by_merge_label: Mutex<HashMap<String, String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> {
|
||||||
|
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> {
|
pub fn open_welcome_window(app: &AppHandle) -> Result<(), AppCommandError> {
|
||||||
if let Some(existing) = app.get_webview_window("welcome") {
|
if let Some(existing) = app.get_webview_window("welcome") {
|
||||||
ensure_windows_undecorated(&existing);
|
ensure_windows_undecorated(&existing);
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ pub fn run() {
|
|||||||
.manage(TerminalManager::new())
|
.manage(TerminalManager::new())
|
||||||
.manage(windows::SettingsWindowState::new())
|
.manage(windows::SettingsWindowState::new())
|
||||||
.manage(windows::CommitWindowState::new())
|
.manage(windows::CommitWindowState::new())
|
||||||
|
.manage(windows::MergeWindowState::new())
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let app_data_dir = app.path().app_data_dir()?;
|
let app_data_dir = app.path().app_data_dir()?;
|
||||||
let app_version = env!("CARGO_PKG_VERSION");
|
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::MergeWindowState>() {
|
||||||
|
windows::restore_window_after_merge(app, &state, &label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
if let tauri::WindowEvent::CloseRequested { .. } = event {
|
||||||
if label.starts_with("folder-") {
|
if label.starts_with("folder-") {
|
||||||
let app = window.app_handle();
|
let app = window.app_handle();
|
||||||
@@ -181,6 +194,7 @@ pub fn run() {
|
|||||||
folders::get_git_branch,
|
folders::get_git_branch,
|
||||||
folders::git_init,
|
folders::git_init,
|
||||||
folders::git_pull,
|
folders::git_pull,
|
||||||
|
folders::git_start_pull_merge,
|
||||||
folders::git_fetch,
|
folders::git_fetch,
|
||||||
folders::git_push,
|
folders::git_push,
|
||||||
folders::git_new_branch,
|
folders::git_new_branch,
|
||||||
@@ -207,6 +221,11 @@ pub fn run() {
|
|||||||
folders::git_merge,
|
folders::git_merge,
|
||||||
folders::git_rebase,
|
folders::git_rebase,
|
||||||
folders::git_delete_branch,
|
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::save_folder_opened_conversations,
|
||||||
folders::start_file_tree_watch,
|
folders::start_file_tree_watch,
|
||||||
folders::stop_file_tree_watch,
|
folders::stop_file_tree_watch,
|
||||||
@@ -226,6 +245,7 @@ pub fn run() {
|
|||||||
windows::open_settings_window,
|
windows::open_settings_window,
|
||||||
windows::list_open_folders,
|
windows::list_open_folders,
|
||||||
windows::focus_folder_window,
|
windows::focus_folder_window,
|
||||||
|
windows::open_merge_window,
|
||||||
system_settings::get_system_proxy_settings,
|
system_settings::get_system_proxy_settings,
|
||||||
system_settings::update_system_proxy_settings,
|
system_settings::update_system_proxy_settings,
|
||||||
system_settings::get_system_language_settings,
|
system_settings::get_system_language_settings,
|
||||||
|
|||||||
@@ -347,3 +347,173 @@
|
|||||||
background-color: rgba(248, 81, 73, 0.2);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
128
src/app/merge/page.tsx
Normal file
128
src/app/merge/page.tsx
Normal file
@@ -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<FolderLoadState>({
|
||||||
|
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 (
|
||||||
|
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||||
|
<AppTitleBar
|
||||||
|
center={
|
||||||
|
<div className="text-sm font-semibold tracking-tight">
|
||||||
|
{t("title")}
|
||||||
|
{hasValidFolderId && folder ? ` · ${folder.name}` : ""}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<main className="flex-1 min-h-0 p-3">
|
||||||
|
{!hasValidFolderId ? (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{t("invalidFolderId")}
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t("loadingRepo")}
|
||||||
|
</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : folder ? (
|
||||||
|
<MergeWorkspace
|
||||||
|
folderId={normalizedFolderId}
|
||||||
|
folderPath={folder.path}
|
||||||
|
operation={operation}
|
||||||
|
onCompleted={closeWindow}
|
||||||
|
onAborted={closeWindow}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<AppToaster
|
||||||
|
position="bottom-right"
|
||||||
|
duration={TOAST_DURATION_MS}
|
||||||
|
closeButton
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MergePage() {
|
||||||
|
return (
|
||||||
|
<Suspense>
|
||||||
|
<MergePageInner />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -80,10 +80,13 @@ import {
|
|||||||
openFolderWindow,
|
openFolderWindow,
|
||||||
openCommitWindow,
|
openCommitWindow,
|
||||||
setFolderParentBranch,
|
setFolderParentBranch,
|
||||||
|
gitListConflicts,
|
||||||
} from "@/lib/tauri"
|
} from "@/lib/tauri"
|
||||||
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
|
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
|
||||||
|
import { ConflictDialog } from "@/components/layout/conflict-dialog"
|
||||||
import { disposeTauriListener } from "@/lib/tauri-listener"
|
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 { toast } from "sonner"
|
||||||
import { useFolderContext } from "@/contexts/folder-context"
|
import { useFolderContext } from "@/contexts/folder-context"
|
||||||
import { useTaskContext } from "@/contexts/task-context"
|
import { useTaskContext } from "@/contexts/task-context"
|
||||||
@@ -134,6 +137,7 @@ export function BranchDropdown({
|
|||||||
const [worktreeBranchName, setWorktreeBranchName] = useState("")
|
const [worktreeBranchName, setWorktreeBranchName] = useState("")
|
||||||
const [worktreePath, setWorktreePath] = useState("")
|
const [worktreePath, setWorktreePath] = useState("")
|
||||||
const [manageRemotesOpen, setManageRemotesOpen] = useState(false)
|
const [manageRemotesOpen, setManageRemotesOpen] = useState(false)
|
||||||
|
const [conflictInfo, setConflictInfo] = useState<GitConflictInfo | null>(null)
|
||||||
const taskSeq = useRef(0)
|
const taskSeq = useRef(0)
|
||||||
const worktreeBranchSet = useMemo(
|
const worktreeBranchSet = useMemo(
|
||||||
() => new Set(branchList.worktree_branches),
|
() => new Set(branchList.worktree_branches),
|
||||||
@@ -184,7 +188,7 @@ export function BranchDropdown({
|
|||||||
async function runGitTask<T>(
|
async function runGitTask<T>(
|
||||||
label: string,
|
label: string,
|
||||||
action: () => Promise<T>,
|
action: () => Promise<T>,
|
||||||
getSuccessDescription?: (result: T) => string | undefined
|
getSuccessDescription?: (result: T) => string | false | undefined
|
||||||
) {
|
) {
|
||||||
const taskId = `git-${++taskSeq.current}-${Date.now()}`
|
const taskId = `git-${++taskSeq.current}-${Date.now()}`
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -195,6 +199,7 @@ export function BranchDropdown({
|
|||||||
const successDescription = getSuccessDescription?.(result)
|
const successDescription = getSuccessDescription?.(result)
|
||||||
updateTask(taskId, { status: "completed" })
|
updateTask(taskId, { status: "completed" })
|
||||||
onBranchChange()
|
onBranchChange()
|
||||||
|
if (successDescription !== false) {
|
||||||
toast.success(
|
toast.success(
|
||||||
t("toasts.taskCompleted", { label }),
|
t("toasts.taskCompleted", { label }),
|
||||||
successDescription
|
successDescription
|
||||||
@@ -203,11 +208,13 @@ export function BranchDropdown({
|
|||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
)
|
)
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
removeTask(taskId)
|
removeTask(taskId)
|
||||||
const errorTitle = t("toasts.taskFailed", { label })
|
const errorTitle = t("toasts.taskFailed", { label })
|
||||||
pushAlert("error", errorTitle, String(err))
|
const errorMsg = toErrorMessage(err)
|
||||||
toast.error(errorTitle, { description: String(err) })
|
pushAlert("error", errorTitle, errorMsg)
|
||||||
|
toast.error(errorTitle, { description: errorMsg })
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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() {
|
function handleMergeParent() {
|
||||||
if (!parentBranch) return
|
if (!parentBranch) return
|
||||||
setConfirmAction({ type: "merge", branchName: parentBranch })
|
setConfirmAction({ type: "merge", branchName: parentBranch })
|
||||||
@@ -316,6 +434,10 @@ export function BranchDropdown({
|
|||||||
t("tasks.mergeBranch", { branchName }),
|
t("tasks.mergeBranch", { branchName }),
|
||||||
() => gitMerge(folderPath, branchName),
|
() => gitMerge(folderPath, branchName),
|
||||||
(result) => {
|
(result) => {
|
||||||
|
if (result.conflict?.has_conflicts) {
|
||||||
|
setConflictInfo(result.conflict)
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (result.merged_commits === 0) {
|
if (result.merged_commits === 0) {
|
||||||
return t("toasts.mergeNoNewCommits", { branchName })
|
return t("toasts.mergeNoNewCommits", { branchName })
|
||||||
}
|
}
|
||||||
@@ -324,8 +446,16 @@ export function BranchDropdown({
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
case "rebase":
|
case "rebase":
|
||||||
await runGitTask(t("tasks.rebaseTo", { branchName }), () =>
|
await runGitTask(
|
||||||
gitRebase(folderPath, branchName)
|
t("tasks.rebaseTo", { branchName }),
|
||||||
|
() => gitRebase(folderPath, branchName),
|
||||||
|
(result) => {
|
||||||
|
if (result.conflict?.has_conflicts) {
|
||||||
|
setConflictInfo(result.conflict)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
case "delete":
|
case "delete":
|
||||||
@@ -520,6 +650,10 @@ export function BranchDropdown({
|
|||||||
t("tasks.pullCode"),
|
t("tasks.pullCode"),
|
||||||
() => gitPull(folderPath),
|
() => gitPull(folderPath),
|
||||||
(result) => {
|
(result) => {
|
||||||
|
if (result.conflict?.has_conflicts) {
|
||||||
|
setConflictInfo(result.conflict)
|
||||||
|
return false
|
||||||
|
}
|
||||||
if (result.updated_files === 0) {
|
if (result.updated_files === 0) {
|
||||||
return t("toasts.allFilesUpToDate")
|
return t("toasts.allFilesUpToDate")
|
||||||
}
|
}
|
||||||
@@ -552,39 +686,16 @@ export function BranchDropdown({
|
|||||||
setDropdownOpen(false)
|
setDropdownOpen(false)
|
||||||
openCommitWindow(folder.id).catch((err) => {
|
openCommitWindow(folder.id).catch((err) => {
|
||||||
const title = t("toasts.openCommitWindowFailed")
|
const title = t("toasts.openCommitWindowFailed")
|
||||||
pushAlert("error", title, String(err))
|
const msg = toErrorMessage(err)
|
||||||
toast.error(title, { description: String(err) })
|
pushAlert("error", title, msg)
|
||||||
|
toast.error(title, { description: msg })
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
<GitCommitHorizontal className="h-3.5 w-3.5" />
|
||||||
{t("openCommitWindow")}
|
{t("openCommitWindow")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem disabled={loading} onSelect={handlePush}>
|
||||||
disabled={loading}
|
|
||||||
onSelect={() =>
|
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Upload className="h-3.5 w-3.5" />
|
<Upload className="h-3.5 w-3.5" />
|
||||||
{t("pushCode")}
|
{t("pushCode")}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -846,6 +957,14 @@ export function BranchDropdown({
|
|||||||
folderPath={folderPath}
|
folderPath={folderPath}
|
||||||
onSaved={() => loadAllBranches()}
|
onSaved={() => loadAllBranches()}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConflictDialog
|
||||||
|
conflictInfo={conflictInfo}
|
||||||
|
folderId={folder?.id ?? 0}
|
||||||
|
folderPath={folderPath}
|
||||||
|
onClose={() => setConflictInfo(null)}
|
||||||
|
onResolved={onBranchChange}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
243
src/components/layout/conflict-dialog.tsx
Normal file
243
src/components/layout/conflict-dialog.tsx
Normal file
@@ -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<string[]>([])
|
||||||
|
const [resolvedFiles, setResolvedFiles] = useState<Set<string>>(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 (
|
||||||
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||||
|
{t("title")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{t("description")}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<ScrollArea className="max-h-60">
|
||||||
|
<div className="space-y-1 pr-3">
|
||||||
|
{conflictedFiles.map((file) => {
|
||||||
|
const isResolved = resolvedFiles.has(file)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file}
|
||||||
|
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm"
|
||||||
|
>
|
||||||
|
{isResolved ? (
|
||||||
|
<Check className="h-3.5 w-3.5 shrink-0 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<FileWarning className="h-3.5 w-3.5 shrink-0 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
isResolved
|
||||||
|
? "text-muted-foreground line-through"
|
||||||
|
: "text-foreground"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{file}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
<DialogFooter className="flex-row justify-between sm:justify-between">
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAbort}
|
||||||
|
disabled={aborting || completing}
|
||||||
|
>
|
||||||
|
{aborting && (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("abort")}
|
||||||
|
</Button>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleOpenMergeTool}
|
||||||
|
disabled={aborting || completing}
|
||||||
|
>
|
||||||
|
{t("openMergeTool")}
|
||||||
|
</Button>
|
||||||
|
{allResolved && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleComplete}
|
||||||
|
disabled={completing || aborting}
|
||||||
|
>
|
||||||
|
{completing && (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("completeMerge")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
102
src/components/merge/conflict-parser.ts
Normal file
102
src/components/merge/conflict-parser.ts
Normal file
@@ -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(">>>>>>>")
|
||||||
|
}
|
||||||
355
src/components/merge/merge-diff.ts
Normal file
355
src/components/merge/merge-diff.ts
Normal file
@@ -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<number>(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<number>()
|
||||||
|
|
||||||
|
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, "left" | "right">
|
||||||
|
): 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")
|
||||||
|
}
|
||||||
291
src/components/merge/merge-workspace.tsx
Normal file
291
src/components/merge/merge-workspace.tsx
Normal file
@@ -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<string[]>([])
|
||||||
|
const [resolvedFiles, setResolvedFiles] = useState<Set<string>>(new Set())
|
||||||
|
const [selectedFile, setSelectedFile] = useState<string | null>(null)
|
||||||
|
const [versions, setVersions] = useState<GitConflictFileVersions | null>(null)
|
||||||
|
const [loadingVersions, setLoadingVersions] = useState(false)
|
||||||
|
const [resolving, setResolving] = useState(false)
|
||||||
|
const [aborting, setAborting] = useState(false)
|
||||||
|
const [completing, setCompleting] = useState(false)
|
||||||
|
const currentContentRef = useRef<string>("")
|
||||||
|
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 (
|
||||||
|
<div className="flex h-full flex-col gap-2">
|
||||||
|
<ResizablePanelGroup
|
||||||
|
direction="horizontal"
|
||||||
|
className="flex-1 min-h-0 rounded-lg border"
|
||||||
|
>
|
||||||
|
{/* Left sidebar: conflict file list */}
|
||||||
|
<ResizablePanel defaultSize={18} minSize={12} maxSize={30}>
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
<div className="border-b px-3 py-2 text-xs font-medium text-muted-foreground">
|
||||||
|
{t("conflictFiles")} ({files.length})
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-1">
|
||||||
|
{files.map((file) => {
|
||||||
|
const isResolved = resolvedFiles.has(file)
|
||||||
|
const isSelected = file === selectedFile
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={file}
|
||||||
|
type="button"
|
||||||
|
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? "bg-accent text-accent-foreground"
|
||||||
|
: "hover:bg-accent/50"
|
||||||
|
}`}
|
||||||
|
onClick={() => !isResolved && selectFile(file)}
|
||||||
|
disabled={isResolved}
|
||||||
|
>
|
||||||
|
{isResolved ? (
|
||||||
|
<Check className="h-3 w-3 shrink-0 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<FileWarning className="h-3 w-3 shrink-0 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`truncate ${isResolved ? "text-muted-foreground line-through" : ""}`}
|
||||||
|
>
|
||||||
|
{file}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{files.length === 0 && (
|
||||||
|
<div className="px-2 py-4 text-center text-xs text-muted-foreground">
|
||||||
|
{t("noConflicts")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle />
|
||||||
|
|
||||||
|
{/* Main area: three-pane merge editor */}
|
||||||
|
<ResizablePanel defaultSize={82}>
|
||||||
|
{loadingVersions ? (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
{t("loadingFile")}
|
||||||
|
</div>
|
||||||
|
) : versions && selectedFile ? (
|
||||||
|
<ThreePaneMergeEditor
|
||||||
|
key={selectedFile}
|
||||||
|
base={versions.base}
|
||||||
|
ours={versions.ours}
|
||||||
|
theirs={versions.theirs}
|
||||||
|
merged={versions.merged}
|
||||||
|
language={language}
|
||||||
|
onContentChange={handleContentChange}
|
||||||
|
onConflictStatusChange={handleConflictStatusChange}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
{t("selectFile")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
|
||||||
|
{/* Bottom toolbar */}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAbort}
|
||||||
|
disabled={aborting || completing || resolving}
|
||||||
|
>
|
||||||
|
{aborting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||||
|
<X className="mr-1 h-3.5 w-3.5" />
|
||||||
|
{t("abortMerge")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleResolve}
|
||||||
|
disabled={
|
||||||
|
!selectedFile ||
|
||||||
|
resolving ||
|
||||||
|
aborting ||
|
||||||
|
completing ||
|
||||||
|
(selectedFile !== null && resolvedFiles.has(selectedFile))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{resolving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||||
|
<Check className="mr-1 h-3.5 w-3.5" />
|
||||||
|
{t("markResolved")}
|
||||||
|
</Button>
|
||||||
|
{allResolved && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleComplete}
|
||||||
|
disabled={completing || aborting}
|
||||||
|
>
|
||||||
|
{completing && (
|
||||||
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
|
)}
|
||||||
|
<CheckCheck className="mr-1 h-3.5 w-3.5" />
|
||||||
|
{t("completeMerge")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
758
src/components/merge/three-pane-merge-editor.tsx
Normal file
758
src/components/merge/three-pane-merge-editor.tsx
Normal file
@@ -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<MonacoEditorNs.IStandaloneCodeEditor | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const centerEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
const rightEditorRef = useRef<MonacoEditorNs.IStandaloneCodeEditor | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decorations collections
|
||||||
|
const leftDecorationsRef =
|
||||||
|
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null)
|
||||||
|
const centerDecorationsRef =
|
||||||
|
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(null)
|
||||||
|
const rightDecorationsRef =
|
||||||
|
useRef<MonacoEditorNs.IEditorDecorationsCollection | null>(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<string, "left" | "right">
|
||||||
|
>(new Map())
|
||||||
|
|
||||||
|
// Track ignored hunks
|
||||||
|
const [ignoredHunks, setIgnoredHunks] = useState<Set<string>>(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 = (
|
||||||
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
||||||
|
Loading editor...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn("flex h-full flex-col", className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center border-b bg-muted/50 px-3 py-1.5">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("localVersion")}
|
||||||
|
</div>
|
||||||
|
<div className="flex min-w-0 flex-1 items-center justify-center gap-2">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-foreground">
|
||||||
|
{t("result")}
|
||||||
|
{unresolvedConflicts > 0 && (
|
||||||
|
<span className="text-red-500">
|
||||||
|
({unresolvedConflicts}{" "}
|
||||||
|
{unresolvedConflicts === 1 ? "conflict" : "conflicts"})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{pendingNonConflicts > 0 && (
|
||||||
|
<span className="text-amber-500">
|
||||||
|
({pendingNonConflicts} pending)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{totalChanges > 0 &&
|
||||||
|
unresolvedConflicts === 0 &&
|
||||||
|
pendingNonConflicts === 0 && (
|
||||||
|
<span className="text-green-500">
|
||||||
|
<CheckCheck className="inline h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{pendingNonConflicts > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-1.5 text-[10px]"
|
||||||
|
onClick={handleApplyLeftNonConflicting}
|
||||||
|
>
|
||||||
|
<ArrowRight className="mr-0.5 h-2.5 w-2.5" />
|
||||||
|
{t("applyLeftNonConflicting")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-1.5 text-[10px]"
|
||||||
|
onClick={handleApplyAllNonConflicting}
|
||||||
|
>
|
||||||
|
{t("applyAllNonConflicting")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-1.5 text-[10px]"
|
||||||
|
onClick={handleApplyRightNonConflicting}
|
||||||
|
>
|
||||||
|
{t("applyRightNonConflicting")}
|
||||||
|
<ArrowLeft className="ml-0.5 h-2.5 w-2.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-medium text-muted-foreground">
|
||||||
|
{t("remoteVersion")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Three-panel layout: [left editor + gutter] | center editor | [gutter + right editor] */}
|
||||||
|
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
|
||||||
|
{/* Left: Ours (local) + arrow gutter */}
|
||||||
|
<ResizablePanel defaultSize={34} minSize={15}>
|
||||||
|
<div className="flex h-full">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<MonacoEditor
|
||||||
|
value={ours}
|
||||||
|
language={language}
|
||||||
|
theme={editorTheme}
|
||||||
|
beforeMount={defineMonacoThemes}
|
||||||
|
onMount={handleLeftMount}
|
||||||
|
loading={loadingEl}
|
||||||
|
options={readonlyOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ArrowGutter
|
||||||
|
items={leftGutterItems}
|
||||||
|
direction="right"
|
||||||
|
editorRef={leftEditorRef}
|
||||||
|
scrollTick={scrollTick}
|
||||||
|
onApply={(id) => applyHunk(id, "left")}
|
||||||
|
title={t("acceptLocal")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle />
|
||||||
|
|
||||||
|
{/* Center: Result (editable) */}
|
||||||
|
<ResizablePanel defaultSize={32} minSize={15}>
|
||||||
|
<MonacoEditor
|
||||||
|
defaultValue={base}
|
||||||
|
language={language}
|
||||||
|
theme={editorTheme}
|
||||||
|
beforeMount={defineMonacoThemes}
|
||||||
|
onMount={handleCenterMount}
|
||||||
|
loading={loadingEl}
|
||||||
|
options={editorOptions}
|
||||||
|
/>
|
||||||
|
</ResizablePanel>
|
||||||
|
|
||||||
|
<ResizableHandle />
|
||||||
|
|
||||||
|
{/* Right: arrow gutter + Theirs (remote) */}
|
||||||
|
<ResizablePanel defaultSize={34} minSize={15}>
|
||||||
|
<div className="flex h-full">
|
||||||
|
<ArrowGutter
|
||||||
|
items={rightGutterItems}
|
||||||
|
direction="left"
|
||||||
|
editorRef={rightEditorRef}
|
||||||
|
scrollTick={scrollTick}
|
||||||
|
onApply={(id) => applyHunk(id, "right")}
|
||||||
|
title={t("acceptRemote")}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<MonacoEditor
|
||||||
|
value={theirs}
|
||||||
|
language={language}
|
||||||
|
theme={editorTheme}
|
||||||
|
beforeMount={defineMonacoThemes}
|
||||||
|
onMount={handleRightMount}
|
||||||
|
loading={loadingEl}
|
||||||
|
options={readonlyOptions}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</ResizablePanel>
|
||||||
|
</ResizablePanelGroup>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Arrow Gutter Component
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface ArrowGutterProps {
|
||||||
|
items: Array<{ hunk: MergeHunk; lineNumber: number }>
|
||||||
|
direction: "left" | "right"
|
||||||
|
editorRef: React.RefObject<MonacoEditorNs.IStandaloneCodeEditor | null>
|
||||||
|
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 (
|
||||||
|
<div className="merge-gutter-column">
|
||||||
|
{positioned.map(({ hunk, top }) => (
|
||||||
|
<button
|
||||||
|
key={hunk.id}
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
"merge-gutter-arrow-btn",
|
||||||
|
hunk.type === "conflict"
|
||||||
|
? "merge-gutter-arrow-conflict"
|
||||||
|
: "merge-gutter-arrow-accept"
|
||||||
|
)}
|
||||||
|
style={{ top: `${top}px` }}
|
||||||
|
onClick={() => onApply(hunk.id)}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
{direction === "right" ? "\u00BB" : "\u00AB"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 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
|
||||||
|
}
|
||||||
44
src/components/merge/use-sync-scroll.ts
Normal file
44
src/components/merge/use-sync-scroll.ts
Normal file
@@ -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<EditorInstance[]>([])
|
||||||
|
|
||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -540,6 +540,31 @@
|
|||||||
"invalidFolderId": "معرّف المجلد غير صالح",
|
"invalidFolderId": "معرّف المجلد غير صالح",
|
||||||
"loadingRepo": "جارٍ تحميل المستودع..."
|
"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": {
|
"Folder": {
|
||||||
"common": {
|
"common": {
|
||||||
"all": "الكل",
|
"all": "الكل",
|
||||||
@@ -815,6 +840,15 @@
|
|||||||
"remoteUrlPlaceholder": "عنوان URL للمستودع البعيد",
|
"remoteUrlPlaceholder": "عنوان URL للمستودع البعيد",
|
||||||
"addRemote": "إضافة",
|
"addRemote": "إضافة",
|
||||||
"savingRemotes": "جارٍ الحفظ..."
|
"savingRemotes": "جارٍ الحفظ..."
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"title": "تعارضات الدمج",
|
||||||
|
"description": "الملفات التالية بها تعارضات تحتاج إلى حل:",
|
||||||
|
"abort": "إلغاء الدمج",
|
||||||
|
"openMergeTool": "فتح أداة الدمج",
|
||||||
|
"completeMerge": "إتمام الدمج",
|
||||||
|
"abortSuccess": "تم إلغاء الدمج بنجاح",
|
||||||
|
"completeSuccess": "تم إتمام الدمج بنجاح"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"commitDialog": {
|
"commitDialog": {
|
||||||
|
|||||||
@@ -540,6 +540,31 @@
|
|||||||
"invalidFolderId": "Ungültige Ordner-ID",
|
"invalidFolderId": "Ungültige Ordner-ID",
|
||||||
"loadingRepo": "Repository wird geladen..."
|
"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": {
|
"Folder": {
|
||||||
"common": {
|
"common": {
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
@@ -815,6 +840,15 @@
|
|||||||
"remoteUrlPlaceholder": "Remote-URL",
|
"remoteUrlPlaceholder": "Remote-URL",
|
||||||
"addRemote": "Hinzufügen",
|
"addRemote": "Hinzufügen",
|
||||||
"savingRemotes": "Speichern..."
|
"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": {
|
"commitDialog": {
|
||||||
|
|||||||
@@ -540,6 +540,31 @@
|
|||||||
"invalidFolderId": "Invalid folder ID",
|
"invalidFolderId": "Invalid folder ID",
|
||||||
"loadingRepo": "Loading repository..."
|
"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": {
|
"Folder": {
|
||||||
"common": {
|
"common": {
|
||||||
"all": "All",
|
"all": "All",
|
||||||
@@ -815,6 +840,15 @@
|
|||||||
"remoteUrlPlaceholder": "Remote URL",
|
"remoteUrlPlaceholder": "Remote URL",
|
||||||
"addRemote": "Add",
|
"addRemote": "Add",
|
||||||
"savingRemotes": "Saving..."
|
"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": {
|
"commitDialog": {
|
||||||
|
|||||||
@@ -540,6 +540,31 @@
|
|||||||
"invalidFolderId": "ID de carpeta no válido",
|
"invalidFolderId": "ID de carpeta no válido",
|
||||||
"loadingRepo": "Cargando repositorio..."
|
"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": {
|
"Folder": {
|
||||||
"common": {
|
"common": {
|
||||||
"all": "Todo",
|
"all": "Todo",
|
||||||
@@ -815,6 +840,15 @@
|
|||||||
"remoteUrlPlaceholder": "URL del remoto",
|
"remoteUrlPlaceholder": "URL del remoto",
|
||||||
"addRemote": "Añadir",
|
"addRemote": "Añadir",
|
||||||
"savingRemotes": "Guardando..."
|
"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": {
|
"commitDialog": {
|
||||||
|
|||||||
@@ -540,6 +540,31 @@
|
|||||||
"invalidFolderId": "ID de dossier invalide",
|
"invalidFolderId": "ID de dossier invalide",
|
||||||
"loadingRepo": "Chargement du dépôt..."
|
"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": {
|
"Folder": {
|
||||||
"common": {
|
"common": {
|
||||||
"all": "Tout",
|
"all": "Tout",
|
||||||
@@ -815,6 +840,15 @@
|
|||||||
"remoteUrlPlaceholder": "URL du dépôt distant",
|
"remoteUrlPlaceholder": "URL du dépôt distant",
|
||||||
"addRemote": "Ajouter",
|
"addRemote": "Ajouter",
|
||||||
"savingRemotes": "Enregistrement..."
|
"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": {
|
"commitDialog": {
|
||||||
|
|||||||
@@ -540,6 +540,31 @@
|
|||||||
"invalidFolderId": "無効なフォルダID",
|
"invalidFolderId": "無効なフォルダID",
|
||||||
"loadingRepo": "リポジトリを読み込み中..."
|
"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": {
|
"Folder": {
|
||||||
"common": {
|
"common": {
|
||||||
"all": "すべて",
|
"all": "すべて",
|
||||||
@@ -815,6 +840,15 @@
|
|||||||
"remoteUrlPlaceholder": "リモート URL",
|
"remoteUrlPlaceholder": "リモート URL",
|
||||||
"addRemote": "追加",
|
"addRemote": "追加",
|
||||||
"savingRemotes": "保存中..."
|
"savingRemotes": "保存中..."
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"title": "マージコンフリクト",
|
||||||
|
"description": "以下のファイルにコンフリクトがあります。解決が必要です:",
|
||||||
|
"abort": "マージを中止",
|
||||||
|
"openMergeTool": "マージツールを開く",
|
||||||
|
"completeMerge": "マージ完了",
|
||||||
|
"abortSuccess": "マージが中止されました",
|
||||||
|
"completeSuccess": "マージが完了しました"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"commitDialog": {
|
"commitDialog": {
|
||||||
|
|||||||
@@ -540,6 +540,31 @@
|
|||||||
"invalidFolderId": "유효하지 않은 폴더 ID",
|
"invalidFolderId": "유효하지 않은 폴더 ID",
|
||||||
"loadingRepo": "저장소를 불러오는 중..."
|
"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": {
|
"Folder": {
|
||||||
"common": {
|
"common": {
|
||||||
"all": "전체",
|
"all": "전체",
|
||||||
@@ -815,6 +840,15 @@
|
|||||||
"remoteUrlPlaceholder": "원격 URL",
|
"remoteUrlPlaceholder": "원격 URL",
|
||||||
"addRemote": "추가",
|
"addRemote": "추가",
|
||||||
"savingRemotes": "저장 중..."
|
"savingRemotes": "저장 중..."
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"title": "병합 충돌",
|
||||||
|
"description": "다음 파일에 충돌이 있어 해결이 필요합니다:",
|
||||||
|
"abort": "병합 중단",
|
||||||
|
"openMergeTool": "병합 도구 열기",
|
||||||
|
"completeMerge": "병합 완료",
|
||||||
|
"abortSuccess": "병합이 중단되었습니다",
|
||||||
|
"completeSuccess": "병합이 완료되었습니다"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"commitDialog": {
|
"commitDialog": {
|
||||||
|
|||||||
@@ -540,6 +540,31 @@
|
|||||||
"invalidFolderId": "ID de pasta inválido",
|
"invalidFolderId": "ID de pasta inválido",
|
||||||
"loadingRepo": "Carregando repositório..."
|
"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": {
|
"Folder": {
|
||||||
"common": {
|
"common": {
|
||||||
"all": "Todos",
|
"all": "Todos",
|
||||||
@@ -815,6 +840,15 @@
|
|||||||
"remoteUrlPlaceholder": "URL do remoto",
|
"remoteUrlPlaceholder": "URL do remoto",
|
||||||
"addRemote": "Adicionar",
|
"addRemote": "Adicionar",
|
||||||
"savingRemotes": "Salvando..."
|
"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": {
|
"commitDialog": {
|
||||||
|
|||||||
@@ -540,6 +540,31 @@
|
|||||||
"invalidFolderId": "无效的 folderId",
|
"invalidFolderId": "无效的 folderId",
|
||||||
"loadingRepo": "正在加载仓库..."
|
"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": {
|
"Folder": {
|
||||||
"common": {
|
"common": {
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
@@ -815,6 +840,15 @@
|
|||||||
"remoteUrlPlaceholder": "远程 URL",
|
"remoteUrlPlaceholder": "远程 URL",
|
||||||
"addRemote": "添加",
|
"addRemote": "添加",
|
||||||
"savingRemotes": "保存中..."
|
"savingRemotes": "保存中..."
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"title": "合并冲突",
|
||||||
|
"description": "以下文件存在冲突,需要手动解决:",
|
||||||
|
"abort": "中止合并",
|
||||||
|
"openMergeTool": "打开合并工具",
|
||||||
|
"completeMerge": "完成合并",
|
||||||
|
"abortSuccess": "合并已中止",
|
||||||
|
"completeSuccess": "合并完成"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"commitDialog": {
|
"commitDialog": {
|
||||||
|
|||||||
@@ -540,6 +540,31 @@
|
|||||||
"invalidFolderId": "無效的 folderId",
|
"invalidFolderId": "無效的 folderId",
|
||||||
"loadingRepo": "正在載入倉庫..."
|
"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": {
|
"Folder": {
|
||||||
"common": {
|
"common": {
|
||||||
"all": "全部",
|
"all": "全部",
|
||||||
@@ -815,6 +840,15 @@
|
|||||||
"remoteUrlPlaceholder": "遠端 URL",
|
"remoteUrlPlaceholder": "遠端 URL",
|
||||||
"addRemote": "新增",
|
"addRemote": "新增",
|
||||||
"savingRemotes": "儲存中..."
|
"savingRemotes": "儲存中..."
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"title": "合併衝突",
|
||||||
|
"description": "以下檔案存在衝突,需要手動解決:",
|
||||||
|
"abort": "中止合併",
|
||||||
|
"openMergeTool": "開啟合併工具",
|
||||||
|
"completeMerge": "完成合併",
|
||||||
|
"abortSuccess": "合併已中止",
|
||||||
|
"completeSuccess": "合併完成"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"commitDialog": {
|
"commitDialog": {
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import type {
|
|||||||
GitPullResult,
|
GitPullResult,
|
||||||
GitPushResult,
|
GitPushResult,
|
||||||
GitMergeResult,
|
GitMergeResult,
|
||||||
|
GitRebaseResult,
|
||||||
|
GitConflictFileVersions,
|
||||||
GitCommitResult,
|
GitCommitResult,
|
||||||
GitRemote,
|
GitRemote,
|
||||||
PreflightResult,
|
PreflightResult,
|
||||||
@@ -450,6 +452,10 @@ export async function gitPull(path: string): Promise<GitPullResult> {
|
|||||||
return invoke("git_pull", { path })
|
return invoke("git_pull", { path })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function gitStartPullMerge(path: string): Promise<void> {
|
||||||
|
return invoke("git_start_pull_merge", { path })
|
||||||
|
}
|
||||||
|
|
||||||
export async function gitFetch(path: string): Promise<string> {
|
export async function gitFetch(path: string): Promise<string> {
|
||||||
return invoke("git_fetch", { path })
|
return invoke("git_fetch", { path })
|
||||||
}
|
}
|
||||||
@@ -503,7 +509,7 @@ export async function gitMerge(
|
|||||||
export async function gitRebase(
|
export async function gitRebase(
|
||||||
path: string,
|
path: string,
|
||||||
branchName: string
|
branchName: string
|
||||||
): Promise<string> {
|
): Promise<GitRebaseResult> {
|
||||||
return invoke("git_rebase", { path, branchName })
|
return invoke("git_rebase", { path, branchName })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +521,46 @@ export async function gitDeleteBranch(
|
|||||||
return invoke("git_delete_branch", { path, branchName, force })
|
return invoke("git_delete_branch", { path, branchName, force })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function gitListConflicts(path: string): Promise<string[]> {
|
||||||
|
return invoke("git_list_conflicts", { path })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gitConflictFileVersions(
|
||||||
|
path: string,
|
||||||
|
file: string
|
||||||
|
): Promise<GitConflictFileVersions> {
|
||||||
|
return invoke("git_conflict_file_versions", { path, file })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gitResolveConflict(
|
||||||
|
path: string,
|
||||||
|
file: string,
|
||||||
|
content: string
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke("git_resolve_conflict", { path, file, content })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gitAbortOperation(
|
||||||
|
path: string,
|
||||||
|
operation: string
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke("git_abort_operation", { path, operation })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function gitContinueOperation(
|
||||||
|
path: string,
|
||||||
|
operation: string
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke("git_continue_operation", { path, operation })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openMergeWindow(
|
||||||
|
folderId: number,
|
||||||
|
operation: string
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke("open_merge_window", { folderId, operation })
|
||||||
|
}
|
||||||
|
|
||||||
export async function gitStash(path: string): Promise<string> {
|
export async function gitStash(path: string): Promise<string> {
|
||||||
return invoke("git_stash", { path })
|
return invoke("git_stash", { path })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -666,8 +666,15 @@ export interface GitBranchList {
|
|||||||
worktree_branches: string[]
|
worktree_branches: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitConflictInfo {
|
||||||
|
has_conflicts: boolean
|
||||||
|
conflicted_files: string[]
|
||||||
|
operation: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitPullResult {
|
export interface GitPullResult {
|
||||||
updated_files: number
|
updated_files: number
|
||||||
|
conflict?: GitConflictInfo | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitPushResult {
|
export interface GitPushResult {
|
||||||
@@ -677,6 +684,19 @@ export interface GitPushResult {
|
|||||||
|
|
||||||
export interface GitMergeResult {
|
export interface GitMergeResult {
|
||||||
merged_commits: number
|
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 {
|
export interface GitCommitResult {
|
||||||
|
|||||||
Reference in New Issue
Block a user