添加git推送窗口,显示待提交列表和查看文件差异

This commit is contained in:
xintaofei
2026-03-21 20:37:19 +08:00
parent 048b8a8480
commit d9032f1c82
18 changed files with 986 additions and 137 deletions

View File

@@ -67,7 +67,6 @@ import {
gitInit,
gitPull,
gitFetch,
gitPush,
gitNewBranch,
gitWorktreeAdd,
gitCheckout,
@@ -78,9 +77,8 @@ import {
openFolderWindow,
openCommitWindow,
setFolderParentBranch,
gitListConflicts,
gitHasMergeHead,
openStashWindow,
openPushWindow,
} from "@/lib/tauri"
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
import { ConflictDialog } from "@/components/layout/conflict-dialog"
@@ -300,138 +298,6 @@ export function BranchDropdown({
})
}
// Uses operation "merge" intentionally: MERGE_HEAD exists so merge state is
// already active. MergeWorkspace won't call gitStartPullMerge (only for "pull"),
// and ConflictDialog abort correctly runs git merge --abort.
async function showMergeConflictDialog() {
try {
const remaining = await gitListConflicts(folderPath)
setConflictInfo({
has_conflicts: true,
conflicted_files: remaining,
operation: "merge",
})
} catch {
setConflictInfo({
has_conflicts: true,
conflicted_files: [],
operation: "merge",
})
}
}
async function handlePush() {
// Pre-check: if MERGE_HEAD exists, show conflict dialog immediately
try {
if (await gitHasMergeHead(folderPath)) {
await showMergeConflictDialog()
return
}
} catch {
// Pre-check failed, continue with normal push flow
}
const taskId = `git-${++taskSeq.current}-${Date.now()}`
const label = t("tasks.pushCode")
setLoading(true)
addTask(taskId, label)
updateTask(taskId, { status: "running" })
try {
const result = await withCredentialRetry(
(creds) => gitPush(folderPath, creds),
{ folderPath }
)
updateTask(taskId, { status: "completed" })
onBranchChange()
let description: string | undefined
if (result.upstream_set) {
description =
result.pushed_commits === 0
? t("toasts.upstreamSet")
: t("toasts.upstreamSetAndPushed", {
count: result.pushed_commits,
})
} else if (result.pushed_commits === 0) {
description = t("toasts.noCommitsToPush")
} else {
description = t("toasts.pushedCommits", {
count: result.pushed_commits,
})
}
toast.success(t("toasts.taskCompleted", { label }), {
description,
})
} catch (err) {
const errorMsg = toErrorMessage(err)
if (/MERGE_HEAD|unfinished merge/i.test(errorMsg)) {
// Unfinished merge — show conflict dialog
removeTask(taskId)
await showMergeConflictDialog()
} else if (/rejected|fetch first/i.test(errorMsg)) {
// Remote has new commits — auto-pull then retry push
updateTask(taskId, {
status: "running",
})
try {
const pullResult = await withCredentialRetry(
(creds) => gitPull(folderPath, creds),
{ folderPath }
)
if (pullResult.conflict?.has_conflicts) {
removeTask(taskId)
onBranchChange()
setConflictInfo(pullResult.conflict)
} else {
// Pull succeeded, retry push
updateTask(taskId, { status: "running" })
const pushResult = await withCredentialRetry(
(creds) => gitPush(folderPath, creds),
{ folderPath }
)
updateTask(taskId, { status: "completed" })
onBranchChange()
let description: string | undefined
if (pushResult.upstream_set) {
description =
pushResult.pushed_commits === 0
? t("toasts.upstreamSet")
: t("toasts.upstreamSetAndPushed", {
count: pushResult.pushed_commits,
})
} else if (pushResult.pushed_commits === 0) {
description = t("toasts.noCommitsToPush")
} else {
description = t("toasts.pushedCommits", {
count: pushResult.pushed_commits,
})
}
toast.success(t("toasts.taskCompleted", { label }), {
description,
})
}
} catch (pullErr) {
const pullErrMsg = toErrorMessage(pullErr)
if (/MERGE_HEAD|unfinished merge/i.test(pullErrMsg)) {
removeTask(taskId)
await showMergeConflictDialog()
} else {
removeTask(taskId)
const pullErrTitle = t("toasts.taskFailed", { label })
pushAlert("error", pullErrTitle, pullErrMsg)
toast.error(pullErrTitle, { description: pullErrMsg })
}
}
} else {
removeTask(taskId)
const errorTitle = t("toasts.taskFailed", { label })
pushAlert("error", errorTitle, errorMsg)
toast.error(errorTitle, { description: errorMsg })
}
} finally {
setLoading(false)
}
}
function handleMergeParent() {
if (!parentBranch) return
setConfirmAction({ type: "merge", branchName: parentBranch })
@@ -751,7 +617,19 @@ export function BranchDropdown({
<GitCommitHorizontal className="h-3.5 w-3.5" />
{t("openCommitWindow")}
</DropdownMenuItem>
<DropdownMenuItem disabled={loading} onSelect={handlePush}>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
if (!folder) return
setDropdownOpen(false)
openPushWindow(folder.id).catch((err) => {
const title = t("toasts.openPushWindowFailed")
const msg = toErrorMessage(err)
pushAlert("error", title, msg)
toast.error(title, { description: msg })
})
}}
>
<Upload className="h-3.5 w-3.5" />
{t("pushCode")}
</DropdownMenuItem>

View File

@@ -0,0 +1,628 @@
"use client"
import type { ReactElement } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import {
ChevronsDownUp,
ChevronsUpDown,
CloudOff,
Loader2,
Upload,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { ScrollArea } from "@/components/ui/scroll-area"
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable"
import {
FileTree,
FileTreeFile,
FileTreeFolder,
} from "@/components/ai-elements/file-tree"
import {
Commit,
CommitContent,
CommitFileAdditions,
CommitFileChanges,
CommitFileDeletions,
CommitFileIcon,
CommitFileInfo,
CommitFilePath,
CommitFiles,
CommitFileStatus,
CommitHash,
CommitHeader,
CommitInfo,
CommitMessage,
CommitMetadata,
CommitTimestamp,
} from "@/components/ai-elements/commit"
import { DiffViewer } from "@/components/diff/diff-viewer"
import { Button } from "@/components/ui/button"
import { gitLog, gitPush, gitShowFile } from "@/lib/tauri"
import { toErrorMessage } from "@/lib/app-error"
import { languageFromPath } from "@/lib/language-detect"
import type { GitLogEntry, GitLogFileChange } from "@/lib/types"
import { useGitCredential } from "@/contexts/git-credential-context"
// --- File tree types & builder (same as aux-panel-git-log-tab) ---
type CommitFileTreeDirNode = {
kind: "dir"
name: string
path: string
children: CommitFileTreeNode[]
fileCount: number
}
type CommitFileTreeFileNode = {
kind: "file"
name: string
path: string
change: GitLogFileChange
}
type CommitFileTreeNode = CommitFileTreeDirNode | CommitFileTreeFileNode
interface MutableCommitFileTreeDirNode {
kind: "dir"
name: string
path: string
children: Map<string, MutableCommitFileTreeDirNode | CommitFileTreeFileNode>
}
function normalizePathSegments(path: string): string[] {
const normalized = path.replace(/\\/g, "/").replace(/^\/+|\/+$/g, "")
if (!normalized) return []
return normalized.split("/").filter(Boolean)
}
function toSortedTreeNodes(
dir: MutableCommitFileTreeDirNode
): CommitFileTreeNode[] {
return Array.from(dir.children.values())
.map<CommitFileTreeNode>((node) => {
if (node.kind === "file") return node
return {
kind: "dir" as const,
fileCount: 0,
name: node.name,
path: node.path,
children: toSortedTreeNodes(node),
}
})
.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "dir" ? -1 : 1
return a.name.localeCompare(b.name, undefined, { sensitivity: "base" })
})
}
function compressAndAnnotateDir(
node: CommitFileTreeDirNode
): CommitFileTreeDirNode {
let compressedChildren: CommitFileTreeNode[] = 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: CommitFileTreeDirNode = {
...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 buildCommitFileTree(files: GitLogFileChange[]): CommitFileTreeNode[] {
const root: MutableCommitFileTreeDirNode = {
kind: "dir",
name: "",
path: "",
children: new Map(),
}
for (const change of files) {
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: MutableCommitFileTreeDirNode = {
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: CommitFileTreeNode[],
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 mapFileStatus(
status: string
): "added" | "modified" | "deleted" | "renamed" {
switch (status.toUpperCase().charAt(0)) {
case "A":
return "added"
case "D":
return "deleted"
case "R":
return "renamed"
default:
return "modified"
}
}
function formatRelativeTime(
dateStr: string,
t: (
key:
| "time.monthsAgo"
| "time.daysAgo"
| "time.hoursAgo"
| "time.minsAgo"
| "time.justNow",
values?: { count: number }
) => string
): string {
const date = new Date(dateStr)
if (Number.isNaN(date.getTime())) return dateStr
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffMin = Math.floor(diffMs / 60_000)
const diffHour = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHour / 24)
if (diffDay > 30) {
const diffMonth = Math.floor(diffDay / 30)
return t("time.monthsAgo", { count: diffMonth })
}
if (diffDay > 0) return t("time.daysAgo", { count: diffDay })
if (diffHour > 0) return t("time.hoursAgo", { count: diffHour })
if (diffMin > 0) return t("time.minsAgo", { count: diffMin })
return t("time.justNow", { count: 0 })
}
function parseDate(dateStr: string): Date | null {
const date = new Date(dateStr)
return Number.isNaN(date.getTime()) ? null : date
}
// --- Main component ---
interface PushWorkspaceProps {
folderPath: string
folderName: string
}
export function PushWorkspace({ folderPath, folderName }: PushWorkspaceProps) {
const t = useTranslations("Folder.pushWindow")
const tLog = useTranslations("Folder.gitLogTab")
const { withCredentialRetry } = useGitCredential()
const [commits, setCommits] = useState<GitLogEntry[]>([])
const [listLoading, setListLoading] = useState(false)
const [openByCommit, setOpenByCommit] = useState<Record<string, boolean>>({})
const [pushing, setPushing] = useState(false)
const [selectedFile, setSelectedFile] = useState<string | null>(null)
const [selectedCommit, setSelectedCommit] = useState<string | null>(null)
const [originalContent, setOriginalContent] = useState("")
const [modifiedContent, setModifiedContent] = useState("")
const [diffLoading, setDiffLoading] = useState(false)
const unpushedCommits = useMemo(
() => commits.filter((c) => c.pushed === false),
[commits]
)
const loadCommits = useCallback(async () => {
setListLoading(true)
try {
const entries = await gitLog(folderPath, 100)
setCommits(entries)
} catch (err) {
toast.error(toErrorMessage(err))
} finally {
setListLoading(false)
}
}, [folderPath])
useEffect(() => {
loadCommits()
}, [loadCommits])
async function handleSelectFile(commitHash: string, file: string) {
setSelectedFile(file)
setSelectedCommit(commitHash)
setDiffLoading(true)
try {
const [orig, mod] = await Promise.all([
gitShowFile(folderPath, file, `${commitHash}~1`).catch(() => ""),
gitShowFile(folderPath, file, commitHash).catch(() => ""),
])
setOriginalContent(orig)
setModifiedContent(mod)
} catch {
setOriginalContent("")
setModifiedContent("")
} finally {
setDiffLoading(false)
}
}
async function handlePush() {
setPushing(true)
try {
const result = await withCredentialRetry(
(creds) => gitPush(folderPath, creds),
{ folderPath }
)
let description: string | undefined
if (result.upstream_set) {
description =
result.pushed_commits === 0
? t("toasts.upstreamSet")
: t("toasts.upstreamSetAndPushed", {
count: result.pushed_commits,
})
} else if (result.pushed_commits === 0) {
description = t("toasts.noCommitsToPush")
} else {
description = t("toasts.pushedCommits", {
count: result.pushed_commits,
})
}
toast.success(t("toasts.pushSuccess"), { description })
await loadCommits()
setSelectedFile(null)
setSelectedCommit(null)
setOpenByCommit({})
} catch (err) {
toast.error(t("toasts.pushFailed"), {
description: toErrorMessage(err),
})
} finally {
setPushing(false)
}
}
return (
<div className="flex h-full flex-col">
<ResizablePanelGroup direction="horizontal" className="min-h-0 flex-1">
{/* Left panel: commit list */}
<ResizablePanel defaultSize={35} minSize={25}>
<div className="flex h-full flex-col">
<ScrollArea className="min-h-0 flex-1">
{listLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : unpushedCommits.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t("noUnpushedCommits")}
</div>
) : (
<div className="flex flex-col gap-2 p-2">
{unpushedCommits.map((entry) => {
const commitKey = entry.full_hash
const commitDate = parseDate(entry.date)
const isOpen = !!openByCommit[commitKey]
return (
<Commit
key={commitKey}
open={isOpen}
onOpenChange={(open) => {
setOpenByCommit((prev) => ({
...prev,
[commitKey]: open,
}))
}}
>
<CommitHeader>
<CommitInfo className="min-w-0">
<CommitMessage className="line-clamp-1 leading-snug">
{entry.message}
</CommitMessage>
<CommitMetadata className="mt-1 min-w-0 flex items-center gap-1.5">
<span
className="inline-flex shrink-0"
title={t("unpushed")}
>
<CloudOff
className="text-amber-500"
size={12}
/>
</span>
<span className="truncate">{entry.author}</span>
<CommitTimestamp
className="shrink-0"
date={commitDate ?? new Date()}
>
{formatRelativeTime(entry.date, tLog)}
</CommitTimestamp>
<CommitHash className="text-primary/70">
{entry.hash}
</CommitHash>
</CommitMetadata>
</CommitInfo>
</CommitHeader>
<CommitContent>
{entry.files.length === 0 ? (
<p className="text-xs text-muted-foreground">
{tLog("noFileChangeDetails")}
</p>
) : (
<PushCommitFilesTree
commitHash={entry.full_hash}
files={entry.files}
folderName={folderName}
onSelectFile={(file) =>
handleSelectFile(entry.full_hash, file)
}
/>
)}
</CommitContent>
</Commit>
)
})}
</div>
)}
</ScrollArea>
{/* Push button */}
<div className="border-t p-2">
<Button
className="w-full"
disabled={pushing || unpushedCommits.length === 0}
onClick={handlePush}
>
{pushing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Upload className="mr-2 h-4 w-4" />
)}
{t("push")}
{unpushedCommits.length > 0 && ` (${unpushedCommits.length})`}
</Button>
</div>
</div>
</ResizablePanel>
<ResizableHandle />
{/* Right panel: diff viewer */}
<ResizablePanel defaultSize={65} minSize={40}>
{diffLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : selectedFile && selectedCommit ? (
<DiffViewer
original={originalContent}
modified={modifiedContent}
originalLabel={`${selectedCommit.slice(0, 7)}~ (${t("before")})`}
modifiedLabel={`${selectedCommit.slice(0, 7)} (${t("after")})`}
language={languageFromPath(selectedFile)}
className="h-full"
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{t("selectFileToViewDiff")}
</div>
)}
</ResizablePanel>
</ResizablePanelGroup>
</div>
)
}
// --- Commit Files Tree for Push Window ---
function PushCommitFilesTree({
commitHash,
files,
folderName,
onSelectFile,
}: {
commitHash: string
files: GitLogFileChange[]
folderName: string
onSelectFile: (file: string) => void
}) {
const tLog = useTranslations("Folder.gitLogTab")
const rootPath = "__push_file_tree_root__"
const treeNodes = useMemo(() => buildCommitFileTree(files), [files])
const allDirectoryPaths = useMemo(() => {
const paths = collectExpandedDirectoryPaths(treeNodes)
paths.add(rootPath)
return paths
}, [treeNodes])
const [expandedPaths, setExpandedPaths] =
useState<Set<string>>(allDirectoryPaths)
useEffect(() => {
setExpandedPaths(allDirectoryPaths)
}, [allDirectoryPaths])
const canExpandAll = useMemo(() => {
if (allDirectoryPaths.size === 0) return false
for (const path of allDirectoryPaths) {
if (!expandedPaths.has(path)) return true
}
return false
}, [allDirectoryPaths, expandedPaths])
const canCollapseAll = expandedPaths.size > 0
const toggleExpanded = useCallback(() => {
if (canExpandAll) {
setExpandedPaths(new Set(allDirectoryPaths))
return
}
setExpandedPaths(new Set())
}, [allDirectoryPaths, canExpandAll])
const renderNode = (node: CommitFileTreeNode): ReactElement => {
if (node.kind === "dir") {
return (
<FileTreeFolder
key={node.path}
path={node.path}
name={node.name}
suffix={`(${node.fileCount})`}
suffixClassName="text-muted-foreground/45"
title={node.path}
>
{node.children.map(renderNode)}
</FileTreeFolder>
)
}
const file = node.change
return (
<FileTreeFile
key={`${commitHash}:${file.path}`}
className="w-full min-w-0 cursor-pointer"
name={node.name}
onClick={() => onSelectFile(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={mapFileStatus(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>
)
}
return (
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] text-muted-foreground">
{tLog("filesTitle")}
</p>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-5"
onClick={toggleExpanded}
disabled={!canExpandAll && !canCollapseAll}
title={
canExpandAll ? tLog("expandAllFiles") : tLog("collapseAllFiles")
}
>
{canExpandAll ? (
<ChevronsUpDown className="size-3.5" />
) : (
<ChevronsDownUp className="size-3.5" />
)}
</Button>
</div>
</div>
<CommitFiles>
<FileTree
className="max-h-[32rem] overflow-auto rounded-md border-border/60 bg-transparent text-xs [&>div]:p-1"
expanded={expandedPaths}
onExpandedChange={setExpandedPaths}
>
<FileTreeFolder
path={rootPath}
name={folderName}
suffix={`(${files.length})`}
suffixClassName="text-muted-foreground/45"
title={folderName}
>
{treeNodes.map(renderNode)}
</FileTreeFolder>
</FileTree>
</CommitFiles>
</div>
)
}