添加git推送窗口,显示待提交列表和查看文件差异
This commit is contained in:
@@ -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>
|
||||
|
||||
628
src/components/layout/push-workspace.tsx
Normal file
628
src/components/layout/push-workspace.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user