"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 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() 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([]) const containerRef = useRef(null) const [containerWidth, setContainerWidth] = useState(0) const [selected, setSelected] = useState>(new Set()) const [diffOriginal, setDiffOriginal] = useState("") const [diffModified, setDiffModified] = useState("") const [diffLanguage, setDiffLanguage] = useState("plaintext") const [diffFile, setDiffFile] = useState(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(null) const [untrackedOpen, setUntrackedOpen] = useState(false) const [expandedTrackedDirs, setExpandedTrackedDirs] = useState>( new Set() ) const [expandedUntrackedDirs, setExpandedUntrackedDirs] = useState< Set >(new Set()) const [confirm, setConfirm] = useState(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() } 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) => { 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 ( {node.children.map(renderNode)} {hasNonDeleted && ( handleRollbackDir(node.path, dirFiles)} > {t("actions.rollback")} )} handleDeleteDir(node.path, dirFiles)} > {tCommon("delete")} ) } const isDeleted = node.entry.status === " D" || node.entry.status === "D" return ( {node.entry.status} {!isDeleted && ( handleRollbackFile(node.path)}> {t("actions.rollback")} )} handleDeleteFile(node.path)} > {tCommon("delete")} ) }, [ 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 ( {node.children.map(renderNode)} { void handleAddDirToVcs(node.path, dirFiles) }} > {t("actions.addToVcs")} handleDeleteDir(node.path, dirFiles)} > {tCommon("delete")} ) } return ( handleAddToVcs(node.path)}> {t("actions.addToVcs")} handleDeleteFile(node.path)} > {tCommon("delete")} ) }, [ 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 (
{error && (
{error}
)}
{loadingStatus ? t("loading") : t("selectionCount", { selected: selected.size, total: entries.length, })}
{entries.length === 0 && !loadingStatus ? (
{t("emptyFiles")}
) : (
{trackedEntries.length > 0 && (
{t("trackedChanges", { count: trackedEntries.length, })}
{trackedTree.map(renderTrackedNode)} handleRollbackDir( ".", trackedFiles, folderName ) } > {t("actions.rollback")} handleDeleteDir(".", trackedFiles, folderName) } > {tCommon("delete")}
)} {untrackedEntries.length > 0 && (
{untrackedOpen && ( {untrackedTree.map(renderUntrackedNode)} { void handleAddDirToVcs( ".", untrackedFiles, folderName ) }} > {t("actions.addToVcs")} handleDeleteDir( ".", untrackedFiles, folderName ) } > {tCommon("delete")} )}
)}
)}
{t("commitMessage")}