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 @@