Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Backend/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,4 @@ directories = "6"
serde_json = "1.0"
time = { version = "0.3", features = ["local-offset"] }
zip = "6.0"
shlex = "1.2"
4 changes: 4 additions & 0 deletions Backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,10 @@ fn build_invoke_handler<R: tauri::Runtime>() -> impl Fn(tauri::ipc::Invoke<R>) -
tauri_commands::open_repo,
tauri_commands::clone_repo,
tauri_commands::git_diff_file,
tauri_commands::git_conflict_details,
tauri_commands::git_resolve_conflict_side,
tauri_commands::git_save_merge_result,
tauri_commands::git_launch_merge_tool,
tauri_commands::git_delete_branch,
tauri_commands::git_merge_branch,
tauri_commands::git_diff_commit,
Expand Down
132 changes: 132 additions & 0 deletions Backend/src/tauri_commands/conflicts.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
use std::path::PathBuf;
use std::process::Command;

use log::{debug, warn};
use openvcs_core::models::{ConflictDetails, ConflictSide};
use shlex::split;
use tauri::State;

use crate::settings::ExternalTool;
use crate::state::AppState;

use super::{current_repo_or_err, run_repo_task};

#[tauri::command]
pub async fn git_conflict_details(
state: State<'_, AppState>,
path: String,
) -> Result<ConflictDetails, String> {
let repo = current_repo_or_err(&state)?;
run_repo_task("git_conflict_details", repo, move |repo| {
repo.inner()
.conflict_details(&PathBuf::from(&path))
.map_err(|e| e.to_string())
})
.await
}

#[tauri::command]
pub async fn git_resolve_conflict_side(
state: State<'_, AppState>,
path: String,
side: String,
) -> Result<(), String> {
let repo = current_repo_or_err(&state)?;
run_repo_task("git_resolve_conflict_side", repo, move |repo| {
let which = match side.to_lowercase().as_str() {
"ours" => ConflictSide::Ours,
"theirs" => ConflictSide::Theirs,
other => {
return Err(format!("invalid conflict side '{other}'"));
}
};
repo.inner()
.checkout_conflict_side(&PathBuf::from(&path), which)
.map_err(|e| e.to_string())
})
.await
}

#[tauri::command]
pub async fn git_save_merge_result(
state: State<'_, AppState>,
path: String,
content: String,
) -> Result<(), String> {
let repo = current_repo_or_err(&state)?;
run_repo_task("git_save_merge_result", repo, move |repo| {
repo.inner()
.write_merge_result(&PathBuf::from(&path), content.as_bytes())
.map_err(|e| e.to_string())
})
.await
}

fn tool_args(tool: &ExternalTool) -> (String, Vec<String>) {
let path = tool.path.clone();
let args = split(tool.args.trim())
.unwrap_or_default()
.into_iter()
.collect::<Vec<_>>();
(path, args)
}

#[tauri::command]
pub async fn git_launch_merge_tool(
state: State<'_, AppState>,
path: String,
) -> Result<(), String> {
let cfg = state.config();
let tool = cfg.diff.external_merge.clone();
if !tool.enabled || tool.path.trim().is_empty() {
return Err("no external merge tool configured".into());
}

let repo = current_repo_or_err(&state)?;
let (tool_path, args_template) = tool_args(&tool);
let has_args = !tool.args.trim().is_empty();
let includes_placeholder = args_template.iter().any(|arg| arg.contains("{path}"));

run_repo_task("git_launch_merge_tool", repo, move |repo| {
let repo_root = repo.inner().workdir().to_path_buf();
let rel = PathBuf::from(&path);
let abs = if rel.is_absolute() { rel.clone() } else { repo_root.join(&rel) };

let mut cmd = Command::new(&tool_path);
cmd.current_dir(&repo_root);

let replace_tokens = |raw: &str| {
raw.replace("{path}", abs.to_string_lossy().as_ref())
.replace("{repo}", repo_root.to_string_lossy().as_ref())
};

let mut expanded: Vec<String> = Vec::new();
if !has_args {
expanded.push(abs.to_string_lossy().to_string());
} else {
for arg in args_template {
expanded.push(replace_tokens(&arg));
}
if !includes_placeholder {
expanded.push(abs.to_string_lossy().to_string());
}
}

for arg in &expanded {
cmd.arg(arg);
}

debug!(
"git_launch_merge_tool: spawning {} with args {:?}",
tool_path,
expanded
);

cmd.spawn().map(|_| ()).map_err(|e| {
warn!("git_launch_merge_tool: failed to spawn merge tool: {e}");
e.to_string()
})
})
.await
}

2 changes: 2 additions & 0 deletions Backend/src/tauri_commands/mod.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
mod backends;
mod branches;
mod commit;
mod conflicts;
mod general;
mod lfs;
mod remotes;
Expand All @@ -14,6 +15,7 @@ mod themes;
pub use backends::*;
pub use branches::*;
pub use commit::*;
pub use conflicts::*;
pub use general::*;
pub use lfs::*;
pub use remotes::*;
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

34 changes: 34 additions & 0 deletions Frontend/src/modals/merge.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<div class="modal" id="merge-modal" aria-hidden="true">
<div class="backdrop" data-close="true"></div>
<div class="dialog merge-dialog" role="dialog" aria-modal="true" aria-labelledby="merge-title">
<header class="modal-head">
<h2 id="merge-title">Resolve merge conflict</h2>
<button class="icon-btn" data-close="true" aria-label="Close">×</button>
</header>
<section class="merge-info">
<div class="merge-path" id="merge-path"></div>
</section>
<section class="merge-columns">
<div class="merge-column">
<h3>Base</h3>
<pre id="merge-base" class="merge-readonly"></pre>
</div>
<div class="merge-column">
<h3>Mine</h3>
<pre id="merge-ours" class="merge-readonly"></pre>
</div>
<div class="merge-column">
<h3>Theirs</h3>
<pre id="merge-theirs" class="merge-readonly"></pre>
</div>
</section>
<section class="merge-editor">
<label for="merge-result">Merged result</label>
<textarea id="merge-result" spellcheck="false"></textarea>
</section>
<footer class="modal-foot">
<button class="btn" data-close="true">Cancel</button>
<button class="btn primary" id="merge-apply">Apply merge</button>
</footer>
</div>
</div>
24 changes: 24 additions & 0 deletions Frontend/src/modals/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,30 @@ <h3 id="settings-title" style="margin:0">Settings</h3>
<span class="help-tip" title="Show a placeholder message for binary files in the diff view.">?</span>
</label>
</div>

<div class="group">
<label for="set-merge-mode">Merge conflicts
<span class="help-tip" title="Choose how conflicts are resolved from the diff view.">?</span>
</label>
<select id="set-merge-mode">
<option value="builtin">Built-in merge editor</option>
<option value="custom">Launch custom tool</option>
</select>
</div>

<div class="group" data-merge-custom>
<label for="set-merge-path">Custom merge tool path
<span class="help-tip" title="Executable to launch when resolving conflicts.">?</span>
</label>
<input id="set-merge-path" type="text" placeholder="/usr/bin/tool" />
</div>

<div class="group" data-merge-custom>
<label for="set-merge-args">Custom merge tool arguments
<span class="help-tip" title="Use placeholders like {mine}, {theirs}, and {merged}.">?</span>
</label>
<input id="set-merge-args" type="text" placeholder="--arg {mine} {theirs} {merged}" />
</div>
</form>

<!-- LFS -->
Expand Down
107 changes: 107 additions & 0 deletions Frontend/src/scripts/features/conflicts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { TAURI } from '../lib/tauri';
import { notify } from '../lib/notify';
import { hydrate, openModal, closeModal } from '../ui/modals';
import { hydrateStatus } from './repo';
import type { FileStatus, ConflictDetails, GlobalSettings } from '../types';

let mergeModalWired = false;
let currentConflict: { path: string; details: ConflictDetails } | null = null;

const externalToolState = {
loaded: false,
enabled: false,
};

async function ensureMergeModal() {
hydrate('merge-modal');
if (mergeModalWired) return;
const modal = document.getElementById('merge-modal') as HTMLElement | null;
if (!modal) return;

const applyBtn = modal.querySelector<HTMLButtonElement>('#merge-apply');
applyBtn?.addEventListener('click', async () => {
if (!TAURI.has) { notify('Saving merges requires the desktop app.'); return; }
if (!currentConflict) { notify('No conflict selected.'); return; }
const textarea = modal.querySelector<HTMLTextAreaElement>('#merge-result');
const content = textarea?.value ?? '';
const path = currentConflict.path;
try {
await TAURI.invoke('git_save_merge_result', { path, content });
notify('Saved merge result');
closeModal('merge-modal');
await Promise.allSettled([hydrateStatus()]);
} catch (err) {
console.error(err);
notify('Failed to save merge result');
}
});

mergeModalWired = true;
}

function setPreText(root: HTMLElement, selector: string, value?: string | null) {
const el = root.querySelector<HTMLElement>(selector);
if (!el) return;
el.textContent = value ?? '';
}

export async function openMergeModal(file: FileStatus, details: ConflictDetails) {
await ensureMergeModal();
const modal = document.getElementById('merge-modal') as HTMLElement | null;
if (!modal) return;

currentConflict = { path: file.path, details };

const pathLabel = modal.querySelector<HTMLElement>('#merge-path');
if (pathLabel) pathLabel.textContent = file.path || '(unknown file)';

const base = details.base ?? '';
const ours = details.ours ?? '';
const theirs = details.theirs ?? '';
setPreText(modal, '#merge-base', base);
setPreText(modal, '#merge-ours', ours);
setPreText(modal, '#merge-theirs', theirs);

const textarea = modal.querySelector<HTMLTextAreaElement>('#merge-result');
if (textarea) {
textarea.value = ours || theirs || base || '';
}

openModal('merge-modal');
}

async function ensureExternalMergeConfig() {
if (externalToolState.loaded || !TAURI.has) return;
try {
const cfg = await TAURI.invoke<GlobalSettings>('get_global_settings');
const tool = cfg?.diff?.external_merge;
externalToolState.enabled = !!(tool && tool.enabled && (tool.path || '').trim().length > 0);
} catch (err) {
console.error('Failed to load merge tool config', err);
externalToolState.enabled = false;
} finally {
externalToolState.loaded = true;
}
}

export async function hasExternalMergeTool(): Promise<boolean> {
if (!TAURI.has) return false;
await ensureExternalMergeConfig();
return externalToolState.enabled;
}

export async function launchExternalMergeTool(path: string): Promise<void> {
if (!TAURI.has) { notify('Launching merge tools requires the desktop app.'); return; }
if (!(await hasExternalMergeTool())) {
notify('No custom merge tool configured');
return;
}
try {
await TAURI.invoke('git_launch_merge_tool', { path });
notify('Opened custom merge tool');
} catch (err) {
console.error(err);
notify('Failed to open merge tool');
}
}

Loading
Loading