feat(branch-panel): add branch filtering, persistent view state, and UI polish

- Add a text filter with highlight and clear (Esc to clear) that narrows visible branches and auto-opens folders while filtering.
- Persist panel view state (local/remote open + collapsed folders) to localStorage under "gitlite.branchPanelView.v2" so folder open/collapse and section visibility survive reloads.
- Reveal current branch action: clears filter, expands path to current branch, scrolls it into view and briefly flashes it.
- Rework branch list rendering: unified folder/branch ids, scope-aware rows, tracking status computation (tracked/gone/local/remote-tracked), richer titles/tooltips, updated icons, and better context-menu positioning/behavior (separate showBranchMenuAt + open-from-button).
- Prevent toggling folder collapse while filtering; folder rows are implicitly open when a filter is active.
- Add slim, rounded custom scrollbars for the left sidebar in src/app.css.

No external APIs changed; behavior is additive and intended to improve branch navigation and discoverability.
This commit is contained in:
2026-09-17 17:37:41 +02:00
parent 3ef2941190
commit f71a07ab11
2 changed files with 719 additions and 202 deletions
+40
View File
@@ -9253,3 +9253,43 @@ section > header.page-header.page-header {
.sidebar-tags-list { min-height: 0; overflow: auto; padding: 4px 0; } .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 { 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; } .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); }
+647 -170
View File
@@ -1,8 +1,31 @@
<script lang="ts"> <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 { tick } from "svelte";
import type { GitBranch as GitBranchInfo } from "../types"; import type { GitBranch as GitBranchInfo } from "../types";
type Scope = "local" | "remote";
type BranchTreeNode = BranchFolderNode | BranchLeafNode; type BranchTreeNode = BranchFolderNode | BranchLeafNode;
interface BranchFolderNode { interface BranchFolderNode {
@@ -33,6 +56,7 @@
branchCount: number; branchCount: number;
current: boolean; current: boolean;
branches: GitBranchInfo[]; branches: GitBranchInfo[];
scope: Scope;
} }
interface BranchLeafRow { interface BranchLeafRow {
@@ -41,7 +65,7 @@
branch: GitBranchInfo; branch: GitBranchInfo;
displayName: string; displayName: string;
depth: number; depth: number;
scopeLabel: string; scope: Scope;
} }
interface Props { interface Props {
@@ -84,35 +108,98 @@
onToggleCollapsed = () => {}, onToggleCollapsed = () => {},
}: Props = $props(); }: Props = $props();
let localOpen = $state(true); const VIEW_STATE_KEY = "gitlite.branchPanelView.v2";
let remoteOpen = $state(false); 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 createOpen = $state(false);
let newBranchName = $state(""); let newBranchName = $state("");
let createInput = $state<HTMLInputElement | null>(null); 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 contextBranch = $state<GitBranchInfo | null>(null);
let contextFolder = $state<BranchFolderRow | null>(null); let contextFolder = $state<BranchFolderRow | null>(null);
let branchContextMenuElement = $state<HTMLElement | null>(null); let branchContextMenuElement = $state<HTMLElement | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set());
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local")); $effect(() => {
let remoteBranchRows = $derived(buildBranchRows("remote", remoteBranches, "remote")); const snapshot: StoredViewState = {
localOpen,
function createFolder(id: string, name: string): BranchFolderNode { remoteOpen,
return { collapsedFolders: [...collapsedBranchFolders],
kind: "folder",
id,
name,
children: [],
branchCount: 0,
current: false,
branches: [],
folders: new Map(),
}; };
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`, ""); const root = createFolder(`${scope}:root`, "");
for (const branch of branchList) { for (const branch of branchList) {
@@ -123,11 +210,10 @@
for (let index = 0; index < folderParts.length; index += 1) { for (let index = 0; index < folderParts.length; index += 1) {
const folderName = folderParts[index]; const folderName = folderParts[index];
const folderPath = folderParts.slice(0, index + 1).join("/");
let folder = parent.folders.get(folderName); let folder = parent.folders.get(folderName);
if (!folder) { 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.folders.set(folderName, folder);
parent.children.push(folder); parent.children.push(folder);
} }
@@ -138,18 +224,13 @@
parent = folder; parent = folder;
} }
parent.children.push({ parent.children.push({ kind: "branch", id: branchId(scope, branch.name), branch, displayName });
kind: "branch",
id: `${scope}:branch:${branch.name}`,
branch,
displayName,
});
} }
sortBranchNodes(root.children); sortBranchNodes(root.children);
const rows: BranchRow[] = []; const rows: BranchRow[] = [];
flattenBranchNodes(root.children, rows, 0, scopeLabel); flattenBranchNodes(root.children, rows, 0, scope);
return rows; 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) { for (const node of nodes) {
if (node.kind === "folder") { if (node.kind === "folder") {
rows.push({ rows.push({
@@ -177,35 +258,90 @@
branchCount: node.branchCount, branchCount: node.branchCount,
current: node.current, current: node.current,
branches: node.branches, branches: node.branches,
scope,
}); });
if (isBranchFolderOpen(node.id)) { if (isBranchFolderOpen(node.id)) {
flattenBranchNodes(node.children, rows, depth + 1, scopeLabel); flattenBranchNodes(node.children, rows, depth + 1, scope);
} }
} else { } else {
rows.push({ rows.push({ kind: "branch", id: node.id, branch: node.branch, displayName: node.displayName, depth, scope });
kind: "branch",
id: node.id,
branch: node.branch,
displayName: node.displayName,
depth,
scopeLabel,
});
} }
} }
} }
function isBranchFolderOpen(id: string) { function isBranchFolderOpen(id: string) {
return !collapsedBranchFolders.has(id); return filtering || !collapsedBranchFolders.has(id);
} }
function toggleBranchFolder(id: string) { function toggleBranchFolder(id: string) {
if (filtering) return;
const next = new Set(collapsedBranchFolders); const next = new Set(collapsedBranchFolders);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id);
else next.add(id); else next.add(id);
collapsedBranchFolders = next; 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() { function openCreateForm() {
if (!hasRepository || isBusy) return; if (!hasRepository || isBusy) return;
if (collapsed) onToggleCollapsed(); if (collapsed) onToggleCollapsed();
@@ -228,6 +364,13 @@
localOpen = true; localOpen = true;
} }
function handleFilterKeydown(event: KeyboardEvent) {
if (event.key === "Escape" && filterText) {
event.stopPropagation();
filterText = "";
}
}
function checkoutOnDoubleClick(event: MouseEvent, branch: GitBranchInfo) { function checkoutOnDoubleClick(event: MouseEvent, branch: GitBranchInfo) {
if (branch.current || isBusy) return; if (branch.current || isBusy) return;
const target = event.target instanceof HTMLElement ? event.target : null; const target = event.target instanceof HTMLElement ? event.target : null;
@@ -246,27 +389,43 @@
}; };
} }
async function openBranchContextMenu(event: MouseEvent, branch: GitBranchInfo) { async function showBranchMenuAt(branch: GitBranchInfo, x: number, y: number) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return; if (isBusy) return;
contextFolder = null;
contextBranch = branch; contextBranch = branch;
contextMenuX = event.clientX + 2; contextMenuX = x + 2;
contextMenuY = event.clientY + 2; contextMenuY = y + 2;
await tick(); await tick();
if (contextBranch !== branch) return; if (contextBranch !== branch) return;
const position = fitContextMenuToViewport(branchContextMenuElement, event.clientX, event.clientY); const position = fitContextMenuToViewport(branchContextMenuElement, x, y);
contextMenuX = position.x; contextMenuX = position.x;
contextMenuY = position.y; 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) { async function openFolderContextMenu(event: MouseEvent, folder: BranchFolderRow) {
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return; if (isBusy || (folder.depth === 0 && folder.branches.every((branch) => branch.remote))) return;
contextBranch = null;
contextFolder = folder; contextFolder = folder;
contextMenuX = event.clientX + 2; contextMenuX = event.clientX + 2;
contextMenuY = event.clientY + 2; contextMenuY = event.clientY + 2;
@@ -301,7 +460,8 @@
const branch = contextBranch; const branch = contextBranch;
if (!branch || branch.current || isBusy) return; if (!branch || branch.current || isBusy) return;
closeBranchContextMenu(); closeBranchContextMenu();
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch); if (branch.remote) await onDeleteRemoteBranch(branch);
else await onDeleteBranch(branch);
} }
async function deleteContextFolder() { async function deleteContextFolder() {
@@ -339,16 +499,97 @@
await onRebase(branch); await onRebase(branch);
} }
function closeAllContextMenus() {
closeBranchContextMenu();
}
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeAllContextMenus(); if (event.key === "Escape") closeBranchContextMenu();
} }
</script> </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"> <section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
<div class="section-head"> <div class="section-head">
@@ -387,9 +628,10 @@
{:else if !hasRepository} {:else if !hasRepository}
<p class="blank-state">Open a repository to list branches.</p> <p class="blank-state">Open a repository to list branches.</p>
{:else} {:else}
<div class="branch-list overflow-auto p-2 flex flex-col gap-0"> <div class="bp-body">
<div class="bp-top">
{#if createOpen} {#if createOpen}
<form class="branch-create-form" onsubmit={submitCreate}> <form class="branch-create-form bp-create" onsubmit={submitCreate}>
<GitBranch size={15} aria-hidden="true" /> <GitBranch size={15} aria-hidden="true" />
<input <input
bind:this={createInput} bind:this={createInput}
@@ -399,6 +641,7 @@
spellcheck="false" spellcheck="false"
placeholder="new-branch-name" placeholder="new-branch-name"
aria-label="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"> <button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title="Create branch">
<Check size={14} aria-hidden="true" /> <Check size={14} aria-hidden="true" />
@@ -409,143 +652,94 @@
</form> </form>
{/if} {/if}
<div class="branch-group"> {#if currentBranch}
<button {@const tracking = trackingFor(currentBranch)}
class="branch-group-toggle" <button class="bp-current" type="button" onclick={revealCurrentBranch} title="Reveal current branch in list">
type="button" <span class="bp-current-icon" aria-hidden="true"><CircleDot size={14} /></span>
onclick={() => { localOpen = !localOpen; }} <span class="bp-current-text">
aria-expanded={localOpen} <strong>{currentBranch.name}</strong>
> <small>
{#if localOpen} {#if tracking.kind === "tracked"}
<ChevronDown size={14} aria-hidden="true" /> <Cloud size={10} aria-hidden="true" /> {tracking.upstream}
{:else if tracking.kind === "gone"}
<CloudOff size={10} aria-hidden="true" /> {tracking.upstream} (gone)
{:else} {:else}
<ChevronRight size={14} aria-hidden="true" /> <Laptop size={10} aria-hidden="true" /> Not published
{/if} {/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>Local</span>
<span class="branch-group-count">{localBranches.length}</span> <span class="bp-group-count">{filtering ? `${visibleLocal.length}/${localBranches.length}` : localBranches.length}</span>
</button> </button>
{#if localOpen} {#if showLocal}
{#if localBranches.length === 0} {#if localBranches.length === 0}
<div class="branch-empty">No local branches.</div> <div class="bp-empty">No local branches.</div>
{:else} {:else}
{#each localBranchRows as row (row.id)} {@render branchRows(localBranchRows)}
{#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}
{/if} {/if}
{/if} {/if}
</div> </div>
<div class="branch-group"> <div class="bp-group">
<button <button
class="branch-group-toggle" class="bp-group-head"
type="button" type="button"
onclick={() => { remoteOpen = !remoteOpen; }} onclick={() => { if (!filtering) remoteOpen = !remoteOpen; }}
aria-expanded={remoteOpen} aria-expanded={showRemote}
> >
{#if remoteOpen} {#if showRemote}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
<ChevronDown size={14} aria-hidden="true" /> <Cloud size={12} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Remote</span> <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> </button>
{#if remoteOpen} {#if showRemote}
{#if remoteBranches.length === 0} {#if remoteBranches.length === 0}
<div class="branch-empty">No remote branches.</div> <div class="bp-empty">No remote branches.</div>
{:else} {:else}
{#each remoteBranchRows as row (row.id)} {@render branchRows(remoteBranchRows)}
{#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}
{/if} {/if}
{/if} {/if}
</div> </div>
{#if filtering && visibleLocal.length === 0 && visibleRemote.length === 0}
<div class="bp-empty">No branches match “{filterText.trim()}”.</div>
{/if}
</div>
</div> </div>
{/if} {/if}
@@ -619,5 +813,288 @@
</button> </button>
</div> </div>
{/if} {/if}
</section> </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>