add more features

This commit is contained in:
Christoph Brandau
2026-06-27 13:49:56 +02:00
parent 3b9d636fdb
commit 6a78e768bc
7 changed files with 989 additions and 18 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(cargo build *)",
"Bash(npm run *)",
"Bash(kill %1)"
]
}
}
+274
View File
@@ -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");
+7 -4
View 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");
+388 -9
View File
@@ -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,15 +1109,26 @@
<div class="commit-actions"> <div class="commit-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time> <time datetime={item.date}>{formatCommitDate(item.date)}</time>
<button <div class="commit-action-buttons">
type="button" <button
onclick={() => restoreSelectedFileFromCommit(item)} type="button"
disabled={isBusy} onclick={() => diffSelectedFileFromCommit(item)}
title="Restore selected file from this commit" disabled={isBusy}
> title="Show changes between this commit and the current working tree"
<RotateCcw size={15} aria-hidden="true" /> >
Restore <GitCompare size={15} aria-hidden="true" />
</button> 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> </div>
</article> </article>
{/each} {/each}
@@ -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
View File
@@ -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
View File
@@ -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 });
}
+17
View 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;
}