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:
Arvin Xu
2026-06-13 20:07:45 +08:00
committed by GitHub
parent 09fd6f3411
commit 381e87474c
13 changed files with 512 additions and 3 deletions
@@ -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.
*
+44
View File
@@ -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.
+7
View File
@@ -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",
+7
View File
@@ -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 */
+8
View File
@@ -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',
+12
View File
@@ -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)',
});
+14
View File
@@ -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();
+34
View File
@@ -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,