mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +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.
|
||||
|
||||
Reference in New Issue
Block a user