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,
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct GitRepositoryFile {
|
pub struct GitRepositoryFile {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
@@ -330,6 +349,96 @@ pub fn restore_file_from_commit(
|
|||||||
status_for_repo(&repo)
|
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> {
|
fn resolve_repo(path: &str) -> Result<PathBuf, String> {
|
||||||
if path.trim().is_empty() {
|
if path.trim().is_empty() {
|
||||||
return Err("Repository-Pfad darf nicht leer sein.".to_string());
|
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)
|
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 {
|
fn map_name_status(status: &str) -> FileStatusKind {
|
||||||
match status.chars().next() {
|
match status.chars().next() {
|
||||||
Some('M') => FileStatusKind::Modified,
|
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]
|
#[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");
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
mod git;
|
mod git;
|
||||||
|
|
||||||
use git::{
|
use git::{
|
||||||
checkout_branch, commit, get_status, list_branches, list_commits, list_file_history,
|
checkout_branch, commit, compare_commits, diff_file_against_working_tree, get_status,
|
||||||
list_repository_files, merge_branch, open_repository, pull, push, restore_file_from_commit,
|
list_branches, list_commits, list_file_history, list_repository_files, merge_branch,
|
||||||
restore_files, restore_to_commit, stage_files, unstage_files,
|
open_repository, pull, push, restore_file_from_commit, restore_files, restore_to_commit,
|
||||||
|
stage_files, unstage_files,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -26,7 +27,9 @@ fn main() {
|
|||||||
restore_file_from_commit,
|
restore_file_from_commit,
|
||||||
merge_branch,
|
merge_branch,
|
||||||
list_repository_files,
|
list_repository_files,
|
||||||
list_file_history
|
list_file_history,
|
||||||
|
compare_commits,
|
||||||
|
diff_file_against_working_tree
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
+379
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
ArrowRight,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Check,
|
Check,
|
||||||
@@ -9,6 +10,7 @@
|
|||||||
Folder,
|
Folder,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
GitBranch,
|
GitBranch,
|
||||||
|
GitCompare,
|
||||||
GitMerge,
|
GitMerge,
|
||||||
History,
|
History,
|
||||||
LoaderCircle,
|
LoaderCircle,
|
||||||
@@ -16,11 +18,14 @@
|
|||||||
RotateCcw,
|
RotateCcw,
|
||||||
Undo2,
|
Undo2,
|
||||||
Upload,
|
Upload,
|
||||||
|
X,
|
||||||
} from "@lucide/svelte";
|
} from "@lucide/svelte";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
checkoutBranch,
|
checkoutBranch,
|
||||||
commit,
|
commit,
|
||||||
|
compareCommits,
|
||||||
|
diffFileAgainstWorkingTree,
|
||||||
getStatus,
|
getStatus,
|
||||||
listBranches,
|
listBranches,
|
||||||
listCommits,
|
listCommits,
|
||||||
@@ -40,7 +45,9 @@
|
|||||||
FileStatusKind,
|
FileStatusKind,
|
||||||
GitBranch as GitBranchInfo,
|
GitBranch as GitBranchInfo,
|
||||||
GitCommit,
|
GitCommit,
|
||||||
|
GitCommitComparison,
|
||||||
GitCommitFile,
|
GitCommitFile,
|
||||||
|
GitDiffFile,
|
||||||
GitFileStatus,
|
GitFileStatus,
|
||||||
GitRepositoryFile,
|
GitRepositoryFile,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
@@ -71,6 +78,11 @@
|
|||||||
let commitMessage = "";
|
let commitMessage = "";
|
||||||
let errorMessage = "";
|
let errorMessage = "";
|
||||||
let operation = "";
|
let operation = "";
|
||||||
|
let compareFrom = "";
|
||||||
|
let compareTo = "";
|
||||||
|
let comparison: GitCommitComparison | null = null;
|
||||||
|
let compareDialogOpen = false;
|
||||||
|
let selectedDiffPath = "";
|
||||||
|
|
||||||
$: isBusy = operation.length > 0;
|
$: isBusy = operation.length > 0;
|
||||||
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
$: hasRepository = activeRepoPath.length > 0 && status !== null;
|
||||||
@@ -78,6 +90,15 @@
|
|||||||
$: stagedCount = status?.files.filter((file) => file.staged !== null).length ?? 0;
|
$: stagedCount = status?.files.filter((file) => file.staged !== null).length ?? 0;
|
||||||
$: unstagedCount = status?.files.filter((file) => file.unstaged !== null).length ?? 0;
|
$: unstagedCount = status?.files.filter((file) => file.unstaged !== null).length ?? 0;
|
||||||
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !isBusy;
|
$: 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);
|
$: explorerTree = buildExplorerTree(repoFiles);
|
||||||
$: visibleExplorerNodes = flattenExplorerTree(explorerTree, expandedExplorerPaths);
|
$: visibleExplorerNodes = flattenExplorerTree(explorerTree, expandedExplorerPaths);
|
||||||
$: selectedExplorerLabel = selectedExplorerPath
|
$: selectedExplorerLabel = selectedExplorerPath
|
||||||
@@ -264,6 +285,31 @@
|
|||||||
|
|
||||||
async function refreshCommitHistory(path = activeRepoPath) {
|
async function refreshCommitHistory(path = activeRepoPath) {
|
||||||
commits = await listCommits(path, 100);
|
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) {
|
async function refreshExplorerFiles(path = activeRepoPath) {
|
||||||
@@ -302,6 +348,11 @@
|
|||||||
selectedExplorerKind = "file";
|
selectedExplorerKind = "file";
|
||||||
expandedExplorerPaths = new Set<string>();
|
expandedExplorerPaths = new Set<string>();
|
||||||
fileHistory = [];
|
fileHistory = [];
|
||||||
|
compareFrom = "";
|
||||||
|
compareTo = "";
|
||||||
|
comparison = null;
|
||||||
|
compareDialogOpen = false;
|
||||||
|
selectedDiffPath = "";
|
||||||
await refreshBranchList(activeRepoPath);
|
await refreshBranchList(activeRepoPath);
|
||||||
await refreshCommitHistory(activeRepoPath);
|
await refreshCommitHistory(activeRepoPath);
|
||||||
await refreshExplorerFiles(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) {
|
function submitRepo(event: SubmitEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
void openRepo();
|
void openRepo();
|
||||||
@@ -883,6 +1109,16 @@
|
|||||||
|
|
||||||
<div class="commit-actions">
|
<div class="commit-actions">
|
||||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||||
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onclick={() => restoreSelectedFileFromCommit(item)}
|
onclick={() => restoreSelectedFileFromCommit(item)}
|
||||||
@@ -893,6 +1129,7 @@
|
|||||||
Restore
|
Restore
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
@@ -968,6 +1205,148 @@
|
|||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</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>
|
</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>
|
</main>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={handleWindowKeydown} />
|
||||||
|
|||||||
+271
-4
@@ -181,7 +181,8 @@ textarea {
|
|||||||
.status-panel,
|
.status-panel,
|
||||||
.commit-panel,
|
.commit-panel,
|
||||||
.file-history-panel,
|
.file-history-panel,
|
||||||
.history-panel {
|
.history-panel,
|
||||||
|
.compare-panel {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 1px solid #d2d8de;
|
border: 1px solid #d2d8de;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -408,8 +409,8 @@ textarea {
|
|||||||
|
|
||||||
.main-panel {
|
.main-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto 1fr;
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
overflow: hidden;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-summary {
|
.repo-summary {
|
||||||
@@ -745,6 +746,257 @@ textarea {
|
|||||||
font-size: 12px;
|
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,
|
.blank-state,
|
||||||
.empty-note {
|
.empty-note {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -802,10 +1054,25 @@ textarea {
|
|||||||
.repo-form,
|
.repo-form,
|
||||||
.workspace,
|
.workspace,
|
||||||
.repo-summary,
|
.repo-summary,
|
||||||
.change-lanes {
|
.change-lanes,
|
||||||
|
.compare-form {
|
||||||
grid-template-columns: 1fr;
|
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,
|
.toolbar,
|
||||||
.sync-stats {
|
.sync-stats {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
|
|||||||
+23
-1
@@ -1,6 +1,12 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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> {
|
export function openRepository(path: string): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("open_repository", { path });
|
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[]> {
|
export function listFileHistory(path: string, file: string, limit = 100): Promise<GitCommit[]> {
|
||||||
return invoke<GitCommit[]>("list_file_history", { path, file, limit });
|
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;
|
tracked: boolean;
|
||||||
status: FileStatusKind | null;
|
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