diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 532561d1e..4100801e8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -231,6 +231,7 @@ pub fn run() { git::commands::git_unstage, git::commands::git_discard, git::commands::git_commit, + git::commands::git_undo_commit, git::commands::git_fetch, git::commands::git_pull_ff_only, git::commands::git_push, diff --git a/src-tauri/src/modules/git/commands.rs b/src-tauri/src/modules/git/commands.rs index 5bda9a848..2ff1e7199 100644 --- a/src-tauri/src/modules/git/commands.rs +++ b/src-tauri/src/modules/git/commands.rs @@ -155,6 +155,21 @@ pub async fn git_commit( .await } +#[tauri::command] +pub async fn git_undo_commit( + repo_root: String, + expected_head_sha: String, + workspace: Option, + app: AppHandle, +) -> Result<(), String> { + let workspace = WorkspaceEnv::from_option(workspace); + blocking(app, move |r| { + operations::undo_last_commit(r, &repo_root, &expected_head_sha, &workspace) + .map_err(Into::into) + }) + .await +} + #[tauri::command] pub async fn git_fetch( repo_root: String, diff --git a/src-tauri/src/modules/git/operations.rs b/src-tauri/src/modules/git/operations.rs index d3c52661e..4bb31bf67 100644 --- a/src-tauri/src/modules/git/operations.rs +++ b/src-tauri/src/modules/git/operations.rs @@ -436,6 +436,63 @@ pub fn commit( }) } +pub fn undo_last_commit( + registry: &WorkspaceRegistry, + repo_root: &str, + expected_head_sha: &str, + workspace: &WorkspaceEnv, +) -> Result<()> { + let repo_root = authorized_repo_root(registry, repo_root, workspace)?; + ensure_git_available(&repo_root.workspace)?; + + if expected_head_sha.len() != 40 + || !expected_head_sha.bytes().all(|b| b.is_ascii_hexdigit()) + { + return Err(GitError::command( + "git update-ref", + "expected a full commit sha", + )); + } + + // Derive the parent from the expected sha, not from HEAD, so a racing + // commit cannot redirect which commit gets undone. + let parent = git_stdout_line_opt( + &repo_root.workspace, + &repo_root.git_path, + [ + "rev-parse", + "--verify", + "--quiet", + &format!("{expected_head_sha}^"), + ], + )? + .ok_or_else(|| GitError::command("git update-ref", "cannot undo the initial commit"))?; + + // A soft reset is just a ref move; update-ref with an old-value check makes + // it an atomic compare-and-swap, so HEAD moving after the commit list was + // loaded fails the update instead of discarding the wrong commit. + let output = run_git( + &repo_root.workspace, + Some(&repo_root.git_path), + [ + "update-ref", + "-m", + "reset: moving to HEAD~1 (undo last commit)", + "HEAD", + &parent, + expected_head_sha, + ], + DEFAULT_TIMEOUT_SECS, + )?; + if output.exit_code != Some(0) { + return Err(GitError::command( + "git update-ref", + "HEAD changed since the commit list was loaded; refresh and try again", + )); + } + Ok(()) +} + pub fn push( registry: &WorkspaceRegistry, repo_root: &str, diff --git a/src-tauri/tests/git_operations.rs b/src-tauri/tests/git_operations.rs index cb8e6f6a7..244584a70 100644 --- a/src-tauri/tests/git_operations.rs +++ b/src-tauri/tests/git_operations.rs @@ -564,3 +564,98 @@ fn list_branches_keeps_current_branch_local_and_surfaces_worktrees() { assert!(!feature[0].is_head); assert!(feature[0].worktree_path.is_some()); } + +#[test] +fn undo_last_commit_moves_changes_back_to_index() { + if skip_if_no_git() { + return; + } + let fx = GitRepoFixture::new(); + fx.write_file("a.txt", "alpha\n"); + fx.run_git(&["add", "a.txt"]); + fx.run_git(&["commit", "-q", "-m", "first"]); + fx.write_file("b.txt", "beta\n"); + fx.run_git(&["add", "b.txt"]); + fx.run_git(&["commit", "-q", "-m", "second"]); + + let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace) + .expect("log"); + assert_eq!(entries.len(), 2); + let head_sha = entries[0].sha.clone(); + + operations::undo_last_commit(&fx.registry, &fx.repo_str(), &head_sha, &fx.workspace) + .expect("undo"); + + let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace) + .expect("log after undo"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].subject, "first"); + + let snap = operations::status(&fx.registry, &fx.repo_str(), &fx.workspace).unwrap(); + let entry = snap + .changed_files + .iter() + .find(|f| f.path == "b.txt") + .expect("b.txt back in index"); + assert!(entry.staged); +} + +#[test] +fn undo_last_commit_rejects_stale_head_sha() { + if skip_if_no_git() { + return; + } + let fx = GitRepoFixture::new(); + fx.write_file("a.txt", "alpha\n"); + fx.run_git(&["add", "a.txt"]); + fx.run_git(&["commit", "-q", "-m", "first"]); + fx.write_file("b.txt", "beta\n"); + fx.run_git(&["add", "b.txt"]); + fx.run_git(&["commit", "-q", "-m", "second"]); + + // A sha that exists and has a parent but is no longer HEAD: the atomic + // compare-and-swap must refuse to move the ref. + let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace) + .expect("log"); + let stale_sha = entries[1].sha.clone(); + let err = + operations::undo_last_commit(&fx.registry, &fx.repo_str(), &stale_sha, &fx.workspace) + .expect_err("stale sha must be rejected"); + assert!(matches!(err, GitError::CommandFailed { .. })); + + let err = operations::undo_last_commit( + &fx.registry, + &fx.repo_str(), + "not-a-sha", + &fx.workspace, + ) + .expect_err("malformed sha must be rejected"); + assert!(matches!(err, GitError::CommandFailed { .. })); + + let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace) + .expect("log"); + assert_eq!(entries.len(), 2, "history must be untouched"); +} + +#[test] +fn undo_last_commit_rejects_initial_commit() { + if skip_if_no_git() { + return; + } + let fx = GitRepoFixture::new(); + fx.write_file("a.txt", "alpha\n"); + fx.run_git(&["add", "a.txt"]); + fx.run_git(&["commit", "-q", "-m", "first"]); + + let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace) + .expect("log"); + let head_sha = entries[0].sha.clone(); + + let err = operations::undo_last_commit(&fx.registry, &fx.repo_str(), &head_sha, &fx.workspace) + .expect_err("initial commit cannot be undone"); + assert!(matches!(err, GitError::CommandFailed { .. })); + + let entries = operations::log(&fx.registry, &fx.repo_str(), 10, None, &fx.workspace) + .expect("log"); + assert_eq!(entries.len(), 1, "history must be untouched"); +} diff --git a/src/app/App.tsx b/src/app/App.tsx index a3edf78b6..de214dfdd 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -595,6 +595,9 @@ export default function App() { const explorerGitDecorations = usePreferencesStore( (s) => s.explorerGitDecorations, ); + const sourceControlUndoCommit = usePreferencesStore( + (s) => s.sourceControlUndoCommit, + ); const openPreviewTab = useCallback( (url: string) => { @@ -1136,6 +1139,8 @@ export default function App() { onOpenGitGraph={openGitGraphFromContext} onOpenFile={handleOpenFile} onNavigateToPath={cdInNewTab} + onOpenCommitFile={openCommitFileDiffTab} + showUndoCommit={sourceControlUndoCommit} /> )} diff --git a/src/modules/ai/lib/native.ts b/src/modules/ai/lib/native.ts index 507b7957a..e3b0d9c8d 100644 --- a/src/modules/ai/lib/native.ts +++ b/src/modules/ai/lib/native.ts @@ -317,6 +317,12 @@ export const native = { message, workspace: currentWorkspaceEnv(), }), + gitUndoCommit: (repoRoot: string, expectedHeadSha: string) => + invoke("git_undo_commit", { + repoRoot, + expectedHeadSha, + workspace: currentWorkspaceEnv(), + }), gitFetch: (repoRoot: string) => invoke("git_fetch", { repoRoot, diff --git a/src/modules/settings/store.ts b/src/modules/settings/store.ts index a300c6f7d..176a84525 100644 --- a/src/modules/settings/store.ts +++ b/src/modules/settings/store.ts @@ -146,6 +146,7 @@ export type Preferences = { editorWordWrap: boolean; showHidden: boolean; explorerGitDecorations: boolean; + sourceControlUndoCommit: boolean; terminalWebglEnabled: boolean; terminalCursorBlink: boolean; terminalFontFamily: string; @@ -213,6 +214,7 @@ const KEY_EDITOR_WORD_WRAP = "editorWordWrap"; const KEY_SHOW_HIDDEN = "showHidden"; const LEGACY_KEY_SHOW_HIDDEN_DIRS = "showHiddenDirectories"; const KEY_EXPLORER_GIT_DECORATIONS = "explorerGitDecorations"; +const KEY_SOURCE_CONTROL_UNDO_COMMIT = "sourceControlUndoCommit"; const KEY_TERMINAL_WEBGL_ENABLED = "terminalWebglEnabled"; const KEY_TERMINAL_CURSOR_BLINK = "terminalCursorBlink"; const KEY_TERMINAL_FONT_FAMILY = "terminalFontFamily"; @@ -281,6 +283,7 @@ export const DEFAULT_PREFERENCES: Preferences = { editorWordWrap: false, showHidden: false, explorerGitDecorations: true, + sourceControlUndoCommit: true, terminalWebglEnabled: true, terminalCursorBlink: false, terminalFontFamily: "", @@ -417,6 +420,9 @@ export async function loadPreferences(): Promise { explorerGitDecorations: get(KEY_EXPLORER_GIT_DECORATIONS) ?? DEFAULT_PREFERENCES.explorerGitDecorations, + sourceControlUndoCommit: + get(KEY_SOURCE_CONTROL_UNDO_COMMIT) ?? + DEFAULT_PREFERENCES.sourceControlUndoCommit, terminalWebglEnabled: get(KEY_TERMINAL_WEBGL_ENABLED) ?? DEFAULT_PREFERENCES.terminalWebglEnabled, @@ -650,6 +656,12 @@ export async function setExplorerGitDecorations(value: boolean): Promise { await writePref(KEY_EXPLORER_GIT_DECORATIONS, value); } +export async function setSourceControlUndoCommit( + value: boolean, +): Promise { + await writePref(KEY_SOURCE_CONTROL_UNDO_COMMIT, value); +} + export async function setTerminalWebglEnabled(value: boolean): Promise { await writePref(KEY_TERMINAL_WEBGL_ENABLED, value); } @@ -786,6 +798,7 @@ export async function onPreferencesChange( [KEY_EDITOR_WORD_WRAP]: "editorWordWrap", [KEY_SHOW_HIDDEN]: "showHidden", [KEY_EXPLORER_GIT_DECORATIONS]: "explorerGitDecorations", + [KEY_SOURCE_CONTROL_UNDO_COMMIT]: "sourceControlUndoCommit", [KEY_TERMINAL_WEBGL_ENABLED]: "terminalWebglEnabled", [KEY_TERMINAL_CURSOR_BLINK]: "terminalCursorBlink", [KEY_TERMINAL_FONT_FAMILY]: "terminalFontFamily", diff --git a/src/modules/source-control/CommitHistorySection.tsx b/src/modules/source-control/CommitHistorySection.tsx new file mode 100644 index 000000000..7093c057b --- /dev/null +++ b/src/modules/source-control/CommitHistorySection.tsx @@ -0,0 +1,607 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Spinner } from "@/components/ui/spinner"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/lib/utils"; +import { + native, + type GitCommitFileChange, + type GitLogEntry, +} from "@/modules/ai/lib/native"; +import { fileIconUrl } from "@/modules/explorer/lib/iconResolver"; +import { + ArrowDown01Icon, + ArrowRight01Icon, + ArrowTurnBackwardIcon, + GitBranchIcon, + Refresh01Icon, +} from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; + +export const HISTORY_HEADER_PX = 29; + +const PAGE_SIZE = 30; +const NEAR_BOTTOM_PX = 160; +const FILES_CACHE_LIMIT = 16; + +type CommitFileDiffOpenInput = { + repoRoot: string; + sha: string; + shortSha: string; + subject: string; + path: string; + originalPath: string | null; +}; + +type Props = { + repoRoot: string | null; + refreshKey: unknown; + collapsed: boolean; + topCommitPushed: boolean; + showUndoCommit: boolean; + onToggleCollapsed: () => void; + onOpenCommitFile: (input: CommitFileDiffOpenInput) => void; + onOpenGitGraph?: () => void; + onDidUndoCommit?: () => void; +}; + +type LoadStatus = "idle" | "initial" | "more" | "error"; + +type FilesEntry = + | { state: "loading" } + | { state: "loaded"; files: GitCommitFileChange[] } + | { state: "error"; error: string }; + +function basename(path: string): string { + const parts = path.split(/[\\/]/).filter(Boolean); + return parts.length > 0 ? parts[parts.length - 1] : path; +} + +function normalizeError(error: unknown): string { + if (typeof error === "string") return error; + if (error && typeof error === "object" && "message" in error) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string") return message; + } + return "Unknown error"; +} + +export function relativeTime(secs: number, nowMs: number): string { + if (!secs) return ""; + const diff = Math.max(0, Math.floor(nowMs / 1000) - secs); + if (diff < 60) return "now"; + const mins = Math.floor(diff / 60); + if (mins < 60) return `${mins}m`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h`; + const days = Math.floor(hours / 24); + if (days < 30) return `${days}d`; + const months = Math.floor(days / 30); + if (months < 12) return `${months}mo`; + return `${Math.floor(months / 12)}y`; +} + +function statusTone(code: string): string { + switch (code.toUpperCase()) { + case "A": + return "text-emerald-600 dark:text-emerald-400"; + case "M": + return "text-amber-600 dark:text-amber-300"; + case "D": + return "text-rose-600 dark:text-rose-400"; + case "R": + case "C": + return "text-sky-600 dark:text-sky-300"; + default: + return "text-muted-foreground"; + } +} + +export function CommitHistorySection({ + repoRoot, + refreshKey, + collapsed, + topCommitPushed, + showUndoCommit, + onToggleCollapsed, + onOpenCommitFile, + onOpenGitGraph, + onDidUndoCommit, +}: Props) { + const [commits, setCommits] = useState([]); + const [loadStatus, setLoadStatus] = useState("idle"); + const [error, setError] = useState(null); + const [endReached, setEndReached] = useState(false); + const [expandedSha, setExpandedSha] = useState(null); + const [filesTick, setFilesTick] = useState(0); + + const requestIdRef = useRef(0); + const inflightMoreRef = useRef(false); + const filesInflightRef = useRef(new Set()); + const filesCacheRef = useRef(new Map()); + const commitsRef = useRef([]); + const scrollRef = useRef(null); + const bumpFiles = useCallback(() => setFilesTick((n) => n + 1), []); + + const replaceCommits = useCallback((entries: GitLogEntry[]) => { + commitsRef.current = entries; + setCommits(entries); + }, []); + + const loadInitial = useCallback(async () => { + const requestId = ++requestIdRef.current; + if (!repoRoot) { + replaceCommits([]); + setLoadStatus("idle"); + setError(null); + setEndReached(true); + return; + } + setLoadStatus("initial"); + setError(null); + try { + const entries = await native.gitLog(repoRoot, { limit: PAGE_SIZE }); + if (requestId !== requestIdRef.current) return; + const prev = commitsRef.current; + const unchanged = + prev.length >= entries.length && + entries.every((e, i) => prev[i]?.sha === e.sha); + if (!unchanged) { + filesInflightRef.current.clear(); + filesCacheRef.current.clear(); + bumpFiles(); + setExpandedSha(null); + replaceCommits(entries); + setEndReached(entries.length < PAGE_SIZE); + } + setLoadStatus("idle"); + } catch (err) { + if (requestId !== requestIdRef.current) return; + setError(normalizeError(err)); + setLoadStatus("error"); + } + }, [bumpFiles, repoRoot, replaceCommits]); + + const loadMore = useCallback(async () => { + if (!repoRoot) return; + if (inflightMoreRef.current || endReached) return; + if (loadStatus !== "idle") return; + const last = commitsRef.current[commitsRef.current.length - 1]; + if (!last) return; + const requestId = requestIdRef.current; + inflightMoreRef.current = true; + setLoadStatus("more"); + try { + const entries = await native.gitLog(repoRoot, { + limit: PAGE_SIZE, + beforeSha: last.sha, + }); + if (requestId !== requestIdRef.current) return; + const seen = new Set(commitsRef.current.map((c) => c.sha)); + const merged = [...commitsRef.current]; + for (const e of entries) if (!seen.has(e.sha)) merged.push(e); + replaceCommits(merged); + if (entries.length < PAGE_SIZE) setEndReached(true); + setLoadStatus("idle"); + } catch (err) { + if (requestId !== requestIdRef.current) return; + toast.error(`Failed to load older commits: ${normalizeError(err)}`); + setLoadStatus("idle"); + } finally { + inflightMoreRef.current = false; + } + }, [endReached, loadStatus, repoRoot, replaceCommits]); + + const lastRepoRef = useRef(undefined); + + // biome-ignore lint/correctness/useExhaustiveDependencies: refreshKey re-runs the initial load after commit/push/refresh + useEffect(() => { + if (lastRepoRef.current !== repoRoot) { + lastRepoRef.current = repoRoot; + filesInflightRef.current.clear(); + filesCacheRef.current.clear(); + bumpFiles(); + setExpandedSha(null); + replaceCommits([]); + } + void loadInitial(); + }, [bumpFiles, loadInitial, refreshKey, repoRoot, replaceCommits]); + + const fetchFiles = useCallback( + async (sha: string) => { + if (!repoRoot) return; + if (filesInflightRef.current.has(sha)) return; + const cache = filesCacheRef.current; + const existing = cache.get(sha); + if (existing && existing.state !== "error") return; + filesInflightRef.current.add(sha); + cache.set(sha, { state: "loading" }); + bumpFiles(); + try { + const files = await native.gitCommitFiles(repoRoot, sha); + cache.set(sha, { state: "loaded", files }); + while (cache.size > FILES_CACHE_LIMIT) { + const oldest = cache.keys().next().value; + if (oldest === undefined || oldest === sha) break; + cache.delete(oldest); + } + bumpFiles(); + } catch (err) { + cache.set(sha, { state: "error", error: normalizeError(err) }); + bumpFiles(); + } finally { + filesInflightRef.current.delete(sha); + } + }, + [bumpFiles, repoRoot], + ); + + const toggleCommit = useCallback( + (sha: string) => { + setExpandedSha((prev) => (prev === sha ? null : sha)); + void fetchFiles(sha); + }, + [fetchFiles], + ); + + const handleScroll = useCallback(() => { + const el = scrollRef.current; + if (!el) return; + const remaining = el.scrollHeight - el.scrollTop - el.clientHeight; + if (remaining < NEAR_BOTTOM_PX) void loadMore(); + }, [loadMore]); + + useEffect(() => { + if (collapsed) return; + if (loadStatus !== "idle" || endReached || commits.length === 0) return; + const el = scrollRef.current; + if (!el) return; + if (el.scrollHeight - el.clientHeight > NEAR_BOTTOM_PX) return; + const id = window.setTimeout(() => void loadMore(), 0); + return () => window.clearTimeout(id); + }, [collapsed, commits.length, endReached, loadMore, loadStatus]); + + const handleRefresh = useCallback(() => { + void loadInitial(); + }, [loadInitial]); + + const [pendingUndo, setPendingUndo] = useState(null); + const [undoBusy, setUndoBusy] = useState(false); + + const confirmUndo = useCallback(async () => { + const commit = pendingUndo; + if (!commit || !repoRoot || undoBusy) return; + setUndoBusy(true); + try { + await native.gitUndoCommit(repoRoot, commit.sha); + setPendingUndo(null); + void loadInitial(); + onDidUndoCommit?.(); + } catch (err) { + setPendingUndo(null); + toast.error(`Undo commit failed: ${normalizeError(err)}`); + } finally { + setUndoBusy(false); + } + }, [loadInitial, onDidUndoCommit, pendingUndo, repoRoot, undoBusy]); + + const nowMs = Date.now(); + + return ( +
+
+ +
+ + + + + + Refresh commits + + + {onOpenGitGraph ? ( + + + + + + Open commit graph + + + ) : null} +
+
+ + {collapsed ? null : ( +
+ {loadStatus === "initial" && commits.length === 0 ? ( +
+ +
+ ) : loadStatus === "error" && commits.length === 0 ? ( +
+

{error}

+ +
+ ) : commits.length === 0 ? ( +

+ No commits yet. +

+ ) : ( +
+ {commits.map((commit, index) => ( + 0 + } + onRequestUndo={setPendingUndo} + onToggle={toggleCommit} + onRetryFiles={fetchFiles} + onOpenCommitFile={onOpenCommitFile} + /> + ))} + {loadStatus === "more" ? ( +
+ +
+ ) : null} +
+ )} +
+ )} + + { + if (!o && !undoBusy) setPendingUndo(null); + }} + > + + + Undo last commit? + + {pendingUndo + ? `"${pendingUndo.subject}" will be removed from history and its changes returned to the staged area.${ + topCommitPushed + ? " This commit looks already pushed; undoing it rewrites history and the branch will diverge from its upstream." + : "" + }` + : null} + + + + Cancel + { + event.preventDefault(); + void confirmUndo(); + }} + > + {undoBusy ? "Undoing" : "Undo Commit"} + + + + +
+ ); +} + +const CommitRow = memo(function CommitRow({ + commit, + nowMs, + expanded, + filesEntry, + repoRoot, + canUndo, + onRequestUndo, + onToggle, + onRetryFiles, + onOpenCommitFile, +}: { + commit: GitLogEntry; + nowMs: number; + expanded: boolean; + filesEntry: FilesEntry | null; + repoRoot: string | null; + canUndo: boolean; + onRequestUndo: (commit: GitLogEntry) => void; + onToggle: (sha: string) => void; + onRetryFiles: (sha: string) => void; + onOpenCommitFile: (input: CommitFileDiffOpenInput) => void; +}) { + const isMerge = commit.parents.length > 1; + return ( +
+
+ + {canUndo ? ( + + + + + + Undo last commit + + + ) : null} +
+ + {expanded ? ( +
+
+ {commit.shortSha} + {commit.author} + + {commit.filesChanged}{" "} + {commit.filesChanged === 1 ? "file" : "files"} + +
+ {!filesEntry || filesEntry.state === "loading" ? ( +
+ Loading files +
+ ) : filesEntry.state === "error" ? ( +
+

{filesEntry.error}

+ +
+ ) : ( + filesEntry.files.map((file) => ( + + )) + )} +
+ ) : null} +
+ ); +}); diff --git a/src/modules/source-control/SourceControlPanel.tsx b/src/modules/source-control/SourceControlPanel.tsx index 4fe97401a..b7a9e2b02 100644 --- a/src/modules/source-control/SourceControlPanel.tsx +++ b/src/modules/source-control/SourceControlPanel.tsx @@ -10,6 +10,11 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; import { ContextMenu, ContextMenuContent, @@ -52,14 +57,12 @@ import { AiContentGenerator02Icon, Alert02Icon, ArrowDown01Icon, - ArrowRight01Icon, ArrowUp01Icon, CheckmarkCircle01Icon, Download01Icon, Folder01Icon, FolderCloudIcon, FolderGitTwoIcon, - GitBranchIcon, Refresh01Icon, RemoveSquareIcon, Tick02Icon, @@ -76,6 +79,11 @@ import { type KeyboardEvent, type ReactNode, } from "react"; +import type { PanelImperativeHandle } from "react-resizable-panels"; +import { + CommitHistorySection, + HISTORY_HEADER_PX, +} from "./CommitHistorySection"; import type { SourceControlSummary } from "./useSourceControl"; import { useSourceControlPanel, @@ -96,8 +104,52 @@ type Props = { }) => void; onOpenFile?: (absolutePath: string) => void; onNavigateToPath?: (path: string) => void; + onOpenCommitFile?: (input: { + repoRoot: string; + sha: string; + shortSha: string; + subject: string; + path: string; + originalPath: string | null; + }) => void; + showUndoCommit?: boolean; }; +const HISTORY_HEIGHT_STORAGE_KEY = "terax.sourceControl.history.height"; +const HISTORY_COLLAPSED_STORAGE_KEY = "terax.sourceControl.history.collapsed"; +const HISTORY_DEFAULT_HEIGHT = 220; +const HISTORY_MIN_HEIGHT = 100; +const HISTORY_MAX_HEIGHT = 600; + +function clampHistoryHeight(height: number): number { + return Math.min( + HISTORY_MAX_HEIGHT, + Math.max(HISTORY_MIN_HEIGHT, Math.round(height)), + ); +} + +function readHistoryHeight(): number { + try { + const stored = window.localStorage.getItem(HISTORY_HEIGHT_STORAGE_KEY); + const parsed = stored ? Number.parseInt(stored, 10) : NaN; + return Number.isFinite(parsed) + ? clampHistoryHeight(parsed) + : HISTORY_DEFAULT_HEIGHT; + } catch { + return HISTORY_DEFAULT_HEIGHT; + } +} + +function readHistoryCollapsed(): boolean { + try { + return ( + window.localStorage.getItem(HISTORY_COLLAPSED_STORAGE_KEY) === "1" + ); + } catch { + return false; + } +} + const SOURCE_CONTROL_TOOLTIP_CLASS = "border border-border/70 bg-zinc-950 text-zinc-100 shadow-lg shadow-black/30 dark:border-border/60 dark:bg-zinc-950 dark:text-zinc-100"; @@ -348,6 +400,8 @@ export const SourceControlPanel = memo(function SourceControlPanel({ onOpenDiff, onOpenFile, onNavigateToPath, + onOpenCommitFile, + showUndoCommit = true, }: Props) { const scm = useSourceControlPanel(open, sourceControl, onOpenDiff); const refreshAnimationRef = useRef(null); @@ -355,15 +409,63 @@ export const SourceControlPanel = memo(function SourceControlPanel({ const scrollRef = useRef(null); const containerRef = useRef(null); const [focusedRowKey, setFocusedRowKey] = useState(null); + const historyPanelRef = useRef(null); + const historyHeightRef = useRef(readHistoryHeight()); + const historyWriteTimerRef = useRef(0); + const [historyCollapsed, setHistoryCollapsed] = + useState(readHistoryCollapsed); useEffect(() => { return () => { if (refreshAnimationRef.current) { window.clearTimeout(refreshAnimationRef.current); } + if (historyWriteTimerRef.current) { + window.clearTimeout(historyWriteTimerRef.current); + } }; }, []); + const persistHistoryHeight = useCallback((next: number) => { + historyHeightRef.current = next; + if (historyWriteTimerRef.current) { + window.clearTimeout(historyWriteTimerRef.current); + } + historyWriteTimerRef.current = window.setTimeout(() => { + historyWriteTimerRef.current = 0; + try { + window.localStorage.setItem(HISTORY_HEIGHT_STORAGE_KEY, String(next)); + } catch { + // storage may fail in private mode + } + }, 200); + }, []); + + const persistHistoryCollapsed = useCallback((collapsed: boolean) => { + setHistoryCollapsed((prev) => { + if (prev === collapsed) return prev; + try { + window.localStorage.setItem( + HISTORY_COLLAPSED_STORAGE_KEY, + collapsed ? "1" : "0", + ); + } catch { + // storage may fail in private mode + } + return collapsed; + }); + }, []); + + const toggleHistoryCollapsed = useCallback(() => { + const panel = historyPanelRef.current; + if (!panel) return; + if (panel.isCollapsed()) { + panel.resize(`${clampHistoryHeight(historyHeightRef.current)}px`); + } else { + panel.collapse(); + } + }, []); + const isRefreshing = scm.panelState === "loading"; const repoLabel = useMemo(() => { if (!scm.status) return "Source Control"; @@ -717,28 +819,6 @@ export const SourceControlPanel = memo(function SourceControlPanel({ - {onOpenGitGraph ? ( - - ) : null} - {scm.panelState === "loading" ? ( ) : null} @@ -763,7 +843,15 @@ export const SourceControlPanel = memo(function SourceControlPanel({ ) : null} {scm.panelState === "ready" && scm.status ? ( - <> + +
)} - + + + { + const collapsed = size.inPixels <= HISTORY_HEADER_PX; + if (!collapsed) persistHistoryHeight(size.inPixels); + persistHistoryCollapsed(collapsed); + }} + > + {})} + onOpenGitGraph={onOpenGitGraph} + onDidUndoCommit={() => void scm.refresh()} + showUndoCommit={showUndoCommit} + /> + + ) : null} diff --git a/src/settings/sections/GeneralSection.tsx b/src/settings/sections/GeneralSection.tsx index 321df08e1..828850ee8 100644 --- a/src/settings/sections/GeneralSection.tsx +++ b/src/settings/sections/GeneralSection.tsx @@ -27,6 +27,7 @@ import { setExplorerGitDecorations, setRestoreWindowState, setShowHidden, + setSourceControlUndoCommit, setTerminalCursorBlink, setTerminalFontFamily, setTerminalFontSize, @@ -94,6 +95,9 @@ export function GeneralSection() { const explorerGitDecorations = usePreferencesStore( (s) => s.explorerGitDecorations, ); + const sourceControlUndoCommit = usePreferencesStore( + (s) => s.sourceControlUndoCommit, + ); const terminalWebglEnabled = usePreferencesStore( (s) => s.terminalWebglEnabled, ); @@ -256,6 +260,15 @@ export function GeneralSection() { onCheckedChange={(v) => void setExplorerGitDecorations(v)} /> + + void setSourceControlUndoCommit(v)} + /> +