Initial commit
This commit is contained in:
998
src/components/layout/commit-dialog.tsx
Normal file
998
src/components/layout/commit-dialog.tsx
Normal file
@@ -0,0 +1,998 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Check, ChevronDown, ChevronRight, Loader2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { Textarea } from "@/components/ui/textarea"
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "@/components/ui/resizable"
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu"
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog"
|
||||
import {
|
||||
FileTree,
|
||||
FileTreeFile,
|
||||
FileTreeFolder,
|
||||
} from "@/components/ai-elements/file-tree"
|
||||
import {
|
||||
gitAddFiles,
|
||||
gitCommit,
|
||||
gitRollbackFile,
|
||||
gitShowFile,
|
||||
gitStatus,
|
||||
deleteFileTreeEntry,
|
||||
readFilePreview,
|
||||
} from "@/lib/tauri"
|
||||
import type { GitStatusEntry } from "@/lib/types"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
import { DiffViewer } from "@/components/diff/diff-viewer"
|
||||
import { languageFromPath } from "@/lib/language-detect"
|
||||
|
||||
interface CommitWorkspaceProps {
|
||||
folderPath: string
|
||||
onCommitted?: () => void
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
interface TreeFileNode {
|
||||
kind: "file"
|
||||
name: string
|
||||
path: string
|
||||
entry: GitStatusEntry
|
||||
}
|
||||
|
||||
interface TreeDirNode {
|
||||
kind: "dir"
|
||||
name: string
|
||||
path: string
|
||||
children: TreeNode[]
|
||||
}
|
||||
|
||||
type TreeNode = TreeFileNode | TreeDirNode
|
||||
|
||||
const UNTRACKED_STATUS = "??"
|
||||
const DEFAULT_LEFT_PANE_WIDTH = 420
|
||||
const MIN_LEFT_PANE_WIDTH = 320
|
||||
const MIN_RIGHT_PANE_WIDTH = 360
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.max(min, Math.min(max, value))
|
||||
}
|
||||
|
||||
function toPercent(pixels: number, totalPixels: number): number {
|
||||
if (totalPixels <= 0) return 0
|
||||
return (pixels / totalPixels) * 100
|
||||
}
|
||||
|
||||
function buildFileTree(entries: GitStatusEntry[]): TreeNode[] {
|
||||
type BuildDir = {
|
||||
name: string
|
||||
path: string
|
||||
dirs: Map<string, BuildDir>
|
||||
files: TreeFileNode[]
|
||||
}
|
||||
|
||||
const root: BuildDir = {
|
||||
name: "",
|
||||
path: "",
|
||||
dirs: new Map(),
|
||||
files: [],
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const parts = entry.file.split("/").filter(Boolean)
|
||||
if (parts.length === 0) continue
|
||||
|
||||
let current = root
|
||||
let currentPath = ""
|
||||
|
||||
for (let i = 0; i < parts.length; i += 1) {
|
||||
const part = parts[i]
|
||||
const isLeaf = i === parts.length - 1
|
||||
currentPath = currentPath ? `${currentPath}/${part}` : part
|
||||
|
||||
if (isLeaf) {
|
||||
current.files.push({
|
||||
kind: "file",
|
||||
name: part,
|
||||
path: currentPath,
|
||||
entry,
|
||||
})
|
||||
} else {
|
||||
const found = current.dirs.get(part)
|
||||
if (found) {
|
||||
current = found
|
||||
} else {
|
||||
const next: BuildDir = {
|
||||
name: part,
|
||||
path: currentPath,
|
||||
dirs: new Map(),
|
||||
files: [],
|
||||
}
|
||||
current.dirs.set(part, next)
|
||||
current = next
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sortNodes(nodes: TreeNode[]) {
|
||||
return nodes.sort((a, b) => {
|
||||
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
}
|
||||
|
||||
function toNodes(dir: BuildDir): TreeNode[] {
|
||||
const dirs: TreeNode[] = Array.from(dir.dirs.values()).map((child) => ({
|
||||
kind: "dir",
|
||||
name: child.name,
|
||||
path: child.path,
|
||||
children: toNodes(child),
|
||||
}))
|
||||
|
||||
return sortNodes([...dirs, ...dir.files])
|
||||
}
|
||||
|
||||
return toNodes(root)
|
||||
}
|
||||
|
||||
/** Depth-first traversal to find the first file node (matches visual order). */
|
||||
function findFirstFile(nodes: TreeNode[]): string | undefined {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "file") return node.path
|
||||
const found = findFirstFile(node.children)
|
||||
if (found) return found
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function collectDirPaths(entries: GitStatusEntry[]) {
|
||||
const paths = new Set<string>()
|
||||
|
||||
for (const entry of entries) {
|
||||
const parts = entry.file.split("/").filter(Boolean)
|
||||
if (parts.length < 2) continue
|
||||
|
||||
let currentPath = ""
|
||||
for (let i = 0; i < parts.length - 1; i += 1) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]
|
||||
paths.add(currentPath)
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
interface ConfirmState {
|
||||
open: boolean
|
||||
title: string
|
||||
description: string
|
||||
action: (() => void) | null
|
||||
variant: "default" | "destructive"
|
||||
}
|
||||
|
||||
const CONFIRM_INITIAL: ConfirmState = {
|
||||
open: false,
|
||||
title: "",
|
||||
description: "",
|
||||
action: null,
|
||||
variant: "default",
|
||||
}
|
||||
|
||||
export function CommitWorkspace({
|
||||
folderPath,
|
||||
onCommitted,
|
||||
onCancel,
|
||||
}: CommitWorkspaceProps) {
|
||||
const [entries, setEntries] = useState<GitStatusEntry[]>([])
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [diffOriginal, setDiffOriginal] = useState("")
|
||||
const [diffModified, setDiffModified] = useState("")
|
||||
const [diffLanguage, setDiffLanguage] = useState("plaintext")
|
||||
const [diffFile, setDiffFile] = useState<string | null>(null)
|
||||
const messageRef = useRef("")
|
||||
const [hasMessage, setHasMessage] = useState(false)
|
||||
const [messageInputKey, setMessageInputKey] = useState(0)
|
||||
const [loadingStatus, setLoadingStatus] = useState(false)
|
||||
const [loadingDiff, setLoadingDiff] = useState(false)
|
||||
const [committing, setCommitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [untrackedOpen, setUntrackedOpen] = useState(false)
|
||||
const [expandedTrackedDirs, setExpandedTrackedDirs] = useState<Set<string>>(
|
||||
new Set()
|
||||
)
|
||||
const [expandedUntrackedDirs, setExpandedUntrackedDirs] = useState<
|
||||
Set<string>
|
||||
>(new Set())
|
||||
const [confirm, setConfirm] = useState<ConfirmState>(CONFIRM_INITIAL)
|
||||
|
||||
// Use refs to track mutable values without causing callback recreation
|
||||
const diffFileRef = useRef(diffFile)
|
||||
diffFileRef.current = diffFile
|
||||
const entriesRef = useRef(entries)
|
||||
entriesRef.current = entries
|
||||
|
||||
const folderName = useMemo(() => {
|
||||
const parts = folderPath.replace(/[/\\]+$/, "").split(/[/\\]/)
|
||||
return parts[parts.length - 1] || folderPath
|
||||
}, [folderPath])
|
||||
|
||||
const trackedEntries = useMemo(
|
||||
() => entries.filter((entry) => entry.status !== UNTRACKED_STATUS),
|
||||
[entries]
|
||||
)
|
||||
const untrackedEntries = useMemo(
|
||||
() => entries.filter((entry) => entry.status === UNTRACKED_STATUS),
|
||||
[entries]
|
||||
)
|
||||
const trackedTree = useMemo(
|
||||
() => buildFileTree(trackedEntries),
|
||||
[trackedEntries]
|
||||
)
|
||||
const untrackedTree = useMemo(
|
||||
() => buildFileTree(untrackedEntries),
|
||||
[untrackedEntries]
|
||||
)
|
||||
const filePathSet = useMemo(
|
||||
() => new Set(entries.map((entry) => entry.file)),
|
||||
[entries]
|
||||
)
|
||||
const trackedFiles = useMemo(
|
||||
() => trackedEntries.map((entry) => entry.file),
|
||||
[trackedEntries]
|
||||
)
|
||||
const untrackedFiles = useMemo(
|
||||
() => untrackedEntries.map((entry) => entry.file),
|
||||
[untrackedEntries]
|
||||
)
|
||||
|
||||
// Shared diff loading logic — extracted to avoid duplication
|
||||
const loadDiff = useCallback(
|
||||
async (file: string, allEntries?: GitStatusEntry[]) => {
|
||||
if (!folderPath) return
|
||||
setDiffFile(file)
|
||||
setDiffLanguage(languageFromPath(file))
|
||||
setLoadingDiff(true)
|
||||
setDiffOriginal("")
|
||||
setDiffModified("")
|
||||
|
||||
try {
|
||||
const statusSource = allEntries ?? entriesRef.current
|
||||
const isUntracked =
|
||||
statusSource.find((e) => e.file === file)?.status === UNTRACKED_STATUS
|
||||
|
||||
const [originalContent, modifiedContent] = await Promise.all([
|
||||
isUntracked
|
||||
? Promise.resolve("")
|
||||
: gitShowFile(folderPath, file).catch(() => ""),
|
||||
readFilePreview(folderPath, file)
|
||||
.then((r) => r.content)
|
||||
.catch(() => ""),
|
||||
])
|
||||
|
||||
setDiffOriginal(originalContent)
|
||||
setDiffModified(modifiedContent)
|
||||
} catch {
|
||||
setDiffOriginal("")
|
||||
setDiffModified("")
|
||||
} finally {
|
||||
setLoadingDiff(false)
|
||||
}
|
||||
},
|
||||
[folderPath]
|
||||
)
|
||||
|
||||
const loadStatus = useCallback(async () => {
|
||||
if (!folderPath) return
|
||||
setLoadingStatus(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await gitStatus(folderPath)
|
||||
setEntries(result)
|
||||
const tracked = result.filter(
|
||||
(entry) => entry.status !== UNTRACKED_STATUS
|
||||
)
|
||||
const untracked = result.filter(
|
||||
(entry) => entry.status === UNTRACKED_STATUS
|
||||
)
|
||||
setSelected(new Set(tracked.map((entry) => entry.file)))
|
||||
const trackedDirs = collectDirPaths(tracked)
|
||||
trackedDirs.add(folderName)
|
||||
setExpandedTrackedDirs(trackedDirs)
|
||||
const untrackedDirs = collectDirPaths(untracked)
|
||||
untrackedDirs.add(folderName)
|
||||
setExpandedUntrackedDirs(untrackedDirs)
|
||||
|
||||
// Auto-select the first file in visual tree order for diff preview
|
||||
const firstFile =
|
||||
findFirstFile(buildFileTree(tracked)) ??
|
||||
findFirstFile(buildFileTree(untracked))
|
||||
if (firstFile) {
|
||||
await loadDiff(firstFile, result)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
setEntries([])
|
||||
setExpandedTrackedDirs(new Set())
|
||||
setExpandedUntrackedDirs(new Set())
|
||||
} finally {
|
||||
setLoadingStatus(false)
|
||||
}
|
||||
}, [folderPath, folderName, loadDiff])
|
||||
|
||||
useEffect(() => {
|
||||
if (!folderPath) return
|
||||
setDiffOriginal("")
|
||||
setDiffModified("")
|
||||
setDiffLanguage("plaintext")
|
||||
setDiffFile(null)
|
||||
messageRef.current = ""
|
||||
setHasMessage(false)
|
||||
setMessageInputKey((key) => key + 1)
|
||||
setUntrackedOpen(false)
|
||||
void loadStatus()
|
||||
}, [folderPath, loadStatus])
|
||||
|
||||
const handleViewDiff = useCallback(
|
||||
(file: string) => {
|
||||
if (!folderPath || diffFileRef.current === file) return
|
||||
void loadDiff(file)
|
||||
},
|
||||
[folderPath, loadDiff]
|
||||
)
|
||||
|
||||
const toggleFile = useCallback((file: string) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(file)) {
|
||||
next.delete(file)
|
||||
} else {
|
||||
next.add(file)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const toggleAll = useCallback(() => {
|
||||
setSelected((prev) => {
|
||||
if (prev.size === entries.length) {
|
||||
return new Set<string>()
|
||||
}
|
||||
return new Set(entries.map((entry) => entry.file))
|
||||
})
|
||||
}, [entries])
|
||||
|
||||
const toggleGroup = useCallback((files: string[]) => {
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev)
|
||||
const allInGroupSelected = files.every((file) => next.has(file))
|
||||
if (allInGroupSelected) {
|
||||
files.forEach((file) => next.delete(file))
|
||||
} else {
|
||||
files.forEach((file) => next.add(file))
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handleSelectPath = useCallback(
|
||||
(path: string) => {
|
||||
if (!filePathSet.has(path)) return
|
||||
handleViewDiff(path)
|
||||
},
|
||||
[filePathSet, handleViewDiff]
|
||||
)
|
||||
|
||||
const handleCommit = useCallback(async () => {
|
||||
const commitMessage = messageRef.current.trim()
|
||||
if (!commitMessage || selected.size === 0 || !folderPath) return
|
||||
setCommitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await gitCommit(
|
||||
folderPath,
|
||||
commitMessage,
|
||||
Array.from(selected)
|
||||
)
|
||||
toast.success("提交代码完成", {
|
||||
description: `已提交 ${result.committed_files} 个文件`,
|
||||
})
|
||||
onCommitted?.()
|
||||
} catch (err) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setCommitting(false)
|
||||
}
|
||||
}, [folderPath, onCommitted, selected])
|
||||
|
||||
// --- Context menu actions ---
|
||||
|
||||
const handleAddToVcs = useCallback(
|
||||
async (file: string) => {
|
||||
if (!folderPath) return
|
||||
try {
|
||||
await gitAddFiles(folderPath, [file])
|
||||
toast.success("已添加到 VCS", { description: file })
|
||||
void loadStatus()
|
||||
} catch (err) {
|
||||
toast.error("添加到 VCS 失败", { description: String(err) })
|
||||
}
|
||||
},
|
||||
[folderPath, loadStatus]
|
||||
)
|
||||
|
||||
const handleDeleteFile = useCallback(
|
||||
(file: string) => {
|
||||
setConfirm({
|
||||
open: true,
|
||||
title: "确认删除",
|
||||
description: `确定要删除文件「${file}」吗?此操作不可恢复。`,
|
||||
variant: "destructive",
|
||||
action: () => {
|
||||
void (async () => {
|
||||
if (!folderPath) return
|
||||
try {
|
||||
await deleteFileTreeEntry(folderPath, file)
|
||||
toast.success("文件已删除", { description: file })
|
||||
// If deleted file was being viewed, clear the diff
|
||||
if (diffFileRef.current === file) {
|
||||
setDiffFile(null)
|
||||
setDiffOriginal("")
|
||||
setDiffModified("")
|
||||
}
|
||||
setSelected((prev) => {
|
||||
if (!prev.has(file)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(file)
|
||||
return next
|
||||
})
|
||||
void loadStatus()
|
||||
} catch (err) {
|
||||
toast.error("删除失败", { description: String(err) })
|
||||
}
|
||||
})()
|
||||
},
|
||||
})
|
||||
},
|
||||
[folderPath, loadStatus]
|
||||
)
|
||||
|
||||
const handleRollbackFile = useCallback(
|
||||
(file: string) => {
|
||||
setConfirm({
|
||||
open: true,
|
||||
title: "确认回滚",
|
||||
description: `确定要回滚文件「${file}」到 HEAD 版本吗?未保存的修改将丢失。`,
|
||||
variant: "destructive",
|
||||
action: () => {
|
||||
void (async () => {
|
||||
if (!folderPath) return
|
||||
try {
|
||||
await gitRollbackFile(folderPath, file)
|
||||
toast.success("文件已回滚", { description: file })
|
||||
if (diffFileRef.current === file) {
|
||||
setDiffFile(null)
|
||||
setDiffOriginal("")
|
||||
setDiffModified("")
|
||||
}
|
||||
setSelected((prev) => {
|
||||
if (!prev.has(file)) return prev
|
||||
const next = new Set(prev)
|
||||
next.delete(file)
|
||||
return next
|
||||
})
|
||||
void loadStatus()
|
||||
} catch (err) {
|
||||
toast.error("回滚失败", { description: String(err) })
|
||||
}
|
||||
})()
|
||||
},
|
||||
})
|
||||
},
|
||||
[folderPath, loadStatus]
|
||||
)
|
||||
|
||||
const closeConfirm = useCallback(() => {
|
||||
setConfirm(CONFIRM_INITIAL)
|
||||
}, [])
|
||||
|
||||
const confirmActionRef = useRef(confirm.action)
|
||||
confirmActionRef.current = confirm.action
|
||||
|
||||
const executeConfirmAction = useCallback(() => {
|
||||
confirmActionRef.current?.()
|
||||
setConfirm(CONFIRM_INITIAL)
|
||||
}, [])
|
||||
|
||||
const allSelected = useMemo(
|
||||
() => entries.length > 0 && selected.size === entries.length,
|
||||
[entries.length, selected.size]
|
||||
)
|
||||
const trackedAllSelected = useMemo(
|
||||
() =>
|
||||
trackedFiles.length > 0 &&
|
||||
trackedFiles.every((file) => selected.has(file)),
|
||||
[trackedFiles, selected]
|
||||
)
|
||||
const untrackedAllSelected = useMemo(
|
||||
() =>
|
||||
untrackedFiles.length > 0 &&
|
||||
untrackedFiles.every((file) => selected.has(file)),
|
||||
[untrackedFiles, selected]
|
||||
)
|
||||
|
||||
const handleMessageChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const nextValue = e.target.value
|
||||
messageRef.current = nextValue
|
||||
const nextHasMessage = nextValue.trim().length > 0
|
||||
setHasMessage((prev) => (prev === nextHasMessage ? prev : nextHasMessage))
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
const updateWidth = (next: number) => {
|
||||
setContainerWidth((prev) => (Math.abs(prev - next) < 1 ? prev : next))
|
||||
}
|
||||
|
||||
updateWidth(container.clientWidth)
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
updateWidth(entries[0]?.contentRect.width ?? container.clientWidth)
|
||||
})
|
||||
|
||||
observer.observe(container)
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const safeContainerWidth =
|
||||
containerWidth > 0
|
||||
? containerWidth
|
||||
: DEFAULT_LEFT_PANE_WIDTH + MIN_RIGHT_PANE_WIDTH + 240
|
||||
const leftMinSize = clamp(
|
||||
toPercent(MIN_LEFT_PANE_WIDTH, safeContainerWidth),
|
||||
5,
|
||||
95
|
||||
)
|
||||
const rightMinSize = clamp(
|
||||
toPercent(MIN_RIGHT_PANE_WIDTH, safeContainerWidth),
|
||||
5,
|
||||
95
|
||||
)
|
||||
const leftMaxSize = Math.max(leftMinSize, 100 - rightMinSize)
|
||||
const leftDefaultSize = clamp(
|
||||
toPercent(DEFAULT_LEFT_PANE_WIDTH, safeContainerWidth),
|
||||
leftMinSize,
|
||||
leftMaxSize
|
||||
)
|
||||
|
||||
// --- Render helpers for file tree nodes ---
|
||||
|
||||
const renderTrackedNode = useCallback(
|
||||
function renderNode(node: TreeNode): React.ReactNode {
|
||||
if (node.kind === "dir") {
|
||||
return (
|
||||
<FileTreeFolder
|
||||
key={`tracked:${node.path}`}
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
>
|
||||
{node.children.map(renderNode)}
|
||||
</FileTreeFolder>
|
||||
)
|
||||
}
|
||||
|
||||
const isDeleted = node.entry.status === " D" || node.entry.status === "D"
|
||||
|
||||
return (
|
||||
<ContextMenu key={`tracked:${node.path}`}>
|
||||
<ContextMenuTrigger>
|
||||
<FileTreeFile
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
className="gap-1 px-1.5 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFile(node.path)
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
selected.has(node.path)
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input"
|
||||
)}
|
||||
aria-label={`${selected.has(node.path) ? "取消选择" : "选择"} ${node.path}`}
|
||||
>
|
||||
{selected.has(node.path) && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 truncate text-left hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleViewDiff(node.path)
|
||||
}}
|
||||
title={node.path}
|
||||
>
|
||||
{node.name}
|
||||
</button>
|
||||
<span className="w-6 shrink-0 text-right text-xs font-medium text-muted-foreground">
|
||||
{node.entry.status}
|
||||
</span>
|
||||
</FileTreeFile>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{!isDeleted && (
|
||||
<ContextMenuItem onClick={() => handleRollbackFile(node.path)}>
|
||||
回滚
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteFile(node.path)}
|
||||
>
|
||||
删除
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
},
|
||||
[selected, toggleFile, handleViewDiff, handleRollbackFile, handleDeleteFile]
|
||||
)
|
||||
|
||||
const renderUntrackedNode = useCallback(
|
||||
function renderNode(node: TreeNode): React.ReactNode {
|
||||
if (node.kind === "dir") {
|
||||
return (
|
||||
<FileTreeFolder
|
||||
key={`untracked:${node.path}`}
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
>
|
||||
{node.children.map(renderNode)}
|
||||
</FileTreeFolder>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu key={`untracked:${node.path}`}>
|
||||
<ContextMenuTrigger>
|
||||
<FileTreeFile
|
||||
name={node.name}
|
||||
path={node.path}
|
||||
className="gap-1 px-1.5 py-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleFile(node.path)
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
selected.has(node.path)
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input"
|
||||
)}
|
||||
aria-label={`${selected.has(node.path) ? "取消选择" : "选择"} ${node.path}`}
|
||||
>
|
||||
{selected.has(node.path) && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 truncate text-left hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleViewDiff(node.path)
|
||||
}}
|
||||
title={node.path}
|
||||
>
|
||||
{node.name}
|
||||
</button>
|
||||
</FileTreeFile>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => handleAddToVcs(node.path)}>
|
||||
添加到 VCS
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => handleDeleteFile(node.path)}
|
||||
>
|
||||
删除
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
)
|
||||
},
|
||||
[selected, toggleFile, handleViewDiff, handleAddToVcs, handleDeleteFile]
|
||||
)
|
||||
|
||||
const toggleTrackedGroup = useCallback(
|
||||
() => toggleGroup(trackedFiles),
|
||||
[toggleGroup, trackedFiles]
|
||||
)
|
||||
const toggleUntrackedGroup = useCallback(
|
||||
() => toggleGroup(untrackedFiles),
|
||||
[toggleGroup, untrackedFiles]
|
||||
)
|
||||
const toggleUntrackedOpen = useCallback(
|
||||
() => setUntrackedOpen((open) => !open),
|
||||
[]
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex h-full min-h-0 overflow-hidden rounded-lg border bg-card"
|
||||
>
|
||||
<ResizablePanelGroup
|
||||
direction="horizontal"
|
||||
className="h-full min-h-0 min-w-0"
|
||||
>
|
||||
<ResizablePanel
|
||||
defaultSize={leftDefaultSize}
|
||||
minSize={leftMinSize}
|
||||
maxSize={leftMaxSize}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{error && (
|
||||
<div className="border-b border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex h-9 items-center gap-2 border-b bg-muted/50 px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleAll}
|
||||
className={cn(
|
||||
"flex h-4 w-4 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
allSelected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input"
|
||||
)}
|
||||
aria-label={allSelected ? "取消选择全部文件" : "选择全部文件"}
|
||||
>
|
||||
{allSelected && <Check className="h-3 w-3" />}
|
||||
</button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{loadingStatus
|
||||
? "加载中..."
|
||||
: `${selected.size} / ${entries.length} 个文件`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<ScrollArea className="h-full">
|
||||
{entries.length === 0 && !loadingStatus ? (
|
||||
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
|
||||
没有改动的文件
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 p-2">
|
||||
{trackedEntries.length > 0 && (
|
||||
<section className="space-y-1">
|
||||
<div className="flex items-center gap-2 px-1 text-[11px] text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTrackedGroup}
|
||||
className={cn(
|
||||
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
trackedAllSelected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input"
|
||||
)}
|
||||
aria-label={
|
||||
trackedAllSelected
|
||||
? "取消选择已跟踪改动"
|
||||
: "选择已跟踪改动"
|
||||
}
|
||||
>
|
||||
{trackedAllSelected && (
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</button>
|
||||
<span>已跟踪改动 ({trackedEntries.length})</span>
|
||||
</div>
|
||||
<FileTree
|
||||
className="rounded-none border-0 bg-transparent font-sans text-sm [&>div]:p-1"
|
||||
expanded={expandedTrackedDirs}
|
||||
onExpandedChange={setExpandedTrackedDirs}
|
||||
selectedPath={diffFile ?? undefined}
|
||||
onSelect={handleSelectPath}
|
||||
>
|
||||
<FileTreeFolder name={folderName} path={folderName}>
|
||||
{trackedTree.map(renderTrackedNode)}
|
||||
</FileTreeFolder>
|
||||
</FileTree>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{untrackedEntries.length > 0 && (
|
||||
<section className="space-y-1">
|
||||
<div className="flex w-full items-center gap-2 px-1 py-0.5 text-[11px] text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleUntrackedGroup}
|
||||
className={cn(
|
||||
"flex h-3.5 w-3.5 shrink-0 items-center justify-center rounded border transition-colors",
|
||||
untrackedAllSelected
|
||||
? "border-primary bg-primary text-primary-foreground"
|
||||
: "border-input"
|
||||
)}
|
||||
aria-label={
|
||||
untrackedAllSelected
|
||||
? "取消选择未跟踪文件"
|
||||
: "选择未跟踪文件"
|
||||
}
|
||||
>
|
||||
{untrackedAllSelected && (
|
||||
<Check className="h-2.5 w-2.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 hover:text-foreground"
|
||||
onClick={toggleUntrackedOpen}
|
||||
>
|
||||
{untrackedOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
<span>未跟踪文件 ({untrackedEntries.length})</span>
|
||||
</button>
|
||||
</div>
|
||||
{untrackedOpen && (
|
||||
<FileTree
|
||||
className="rounded-none border-0 bg-transparent font-sans text-sm [&>div]:p-1"
|
||||
expanded={expandedUntrackedDirs}
|
||||
onExpandedChange={setExpandedUntrackedDirs}
|
||||
selectedPath={diffFile ?? undefined}
|
||||
onSelect={handleSelectPath}
|
||||
>
|
||||
<FileTreeFolder name={folderName} path={folderName}>
|
||||
{untrackedTree.map(renderUntrackedNode)}
|
||||
</FileTreeFolder>
|
||||
</FileTree>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="border-t p-3">
|
||||
<div className="mb-2 text-xs text-muted-foreground">提交消息</div>
|
||||
<Textarea
|
||||
key={messageInputKey}
|
||||
placeholder="输入提交信息..."
|
||||
defaultValue=""
|
||||
onChange={handleMessageChange}
|
||||
className="min-h-[90px] resize-y"
|
||||
/>
|
||||
<div className="mt-3 flex items-center justify-end gap-2">
|
||||
<Button variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
disabled={committing || !hasMessage || selected.size === 0}
|
||||
onClick={handleCommit}
|
||||
>
|
||||
{committing && (
|
||||
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
提交 ({selected.size})
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle />
|
||||
|
||||
<ResizablePanel
|
||||
defaultSize={100 - leftDefaultSize}
|
||||
minSize={rightMinSize}
|
||||
>
|
||||
<div className="flex h-full min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{!diffFile ? (
|
||||
<>
|
||||
<div className="flex h-9 items-center gap-3 border-b bg-muted/50 px-3 text-xs text-muted-foreground">
|
||||
<span className="font-medium">HEAD</span>
|
||||
<span className="text-muted-foreground/60">↔</span>
|
||||
<span className="font-medium">Working Tree</span>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
点击文件名查看差异
|
||||
</div>
|
||||
</>
|
||||
) : loadingDiff ? (
|
||||
<>
|
||||
<div className="flex h-9 items-center gap-3 border-b bg-muted/50 px-3 text-xs text-muted-foreground">
|
||||
<span className="font-medium">HEAD</span>
|
||||
<span className="text-muted-foreground/60">↔</span>
|
||||
<span className="font-medium">Working Tree</span>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
加载差异...
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<DiffViewer
|
||||
key={diffFile}
|
||||
original={diffOriginal}
|
||||
modified={diffModified}
|
||||
originalLabel="HEAD"
|
||||
modifiedLabel="Working Tree"
|
||||
language={diffLanguage}
|
||||
className="h-full [&>div:first-child]:h-9 [&>div:first-child]:py-0"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
|
||||
<AlertDialog
|
||||
open={confirm.open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) closeConfirm()
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{confirm.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{confirm.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
variant={
|
||||
confirm.variant === "destructive" ? "destructive" : "default"
|
||||
}
|
||||
onClick={executeConfirmAction}
|
||||
>
|
||||
确认
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user