diff --git a/cptr/frontend/src/lib/apis/git.ts b/cptr/frontend/src/lib/apis/git.ts
index 19794da..6bf0082 100644
--- a/cptr/frontend/src/lib/apis/git.ts
+++ b/cptr/frontend/src/lib/apis/git.ts
@@ -84,6 +84,20 @@ export const gitUnstash = (root: string, index = 0) =>
export const createGitBranch = (root: string, name: string) =>
fetchJSON('/api/git/branch', jsonBody({ root, name }));
+export const renameGitBranch = (root: string, old_name: string, new_name: string) =>
+ fetchJSON('/api/git/branch', {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ root, old_name, new_name })
+ });
+
+export const deleteGitBranch = (root: string, name: string) =>
+ fetchJSON('/api/git/branch', {
+ method: 'DELETE',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ root, name })
+ });
+
export const checkoutBranch = (root: string, branch: string) =>
fetchJSON('/api/git/checkout', jsonBody({ root, branch }));
diff --git a/cptr/frontend/src/lib/components/DropdownMenu.svelte b/cptr/frontend/src/lib/components/DropdownMenu.svelte
index e3392f5..63c1b3c 100644
--- a/cptr/frontend/src/lib/components/DropdownMenu.svelte
+++ b/cptr/frontend/src/lib/components/DropdownMenu.svelte
@@ -15,6 +15,10 @@
check?: boolean;
/** Optional keyboard shortcut hint displayed as a single pill on the right. */
shortcut?: string;
+ /** Optional trailing action button, e.g. row options. */
+ actionIcon?: string;
+ actionLabel?: string;
+ actionOnclick?: (anchor: HTMLElement) => void;
}
interface Props {
@@ -358,39 +362,55 @@
{#if item.divider}
{:else}
- {
- item.onclick();
- onclose();
- }}
>
- {#if item.image}
-
- {:else if item.icon}
-
- {/if}
- {item.label}
- {#if item.shortcut}
-
- {/if}
- {#if item.check && item.active}
- {
+ item.onclick();
+ onclose();
+ }}
+ >
+ {#if item.image}
+
+ {:else if item.icon}
+
+ {/if}
+ {item.label}
+ {#if item.shortcut}
+
+ {/if}
+ {#if item.check && item.active}
+
+
+
+ {/if}
+
+ {#if item.actionIcon && item.actionOnclick}
+ {
+ e.stopPropagation();
+ item.actionOnclick?.(e.currentTarget as HTMLElement);
+ }}
>
-
-
+
+
{/if}
-
+
{/if}
{/each}
{/if}
diff --git a/cptr/frontend/src/lib/components/GitBar.svelte b/cptr/frontend/src/lib/components/GitBar.svelte
index 81c9912..77eee3a 100644
--- a/cptr/frontend/src/lib/components/GitBar.svelte
+++ b/cptr/frontend/src/lib/components/GitBar.svelte
@@ -18,7 +18,9 @@
gitStash,
gitUnstash,
checkoutBranch,
- createGitBranch
+ createGitBranch,
+ deleteGitBranch,
+ renameGitBranch
} from '$lib/apis/git';
import { gitStatusStore } from '$lib/stores/gitStatus.svelte';
import Icon from './Icon.svelte';
@@ -39,6 +41,13 @@
type DiffHunk = { header: string; lines: { type: string; content: string }[] };
type DiffFile = { path: string; hunks: DiffHunk[] };
type Commit = { hash: string; short_hash: string; author: string; date: string; message: string };
+ type BranchItem = {
+ name: string;
+ is_current?: boolean;
+ is_local?: boolean;
+ is_remote?: boolean;
+ upstream?: string | null;
+ };
let expanded = $state(false);
let view = $state<'changes' | 'history'>('changes');
@@ -52,12 +61,14 @@
current: string;
local: string[];
remote: string[];
- all?: { name: string; is_current?: boolean; is_local?: boolean; upstream?: string | null }[];
+ all?: BranchItem[];
} | null>(null);
let branchSearch = $state('');
let newBranchName = $state('');
let creatingBranch = $state(false);
let pendingCheckoutBranch = $state(null);
+ let pendingCreateBranch = $state(null);
+ let branchActionMenu = $state<{ branch: BranchItem; anchor: HTMLElement } | null>(null);
let selectedFile = $state(null);
let selectedCommit = $state(null);
let fileDiff = $state([]);
@@ -105,7 +116,12 @@
icon: 'git-branch',
active: Boolean(branch.is_current),
check: true,
- onclick: () => switchBranch(branch.name)
+ onclick: () => switchBranch(branch.name),
+ actionIcon: 'three-dots',
+ actionLabel: 'Branch actions',
+ actionOnclick: (anchor: HTMLElement) => {
+ branchActionMenu = { branch, anchor };
+ }
}))
);
@@ -390,13 +406,44 @@
async function createBranch() {
if (!newBranchName.trim()) return;
+ const branch = newBranchName.trim();
+ if (totalChanges > 0) {
+ pendingCreateBranch = branch;
+ creatingBranch = false;
+ showBranches = false;
+ return;
+ }
+ await performBranchCreate(branch, 'bring');
+ }
+
+ async function performBranchCreate(branch: string, changeMode: 'bring' | 'leave') {
loading = true;
- await createGitBranch(workspacePath, newBranchName.trim());
- newBranchName = '';
- creatingBranch = false;
- showBranches = false;
- loading = false;
- await refresh();
+ try {
+ if (changeMode === 'leave') {
+ const currentBranch = gitStatus?.branch || 'current branch';
+ const stashResult = (await gitStash(
+ workspacePath,
+ `cptr-branch-switch:${currentBranch}`
+ )) as {
+ ok?: boolean;
+ message?: string;
+ };
+ if (!stashResult.ok) {
+ flash(stashResult.message || 'No changes stashed');
+ return;
+ }
+ }
+ await createGitBranch(workspacePath, branch);
+ newBranchName = '';
+ creatingBranch = false;
+ showBranches = false;
+ pendingCreateBranch = null;
+ } catch (e) {
+ flash(e instanceof Error ? e.message : 'Failed to create branch');
+ } finally {
+ loading = false;
+ await refresh();
+ }
}
function flash(m: string) {
@@ -428,6 +475,45 @@
commitMenu = null;
}
+ function closeBranchActionMenu() {
+ branchActionMenu = null;
+ }
+
+ async function renameBranch(branch: BranchItem) {
+ if (!branch.is_local) return;
+ const nextName = window.prompt('Rename branch', branch.name)?.trim();
+ if (!nextName || nextName === branch.name) return;
+ try {
+ await renameGitBranch(workspacePath, branch.name, nextName);
+ flash('Branch renamed');
+ closeBranchActionMenu();
+ await refresh({ force: true });
+ await loadBranches();
+ } catch (e) {
+ flash(e instanceof Error ? e.message : 'Failed to rename branch');
+ }
+ }
+
+ function copyBranchName(branch: BranchItem) {
+ navigator.clipboard.writeText(branch.name);
+ flash('Branch name copied');
+ closeBranchActionMenu();
+ }
+
+ async function deleteBranch(branch: BranchItem) {
+ if (!branch.is_local || branch.is_current) return;
+ if (!confirm(`Delete branch "${branch.name}"?`)) return;
+ try {
+ await deleteGitBranch(workspacePath, branch.name);
+ flash('Branch deleted');
+ closeBranchActionMenu();
+ await refresh({ force: true });
+ await loadBranches();
+ } catch (e) {
+ flash(e instanceof Error ? e.message : 'Failed to delete branch');
+ }
+ }
+
async function discardFile(path: string) {
await discardChanges(workspacePath, [path]);
if (selectedFile === path) {
@@ -1123,6 +1209,74 @@
{/if}
+{#if pendingCreateBranch}
+ (pendingCreateBranch = null)} class="w-full max-w-sm mx-4">
+
+
Create branch?
+
+ You have {totalChanges} changed {totalChanges === 1 ? 'file' : 'files'} on
+ {gitStatus?.branch} . Choose
+ whether to leave those changes here or bring them to
+ {pendingCreateBranch} .
+
+
+ (pendingCreateBranch = null)}
+ >
+ Cancel
+
+ performBranchCreate(pendingCreateBranch!, 'leave')}
+ disabled={loading}
+ >
+ Leave changes
+
+ performBranchCreate(pendingCreateBranch!, 'bring')}
+ disabled={loading}
+ >
+ Bring changes
+
+
+
+
+{/if}
+
+{#if branchActionMenu}
+ renameBranch(branchActionMenu!.branch)
+ }
+ ]
+ : []),
+ {
+ label: 'Copy branch name',
+ icon: 'copy',
+ onclick: () => copyBranchName(branchActionMenu!.branch)
+ },
+ ...(branchActionMenu.branch.is_local && !branchActionMenu.branch.is_current
+ ? [
+ {
+ label: 'Delete',
+ icon: 'xmark',
+ onclick: () => deleteBranch(branchActionMenu!.branch)
+ }
+ ]
+ : [])
+ ]}
+ onclose={closeBranchActionMenu}
+ />
+{/if}
+
{#if contextMenu}
None:
await _run("branch", "-d", name, cwd=root)
+async def rename_branch(root: str, old_name: str, new_name: str) -> None:
+ """Rename a local branch."""
+ await _run("branch", "-m", old_name, new_name, cwd=root)
+
+
async def pull(root: str) -> dict[str, Any]:
"""Pull from remote."""
code, out, err = await _run("pull", cwd=root, check=False)