"use client" import { type ReactElement, type UIEvent, useCallback, useEffect, useMemo, useState, } from "react" import { useTranslations } from "next-intl" import { ChevronDown, ChevronRight, ChevronsDownUp, ChevronsUpDown, CircleHelp, CloudCheck, CloudOff, GitBranch, GitBranchPlus, GitCompare, RefreshCw, } from "lucide-react" import { Commit, CommitActions, CommitContent, CommitCopyButton, CommitFileAdditions, CommitFileChanges, CommitFileDeletions, CommitFileIcon, CommitFileInfo, CommitFilePath, CommitFiles, CommitFileStatus, CommitHash, CommitHeader, CommitInfo, CommitMessage, CommitMetadata, CommitTimestamp, } from "@/components/ai-elements/commit" import { FileTree, FileTreeFile, FileTreeFolder, } from "@/components/ai-elements/file-tree" import { Button } from "@/components/ui/button" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Input } from "@/components/ui/input" import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover" import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible" import { Skeleton } from "@/components/ui/skeleton" import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { getGitBranch, gitCommitBranches, gitListAllBranches, gitLog, gitNewBranch, } from "@/lib/tauri" import type { GitBranchList, GitLogEntry, GitLogFileChange } from "@/lib/types" import { toast } from "sonner" import { toErrorMessage } from "@/lib/app-error" function formatRelativeTime( dateStr: string, t: ( key: | "time.monthsAgo" | "time.daysAgo" | "time.hoursAgo" | "time.minsAgo" | "time.justNow", values?: { count: number } ) => string ): string { const date = new Date(dateStr) if (Number.isNaN(date.getTime())) return dateStr const now = new Date() const diffMs = now.getTime() - date.getTime() const diffMin = Math.floor(diffMs / 60_000) const diffHour = Math.floor(diffMin / 60) const diffDay = Math.floor(diffHour / 24) if (diffDay > 30) { const diffMonth = Math.floor(diffDay / 30) return t("time.monthsAgo", { count: diffMonth }) } if (diffDay > 0) return t("time.daysAgo", { count: diffDay }) if (diffHour > 0) return t("time.hoursAgo", { count: diffHour }) if (diffMin > 0) return t("time.minsAgo", { count: diffMin }) return t("time.justNow", { count: 0 }) } function parseDate(dateStr: string): Date | null { const date = new Date(dateStr) return Number.isNaN(date.getTime()) ? null : date } function filterRecordByCommitHashes( record: Record, hashes: Set ): Record { const next: Record = {} for (const [key, value] of Object.entries(record)) { if (hashes.has(key)) { next[key] = value } } return next } function mapFileStatus( status: string ): "added" | "modified" | "deleted" | "renamed" { switch (status.toUpperCase().charAt(0)) { case "A": return "added" case "D": return "deleted" case "R": return "renamed" default: return "modified" } } function getPushStatusMeta( pushed: boolean | null, labels: { pushed: string notPushed: string unknown: string } ): { label: string icon: typeof CloudCheck className: string } { if (pushed === true) { return { label: labels.pushed, icon: CloudCheck, className: "text-emerald-500", } } if (pushed === false) { return { label: labels.notPushed, icon: CloudOff, className: "text-amber-500", } } return { label: labels.unknown, icon: CircleHelp, className: "text-muted-foreground", } } type CommitFileTreeDirNode = { kind: "dir" name: string path: string children: CommitFileTreeNode[] fileCount: number } type CommitFileTreeFileNode = { kind: "file" name: string path: string change: GitLogFileChange } type CommitFileTreeNode = CommitFileTreeDirNode | CommitFileTreeFileNode interface CommitBranchTarget { fullHash: string shortHash: string } interface MutableCommitFileTreeDirNode { kind: "dir" name: string path: string children: Map } function normalizePathSegments(path: string): string[] { const normalized = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") if (!normalized) return [] return normalized.split("/").filter(Boolean) } function toSortedTreeNodes( dir: MutableCommitFileTreeDirNode ): CommitFileTreeNode[] { return Array.from(dir.children.values()) .map((node) => { if (node.kind === "file") return node return { kind: "dir" as const, fileCount: 0, name: node.name, path: node.path, children: toSortedTreeNodes(node), } }) .sort((a, b) => { if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1 return a.name.localeCompare(b.name, undefined, { sensitivity: "base" }) }) } function compressAndAnnotateDir( node: CommitFileTreeDirNode ): CommitFileTreeDirNode { let compressedChildren: CommitFileTreeNode[] = node.children.map((child) => { if (child.kind === "file") return child return compressAndAnnotateDir(child) }) let fileCount = compressedChildren.reduce((count, child) => { if (child.kind === "file") return count + 1 return count + child.fileCount }, 0) let nextNode: CommitFileTreeDirNode = { ...node, children: compressedChildren, fileCount, } // Merge "dir/dir/dir" chains where each directory only has one directory child. while ( nextNode.children.length === 1 && nextNode.children[0].kind === "dir" ) { const onlyChild = nextNode.children[0] nextNode = { kind: "dir", name: `${nextNode.name}/${onlyChild.name}`, path: onlyChild.path, children: onlyChild.children, fileCount: onlyChild.fileCount, } } compressedChildren = nextNode.children fileCount = compressedChildren.reduce((count, child) => { if (child.kind === "file") return count + 1 return count + child.fileCount }, 0) return { ...nextNode, children: compressedChildren, fileCount, } } function buildCommitFileTree(files: GitLogFileChange[]): CommitFileTreeNode[] { const root: MutableCommitFileTreeDirNode = { kind: "dir", name: "", path: "", children: new Map(), } for (const change of files) { const segments = normalizePathSegments(change.path) if (segments.length === 0) continue let current = root for (const [index, segment] of segments.entries()) { const nodePath = segments.slice(0, index + 1).join("/") const isLeaf = index === segments.length - 1 if (isLeaf) { current.children.set(`file:${nodePath}`, { kind: "file", name: segment, path: nodePath, change, }) continue } const dirKey = `dir:${nodePath}` const existing = current.children.get(dirKey) if (existing && existing.kind === "dir") { current = existing continue } const nextDir: MutableCommitFileTreeDirNode = { kind: "dir", name: segment, path: nodePath, children: new Map(), } current.children.set(dirKey, nextDir) current = nextDir } } const sortedNodes = toSortedTreeNodes(root) return sortedNodes.map((node) => { if (node.kind === "file") return node return compressAndAnnotateDir(node) }) } function collectExpandedDirectoryPaths( nodes: CommitFileTreeNode[], expanded = new Set() ): Set { for (const node of nodes) { if (node.kind !== "dir") continue expanded.add(node.path) collectExpandedDirectoryPaths(node.children, expanded) } return expanded } function CommitFilesTree({ commitHash, files, folderName, onOpenCommitDiff, onOpenFilePreview, }: { commitHash: string files: GitLogFileChange[] folderName: string onOpenCommitDiff: ( commit: string, path?: string, description?: string ) => void onOpenFilePreview: (path: string) => void }) { const t = useTranslations("Folder.gitLogTab") const tCommon = useTranslations("Folder.common") const rootPath = "__commit_file_tree_root__" const treeNodes = useMemo(() => buildCommitFileTree(files), [files]) const allDirectoryPaths = useMemo(() => { const paths = collectExpandedDirectoryPaths(treeNodes) paths.add(rootPath) return paths }, [treeNodes]) const [expandedPaths, setExpandedPaths] = useState>(allDirectoryPaths) useEffect(() => { setExpandedPaths(allDirectoryPaths) }, [allDirectoryPaths]) const canExpandAll = useMemo(() => { if (allDirectoryPaths.size === 0) return false for (const path of allDirectoryPaths) { if (!expandedPaths.has(path)) return true } return false }, [allDirectoryPaths, expandedPaths]) const canCollapseAll = expandedPaths.size > 0 const toggleExpanded = useCallback(() => { if (canExpandAll) { setExpandedPaths(new Set(allDirectoryPaths)) return } setExpandedPaths(new Set()) }, [allDirectoryPaths, canExpandAll]) const renderNode = (node: CommitFileTreeNode): ReactElement => { if (node.kind === "dir") { return ( {node.children.map(renderNode)} ) } const file = node.change return ( { void onOpenCommitDiff(commitHash, file.path) }} path={node.path} title={file.path} > <> {file.status} {node.name} { void onOpenCommitDiff(commitHash, file.path) }} > {tCommon("viewDiff")} { void onOpenFilePreview(file.path) }} > {tCommon("openFile")} ) } return (

{t("filesTitle")}

{treeNodes.map(renderNode)}
) } function BranchSelector({ branchList, currentBranch, selectedBranch, onBranchChange, onRefresh, refreshing, }: { branchList: GitBranchList currentBranch: string | null selectedBranch: string | null onBranchChange: (branch: string) => void onRefresh: () => void refreshing: boolean }) { const t = useTranslations("Folder.gitLogTab.branchSelector") const [popoverOpen, setPopoverOpen] = useState(false) const [localOpen, setLocalOpen] = useState(true) const [remoteOpen, setRemoteOpen] = useState(false) const groupedRemoteBranches = useMemo(() => { const groups: Record = {} for (const b of branchList.remote) { const slashIndex = b.indexOf("/") const remoteName = slashIndex > 0 ? b.substring(0, slashIndex) : "origin" if (!groups[remoteName]) groups[remoteName] = [] groups[remoteName].push(b) } return groups }, [branchList.remote]) const remoteNames = Object.keys(groupedRemoteBranches) const hasMultipleRemotes = remoteNames.length > 1 const handleSelect = (branch: string) => { onBranchChange(branch) setPopoverOpen(false) } function renderBranchItem(branch: string, displayName?: string, indent = 0) { const isCurrent = branch === selectedBranch return ( ) } return (
{branchList.local.length > 0 && ( {t("localBranches")} {branchList.local.map((branch) => renderBranchItem(branch, undefined, 1) )} )} {branchList.remote.length > 0 && ( {t("remoteBranches")} {hasMultipleRemotes ? remoteNames.map((remoteName) => ( {remoteName} ( {groupedRemoteBranches[remoteName].length}) {groupedRemoteBranches[remoteName].map((branch) => renderBranchItem( branch, branch.substring(remoteName.length + 1), 3 ) )} )) : branchList.remote.map((branch) => { const slashIndex = branch.indexOf("/") const shortName = slashIndex > 0 ? branch.substring(slashIndex + 1) : branch return renderBranchItem(branch, shortName, 1) })} )}
) } export function GitLogTab() { const t = useTranslations("Folder.gitLogTab") const tCommon = useTranslations("Folder.common") const { folder } = useFolderContext() const { openCommitDiff, openFilePreview } = useWorkspaceContext() const [entries, setEntries] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [error, setError] = useState(null) const [scrolled, setScrolled] = useState(false) const [openByCommit, setOpenByCommit] = useState>({}) const [branchesByCommit, setBranchesByCommit] = useState< Record >({}) const [branchesLoading, setBranchesLoading] = useState< Record >({}) const [branchesError, setBranchesError] = useState>({}) // Branch filter state const [branchList, setBranchList] = useState({ local: [], remote: [], worktree_branches: [], }) const [currentBranch, setCurrentBranch] = useState(null) const [selectedBranch, setSelectedBranch] = useState(null) const [newBranchTarget, setNewBranchTarget] = useState(null) const [newBranchName, setNewBranchName] = useState("") const [creatingBranch, setCreatingBranch] = useState(false) const hasBranches = branchList.local.length > 0 || branchList.remote.length > 0 const pushStatusLabels = useMemo( () => ({ pushed: t("pushStatus.pushed"), notPushed: t("pushStatus.notPushed"), unknown: t("pushStatus.unknown"), }), [t] ) const folderName = useMemo(() => { const path = folder?.path ?? "" const parts = path.split(/[\\/]/).filter(Boolean) return (parts[parts.length - 1] ?? path) || t("workspace") }, [folder?.path, t]) const handleBranchChange = useCallback((branch: string) => { setSelectedBranch(branch) }, []) const refreshBranches = useCallback( async (nextSelectedBranch?: string | null) => { if (!folder?.path) return try { const [allBranches, current] = await Promise.all([ gitListAllBranches(folder.path), getGitBranch(folder.path), ]) setBranchList(allBranches) setCurrentBranch(current) setSelectedBranch(nextSelectedBranch ?? current) } catch { // Silently ignore — branches dropdown won't appear } }, [folder?.path] ) // Fetch branches on mount useEffect(() => { void refreshBranches() }, [refreshBranches]) const fetchCommitBranches = useCallback( async (fullHash: string) => { if (!folder?.path) return if (branchesByCommit[fullHash] || branchesLoading[fullHash]) return setBranchesLoading((prev) => ({ ...prev, [fullHash]: true })) setBranchesError((prev) => { if (!prev[fullHash]) return prev const next = { ...prev } delete next[fullHash] return next }) try { const branches = await gitCommitBranches(folder.path, fullHash) setBranchesByCommit((prev) => ({ ...prev, [fullHash]: branches })) } catch (e) { setBranchesError((prev) => ({ ...prev, [fullHash]: toErrorMessage(e), })) } finally { setBranchesLoading((prev) => ({ ...prev, [fullHash]: false })) } }, [branchesByCommit, branchesLoading, folder?.path] ) const fetchLog = useCallback( async (options?: { inline?: boolean; branch?: string | null }) => { const inline = options?.inline ?? false const branch = options?.branch ?? selectedBranch if (!folder?.path) return if (inline) { setRefreshing(true) } else { setLoading(true) setOpenByCommit({}) setBranchesByCommit({}) setBranchesLoading({}) setBranchesError({}) } setError(null) try { const log = await gitLog(folder.path, 100, branch ?? undefined) setEntries(log) if (inline) { const commitHashes = new Set(log.map((entry) => entry.full_hash)) setOpenByCommit((prev) => filterRecordByCommitHashes(prev, commitHashes) ) setBranchesByCommit((prev) => filterRecordByCommitHashes(prev, commitHashes) ) setBranchesLoading((prev) => filterRecordByCommitHashes(prev, commitHashes) ) setBranchesError((prev) => filterRecordByCommitHashes(prev, commitHashes) ) } } catch (e) { setError(toErrorMessage(e)) } finally { if (inline) { setRefreshing(false) } else { setLoading(false) } } }, [folder?.path, selectedBranch] ) const handleRefresh = useCallback(() => { void fetchLog({ inline: true }) }, [fetchLog]) const handleOpenNewBranchDialog = useCallback((entry: GitLogEntry) => { setNewBranchName("") setNewBranchTarget({ fullHash: entry.full_hash, shortHash: entry.hash, }) }, []) const handleCreateBranchFromCommit = useCallback(async () => { const name = newBranchName.trim() if (!folder?.path || !newBranchTarget || !name || creatingBranch) return setCreatingBranch(true) try { await gitNewBranch(folder.path, name, newBranchTarget.fullHash) setNewBranchTarget(null) setNewBranchName("") await refreshBranches(name) toast.success(t("toasts.createdAndSwitchedNewBranch"), { description: t("toasts.newBranchFromCommit", { name, shortHash: newBranchTarget.shortHash, }), }) } catch (error) { toast.error(t("toasts.createBranchFailed"), { description: error instanceof Error ? error.message : String(error), }) } finally { setCreatingBranch(false) } }, [ creatingBranch, folder?.path, newBranchName, newBranchTarget, refreshBranches, t, ]) useEffect(() => { void fetchLog() }, [fetchLog]) const handleScroll = useCallback((e: UIEvent) => { const nextScrolled = e.currentTarget.scrollTop > 0 setScrolled((prev) => (prev === nextScrolled ? prev : nextScrolled)) }, []) if (loading) { return (
{hasBranches && ( )}
{Array.from({ length: 5 }).map((_, i) => (
))}
) } if (error) { return (
{hasBranches && ( )}

{error}

) } if (entries.length === 0) { return (
{hasBranches && ( )}

{t("noCommitsFound")}

) } return (
{hasBranches && (
)} {entries.map((entry) => { const commitKey = entry.full_hash const commitDate = parseDate(entry.date) const pushStatus = getPushStatusMeta( entry.pushed, pushStatusLabels ) const PushStatusIcon = pushStatus.icon const commitBranches = branchesByCommit[commitKey] const isBranchLoading = !!branchesLoading[commitKey] const branchError = branchesError[commitKey] const isOpen = !!openByCommit[commitKey] return (
{ setOpenByCommit((prev) => ({ ...prev, [commitKey]: open, })) if (open) { void fetchCommitBranches(commitKey) } }} open={isOpen} > {entry.message} {entry.author} {formatRelativeTime(entry.date, t)} {entry.hash}
{t("hash")} {entry.full_hash} {t("author")} {entry.author} ·

{entry.message}

{entry.files.length === 0 ? (

{t("filesTitle")}

{t("noFileChangeDetails")}

) : ( )}

{t("branchesTitle")}

{isBranchLoading ? (

{t("loadingBranches")}

) : branchError ? (

{branchError}

) : commitBranches && commitBranches.length > 0 ? (
{commitBranches.map((branch) => ( {branch} ))}
) : (

{t("noContainingBranches")}

)}
{ handleOpenNewBranchDialog(entry) }} > {t("newBranch")} { void openCommitDiff( entry.full_hash, undefined, entry.message ) }} > {tCommon("viewDiff")}
) })}
{ void fetchLog() }} > {tCommon("refresh")}
{ if (!open && !creatingBranch) { setNewBranchTarget(null) setNewBranchName("") } }} > {t("dialogs.newBranchTitle")} {t("dialogs.newBranchDescription", { shortHash: newBranchTarget?.shortHash ?? "-", })} setNewBranchName(event.target.value)} onKeyDown={(event) => { if ( event.nativeEvent.isComposing || event.key === "Process" || event.key !== "Enter" ) { return } void handleCreateBranchFromCommit() }} autoFocus />
) }