Compare commits

...
5 Commits
Author SHA1 Message Date
Christoph c4a922894f style(app): update styles for file history dialog
publish / Build and publish gitty-desktop to AUR (release) Failing after 4m40s
publish / publish-tauri (appimage, 1, ubuntu-22.04, linux-x86_64) (release) Canceled after 37m41s
publish / publish-tauri (nsis, , windows-latest, windows-x86_64) (release) Canceled after 37m26s
2026-08-04 18:41:19 +02:00
Christoph 41f474f1eb feat(dialog): add file history dialog component 2026-08-04 18:41:19 +02:00
Christoph 6dd70ec52a refactor(app): remove file history panel and related logic 2026-08-04 18:41:19 +02:00
Christoph 90070697dd refactor(explorer): update context menu for file history 2026-08-04 18:41:19 +02:00
Christoph 50524c2759 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
2026-08-04 18:04:10 +02:00
12 changed files with 677 additions and 679 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ The command list below includes the repository-management and synchronization AP
- `fetch(path: string, prune?: boolean, remote?: string): Promise<GitStatus>`
- `pull(path: string, strategy?: "merge" | "rebase" | "ff-only", remote?: string, branch?: string): Promise<GitStatus>`
- `push(path: string, forceWithLease?: boolean, remote?: string): Promise<GitStatus>`
- `list_commits(path: string, limit?: number): Promise<GitCommit[]>`
- `list_commits(path: string, limit?: number, skip?: number): Promise<GitCommit[]>`
- `restore_to_commit(path: string, commit: string): Promise<GitStatus>`
- `restore_file_from_commit(path: string, commit: string, file: string): Promise<GitStatus>` (the `file` argument can also be a folder path)
- `merge_branch(path: string, branch: string, strategy?: "default" | "squash" | "ff-only" | "no-ff"): Promise<GitStatus>`
+53 -3
View File
@@ -2917,17 +2917,31 @@ fn cherry_pick_status_or_error(
}
#[tauri::command]
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
pub fn list_commits(
path: String,
limit: Option<u32>,
skip: Option<u32>,
) -> Result<Vec<GitCommit>, String> {
let repo = resolve_repo(&path)?;
commits_for_repo(&repo, limit)
commit_page_for_repo(&repo, limit, skip)
}
fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
let bounded_limit = limit.unwrap_or(100).clamp(1, 500);
commit_page_for_repo(repo, Some(bounded_limit), None)
}
fn commit_page_for_repo(
repo: &Path,
limit: Option<u32>,
skip: Option<u32>,
) -> Result<Vec<GitCommit>, String> {
if verify_commit(repo, "HEAD").is_err() {
return Ok(Vec::new());
}
let limit = limit.unwrap_or(100).clamp(1, 500).to_string();
let limit = limit.unwrap_or(100).clamp(1, 5_000).to_string();
let skip = skip.unwrap_or(0).min(10_000_000).to_string();
// Fetch the per-commit changed files inline via `--name-status` in a single
// `git log` process, instead of spawning one `git diff-tree` per commit
// (which was ~100 extra processes and the main cost of opening a repo).
@@ -2943,6 +2957,8 @@ fn commits_for_repo(repo: &Path, limit: Option<u32>) -> Result<Vec<GitCommit>, S
"-z",
"--root",
"--pretty=format:%x1e%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%D%x1f%P%x1f%s%x1f",
"--skip",
skip.as_str(),
"-n",
limit.as_str(),
],
@@ -6339,6 +6355,40 @@ mod tests {
}));
}
#[test]
fn commit_pages_use_stable_non_overlapping_offsets() {
let repo = init_temp_repo("commit_pages");
commit_initial_file(&repo.path);
for index in 1..=3 {
fs::write(repo.path.join("old.txt"), format!("version {index}\n"))
.expect("tracked file should change");
run_git_test(&repo.path, ["add", "old.txt"]);
run_git_test(
&repo.path,
["commit", "-q", "-m", format!("commit {index}").as_str()],
);
}
let all = commits_for_repo(&repo.path, Some(10)).expect("commits should load");
let first = commit_page_for_repo(&repo.path, Some(2), Some(0))
.expect("first commit page should load");
let second = commit_page_for_repo(&repo.path, Some(2), Some(2))
.expect("second commit page should load");
assert_eq!(first.len(), 2);
assert_eq!(second.len(), 2);
assert_eq!(first[0].hash, all[0].hash);
assert_eq!(first[1].hash, all[1].hash);
assert_eq!(second[0].hash, all[2].hash);
assert_eq!(second[1].hash, all[3].hash);
assert!(
first
.iter()
.all(|commit| second.iter().all(|other| other.hash != commit.hash))
);
}
#[tokio::test]
#[cfg_attr(
windows,
+145 -142
View File
@@ -8,37 +8,27 @@
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,
@@ -229,7 +219,6 @@
const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2";
const EXPLORER_PANEL_COLLAPSED_KEY = "gitlite.explorerPanelCollapsed.v1";
const HISTORY_ASIDE_WIDTH_KEY = "gitlite.historyAsideWidth.v1";
const FILE_HISTORY_WIDTH_KEY = "gitlite.fileHistoryWidth.v1";
const COMMIT_PANEL_DEFAULT_HEIGHT = 220;
const COMMIT_PANEL_MIN_HEIGHT = COMMIT_PANEL_DEFAULT_HEIGHT;
const COMMIT_PANEL_MAX_HEIGHT = 640;
@@ -246,10 +235,8 @@
const HISTORY_ASIDE_DEFAULT_WIDTH = 620;
const HISTORY_ASIDE_MIN_WIDTH = 560;
const HISTORY_ASIDE_MAX_WIDTH = 920;
const FILE_HISTORY_DEFAULT_WIDTH = 300;
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 +266,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";
@@ -286,6 +277,8 @@
let expandedCommitHashes = new Set<string>();
let fileHistory: GitCommit[] = [];
let fileHistoryLoading = false;
let fileHistoryError = "";
let fileHistoryDialogOpen = false;
let fileHistoryRequestId = 0;
let activeFileHistoryRequestId = "";
let repoOpenRequestId = 0;
@@ -418,11 +411,6 @@
let resizingHistoryAside = false;
let historyResizeStartX = 0;
let historyResizeStartWidth = 0;
let fileHistoryWidth = loadFileHistoryWidth();
let resizingFileHistory = false;
let fileHistoryResizeStartX = 0;
let fileHistoryResizeStartWidth = 0;
let fileHistoryCollapsed = true;
let themeMediaQuery: MediaQueryList | undefined;
let appVersion = "";
@@ -754,7 +742,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);
@@ -1516,28 +1504,6 @@
}
}
function clampFileHistoryWidth(value: number): number {
return Math.min(FILE_HISTORY_MAX_WIDTH, Math.max(FILE_HISTORY_MIN_WIDTH, Math.round(value)));
}
function loadFileHistoryWidth(): number {
try {
const stored = Number(localStorage.getItem(FILE_HISTORY_WIDTH_KEY));
if (Number.isFinite(stored) && stored > 0) return clampFileHistoryWidth(stored);
} catch {
// Fall through to the default below.
}
return FILE_HISTORY_DEFAULT_WIDTH;
}
function persistFileHistoryWidth(value: number) {
try {
localStorage.setItem(FILE_HISTORY_WIDTH_KEY, String(value));
} catch {
// Local storage is best-effort only; resizing must keep working without it.
}
}
function startCommitPanelResize(event: PointerEvent) {
event.preventDefault();
resizingCommitPanel = true;
@@ -1719,50 +1685,14 @@
persistHistoryAsideWidth(historyAsideWidth);
}
function startFileHistoryResize(event: PointerEvent) {
if (fileHistoryCollapsed) return;
event.preventDefault();
resizingFileHistory = true;
fileHistoryResizeStartX = event.clientX;
fileHistoryResizeStartWidth = fileHistoryWidth;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onFileHistoryResizeMove(event: PointerEvent) {
if (!resizingFileHistory) return;
fileHistoryWidth = clampFileHistoryWidth(fileHistoryResizeStartWidth + (fileHistoryResizeStartX - event.clientX));
}
function endFileHistoryResize(event: PointerEvent) {
if (!resizingFileHistory) return;
resizingFileHistory = false;
persistFileHistoryWidth(fileHistoryWidth);
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onFileHistoryResizeKeydown(event: KeyboardEvent) {
if (fileHistoryCollapsed || (event.key !== "ArrowLeft" && event.key !== "ArrowRight")) return;
event.preventDefault();
fileHistoryWidth = clampFileHistoryWidth(fileHistoryWidth + (event.key === "ArrowLeft" ? 20 : -20));
persistFileHistoryWidth(fileHistoryWidth);
}
function toggleFileHistoryCollapsed() {
fileHistoryCollapsed = !fileHistoryCollapsed;
}
function revealFileHistory() {
fileHistoryCollapsed = false;
}
function hideAndResetFileHistory() {
fileHistoryRequestId += 1;
cancelActiveFileHistoryLoad();
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistoryError = "";
fileHistory = [];
fileHistoryCollapsed = true;
fileHistoryDialogOpen = false;
}
function rememberRecentRepo(path: string) {
@@ -1826,6 +1756,10 @@
branches = [];
stashes = [];
commits = [];
commitHistoryHasMore = false;
commitHistoryLoadingMore = false;
commitHistoryLoadError = "";
commitHistoryRequestId += 1;
lastFileHistoryHeadHash = "";
repoFiles = [];
selectedExplorerPath = "";
@@ -1833,6 +1767,9 @@
expandedExplorerPaths = new Set();
expandedCommitHashes = new Set();
fileHistory = [];
fileHistoryLoading = false;
fileHistoryError = "";
fileHistoryDialogOpen = false;
compareFrom = "";
compareTo = "";
comparison = null;
@@ -1945,7 +1882,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 +1907,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);
@@ -1965,10 +1941,7 @@
if (selectedExplorerPath && !explorerPathExists(repoFiles, selectedExplorerPath)) {
selectedExplorerPath = "";
selectedExplorerKind = "file";
cancelActiveFileHistoryLoad();
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistory = [];
hideAndResetFileHistory();
}
}
@@ -1977,7 +1950,7 @@
await refreshBranchList(path);
await refreshTags(path);
await refreshCommitHistory(path);
if (lastFileHistoryHeadHash !== previousHeadHash && !fileHistoryCollapsed) {
if (lastFileHistoryHeadHash !== previousHeadHash && fileHistoryDialogOpen) {
await refreshFileHistory(path);
}
}
@@ -1992,7 +1965,9 @@
void cancelFileHistory(requestId).catch(() => {});
}
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath) {
async function refreshFileHistory(path = activeRepoPath, file = selectedExplorerPath, force = false) {
if (!force && !fileHistoryDialogOpen && !globalSearchOpen) return;
const requestId = ++fileHistoryRequestId;
cancelActiveFileHistoryLoad();
@@ -2006,6 +1981,7 @@
const historyRequestId = `file-history-${requestId}-${Date.now()}`;
activeFileHistoryRequestId = historyRequestId;
fileHistoryLoading = true;
fileHistoryError = "";
fileHistory = [];
try {
@@ -2017,7 +1993,10 @@
const message = errorToMessage(error);
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
fileHistory = [];
if (!isCancellationMessage(message)) errorMessage = message;
if (!isCancellationMessage(message)) {
fileHistoryError = message;
if (!fileHistoryDialogOpen) errorMessage = message;
}
}
} finally {
if (requestId === fileHistoryRequestId && activeFileHistoryRequestId === historyRequestId) {
@@ -2043,7 +2022,7 @@
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
// Single backend round-trip: resolves the repo and reads status, branches,
// commits and files in one pass instead of four sequential git calls.
const bundle = await openRepositoryBundle(path, 100);
const bundle = await openRepositoryBundle(path, COMMIT_HISTORY_PAGE_SIZE + 1);
if (requestId !== repoOpenRequestId) return;
resetRepositoryState(false);
applyStatus(bundle.status);
@@ -2117,7 +2096,7 @@
directoryName || undefined,
username,
password,
100,
COMMIT_HISTORY_PAGE_SIZE + 1,
);
resetRepositoryState(false);
applyStatus(bundle.status);
@@ -3679,19 +3658,37 @@
return folders;
}
// Loads history for a selected explorer node without blocking the rest of the UI
// (isBusy/runOperation would disable every button in the app while this awaits).
// A request id guards against a slower, stale request overwriting a newer selection.
// Loads history without blocking the rest of the UI. A request id guards
// against a slower, stale request overwriting a newer file selection.
async function loadSelectedFileHistory(path: string, repo = activeRepoPath) {
await refreshFileHistory(repo, path);
await refreshFileHistory(repo, path, true);
}
function openFileHistoryDialog(node: ExplorerNode) {
if (!activeRepoPath || node.kind !== "file" || !node.tracked) return;
selectedExplorerPath = node.path;
selectedExplorerKind = "file";
fileHistoryDialogOpen = true;
fileHistoryError = "";
void loadSelectedFileHistory(node.path);
trackEvent("file_history_opened", {
source: "explorer_context_menu",
});
}
function closeFileHistoryDialog() {
fileHistoryRequestId += 1;
cancelActiveFileHistoryLoad();
activeFileHistoryRequestId = "";
fileHistoryLoading = false;
fileHistoryError = "";
fileHistoryDialogOpen = false;
}
async function selectExplorerNode(node: ExplorerNode) {
if (!activeRepoPath) return;
selectedExplorerPath = node.path;
selectedExplorerKind = node.kind;
revealFileHistory();
void loadSelectedFileHistory(node.path);
trackEvent("explorer_node_selected", {
kind: node.kind,
tracked: node.tracked ? 1 : 0,
@@ -3703,7 +3700,6 @@
selectedExplorerPath = file.path;
selectedExplorerKind = "file";
expandedExplorerPaths = new Set([...expandedExplorerPaths, ...explorerParentFolders(file.path)]);
revealFileHistory();
void loadSelectedFileHistory(file.path);
trackEvent("explorer_file_selected", {
@@ -3728,8 +3724,6 @@
if (!activeRepoPath || node.kind !== "file") return;
selectedExplorerPath = node.path;
selectedExplorerKind = "file";
revealFileHistory();
void loadSelectedFileHistory(node.path);
try {
await openRepositoryFile(activeRepoPath, node.path);
@@ -3973,7 +3967,8 @@
helpOpen = false;
return;
}
if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu();
if (event.key === "Escape" && fileHistoryDialogOpen && !isBusy) closeFileHistoryDialog();
else if (event.key === "Escape" && repoTabContextMenu) closeRepoTabContextMenu();
else if (event.key === "Escape" && pendingDiscard && !isBusy) closeDiscardConfirm();
else if (event.key === "Escape" && compareDialogOpen) closeCompareDialog();
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
@@ -4335,7 +4330,7 @@
<section
class="workspace"
aria-label="Git workspace"
style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-width: {historyAsideWidth}px; --file-history-width: {fileHistoryWidth}px;"
style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-width: {historyAsideWidth}px;"
>
<!-- Left sidebar: branches + explorer -->
@@ -4438,6 +4433,7 @@
onCollapseAllFolders={collapseAllExplorerFolders}
onSelectNode={selectExplorerNode}
onOpenFile={openFileFromExplorer}
onFileHistory={openFileHistoryDialog}
onBlame={openBlame}
collapsed={explorerPanelCollapsed}
onToggleCollapsed={toggleExplorerPanelCollapsed}
@@ -4562,8 +4558,8 @@
onkeydown={onHistoryAsideResizeKeydown}
></div>
<!-- Right sidebar: commit graph + file history -->
<aside class="history-aside" class:file-history-collapsed={fileHistoryCollapsed} aria-label="Commit history">
<!-- Right sidebar: commit graph -->
<aside class="history-aside" aria-label="Commit history">
<HistoryPanel
{commits}
{localBranchNames}
@@ -4572,7 +4568,11 @@
repositoryKey={activeRepoPath}
{hasRepository}
{isBusy}
hasMore={commitHistoryHasMore}
isLoadingMore={commitHistoryLoadingMore}
loadMoreError={commitHistoryLoadError}
{expandedCommitHashes}
onLoadMore={loadMoreCommitHistory}
onRestoreCommit={restoreCommit}
onPreviewCommitFile={previewCommitFileFromHistory}
onCreateBranchFromCommit={openNewBranchDialog}
@@ -4584,36 +4584,6 @@
expandedCommitHashes = next;
}}
/>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="file-history-resize-handle"
class:resizing={resizingFileHistory}
role="separator"
aria-orientation="vertical"
aria-label="Resize file history width"
aria-valuenow={fileHistoryWidth}
aria-valuemin={FILE_HISTORY_MIN_WIDTH}
aria-valuemax={FILE_HISTORY_MAX_WIDTH}
tabindex={fileHistoryCollapsed ? -1 : 0}
onpointerdown={startFileHistoryResize}
onpointermove={onFileHistoryResizeMove}
onpointerup={endFileHistoryResize}
onpointercancel={endFileHistoryResize}
onkeydown={onFileHistoryResizeKeydown}
></div>
<FileHistoryPanel
{fileHistory}
{selectedExplorerPath}
selectedExplorerLabel={selectedExplorerPath ? `${selectedExplorerKind === "folder" ? "Folder" : "File"} history` : "File history"}
{hasRepository}
{isBusy}
isLoading={fileHistoryLoading}
onDiff={diffSelectedFileFromCommit}
onRestore={restoreSelectedFileFromCommit}
collapsed={fileHistoryCollapsed}
onToggleCollapsed={toggleFileHistoryCollapsed}
/>
</aside>
</section>
{/if}
@@ -4665,7 +4635,9 @@
{/if}
{#if helpOpen}
<HelpOverlay language={appLanguage} onClose={() => { helpOpen = false; }} />
{#await import("./lib/components/HelpOverlay.svelte") then module}
<module.default language={appLanguage} onClose={() => { helpOpen = false; }} />
{/await}
{/if}
{#if aiReviewOpen && aiReviewResult}
@@ -4688,7 +4660,8 @@
{/if}
{#if linePatchOpen && linePatchFile}
<LinePatchDialog
{#await import("./lib/components/LinePatchDialog.svelte") then module}
<module.default
file={linePatchFile}
staged={linePatchStaged}
patch={linePatchText}
@@ -4699,10 +4672,12 @@
onRefresh={refreshLinePatch}
onApply={applyLinePatch}
/>
{/await}
{/if}
{#if blameOpen}
<BlameDialog
{#await import("./lib/components/BlameDialog.svelte") then module}
<module.default
filePath={blameFilePath}
lines={blameLines}
{isBusy}
@@ -4710,6 +4685,7 @@
error={blameError}
onClose={closeBlame}
/>
{/await}
{/if}
{#if pendingDiscard}
@@ -4723,8 +4699,24 @@
/>
{/if}
{#if fileHistoryDialogOpen}
{#await import("./lib/components/FileHistoryDialog.svelte") then module}
<module.default
{fileHistory}
filePath={selectedExplorerPath}
{isBusy}
isLoading={fileHistoryLoading}
error={fileHistoryError}
onDiff={diffSelectedFileFromCommit}
onRestore={restoreSelectedFileFromCommit}
onClose={closeFileHistoryDialog}
/>
{/await}
{/if}
{#if globalSearchOpen}
<GlobalSearchDialog
{#await import("./lib/components/GlobalSearchDialog.svelte") then module}
<module.default
{hasRepository}
{isBusy}
isSearching={globalSearchBusy}
@@ -4741,10 +4733,12 @@
onFileHistoryDiff={diffSelectedFileFromCommit}
onFileHistoryRestore={restoreSelectedFileFromCommit}
/>
{/await}
{/if}
{#if worktreeDialogOpen}
<WorktreeDialog
{#await import("./lib/components/WorktreeDialog.svelte") then module}
<module.default
{worktrees}
{branches}
initialBranch={worktreeInitialBranch}
@@ -4762,6 +4756,7 @@
onRepair={repairSelectedWorktree}
onClose={closeWorktreeDialog}
/>
{/await}
{/if}
<!-- Create a branch from a specific commit in the history -->
@@ -4797,17 +4792,20 @@
<!-- Choose the AI provider/model used to generate commit messages -->
{#if aiSettingsOpen}
<AiSettingsDialog
{#await import("./lib/components/AiSettingsDialog.svelte") then module}
<module.default
settings={aiSettings}
localModels={localModelOptions}
onSave={saveAiSettings}
onClose={() => { aiSettingsOpen = false; }}
/>
{/await}
{/if}
<!-- Compare: pick the two commits to diff -->
{#if interactiveRebaseOpen}
<InteractiveRebaseDialog
{#await import("./lib/components/InteractiveRebaseDialog.svelte") then module}
<module.default
{branches}
currentBranch={status?.current_branch ?? ""}
base={interactiveRebaseBase}
@@ -4820,6 +4818,7 @@
onStart={runInteractiveRebase}
onClose={() => { if (!isBusy) interactiveRebaseOpen = false; }}
/>
{/await}
{/if}
{#if reflogOpen}
@@ -4854,7 +4853,8 @@
<!-- Compare diff dialog (rendered last so it overlays the search dialog when opened from a hit) -->
{#if compareDialogOpen && comparison}
<CompareDialog
{#await import("./lib/components/CompareDialog.svelte") then module}
<module.default
{comparison}
{selectedDiffPath}
{isBusy}
@@ -4864,6 +4864,7 @@
onRestore={restorePreviewedCommitFile}
onSelectFile={selectDiffFile}
/>
{/await}
{/if}
<!-- Credential dialog for push/pull -->
@@ -4918,7 +4919,8 @@
<!-- Conflict resolve dialog -->
{#if resolveDialogOpen}
<ResolveDialog
{#await import("./lib/components/ResolveDialog.svelte") then module}
<module.default
{conflictedFiles}
{conflictTarget}
{conflict}
@@ -4930,4 +4932,5 @@
onMarkResolved={handleMarkResolved}
onApply={applyPreparedResolutions}
/>
{/await}
{/if}
+195 -253
View File
@@ -1517,20 +1517,15 @@
.history-aside {
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) 8px minmax(240px, var(--file-history-width, 300px));
grid-template-columns: minmax(0, 1fr);
min-width: 0;
min-height: 0;
column-gap: 0;
row-gap: 8px;
}
.history-aside.file-history-collapsed {
grid-template-columns: minmax(0, 1fr) 0 42px;
}
.left-sidebar-resize-handle,
.history-resize-handle,
.file-history-resize-handle {
.history-resize-handle {
position: relative;
z-index: 10;
display: flex;
@@ -1541,8 +1536,7 @@
touch-action: none;
}
.left-sidebar-resize-handle::before,
.history-resize-handle::before,
.file-history-resize-handle::before {
.history-resize-handle::before {
content: "";
width: 3px;
height: 40px;
@@ -1553,26 +1547,16 @@
.left-sidebar-resize-handle:hover::before,
.left-sidebar-resize-handle.resizing::before,
.history-resize-handle:hover::before,
.history-resize-handle.resizing::before,
.file-history-resize-handle:hover::before,
.file-history-resize-handle.resizing::before {
.history-resize-handle.resizing::before {
background: var(--color-accent);
box-shadow: 0 0 14px rgba(65,209,255,0.3);
}
.left-sidebar-resize-handle:focus-visible,
.history-resize-handle:focus-visible,
.file-history-resize-handle:focus-visible {
.history-resize-handle:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
.history-aside.file-history-collapsed .file-history-resize-handle {
pointer-events: none;
}
.history-aside.file-history-collapsed .file-history-resize-handle::before {
display: none;
}
.left-sidebar {
display: grid;
grid-template-rows: minmax(180px, var(--branch-panel-height, 260px)) 14px minmax(150px, var(--stash-panel-height, 190px)) 14px minmax(220px, 1fr);
@@ -2635,225 +2619,6 @@
background: rgba(65,209,255,0.08);
color: var(--color-ink);
}
.file-history-panel.collapsed {
display: grid;
grid-template-rows: 1fr;
overflow: hidden;
}
.file-history-rail-toggle {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
justify-items: center;
gap: 8px;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
padding: 12px 0;
border: 0;
border-radius: 0;
color: var(--color-ink-dim);
background: linear-gradient(180deg, rgba(100,108,255,0.12), rgba(65,209,255,0.04));
}
.file-history-rail-toggle:hover:not(:disabled) {
color: var(--color-ink);
background: linear-gradient(180deg, rgba(100,108,255,0.18), rgba(65,209,255,0.08));
}
.file-history-rail-toggle span {
writing-mode: vertical-rl;
text-orientation: mixed;
overflow: hidden;
color: currentColor;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
}
.file-history-rail-toggle small {
display: inline-grid;
place-items: center;
min-width: 22px;
min-height: 22px;
border-radius: 999px;
color: var(--color-ink);
background: rgba(94,110,156,0.18);
font-size: 11px;
font-weight: 800;
}
.file-history-head { align-items: flex-start; }
.file-history-heading { min-width: 0; flex: 1 1 auto; overflow: hidden; }
.file-history-head-actions {
display: flex;
align-items: center;
flex: 0 0 auto;
gap: 6px;
}
.file-history-toggle {
width: 26px;
min-width: 26px;
min-height: 26px;
padding: 0;
border-color: rgba(65,209,255,0.2);
border-radius: 7px;
color: var(--color-ink-dim);
background: rgba(65,209,255,0.06);
}
.file-history-toggle:hover:not(:disabled) {
border-color: rgba(65,209,255,0.45);
color: #ffffff;
background: rgba(65,209,255,0.13);
}
.section-head .file-history-name {
display: -webkit-box;
max-height: 2.4em;
margin: 2px 0 0;
overflow: hidden;
color: var(--color-ink);
font-size: 14px;
font-weight: 700;
line-height: 1.2;
overflow-wrap: anywhere;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.file-history-count { flex: 0 0 auto; margin-top: 2px; }
.file-history-row { grid-template-columns: minmax(0, 1fr); min-width: 0; overflow: hidden; }
.file-history-row .commit-line { overflow: hidden; }
.file-history-row .commit-line strong {
display: -webkit-box;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.file-history-actions { display: grid; grid-template-columns: minmax(0, 1fr); width: 100%; align-items: center; gap: 6px; }
.file-history-actions .commit-action-buttons { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); width: 100%; justify-content: stretch; }
.file-history-actions .commit-action-buttons button { min-width: 0; justify-content: center; padding-inline: 6px; }
/* --- File history loading (scoped, non-blocking) --- */
.file-history-loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
min-height: 156px;
padding: 22px;
overflow: hidden;
background:
radial-gradient(circle at center 42%, rgba(111, 140, 255, 0.1), transparent 52%),
linear-gradient(180deg, rgba(15, 18, 31, 0.55), transparent);
}
.file-history-loading-mark {
position: relative;
display: grid;
place-items: center;
width: 86px;
height: 86px;
isolation: isolate;
}
.file-history-loading-halo {
position: absolute;
inset: 10px;
border: 1px solid rgba(111, 140, 255, 0.22);
border-radius: 18px;
transform: rotate(45deg);
animation: fhl-halo-breathe 2.4s ease-in-out infinite;
}
.file-history-loading-traces {
position: absolute;
inset: -12px;
width: 110px;
height: 110px;
overflow: visible;
}
.file-history-loading-traces .fhl-trace {
fill: none;
stroke-width: 2.5;
stroke-linecap: round;
stroke-dasharray: 92;
stroke-dashoffset: 92;
filter: drop-shadow(0 0 6px rgba(77, 182, 214, 0.3));
animation: fhl-trace-draw 2.4s ease-in-out infinite;
}
.file-history-loading-traces .fhl-trace-main {
stroke: #6f8cff;
}
.file-history-loading-traces .fhl-trace-branch {
stroke: #4db6d6;
animation-delay: 0.28s;
}
.file-history-loading-icon {
position: relative;
z-index: 1;
width: 64px;
height: 64px;
object-fit: contain;
filter:
drop-shadow(0 10px 14px rgba(0, 0, 0, 0.45))
drop-shadow(0 0 12px rgba(77, 182, 214, 0.2));
animation: fhl-icon-float 2.4s ease-in-out infinite;
}
.file-history-loading-label {
color: var(--color-ink-faint);
font-size: 12.5px;
font-weight: 800;
}
.file-history-loading-bar {
position: relative;
width: 118px;
height: 3px;
overflow: hidden;
border-radius: 999px;
background: rgba(111, 140, 255, 0.14);
}
.file-history-loading-bar span {
position: absolute;
inset: 0;
width: 46%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, #6f8cff 42%, #4db6d6 74%, transparent);
box-shadow: 0 0 12px rgba(77, 182, 214, 0.24);
animation: fhl-bar-slide 1.35s ease-in-out infinite;
}
@keyframes fhl-halo-breathe {
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.94); }
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
}
@keyframes fhl-icon-float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-3px) scale(1.02); }
}
@keyframes fhl-trace-draw {
0% { stroke-dashoffset: 92; opacity: 0; }
36% { opacity: 1; }
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
}
@keyframes fhl-bar-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@media (prefers-reduced-motion: reduce) {
.file-history-loading-halo,
.file-history-loading-traces .fhl-trace,
.file-history-loading-icon,
.file-history-loading-bar span { animation: none; }
.file-history-loading-traces .fhl-trace { stroke-dashoffset: 0; opacity: 0.72; }
}
/* --- Git graph --- */
.graph-list {
@@ -2861,6 +2626,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);
@@ -3091,6 +2881,100 @@
box-shadow: var(--app-dialog-shadow);
overflow: hidden;
}
.file-history-dialog-backdrop { z-index: 70; }
.file-history-dialog {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: min(780px, calc(100vw - 32px));
height: min(720px, calc(100vh - 32px));
border: 1px solid var(--color-border);
border-radius: 12px;
outline: 0;
background: var(--app-dialog-bg);
box-shadow: var(--app-dialog-shadow);
overflow: hidden;
}
.file-history-dialog-header {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 11px;
min-height: 72px;
padding: 12px 14px;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-dim);
}
.file-history-dialog-icon {
display: grid;
place-items: center;
width: 38px;
height: 38px;
border: 1px solid color-mix(in srgb, var(--color-primary) 24%, var(--color-border));
border-radius: 9px;
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 9%, var(--color-surface-solid));
}
.file-history-dialog-heading { display: grid; min-width: 0; gap: 2px; }
.file-history-dialog-heading h2 {
margin: 0;
overflow: hidden;
color: var(--color-ink);
font-size: 15px;
font-weight: 750;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-history-dialog-path {
overflow: hidden;
color: var(--color-ink-faint);
font: 10.5px/1.35 var(--font-mono);
text-overflow: ellipsis;
white-space: nowrap;
}
.file-history-dialog-body { min-height: 0; overflow: hidden; background: var(--color-surface-solid); }
.file-history-dialog-list { height: 100%; padding: 6px; overflow: auto; }
.file-history-dialog-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 12px;
min-height: 66px;
padding: 9px 10px;
border-bottom: 1px solid var(--color-border-subtle);
}
.file-history-dialog-row:last-child { border-bottom: 0; }
.file-history-dialog-row:hover { background: var(--color-surface-hover); }
.file-history-dialog-commit { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; gap: 9px; min-width: 0; }
.file-history-dialog-commit > div { display: grid; min-width: 0; gap: 4px; }
.file-history-dialog-commit strong {
overflow: hidden;
color: var(--color-ink);
font-size: 12.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-history-dialog-commit span { overflow: hidden; color: var(--color-ink-faint); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; }
.file-history-dialog-commit code { color: var(--color-primary); font-family: var(--font-mono); }
.file-history-dialog-row time { color: var(--color-ink-faint); font-size: 10.5px; white-space: nowrap; }
.file-history-dialog-actions { display: flex; align-items: center; gap: 5px; }
.file-history-dialog-actions button { min-height: 28px; white-space: nowrap; }
.file-history-dialog-state {
display: grid;
place-items: center;
align-content: center;
gap: 7px;
height: 100%;
min-height: 220px;
padding: 28px;
color: var(--color-ink-faint);
text-align: center;
}
.file-history-dialog-state svg { color: var(--color-primary); }
.file-history-dialog-state strong { color: var(--color-ink); font-size: 13px; }
.file-history-dialog-state span { max-width: 520px; font-size: 11.5px; line-height: 1.5; overflow-wrap: anywhere; }
.file-history-dialog-state.error svg,
.file-history-dialog-state.error strong { color: var(--color-danger); }
.compare-dialog {
width: min(1560px, calc(100vw - 32px));
height: min(940px, calc(100vh - 32px));
@@ -5672,12 +5556,10 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.left-sidebar-resize-handle,
.history-resize-handle,
.file-history-resize-handle,
.panel-resize-handle,
.left-panel-resize-handle { background: var(--color-border-subtle); }
.left-sidebar-resize-handle::before,
.history-resize-handle::before,
.file-history-resize-handle::before { width: 1px; height: 48px; background: var(--color-border); }
.history-resize-handle::before { width: 1px; height: 48px; background: var(--color-border); }
.panel-resize-handle::before { width: 44px; height: 2px; }
.status-toolbar {
@@ -5743,7 +5625,7 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.explorer-row .status-badge { min-width: 18px; overflow: hidden; color: #d9891b; background: transparent; text-indent: -999px; }
.explorer-row .status-badge::after { content: "M"; float: right; text-indent: 0; }
.history-panel, .file-history-panel { min-height: 0; }
.history-panel { min-height: 0; }
.graph-list { background: var(--color-surface-solid); }
.graph-gutter { background: var(--color-surface-dim); }
.commit-body {
@@ -5753,10 +5635,12 @@ 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; }
.workspace-statusbar {
display: flex;
align-items: center;
@@ -5808,6 +5692,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 +5994,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 +6076,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;
}
@@ -6209,13 +6146,11 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.top-section { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr) 14px var(--commit-panel-height, 190px); }
}
/* Compact: stack history panels vertically, narrow sidebars */
/* Compact workspace with narrow sidebars */
@media (max-width: 960px) {
.workspace { grid-template-columns: 180px 8px minmax(0, 1fr) 0 300px; row-gap: 6px; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1.3fr) minmax(0, 0.7fr); row-gap: 6px; }
.history-aside.file-history-collapsed { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) 42px; }
.history-resize-handle,
.file-history-resize-handle { display: none; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr); row-gap: 0; }
.history-resize-handle { display: none; }
.shell-body { gap: 6px; }
.left-sidebar {
grid-template-rows: minmax(180px, var(--branch-panel-height, 230px)) 14px minmax(150px, var(--stash-panel-height, 170px)) 14px minmax(220px, 1fr);
@@ -6262,8 +6197,15 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.tb-btn { width: 34px; }
.workspace { grid-template-columns: 1fr; gap: 6px; }
.left-sidebar-resize-handle { display: none; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr) minmax(150px, 0.5fr); min-height: 380px; }
.history-aside.file-history-collapsed { grid-template-rows: minmax(200px, 1fr) 42px; }
.history-aside { grid-template-columns: 1fr; grid-template-rows: minmax(200px, 1fr); min-height: 240px; }
.file-history-dialog-backdrop { padding: 8px; }
.file-history-dialog { width: 100%; height: 100%; }
.file-history-dialog-header { min-height: 64px; padding: 9px 10px; }
.file-history-dialog-icon { width: 34px; height: 34px; }
.file-history-dialog-row { grid-template-columns: minmax(0, 1fr) auto; gap: 7px; }
.file-history-dialog-row time { display: none; }
.file-history-dialog-actions { display: grid; }
.file-history-dialog-actions button { width: 32px; min-width: 32px; padding: 0; overflow: hidden; color: var(--color-ink-muted); font-size: 0; gap: 0; }
.left-sidebar {
grid-template-rows: minmax(180px, var(--branch-panel-height, 240px)) 14px minmax(150px, var(--stash-panel-height, 180px)) 14px minmax(220px, 1fr);
min-height: 560px;
+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 {
+25 -4
View File
@@ -11,6 +11,7 @@
FileCog,
FileImage,
FileJson,
FileSearch,
FileSpreadsheet,
FileText,
FileType,
@@ -37,6 +38,7 @@
onCollapseAllFolders: () => void;
onSelectNode: (node: ExplorerNode) => void;
onOpenFile: (node: ExplorerNode) => void;
onFileHistory: (node: ExplorerNode) => void;
onBlame: (node: ExplorerNode) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
@@ -54,6 +56,7 @@
onCollapseAllFolders = () => {},
onSelectNode = () => {},
onOpenFile = () => {},
onFileHistory = () => {},
onBlame = () => {},
collapsed = false,
onToggleCollapsed = () => {},
@@ -175,7 +178,7 @@
contextNode = node;
contextMenuX = Math.max(8, Math.min(event.clientX + 2, window.innerWidth - 192));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 56));
contextMenuY = Math.max(8, Math.min(event.clientY + 2, window.innerHeight - 132));
}
function closeFileContextMenu() {
@@ -196,6 +199,13 @@
onBlame(node);
}
function openContextFileHistory() {
const node = contextNode;
if (!node || node.kind !== "file" || !node.tracked) return;
closeFileContextMenu();
onFileHistory(node);
}
function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeFileContextMenu();
}
@@ -261,12 +271,14 @@
{:else}
<div class="explorer-list overflow-auto p-2">
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="explorer-row"
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
class:folder={node.kind === "folder"}
style={`--depth: ${node.depth}`}
title={node.path}
oncontextmenu={(event) => openFileContextMenu(event, node)}
>
{#if node.kind === "folder"}
<button
@@ -328,9 +340,8 @@
class="explorer-select"
type="button"
onclick={() => onSelectNode(node)}
oncontextmenu={(event) => openFileContextMenu(event, node)}
disabled={isBusy}
title={`Show history for ${node.path}`}
title={`Select ${node.path}`}
>
<span>{node.name}</span>
</button>
@@ -355,6 +366,16 @@
tabindex="-1"
aria-label={`Actions for ${contextNode.path}`}
>
<button
type="button"
role="menuitem"
onclick={openContextFileHistory}
disabled={!contextNode.tracked}
title={contextNode.tracked ? "Show the commit history for this file" : "File history is only available for tracked files"}
>
<History size={14} aria-hidden="true" />
File history
</button>
<button
type="button"
role="menuitem"
@@ -372,7 +393,7 @@
disabled={!contextNode.tracked || contextNode.status === "deleted"}
title={!contextNode.tracked || contextNode.status === "deleted" ? "Blame is only available for tracked files" : "Show blame for this file"}
>
<History size={14} aria-hidden="true" />
<FileSearch size={14} aria-hidden="true" />
Blame
</button>
</div>
+106
View File
@@ -0,0 +1,106 @@
<script lang="ts">
import { AlertCircle, GitCompare, History, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { GitCommit } from "../types";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface Props {
fileHistory: GitCommit[];
filePath: string;
isBusy: boolean;
isLoading: boolean;
error?: string;
onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void;
onClose: () => void;
}
let {
fileHistory = [],
filePath = "",
isBusy = false,
isLoading = false,
error = "",
onDiff = () => {},
onRestore = () => {},
onClose = () => {},
}: Props = $props();
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return commitDateFormatter.format(date);
}
function fileName(path: string): string {
return path.split(/[\\/]/).pop() ?? path;
}
function closeFromBackdrop(event: MouseEvent) {
if (event.target === event.currentTarget && !isBusy) onClose();
}
</script>
<div class="dialog-backdrop app-chrome-backdrop file-history-dialog-backdrop" role="presentation" onclick={closeFromBackdrop}>
<div class="file-history-dialog" role="dialog" aria-modal="true" aria-labelledby="file-history-dialog-title" tabindex="-1">
<header class="file-history-dialog-header">
<div class="file-history-dialog-icon" aria-hidden="true"><History size={19} /></div>
<div class="file-history-dialog-heading">
<span class="eyebrow">File history</span>
<h2 id="file-history-dialog-title">{fileName(filePath)}</h2>
<span class="file-history-dialog-path" title={filePath}>{filePath}</span>
</div>
<button class="dialog-icon-button" type="button" onclick={onClose} disabled={isBusy} aria-label="Close file history">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="file-history-dialog-body">
{#if isLoading}
<div class="file-history-dialog-state" role="status" aria-live="polite">
<LoaderCircle class="spin" size={22} aria-hidden="true" />
<strong>Loading file history…</strong>
<span>Reading commits that changed this file.</span>
</div>
{:else if error}
<div class="file-history-dialog-state error" role="alert">
<AlertCircle size={22} aria-hidden="true" />
<strong>File history could not be loaded.</strong>
<span>{error}</span>
</div>
{:else if fileHistory.length === 0}
<div class="file-history-dialog-state">
<History size={22} aria-hidden="true" />
<strong>No history found.</strong>
<span>This file has no committed changes yet.</span>
</div>
{:else}
<div class="file-history-dialog-list">
{#each fileHistory as item (item.hash)}
<article class="file-history-dialog-row">
<div class="file-history-dialog-commit">
<span class="commit-avatar" aria-hidden="true">{item.author_name.trim().slice(0, 2).toUpperCase() || "?"}</span>
<div>
<strong title={item.summary}>{item.summary}</strong>
<span><code>{item.short_hash}</code> · {item.author_name}</span>
</div>
</div>
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="file-history-dialog-actions">
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes against the working tree">
<GitCompare size={14} aria-hidden="true" /> Diff
</button>
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore this file from the selected commit">
<RotateCcw size={14} aria-hidden="true" /> Restore
</button>
</div>
</article>
{/each}
</div>
{/if}
</div>
</div>
</div>
-172
View File
@@ -1,172 +0,0 @@
<script lang="ts">
import { ChevronLeft, ChevronRight, GitCompare, History, RotateCcw } from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { GitCommit } from "../types";
interface Props {
fileHistory: GitCommit[];
selectedExplorerPath: string;
selectedExplorerLabel: string;
hasRepository: boolean;
isBusy: boolean;
isLoading?: boolean;
onDiff: (commit: GitCommit) => void;
onRestore: (commit: GitCommit) => void;
collapsed?: boolean;
onToggleCollapsed?: () => void;
}
let {
fileHistory = [],
selectedExplorerPath = "",
selectedExplorerLabel = "File history",
hasRepository = false,
isBusy = false,
isLoading = false,
onDiff = () => {},
onRestore = () => {},
collapsed = false,
onToggleCollapsed = () => {},
}: Props = $props();
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);
}
function fileName(path: string): string {
return path.split("/").pop() ?? path;
}
function pathTooltip(node: HTMLElement, text: string) {
let el: HTMLDivElement | null = null;
function show(e: MouseEvent) {
if (!text || !text.includes("/")) return;
el = document.createElement("div");
el.className = "path-tooltip";
el.textContent = text;
document.body.appendChild(el);
move(e);
}
function move(e: MouseEvent) {
if (!el) return;
const tw = el.offsetWidth;
const left = Math.max(8, Math.min(e.clientX - tw / 2, window.innerWidth - tw - 8));
el.style.left = `${left}px`;
el.style.top = `${e.clientY - 44}px`;
}
function hide() {
el?.remove();
el = null;
}
node.addEventListener("mouseenter", show);
node.addEventListener("mousemove", move);
node.addEventListener("mouseleave", hide);
return {
update(v: string) { text = v; },
destroy() {
hide();
node.removeEventListener("mouseenter", show);
node.removeEventListener("mousemove", move);
node.removeEventListener("mouseleave", hide);
},
};
}
</script>
<section class="panel file-history-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Selected file history">
{#if collapsed}
<button
class="file-history-rail-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title="Expand file history"
aria-label="Expand file history panel"
>
<ChevronLeft size={16} aria-hidden="true" />
<span>File history</span>
{#if selectedExplorerPath}
<small>{fileHistory.length}</small>
{/if}
</button>
{:else}
<div class="section-head file-history-head">
<div class="file-history-heading">
<span class="eyebrow">{selectedExplorerLabel}</span>
<h2
class="file-history-name"
use:pathTooltip={selectedExplorerPath}
>{selectedExplorerPath ? fileName(selectedExplorerPath) : "No file"}</h2>
</div>
<div class="file-history-head-actions">
<span class="pill pill-count file-history-count">{fileHistory.length}</span>
<button
class="file-history-toggle panel-collapse-toggle"
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title="Collapse file history"
aria-label="Collapse file history panel"
>
<ChevronRight size={14} aria-hidden="true" />
</button>
</div>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if !selectedExplorerPath}
<div class="blank-state">Select a file in Explorer.</div>
{:else if isLoading}
<div class="file-history-loading" role="status" aria-live="polite">
<div class="file-history-loading-mark" aria-hidden="true">
<span class="file-history-loading-halo"></span>
<svg class="file-history-loading-traces" viewBox="0 0 120 120">
<path class="fhl-trace fhl-trace-main" d="M14 82 C38 60, 48 58, 60 60 S84 66, 106 36" />
<path class="fhl-trace fhl-trace-branch" d="M28 36 C52 48, 70 70, 91 94" />
</svg>
<img src={iconUrl} alt="" class="file-history-loading-icon" />
</div>
<span class="file-history-loading-label">Loading history…</span>
<span class="file-history-loading-bar" aria-hidden="true"><span></span></span>
</div>
{:else if fileHistory.length === 0}
<div class="blank-state">No history returned for this selection.</div>
{:else}
<div class="history-list overflow-auto p-2">
{#each fileHistory as item (item.hash)}
<article class="commit-row compact file-history-row">
<div class="commit-line">
<History size={16} aria-hidden="true" />
<div class="commit-line-text">
<strong title={item.summary}>{item.summary}</strong>
<span>{item.short_hash} - {item.author_name}</span>
</div>
</div>
<div class="commit-actions file-history-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<div class="commit-action-buttons">
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes vs working tree">
<GitCompare size={15} aria-hidden="true" />
Diff
</button>
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore selected file from this commit">
<RotateCcw size={15} aria-hidden="true" />
Restore
</button>
</div>
</div>
</article>
{/each}
</div>
{/if}
{/if}
</section>
+6 -1
View File
@@ -15,6 +15,11 @@
import type { GitCommit, GitRepositoryFile, GitSearchHit } from "../types";
import LanguageIcon from "./LanguageIcon.svelte";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
type SearchTab = "code" | "files";
interface Props {
@@ -82,7 +87,7 @@
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
return commitDateFormatter.format(date);
}
function displayPath(hit: GitSearchHit): string {
+45 -9
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
interface GraphSegment {
@@ -27,6 +27,10 @@
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
];
const GRAPH_LANE = 18;
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
interface Props {
commits: GitCommit[];
@@ -36,7 +40,11 @@
repositoryKey: string;
hasRepository: boolean;
isBusy: boolean;
hasMore: boolean;
isLoadingMore: boolean;
loadMoreError: string;
expandedCommitHashes: Set<string>;
onLoadMore: () => void | Promise<void>;
onRestoreCommit: (commit: GitCommit) => void;
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
onToggleCommitFiles: (hash: string) => void;
@@ -53,7 +61,11 @@
repositoryKey = "",
hasRepository = false,
isBusy = false,
hasMore = false,
isLoadingMore = false,
loadMoreError = "",
expandedCommitHashes = new Set(),
onLoadMore = () => {},
onRestoreCommit = () => {},
onPreviewCommitFile = () => {},
onToggleCommitFiles = () => {},
@@ -71,6 +83,17 @@
let contextMenuX = $state(0);
let contextMenuY = $state(0);
function observeHistoryEnd(node: HTMLElement) {
const root = node.closest<HTMLElement>(".history-list");
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting) && hasMore && !isLoadingMore && !loadMoreError && !isBusy) {
void onLoadMore();
}
}, { root, rootMargin: "240px 0px" });
observer.observe(node);
return { destroy: () => observer.disconnect() };
}
function laneColor(col: number): string {
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
}
@@ -464,7 +487,7 @@
function formatCommitDate(value: string): string {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
return commitDateFormatter.format(date);
}
$effect(() => {
@@ -518,8 +541,8 @@
<span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
</div>
<div class="section-head-actions">
{#if localBranchNames.length > 0}
<div class="section-head-actions">
<button
class="graph-branch-dialog-button"
type="button"
@@ -530,21 +553,19 @@
Branches
<span>{visibleBranchCount}/{localBranchNames.length}</span>
</button>
{/if}
<span class="pill pill-count" title={visibleCommits.length === commits.length ? "Commits" : `${visibleCommits.length} of ${commits.length} commits shown`}>
{visibleCommits.length}
</span>
</div>
{/if}
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if commits.length === 0}
<div class="blank-state">No commits returned.</div>
{:else if visibleCommits.length === 0}
<div class="blank-state">No commits match the selected branches.</div>
{:else}
<div class="history-list graph-list overflow-auto">
{#if visibleCommits.length === 0}
<div class="blank-state">No loaded commits match the selected branches.</div>
{/if}
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
{@const row = graphRows[rowIndex]}
@@ -698,6 +719,21 @@
</div>
</article>
{/each}
{#if hasMore || isLoadingMore || loadMoreError}
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
{#if isLoadingMore}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
<span>Loading older commits…</span>
{:else if loadMoreError}
<span title={loadMoreError}>Older commits could not be loaded.</span>
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
{:else}
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
Load older commits
</button>
{/if}
</div>
{/if}
</div>
{/if}
+4 -2
View File
@@ -144,6 +144,8 @@
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null));
let stagedFiles = $derived(changedFiles.filter((file) => file.staged !== null));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
@@ -201,7 +203,7 @@
</div>
</header>
<div class="status-file-list">
{#each changedFiles.filter((file) => file.unstaged !== null) as file (`unstaged:${fileKey(file)}`)}
{#each unstagedFiles as file (`unstaged:${fileKey(file)}`)}
{@const stageTargets = selectedStageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
@@ -241,7 +243,7 @@
</div>
</header>
<div class="status-file-list">
{#each changedFiles.filter((file) => file.staged !== null) as file (`staged:${fileKey(file)}`)}
{#each stagedFiles as file (`staged:${fileKey(file)}`)}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class="status-file-row" class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
+2 -2
View File
@@ -366,8 +366,8 @@ export function credDelete(key: string): Promise<void> {
return invoke<void>("cred_delete", { key });
}
export function listCommits(path: string, limit = 100): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_commits", { path, limit });
export function listCommits(path: string, limit = 100, skip = 0): Promise<GitCommit[]> {
return invoke<GitCommit[]>("list_commits", { path, limit, skip });
}
export function restoreToCommit(path: string, commit: string): Promise<GitStatus> {