feat(title-bar): add folder-actions menu and branch dropdown with git operations

This commit is contained in:
xintaofei
2026-04-22 00:23:27 +08:00
parent 08fb508736
commit f3bdf94723
19 changed files with 1511 additions and 46 deletions

View File

@@ -2120,6 +2120,29 @@ pub async fn git_rebase(
})
}
#[cfg_attr(feature = "tauri-runtime", tauri::command)]
pub async fn git_delete_branch(
path: String,
branch_name: String,
force: bool,
) -> Result<String, AppCommandError> {
let flag = if force { "-D" } else { "-d" };
let output = crate::process::tokio_command("git")
.args(["branch", flag, &branch_name])
.current_dir(&path)
.output()
.await
.map_err(AppCommandError::io)?;
if !output.status.success() {
return Err(git_command_error(
&format!("branch {flag}"),
&output.stderr,
));
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
pub(crate) async fn git_delete_remote_branch_core(
path: &str,
remote: &str,

View File

@@ -264,6 +264,7 @@ mod tauri_app {
folders::git_set_remote_url,
folders::git_merge,
folders::git_rebase,
folders::git_delete_branch,
folders::git_delete_remote_branch,
folders::git_list_conflicts,
folders::git_conflict_file_versions,

View File

@@ -450,6 +450,23 @@ pub async fn git_rebase(
Ok(Json(result))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitDeleteBranchParams {
pub path: String,
pub branch_name: String,
pub force: bool,
}
pub async fn git_delete_branch(
Json(params): Json<GitDeleteBranchParams>,
) -> Result<Json<String>, AppCommandError> {
let result =
folder_commands::git_delete_branch(params.path, params.branch_name, params.force)
.await?;
Ok(Json(result))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct GitDeleteRemoteBranchParams {

View File

@@ -243,6 +243,7 @@ pub fn build_router(state: Arc<AppState>, token: String, static_dir: std::path::
.route("/git_fetch", post(handlers::git::git_fetch))
.route("/git_commit", post(handlers::git::git_commit))
.route("/git_fetch_remote", post(handlers::git::git_fetch_remote))
.route("/git_delete_branch", post(handlers::git::git_delete_branch))
.route(
"/git_delete_remote_branch",
post(handlers::git::git_delete_remote_branch),

View File

@@ -0,0 +1,950 @@
"use client"
import { useState, useRef, useCallback, useMemo, useEffect } from "react"
import {
ArchiveRestore,
Archive,
ArrowDownToLine,
ArrowLeftRight,
ChevronDown,
ChevronRight,
FolderGit2,
FolderOpen,
GitBranch,
GitBranchPlus,
GitCommitHorizontal,
GitFork,
GitMerge,
GitPullRequestArrow,
Globe,
Loader2,
RefreshCw,
Trash2,
Upload,
} from "lucide-react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { ScrollArea } from "@/components/ui/scroll-area"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import {
gitInit,
gitPull,
gitFetch,
gitNewBranch,
gitWorktreeAdd,
gitListAllBranches,
gitMerge,
gitRebase,
gitDeleteBranch,
gitDeleteRemoteBranch,
openCommitWindow,
openPushWindow,
openStashWindow,
setFolderParentBranch,
} from "@/lib/api"
import { openFileDialog, subscribe } from "@/lib/platform"
import { RemoteManageDialog } from "@/components/layout/remote-manage-dialog"
import { ConflictDialog } from "@/components/layout/conflict-dialog"
import { StashDialog } from "@/components/layout/stash-dialog"
import { toErrorMessage } from "@/lib/app-error"
import type { GitBranchList, GitConflictInfo } from "@/lib/types"
import { useActiveFolder } from "@/contexts/active-folder-context"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useTaskContext } from "@/contexts/task-context"
import { useAlertContext } from "@/contexts/alert-context"
import { useGitCredential } from "@/contexts/git-credential-context"
const emitEvent = async (event: string, payload?: unknown) => {
try {
const { emit } = await import("@tauri-apps/api/event")
await emit(event, payload)
} catch {
/* not in Tauri */
}
}
type ConfirmAction = {
type: "merge" | "rebase" | "delete" | "forceDelete" | "deleteRemote"
branchName: string
}
interface GitCommitSucceededEventPayload {
folder_id: number
committed_files: number
}
interface GitPushSucceededEventPayload {
folder_id: number
pushed_commits: number
upstream_set: boolean
}
export function BranchDropdown() {
const t = useTranslations("Folder.branchDropdown")
const tCommon = useTranslations("Folder.common")
const { activeFolder } = useActiveFolder()
const { branches, refreshFolder, openFolder } = useAppWorkspace()
const { addTask, updateTask, removeTask } = useTaskContext()
const { pushAlert } = useAlertContext()
const { withCredentialRetry } = useGitCredential()
const folderPath = activeFolder?.path ?? ""
const folderId = activeFolder?.id ?? 0
const branch = activeFolder
? (branches.get(activeFolder.id) ?? activeFolder.git_branch ?? null)
: null
const parentBranch = activeFolder?.parent_branch ?? null
const [branchList, setBranchList] = useState<GitBranchList>({
local: [],
remote: [],
worktree_branches: [],
})
const [newBranchOpen, setNewBranchOpen] = useState(false)
const [newBranchName, setNewBranchName] = useState("")
const [loading, setLoading] = useState(false)
const [dropdownOpen, setDropdownOpen] = useState(false)
const [branchLoading, setBranchLoading] = useState(false)
const [localOpen, setLocalOpen] = useState(false)
const [remoteOpen, setRemoteOpen] = useState(false)
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null)
const [worktreeOpen, setWorktreeOpen] = useState(false)
const [worktreeBranchName, setWorktreeBranchName] = useState("")
const [worktreePath, setWorktreePath] = useState("")
const [manageRemotesOpen, setManageRemotesOpen] = useState(false)
const [stashDialogOpen, setStashDialogOpen] = useState(false)
const [conflictInfo, setConflictInfo] = useState<GitConflictInfo | null>(null)
const taskSeq = useRef(0)
const worktreeBranchSet = useMemo(
() => new Set(branchList.worktree_branches),
[branchList.worktree_branches]
)
const groupedRemoteBranches = useMemo(() => {
const groups: Record<string, string[]> = {}
for (const b of branchList.remote) {
const slashIndex = b.indexOf("/")
const remoteName = slashIndex > 0 ? b.substring(0, slashIndex) : "origin"
if (!groups[remoteName]) groups[remoteName] = []
groups[remoteName].push(b)
}
return groups
}, [branchList.remote])
const remoteNames = Object.keys(groupedRemoteBranches)
const hasMultipleRemotes = remoteNames.length > 1
const refresh = useCallback(() => {
if (folderId) void refreshFolder(folderId)
}, [folderId, refreshFolder])
useEffect(() => {
if (!folderId) return
let unlisten: (() => void) | null = null
subscribe<GitCommitSucceededEventPayload>(
"folder://git-commit-succeeded",
(payload) => {
if (payload.folder_id !== folderId) return
toast.success(t("toasts.commitCodeCompleted"), {
description: t("toasts.committedFiles", {
count: payload.committed_files,
}),
})
refresh()
}
)
.then((fn) => {
unlisten = fn
})
.catch((err) => {
console.error("[BranchDropdown] failed to listen commit event:", err)
})
return () => {
unlisten?.()
}
}, [folderId, refresh, t])
useEffect(() => {
if (!folderId) return
let unlisten: (() => void) | null = null
subscribe<GitPushSucceededEventPayload>(
"folder://git-push-succeeded",
(payload) => {
if (payload.folder_id !== folderId) return
const { pushed_commits, upstream_set } = payload
let description: string
if (upstream_set) {
description =
pushed_commits === 0
? t("toasts.upstreamSet")
: t("toasts.upstreamSetAndPushed", { count: pushed_commits })
} else if (pushed_commits === 0) {
description = t("toasts.noCommitsToPush")
} else {
description = t("toasts.pushedCommits", { count: pushed_commits })
}
toast.success(t("toasts.pushCodeCompleted"), { description })
refresh()
}
)
.then((fn) => {
unlisten = fn
})
.catch((err) => {
console.error("[BranchDropdown] failed to listen push event:", err)
})
return () => {
unlisten?.()
}
}, [folderId, refresh, t])
async function runGitTask<T>(
label: string,
action: () => Promise<T>,
getSuccessDescription?: (result: T) => string | false | undefined,
onError?: (errorMsg: string) => boolean
) {
const taskId = `git-${++taskSeq.current}-${Date.now()}`
setLoading(true)
addTask(taskId, label)
updateTask(taskId, { status: "running" })
try {
const result = await action()
const successDescription = getSuccessDescription?.(result)
updateTask(taskId, { status: "completed" })
refresh()
void emitEvent("folder://git-branch-changed", { folder_id: folderId })
if (successDescription !== false) {
toast.success(
t("toasts.taskCompleted", { label }),
successDescription ? { description: successDescription } : undefined
)
}
} catch (err) {
removeTask(taskId)
const errorMsg = toErrorMessage(err)
if (onError?.(errorMsg)) {
return
}
const errorTitle = t("toasts.taskFailed", { label })
pushAlert("error", errorTitle, errorMsg)
toast.error(errorTitle, { description: errorMsg })
} finally {
setLoading(false)
}
}
const loadAllBranches = useCallback(async () => {
if (!folderPath) return
setBranchLoading(true)
try {
const list = await gitListAllBranches(folderPath)
setBranchList(list)
} catch {
setBranchList({ local: [], remote: [], worktree_branches: [] })
} finally {
setBranchLoading(false)
}
}, [folderPath])
function handleDropdownOpenChange(open: boolean) {
setDropdownOpen(open)
if (open && branch !== null) {
void loadAllBranches()
}
if (!open) {
setLocalOpen(false)
setRemoteOpen(false)
}
}
async function handleNewBranch() {
const name = newBranchName.trim()
if (!name) return
setNewBranchOpen(false)
setNewBranchName("")
await runGitTask(t("tasks.newBranch", { name }), () =>
gitNewBranch(folderPath, name)
)
}
function handleOpenWorktreeDialog() {
const chars = "abcdefghijklmnopqrstuvwxyz0123456789"
let random = ""
for (let i = 0; i < 6; i++) {
random += chars[Math.floor(Math.random() * chars.length)]
}
const folderName = folderPath.split("/").filter(Boolean).pop() ?? "project"
const currentBranch = branch ?? "main"
const defaultBranch = `cv-${currentBranch}-${random}`
const parentDir = folderPath.substring(0, folderPath.lastIndexOf("/"))
setWorktreeBranchName(defaultBranch)
setWorktreePath(`${parentDir}/${folderName}-${currentBranch}-${random}`)
setWorktreeOpen(true)
}
async function handleBrowseWorktreePath() {
const selected = await openFileDialog({ directory: true, multiple: false })
if (selected) {
setWorktreePath(Array.isArray(selected) ? selected[0] : selected)
}
}
async function handleNewWorktree() {
const name = worktreeBranchName.trim()
const wtPath = worktreePath.trim()
if (!name || !wtPath) return
setWorktreeOpen(false)
await runGitTask(t("tasks.newWorktree", { name }), async () => {
await gitWorktreeAdd(folderPath, name, wtPath)
await openFolder(wtPath)
await setFolderParentBranch(wtPath, branch)
})
}
function handleMergeParent() {
if (!parentBranch) return
setConfirmAction({ type: "merge", branchName: parentBranch })
}
async function handleConfirm() {
if (!confirmAction) return
const { type, branchName } = confirmAction
setConfirmAction(null)
switch (type) {
case "merge":
await runGitTask(
t("tasks.mergeBranch", { branchName }),
() => gitMerge(folderPath, branchName),
(result) => {
if (result.conflict?.has_conflicts) {
setConflictInfo(result.conflict)
return false
}
if (result.merged_commits === 0) {
return t("toasts.mergeNoNewCommits", { branchName })
}
return t("toasts.mergedCommits", { count: result.merged_commits })
}
)
break
case "rebase":
await runGitTask(
t("tasks.rebaseTo", { branchName }),
() => gitRebase(folderPath, branchName),
(result) => {
if (result.conflict?.has_conflicts) {
setConflictInfo(result.conflict)
return false
}
return undefined
}
)
break
case "delete":
await runGitTask(
t("tasks.deleteBranch", { branchName }),
() => gitDeleteBranch(folderPath, branchName),
undefined,
(errorMsg) => {
if (/not fully merged/i.test(errorMsg)) {
setConfirmAction({ type: "forceDelete", branchName })
return true
}
return false
}
)
break
case "forceDelete":
await runGitTask(t("tasks.deleteBranch", { branchName }), () =>
gitDeleteBranch(folderPath, branchName, true)
)
break
case "deleteRemote": {
const idx = branchName.indexOf("/")
const remote = branchName.substring(0, idx)
const rb = branchName.substring(idx + 1)
await runGitTask(t("tasks.deleteRemoteBranch", { branchName }), () =>
withCredentialRetry(
(creds) => gitDeleteRemoteBranch(folderPath, remote, rb, creds),
{ folderPath }
)
)
break
}
}
}
function getConfirmTitle() {
if (!confirmAction) return ""
switch (confirmAction.type) {
case "merge":
return t("confirm.mergeTitle")
case "rebase":
return t("confirm.rebaseTitle")
case "delete":
return t("confirm.deleteTitle")
case "forceDelete":
return t("confirm.forceDeleteTitle")
case "deleteRemote":
return t("confirm.deleteRemoteTitle")
}
}
function getConfirmDescription() {
if (!confirmAction) return ""
switch (confirmAction.type) {
case "merge":
return t("confirm.mergeDescription", {
branchName: confirmAction.branchName,
currentBranch: branch ?? "-",
})
case "rebase":
return t("confirm.rebaseDescription", {
currentBranch: branch ?? "-",
branchName: confirmAction.branchName,
})
case "delete":
return t("confirm.deleteDescription", {
branchName: confirmAction.branchName,
})
case "forceDelete":
return t("confirm.forceDeleteDescription", {
branchName: confirmAction.branchName,
})
case "deleteRemote":
return t("confirm.deleteRemoteDescription", {
branchName: confirmAction.branchName,
})
}
}
function renderBranchItem(
b: string,
isRemote: boolean,
displayName?: string
) {
const label = displayName ?? b
const isCurrent = b === branch
const isTrackingCurrent =
isRemote && !!branch && b.replace(/^[^/]+\//, "") === branch
const isWorktree = worktreeBranchSet.has(
isRemote ? b.replace(/^[^/]+\//, "") : b
)
const BranchIcon = isWorktree ? FolderGit2 : GitBranch
if (isCurrent) {
return (
<div
key={b}
className="flex select-none items-center gap-2.5 rounded-xl px-3 py-2 text-sm opacity-50"
>
<BranchIcon className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{label}</span>
<span className="ml-auto text-xs">{t("current")}</span>
</div>
)
}
return (
<DropdownMenuSub key={b}>
<DropdownMenuSubTrigger
className="hover:bg-accent hover:text-accent-foreground"
disabled={loading}
>
<BranchIcon className="h-3.5 w-3.5" />
{label}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "merge", branchName: b })
}}
>
<GitMerge className="h-3.5 w-3.5" />
{t("mergeBranchIntoCurrent", {
branchName: b,
currentBranch: branch ?? "-",
})}
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({ type: "rebase", branchName: b })
}}
>
<GitPullRequestArrow className="h-3.5 w-3.5" />
{t("rebaseCurrentToBranch", {
currentBranch: branch ?? "-",
branchName: b,
})}
</DropdownMenuItem>
{!isTrackingCurrent && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onSelect={() => {
setDropdownOpen(false)
setConfirmAction({
type: isRemote ? "deleteRemote" : "delete",
branchName: b,
})
}}
>
<Trash2 className="h-3.5 w-3.5" />
{t("deleteBranch")}
</DropdownMenuItem>
</>
)}
</DropdownMenuSubContent>
</DropdownMenuSub>
)
}
if (!activeFolder) return null
const folderName = activeFolder.name
if (branch === null) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="flex min-w-0 items-center gap-1 text-sm tracking-tight outline-none transition-colors cursor-default hover:text-foreground/80">
<GitFork className="h-3 w-3 shrink-0" />
<span className="max-w-[320px] truncate">
{folderName}
<span className="mx-1.5 inline-block h-3 w-px bg-foreground/20 align-middle" />
<span className="text-primary">{t("versionControl")}</span>
</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-64" align="start">
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(t("tasks.initGitRepo"), () => gitInit(folderPath))
}
>
<GitBranch className="h-3.5 w-3.5" />
{t("initGitRepo")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
return (
<>
<DropdownMenu open={dropdownOpen} onOpenChange={handleDropdownOpenChange}>
<DropdownMenuTrigger asChild>
<button className="flex min-w-0 items-center gap-1 text-sm tracking-tight outline-none transition-colors cursor-default hover:text-foreground/80">
<GitBranch className="h-3 w-3 shrink-0" />
<span className="max-w-[320px] truncate">
{folderName}
<span className="mx-1.5 inline-block h-3 w-px bg-foreground/20 align-middle" />
<span className="text-primary">{branch}</span>
</span>
<ChevronDown className="h-3 w-3 shrink-0 opacity-50" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-64" align="start">
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(
t("tasks.pullCode"),
() =>
withCredentialRetry((creds) => gitPull(folderPath, creds), {
folderPath,
}),
(result) => {
if (result.conflict?.has_conflicts) {
setConflictInfo(result.conflict)
return false
}
if (result.updated_files === 0) {
return t("toasts.allFilesUpToDate")
}
return t("toasts.updatedFiles", {
count: result.updated_files,
})
}
)
}
>
<ArrowDownToLine className="h-3.5 w-3.5" />
{t("pullCode")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() =>
runGitTask(t("tasks.fetchInfo"), () =>
withCredentialRetry((creds) => gitFetch(folderPath, creds), {
folderPath,
})
)
}
>
<RefreshCw className="h-3.5 w-3.5" />
{t("fetchRemoteBranches")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
if (!folderId) return
setDropdownOpen(false)
openCommitWindow(folderId).catch((err) => {
const title = t("toasts.openCommitWindowFailed")
const msg = toErrorMessage(err)
pushAlert("error", title, msg)
toast.error(title, { description: msg })
})
}}
>
<GitCommitHorizontal className="h-3.5 w-3.5" />
{t("openCommitWindow")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
if (!folderId) return
setDropdownOpen(false)
openPushWindow(folderId).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>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
setNewBranchName("")
setNewBranchOpen(true)
}}
>
<GitBranchPlus className="h-3.5 w-3.5" />
{t("newBranch")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={handleOpenWorktreeDialog}
>
<FolderGit2 className="h-3.5 w-3.5" />
{t("newWorktree")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
setDropdownOpen(false)
setStashDialogOpen(true)
}}
>
<Archive className="h-3.5 w-3.5" />
{t("stashChanges")}
</DropdownMenuItem>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
if (!folderId) return
openStashWindow(folderId).catch((err) => {
const msg = toErrorMessage(err)
pushAlert("error", t("stashPop"), msg)
})
}}
>
<ArchiveRestore className="h-3.5 w-3.5" />
{t("stashPop")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem
disabled={loading}
onSelect={() => {
setDropdownOpen(false)
setManageRemotesOpen(true)
}}
>
<Globe className="h-3.5 w-3.5" />
{t("manageRemotes")}
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
{branchLoading ? (
<div className="flex items-center justify-center py-3">
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
</div>
) : (
<ScrollArea className="max-h-64">
<Collapsible open={localOpen} onOpenChange={setLocalOpen}>
<CollapsibleTrigger className="flex w-full select-none items-center gap-2.5 rounded-xl px-3 py-2 text-sm outline-hidden hover:bg-accent hover:text-accent-foreground">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{t("localBranches", { count: branchList.local.length })}
</CollapsibleTrigger>
<CollapsibleContent>
{branchList.local.length === 0 ? (
<DropdownMenuItem disabled>
{t("noLocalBranches")}
</DropdownMenuItem>
) : (
branchList.local.map((b) => renderBranchItem(b, false))
)}
</CollapsibleContent>
</Collapsible>
<Collapsible open={remoteOpen} onOpenChange={setRemoteOpen}>
<CollapsibleTrigger className="flex w-full select-none items-center gap-2.5 rounded-xl px-3 py-2 text-sm outline-hidden hover:bg-accent hover:text-accent-foreground">
<ChevronRight className="h-3.5 w-3.5 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{t("remoteBranches", { count: branchList.remote.length })}
</CollapsibleTrigger>
<CollapsibleContent>
{branchList.remote.length === 0 ? (
<DropdownMenuItem disabled>
{t("noRemoteBranches")}
</DropdownMenuItem>
) : hasMultipleRemotes ? (
remoteNames.map((remoteName) => (
<Collapsible key={remoteName}>
<CollapsibleTrigger className="flex w-full select-none items-center gap-2.5 rounded-xl px-3 py-2 pl-6 text-sm outline-hidden hover:bg-accent hover:text-accent-foreground">
<ChevronRight className="h-3 w-3 shrink-0 transition-transform [[data-state=open]>&]:rotate-90" />
{remoteName} (
{groupedRemoteBranches[remoteName].length})
</CollapsibleTrigger>
<CollapsibleContent className="pl-3">
{groupedRemoteBranches[remoteName].map((b) =>
renderBranchItem(
b,
true,
b.substring(remoteName.length + 1)
)
)}
</CollapsibleContent>
</Collapsible>
))
) : (
branchList.remote.map((b) => {
const slashIndex = b.indexOf("/")
const shortName =
slashIndex > 0 ? b.substring(slashIndex + 1) : b
return renderBranchItem(b, true, shortName)
})
)}
</CollapsibleContent>
</Collapsible>
</ScrollArea>
)}
</DropdownMenuContent>
</DropdownMenu>
{parentBranch && (
<button
className="flex cursor-default select-none items-center gap-1 rounded px-1.5 py-0.5 text-xs text-orange-500 transition-colors hover:bg-accent hover:text-orange-600 dark:text-orange-400 dark:hover:text-orange-300"
disabled={loading}
onClick={handleMergeParent}
title={t("parentBranchHint", { parentBranch })}
>
<ArrowLeftRight className="h-3 w-3 shrink-0" />
<span className="max-w-32 truncate">{parentBranch}</span>
</button>
)}
<AlertDialog
open={confirmAction !== null}
onOpenChange={(open) => {
if (!open) setConfirmAction(null)
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{getConfirmTitle()}</AlertDialogTitle>
<AlertDialogDescription>
{getConfirmDescription()}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{tCommon("cancel")}</AlertDialogCancel>
<AlertDialogAction
variant={
confirmAction?.type === "delete" ||
confirmAction?.type === "forceDelete" ||
confirmAction?.type === "deleteRemote"
? "destructive"
: "default"
}
onClick={handleConfirm}
>
{tCommon("confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={newBranchOpen} onOpenChange={setNewBranchOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("dialogs.newBranchTitle")}</DialogTitle>
<DialogDescription>
{t("dialogs.newBranchDescription", { branch: branch ?? "-" })}
</DialogDescription>
</DialogHeader>
<Input
placeholder={t("dialogs.branchNamePlaceholder")}
value={newBranchName}
onChange={(e) => setNewBranchName(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.key === "Process") return
if (e.key === "Enter") handleNewBranch()
}}
autoFocus
/>
<DialogFooter>
<Button variant="outline" onClick={() => setNewBranchOpen(false)}>
{tCommon("cancel")}
</Button>
<Button
disabled={!newBranchName.trim() || loading}
onClick={handleNewBranch}
>
{tCommon("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={worktreeOpen} onOpenChange={setWorktreeOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("dialogs.newWorktreeTitle")}</DialogTitle>
<DialogDescription>
{t("dialogs.newWorktreeDescription", { branch: branch ?? "-" })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="wt-branch">{t("dialogs.branchNameLabel")}</Label>
<Input
id="wt-branch"
placeholder={t("dialogs.branchNamePlaceholder")}
value={worktreeBranchName}
onChange={(e) => setWorktreeBranchName(e.target.value)}
onKeyDown={(e) => {
if (e.nativeEvent.isComposing || e.key === "Process") return
if (e.key === "Enter") handleNewWorktree()
}}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="wt-path">{t("dialogs.worktreePathLabel")}</Label>
<div className="flex gap-2">
<Input
id="wt-path"
placeholder={t("dialogs.worktreePathPlaceholder")}
value={worktreePath}
onChange={(e) => setWorktreePath(e.target.value)}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={handleBrowseWorktreePath}
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setWorktreeOpen(false)}>
{tCommon("cancel")}
</Button>
<Button
disabled={
!worktreeBranchName.trim() || !worktreePath.trim() || loading
}
onClick={handleNewWorktree}
>
{tCommon("create")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<RemoteManageDialog
open={manageRemotesOpen}
onOpenChange={setManageRemotesOpen}
folderPath={folderPath}
onSaved={() => loadAllBranches()}
/>
<ConflictDialog
conflictInfo={conflictInfo}
folderId={folderId}
folderPath={folderPath}
onClose={() => setConflictInfo(null)}
onResolved={refresh}
/>
<StashDialog
open={stashDialogOpen}
folderPath={folderPath}
onClose={() => setStashDialogOpen(false)}
onStashed={refresh}
/>
</>
)
}

View File

@@ -0,0 +1,174 @@
"use client"
import { useState, useMemo } from "react"
import { useTranslations } from "next-intl"
import { toast } from "sonner"
import { FolderOpen, Loader2 } from "lucide-react"
import { cloneRepository } from "@/lib/api"
import { toErrorMessage } from "@/lib/app-error"
import { isDesktop, openFileDialog } from "@/lib/platform"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { useGitCredential } from "@/contexts/git-credential-context"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
interface CloneDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
export function CloneDialog({ open, onOpenChange }: CloneDialogProps) {
const t = useTranslations("Folder.cloneDialog")
const tToasts = useTranslations("Folder.toasts")
const { openFolder } = useAppWorkspace()
const { withCredentialRetry } = useGitCredential()
const [url, setUrl] = useState("")
const [targetDir, setTargetDir] = useState("")
const [cloning, setCloning] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
const [error, setError] = useState<string | null>(null)
const repoName = useMemo(
() =>
url
.replace(/\.git$/, "")
.split("/")
.pop() ?? "repo",
[url]
)
const handleBrowse = async () => {
if (isDesktop()) {
const selected = await openFileDialog({
directory: true,
multiple: false,
})
if (selected) {
setTargetDir(Array.isArray(selected) ? selected[0] : selected)
}
} else {
setBrowserOpen(true)
}
}
const resetForm = () => {
setUrl("")
setTargetDir("")
setError(null)
}
const handleClone = async () => {
if (!url || !targetDir) return
const fullPath = `${targetDir}/${repoName}`
setCloning(true)
setError(null)
try {
await withCredentialRetry(
(creds) => cloneRepository(url, fullPath, creds),
{ remoteUrl: url }
)
await openFolder(fullPath)
onOpenChange(false)
resetForm()
} catch (err) {
const msg = toErrorMessage(err)
setError(msg)
toast.error(tToasts("cloneFailed"), { description: msg })
} finally {
setCloning(false)
}
}
return (
<>
<Dialog
open={open}
onOpenChange={(v) => {
onOpenChange(v)
if (!v) resetForm()
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t("title")}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-2">
<Label htmlFor="clone-repo-url">{t("repositoryUrl")}</Label>
<Input
id="clone-repo-url"
placeholder={t("repositoryUrlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
disabled={cloning}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="clone-target-dir">{t("directory")}</Label>
<div className="flex gap-2">
<Input
id="clone-target-dir"
placeholder={t("directoryPlaceholder")}
value={targetDir}
onChange={(e) => setTargetDir(e.target.value)}
disabled={cloning}
className="flex-1"
/>
<Button
variant="outline"
size="icon"
onClick={handleBrowse}
disabled={cloning}
title={t("browseDirectory")}
aria-label={t("browseDirectory")}
type="button"
>
<FolderOpen className="h-4 w-4" />
</Button>
</div>
{targetDir && url && (
<p className="text-xs text-muted-foreground">
{t("clonePath", { path: `${targetDir}/${repoName}` })}
</p>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => onOpenChange(false)}
disabled={cloning}
type="button"
>
{t("cancel")}
</Button>
<Button
onClick={handleClone}
disabled={!url || !targetDir || cloning}
type="button"
>
{cloning && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
{t("clone")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DirectoryBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
onSelect={(path) => setTargetDir(path)}
/>
</>
)
}

View File

@@ -37,7 +37,9 @@ import {
matchShortcutEvent,
} from "@/lib/keyboard-shortcuts"
import { AppTitleBar } from "./app-title-bar"
import { BranchDropdown } from "./branch-dropdown"
import { CommandDropdown } from "./command-dropdown"
import { NewFolderDropdown } from "./new-folder-dropdown"
import { SearchCommandDialog } from "@/components/conversations/search-command-dialog"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
import { cn } from "@/lib/utils"
@@ -293,24 +295,30 @@ export function FolderTitleBar() {
>
<Menu className="h-4 w-4" />
</Button>
<NewFolderDropdown />
<BranchDropdown />
</div>
) : (
<div className="flex h-8 flex-1 items-center">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-foreground/80"
onClick={toggle}
title={tTitleBar("withShortcut", {
label: tTitleBar(isOpen ? "hideSidebar" : "showSidebar"),
shortcut: formatShortcutLabel(
shortcuts.toggle_sidebar,
isMac
),
})}
>
<PanelLeft className="h-3.5 w-3.5" />
</Button>
<div className="flex h-8 flex-1 items-center gap-6">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-foreground/80"
onClick={toggle}
title={tTitleBar("withShortcut", {
label: tTitleBar(isOpen ? "hideSidebar" : "showSidebar"),
shortcut: formatShortcutLabel(
shortcuts.toggle_sidebar,
isMac
),
})}
>
<PanelLeft className="h-3.5 w-3.5" />
</Button>
<NewFolderDropdown />
</div>
<BranchDropdown />
<div data-tauri-drag-region className="h-8 flex-1" />
</div>
)

View File

@@ -0,0 +1,79 @@
"use client"
import { useState } from "react"
import { FolderOpen, FolderPlus, GitBranch, Rocket } from "lucide-react"
import { useTranslations } from "next-intl"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Button } from "@/components/ui/button"
import { openProjectBootWindow } from "@/lib/api"
import { isDesktop, openFileDialog } from "@/lib/platform"
import { useAppWorkspace } from "@/contexts/app-workspace-context"
import { CloneDialog } from "@/components/layout/clone-dialog"
import { DirectoryBrowserDialog } from "@/components/shared/directory-browser-dialog"
export function NewFolderDropdown() {
const t = useTranslations("Folder.folderNameDropdown")
const { openFolder } = useAppWorkspace()
const [cloneOpen, setCloneOpen] = useState(false)
const [browserOpen, setBrowserOpen] = useState(false)
async function handleOpenFolder() {
if (isDesktop()) {
const selected = await openFileDialog({
directory: true,
multiple: false,
})
if (selected) {
await openFolder(Array.isArray(selected) ? selected[0] : selected)
}
} else {
setBrowserOpen(true)
}
}
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 hover:text-foreground/80"
title={t("openFolder")}
>
<FolderPlus className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="min-w-56" align="start">
<DropdownMenuItem onSelect={handleOpenFolder}>
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
{t("openFolder")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setCloneOpen(true)}>
<GitBranch className="h-3.5 w-3.5 shrink-0" />
{t("cloneRepository")}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => openProjectBootWindow()}>
<Rocket className="h-3.5 w-3.5 shrink-0" />
{t("projectBoot")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<CloneDialog open={cloneOpen} onOpenChange={setCloneOpen} />
<DirectoryBrowserDialog
open={browserOpen}
onOpenChange={setBrowserOpen}
onSelect={(path) => {
openFolder(path).catch((err) => {
console.error("[NewFolderDropdown] failed to open folder:", err)
})
}}
/>
</>
)
}

View File

@@ -1021,7 +1021,8 @@
"fetchInfo": "جلب المعلومات",
"pushCode": "دفع الكود",
"stashChanges": "تخزين التغييرات في stash",
"stashPop": "استرجاع stash"
"stashPop": "استرجاع stash",
"deleteBranch": "حذف الفرع {branchName}"
},
"confirm": {
"mergeTitle": "دمج الفرع",
@@ -1029,7 +1030,11 @@
"mergeDescription": "دمج {branchName} في الفرع الحالي {currentBranch}؟",
"rebaseDescription": "إجراء rebase للفرع الحالي {currentBranch} على {branchName}؟",
"deleteRemoteTitle": "حذف الفرع البعيد",
"deleteRemoteDescription": "هل تريد حذف الفرع البعيد {branchName}؟ سيؤدي ذلك إلى إزالته من المستودع البعيد ولا يمكن التراجع عن هذا الإجراء."
"deleteRemoteDescription": "هل تريد حذف الفرع البعيد {branchName}؟ سيؤدي ذلك إلى إزالته من المستودع البعيد ولا يمكن التراجع عن هذا الإجراء.",
"deleteTitle": "حذف الفرع",
"deleteDescription": "حذف الفرع {branchName}؟ لا يمكن التراجع عن هذا الإجراء.",
"forceDeleteTitle": "حذف الفرع بالقوة",
"forceDeleteDescription": "الفرع {branchName} لم يتم دمجه بالكامل. هل أنت متأكد من أنك تريد حذفه بالقوة؟ لا يمكن التراجع عن هذا الإجراء."
},
"current": "الحالي",
"switchToBranch": "التبديل إلى هذا الفرع",
@@ -1100,7 +1105,8 @@
"dropSuccess": "تم حذف التخبئة",
"confirmApply": "تطبيق التخبئة {ref} على دليل العمل؟",
"cancel": "إلغاء"
}
},
"deleteBranch": "حذف الفرع"
},
"commitDialog": {
"toasts": {
@@ -1781,6 +1787,20 @@
"openStashFailed": "Failed to open stash window",
"openMergeFailed": "Failed to open merge window"
}
},
"cloneDialog": {
"title": "استنساخ مستودع",
"repositoryUrl": "رابط المستودع",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "المجلد",
"directoryPlaceholder": "اختر مجلد الهدف...",
"browseDirectory": "تصفح المجلد",
"cancel": "إلغاء",
"clone": "استنساخ",
"clonePath": "مسار الاستنساخ: {path}"
},
"toasts": {
"cloneFailed": "فشل استنساخ المستودع"
}
},
"ProjectBoot": {

View File

@@ -1021,7 +1021,8 @@
"fetchInfo": "Informationen fetchen",
"pushCode": "Code pushen",
"stashChanges": "Änderungen stashen",
"stashPop": "Stash anwenden"
"stashPop": "Stash anwenden",
"deleteBranch": "Branch {branchName} löschen"
},
"confirm": {
"mergeTitle": "Branch mergen",
@@ -1029,7 +1030,11 @@
"mergeDescription": "{branchName} in den aktuellen Branch {currentBranch} mergen?",
"rebaseDescription": "Aktuellen Branch {currentBranch} auf {branchName} rebasen?",
"deleteRemoteTitle": "Remote-Branch löschen",
"deleteRemoteDescription": "Remote-Branch {branchName} löschen? Dies entfernt ihn aus dem Remote-Repository und kann nicht rückgängig gemacht werden."
"deleteRemoteDescription": "Remote-Branch {branchName} löschen? Dies entfernt ihn aus dem Remote-Repository und kann nicht rückgängig gemacht werden.",
"deleteTitle": "Branch löschen",
"deleteDescription": "Branch {branchName} löschen? Diese Aktion kann nicht rückgängig gemacht werden.",
"forceDeleteTitle": "Branch erzwungen löschen",
"forceDeleteDescription": "Der Branch {branchName} ist nicht vollständig gemergt. Möchten Sie ihn wirklich erzwungen löschen? Diese Aktion kann nicht rückgängig gemacht werden."
},
"current": "Aktuell",
"switchToBranch": "Zu diesem Branch wechseln",
@@ -1100,7 +1105,8 @@
"dropSuccess": "Stash gelöscht",
"confirmApply": "Stash {ref} auf das Arbeitsverzeichnis anwenden?",
"cancel": "Abbrechen"
}
},
"deleteBranch": "Branch löschen"
},
"commitDialog": {
"toasts": {
@@ -1781,6 +1787,20 @@
"openStashFailed": "Failed to open stash window",
"openMergeFailed": "Failed to open merge window"
}
},
"cloneDialog": {
"title": "Repository klonen",
"repositoryUrl": "Repository-URL",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "Verzeichnis",
"directoryPlaceholder": "Zielverzeichnis auswählen...",
"browseDirectory": "Verzeichnis durchsuchen",
"cancel": "Abbrechen",
"clone": "Klonen",
"clonePath": "Klonpfad: {path}"
},
"toasts": {
"cloneFailed": "Repository konnte nicht geklont werden"
}
},
"ProjectBoot": {

View File

@@ -1021,7 +1021,8 @@
"fetchInfo": "Fetch info",
"pushCode": "Push code",
"stashChanges": "Stash changes",
"stashPop": "Pop stash"
"stashPop": "Pop stash",
"deleteBranch": "Delete branch {branchName}"
},
"confirm": {
"mergeTitle": "Merge branch",
@@ -1029,7 +1030,11 @@
"mergeDescription": "Merge {branchName} into current branch {currentBranch}?",
"rebaseDescription": "Rebase current branch {currentBranch} onto {branchName}?",
"deleteRemoteTitle": "Delete Remote Branch",
"deleteRemoteDescription": "Delete remote branch {branchName}? This will remove it from the remote repository and cannot be undone."
"deleteRemoteDescription": "Delete remote branch {branchName}? This will remove it from the remote repository and cannot be undone.",
"deleteTitle": "Delete branch",
"deleteDescription": "Delete branch {branchName}? This action cannot be undone.",
"forceDeleteTitle": "Force Delete Branch",
"forceDeleteDescription": "Branch {branchName} is not fully merged. Are you sure you want to force delete it? This action cannot be undone."
},
"current": "Current",
"switchToBranch": "Switch to this branch",
@@ -1100,7 +1105,8 @@
"dropSuccess": "Stash dropped",
"confirmApply": "Apply stash {ref} to working directory?",
"cancel": "Cancel"
}
},
"deleteBranch": "Delete branch"
},
"commitDialog": {
"toasts": {
@@ -1781,6 +1787,20 @@
"openStashFailed": "Failed to open stash window",
"openMergeFailed": "Failed to open merge window"
}
},
"cloneDialog": {
"title": "Clone Repository",
"repositoryUrl": "Repository URL",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "Directory",
"directoryPlaceholder": "Select target directory...",
"browseDirectory": "Browse directory",
"cancel": "Cancel",
"clone": "Clone",
"clonePath": "Clone path: {path}"
},
"toasts": {
"cloneFailed": "Failed to clone repository"
}
},
"ProjectBoot": {

View File

@@ -1021,7 +1021,8 @@
"fetchInfo": "Obtener información",
"pushCode": "Enviar código",
"stashChanges": "Guardar cambios en stash",
"stashPop": "Aplicar stash"
"stashPop": "Aplicar stash",
"deleteBranch": "Eliminar rama {branchName}"
},
"confirm": {
"mergeTitle": "Fusionar rama",
@@ -1029,7 +1030,11 @@
"mergeDescription": "¿Fusionar {branchName} en la rama actual {currentBranch}?",
"rebaseDescription": "¿Hacer rebase de la rama actual {currentBranch} sobre {branchName}?",
"deleteRemoteTitle": "Eliminar rama remota",
"deleteRemoteDescription": "¿Eliminar la rama remota {branchName}? Esto la eliminará del repositorio remoto y no se puede deshacer."
"deleteRemoteDescription": "¿Eliminar la rama remota {branchName}? Esto la eliminará del repositorio remoto y no se puede deshacer.",
"deleteTitle": "Eliminar rama",
"deleteDescription": "¿Eliminar la rama {branchName}? Esta acción no se puede deshacer.",
"forceDeleteTitle": "Forzar eliminación de rama",
"forceDeleteDescription": "La rama {branchName} no está completamente fusionada. ¿Estás seguro de que quieres forzar su eliminación? Esta acción no se puede deshacer."
},
"current": "Actual",
"switchToBranch": "Cambiar a esta rama",
@@ -1100,7 +1105,8 @@
"dropSuccess": "Stash eliminado",
"confirmApply": "¿Aplicar stash {ref} al directorio de trabajo?",
"cancel": "Cancelar"
}
},
"deleteBranch": "Eliminar rama"
},
"commitDialog": {
"toasts": {
@@ -1781,6 +1787,20 @@
"openStashFailed": "Failed to open stash window",
"openMergeFailed": "Failed to open merge window"
}
},
"cloneDialog": {
"title": "Clonar repositorio",
"repositoryUrl": "URL del repositorio",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "Directorio",
"directoryPlaceholder": "Selecciona el directorio de destino...",
"browseDirectory": "Explorar directorio",
"cancel": "Cancelar",
"clone": "Clonar",
"clonePath": "Ruta de clonación: {path}"
},
"toasts": {
"cloneFailed": "No se pudo clonar el repositorio"
}
},
"ProjectBoot": {

View File

@@ -1021,7 +1021,8 @@
"fetchInfo": "Récupérer les infos",
"pushCode": "Push du code",
"stashChanges": "Stash des changements",
"stashPop": "Appliquer le stash"
"stashPop": "Appliquer le stash",
"deleteBranch": "Supprimer la branche {branchName}"
},
"confirm": {
"mergeTitle": "Fusionner la branche",
@@ -1029,7 +1030,11 @@
"mergeDescription": "Fusionner {branchName} dans la branche actuelle {currentBranch} ?",
"rebaseDescription": "Rebaser la branche actuelle {currentBranch} sur {branchName} ?",
"deleteRemoteTitle": "Supprimer la branche distante",
"deleteRemoteDescription": "Supprimer la branche distante {branchName} ? Cette action la supprimera du dépôt distant et ne pourra pas être annulée."
"deleteRemoteDescription": "Supprimer la branche distante {branchName} ? Cette action la supprimera du dépôt distant et ne pourra pas être annulée.",
"deleteTitle": "Supprimer la branche",
"deleteDescription": "Supprimer la branche {branchName} ? Cette action est irréversible.",
"forceDeleteTitle": "Forcer la suppression de la branche",
"forceDeleteDescription": "La branche {branchName} n'est pas entièrement fusionnée. Êtes-vous sûr de vouloir la supprimer de force ? Cette action est irréversible."
},
"current": "Actuelle",
"switchToBranch": "Basculer vers cette branche",
@@ -1100,7 +1105,8 @@
"dropSuccess": "Remise supprimée",
"confirmApply": "Appliquer la remise {ref} au répertoire de travail ?",
"cancel": "Annuler"
}
},
"deleteBranch": "Supprimer la branche"
},
"commitDialog": {
"toasts": {
@@ -1781,6 +1787,20 @@
"openStashFailed": "Failed to open stash window",
"openMergeFailed": "Failed to open merge window"
}
},
"cloneDialog": {
"title": "Cloner un dépôt",
"repositoryUrl": "URL du dépôt",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "Répertoire",
"directoryPlaceholder": "Sélectionnez le répertoire cible...",
"browseDirectory": "Parcourir le répertoire",
"cancel": "Annuler",
"clone": "Cloner",
"clonePath": "Chemin de clonage : {path}"
},
"toasts": {
"cloneFailed": "Échec du clonage du dépôt"
}
},
"ProjectBoot": {

View File

@@ -1021,7 +1021,8 @@
"fetchInfo": "情報をフェッチ",
"pushCode": "コードをプッシュ",
"stashChanges": "変更を stash",
"stashPop": "stash を pop"
"stashPop": "stash を pop",
"deleteBranch": "ブランチ {branchName} を削除"
},
"confirm": {
"mergeTitle": "ブランチをマージ",
@@ -1029,7 +1030,11 @@
"mergeDescription": "{branchName} を現在のブランチ {currentBranch} にマージしますか?",
"rebaseDescription": "現在のブランチ {currentBranch} を {branchName} にリベースしますか?",
"deleteRemoteTitle": "リモートブランチの削除",
"deleteRemoteDescription": "リモートブランチ {branchName} を削除しますか?この操作はリモートリポジトリからブランチを削除し、元に戻せません。"
"deleteRemoteDescription": "リモートブランチ {branchName} を削除しますか?この操作はリモートリポジトリからブランチを削除し、元に戻せません。",
"deleteTitle": "ブランチを削除",
"deleteDescription": "ブランチ {branchName} を削除しますか?この操作は元に戻せません。",
"forceDeleteTitle": "ブランチを強制削除",
"forceDeleteDescription": "ブランチ {branchName} はまだ完全にマージされていません。強制削除してもよろしいですか?この操作は元に戻せません。"
},
"current": "現在",
"switchToBranch": "このブランチに切り替え",
@@ -1100,7 +1105,8 @@
"dropSuccess": "スタッシュを削除しました",
"confirmApply": "スタッシュ {ref} を作業ディレクトリに適用しますか?",
"cancel": "キャンセル"
}
},
"deleteBranch": "ブランチを削除"
},
"commitDialog": {
"toasts": {
@@ -1781,6 +1787,20 @@
"openStashFailed": "Failed to open stash window",
"openMergeFailed": "Failed to open merge window"
}
},
"cloneDialog": {
"title": "リポジトリをクローン",
"repositoryUrl": "リポジトリ URL",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "ディレクトリ",
"directoryPlaceholder": "保存先ディレクトリを選択...",
"browseDirectory": "ディレクトリを参照",
"cancel": "キャンセル",
"clone": "クローン",
"clonePath": "クローンパス: {path}"
},
"toasts": {
"cloneFailed": "リポジトリのクローンに失敗しました"
}
},
"ProjectBoot": {

View File

@@ -1021,7 +1021,8 @@
"fetchInfo": "정보 fetch",
"pushCode": "코드 push",
"stashChanges": "변경 사항 stash",
"stashPop": "stash pop"
"stashPop": "stash pop",
"deleteBranch": "브랜치 {branchName} 삭제"
},
"confirm": {
"mergeTitle": "브랜치 병합",
@@ -1029,7 +1030,11 @@
"mergeDescription": "{branchName}을(를) 현재 브랜치 {currentBranch}에 병합할까요?",
"rebaseDescription": "현재 브랜치 {currentBranch}를 {branchName} 위로 리베이스할까요?",
"deleteRemoteTitle": "원격 브랜치 삭제",
"deleteRemoteDescription": "원격 브랜치 {branchName}을(를) 삭제하시겠습니까? 이 작업은 원격 저장소에서 브랜치를 제거하며 되돌릴 수 없습니다."
"deleteRemoteDescription": "원격 브랜치 {branchName}을(를) 삭제하시겠습니까? 이 작업은 원격 저장소에서 브랜치를 제거하며 되돌릴 수 없습니다.",
"deleteTitle": "브랜치 삭제",
"deleteDescription": "브랜치 {branchName}을(를) 삭제할까요? 이 작업은 되돌릴 수 없습니다.",
"forceDeleteTitle": "브랜치 강제 삭제",
"forceDeleteDescription": "브랜치 {branchName}가 완전히 병합되지 않았습니다. 강제 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다."
},
"current": "현재",
"switchToBranch": "이 브랜치로 전환",
@@ -1100,7 +1105,8 @@
"dropSuccess": "스태시가 삭제되었습니다",
"confirmApply": "스태시 {ref}을(를) 작업 디렉토리에 적용하시겠습니까?",
"cancel": "취소"
}
},
"deleteBranch": "브랜치 삭제"
},
"commitDialog": {
"toasts": {
@@ -1781,6 +1787,20 @@
"openStashFailed": "Failed to open stash window",
"openMergeFailed": "Failed to open merge window"
}
},
"cloneDialog": {
"title": "저장소 클론",
"repositoryUrl": "저장소 URL",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "디렉터리",
"directoryPlaceholder": "대상 디렉터리 선택...",
"browseDirectory": "디렉터리 찾아보기",
"cancel": "취소",
"clone": "클론",
"clonePath": "클론 경로: {path}"
},
"toasts": {
"cloneFailed": "저장소 클론에 실패했습니다"
}
},
"ProjectBoot": {

View File

@@ -1021,7 +1021,8 @@
"fetchInfo": "Buscar informações",
"pushCode": "Enviar código",
"stashChanges": "Fazer stash das alterações",
"stashPop": "Aplicar stash"
"stashPop": "Aplicar stash",
"deleteBranch": "Excluir branch {branchName}"
},
"confirm": {
"mergeTitle": "Mesclar branch",
@@ -1029,7 +1030,11 @@
"mergeDescription": "Mesclar {branchName} na branch atual {currentBranch}?",
"rebaseDescription": "Fazer rebase da branch atual {currentBranch} sobre {branchName}?",
"deleteRemoteTitle": "Excluir branch remoto",
"deleteRemoteDescription": "Excluir o branch remoto {branchName}? Isso o removerá do repositório remoto e não poderá ser desfeito."
"deleteRemoteDescription": "Excluir o branch remoto {branchName}? Isso o removerá do repositório remoto e não poderá ser desfeito.",
"deleteTitle": "Excluir branch",
"deleteDescription": "Excluir a branch {branchName}? Esta ação não pode ser desfeita.",
"forceDeleteTitle": "Forçar exclusão do branch",
"forceDeleteDescription": "O branch {branchName} não está totalmente mesclado. Tem certeza de que deseja forçar a exclusão? Esta ação não pode ser desfeita."
},
"current": "Atual",
"switchToBranch": "Mudar para esta branch",
@@ -1100,7 +1105,8 @@
"dropSuccess": "Stash eliminado",
"confirmApply": "Aplicar stash {ref} ao diretório de trabalho?",
"cancel": "Cancelar"
}
},
"deleteBranch": "Excluir branch"
},
"commitDialog": {
"toasts": {
@@ -1781,6 +1787,20 @@
"openStashFailed": "Failed to open stash window",
"openMergeFailed": "Failed to open merge window"
}
},
"cloneDialog": {
"title": "Clonar repositório",
"repositoryUrl": "URL do repositório",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "Diretório",
"directoryPlaceholder": "Selecione o diretório de destino...",
"browseDirectory": "Procurar diretório",
"cancel": "Cancelar",
"clone": "Clonar",
"clonePath": "Caminho de clonagem: {path}"
},
"toasts": {
"cloneFailed": "Falha ao clonar o repositório"
}
},
"ProjectBoot": {

View File

@@ -1021,7 +1021,8 @@
"fetchInfo": "获取信息",
"pushCode": "推送代码",
"stashChanges": "贮藏更改",
"stashPop": "取消贮藏"
"stashPop": "取消贮藏",
"deleteBranch": "删除分支 {branchName}"
},
"confirm": {
"mergeTitle": "合并分支",
@@ -1029,7 +1030,11 @@
"mergeDescription": "确定将 {branchName} 合并到当前分支 {currentBranch} 吗?",
"rebaseDescription": "确定将当前分支 {currentBranch} 变基到 {branchName} 吗?",
"deleteRemoteTitle": "删除远程分支",
"deleteRemoteDescription": "确定删除远程分支 {branchName} 吗?此操作将从远程仓库中移除该分支,且不可恢复。"
"deleteRemoteDescription": "确定删除远程分支 {branchName} 吗?此操作将从远程仓库中移除该分支,且不可恢复。",
"deleteTitle": "删除分支",
"deleteDescription": "确定删除分支 {branchName} 吗?此操作不可恢复。",
"forceDeleteTitle": "强制删除分支",
"forceDeleteDescription": "分支 {branchName} 尚未完全合并,确定要强制删除吗?此操作不可恢复。"
},
"current": "当前",
"switchToBranch": "切换到此分支",
@@ -1100,7 +1105,8 @@
"dropSuccess": "贮藏已删除",
"confirmApply": "将贮藏 {ref} 应用到工作目录?",
"cancel": "取消"
}
},
"deleteBranch": "删除分支"
},
"commitDialog": {
"toasts": {
@@ -1781,6 +1787,20 @@
"openStashFailed": "打开贮藏窗口失败",
"openMergeFailed": "打开合并窗口失败"
}
},
"cloneDialog": {
"title": "克隆仓库",
"repositoryUrl": "仓库地址",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "目录",
"directoryPlaceholder": "选择目标目录...",
"browseDirectory": "浏览目录",
"cancel": "取消",
"clone": "克隆",
"clonePath": "克隆路径: {path}"
},
"toasts": {
"cloneFailed": "克隆仓库失败"
}
},
"ProjectBoot": {

View File

@@ -1021,7 +1021,8 @@
"fetchInfo": "獲取資訊",
"pushCode": "推送程式碼",
"stashChanges": "暫存變更",
"stashPop": "取消暫存"
"stashPop": "取消暫存",
"deleteBranch": "刪除分支 {branchName}"
},
"confirm": {
"mergeTitle": "合併分支",
@@ -1029,7 +1030,11 @@
"mergeDescription": "確定將 {branchName} 合併到目前分支 {currentBranch} 嗎?",
"rebaseDescription": "確定將目前分支 {currentBranch} 變基到 {branchName} 嗎?",
"deleteRemoteTitle": "刪除遠端分支",
"deleteRemoteDescription": "確定刪除遠端分支 {branchName} 嗎?此操作將從遠端倉庫中移除該分支,且不可恢復。"
"deleteRemoteDescription": "確定刪除遠端分支 {branchName} 嗎?此操作將從遠端倉庫中移除該分支,且不可恢復。",
"deleteTitle": "刪除分支",
"deleteDescription": "確定刪除分支 {branchName} 嗎?此操作無法復原。",
"forceDeleteTitle": "強制刪除分支",
"forceDeleteDescription": "分支 {branchName} 尚未完全合併,確定要強制刪除嗎?此操作不可恢復。"
},
"current": "目前",
"switchToBranch": "切換到此分支",
@@ -1100,7 +1105,8 @@
"dropSuccess": "貯藏已刪除",
"confirmApply": "將貯藏 {ref} 套用到工作目錄?",
"cancel": "取消"
}
},
"deleteBranch": "刪除分支"
},
"commitDialog": {
"toasts": {
@@ -1781,6 +1787,20 @@
"openStashFailed": "Failed to open stash window",
"openMergeFailed": "Failed to open merge window"
}
},
"cloneDialog": {
"title": "複製倉庫",
"repositoryUrl": "倉庫地址",
"repositoryUrlPlaceholder": "https://github.com/user/repo.git",
"directory": "目錄",
"directoryPlaceholder": "選擇目標目錄...",
"browseDirectory": "瀏覽目錄",
"cancel": "取消",
"clone": "複製",
"clonePath": "克隆路徑: {path}"
},
"toasts": {
"cloneFailed": "複製倉庫失敗"
}
},
"ProjectBoot": {

View File

@@ -792,6 +792,18 @@ export async function gitRebase(
return getTransport().call("git_rebase", { path, branchName })
}
export async function gitDeleteBranch(
path: string,
branchName: string,
force: boolean = false
): Promise<string> {
return getTransport().call("git_delete_branch", {
path,
branchName,
force,
})
}
export async function gitDeleteRemoteBranch(
path: string,
remote: string,