1565 lines
48 KiB
TypeScript
1565 lines
48 KiB
TypeScript
"use client"
|
|
|
|
import {
|
|
type ReactElement,
|
|
useCallback,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from "react"
|
|
import { listen, type UnlistenFn } from "@tauri-apps/api/event"
|
|
import { ChevronsDownUp, ChevronsUpDown } 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 {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
} from "@/components/ui/context-menu"
|
|
import { Skeleton } from "@/components/ui/skeleton"
|
|
import { useAuxPanelContext } from "@/contexts/aux-panel-context"
|
|
import { useFolderContext } from "@/contexts/folder-context"
|
|
import { useWorkspaceContext } from "@/contexts/workspace-context"
|
|
import {
|
|
gitDiff,
|
|
gitAddFiles,
|
|
gitRollbackFile,
|
|
gitStatus,
|
|
openCommitWindow,
|
|
startFileTreeWatch,
|
|
stopFileTreeWatch,
|
|
} from "@/lib/tauri"
|
|
import { disposeTauriListener } from "@/lib/tauri-listener"
|
|
import type { FileTreeChangedEvent, 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"
|
|
|
|
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()
|
|
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 filterDirectoryGitCandidates(
|
|
entries: DirectoryGitCandidateEntry[],
|
|
action: DirectoryGitAction
|
|
): DirectoryGitCandidateEntry[] {
|
|
if (action === "add") {
|
|
return entries.filter((entry) => entry.status.trim().length > 0)
|
|
}
|
|
|
|
return entries.filter((entry) => {
|
|
const fileState = classifyGitFileState(entry.status)
|
|
return fileState !== "untracked"
|
|
})
|
|
}
|
|
|
|
function normalizeDiffPath(rawPath: string): string | null {
|
|
const trimmed = rawPath.trim().replace(/^"|"$/g, "")
|
|
if (!trimmed || trimmed === "/dev/null") return null
|
|
if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) {
|
|
return trimmed.slice(2).replace(/\\/g, "/")
|
|
}
|
|
return trimmed.replace(/\\/g, "/")
|
|
}
|
|
|
|
function parsePathFromDiffGitLine(line: string): string | null {
|
|
if (!line.startsWith("diff --git ")) return null
|
|
const match = line.match(/^diff --git\s+(.+?)\s+(.+)$/)
|
|
if (!match) return null
|
|
return normalizeDiffPath(match[2]) ?? normalizeDiffPath(match[1])
|
|
}
|
|
|
|
function parseDiffStatsMap(
|
|
diffText: string
|
|
): Map<string, { additions: number; deletions: number }> {
|
|
const stats = new Map<string, { additions: number; deletions: number }>()
|
|
let currentPath: string | null = null
|
|
|
|
for (const line of diffText.split("\n")) {
|
|
const nextPath = parsePathFromDiffGitLine(line)
|
|
if (nextPath) {
|
|
currentPath = nextPath
|
|
if (!stats.has(currentPath)) {
|
|
stats.set(currentPath, { additions: 0, deletions: 0 })
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (!currentPath) continue
|
|
const current = stats.get(currentPath)
|
|
if (!current) continue
|
|
|
|
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
current.additions += 1
|
|
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
current.deletions += 1
|
|
}
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
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")
|
|
}
|
|
|
|
function shouldRefreshFromEvent(event: FileTreeChangedEvent): boolean {
|
|
const shouldRefreshGitStatus = event.refresh_git_status ?? true
|
|
if (!shouldRefreshGitStatus) return false
|
|
if (event.kind === "access") return false
|
|
return true
|
|
}
|
|
|
|
function toWorkingTreeChanges(
|
|
entries: GitStatusEntry[],
|
|
diffText: string
|
|
): WorkingTreeChange[] {
|
|
const stats = parseDiffStatsMap(diffText)
|
|
|
|
return entries
|
|
.map((entry) => {
|
|
const path = normalizeGitStatusPath(entry.file)
|
|
if (!path) return null
|
|
const diffStat = stats.get(path)
|
|
return {
|
|
path,
|
|
status: entry.status.trim() || "M",
|
|
additions: diffStat?.additions ?? 0,
|
|
deletions: diffStat?.deletions ?? 0,
|
|
}
|
|
})
|
|
.filter((change): change is WorkingTreeChange => change !== null)
|
|
.sort((left, right) =>
|
|
left.path.localeCompare(right.path, undefined, { sensitivity: "base" })
|
|
)
|
|
}
|
|
|
|
export function GitChangesTab() {
|
|
const t = useTranslations("Folder.gitChangesTab")
|
|
const tCommon = useTranslations("Folder.common")
|
|
const { folder } = useFolderContext()
|
|
const { activeTab } = useAuxPanelContext()
|
|
const { openFilePreview, openWorkingTreeDiff } = useWorkspaceContext()
|
|
|
|
const [changes, setChanges] = useState<WorkingTreeChange[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(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 [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 refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
|
|
const isChangesTabActive = activeTab === "changes"
|
|
|
|
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 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 fetchChanges = useCallback(
|
|
async (options?: { inline?: boolean }) => {
|
|
if (!folder?.path) {
|
|
setLoading(false)
|
|
setError(null)
|
|
setChanges([])
|
|
return
|
|
}
|
|
|
|
const inline = options?.inline ?? false
|
|
if (!inline) {
|
|
setLoading(true)
|
|
}
|
|
setError(null)
|
|
|
|
try {
|
|
const statusEntries = await gitStatus(folder.path)
|
|
const hasTrackedEntries = statusEntries.some(
|
|
(entry) => !isUntrackedStatus(entry.status)
|
|
)
|
|
const diffText = hasTrackedEntries
|
|
? await gitDiff(folder.path).catch(() => "")
|
|
: ""
|
|
setChanges(toWorkingTreeChanges(statusEntries, diffText))
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : String(err))
|
|
} finally {
|
|
if (!inline) {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
},
|
|
[folder?.path]
|
|
)
|
|
|
|
useEffect(() => {
|
|
if (!isChangesTabActive) return
|
|
void fetchChanges()
|
|
}, [fetchChanges, isChangesTabActive])
|
|
|
|
useEffect(() => {
|
|
const rootPath = folder?.path
|
|
if (!rootPath || !isChangesTabActive) return
|
|
|
|
let unlisten: UnlistenFn | null = null
|
|
const normalizedRootPath = normalizeComparePath(rootPath)
|
|
|
|
const scheduleRefresh = () => {
|
|
if (refreshTimerRef.current) {
|
|
clearTimeout(refreshTimerRef.current)
|
|
}
|
|
refreshTimerRef.current = setTimeout(() => {
|
|
void fetchChanges({ inline: true })
|
|
}, 220)
|
|
}
|
|
|
|
const setup = async () => {
|
|
try {
|
|
await startFileTreeWatch(rootPath)
|
|
} catch {
|
|
// ignore watch startup errors
|
|
}
|
|
|
|
try {
|
|
unlisten = await listen<FileTreeChangedEvent>(
|
|
"folder://file-tree-changed",
|
|
(event) => {
|
|
if (
|
|
normalizeComparePath(event.payload.root_path) !==
|
|
normalizedRootPath
|
|
) {
|
|
return
|
|
}
|
|
if (!shouldRefreshFromEvent(event.payload)) return
|
|
scheduleRefresh()
|
|
}
|
|
)
|
|
} catch {
|
|
// ignore listen errors
|
|
}
|
|
}
|
|
|
|
void setup()
|
|
|
|
return () => {
|
|
if (refreshTimerRef.current) {
|
|
clearTimeout(refreshTimerRef.current)
|
|
refreshTimerRef.current = null
|
|
}
|
|
disposeTauriListener(unlisten, "AuxPanelGitChanges.fileTreeChanged")
|
|
void stopFileTreeWatch(rootPath)
|
|
}
|
|
}, [fetchChanges, folder?.path, isChangesTabActive])
|
|
|
|
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 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)
|
|
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
|
|
}
|
|
|
|
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 fetchChanges({ inline: true })
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
toast.error(t("toasts.addToVcsFailed"), { description: message })
|
|
}
|
|
},
|
|
[fetchChanges, folder?.path, openDirectoryGitActionDialog, 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 fetchChanges({ inline: true })
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
toast.error(t("toasts.rollbackFailed"), { description: message })
|
|
} finally {
|
|
setRollingBack(false)
|
|
}
|
|
}, [fetchChanges, folder?.path, rollbackTarget, t])
|
|
|
|
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 {
|
|
for (const filePath of selectedPaths) {
|
|
await gitRollbackFile(folder.path, filePath)
|
|
}
|
|
toast.success(
|
|
t("toasts.rolledBackFiles", {
|
|
count: selectedPaths.length,
|
|
})
|
|
)
|
|
}
|
|
|
|
resetDirectoryGitActionDialog()
|
|
await fetchChanges({ inline: true })
|
|
} 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,
|
|
fetchChanges,
|
|
folder?.path,
|
|
resetDirectoryGitActionDialog,
|
|
t,
|
|
])
|
|
|
|
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 asChild>
|
|
<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={() => {
|
|
handleRequestRollback(target)
|
|
}}
|
|
variant="destructive"
|
|
>
|
|
{t("actions.rollback")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
void handleAddToVcs(target)
|
|
}}
|
|
>
|
|
{t("actions.addToVcs")}
|
|
</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 asChild>
|
|
<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={() => {
|
|
handleRequestRollback(target)
|
|
}}
|
|
variant="destructive"
|
|
>
|
|
{t("actions.rollback")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
void handleAddToVcs(target)
|
|
}}
|
|
>
|
|
{t("actions.addToVcs")}
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
)
|
|
},
|
|
[
|
|
handleOpenCommitWindow,
|
|
handleAddToVcs,
|
|
handleRequestRollback,
|
|
openFilePreview,
|
|
openWorkingTreeDiff,
|
|
t,
|
|
tCommon,
|
|
]
|
|
)
|
|
|
|
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 asChild>
|
|
<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={() => {
|
|
handleRequestRollback(target)
|
|
}}
|
|
variant="destructive"
|
|
>
|
|
{t("actions.rollback")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
void handleAddToVcs(target)
|
|
}}
|
|
>
|
|
{t("actions.addToVcs")}
|
|
</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 asChild>
|
|
<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={() => {
|
|
handleRequestRollback(target)
|
|
}}
|
|
variant="destructive"
|
|
>
|
|
{t("actions.rollback")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
void handleAddToVcs(target)
|
|
}}
|
|
>
|
|
{t("actions.addToVcs")}
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
)
|
|
},
|
|
[
|
|
handleOpenCommitWindow,
|
|
handleAddToVcs,
|
|
handleRequestRollback,
|
|
openFilePreview,
|
|
openWorkingTreeDiff,
|
|
t,
|
|
tCommon,
|
|
]
|
|
)
|
|
|
|
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="h-full min-h-0 overflow-y-auto">
|
|
{trackedChanges.length === 0 && untrackedChanges.length === 0 ? (
|
|
<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 asChild>
|
|
<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={() => {
|
|
handleRequestRollback({
|
|
kind: "dir",
|
|
path: "",
|
|
name: folderName,
|
|
})
|
|
}}
|
|
variant="destructive"
|
|
>
|
|
{t("actions.rollback")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
void handleAddToVcs({
|
|
kind: "dir",
|
|
path: "",
|
|
name: folderName,
|
|
})
|
|
}}
|
|
>
|
|
{t("actions.addToVcs")}
|
|
</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 asChild>
|
|
<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={() => {
|
|
handleRequestRollback({
|
|
kind: "dir",
|
|
path: "",
|
|
name: folderName,
|
|
})
|
|
}}
|
|
variant="destructive"
|
|
>
|
|
{t("actions.rollback")}
|
|
</ContextMenuItem>
|
|
<ContextMenuItem
|
|
onSelect={() => {
|
|
void handleAddToVcs({
|
|
kind: "dir",
|
|
path: "",
|
|
name: folderName,
|
|
})
|
|
}}
|
|
>
|
|
{t("actions.addToVcs")}
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
</FileTree>
|
|
</section>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<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>
|
|
) : 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>
|
|
<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"
|
|
? "destructive"
|
|
: "default"
|
|
}
|
|
disabled={
|
|
directoryGitLoading ||
|
|
directoryGitSubmitting ||
|
|
directoryGitSelectedPaths.size === 0
|
|
}
|
|
onClick={() => {
|
|
void handleDirectoryGitActionConfirm()
|
|
}}
|
|
>
|
|
{directoryGitActionType === "add"
|
|
? t("actions.addToVcs")
|
|
: 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>
|
|
</>
|
|
)
|
|
}
|