add more features
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cargo build *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(kill %1)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,25 @@ pub struct GitCommitFile {
|
||||
pub status: FileStatusKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitDiffFile {
|
||||
pub path: String,
|
||||
pub old_path: Option<String>,
|
||||
pub status: FileStatusKind,
|
||||
pub additions: u32,
|
||||
pub deletions: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitCommitComparison {
|
||||
pub from_hash: String,
|
||||
pub from_short: String,
|
||||
pub to_hash: String,
|
||||
pub to_short: String,
|
||||
pub files: Vec<GitDiffFile>,
|
||||
pub patch: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct GitRepositoryFile {
|
||||
pub path: String,
|
||||
@@ -330,6 +349,96 @@ pub fn restore_file_from_commit(
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn compare_commits(
|
||||
path: String,
|
||||
from: String,
|
||||
to: String,
|
||||
) -> Result<GitCommitComparison, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let from_hash = verify_commit(&repo, &from)?;
|
||||
let to_hash = verify_commit(&repo, &to)?;
|
||||
|
||||
let name_status = run_git(
|
||||
&repo,
|
||||
[
|
||||
"diff",
|
||||
"--name-status",
|
||||
"-M",
|
||||
"-z",
|
||||
from_hash.as_str(),
|
||||
to_hash.as_str(),
|
||||
],
|
||||
)?;
|
||||
let numstat = run_git(
|
||||
&repo,
|
||||
[
|
||||
"diff",
|
||||
"--numstat",
|
||||
"-M",
|
||||
"-z",
|
||||
from_hash.as_str(),
|
||||
to_hash.as_str(),
|
||||
],
|
||||
)?;
|
||||
let patch_output = run_git(&repo, ["diff", "-M", from_hash.as_str(), to_hash.as_str()])?;
|
||||
|
||||
let files = parse_diff_files(&name_status, &numstat)?;
|
||||
let patch = String::from_utf8_lossy(&patch_output).to_string();
|
||||
|
||||
Ok(GitCommitComparison {
|
||||
from_short: short_hash(&from_hash),
|
||||
to_short: short_hash(&to_hash),
|
||||
from_hash,
|
||||
to_hash,
|
||||
files,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn diff_file_against_working_tree(
|
||||
path: String,
|
||||
commit: String,
|
||||
file: String,
|
||||
) -> Result<GitCommitComparison, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
validate_files(std::slice::from_ref(&file))?;
|
||||
let commit_hash = verify_commit(&repo, &commit)?;
|
||||
|
||||
let name_status = run_git_with_paths(
|
||||
&repo,
|
||||
&["diff", "--name-status", "-M", "-z", commit_hash.as_str()],
|
||||
std::slice::from_ref(&file),
|
||||
)?;
|
||||
let numstat = run_git_with_paths(
|
||||
&repo,
|
||||
&["diff", "--numstat", "-M", "-z", commit_hash.as_str()],
|
||||
std::slice::from_ref(&file),
|
||||
)?;
|
||||
let patch_output = run_git_with_paths(
|
||||
&repo,
|
||||
&["diff", "-M", commit_hash.as_str()],
|
||||
std::slice::from_ref(&file),
|
||||
)?;
|
||||
|
||||
let files = parse_diff_files(&name_status, &numstat)?;
|
||||
let patch = String::from_utf8_lossy(&patch_output).to_string();
|
||||
|
||||
Ok(GitCommitComparison {
|
||||
from_short: short_hash(&commit_hash),
|
||||
to_short: "working tree".to_string(),
|
||||
from_hash: commit_hash,
|
||||
to_hash: String::new(),
|
||||
files,
|
||||
patch,
|
||||
})
|
||||
}
|
||||
|
||||
fn short_hash(hash: &str) -> String {
|
||||
hash.chars().take(7).collect()
|
||||
}
|
||||
|
||||
fn resolve_repo(path: &str) -> Result<PathBuf, String> {
|
||||
if path.trim().is_empty() {
|
||||
return Err("Repository-Pfad darf nicht leer sein.".to_string());
|
||||
@@ -609,6 +718,72 @@ fn parse_commit_files(output: &[u8]) -> Result<Vec<GitCommitFile>, String> {
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn parse_diff_files(name_status: &[u8], numstat: &[u8]) -> Result<Vec<GitDiffFile>, String> {
|
||||
let status_files = parse_commit_files(name_status)?;
|
||||
let counts = parse_numstat_z(numstat);
|
||||
|
||||
let files = status_files
|
||||
.into_iter()
|
||||
.map(|file| {
|
||||
let (additions, deletions) = counts
|
||||
.iter()
|
||||
.find(|(path, _, _)| *path == file.path)
|
||||
.map(|(_, additions, deletions)| (*additions, *deletions))
|
||||
.unwrap_or((0, 0));
|
||||
|
||||
GitDiffFile {
|
||||
path: file.path,
|
||||
old_path: file.old_path,
|
||||
status: file.status,
|
||||
additions,
|
||||
deletions,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn parse_numstat_z(output: &[u8]) -> Vec<(String, u32, u32)> {
|
||||
let tokens: Vec<String> = output
|
||||
.split(|byte| *byte == 0)
|
||||
.filter(|entry| !entry.is_empty())
|
||||
.map(|entry| String::from_utf8_lossy(entry).to_string())
|
||||
.collect();
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut index = 0;
|
||||
|
||||
while index < tokens.len() {
|
||||
let mut parts = tokens[index].splitn(3, '\t');
|
||||
let additions = parse_numstat_count(parts.next().unwrap_or(""));
|
||||
let deletions = parse_numstat_count(parts.next().unwrap_or(""));
|
||||
let rest = parts.next().unwrap_or("").to_string();
|
||||
index += 1;
|
||||
|
||||
// For renames/copies, `--numstat -z` leaves the path empty and emits the
|
||||
// old and new paths as two separate NUL-terminated tokens.
|
||||
let path = if rest.is_empty() {
|
||||
if index + 1 >= tokens.len() {
|
||||
break;
|
||||
}
|
||||
let new_path = tokens[index + 1].clone();
|
||||
index += 2;
|
||||
new_path
|
||||
} else {
|
||||
rest
|
||||
};
|
||||
|
||||
result.push((path, additions, deletions));
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn parse_numstat_count(value: &str) -> u32 {
|
||||
value.trim().parse().unwrap_or(0)
|
||||
}
|
||||
|
||||
fn map_name_status(status: &str) -> FileStatusKind {
|
||||
match status.chars().next() {
|
||||
Some('M') => FileStatusKind::Modified,
|
||||
@@ -1231,6 +1406,105 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_diff_files_merges_name_status_with_numstat_counts() {
|
||||
let name_status = b"M\0src/main.rs\0A\0README.md\0R100\0old.txt\0new.txt\0";
|
||||
let numstat = b"4\t2\tsrc/main.rs\010\t0\tREADME.md\00\t0\t\0old.txt\0new.txt\0";
|
||||
|
||||
let files = parse_diff_files(name_status, numstat).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
files,
|
||||
vec![
|
||||
GitDiffFile {
|
||||
path: "src/main.rs".to_string(),
|
||||
old_path: None,
|
||||
status: FileStatusKind::Modified,
|
||||
additions: 4,
|
||||
deletions: 2,
|
||||
},
|
||||
GitDiffFile {
|
||||
path: "README.md".to_string(),
|
||||
old_path: None,
|
||||
status: FileStatusKind::Added,
|
||||
additions: 10,
|
||||
deletions: 0,
|
||||
},
|
||||
GitDiffFile {
|
||||
path: "new.txt".to_string(),
|
||||
old_path: Some("old.txt".to_string()),
|
||||
status: FileStatusKind::Renamed,
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_numstat_treats_binary_dashes_as_zero() {
|
||||
let counts = parse_numstat_z(b"-\t-\tlogo.png\0");
|
||||
|
||||
assert_eq!(counts, vec![("logo.png".to_string(), 0, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_commits_reports_changes_between_two_commits() {
|
||||
let repo = init_temp_repo("compare_commits");
|
||||
commit_initial_file(&repo.path);
|
||||
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
|
||||
fs::write(repo.path.join("old.txt"), "original\nsecond line\n")
|
||||
.expect("tracked file should change");
|
||||
fs::write(repo.path.join("added.txt"), "brand new\n").expect("added file should be written");
|
||||
run_git_test(&repo.path, ["add", "old.txt", "added.txt"]);
|
||||
run_git_test(&repo.path, ["commit", "-q", "-m", "second"]);
|
||||
let second_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
|
||||
let comparison = compare_commits(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
first_commit,
|
||||
second_commit,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(comparison
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified));
|
||||
assert!(comparison
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "added.txt" && file.status == FileStatusKind::Added));
|
||||
assert!(comparison.patch.contains("second line"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn diff_file_against_working_tree_reports_uncommitted_changes() {
|
||||
let repo = init_temp_repo("diff_against_working_tree");
|
||||
commit_initial_file(&repo.path);
|
||||
let first_commit = git_output_test(&repo.path, ["rev-parse", "HEAD"]);
|
||||
|
||||
// Change the file on disk without committing.
|
||||
fs::write(repo.path.join("old.txt"), "working tree change\n")
|
||||
.expect("working tree file should change");
|
||||
|
||||
let comparison = diff_file_against_working_tree(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
first_commit,
|
||||
"old.txt".to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(comparison.to_short, "working tree");
|
||||
assert!(comparison.to_hash.is_empty());
|
||||
assert!(comparison
|
||||
.files
|
||||
.iter()
|
||||
.any(|file| file.path == "old.txt" && file.status == FileStatusKind::Modified));
|
||||
assert!(comparison.patch.contains("working tree change"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_staged_added_file_removes_it_from_index_and_worktree() {
|
||||
let repo = init_temp_repo("restore_staged_added_file");
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
mod git;
|
||||
|
||||
use git::{
|
||||
checkout_branch, commit, get_status, list_branches, list_commits, list_file_history,
|
||||
list_repository_files, merge_branch, open_repository, pull, push, restore_file_from_commit,
|
||||
restore_files, restore_to_commit, stage_files, unstage_files,
|
||||
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, restore_file_from_commit, restore_files, restore_to_commit,
|
||||
stage_files, unstage_files,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
@@ -26,7 +27,9 @@ fn main() {
|
||||
restore_file_from_commit,
|
||||
merge_branch,
|
||||
list_repository_files,
|
||||
list_file_history
|
||||
list_file_history,
|
||||
compare_commits,
|
||||
diff_file_against_working_tree
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
+388
-9
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Check,
|
||||
@@ -9,6 +10,7 @@
|
||||
Folder,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
GitCompare,
|
||||
GitMerge,
|
||||
History,
|
||||
LoaderCircle,
|
||||
@@ -16,11 +18,14 @@
|
||||
RotateCcw,
|
||||
Undo2,
|
||||
Upload,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
import {
|
||||
checkoutBranch,
|
||||
commit,
|
||||
compareCommits,
|
||||
diffFileAgainstWorkingTree,
|
||||
getStatus,
|
||||
listBranches,
|
||||
listCommits,
|
||||
@@ -40,7 +45,9 @@
|
||||
FileStatusKind,
|
||||
GitBranch as GitBranchInfo,
|
||||
GitCommit,
|
||||
GitCommitComparison,
|
||||
GitCommitFile,
|
||||
GitDiffFile,
|
||||
GitFileStatus,
|
||||
GitRepositoryFile,
|
||||
GitStatus,
|
||||
@@ -71,6 +78,11 @@
|
||||
let commitMessage = "";
|
||||
let errorMessage = "";
|
||||
let operation = "";
|
||||
let compareFrom = "";
|
||||
let compareTo = "";
|
||||
let comparison: GitCommitComparison | null = null;
|
||||
let compareDialogOpen = false;
|
||||
let selectedDiffPath = "";
|
||||
|
||||
$: isBusy = operation.length > 0;
|
||||
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
||||
@@ -78,6 +90,15 @@
|
||||
$: stagedCount = status?.files.filter((file) => file.staged !== null).length ?? 0;
|
||||
$: unstagedCount = status?.files.filter((file) => file.unstaged !== null).length ?? 0;
|
||||
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !isBusy;
|
||||
$: canCompare =
|
||||
hasRepository &&
|
||||
compareFrom.length > 0 &&
|
||||
compareTo.length > 0 &&
|
||||
compareFrom !== compareTo &&
|
||||
!isBusy;
|
||||
$: diffByPath = comparison ? buildDiffByPath(comparison.patch) : new Map<string, string>();
|
||||
$: selectedDiffFile = comparison?.files.find((file) => file.path === selectedDiffPath) ?? null;
|
||||
$: selectedDiffPatch = selectedDiffFile ? diffByPath.get(selectedDiffFile.path) ?? "" : "";
|
||||
$: explorerTree = buildExplorerTree(repoFiles);
|
||||
$: visibleExplorerNodes = flattenExplorerTree(explorerTree, expandedExplorerPaths);
|
||||
$: selectedExplorerLabel = selectedExplorerPath
|
||||
@@ -264,6 +285,31 @@
|
||||
|
||||
async function refreshCommitHistory(path = activeRepoPath) {
|
||||
commits = await listCommits(path, 100);
|
||||
reconcileCompareSelection();
|
||||
}
|
||||
|
||||
function reconcileCompareSelection() {
|
||||
const hashes = new Set(commits.map((item) => item.hash));
|
||||
|
||||
if (compareFrom && !hashes.has(compareFrom)) {
|
||||
compareFrom = "";
|
||||
}
|
||||
|
||||
if (compareTo && !hashes.has(compareTo)) {
|
||||
compareTo = "";
|
||||
}
|
||||
|
||||
// Only reconcile commit-to-commit comparisons; a working-tree diff has an
|
||||
// empty `to_hash` and should stay until the user closes it.
|
||||
if (
|
||||
comparison &&
|
||||
comparison.to_hash.length > 0 &&
|
||||
(!hashes.has(comparison.from_hash) || !hashes.has(comparison.to_hash))
|
||||
) {
|
||||
comparison = null;
|
||||
compareDialogOpen = false;
|
||||
selectedDiffPath = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshExplorerFiles(path = activeRepoPath) {
|
||||
@@ -302,6 +348,11 @@
|
||||
selectedExplorerKind = "file";
|
||||
expandedExplorerPaths = new Set<string>();
|
||||
fileHistory = [];
|
||||
compareFrom = "";
|
||||
compareTo = "";
|
||||
comparison = null;
|
||||
compareDialogOpen = false;
|
||||
selectedDiffPath = "";
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
@@ -509,6 +560,181 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function compareSelectedCommits() {
|
||||
if (!canCompare) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation("Comparing commits", async () => {
|
||||
const result = await compareCommits(activeRepoPath, compareFrom, compareTo);
|
||||
comparison = result;
|
||||
selectedDiffPath = result.files[0]?.path ?? "";
|
||||
compareDialogOpen = true;
|
||||
});
|
||||
}
|
||||
|
||||
function submitCompare(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
void compareSelectedCommits();
|
||||
}
|
||||
|
||||
async function diffSelectedFileFromCommit(historyCommit: GitCommit) {
|
||||
if (!activeRepoPath || !selectedExplorerPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
await runOperation(`Diffing ${selectedExplorerPath}`, async () => {
|
||||
const result = await diffFileAgainstWorkingTree(
|
||||
activeRepoPath,
|
||||
historyCommit.hash,
|
||||
selectedExplorerPath,
|
||||
);
|
||||
comparison = result;
|
||||
selectedDiffPath = result.files[0]?.path ?? selectedExplorerPath;
|
||||
compareDialogOpen = true;
|
||||
});
|
||||
}
|
||||
|
||||
function openCompareDialog() {
|
||||
if (comparison) {
|
||||
compareDialogOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
function closeCompareDialog() {
|
||||
compareDialogOpen = false;
|
||||
}
|
||||
|
||||
function selectDiffFile(file: GitDiffFile) {
|
||||
selectedDiffPath = file.path;
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && compareDialogOpen) {
|
||||
closeCompareDialog();
|
||||
}
|
||||
}
|
||||
|
||||
function buildDiffByPath(patch: string): Map<string, string> {
|
||||
const segments = splitPatchByFile(patch);
|
||||
const map = new Map<string, string>();
|
||||
|
||||
for (const segment of segments) {
|
||||
const path = patchFilePath(segment);
|
||||
if (path) {
|
||||
map.set(path, segment);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
function splitPatchByFile(patch: string): string[] {
|
||||
if (!patch.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
|
||||
for (const line of patch.split("\n")) {
|
||||
if (line.startsWith("diff --git ") && current.length > 0) {
|
||||
segments.push(current.join("\n"));
|
||||
current = [];
|
||||
}
|
||||
current.push(line);
|
||||
}
|
||||
|
||||
if (current.length > 0) {
|
||||
segments.push(current.join("\n"));
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function patchFilePath(segment: string): string {
|
||||
let plusPath = "";
|
||||
let minusPath = "";
|
||||
|
||||
for (const line of segment.split("\n")) {
|
||||
if (line.startsWith("+++ ")) {
|
||||
plusPath = stripDiffPathPrefix(line.slice(4));
|
||||
} else if (line.startsWith("--- ")) {
|
||||
minusPath = stripDiffPathPrefix(line.slice(4));
|
||||
} else if (line.startsWith("@@")) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (plusPath && plusPath !== "/dev/null") {
|
||||
return plusPath;
|
||||
}
|
||||
|
||||
return minusPath;
|
||||
}
|
||||
|
||||
function stripDiffPathPrefix(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
|
||||
if (trimmed === "/dev/null") {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) {
|
||||
return trimmed.slice(2);
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function commitOptionLabel(item: GitCommit): string {
|
||||
return `${item.short_hash} - ${item.summary}`;
|
||||
}
|
||||
|
||||
function displayDiffFile(file: GitDiffFile): string {
|
||||
if (!file.old_path) {
|
||||
return file.path;
|
||||
}
|
||||
|
||||
return `${file.old_path} -> ${file.path}`;
|
||||
}
|
||||
|
||||
type DiffLineKind = "meta" | "hunk" | "add" | "del" | "context";
|
||||
|
||||
function diffLineKind(line: string): DiffLineKind {
|
||||
if (
|
||||
line.startsWith("diff ") ||
|
||||
line.startsWith("index ") ||
|
||||
line.startsWith("--- ") ||
|
||||
line.startsWith("+++ ") ||
|
||||
line.startsWith("new file") ||
|
||||
line.startsWith("deleted file") ||
|
||||
line.startsWith("rename ") ||
|
||||
line.startsWith("similarity ")
|
||||
) {
|
||||
return "meta";
|
||||
}
|
||||
|
||||
if (line.startsWith("@@")) {
|
||||
return "hunk";
|
||||
}
|
||||
|
||||
if (line.startsWith("+")) {
|
||||
return "add";
|
||||
}
|
||||
|
||||
if (line.startsWith("-")) {
|
||||
return "del";
|
||||
}
|
||||
|
||||
return "context";
|
||||
}
|
||||
|
||||
function diffLines(patch: string): { kind: DiffLineKind; text: string }[] {
|
||||
const lines = patch.replace(/\n$/, "").split("\n");
|
||||
return lines.map((text) => ({ kind: diffLineKind(text), text }));
|
||||
}
|
||||
|
||||
function submitRepo(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
void openRepo();
|
||||
@@ -883,15 +1109,26 @@
|
||||
|
||||
<div class="commit-actions">
|
||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => restoreSelectedFileFromCommit(item)}
|
||||
disabled={isBusy}
|
||||
title="Restore selected file from this commit"
|
||||
>
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
<div class="commit-action-buttons">
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => diffSelectedFileFromCommit(item)}
|
||||
disabled={isBusy}
|
||||
title="Show changes between this commit and the current working tree"
|
||||
>
|
||||
<GitCompare size={15} aria-hidden="true" />
|
||||
Diff
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => restoreSelectedFileFromCommit(item)}
|
||||
disabled={isBusy}
|
||||
title="Restore selected file from this commit"
|
||||
>
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
@@ -968,6 +1205,148 @@
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="compare-panel" aria-label="Compare commits">
|
||||
<div class="section-heading">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2>Commits</h2>
|
||||
</div>
|
||||
{#if comparison}
|
||||
<span class="counter">{comparison.files.length} files</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
{:else if commits.length < 2}
|
||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
||||
{:else}
|
||||
<form class="compare-form" onsubmit={submitCompare}>
|
||||
<label class="compare-field">
|
||||
<span>From (older)</span>
|
||||
<select bind:value={compareFrom} disabled={isBusy}>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
||||
|
||||
<label class="compare-field">
|
||||
<span>To (newer)</span>
|
||||
<select bind:value={compareTo} disabled={isBusy}>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="primary-button" type="submit" disabled={!canCompare}>
|
||||
{#if operation === "Comparing commits"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitCompare size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Compare
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if compareFrom && compareTo && compareFrom === compareTo}
|
||||
<div class="blank-state">Select two different commits to compare.</div>
|
||||
{:else if comparison}
|
||||
<div class="compare-summary">
|
||||
<div class="compare-range">
|
||||
<span class="hash">{comparison.from_short}</span>
|
||||
<ArrowRight size={15} aria-hidden="true" />
|
||||
<span class="hash">{comparison.to_short}</span>
|
||||
<span class="compare-count">{comparison.files.length} changed files</span>
|
||||
</div>
|
||||
<button type="button" onclick={openCompareDialog} disabled={isBusy}>
|
||||
<GitCompare size={15} aria-hidden="true" />
|
||||
View comparison
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="blank-state">Pick two commits and run a comparison.</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{#if compareDialogOpen && comparison}
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(event) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeCompareDialog();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Commit comparison"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="dialog-range">
|
||||
<span class="hash">{comparison.from_short}</span>
|
||||
<ArrowRight size={16} aria-hidden="true" />
|
||||
<span class="hash">{comparison.to_short}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<button type="button" class="dialog-close" onclick={closeCompareDialog} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if comparison.files.length === 0}
|
||||
<div class="blank-state">No differences to show — these versions are identical.</div>
|
||||
{:else}
|
||||
<div class="dialog-body">
|
||||
<aside class="dialog-files" aria-label="Changed files">
|
||||
{#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<button
|
||||
type="button"
|
||||
class:active={selectedDiffPath === file.path}
|
||||
class="dialog-file-row"
|
||||
onclick={() => selectDiffFile(file)}
|
||||
title={displayDiffFile(file)}
|
||||
>
|
||||
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
|
||||
<strong>{displayDiffFile(file)}</strong>
|
||||
<span class="diff-counts">
|
||||
<span class="adds">+{file.additions}</span>
|
||||
<span class="dels">-{file.deletions}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</aside>
|
||||
|
||||
<div class="dialog-diff" aria-label="File diff">
|
||||
{#if !selectedDiffFile}
|
||||
<div class="blank-state">Select a file to see its changes.</div>
|
||||
{:else if selectedDiffPatch.trim().length === 0}
|
||||
<div class="blank-state">No textual changes for this file.</div>
|
||||
{:else}
|
||||
<pre class="diff-view" aria-label="Unified diff">{#each diffLines(selectedDiffPatch) as line}<span
|
||||
class={`diff-line ${line.kind}`}>{line.text || " "}</span>{/each}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<svelte:window on:keydown={handleWindowKeydown} />
|
||||
|
||||
+271
-4
@@ -181,7 +181,8 @@ textarea {
|
||||
.status-panel,
|
||||
.commit-panel,
|
||||
.file-history-panel,
|
||||
.history-panel {
|
||||
.history-panel,
|
||||
.compare-panel {
|
||||
min-height: 0;
|
||||
border: 1px solid #d2d8de;
|
||||
border-radius: 8px;
|
||||
@@ -408,8 +409,8 @@ textarea {
|
||||
|
||||
.main-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow: hidden;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.repo-summary {
|
||||
@@ -745,6 +746,257 @@ textarea {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.commit-action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.compare-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto;
|
||||
margin: 0 10px 10px;
|
||||
}
|
||||
|
||||
.compare-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
|
||||
align-items: end;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-bottom: 1px solid #dce1e5;
|
||||
}
|
||||
|
||||
.compare-field {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.compare-field span {
|
||||
color: #596670;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.compare-field select {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #c4ccd3;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
color: #202326;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.compare-arrow {
|
||||
margin-bottom: 8px;
|
||||
color: #4f7d96;
|
||||
}
|
||||
|
||||
.compare-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.compare-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: #4f7d96;
|
||||
}
|
||||
|
||||
.compare-range .hash {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e7f1fb;
|
||||
color: #255b8b;
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.compare-count {
|
||||
color: #596670;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: rgba(20, 26, 31, 0.45);
|
||||
}
|
||||
|
||||
.dialog {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(1100px, 100%);
|
||||
height: min(760px, 100%);
|
||||
border: 1px solid #c4ccd3;
|
||||
border-radius: 12px;
|
||||
background: #fbfcfd;
|
||||
box-shadow: 0 18px 48px rgba(20, 26, 31, 0.32);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dialog-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid #dce1e5;
|
||||
}
|
||||
|
||||
.dialog-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 2px 0 0;
|
||||
color: #4f7d96;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.dialog-range .hash {
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
background: #e7f1fb;
|
||||
color: #255b8b;
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dialog-close {
|
||||
min-height: 34px;
|
||||
min-width: 34px;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
display: grid;
|
||||
grid-template-columns: 300px minmax(0, 1fr);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.dialog-files {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 5px;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
border-right: 1px solid #dce1e5;
|
||||
background: #f4f6f8;
|
||||
}
|
||||
|
||||
.dialog-file-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 8px;
|
||||
border: 1px solid #dce1e5;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.dialog-file-row.active {
|
||||
border-color: #9cc2e6;
|
||||
background: #e7f1fb;
|
||||
}
|
||||
|
||||
.dialog-file-row strong {
|
||||
overflow: hidden;
|
||||
color: #202326;
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dialog-diff {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.dialog-diff .diff-view {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.diff-counts {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.diff-counts .adds {
|
||||
color: #176239;
|
||||
}
|
||||
|
||||
.diff-counts .dels {
|
||||
color: #9b2e29;
|
||||
}
|
||||
|
||||
.diff-view {
|
||||
min-height: 0;
|
||||
max-height: 420px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
border: 1px solid #dce1e5;
|
||||
border-radius: 6px;
|
||||
background: #ffffff;
|
||||
font-family: "Cascadia Code", "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.diff-line {
|
||||
display: block;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.diff-line.meta {
|
||||
color: #6c7882;
|
||||
}
|
||||
|
||||
.diff-line.hunk {
|
||||
color: #255b8b;
|
||||
background: #eef4fb;
|
||||
}
|
||||
|
||||
.diff-line.add {
|
||||
color: #176239;
|
||||
background: #e6f5ec;
|
||||
}
|
||||
|
||||
.diff-line.del {
|
||||
color: #9b2e29;
|
||||
background: #fdeceb;
|
||||
}
|
||||
|
||||
.diff-line.context {
|
||||
color: #3a444c;
|
||||
}
|
||||
|
||||
.blank-state,
|
||||
.empty-note {
|
||||
display: grid;
|
||||
@@ -802,10 +1054,25 @@ textarea {
|
||||
.repo-form,
|
||||
.workspace,
|
||||
.repo-summary,
|
||||
.change-lanes {
|
||||
.change-lanes,
|
||||
.compare-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.compare-arrow {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dialog-body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.dialog-files {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #dce1e5;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.sync-stats {
|
||||
justify-content: flex-start;
|
||||
|
||||
+23
-1
@@ -1,6 +1,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import type { GitBranch, GitCommit, GitRepositoryFile, GitStatus } from "./types";
|
||||
import type {
|
||||
GitBranch,
|
||||
GitCommit,
|
||||
GitCommitComparison,
|
||||
GitRepositoryFile,
|
||||
GitStatus,
|
||||
} from "./types";
|
||||
|
||||
export function openRepository(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("open_repository", { path });
|
||||
@@ -73,3 +79,19 @@ export function listRepositoryFiles(path: string): Promise<GitRepositoryFile[]>
|
||||
export function listFileHistory(path: string, file: string, limit = 100): Promise<GitCommit[]> {
|
||||
return invoke<GitCommit[]>("list_file_history", { path, file, limit });
|
||||
}
|
||||
|
||||
export function compareCommits(
|
||||
path: string,
|
||||
from: string,
|
||||
to: string,
|
||||
): Promise<GitCommitComparison> {
|
||||
return invoke<GitCommitComparison>("compare_commits", { path, from, to });
|
||||
}
|
||||
|
||||
export function diffFileAgainstWorkingTree(
|
||||
path: string,
|
||||
commit: string,
|
||||
file: string,
|
||||
): Promise<GitCommitComparison> {
|
||||
return invoke<GitCommitComparison>("diff_file_against_working_tree", { path, commit, file });
|
||||
}
|
||||
|
||||
@@ -52,3 +52,20 @@ export interface GitRepositoryFile {
|
||||
tracked: boolean;
|
||||
status: FileStatusKind | null;
|
||||
}
|
||||
|
||||
export interface GitDiffFile {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
status: FileStatusKind;
|
||||
additions: number;
|
||||
deletions: number;
|
||||
}
|
||||
|
||||
export interface GitCommitComparison {
|
||||
from_hash: string;
|
||||
from_short: string;
|
||||
to_hash: string;
|
||||
to_short: string;
|
||||
files: GitDiffFile[];
|
||||
patch: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user