Files
codeg/src/components/layout/aux-panel-git-changes-tab.tsx
xintaofei c691fb0c07 feat(workspace): add no-folder empty states and gate folder-only actions
- Sidebar: replace the "no conversations" placeholder with Open Folder, Clone Repository, and Project Boot buttons when the workspace has no open folders.
- Title bar: disable the terminal and auxiliary-panel toggle buttons while no folder is active.
- Aux panel: show a shared localized "no folder open" prompt in the file tree, git changes, and git log tabs when no folder is active.
- Add auxPanel.noFolderTitle / noFolderHint translations across all ten supported locales.
2026-04-22 10:36:27 +08:00

1672 lines
53 KiB
TypeScript

"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<string, MutableChangeTreeDirNode | ChangeTreeFileNode>
}
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<string>()
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<ChangeTreeNode>((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<string>()
): Set<string> {
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<Set<string>>(
new Set()
)
const [expandedUntrackedPaths, setExpandedUntrackedPaths] = useState<
Set<string>
>(new Set())
const [rollbackTarget, setRollbackTarget] = useState<GitActionTarget | null>(
null
)
const [rollingBack, setRollingBack] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<GitActionTarget | null>(null)
const [deleting, setDeleting] = useState(false)
const [directoryGitActionType, setDirectoryGitActionType] =
useState<DirectoryGitAction | null>(null)
const [directoryGitActionTarget, setDirectoryGitActionTarget] =
useState<GitActionTarget | null>(null)
const [directoryGitCandidates, setDirectoryGitCandidates] = useState<
DirectoryGitCandidateEntry[]
>([])
const [directoryGitSelectedPaths, setDirectoryGitSelectedPaths] = useState<
Set<string>
>(new Set())
const [directoryGitLoading, setDirectoryGitLoading] = useState(false)
const [directoryGitSubmitting, setDirectoryGitSubmitting] = useState(false)
const [directoryGitError, setDirectoryGitError] = useState<string | null>(
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<WorkingTreeChange[]>(() => {
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<string>()
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<string>()
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<string>()
}
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 (
<ContextMenu key={`tracked:${node.path}`}>
<ContextMenuTrigger>
<FileTreeFolder
path={node.path}
name={node.name}
suffix={`(${node.fileCount})`}
suffixClassName="text-muted-foreground/45"
title={node.path}
>
{node.children.map(renderNode)}
</FileTreeFolder>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
handleOpenCommitWindow()
}}
>
{t("actions.commitCode")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void openWorkingTreeDiff(node.path, { mode: "overview" })
}}
>
{tCommon("viewDiff")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleAttachToSession(node.path)
}}
disabled={!canAttachToSession}
>
{tFileTree("attachToCurrentSession")}
</ContextMenuItem>
<ContextMenuItem disabled>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestRollback(target)
}}
variant="destructive"
>
{t("actions.rollback")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(target, "tracked")
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
const file = node.change
const canOpenCurrentFile = canOpenFile(file.status)
const target: GitActionTarget = {
kind: "file",
path: file.path,
name: node.name,
}
return (
<ContextMenu key={`tracked:${file.path}`}>
<ContextMenuTrigger>
<FileTreeFile
className="w-full min-w-0 cursor-pointer"
name={node.name}
onClick={() => {
void openWorkingTreeDiff(file.path)
}}
path={node.path}
title={file.path}
>
<>
<span className="size-4 shrink-0" />
<CommitFileInfo className="flex-1 min-w-0 gap-1.5">
<CommitFileStatus status={mapStatus(file.status)}>
{file.status}
</CommitFileStatus>
<CommitFileIcon />
<CommitFilePath title={file.path}>{node.name}</CommitFilePath>
</CommitFileInfo>
<CommitFileChanges>
<CommitFileAdditions count={file.additions} />
<CommitFileDeletions count={file.deletions} />
</CommitFileChanges>
</>
</FileTreeFile>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
handleOpenCommitWindow()
}}
>
{t("actions.commitCode")}
</ContextMenuItem>
<ContextMenuItem
disabled={!canOpenCurrentFile}
onSelect={() => {
if (!canOpenCurrentFile) return
void openFilePreview(file.path)
}}
>
{tCommon("openFile")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void openWorkingTreeDiff(file.path)
}}
>
{tCommon("viewDiff")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleAttachToSession(file.path)
}}
disabled={!canAttachToSession}
>
{tFileTree("attachToCurrentSession")}
</ContextMenuItem>
<ContextMenuItem disabled>{t("actions.addToVcs")}</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestRollback(target)
}}
variant="destructive"
>
{t("actions.rollback")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(target, "tracked")
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
},
[
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 (
<ContextMenu key={`untracked:${node.path}`}>
<ContextMenuTrigger>
<FileTreeFolder
path={node.path}
name={node.name}
suffix={`(${node.fileCount})`}
suffixClassName="text-muted-foreground/45"
title={node.path}
>
{node.children.map(renderNode)}
</FileTreeFolder>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
handleOpenCommitWindow()
}}
>
{t("actions.commitCode")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void openWorkingTreeDiff(node.path, { mode: "overview" })
}}
>
{tCommon("viewDiff")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleAttachToSession(node.path)
}}
disabled={!canAttachToSession}
>
{tFileTree("attachToCurrentSession")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void handleAddToVcs(target)
}}
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestRollback(target)
}}
variant="destructive"
>
{t("actions.rollback")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(target, "untracked")
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
const file = node.change
const target: GitActionTarget = {
kind: "file",
path: file.path,
name: node.name,
}
return (
<ContextMenu key={`untracked:${file.path}`}>
<ContextMenuTrigger>
<FileTreeFile
className="w-full min-w-0 cursor-pointer"
name={node.name}
onClick={() => {
void openWorkingTreeDiff(file.path)
}}
path={node.path}
title={file.path}
>
<>
<span className="size-4 shrink-0" />
<CommitFileInfo className="flex-1 min-w-0 gap-1.5">
<CommitFileIcon />
<CommitFilePath title={file.path}>{node.name}</CommitFilePath>
</CommitFileInfo>
</>
</FileTreeFile>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
handleOpenCommitWindow()
}}
>
{t("actions.commitCode")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void openFilePreview(file.path)
}}
>
{tCommon("openFile")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void openWorkingTreeDiff(file.path)
}}
>
{tCommon("viewDiff")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleAttachToSession(file.path)
}}
disabled={!canAttachToSession}
>
{tFileTree("attachToCurrentSession")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void handleAddToVcs(target)
}}
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestRollback(target)
}}
variant="destructive"
>
{t("actions.rollback")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(target, "untracked")
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
},
[
canAttachToSession,
handleAttachToSession,
handleOpenCommitWindow,
handleAddToVcs,
handleRequestDelete,
handleRequestRollback,
openFilePreview,
openWorkingTreeDiff,
t,
tCommon,
tFileTree,
]
)
if (!folder) {
return <AuxPanelNoFolderEmpty />
}
if (loading) {
return (
<div className="p-2 space-y-2">
<Skeleton className="h-6 w-full" />
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-2/3" />
</div>
)
}
if (error) {
return (
<div className="p-2 text-xs text-destructive">
<p>{error}</p>
</div>
)
}
return (
<div className="flex flex-col h-full min-h-0">
{workspaceState.degraded && (
<WorkspaceDegradedBanner onRetry={workspaceState.restart} />
)}
<ScrollArea className="flex-1 min-h-0" x="scroll">
{trackedChanges.length === 0 && untrackedChanges.length === 0 ? (
!workspaceState.isGitRepo ? (
<div className="flex flex-col items-center justify-center h-full gap-1 p-6 text-center">
<GitBranch
className="size-5 text-muted-foreground/60"
aria-hidden
/>
<p className="text-sm font-medium">{t("notAGitRepoTitle")}</p>
<p className="text-xs text-muted-foreground">
{t("notAGitRepoHint")}
</p>
</div>
) : (
<div className="flex items-center justify-center h-full p-4">
<p className="text-xs text-muted-foreground text-center">
{t("noChanges")}
</p>
</div>
)
) : (
<div className="space-y-2 pb-2">
{trackedChanges.length > 0 && (
<section className="space-y-1">
<div className="flex items-center justify-between px-2 py-1 text-[11px] text-muted-foreground">
<span>
{t("trackedChanges", { count: trackedChanges.length })}
</span>
<Button
variant="ghost"
size="icon"
className="size-5"
onClick={toggleTrackedExpanded}
disabled={!trackedCanExpand && !trackedCanCollapse}
title={
trackedCanExpand
? t("expandTracked")
: t("collapseTracked")
}
aria-label={
trackedCanExpand
? t("expandTracked")
: t("collapseTracked")
}
>
{trackedCanExpand ? (
<ChevronsUpDown className="size-3.5" />
) : (
<ChevronsDownUp className="size-3.5" />
)}
</Button>
</div>
<FileTree
className="rounded-none border-0 bg-transparent text-xs [&>div]:p-0"
expanded={expandedTrackedPaths}
onExpandedChange={setExpandedTrackedPaths}
>
<ContextMenu>
<ContextMenuTrigger>
<FileTreeFolder
path={TRACKED_ROOT_PATH}
name={folderName}
suffix={`(${trackedChanges.length})`}
suffixClassName="text-muted-foreground/45"
title={folderName}
>
{trackedTreeNodes.map(renderTrackedNode)}
</FileTreeFolder>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
handleOpenCommitWindow()
}}
>
{t("actions.commitCode")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void openWorkingTreeDiff(".", {
mode: "overview",
})
}}
>
{tCommon("viewDiff")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleAttachToSession("")
}}
disabled={!canAttachToSession}
>
{tFileTree("attachToCurrentSession")}
</ContextMenuItem>
<ContextMenuItem disabled>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestRollback({
kind: "dir",
path: "",
name: folderName,
})
}}
variant="destructive"
>
{t("actions.rollback")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(
{
kind: "dir",
path: "",
name: folderName,
},
"tracked"
)
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</FileTree>
</section>
)}
{untrackedChanges.length > 0 && (
<section className="space-y-1">
<div className="flex items-center justify-between px-2 py-1 text-[11px] text-muted-foreground">
<span>
{t("untrackedFiles", { count: untrackedChanges.length })}
</span>
<Button
variant="ghost"
size="icon"
className="size-5"
onClick={toggleUntrackedExpanded}
disabled={!untrackedCanExpand && !untrackedCanCollapse}
title={
untrackedCanExpand
? t("expandUntracked")
: t("collapseUntracked")
}
aria-label={
untrackedCanExpand
? t("expandUntracked")
: t("collapseUntracked")
}
>
{untrackedCanExpand ? (
<ChevronsUpDown className="size-3.5" />
) : (
<ChevronsDownUp className="size-3.5" />
)}
</Button>
</div>
<FileTree
className="rounded-none border-0 bg-transparent text-xs [&>div]:p-0"
expanded={expandedUntrackedPaths}
onExpandedChange={setExpandedUntrackedPaths}
>
<ContextMenu>
<ContextMenuTrigger>
<FileTreeFolder
path={UNTRACKED_ROOT_PATH}
name={folderName}
suffix={`(${untrackedChanges.length})`}
suffixClassName="text-muted-foreground/45"
title={folderName}
>
{untrackedTreeNodes.map(renderUntrackedNode)}
</FileTreeFolder>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={() => {
handleOpenCommitWindow()
}}
>
{t("actions.commitCode")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void openWorkingTreeDiff(".", {
mode: "overview",
})
}}
>
{tCommon("viewDiff")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleAttachToSession("")
}}
disabled={!canAttachToSession}
>
{tFileTree("attachToCurrentSession")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void handleAddToVcs({
kind: "dir",
path: "",
name: folderName,
})
}}
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestRollback({
kind: "dir",
path: "",
name: folderName,
})
}}
variant="destructive"
>
{t("actions.rollback")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
handleRequestDelete(
{
kind: "dir",
path: "",
name: folderName,
},
"untracked"
)
}}
variant="destructive"
>
{t("actions.delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</FileTree>
</section>
)}
</div>
)}
</ScrollArea>
<Dialog
open={Boolean(directoryGitActionType && directoryGitActionTarget)}
onOpenChange={(open) => {
if (open) return
resetDirectoryGitActionDialog()
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{directoryGitActionType === "add"
? t("actions.addToVcs")
: directoryGitActionType &&
isDeleteAction(directoryGitActionType)
? t("actions.delete")
: t("actions.rollback")}
</DialogTitle>
<DialogDescription>
{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")}
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted-foreground">
{t("directoryDialog.selectionCount", {
selected: directoryGitSelectedPaths.size,
total: directoryGitAllFilePaths.length,
})}
</span>
<Button
type="button"
size="xs"
variant="outline"
disabled={directoryGitLoading || directoryGitSubmitting}
onClick={handleToggleDirectoryGitSelectAll}
>
{directoryGitAllSelected
? t("directoryDialog.unselectAll")
: t("directoryDialog.selectAll")}
</Button>
</div>
<div className="max-h-80 overflow-auto rounded-md border">
{directoryGitLoading ? (
<div className="py-8 text-center text-xs text-muted-foreground">
{t("directoryDialog.loadingCandidates")}
</div>
) : directoryGitError ? (
<div className="p-3 text-xs text-destructive">
{directoryGitError}
</div>
) : directoryGitCandidates.length > 0 ? (
<div className="divide-y">
{directoryGitCandidates.map((entry) => {
const selected = directoryGitSelectedPaths.has(entry.path)
return (
<button
key={entry.path}
type="button"
className="flex w-full items-center gap-2 px-3 py-2 text-left text-xs hover:bg-muted/40"
onClick={() => {
handleToggleDirectoryGitFile(entry.path)
}}
disabled={directoryGitSubmitting}
>
<span
className={
selected
? "flex h-4 w-4 shrink-0 items-center justify-center rounded border border-primary bg-primary text-[10px] text-primary-foreground"
: "flex h-4 w-4 shrink-0 items-center justify-center rounded border border-input"
}
aria-hidden
>
{selected ? "✓" : ""}
</span>
<span className="flex-1 truncate" title={entry.path}>
{entry.path}
</span>
{entry.status !== UNTRACKED_STATUS && (
<span className="shrink-0 text-muted-foreground">
{entry.status}
</span>
)}
</button>
)
})}
</div>
) : (
<div className="py-8 text-center text-xs text-muted-foreground">
{t("directoryDialog.noOperableFiles")}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={directoryGitSubmitting}
onClick={resetDirectoryGitActionDialog}
>
{tCommon("cancel")}
</Button>
<Button
type="button"
variant={
directoryGitActionType === "rollback" ||
(directoryGitActionType &&
isDeleteAction(directoryGitActionType))
? "destructive"
: "default"
}
disabled={
directoryGitLoading ||
directoryGitSubmitting ||
directoryGitSelectedPaths.size === 0
}
onClick={() => {
void handleDirectoryGitActionConfirm()
}}
>
{directoryGitActionType === "add"
? t("actions.addToVcs")
: directoryGitActionType &&
isDeleteAction(directoryGitActionType)
? t("actions.delete")
: t("actions.rollback")}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
<AlertDialog
open={Boolean(rollbackTarget)}
onOpenChange={(open) => {
if (open) return
setRollbackTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("rollbackConfirm.title")}</AlertDialogTitle>
<AlertDialogDescription>
{rollbackTarget
? t("rollbackConfirm.descriptionWithTarget", {
kind:
rollbackTarget.kind === "dir"
? t("rollbackConfirm.kindDirectory")
: t("rollbackConfirm.kindFile"),
name: rollbackTarget.name,
})
: t("rollbackConfirm.descriptionFallback")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={rollingBack}>
{tCommon("cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={rollingBack}
onClick={() => {
void handleRollbackConfirm()
}}
>
{t("actions.rollback")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => {
if (open) return
setDeleteTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteConfirm.title")}</AlertDialogTitle>
<AlertDialogDescription>
{deleteTarget
? t("deleteConfirm.descriptionWithTarget", {
kind:
deleteTarget.kind === "dir"
? t("deleteConfirm.kindDirectory")
: t("deleteConfirm.kindFile"),
name: deleteTarget.name,
})
: t("deleteConfirm.descriptionFallback")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleting}>
{tCommon("cancel")}
</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
disabled={deleting}
onClick={() => {
void handleDeleteConfirm()
}}
>
{t("actions.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
)
}