Optimize repository loading and enhance Git UI
Consolidate multiple Git backend calls into a single bundle to reduce overhead when opening and auto-refreshing repositories. This significantly improves performance by fetching status, branches, commits, and files in one optimized operation, instead of re-running 'git rev-parse' and 'git status' multiple times. Also, streamline commit history loading by fetching file changes inline via 'git log --name-status -z', eliminating expensive per-commit 'git diff-tree' processes. Additionally, introduce the ability to create new branches from a specific commit in the history and refactor the commit comparison feature into a dedicated dialog. The status panel now displays concise file names.
This commit is contained in:
@@ -23,7 +23,12 @@
|
||||
"Bash(npx vite *)",
|
||||
"Bash(cargo tree *)",
|
||||
"Bash(jobs)",
|
||||
"Bash(npx svelte-check *)"
|
||||
"Bash(npx svelte-check *)",
|
||||
"Bash(git log *)",
|
||||
"Bash(xxd)",
|
||||
"Bash(python3 -)",
|
||||
"Bash(echo \"exit: $?\")",
|
||||
"Bash(grep -n \"input,\\\\|select,\\\\|input {\\\\|select {\\\\|.repo-form input\\\\|input:focus\\\\|::placeholder\" src/app.css)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+143
-6
@@ -208,6 +208,41 @@ pub fn open_repository(path: String) -> Result<GitStatus, String> {
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct RepositoryBundle {
|
||||
pub status: GitStatus,
|
||||
pub branches: Vec<GitBranch>,
|
||||
pub commits: Vec<GitCommit>,
|
||||
pub files: Vec<GitRepositoryFile>,
|
||||
}
|
||||
|
||||
/// Opens a repository and gathers everything the UI needs in a single call.
|
||||
///
|
||||
/// Runs on a blocking thread (so the UI/overlay stays responsive) and resolves
|
||||
/// the repo and its status only once, instead of the previous four separate
|
||||
/// commands that each re-ran `git rev-parse` and `git status`.
|
||||
#[tauri::command]
|
||||
pub async fn open_repository_bundle(
|
||||
path: String,
|
||||
commit_limit: Option<u32>,
|
||||
) -> Result<RepositoryBundle, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || -> Result<RepositoryBundle, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let status = status_for_repo(&repo)?;
|
||||
let branches = branches_for_repo(&repo)?;
|
||||
let commits = commits_for_repo(&repo, commit_limit)?;
|
||||
let files = repository_files_with_status(&repo, &status)?;
|
||||
Ok(RepositoryBundle {
|
||||
status,
|
||||
branches,
|
||||
commits,
|
||||
files,
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(|err| format!("Repository konnte nicht geladen werden: {err}"))?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_status(path: String) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
@@ -217,8 +252,12 @@ pub fn get_status(path: String) -> Result<GitStatus, String> {
|
||||
#[tauri::command]
|
||||
pub fn list_branches(path: String) -> Result<Vec<GitBranch>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
branches_for_repo(&repo)
|
||||
}
|
||||
|
||||
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||
let output = run_git(
|
||||
&repo,
|
||||
repo,
|
||||
[
|
||||
"for-each-ref",
|
||||
"--format=%(refname)\t%(HEAD)",
|
||||
@@ -282,10 +321,23 @@ pub fn checkout_branch(path: String, branch: String) -> Result<GitStatus, String
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn create_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
pub fn create_branch(
|
||||
path: String,
|
||||
branch: String,
|
||||
start_point: Option<String>,
|
||||
) -> Result<GitStatus, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
let branch = validate_new_branch_name(&repo, &branch)?;
|
||||
match start_point {
|
||||
Some(start) if !start.trim().is_empty() => {
|
||||
// Resolve the requested commit first so we fail clearly if it is gone.
|
||||
let start = verify_commit(&repo, &start)?;
|
||||
run_git(&repo, ["checkout", "-b", branch.as_str(), start.as_str()])?;
|
||||
}
|
||||
_ => {
|
||||
run_git(&repo, ["checkout", "-b", branch.as_str()])?;
|
||||
}
|
||||
}
|
||||
status_for_repo(&repo)
|
||||
}
|
||||
|
||||
@@ -586,23 +638,34 @@ pub fn merge_branch(path: String, branch: String) -> Result<GitStatus, String> {
|
||||
#[tauri::command]
|
||||
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
||||
let repo = resolve_repo(&path)?;
|
||||
if verify_commit(&repo, "HEAD").is_err() {
|
||||
commits_for_repo(&repo, limit)
|
||||
}
|
||||
|
||||
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
||||
if verify_commit(repo, "HEAD").is_err() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let limit = limit.unwrap_or(100).clamp(1, 500).to_string();
|
||||
// Fetch the per-commit changed files inline via `--name-status` in a single
|
||||
// `git log` process, instead of spawning one `git diff-tree` per commit
|
||||
// (which was ~100 extra processes and the main cost of opening a repo).
|
||||
let output = run_git(
|
||||
&repo,
|
||||
repo,
|
||||
[
|
||||
"log",
|
||||
"--decorate=short",
|
||||
"--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1e",
|
||||
"--name-status",
|
||||
"-M",
|
||||
"-z",
|
||||
"--root",
|
||||
"--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f",
|
||||
"-n",
|
||||
limit.as_str(),
|
||||
],
|
||||
)?;
|
||||
|
||||
parse_commit_log(&repo, &output)
|
||||
parse_commit_log_inline(&output)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1242,6 +1305,13 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
||||
|
||||
fn repository_files(repo: &Path) -> Result<Vec<GitRepositoryFile>, String> {
|
||||
let status = status_for_repo(repo)?;
|
||||
repository_files_with_status(repo, &status)
|
||||
}
|
||||
|
||||
fn repository_files_with_status(
|
||||
repo: &Path,
|
||||
status: &GitStatus,
|
||||
) -> Result<Vec<GitRepositoryFile>, String> {
|
||||
let mut files = BTreeMap::<String, GitRepositoryFile>::new();
|
||||
|
||||
let tracked_output = run_git(repo, ["ls-files", "-z", "--cached", "--deleted"])?;
|
||||
@@ -1580,6 +1650,71 @@ fn commit_search_metadata(repo: &Path, commit: &str) -> Result<GitSearchCommitMe
|
||||
})
|
||||
}
|
||||
|
||||
/// Parses `git log --name-status -z` output where each commit's changed files
|
||||
/// are embedded inline (see `commits_for_repo`), so no per-commit git process is
|
||||
/// needed. Record layout: `\x1e` then eight `\x1f`-separated header fields, then
|
||||
/// git's newline, then the NUL-separated name-status entries.
|
||||
fn parse_commit_log_inline(output: &[u8]) -> Result<Vec<GitCommit>, String> {
|
||||
const FIELD_SEPARATOR: u8 = 0x1f;
|
||||
const RECORD_SEPARATOR: u8 = 0x1e;
|
||||
|
||||
let mut commits = Vec::new();
|
||||
|
||||
for record in output.split(|byte| *byte == RECORD_SEPARATOR) {
|
||||
// Skip the empty leading chunk and any stray separators left by `-z`.
|
||||
if record
|
||||
.iter()
|
||||
.all(|&byte| matches!(byte, 0 | b'\n' | b'\r' | b' ' | b'\t'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let parts: Vec<&[u8]> = record.splitn(9, |byte| *byte == FIELD_SEPARATOR).collect();
|
||||
if parts.len() < 8 {
|
||||
return Err(format!(
|
||||
"Unerwarteter Git-Log-Eintrag: {}",
|
||||
String::from_utf8_lossy(record)
|
||||
));
|
||||
}
|
||||
|
||||
// Field 8 (if present) holds the name-status list, preceded by the newline
|
||||
// git inserts between the pretty-format output and the diff.
|
||||
let mut files_bytes: &[u8] = parts.get(8).copied().unwrap_or(&[]);
|
||||
while let Some((&first, rest)) = files_bytes.split_first() {
|
||||
if matches!(first, b'\n' | b'\r') {
|
||||
files_bytes = rest;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let refs = String::from_utf8_lossy(parts[5])
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
let parents = String::from_utf8_lossy(parts[6])
|
||||
.split_whitespace()
|
||||
.map(ToString::to_string)
|
||||
.collect();
|
||||
|
||||
commits.push(GitCommit {
|
||||
hash: String::from_utf8_lossy(parts[0]).trim().to_string(),
|
||||
short_hash: String::from_utf8_lossy(parts[1]).trim().to_string(),
|
||||
author_name: String::from_utf8_lossy(parts[2]).to_string(),
|
||||
author_email: String::from_utf8_lossy(parts[3]).to_string(),
|
||||
date: String::from_utf8_lossy(parts[4]).trim().to_string(),
|
||||
refs,
|
||||
parents,
|
||||
summary: String::from_utf8_lossy(parts[7]).to_string(),
|
||||
files: parse_commit_files(files_bytes)?,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(commits)
|
||||
}
|
||||
|
||||
fn parse_commit_log(repo: &Path, output: &[u8]) -> Result<Vec<GitCommit>, String> {
|
||||
const FIELD_SEPARATOR: char = '\x1f';
|
||||
const RECORD_SEPARATOR: char = '\x1e';
|
||||
@@ -3234,6 +3369,7 @@ mod tests {
|
||||
let status = create_branch(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"feature/new-panel".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -3246,6 +3382,7 @@ mod tests {
|
||||
let err = create_branch(
|
||||
repo.path.to_string_lossy().to_string(),
|
||||
"feature/new-panel".to_string(),
|
||||
None,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(err.contains("existiert bereits"));
|
||||
|
||||
@@ -6,7 +6,8 @@ use git::{
|
||||
cancel_code_search, checkout_branch, commit, compare_commits, compare_file_to_head,
|
||||
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save,
|
||||
diff_file_against_working_tree, get_remote_url, get_status, list_branches, list_commits,
|
||||
list_file_history, list_repository_files, merge_branch, open_repository, pull, push,
|
||||
list_file_history, list_repository_files, merge_branch, open_repository,
|
||||
open_repository_bundle, pull, push,
|
||||
read_conflict, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
||||
restore_files, restore_to_commit, search_code_introductions, stage_files, unstage_files,
|
||||
SearchCancellationState,
|
||||
@@ -34,6 +35,7 @@ fn main() {
|
||||
restore_file_from_commit,
|
||||
merge_branch,
|
||||
list_repository_files,
|
||||
open_repository_bundle,
|
||||
list_file_history,
|
||||
compare_commits,
|
||||
compare_file_to_head,
|
||||
|
||||
+89
-39
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { onDestroy, onMount, tick } from "svelte";
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { AlertCircle, Check, FolderOpen, GitBranch, GitMerge, LoaderCircle } from "@lucide/svelte";
|
||||
@@ -8,12 +8,13 @@
|
||||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
||||
import CompareDialog from "./lib/components/CompareDialog.svelte";
|
||||
import ComparePanel from "./lib/components/ComparePanel.svelte";
|
||||
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
|
||||
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||
import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte";
|
||||
import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte";
|
||||
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
||||
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
||||
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
|
||||
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
||||
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
||||
@@ -33,7 +34,7 @@
|
||||
listFileHistory,
|
||||
listRepositoryFiles,
|
||||
mergeBranch,
|
||||
openRepository,
|
||||
openRepositoryBundle,
|
||||
pull,
|
||||
push,
|
||||
getRemoteUrl,
|
||||
@@ -96,6 +97,8 @@
|
||||
let compareFrom = "";
|
||||
let compareTo = "";
|
||||
let comparison: GitCommitComparison | null = null;
|
||||
let newBranchCommit: GitCommit | null = null;
|
||||
let compareSelectOpen = false;
|
||||
let compareDialogOpen = false;
|
||||
let selectedDiffPath = "";
|
||||
let diffHighlightQuery = "";
|
||||
@@ -167,15 +170,18 @@
|
||||
}
|
||||
|
||||
async function autoRefreshTick() {
|
||||
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || globalSearchOpen) return;
|
||||
if (!autoRefreshEnabled || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || newBranchCommit || globalSearchOpen) return;
|
||||
autoRefreshInFlight = true;
|
||||
try {
|
||||
// Cheap fast path: only fetch status; skip the heavy reload if nothing changed.
|
||||
const nextStatus = await getStatus(activeRepoPath);
|
||||
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
|
||||
applyStatus(nextStatus);
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
// Something changed — reload branches, commits and files in one bundled call.
|
||||
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
} catch { /* ignore transient errors */ } finally {
|
||||
autoRefreshInFlight = false;
|
||||
@@ -321,12 +327,12 @@
|
||||
|
||||
// ── Refresh helpers ────────────────────────────────────────────────────────
|
||||
|
||||
async function refreshBranchList(path = activeRepoPath) {
|
||||
branches = await listBranches(path);
|
||||
async function refreshBranchList(path = activeRepoPath, prefetched?: GitBranchInfo[]) {
|
||||
branches = prefetched ?? (await listBranches(path));
|
||||
}
|
||||
|
||||
async function refreshCommitHistory(path = activeRepoPath) {
|
||||
commits = await listCommits(path, 100);
|
||||
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
|
||||
commits = prefetched ?? (await listCommits(path, 100));
|
||||
const hashes = new Set(commits.map((c) => c.hash));
|
||||
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
|
||||
if (compareTo && !hashes.has(compareTo)) compareTo = "";
|
||||
@@ -338,8 +344,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshExplorerFiles(path = activeRepoPath) {
|
||||
repoFiles = await listRepositoryFiles(path);
|
||||
async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) {
|
||||
repoFiles = prefetched ?? (await listRepositoryFiles(path));
|
||||
const folderPaths = allExplorerFolderPaths(repoFiles);
|
||||
expandedExplorerPaths = new Set([...expandedExplorerPaths].filter((folder) => folderPaths.has(folder)));
|
||||
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
|
||||
@@ -361,20 +367,28 @@
|
||||
repoPath = path;
|
||||
|
||||
await runOperation("Opening repository", async () => {
|
||||
const nextStatus = await openRepository(path);
|
||||
applyStatus(nextStatus);
|
||||
// Paint the loading overlay before the (potentially slow) git enumeration
|
||||
// starts — otherwise the first paint is deferred until the bundle resolves
|
||||
// and the overlay appears to "come late".
|
||||
await tick();
|
||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||
// Single backend round-trip: resolves the repo and reads status, branches,
|
||||
// commits and files in one pass instead of four sequential git calls.
|
||||
const bundle = await openRepositoryBundle(path, 100);
|
||||
applyStatus(bundle.status);
|
||||
branches = []; commits = []; repoFiles = [];
|
||||
selectedExplorerPath = ""; selectedExplorerKind = "file";
|
||||
expandedExplorerPaths = new Set(); expandedCommitHashes = new Set();
|
||||
fileHistory = []; compareFrom = ""; compareTo = "";
|
||||
comparison = null; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null;
|
||||
comparison = null; compareSelectOpen = false; compareDialogOpen = false; selectedDiffPath = ""; pendingRestoreFile = null;
|
||||
newBranchCommit = null;
|
||||
if (globalSearchBusy) void cancelGlobalSearch();
|
||||
globalSearchResults = []; globalSearchOpen = false; globalSearchError = "";
|
||||
resolveDialogOpen = false; conflictTarget = ""; conflict = null;
|
||||
preparedResolutions = {};
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -429,6 +443,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
function openNewBranchDialog(commit: GitCommit) {
|
||||
if (!activeRepoPath || isBusy) return;
|
||||
newBranchCommit = commit;
|
||||
}
|
||||
|
||||
async function createBranchFromCommit(branchName: string) {
|
||||
const target = newBranchCommit;
|
||||
const name = branchName.trim();
|
||||
if (!activeRepoPath || !target || !name) return;
|
||||
await runOperation(`Creating ${name}`, async () => {
|
||||
applyStatus(await createBranch(activeRepoPath, name, target.hash));
|
||||
newBranchCommit = null;
|
||||
await refreshBranchList(activeRepoPath);
|
||||
await refreshCommitHistory(activeRepoPath);
|
||||
await refreshExplorerFiles(activeRepoPath);
|
||||
await refreshFileHistory(activeRepoPath);
|
||||
});
|
||||
}
|
||||
|
||||
async function merge(branch: GitBranchInfo) {
|
||||
if (!activeRepoPath || branch.current) return;
|
||||
await runOperation(`Merging ${branch.name}`, async () => {
|
||||
@@ -780,6 +813,11 @@
|
||||
|
||||
// ── Compare ────────────────────────────────────────────────────────────────
|
||||
|
||||
function openCompareSelect() {
|
||||
if (!hasRepository) return;
|
||||
compareSelectOpen = true;
|
||||
}
|
||||
|
||||
async function compareSelectedCommits() {
|
||||
if (!canCompare) return;
|
||||
await runOperation("Comparing commits", async () => {
|
||||
@@ -788,6 +826,7 @@
|
||||
selectedDiffPath = result.files[0]?.path ?? "";
|
||||
diffHighlightQuery = "";
|
||||
pendingRestoreFile = null;
|
||||
compareSelectOpen = false;
|
||||
compareDialogOpen = true;
|
||||
});
|
||||
}
|
||||
@@ -816,10 +855,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
function openCompareDialog() {
|
||||
if (comparison) compareDialogOpen = true;
|
||||
}
|
||||
|
||||
function closeCompareDialog() {
|
||||
compareDialogOpen = false;
|
||||
pendingRestoreFile = null;
|
||||
@@ -947,6 +982,8 @@
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
|
||||
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
|
||||
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
|
||||
else if (event.key === "Escape" && globalSearchOpen) closeGlobalSearchDialog();
|
||||
}
|
||||
</script>
|
||||
@@ -972,6 +1009,7 @@
|
||||
onPush={pushRepo}
|
||||
onRefresh={refreshRepo}
|
||||
onSearch={() => { globalSearchOpen = true; }}
|
||||
onCompare={openCompareSelect}
|
||||
onToggleAutoRefresh={toggleAutoRefresh}
|
||||
/>
|
||||
|
||||
@@ -1063,7 +1101,7 @@
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<!-- Center: summary + status + commit + compare -->
|
||||
<!-- Center: summary + status + commit -->
|
||||
<section class="main-panel" aria-label="Repository status">
|
||||
<div class="repo-summary">
|
||||
<div class="repo-meta">
|
||||
@@ -1106,21 +1144,6 @@
|
||||
onCommitMessageChange={(msg) => { commitMessage = msg; }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ComparePanel
|
||||
{commits}
|
||||
{hasRepository}
|
||||
{isBusy}
|
||||
{compareFrom}
|
||||
{compareTo}
|
||||
{canCompare}
|
||||
{comparison}
|
||||
{operation}
|
||||
onCompareFromChange={(val) => { compareFrom = val; }}
|
||||
onCompareToChange={(val) => { compareTo = val; }}
|
||||
onCompare={compareSelectedCommits}
|
||||
onOpenDialog={openCompareDialog}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- Right sidebar: commit graph + file history -->
|
||||
@@ -1132,6 +1155,7 @@
|
||||
{expandedCommitHashes}
|
||||
onRestoreCommit={restoreCommit}
|
||||
onPreviewCommitFile={previewCommitFileFromHistory}
|
||||
onCreateBranchFromCommit={openNewBranchDialog}
|
||||
onToggleCommitFiles={(hash) => {
|
||||
const next = new Set(expandedCommitHashes);
|
||||
if (next.has(hash)) next.delete(hash); else next.add(hash);
|
||||
@@ -1185,6 +1209,32 @@
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Create a branch from a specific commit in the history -->
|
||||
{#if newBranchCommit}
|
||||
<NewBranchDialog
|
||||
commit={newBranchCommit}
|
||||
{isBusy}
|
||||
onCreate={createBranchFromCommit}
|
||||
onClose={() => { newBranchCommit = null; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Compare: pick the two commits to diff -->
|
||||
{#if compareSelectOpen}
|
||||
<CompareSelectDialog
|
||||
{commits}
|
||||
{compareFrom}
|
||||
{compareTo}
|
||||
{canCompare}
|
||||
{isBusy}
|
||||
{operation}
|
||||
onCompareFromChange={(val) => { compareFrom = val; }}
|
||||
onCompareToChange={(val) => { compareTo = val; }}
|
||||
onCompare={compareSelectedCommits}
|
||||
onClose={() => { compareSelectOpen = false; }}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
|
||||
{#if compareDialogOpen && comparison}
|
||||
<CompareDialog
|
||||
|
||||
+52
-3
@@ -601,7 +601,7 @@
|
||||
|
||||
.main-panel {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -664,7 +664,8 @@
|
||||
|
||||
.top-section {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: minmax(0, 1fr) minmax(210px, auto);
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
@@ -1093,6 +1094,55 @@
|
||||
width: min(1180px, calc(100vw - 32px));
|
||||
height: min(840px, calc(100vh - 32px));
|
||||
}
|
||||
.compare-select-dialog {
|
||||
display: block;
|
||||
width: min(720px, calc(100vw - 32px));
|
||||
height: auto;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
}
|
||||
.new-branch-dialog {
|
||||
display: block;
|
||||
width: min(520px, calc(100vw - 32px));
|
||||
height: auto;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
}
|
||||
.new-branch-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
.new-branch-target {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--color-border-subtle);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
.new-branch-summary {
|
||||
overflow: hidden;
|
||||
color: var(--color-ink-dim);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.new-branch-field { display: grid; gap: 6px; }
|
||||
.new-branch-field span {
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 10.5px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.new-branch-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--color-border-subtle); background: var(--color-surface-dim); }
|
||||
.dialog-header > div:first-child { min-width: 0; }
|
||||
@@ -2182,7 +2232,6 @@
|
||||
|
||||
@media (min-width: 1800px) {
|
||||
.workspace { grid-template-columns: 320px minmax(0, 1fr) 680px; }
|
||||
.top-section { grid-template-columns: minmax(0, 1fr) 380px; }
|
||||
}
|
||||
|
||||
@media (max-width: 1400px) {
|
||||
|
||||
+13
-1
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Download, GitBranch, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
||||
import { Download, GitBranch, GitCompare, LoaderCircle, Minus, RefreshCw, Search, Upload, X } from "@lucide/svelte";
|
||||
|
||||
export let branch: string = "";
|
||||
export let ahead: number = 0;
|
||||
@@ -16,6 +16,7 @@
|
||||
export let onPush: () => void = () => {};
|
||||
export let onRefresh: () => void = () => {};
|
||||
export let onSearch: () => void = () => {};
|
||||
export let onCompare: () => void = () => {};
|
||||
export let onToggleAutoRefresh: () => void = () => {};
|
||||
|
||||
const win = getCurrentWindow();
|
||||
@@ -92,6 +93,17 @@
|
||||
<span class="tb-action-label">Search</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onCompare}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Compare commits"
|
||||
aria-label="Compare commits"
|
||||
>
|
||||
<GitCompare size={14} aria-hidden="true" />
|
||||
<span class="tb-action-label">Compare</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onPull}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitCommit } from "../types";
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
compareFrom: string;
|
||||
compareTo: string;
|
||||
canCompare: boolean;
|
||||
isBusy: boolean;
|
||||
operation: string;
|
||||
onCompareFromChange: (val: string) => void;
|
||||
onCompareToChange: (val: string) => void;
|
||||
onCompare: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
compareFrom = "",
|
||||
compareTo = "",
|
||||
canCompare = false,
|
||||
isBusy = false,
|
||||
operation = "",
|
||||
onCompareFromChange = () => {},
|
||||
onCompareToChange = () => {},
|
||||
onCompare = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function commitOptionLabel(item: GitCommit): string {
|
||||
return `${item.short_hash} - ${item.summary}`;
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
onCompare();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select commits to compare" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Select commits</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if commits.length < 2}
|
||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
||||
{:else}
|
||||
<form class="compare-form" onsubmit={handleSubmit}>
|
||||
<label class="compare-field">
|
||||
<span>From (older)</span>
|
||||
<select
|
||||
value={compareFrom}
|
||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
||||
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
|
||||
value={compareTo}
|
||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
||||
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="btn-primary" 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}
|
||||
<div class="blank-state">Pick two commits and run a comparison.</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight, RotateCcw } from "@lucide/svelte";
|
||||
import { ChevronDown, ChevronRight, GitBranch, RotateCcw } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||
|
||||
interface GraphSegment {
|
||||
@@ -29,6 +29,7 @@
|
||||
onRestoreCommit: (commit: GitCommit) => void;
|
||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||
onToggleCommitFiles: (hash: string) => void;
|
||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -39,6 +40,7 @@
|
||||
onRestoreCommit = () => {},
|
||||
onPreviewCommitFile = () => {},
|
||||
onToggleCommitFiles = () => {},
|
||||
onCreateBranchFromCommit = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function laneColor(col: number): string {
|
||||
@@ -220,6 +222,10 @@
|
||||
<div class="commit-actions">
|
||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<div class="commit-action-buttons">
|
||||
<button class="btn-sm" type="button" onclick={() => onCreateBranchFromCommit(item)} disabled={isBusy} title="Create a new branch from this commit">
|
||||
<GitBranch size={15} aria-hidden="true" />
|
||||
Branch
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Reset current branch to this commit">
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Restore
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script lang="ts">
|
||||
import { GitBranch, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { GitCommit } from "../types";
|
||||
|
||||
interface Props {
|
||||
commit: GitCommit;
|
||||
isBusy: boolean;
|
||||
onCreate: (name: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commit,
|
||||
isBusy = false,
|
||||
onCreate = () => {},
|
||||
onClose = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let name = $state("");
|
||||
|
||||
function submit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const value = name.trim();
|
||||
if (!value) return;
|
||||
onCreate(value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog new-branch-dialog" role="dialog" aria-modal="true" aria-label="Create branch from commit" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">New branch</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">From commit</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="new-branch-form" onsubmit={submit}>
|
||||
<div class="new-branch-target">
|
||||
<span class="hash">{commit.short_hash}</span>
|
||||
<span class="new-branch-summary" title={commit.summary}>{commit.summary}</span>
|
||||
</div>
|
||||
|
||||
<label class="new-branch-field">
|
||||
<span>Branch name</span>
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
bind:value={name}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="feature/my-branch"
|
||||
disabled={isBusy}
|
||||
autofocus
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="new-branch-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>
|
||||
Cancel
|
||||
</button>
|
||||
<button class="btn-primary" type="submit" disabled={isBusy || name.trim().length === 0}>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Create branch
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -38,6 +38,14 @@
|
||||
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||
}
|
||||
|
||||
function baseName(path: string): string {
|
||||
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
||||
}
|
||||
|
||||
function fileName(file: GitFileStatus): string {
|
||||
return file.old_path ? `${baseName(file.old_path)} -> ${baseName(file.path)}` : baseName(file.path);
|
||||
}
|
||||
|
||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||
</script>
|
||||
@@ -90,7 +98,7 @@
|
||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<article class="file-row">
|
||||
<div class="file-title">
|
||||
<strong title={displayPath(file)}>{displayPath(file)}</strong>
|
||||
<strong title={displayPath(file)}>{fileName(file)}</strong>
|
||||
</div>
|
||||
|
||||
<div class="change-lanes">
|
||||
|
||||
+11
-2
@@ -8,6 +8,7 @@ import type {
|
||||
GitRepositoryFile,
|
||||
GitSearchHit,
|
||||
GitStatus,
|
||||
RepositoryBundle,
|
||||
StoredCredential,
|
||||
} from "./types";
|
||||
|
||||
@@ -15,6 +16,10 @@ export function openRepository(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("open_repository", { path });
|
||||
}
|
||||
|
||||
export function openRepositoryBundle(path: string, commitLimit = 100): Promise<RepositoryBundle> {
|
||||
return invoke<RepositoryBundle>("open_repository_bundle", { path, commitLimit });
|
||||
}
|
||||
|
||||
export function getStatus(path: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("get_status", { path });
|
||||
}
|
||||
@@ -27,8 +32,12 @@ export function checkoutBranch(path: string, branch: string): Promise<GitStatus>
|
||||
return invoke<GitStatus>("checkout_branch", { path, branch });
|
||||
}
|
||||
|
||||
export function createBranch(path: string, branch: string): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("create_branch", { path, branch });
|
||||
export function createBranch(
|
||||
path: string,
|
||||
branch: string,
|
||||
startPoint?: string,
|
||||
): Promise<GitStatus> {
|
||||
return invoke<GitStatus>("create_branch", { path, branch, startPoint: startPoint ?? null });
|
||||
}
|
||||
|
||||
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
||||
|
||||
@@ -54,6 +54,13 @@ export interface GitRepositoryFile {
|
||||
status: FileStatusKind | null;
|
||||
}
|
||||
|
||||
export interface RepositoryBundle {
|
||||
status: GitStatus;
|
||||
branches: GitBranch[];
|
||||
commits: GitCommit[];
|
||||
files: GitRepositoryFile[];
|
||||
}
|
||||
|
||||
export interface GitDiffFile {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
|
||||
Reference in New Issue
Block a user