Add collapsible local and remote branch groups to the branch visibility dialog, including toggle buttons and selection counts. Introduce comprehensive dialog styling and responsive rules to match the app UI. Opening the dialog now defaults to local open and remote closed for faster access. - Add CSS for branch-filter dialog layout, theming, and responsiveness - Implement group toggles with chevrons and visible selected counts - Default to local group open and remote group closed on dialog open
1270 lines
48 KiB
Svelte
1270 lines
48 KiB
Svelte
<script lang="ts">
|
|
import { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
|
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
|
|
|
interface GraphSegment {
|
|
fromCol: number;
|
|
toCol: number;
|
|
color: string;
|
|
branches: string[];
|
|
}
|
|
|
|
interface GraphRow {
|
|
dotCol: number;
|
|
dotColor: string;
|
|
branchLabels: string[];
|
|
top: GraphSegment[];
|
|
bottom: GraphSegment[];
|
|
}
|
|
|
|
interface VisibleCommitEntry {
|
|
commit: GitCommit;
|
|
graphCommit: GitCommit;
|
|
}
|
|
|
|
type BranchVisibilityMode = "focus" | "local" | "all" | "custom";
|
|
type CommitRefKind = "local" | "remote" | "head";
|
|
|
|
interface CommitBranchDecoration {
|
|
label: string;
|
|
kind: CommitRefKind;
|
|
current: boolean;
|
|
trackedRemote: string;
|
|
localOnly: boolean;
|
|
representedBranches: string[];
|
|
}
|
|
|
|
interface CommitRefSummary {
|
|
branches: CommitBranchDecoration[];
|
|
tags: string[];
|
|
other: string[];
|
|
primaryBranch: CommitBranchDecoration | null;
|
|
primaryTag: string;
|
|
overflowCount: number;
|
|
}
|
|
|
|
const GRAPH_COLORS = [
|
|
"#69a7ff", "#5bd18a", "#d8a74a", "#ba82ff",
|
|
"#ff7c9f", "#48c7d8", "#c5cf54", "#e18c55",
|
|
];
|
|
const GRAPH_LANE = 18;
|
|
const GRAPH_REF_ARM = 16;
|
|
const GRAPH_VISIBILITY_STORAGE_PREFIX = "gitlite.graphVisibility.v1:";
|
|
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
|
|
dateStyle: "medium",
|
|
timeStyle: "short",
|
|
});
|
|
|
|
interface Props {
|
|
commits: GitCommit[];
|
|
localBranchNames: string[];
|
|
localBranchUpstreams: Record<string, string>;
|
|
remoteBranchNames: string[];
|
|
activeBranch: string;
|
|
activeUpstream: string;
|
|
activeAhead: number;
|
|
activeBehind: number;
|
|
repositoryKey: string;
|
|
hasRepository: boolean;
|
|
isBusy: boolean;
|
|
hasMore: boolean;
|
|
isLoadingMore: boolean;
|
|
loadMoreError: string;
|
|
expandedCommitHashes: Set<string>;
|
|
selectedCommitHash: string;
|
|
onLoadMore: () => void | Promise<void>;
|
|
onRestoreCommit: (commit: GitCommit) => void;
|
|
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
|
onToggleCommitFiles: (hash: string) => void;
|
|
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
|
onCherryPickCommit: (commit: GitCommit) => void;
|
|
onRevertCommit: (commit: GitCommit) => void;
|
|
onOpenCommitNote: (commit: GitCommit) => void;
|
|
onLoadCommitNote: (commit: GitCommit) => Promise<string | null>;
|
|
onSelectCommit: (commit: GitCommit) => void;
|
|
}
|
|
|
|
let {
|
|
commits = [],
|
|
localBranchNames = [],
|
|
localBranchUpstreams = {},
|
|
remoteBranchNames = [],
|
|
activeBranch = "",
|
|
activeUpstream = "",
|
|
activeAhead = 0,
|
|
activeBehind = 0,
|
|
repositoryKey = "",
|
|
hasRepository = false,
|
|
isBusy = false,
|
|
hasMore = false,
|
|
isLoadingMore = false,
|
|
loadMoreError = "",
|
|
expandedCommitHashes = new Set(),
|
|
selectedCommitHash = "",
|
|
onLoadMore = () => {},
|
|
onRestoreCommit = () => {},
|
|
onPreviewCommitFile = () => {},
|
|
onToggleCommitFiles = () => {},
|
|
onCreateBranchFromCommit = () => {},
|
|
onCherryPickCommit = () => {},
|
|
onRevertCommit = () => {},
|
|
onOpenCommitNote = () => {},
|
|
onLoadCommitNote = async () => null,
|
|
onSelectCommit = () => {},
|
|
}: Props = $props();
|
|
|
|
let branchVisibilityMode = $state<BranchVisibilityMode>("focus");
|
|
let customVisibleBranches = $state<Set<string>>(new Set());
|
|
let loadedVisibilityRepository = $state("");
|
|
let branchDialogOpen = $state(false);
|
|
let localBranchGroupOpen = $state(true);
|
|
let remoteBranchGroupOpen = $state(false);
|
|
let expandedRefsCommitHash = $state("");
|
|
let panelElement = $state<HTMLElement | null>(null);
|
|
let contextCommit = $state<GitCommit | null>(null);
|
|
let contextMenuX = $state(0);
|
|
let contextMenuY = $state(0);
|
|
let notePreviews = $state<Record<string, string>>({});
|
|
let notePreviewLoading = $state<Set<string>>(new Set());
|
|
let notePreviewErrors = $state<Set<string>>(new Set());
|
|
|
|
$effect(() => {
|
|
repositoryKey;
|
|
notePreviews = {};
|
|
notePreviewLoading = new Set();
|
|
notePreviewErrors = new Set();
|
|
});
|
|
|
|
function observeHistoryEnd(node: HTMLElement) {
|
|
const root = node.closest<HTMLElement>(".history-list");
|
|
const observer = new IntersectionObserver((entries) => {
|
|
if (entries.some((entry) => entry.isIntersecting) && hasMore && !isLoadingMore && !loadMoreError && !isBusy) {
|
|
void onLoadMore();
|
|
}
|
|
}, { root, rootMargin: "240px 0px" });
|
|
observer.observe(node);
|
|
return { destroy: () => observer.disconnect() };
|
|
}
|
|
|
|
function laneColor(col: number): string {
|
|
return GRAPH_COLORS[((col % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
|
|
}
|
|
|
|
function graphColX(col: number): number {
|
|
return col * GRAPH_LANE + GRAPH_LANE / 2;
|
|
}
|
|
|
|
function graphPath(seg: GraphSegment, fromY: number, toY: number): string {
|
|
const x1 = graphColX(seg.fromCol);
|
|
const x2 = graphColX(seg.toCol);
|
|
if (x1 === x2) return `M ${x1} ${fromY} L ${x2} ${toY}`;
|
|
const midY = (fromY + toY) / 2;
|
|
return `M ${x1} ${fromY} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${toY}`;
|
|
}
|
|
|
|
function computeGraph(items: GitCommit[], branchMembership = new Map<string, string[]>()): { rows: GraphRow[]; columns: number } {
|
|
const rows: GraphRow[] = [];
|
|
let lanes: (string | null)[] = [];
|
|
let laneBranches: string[][] = [];
|
|
let maxColumns = 1;
|
|
|
|
for (const commit of items) {
|
|
const before = lanes.slice();
|
|
const beforeBranches = laneBranches.map((branches) => branches.slice());
|
|
|
|
let col = before.indexOf(commit.hash);
|
|
if (col === -1) {
|
|
col = before.indexOf(null);
|
|
if (col === -1) col = before.length;
|
|
}
|
|
|
|
const after = before.slice();
|
|
while (after.length <= col) after.push(null);
|
|
const afterBranches = beforeBranches.map((branches) => branches.slice());
|
|
while (afterBranches.length <= col) afterBranches.push([]);
|
|
|
|
for (let k = 0; k < after.length; k++) {
|
|
if (after[k] === commit.hash) {
|
|
after[k] = null;
|
|
afterBranches[k] = [];
|
|
}
|
|
}
|
|
|
|
const currentBranches = branchMembership.get(commit.hash) ?? localBranchRefs(commit);
|
|
const commitBranches = uniqueStrings([...(beforeBranches[col] ?? []), ...currentBranches]);
|
|
const firstParent = commit.parents[0] ?? null;
|
|
after[col] = firstParent;
|
|
afterBranches[col] = firstParent
|
|
? (branchMembership.get(firstParent) ?? commitBranches).slice()
|
|
: [];
|
|
|
|
const fromCommit = new Set<number>([col]);
|
|
for (let p = 1; p < commit.parents.length; p++) {
|
|
let slot = after.indexOf(null);
|
|
if (slot === -1) { slot = after.length; after.push(null); afterBranches.push([]); }
|
|
after[slot] = commit.parents[p];
|
|
afterBranches[slot] = (branchMembership.get(commit.parents[p]) ?? commitBranches).slice();
|
|
fromCommit.add(slot);
|
|
}
|
|
|
|
const top: GraphSegment[] = [];
|
|
for (let k = 0; k < before.length; k++) {
|
|
const target = before[k];
|
|
if (target == null) continue;
|
|
top.push({ fromCol: k, toCol: target === commit.hash ? col : k, color: laneColor(k), branches: beforeBranches[k] ?? [] });
|
|
}
|
|
|
|
const bottom: GraphSegment[] = [];
|
|
for (let k = 0; k < after.length; k++) {
|
|
if (after[k] == null) continue;
|
|
bottom.push({
|
|
fromCol: fromCommit.has(k) ? col : k,
|
|
toCol: k,
|
|
color: laneColor(k),
|
|
branches: fromCommit.has(k) ? commitBranches : afterBranches[k] ?? [],
|
|
});
|
|
}
|
|
|
|
rows.push({ dotCol: col, dotColor: laneColor(col), branchLabels: currentBranches, top, bottom });
|
|
|
|
lanes = after.slice();
|
|
laneBranches = afterBranches.map((branches) => branches.slice());
|
|
while (lanes.length > 0 && lanes[lanes.length - 1] == null) {
|
|
lanes.pop();
|
|
laneBranches.pop();
|
|
}
|
|
maxColumns = Math.max(maxColumns, before.length, after.length, col + 1);
|
|
}
|
|
|
|
return { rows, columns: maxColumns };
|
|
}
|
|
|
|
function statusLabel(kind: FileStatusKind): string {
|
|
return kind;
|
|
}
|
|
|
|
function displayCommitFile(file: GitCommitFile): string {
|
|
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
|
}
|
|
|
|
function baseName(path: string): string {
|
|
return path.split(/[\\/]/).filter(Boolean).pop() ?? path;
|
|
}
|
|
|
|
function commitFileName(file: GitCommitFile): string {
|
|
return file.old_path
|
|
? `${baseName(file.old_path)} -> ${baseName(file.path)}`
|
|
: baseName(file.path);
|
|
}
|
|
|
|
function authorInitials(name: string): string {
|
|
const parts = name.trim().split(/\s+/).filter(Boolean);
|
|
if (parts.length === 0) return "?";
|
|
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
|
|
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
|
|
}
|
|
|
|
function commitKind(commit: GitCommit): "merge" | "root" | "commit" {
|
|
if (commit.parents.length > 1) return "merge";
|
|
if (commit.parents.length === 0) return "root";
|
|
return "commit";
|
|
}
|
|
|
|
function commitKindLabel(commit: GitCommit): string {
|
|
const kind = commitKind(commit);
|
|
if (kind === "merge") return "merge";
|
|
if (kind === "root") return "root";
|
|
return "commit";
|
|
}
|
|
|
|
let localBranchNameSet = $derived(new Set(localBranchNames));
|
|
let remoteBranchNameSet = $derived(new Set(remoteBranchNames));
|
|
|
|
function uniqueStrings(values: string[]): string[] {
|
|
return [...new Set(values.filter(Boolean))];
|
|
}
|
|
|
|
let graphBranchNames = $derived(uniqueStrings([...localBranchNames, ...remoteBranchNames, activeUpstream].filter(Boolean)));
|
|
let graphBranchNameSet = $derived(new Set(graphBranchNames));
|
|
|
|
function focusBranchNames(): string[] {
|
|
const focused = uniqueStrings([activeBranch, activeUpstream].filter((branch) => graphBranchNameSet.has(branch)));
|
|
if (focused.length > 0) return focused;
|
|
return localBranchNames[0] ? [localBranchNames[0]] : graphBranchNames.slice(0, 1);
|
|
}
|
|
|
|
function branchNamesForMode(): string[] {
|
|
if (branchVisibilityMode === "focus") return focusBranchNames();
|
|
if (branchVisibilityMode === "local") return localBranchNames;
|
|
if (branchVisibilityMode === "all") return graphBranchNames;
|
|
return graphBranchNames.filter((branch) => customVisibleBranches.has(branch));
|
|
}
|
|
|
|
let visibleGraphBranchNames = $derived(branchNamesForMode());
|
|
let visibleGraphBranchNameSet = $derived(new Set(visibleGraphBranchNames));
|
|
|
|
function branchIsVisible(branch: string): boolean {
|
|
return visibleGraphBranchNameSet.has(branch);
|
|
}
|
|
|
|
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
|
|
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
|
|
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
|
|
|
|
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
|
|
if (containingBranches.length === 0) return commit.short_hash;
|
|
return `Branches containing this commit: ${containingBranches.join(", ")}`;
|
|
}
|
|
|
|
function segmentIsVisible(segment: GraphSegment): boolean {
|
|
return segment.branches.length === 0 || segment.branches.some(branchIsVisible);
|
|
}
|
|
|
|
function syncClassForBranches(labels: string[]): "" | "ahead" | "behind" {
|
|
if (!activeBranch || !activeUpstream) return "";
|
|
const hasLocal = labels.includes(activeBranch);
|
|
const hasRemote = labels.includes(activeUpstream);
|
|
if (hasLocal && !hasRemote) return "ahead";
|
|
if (hasRemote && !hasLocal) return "behind";
|
|
return "";
|
|
}
|
|
|
|
function branchesAreVisible(branches: string[]): boolean {
|
|
if (graphBranchNames.length === 0) return true;
|
|
return branches.some((branch) => visibleGraphBranchNameSet.has(branch));
|
|
}
|
|
|
|
function rowGraphIsVisible(row: GraphRow | undefined): boolean {
|
|
return branchesAreVisible(row?.branchLabels ?? []);
|
|
}
|
|
|
|
function nearestVisibleGraphParents(
|
|
hash: string,
|
|
visibleHashes: Set<string>,
|
|
commitByHash: Map<string, GitCommit>,
|
|
seen: Set<string>,
|
|
): string[] {
|
|
if (visibleHashes.has(hash)) return [hash];
|
|
if (seen.has(hash)) return [];
|
|
seen.add(hash);
|
|
|
|
const commit = commitByHash.get(hash);
|
|
if (!commit) return [];
|
|
return uniqueStrings(
|
|
commit.parents.flatMap((parentHash) => (
|
|
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set(seen))
|
|
)),
|
|
);
|
|
}
|
|
|
|
function branchMembershipByHash(items: GitCommit[]): Map<string, string[]> {
|
|
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
|
|
const membership = new Map<string, Set<string>>();
|
|
|
|
for (const commit of items) {
|
|
for (const branch of graphBranchRefs(commit)) {
|
|
const stack = [commit.hash];
|
|
const seen = new Set<string>();
|
|
|
|
while (stack.length > 0) {
|
|
const hash = stack.pop();
|
|
if (!hash || seen.has(hash)) continue;
|
|
seen.add(hash);
|
|
|
|
let branches = membership.get(hash);
|
|
if (!branches) {
|
|
branches = new Set<string>();
|
|
membership.set(hash, branches);
|
|
}
|
|
branches.add(branch);
|
|
|
|
const parentCommit = commitByHash.get(hash);
|
|
if (parentCommit) stack.push(...parentCommit.parents);
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Map(
|
|
items.map((commit) => [
|
|
commit.hash,
|
|
graphBranchNames.filter((branch) => membership.get(commit.hash)?.has(branch)),
|
|
]),
|
|
);
|
|
}
|
|
|
|
function visibleCommitEntriesForGraph(items: GitCommit[], branchMembership: Map<string, string[]>): VisibleCommitEntry[] {
|
|
const visibleItems = items.filter((commit) => branchesAreVisible(branchMembership.get(commit.hash) ?? []));
|
|
const visibleHashes = new Set(visibleItems.map((commit) => commit.hash));
|
|
const commitByHash = new Map(items.map((commit) => [commit.hash, commit]));
|
|
|
|
return visibleItems.map((commit) => {
|
|
const parents = uniqueStrings(
|
|
commit.parents.flatMap((parentHash) => (
|
|
nearestVisibleGraphParents(parentHash, visibleHashes, commitByHash, new Set())
|
|
)),
|
|
);
|
|
return { commit, graphCommit: { ...commit, parents } };
|
|
});
|
|
}
|
|
|
|
function toggleGraphBranch(branch: string) {
|
|
const next = branchVisibilityMode === "custom"
|
|
? new Set(customVisibleBranches)
|
|
: new Set(visibleGraphBranchNames);
|
|
if (next.has(branch)) next.delete(branch); else next.add(branch);
|
|
customVisibleBranches = next;
|
|
branchVisibilityMode = "custom";
|
|
}
|
|
|
|
function showAllGraphBranches() {
|
|
branchVisibilityMode = "all";
|
|
}
|
|
|
|
function hideAllGraphBranches() {
|
|
customVisibleBranches = new Set();
|
|
branchVisibilityMode = "custom";
|
|
}
|
|
|
|
function showFocusGraphBranches() {
|
|
branchVisibilityMode = "focus";
|
|
}
|
|
|
|
function openBranchDialog() {
|
|
localBranchGroupOpen = true;
|
|
remoteBranchGroupOpen = false;
|
|
branchDialogOpen = true;
|
|
}
|
|
|
|
function closeBranchDialog() {
|
|
branchDialogOpen = false;
|
|
}
|
|
|
|
function handleBranchDialogKeydown(event: KeyboardEvent) {
|
|
if (branchDialogOpen && event.key === "Escape") {
|
|
closeBranchDialog();
|
|
}
|
|
}
|
|
|
|
function toggleCommitRefs(commit: GitCommit) {
|
|
expandedRefsCommitHash = expandedRefsCommitHash === commit.hash ? "" : commit.hash;
|
|
}
|
|
|
|
function openCommitActionMenu(event: MouseEvent, commit: GitCommit) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
if (isBusy) return;
|
|
|
|
if (contextCommit?.hash === commit.hash) {
|
|
closeCommitContextMenu();
|
|
return;
|
|
}
|
|
|
|
const panelRect = panelElement?.getBoundingClientRect();
|
|
const buttonRect = event.currentTarget instanceof HTMLElement
|
|
? event.currentTarget.getBoundingClientRect()
|
|
: null;
|
|
const rawX = panelRect && buttonRect ? buttonRect.right - panelRect.left - 184 : event.offsetX;
|
|
const rawY = panelRect && buttonRect ? buttonRect.bottom - panelRect.top + 4 : event.offsetY;
|
|
const maxX = Math.max(8, (panelRect?.width ?? window.innerWidth) - 192);
|
|
const maxY = Math.max(8, (panelRect?.height ?? window.innerHeight) - 96);
|
|
|
|
contextCommit = commit;
|
|
contextMenuX = Math.max(8, Math.min(rawX, maxX));
|
|
contextMenuY = Math.max(8, Math.min(rawY, maxY));
|
|
}
|
|
|
|
function closeCommitContextMenu() {
|
|
contextCommit = null;
|
|
}
|
|
|
|
async function createBranchFromContextCommit() {
|
|
const commit = contextCommit;
|
|
if (!commit || isBusy) return;
|
|
closeCommitContextMenu();
|
|
await onCreateBranchFromCommit(commit);
|
|
}
|
|
|
|
async function restoreContextCommit() {
|
|
const commit = contextCommit;
|
|
if (!commit || isBusy) return;
|
|
closeCommitContextMenu();
|
|
await onRestoreCommit(commit);
|
|
}
|
|
|
|
async function cherryPickContextCommit() {
|
|
const commit = contextCommit;
|
|
if (!commit || isBusy) return;
|
|
closeCommitContextMenu();
|
|
await onCherryPickCommit(commit);
|
|
}
|
|
|
|
async function revertContextCommit() {
|
|
const commit = contextCommit;
|
|
if (!commit || isBusy) return;
|
|
closeCommitContextMenu();
|
|
await onRevertCommit(commit);
|
|
}
|
|
|
|
async function openContextCommitNote() {
|
|
const commit = contextCommit;
|
|
if (!commit || isBusy) return;
|
|
closeCommitContextMenu();
|
|
onSelectCommit(commit);
|
|
await onOpenCommitNote(commit);
|
|
}
|
|
|
|
async function openCommitNote(commit: GitCommit) {
|
|
if (isBusy) return;
|
|
onSelectCommit(commit);
|
|
await onOpenCommitNote(commit);
|
|
}
|
|
|
|
async function loadCommitNotePreview(commit: GitCommit) {
|
|
if (!commit.has_note || notePreviewLoading.has(commit.hash)) return;
|
|
|
|
const loading = new Set(notePreviewLoading);
|
|
loading.add(commit.hash);
|
|
notePreviewLoading = loading;
|
|
|
|
const errors = new Set(notePreviewErrors);
|
|
errors.delete(commit.hash);
|
|
notePreviewErrors = errors;
|
|
|
|
try {
|
|
const note = await onLoadCommitNote(commit);
|
|
notePreviews = {
|
|
...notePreviews,
|
|
[commit.hash]: note?.trim() || "This Git note is empty.",
|
|
};
|
|
} catch {
|
|
const nextErrors = new Set(notePreviewErrors);
|
|
nextErrors.add(commit.hash);
|
|
notePreviewErrors = nextErrors;
|
|
} finally {
|
|
const nextLoading = new Set(notePreviewLoading);
|
|
nextLoading.delete(commit.hash);
|
|
notePreviewLoading = nextLoading;
|
|
}
|
|
}
|
|
|
|
function handleWindowKeydown(event: KeyboardEvent) {
|
|
if (event.key !== "Escape") return;
|
|
closeCommitContextMenu();
|
|
expandedRefsCommitHash = "";
|
|
handleBranchDialogKeydown(event);
|
|
}
|
|
|
|
function localBranchRefs(commit: GitCommit): string[] {
|
|
const seen = new Set<string>();
|
|
const labels: string[] = [];
|
|
for (const ref of commit.refs) {
|
|
const label = refLabel(ref);
|
|
if (!localBranchNameSet.has(label) || seen.has(label)) continue;
|
|
seen.add(label);
|
|
labels.push(label);
|
|
}
|
|
return labels;
|
|
}
|
|
|
|
function graphBranchRefs(commit: GitCommit): string[] {
|
|
const seen = new Set<string>();
|
|
const labels: string[] = [];
|
|
for (const ref of commit.refs) {
|
|
const label = refLabel(ref);
|
|
if (!graphBranchNameSet.has(label) || seen.has(label)) continue;
|
|
seen.add(label);
|
|
labels.push(label);
|
|
}
|
|
return labels;
|
|
}
|
|
|
|
function refLabel(ref: string): string {
|
|
return ref.replace(/^HEAD ->\s*/, "").replace(/^tag:\s*/, "");
|
|
}
|
|
|
|
function isSymbolicRemoteHead(ref: string): boolean {
|
|
if (ref.startsWith("HEAD ->") || ref.startsWith("tag:")) return false;
|
|
const label = refLabel(ref);
|
|
return !localBranchNameSet.has(label) && /(?:^|\/)HEAD(?:\s*->|$)/.test(ref);
|
|
}
|
|
|
|
function configuredUpstreamForBranch(local: string): string {
|
|
return localBranchUpstreams[local] ?? (local === activeBranch ? activeUpstream : "");
|
|
}
|
|
|
|
function matchingRemoteBranch(local: string, remoteBranches: string[]): string {
|
|
const configuredUpstream = configuredUpstreamForBranch(local);
|
|
return configuredUpstream && remoteBranches.includes(configuredUpstream) ? configuredUpstream : "";
|
|
}
|
|
|
|
function compareBranchDecorations(left: CommitBranchDecoration, right: CommitBranchDecoration): number {
|
|
const priority = (item: CommitBranchDecoration) => {
|
|
if (item.label === activeBranch) return 0;
|
|
if (item.kind === "head") return 1;
|
|
if (item.kind === "local") return 2;
|
|
if (item.label === activeUpstream) return 3;
|
|
return 4;
|
|
};
|
|
return priority(left) - priority(right) || left.label.localeCompare(right.label, undefined, { numeric: true });
|
|
}
|
|
|
|
function commitRefSummary(commit: GitCommit): CommitRefSummary {
|
|
const local = new Set<string>();
|
|
const remote = new Set<string>();
|
|
const tags = new Set<string>();
|
|
const other = new Set<string>();
|
|
let detachedHead = false;
|
|
|
|
for (const ref of commit.refs) {
|
|
if (isSymbolicRemoteHead(ref)) continue;
|
|
const label = refLabel(ref);
|
|
if (!label) continue;
|
|
if (ref.startsWith("tag:")) {
|
|
tags.add(label);
|
|
} else if (localBranchNameSet.has(label)) {
|
|
local.add(label);
|
|
} else if (remoteBranchNameSet.has(label) || label === activeUpstream) {
|
|
remote.add(label);
|
|
} else if (label === "HEAD") {
|
|
detachedHead = true;
|
|
} else {
|
|
other.add(label);
|
|
}
|
|
}
|
|
|
|
const remainingRemote = [...remote];
|
|
const branches: CommitBranchDecoration[] = [...local].map((label) => {
|
|
const configuredUpstream = configuredUpstreamForBranch(label);
|
|
const trackedRemote = matchingRemoteBranch(label, remainingRemote);
|
|
if (trackedRemote) remainingRemote.splice(remainingRemote.indexOf(trackedRemote), 1);
|
|
return {
|
|
label,
|
|
kind: "local",
|
|
current: label === activeBranch,
|
|
trackedRemote,
|
|
localOnly: !configuredUpstream,
|
|
representedBranches: trackedRemote ? [label, trackedRemote] : [label],
|
|
};
|
|
});
|
|
|
|
if (detachedHead) {
|
|
branches.push({
|
|
label: "HEAD",
|
|
kind: "head",
|
|
current: true,
|
|
trackedRemote: "",
|
|
localOnly: false,
|
|
representedBranches: [],
|
|
});
|
|
}
|
|
|
|
branches.push(...remainingRemote.map((label) => ({
|
|
label,
|
|
kind: "remote" as const,
|
|
current: false,
|
|
trackedRemote: "",
|
|
localOnly: false,
|
|
representedBranches: [label],
|
|
})));
|
|
|
|
const sortedBranches = branches.sort(compareBranchDecorations);
|
|
const primaryBranch = sortedBranches.find(
|
|
(branch) => branch.kind === "head" || branch.representedBranches.some(branchIsVisible),
|
|
) ?? null;
|
|
const sortedTags = [...tags].sort((left, right) => left.localeCompare(right, undefined, { numeric: true }));
|
|
const sortedOther = [...other].sort((left, right) => left.localeCompare(right));
|
|
const primaryTag = sortedTags[0] ?? "";
|
|
|
|
return {
|
|
branches: sortedBranches,
|
|
tags: sortedTags,
|
|
other: sortedOther,
|
|
primaryBranch,
|
|
primaryTag,
|
|
overflowCount:
|
|
Math.max(0, sortedBranches.length - (primaryBranch ? 1 : 0))
|
|
+ Math.max(0, sortedTags.length - (primaryTag ? 1 : 0))
|
|
+ sortedOther.length,
|
|
};
|
|
}
|
|
|
|
function branchStatusLabel(branch: CommitBranchDecoration): string {
|
|
if (branch.label === activeBranch) {
|
|
const parts = [];
|
|
if (activeAhead > 0) parts.push(`↑${activeAhead}`);
|
|
if (activeBehind > 0) parts.push(`↓${activeBehind}`);
|
|
return parts.join(" ");
|
|
}
|
|
if (branch.label === activeUpstream && activeBehind > 0) return `↓${activeBehind}`;
|
|
return "";
|
|
}
|
|
|
|
function branchDecorationTitle(branch: CommitBranchDecoration): string {
|
|
if (branch.localOnly) {
|
|
const status = branchStatusLabel(branch);
|
|
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
|
|
}
|
|
const status = branchStatusLabel(branch);
|
|
if (branch.trackedRemote) {
|
|
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
|
|
}
|
|
if (status) return `${branch.label} · ${status}`;
|
|
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
|
|
}
|
|
|
|
function formatCommitDate(value: string): string {
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return value;
|
|
return commitDateFormatter.format(date);
|
|
}
|
|
|
|
$effect(() => {
|
|
const currentRepository = repositoryKey;
|
|
if (loadedVisibilityRepository === currentRepository) return;
|
|
loadedVisibilityRepository = currentRepository;
|
|
expandedRefsCommitHash = "";
|
|
|
|
if (!currentRepository) {
|
|
branchVisibilityMode = "focus";
|
|
customVisibleBranches = new Set();
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const stored = localStorage.getItem(`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`);
|
|
if (!stored) {
|
|
branchVisibilityMode = "focus";
|
|
customVisibleBranches = new Set();
|
|
return;
|
|
}
|
|
const parsed = JSON.parse(stored) as { mode?: unknown; branches?: unknown };
|
|
const mode = parsed.mode;
|
|
branchVisibilityMode = mode === "focus" || mode === "local" || mode === "all" || mode === "custom"
|
|
? mode
|
|
: "focus";
|
|
customVisibleBranches = new Set(
|
|
Array.isArray(parsed.branches)
|
|
? parsed.branches.filter((branch): branch is string => typeof branch === "string")
|
|
: [],
|
|
);
|
|
} catch {
|
|
branchVisibilityMode = "focus";
|
|
customVisibleBranches = new Set();
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
const currentRepository = repositoryKey;
|
|
const mode = branchVisibilityMode;
|
|
const branches = [...customVisibleBranches];
|
|
if (!currentRepository || loadedVisibilityRepository !== currentRepository) return;
|
|
try {
|
|
localStorage.setItem(
|
|
`${GRAPH_VISIBILITY_STORAGE_PREFIX}${currentRepository}`,
|
|
JSON.stringify({ mode, branches }),
|
|
);
|
|
} catch {
|
|
// The graph still works if storage is unavailable.
|
|
}
|
|
});
|
|
|
|
$effect(() => {
|
|
if (graphBranchNames.length === 0 || customVisibleBranches.size === 0) return;
|
|
const available = new Set(graphBranchNames);
|
|
const next = new Set([...customVisibleBranches].filter((branch) => available.has(branch)));
|
|
if (next.size !== customVisibleBranches.size) customVisibleBranches = next;
|
|
});
|
|
|
|
$effect(() => {
|
|
if (!selectedCommitHash) return;
|
|
queueMicrotask(() => {
|
|
panelElement
|
|
?.querySelector<HTMLElement>(`[data-commit-hash="${CSS.escape(selectedCommitHash)}"]`)
|
|
?.scrollIntoView({ block: "center", behavior: "smooth" });
|
|
});
|
|
});
|
|
|
|
let branchMembership = $derived(branchMembershipByHash(commits));
|
|
let visibleCommitEntries = $derived(visibleCommitEntriesForGraph(commits, branchMembership));
|
|
let visibleCommits = $derived(visibleCommitEntries.map((entry) => entry.commit));
|
|
let graphCommits = $derived(visibleCommitEntries.map((entry) => entry.graphCommit));
|
|
let visibleBranchCount = $derived(visibleGraphBranchNames.length);
|
|
let graph = $derived(computeGraph(graphCommits, branchMembership));
|
|
let graphRows = $derived(graph.rows);
|
|
let graphWidth = $derived(Math.max(Math.max(graph.columns, 1) * GRAPH_LANE + 18, 42));
|
|
</script>
|
|
|
|
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
|
|
|
|
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
|
|
<div class="section-head">
|
|
<div>
|
|
<span class="eyebrow">History</span>
|
|
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
|
</div>
|
|
{#if graphBranchNames.length > 0}
|
|
<div class="section-head-actions">
|
|
<button
|
|
class="graph-branch-dialog-button"
|
|
type="button"
|
|
onclick={openBranchDialog}
|
|
title="Customize visible branches"
|
|
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
|
|
>
|
|
<GitBranch size={13} aria-hidden="true" />
|
|
Branches
|
|
<span>{visibleBranchCount}/{graphBranchNames.length}</span>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if !hasRepository}
|
|
<div class="blank-state">No repository loaded.</div>
|
|
{:else if commits.length === 0}
|
|
<div class="blank-state">No commits returned.</div>
|
|
{:else}
|
|
<div class="history-list graph-list overflow-auto">
|
|
{#if visibleCommits.length === 0}
|
|
<div class="blank-state">No loaded commits match the selected branches.</div>
|
|
{/if}
|
|
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
|
|
{@const item = entry.commit}
|
|
{@const row = graphRows[rowIndex]}
|
|
{@const refSummary = commitRefSummary(item)}
|
|
{@const rowSyncClass = syncClassForBranches(row?.branchLabels ?? [])}
|
|
<article
|
|
class="commit-row graph-row"
|
|
class:selected={selectedCommitHash === item.hash}
|
|
class:has-note={item.has_note}
|
|
data-commit-hash={item.hash}
|
|
class:graph-ahead-row={rowSyncClass === "ahead"}
|
|
class:graph-behind-row={rowSyncClass === "behind"}
|
|
class:merge-row={item.parents.length > 1}
|
|
class:root-row={item.parents.length === 0}
|
|
class:tip-row={item.refs.length > 0}
|
|
>
|
|
<div class="graph-gutter" style={`width:${graphWidth}px`} aria-hidden="true">
|
|
{#if row}
|
|
<svg class="graph-svg" viewBox={`0 0 ${graphWidth} 100`} preserveAspectRatio="none">
|
|
{#each row.top as seg}
|
|
<path
|
|
class:hidden-branch={!segmentIsVisible(seg)}
|
|
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
|
|
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
|
|
d={graphPath(seg, 0, 50)}
|
|
stroke={seg.color}
|
|
stroke-width="2.2"
|
|
vector-effect="non-scaling-stroke"
|
|
/>
|
|
{/each}
|
|
{#each row.bottom as seg}
|
|
<path
|
|
class:hidden-branch={!segmentIsVisible(seg)}
|
|
class:graph-segment-ahead={syncClassForBranches(seg.branches) === "ahead"}
|
|
class:graph-segment-behind={syncClassForBranches(seg.branches) === "behind"}
|
|
d={graphPath(seg, 50, 100)}
|
|
stroke={seg.color}
|
|
stroke-width="2.2"
|
|
vector-effect="non-scaling-stroke"
|
|
/>
|
|
{/each}
|
|
{#if refSummary.primaryBranch}
|
|
<path
|
|
class="graph-ref-connector"
|
|
d={`M ${graphColX(row.dotCol)} 50 L ${graphWidth - GRAPH_REF_ARM * 2} 50`}
|
|
stroke={row.dotColor}
|
|
stroke-width="1.5"
|
|
vector-effect="non-scaling-stroke"
|
|
/>
|
|
{/if}
|
|
</svg>
|
|
<span
|
|
class="graph-dot"
|
|
class:merge={item.parents.length > 1}
|
|
class:tip={item.refs.length > 0}
|
|
class:hidden-branch={!rowGraphIsVisible(row)}
|
|
class:ahead={rowSyncClass === "ahead"}
|
|
class:behind={rowSyncClass === "behind"}
|
|
title={commitHoverTitle(item, row)}
|
|
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
|
></span>
|
|
{/if}
|
|
</div>
|
|
|
|
<div
|
|
class="commit-body"
|
|
class:has-branch-ref={Boolean(refSummary.primaryBranch)}
|
|
style={`--ref-lane-color:${row?.dotColor ?? GRAPH_COLORS[0]}`}
|
|
>
|
|
{#if item.has_note}
|
|
<span class="commit-note-rail" aria-hidden="true"></span>
|
|
{/if}
|
|
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
|
|
<div class="commit-ref-area">
|
|
<div class="commit-ref-strip" aria-label="Commit references">
|
|
{#if refSummary.primaryBranch}
|
|
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
|
|
<span
|
|
class="compact-ref-chip branch"
|
|
class:current={refSummary.primaryBranch.current}
|
|
class:remote={refSummary.primaryBranch.kind === "remote"}
|
|
title={branchDecorationTitle(refSummary.primaryBranch)}
|
|
>
|
|
<GitBranch class="compact-ref-branch-icon" size={10} aria-hidden="true" />
|
|
<span>{refSummary.primaryBranch.label}</span>
|
|
{#if !refSummary.primaryBranch.localOnly && branchStatusLabel(refSummary.primaryBranch)}
|
|
<small>{branchStatusLabel(refSummary.primaryBranch)}</small>
|
|
{/if}
|
|
</span>
|
|
{#if refSummary.primaryBranch.localOnly}
|
|
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
|
|
LOCAL
|
|
</span>
|
|
{/if}
|
|
</span>
|
|
{/if}
|
|
{#if refSummary.primaryTag}
|
|
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
|
|
<Tag size={10} aria-hidden="true" />
|
|
<span>{refSummary.primaryTag}</span>
|
|
</span>
|
|
{/if}
|
|
{#if refSummary.overflowCount > 0}
|
|
<button
|
|
class="compact-ref-overflow"
|
|
type="button"
|
|
onclick={() => toggleCommitRefs(item)}
|
|
aria-expanded={expandedRefsCommitHash === item.hash}
|
|
aria-controls={`commit-refs-${item.hash}`}
|
|
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
|
|
>
|
|
+{refSummary.overflowCount}
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
|
|
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
|
|
<strong>References on this commit</strong>
|
|
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
|
|
<section>
|
|
<span>Local</span>
|
|
<div>
|
|
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
|
|
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
|
|
<i aria-hidden="true"></i>{branch.label}
|
|
{#if branch.current}<small>Current</small>{/if}
|
|
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
|
|
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
|
|
</span>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
|
|
<section>
|
|
<span>Remote</span>
|
|
<div>
|
|
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
|
|
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
{#if refSummary.tags.length > 0}
|
|
<section>
|
|
<span>Tags</span>
|
|
<div>
|
|
{#each refSummary.tags as tag}
|
|
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
{#if refSummary.other.length > 0}
|
|
<section>
|
|
<span>Other</span>
|
|
<div>
|
|
{#each refSummary.other as ref}
|
|
<span class="commit-ref-detail-item">{ref}</span>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="commit-card-head">
|
|
<span class="commit-avatar">
|
|
{authorInitials(item.author_name)}
|
|
</span>
|
|
<div class="commit-card-main">
|
|
<div class="commit-title-row">
|
|
<strong class="commit-summary" title={item.summary}>{item.summary}</strong>
|
|
<span class={`commit-kind ${commitKind(item)}`}>
|
|
{#if item.parents.length > 1}
|
|
<GitMerge size={12} aria-hidden="true" />
|
|
{/if}
|
|
{commitKindLabel(item)}
|
|
</span>
|
|
</div>
|
|
<div class="commit-meta-line">
|
|
<span class="commit-hash">{item.short_hash}</span>
|
|
<span class="commit-author" title={item.author_email}>{item.author_name}</span>
|
|
{#if item.has_note}
|
|
<span class="commit-note-indicator">
|
|
<button
|
|
class="commit-note-presence"
|
|
type="button"
|
|
onpointerenter={() => void loadCommitNotePreview(item)}
|
|
onfocus={() => void loadCommitNotePreview(item)}
|
|
onclick={() => openCommitNote(item)}
|
|
disabled={isBusy}
|
|
aria-label={`Open Git note for ${item.short_hash}`}
|
|
aria-describedby={`commit-note-preview-${item.hash}`}
|
|
>
|
|
<StickyNote size={11} aria-hidden="true" />
|
|
<span>Note</span>
|
|
</button>
|
|
<span
|
|
class="commit-note-tooltip"
|
|
id={`commit-note-preview-${item.hash}`}
|
|
role="tooltip"
|
|
>
|
|
<span class="commit-note-tooltip-head">
|
|
<StickyNote size={12} aria-hidden="true" />
|
|
Git Note
|
|
<small>Click to open</small>
|
|
</span>
|
|
<span class="commit-note-tooltip-body">
|
|
{#if notePreviewLoading.has(item.hash)}
|
|
Loading note…
|
|
{:else if notePreviewErrors.has(item.hash)}
|
|
Note could not be loaded.
|
|
{:else}
|
|
{notePreviews[item.hash] ?? "Hover to load the note."}
|
|
{/if}
|
|
</span>
|
|
</span>
|
|
</span>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{#if item.files.length > 0}
|
|
<div class="commit-files">
|
|
<button
|
|
class="commit-files-toggle"
|
|
type="button"
|
|
onclick={() => onToggleCommitFiles(item.hash)}
|
|
aria-expanded={expandedCommitHashes.has(item.hash)}
|
|
>
|
|
{#if expandedCommitHashes.has(item.hash)}
|
|
<ChevronDown size={14} aria-hidden="true" />
|
|
{:else}
|
|
<ChevronRight size={14} aria-hidden="true" />
|
|
{/if}
|
|
{item.files.length} {item.files.length === 1 ? "file" : "files"} changed
|
|
</button>
|
|
|
|
{#if expandedCommitHashes.has(item.hash)}
|
|
<div class="commit-file-list" aria-label="Changed files">
|
|
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
|
|
<button
|
|
class="commit-file-button"
|
|
type="button"
|
|
onclick={() => onPreviewCommitFile(item, file)}
|
|
disabled={isBusy}
|
|
title={`Show differences before restoring - ${displayCommitFile(file)}`}
|
|
>
|
|
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
|
|
<strong>{commitFileName(file)}</strong>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="commit-actions">
|
|
<time class="commit-time" datetime={item.date}>{formatCommitDate(item.date)}</time>
|
|
<div class="commit-action-buttons">
|
|
{#if !item.has_note}
|
|
<button
|
|
class="commit-menu-button commit-note-button"
|
|
type="button"
|
|
onclick={() => openCommitNote(item)}
|
|
disabled={isBusy}
|
|
title={`Add a Git note to ${item.short_hash}`}
|
|
aria-label={`Add a Git note to ${item.short_hash}`}
|
|
>
|
|
<StickyNote size={14} aria-hidden="true" />
|
|
</button>
|
|
{/if}
|
|
<button
|
|
class="commit-menu-button"
|
|
type="button"
|
|
onclick={(event) => openCommitActionMenu(event, item)}
|
|
disabled={isBusy}
|
|
title="Commit actions"
|
|
aria-label={`Actions for ${item.short_hash}`}
|
|
aria-haspopup="menu"
|
|
aria-expanded={contextCommit?.hash === item.hash}
|
|
>
|
|
<EllipsisVertical size={15} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
{/each}
|
|
{#if hasMore || isLoadingMore || loadMoreError}
|
|
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
|
|
{#if isLoadingMore}
|
|
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
|
<span>Loading older commits…</span>
|
|
{:else if loadMoreError}
|
|
<span title={loadMoreError}>Older commits could not be loaded.</span>
|
|
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
|
|
{:else}
|
|
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
|
|
Load older commits
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if contextCommit}
|
|
<div
|
|
class="history-context-menu"
|
|
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
|
|
role="menu"
|
|
tabindex="-1"
|
|
aria-label={`Actions for ${contextCommit.short_hash}`}
|
|
>
|
|
<button type="button" role="menuitem" onclick={createBranchFromContextCommit} disabled={isBusy}>
|
|
<GitBranch size={14} aria-hidden="true" />
|
|
Branch
|
|
</button>
|
|
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
|
|
<StickyNote size={14} aria-hidden="true" />
|
|
Note
|
|
</button>
|
|
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
|
|
<RotateCcw size={14} aria-hidden="true" />
|
|
Restore
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
onclick={cherryPickContextCommit}
|
|
disabled={isBusy}
|
|
title="Apply this commit's changes on top of the current branch"
|
|
>
|
|
<Cherry size={14} aria-hidden="true" />
|
|
Cherry-pick
|
|
</button>
|
|
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
|
|
<RotateCcw size={14} aria-hidden="true" /> Revert
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
{#if branchDialogOpen}
|
|
<div class="branch-filter-backdrop" role="presentation">
|
|
<div
|
|
class="branch-filter-dialog"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label="Select visible branches"
|
|
>
|
|
<header class="branch-filter-dialog-head">
|
|
<div>
|
|
<span class="eyebrow">Git graph</span>
|
|
<h3>Visible branches</h3>
|
|
</div>
|
|
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label="Close branch selection">
|
|
<X size={16} aria-hidden="true" />
|
|
</button>
|
|
</header>
|
|
|
|
<div class="branch-filter-summary">
|
|
<span>{visibleBranchCount} of {graphBranchNames.length} branches selected</span>
|
|
<div class="branch-filter-actions">
|
|
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
|
|
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
|
|
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="branch-filter-dialog-list">
|
|
{#if localBranchNames.length > 0}
|
|
<section class="branch-filter-group">
|
|
<button
|
|
class="branch-filter-group-toggle"
|
|
type="button"
|
|
aria-expanded={localBranchGroupOpen}
|
|
onclick={() => { localBranchGroupOpen = !localBranchGroupOpen; }}
|
|
>
|
|
{#if localBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
|
|
<span>Local</span>
|
|
<em>{localBranchNames.filter(branchIsVisible).length}/{localBranchNames.length}</em>
|
|
</button>
|
|
{#if localBranchGroupOpen}
|
|
<div class="branch-filter-group-list">
|
|
{#each localBranchNames as branch}
|
|
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
|
<input
|
|
type="checkbox"
|
|
checked={branchIsVisible(branch)}
|
|
onchange={() => toggleGraphBranch(branch)}
|
|
/>
|
|
<GitBranch size={14} aria-hidden="true" />
|
|
<span>{branch}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
{#if remoteBranchNames.length > 0}
|
|
<section class="branch-filter-group">
|
|
<button
|
|
class="branch-filter-group-toggle"
|
|
type="button"
|
|
aria-expanded={remoteBranchGroupOpen}
|
|
onclick={() => { remoteBranchGroupOpen = !remoteBranchGroupOpen; }}
|
|
>
|
|
{#if remoteBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
|
|
<span>Remote</span>
|
|
<em>{remoteBranchNames.filter(branchIsVisible).length}/{remoteBranchNames.length}</em>
|
|
</button>
|
|
{#if remoteBranchGroupOpen}
|
|
<div class="branch-filter-group-list">
|
|
{#each remoteBranchNames as branch}
|
|
<label class:muted={!branchIsVisible(branch)} class="branch-filter-option" title={branch}>
|
|
<input
|
|
type="checkbox"
|
|
checked={branchIsVisible(branch)}
|
|
onchange={() => toggleGraphBranch(branch)}
|
|
/>
|
|
<GitBranch size={14} aria-hidden="true" />
|
|
<span>{branch}</span>
|
|
</label>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/if}
|