Files
codeg/src/components/layout/aux-panel-file-tree-tab.tsx
xintaofei 150b927610 fix(ui): unify scrollbar styles across scrollable containers
Add .scrollbar-thin and .scrollbar-thin-edge utility classes in
globals.css and apply them to sidebar, file tree, git changes,
git log, session files, diff preview, and message thread panels.
Replace scattered inline webkit-scrollbar overrides with the
shared classes for consistent appearance and gutter behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 18:50:14 +08:00

2930 lines
98 KiB
TypeScript

"use client"
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react"
import { revealItemInDir, subscribe } from "@/lib/platform"
import ignore from "ignore"
import { Check, ChevronRight } from "lucide-react"
import { useTranslations } from "next-intl"
import { toErrorMessage } from "@/lib/app-error"
import { toast } from "sonner"
import { useFolderContext } from "@/contexts/folder-context"
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
import { useTabContext } from "@/contexts/tab-context"
import { useTerminalContext } from "@/contexts/terminal-context"
import { useWorkspaceContext } from "@/contexts/workspace-context"
import {
createFileTreeEntry,
deleteFileTreeEntry,
gitAddFiles,
getGitBranch,
getFileTree,
gitListAllBranches,
gitRollbackFile,
gitStatus,
readFileForEdit,
readFilePreview,
openCommitWindow,
renameFileTreeEntry,
saveFileCopy,
startFileTreeWatch,
stopFileTreeWatch,
} from "@/lib/api"
import { emitAttachFileToSession } from "@/lib/session-attachment-events"
import type {
FileTreeChangedEvent,
FileTreeNode,
GitBranchList,
GitStatusEntry,
} from "@/lib/types"
import {
FileTree,
FileTreeFolder,
FileTreeFile,
} from "@/components/ai-elements/file-tree"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { Skeleton } from "@/components/ui/skeleton"
import { joinFsPath } from "@/lib/path-utils"
function parentDir(filePath: string): string {
const slashIndex = filePath.lastIndexOf("/")
const backslashIndex = filePath.lastIndexOf("\\")
const splitIndex = Math.max(slashIndex, backslashIndex)
if (splitIndex < 0) return filePath
if (splitIndex === 0) return filePath.slice(0, 1)
return filePath.slice(0, splitIndex)
}
function baseName(path: string): string {
return path.split(/[/\\]/).pop() || path
}
const FILE_TREE_ROOT_PATH = "__workspace_root__"
const GITIGNORE_MUTED_CLASS = "text-muted-foreground/55"
interface FileActionTarget {
kind: "file" | "dir"
path: string
name: string
}
interface ExternalConflictPrompt {
path: string
diskContent: string
unsavedContent: string
signature: string
}
type GitFileState =
| "untracked"
| "modified"
| "staged"
| "conflicted"
| "deleted"
| "renamed"
function normalizeGitStatusPath(path: string): string {
const normalized = path.trim()
const renameSeparator = " -> "
const renameIndex = normalized.lastIndexOf(renameSeparator)
if (renameIndex < 0) return normalized
return normalized.slice(renameIndex + renameSeparator.length).trim()
}
function normalizeComparePath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "")
}
function isGitMetadataPath(path: string): boolean {
const normalized = normalizeComparePath(path)
return normalized === ".git" || normalized.startsWith(".git/")
}
function classifyGitFileState(status: string): GitFileState | null {
const code = status.trim().toUpperCase()
if (!code) return null
if (code === "??") 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 getGitFileStateClassName(status?: string): string {
if (!status) return ""
const state = classifyGitFileState(status)
if (state === "untracked") return "text-red-500 dark:text-red-400"
if (state === "modified") return "text-emerald-600 dark:text-emerald-400"
if (state === "staged") return "text-emerald-500 dark:text-emerald-400"
if (state === "conflicted") return "text-amber-500 dark:text-amber-400"
if (state === "deleted") return "text-orange-500 dark:text-orange-400"
if (state === "renamed") return "text-violet-500 dark:text-violet-400"
return ""
}
function getParentPath(path: string): string | null {
const splitIdx = path.lastIndexOf("/")
if (splitIdx < 0) return null
return path.slice(0, splitIdx)
}
function hasIgnoredAncestor(path: string, ignoredPaths: ReadonlySet<string>) {
let current = path
while (true) {
const parent = getParentPath(current)
if (!parent) return false
if (ignoredPaths.has(parent)) return true
current = parent
}
}
function getRelativePathDepth(path: string): number {
if (!path) return 0
return path.split("/").filter(Boolean).length
}
type DirectoryGitAction = "add" | "rollback"
interface DirectoryGitCandidateEntry {
path: string
status: string
}
type DirectoryGitTreeNode = DirectoryGitTreeDirNode | DirectoryGitTreeFileNode
interface DirectoryGitTreeDirNode {
kind: "dir"
name: string
path: string
children: DirectoryGitTreeNode[]
fileCount: number
}
interface DirectoryGitTreeFileNode {
kind: "file"
name: string
path: string
status: string
}
interface MutableDirectoryGitTreeDirNode {
kind: "dir"
name: string
path: string
children: Map<
string,
MutableDirectoryGitTreeDirNode | DirectoryGitTreeFileNode
>
}
const DIRECTORY_GIT_TREE_ROOT_PATH = "__directory_git_tree_root__"
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 filterDirectoryGitCandidates(
entries: DirectoryGitCandidateEntry[],
action: DirectoryGitAction
): DirectoryGitCandidateEntry[] {
if (action === "add") {
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 buildDirectoryGitTree(
entries: DirectoryGitCandidateEntry[],
directoryPath: string
): DirectoryGitTreeNode[] {
const normalizedDirPath = normalizeComparePath(directoryPath)
const root: MutableDirectoryGitTreeDirNode = {
kind: "dir",
name: "",
path: "",
children: new Map(),
}
for (const entry of entries) {
let relativePath = normalizeComparePath(entry.path)
if (normalizedDirPath && relativePath.startsWith(`${normalizedDirPath}/`)) {
relativePath = relativePath.slice(normalizedDirPath.length + 1)
}
const segments = relativePath.split("/").filter(Boolean)
if (segments.length === 0) continue
let current = root
for (const [index, segment] of segments.entries()) {
const isLeaf = index === segments.length - 1
const nestedPath = segments.slice(0, index + 1).join("/")
const nodePath = normalizedDirPath
? `${normalizedDirPath}/${nestedPath}`
: nestedPath
if (isLeaf) {
current.children.set(`file:${nodePath}`, {
kind: "file",
name: segment,
path: nodePath,
status: entry.status,
})
continue
}
const dirKey = `dir:${nodePath}`
const existing = current.children.get(dirKey)
if (existing && existing.kind === "dir") {
current = existing
continue
}
const nextDir: MutableDirectoryGitTreeDirNode = {
kind: "dir",
name: segment,
path: nodePath,
children: new Map(),
}
current.children.set(dirKey, nextDir)
current = nextDir
}
}
const toSortedTreeNodes = (
dir: MutableDirectoryGitTreeDirNode
): DirectoryGitTreeNode[] => {
return Array.from(dir.children.values())
.map<DirectoryGitTreeNode>((node) => {
if (node.kind === "file") return node
return {
kind: "dir" as const,
name: node.name,
path: node.path,
children: toSortedTreeNodes(node),
fileCount: 0,
}
})
.sort((left, right) => {
if (left.kind !== right.kind) return left.kind === "dir" ? -1 : 1
return left.name.localeCompare(right.name, undefined, {
sensitivity: "base",
})
})
}
const annotateDirectory = (
node: DirectoryGitTreeDirNode
): DirectoryGitTreeDirNode => {
const nextChildren = node.children.map((child) => {
if (child.kind === "file") return child
return annotateDirectory(child)
})
const fileCount = nextChildren.reduce((count, child) => {
if (child.kind === "file") return count + 1
return count + child.fileCount
}, 0)
return {
...node,
children: nextChildren,
fileCount,
}
}
return toSortedTreeNodes(root).map((node) => {
if (node.kind === "file") return node
return annotateDirectory(node)
})
}
function collectDirectoryGitTreeExpandedPaths(
nodes: DirectoryGitTreeNode[],
expanded = new Set<string>()
): Set<string> {
for (const node of nodes) {
if (node.kind !== "dir") continue
expanded.add(node.path)
collectDirectoryGitTreeExpandedPaths(node.children, expanded)
}
return expanded
}
function collectDirectoryGitTreeLeafPaths(
node: DirectoryGitTreeNode
): string[] {
if (node.kind === "file") return [node.path]
return node.children.flatMap(collectDirectoryGitTreeLeafPaths)
}
interface RenderNodeProps {
node: FileTreeNode
expandedPaths: ReadonlySet<string>
workspacePath: string
activeSessionTabId: string | null
gitEnabled: boolean
gitStatusByPath: ReadonlyMap<string, string>
gitChangedDirPaths: ReadonlySet<string>
untrackedDirPaths: ReadonlySet<string>
gitignoreIgnoredPaths: ReadonlySet<string>
ancestorGitignoreIgnored: boolean
ancestorUntracked: boolean
onOpenFilePreview: (path: string) => void
onOpenFileDiff: (path: string) => void
onOpenDirDiff: (path: string) => void
onOpenCommitWindow: () => void
onRequestCompareWithBranch: (target: FileActionTarget) => void
onRequestRollback: (target: FileActionTarget) => void
onOpenDirInTerminal: (dirPath: string, fileName: string) => Promise<void>
onRequestAddToVcs: (target: FileActionTarget) => void
onRequestRename: (target: FileActionTarget) => void
onRequestCreate: (parentPath: string, kind: "file" | "dir") => void
onRequestDelete: (target: FileActionTarget) => void
onRefresh: () => void
}
function RenderNode({
node,
expandedPaths,
workspacePath,
activeSessionTabId,
gitEnabled,
gitStatusByPath,
gitChangedDirPaths,
untrackedDirPaths,
gitignoreIgnoredPaths,
ancestorGitignoreIgnored,
ancestorUntracked,
onOpenFilePreview,
onOpenFileDiff,
onOpenDirDiff,
onOpenCommitWindow,
onRequestCompareWithBranch,
onRequestRollback,
onOpenDirInTerminal,
onRequestAddToVcs,
onRequestCreate,
onRequestRename,
onRequestDelete,
onRefresh,
}: RenderNodeProps) {
const t = useTranslations("Folder.fileTreeTab")
const tCommon = useTranslations("Folder.common")
const isGitignoreIgnored =
ancestorGitignoreIgnored || gitignoreIgnoredPaths.has(node.path)
const systemExplorerLabel =
typeof navigator === "undefined"
? t("openInFileManager")
: (() => {
const platform =
`${navigator.platform} ${navigator.userAgent}`.toLowerCase()
if (platform.includes("mac")) return t("openInFinder")
if (platform.includes("win")) return t("openInExplorer")
return t("openInFileManager")
})()
if (node.kind === "file") {
const gitStatusCode =
gitStatusByPath.get(node.path) ?? (ancestorUntracked ? "??" : undefined)
const absolutePath = joinFsPath(workspacePath, node.path)
const dirPath = parentDir(absolutePath)
const isGitMenuDisabled = !gitEnabled || isGitignoreIgnored
const handleAttachToSession = () => {
if (!activeSessionTabId) return
emitAttachFileToSession({
tabId: activeSessionTabId,
path: absolutePath,
})
}
const handleOpenInSystemExplorer = async () => {
try {
await revealItemInDir(absolutePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.openDirectoryFailed"), { description: message })
}
}
return (
<ContextMenu>
<ContextMenuTrigger>
<FileTreeFile
path={node.path}
name={node.name}
className={
isGitignoreIgnored
? GITIGNORE_MUTED_CLASS
: getGitFileStateClassName(gitStatusCode)
}
/>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={() => onOpenFilePreview(node.path)}>
{tCommon("openFile")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => void handleAttachToSession()}
disabled={!activeSessionTabId}
>
{t("attachToCurrentSession")}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>{t("new")}</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onSelect={() => onRequestCreate(node.path, "file")}
>
{t("newFile")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onRequestCreate(node.path, "dir")}
>
{t("newDirectory")}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSub>
<ContextMenuSubTrigger disabled={isGitMenuDisabled}>
{t("git")}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onSelect={() => onOpenCommitWindow()}
disabled={isGitMenuDisabled}
>
{t("actions.commitCode")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onRequestAddToVcs(node)}
disabled={
isGitMenuDisabled ||
classifyGitFileState(gitStatusCode ?? "") !== "untracked"
}
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onOpenFileDiff(node.path)}
disabled={isGitMenuDisabled}
>
{tCommon("viewDiff")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onRequestCompareWithBranch(node)}
disabled={isGitMenuDisabled}
>
{t("compareWithBranch")}
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onSelect={() => onRequestRollback(node)}
disabled={isGitMenuDisabled}
>
{t("actions.rollback")}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem onSelect={() => onRequestRename(node)}>
{tCommon("rename")}
</ContextMenuItem>
<ContextMenuItem onSelect={onRefresh}>
{t("reloadFromDisk")}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>{t("openIn")}</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onSelect={() => void handleOpenInSystemExplorer()}
>
{systemExplorerLabel}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => void onOpenDirInTerminal(dirPath, node.name)}
>
{t("openInTerminal")}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem
onSelect={() => onRequestDelete(node)}
variant="destructive"
>
{tCommon("delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
const absolutePath = joinFsPath(workspacePath, node.path)
const isThisDirUntracked =
ancestorUntracked || untrackedDirPaths.has(node.path)
const dirHasChanges =
!isGitignoreIgnored &&
(gitChangedDirPaths.has(node.path) || isThisDirUntracked)
const isGitMenuDisabled = !gitEnabled || isGitignoreIgnored
const shouldRenderChildren = expandedPaths.has(node.path)
const handleAttachDirToSession = () => {
if (!activeSessionTabId) return
emitAttachFileToSession({
tabId: activeSessionTabId,
path: absolutePath,
})
}
const handleOpenDirInSystemExplorer = async () => {
try {
await revealItemInDir(absolutePath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.openDirectoryFailed"), { description: message })
}
}
return (
<ContextMenu>
<ContextMenuTrigger>
<FileTreeFolder
path={node.path}
name={node.name}
nameClassName={
isGitignoreIgnored
? GITIGNORE_MUTED_CLASS
: dirHasChanges
? "text-emerald-600 dark:text-emerald-400"
: undefined
}
iconClassName={isGitignoreIgnored ? GITIGNORE_MUTED_CLASS : undefined}
>
{shouldRenderChildren
? node.children.map((child) => (
<RenderNode
key={child.path}
node={child}
expandedPaths={expandedPaths}
workspacePath={workspacePath}
activeSessionTabId={activeSessionTabId}
gitEnabled={gitEnabled}
gitStatusByPath={gitStatusByPath}
gitChangedDirPaths={gitChangedDirPaths}
untrackedDirPaths={untrackedDirPaths}
gitignoreIgnoredPaths={gitignoreIgnoredPaths}
ancestorGitignoreIgnored={isGitignoreIgnored}
ancestorUntracked={isThisDirUntracked}
onOpenFilePreview={onOpenFilePreview}
onOpenFileDiff={onOpenFileDiff}
onOpenDirDiff={onOpenDirDiff}
onOpenCommitWindow={onOpenCommitWindow}
onRequestCompareWithBranch={onRequestCompareWithBranch}
onRequestRollback={onRequestRollback}
onOpenDirInTerminal={onOpenDirInTerminal}
onRequestCreate={onRequestCreate}
onRequestAddToVcs={onRequestAddToVcs}
onRequestRename={onRequestRename}
onRequestDelete={onRequestDelete}
onRefresh={onRefresh}
/>
))
: null}
</FileTreeFolder>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem
onSelect={handleAttachDirToSession}
disabled={!activeSessionTabId}
>
{t("attachToCurrentSession")}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>{t("new")}</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onSelect={() => onRequestCreate(node.path, "file")}
>
{t("newFile")}
</ContextMenuItem>
<ContextMenuItem onSelect={() => onRequestCreate(node.path, "dir")}>
{t("newDirectory")}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSub>
<ContextMenuSubTrigger disabled={isGitMenuDisabled}>
{t("git")}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onSelect={() => onOpenCommitWindow()}
disabled={isGitMenuDisabled}
>
{t("actions.commitCode")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onRequestAddToVcs(node)}
disabled={isGitMenuDisabled}
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onOpenDirDiff(node.path)}
disabled={isGitMenuDisabled}
>
{tCommon("viewDiff")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onRequestCompareWithBranch(node)}
disabled={isGitMenuDisabled}
>
{t("compareWithBranch")}
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onSelect={() => onRequestRollback(node)}
disabled={isGitMenuDisabled}
>
{t("actions.rollback")}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem onSelect={() => onRequestRename(node)}>
{tCommon("rename")}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>{t("openIn")}</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onSelect={() => void handleOpenDirInSystemExplorer()}
>
{systemExplorerLabel}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => void onOpenDirInTerminal(absolutePath, node.name)}
>
{t("openInTerminal")}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem onSelect={onRefresh}>
{t("reloadFromDisk")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => onRequestDelete(node)}
variant="destructive"
>
{tCommon("delete")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}
export function FileTreeTab() {
const t = useTranslations("Folder.fileTreeTab")
const tCommon = useTranslations("Folder.common")
const { activeTab, pendingRevealPath, consumePendingRevealPath } =
useAuxPanelContext()
const { folder } = useFolderContext()
const { tabs, activeTabId } = useTabContext()
const { createTerminalInDirectory } = useTerminalContext()
const {
activeFileTab,
activeFilePath,
openBranchDiff,
openExternalConflictDiff,
openFilePreview,
openWorkingTreeDiff,
} = useWorkspaceContext()
const [nodes, setNodes] = useState<FileTreeNode[]>([])
const [gitStatusByPath, setGitStatusByPath] = useState<Map<string, string>>(
new Map()
)
const [gitEnabled, setGitEnabled] = useState(false)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [renameTarget, setRenameTarget] = useState<FileActionTarget | null>(
null
)
const [renameValue, setRenameValue] = useState("")
const [renaming, setRenaming] = useState(false)
const [createParentPath, setCreateParentPath] = useState<string | null>(null)
const [createKind, setCreateKind] = useState<"file" | "dir">("file")
const [createName, setCreateName] = useState("")
const [creating, setCreating] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<FileActionTarget | null>(
null
)
const [deleting, setDeleting] = useState(false)
const [rollbackTarget, setRollbackTarget] = useState<FileActionTarget | null>(
null
)
const [rollingBack, setRollingBack] = useState(false)
const [compareTarget, setCompareTarget] = useState<FileActionTarget | null>(
null
)
const [externalConflictPrompt, setExternalConflictPrompt] =
useState<ExternalConflictPrompt | null>(null)
const [savingExternalConflictCopy, setSavingExternalConflictCopy] =
useState(false)
const [directoryGitActionType, setDirectoryGitActionType] =
useState<DirectoryGitAction | null>(null)
const [directoryGitActionTarget, setDirectoryGitActionTarget] =
useState<FileActionTarget | null>(null)
const [directoryGitCandidates, setDirectoryGitCandidates] = useState<
DirectoryGitCandidateEntry[]
>([])
const [directoryGitSelectedPaths, setDirectoryGitSelectedPaths] = useState<
Set<string>
>(new Set())
const [directoryGitExpandedPaths, setDirectoryGitExpandedPaths] = useState<
Set<string>
>(new Set([DIRECTORY_GIT_TREE_ROOT_PATH]))
const [directoryGitLoading, setDirectoryGitLoading] = useState(false)
const [directoryGitSubmitting, setDirectoryGitSubmitting] = useState(false)
const [directoryGitError, setDirectoryGitError] = useState<string | null>(
null
)
const [compareBranchFilter, setCompareBranchFilter] = useState("")
const [compareCurrentBranch, setCompareCurrentBranch] = useState<
string | null
>(null)
const [compareBranchList, setCompareBranchList] = useState<GitBranchList>({
local: [],
remote: [],
worktree_branches: [],
})
const [compareBranchLoading, setCompareBranchLoading] = useState(false)
const [compareRecentOpen, setCompareRecentOpen] = useState(true)
const [compareLocalOpen, setCompareLocalOpen] = useState(false)
const [compareRemoteOpen, setCompareRemoteOpen] = useState(false)
const [comparing, setComparing] = useState(false)
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(
() => new Set([FILE_TREE_ROOT_PATH])
)
const [loadedTreeDepth, setLoadedTreeDepth] = useState(1)
const [gitignoreIgnoredPaths, setGitignoreIgnoredPaths] = useState<
Set<string>
>(new Set())
const isFileTreeTabActive = activeTab === "file_tree"
const activeFileTabRef = useRef(activeFileTab)
const filePathSetRef = useRef<Set<string>>(new Set())
const loadedTreeDepthRef = useRef(1)
const isFileTreeTabActiveRef = useRef(isFileTreeTabActive)
const pendingTreeRefreshRef = useRef(false)
const pendingTreeRefreshNeedsStatusRef = useRef(false)
const pendingStatusRefreshRef = useRef(false)
const treeRefreshNeedsStatusRef = useRef(false)
const externalConflictSignatureByPathRef = useRef<Map<string, string>>(
new Map()
)
const treeRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const statusRefreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
null
)
useEffect(() => {
activeFileTabRef.current = activeFileTab
}, [activeFileTab])
useEffect(() => {
setExpandedPaths(new Set([FILE_TREE_ROOT_PATH]))
loadedTreeDepthRef.current = 1
setLoadedTreeDepth(1)
setGitignoreIgnoredPaths(new Set())
setExternalConflictPrompt(null)
setSavingExternalConflictCopy(false)
externalConflictSignatureByPathRef.current.clear()
}, [folder?.path])
// Handle pending reveal path: expand all ancestor directories once tree is loaded
const hasNodes = nodes.length > 0
useEffect(() => {
if (!pendingRevealPath || !hasNodes) return
consumePendingRevealPath()
setExpandedPaths((prev) => {
const next = new Set(prev)
next.add(FILE_TREE_ROOT_PATH)
let idx = pendingRevealPath.indexOf("/")
while (idx !== -1) {
next.add(pendingRevealPath.slice(0, idx))
idx = pendingRevealPath.indexOf("/", idx + 1)
}
next.add(pendingRevealPath)
return next
})
}, [pendingRevealPath, consumePendingRevealPath, hasNodes])
useEffect(() => {
if (!activeFileTab || activeFileTab.kind !== "file") return
if (!activeFileTab.path) return
if (activeFileTab.loading || activeFileTab.isDirty) return
const activeFilePath = activeFileTab.path
externalConflictSignatureByPathRef.current.delete(activeFilePath)
setExternalConflictPrompt((current) =>
current &&
normalizeComparePath(current.path) ===
normalizeComparePath(activeFilePath)
? null
: current
)
}, [activeFileTab])
useEffect(() => {
loadedTreeDepthRef.current = loadedTreeDepth
}, [loadedTreeDepth])
const activeSessionTabId = useMemo(() => {
const activeTab = tabs.find((tab) => tab.id === activeTabId)
if (!activeTab) return null
if (activeTab.kind !== "conversation") {
return null
}
return activeTab.id
}, [tabs, activeTabId])
const applyGitStatusResult = useCallback(
(entries: { file: string; status: string }[]) => {
const nextStatusByPath = new Map<string, string>()
for (const entry of entries) {
const raw = normalizeGitStatusPath(entry.file)
if (!raw) continue
// Strip trailing slash (directory entries from -unormal)
const normalizedPath = raw.replace(/\/+$/, "")
if (!normalizedPath) continue
nextStatusByPath.set(normalizedPath, entry.status)
}
setGitEnabled(true)
setGitStatusByPath(nextStatusByPath)
},
[]
)
const refreshGitStatus = useCallback(async () => {
if (!folder?.path) {
setGitStatusByPath(new Map())
setGitEnabled(false)
return
}
try {
const result = await gitStatus(folder.path)
applyGitStatusResult(result)
} catch {
setGitEnabled(false)
setGitStatusByPath(new Map())
}
}, [applyGitStatusResult, folder?.path])
const fetchTree = useCallback(
async (options?: {
skipTree?: boolean
skipStatus?: boolean
silent?: boolean
maxDepth?: number
}) => {
if (!folder?.path) {
setNodes([])
loadedTreeDepthRef.current = 1
setLoadedTreeDepth(1)
setGitStatusByPath(new Map())
setGitEnabled(false)
setLoading(false)
return
}
const skipTree = options?.skipTree ?? false
const skipStatus = options?.skipStatus ?? false
const silent = options?.silent ?? false
const maxDepth = options?.maxDepth ?? loadedTreeDepthRef.current
if (!silent) setLoading(true)
setError(null)
let loadingReleased = false
try {
if (skipTree) {
if (!skipStatus) {
await refreshGitStatus()
}
return
}
if (skipStatus) {
const treeResult = await getFileTree(folder.path, maxDepth)
setNodes(treeResult)
setLoadedTreeDepth((prev) => {
const next = Math.max(prev, maxDepth)
loadedTreeDepthRef.current = next
return next
})
return
}
const treePromise = getFileTree(folder.path, maxDepth)
const gitStatusPromise = gitStatus(folder.path)
const treeResult = await treePromise
setNodes(treeResult)
setLoadedTreeDepth((prev) => {
const next = Math.max(prev, maxDepth)
loadedTreeDepthRef.current = next
return next
})
// Show file tree as soon as it's ready; git status can follow.
if (!silent) {
setLoading(false)
loadingReleased = true
}
try {
const gitStatusResult = await gitStatusPromise
applyGitStatusResult(gitStatusResult)
} catch {
setGitEnabled(false)
setGitStatusByPath(new Map())
}
} catch (e) {
setError(toErrorMessage(e))
} finally {
if (!silent && !loadingReleased) setLoading(false)
}
},
[applyGitStatusResult, folder?.path, refreshGitStatus]
)
useEffect(() => {
isFileTreeTabActiveRef.current = isFileTreeTabActive
if (!isFileTreeTabActive) return
if (pendingTreeRefreshRef.current) {
const needsStatus =
pendingTreeRefreshNeedsStatusRef.current ||
pendingStatusRefreshRef.current
pendingTreeRefreshRef.current = false
pendingTreeRefreshNeedsStatusRef.current = false
pendingStatusRefreshRef.current = false
void fetchTree({ silent: true, skipStatus: !needsStatus })
return
}
if (pendingStatusRefreshRef.current) {
pendingStatusRefreshRef.current = false
void fetchTree({ skipTree: true, silent: true })
}
}, [fetchTree, isFileTreeTabActive])
useEffect(() => {
pendingTreeRefreshRef.current = false
pendingTreeRefreshNeedsStatusRef.current = false
pendingStatusRefreshRef.current = false
treeRefreshNeedsStatusRef.current = false
}, [folder?.path])
const filePathSet = useMemo(() => {
const paths = new Set<string>()
const collect = (items: FileTreeNode[]) => {
for (const item of items) {
if (item.kind === "file") {
paths.add(item.path)
} else {
collect(item.children)
}
}
}
collect(nodes)
return paths
}, [nodes])
const dirChildrenByPath = useMemo(() => {
const next = new Map<string, FileTreeNode[]>()
next.set("", nodes)
const collect = (items: FileTreeNode[]) => {
for (const item of items) {
if (item.kind !== "dir") continue
next.set(item.path, item.children)
collect(item.children)
}
}
collect(nodes)
return next
}, [nodes])
const expandedDirPaths = useMemo(() => {
const dirs = new Set<string>([""])
for (const path of expandedPaths) {
if (path === FILE_TREE_ROOT_PATH) continue
dirs.add(path)
}
return Array.from(dirs)
}, [expandedPaths])
const desiredTreeDepth = useMemo(() => {
let nextDepth = 1
for (const path of expandedPaths) {
if (path === FILE_TREE_ROOT_PATH) continue
nextDepth = Math.max(nextDepth, getRelativePathDepth(path) + 1)
}
return nextDepth
}, [expandedPaths])
useEffect(() => {
filePathSetRef.current = filePathSet
}, [filePathSet])
useEffect(() => {
if (!folder?.path) {
setGitignoreIgnoredPaths(new Set())
return
}
let canceled = false
const loadIgnoredPaths = async () => {
const nextIgnoredPaths = new Set<string>()
const sortedDirs = [...expandedDirPaths].sort(
(left, right) => left.length - right.length
)
for (const dirPath of sortedDirs) {
if (hasIgnoredAncestor(dirPath, nextIgnoredPaths)) continue
const children = dirChildrenByPath.get(dirPath)
if (!children || children.length === 0) continue
const gitignoreNode = children.find(
(child) => child.kind === "file" && child.name === ".gitignore"
)
if (!gitignoreNode || gitignoreNode.kind !== "file") continue
try {
const result = await readFilePreview(folder.path, gitignoreNode.path)
const matcher = ignore().add(result.content)
// Collect all descendant nodes so multi-level patterns like
// "public/vs" can be matched using relative paths.
const descendants: FileTreeNode[] = []
const collectDescendants = (parent: string) => {
const items = dirChildrenByPath.get(parent)
if (!items) return
for (const item of items) {
descendants.push(item)
if (item.kind === "dir") collectDescendants(item.path)
}
}
collectDescendants(dirPath)
for (const desc of descendants) {
if (hasIgnoredAncestor(desc.path, nextIgnoredPaths)) continue
const relativePath =
dirPath === "" ? desc.path : desc.path.slice(dirPath.length + 1)
if (!relativePath) continue
const ignored =
desc.kind === "dir"
? matcher.ignores(`${relativePath}/`) ||
matcher.ignores(`${relativePath}/.codeg-ignore-probe`)
: matcher.ignores(relativePath)
if (ignored) {
nextIgnoredPaths.add(desc.path)
}
}
} catch {
// Ignore parser/read failures for non-critical visual hints.
}
}
if (!canceled) {
setGitignoreIgnoredPaths(nextIgnoredPaths)
}
}
void loadIgnoredPaths()
return () => {
canceled = true
}
}, [dirChildrenByPath, expandedDirPaths, folder?.path])
const gitChangedDirPaths = useMemo(() => {
const dirs = new Set<string>()
for (const filePath of gitStatusByPath.keys()) {
let current = filePath
// Walk up the path collecting all parent directories
while (true) {
const slashIdx = current.lastIndexOf("/")
const backslashIdx = current.lastIndexOf("\\")
const splitIdx = Math.max(slashIdx, backslashIdx)
if (splitIdx <= 0) break
current = current.slice(0, splitIdx)
dirs.add(current)
}
}
return dirs
}, [gitStatusByPath])
// Directories that are entirely untracked (from git status -unormal)
const untrackedDirPaths = useMemo(() => {
const dirs = new Set<string>()
for (const [path, status] of gitStatusByPath.entries()) {
if (status.trim() === "??") {
// Check if this path is a directory in the file tree
if (dirChildrenByPath.has(path)) {
dirs.add(path)
}
}
}
return dirs
}, [gitStatusByPath, dirChildrenByPath])
const handleTreeSelect = useCallback(
(path: string) => {
if (!filePathSet.has(path)) return
void openFilePreview(path)
},
[filePathSet, openFilePreview]
)
const handleOpenDirInTerminal = useCallback(
async (dirPath: string, fileName: string) => {
const terminalTitle = t("terminalTitle", { name: baseName(fileName) })
const terminalId = await createTerminalInDirectory(dirPath, terminalTitle)
if (!terminalId) {
toast.error(t("toasts.openBuiltinTerminalFailed"))
}
},
[createTerminalInDirectory, t]
)
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 handleRequestCreate = useCallback(
(parentPath: string, kind: "file" | "dir") => {
setCreateParentPath(parentPath)
setCreateKind(kind)
setCreateName("")
},
[]
)
const handleRequestRename = useCallback((target: FileActionTarget) => {
setRenameTarget(target)
setRenameValue(target.name)
}, [])
const handleRequestDelete = useCallback((target: FileActionTarget) => {
setDeleteTarget(target)
}, [])
const resetDirectoryGitActionDialog = useCallback(() => {
setDirectoryGitActionType(null)
setDirectoryGitActionTarget(null)
setDirectoryGitCandidates([])
setDirectoryGitSelectedPaths(new Set())
setDirectoryGitExpandedPaths(new Set([DIRECTORY_GIT_TREE_ROOT_PATH]))
setDirectoryGitError(null)
setDirectoryGitLoading(false)
setDirectoryGitSubmitting(false)
}, [])
const openDirectoryGitActionDialog = useCallback(
async (action: DirectoryGitAction, target: FileActionTarget) => {
if (!folder?.path) return
setDirectoryGitActionType(action)
setDirectoryGitActionTarget(target)
setDirectoryGitCandidates([])
setDirectoryGitSelectedPaths(new Set())
setDirectoryGitExpandedPaths(new Set([DIRECTORY_GIT_TREE_ROOT_PATH]))
setDirectoryGitError(null)
setDirectoryGitLoading(true)
try {
const statusEntries = await gitStatus(folder.path)
const scopedEntries = scopeGitStatusEntriesForDirectory(
statusEntries,
target.path
)
const candidates = filterDirectoryGitCandidates(scopedEntries, action)
if (candidates.length === 0) {
resetDirectoryGitActionDialog()
toast.info(
action === "add"
? t("toasts.noAddableFilesInDir")
: t("toasts.noRollbackFilesInDir")
)
return
}
const treeNodes = buildDirectoryGitTree(candidates, target.path)
const expanded = collectDirectoryGitTreeExpandedPaths(treeNodes)
expanded.add(DIRECTORY_GIT_TREE_ROOT_PATH)
setDirectoryGitCandidates(candidates)
setDirectoryGitSelectedPaths(
new Set(candidates.map((entry) => entry.path))
)
setDirectoryGitExpandedPaths(expanded)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setDirectoryGitError(message)
} finally {
setDirectoryGitLoading(false)
}
},
[folder?.path, resetDirectoryGitActionDialog, t]
)
const handleRequestRollback = useCallback(
(target: FileActionTarget) => {
if (target.kind === "dir") {
void openDirectoryGitActionDialog("rollback", target)
return
}
setRollbackTarget(target)
},
[openDirectoryGitActionDialog]
)
const handleAddToVcs = useCallback(
async (target: FileActionTarget) => {
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 fetchTree()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.addToVcsFailed"), { description: message })
}
},
[fetchTree, folder?.path, openDirectoryGitActionDialog, t]
)
const loadCompareBranches = useCallback(async () => {
if (!folder?.path) {
setCompareBranchList({ local: [], remote: [], worktree_branches: [] })
setCompareCurrentBranch(null)
return
}
setCompareBranchLoading(true)
try {
const [branchesResult, currentBranchResult] = await Promise.allSettled([
gitListAllBranches(folder.path),
getGitBranch(folder.path),
])
if (branchesResult.status === "fulfilled") {
setCompareBranchList(branchesResult.value)
} else {
setCompareBranchList({ local: [], remote: [], worktree_branches: [] })
const message =
branchesResult.reason instanceof Error
? branchesResult.reason.message
: String(branchesResult.reason)
toast.error(t("toasts.loadBranchesFailed"), { description: message })
}
if (currentBranchResult.status === "fulfilled") {
setCompareCurrentBranch(currentBranchResult.value)
} else {
setCompareCurrentBranch(null)
}
} catch (error) {
setCompareBranchList({ local: [], remote: [], worktree_branches: [] })
setCompareCurrentBranch(null)
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.loadBranchesFailed"), { description: message })
} finally {
setCompareBranchLoading(false)
}
}, [folder?.path, t])
const handleRequestCompareWithBranch = useCallback(
(target: FileActionTarget) => {
setCompareTarget(target)
setCompareBranchFilter("")
setCompareRecentOpen(true)
setCompareLocalOpen(false)
setCompareRemoteOpen(false)
void loadCompareBranches()
},
[loadCompareBranches]
)
const compareFilterKeyword = useMemo(
() => compareBranchFilter.trim().toLowerCase(),
[compareBranchFilter]
)
const filteredCompareRecentBranches = useMemo(() => {
if (!compareCurrentBranch) return []
if (!compareFilterKeyword) return [compareCurrentBranch]
return compareCurrentBranch.toLowerCase().includes(compareFilterKeyword)
? [compareCurrentBranch]
: []
}, [compareCurrentBranch, compareFilterKeyword])
const filteredCompareBranches = useMemo(() => {
if (!compareFilterKeyword) {
return compareBranchList
}
return {
local: compareBranchList.local.filter((branch) =>
branch.toLowerCase().includes(compareFilterKeyword)
),
remote: compareBranchList.remote.filter((branch) =>
branch.toLowerCase().includes(compareFilterKeyword)
),
}
}, [compareBranchList, compareFilterKeyword])
const groupedCompareRemoteBranches = useMemo(() => {
const groups: Record<string, string[]> = {}
for (const b of filteredCompareBranches.remote) {
const slashIndex = b.indexOf("/")
const remoteName = slashIndex > 0 ? b.substring(0, slashIndex) : "origin"
if (!groups[remoteName]) groups[remoteName] = []
groups[remoteName].push(b)
}
return groups
}, [filteredCompareBranches.remote])
const compareRemoteNames = Object.keys(groupedCompareRemoteBranches)
const hasMultipleCompareRemotes = compareRemoteNames.length > 1
const directoryGitTreeNodes = useMemo(() => {
if (!directoryGitActionTarget) return []
return buildDirectoryGitTree(
directoryGitCandidates,
directoryGitActionTarget.path
)
}, [directoryGitActionTarget, directoryGitCandidates])
const directoryGitAllFilePaths = useMemo(
() => directoryGitCandidates.map((entry) => entry.path),
[directoryGitCandidates]
)
const directoryGitAllSelected = useMemo(
() =>
directoryGitAllFilePaths.length > 0 &&
directoryGitAllFilePaths.every((path) =>
directoryGitSelectedPaths.has(path)
),
[directoryGitAllFilePaths, directoryGitSelectedPaths]
)
const directoryGitFilePathSet = useMemo(
() => new Set(directoryGitAllFilePaths),
[directoryGitAllFilePaths]
)
const directoryGitLeafPathsByDirPath = useMemo(() => {
const next = new Map<string, string[]>()
const collect = (node: DirectoryGitTreeNode) => {
if (node.kind === "file") return
next.set(node.path, collectDirectoryGitTreeLeafPaths(node))
for (const child of node.children) {
if (child.kind === "dir") collect(child)
}
}
for (const node of directoryGitTreeNodes) {
if (node.kind === "dir") collect(node)
}
return next
}, [directoryGitTreeNodes])
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 handleToggleDirectoryGitSelectAll = useCallback(() => {
setDirectoryGitSelectedPaths((prev) => {
if (
directoryGitAllFilePaths.length > 0 &&
directoryGitAllFilePaths.every((path) => prev.has(path))
) {
return new Set<string>()
}
return new Set(directoryGitAllFilePaths)
})
}, [directoryGitAllFilePaths])
const handleToggleDirectoryGitDir = useCallback(
(dirPath: string) => {
const leafPaths = directoryGitLeafPathsByDirPath.get(dirPath) ?? []
if (leafPaths.length === 0) return
setDirectoryGitSelectedPaths((prev) => {
const next = new Set(prev)
const allSelected = leafPaths.every((path) => next.has(path))
if (allSelected) {
for (const path of leafPaths) next.delete(path)
} else {
for (const path of leafPaths) next.add(path)
}
return next
})
},
[directoryGitLeafPathsByDirPath]
)
const handleDirectoryGitTreeSelect = useCallback(
(path: string) => {
if (path === DIRECTORY_GIT_TREE_ROOT_PATH) {
handleToggleDirectoryGitSelectAll()
return
}
if (directoryGitLeafPathsByDirPath.has(path)) {
handleToggleDirectoryGitDir(path)
return
}
if (directoryGitFilePathSet.has(path)) {
handleToggleDirectoryGitFile(path)
}
},
[
directoryGitFilePathSet,
directoryGitLeafPathsByDirPath,
handleToggleDirectoryGitDir,
handleToggleDirectoryGitFile,
handleToggleDirectoryGitSelectAll,
]
)
const renderDirectoryGitTreeNode = useCallback(
(node: DirectoryGitTreeNode): ReactNode => {
if (node.kind === "dir") {
const leafPaths = directoryGitLeafPathsByDirPath.get(node.path) ?? []
const allSelected =
leafPaths.length > 0 &&
leafPaths.every((path) => directoryGitSelectedPaths.has(path))
const partiallySelected =
!allSelected &&
leafPaths.some((path) => directoryGitSelectedPaths.has(path))
return (
<FileTreeFolder
key={node.path}
path={node.path}
name={`${allSelected ? "[x]" : partiallySelected ? "[-]" : "[ ]"} ${node.name}`}
suffix={`(${node.fileCount})`}
suffixClassName="text-muted-foreground/45"
title={node.path}
>
{node.children.map(renderDirectoryGitTreeNode)}
</FileTreeFolder>
)
}
const selected = directoryGitSelectedPaths.has(node.path)
return (
<FileTreeFile
key={node.path}
path={node.path}
name={node.name}
className="gap-1 px-1.5 py-1"
title={node.path}
>
<button
type="button"
onClick={(event) => {
event.stopPropagation()
handleToggleDirectoryGitFile(node.path)
}}
className={
selected
? "flex h-4 w-4 shrink-0 items-center justify-center rounded border border-primary bg-primary text-primary-foreground transition-colors"
: "flex h-4 w-4 shrink-0 items-center justify-center rounded border border-input transition-colors"
}
aria-label={t("aria.selectPath", {
action: selected ? t("actions.unselect") : t("actions.select"),
path: node.path,
})}
disabled={directoryGitSubmitting}
>
{selected && <Check className="h-3 w-3" />}
</button>
<button
type="button"
className="flex-1 truncate text-left"
onClick={(event) => {
event.stopPropagation()
handleToggleDirectoryGitFile(node.path)
}}
title={node.path}
disabled={directoryGitSubmitting}
>
{node.name}
</button>
<span className="w-8 shrink-0 text-right text-[10px] font-medium text-muted-foreground">
{node.status}
</span>
</FileTreeFile>
)
},
[
directoryGitLeafPathsByDirPath,
directoryGitSelectedPaths,
directoryGitSubmitting,
handleToggleDirectoryGitFile,
t,
]
)
const handleCreateConfirm = useCallback(async () => {
if (!folder?.path || createParentPath === null) return
const trimmedName = createName.trim()
if (!trimmedName) {
setCreateParentPath(null)
return
}
setCreating(true)
try {
await createFileTreeEntry(
folder.path,
createParentPath,
trimmedName,
createKind
)
setCreateParentPath(null)
setCreateName("")
await fetchTree()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.createFailed"), { description: message })
} finally {
setCreating(false)
}
}, [createKind, createName, createParentPath, fetchTree, folder?.path, t])
const handleRenameConfirm = useCallback(async () => {
if (!folder?.path || !renameTarget) return
const nextName = renameValue.trim()
if (!nextName || nextName === renameTarget.name) {
setRenameTarget(null)
return
}
setRenaming(true)
try {
await renameFileTreeEntry(folder.path, renameTarget.path, nextName)
setRenameTarget(null)
setRenameValue("")
await fetchTree()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.renameFailed"), { description: message })
} finally {
setRenaming(false)
}
}, [fetchTree, folder?.path, renameTarget, renameValue, t])
const handleDeleteConfirm = useCallback(async () => {
if (!folder?.path || !deleteTarget) return
setDeleting(true)
try {
await deleteFileTreeEntry(folder.path, deleteTarget.path)
setDeleteTarget(null)
await fetchTree()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.deleteFailed"), { description: message })
} finally {
setDeleting(false)
}
}, [deleteTarget, fetchTree, folder?.path, t])
const handleRollbackConfirm = useCallback(async () => {
if (!folder?.path || !rollbackTarget) return
setRollingBack(true)
try {
await gitRollbackFile(folder.path, rollbackTarget.path)
toast.success(t("toasts.rolledBack", { name: rollbackTarget.name }))
setRollbackTarget(null)
await fetchTree()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.rollbackFailed"), { description: message })
} finally {
setRollingBack(false)
}
}, [fetchTree, folder?.path, rollbackTarget, t])
const handleDirectoryGitActionConfirm = useCallback(async () => {
if (!folder?.path || !directoryGitActionType) return
if (directoryGitSelectedPaths.size === 0) return
const selectedPaths = Array.from(directoryGitSelectedPaths)
setDirectoryGitSubmitting(true)
setDirectoryGitError(null)
try {
if (directoryGitActionType === "add") {
await gitAddFiles(folder.path, selectedPaths)
toast.success(
t("toasts.addedFilesToVcs", {
count: selectedPaths.length,
})
)
} else {
for (const filePath of selectedPaths) {
await gitRollbackFile(folder.path, filePath)
}
toast.success(
t("toasts.rolledBackFiles", {
count: selectedPaths.length,
})
)
}
resetDirectoryGitActionDialog()
await fetchTree()
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
setDirectoryGitError(message)
toast.error(
directoryGitActionType === "add"
? t("toasts.addToVcsFailed")
: t("toasts.rollbackFailed"),
{
description: message,
}
)
} finally {
setDirectoryGitSubmitting(false)
}
}, [
directoryGitActionType,
directoryGitSelectedPaths,
fetchTree,
folder?.path,
resetDirectoryGitActionDialog,
t,
])
const handleCompareBranchClick = useCallback(
async (branch: string) => {
const nextBranch = branch.trim()
if (!compareTarget || !nextBranch || comparing) return
setComparing(true)
try {
if (compareTarget.kind === "dir") {
await openBranchDiff(nextBranch, compareTarget.path, {
mode: "overview",
})
} else {
await openBranchDiff(nextBranch, compareTarget.path)
}
setCompareTarget(null)
setCompareBranchFilter("")
setCompareCurrentBranch(null)
} finally {
setComparing(false)
}
},
[compareTarget, comparing, openBranchDiff]
)
const handleCompareExternalConflict = useCallback(() => {
if (!externalConflictPrompt) return
const latestTab = activeFileTabRef.current
const unsavedContent =
latestTab &&
latestTab.kind === "file" &&
latestTab.path &&
normalizeComparePath(latestTab.path) ===
normalizeComparePath(externalConflictPrompt.path) &&
!latestTab.loading
? latestTab.content
: externalConflictPrompt.unsavedContent
openExternalConflictDiff(
externalConflictPrompt.path,
externalConflictPrompt.diskContent,
unsavedContent
)
setExternalConflictPrompt(null)
}, [externalConflictPrompt, openExternalConflictDiff])
const handleReloadExternalConflict = useCallback(() => {
if (!externalConflictPrompt) return
externalConflictSignatureByPathRef.current.delete(
externalConflictPrompt.path
)
setExternalConflictPrompt(null)
void openFilePreview(externalConflictPrompt.path)
}, [externalConflictPrompt, openFilePreview])
const handleSaveExternalConflictCopy = useCallback(async () => {
if (!folder?.path || !externalConflictPrompt) return
const latestTab = activeFileTabRef.current
const unsavedContent =
latestTab &&
latestTab.kind === "file" &&
latestTab.path &&
normalizeComparePath(latestTab.path) ===
normalizeComparePath(externalConflictPrompt.path) &&
!latestTab.loading
? latestTab.content
: externalConflictPrompt.unsavedContent
setSavingExternalConflictCopy(true)
try {
const result = await saveFileCopy(
folder.path,
externalConflictPrompt.path,
unsavedContent
)
toast.success(t("toasts.savedAsCopy"), {
description: result.path,
})
setExternalConflictPrompt(null)
void fetchTree({ silent: true })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.saveCopyFailed"), { description: message })
} finally {
setSavingExternalConflictCopy(false)
}
}, [externalConflictPrompt, fetchTree, folder?.path, t])
const rootNodeName = useMemo(() => {
if (!folder?.path) return t("workspace")
return baseName(folder.path)
}, [folder?.path, t])
const systemExplorerLabel =
typeof navigator === "undefined"
? t("openInFileManager")
: (() => {
const platform =
`${navigator.platform} ${navigator.userAgent}`.toLowerCase()
if (platform.includes("mac")) return t("openInFinder")
if (platform.includes("win")) return t("openInExplorer")
return t("openInFileManager")
})()
const rootTarget: FileActionTarget = useMemo(
() => ({ kind: "dir", path: "", name: rootNodeName }),
[rootNodeName]
)
useEffect(() => {
if (!isFileTreeTabActive) return
void fetchTree()
}, [fetchTree, isFileTreeTabActive])
useEffect(() => {
if (!isFileTreeTabActive || !folder?.path) return
if (desiredTreeDepth <= loadedTreeDepth) return
void fetchTree({ silent: true, maxDepth: desiredTreeDepth })
}, [
desiredTreeDepth,
fetchTree,
folder?.path,
isFileTreeTabActive,
loadedTreeDepth,
])
useEffect(() => {
const rootPath = folder?.path
if (!rootPath) return
let unlisten: (() => void) | null = null
const normalizedRootPath = normalizeComparePath(rootPath)
const scheduleTreeRefresh = (refreshGitStatus: boolean) => {
if (!isFileTreeTabActiveRef.current) {
pendingTreeRefreshRef.current = true
pendingTreeRefreshNeedsStatusRef.current =
pendingTreeRefreshNeedsStatusRef.current || refreshGitStatus
if (refreshGitStatus) {
pendingStatusRefreshRef.current = false
}
return
}
treeRefreshNeedsStatusRef.current =
treeRefreshNeedsStatusRef.current || refreshGitStatus
if (treeRefreshTimerRef.current) {
clearTimeout(treeRefreshTimerRef.current)
}
treeRefreshTimerRef.current = setTimeout(() => {
const needsStatus = treeRefreshNeedsStatusRef.current
treeRefreshNeedsStatusRef.current = false
void fetchTree({ silent: true, skipStatus: !needsStatus })
}, 180)
}
const scheduleStatusRefresh = () => {
if (!isFileTreeTabActiveRef.current) {
if (pendingTreeRefreshRef.current) {
pendingTreeRefreshNeedsStatusRef.current = true
} else {
pendingStatusRefreshRef.current = true
}
return
}
if (statusRefreshTimerRef.current) {
clearTimeout(statusRefreshTimerRef.current)
}
statusRefreshTimerRef.current = setTimeout(() => {
void fetchTree({ skipTree: true, silent: true })
}, 120)
}
const getActiveChangedFilePath = (
changedPaths: string[],
fullReload: boolean
) => {
if (fullReload) return null
const currentTab = activeFileTabRef.current
if (!currentTab || currentTab.kind !== "file") return null
if (!currentTab.path || currentTab.loading) return null
const normalizedActivePath = normalizeComparePath(currentTab.path)
const activePathChanged = changedPaths.some(
(changedPath) =>
normalizeComparePath(changedPath) === normalizedActivePath
)
if (!activePathChanged) return null
return currentTab.path
}
type ActiveFileChangeDecision =
| { kind: "none" }
| { kind: "reload"; path: string }
| {
kind: "conflict"
path: string
diskContent: string
unsavedContent: string
signature: string
}
const resolveActiveFileChangeDecision = async (
path: string
): Promise<ActiveFileChangeDecision> => {
const currentTab = activeFileTabRef.current
if (!currentTab || currentTab.kind !== "file") return { kind: "none" }
if (
normalizeComparePath(currentTab.path ?? "") !==
normalizeComparePath(path)
) {
return { kind: "none" }
}
if (currentTab.loading) return { kind: "none" }
const knownTabEtag = currentTab.etag ?? null
try {
const latest = await readFileForEdit(rootPath, path)
const latestTab = activeFileTabRef.current
if (!latestTab || latestTab.kind !== "file") return { kind: "none" }
if (
normalizeComparePath(latestTab.path ?? "") !==
normalizeComparePath(path)
) {
return { kind: "none" }
}
if (latestTab.loading) return { kind: "none" }
const latestTabEtag = latestTab.etag ?? null
if (latest.etag === latestTabEtag) return { kind: "none" }
if (latestTab.isDirty) {
return {
kind: "conflict",
path,
diskContent: latest.content,
unsavedContent: latestTab.content,
signature: `${path}:${latest.etag}`,
}
}
return { kind: "reload", path }
} catch {
const latestTab = activeFileTabRef.current
if (!latestTab || latestTab.kind !== "file") return { kind: "none" }
if (
normalizeComparePath(latestTab.path ?? "") !==
normalizeComparePath(path)
) {
return { kind: "none" }
}
if (latestTab.loading) return { kind: "none" }
if (latestTab.isDirty) return { kind: "none" }
if (!knownTabEtag) return { kind: "reload", path }
// Fallback: if probe fails but tab is clean, reload to reflect latest disk state.
return { kind: "reload", path }
}
}
const setup = async () => {
try {
await startFileTreeWatch(rootPath)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
toast.error(t("toasts.watchStartFailed"), { description: message })
}
try {
unlisten = await subscribe<FileTreeChangedEvent>(
"folder://file-tree-changed",
(payload) => {
if (
normalizeComparePath(payload.root_path) !== normalizedRootPath
) {
return
}
const changedPaths = payload.changed_paths.map(normalizeComparePath)
const shouldRefreshGitStatus = payload.refresh_git_status ?? true
const nonGitChangedPaths = changedPaths.filter(
(path) => !isGitMetadataPath(path)
)
const onlyGitMetadataChanges =
changedPaths.length > 0 && nonGitChangedPaths.length === 0
const hasUnknownPath = nonGitChangedPaths.some(
(path) => !filePathSetRef.current.has(path)
)
const needsTreeRefresh =
payload.full_reload ||
(!onlyGitMetadataChanges &&
(payload.kind !== "modify" ||
nonGitChangedPaths.length === 0 ||
hasUnknownPath))
if (onlyGitMetadataChanges && !payload.full_reload) {
if (shouldRefreshGitStatus) {
scheduleStatusRefresh()
}
} else if (needsTreeRefresh) {
scheduleTreeRefresh(shouldRefreshGitStatus)
} else if (shouldRefreshGitStatus) {
scheduleStatusRefresh()
}
if (onlyGitMetadataChanges && !payload.full_reload) {
return
}
const changedActivePath = getActiveChangedFilePath(
nonGitChangedPaths,
payload.full_reload
)
if (!changedActivePath) return
void (async () => {
const decision =
await resolveActiveFileChangeDecision(changedActivePath)
if (decision.kind === "none") return
if (decision.kind === "reload") {
externalConflictSignatureByPathRef.current.delete(decision.path)
void openFilePreview(decision.path)
return
}
const shownSignature =
externalConflictSignatureByPathRef.current.get(decision.path)
if (shownSignature === decision.signature) return
externalConflictSignatureByPathRef.current.set(
decision.path,
decision.signature
)
setExternalConflictPrompt((current) => {
if (current?.signature === decision.signature) return current
return {
path: decision.path,
diskContent: decision.diskContent,
unsavedContent: decision.unsavedContent,
signature: decision.signature,
}
})
})()
}
)
} catch (error) {
console.error("[FileTreeTab] failed to listen file watch event:", error)
}
}
void setup()
return () => {
if (treeRefreshTimerRef.current) {
clearTimeout(treeRefreshTimerRef.current)
treeRefreshTimerRef.current = null
}
treeRefreshNeedsStatusRef.current = false
if (statusRefreshTimerRef.current) {
clearTimeout(statusRefreshTimerRef.current)
statusRefreshTimerRef.current = null
}
pendingTreeRefreshRef.current = false
pendingTreeRefreshNeedsStatusRef.current = false
pendingStatusRefreshRef.current = false
unlisten?.()
void stopFileTreeWatch(rootPath)
}
}, [fetchTree, folder?.path, openFilePreview, t])
if (loading && nodes.length === 0) {
return (
<div className="p-3 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2 ml-4" />
<Skeleton className="h-4 w-2/3 ml-4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-4 w-3/4 ml-4" />
</div>
)
}
if (error) {
return (
<div className="p-3 text-xs text-destructive">
<p>{error}</p>
<Button
variant="ghost"
size="xs"
className="mt-2"
onClick={() => {
void fetchTree()
}}
>
{t("retry")}
</Button>
</div>
)
}
return (
<div className="flex flex-col h-full">
<ContextMenu>
<ContextMenuTrigger asChild>
<div className="flex-1 min-h-0 overflow-auto pb-1 scrollbar-thin-edge">
<FileTree
key={folder?.path ?? "file-tree-empty"}
className="border-0 rounded-none bg-transparent w-max min-w-full"
expanded={expandedPaths}
onExpandedChange={setExpandedPaths}
selectedPath={activeFilePath ?? undefined}
onSelect={handleTreeSelect}
>
{folder?.path && (
<ContextMenu>
<ContextMenuTrigger>
<FileTreeFolder
path={FILE_TREE_ROOT_PATH}
name={rootNodeName}
className="font-medium"
>
{nodes.map((node) => (
<RenderNode
key={node.path}
node={node}
expandedPaths={expandedPaths}
workspacePath={folder.path}
activeSessionTabId={activeSessionTabId}
gitEnabled={gitEnabled}
gitStatusByPath={gitStatusByPath}
gitChangedDirPaths={gitChangedDirPaths}
untrackedDirPaths={untrackedDirPaths}
gitignoreIgnoredPaths={gitignoreIgnoredPaths}
ancestorGitignoreIgnored={false}
ancestorUntracked={false}
onOpenFilePreview={(path) => {
void openFilePreview(path)
}}
onOpenFileDiff={(path) => {
void openWorkingTreeDiff(path)
}}
onOpenDirDiff={(path) => {
void openWorkingTreeDiff(path, {
mode: "overview",
})
}}
onOpenCommitWindow={handleOpenCommitWindow}
onRequestCompareWithBranch={
handleRequestCompareWithBranch
}
onRequestRollback={handleRequestRollback}
onOpenDirInTerminal={handleOpenDirInTerminal}
onRequestCreate={handleRequestCreate}
onRequestAddToVcs={handleAddToVcs}
onRequestRename={handleRequestRename}
onRequestDelete={handleRequestDelete}
onRefresh={fetchTree}
/>
))}
</FileTreeFolder>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger>{t("new")}</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onSelect={() => handleRequestCreate("", "file")}
>
{t("newFile")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => handleRequestCreate("", "dir")}
>
{t("newDirectory")}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSub>
<ContextMenuSubTrigger disabled={!gitEnabled}>
{t("git")}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onSelect={() => handleOpenCommitWindow()}
disabled={!gitEnabled}
>
{t("actions.commitCode")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => void handleAddToVcs(rootTarget)}
disabled={!gitEnabled}
>
{t("actions.addToVcs")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() =>
void openWorkingTreeDiff(".", {
mode: "overview",
})
}
disabled={!gitEnabled}
>
{tCommon("viewDiff")}
</ContextMenuItem>
<ContextMenuItem
onSelect={() =>
handleRequestCompareWithBranch(rootTarget)
}
disabled={!gitEnabled}
>
{t("compareWithBranch")}
</ContextMenuItem>
<ContextMenuItem
variant="destructive"
onSelect={() => handleRequestRollback(rootTarget)}
disabled={!gitEnabled}
>
{t("actions.rollback")}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem
onSelect={() => {
void fetchTree()
}}
>
{t("reloadFromDisk")}
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>
{t("openIn")}
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem
onSelect={() => {
void revealItemInDir(folder.path)
}}
>
{systemExplorerLabel}
</ContextMenuItem>
<ContextMenuItem
onSelect={() => {
void handleOpenDirInTerminal(
folder.path,
rootNodeName
)
}}
>
{t("openInTerminal")}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
)}
</FileTree>
</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger>{t("new")}</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem onSelect={() => handleRequestCreate("", "file")}>
{t("newFile")}
</ContextMenuItem>
<ContextMenuItem onSelect={() => handleRequestCreate("", "dir")}>
{t("newDirectory")}
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem
onSelect={() => {
void fetchTree()
}}
>
{t("reloadFromDisk")}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<Dialog
open={createParentPath !== null}
onOpenChange={(open) => {
if (open) return
setCreateParentPath(null)
setCreateName("")
}}
>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault()
const input = (
e.currentTarget as HTMLElement | null
)?.querySelector("input")
if (input) requestAnimationFrame(() => input.focus())
}}
>
<DialogHeader>
<DialogTitle>
{createKind === "dir"
? t("createDialog.newDirectory")
: t("createDialog.newFile")}
</DialogTitle>
<DialogDescription>
{t("createDialog.description", {
kind:
createKind === "dir"
? t("newDirectory").toLowerCase()
: t("newFile").toLowerCase(),
})}
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault()
void handleCreateConfirm()
}}
className="space-y-4"
>
<Input
value={createName}
onChange={(event) => setCreateName(event.target.value)}
disabled={creating}
placeholder={
createKind === "dir"
? t("createDialog.placeholderDirectory")
: t("createDialog.placeholderFile")
}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={creating}
onClick={() => {
setCreateParentPath(null)
setCreateName("")
}}
>
{tCommon("cancel")}
</Button>
<Button type="submit" disabled={creating || !createName.trim()}>
{tCommon("create")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(renameTarget)}
onOpenChange={(open) => {
if (open) return
setRenameTarget(null)
setRenameValue("")
}}
>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault()
const input = (
e.currentTarget as HTMLElement | null
)?.querySelector("input")
if (input) requestAnimationFrame(() => input.focus())
}}
>
<DialogHeader>
<DialogTitle>
{renameTarget?.kind === "dir"
? t("renameDialog.renameDirectory")
: t("renameDialog.renameFile")}
</DialogTitle>
<DialogDescription>
{t("renameDialog.description")}
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
event.preventDefault()
void handleRenameConfirm()
}}
className="space-y-4"
>
<Input
value={renameValue}
onChange={(event) => setRenameValue(event.target.value)}
disabled={renaming}
placeholder={
renameTarget?.kind === "dir"
? t("renameDialog.placeholderDirectory")
: t("renameDialog.placeholderFile")
}
/>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={renaming}
onClick={() => {
setRenameTarget(null)
setRenameValue("")
}}
>
{tCommon("cancel")}
</Button>
<Button type="submit" disabled={renaming}>
{tCommon("confirm")}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(directoryGitActionType && directoryGitActionTarget)}
onOpenChange={(open) => {
if (open) return
resetDirectoryGitActionDialog()
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{directoryGitActionType === "add"
? t("actions.addToVcs")
: t("actions.rollback")}
</DialogTitle>
<DialogDescription>
{directoryGitActionTarget
? directoryGitActionType === "add"
? t("directoryDialog.descriptionAdd", {
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>
) : directoryGitTreeNodes.length > 0 &&
directoryGitActionTarget ? (
<FileTree
className="text-xs [&>div]:p-1"
expanded={directoryGitExpandedPaths}
onSelect={handleDirectoryGitTreeSelect}
onExpandedChange={setDirectoryGitExpandedPaths}
>
<FileTreeFolder
path={DIRECTORY_GIT_TREE_ROOT_PATH}
name={directoryGitActionTarget.name}
suffix={`(${directoryGitAllFilePaths.length})`}
suffixClassName="text-muted-foreground/45"
title={directoryGitActionTarget.path}
>
{directoryGitTreeNodes.map(renderDirectoryGitTreeNode)}
</FileTreeFolder>
</FileTree>
) : (
<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"
? "destructive"
: "default"
}
disabled={
directoryGitLoading ||
directoryGitSubmitting ||
directoryGitSelectedPaths.size === 0
}
onClick={() => {
void handleDirectoryGitActionConfirm()
}}
>
{directoryGitActionType === "add"
? t("actions.addToVcs")
: t("actions.rollback")}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(compareTarget)}
onOpenChange={(open) => {
if (open) return
setCompareTarget(null)
setCompareBranchFilter("")
setCompareCurrentBranch(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("compareDialog.title")}</DialogTitle>
<DialogDescription>
{compareTarget
? t("compareDialog.descriptionWithTarget", {
kind:
compareTarget.kind === "dir"
? t("compareDialog.kindDirectory")
: t("compareDialog.kindFile"),
path: compareTarget.path,
})
: t("compareDialog.descriptionFallback")}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<Input
value={compareBranchFilter}
onChange={(event) => setCompareBranchFilter(event.target.value)}
placeholder={t("compareDialog.filterPlaceholder")}
autoFocus
disabled={comparing}
/>
<div className="text-xs text-muted-foreground">
{t("compareDialog.singleClickHint")}
</div>
<div className="space-y-2">
<div className="max-h-56 overflow-y-auto rounded-xl border p-2 space-y-3">
{compareBranchLoading ? (
<div className="py-6 text-center text-xs text-muted-foreground">
{t("compareDialog.loadingBranches")}
</div>
) : (
<>
<Collapsible
open={compareRecentOpen}
onOpenChange={setCompareRecentOpen}
>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{t("compareDialog.recentBranches", {
count: filteredCompareRecentBranches.length,
})}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-1 pt-1">
{filteredCompareRecentBranches.length > 0 ? (
filteredCompareRecentBranches.map((branch) => (
<Button
key={`recent-${branch}`}
type="button"
size="xs"
variant="ghost"
className="w-full justify-start"
onClick={() => {
void handleCompareBranchClick(branch)
}}
disabled={comparing}
>
{branch}
</Button>
))
) : (
<div className="px-2 text-xs text-muted-foreground">
{t("compareDialog.noCurrentBranch")}
</div>
)}
</CollapsibleContent>
</Collapsible>
<Collapsible
open={compareLocalOpen}
onOpenChange={setCompareLocalOpen}
>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{t("compareDialog.localBranches", {
count: filteredCompareBranches.local.length,
})}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-1 pt-1">
{filteredCompareBranches.local.length > 0 ? (
filteredCompareBranches.local.map((branch) => (
<Button
key={`local-${branch}`}
type="button"
size="xs"
variant="ghost"
className="w-full justify-start"
onClick={() => {
void handleCompareBranchClick(branch)
}}
disabled={comparing}
>
{branch}
</Button>
))
) : (
<div className="px-2 text-xs text-muted-foreground">
{t("compareDialog.noMatchingBranches")}
</div>
)}
</CollapsibleContent>
</Collapsible>
<Collapsible
open={compareRemoteOpen}
onOpenChange={setCompareRemoteOpen}
>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{t("compareDialog.remoteBranches", {
count: filteredCompareBranches.remote.length,
})}
</CollapsibleTrigger>
<CollapsibleContent className="space-y-1 pt-1">
{filteredCompareBranches.remote.length > 0 ? (
hasMultipleCompareRemotes ? (
compareRemoteNames.map((remoteName) => (
<Collapsible key={remoteName}>
<CollapsibleTrigger className="flex w-full items-center gap-2.5 rounded-xl px-2 py-1.5 pl-5 text-sm hover:bg-accent hover:text-accent-foreground select-none outline-hidden">
<ChevronRight className="h-3 w-3 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{remoteName} (
{
groupedCompareRemoteBranches[remoteName]
.length
}
)
</CollapsibleTrigger>
<CollapsibleContent className="space-y-1 pt-1 pl-3">
{groupedCompareRemoteBranches[remoteName].map(
(branch) => (
<Button
key={`remote-${branch}`}
type="button"
size="xs"
variant="ghost"
className="w-full justify-start"
onClick={() => {
void handleCompareBranchClick(branch)
}}
disabled={comparing}
>
{branch.substring(
remoteName.length + 1
)}
</Button>
)
)}
</CollapsibleContent>
</Collapsible>
))
) : (
filteredCompareBranches.remote.map((branch) => {
const slashIndex = branch.indexOf("/")
const shortName =
slashIndex > 0
? branch.substring(slashIndex + 1)
: branch
return (
<Button
key={`remote-${branch}`}
type="button"
size="xs"
variant="ghost"
className="w-full justify-start pl-4"
onClick={() => {
void handleCompareBranchClick(branch)
}}
disabled={comparing}
>
{shortName}
</Button>
)
})
)
) : (
<div className="px-2 text-xs text-muted-foreground">
{t("compareDialog.noMatchingBranches")}
</div>
)}
</CollapsibleContent>
</Collapsible>
</>
)}
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={comparing}
onClick={() => {
setCompareTarget(null)
setCompareBranchFilter("")
setCompareCurrentBranch(null)
}}
>
{tCommon("cancel")}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
<Dialog
open={Boolean(externalConflictPrompt)}
onOpenChange={(open) => {
if (open) return
setExternalConflictPrompt(null)
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("externalConflictDialog.title")}</DialogTitle>
<DialogDescription>
{externalConflictPrompt
? t("externalConflictDialog.descriptionWithPath", {
path: externalConflictPrompt.path,
})
: t("externalConflictDialog.descriptionFallback")}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={savingExternalConflictCopy}
onClick={handleCompareExternalConflict}
>
{t("externalConflictDialog.compare")}
</Button>
<Button
type="button"
variant="outline"
disabled={savingExternalConflictCopy}
onClick={() => {
void handleSaveExternalConflictCopy()
}}
>
{savingExternalConflictCopy
? t("externalConflictDialog.savingCopy")
: t("externalConflictDialog.saveAsCopy")}
</Button>
<Button
type="button"
variant="destructive"
disabled={savingExternalConflictCopy}
onClick={handleReloadExternalConflict}
>
{t("externalConflictDialog.reload")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<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()
}}
>
{tCommon("delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog
open={Boolean(rollbackTarget)}
onOpenChange={(open) => {
if (open) return
setRollbackTarget(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("rollbackConfirm.title")}</AlertDialogTitle>
<AlertDialogDescription>
{rollbackTarget
? t("rollbackConfirm.descriptionWithTarget", {
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>
</div>
)
}