From f3bdf947236df31bc395149adb85ac0e80fb8dd4 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 22 Apr 2026 00:23:27 +0800 Subject: [PATCH] feat(title-bar): add folder-actions menu and branch dropdown with git operations --- src-tauri/src/commands/folders.rs | 23 + src-tauri/src/lib.rs | 1 + src-tauri/src/web/handlers/git.rs | 17 + src-tauri/src/web/router.rs | 1 + src/components/layout/branch-dropdown.tsx | 950 ++++++++++++++++++ src/components/layout/clone-dialog.tsx | 174 ++++ src/components/layout/folder-title-bar.tsx | 40 +- src/components/layout/new-folder-dropdown.tsx | 79 ++ src/i18n/messages/ar.json | 26 +- src/i18n/messages/de.json | 26 +- src/i18n/messages/en.json | 26 +- src/i18n/messages/es.json | 26 +- src/i18n/messages/fr.json | 26 +- src/i18n/messages/ja.json | 26 +- src/i18n/messages/ko.json | 26 +- src/i18n/messages/pt.json | 26 +- src/i18n/messages/zh-CN.json | 26 +- src/i18n/messages/zh-TW.json | 26 +- src/lib/api.ts | 12 + 19 files changed, 1511 insertions(+), 46 deletions(-) create mode 100644 src/components/layout/branch-dropdown.tsx create mode 100644 src/components/layout/clone-dialog.tsx create mode 100644 src/components/layout/new-folder-dropdown.tsx diff --git a/src-tauri/src/commands/folders.rs b/src-tauri/src/commands/folders.rs index 417561e..a0d4e3c 100644 --- a/src-tauri/src/commands/folders.rs +++ b/src-tauri/src/commands/folders.rs @@ -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 { + 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, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 211e38c..45117f7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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, diff --git a/src-tauri/src/web/handlers/git.rs b/src-tauri/src/web/handlers/git.rs index 889880b..c62d3c9 100644 --- a/src-tauri/src/web/handlers/git.rs +++ b/src-tauri/src/web/handlers/git.rs @@ -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, +) -> Result, 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 { diff --git a/src-tauri/src/web/router.rs b/src-tauri/src/web/router.rs index b0d3574..3a9771a 100644 --- a/src-tauri/src/web/router.rs +++ b/src-tauri/src/web/router.rs @@ -243,6 +243,7 @@ pub fn build_router(state: Arc, 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), diff --git a/src/components/layout/branch-dropdown.tsx b/src/components/layout/branch-dropdown.tsx new file mode 100644 index 0000000..a951977 --- /dev/null +++ b/src/components/layout/branch-dropdown.tsx @@ -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({ + 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(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(null) + const taskSeq = useRef(0) + + const worktreeBranchSet = useMemo( + () => new Set(branchList.worktree_branches), + [branchList.worktree_branches] + ) + const groupedRemoteBranches = useMemo(() => { + const groups: Record = {} + 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( + "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( + "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( + label: string, + action: () => Promise, + 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 ( +
+ + {label} + {t("current")} +
+ ) + } + + return ( + + + + {label} + + + { + setDropdownOpen(false) + setConfirmAction({ type: "merge", branchName: b }) + }} + > + + {t("mergeBranchIntoCurrent", { + branchName: b, + currentBranch: branch ?? "-", + })} + + { + setDropdownOpen(false) + setConfirmAction({ type: "rebase", branchName: b }) + }} + > + + {t("rebaseCurrentToBranch", { + currentBranch: branch ?? "-", + branchName: b, + })} + + {!isTrackingCurrent && ( + <> + + { + setDropdownOpen(false) + setConfirmAction({ + type: isRemote ? "deleteRemote" : "delete", + branchName: b, + }) + }} + > + + {t("deleteBranch")} + + + )} + + + ) + } + + if (!activeFolder) return null + + const folderName = activeFolder.name + + if (branch === null) { + return ( + + + + + + + runGitTask(t("tasks.initGitRepo"), () => gitInit(folderPath)) + } + > + + {t("initGitRepo")} + + + + ) + } + + return ( + <> + + + + + + + + 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, + }) + } + ) + } + > + + {t("pullCode")} + + + runGitTask(t("tasks.fetchInfo"), () => + withCredentialRetry((creds) => gitFetch(folderPath, creds), { + folderPath, + }) + ) + } + > + + {t("fetchRemoteBranches")} + + + + + { + 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 }) + }) + }} + > + + {t("openCommitWindow")} + + { + 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 }) + }) + }} + > + + {t("pushCode")} + + + + + { + setNewBranchName("") + setNewBranchOpen(true) + }} + > + + {t("newBranch")} + + + + {t("newWorktree")} + + + + + { + setDropdownOpen(false) + setStashDialogOpen(true) + }} + > + + {t("stashChanges")} + + { + if (!folderId) return + openStashWindow(folderId).catch((err) => { + const msg = toErrorMessage(err) + pushAlert("error", t("stashPop"), msg) + }) + }} + > + + {t("stashPop")} + + + + + { + setDropdownOpen(false) + setManageRemotesOpen(true) + }} + > + + {t("manageRemotes")} + + + + {branchLoading ? ( +
+ +
+ ) : ( + + + + + {t("localBranches", { count: branchList.local.length })} + + + {branchList.local.length === 0 ? ( + + {t("noLocalBranches")} + + ) : ( + branchList.local.map((b) => renderBranchItem(b, false)) + )} + + + + + + + {t("remoteBranches", { count: branchList.remote.length })} + + + {branchList.remote.length === 0 ? ( + + {t("noRemoteBranches")} + + ) : hasMultipleRemotes ? ( + remoteNames.map((remoteName) => ( + + + + {remoteName} ( + {groupedRemoteBranches[remoteName].length}) + + + {groupedRemoteBranches[remoteName].map((b) => + renderBranchItem( + b, + true, + b.substring(remoteName.length + 1) + ) + )} + + + )) + ) : ( + branchList.remote.map((b) => { + const slashIndex = b.indexOf("/") + const shortName = + slashIndex > 0 ? b.substring(slashIndex + 1) : b + return renderBranchItem(b, true, shortName) + }) + )} + + + + )} +
+
+ + {parentBranch && ( + + )} + + { + if (!open) setConfirmAction(null) + }} + > + + + {getConfirmTitle()} + + {getConfirmDescription()} + + + + {tCommon("cancel")} + + {tCommon("confirm")} + + + + + + + + + {t("dialogs.newBranchTitle")} + + {t("dialogs.newBranchDescription", { branch: branch ?? "-" })} + + + setNewBranchName(e.target.value)} + onKeyDown={(e) => { + if (e.nativeEvent.isComposing || e.key === "Process") return + if (e.key === "Enter") handleNewBranch() + }} + autoFocus + /> + + + + + + + + + + + {t("dialogs.newWorktreeTitle")} + + {t("dialogs.newWorktreeDescription", { branch: branch ?? "-" })} + + +
+
+ + setWorktreeBranchName(e.target.value)} + onKeyDown={(e) => { + if (e.nativeEvent.isComposing || e.key === "Process") return + if (e.key === "Enter") handleNewWorktree() + }} + autoFocus + /> +
+
+ +
+ setWorktreePath(e.target.value)} + className="flex-1" + /> + +
+
+
+ + + + +
+
+ + loadAllBranches()} + /> + + setConflictInfo(null)} + onResolved={refresh} + /> + + setStashDialogOpen(false)} + onStashed={refresh} + /> + + ) +} diff --git a/src/components/layout/clone-dialog.tsx b/src/components/layout/clone-dialog.tsx new file mode 100644 index 0000000..199c092 --- /dev/null +++ b/src/components/layout/clone-dialog.tsx @@ -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(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 ( + <> + { + onOpenChange(v) + if (!v) resetForm() + }} + > + + + {t("title")} + +
+
+ + setUrl(e.target.value)} + disabled={cloning} + autoFocus + /> +
+
+ +
+ setTargetDir(e.target.value)} + disabled={cloning} + className="flex-1" + /> + +
+ {targetDir && url && ( +

+ {t("clonePath", { path: `${targetDir}/${repoName}` })} +

+ )} +
+ {error &&

{error}

} +
+ + + + +
+
+ setTargetDir(path)} + /> + + ) +} diff --git a/src/components/layout/folder-title-bar.tsx b/src/components/layout/folder-title-bar.tsx index fea232c..7384fe8 100644 --- a/src/components/layout/folder-title-bar.tsx +++ b/src/components/layout/folder-title-bar.tsx @@ -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() { > + + ) : ( -
- +
+
+ + +
+
) diff --git a/src/components/layout/new-folder-dropdown.tsx b/src/components/layout/new-folder-dropdown.tsx new file mode 100644 index 0000000..98fb99c --- /dev/null +++ b/src/components/layout/new-folder-dropdown.tsx @@ -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 ( + <> + + + + + + + + {t("openFolder")} + + setCloneOpen(true)}> + + {t("cloneRepository")} + + openProjectBootWindow()}> + + {t("projectBoot")} + + + + + { + openFolder(path).catch((err) => { + console.error("[NewFolderDropdown] failed to open folder:", err) + }) + }} + /> + + ) +} diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index e62b9b7..07ac306 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -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": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index f3333d0..3eff2c4 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -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": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index f4416df..4de4ce7 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -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": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 94d83a8..5c1f997 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -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": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 3cb7d51..aa1139a 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -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": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index fc45cd8..cd0ea0f 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -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": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index f32d8df..d9f6365 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -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": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index ba50062..02d8acf 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -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": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index b1ce53e..2df58de 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -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": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index cba4a96..7cc5824 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -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": { diff --git a/src/lib/api.ts b/src/lib/api.ts index f67c2c6..6bb7d5c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 { + return getTransport().call("git_delete_branch", { + path, + branchName, + force, + }) +} + export async function gitDeleteRemoteBranch( path: string, remote: string,