"use client" import { type ReactElement, useCallback, useEffect, useMemo, useRef, useState, } from "react" import { listen, type UnlistenFn } from "@tauri-apps/api/event" import { ChevronsDownUp, ChevronsUpDown } from "lucide-react" import { useTranslations } from "next-intl" import { toast } from "sonner" import { CommitFileAdditions, CommitFileChanges, CommitFileDeletions, CommitFileIcon, CommitFileInfo, CommitFilePath, CommitFileStatus, } 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 { Skeleton } from "@/components/ui/skeleton" import { useAuxPanelContext } from "@/contexts/aux-panel-context" import { useFolderContext } from "@/contexts/folder-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { gitDiff, gitAddFiles, gitRollbackFile, gitStatus, openCommitWindow, startFileTreeWatch, stopFileTreeWatch, } from "@/lib/tauri" import { disposeTauriListener } from "@/lib/tauri-listener" import type { FileTreeChangedEvent, GitStatusEntry } from "@/lib/types" import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" interface WorkingTreeChange { path: string status: string additions: number deletions: number } interface GitActionTarget { kind: "file" | "dir" path: string name: string } type DirectoryGitAction = "add" | "rollback" interface DirectoryGitCandidateEntry { path: string status: string } type ChangeTreeDirNode = { kind: "dir" name: string path: string children: ChangeTreeNode[] fileCount: number } type ChangeTreeFileNode = { kind: "file" name: string path: string change: WorkingTreeChange } type ChangeTreeNode = ChangeTreeDirNode | ChangeTreeFileNode interface MutableChangeTreeDirNode { kind: "dir" name: string path: string children: Map } const TRACKED_ROOT_PATH = "__working_tree_tracked_root__" const UNTRACKED_ROOT_PATH = "__working_tree_untracked_root__" const UNTRACKED_STATUS = "??" type GitFileState = | "untracked" | "modified" | "staged" | "conflicted" | "deleted" | "renamed" function classifyGitFileState(status: string): GitFileState | null { const code = status.trim().toUpperCase() if (!code) return null if (code === UNTRACKED_STATUS) return "untracked" if (code.includes("U")) return "conflicted" if (code.includes("R") || code.includes("C")) return "renamed" if (code.includes("D")) return "deleted" if (code.includes("M") || code.includes("T")) return "modified" if (code.includes("A")) return "staged" return null } function normalizePathSegments(path: string): string[] { const normalized = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "") if (!normalized) return [] return normalized.split("/").filter(Boolean) } function normalizeGitStatusPath(path: string): string { const normalized = path.trim() const renameSeparator = " -> " const renameIndex = normalized.lastIndexOf(renameSeparator) if (renameIndex < 0) return normalized return normalized.slice(renameIndex + renameSeparator.length).trim() } function normalizeComparePath(path: string): string { return path.replace(/\\/g, "/").replace(/\/+$/, "") } function isPathInDirectory(path: string, directoryPath: string): boolean { const normalizedPath = normalizeComparePath(path) const normalizedDir = normalizeComparePath(directoryPath) if (!normalizedDir) return normalizedPath.length > 0 return ( normalizedPath === normalizedDir || normalizedPath.startsWith(`${normalizedDir}/`) ) } function scopeGitStatusEntriesForDirectory( entries: GitStatusEntry[], directoryPath: string ): DirectoryGitCandidateEntry[] { const normalizedDirPath = normalizeComparePath(directoryPath) const scopedEntries: DirectoryGitCandidateEntry[] = [] const dedupByPath = new Set() for (const entry of entries) { const normalizedPath = normalizeComparePath( normalizeGitStatusPath(entry.file) ) if (!normalizedPath) continue if (!isPathInDirectory(normalizedPath, normalizedDirPath)) continue if (normalizedPath === normalizedDirPath) continue if (dedupByPath.has(normalizedPath)) continue dedupByPath.add(normalizedPath) scopedEntries.push({ path: normalizedPath, status: entry.status }) } return scopedEntries.sort((left, right) => left.path.localeCompare(right.path, undefined, { sensitivity: "base" }) ) } function filterDirectoryGitCandidates( entries: DirectoryGitCandidateEntry[], action: DirectoryGitAction ): DirectoryGitCandidateEntry[] { if (action === "add") { return entries.filter((entry) => entry.status.trim().length > 0) } return entries.filter((entry) => { const fileState = classifyGitFileState(entry.status) return fileState !== "untracked" }) } function normalizeDiffPath(rawPath: string): string | null { const trimmed = rawPath.trim().replace(/^"|"$/g, "") if (!trimmed || trimmed === "/dev/null") return null if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) { return trimmed.slice(2).replace(/\\/g, "/") } return trimmed.replace(/\\/g, "/") } function parsePathFromDiffGitLine(line: string): string | null { if (!line.startsWith("diff --git ")) return null const match = line.match(/^diff --git\s+(.+?)\s+(.+)$/) if (!match) return null return normalizeDiffPath(match[2]) ?? normalizeDiffPath(match[1]) } function parseDiffStatsMap( diffText: string ): Map { const stats = new Map() let currentPath: string | null = null for (const line of diffText.split("\n")) { const nextPath = parsePathFromDiffGitLine(line) if (nextPath) { currentPath = nextPath if (!stats.has(currentPath)) { stats.set(currentPath, { additions: 0, deletions: 0 }) } continue } if (!currentPath) continue const current = stats.get(currentPath) if (!current) continue if (line.startsWith("+") && !line.startsWith("+++")) { current.additions += 1 } else if (line.startsWith("-") && !line.startsWith("---")) { current.deletions += 1 } } return stats } function toSortedTreeNodes(dir: MutableChangeTreeDirNode): ChangeTreeNode[] { return Array.from(dir.children.values()) .map((node) => { if (node.kind === "file") return node return { kind: "dir", fileCount: 0, name: node.name, path: node.path, children: toSortedTreeNodes(node), } }) .sort((left, right) => { if (left.kind !== right.kind) return left.kind === "dir" ? -1 : 1 return left.name.localeCompare(right.name, undefined, { sensitivity: "base", }) }) } function compressAndAnnotateDir(node: ChangeTreeDirNode): ChangeTreeDirNode { let compressedChildren = 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: ChangeTreeDirNode = { ...node, children: compressedChildren, fileCount, } 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 buildChangeFileTree(changes: WorkingTreeChange[]): ChangeTreeNode[] { const root: MutableChangeTreeDirNode = { kind: "dir", name: "", path: "", children: new Map(), } for (const change of changes) { 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: MutableChangeTreeDirNode = { 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: ChangeTreeNode[], 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 isUntrackedStatus(status: string): boolean { return status.trim().toUpperCase() === UNTRACKED_STATUS } function mapStatus( status: string ): "added" | "modified" | "deleted" | "renamed" { const normalized = status.trim().toUpperCase() if (normalized.includes("A")) return "added" if (normalized.includes("R") || normalized.includes("C")) return "renamed" if (normalized.includes("D")) return "deleted" return "modified" } function canOpenFile(status: string): boolean { return !status.trim().toUpperCase().includes("D") } function shouldRefreshFromEvent(event: FileTreeChangedEvent): boolean { const shouldRefreshGitStatus = event.refresh_git_status ?? true if (!shouldRefreshGitStatus) return false if (event.kind === "access") return false return true } function toWorkingTreeChanges( entries: GitStatusEntry[], diffText: string ): WorkingTreeChange[] { const stats = parseDiffStatsMap(diffText) return entries .map((entry) => { const path = normalizeGitStatusPath(entry.file) if (!path) return null const diffStat = stats.get(path) return { path, status: entry.status.trim() || "M", additions: diffStat?.additions ?? 0, deletions: diffStat?.deletions ?? 0, } }) .filter((change): change is WorkingTreeChange => change !== null) .sort((left, right) => left.path.localeCompare(right.path, undefined, { sensitivity: "base" }) ) } export function GitChangesTab() { const t = useTranslations("Folder.gitChangesTab") const tCommon = useTranslations("Folder.common") const { folder } = useFolderContext() const { activeTab } = useAuxPanelContext() const { openFilePreview, openWorkingTreeDiff } = useWorkspaceContext() const [changes, setChanges] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [expandedTrackedPaths, setExpandedTrackedPaths] = useState>( new Set() ) const [expandedUntrackedPaths, setExpandedUntrackedPaths] = useState< Set >(new Set()) const [rollbackTarget, setRollbackTarget] = useState( null ) const [rollingBack, setRollingBack] = useState(false) const [directoryGitActionType, setDirectoryGitActionType] = useState(null) const [directoryGitActionTarget, setDirectoryGitActionTarget] = useState(null) const [directoryGitCandidates, setDirectoryGitCandidates] = useState< DirectoryGitCandidateEntry[] >([]) const [directoryGitSelectedPaths, setDirectoryGitSelectedPaths] = useState< Set >(new Set()) const [directoryGitLoading, setDirectoryGitLoading] = useState(false) const [directoryGitSubmitting, setDirectoryGitSubmitting] = useState(false) const [directoryGitError, setDirectoryGitError] = useState( null ) const hasHydratedTrackedPaths = useRef(false) const hasHydratedUntrackedPaths = useRef(false) const refreshTimerRef = useRef | null>(null) const isChangesTabActive = activeTab === "changes" 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 trackedChanges = useMemo( () => changes.filter((change) => !isUntrackedStatus(change.status)), [changes] ) const untrackedChanges = useMemo( () => changes.filter((change) => isUntrackedStatus(change.status)), [changes] ) const trackedTreeNodes = useMemo( () => buildChangeFileTree(trackedChanges), [trackedChanges] ) const untrackedTreeNodes = useMemo( () => buildChangeFileTree(untrackedChanges), [untrackedChanges] ) const allTrackedDirectoryPaths = useMemo(() => { const paths = collectExpandedDirectoryPaths(trackedTreeNodes) paths.add(TRACKED_ROOT_PATH) return paths }, [trackedTreeNodes]) const allUntrackedDirectoryPaths = useMemo(() => { const paths = collectExpandedDirectoryPaths(untrackedTreeNodes) paths.add(UNTRACKED_ROOT_PATH) return paths }, [untrackedTreeNodes]) useEffect(() => { hasHydratedTrackedPaths.current = false hasHydratedUntrackedPaths.current = false setExpandedTrackedPaths(new Set()) setExpandedUntrackedPaths(new Set()) }, [folder?.path]) useEffect(() => { setExpandedTrackedPaths((prev) => { if (!hasHydratedTrackedPaths.current) { if (trackedChanges.length === 0) return prev hasHydratedTrackedPaths.current = true return new Set(allTrackedDirectoryPaths) } const next = new Set() for (const path of prev) { if (allTrackedDirectoryPaths.has(path)) next.add(path) } return next }) }, [allTrackedDirectoryPaths, trackedChanges.length]) useEffect(() => { setExpandedUntrackedPaths((prev) => { if (!hasHydratedUntrackedPaths.current) { if (untrackedChanges.length === 0) return prev hasHydratedUntrackedPaths.current = true return new Set() } const next = new Set() for (const path of prev) { if (allUntrackedDirectoryPaths.has(path)) next.add(path) } return next }) }, [allUntrackedDirectoryPaths, untrackedChanges.length]) const fetchChanges = useCallback( async (options?: { inline?: boolean }) => { if (!folder?.path) { setLoading(false) setError(null) setChanges([]) return } const inline = options?.inline ?? false if (!inline) { setLoading(true) } setError(null) try { const statusEntries = await gitStatus(folder.path) const hasTrackedEntries = statusEntries.some( (entry) => !isUntrackedStatus(entry.status) ) const diffText = hasTrackedEntries ? await gitDiff(folder.path).catch(() => "") : "" setChanges(toWorkingTreeChanges(statusEntries, diffText)) } catch (err) { setError(err instanceof Error ? err.message : String(err)) } finally { if (!inline) { setLoading(false) } } }, [folder?.path] ) useEffect(() => { if (!isChangesTabActive) return void fetchChanges() }, [fetchChanges, isChangesTabActive]) useEffect(() => { const rootPath = folder?.path if (!rootPath || !isChangesTabActive) return let unlisten: UnlistenFn | null = null const normalizedRootPath = normalizeComparePath(rootPath) const scheduleRefresh = () => { if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current) } refreshTimerRef.current = setTimeout(() => { void fetchChanges({ inline: true }) }, 220) } const setup = async () => { try { await startFileTreeWatch(rootPath) } catch { // ignore watch startup errors } try { unlisten = await listen( "folder://file-tree-changed", (event) => { if ( normalizeComparePath(event.payload.root_path) !== normalizedRootPath ) { return } if (!shouldRefreshFromEvent(event.payload)) return scheduleRefresh() } ) } catch { // ignore listen errors } } void setup() return () => { if (refreshTimerRef.current) { clearTimeout(refreshTimerRef.current) refreshTimerRef.current = null } disposeTauriListener(unlisten, "AuxPanelGitChanges.fileTreeChanged") void stopFileTreeWatch(rootPath) } }, [fetchChanges, folder?.path, isChangesTabActive]) const trackedCanExpand = useMemo(() => { if (trackedTreeNodes.length === 0) return false for (const path of allTrackedDirectoryPaths) { if (!expandedTrackedPaths.has(path)) return true } return false }, [allTrackedDirectoryPaths, expandedTrackedPaths, trackedTreeNodes.length]) const trackedCanCollapse = useMemo( () => trackedTreeNodes.length > 0 && expandedTrackedPaths.size > 0, [expandedTrackedPaths.size, trackedTreeNodes.length] ) const untrackedCanExpand = useMemo(() => { if (untrackedTreeNodes.length === 0) return false for (const path of allUntrackedDirectoryPaths) { if (!expandedUntrackedPaths.has(path)) return true } return false }, [ allUntrackedDirectoryPaths, expandedUntrackedPaths, untrackedTreeNodes.length, ]) const untrackedCanCollapse = useMemo( () => untrackedTreeNodes.length > 0 && expandedUntrackedPaths.size > 0, [expandedUntrackedPaths.size, untrackedTreeNodes.length] ) const toggleTrackedExpanded = useCallback(() => { if (trackedCanExpand) { setExpandedTrackedPaths(new Set(allTrackedDirectoryPaths)) return } setExpandedTrackedPaths(new Set()) }, [allTrackedDirectoryPaths, trackedCanExpand]) const toggleUntrackedExpanded = useCallback(() => { if (untrackedCanExpand) { setExpandedUntrackedPaths(new Set(allUntrackedDirectoryPaths)) return } setExpandedUntrackedPaths(new Set()) }, [allUntrackedDirectoryPaths, untrackedCanExpand]) const handleOpenCommitWindow = useCallback(() => { if (!folder) return openCommitWindow(folder.id).catch((error) => { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.openCommitWindowFailed"), { description: message, }) }) }, [folder, t]) const resetDirectoryGitActionDialog = useCallback(() => { setDirectoryGitActionType(null) setDirectoryGitActionTarget(null) setDirectoryGitCandidates([]) setDirectoryGitSelectedPaths(new Set()) setDirectoryGitError(null) setDirectoryGitLoading(false) setDirectoryGitSubmitting(false) }, []) const openDirectoryGitActionDialog = useCallback( async (action: DirectoryGitAction, target: GitActionTarget) => { if (!folder?.path) return setDirectoryGitActionType(action) setDirectoryGitActionTarget(target) setDirectoryGitCandidates([]) setDirectoryGitSelectedPaths(new Set()) setDirectoryGitError(null) setDirectoryGitLoading(true) try { const statusEntries = await gitStatus(folder.path) const scopedEntries = scopeGitStatusEntriesForDirectory( statusEntries, target.path ) const candidates = filterDirectoryGitCandidates(scopedEntries, action) if (candidates.length === 0) { resetDirectoryGitActionDialog() toast.info( action === "add" ? t("toasts.noAddableFilesInDir") : t("toasts.noRollbackFilesInDir") ) return } setDirectoryGitCandidates(candidates) setDirectoryGitSelectedPaths( new Set(candidates.map((entry) => entry.path)) ) } catch (error) { const message = error instanceof Error ? error.message : String(error) setDirectoryGitError(message) } finally { setDirectoryGitLoading(false) } }, [folder?.path, resetDirectoryGitActionDialog, t] ) const handleRequestRollback = useCallback( (target: GitActionTarget) => { if (target.kind === "dir") { void openDirectoryGitActionDialog("rollback", target) return } setRollbackTarget(target) }, [openDirectoryGitActionDialog] ) const handleAddToVcs = useCallback( async (target: GitActionTarget) => { if (target.kind === "dir") { await openDirectoryGitActionDialog("add", target) return } if (!folder?.path) return try { await gitAddFiles(folder.path, [target.path]) toast.success(t("toasts.addedToVcs", { name: target.name })) await fetchChanges({ inline: true }) } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.addToVcsFailed"), { description: message }) } }, [fetchChanges, folder?.path, openDirectoryGitActionDialog, t] ) const handleRollbackConfirm = useCallback(async () => { if (!folder?.path || !rollbackTarget) return setRollingBack(true) try { await gitRollbackFile(folder.path, rollbackTarget.path) toast.success(t("toasts.rolledBack", { name: rollbackTarget.name })) setRollbackTarget(null) await fetchChanges({ inline: true }) } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.rollbackFailed"), { description: message }) } finally { setRollingBack(false) } }, [fetchChanges, folder?.path, rollbackTarget, t]) const directoryGitAllFilePaths = useMemo( () => directoryGitCandidates.map((entry) => entry.path), [directoryGitCandidates] ) const directoryGitAllSelected = useMemo( () => directoryGitAllFilePaths.length > 0 && directoryGitAllFilePaths.every((path) => directoryGitSelectedPaths.has(path) ), [directoryGitAllFilePaths, directoryGitSelectedPaths] ) const handleToggleDirectoryGitSelectAll = useCallback(() => { setDirectoryGitSelectedPaths((prev) => { const next = new Set(prev) const allSelected = directoryGitAllFilePaths.length > 0 && directoryGitAllFilePaths.every((path) => next.has(path)) if (allSelected) { return new Set() } return new Set(directoryGitAllFilePaths) }) }, [directoryGitAllFilePaths]) const handleToggleDirectoryGitFile = useCallback((path: string) => { setDirectoryGitSelectedPaths((prev) => { const next = new Set(prev) if (next.has(path)) { next.delete(path) } else { next.add(path) } return next }) }, []) const handleDirectoryGitActionConfirm = useCallback(async () => { if (!folder?.path || !directoryGitActionType) return if (directoryGitSelectedPaths.size === 0) return const selectedPaths = Array.from(directoryGitSelectedPaths) setDirectoryGitSubmitting(true) setDirectoryGitError(null) try { if (directoryGitActionType === "add") { await gitAddFiles(folder.path, selectedPaths) toast.success( t("toasts.addedFilesToVcs", { count: selectedPaths.length, }) ) } else { for (const filePath of selectedPaths) { await gitRollbackFile(folder.path, filePath) } toast.success( t("toasts.rolledBackFiles", { count: selectedPaths.length, }) ) } resetDirectoryGitActionDialog() await fetchChanges({ inline: true }) } catch (error) { const message = error instanceof Error ? error.message : String(error) setDirectoryGitError(message) toast.error( directoryGitActionType === "add" ? t("toasts.addToVcsFailed") : t("toasts.rollbackFailed"), { description: message, } ) } finally { setDirectoryGitSubmitting(false) } }, [ directoryGitActionType, directoryGitSelectedPaths, fetchChanges, folder?.path, resetDirectoryGitActionDialog, t, ]) useEffect(() => { setRollbackTarget(null) resetDirectoryGitActionDialog() }, [folder?.path, resetDirectoryGitActionDialog]) const renderTrackedNode = useCallback( function renderNode(node: ChangeTreeNode): ReactElement { if (node.kind === "dir") { const target: GitActionTarget = { kind: "dir", path: node.path, name: node.name, } return ( {node.children.map(renderNode)} { handleOpenCommitWindow() }} > {t("actions.commitCode")} { void openWorkingTreeDiff(node.path, { mode: "overview" }) }} > {tCommon("viewDiff")} { handleRequestRollback(target) }} variant="destructive" > {t("actions.rollback")} { void handleAddToVcs(target) }} > {t("actions.addToVcs")} ) } const file = node.change const canOpenCurrentFile = canOpenFile(file.status) const target: GitActionTarget = { kind: "file", path: file.path, name: node.name, } return ( { void openWorkingTreeDiff(file.path) }} path={node.path} title={file.path} > <> {file.status} {node.name} { handleOpenCommitWindow() }} > {t("actions.commitCode")} { if (!canOpenCurrentFile) return void openFilePreview(file.path) }} > {tCommon("openFile")} { void openWorkingTreeDiff(file.path) }} > {tCommon("viewDiff")} { handleRequestRollback(target) }} variant="destructive" > {t("actions.rollback")} { void handleAddToVcs(target) }} > {t("actions.addToVcs")} ) }, [ handleOpenCommitWindow, handleAddToVcs, handleRequestRollback, openFilePreview, openWorkingTreeDiff, t, tCommon, ] ) const renderUntrackedNode = useCallback( function renderNode(node: ChangeTreeNode): ReactElement { if (node.kind === "dir") { const target: GitActionTarget = { kind: "dir", path: node.path, name: node.name, } return ( {node.children.map(renderNode)} { handleOpenCommitWindow() }} > {t("actions.commitCode")} { void openWorkingTreeDiff(node.path, { mode: "overview" }) }} > {tCommon("viewDiff")} { handleRequestRollback(target) }} variant="destructive" > {t("actions.rollback")} { void handleAddToVcs(target) }} > {t("actions.addToVcs")} ) } const file = node.change const target: GitActionTarget = { kind: "file", path: file.path, name: node.name, } return ( { void openWorkingTreeDiff(file.path) }} path={node.path} title={file.path} > <> {node.name} { handleOpenCommitWindow() }} > {t("actions.commitCode")} { void openFilePreview(file.path) }} > {tCommon("openFile")} { void openWorkingTreeDiff(file.path) }} > {tCommon("viewDiff")} { handleRequestRollback(target) }} variant="destructive" > {t("actions.rollback")} { void handleAddToVcs(target) }} > {t("actions.addToVcs")} ) }, [ handleOpenCommitWindow, handleAddToVcs, handleRequestRollback, openFilePreview, openWorkingTreeDiff, t, tCommon, ] ) if (loading) { return (
) } if (error) { return (

{error}

) } return ( <>
{trackedChanges.length === 0 && untrackedChanges.length === 0 ? (

{t("noChanges")}

) : (
{trackedChanges.length > 0 && (
{t("trackedChanges", { count: trackedChanges.length })}
{trackedTreeNodes.map(renderTrackedNode)} { handleOpenCommitWindow() }} > {t("actions.commitCode")} { void openWorkingTreeDiff(".", { mode: "overview", }) }} > {tCommon("viewDiff")} { handleRequestRollback({ kind: "dir", path: "", name: folderName, }) }} variant="destructive" > {t("actions.rollback")} { void handleAddToVcs({ kind: "dir", path: "", name: folderName, }) }} > {t("actions.addToVcs")}
)} {untrackedChanges.length > 0 && (
{t("untrackedFiles", { count: untrackedChanges.length })}
{untrackedTreeNodes.map(renderUntrackedNode)} { handleOpenCommitWindow() }} > {t("actions.commitCode")} { void openWorkingTreeDiff(".", { mode: "overview", }) }} > {tCommon("viewDiff")} { handleRequestRollback({ kind: "dir", path: "", name: folderName, }) }} variant="destructive" > {t("actions.rollback")} { void handleAddToVcs({ kind: "dir", path: "", name: folderName, }) }} > {t("actions.addToVcs")}
)}
)}
{ if (open) return resetDirectoryGitActionDialog() }} > {directoryGitActionType === "add" ? t("actions.addToVcs") : t("actions.rollback")} {directoryGitActionTarget ? directoryGitActionType === "add" ? t("directoryDialog.descriptionAdd", { path: directoryGitActionTarget.path, }) : t("directoryDialog.descriptionRollback", { path: directoryGitActionTarget.path, }) : t("directoryDialog.descriptionFallback")}
{t("directoryDialog.selectionCount", { selected: directoryGitSelectedPaths.size, total: directoryGitAllFilePaths.length, })}
{directoryGitLoading ? (
{t("directoryDialog.loadingCandidates")}
) : directoryGitError ? (
{directoryGitError}
) : directoryGitCandidates.length > 0 ? (
{directoryGitCandidates.map((entry) => { const selected = directoryGitSelectedPaths.has(entry.path) return ( ) })}
) : (
{t("directoryDialog.noOperableFiles")}
)}
{ if (open) return setRollbackTarget(null) }} > {t("rollbackConfirm.title")} {rollbackTarget ? t("rollbackConfirm.descriptionWithTarget", { kind: rollbackTarget.kind === "dir" ? t("rollbackConfirm.kindDirectory") : t("rollbackConfirm.kindFile"), name: rollbackTarget.name, }) : t("rollbackConfirm.descriptionFallback")} {tCommon("cancel")} { void handleRollbackConfirm() }} > {t("actions.rollback")} ) }