neu
This commit is contained in:
+143
-11
@@ -53,6 +53,7 @@ pub struct GitCommit {
|
||||
pub author_email: String,
|
||||
pub date: String,
|
||||
pub refs: Vec<String>,
|
||||
pub parents: Vec<String>,
|
||||
pub files: Vec<GitCommitFile>,
|
||||
}
|
||||
|
||||
@@ -89,6 +90,9 @@ pub struct ConflictFile {
|
||||
pub ours: Option<String>,
|
||||
pub theirs: 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)]
|
||||
@@ -317,7 +321,7 @@ pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>,
|
||||
[
|
||||
"log",
|
||||
"--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",
|
||||
limit.as_str(),
|
||||
],
|
||||
@@ -349,7 +353,7 @@ pub fn list_file_history(
|
||||
let mut args = vec![
|
||||
OsString::from("log"),
|
||||
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(limit),
|
||||
];
|
||||
@@ -480,18 +484,79 @@ pub fn read_conflict(path: String, file: String) -> Result<ConflictFile, String>
|
||||
let repo = resolve_repo(&path)?;
|
||||
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}"))?;
|
||||
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 {
|
||||
base: read_index_stage(&repo, 1, &file),
|
||||
ours: read_index_stage(&repo, 2, &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,
|
||||
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]
|
||||
pub fn resolve_conflict(path: String, file: String, content: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -673,8 +738,8 @@ fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String
|
||||
continue;
|
||||
}
|
||||
|
||||
let fields: Vec<&str> = record.splitn(7, FIELD_SEPARATOR).collect();
|
||||
if fields.len() != 7 {
|
||||
let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect();
|
||||
if fields.len() != 8 {
|
||||
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)
|
||||
.collect();
|
||||
|
||||
let parents = fields[6]
|
||||
.split_whitespace()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
|
||||
let hash = fields[0].to_string();
|
||||
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(),
|
||||
date: fields[4].to_string(),
|
||||
refs,
|
||||
summary: fields[6].to_string(),
|
||||
parents,
|
||||
summary: fields[7].to_string(),
|
||||
files,
|
||||
});
|
||||
}
|
||||
@@ -716,8 +787,8 @@ fn parse_commit_log_metadata(output: &[u8]) -> Result<Vec<GitCommit>, String> {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fields: Vec<&str> = record.splitn(7, FIELD_SEPARATOR).collect();
|
||||
if fields.len() != 7 {
|
||||
let fields: Vec<&str> = record.splitn(8, FIELD_SEPARATOR).collect();
|
||||
if fields.len() != 8 {
|
||||
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)
|
||||
.collect();
|
||||
|
||||
let parents = fields[6]
|
||||
.split_whitespace()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
|
||||
commits.push(GitCommit {
|
||||
hash: fields[0].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(),
|
||||
date: fields[4].to_string(),
|
||||
refs,
|
||||
summary: fields[6].to_string(),
|
||||
parents,
|
||||
summary: fields[7].to_string(),
|
||||
files: Vec::new(),
|
||||
});
|
||||
}
|
||||
@@ -1451,7 +1528,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
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();
|
||||
|
||||
assert_eq!(
|
||||
@@ -1463,6 +1540,10 @@ mod tests {
|
||||
author_email: "ada@example.com".to_string(),
|
||||
date: "2026-06-26T12:34:56+02:00".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(),
|
||||
files: Vec::new(),
|
||||
}]
|
||||
@@ -1646,6 +1727,57 @@ mod tests {
|
||||
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]
|
||||
fn restore_staged_added_file_removes_it_from_index_and_worktree() {
|
||||
let repo = init_temp_repo("restore_staged_added_file");
|
||||
|
||||
@@ -5,8 +5,8 @@ mod git;
|
||||
use git::{
|
||||
checkout_branch, commit, compare_commits, diff_file_against_working_tree, get_status,
|
||||
list_branches, list_commits, list_file_history, list_repository_files, merge_branch,
|
||||
open_repository, pull, push, read_conflict, resolve_conflict, restore_file_from_commit,
|
||||
restore_files, restore_to_commit, stage_files, unstage_files,
|
||||
open_repository, pull, push, read_conflict, resolve_conflict, resolve_conflict_side,
|
||||
restore_file_from_commit, restore_files, restore_to_commit, stage_files, unstage_files,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -31,7 +31,8 @@ fn main() {
|
||||
compare_commits,
|
||||
diff_file_against_working_tree,
|
||||
read_conflict,
|
||||
resolve_conflict
|
||||
resolve_conflict,
|
||||
resolve_conflict_side
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+563
-122
@@ -1,4 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
@@ -37,6 +39,7 @@
|
||||
push,
|
||||
readConflict,
|
||||
resolveConflict,
|
||||
resolveConflictSide,
|
||||
restoreFileFromCommit,
|
||||
restoreFiles,
|
||||
restoreToCommit,
|
||||
@@ -74,6 +77,35 @@
|
||||
| { kind: "text"; lines: 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 activeRepoPath = "";
|
||||
let status: GitStatus | null = null;
|
||||
@@ -83,6 +115,7 @@
|
||||
let selectedExplorerPath = "";
|
||||
let selectedExplorerKind: ExplorerNodeKind = "file";
|
||||
let expandedExplorerPaths = new Set<string>();
|
||||
let expandedCommitHashes = new Set<string>();
|
||||
let fileHistory: GitCommit[] = [];
|
||||
let commitMessage = "";
|
||||
let errorMessage = "";
|
||||
@@ -99,6 +132,13 @@
|
||||
let conflictParts: ConflictPart[] = [];
|
||||
let conflictChoices: (ConflictChoice | null)[] = [];
|
||||
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;
|
||||
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
||||
@@ -124,6 +164,15 @@
|
||||
? resolveContent
|
||||
: buildResolution(conflictParts, conflictChoices);
|
||||
$: 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>();
|
||||
$: selectedDiffFile = comparison?.files.find((file) => file.path === selectedDiffPath) ?? null;
|
||||
$: selectedDiffPatch = selectedDiffFile ? diffByPath.get(selectedDiffFile.path) ?? "" : "";
|
||||
@@ -137,8 +186,71 @@
|
||||
status = nextStatus;
|
||||
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
|
||||
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[] {
|
||||
const roots: ExplorerNode[] = [];
|
||||
const folders = new Map<string, ExplorerNode>();
|
||||
@@ -375,6 +487,7 @@
|
||||
selectedExplorerPath = "";
|
||||
selectedExplorerKind = "file";
|
||||
expandedExplorerPaths = new Set<string>();
|
||||
expandedCommitHashes = new Set<string>();
|
||||
fileHistory = [];
|
||||
compareFrom = "";
|
||||
compareTo = "";
|
||||
@@ -388,6 +501,8 @@
|
||||
conflictParts = [];
|
||||
conflictChoices = [];
|
||||
manualMode = false;
|
||||
binarySide = null;
|
||||
preparedResolutions = {};
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
@@ -639,10 +754,29 @@
|
||||
async function loadConflict(file: string) {
|
||||
conflictTarget = 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);
|
||||
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[] {
|
||||
@@ -765,6 +899,92 @@
|
||||
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 {
|
||||
// Strip a trailing CR for display only; the stored line keeps it so the
|
||||
// rebuilt file preserves its original line endings.
|
||||
@@ -786,6 +1006,7 @@
|
||||
|
||||
const first = conflictedFiles[0].path;
|
||||
await runOperation("Loading conflicts", async () => {
|
||||
preparedResolutions = {};
|
||||
resolveDialogOpen = true;
|
||||
await loadConflict(first);
|
||||
});
|
||||
@@ -801,29 +1022,59 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function saveResolution() {
|
||||
if (!activeRepoPath || !conflictTarget || isBusy) {
|
||||
function chooseBinarySide(side: "ours" | "theirs") {
|
||||
binarySide = side;
|
||||
}
|
||||
|
||||
async function markCurrentResolved() {
|
||||
if (!conflict || !conflictTarget || !canMarkResolved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
resolveHasMarkers &&
|
||||
!window.confirm(
|
||||
"Conflict markers (<<<<<<< / >>>>>>>) are still present. Mark this file as resolved anyway?",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const prepared: PreparedResolution = conflict.binary
|
||||
? { kind: "side", side: binarySide as "ours" | "theirs" }
|
||||
: { kind: "content", content: resolvedContent };
|
||||
|
||||
const resolvedPath = conflictTarget;
|
||||
const content = resolvedContent;
|
||||
await runOperation(`Resolving ${resolvedPath}`, async () => {
|
||||
const nextStatus = await resolveConflict(activeRepoPath, resolvedPath, content);
|
||||
applyStatus(nextStatus);
|
||||
preparedResolutions = { ...preparedResolutions, [resolvedPath]: prepared };
|
||||
|
||||
// Jump to the next file that still needs a decision, if any.
|
||||
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 refreshFileHistory(activeRepoPath);
|
||||
|
||||
const remaining = nextStatus.files.filter(
|
||||
const remaining = (nextStatus?.files ?? status?.files ?? []).filter(
|
||||
(file) => file.staged === "conflicted" || file.unstaged === "conflicted",
|
||||
);
|
||||
|
||||
@@ -835,12 +1086,26 @@
|
||||
conflictParts = [];
|
||||
conflictChoices = [];
|
||||
manualMode = false;
|
||||
binarySide = null;
|
||||
} else {
|
||||
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() {
|
||||
resolveDialogOpen = false;
|
||||
}
|
||||
@@ -997,6 +1262,16 @@
|
||||
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 {
|
||||
if (!file.old_path) {
|
||||
return file.path;
|
||||
@@ -1062,6 +1337,19 @@
|
||||
<RefreshCw class={operation === "Refreshing" ? "spin" : ""} size={16} aria-hidden="true" />
|
||||
Refresh
|
||||
</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>
|
||||
</header>
|
||||
|
||||
@@ -1103,40 +1391,60 @@
|
||||
<span class="counter">{branches.length}</span>
|
||||
</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}
|
||||
<p class="empty-note">Open a repository to list branches.</p>
|
||||
{:else if branches.length === 0}
|
||||
<p class="empty-note">No branches returned.</p>
|
||||
{:else}
|
||||
<div class="branch-list">
|
||||
{#each branches as branch (branch.name)}
|
||||
<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 localBranches.length > 0}
|
||||
<div class="branch-group-label">
|
||||
<span>Local</span>
|
||||
<span class="branch-group-count">{localBranches.length}</span>
|
||||
</div>
|
||||
{#each localBranches as branch (branch.name)}
|
||||
{@render branchRow(branch)}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#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>
|
||||
{/each}
|
||||
{#if remoteBranches.length > 0}
|
||||
<div class="branch-group-label">
|
||||
<span>Remote</span>
|
||||
<span class="branch-group-count">{remoteBranches.length}</span>
|
||||
</div>
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
{@render branchRow(branch)}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -1393,73 +1701,6 @@
|
||||
{/if}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
@@ -1533,6 +1774,137 @@
|
||||
{/if}
|
||||
</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>
|
||||
|
||||
{#if compareDialogOpen && comparison}
|
||||
@@ -1641,13 +2013,19 @@
|
||||
<button
|
||||
type="button"
|
||||
class:active={conflictTarget === file.path}
|
||||
class:prepared={preparedResolutions[file.path] != null}
|
||||
class="dialog-file-row"
|
||||
onclick={() => selectConflictFile(file.path)}
|
||||
disabled={isBusy}
|
||||
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>
|
||||
{#if preparedResolutions[file.path]}
|
||||
<Check size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</aside>
|
||||
@@ -1656,7 +2034,48 @@
|
||||
{#if !conflict}
|
||||
<div class="blank-state">Select a file to resolve.</div>
|
||||
{: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}
|
||||
<button type="button" onclick={disableManualEdit} disabled={isBusy}>
|
||||
Back to guided
|
||||
@@ -1773,27 +2192,49 @@
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="resolve-actions">
|
||||
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
|
||||
{#if currentPrepared}
|
||||
<span class="prepared-tag">
|
||||
<Check size={14} aria-hidden="true" />
|
||||
Prepared
|
||||
</span>
|
||||
{/if}
|
||||
<button
|
||||
class="primary-button"
|
||||
type="button"
|
||||
onclick={saveResolution}
|
||||
disabled={isBusy}
|
||||
onclick={markCurrentResolved}
|
||||
disabled={!canMarkResolved}
|
||||
title="Prepare this file's resolution (applied with Apply resolved)"
|
||||
>
|
||||
{#if operation.startsWith("Resolving")}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Save & mark resolved
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{currentPrepared ? "Update decision" : "Mark as resolved"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</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}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+254
-7
@@ -127,6 +127,17 @@ textarea {
|
||||
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 {
|
||||
border-color: #256f8f;
|
||||
color: #ffffff;
|
||||
@@ -180,11 +191,16 @@ textarea {
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
grid-template-columns: 320px minmax(0, 1fr) 440px;
|
||||
min-height: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.history-aside {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.left-sidebar {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(220px, 0.9fr) minmax(260px, 1.1fr);
|
||||
@@ -286,6 +302,34 @@ textarea {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -485,7 +529,7 @@ textarea {
|
||||
|
||||
.side-stack {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(210px, 0.75fr) minmax(0, 1fr);
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
gap: 10px;
|
||||
}
|
||||
@@ -717,6 +761,28 @@ textarea {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
@@ -769,6 +835,75 @@ textarea {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
@@ -954,6 +1089,46 @@ textarea {
|
||||
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 {
|
||||
margin: 2px 0 0;
|
||||
color: #202326;
|
||||
@@ -961,8 +1136,8 @@ textarea {
|
||||
}
|
||||
|
||||
.resolve-editor {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
gap: 8px;
|
||||
@@ -999,8 +1174,8 @@ textarea {
|
||||
|
||||
.resolve-textarea {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
min-height: 120px;
|
||||
padding: 10px;
|
||||
border: 1px solid #c4ccd3;
|
||||
border-radius: 6px;
|
||||
@@ -1018,6 +1193,7 @@ textarea {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
@@ -1148,6 +1324,67 @@ textarea {
|
||||
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 {
|
||||
display: flex;
|
||||
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) {
|
||||
.workspace {
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
grid-template-columns: 260px minmax(0, 1fr) 330px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
@@ -1309,6 +1552,10 @@ textarea {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.history-aside {
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.branches {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
@@ -108,3 +108,11 @@ export function resolveConflict(
|
||||
): Promise<GitStatus> {
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface GitCommit {
|
||||
author_email: string;
|
||||
date: string;
|
||||
refs: string[];
|
||||
parents: string[];
|
||||
files: GitCommitFile[];
|
||||
}
|
||||
|
||||
@@ -76,4 +77,7 @@ export interface ConflictFile {
|
||||
ours: string | null;
|
||||
theirs: string | null;
|
||||
base: string | null;
|
||||
binary: boolean;
|
||||
ours_size: number | null;
|
||||
theirs_size: number | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user