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:
@@ -106,7 +106,7 @@ The command list below includes the repository-management and synchronization AP
|
|||||||
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
|
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
|
||||||
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: 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>`
|
- `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_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)
|
- `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>`
|
- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
|
||||||
|
|||||||
+53
-3
@@ -2917,17 +2917,31 @@ fn cherry_pick_status_or_error(
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[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)?;
|
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> {
|
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() {
|
if verify_commit(repo, "HEAD").is_err() {
|
||||||
return Ok(Vec::new());
|
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
|
// 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
|
// `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).
|
// (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",
|
"-z",
|
||||||
"--root",
|
"--root",
|
||||||
"--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f",
|
"--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f",
|
||||||
|
"--skip",
|
||||||
|
skip.as_str(),
|
||||||
"-n",
|
"-n",
|
||||||
limit.as_str(),
|
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]
|
#[tokio::test]
|
||||||
#[cfg_attr(
|
#[cfg_attr(
|
||||||
windows,
|
windows,
|
||||||
|
|||||||
+170
-109
@@ -8,37 +8,28 @@
|
|||||||
import TitleBar from "./lib/TitleBar.svelte";
|
import TitleBar from "./lib/TitleBar.svelte";
|
||||||
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
import RepoToolbar from "./lib/RepoToolbar.svelte";
|
||||||
import RepoTabs from "./lib/RepoTabs.svelte";
|
import RepoTabs from "./lib/RepoTabs.svelte";
|
||||||
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
|
||||||
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
|
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
|
||||||
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
|
import AiCommitSplitDialog from "./lib/components/AiCommitSplitDialog.svelte";
|
||||||
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
|
import AnalyticsNoticeDialog from "./lib/components/AnalyticsNoticeDialog.svelte";
|
||||||
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
|
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
|
||||||
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
|
||||||
import BlameDialog from "./lib/components/BlameDialog.svelte";
|
|
||||||
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
import BranchPanel from "./lib/components/BranchPanel.svelte";
|
||||||
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
|
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
|
||||||
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
import CommitPanel from "./lib/components/CommitPanel.svelte";
|
||||||
import CompareDialog from "./lib/components/CompareDialog.svelte";
|
|
||||||
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
|
import CompareSelectDialog from "./lib/components/CompareSelectDialog.svelte";
|
||||||
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
import CredentialDialog from "./lib/components/CredentialDialog.svelte";
|
||||||
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
|
import DiscardConfirmDialog from "./lib/components/DiscardConfirmDialog.svelte";
|
||||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||||
import FileHistoryPanel from "./lib/components/FileHistoryPanel.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 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 NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
||||||
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
||||||
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
|
import ReflogDialog from "./lib/components/ReflogDialog.svelte";
|
||||||
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
|
import RepoLoadingOverlay from "./lib/components/RepoLoadingOverlay.svelte";
|
||||||
import ResolveDialog from "./lib/components/ResolveDialog.svelte";
|
|
||||||
import StashPanel from "./lib/components/StashPanel.svelte";
|
import StashPanel from "./lib/components/StashPanel.svelte";
|
||||||
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
import StatusPanel from "./lib/components/StatusPanel.svelte";
|
||||||
import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
|
import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
|
||||||
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
import UpdateToast from "./lib/components/UpdateToast.svelte";
|
||||||
import WorktreeDialog from "./lib/components/WorktreeDialog.svelte";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
amendCommit,
|
amendCommit,
|
||||||
@@ -250,6 +241,7 @@
|
|||||||
const FILE_HISTORY_MIN_WIDTH = 240;
|
const FILE_HISTORY_MIN_WIDTH = 240;
|
||||||
const FILE_HISTORY_MAX_WIDTH = 520;
|
const FILE_HISTORY_MAX_WIDTH = 520;
|
||||||
const ERROR_AUTO_HIDE_MS = 6000;
|
const ERROR_AUTO_HIDE_MS = 6000;
|
||||||
|
const COMMIT_HISTORY_PAGE_SIZE = 50;
|
||||||
|
|
||||||
// ── State ──────────────────────────────────────────────────────────────────
|
// ── State ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -279,6 +271,10 @@
|
|||||||
let tags: GitTag[] = [];
|
let tags: GitTag[] = [];
|
||||||
let stashes: GitStash[] = [];
|
let stashes: GitStash[] = [];
|
||||||
let commits: GitCommit[] = [];
|
let commits: GitCommit[] = [];
|
||||||
|
let commitHistoryHasMore = false;
|
||||||
|
let commitHistoryLoadingMore = false;
|
||||||
|
let commitHistoryLoadError = "";
|
||||||
|
let commitHistoryRequestId = 0;
|
||||||
let repoFiles: GitRepositoryFile[] = [];
|
let repoFiles: GitRepositoryFile[] = [];
|
||||||
let selectedExplorerPath = "";
|
let selectedExplorerPath = "";
|
||||||
let selectedExplorerKind: ExplorerNodeKind = "file";
|
let selectedExplorerKind: ExplorerNodeKind = "file";
|
||||||
@@ -754,7 +750,7 @@
|
|||||||
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
|
if (statusFingerprint(nextStatus) === lastStatusFingerprint) return;
|
||||||
applyStatus(nextStatus);
|
applyStatus(nextStatus);
|
||||||
// Something changed — reload branches, commits and files in one bundled call.
|
// 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;
|
if (!sameRepoPath(path, activeRepoPath)) return;
|
||||||
const previousHeadHash = lastFileHistoryHeadHash;
|
const previousHeadHash = lastFileHistoryHeadHash;
|
||||||
await refreshBranchList(path, bundle.branches);
|
await refreshBranchList(path, bundle.branches);
|
||||||
@@ -1826,6 +1822,10 @@
|
|||||||
branches = [];
|
branches = [];
|
||||||
stashes = [];
|
stashes = [];
|
||||||
commits = [];
|
commits = [];
|
||||||
|
commitHistoryHasMore = false;
|
||||||
|
commitHistoryLoadingMore = false;
|
||||||
|
commitHistoryLoadError = "";
|
||||||
|
commitHistoryRequestId += 1;
|
||||||
lastFileHistoryHeadHash = "";
|
lastFileHistoryHeadHash = "";
|
||||||
repoFiles = [];
|
repoFiles = [];
|
||||||
selectedExplorerPath = "";
|
selectedExplorerPath = "";
|
||||||
@@ -1945,7 +1945,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshCommitHistory(path = activeRepoPath, prefetched?: GitCommit[]) {
|
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 ?? "";
|
lastFileHistoryHeadHash = commits[0]?.hash ?? "";
|
||||||
const hashes = new Set(commits.map((c) => c.hash));
|
const hashes = new Set(commits.map((c) => c.hash));
|
||||||
if (compareFrom && !hashes.has(compareFrom)) compareFrom = "";
|
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[]) {
|
async function refreshExplorerFiles(path = activeRepoPath, prefetched?: GitRepositoryFile[]) {
|
||||||
repoFiles = prefetched ?? (await listRepositoryFiles(path));
|
repoFiles = prefetched ?? (await listRepositoryFiles(path));
|
||||||
const folderPaths = allExplorerFolderPaths(repoFiles);
|
const folderPaths = allExplorerFolderPaths(repoFiles);
|
||||||
@@ -2043,7 +2082,7 @@
|
|||||||
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
|
||||||
// Single backend round-trip: resolves the repo and reads status, branches,
|
// Single backend round-trip: resolves the repo and reads status, branches,
|
||||||
// commits and files in one pass instead of four sequential git calls.
|
// 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;
|
if (requestId !== repoOpenRequestId) return;
|
||||||
resetRepositoryState(false);
|
resetRepositoryState(false);
|
||||||
applyStatus(bundle.status);
|
applyStatus(bundle.status);
|
||||||
@@ -2117,7 +2156,7 @@
|
|||||||
directoryName || undefined,
|
directoryName || undefined,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
100,
|
COMMIT_HISTORY_PAGE_SIZE + 1,
|
||||||
);
|
);
|
||||||
resetRepositoryState(false);
|
resetRepositoryState(false);
|
||||||
applyStatus(bundle.status);
|
applyStatus(bundle.status);
|
||||||
@@ -4572,7 +4611,11 @@
|
|||||||
repositoryKey={activeRepoPath}
|
repositoryKey={activeRepoPath}
|
||||||
{hasRepository}
|
{hasRepository}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
|
hasMore={commitHistoryHasMore}
|
||||||
|
isLoadingMore={commitHistoryLoadingMore}
|
||||||
|
loadMoreError={commitHistoryLoadError}
|
||||||
{expandedCommitHashes}
|
{expandedCommitHashes}
|
||||||
|
onLoadMore={loadMoreCommitHistory}
|
||||||
onRestoreCommit={restoreCommit}
|
onRestoreCommit={restoreCommit}
|
||||||
onPreviewCommitFile={previewCommitFileFromHistory}
|
onPreviewCommitFile={previewCommitFileFromHistory}
|
||||||
onCreateBranchFromCommit={openNewBranchDialog}
|
onCreateBranchFromCommit={openNewBranchDialog}
|
||||||
@@ -4665,7 +4708,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if helpOpen}
|
{#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}
|
||||||
|
|
||||||
{#if aiReviewOpen && aiReviewResult}
|
{#if aiReviewOpen && aiReviewResult}
|
||||||
@@ -4688,28 +4733,32 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if linePatchOpen && linePatchFile}
|
{#if linePatchOpen && linePatchFile}
|
||||||
<LinePatchDialog
|
{#await import("./lib/components/LinePatchDialog.svelte") then module}
|
||||||
file={linePatchFile}
|
<module.default
|
||||||
staged={linePatchStaged}
|
file={linePatchFile}
|
||||||
patch={linePatchText}
|
staged={linePatchStaged}
|
||||||
{isBusy}
|
patch={linePatchText}
|
||||||
isLoading={linePatchLoading}
|
{isBusy}
|
||||||
error={linePatchError}
|
isLoading={linePatchLoading}
|
||||||
onClose={closeLinePatch}
|
error={linePatchError}
|
||||||
onRefresh={refreshLinePatch}
|
onClose={closeLinePatch}
|
||||||
onApply={applyLinePatch}
|
onRefresh={refreshLinePatch}
|
||||||
/>
|
onApply={applyLinePatch}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if blameOpen}
|
{#if blameOpen}
|
||||||
<BlameDialog
|
{#await import("./lib/components/BlameDialog.svelte") then module}
|
||||||
filePath={blameFilePath}
|
<module.default
|
||||||
lines={blameLines}
|
filePath={blameFilePath}
|
||||||
{isBusy}
|
lines={blameLines}
|
||||||
isLoading={blameLoading}
|
{isBusy}
|
||||||
error={blameError}
|
isLoading={blameLoading}
|
||||||
onClose={closeBlame}
|
error={blameError}
|
||||||
/>
|
onClose={closeBlame}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if pendingDiscard}
|
{#if pendingDiscard}
|
||||||
@@ -4724,44 +4773,48 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if globalSearchOpen}
|
{#if globalSearchOpen}
|
||||||
<GlobalSearchDialog
|
{#await import("./lib/components/GlobalSearchDialog.svelte") then module}
|
||||||
{hasRepository}
|
<module.default
|
||||||
{isBusy}
|
{hasRepository}
|
||||||
isSearching={globalSearchBusy}
|
{isBusy}
|
||||||
error={globalSearchError}
|
isSearching={globalSearchBusy}
|
||||||
results={globalSearchResults}
|
error={globalSearchError}
|
||||||
files={repoFiles}
|
results={globalSearchResults}
|
||||||
fileHistory={fileHistory}
|
files={repoFiles}
|
||||||
selectedFilePath={selectedExplorerPath}
|
fileHistory={fileHistory}
|
||||||
onClose={closeGlobalSearchDialog}
|
selectedFilePath={selectedExplorerPath}
|
||||||
onSearch={runGlobalSearch}
|
onClose={closeGlobalSearchDialog}
|
||||||
onCancel={cancelGlobalSearch}
|
onSearch={runGlobalSearch}
|
||||||
onDiff={diffSearchHit}
|
onCancel={cancelGlobalSearch}
|
||||||
onSelectFile={selectFileFromSearch}
|
onDiff={diffSearchHit}
|
||||||
onFileHistoryDiff={diffSelectedFileFromCommit}
|
onSelectFile={selectFileFromSearch}
|
||||||
onFileHistoryRestore={restoreSelectedFileFromCommit}
|
onFileHistoryDiff={diffSelectedFileFromCommit}
|
||||||
/>
|
onFileHistoryRestore={restoreSelectedFileFromCommit}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if worktreeDialogOpen}
|
{#if worktreeDialogOpen}
|
||||||
<WorktreeDialog
|
{#await import("./lib/components/WorktreeDialog.svelte") then module}
|
||||||
{worktrees}
|
<module.default
|
||||||
{branches}
|
{worktrees}
|
||||||
initialBranch={worktreeInitialBranch}
|
{branches}
|
||||||
isLoading={worktreesLoading}
|
initialBranch={worktreeInitialBranch}
|
||||||
{isBusy}
|
isLoading={worktreesLoading}
|
||||||
error={worktreeError}
|
{isBusy}
|
||||||
onRefresh={refreshWorktrees}
|
error={worktreeError}
|
||||||
onOpen={openWorktreeTab}
|
onRefresh={refreshWorktrees}
|
||||||
onAdd={createWorktree}
|
onOpen={openWorktreeTab}
|
||||||
onRemove={removeSelectedWorktree}
|
onAdd={createWorktree}
|
||||||
onMove={moveSelectedWorktree}
|
onRemove={removeSelectedWorktree}
|
||||||
onLock={lockSelectedWorktree}
|
onMove={moveSelectedWorktree}
|
||||||
onUnlock={unlockSelectedWorktree}
|
onLock={lockSelectedWorktree}
|
||||||
onPrune={pruneStaleWorktrees}
|
onUnlock={unlockSelectedWorktree}
|
||||||
onRepair={repairSelectedWorktree}
|
onPrune={pruneStaleWorktrees}
|
||||||
onClose={closeWorktreeDialog}
|
onRepair={repairSelectedWorktree}
|
||||||
/>
|
onClose={closeWorktreeDialog}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Create a branch from a specific commit in the history -->
|
<!-- Create a branch from a specific commit in the history -->
|
||||||
@@ -4797,29 +4850,33 @@
|
|||||||
|
|
||||||
<!-- Choose the AI provider/model used to generate commit messages -->
|
<!-- Choose the AI provider/model used to generate commit messages -->
|
||||||
{#if aiSettingsOpen}
|
{#if aiSettingsOpen}
|
||||||
<AiSettingsDialog
|
{#await import("./lib/components/AiSettingsDialog.svelte") then module}
|
||||||
settings={aiSettings}
|
<module.default
|
||||||
localModels={localModelOptions}
|
settings={aiSettings}
|
||||||
onSave={saveAiSettings}
|
localModels={localModelOptions}
|
||||||
onClose={() => { aiSettingsOpen = false; }}
|
onSave={saveAiSettings}
|
||||||
/>
|
onClose={() => { aiSettingsOpen = false; }}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Compare: pick the two commits to diff -->
|
<!-- Compare: pick the two commits to diff -->
|
||||||
{#if interactiveRebaseOpen}
|
{#if interactiveRebaseOpen}
|
||||||
<InteractiveRebaseDialog
|
{#await import("./lib/components/InteractiveRebaseDialog.svelte") then module}
|
||||||
{branches}
|
<module.default
|
||||||
currentBranch={status?.current_branch ?? ""}
|
{branches}
|
||||||
base={interactiveRebaseBase}
|
currentBranch={status?.current_branch ?? ""}
|
||||||
commits={interactiveRebaseCommits}
|
base={interactiveRebaseBase}
|
||||||
isLoading={interactiveRebaseLoading}
|
commits={interactiveRebaseCommits}
|
||||||
{isBusy}
|
isLoading={interactiveRebaseLoading}
|
||||||
{operation}
|
{isBusy}
|
||||||
error={interactiveRebaseError}
|
{operation}
|
||||||
onBaseChange={loadInteractiveRebaseRange}
|
error={interactiveRebaseError}
|
||||||
onStart={runInteractiveRebase}
|
onBaseChange={loadInteractiveRebaseRange}
|
||||||
onClose={() => { if (!isBusy) interactiveRebaseOpen = false; }}
|
onStart={runInteractiveRebase}
|
||||||
/>
|
onClose={() => { if (!isBusy) interactiveRebaseOpen = false; }}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if reflogOpen}
|
{#if reflogOpen}
|
||||||
@@ -4854,16 +4911,18 @@
|
|||||||
|
|
||||||
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
|
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
|
||||||
{#if compareDialogOpen && comparison}
|
{#if compareDialogOpen && comparison}
|
||||||
<CompareDialog
|
{#await import("./lib/components/CompareDialog.svelte") then module}
|
||||||
{comparison}
|
<module.default
|
||||||
{selectedDiffPath}
|
{comparison}
|
||||||
{isBusy}
|
{selectedDiffPath}
|
||||||
highlightQuery={diffHighlightQuery}
|
{isBusy}
|
||||||
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
|
highlightQuery={diffHighlightQuery}
|
||||||
onClose={closeCompareDialog}
|
restoreLabel={pendingRestoreFile ? "Restore file" : ""}
|
||||||
onRestore={restorePreviewedCommitFile}
|
onClose={closeCompareDialog}
|
||||||
onSelectFile={selectDiffFile}
|
onRestore={restorePreviewedCommitFile}
|
||||||
/>
|
onSelectFile={selectDiffFile}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Credential dialog for push/pull -->
|
<!-- Credential dialog for push/pull -->
|
||||||
@@ -4918,16 +4977,18 @@
|
|||||||
|
|
||||||
<!-- Conflict resolve dialog -->
|
<!-- Conflict resolve dialog -->
|
||||||
{#if resolveDialogOpen}
|
{#if resolveDialogOpen}
|
||||||
<ResolveDialog
|
{#await import("./lib/components/ResolveDialog.svelte") then module}
|
||||||
{conflictedFiles}
|
<module.default
|
||||||
{conflictTarget}
|
{conflictedFiles}
|
||||||
{conflict}
|
{conflictTarget}
|
||||||
{preparedResolutions}
|
{conflict}
|
||||||
{isBusy}
|
{preparedResolutions}
|
||||||
{operation}
|
{isBusy}
|
||||||
onClose={() => { resolveDialogOpen = false; }}
|
{operation}
|
||||||
onSelectFile={selectConflictFile}
|
onClose={() => { resolveDialogOpen = false; }}
|
||||||
onMarkResolved={handleMarkResolved}
|
onSelectFile={selectConflictFile}
|
||||||
onApply={applyPreparedResolutions}
|
onMarkResolved={handleMarkResolved}
|
||||||
/>
|
onApply={applyPreparedResolutions}
|
||||||
|
/>
|
||||||
|
{/await}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+82
@@ -2861,6 +2861,31 @@
|
|||||||
background: var(--color-surface-dim);
|
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 {
|
.graph-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
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 + .graph-row .commit-body { border-top: 1px solid var(--color-border-subtle); }
|
||||||
.graph-row:hover .commit-body { background: var(--color-surface-hover); }
|
.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-avatar { border-radius: 50%; }
|
||||||
.commit-kind, .commit-branch-chip, .ref-chip { border-radius: 4px !important; }
|
.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; }
|
.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; }
|
*, *::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 {
|
:root[data-theme="light"] .repo-tab-wrap {
|
||||||
background: rgba(255,255,255,0.5);
|
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;
|
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 {
|
:root[data-theme="light"] .cred-seg-btn.active {
|
||||||
border-color: rgba(49,95,214,0.26);
|
border-color: rgba(49,95,214,0.26);
|
||||||
background: linear-gradient(135deg, rgba(49,95,214,0.12), rgba(15,143,181,0.08));
|
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);
|
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 {
|
:root[data-theme="light"] .cred-close {
|
||||||
border-color: var(--color-border-subtle);
|
border-color: var(--color-border-subtle);
|
||||||
color: var(--color-ink-dim);
|
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-input input,
|
||||||
:root[data-theme="light"] .cred-expiry input[type="date"] {
|
:root[data-theme="light"] .cred-expiry input[type="date"] {
|
||||||
|
border-color: var(--color-border-input);
|
||||||
background: rgba(255,255,255,0.92);
|
background: rgba(255,255,255,0.92);
|
||||||
color-scheme: light;
|
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 {
|
:root[data-theme="light"] .cred-token-hint code {
|
||||||
color: #0d78a0;
|
color: #0d78a0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,11 @@
|
|||||||
import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
|
import { FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
|
||||||
import type { GitBlameLine } from "../types";
|
import type { GitBlameLine } from "../types";
|
||||||
|
|
||||||
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
interface BlameGroup {
|
interface BlameGroup {
|
||||||
id: string;
|
id: string;
|
||||||
hash: string;
|
hash: string;
|
||||||
@@ -66,7 +71,7 @@
|
|||||||
if (!seconds) return "";
|
if (!seconds) return "";
|
||||||
const date = new Date(seconds * 1000);
|
const date = new Date(seconds * 1000);
|
||||||
if (Number.isNaN(date.getTime())) return "";
|
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 {
|
function groupTooltip(group: BlameGroup): string {
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
import iconUrl from "../../../src-tauri/icons/icon.png";
|
import iconUrl from "../../../src-tauri/icons/icon.png";
|
||||||
import type { GitCommit } from "../types";
|
import type { GitCommit } from "../types";
|
||||||
|
|
||||||
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
fileHistory: GitCommit[];
|
fileHistory: GitCommit[];
|
||||||
selectedExplorerPath: string;
|
selectedExplorerPath: string;
|
||||||
@@ -32,7 +37,7 @@
|
|||||||
function formatCommitDate(value: string): string {
|
function formatCommitDate(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return 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 {
|
function fileName(path: string): string {
|
||||||
|
|||||||
@@ -15,6 +15,11 @@
|
|||||||
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
|
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
|
||||||
import LanguageIcon from "./LanguageIcon.svelte";
|
import LanguageIcon from "./LanguageIcon.svelte";
|
||||||
|
|
||||||
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
type SearchTab = "code" | "files";
|
type SearchTab = "code" | "files";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -82,7 +87,7 @@
|
|||||||
function formatCommitDate(value: string): string {
|
function formatCommitDate(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return 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 {
|
function displayPath(hit: GitSearchHit): string {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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";
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||||
|
|
||||||
interface GraphSegment {
|
interface GraphSegment {
|
||||||
@@ -27,6 +27,10 @@
|
|||||||
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
||||||
];
|
];
|
||||||
const GRAPH_LANE = 18;
|
const GRAPH_LANE = 18;
|
||||||
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
@@ -36,7 +40,11 @@
|
|||||||
repositoryKey: string;
|
repositoryKey: string;
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
|
hasMore: boolean;
|
||||||
|
isLoadingMore: boolean;
|
||||||
|
loadMoreError: string;
|
||||||
expandedCommitHashes: Set<string>;
|
expandedCommitHashes: Set<string>;
|
||||||
|
onLoadMore: () => void | Promise<void>;
|
||||||
onRestoreCommit: (commit: GitCommit) => void;
|
onRestoreCommit: (commit: GitCommit) => void;
|
||||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||||
onToggleCommitFiles: (hash: string) => void;
|
onToggleCommitFiles: (hash: string) => void;
|
||||||
@@ -53,7 +61,11 @@
|
|||||||
repositoryKey = "",
|
repositoryKey = "",
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
|
hasMore = false,
|
||||||
|
isLoadingMore = false,
|
||||||
|
loadMoreError = "",
|
||||||
expandedCommitHashes = new Set(),
|
expandedCommitHashes = new Set(),
|
||||||
|
onLoadMore = () => {},
|
||||||
onRestoreCommit = () => {},
|
onRestoreCommit = () => {},
|
||||||
onPreviewCommitFile = () => {},
|
onPreviewCommitFile = () => {},
|
||||||
onToggleCommitFiles = () => {},
|
onToggleCommitFiles = () => {},
|
||||||
@@ -71,6 +83,17 @@
|
|||||||
let contextMenuX = $state(0);
|
let contextMenuX = $state(0);
|
||||||
let contextMenuY = $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 {
|
function laneColor(col: number): string {
|
||||||
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
||||||
}
|
}
|
||||||
@@ -464,7 +487,7 @@
|
|||||||
function formatCommitDate(value: string): string {
|
function formatCommitDate(value: string): string {
|
||||||
const date = new Date(value);
|
const date = new Date(value);
|
||||||
if (Number.isNaN(date.getTime())) return value;
|
if (Number.isNaN(date.getTime())) return value;
|
||||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
return commitDateFormatter.format(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
@@ -518,8 +541,8 @@
|
|||||||
<span class="eyebrow">History</span>
|
<span class="eyebrow">History</span>
|
||||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
||||||
</div>
|
</div>
|
||||||
<div class="section-head-actions">
|
{#if localBranchNames.length > 0}
|
||||||
{#if localBranchNames.length > 0}
|
<div class="section-head-actions">
|
||||||
<button
|
<button
|
||||||
class="graph-branch-dialog-button"
|
class="graph-branch-dialog-button"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -530,21 +553,19 @@
|
|||||||
Branches
|
Branches
|
||||||
<span>{visibleBranchCount}/{localBranchNames.length}</span>
|
<span>{visibleBranchCount}/{localBranchNames.length}</span>
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
</div>
|
||||||
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
|
{/if}
|
||||||
{visibleCommits.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<div class="blank-state">No repository loaded.</div>
|
<div class="blank-state">No repository loaded.</div>
|
||||||
{:else if commits.length === 0}
|
{:else if commits.length === 0}
|
||||||
<div class="blank-state">No commits returned.</div>
|
<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}
|
{:else}
|
||||||
<div class="history-list graph-list overflow-auto">
|
<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)}
|
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
||||||
{@const item = entry.commit}
|
{@const item = entry.commit}
|
||||||
{@const row = graphRows[rowIndex]}
|
{@const row = graphRows[rowIndex]}
|
||||||
@@ -698,6 +719,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,8 @@
|
|||||||
|
|
||||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== 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 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);
|
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
|
||||||
|
|
||||||
@@ -201,7 +203,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="status-file-list">
|
<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)}
|
{@const stageTargets = selectedStageTargets(file)}
|
||||||
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
<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`}>
|
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||||
@@ -241,7 +243,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="status-file-list">
|
<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)}
|
{@const unstageTargets = selectedUnstageTargets(file)}
|
||||||
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
|
<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`}>
|
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
|
||||||
|
|||||||
+2
-2
@@ -366,8 +366,8 @@ export function credDelete(key: string): Promise<void> {
|
|||||||
return invoke<void>("cred_delete", { key });
|
return invoke<void>("cred_delete", { key });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
|
export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCommit[]> {
|
||||||
return invoke<GitCommit[]>("list_commits", { path, limit });
|
return invoke<GitCommit[]>("list_commits", { path, limit, skip });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
|
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {
|
||||||
|
|||||||
Reference in New Issue
Block a user