From 50524c2759f8d23522c88d732c080d1cb7985a2f Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Tue, 4 Aug 2026 18:04:10 +0200 Subject: [PATCH] feat(commits): enhance commit listing with pagination and skipping This update introduces pagination and skipping functionality for the commit listing feature, allowing users to load commits in pages and navigate through them more efficiently. The UI has been adjusted to support loading more commits dynamically, improving the overall user experience when dealing with large repositories. - Added pagination support for commit history - Introduced a loading mechanism for fetching more commits - Updated UI components to reflect changes in commit loading behavior --- docs/api-contract.md | 2 +- src-tauri/src/git.rs | 56 +++- src/App.svelte | 279 +++++++++++-------- src/app.css | 82 ++++++ src/lib/components/BlameDialog.svelte | 7 +- src/lib/components/FileHistoryPanel.svelte | 7 +- src/lib/components/GlobalSearchDialog.svelte | 7 +- src/lib/components/HistoryPanel.svelte | 58 +++- src/lib/components/StatusPanel.svelte | 6 +- src/lib/git.ts | 4 +- 10 files changed, 377 insertions(+), 131 deletions(-) diff --git a/docs/api-contract.md b/docs/api-contract.md index c0b4a41..e7fa323 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -106,7 +106,7 @@ The command list below includes the repository-management and synchronization AP - `fetch(path: string, prune?: boolean, remote?: string): Promise` - `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise` - `push(path: string, forceWithLease?: boolean, remote?: string): Promise` -- `list_commits(path: string, limit?: number): Promise` +- `list_commits(path: string, limit?: number, skip?: number): Promise` - `restore_to_commit(path: string, commit: string): Promise` - `restore_file_from_commit(path: string, commit: string, file: string): Promise` (the `file` argument can also be a folder path) - `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise` diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 4bfb95e..edbf398 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -2917,17 +2917,31 @@ fn cherry_pick_status_or_error( } #[tauri::command] -pub fn list_commits(path: String, limit: Option) -> Result, String> { +pub fn list_commits( + path: String, + limit: Option, + skip: Option, +) -> Result, String> { let repo = resolve_repo(&path)?; - commits_for_repo(&repo, limit) + commit_page_for_repo(&repo, limit, skip) } fn commits_for_repo(repo: &Path, limit: Option) -> Result, String> { + let bounded_limit = limit.unwrap_or(100).clamp(1, 500); + commit_page_for_repo(repo, Some(bounded_limit), None) +} + +fn commit_page_for_repo( + repo: &Path, + limit: Option, + skip: Option, +) -> Result, String> { if verify_commit(repo, "HEAD").is_err() { return Ok(Vec::new()); } - let limit = limit.unwrap_or(100).clamp(1, 500).to_string(); + let limit = limit.unwrap_or(100).clamp(1, 5_000).to_string(); + let skip = skip.unwrap_or(0).min(10_000_000).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). @@ -2943,6 +2957,8 @@ fn commits_for_repo(repo: &Path, limit: Option) -> Result, S "-z", "--root", "--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f", + "--skip", + skip.as_str(), "-n", limit.as_str(), ], @@ -6339,6 +6355,40 @@ mod tests { })); } + #[test] + fn commit_pages_use_stable_non_overlapping_offsets() { + let repo = init_temp_repo("commit_pages"); + commit_initial_file(&repo.path); + + for index in 1..=3 { + fs::write(repo.path.join("old.txt"), format!("version {index}\n")) + .expect("tracked file should change"); + run_git_test(&repo.path, ["add", "old.txt"]); + run_git_test( + &repo.path, + ["commit", "-q", "-m", format!("commit {index}").as_str()], + ); + } + + let all = commits_for_repo(&repo.path, Some(10)).expect("commits should load"); + let first = commit_page_for_repo(&repo.path, Some(2), Some(0)) + .expect("first commit page should load"); + let second = commit_page_for_repo(&repo.path, Some(2), Some(2)) + .expect("second commit page should load"); + + assert_eq!(first.len(), 2); + assert_eq!(second.len(), 2); + assert_eq!(first[0].hash, all[0].hash); + assert_eq!(first[1].hash, all[1].hash); + assert_eq!(second[0].hash, all[2].hash); + assert_eq!(second[1].hash, all[3].hash); + assert!( + first + .iter() + .all(|commit| second.iter().all(|other| other.hash != commit.hash)) + ); + } + #[tokio::test] #[cfg_attr( windows, diff --git a/src/App.svelte b/src/App.svelte index ff19478..5c83312 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -8,37 +8,28 @@ import TitleBar from "./lib/TitleBar.svelte"; import RepoToolbar from "./lib/RepoToolbar.svelte"; import RepoTabs from "./lib/RepoTabs.svelte"; - import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte"; import AiReviewDialog from "./lib/components/AiReviewDialog.svelte"; import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte"; import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte"; import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte"; import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; - import BlameDialog from "./lib/components/BlameDialog.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte"; import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte"; import CommitPanel from "./lib/components/CommitPanel.svelte"; - import CompareDialog from "./lib/components/CompareDialog.svelte"; import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte"; import CredentialDialog from "./lib/components/CredentialDialog.svelte"; import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte"; import ExplorerPanel from "./lib/components/ExplorerPanel.svelte"; import FileHistoryPanel from "./lib/components/FileHistoryPanel.svelte"; - import GlobalSearchDialog from "./lib/components/GlobalSearchDialog.svelte"; - import HelpOverlay from "./lib/components/HelpOverlay.svelte"; import HistoryPanel from "./lib/components/HistoryPanel.svelte"; - import InteractiveRebaseDialog from "./lib/components/InteractiveRebaseDialog.svelte"; - import LinePatchDialog from "./lib/components/LinePatchDialog.svelte"; import NewBranchDialog from "./lib/components/NewBranchDialog.svelte"; import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte"; import ReflogDialog from "./lib/components/ReflogDialog.svelte"; import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte"; - import ResolveDialog from "./lib/components/ResolveDialog.svelte"; import StashPanel from "./lib/components/StashPanel.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte"; import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte"; - import WorktreeDialog from "./lib/components/WorktreeDialog.svelte"; import { amendCommit, @@ -250,6 +241,7 @@ const FILE_HISTORY_MIN_WIDTH = 240; const FILE_HISTORY_MAX_WIDTH = 520; const ERROR_AUTO_HIDE_MS = 6000; + const COMMIT_HISTORY_PAGE_SIZE = 50; // ── State ────────────────────────────────────────────────────────────────── @@ -279,6 +271,10 @@ let tags: GitTag[] = []; let stashes: GitStash[] = []; let commits: GitCommit[] = []; + let commitHistoryHasMore = false; + let commitHistoryLoadingMore = false; + let commitHistoryLoadError = ""; + let commitHistoryRequestId = 0; let repoFiles: GitRepositoryFile[] = []; let selectedExplorerPath = ""; let selectedExplorerKind: ExplorerNodeKind = "file"; @@ -754,7 +750,7 @@ if (statusFingerprint(nextStatus) === lastStatusFingerprint) return; applyStatus(nextStatus); // Something changed — reload branches, commits and files in one bundled call. - const bundle = await openRepositoryBundle(path, 100); + const bundle = await openRepositoryBundle(path, Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length) + 1); if (!sameRepoPath(path, activeRepoPath)) return; const previousHeadHash = lastFileHistoryHeadHash; await refreshBranchList(path, bundle.branches); @@ -1826,6 +1822,10 @@ branches = []; stashes = []; commits = []; + commitHistoryHasMore = false; + commitHistoryLoadingMore = false; + commitHistoryLoadError = ""; + commitHistoryRequestId += 1; lastFileHistoryHeadHash = ""; repoFiles = []; selectedExplorerPath = ""; @@ -1945,7 +1945,19 @@ } async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) { - commits = prefetched ?? (await listCommits(path, 100)); + const targetLimit = prefetched + ? Math.max(COMMIT_HISTORY_PAGE_SIZE, prefetched.length - 1) + : Math.max(COMMIT_HISTORY_PAGE_SIZE, commits.length); + const requestId = ++commitHistoryRequestId; + commitHistoryLoadingMore = false; + commitHistoryLoadError = ""; + const history = prefetched ?? (await listCommits(path, targetLimit + 1, 0)); + if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return; + + commits = history.slice(0, targetLimit); + commitHistoryHasMore = history.length > targetLimit; + commitHistoryLoadingMore = false; + commitHistoryLoadError = ""; lastFileHistoryHeadHash = commits[0]?.hash ?? ""; const hashes = new Set(commits.map((c) => c.hash)); if (compareFrom && !hashes.has(compareFrom)) compareFrom = ""; @@ -1958,6 +1970,33 @@ } } + async function loadMoreCommitHistory() { + if (!activeRepoPath || commitHistoryLoadingMore || !commitHistoryHasMore || isBusy) return; + + const path = activeRepoPath; + const offset = commits.length; + const requestId = ++commitHistoryRequestId; + commitHistoryLoadingMore = true; + commitHistoryLoadError = ""; + + try { + const history = await listCommits(path, COMMIT_HISTORY_PAGE_SIZE + 1, offset); + if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return; + + const knownHashes = new Set(commits.map((commit) => commit.hash)); + const nextPage = history + .slice(0, COMMIT_HISTORY_PAGE_SIZE) + .filter((commit) => !knownHashes.has(commit.hash)); + commits = [...commits, ...nextPage]; + commitHistoryHasMore = history.length > COMMIT_HISTORY_PAGE_SIZE; + } catch (error) { + if (requestId !== commitHistoryRequestId || !sameRepoPath(path, activeRepoPath)) return; + commitHistoryLoadError = errorToMessage(error); + } finally { + if (requestId === commitHistoryRequestId) commitHistoryLoadingMore = false; + } + } + async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) { repoFiles = prefetched ?? (await listRepositoryFiles(path)); const folderPaths = allExplorerFolderPaths(repoFiles); @@ -2043,7 +2082,7 @@ await new Promise((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); + const bundle = await openRepositoryBundle(path, COMMIT_HISTORY_PAGE_SIZE + 1); if (requestId !== repoOpenRequestId) return; resetRepositoryState(false); applyStatus(bundle.status); @@ -2117,7 +2156,7 @@ directoryName || undefined, username, password, - 100, + COMMIT_HISTORY_PAGE_SIZE + 1, ); resetRepositoryState(false); applyStatus(bundle.status); @@ -4572,7 +4611,11 @@ repositoryKey={activeRepoPath} {hasRepository} {isBusy} + hasMore={commitHistoryHasMore} + isLoadingMore={commitHistoryLoadingMore} + loadMoreError={commitHistoryLoadError} {expandedCommitHashes} + onLoadMore={loadMoreCommitHistory} onRestoreCommit={restoreCommit} onPreviewCommitFile={previewCommitFileFromHistory} onCreateBranchFromCommit={openNewBranchDialog} @@ -4665,7 +4708,9 @@ {/if} {#if helpOpen} - { helpOpen = false; }} /> + {#await import("./lib/components/HelpOverlay.svelte") then module} + { helpOpen = false; }} /> + {/await} {/if} {#if aiReviewOpen && aiReviewResult} @@ -4688,28 +4733,32 @@ {/if} {#if linePatchOpen && linePatchFile} - + {#await import("./lib/components/LinePatchDialog.svelte") then module} + + {/await} {/if} {#if blameOpen} - + {#await import("./lib/components/BlameDialog.svelte") then module} + + {/await} {/if} {#if pendingDiscard} @@ -4724,44 +4773,48 @@ {/if} {#if globalSearchOpen} - + {#await import("./lib/components/GlobalSearchDialog.svelte") then module} + + {/await} {/if} {#if worktreeDialogOpen} - + {#await import("./lib/components/WorktreeDialog.svelte") then module} + + {/await} {/if} @@ -4797,29 +4850,33 @@ {#if aiSettingsOpen} - { aiSettingsOpen = false; }} - /> + {#await import("./lib/components/AiSettingsDialog.svelte") then module} + { aiSettingsOpen = false; }} + /> + {/await} {/if} {#if interactiveRebaseOpen} - { if (!isBusy) interactiveRebaseOpen = false; }} - /> + {#await import("./lib/components/InteractiveRebaseDialog.svelte") then module} + { if (!isBusy) interactiveRebaseOpen = false; }} + /> + {/await} {/if} {#if reflogOpen} @@ -4854,16 +4911,18 @@ {#if compareDialogOpen && comparison} - + {#await import("./lib/components/CompareDialog.svelte") then module} + + {/await} {/if} @@ -4918,16 +4977,18 @@ {#if resolveDialogOpen} - { resolveDialogOpen = false; }} - onSelectFile={selectConflictFile} - onMarkResolved={handleMarkResolved} - onApply={applyPreparedResolutions} - /> + {#await import("./lib/components/ResolveDialog.svelte") then module} + { resolveDialogOpen = false; }} + onSelectFile={selectConflictFile} + onMarkResolved={handleMarkResolved} + onApply={applyPreparedResolutions} + /> + {/await} {/if} diff --git a/src/app.css b/src/app.css index 2b0e8e6..1e191e1 100644 --- a/src/app.css +++ b/src/app.css @@ -2861,6 +2861,31 @@ background: var(--color-surface-dim); } + .history-load-more { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 54px; + padding: 9px 12px; + border-top: 1px solid var(--color-border-subtle); + color: var(--color-ink-faint); + background: var(--color-surface-dim); + font-size: 11.5px; + } + + .history-load-more-button { + min-height: 30px; + border-color: transparent; + color: var(--color-primary); + background: transparent; + } + + .history-load-more-button:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--color-primary) 22%, transparent); + background: color-mix(in srgb, var(--color-primary) 8%, transparent); + } + .graph-row { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -5753,6 +5778,10 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s } .graph-row + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); } .graph-row:hover .commit-body { background: var(--color-surface-hover); } +.graph-row { + content-visibility: auto; + contain-intrinsic-block-size: 108px; +} .commit-avatar { border-radius: 50%; } .commit-kind, .commit-branch-chip, .ref-chip { border-radius: 4px !important; } .file-history-row { margin: 0; border: 0; border-bottom: 1px solid var(--color-border-subtle); border-radius: 0; background: transparent; } @@ -5808,6 +5837,10 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; } } +@media (hover: none), (pointer: coarse) { + .status-file-actions { opacity: 1; } +} + :root[data-theme="light"] .repo-tab-wrap { background: rgba(255,255,255,0.5); } @@ -6106,12 +6139,53 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s background: #eef3f9; } +:root[data-theme="light"] .cred-card { + border-color: rgba(49, 95, 214, 0.22); + background: #ffffff; + box-shadow: 0 28px 72px rgba(28, 44, 74, 0.2), 0 0 0 1px rgba(255,255,255,0.82) inset; +} + +:root[data-theme="light"] .cred-hero { + border-bottom-color: rgba(34, 49, 78, 0.1); + background: + radial-gradient(circle at 8% 0%, rgba(49,95,214,0.13), transparent 42%), + linear-gradient(135deg, #f8faff, #eef4fc); +} + +:root[data-theme="light"] .cred-hero-icon { + border-color: rgba(49,95,214,0.2); + color: #ffffff; + background: linear-gradient(135deg, #4d8dff, #0f8fb5); + box-shadow: 0 12px 26px rgba(49,95,214,0.2), inset 0 1px 0 rgba(255,255,255,0.24); +} + +:root[data-theme="light"] .cred-hero-label { color: #315fd6; } +:root[data-theme="light"] .cred-hero-title { color: #172033; } +:root[data-theme="light"] .cred-hero-copy { color: #526078; } + +:root[data-theme="light"] .cred-security-note { + border-color: rgba(15,143,181,0.2); + color: #21647a; + background: rgba(15,143,181,0.07); +} + +:root[data-theme="light"] .cred-security-note svg { color: #0f8fb5; } + +:root[data-theme="light"] .cred-body { + background: #ffffff; +} + :root[data-theme="light"] .cred-seg-btn.active { border-color: rgba(49,95,214,0.26); background: linear-gradient(135deg, rgba(49,95,214,0.12), rgba(15,143,181,0.08)); box-shadow: 0 8px 18px rgba(28,44,74,0.1), inset 0 1px 0 rgba(255,255,255,0.9); } +:root[data-theme="light"] .cred-seg-btn:hover:not(.active) { + color: var(--color-ink); + background: rgba(255,255,255,0.68); +} + :root[data-theme="light"] .cred-close { border-color: var(--color-border-subtle); color: var(--color-ink-dim); @@ -6147,10 +6221,18 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s :root[data-theme="light"] .cred-input input, :root[data-theme="light"] .cred-expiry input[type="date"] { + border-color: var(--color-border-input); background: rgba(255,255,255,0.92); color-scheme: light; } +:root[data-theme="light"] .cred-input input::placeholder { color: #8995a8; } + +:root[data-theme="light"] .cred-reveal:hover { + color: var(--color-ink); + background: var(--color-surface-hover); +} + :root[data-theme="light"] .cred-token-hint code { color: #0d78a0; } diff --git a/src/lib/components/BlameDialog.svelte b/src/lib/components/BlameDialog.svelte index 0738f02..813429b 100644 --- a/src/lib/components/BlameDialog.svelte +++ b/src/lib/components/BlameDialog.svelte @@ -2,6 +2,11 @@ import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte"; import type { GitBlameLine } from "../types"; + const commitDateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + interface BlameGroup { id: string; hash: string; @@ -66,7 +71,7 @@ if (!seconds) return ""; const date = new Date(seconds * 1000); if (Number.isNaN(date.getTime())) return ""; - return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); + return commitDateFormatter.format(date); } function groupTooltip(group: BlameGroup): string { diff --git a/src/lib/components/FileHistoryPanel.svelte b/src/lib/components/FileHistoryPanel.svelte index 5cf3b1d..dd32720 100644 --- a/src/lib/components/FileHistoryPanel.svelte +++ b/src/lib/components/FileHistoryPanel.svelte @@ -3,6 +3,11 @@ import iconUrl from "../../../src-tauri/icons/icon.png"; import type { GitCommit } from "../types"; + const commitDateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + interface Props { fileHistory: GitCommit[]; selectedExplorerPath: string; @@ -32,7 +37,7 @@ function formatCommitDate(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); + return commitDateFormatter.format(date); } function fileName(path: string): string { diff --git a/src/lib/components/GlobalSearchDialog.svelte b/src/lib/components/GlobalSearchDialog.svelte index 10c99df..d3d681a 100644 --- a/src/lib/components/GlobalSearchDialog.svelte +++ b/src/lib/components/GlobalSearchDialog.svelte @@ -15,6 +15,11 @@ import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types"; import LanguageIcon from "./LanguageIcon.svelte"; + const commitDateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); + type SearchTab = "code" | "files"; interface Props { @@ -82,7 +87,7 @@ function formatCommitDate(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return value; - return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date); + return commitDateFormatter.format(date); } function displayPath(hit: GitSearchHit): string { diff --git a/src/lib/components/HistoryPanel.svelte b/src/lib/components/HistoryPanel.svelte index 70958f6..0ee555b 100644 --- a/src/lib/components/HistoryPanel.svelte +++ b/src/lib/components/HistoryPanel.svelte @@ -1,5 +1,5 @@