mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-13 19:20:04 +00:00
✨ feat(device): add rename & delete actions to branch switcher (#15774)
Hover a branch row in the branch switcher to rename or delete it. Wires new renameGitBranch / deleteGitBranch operations through both transports (Electron IPC for the local machine, device.* TRPC RPCs for remote/web), mirroring the existing checkoutGitBranch / revertGitFile stack. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -390,6 +390,14 @@ export default class GatewayConnectionCtr extends ControllerModule {
|
||||
);
|
||||
}
|
||||
|
||||
case 'renameGitBranch': {
|
||||
return this.gitCtr.renameGitBranch(params as { from: string; path: string; to: string });
|
||||
}
|
||||
|
||||
case 'deleteGitBranch': {
|
||||
return this.gitCtr.deleteGitBranch(params as { branch: string; path: string });
|
||||
}
|
||||
|
||||
case 'pullGitBranch': {
|
||||
return this.gitCtr.pullGitBranch(params as { path: string });
|
||||
}
|
||||
|
||||
@@ -10,12 +10,14 @@ import type {
|
||||
GitBranchInfo,
|
||||
GitBranchListItem,
|
||||
GitCheckoutResult,
|
||||
GitDeleteBranchResult,
|
||||
GitFileDiffStatus,
|
||||
GitFileRevertResult,
|
||||
GitLinkedPullRequestResult,
|
||||
GitPullResult,
|
||||
GitPushResult,
|
||||
GitRemoteBranchListItem,
|
||||
GitRenameBranchResult,
|
||||
GitWorkingTreeFiles,
|
||||
GitWorkingTreePatch,
|
||||
GitWorkingTreePatches,
|
||||
@@ -1084,6 +1086,67 @@ export default class GitController extends ControllerModule {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a local branch (`git branch -m <from> <to>`). Works on the current
|
||||
* branch too. Uses the non-force `-m`, so git rejects (and we surface) a
|
||||
* rename onto an existing branch name. Mirrors `checkoutGitBranch`'s early
|
||||
* ref validation on the new name.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async renameGitBranch(payload: {
|
||||
from: string;
|
||||
path: string;
|
||||
to: string;
|
||||
}): Promise<GitRenameBranchResult> {
|
||||
const { path: dirPath, from, to } = payload;
|
||||
if (!from?.trim() || !to?.trim()) {
|
||||
return { error: 'Branch name is required', success: false };
|
||||
}
|
||||
// Reject obviously invalid refs early to avoid a confusing git error
|
||||
if (/[\s~^:?*[\\]/.test(to) || to.startsWith('-') || to.includes('..')) {
|
||||
return { error: `Invalid branch name: ${to}`, success: false };
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
await execFileAsync('git', ['branch', '-m', from, to], { cwd: dirPath, timeout: 10_000 });
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
|
||||
logger.debug('[renameGitBranch] failed', { from, stderr, to });
|
||||
return { error: stderr || 'git branch rename failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a local branch (`git branch -D <branch>`). Force delete (`-D`) is
|
||||
* intentional: the UI gates this behind an explicit confirm, so we don't want
|
||||
* git's "not fully merged" guard to block a deliberate cleanup. git still
|
||||
* refuses to delete the currently checked-out branch, and that error is
|
||||
* surfaced to the renderer.
|
||||
*/
|
||||
@IpcMethod()
|
||||
async deleteGitBranch(payload: { branch: string; path: string }): Promise<GitDeleteBranchResult> {
|
||||
const { path: dirPath, branch } = payload;
|
||||
if (!branch?.trim()) {
|
||||
return { error: 'Branch name is required', success: false };
|
||||
}
|
||||
// Reject obviously invalid refs early to avoid a confusing git error
|
||||
if (/[\s~^:?*[\\]/.test(branch) || branch.startsWith('-') || branch.includes('..')) {
|
||||
return { error: `Invalid branch name: ${branch}`, success: false };
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
try {
|
||||
await execFileAsync('git', ['branch', '-D', branch], { cwd: dirPath, timeout: 10_000 });
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
const stderr: string = (error?.stderr ?? error?.message ?? '').toString().trim();
|
||||
logger.debug('[deleteGitBranch] failed', { branch, stderr });
|
||||
return { error: stderr || 'git branch delete failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull the current branch's upstream via fast-forward only.
|
||||
*
|
||||
|
||||
@@ -163,6 +163,50 @@ export const deviceRouter = router({
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Rename a branch in a directory on a remote device, via the device's
|
||||
* `renameGitBranch` RPC.
|
||||
*/
|
||||
renameGitBranch: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
deviceId: z.string(),
|
||||
from: z.string(),
|
||||
path: z.string(),
|
||||
to: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.renameGitBranch({
|
||||
deviceId: input.deviceId,
|
||||
from: input.from,
|
||||
path: input.path,
|
||||
to: input.to,
|
||||
userId: ctx.userId,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Delete a branch in a directory on a remote device, via the device's
|
||||
* `deleteGitBranch` RPC.
|
||||
*/
|
||||
deleteGitBranch: deviceProcedure
|
||||
.input(
|
||||
z.object({
|
||||
branch: z.string(),
|
||||
deviceId: z.string(),
|
||||
path: z.string(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) =>
|
||||
deviceGateway.deleteGitBranch({
|
||||
branch: input.branch,
|
||||
deviceId: input.deviceId,
|
||||
path: input.path,
|
||||
userId: ctx.userId,
|
||||
}),
|
||||
),
|
||||
|
||||
/**
|
||||
* Pull (`--ff-only`) the current branch of a directory on a remote device, via
|
||||
* the device's `pullGitBranch` RPC.
|
||||
|
||||
@@ -14,9 +14,11 @@ import type {
|
||||
DeviceGitBranchInfo,
|
||||
DeviceGitBranchListItem,
|
||||
DeviceGitCheckoutResult,
|
||||
DeviceGitDeleteBranchResult,
|
||||
DeviceGitFileRevertResult,
|
||||
DeviceGitLinkedPullRequestResult,
|
||||
DeviceGitRemoteBranchListItem,
|
||||
DeviceGitRenameBranchResult,
|
||||
DeviceGitSyncResult,
|
||||
DeviceGitWorkingTreeFiles,
|
||||
DeviceGitWorkingTreePatches,
|
||||
@@ -272,6 +274,73 @@ export class DeviceGateway {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename a branch in a directory on a remote device via the `renameGitBranch`
|
||||
* device RPC.
|
||||
*/
|
||||
async renameGitBranch(params: {
|
||||
deviceId: string;
|
||||
from: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
to: string;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitRenameBranchResult> {
|
||||
const { userId, deviceId, from, to, path, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitRenameBranchResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'renameGitBranch', params: { from, path, to } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('renameGitBranch: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return { error: result.error || 'Rename failed', success: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('renameGitBranch: error for deviceId=%s — %O', deviceId, error);
|
||||
return { error: (error as Error)?.message || 'Rename failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a branch in a directory on a remote device via the `deleteGitBranch`
|
||||
* device RPC.
|
||||
*/
|
||||
async deleteGitBranch(params: {
|
||||
branch: string;
|
||||
deviceId: string;
|
||||
path: string;
|
||||
timeout?: number;
|
||||
userId: string;
|
||||
}): Promise<DeviceGitDeleteBranchResult> {
|
||||
const { userId, deviceId, branch, path, timeout = 30_000 } = params;
|
||||
const client = this.getClient();
|
||||
if (!client) return { error: 'Device gateway not configured', success: false };
|
||||
|
||||
try {
|
||||
const result = await client.invokeRpc<DeviceGitDeleteBranchResult>(
|
||||
{ deviceId, timeout, userId },
|
||||
{ method: 'deleteGitBranch', params: { branch, path } },
|
||||
);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
log('deleteGitBranch: failed for deviceId=%s — %s', deviceId, result.error);
|
||||
return { error: result.error || 'Delete failed', success: false };
|
||||
}
|
||||
|
||||
return result.data;
|
||||
} catch (error) {
|
||||
log('deleteGitBranch: error for deviceId=%s — %O', deviceId, error);
|
||||
return { error: (error as Error)?.message || 'Delete failed', success: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull (`--ff-only`) the current branch of a directory on a remote device via
|
||||
* the `pullGitBranch` device RPC.
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "Checkout new branch…",
|
||||
"workingDirectory.createBranchTitle": "Create new branch",
|
||||
"workingDirectory.current": "Current working directory",
|
||||
"workingDirectory.deleteBranchAction": "Delete branch",
|
||||
"workingDirectory.deleteBranchConfirm": "Delete branch “{{name}}”? This permanently removes it, including any unmerged commits.",
|
||||
"workingDirectory.deleteBranchTitle": "Delete branch",
|
||||
"workingDirectory.deleteFailed": "Delete failed",
|
||||
"workingDirectory.detachedHead": "Detached HEAD at {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "Added {{added}} · Modified {{modified}} · Deleted {{deleted}}",
|
||||
"workingDirectory.filesAdded": "Added",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "Recent",
|
||||
"workingDirectory.refreshGitStatus": "Refresh branch & PR status",
|
||||
"workingDirectory.removeRecent": "Remove from recent",
|
||||
"workingDirectory.renameBranchAction": "Rename branch",
|
||||
"workingDirectory.renameBranchTitle": "Rename branch",
|
||||
"workingDirectory.renameFailed": "Rename failed",
|
||||
"workingDirectory.selectFolder": "Select folder",
|
||||
"workingDirectory.title": "Working Directory",
|
||||
"workingDirectory.topicDescription": "Override Agent default for this conversation only",
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
"workingDirectory.createBranchAction": "检出新分支…",
|
||||
"workingDirectory.createBranchTitle": "创建新分支",
|
||||
"workingDirectory.current": "当前工作目录",
|
||||
"workingDirectory.deleteBranchAction": "删除分支",
|
||||
"workingDirectory.deleteBranchConfirm": "确定删除分支「{{name}}」吗?此操作会永久删除该分支,包括尚未合并的提交。",
|
||||
"workingDirectory.deleteBranchTitle": "删除分支",
|
||||
"workingDirectory.deleteFailed": "删除失败",
|
||||
"workingDirectory.detachedHead": "游离 HEAD,当前提交 {{sha}}",
|
||||
"workingDirectory.diffStatTooltip": "新增 {{added}} · 修改 {{modified}} · 删除 {{deleted}}",
|
||||
"workingDirectory.filesAdded": "新增",
|
||||
@@ -46,6 +50,9 @@
|
||||
"workingDirectory.recent": "最近使用",
|
||||
"workingDirectory.refreshGitStatus": "刷新分支与 PR 状态",
|
||||
"workingDirectory.removeRecent": "从最近目录中移除",
|
||||
"workingDirectory.renameBranchAction": "重命名分支",
|
||||
"workingDirectory.renameBranchTitle": "重命名分支",
|
||||
"workingDirectory.renameFailed": "重命名失败",
|
||||
"workingDirectory.selectFolder": "选择文件夹",
|
||||
"workingDirectory.title": "工作目录",
|
||||
"workingDirectory.topicDescription": "仅覆盖当前对话的工作目录",
|
||||
|
||||
@@ -173,6 +173,16 @@ export interface GitFileRevertResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface GitRenameBranchResult {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface GitDeleteBranchResult {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export interface GitPullResult {
|
||||
error?: string;
|
||||
/** True when `git pull` reported the branch was already up-to-date */
|
||||
|
||||
@@ -19,6 +19,11 @@ export default {
|
||||
'workingDirectory.createBranchAction': 'Checkout new branch…',
|
||||
'workingDirectory.createBranchTitle': 'Create new branch',
|
||||
'workingDirectory.current': 'Current working directory',
|
||||
'workingDirectory.deleteBranchAction': 'Delete branch',
|
||||
'workingDirectory.deleteBranchConfirm':
|
||||
'Delete branch “{{name}}”? This permanently removes it, including any unmerged commits.',
|
||||
'workingDirectory.deleteBranchTitle': 'Delete branch',
|
||||
'workingDirectory.deleteFailed': 'Delete failed',
|
||||
'workingDirectory.detachedHead': 'Detached HEAD at {{sha}}',
|
||||
'workingDirectory.diffStatTooltip':
|
||||
'Added {{added}} · Modified {{modified}} · Deleted {{deleted}}',
|
||||
@@ -50,6 +55,9 @@ export default {
|
||||
'workingDirectory.recent': 'Recent',
|
||||
'workingDirectory.refreshGitStatus': 'Refresh branch & PR status',
|
||||
'workingDirectory.removeRecent': 'Remove from recent',
|
||||
'workingDirectory.renameBranchAction': 'Rename branch',
|
||||
'workingDirectory.renameBranchTitle': 'Rename branch',
|
||||
'workingDirectory.renameFailed': 'Rename failed',
|
||||
'workingDirectory.selectFolder': 'Select folder',
|
||||
'workingDirectory.title': 'Working Directory',
|
||||
'workingDirectory.topicDescription': 'Override Agent default for this conversation only',
|
||||
|
||||
@@ -270,6 +270,18 @@ export interface DeviceGitFileRevertResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/** Result of the `renameGitBranch` device RPC. Mirrors the desktop `GitRenameBranchResult`. */
|
||||
export interface DeviceGitRenameBranchResult {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/** Result of the `deleteGitBranch` device RPC. Mirrors the desktop `GitDeleteBranchResult`. */
|
||||
export interface DeviceGitDeleteBranchResult {
|
||||
error?: string;
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repo-relative paths of dirty working-tree files for a directory on a remote
|
||||
* device, returned by the `getGitWorkingTreeFiles` device RPC. Powers the Files
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Icon, Input } from '@lobehub/ui';
|
||||
import { Icon, Input, Tooltip } from '@lobehub/ui';
|
||||
import {
|
||||
confirmModal,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuPopup,
|
||||
DropdownMenuPortal,
|
||||
@@ -13,10 +14,20 @@ import {
|
||||
GitBranchIcon,
|
||||
GitBranchPlusIcon,
|
||||
LoaderIcon,
|
||||
PencilIcon,
|
||||
RefreshCwIcon,
|
||||
SearchIcon,
|
||||
Trash2Icon,
|
||||
} from 'lucide-react';
|
||||
import { memo, type ReactElement, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
memo,
|
||||
type MouseEvent,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import useSWR from 'swr';
|
||||
|
||||
@@ -25,6 +36,7 @@ import { gitService } from '@/services/git';
|
||||
import { useFetchGitWorkingTreeStatus } from '@/store/device';
|
||||
|
||||
import { openCreateBranchModal } from './CreateBranchModal';
|
||||
import { openRenameBranchModal } from './RenameBranchModal';
|
||||
|
||||
const styles = createStaticStyles(({ css }) => ({
|
||||
branchLabel: css`
|
||||
@@ -67,11 +79,52 @@ const styles = createStaticStyles(({ css }) => ({
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: ${cssVar.colorText};
|
||||
|
||||
/* Swap the checkmark for the row actions while hovering the row. */
|
||||
&:hover .branch-row-actions {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
&:hover .branch-row-check {
|
||||
display: none;
|
||||
}
|
||||
`,
|
||||
itemCheck: css`
|
||||
flex: none;
|
||||
color: ${cssVar.colorPrimary};
|
||||
`,
|
||||
rowAction: css`
|
||||
cursor: pointer;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 4px;
|
||||
|
||||
color: ${cssVar.colorTextTertiary};
|
||||
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
color: ${cssVar.colorText};
|
||||
background: ${cssVar.colorFillSecondary};
|
||||
}
|
||||
`,
|
||||
rowActionDanger: css`
|
||||
&:hover {
|
||||
color: ${cssVar.colorError};
|
||||
background: ${cssVar.colorErrorBg};
|
||||
}
|
||||
`,
|
||||
rowActions: css`
|
||||
display: none;
|
||||
flex: none;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
`,
|
||||
itemIcon: css`
|
||||
flex: none;
|
||||
color: ${cssVar.colorTextSecondary};
|
||||
@@ -180,6 +233,7 @@ const BranchSwitcher = memo<BranchSwitcherProps>(
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation('device');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
const [search, setSearch] = useState('');
|
||||
const [busyBranch, setBusyBranch] = useState<string | null>(null);
|
||||
|
||||
@@ -285,6 +339,56 @@ const BranchSwitcher = memo<BranchSwitcherProps>(
|
||||
openCreateBranchModal({ onSubmit: handleCreateBranch });
|
||||
}, [handleCreateBranch, onOpenChange]);
|
||||
|
||||
// Rename a branch from a modal. Closes the dropdown first (mirrors create),
|
||||
// then reconciles via onAfterCheckout — a renamed current branch updates the
|
||||
// header label, and the list refetches when the dropdown reopens.
|
||||
const handleRename = useCallback(
|
||||
(event: MouseEvent, branch: string) => {
|
||||
event.stopPropagation();
|
||||
onOpenChange(false);
|
||||
openRenameBranchModal({
|
||||
currentName: branch,
|
||||
onSubmit: async (newName) => {
|
||||
if (newName === branch) return undefined;
|
||||
const result = await gitService.renameGitBranch({
|
||||
deviceId,
|
||||
from: branch,
|
||||
path,
|
||||
to: newName,
|
||||
});
|
||||
onAfterCheckout?.();
|
||||
if (result.success) return undefined;
|
||||
return result.error || t('workingDirectory.renameFailed');
|
||||
},
|
||||
});
|
||||
},
|
||||
[deviceId, onAfterCheckout, onOpenChange, path, t],
|
||||
);
|
||||
|
||||
// Delete a branch behind a destructive confirm. git rejects deleting the
|
||||
// checked-out branch, so the action is hidden for the current branch.
|
||||
const handleDelete = useCallback(
|
||||
(event: MouseEvent, branch: string) => {
|
||||
event.stopPropagation();
|
||||
onOpenChange(false);
|
||||
confirmModal({
|
||||
cancelText: tCommon('cancel'),
|
||||
content: t('workingDirectory.deleteBranchConfirm', { name: branch }),
|
||||
okButtonProps: { danger: true },
|
||||
okText: tCommon('delete'),
|
||||
onOk: async () => {
|
||||
const result = await gitService.deleteGitBranch({ branch, deviceId, path });
|
||||
onAfterCheckout?.();
|
||||
if (!result.success) {
|
||||
message.error(result.error || t('workingDirectory.deleteFailed'));
|
||||
}
|
||||
},
|
||||
title: t('workingDirectory.deleteBranchTitle'),
|
||||
});
|
||||
},
|
||||
[deviceId, onAfterCheckout, onOpenChange, path, t, tCommon],
|
||||
);
|
||||
|
||||
return (
|
||||
<DropdownMenuRoot open={open} onOpenChange={onOpenChange}>
|
||||
<DropdownMenuTrigger>{children}</DropdownMenuTrigger>
|
||||
@@ -361,8 +465,34 @@ const BranchSwitcher = memo<BranchSwitcherProps>(
|
||||
)}
|
||||
</div>
|
||||
{isCurrent && (
|
||||
<Icon className={styles.itemCheck} icon={CheckIcon} size={14} />
|
||||
<Icon
|
||||
className={cx('branch-row-check', styles.itemCheck)}
|
||||
icon={CheckIcon}
|
||||
size={14}
|
||||
/>
|
||||
)}
|
||||
<div className={cx('branch-row-actions', styles.rowActions)}>
|
||||
<Tooltip title={t('workingDirectory.renameBranchAction')}>
|
||||
<div
|
||||
className={styles.rowAction}
|
||||
role="button"
|
||||
onClick={(e) => handleRename(e, branch.name)}
|
||||
>
|
||||
<Icon icon={PencilIcon} size={13} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
{!isCurrent && (
|
||||
<Tooltip title={t('workingDirectory.deleteBranchAction')}>
|
||||
<div
|
||||
className={cx(styles.rowAction, styles.rowActionDanger)}
|
||||
role="button"
|
||||
onClick={(e) => handleDelete(e, branch.name)}
|
||||
>
|
||||
<Icon icon={Trash2Icon} size={13} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
'use client';
|
||||
|
||||
import { Button, Flexbox, Input, Text } from '@lobehub/ui';
|
||||
import { createModal, type ModalInstance, useModalContext } from '@lobehub/ui/base-ui';
|
||||
import { type InputRef } from 'antd';
|
||||
import { cssVar } from 'antd-style';
|
||||
import { t } from 'i18next';
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface RenameBranchContentProps {
|
||||
/** The branch's current name — prefilled and selected for quick editing. */
|
||||
currentName: string;
|
||||
/**
|
||||
* Rename the branch. Return an error message to show inline and keep the
|
||||
* modal open; return undefined on success (the modal closes).
|
||||
*/
|
||||
onSubmit: (name: string) => Promise<string | undefined>;
|
||||
}
|
||||
|
||||
const RenameBranchContent = memo<RenameBranchContentProps>(({ currentName, onSubmit }) => {
|
||||
const { t: tDevice } = useTranslation('device');
|
||||
const { t: tCommon } = useTranslation('common');
|
||||
const { close } = useModalContext();
|
||||
const [value, setValue] = useState(currentName);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Focus + select the whole name so the user can immediately retype.
|
||||
queueMicrotask(() => inputRef.current?.select());
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (loading) return;
|
||||
const name = value.trim();
|
||||
if (!name || name === currentName) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const message = await onSubmit(name);
|
||||
if (message) {
|
||||
setError(message);
|
||||
return;
|
||||
}
|
||||
close();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [close, currentName, loading, onSubmit, value]);
|
||||
|
||||
const trimmed = value.trim();
|
||||
return (
|
||||
<Flexbox gap={16}>
|
||||
<Flexbox gap={6}>
|
||||
<Input
|
||||
placeholder={tDevice('workingDirectory.newBranchPlaceholder')}
|
||||
ref={inputRef}
|
||||
value={value}
|
||||
onPressEnter={handleSubmit}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setError(undefined);
|
||||
}}
|
||||
/>
|
||||
{error ? <Text style={{ color: cssVar.colorError, fontSize: 12 }}>{error}</Text> : null}
|
||||
</Flexbox>
|
||||
<Flexbox horizontal gap={8} justify={'flex-end'}>
|
||||
<Button disabled={loading} onClick={close}>
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!trimmed || trimmed === currentName}
|
||||
loading={loading}
|
||||
type={'primary'}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{tDevice('workingDirectory.renameBranchAction')}
|
||||
</Button>
|
||||
</Flexbox>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
RenameBranchContent.displayName = 'RenameBranchContent';
|
||||
|
||||
/**
|
||||
* Branch-name entry for renaming a local branch. Prefills the current name and
|
||||
* submits the new one; the dropdown closes before this opens (mirrors the
|
||||
* create-branch flow).
|
||||
*/
|
||||
export const openRenameBranchModal = (options: {
|
||||
currentName: string;
|
||||
onSubmit: (name: string) => Promise<string | undefined>;
|
||||
}): ModalInstance =>
|
||||
createModal({
|
||||
content: <RenameBranchContent currentName={options.currentName} onSubmit={options.onSubmit} />,
|
||||
footer: null,
|
||||
maskClosable: true,
|
||||
styles: { header: { borderBottom: 'none' } },
|
||||
title: t('workingDirectory.renameBranchTitle', { ns: 'device' }),
|
||||
width: 'min(90vw, 480px)',
|
||||
});
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
type GitBranchInfo,
|
||||
type GitBranchListItem,
|
||||
type GitCheckoutResult,
|
||||
type GitDeleteBranchResult,
|
||||
type GitFileRevertResult,
|
||||
type GitLinkedPullRequestResult,
|
||||
type GitPullResult,
|
||||
type GitPushResult,
|
||||
type GitRemoteBranchListItem,
|
||||
type GitRenameBranchResult,
|
||||
type GitWorkingTreeFiles,
|
||||
type GitWorkingTreePatches,
|
||||
type GitWorkingTreeStatus,
|
||||
@@ -89,6 +91,18 @@ class ElectronGitService {
|
||||
async revertGitFile(params: { filePath: string; path: string }): Promise<GitFileRevertResult> {
|
||||
return this.ipc.git.revertGitFile(params);
|
||||
}
|
||||
|
||||
async renameGitBranch(params: {
|
||||
from: string;
|
||||
path: string;
|
||||
to: string;
|
||||
}): Promise<GitRenameBranchResult> {
|
||||
return this.ipc.git.renameGitBranch(params);
|
||||
}
|
||||
|
||||
async deleteGitBranch(params: { branch: string; path: string }): Promise<GitDeleteBranchResult> {
|
||||
return this.ipc.git.deleteGitBranch(params);
|
||||
}
|
||||
}
|
||||
|
||||
export const electronGitService = new ElectronGitService();
|
||||
|
||||
@@ -9,7 +9,9 @@ import type {
|
||||
DeviceGitAheadBehind,
|
||||
DeviceGitBranchListItem,
|
||||
DeviceGitCheckoutResult,
|
||||
DeviceGitDeleteBranchResult,
|
||||
DeviceGitLinkedPullRequest,
|
||||
DeviceGitRenameBranchResult,
|
||||
DeviceGitSyncResult,
|
||||
DeviceGitWorkingTreeStatus,
|
||||
} from '@lobechat/types';
|
||||
@@ -64,6 +66,38 @@ class GitService {
|
||||
: electronGitService.checkoutGitBranch({ branch, create, path });
|
||||
}
|
||||
|
||||
/** Rename a branch in a working directory. */
|
||||
renameGitBranch({
|
||||
deviceId,
|
||||
from,
|
||||
path,
|
||||
to,
|
||||
}: {
|
||||
deviceId?: string;
|
||||
from: string;
|
||||
path: string;
|
||||
to: string;
|
||||
}): Promise<DeviceGitRenameBranchResult> {
|
||||
return deviceId
|
||||
? lambdaClient.device.renameGitBranch.mutate({ deviceId, from, path, to })
|
||||
: electronGitService.renameGitBranch({ from, path, to });
|
||||
}
|
||||
|
||||
/** Delete a branch in a working directory. */
|
||||
deleteGitBranch({
|
||||
branch,
|
||||
deviceId,
|
||||
path,
|
||||
}: {
|
||||
branch: string;
|
||||
deviceId?: string;
|
||||
path: string;
|
||||
}): Promise<DeviceGitDeleteBranchResult> {
|
||||
return deviceId
|
||||
? lambdaClient.device.deleteGitBranch.mutate({ branch, deviceId, path })
|
||||
: electronGitService.deleteGitBranch({ branch, path });
|
||||
}
|
||||
|
||||
/** Pull (`--ff-only`) the current branch of a working directory. */
|
||||
pullGitBranch({
|
||||
deviceId,
|
||||
|
||||
Reference in New Issue
Block a user