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
+388 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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 });
}
+17
View 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;
}