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} - + {#if item.actionIcon && item.actionOnclick} + {/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}. +

+
+ + + +
+
+
+{/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)