Auth for submodule ops, revision checkout, central confirm dialog, unified sidebar sizing #46
+40
@@ -9253,3 +9253,43 @@ section > header.page-header.page-header {
|
||||
.sidebar-tags-list { min-height: 0; overflow: auto; padding: 4px 0; }
|
||||
.left-sidebar .tags-panel .tag-create-form { grid-template-columns: auto minmax(0, 1fr) auto auto; margin: 4px 8px; }
|
||||
.left-sidebar .tags-panel .tag-create-form input[aria-label="Tag message"] { grid-column: 2 / -1; grid-row: 2; }
|
||||
|
||||
|
||||
/* --- Sidebar scrollbars ---------------------------------------------------
|
||||
Slim, rounded thumb that brightens while hovering a scroll area.
|
||||
scrollbar-width/color are reset so WebKit uses the pseudo-element styling.
|
||||
The panel lists are named explicitly so they win over any other rule. */
|
||||
.left-sidebar,
|
||||
.left-sidebar *,
|
||||
.left-sidebar .sidebar-tags-list,
|
||||
.left-sidebar .sidebar-worktree-list,
|
||||
.left-sidebar .stash-list,
|
||||
.left-sidebar .explorer-list {
|
||||
scrollbar-width: auto !important;
|
||||
scrollbar-color: auto !important;
|
||||
}
|
||||
.left-sidebar ::-webkit-scrollbar,
|
||||
.left-sidebar .sidebar-tags-list::-webkit-scrollbar,
|
||||
.left-sidebar .sidebar-worktree-list::-webkit-scrollbar,
|
||||
.left-sidebar .stash-list::-webkit-scrollbar,
|
||||
.left-sidebar .explorer-list::-webkit-scrollbar { width: 8px !important; height: 8px !important; background: transparent; }
|
||||
.left-sidebar ::-webkit-scrollbar-track,
|
||||
.left-sidebar .sidebar-tags-list::-webkit-scrollbar-track,
|
||||
.left-sidebar .sidebar-worktree-list::-webkit-scrollbar-track,
|
||||
.left-sidebar .stash-list::-webkit-scrollbar-track,
|
||||
.left-sidebar .explorer-list::-webkit-scrollbar-track { margin: 6px 0; background: transparent; }
|
||||
.left-sidebar ::-webkit-scrollbar-corner { background: transparent; }
|
||||
.left-sidebar ::-webkit-scrollbar-thumb,
|
||||
.left-sidebar .sidebar-tags-list::-webkit-scrollbar-thumb,
|
||||
.left-sidebar .sidebar-worktree-list::-webkit-scrollbar-thumb,
|
||||
.left-sidebar .stash-list::-webkit-scrollbar-thumb,
|
||||
.left-sidebar .explorer-list::-webkit-scrollbar-thumb {
|
||||
min-height: 28px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background-clip: padding-box;
|
||||
background-color: color-mix(in srgb, var(--app-scrollbar-thumb) 55%, transparent);
|
||||
}
|
||||
.left-sidebar :hover::-webkit-scrollbar-thumb { background-color: var(--app-scrollbar-thumb); }
|
||||
.left-sidebar ::-webkit-scrollbar-thumb:hover,
|
||||
.left-sidebar ::-webkit-scrollbar-thumb:active { background-color: var(--app-scrollbar-thumb-hover); }
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Trash2, X } from "@lucide/svelte";
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CircleDot,
|
||||
Cloud,
|
||||
CloudOff,
|
||||
Ellipsis,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
GitCompare,
|
||||
GitMerge,
|
||||
Globe,
|
||||
HardDrive,
|
||||
Laptop,
|
||||
Link2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Trash2,
|
||||
X,
|
||||
} from "@lucide/svelte";
|
||||
import { tick } from "svelte";
|
||||
import type { GitBranch as GitBranchInfo } from "../types";
|
||||
|
||||
type Scope = "local" | "remote";
|
||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||
|
||||
interface BranchFolderNode {
|
||||
@@ -33,6 +56,7 @@
|
||||
branchCount: number;
|
||||
current: boolean;
|
||||
branches: GitBranchInfo[];
|
||||
scope: Scope;
|
||||
}
|
||||
|
||||
interface BranchLeafRow {
|
||||
@@ -41,7 +65,7 @@
|
||||
branch: GitBranchInfo;
|
||||
displayName: string;
|
||||
depth: number;
|
||||
scopeLabel: string;
|
||||
scope: Scope;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -84,35 +108,98 @@
|
||||
onToggleCollapsed = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let localOpen = $state(true);
|
||||
let remoteOpen = $state(false);
|
||||
const VIEW_STATE_KEY = "gitlite.branchPanelView.v2";
|
||||
const INDENT = 14;
|
||||
|
||||
interface StoredViewState {
|
||||
localOpen?: boolean;
|
||||
remoteOpen?: boolean;
|
||||
collapsedFolders?: string[];
|
||||
}
|
||||
|
||||
function loadViewState(): StoredViewState {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem(VIEW_STATE_KEY) ?? "{}");
|
||||
return parsed && typeof parsed === "object" ? (parsed as StoredViewState) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const storedView = loadViewState();
|
||||
|
||||
let localOpen = $state(storedView.localOpen ?? true);
|
||||
let remoteOpen = $state(storedView.remoteOpen ?? false);
|
||||
let collapsedBranchFolders = $state<Set<string>>(new Set(storedView.collapsedFolders ?? []));
|
||||
let createOpen = $state(false);
|
||||
let newBranchName = $state("");
|
||||
let createInput = $state<HTMLInputElement | null>(null);
|
||||
let filterText = $state("");
|
||||
let filterInput = $state<HTMLInputElement | null>(null);
|
||||
let scrollElement = $state<HTMLElement | null>(null);
|
||||
let flashId = $state<string | null>(null);
|
||||
let contextBranch = $state<GitBranchInfo | null>(null);
|
||||
let contextFolder = $state<BranchFolderRow | null>(null);
|
||||
let branchContextMenuElement = $state<HTMLElement | null>(null);
|
||||
let contextMenuX = $state(0);
|
||||
let contextMenuY = $state(0);
|
||||
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
||||
|
||||
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
|
||||
let remoteBranchRows = $derived(buildBranchRows("remote", remoteBranches, "remote"));
|
||||
|
||||
function createFolder(id: string, name: string): BranchFolderNode {
|
||||
return {
|
||||
kind: "folder",
|
||||
id,
|
||||
name,
|
||||
children: [],
|
||||
branchCount: 0,
|
||||
current: false,
|
||||
branches: [],
|
||||
folders: new Map(),
|
||||
$effect(() => {
|
||||
const snapshot: StoredViewState = {
|
||||
localOpen,
|
||||
remoteOpen,
|
||||
collapsedFolders: [...collapsedBranchFolders],
|
||||
};
|
||||
try {
|
||||
localStorage.setItem(VIEW_STATE_KEY, JSON.stringify(snapshot));
|
||||
} catch {
|
||||
/* Optional preference. */
|
||||
}
|
||||
});
|
||||
|
||||
let query = $derived(filterText.trim().toLowerCase());
|
||||
let filtering = $derived(query.length > 0);
|
||||
let currentBranch = $derived(localBranches.find((branch) => branch.current) ?? null);
|
||||
let remoteBranchNames = $derived(new Set(remoteBranches.map((branch) => branch.name)));
|
||||
let trackingLocalByRemote = $derived(
|
||||
new Map(localBranches.flatMap((branch) => (branch.upstream ? [[branch.upstream, branch.name] as const] : []))),
|
||||
);
|
||||
|
||||
let visibleLocal = $derived(filtering ? localBranches.filter(matchesFilter) : localBranches);
|
||||
let visibleRemote = $derived(filtering ? remoteBranches.filter(matchesFilter) : remoteBranches);
|
||||
let localBranchRows = $derived(buildBranchRows("local", visibleLocal));
|
||||
let remoteBranchRows = $derived(buildBranchRows("remote", visibleRemote));
|
||||
let showLocal = $derived(filtering ? visibleLocal.length > 0 : localOpen);
|
||||
let showRemote = $derived(filtering ? visibleRemote.length > 0 : remoteOpen);
|
||||
|
||||
function matchesFilter(branch: GitBranchInfo) {
|
||||
return branch.name.toLowerCase().includes(query);
|
||||
}
|
||||
|
||||
function buildBranchRows(scope: string, branchList: GitBranchInfo[], scopeLabel: string): BranchRow[] {
|
||||
function highlightParts(text: string) {
|
||||
if (!filtering) return [{ text, match: false }];
|
||||
const index = text.toLowerCase().indexOf(query);
|
||||
if (index < 0) return [{ text, match: false }];
|
||||
return [
|
||||
{ text: text.slice(0, index), match: false },
|
||||
{ text: text.slice(index, index + query.length), match: true },
|
||||
{ text: text.slice(index + query.length), match: false },
|
||||
].filter((part) => part.text.length > 0);
|
||||
}
|
||||
|
||||
function createFolder(id: string, name: string): BranchFolderNode {
|
||||
return { kind: "folder", id, name, children: [], branchCount: 0, current: false, branches: [], folders: new Map() };
|
||||
}
|
||||
|
||||
function branchId(scope: Scope, name: string) {
|
||||
return `${scope}:branch:${name}`;
|
||||
}
|
||||
|
||||
function folderId(scope: Scope, path: string) {
|
||||
return `${scope}:folder:${path}`;
|
||||
}
|
||||
|
||||
function buildBranchRows(scope: Scope, branchList: GitBranchInfo[]): BranchRow[] {
|
||||
const root = createFolder(`${scope}:root`, "");
|
||||
|
||||
for (const branch of branchList) {
|
||||
@@ -123,11 +210,10 @@
|
||||
|
||||
for (let index = 0; index < folderParts.length; index += 1) {
|
||||
const folderName = folderParts[index];
|
||||
const folderPath = folderParts.slice(0, index + 1).join("/");
|
||||
let folder = parent.folders.get(folderName);
|
||||
|
||||
if (!folder) {
|
||||
folder = createFolder(`${scope}:folder:${folderPath}`, folderName);
|
||||
folder = createFolder(folderId(scope, folderParts.slice(0, index + 1).join("/")), folderName);
|
||||
parent.folders.set(folderName, folder);
|
||||
parent.children.push(folder);
|
||||
}
|
||||
@@ -138,18 +224,13 @@
|
||||
parent = folder;
|
||||
}
|
||||
|
||||
parent.children.push({
|
||||
kind: "branch",
|
||||
id: `${scope}:branch:${branch.name}`,
|
||||
branch,
|
||||
displayName,
|
||||
});
|
||||
parent.children.push({ kind: "branch", id: branchId(scope, branch.name), branch, displayName });
|
||||
}
|
||||
|
||||
sortBranchNodes(root.children);
|
||||
|
||||
const rows: BranchRow[] = [];
|
||||
flattenBranchNodes(root.children, rows, 0, scopeLabel);
|
||||
flattenBranchNodes(root.children, rows, 0, scope);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -166,7 +247,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function flattenBranchNodes(nodes: BranchTreeNode[], rows: BranchRow[], depth: number, scopeLabel: string) {
|
||||
function flattenBranchNodes(nodes: BranchTreeNode[], rows: BranchRow[], depth: number, scope: Scope) {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === "folder") {
|
||||
rows.push({
|
||||
@@ -177,35 +258,90 @@
|
||||
branchCount: node.branchCount,
|
||||
current: node.current,
|
||||
branches: node.branches,
|
||||
scope,
|
||||
});
|
||||
|
||||
if (isBranchFolderOpen(node.id)) {
|
||||
flattenBranchNodes(node.children, rows, depth + 1, scopeLabel);
|
||||
flattenBranchNodes(node.children, rows, depth + 1, scope);
|
||||
}
|
||||
} else {
|
||||
rows.push({
|
||||
kind: "branch",
|
||||
id: node.id,
|
||||
branch: node.branch,
|
||||
displayName: node.displayName,
|
||||
depth,
|
||||
scopeLabel,
|
||||
});
|
||||
rows.push({ kind: "branch", id: node.id, branch: node.branch, displayName: node.displayName, depth, scope });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isBranchFolderOpen(id: string) {
|
||||
return !collapsedBranchFolders.has(id);
|
||||
return filtering || !collapsedBranchFolders.has(id);
|
||||
}
|
||||
|
||||
function toggleBranchFolder(id: string) {
|
||||
if (filtering) return;
|
||||
const next = new Set(collapsedBranchFolders);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
collapsedBranchFolders = next;
|
||||
}
|
||||
|
||||
function isRemoteRoot(row: BranchFolderRow) {
|
||||
return row.scope === "remote" && row.depth === 0;
|
||||
}
|
||||
|
||||
type Tracking =
|
||||
| { kind: "tracked"; upstream: string }
|
||||
| { kind: "gone"; upstream: string }
|
||||
| { kind: "local" }
|
||||
| { kind: "remote-tracked"; local: string }
|
||||
| { kind: "remote" };
|
||||
|
||||
function trackingFor(branch: GitBranchInfo): Tracking {
|
||||
if (branch.remote) {
|
||||
const local = trackingLocalByRemote.get(branch.name);
|
||||
return local ? { kind: "remote-tracked", local } : { kind: "remote" };
|
||||
}
|
||||
if (!branch.upstream) return { kind: "local" };
|
||||
return remoteBranchNames.has(branch.upstream)
|
||||
? { kind: "tracked", upstream: branch.upstream }
|
||||
: { kind: "gone", upstream: branch.upstream };
|
||||
}
|
||||
|
||||
function branchTitle(branch: GitBranchInfo) {
|
||||
const tracking = trackingFor(branch);
|
||||
const lines = [branch.name];
|
||||
if (branch.current) lines.push("Current branch (HEAD)");
|
||||
if (tracking.kind === "tracked") lines.push(`Tracks ${tracking.upstream}`);
|
||||
if (tracking.kind === "gone") lines.push(`Upstream ${tracking.upstream} is gone`);
|
||||
if (tracking.kind === "local") lines.push("Local only – not published");
|
||||
if (tracking.kind === "remote-tracked") lines.push(`Checked out locally as ${tracking.local}`);
|
||||
if (!branch.current) lines.push("Double-click to checkout");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function revealCurrentBranch() {
|
||||
const branch = currentBranch;
|
||||
if (!branch) return;
|
||||
|
||||
filterText = "";
|
||||
localOpen = true;
|
||||
|
||||
const parts = branch.name.split("/").filter(Boolean).slice(0, -1);
|
||||
if (parts.length > 0) {
|
||||
const next = new Set(collapsedBranchFolders);
|
||||
for (let index = 0; index < parts.length; index += 1) {
|
||||
next.delete(folderId("local", parts.slice(0, index + 1).join("/")));
|
||||
}
|
||||
collapsedBranchFolders = next;
|
||||
}
|
||||
|
||||
await tick();
|
||||
const id = branchId("local", branch.name);
|
||||
const element = scrollElement?.querySelector<HTMLElement>(`[data-row-id="${CSS.escape(id)}"]`);
|
||||
element?.scrollIntoView({ block: "center", behavior: "smooth" });
|
||||
flashId = id;
|
||||
setTimeout(() => {
|
||||
if (flashId === id) flashId = null;
|
||||
}, 900);
|
||||
}
|
||||
|
||||
function openCreateForm() {
|
||||
if (!hasRepository || isBusy) return;
|
||||
if (collapsed) onToggleCollapsed();
|
||||
@@ -228,6 +364,13 @@
|
||||
localOpen = true;
|
||||
}
|
||||
|
||||
function handleFilterKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape" && filterText) {
|
||||
event.stopPropagation();
|
||||
filterText = "";
|
||||
}
|
||||
}
|
||||
|
||||
function checkoutOnDoubleClick(event: MouseEvent, branch: GitBranchInfo) {
|
||||
if (branch.current || isBusy) return;
|
||||
const target = event.target instanceof HTMLElement ? event.target : null;
|
||||
@@ -246,27 +389,43 @@
|
||||
};
|
||||
}
|
||||
|
||||
async function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
async function showBranchMenuAt(branch: GitBranchInfo, x: number, y: number) {
|
||||
if (isBusy) return;
|
||||
|
||||
contextFolder = null;
|
||||
contextBranch = branch;
|
||||
contextMenuX = event.clientX + 2;
|
||||
contextMenuY = event.clientY + 2;
|
||||
contextMenuX = x + 2;
|
||||
contextMenuY = y + 2;
|
||||
await tick();
|
||||
if (contextBranch !== branch) return;
|
||||
|
||||
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY);
|
||||
const position = fitContextMenuToViewport(branchContextMenuElement, x, y);
|
||||
contextMenuX = position.x;
|
||||
contextMenuY = position.y;
|
||||
}
|
||||
|
||||
function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
void showBranchMenuAt(branch, event.clientX, event.clientY);
|
||||
}
|
||||
|
||||
function openBranchMenuFromButton(event: MouseEvent, branch: GitBranchInfo) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (contextBranch === branch) {
|
||||
closeBranchContextMenu();
|
||||
return;
|
||||
}
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
void showBranchMenuAt(branch, rect.right - 184, rect.bottom);
|
||||
}
|
||||
|
||||
async function openFolderContextMenu(event: MouseEvent, folder: BranchFolderRow) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
|
||||
|
||||
contextBranch = null;
|
||||
contextFolder = folder;
|
||||
contextMenuX = event.clientX + 2;
|
||||
contextMenuY = event.clientY + 2;
|
||||
@@ -301,7 +460,8 @@
|
||||
const branch = contextBranch;
|
||||
if (!branch || branch.current || isBusy) return;
|
||||
closeBranchContextMenu();
|
||||
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
|
||||
if (branch.remote) await onDeleteRemoteBranch(branch);
|
||||
else await onDeleteBranch(branch);
|
||||
}
|
||||
|
||||
async function deleteContextFolder() {
|
||||
@@ -339,16 +499,97 @@
|
||||
await onRebase(branch);
|
||||
}
|
||||
|
||||
function closeAllContextMenus() {
|
||||
closeBranchContextMenu();
|
||||
}
|
||||
|
||||
function handleWindowKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") closeAllContextMenus();
|
||||
if (event.key === "Escape") closeBranchContextMenu();
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
|
||||
<svelte:window onclick={closeBranchContextMenu} onkeydown={handleWindowKeydown} oncontextmenucapture={closeBranchContextMenu} />
|
||||
|
||||
{#snippet branchRows(rows: BranchRow[])}
|
||||
{#each rows as row (row.id)}
|
||||
{#if row.kind === "folder"}
|
||||
{@const open = isBranchFolderOpen(row.id)}
|
||||
<button
|
||||
class="bp-row bp-folder"
|
||||
class:has-current={row.current}
|
||||
class:remote-root={isRemoteRoot(row)}
|
||||
style={`--depth: ${row.depth}; --indent: ${INDENT}px;`}
|
||||
type="button"
|
||||
onclick={() => toggleBranchFolder(row.id)}
|
||||
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||
aria-expanded={open}
|
||||
title={`${row.name} · ${row.branchCount} ${row.branchCount === 1 ? "branch" : "branches"}`}
|
||||
>
|
||||
<span class="bp-chevron" aria-hidden="true">
|
||||
{#if open}<ChevronDown size={12} />{:else}<ChevronRight size={12} />{/if}
|
||||
</span>
|
||||
<span class="bp-icon" aria-hidden="true">
|
||||
{#if isRemoteRoot(row)}
|
||||
<Globe size={13} />
|
||||
{:else if open}
|
||||
<FolderOpen size={13} />
|
||||
{:else}
|
||||
<Folder size={13} />
|
||||
{/if}
|
||||
</span>
|
||||
<span class="bp-name">{row.name}</span>
|
||||
{#if row.current && !open}
|
||||
<span class="bp-current-dot" title="Contains current branch"></span>
|
||||
{/if}
|
||||
<span class="bp-count">{row.branchCount}</span>
|
||||
</button>
|
||||
{:else}
|
||||
{@const tracking = trackingFor(row.branch)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="bp-row bp-branch"
|
||||
class:current={row.branch.current}
|
||||
class:flash={flashId === row.id}
|
||||
class:menu-open={contextBranch === row.branch}
|
||||
data-row-id={row.id}
|
||||
style={`--depth: ${row.depth}; --indent: ${INDENT}px;`}
|
||||
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
|
||||
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
|
||||
title={branchTitle(row.branch)}
|
||||
>
|
||||
<span class="bp-chevron" aria-hidden="true"></span>
|
||||
<span class="bp-icon" aria-hidden="true">
|
||||
{#if row.branch.current}<CircleDot size={13} />{:else}<GitBranch size={13} />{/if}
|
||||
</span>
|
||||
<span class="bp-name">
|
||||
{#each highlightParts(row.displayName) as part, index (index)}
|
||||
{#if part.match}<mark>{part.text}</mark>{:else}{part.text}{/if}
|
||||
{/each}
|
||||
</span>
|
||||
<span class="bp-meta">
|
||||
{#if tracking.kind === "tracked"}
|
||||
<span class="bp-track" aria-label={`Tracks ${tracking.upstream}`}><Cloud size={11} /></span>
|
||||
{:else if tracking.kind === "gone"}
|
||||
<span class="bp-track gone" aria-label="Upstream gone"><CloudOff size={11} /></span>
|
||||
{:else if tracking.kind === "local"}
|
||||
<span class="bp-track local" aria-label="Local only"><Laptop size={11} /></span>
|
||||
{:else if tracking.kind === "remote-tracked"}
|
||||
<span class="bp-track linked" aria-label={`Checked out as ${tracking.local}`}><Link2 size={11} /></span>
|
||||
{/if}
|
||||
{#if row.branch.current}
|
||||
<span class="bp-head-tag">HEAD</span>
|
||||
{/if}
|
||||
</span>
|
||||
<button
|
||||
class="bp-more"
|
||||
type="button"
|
||||
onclick={(event) => openBranchMenuFromButton(event, row.branch)}
|
||||
disabled={isBusy}
|
||||
title="Branch actions"
|
||||
aria-label={`Actions for ${row.branch.name}`}
|
||||
>
|
||||
<Ellipsis size={13} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/snippet}
|
||||
|
||||
<section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
|
||||
<div class="section-head">
|
||||
@@ -387,9 +628,10 @@
|
||||
{:else if !hasRepository}
|
||||
<p class="blank-state">Open a repository to list branches.</p>
|
||||
{:else}
|
||||
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||
<div class="bp-body">
|
||||
<div class="bp-top">
|
||||
{#if createOpen}
|
||||
<form class="branch-create-form" onsubmit={submitCreate}>
|
||||
<form class="branch-create-form bp-create" onsubmit={submitCreate}>
|
||||
<GitBranch size={15} aria-hidden="true" />
|
||||
<input
|
||||
bind:this={createInput}
|
||||
@@ -399,6 +641,7 @@
|
||||
spellcheck="false"
|
||||
placeholder="new-branch-name"
|
||||
aria-label="New branch name"
|
||||
onkeydown={(event) => { if (event.key === "Escape") closeCreateForm(); }}
|
||||
/>
|
||||
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title="Create branch">
|
||||
<Check size={14} aria-hidden="true" />
|
||||
@@ -409,143 +652,94 @@
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
<div class="branch-group">
|
||||
<button
|
||||
class="branch-group-toggle"
|
||||
type="button"
|
||||
onclick={() => { localOpen = !localOpen; }}
|
||||
aria-expanded={localOpen}
|
||||
>
|
||||
{#if localOpen}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{#if currentBranch}
|
||||
{@const tracking = trackingFor(currentBranch)}
|
||||
<button class="bp-current" type="button" onclick={revealCurrentBranch} title="Reveal current branch in list">
|
||||
<span class="bp-current-icon" aria-hidden="true"><CircleDot size={14} /></span>
|
||||
<span class="bp-current-text">
|
||||
<strong>{currentBranch.name}</strong>
|
||||
<small>
|
||||
{#if tracking.kind === "tracked"}
|
||||
<Cloud size={10} aria-hidden="true" /> {tracking.upstream}
|
||||
{:else if tracking.kind === "gone"}
|
||||
<CloudOff size={10} aria-hidden="true" /> {tracking.upstream} (gone)
|
||||
{:else}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
<Laptop size={10} aria-hidden="true" /> Not published
|
||||
{/if}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<label class="bp-filter">
|
||||
<Search size={13} aria-hidden="true" />
|
||||
<input
|
||||
bind:this={filterInput}
|
||||
bind:value={filterText}
|
||||
onkeydown={handleFilterKeydown}
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder="Filter branches"
|
||||
aria-label="Filter branches"
|
||||
/>
|
||||
{#if filterText}
|
||||
<button class="bp-filter-clear" type="button" onclick={() => { filterText = ""; filterInput?.focus(); }} title="Clear filter" aria-label="Clear filter">
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="bp-scroll" bind:this={scrollElement} role="tree" aria-label="Branch list">
|
||||
<div class="bp-group">
|
||||
<button
|
||||
class="bp-group-head"
|
||||
type="button"
|
||||
onclick={() => { if (!filtering) localOpen = !localOpen; }}
|
||||
aria-expanded={showLocal}
|
||||
>
|
||||
{#if showLocal}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
|
||||
<Laptop size={12} aria-hidden="true" />
|
||||
<span>Local</span>
|
||||
<span class="branch-group-count">{localBranches.length}</span>
|
||||
<span class="bp-group-count">{filtering ? `${visibleLocal.length}/${localBranches.length}` : localBranches.length}</span>
|
||||
</button>
|
||||
|
||||
{#if localOpen}
|
||||
{#if showLocal}
|
||||
{#if localBranches.length === 0}
|
||||
<div class="branch-empty">No local branches.</div>
|
||||
<div class="bp-empty">No local branches.</div>
|
||||
{:else}
|
||||
{#each localBranchRows as row (row.id)}
|
||||
{#if row.kind === "folder"}
|
||||
<button
|
||||
class="branch-folder-row"
|
||||
class:current={row.current}
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
type="button"
|
||||
onclick={() => toggleBranchFolder(row.id)}
|
||||
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||
aria-expanded={isBranchFolderOpen(row.id)}
|
||||
title={`${row.name} (${row.branchCount})`}
|
||||
>
|
||||
{#if isBranchFolderOpen(row.id)}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
<Folder size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="branch-folder-name">{row.name}</span>
|
||||
<span class="branch-folder-count">{row.branchCount}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<article
|
||||
class="branch-row"
|
||||
class:current={row.branch.current}
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
|
||||
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
|
||||
title={row.branch.current ? "Current branch" : row.branch.name}
|
||||
>
|
||||
<div class="branch-info">
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{row.displayName}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{#if row.branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{@render branchRows(localBranchRows)}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="branch-group">
|
||||
<div class="bp-group">
|
||||
<button
|
||||
class="branch-group-toggle"
|
||||
class="bp-group-head"
|
||||
type="button"
|
||||
onclick={() => { remoteOpen = !remoteOpen; }}
|
||||
aria-expanded={remoteOpen}
|
||||
onclick={() => { if (!filtering) remoteOpen = !remoteOpen; }}
|
||||
aria-expanded={showRemote}
|
||||
>
|
||||
{#if remoteOpen}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
{#if showRemote}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
|
||||
<Cloud size={12} aria-hidden="true" />
|
||||
<span>Remote</span>
|
||||
<span class="branch-group-count">{remoteBranches.length}</span>
|
||||
<span class="bp-group-count">{filtering ? `${visibleRemote.length}/${remoteBranches.length}` : remoteBranches.length}</span>
|
||||
</button>
|
||||
|
||||
{#if remoteOpen}
|
||||
{#if showRemote}
|
||||
{#if remoteBranches.length === 0}
|
||||
<div class="branch-empty">No remote branches.</div>
|
||||
<div class="bp-empty">No remote branches.</div>
|
||||
{:else}
|
||||
{#each remoteBranchRows as row (row.id)}
|
||||
{#if row.kind === "folder"}
|
||||
<button
|
||||
class="branch-folder-row"
|
||||
class:current={row.current}
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
type="button"
|
||||
onclick={() => toggleBranchFolder(row.id)}
|
||||
oncontextmenu={(event) => openFolderContextMenu(event, row)}
|
||||
aria-expanded={isBranchFolderOpen(row.id)}
|
||||
title={`${row.name} (${row.branchCount})`}
|
||||
>
|
||||
{#if isBranchFolderOpen(row.id)}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
<Folder size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="branch-folder-name">{row.name}</span>
|
||||
<span class="branch-folder-count">{row.branchCount}</span>
|
||||
</button>
|
||||
{:else}
|
||||
<article
|
||||
class="branch-row"
|
||||
class:current={row.branch.current}
|
||||
style={`--branch-indent: ${row.depth * 16}px;`}
|
||||
ondblclick={(event) => checkoutOnDoubleClick(event, row.branch)}
|
||||
oncontextmenu={(event) => openBranchContextMenu(event, row.branch)}
|
||||
title={row.branch.current ? "Current branch" : row.branch.name}
|
||||
>
|
||||
<div class="branch-info">
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{row.displayName}</strong>
|
||||
</div>
|
||||
</div>
|
||||
{#if row.branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{/if}
|
||||
</article>
|
||||
{/if}
|
||||
{/each}
|
||||
{@render branchRows(remoteBranchRows)}
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
|
||||
{#if filtering && visibleLocal.length === 0 && visibleRemote.length === 0}
|
||||
<div class="bp-empty">No branches match “{filterText.trim()}”.</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -619,5 +813,288 @@
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.bp-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bp-top {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px 8px 6px;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
.bp-top :global(.bp-create) { margin-bottom: 0; }
|
||||
|
||||
/* Current branch card */
|
||||
.bp-current {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid color-mix(in srgb, #4eca76 26%, transparent);
|
||||
border-radius: 7px;
|
||||
background: linear-gradient(90deg, color-mix(in srgb, #4eca76 11%, transparent), transparent 85%);
|
||||
color: var(--color-ink);
|
||||
text-align: left;
|
||||
}
|
||||
.bp-current:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, #4eca76 42%, transparent);
|
||||
background: linear-gradient(90deg, color-mix(in srgb, #4eca76 17%, transparent), transparent 90%);
|
||||
}
|
||||
.bp-current-icon { display: grid; place-items: center; color: #4eca76; }
|
||||
.bp-current-text { display: grid; min-width: 0; gap: 1px; }
|
||||
.bp-current-text strong,
|
||||
.bp-current-text small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bp-current-text strong { font-size: 12px; font-weight: 700; }
|
||||
.bp-current-text small {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Filter */
|
||||
.bp-filter {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--color-ink-faint);
|
||||
}
|
||||
.bp-filter :global(svg) { position: absolute; left: 8px; pointer-events: none; }
|
||||
.bp-filter input {
|
||||
height: 26px;
|
||||
padding: 0 26px 0 26px;
|
||||
border-color: var(--color-border-subtle);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bp-filter-clear {
|
||||
position: absolute;
|
||||
right: 3px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
min-height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-ink-faint);
|
||||
}
|
||||
.bp-filter-clear :global(svg) { position: static; }
|
||||
.bp-filter-clear:hover:not(:disabled) { color: var(--color-ink); background: var(--color-surface-hover); }
|
||||
|
||||
/* List */
|
||||
.bp-scroll {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
padding: 0 2px 8px 6px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Scrollbar look comes from .left-sidebar in app.css; start below the sticky group header. */
|
||||
.bp-scroll::-webkit-scrollbar-track { margin-top: 30px; }
|
||||
|
||||
.bp-group + .bp-group { margin-top: 2px; }
|
||||
|
||||
.bp-group-head {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 28px;
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border-subtle);
|
||||
border-radius: 0;
|
||||
background: var(--color-surface);
|
||||
backdrop-filter: blur(12px);
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-align: left;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.bp-group-head:hover:not(:disabled) { color: var(--color-ink-muted); background: var(--color-surface); }
|
||||
.bp-group-count {
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.bp-empty {
|
||||
padding: 8px 10px;
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.bp-row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 12px 14px minmax(0, 1fr) auto auto;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
width: 100%;
|
||||
min-height: 26px;
|
||||
height: 26px;
|
||||
margin: 1px 0 0;
|
||||
padding: 0 4px 0 calc(4px + (var(--depth, 0) + 1) * var(--indent, 14px));
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--color-ink);
|
||||
font-size: 12px;
|
||||
font-weight: 550;
|
||||
text-align: left;
|
||||
cursor: default;
|
||||
}
|
||||
.bp-group-head + .bp-row { margin-top: 4px; }
|
||||
|
||||
/* Indent guides */
|
||||
.bp-row::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: -1px;
|
||||
bottom: 0;
|
||||
left: 10px;
|
||||
width: calc((var(--depth, 0) + 1) * var(--indent, 14px));
|
||||
background-image: linear-gradient(to right, var(--color-border) 1px, transparent 1px);
|
||||
background-size: var(--indent, 14px) 100%;
|
||||
background-repeat: repeat-x;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bp-row:hover:not(:disabled),
|
||||
.bp-row.menu-open {
|
||||
background: var(--color-surface-hover);
|
||||
color: var(--color-ink);
|
||||
}
|
||||
|
||||
.bp-chevron,
|
||||
.bp-icon {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-ink-faint);
|
||||
}
|
||||
|
||||
.bp-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.bp-name mark {
|
||||
border-radius: 2px;
|
||||
color: inherit;
|
||||
background: color-mix(in srgb, var(--color-accent) 30%, transparent);
|
||||
}
|
||||
|
||||
/* Folder rows */
|
||||
.bp-folder { cursor: pointer; }
|
||||
.bp-folder .bp-name { color: var(--color-ink); font-weight: 600; }
|
||||
.bp-folder:hover .bp-name { color: var(--color-ink); }
|
||||
.bp-folder.remote-root .bp-icon { color: var(--color-accent); }
|
||||
.bp-folder.remote-root .bp-name { color: var(--color-ink); font-weight: 700; }
|
||||
.bp-count {
|
||||
grid-column: 5;
|
||||
min-width: 18px;
|
||||
padding-right: 4px;
|
||||
color: var(--color-ink-faint);
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.bp-current-dot {
|
||||
grid-column: 4;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #4eca76;
|
||||
}
|
||||
|
||||
/* Branch rows */
|
||||
.bp-meta {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
.bp-track {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: var(--color-ink-faint);
|
||||
opacity: 0.7;
|
||||
}
|
||||
.bp-track.local { opacity: 0.45; }
|
||||
.bp-track.gone { color: var(--color-sync-ahead); opacity: 1; }
|
||||
.bp-track.linked { color: var(--color-accent); opacity: 0.85; }
|
||||
|
||||
.bp-head-tag {
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
color: #4eca76;
|
||||
background: color-mix(in srgb, #4eca76 14%, transparent);
|
||||
font: 700 9px/1.3 var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.bp-more {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 20px;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
min-height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-ink-faint);
|
||||
opacity: 0;
|
||||
}
|
||||
.bp-branch:hover .bp-more,
|
||||
.bp-branch.menu-open .bp-more,
|
||||
.bp-more:focus-visible { opacity: 1; }
|
||||
.bp-more:hover:not(:disabled) { color: var(--color-ink); background: color-mix(in srgb, var(--color-ink) 10%, transparent); }
|
||||
|
||||
.bp-branch.current {
|
||||
color: var(--color-ink);
|
||||
font-weight: 700;
|
||||
background: color-mix(in srgb, #4eca76 9%, transparent);
|
||||
box-shadow: inset 2px 0 0 #4eca76;
|
||||
}
|
||||
.bp-branch.current .bp-icon { color: #4eca76; }
|
||||
.bp-branch.current:hover { background: color-mix(in srgb, #4eca76 14%, transparent); }
|
||||
|
||||
.bp-branch.flash { animation: bp-flash 900ms ease-out; }
|
||||
@keyframes bp-flash {
|
||||
0%, 30% { background: color-mix(in srgb, #4eca76 30%, transparent); }
|
||||
100% { background: color-mix(in srgb, #4eca76 9%, transparent); }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user