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
This commit is contained in:
2026-08-04 18:04:10 +02:00
parent 0cd97db523
commit 50524c2759
10 changed files with 377 additions and 131 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ The command list below includes the repository-management and synchronization AP
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
- `list_commits(path: string, limit?: number, skip?: number): Promise<GitCommit[]>`
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
+53 -3
View File
@@ -2917,17 +2917,31 @@ fn cherry_pick_status_or_error(
}
#[tauri::command]
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
pub fn list_commits(
path: String,
limit: Option<u32>,
skip: Option<u32>,
) -> Result<Vec<GitCommit>, 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<u32>) -> Result<Vec<GitCommit>, 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<u32>,
skip: 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();
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<u32>) -> Result<Vec<GitCommit>, 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,
+83 -22
View File
@@ -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<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);
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}
<HelpOverlay language={appLanguage} onClose={() => { helpOpen = false; }} />
{#await import("./lib/components/HelpOverlay.svelte") then module}
<module.default language={appLanguage} onClose={() => { helpOpen = false; }} />
{/await}
{/if}
{#if aiReviewOpen && aiReviewResult}
@@ -4688,7 +4733,8 @@
{/if}
{#if linePatchOpen && linePatchFile}
<LinePatchDialog
{#await import("./lib/components/LinePatchDialog.svelte") then module}
<module.default
file={linePatchFile}
staged={linePatchStaged}
patch={linePatchText}
@@ -4699,10 +4745,12 @@
onRefresh={refreshLinePatch}
onApply={applyLinePatch}
/>
{/await}
{/if}
{#if blameOpen}
<BlameDialog
{#await import("./lib/components/BlameDialog.svelte") then module}
<module.default
filePath={blameFilePath}
lines={blameLines}
{isBusy}
@@ -4710,6 +4758,7 @@
error={blameError}
onClose={closeBlame}
/>
{/await}
{/if}
{#if pendingDiscard}
@@ -4724,7 +4773,8 @@
{/if}
{#if globalSearchOpen}
<GlobalSearchDialog
{#await import("./lib/components/GlobalSearchDialog.svelte") then module}
<module.default
{hasRepository}
{isBusy}
isSearching={globalSearchBusy}
@@ -4741,10 +4791,12 @@
onFileHistoryDiff={diffSelectedFileFromCommit}
onFileHistoryRestore={restoreSelectedFileFromCommit}
/>
{/await}
{/if}
{#if worktreeDialogOpen}
<WorktreeDialog
{#await import("./lib/components/WorktreeDialog.svelte") then module}
<module.default
{worktrees}
{branches}
initialBranch={worktreeInitialBranch}
@@ -4762,6 +4814,7 @@
onRepair={repairSelectedWorktree}
onClose={closeWorktreeDialog}
/>
{/await}
{/if}
<!-- Create a branch from a specific commit in the history -->
@@ -4797,17 +4850,20 @@
<!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen}
<AiSettingsDialog
{#await import("./lib/components/AiSettingsDialog.svelte") then module}
<module.default
settings={aiSettings}
localModels={localModelOptions}
onSave={saveAiSettings}
onClose={() => { aiSettingsOpen = false; }}
/>
{/await}
{/if}
<!-- Compare: pick the two commits to diff -->
{#if interactiveRebaseOpen}
<InteractiveRebaseDialog
{#await import("./lib/components/InteractiveRebaseDialog.svelte") then module}
<module.default
{branches}
currentBranch={status?.current_branch ?? ""}
base={interactiveRebaseBase}
@@ -4820,6 +4876,7 @@
onStart={runInteractiveRebase}
onClose={() => { if (!isBusy) interactiveRebaseOpen = false; }}
/>
{/await}
{/if}
{#if reflogOpen}
@@ -4854,7 +4911,8 @@
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
{#if compareDialogOpen && comparison}
<CompareDialog
{#await import("./lib/components/CompareDialog.svelte") then module}
<module.default
{comparison}
{selectedDiffPath}
{isBusy}
@@ -4864,6 +4922,7 @@
onRestore={restorePreviewedCommitFile}
onSelectFile={selectDiffFile}
/>
{/await}
{/if}
<!-- Credential dialog for push/pull -->
@@ -4918,7 +4977,8 @@
<!-- Conflict resolve dialog -->
{#if resolveDialogOpen}
<ResolveDialog
{#await import("./lib/components/ResolveDialog.svelte") then module}
<module.default
{conflictedFiles}
{conflictTarget}
{conflict}
@@ -4930,4 +4990,5 @@
onMarkResolved={handleMarkResolved}
onApply={applyPreparedResolutions}
/>
{/await}
{/if}
+82
View File
@@ -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;
}
+6 -1
View File
@@ -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 {
+6 -1
View File
@@ -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 {
+6 -1
View File
@@ -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 {
+45 -9
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
@@ -27,6 +27,10 @@
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
];
const GRAPH_LANE = 18;
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface Props {
commits: GitCommit[];
@@ -36,7 +40,11 @@
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
hasMore: boolean;
isLoadingMore: boolean;
loadMoreError: string;
expandedCommitHashes: Set<string>;
onLoadMore: () => void | Promise<void>;
onRestoreCommit: (commit: GitCommit) => void;
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void;
@@ -53,7 +61,11 @@
repositoryKey = "",
hasRepository = false,
isBusy = false,
hasMore = false,
isLoadingMore = false,
loadMoreError = "",
expandedCommitHashes = new Set(),
onLoadMore = () => {},
onRestoreCommit = () => {},
onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {},
@@ -71,6 +83,17 @@
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function observeHistoryEnd(node: HTMLElement) {
const root = node.closest<HTMLElement>(".history-list");
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting) && hasMore && !isLoadingMore && !loadMoreError && !isBusy) {
void onLoadMore();
}
}, { root, rootMargin: "240px 0px" });
observer.observe(node);
return { destroy: () => observer.disconnect() };
}
function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
}
@@ -464,7 +487,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);
}
$effect(() => {
@@ -518,8 +541,8 @@
<span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
</div>
<div class="section-head-actions">
{#if localBranchNames.length > 0}
<div class="section-head-actions">
<button
class="graph-branch-dialog-button"
type="button"
@@ -530,21 +553,19 @@
Branches
<span>{visibleBranchCount}/{localBranchNames.length}</span>
</button>
{/if}
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
{visibleCommits.length}
</span>
</div>
{/if}
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if commits.length === 0}
<div class="blank-state">No commits returned.</div>
{:else if visibleCommits.length === 0}
<div class="blank-state">No commits match the selected branches.</div>
{:else}
<div class="history-list graph-list overflow-auto">
{#if visibleCommits.length === 0}
<div class="blank-state">No loaded commits match the selected branches.</div>
{/if}
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
{@const row = graphRows[rowIndex]}
@@ -698,6 +719,21 @@
</div>
</article>
{/each}
{#if hasMore || isLoadingMore || loadMoreError}
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
{#if isLoadingMore}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
<span>Loading older commits…</span>
{:else if loadMoreError}
<span title={loadMoreError}>Older commits could not be loaded.</span>
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
{:else}
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
Load older commits
</button>
{/if}
</div>
{/if}
</div>
{/if}
+4 -2
View File
@@ -144,6 +144,8 @@
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null));
let stagedFiles = $derived(changedFiles.filter((file) => file.staged !== null));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
@@ -201,7 +203,7 @@
</div>
</header>
<div class="status-file-list">
{#each changedFiles.filter((file) => file.unstaged !== null) as file (`unstaged:${fileKey(file)}`)}
{#each unstagedFiles as file (`unstaged:${fileKey(file)}`)}
{@const stageTargets = selectedStageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
@@ -241,7 +243,7 @@
</div>
</header>
<div class="status-file-list">
{#each changedFiles.filter((file) => file.staged !== null) as file (`staged:${fileKey(file)}`)}
{#each stagedFiles as file (`staged:${fileKey(file)}`)}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
+2 -2
View File
@@ -366,8 +366,8 @@ export function credDelete(key: string): Promise<void> {
return invoke<void>("cred_delete", { key });
}
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_commits", { path, limit });
export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_commits", { path, limit, skip });
}
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {