neu
This commit is contained in:
+143
-11
@@ -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");
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
+538
-97
@@ -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;
|
|
||||||
|
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;
|
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.
|
||||||
|
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);
|
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,13 +1391,7 @@
|
|||||||
<span class="counter">{branches.length}</span>
|
<span class="counter">{branches.length}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#snippet branchRow(branch: GitBranchInfo)}
|
||||||
<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">
|
<article class:current={branch.current} class="branch-row">
|
||||||
<div class="branch-info">
|
<div class="branch-info">
|
||||||
<GitBranch size={16} aria-hidden="true" />
|
<GitBranch size={16} aria-hidden="true" />
|
||||||
@@ -1136,7 +1418,33 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
</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">
|
||||||
|
{#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}
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#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>
|
</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>
|
||||||
@@ -1655,6 +2033,47 @@
|
|||||||
<div class="resolve-editor" aria-label="Conflict editor">
|
<div class="resolve-editor" aria-label="Conflict editor">
|
||||||
{#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}
|
||||||
|
{#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}
|
{:else}
|
||||||
<div class="resolve-toolbar">
|
<div class="resolve-toolbar">
|
||||||
{#if manualMode}
|
{#if manualMode}
|
||||||
@@ -1774,26 +2193,48 @@
|
|||||||
{/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
|
||||||
|
type="button"
|
||||||
|
onclick={markCurrentResolved}
|
||||||
|
disabled={!canMarkResolved}
|
||||||
|
title="Prepare this file's resolution (applied with Apply 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
|
<button
|
||||||
class="primary-button"
|
class="primary-button"
|
||||||
type="button"
|
type="button"
|
||||||
onclick={saveResolution}
|
onclick={applyPreparedResolutions}
|
||||||
disabled={isBusy}
|
disabled={isBusy || preparedCount === 0}
|
||||||
>
|
>
|
||||||
{#if operation.startsWith("Resolving")}
|
{#if operation.startsWith("Resolving")}
|
||||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||||
{:else}
|
{:else}
|
||||||
<Check size={16} aria-hidden="true" />
|
<Check size={16} aria-hidden="true" />
|
||||||
{/if}
|
{/if}
|
||||||
Save & mark resolved
|
Apply {preparedCount} resolved
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</footer>
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+254
-7
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user