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
440 lines
18 KiB
Svelte
440 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { Check, FileDiff, RotateCcw, Undo2 } from "@lucide/svelte";
|
|
import iconUrl from "../../../src-tauri/icons/icon.png";
|
|
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
|
|
|
interface Props {
|
|
changedFiles: GitFileStatus[];
|
|
stagedCount: number;
|
|
unstagedCount: number;
|
|
hasRepository: boolean;
|
|
isBusy: boolean;
|
|
operation: string;
|
|
status: GitStatus | null;
|
|
selectedFilePath: string;
|
|
onSelectFile: (file: GitFileStatus) => void;
|
|
onStage: (files: GitFileStatus[]) => void;
|
|
onUnstage: (files: GitFileStatus[]) => void;
|
|
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
|
|
onDiscardMany: (files: GitFileStatus[]) => void;
|
|
onPatch: (file: GitFileStatus, staged: boolean) => void;
|
|
onStageAll: () => void;
|
|
onUnstageAll: () => void;
|
|
}
|
|
|
|
let {
|
|
changedFiles = [],
|
|
stagedCount = 0,
|
|
unstagedCount = 0,
|
|
hasRepository = false,
|
|
isBusy = false,
|
|
operation = "",
|
|
status = null,
|
|
selectedFilePath = "",
|
|
onSelectFile = () => {},
|
|
onStage = () => {},
|
|
onUnstage = () => {},
|
|
onDiscard = () => {},
|
|
onDiscardMany = () => {},
|
|
onPatch = () => {},
|
|
onStageAll = () => {},
|
|
onUnstageAll = () => {},
|
|
}: Props = $props();
|
|
|
|
function statusLabel(kind: FileStatusKind | null): string {
|
|
return kind ?? "none";
|
|
}
|
|
|
|
function displayPath(file: GitFileStatus): string {
|
|
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
|
}
|
|
|
|
function baseName(path: string): string {
|
|
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
|
}
|
|
|
|
function fileName(file: GitFileStatus): string {
|
|
return file.old_path ? `${baseName(file.old_path)} -> ${baseName(file.path)}` : baseName(file.path);
|
|
}
|
|
|
|
function canPatch(kind: FileStatusKind | null): boolean {
|
|
return kind === "modified";
|
|
}
|
|
|
|
function fileKey(file: GitFileStatus): string {
|
|
return `${file.old_path ?? ""}:${file.path}`;
|
|
}
|
|
|
|
let selectedStatusPaths = $state<Set<string>>(new Set());
|
|
let selectionAnchorKey = $state("");
|
|
|
|
function isStatusSelected(file: GitFileStatus): boolean {
|
|
return selectedStatusPaths.has(fileKey(file));
|
|
}
|
|
|
|
function selectedFiles(): GitFileStatus[] {
|
|
return changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)));
|
|
}
|
|
|
|
function selectedStageTargets(file: GitFileStatus): GitFileStatus[] {
|
|
const files = isStatusSelected(file) ? selectedFiles() : [file];
|
|
return files.filter((item) => item.unstaged !== null);
|
|
}
|
|
|
|
function selectedUnstageTargets(file: GitFileStatus): GitFileStatus[] {
|
|
const files = isStatusSelected(file) ? selectedFiles() : [file];
|
|
return files.filter((item) => item.staged !== null);
|
|
}
|
|
|
|
function handleFileSelect(event: MouseEvent, file: GitFileStatus) {
|
|
const key = fileKey(file);
|
|
const allKeys = changedFiles.map(fileKey);
|
|
const next = new Set(selectedStatusPaths);
|
|
|
|
if (event.shiftKey && selectionAnchorKey) {
|
|
const anchorIndex = allKeys.indexOf(selectionAnchorKey);
|
|
const currentIndex = allKeys.indexOf(key);
|
|
if (anchorIndex >= 0 && currentIndex >= 0) {
|
|
const start = Math.min(anchorIndex, currentIndex);
|
|
const end = Math.max(anchorIndex, currentIndex);
|
|
for (const itemKey of allKeys.slice(start, end + 1)) next.add(itemKey);
|
|
} else {
|
|
next.add(key);
|
|
}
|
|
} else if (event.ctrlKey || event.metaKey) {
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
selectionAnchorKey = key;
|
|
} else {
|
|
next.clear();
|
|
next.add(key);
|
|
selectionAnchorKey = key;
|
|
}
|
|
|
|
selectedStatusPaths = next;
|
|
onSelectFile(file);
|
|
}
|
|
|
|
function stageFromFile(file: GitFileStatus) {
|
|
const targets = selectedStageTargets(file);
|
|
if (targets.length === 0) return;
|
|
onStage(targets);
|
|
}
|
|
|
|
function unstageFromFile(file: GitFileStatus) {
|
|
const targets = selectedUnstageTargets(file);
|
|
if (targets.length === 0) return;
|
|
onUnstage(targets);
|
|
}
|
|
|
|
// Discard mirrors the stage/unstage target selection: if the clicked row is
|
|
// part of the current multi-selection, the whole selection (filtered to the
|
|
// relevant lane) is discarded; otherwise just that one file.
|
|
function discardStagedFromFile(file: GitFileStatus) {
|
|
const targets = selectedUnstageTargets(file);
|
|
if (targets.length === 0) return;
|
|
onDiscard(targets, true);
|
|
}
|
|
|
|
function discardUnstagedFromFile(file: GitFileStatus) {
|
|
const targets = selectedStageTargets(file);
|
|
if (targets.length === 0) return;
|
|
onDiscard(targets, false);
|
|
}
|
|
|
|
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);
|
|
|
|
$effect(() => {
|
|
const validKeys = new Set(changedFiles.map(fileKey));
|
|
const next = new Set([...selectedStatusPaths].filter((key) => validKeys.has(key)));
|
|
if (next.size !== selectedStatusPaths.size) selectedStatusPaths = next;
|
|
if (selectionAnchorKey && !validKeys.has(selectionAnchorKey)) selectionAnchorKey = "";
|
|
});
|
|
</script>
|
|
|
|
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
|
|
<div class="section-head">
|
|
<div>
|
|
<span class="eyebrow">Workspace</span>
|
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
|
|
</div>
|
|
<div class="status-head-actions">
|
|
<span class="pill pill-count">{stagedCount} staged</span>
|
|
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
|
{#if hasRepository && changedFiles.length > 0}
|
|
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title="Discard all staged and unstaged changes">
|
|
<RotateCcw size={13} aria-hidden="true" /> Discard all
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
{#if !hasRepository}
|
|
<div class="blank-state">No repository loaded.</div>
|
|
{:else if status?.clean}
|
|
<div class="blank-state">Working tree is clean.</div>
|
|
{:else if changedFiles.length === 0}
|
|
<div class="blank-state">No file changes returned.</div>
|
|
{:else}
|
|
<div class="status-lanes overflow-auto">
|
|
<section class="status-lane" aria-label="Unstaged changes">
|
|
<header class="status-lane-head">
|
|
<div class="status-lane-title"><strong>Unstaged</strong><span>{unstagedCount}</span></div>
|
|
<div class="status-lane-actions">
|
|
{#if selectedUnstagedCount > 1}
|
|
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
|
|
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
|
|
<Check size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
|
|
</button>
|
|
{/if}
|
|
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
|
|
<Check size={13} aria-hidden="true" /> Stage all
|
|
</button>
|
|
{#if selectedUnstagedCount > 1}
|
|
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
|
|
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedUnstagedCount}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</header>
|
|
<div class="status-file-list">
|
|
{#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`}>
|
|
<strong>{fileName(file)}</strong>
|
|
<span>{displayPath(file)}</span>
|
|
</button>
|
|
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
|
<div class="status-file-actions">
|
|
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><Check size={14} aria-hidden="true" /></button>
|
|
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
|
|
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="status-lane" aria-label="Staged changes">
|
|
<header class="status-lane-head">
|
|
<div class="status-lane-title"><strong>Staged</strong><span>{stagedCount}</span></div>
|
|
<div class="status-lane-actions">
|
|
{#if selectedStagedCount > 1}
|
|
<span class="status-selection-count">{selectedStagedCount} selected</span>
|
|
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
|
|
<Undo2 size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
|
|
</button>
|
|
{/if}
|
|
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
|
|
<Undo2 size={13} aria-hidden="true" /> Unstage all
|
|
</button>
|
|
{#if selectedStagedCount > 1}
|
|
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
|
|
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedStagedCount}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</header>
|
|
<div class="status-file-list">
|
|
{#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`}>
|
|
<strong>{fileName(file)}</strong>
|
|
<span>{displayPath(file)}</span>
|
|
</button>
|
|
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
|
<div class="status-file-actions">
|
|
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><Undo2 size={14} aria-hidden="true" /></button>
|
|
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
|
|
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
|
|
</div>
|
|
</section>
|
|
</div>
|
|
{/if}
|
|
|
|
{#if isBusy && hasRepository}
|
|
<div class="status-panel-overlay" role="status" aria-live="polite">
|
|
<div class="status-panel-overlay-card">
|
|
<div class="status-panel-overlay-mark">
|
|
<span class="status-panel-overlay-halo halo-one"></span>
|
|
<span class="status-panel-overlay-halo halo-two"></span>
|
|
<svg class="status-panel-overlay-traces" viewBox="0 0 220 220" aria-hidden="true">
|
|
<path class="trace trace-main" d="M28 154 C72 114, 88 108, 110 110 S156 116, 192 68" />
|
|
<path class="trace trace-branch" d="M62 74 C100 88, 126 124, 158 166" />
|
|
<path class="trace trace-cut" d="M46 180 L174 180" />
|
|
</svg>
|
|
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
|
|
</div>
|
|
<span class="status-panel-overlay-label">{operation || "Working"}…</span>
|
|
<div class="status-panel-overlay-bar"><span></span></div>
|
|
</div>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
.status-panel-overlay {
|
|
position: absolute;
|
|
inset: 0;
|
|
z-index: 20;
|
|
display: grid;
|
|
place-items: center;
|
|
background:
|
|
radial-gradient(circle at 50% 38%, rgba(111, 140, 255, 0.1), transparent 55%),
|
|
rgba(7, 10, 16, 0.62);
|
|
backdrop-filter: blur(4px);
|
|
animation: status-panel-overlay-in 120ms ease;
|
|
}
|
|
|
|
.status-panel-overlay-card {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
gap: 12px;
|
|
width: min(300px, calc(100% - 32px));
|
|
padding: 26px 28px 26px;
|
|
border: 1px solid rgba(90, 111, 154, 0.28);
|
|
border-radius: 16px;
|
|
background:
|
|
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
|
|
var(--color-surface-raised);
|
|
color: var(--color-ink);
|
|
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
.status-panel-overlay-mark {
|
|
position: relative;
|
|
width: 104px;
|
|
height: 104px;
|
|
display: grid;
|
|
place-items: center;
|
|
isolation: isolate;
|
|
}
|
|
|
|
.status-panel-overlay-halo {
|
|
position: absolute;
|
|
inset: 6px;
|
|
border: 1px solid rgba(111, 140, 255, 0.22);
|
|
border-radius: 22px;
|
|
transform: rotate(45deg);
|
|
}
|
|
.status-panel-overlay-halo.halo-one {
|
|
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite;
|
|
}
|
|
.status-panel-overlay-halo.halo-two {
|
|
inset: 16px;
|
|
border-color: rgba(77, 182, 214, 0.24);
|
|
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
|
|
}
|
|
|
|
.status-panel-overlay-traces {
|
|
position: absolute;
|
|
inset: -12px;
|
|
width: 128px;
|
|
height: 128px;
|
|
overflow: visible;
|
|
z-index: 0;
|
|
}
|
|
.status-panel-overlay-traces .trace {
|
|
fill: none;
|
|
stroke-width: 3;
|
|
stroke-linecap: round;
|
|
stroke-dasharray: 165;
|
|
stroke-dashoffset: 165;
|
|
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.32));
|
|
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
|
|
}
|
|
.status-panel-overlay-traces .trace-main { stroke: #6f8cff; }
|
|
.status-panel-overlay-traces .trace-branch {
|
|
stroke: #4db6d6;
|
|
animation-delay: 0.28s;
|
|
}
|
|
.status-panel-overlay-traces .trace-cut {
|
|
stroke: rgba(177, 186, 208, 0.42);
|
|
stroke-dasharray: 128;
|
|
stroke-dashoffset: 128;
|
|
animation-delay: 0.55s;
|
|
}
|
|
|
|
.status-panel-overlay-icon {
|
|
position: relative;
|
|
z-index: 1;
|
|
width: 64px;
|
|
height: 64px;
|
|
object-fit: contain;
|
|
filter:
|
|
drop-shadow(0 8px 12px rgba(0, 0, 0, 0.5))
|
|
drop-shadow(0 0 10px rgba(77, 182, 214, 0.2));
|
|
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
|
|
}
|
|
|
|
.status-panel-overlay-label {
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
color: var(--color-ink);
|
|
font-size: 14px;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.status-panel-overlay-bar {
|
|
position: relative;
|
|
width: min(190px, 100%);
|
|
height: 4px;
|
|
overflow: hidden;
|
|
border-radius: 999px;
|
|
background: rgba(111, 140, 255, 0.14);
|
|
}
|
|
.status-panel-overlay-bar span {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 46%;
|
|
border-radius: inherit;
|
|
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
|
|
box-shadow: 0 0 12px rgba(77, 182, 214, 0.26);
|
|
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
|
|
}
|
|
|
|
@keyframes status-panel-overlay-in { from { opacity: 0; } to { opacity: 1; } }
|
|
@keyframes status-panel-overlay-icon-float {
|
|
0%, 100% { transform: translateY(0) scale(1); }
|
|
50% { transform: translateY(-3px) scale(1.015); }
|
|
}
|
|
@keyframes status-panel-overlay-halo-breathe {
|
|
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.95); }
|
|
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
|
|
}
|
|
@keyframes status-panel-overlay-bar-slide {
|
|
0% { transform: translateX(-120%); }
|
|
100% { transform: translateX(320%); }
|
|
}
|
|
@keyframes status-panel-overlay-trace-draw {
|
|
0% { stroke-dashoffset: 165; opacity: 0; }
|
|
36% { opacity: 1; }
|
|
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.status-panel-overlay,
|
|
.status-panel-overlay-halo,
|
|
.status-panel-overlay-traces .trace,
|
|
.status-panel-overlay-icon,
|
|
.status-panel-overlay-bar span { animation: none; }
|
|
.status-panel-overlay-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
|
|
}
|
|
</style>
|