This commit is contained in:
Christoph Brandau
2026-06-27 17:42:58 +02:00
parent ef1974f31f
commit 25700821d1
6 changed files with 976 additions and 143 deletions
+143 -11
View File
@@ -53,6 +53,7 @@ pub struct GitCommit {
pub author_email: String, pub author_email: String,
pub date: String, pub date: String,
pub refs: Vec<String>, pub refs: Vec<String>,
pub parents: Vec<String>,
pub files: Vec<GitCommitFile>, pub files: Vec<GitCommitFile>,
} }
@@ -89,6 +90,9 @@ pub struct ConflictFile {
pub ours: Option<String>, pub ours: Option<String>,
pub theirs: Option<String>, pub theirs: Option<String>,
pub base: Option<String>, pub base: Option<String>,
pub binary: bool,
pub ours_size: Option<u64>,
pub theirs_size: Option<u64>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
@@ -317,7 +321,7 @@ pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>,
[ [
"log", "log",
"--decorate=short", "--decorate=short",
"--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%s%x1e", "--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1e",
"-n", "-n",
limit.as_str(), limit.as_str(),
], ],
@@ -349,7 +353,7 @@ pub fn list_file_history(
let mut args = vec![ let mut args = vec![
OsString::from("log"), OsString::from("log"),
OsString::from("--decorate=short"), OsString::from("--decorate=short"),
OsString::from("--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%s%x1e"), OsString::from("--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1e"),
OsString::from("-n"), OsString::from("-n"),
OsString::from(limit), OsString::from(limit),
]; ];
@@ -480,18 +484,79 @@ pub fn read_conflict(path: String, file: String) -> Result<ConflictFile, String>
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?; validate_files(std::slice::from_ref(&file))?;
let content = std::fs::read_to_string(repo.join(&file)) let bytes = std::fs::read(repo.join(&file))
.map_err(|err| format!("Konfliktdatei konnte nicht gelesen werden: {err}"))?; .map_err(|err| format!("Konfliktdatei konnte nicht gelesen werden: {err}"))?;
let binary = is_binary_bytes(&bytes);
// For binary files we cannot offer a text merge, so we only report the side
// sizes; the UI lets the user pick which side to keep wholesale.
if binary {
return Ok(ConflictFile {
path: file.clone(),
content: String::new(),
ours: None,
theirs: None,
base: None,
binary: true,
ours_size: index_stage_size(&repo, 2, &file),
theirs_size: index_stage_size(&repo, 3, &file),
});
}
Ok(ConflictFile { Ok(ConflictFile {
base: read_index_stage(&repo, 1, &file), base: read_index_stage(&repo, 1, &file),
ours: read_index_stage(&repo, 2, &file), ours: read_index_stage(&repo, 2, &file),
theirs: read_index_stage(&repo, 3, &file), theirs: read_index_stage(&repo, 3, &file),
binary: false,
ours_size: index_stage_size(&repo, 2, &file),
theirs_size: index_stage_size(&repo, 3, &file),
path: file, path: file,
content, content: String::from_utf8_lossy(&bytes).to_string(),
}) })
} }
#[tauri::command]
pub fn resolve_conflict_side(
path: String,
file: String,
side: String,
) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?;
validate_files(std::slice::from_ref(&file))?;
let flag = match side.as_str() {
"ours" => "--ours",
"theirs" => "--theirs",
_ => return Err("Ungueltige Seite. Erlaubt sind 'ours' oder 'theirs'.".to_string()),
};
run_git_with_paths(&repo, &["checkout", flag], std::slice::from_ref(&file))?;
run_git_with_paths(&repo, &["add"], std::slice::from_ref(&file))?;
status_for_repo(&repo)
}
fn is_binary_bytes(bytes: &[u8]) -> bool {
// Git's own heuristic: a NUL byte within the first 8000 bytes marks the
// blob as binary.
bytes.iter().take(8000).any(|byte| *byte == 0)
}
fn index_stage_size(repo: &Path, stage: u8, file: &str) -> Option<u64> {
let spec = format!(":{stage}:{file}");
let output = Command::new("git")
.arg("-C")
.arg(repo)
.args(["cat-file", "-s", spec.as_str()])
.output()
.ok()?;
if output.status.success() {
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
} else {
None
}
}
#[tauri::command] #[tauri::command]
pub fn resolve_conflict(path: String, file: String, content: String) -> Result<GitStatus, String> { pub fn resolve_conflict(path: String, file: String, content: String) -> Result<GitStatus, String> {
let repo = resolve_repo(&path)?; let repo = resolve_repo(&path)?;
@@ -673,8 +738,8 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String
continue; continue;
} }
let fields: Vec<&str> = record.splitn(7, FIELD_SEPARATOR).collect(); let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect();
if fields.len() != 7 { if fields.len() != 8 {
return Err(format!("Unerwarteter Git-Log-Eintrag: {record}")); return Err(format!("Unerwarteter Git-Log-Eintrag: {record}"));
} }
@@ -685,6 +750,11 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String
.map(ToString::to_string) .map(ToString::to_string)
.collect(); .collect();
let parents = fields[6]
.split_whitespace()
.map(ToString::to_string)
.collect();
let hash = fields[0].to_string(); let hash = fields[0].to_string();
let files = commit_files(repo, &hash)?; let files = commit_files(repo, &hash)?;
@@ -695,7 +765,8 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String
author_email: fields[3].to_string(), author_email: fields[3].to_string(),
date: fields[4].to_string(), date: fields[4].to_string(),
refs, refs,
summary: fields[6].to_string(), parents,
summary: fields[7].to_string(),
files, files,
}); });
} }
@@ -716,8 +787,8 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result<Vec<GitCommit>, String> {
continue; continue;
} }
let fields: Vec<&str> = record.splitn(7, FIELD_SEPARATOR).collect(); let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect();
if fields.len() != 7 { if fields.len() != 8 {
return Err(format!("Unerwarteter Git-Log-Eintrag: {record}")); return Err(format!("Unerwarteter Git-Log-Eintrag: {record}"));
} }
@@ -728,6 +799,11 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result<Vec<GitCommit>, String> {
.map(ToString::to_string) .map(ToString::to_string)
.collect(); .collect();
let parents = fields[6]
.split_whitespace()
.map(ToString::to_string)
.collect();
commits.push(GitCommit { commits.push(GitCommit {
hash: fields[0].to_string(), hash: fields[0].to_string(),
short_hash: fields[1].to_string(), short_hash: fields[1].to_string(),
@@ -735,7 +811,8 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result<Vec<GitCommit>, String> {
author_email: fields[3].to_string(), author_email: fields[3].to_string(),
date: fields[4].to_string(), date: fields[4].to_string(),
refs, refs,
summary: fields[6].to_string(), parents,
summary: fields[7].to_string(),
files: Vec::new(), files: Vec::new(),
}); });
} }
@@ -1451,7 +1528,7 @@ mod tests {
#[test] #[test]
fn parses_commit_history_records() { fn parses_commit_history_records() {
let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\x1fAdd history panel\x1e"; let raw = b"1111111111111111111111111111111111111111\x1f1111111\x1fAda Lovelace\x1fada@example.com\x1f2026-06-26T12:34:56+02:00\x1fHEAD -> main, tag: v1\x1f2222222222222222222222222222222222222222 3333333333333333333333333333333333333333\x1fAdd history panel\x1e";
let commits = parse_commit_log_metadata(raw).unwrap(); let commits = parse_commit_log_metadata(raw).unwrap();
assert_eq!( assert_eq!(
@@ -1463,6 +1540,10 @@ mod tests {
author_email: "ada@example.com".to_string(), author_email: "ada@example.com".to_string(),
date: "2026-06-26T12:34:56+02:00".to_string(), date: "2026-06-26T12:34:56+02:00".to_string(),
refs: vec!["HEAD -> main".to_string(), "tag: v1".to_string()], refs: vec!["HEAD -> main".to_string(), "tag: v1".to_string()],
parents: vec![
"2222222222222222222222222222222222222222".to_string(),
"3333333333333333333333333333333333333333".to_string(),
],
summary: "Add history panel".to_string(), summary: "Add history panel".to_string(),
files: Vec::new(), files: Vec::new(),
}] }]
@@ -1646,6 +1727,57 @@ mod tests {
assert_eq!(contents.replace("\r\n", "\n"), "resolved\n"); assert_eq!(contents.replace("\r\n", "\n"), "resolved\n");
} }
#[test]
fn detects_binary_content_by_nul_byte() {
assert!(is_binary_bytes(&[0u8, 1, 2, 3]));
assert!(!is_binary_bytes(b"plain text content\n"));
}
#[test]
fn binary_conflict_can_be_resolved_by_side() {
let repo = init_temp_repo("binary_conflict");
fs::write(repo.path.join("img.bin"), [0u8, 1, 2, 3]).expect("base binary should be written");
run_git_test(&repo.path, ["add", "img.bin"]);
run_git_test(&repo.path, ["commit", "-q", "-m", "base"]);
let main_branch = git_output_test(&repo.path, ["rev-parse", "--abbrev-ref", "HEAD"]);
run_git_test(&repo.path, ["checkout", "-q", "-b", "feature"]);
fs::write(repo.path.join("img.bin"), [0u8, 9, 9, 9, 9, 9]).expect("feature binary");
run_git_test(&repo.path, ["commit", "-q", "-am", "feature bin"]);
run_git_test(&repo.path, ["checkout", "-q", main_branch.as_str()]);
fs::write(repo.path.join("img.bin"), [0u8, 7, 7]).expect("main binary");
run_git_test(&repo.path, ["commit", "-q", "-am", "main bin"]);
let _ = Command::new("git")
.arg("-C")
.arg(&repo.path)
.args(["merge", "--no-edit", "feature"])
.output()
.expect("git merge should start");
let conflict =
read_conflict(repo.path.to_string_lossy().to_string(), "img.bin".to_string()).unwrap();
assert!(conflict.binary);
assert!(conflict.content.is_empty());
assert_eq!(conflict.ours_size, Some(3));
assert_eq!(conflict.theirs_size, Some(6));
let status = resolve_conflict_side(
repo.path.to_string_lossy().to_string(),
"img.bin".to_string(),
"ours".to_string(),
)
.unwrap();
assert!(!status.files.iter().any(|file| {
matches!(file.staged, Some(FileStatusKind::Conflicted))
|| matches!(file.unstaged, Some(FileStatusKind::Conflicted))
}));
let bytes = fs::read(repo.path.join("img.bin")).unwrap();
assert_eq!(bytes, vec![0u8, 7, 7]);
}
#[test] #[test]
fn restore_staged_added_file_removes_it_from_index_and_worktree() { fn restore_staged_added_file_removes_it_from_index_and_worktree() {
let repo = init_temp_repo("restore_staged_added_file"); let repo = init_temp_repo("restore_staged_added_file");
+4 -3
View File
@@ -5,8 +5,8 @@ mod git;
use git::{ use git::{
checkout_branch, commit, compare_commits, diff_file_against_working_tree, get_status, checkout_branch, commit, compare_commits, diff_file_against_working_tree, get_status,
list_branches, list_commits, list_file_history, list_repository_files, merge_branch, list_branches, list_commits, list_file_history, list_repository_files, merge_branch,
open_repository, pull, push, read_conflict, resolve_conflict, restore_file_from_commit, open_repository, pull, push, read_conflict, resolve_conflict, resolve_conflict_side,
restore_files, restore_to_commit, stage_files, unstage_files, restore_file_from_commit, restore_files, restore_to_commit, stage_files, unstage_files,
}; };
fn main() { fn main() {
@@ -31,7 +31,8 @@ fn main() {
compare_commits, compare_commits,
diff_file_against_working_tree, diff_file_against_working_tree,
read_conflict, read_conflict,
resolve_conflict resolve_conflict,
resolve_conflict_side
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+563 -122
View File
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from "svelte";
import { import {
AlertCircle, AlertCircle,
ArrowRight, ArrowRight,
@@ -37,6 +39,7 @@
push, push,
readConflict, readConflict,
resolveConflict, resolveConflict,
resolveConflictSide,
restoreFileFromCommit, restoreFileFromCommit,
restoreFiles, restoreFiles,
restoreToCommit, restoreToCommit,
@@ -74,6 +77,35 @@
| { kind: "text"; lines: string[] } | { kind: "text"; lines: string[] }
| { kind: "conflict"; index: number; oursLines: string[]; theirsLines: string[] }; | { kind: "conflict"; index: number; oursLines: string[]; theirsLines: string[] };
type PreparedResolution =
| { kind: "content"; content: string }
| { kind: "side"; side: "ours" | "theirs" };
interface GraphSegment {
fromCol: number;
toCol: number;
color: string;
}
interface GraphRow {
dotCol: number;
dotColor: string;
top: GraphSegment[];
bottom: GraphSegment[];
}
const GRAPH_COLORS = [
"#2f6fb0",
"#4aa777",
"#c9851f",
"#a05bd0",
"#cc4b6e",
"#1f9ab0",
"#7a8a1f",
"#b0631f",
];
const GRAPH_LANE = 16;
let repoPath = ""; let repoPath = "";
let activeRepoPath = ""; let activeRepoPath = "";
let status: GitStatus | null = null; let status: GitStatus | null = null;
@@ -83,6 +115,7 @@
let selectedExplorerPath = ""; let selectedExplorerPath = "";
let selectedExplorerKind: ExplorerNodeKind = "file"; let selectedExplorerKind: ExplorerNodeKind = "file";
let expandedExplorerPaths = new Set<string>(); let expandedExplorerPaths = new Set<string>();
let expandedCommitHashes = new Set<string>();
let fileHistory: GitCommit[] = []; let fileHistory: GitCommit[] = [];
let commitMessage = ""; let commitMessage = "";
let errorMessage = ""; let errorMessage = "";
@@ -99,6 +132,13 @@
let conflictParts: ConflictPart[] = []; let conflictParts: ConflictPart[] = [];
let conflictChoices: (ConflictChoice | null)[] = []; let conflictChoices: (ConflictChoice | null)[] = [];
let manualMode = false; let manualMode = false;
let binarySide: "ours" | "theirs" | null = null;
let preparedResolutions: Record<string, PreparedResolution> = {};
let autoRefreshEnabled = true;
let autoRefreshInFlight = false;
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
$: isBusy = operation.length > 0; $: isBusy = operation.length > 0;
$: hasRepository = activeRepoPath.length > 0 && status !== null; $: hasRepository = activeRepoPath.length > 0 && status !== null;
@@ -124,6 +164,15 @@
? resolveContent ? resolveContent
: buildResolution(conflictParts, conflictChoices); : buildResolution(conflictParts, conflictChoices);
$: resolveHasMarkers = /^<{7}/m.test(resolvedContent) || /^>{7}/m.test(resolvedContent); $: resolveHasMarkers = /^<{7}/m.test(resolvedContent) || /^>{7}/m.test(resolvedContent);
$: preparedCount = Object.keys(preparedResolutions).length;
$: currentPrepared = conflictTarget.length > 0 && preparedResolutions[conflictTarget] != null;
$: canMarkResolved =
!!conflict && !isBusy && (conflict.binary ? binarySide != null : !resolveHasMarkers);
$: localBranches = branches.filter((branch) => !branch.remote);
$: remoteBranches = branches.filter((branch) => branch.remote);
$: graph = computeGraph(commits);
$: graphRows = graph.rows;
$: graphWidth = Math.max(graph.columns, 1) * GRAPH_LANE;
$: diffByPath = comparison ? buildDiffByPath(comparison.patch) : new Map<string, string>(); $: diffByPath = comparison ? buildDiffByPath(comparison.patch) : new Map<string, string>();
$: selectedDiffFile = comparison?.files.find((file) => file.path === selectedDiffPath) ?? null; $: selectedDiffFile = comparison?.files.find((file) => file.path === selectedDiffPath) ?? null;
$: selectedDiffPatch = selectedDiffFile ? diffByPath.get(selectedDiffFile.path) ?? "" : ""; $: selectedDiffPatch = selectedDiffFile ? diffByPath.get(selectedDiffFile.path) ?? "" : "";
@@ -137,8 +186,71 @@
status = nextStatus; status = nextStatus;
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim(); activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
repoPath = activeRepoPath; repoPath = activeRepoPath;
lastStatusFingerprint = statusFingerprint(nextStatus);
} }
function statusFingerprint(value: GitStatus): string {
return JSON.stringify({
branch: value.current_branch,
upstream: value.upstream,
ahead: value.ahead,
behind: value.behind,
files: value.files,
});
}
async function autoRefreshTick() {
if (
!autoRefreshEnabled ||
!activeRepoPath ||
isBusy ||
autoRefreshInFlight ||
resolveDialogOpen ||
compareDialogOpen
) {
return;
}
autoRefreshInFlight = true;
try {
const nextStatus = await getStatus(activeRepoPath);
// Cheap guard: only do the heavier refresh when something actually changed.
if (statusFingerprint(nextStatus) === lastStatusFingerprint) {
return;
}
applyStatus(nextStatus);
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
} catch {
// Ignore transient errors during background refresh (e.g. mid-operation).
} finally {
autoRefreshInFlight = false;
}
}
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) {
void autoRefreshTick();
}
}
onMount(() => {
autoRefreshTimer = setInterval(() => {
void autoRefreshTick();
}, AUTO_REFRESH_INTERVAL);
});
onDestroy(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
}
});
function buildExplorerTree(files: GitRepositoryFile[]): ExplorerNode[] { function buildExplorerTree(files: GitRepositoryFile[]): ExplorerNode[] {
const roots: ExplorerNode[] = []; const roots: ExplorerNode[] = [];
const folders = new Map<string, ExplorerNode>(); const folders = new Map<string, ExplorerNode>();
@@ -375,6 +487,7 @@
selectedExplorerPath = ""; selectedExplorerPath = "";
selectedExplorerKind = "file"; selectedExplorerKind = "file";
expandedExplorerPaths = new Set<string>(); expandedExplorerPaths = new Set<string>();
expandedCommitHashes = new Set<string>();
fileHistory = []; fileHistory = [];
compareFrom = ""; compareFrom = "";
compareTo = ""; compareTo = "";
@@ -388,6 +501,8 @@
conflictParts = []; conflictParts = [];
conflictChoices = []; conflictChoices = [];
manualMode = false; manualMode = false;
binarySide = null;
preparedResolutions = {};
await refreshBranchList(activeRepoPath); await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath); await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
@@ -639,10 +754,29 @@
async function loadConflict(file: string) { async function loadConflict(file: string) {
conflictTarget = file; conflictTarget = file;
conflict = await readConflict(activeRepoPath, file); conflict = await readConflict(activeRepoPath, file);
const prepared = preparedResolutions[file];
if (conflict.binary) {
conflictParts = [];
conflictChoices = [];
manualMode = false;
resolveContent = "";
binarySide = prepared && prepared.kind === "side" ? prepared.side : null;
return;
}
binarySide = null;
conflictParts = parseConflicts(conflict.content); conflictParts = parseConflicts(conflict.content);
conflictChoices = conflictParts.filter((part) => part.kind === "conflict").map(() => null); conflictChoices = conflictParts.filter((part) => part.kind === "conflict").map(() => null);
manualMode = false;
resolveContent = conflict.content; if (prepared && prepared.kind === "content") {
// Re-open an already prepared text resolution for review in manual mode.
resolveContent = prepared.content;
manualMode = true;
} else {
resolveContent = conflict.content;
manualMode = false;
}
} }
function parseConflicts(content: string): ConflictPart[] { function parseConflicts(content: string): ConflictPart[] {
@@ -765,6 +899,92 @@
manualMode = false; manualMode = false;
} }
function graphColX(column: number): number {
return column * GRAPH_LANE + GRAPH_LANE / 2;
}
function laneColor(column: number): string {
return GRAPH_COLORS[((column % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
}
function computeGraph(items: GitCommit[]): { rows: GraphRow[]; columns: number } {
const rows: GraphRow[] = [];
let lanes: (string | null)[] = [];
let maxColumns = 1;
for (const commit of items) {
const before = lanes.slice();
// Column for this commit: the first lane already waiting for it, else a
// free slot, else a brand new lane on the right.
let col = before.indexOf(commit.hash);
if (col === -1) {
col = before.indexOf(null);
if (col === -1) {
col = before.length;
}
}
const after = before.slice();
while (after.length <= col) {
after.push(null);
}
// Lanes that were waiting for this commit converge into it.
for (let k = 0; k < after.length; k += 1) {
if (after[k] === commit.hash) {
after[k] = null;
}
}
// The commit continues along its first parent in the same column.
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
// Slots that leave the commit dot (first parent + extra merge parents).
const fromCommit = new Set<number>([col]);
for (let p = 1; p < commit.parents.length; p += 1) {
let slot = after.indexOf(null);
if (slot === -1) {
slot = after.length;
after.push(null);
}
after[slot] = commit.parents[p];
fromCommit.add(slot);
}
const top: GraphSegment[] = [];
for (let k = 0; k < before.length; k += 1) {
const target = before[k];
if (target == null) {
continue;
}
const toCol = target === commit.hash ? col : k;
top.push({ fromCol: k, toCol, color: laneColor(k) });
}
const bottom: GraphSegment[] = [];
for (let k = 0; k < after.length; k += 1) {
if (after[k] == null) {
continue;
}
const fromCol = fromCommit.has(k) ? col : k;
bottom.push({ fromCol, toCol: k, color: laneColor(k) });
}
rows.push({ dotCol: col, dotColor: laneColor(col), top, bottom });
// Trim trailing empty lanes so the graph stays compact.
lanes = after.slice();
while (lanes.length > 0 && lanes[lanes.length - 1] == null) {
lanes.pop();
}
maxColumns = Math.max(maxColumns, before.length, after.length, col + 1);
}
return { rows, columns: maxColumns };
}
function displayLine(line: string): string { function displayLine(line: string): string {
// Strip a trailing CR for display only; the stored line keeps it so the // Strip a trailing CR for display only; the stored line keeps it so the
// rebuilt file preserves its original line endings. // rebuilt file preserves its original line endings.
@@ -786,6 +1006,7 @@
const first = conflictedFiles[0].path; const first = conflictedFiles[0].path;
await runOperation("Loading conflicts", async () => { await runOperation("Loading conflicts", async () => {
preparedResolutions = {};
resolveDialogOpen = true; resolveDialogOpen = true;
await loadConflict(first); await loadConflict(first);
}); });
@@ -801,29 +1022,59 @@
}); });
} }
async function saveResolution() { function chooseBinarySide(side: "ours" | "theirs") {
if (!activeRepoPath || !conflictTarget || isBusy) { binarySide = side;
}
async function markCurrentResolved() {
if (!conflict || !conflictTarget || !canMarkResolved) {
return; return;
} }
if ( const prepared: PreparedResolution = conflict.binary
resolveHasMarkers && ? { kind: "side", side: binarySide as "ours" | "theirs" }
!window.confirm( : { kind: "content", content: resolvedContent };
"Conflict markers (<<<<<<< / >>>>>>>) are still present. Mark this file as resolved anyway?",
)
) {
return;
}
const resolvedPath = conflictTarget; const resolvedPath = conflictTarget;
const content = resolvedContent; preparedResolutions = { ...preparedResolutions, [resolvedPath]: prepared };
await runOperation(`Resolving ${resolvedPath}`, async () => {
const nextStatus = await resolveConflict(activeRepoPath, resolvedPath, content); // Jump to the next file that still needs a decision, if any.
applyStatus(nextStatus); const next = conflictedFiles.find(
(file) => file.path !== resolvedPath && preparedResolutions[file.path] == null,
);
if (next) {
await runOperation(`Loading ${next.path}`, async () => {
await loadConflict(next.path);
});
}
}
async function applyPreparedResolutions() {
if (!activeRepoPath || isBusy || preparedCount === 0) {
return;
}
const entries = Object.entries(preparedResolutions);
await runOperation(`Resolving ${entries.length} ${entries.length === 1 ? "file" : "files"}`, async () => {
let nextStatus: GitStatus | null = null;
for (const [file, prepared] of entries) {
nextStatus =
prepared.kind === "side"
? await resolveConflictSide(activeRepoPath, file, prepared.side)
: await resolveConflict(activeRepoPath, file, prepared.content);
}
preparedResolutions = {};
if (nextStatus) {
applyStatus(nextStatus);
}
await refreshExplorerFiles(activeRepoPath); await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath); await refreshFileHistory(activeRepoPath);
const remaining = nextStatus.files.filter( const remaining = (nextStatus?.files ?? status?.files ?? []).filter(
(file) => file.staged === "conflicted" || file.unstaged === "conflicted", (file) => file.staged === "conflicted" || file.unstaged === "conflicted",
); );
@@ -835,12 +1086,26 @@
conflictParts = []; conflictParts = [];
conflictChoices = []; conflictChoices = [];
manualMode = false; manualMode = false;
binarySide = null;
} else { } else {
await loadConflict(remaining[0].path); await loadConflict(remaining[0].path);
} }
}); });
} }
function formatBytes(size: number | null): string {
if (size == null) {
return "missing";
}
if (size < 1024) {
return `${size} B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)} KB`;
}
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function closeResolveDialog() { function closeResolveDialog() {
resolveDialogOpen = false; resolveDialogOpen = false;
} }
@@ -997,6 +1262,16 @@
return `${file.old_path} -> ${file.path}`; return `${file.old_path} -> ${file.path}`;
} }
function toggleCommitFiles(hash: string) {
const next = new Set(expandedCommitHashes);
if (next.has(hash)) {
next.delete(hash);
} else {
next.add(hash);
}
expandedCommitHashes = next;
}
function displayCommitFile(file: GitCommitFile): string { function displayCommitFile(file: GitCommitFile): string {
if (!file.old_path) { if (!file.old_path) {
return file.path; return file.path;
@@ -1062,6 +1337,19 @@
<RefreshCw class={operation === "Refreshing" ? "spin" : ""} size={16} aria-hidden="true" /> <RefreshCw class={operation === "Refreshing" ? "spin" : ""} size={16} aria-hidden="true" />
Refresh Refresh
</button> </button>
<button
type="button"
class="auto-refresh-toggle"
class:active={autoRefreshEnabled}
onclick={toggleAutoRefresh}
aria-pressed={autoRefreshEnabled}
title={autoRefreshEnabled
? "Auto refresh is on — changes appear automatically"
: "Auto refresh is off"}
>
<RefreshCw class={autoRefreshInFlight ? "spin" : ""} size={16} aria-hidden="true" />
Auto {autoRefreshEnabled ? "on" : "off"}
</button>
</div> </div>
</header> </header>
@@ -1103,40 +1391,60 @@
<span class="counter">{branches.length}</span> <span class="counter">{branches.length}</span>
</div> </div>
{#snippet branchRow(branch: GitBranchInfo)}
<article class:current={branch.current} class="branch-row">
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>{branch.remote ? "remote" : "local"}</span>
</div>
</div>
{#if branch.current}
<span class="active-pill">Current</span>
{:else}
<div class="branch-actions">
<button type="button" onclick={() => checkout(branch)} disabled={isBusy}>Checkout</button>
<button
type="button"
onclick={() => merge(branch)}
disabled={isBusy}
title="Merge branch into current branch"
>
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/snippet}
{#if !hasRepository} {#if !hasRepository}
<p class="empty-note">Open a repository to list branches.</p> <p class="empty-note">Open a repository to list branches.</p>
{:else if branches.length === 0} {:else if branches.length === 0}
<p class="empty-note">No branches returned.</p> <p class="empty-note">No branches returned.</p>
{:else} {:else}
<div class="branch-list"> <div class="branch-list">
{#each branches as branch (branch.name)} {#if localBranches.length > 0}
<article class:current={branch.current} class="branch-row"> <div class="branch-group-label">
<div class="branch-info"> <span>Local</span>
<GitBranch size={16} aria-hidden="true" /> <span class="branch-group-count">{localBranches.length}</span>
<div> </div>
<strong>{branch.name}</strong> {#each localBranches as branch (branch.name)}
<span>{branch.remote ? "remote" : "local"}</span> {@render branchRow(branch)}
</div> {/each}
</div> {/if}
{#if branch.current} {#if remoteBranches.length > 0}
<span class="active-pill">Current</span> <div class="branch-group-label">
{:else} <span>Remote</span>
<div class="branch-actions"> <span class="branch-group-count">{remoteBranches.length}</span>
<button type="button" onclick={() => checkout(branch)} disabled={isBusy}>Checkout</button> </div>
<button {#each remoteBranches as branch (branch.name)}
type="button" {@render branchRow(branch)}
onclick={() => merge(branch)} {/each}
disabled={isBusy} {/if}
title="Merge branch into current branch"
>
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/each}
</div> </div>
{/if} {/if}
</section> </section>
@@ -1393,73 +1701,6 @@
{/if} {/if}
</section> </section>
<section class="history-panel" aria-label="Commit history">
<div class="section-heading">
<div>
<span class="eyebrow">History</span>
<h2>Commits</h2>
</div>
<span class="counter">{commits.length}</span>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if commits.length === 0}
<div class="blank-state">No commits returned.</div>
{:else}
<div class="history-list">
{#each commits as item (item.hash)}
<article class="commit-row">
<div class="commit-line">
<History size={16} aria-hidden="true" />
<div>
<strong title={item.summary}>{item.summary}</strong>
<span>{item.short_hash} - {item.author_name}</span>
</div>
</div>
{#if item.refs.length > 0}
<div class="ref-list" aria-label="Commit refs">
{#each item.refs as ref}
<span>{ref}</span>
{/each}
</div>
{/if}
{#if item.files.length > 0}
<div class="commit-file-list" aria-label="Changed files">
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
<button
type="button"
class="commit-file-button"
onclick={() => restoreCommitFile(item, file)}
disabled={isBusy}
title="Restore this file from this commit"
>
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
<strong>{displayCommitFile(file)}</strong>
</button>
{/each}
</div>
{/if}
<div class="commit-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<button
type="button"
onclick={() => restoreCommit(item)}
disabled={isBusy}
title="Reset current branch to this commit"
>
<RotateCcw size={15} aria-hidden="true" />
Restore
</button>
</div>
</article>
{/each}
</div>
{/if}
</section>
</aside> </aside>
</div> </div>
@@ -1533,6 +1774,137 @@
{/if} {/if}
</section> </section>
</section> </section>
<aside class="history-aside" aria-label="Commit history">
<section class="history-panel" aria-label="Commit history">
<div class="section-heading">
<div>
<span class="eyebrow">History</span>
<h2>Commits</h2>
</div>
<span class="counter">{commits.length}</span>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if commits.length === 0}
<div class="blank-state">No commits returned.</div>
{:else}
<div class="history-list graph-list">
{#each commits as item, rowIndex (item.hash)}
{@const row = graphRows[rowIndex]}
<article class="commit-row graph-row">
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
{#if row}
<svg
class="graph-svg"
viewBox={`0 0 ${graphWidth} 100`}
preserveAspectRatio="none"
>
{#each row.top as seg}
<line
x1={graphColX(seg.fromCol)}
y1="0"
x2={graphColX(seg.toCol)}
y2="50"
stroke={seg.color}
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
{/each}
{#each row.bottom as seg}
<line
x1={graphColX(seg.fromCol)}
y1="50"
x2={graphColX(seg.toCol)}
y2="100"
stroke={seg.color}
stroke-width="2"
vector-effect="non-scaling-stroke"
/>
{/each}
</svg>
<span
class="graph-dot"
class:merge={item.parents.length > 1}
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
></span>
{/if}
</div>
<div class="commit-body">
<div class="commit-line">
<div>
<strong title={item.summary}>{item.summary}</strong>
<span>{item.short_hash} - {item.author_name}</span>
</div>
</div>
{#if item.refs.length > 0}
<div class="ref-list" aria-label="Commit refs">
{#each item.refs as ref}
<span>{ref}</span>
{/each}
</div>
{/if}
{#if item.files.length > 0}
<div class="commit-files">
<button
type="button"
class="commit-files-toggle"
onclick={() => toggleCommitFiles(item.hash)}
aria-expanded={expandedCommitHashes.has(item.hash)}
>
{#if expandedCommitHashes.has(item.hash)}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
{item.files.length}
{item.files.length === 1 ? "file" : "files"} changed
</button>
{#if expandedCommitHashes.has(item.hash)}
<div class="commit-file-list" aria-label="Changed files">
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
<button
type="button"
class="commit-file-button"
onclick={() => restoreCommitFile(item, file)}
disabled={isBusy}
title="Restore this file from this commit"
>
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
<strong>{displayCommitFile(file)}</strong>
</button>
{/each}
</div>
{/if}
</div>
{/if}
<div class="commit-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button
type="button"
onclick={() => restoreCommit(item)}
disabled={isBusy}
title="Reset current branch to this commit"
>
<RotateCcw size={15} aria-hidden="true" />
Restore
</button>
</div>
</div>
</div>
</article>
{/each}
</div>
{/if}
</section>
</aside>
</section> </section>
{#if compareDialogOpen && comparison} {#if compareDialogOpen && comparison}
@@ -1641,13 +2013,19 @@
<button <button
type="button" type="button"
class:active={conflictTarget === file.path} class:active={conflictTarget === file.path}
class:prepared={preparedResolutions[file.path] != null}
class="dialog-file-row" class="dialog-file-row"
onclick={() => selectConflictFile(file.path)} onclick={() => selectConflictFile(file.path)}
disabled={isBusy} disabled={isBusy}
title={file.path} title={file.path}
> >
<span class="status-badge conflicted">conflicted</span> <span class={`status-badge ${preparedResolutions[file.path] ? "added" : "conflicted"}`}>
{preparedResolutions[file.path] ? "ready" : "conflicted"}
</span>
<strong>{file.path}</strong> <strong>{file.path}</strong>
{#if preparedResolutions[file.path]}
<Check size={15} aria-hidden="true" />
{/if}
</button> </button>
{/each} {/each}
</aside> </aside>
@@ -1656,7 +2034,48 @@
{#if !conflict} {#if !conflict}
<div class="blank-state">Select a file to resolve.</div> <div class="blank-state">Select a file to resolve.</div>
{:else} {:else}
<div class="resolve-toolbar"> {#if conflict.binary}
<div class="resolve-binary">
<div class="resolve-binary-note">
<AlertCircle size={16} aria-hidden="true" />
<span>
Binary file — it cannot be merged line by line. Pick which version
to keep, then mark it resolved.
</span>
</div>
<div class="resolve-binary-options">
<button
type="button"
class="resolve-binary-card ours"
class:active={binarySide === "ours"}
onclick={() => chooseBinarySide("ours")}
disabled={isBusy || conflict.ours_size == null}
>
<span class="resolve-side-label">Current (ours)</span>
<strong>{formatBytes(conflict.ours_size)}</strong>
<span class="resolve-binary-hint">
{conflict.ours_size == null ? "Deleted on this side" : "Keep this version"}
</span>
</button>
<button
type="button"
class="resolve-binary-card theirs"
class:active={binarySide === "theirs"}
onclick={() => chooseBinarySide("theirs")}
disabled={isBusy || conflict.theirs_size == null}
>
<span class="resolve-side-label">Incoming (theirs)</span>
<strong>{formatBytes(conflict.theirs_size)}</strong>
<span class="resolve-binary-hint">
{conflict.theirs_size == null ? "Deleted on this side" : "Keep this version"}
</span>
</button>
</div>
</div>
{:else}
<div class="resolve-toolbar">
{#if manualMode} {#if manualMode}
<button type="button" onclick={disableManualEdit} disabled={isBusy}> <button type="button" onclick={disableManualEdit} disabled={isBusy}>
Back to guided Back to guided
@@ -1773,27 +2192,49 @@
{/if} {/if}
{/each} {/each}
</div> </div>
{/if}
{/if} {/if}
<div class="resolve-actions"> <div class="resolve-actions">
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span> <span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
{#if currentPrepared}
<span class="prepared-tag">
<Check size={14} aria-hidden="true" />
Prepared
</span>
{/if}
<button <button
class="primary-button"
type="button" type="button"
onclick={saveResolution} onclick={markCurrentResolved}
disabled={isBusy} disabled={!canMarkResolved}
title="Prepare this file's resolution (applied with Apply resolved)"
> >
{#if operation.startsWith("Resolving")} <Check size={16} aria-hidden="true" />
<LoaderCircle class="spin" size={16} aria-hidden="true" /> {currentPrepared ? "Update decision" : "Mark as resolved"}
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Save &amp; mark resolved
</button> </button>
</div> </div>
{/if} {/if}
</div> </div>
</div> </div>
<footer class="dialog-footer">
<span class="dialog-footer-info">
{preparedCount} of {conflictedFiles.length} prepared
</span>
<button
class="primary-button"
type="button"
onclick={applyPreparedResolutions}
disabled={isBusy || preparedCount === 0}
>
{#if operation.startsWith("Resolving")}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Apply {preparedCount} resolved
</button>
</footer>
{/if} {/if}
</div> </div>
</div> </div>
+254 -7
View File
@@ -127,6 +127,17 @@ textarea {
gap: 8px; gap: 8px;
} }
.auto-refresh-toggle.active {
border-color: #2f8f6a;
color: #1b6e4f;
background: #e6f6ee;
}
.auto-refresh-toggle.active:hover:not(:disabled) {
border-color: #25785a;
background: #d8f0e3;
}
.primary-button { .primary-button {
border-color: #256f8f; border-color: #256f8f;
color: #ffffff; color: #ffffff;
@@ -180,11 +191,16 @@ textarea {
.workspace { .workspace {
display: grid; display: grid;
grid-template-columns: 340px minmax(0, 1fr); grid-template-columns: 320px minmax(0, 1fr) 440px;
min-height: 0; min-height: 0;
gap: 10px; gap: 10px;
} }
.history-aside {
display: grid;
min-height: 0;
}
.left-sidebar { .left-sidebar {
display: grid; display: grid;
grid-template-rows: minmax(220px, 0.9fr) minmax(260px, 1.1fr); grid-template-rows: minmax(220px, 0.9fr) minmax(260px, 1.1fr);
@@ -286,6 +302,34 @@ textarea {
padding: 8px; padding: 8px;
} }
.branch-group-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 2px 2px 6px;
color: #697681;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.branch-group-label:not(:first-child) {
margin-top: 12px;
}
.branch-group-count {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 7px;
border-radius: 999px;
color: #4f5d66;
background: #edf0f2;
font-size: 11px;
}
.branch-row { .branch-row {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@@ -485,7 +529,7 @@ textarea {
.side-stack { .side-stack {
display: grid; display: grid;
grid-template-rows: auto minmax(210px, 0.75fr) minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
min-height: 0; min-height: 0;
gap: 10px; gap: 10px;
} }
@@ -717,6 +761,28 @@ textarea {
white-space: nowrap; white-space: nowrap;
} }
.commit-files {
display: grid;
gap: 6px;
}
.commit-files-toggle {
justify-content: flex-start;
gap: 5px;
min-height: 26px;
padding: 0 8px;
border-color: transparent;
background: transparent;
color: #4f5d66;
font-size: 12px;
font-weight: 700;
}
.commit-files-toggle:hover:not(:disabled) {
border-color: #c7ced4;
background: #f6f8f9;
}
.commit-file-list { .commit-file-list {
display: grid; display: grid;
gap: 5px; gap: 5px;
@@ -769,6 +835,75 @@ textarea {
gap: 6px; gap: 6px;
} }
.graph-list {
padding: 0;
}
.graph-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: none;
}
.graph-row + .graph-row {
margin-top: 0;
}
.graph-gutter {
position: relative;
align-self: stretch;
min-height: 100%;
background: #fdfdfe;
}
.graph-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.graph-dot {
position: absolute;
top: 50%;
width: 11px;
height: 11px;
border-radius: 999px;
background: var(--dot-color, #2f6fb0);
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px var(--dot-color, #2f6fb0);
transform: translate(-50%, -50%);
}
.graph-dot.merge {
width: 13px;
height: 13px;
background: #ffffff;
border-color: var(--dot-color, #2f6fb0);
box-shadow: 0 0 0 1px var(--dot-color, #2f6fb0);
}
.commit-body {
display: grid;
gap: 8px;
min-width: 0;
padding: 11px 12px;
}
.graph-row + .graph-row .commit-body {
border-top: 1px solid #e7ebee;
}
.graph-row:hover .commit-body {
background: #f6f9fb;
}
.compare-panel { .compare-panel {
display: grid; display: grid;
grid-template-rows: auto auto; grid-template-rows: auto auto;
@@ -954,6 +1089,46 @@ textarea {
max-height: none; max-height: none;
} }
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid #dce1e5;
background: #f4f6f8;
}
.dialog-footer-info {
color: #4f5d66;
font-size: 13px;
font-weight: 700;
}
.dialog-file-row.prepared {
border-color: #b9ddc6;
background: #f0f8f3;
}
.dialog-file-row.prepared.active {
border-color: #4aa777;
background: #e4f4ea;
}
.dialog-file-row svg {
color: #1f7a4d;
}
.prepared-tag {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: auto;
color: #1f7a4d;
font-size: 12px;
font-weight: 700;
}
.dialog-title { .dialog-title {
margin: 2px 0 0; margin: 2px 0 0;
color: #202326; color: #202326;
@@ -961,8 +1136,8 @@ textarea {
} }
.resolve-editor { .resolve-editor {
display: grid; display: flex;
grid-template-rows: auto minmax(0, 1fr) auto; flex-direction: column;
min-height: 0; min-height: 0;
padding: 10px; padding: 10px;
gap: 8px; gap: 8px;
@@ -999,8 +1174,8 @@ textarea {
.resolve-textarea { .resolve-textarea {
width: 100%; width: 100%;
height: 100%; flex: 1 1 auto;
min-height: 0; min-height: 120px;
padding: 10px; padding: 10px;
border: 1px solid #c4ccd3; border: 1px solid #c4ccd3;
border-radius: 6px; border-radius: 6px;
@@ -1018,6 +1193,7 @@ textarea {
display: grid; display: grid;
align-content: start; align-content: start;
gap: 8px; gap: 8px;
flex: 1 1 auto;
min-height: 0; min-height: 0;
padding: 4px; padding: 4px;
overflow: auto; overflow: auto;
@@ -1148,6 +1324,67 @@ textarea {
color: #3a444c; color: #3a444c;
} }
.resolve-binary {
display: grid;
align-content: start;
gap: 12px;
padding: 4px;
}
.resolve-binary-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
border: 1px solid #e3b778;
border-radius: 6px;
color: #8a4c0e;
background: #fff6e7;
font-size: 13px;
}
.resolve-binary-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.resolve-binary-card {
display: grid;
gap: 6px;
justify-items: start;
padding: 14px;
border: 1px solid #dce1e5;
border-left-width: 3px;
border-radius: 8px;
text-align: left;
}
.resolve-binary-card.ours {
border-left-color: #4aa777;
background: #f1faf4;
}
.resolve-binary-card.theirs {
border-left-color: #5a8bd0;
background: #f1f5fc;
}
.resolve-binary-card.active {
box-shadow: 0 0 0 2px #2f6fb0 inset;
border-color: #2f6fb0;
}
.resolve-binary-card strong {
color: #202326;
font-size: 18px;
}
.resolve-binary-hint {
color: #6c7882;
font-size: 12px;
}
.resolve-actions { .resolve-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1250,9 +1487,15 @@ textarea {
} }
} }
@media (max-width: 1320px) {
.workspace {
grid-template-columns: 300px minmax(0, 1fr) 380px;
}
}
@media (max-width: 1120px) { @media (max-width: 1120px) {
.workspace { .workspace {
grid-template-columns: 300px minmax(0, 1fr); grid-template-columns: 260px minmax(0, 1fr) 330px;
} }
.status-grid { .status-grid {
@@ -1309,6 +1552,10 @@ textarea {
justify-content: flex-start; justify-content: flex-start;
} }
.history-aside {
min-height: 480px;
}
.branches { .branches {
min-height: 260px; min-height: 260px;
} }
+8
View File
@@ -108,3 +108,11 @@ export function resolveConflict(
): Promise<GitStatus> { ): Promise<GitStatus> {
return invoke<GitStatus>("resolve_conflict", { path, file, content }); return invoke<GitStatus>("resolve_conflict", { path, file, content });
} }
export function resolveConflictSide(
path: string,
file: string,
side: "ours" | "theirs",
): Promise<GitStatus> {
return invoke<GitStatus>("resolve_conflict_side", { path, file, side });
}
+4
View File
@@ -38,6 +38,7 @@ export interface GitCommit {
author_email: string; author_email: string;
date: string; date: string;
refs: string[]; refs: string[];
parents: string[];
files: GitCommitFile[]; files: GitCommitFile[];
} }
@@ -76,4 +77,7 @@ export interface ConflictFile {
ours: string | null; ours: string | null;
theirs: string | null; theirs: string | null;
base: string | null; base: string | null;
binary: boolean;
ours_size: number | null;
theirs_size: number | null;
} }