"use client" import { type ReactElement, useCallback, useEffect, useMemo, useRef, useState, } from "react" import { ChevronsDownUp, ChevronsUpDown, GitBranch } 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 { ScrollArea } from "@/components/ui/scroll-area" import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, } from "@/components/ui/context-menu" import { Skeleton } from "@/components/ui/skeleton" import { useActiveFolder } from "@/contexts/active-folder-context" import { useTabContext } from "@/contexts/tab-context" import { useWorkspaceContext } from "@/contexts/workspace-context" import { useWorkspaceStateStore } from "@/hooks/use-workspace-state-store" import { AuxPanelNoFolderEmpty } from "@/components/layout/aux-panel-no-folder-empty" import { WorkspaceDegradedBanner } from "@/components/layout/workspace-degraded-banner" import { deleteFileTreeEntry, gitAddFiles, gitRollbackFile, gitStatus, openCommitWindow, } from "@/lib/api" import { joinFsPath } from "@/lib/path-utils" import { emitAttachFileToSession } from "@/lib/session-attachment-events" import type { 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" | "delete-tracked" | "delete-untracked" 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().replace(/\/+$/, "") 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 isDeleteAction(action: DirectoryGitAction): boolean { return action === "delete-tracked" || action === "delete-untracked" } function filterDirectoryGitCandidates( entries: DirectoryGitCandidateEntry[], action: DirectoryGitAction ): DirectoryGitCandidateEntry[] { if (action === "add") { return entries.filter((entry) => { const fileState = classifyGitFileState(entry.status) return fileState === "untracked" }) } if (action === "delete-tracked") { return entries.filter((entry) => { const fileState = classifyGitFileState(entry.status) return fileState !== null && fileState !== "untracked" }) } if (action === "delete-untracked") { return entries.filter((entry) => { const fileState = classifyGitFileState(entry.status) return fileState === "untracked" }) } return entries.filter((entry) => { const fileState = classifyGitFileState(entry.status) return fileState !== "untracked" }) } 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") } export function GitChangesTab() { const t = useTranslations("Folder.gitChangesTab") const tCommon = useTranslations("Folder.common") const tFileTree = useTranslations("Folder.fileTreeTab") const { activeFolder: folder } = useActiveFolder() const { tabs, activeTabId } = useTabContext() const { openFilePreview, openWorkingTreeDiff } = useWorkspaceContext() const workspaceState = useWorkspaceStateStore(folder?.path ?? 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 [deleteTarget, setDeleteTarget] = useState(null) const [deleting, setDeleting] = 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 folderName = useMemo(() => { const path = folder?.path ?? "" const parts = path.split(/[\\/]/).filter(Boolean) return (parts[parts.length - 1] ?? path) || t("workspace") }, [folder?.path, t]) const activeSessionTabId = useMemo(() => { const activeTab = tabs.find((tab) => tab.id === activeTabId) if (!activeTab || activeTab.kind !== "conversation") return null return activeTab.id }, [activeTabId, tabs]) const canAttachToSession = Boolean(activeSessionTabId && folder?.path) const changes = useMemo(() => { return [...workspaceState.git] .map((entry) => ({ path: entry.path, status: entry.status, additions: entry.additions, deletions: entry.deletions, })) .sort((left, right) => left.path.localeCompare(right.path, undefined, { sensitivity: "base" }) ) }, [workspaceState.git]) const loading = useMemo( () => workspaceState.health === "resyncing" && workspaceState.seq === 0, [workspaceState.health, workspaceState.seq] ) const error = workspaceState.health === "degraded" ? workspaceState.error : null 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 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 handleAttachToSession = useCallback( (relativePath: string) => { if (!activeSessionTabId || !folder?.path) return emitAttachFileToSession({ tabId: activeSessionTabId, path: joinFsPath(folder.path, relativePath), }) }, [activeSessionTabId, folder?.path] ) 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, true) const scopedEntries = scopeGitStatusEntriesForDirectory( statusEntries, target.path ) const candidates = filterDirectoryGitCandidates(scopedEntries, action) if (candidates.length === 0) { resetDirectoryGitActionDialog() toast.info( action === "add" ? t("toasts.noAddableFilesInDir") : isDeleteAction(action) ? t("toasts.noDeletableFilesInDir") : 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 workspaceState.requestResync("git_action:add") } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.addToVcsFailed"), { description: message }) } }, [folder?.path, openDirectoryGitActionDialog, t, workspaceState] ) 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 workspaceState.requestResync("git_action:rollback") } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.rollbackFailed"), { description: message }) } finally { setRollingBack(false) } }, [folder?.path, rollbackTarget, t, workspaceState]) const handleRequestDelete = useCallback( (target: GitActionTarget, scope: "tracked" | "untracked") => { if (target.kind === "dir") { void openDirectoryGitActionDialog( scope === "tracked" ? "delete-tracked" : "delete-untracked", target ) return } setDeleteTarget(target) }, [openDirectoryGitActionDialog] ) const handleDeleteConfirm = useCallback(async () => { if (!folder?.path || !deleteTarget) return setDeleting(true) try { await deleteFileTreeEntry(folder.path, deleteTarget.path) toast.success(t("toasts.deleted", { name: deleteTarget.name })) setDeleteTarget(null) await workspaceState.requestResync("git_action:delete") } catch (error) { const message = error instanceof Error ? error.message : String(error) toast.error(t("toasts.deleteFailed"), { description: message }) } finally { setDeleting(false) } }, [deleteTarget, folder?.path, t, workspaceState]) 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 if (isDeleteAction(directoryGitActionType)) { for (const filePath of selectedPaths) { await deleteFileTreeEntry(folder.path, filePath) } toast.success( t("toasts.deletedFiles", { count: selectedPaths.length, }) ) } else { for (const filePath of selectedPaths) { await gitRollbackFile(folder.path, filePath) } toast.success( t("toasts.rolledBackFiles", { count: selectedPaths.length, }) ) } resetDirectoryGitActionDialog() await workspaceState.requestResync("git_action:batch") } catch (error) { const message = error instanceof Error ? error.message : String(error) setDirectoryGitError(message) toast.error( directoryGitActionType === "add" ? t("toasts.addToVcsFailed") : isDeleteAction(directoryGitActionType) ? t("toasts.deleteFailed") : t("toasts.rollbackFailed"), { description: message, } ) } finally { setDirectoryGitSubmitting(false) } }, [ directoryGitActionType, directoryGitSelectedPaths, folder?.path, resetDirectoryGitActionDialog, t, workspaceState, ]) 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")} { handleAttachToSession(node.path) }} disabled={!canAttachToSession} > {tFileTree("attachToCurrentSession")} {t("actions.addToVcs")} { handleRequestRollback(target) }} variant="destructive" > {t("actions.rollback")} { handleRequestDelete(target, "tracked") }} variant="destructive" > {t("actions.delete")} ) } 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")} { handleAttachToSession(file.path) }} disabled={!canAttachToSession} > {tFileTree("attachToCurrentSession")} {t("actions.addToVcs")} { handleRequestRollback(target) }} variant="destructive" > {t("actions.rollback")} { handleRequestDelete(target, "tracked") }} variant="destructive" > {t("actions.delete")} ) }, [ canAttachToSession, handleAttachToSession, handleOpenCommitWindow, handleRequestDelete, handleRequestRollback, openFilePreview, openWorkingTreeDiff, t, tCommon, tFileTree, ] ) 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")} { handleAttachToSession(node.path) }} disabled={!canAttachToSession} > {tFileTree("attachToCurrentSession")} { void handleAddToVcs(target) }} > {t("actions.addToVcs")} { handleRequestRollback(target) }} variant="destructive" > {t("actions.rollback")} { handleRequestDelete(target, "untracked") }} variant="destructive" > {t("actions.delete")} ) } 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")} { handleAttachToSession(file.path) }} disabled={!canAttachToSession} > {tFileTree("attachToCurrentSession")} { void handleAddToVcs(target) }} > {t("actions.addToVcs")} { handleRequestRollback(target) }} variant="destructive" > {t("actions.rollback")} { handleRequestDelete(target, "untracked") }} variant="destructive" > {t("actions.delete")} ) }, [ canAttachToSession, handleAttachToSession, handleOpenCommitWindow, handleAddToVcs, handleRequestDelete, handleRequestRollback, openFilePreview, openWorkingTreeDiff, t, tCommon, tFileTree, ] ) if (!folder) { return } if (loading) { return (
) } if (error) { return (

{error}

) } return (
{workspaceState.degraded && ( )} {trackedChanges.length === 0 && untrackedChanges.length === 0 ? ( !workspaceState.isGitRepo ? (

{t("notAGitRepoTitle")}

{t("notAGitRepoHint")}

) : (

{t("noChanges")}

) ) : (
{trackedChanges.length > 0 && (
{t("trackedChanges", { count: trackedChanges.length })}
{trackedTreeNodes.map(renderTrackedNode)} { handleOpenCommitWindow() }} > {t("actions.commitCode")} { void openWorkingTreeDiff(".", { mode: "overview", }) }} > {tCommon("viewDiff")} { handleAttachToSession("") }} disabled={!canAttachToSession} > {tFileTree("attachToCurrentSession")} {t("actions.addToVcs")} { handleRequestRollback({ kind: "dir", path: "", name: folderName, }) }} variant="destructive" > {t("actions.rollback")} { handleRequestDelete( { kind: "dir", path: "", name: folderName, }, "tracked" ) }} variant="destructive" > {t("actions.delete")}
)} {untrackedChanges.length > 0 && (
{t("untrackedFiles", { count: untrackedChanges.length })}
{untrackedTreeNodes.map(renderUntrackedNode)} { handleOpenCommitWindow() }} > {t("actions.commitCode")} { void openWorkingTreeDiff(".", { mode: "overview", }) }} > {tCommon("viewDiff")} { handleAttachToSession("") }} disabled={!canAttachToSession} > {tFileTree("attachToCurrentSession")} { void handleAddToVcs({ kind: "dir", path: "", name: folderName, }) }} > {t("actions.addToVcs")} { handleRequestRollback({ kind: "dir", path: "", name: folderName, }) }} variant="destructive" > {t("actions.rollback")} { handleRequestDelete( { kind: "dir", path: "", name: folderName, }, "untracked" ) }} variant="destructive" > {t("actions.delete")}
)}
)}
{ if (open) return resetDirectoryGitActionDialog() }} > {directoryGitActionType === "add" ? t("actions.addToVcs") : directoryGitActionType && isDeleteAction(directoryGitActionType) ? t("actions.delete") : t("actions.rollback")} {directoryGitActionTarget ? directoryGitActionType === "add" ? t("directoryDialog.descriptionAdd", { path: directoryGitActionTarget.path, }) : directoryGitActionType && isDeleteAction(directoryGitActionType) ? t("directoryDialog.descriptionDelete", { 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")} { if (open) return setDeleteTarget(null) }} > {t("deleteConfirm.title")} {deleteTarget ? t("deleteConfirm.descriptionWithTarget", { kind: deleteTarget.kind === "dir" ? t("deleteConfirm.kindDirectory") : t("deleteConfirm.kindFile"), name: deleteTarget.name, }) : t("deleteConfirm.descriptionFallback")} {tCommon("cancel")} { void handleDeleteConfirm() }} > {t("actions.delete")}
) }