This commit is contained in:
Timothy Jaeryang Baek
2026-06-17 11:25:37 +02:00
Unverified
parent 08d2afffc5
commit 6fdcbcfaa5
5 changed files with 246 additions and 36 deletions
+14
View File
@@ -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 }));
@@ -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}
<div class="h-px bg-gray-100/50 dark:bg-white/3 mx-1 my-0.5"></div>
{:else}
<button
class="flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs transition-colors duration-75
<div
class="group flex items-center gap-1 w-full h-6 rounded-xl text-xs transition-colors duration-75
{item.active
? 'text-gray-900 dark:text-white bg-gray-50 dark:bg-white/5'
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-white/5 hover:text-gray-900 dark:hover:text-white'}"
onclick={() => {
item.onclick();
onclose();
}}
>
{#if item.image}
<img src={item.image} alt="" class="w-4 h-4 rounded-full object-cover shrink-0" />
{:else if item.icon}
<Icon name={item.icon} size={14} />
{/if}
<span class="flex-1 text-left truncate">{item.label}</span>
{#if item.shortcut}
<KeyPill text={item.shortcut} class="ml-auto shrink-0" />
{/if}
{#if item.check && item.active}
<svg
class="w-3 h-3 shrink-0 text-gray-400 dark:text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
<button
class="flex items-center gap-2 min-w-0 flex-1 h-full px-2 text-inherit"
onclick={() => {
item.onclick();
onclose();
}}
>
{#if item.image}
<img src={item.image} alt="" class="w-4 h-4 rounded-full object-cover shrink-0" />
{:else if item.icon}
<Icon name={item.icon} size={14} />
{/if}
<span class="flex-1 text-left truncate">{item.label}</span>
{#if item.shortcut}
<KeyPill text={item.shortcut} class="ml-auto shrink-0" />
{/if}
{#if item.check && item.active}
<svg
class="w-3 h-3 shrink-0 text-gray-400 dark:text-gray-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.5"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{/if}
</button>
{#if item.actionIcon && item.actionOnclick}
<button
class="flex items-center justify-center w-5 h-5 mr-0.5 rounded-lg shrink-0 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-200 dark:hover:bg-white/10 transition-all duration-75"
aria-label={item.actionLabel}
onclick={(e) => {
e.stopPropagation();
item.actionOnclick?.(e.currentTarget as HTMLElement);
}}
>
<polyline points="20 6 9 17 4 12" />
</svg>
<Icon name={item.actionIcon} size={12} />
</button>
{/if}
</button>
</div>
{/if}
{/each}
{/if}
+163 -9
View File
@@ -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<string | null>(null);
let pendingCreateBranch = $state<string | null>(null);
let branchActionMenu = $state<{ branch: BranchItem; anchor: HTMLElement } | null>(null);
let selectedFile = $state<string | null>(null);
let selectedCommit = $state<Commit | null>(null);
let fileDiff = $state<DiffFile[]>([]);
@@ -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 @@
</Modal>
{/if}
{#if pendingCreateBranch}
<Modal onclose={() => (pendingCreateBranch = null)} class="w-full max-w-sm mx-4">
<div class="p-4">
<h2 class="text-sm font-medium text-gray-900 dark:text-white">Create branch?</h2>
<p class="mt-1.5 text-xs leading-relaxed text-gray-500 dark:text-gray-400">
You have {totalChanges} changed {totalChanges === 1 ? 'file' : 'files'} on
<span class="font-mono text-gray-700 dark:text-gray-300">{gitStatus?.branch}</span>. Choose
whether to leave those changes here or bring them to
<span class="font-mono text-gray-700 dark:text-gray-300">{pendingCreateBranch}</span>.
</p>
<div class="mt-4 flex items-center justify-end gap-2">
<button
class="px-2.5 py-1 text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
onclick={() => (pendingCreateBranch = null)}
>
Cancel
</button>
<button
class="px-2.5 py-1 text-xs text-gray-500 hover:text-gray-900 dark:text-gray-400 dark:hover:text-white transition-colors"
onclick={() => performBranchCreate(pendingCreateBranch!, 'leave')}
disabled={loading}
>
Leave changes
</button>
<button
class="px-3.5 py-1.5 text-xs bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition-colors rounded-full disabled:opacity-50"
onclick={() => performBranchCreate(pendingCreateBranch!, 'bring')}
disabled={loading}
>
Bring changes
</button>
</div>
</div>
</Modal>
{/if}
{#if branchActionMenu}
<DropdownMenu
anchor={branchActionMenu.anchor}
items={[
...(branchActionMenu.branch.is_local
? [
{
label: 'Rename',
icon: 'pencil',
onclick: () => 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}
<DropdownMenu
anchor={contextMenu.anchor}
+17
View File
@@ -25,6 +25,7 @@ from cptr.utils.git import (
log,
pull,
push,
rename_branch,
show,
stage,
stash_list,
@@ -145,6 +146,12 @@ class DeleteBranchRequest(BaseModel):
name: str
class RenameBranchRequest(BaseModel):
root: str
old_name: str
new_name: str
class RootRequest(BaseModel):
root: str
@@ -235,6 +242,16 @@ async def git_delete_branch(body: DeleteBranchRequest):
_handle_git_error(e)
@router.patch("/branch")
async def git_rename_branch(body: RenameBranchRequest):
"""Rename a local branch."""
try:
await rename_branch(body.root, body.old_name, body.new_name)
return {"ok": True}
except GitError as e:
_handle_git_error(e)
@router.post("/pull")
async def git_pull(body: RootRequest):
"""Pull from remote."""
+5
View File
@@ -485,6 +485,11 @@ async def delete_branch(root: str, name: str) -> 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)