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
+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 {
+47 -11
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}
{#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>
</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> {