mirror of
https://github.com/open-webui/computer.git
synced 2026-11-15 10:25:24 +03:00
refac
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user