Files
codeg/src/components/layout/commit-dialog.tsx
2026-03-15 22:45:15 +08:00

1254 lines
40 KiB
TypeScript

"use client"
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { Check, ChevronDown, ChevronRight, Loader2 } from "lucide-react"
import { useTranslations } from "next-intl"
import { Button } from "@/components/ui/button"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Textarea } from "@/components/ui/textarea"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
FileTree,
FileTreeFile,
FileTreeFolder,
} from "@/components/ai-elements/file-tree"
import {
gitAddFiles,
gitCommit,
gitRollbackFile,
gitShowFile,
gitStatus,
deleteFileTreeEntry,
readFilePreview,
} from "@/lib/tauri"
import type { GitStatusEntry } from "@/lib/types"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
import { DiffViewer } from "@/components/diff/diff-viewer"
import { languageFromPath } from "@/lib/language-detect"
interface CommitWorkspaceProps {
folderPath: string
onCommitted?: () => void
onCancel?: () => void
}
interface TreeFileNode {
kind: "file"
name: string
path: string
entry: GitStatusEntry
}
interface TreeDirNode {
kind: "dir"
name: string
path: string
children: TreeNode[]
}
type TreeNode = TreeFileNode | TreeDirNode
const UNTRACKED_STATUS = "??"
const DEFAULT_LEFT_PANE_WIDTH = 420
const MIN_LEFT_PANE_WIDTH = 320
const MIN_RIGHT_PANE_WIDTH = 360
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value))
}
function toPercent(pixels: number, totalPixels: number): number {
if (totalPixels <= 0) return 0
return (pixels / totalPixels) * 100
}
function buildFileTree(entries: GitStatusEntry[]): TreeNode[] {
type BuildDir = {
name: string
path: string
dirs: Map<string, BuildDir>
files: TreeFileNode[]
}
const root: BuildDir = {
name: "",
path: "",
dirs: new Map(),
files: [],
}
for (const entry of entries) {
const parts = entry.file.split("/").filter(Boolean)
if (parts.length === 0) continue
let current = root
let currentPath = ""
for (let i = 0; i < parts.length; i += 1) {
const part = parts[i]
const isLeaf = i === parts.length - 1
currentPath = currentPath ? `${currentPath}/${part}` : part
if (isLeaf) {
current.files.push({
kind: "file",
name: part,
path: currentPath,
entry,
})
} else {
const found = current.dirs.get(part)
if (found) {
current = found
} else {
const next: BuildDir = {
name: part,
path: currentPath,
dirs: new Map(),
files: [],
}
current.dirs.set(part, next)
current = next
}
}
}
}
function sortNodes(nodes: TreeNode[]) {
return nodes.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
return a.name.localeCompare(b.name)
})
}
function toNodes(dir: BuildDir): TreeNode[] {
const dirs: TreeNode[] = Array.from(dir.dirs.values()).map((child) => ({
kind: "dir",
name: child.name,
path: child.path,
children: toNodes(child),
}))
return sortNodes([...dirs, ...dir.files])
}
return toNodes(root)
}
/** Collect all file paths under a tree node (recursive). */
function collectFilePaths(node: TreeNode): string[] {
if (node.kind === "file") return [node.path]
return node.children.flatMap(collectFilePaths)
}
/** Depth-first traversal to find the first file node (matches visual order). */
function findFirstFile(nodes: TreeNode[]): string | undefined {
for (const node of nodes) {
if (node.kind === "file") return node.path
const found = findFirstFile(node.children)
if (found) return found
}
return undefined
}
function collectDirPaths(entries: GitStatusEntry[]) {
const paths = new Set<string>()
for (const entry of entries) {
const parts = entry.file.split("/").filter(Boolean)
if (parts.length < 2) continue
let currentPath = ""
for (let i = 0; i < parts.length - 1; i += 1) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
paths.add(currentPath)
}
}
return paths
}
interface ConfirmState {
open: boolean
title: string
description: string
action: (() => void) | null
variant: "default" | "destructive"
}
const CONFIRM_INITIAL: ConfirmState = {
open: false,
title: "",
description: "",
action: null,
variant: "default",
}
export function CommitWorkspace({
folderPath,
onCommitted,
onCancel,
}: CommitWorkspaceProps) {
const t = useTranslations("Folder.commitDialog")
const tCommon = useTranslations("Folder.common")
const [entries, setEntries] = useState<GitStatusEntry[]>([])
const containerRef = useRef<HTMLDivElement>(null)
const [containerWidth, setContainerWidth] = useState(0)
const [selected, setSelected] = useState<Set<string>>(new Set())
const [diffOriginal, setDiffOriginal] = useState("")
const [diffModified, setDiffModified] = useState("")
const [diffLanguage, setDiffLanguage] = useState("plaintext")
const [diffFile, setDiffFile] = useState<string | null>(null)
const messageRef = useRef("")
const [hasMessage, setHasMessage] = useState(false)
const [messageInputKey, setMessageInputKey] = useState(0)
const [loadingStatus, setLoadingStatus] = useState(false)
const [loadingDiff, setLoadingDiff] = useState(false)
const [committing, setCommitting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [untrackedOpen, setUntrackedOpen] = useState(false)
const [expandedTrackedDirs, setExpandedTrackedDirs] = useState<Set<string>>(
new Set()
)
const [expandedUntrackedDirs, setExpandedUntrackedDirs] = useState<
Set<string>
>(new Set())
const [confirm, setConfirm] = useState<ConfirmState>(CONFIRM_INITIAL)
// Use refs to track mutable values without causing callback recreation
const diffFileRef = useRef(diffFile)
diffFileRef.current = diffFile
const entriesRef = useRef(entries)
entriesRef.current = entries
const folderName = useMemo(() => {
const parts = folderPath.replace(/[/\\]+$/, "").split(/[/\\]/)
return parts[parts.length - 1] || folderPath
}, [folderPath])
const trackedEntries = useMemo(
() => entries.filter((entry) => entry.status !== UNTRACKED_STATUS),
[entries]
)
const untrackedEntries = useMemo(
() => entries.filter((entry) => entry.status === UNTRACKED_STATUS),
[entries]
)
const trackedTree = useMemo(
() => buildFileTree(trackedEntries),
[trackedEntries]
)
const untrackedTree = useMemo(
() => buildFileTree(untrackedEntries),
[untrackedEntries]
)
const filePathSet = useMemo(
() => new Set(entries.map((entry) => entry.file)),
[entries]
)
const trackedFiles = useMemo(
() => trackedEntries.map((entry) => entry.file),
[trackedEntries]
)
const untrackedFiles = useMemo(
() => untrackedEntries.map((entry) => entry.file),
[untrackedEntries]
)
// Shared diff loading logic — extracted to avoid duplication
const loadDiff = useCallback(
async (file: string, allEntries?: GitStatusEntry[]) => {
if (!folderPath) return
setDiffFile(file)
setDiffLanguage(languageFromPath(file))
setLoadingDiff(true)
setDiffOriginal("")
setDiffModified("")
try {
const statusSource = allEntries ?? entriesRef.current
const isUntracked =
statusSource.find((e) => e.file === file)?.status === UNTRACKED_STATUS
const [originalContent, modifiedContent] = await Promise.all([
isUntracked
? Promise.resolve("")
: gitShowFile(folderPath, file).catch(() => ""),
readFilePreview(folderPath, file)
.then((r) => r.content)
.catch(() => ""),
])
setDiffOriginal(originalContent)
setDiffModified(modifiedContent)
} catch {
setDiffOriginal("")
setDiffModified("")
} finally {
setLoadingDiff(false)
}
},
[folderPath]
)
const loadStatus = useCallback(async () => {
if (!folderPath) return
setLoadingStatus(true)
setError(null)
try {
const result = await gitStatus(folderPath)
setEntries(result)
const tracked = result.filter(
(entry) => entry.status !== UNTRACKED_STATUS
)
const untracked = result.filter(
(entry) => entry.status === UNTRACKED_STATUS
)
setSelected(new Set(tracked.map((entry) => entry.file)))
const trackedDirs = collectDirPaths(tracked)
trackedDirs.add(folderName)
setExpandedTrackedDirs(trackedDirs)
const untrackedDirs = collectDirPaths(untracked)
untrackedDirs.add(folderName)
setExpandedUntrackedDirs(untrackedDirs)
// Auto-select the first file in visual tree order for diff preview
const firstFile =
findFirstFile(buildFileTree(tracked)) ??
findFirstFile(buildFileTree(untracked))
if (firstFile) {
await loadDiff(firstFile, result)
}
} catch (err) {
setError(String(err))
setEntries([])
setExpandedTrackedDirs(new Set())
setExpandedUntrackedDirs(new Set())
} finally {
setLoadingStatus(false)
}
}, [folderPath, folderName, loadDiff])
useEffect(() => {
if (!folderPath) return
setDiffOriginal("")
setDiffModified("")
setDiffLanguage("plaintext")
setDiffFile(null)
messageRef.current = ""
setHasMessage(false)
setMessageInputKey((key) => key + 1)
setUntrackedOpen(false)
void loadStatus()
}, [folderPath, loadStatus])
const handleViewDiff = useCallback(
(file: string) => {
if (!folderPath || diffFileRef.current === file) return
void loadDiff(file)
},
[folderPath, loadDiff]
)
const toggleFile = useCallback((file: string) => {
setSelected((prev) => {
const next = new Set(prev)
if (next.has(file)) {
next.delete(file)
} else {
next.add(file)
}
return next
})
}, [])
const toggleAll = useCallback(() => {
setSelected((prev) => {
if (prev.size === entries.length) {
return new Set<string>()
}
return new Set(entries.map((entry) => entry.file))
})
}, [entries])
const toggleGroup = useCallback((files: string[]) => {
setSelected((prev) => {
const next = new Set(prev)
const allInGroupSelected = files.every((file) => next.has(file))
if (allInGroupSelected) {
files.forEach((file) => next.delete(file))
} else {
files.forEach((file) => next.add(file))
}
return next
})
}, [])
const handleSelectPath = useCallback(
(path: string) => {
if (!filePathSet.has(path)) return
handleViewDiff(path)
},
[filePathSet, handleViewDiff]
)
const handleCommit = useCallback(async () => {
const commitMessage = messageRef.current.trim()
if (!commitMessage || selected.size === 0 || !folderPath) return
setCommitting(true)
setError(null)
try {
const result = await gitCommit(
folderPath,
commitMessage,
Array.from(selected)
)
toast.success(t("toasts.commitCompleted"), {
description: t("toasts.committedFiles", {
count: result.committed_files,
}),
})
onCommitted?.()
} catch (err) {
setError(String(err))
} finally {
setCommitting(false)
}
}, [folderPath, onCommitted, selected, t])
// --- Context menu actions ---
const handleAddToVcs = useCallback(
async (file: string) => {
if (!folderPath) return
try {
await gitAddFiles(folderPath, [file])
toast.success(t("toasts.addedToVcs"), { description: file })
void loadStatus()
} catch (err) {
toast.error(t("toasts.addToVcsFailed"), { description: String(err) })
}
},
[folderPath, loadStatus, t]
)
const handleDeleteFile = useCallback(
(file: string) => {
setConfirm({
open: true,
title: t("confirm.deleteTitle"),
description: t("confirm.deleteDescription", { file }),
variant: "destructive",
action: () => {
void (async () => {
if (!folderPath) return
try {
await deleteFileTreeEntry(folderPath, file)
toast.success(t("toasts.fileDeleted"), { description: file })
// If deleted file was being viewed, clear the diff
if (diffFileRef.current === file) {
setDiffFile(null)
setDiffOriginal("")
setDiffModified("")
}
setSelected((prev) => {
if (!prev.has(file)) return prev
const next = new Set(prev)
next.delete(file)
return next
})
void loadStatus()
} catch (err) {
toast.error(t("toasts.deleteFailed"), {
description: String(err),
})
}
})()
},
})
},
[folderPath, loadStatus, t]
)
const handleRollbackFile = useCallback(
(file: string) => {
setConfirm({
open: true,
title: t("confirm.rollbackTitle"),
description: t("confirm.rollbackDescription", { file }),
variant: "destructive",
action: () => {
void (async () => {
if (!folderPath) return
try {
await gitRollbackFile(folderPath, file)
toast.success(t("toasts.fileRolledBack"), { description: file })
if (diffFileRef.current === file) {
setDiffFile(null)
setDiffOriginal("")
setDiffModified("")
}
setSelected((prev) => {
if (!prev.has(file)) return prev
const next = new Set(prev)
next.delete(file)
return next
})
void loadStatus()
} catch (err) {
toast.error(t("toasts.rollbackFailed"), {
description: String(err),
})
}
})()
},
})
},
[folderPath, loadStatus, t]
)
const handleRollbackDir = useCallback(
(dirPath: string, files: string[], displayName?: string) => {
const label = displayName ?? dirPath
setConfirm({
open: true,
title: t("confirm.rollbackTitle"),
description: t("confirm.rollbackDirDescription", { dir: label }),
variant: "destructive",
action: () => {
void (async () => {
if (!folderPath) return
try {
await gitRollbackFile(folderPath, dirPath)
toast.success(t("toasts.dirRolledBack"), {
description: label,
})
if (diffFileRef.current && files.includes(diffFileRef.current)) {
setDiffFile(null)
setDiffOriginal("")
setDiffModified("")
}
setSelected((prev) => {
const next = new Set(prev)
files.forEach((f) => next.delete(f))
return next
})
void loadStatus()
} catch (err) {
toast.error(t("toasts.rollbackFailed"), {
description: String(err),
})
}
})()
},
})
},
[folderPath, loadStatus, t]
)
const handleDeleteDir = useCallback(
(dirPath: string, files: string[], displayName?: string) => {
const label = displayName ?? dirPath
setConfirm({
open: true,
title: t("confirm.deleteTitle"),
description: t("confirm.deleteDirDescription", { dir: label }),
variant: "destructive",
action: () => {
void (async () => {
if (!folderPath) return
try {
await deleteFileTreeEntry(folderPath, dirPath)
toast.success(t("toasts.dirDeleted"), {
description: label,
})
if (diffFileRef.current && files.includes(diffFileRef.current)) {
setDiffFile(null)
setDiffOriginal("")
setDiffModified("")
}
setSelected((prev) => {
const next = new Set(prev)
files.forEach((f) => next.delete(f))
return next
})
void loadStatus()
} catch (err) {
toast.error(t("toasts.deleteFailed"), {
description: String(err),
})
}
})()
},
})
},
[folderPath, loadStatus, t]
)
const handleAddDirToVcs = useCallback(
async (dirPath: string, files: string[], displayName?: string) => {
if (!folderPath) return
const label = displayName ?? dirPath
try {
await gitAddFiles(folderPath, files)
toast.success(t("toasts.addedToVcs"), { description: label })
void loadStatus()
} catch (err) {
toast.error(t("toasts.addToVcsFailed"), { description: String(err) })
}
},
[folderPath, loadStatus, t]
)
const closeConfirm = useCallback(() => {
setConfirm(CONFIRM_INITIAL)
}, [])
const confirmActionRef = useRef(confirm.action)
confirmActionRef.current = confirm.action
const executeConfirmAction = useCallback(() => {
confirmActionRef.current?.()
setConfirm(CONFIRM_INITIAL)
}, [])
const allSelected = useMemo(
() => entries.length > 0 && selected.size === entries.length,
[entries.length, selected.size]
)
const trackedAllSelected = useMemo(
() =>
trackedFiles.length > 0 &&
trackedFiles.every((file) => selected.has(file)),
[trackedFiles, selected]
)
const untrackedAllSelected = useMemo(
() =>
untrackedFiles.length > 0 &&
untrackedFiles.every((file) => selected.has(file)),
[untrackedFiles, selected]
)
const handleMessageChange = useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const nextValue = e.target.value
messageRef.current = nextValue
const nextHasMessage = nextValue.trim().length > 0
setHasMessage((prev) => (prev === nextHasMessage ? prev : nextHasMessage))
},
[]
)
useEffect(() => {
const container = containerRef.current
if (!container) return
const updateWidth = (next: number) => {
setContainerWidth((prev) => (Math.abs(prev - next) < 1 ? prev : next))
}
updateWidth(container.clientWidth)
const observer = new ResizeObserver((entries) => {
updateWidth(entries[0]?.contentRect.width ?? container.clientWidth)
})
observer.observe(container)
return () => {
observer.disconnect()
}
}, [])
const safeContainerWidth =
containerWidth > 0
? containerWidth
: DEFAULT_LEFT_PANE_WIDTH + MIN_RIGHT_PANE_WIDTH + 240
const leftMinSize = clamp(
toPercent(MIN_LEFT_PANE_WIDTH, safeContainerWidth),
5,
95
)
const rightMinSize = clamp(
toPercent(MIN_RIGHT_PANE_WIDTH, safeContainerWidth),
5,
95
)
const leftMaxSize = Math.max(leftMinSize, 100 - rightMinSize)
const leftDefaultSize = clamp(
toPercent(DEFAULT_LEFT_PANE_WIDTH, safeContainerWidth),
leftMinSize,
leftMaxSize
)
// --- Render helpers for file tree nodes ---
const renderTrackedNode = useCallback(
function renderNode(node: TreeNode): React.ReactNode {
if (node.kind === "dir") {
const dirFiles = collectFilePaths(node)
const hasNonDeleted = node.children.some(
(child) =>
child.kind === "file" &&
child.entry.status !== " D" &&
child.entry.status !== "D"
)
return (
<ContextMenu key={`tracked:${node.path}`}>
<ContextMenuTrigger>
<FileTreeFolder name={node.name} path={node.path}>
{node.children.map(renderNode)}
</FileTreeFolder>
</ContextMenuTrigger>
<ContextMenuContent>
{hasNonDeleted && (
<ContextMenuItem
onClick={() => handleRollbackDir(node.path, dirFiles)}
>
{t("actions.rollback")}
</ContextMenuItem>
)}
<ContextMenuItem
variant="destructive"
onClick={() => handleDeleteDir(node.path, dirFiles)}
>
{tCommon("delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
const isDeleted = node.entry.status === " D" || node.entry.status === "D"
return (
<ContextMenu key={`tracked:${node.path}`}>
<ContextMenuTrigger>
<FileTreeFile
name={node.name}
path={node.path}
className="gap-1 px-1.5 py-1"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleFile(node.path)
}}
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
selected.has(node.path)
? "border-primary bg-primary text-primary-foreground"
: "border-input"
)}
aria-label={t("aria.selectFile", {
action: selected.has(node.path)
? t("actions.unselect")
: t("actions.select"),
path: node.path,
})}
>
{selected.has(node.path) && <Check className="h-3 w-3" />}
</button>
<button
type="button"
className="flex-1 truncate text-left hover:underline"
onClick={(e) => {
e.stopPropagation()
handleViewDiff(node.path)
}}
title={node.path}
>
{node.name}
</button>
<span className="w-6 shrink-0 text-right text-xs font-medium text-muted-foreground">
{node.entry.status}
</span>
</FileTreeFile>
</ContextMenuTrigger>
<ContextMenuContent>
{!isDeleted && (
<ContextMenuItem onClick={() => handleRollbackFile(node.path)}>
{t("actions.rollback")}
</ContextMenuItem>
)}
<ContextMenuItem
variant="destructive"
onClick={() => handleDeleteFile(node.path)}
>
{tCommon("delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
},
[
selected,
toggleFile,
handleViewDiff,
handleRollbackFile,
handleRollbackDir,
handleDeleteFile,
handleDeleteDir,
t,
tCommon,
]
)
const renderUntrackedNode = useCallback(
function renderNode(node: TreeNode): React.ReactNode {
if (node.kind === "dir") {
const dirFiles = collectFilePaths(node)
return (
<ContextMenu key={`untracked:${node.path}`}>
<ContextMenuTrigger>
<FileTreeFolder name={node.name} path={node.path}>
{node.children.map(renderNode)}
</FileTreeFolder>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
void handleAddDirToVcs(node.path, dirFiles)
}}
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onClick={() => handleDeleteDir(node.path, dirFiles)}
>
{tCommon("delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
return (
<ContextMenu key={`untracked:${node.path}`}>
<ContextMenuTrigger>
<FileTreeFile
name={node.name}
path={node.path}
className="gap-1 px-1.5 py-1"
>
<button
type="button"
onClick={(e) => {
e.stopPropagation()
toggleFile(node.path)
}}
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
selected.has(node.path)
? "border-primary bg-primary text-primary-foreground"
: "border-input"
)}
aria-label={t("aria.selectFile", {
action: selected.has(node.path)
? t("actions.unselect")
: t("actions.select"),
path: node.path,
})}
>
{selected.has(node.path) && <Check className="h-3 w-3" />}
</button>
<button
type="button"
className="flex-1 truncate text-left hover:underline"
onClick={(e) => {
e.stopPropagation()
handleViewDiff(node.path)
}}
title={node.path}
>
{node.name}
</button>
</FileTreeFile>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onClick={() => handleAddToVcs(node.path)}>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onClick={() => handleDeleteFile(node.path)}
>
{tCommon("delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
},
[
selected,
toggleFile,
handleViewDiff,
handleAddToVcs,
handleAddDirToVcs,
handleDeleteFile,
handleDeleteDir,
t,
tCommon,
]
)
const toggleTrackedGroup = useCallback(
() => toggleGroup(trackedFiles),
[toggleGroup, trackedFiles]
)
const toggleUntrackedGroup = useCallback(
() => toggleGroup(untrackedFiles),
[toggleGroup, untrackedFiles]
)
const toggleUntrackedOpen = useCallback(
() => setUntrackedOpen((open) => !open),
[]
)
return (
<div
ref={containerRef}
className="flex h-full min-h-0 overflow-hidden rounded-lg border bg-card"
>
<ResizablePanelGroup
direction="horizontal"
className="h-full min-h-0 min-w-0"
>
<ResizablePanel
defaultSize={leftDefaultSize}
minSize={leftMinSize}
maxSize={leftMaxSize}
>
<div className="flex h-full min-h-0 flex-col">
{error && (
<div className="border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<div className="flex h-9 items-center gap-2 border-b bg-muted/50 px-3">
<button
type="button"
onClick={toggleAll}
className={cn(
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
allSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input"
)}
aria-label={
allSelected
? t("aria.unselectAllFiles")
: t("aria.selectAllFiles")
}
>
{allSelected && <Check className="h-3 w-3" />}
</button>
<span className="text-xs text-muted-foreground">
{loadingStatus
? t("loading")
: t("selectionCount", {
selected: selected.size,
total: entries.length,
})}
</span>
</div>
<div className="min-h-0 flex-1">
<ScrollArea className="h-full">
{entries.length === 0 && !loadingStatus ? (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
{t("emptyFiles")}
</div>
) : (
<div className="space-y-3 p-2">
{trackedEntries.length > 0 && (
<section className="space-y-1">
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
<button
type="button"
onClick={toggleTrackedGroup}
className={cn(
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors",
trackedAllSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input"
)}
aria-label={
trackedAllSelected
? t("aria.unselectTracked")
: t("aria.selectTracked")
}
>
{trackedAllSelected && (
<Check className="h-2.5 w-2.5" />
)}
</button>
<span>
{t("trackedChanges", {
count: trackedEntries.length,
})}
</span>
</div>
<FileTree
className="rounded-none border-0 bg-transparent font-sans text-sm [&>div]:p-1"
expanded={expandedTrackedDirs}
onExpandedChange={setExpandedTrackedDirs}
selectedPath={diffFile ?? undefined}
onSelect={handleSelectPath}
>
<ContextMenu>
<ContextMenuTrigger>
<FileTreeFolder
name={folderName}
path={folderName}
>
{trackedTree.map(renderTrackedNode)}
</FileTreeFolder>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() =>
handleRollbackDir(
".",
trackedFiles,
folderName
)
}
>
{t("actions.rollback")}
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onClick={() =>
handleDeleteDir(".", trackedFiles, folderName)
}
>
{tCommon("delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</FileTree>
</section>
)}
{untrackedEntries.length > 0 && (
<section className="space-y-1">
<div className="flex w-full items-center gap-2 px-1 py-0.5 text-[11px] text-muted-foreground">
<button
type="button"
onClick={toggleUntrackedGroup}
className={cn(
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors",
untrackedAllSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input"
)}
aria-label={
untrackedAllSelected
? t("aria.unselectUntracked")
: t("aria.selectUntracked")
}
>
{untrackedAllSelected && (
<Check className="h-2.5 w-2.5" />
)}
</button>
<button
type="button"
className="flex items-center gap-1 hover:text-foreground"
onClick={toggleUntrackedOpen}
>
{untrackedOpen ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
<span>
{t("untrackedFiles", {
count: untrackedEntries.length,
})}
</span>
</button>
</div>
{untrackedOpen && (
<FileTree
className="rounded-none border-0 bg-transparent font-sans text-sm [&>div]:p-1"
expanded={expandedUntrackedDirs}
onExpandedChange={setExpandedUntrackedDirs}
selectedPath={diffFile ?? undefined}
onSelect={handleSelectPath}
>
<ContextMenu>
<ContextMenuTrigger>
<FileTreeFolder
name={folderName}
path={folderName}
>
{untrackedTree.map(renderUntrackedNode)}
</FileTreeFolder>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onClick={() => {
void handleAddDirToVcs(
".",
untrackedFiles,
folderName
)
}}
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onClick={() =>
handleDeleteDir(
".",
untrackedFiles,
folderName
)
}
>
{tCommon("delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</FileTree>
)}
</section>
)}
</div>
)}
</ScrollArea>
</div>
<div className="border-t p-3">
<div className="mb-2 text-xs text-muted-foreground">
{t("commitMessage")}
</div>
<Textarea
key={messageInputKey}
placeholder={t("commitMessagePlaceholder")}
defaultValue=""
onChange={handleMessageChange}
className="min-h-[90px] resize-y"
/>
<div className="mt-3 flex items-center justify-end gap-2">
<Button variant="outline" onClick={onCancel}>
{tCommon("cancel")}
</Button>
<Button
disabled={committing || !hasMessage || selected.size === 0}
onClick={handleCommit}
>
{committing && (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
)}
{t("commitButton", { count: selected.size })}
</Button>
</div>
</div>
</div>
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
defaultSize={100 - leftDefaultSize}
minSize={rightMinSize}
>
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
{!diffFile ? (
<>
<div className="flex h-9 items-center gap-3 border-b bg-muted/50 px-3 text-xs text-muted-foreground">
<span className="font-medium">{t("head")}</span>
<span className="text-muted-foreground/60"></span>
<span className="font-medium">{t("workingTree")}</span>
</div>
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
{t("clickFileToDiff")}
</div>
</>
) : loadingDiff ? (
<>
<div className="flex h-9 items-center gap-3 border-b bg-muted/50 px-3 text-xs text-muted-foreground">
<span className="font-medium">{t("head")}</span>
<span className="text-muted-foreground/60"></span>
<span className="font-medium">{t("workingTree")}</span>
</div>
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t("loadingDiff")}
</div>
</>
) : (
<DiffViewer
key={diffFile}
original={diffOriginal}
modified={diffModified}
originalLabel={t("head")}
modifiedLabel={t("workingTree")}
language={diffLanguage}
className="h-full [&>div:first-child]:h-9 [&>div:first-child]:py-0"
/>
)}
</div>
</ResizablePanel>
</ResizablePanelGroup>
<AlertDialog
open={confirm.open}
onOpenChange={(open) => {
if (!open) closeConfirm()
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{confirm.title}</AlertDialogTitle>
<AlertDialogDescription>
{confirm.description}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
variant={
confirm.variant === "destructive" ? "destructive" : "default"
}
onClick={executeConfirmAction}
>
{tCommon("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}