Files
GitLite/src/lib/components/StatusPanel.svelte
T
Christoph 4533f8aa38
publish / Build and publish Ubuntu AppImage (release) Successful in 19m39s
publish / Build and publish Windows installer (release) Successful in 22m14s
publish / Build and publish AUR packages (release) Successful in 48m41s
style(theme): add dialog & panel CSS vars and refactor status panel styles
Add CSS custom properties for dialog backdrop, shadows, and panel
highlights to centralize visual theming. Refactor the status panel
overlay to consume these tokens and use color-mix instead of hardcoded
rgba values. This improves visual consistency and lets overlays adapt
cleanly to appearance changes and theme variants.

- Centralize dialog and panel visuals with new CSS variables
- Replace fixed rgba values with tokenized colors and color-mix
- Move borders, shadows, and gradients to use the new theme tokens
2026-08-23 23:38:42 +02:00

813 lines
35 KiB
Svelte

<script lang="ts">
import {
Archive,
ArrowLeft,
ArrowRight,
FileDiff,
FileMinus2,
FileType,
FileX,
Folder,
FolderOpen,
FolderMinus,
FolderTree,
FolderX,
RotateCcw,
} from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types";
interface Props {
changedFiles: GitFileStatus[];
stagedCount: number;
unstagedCount: number;
hasRepository: boolean;
isBusy: boolean;
operation: string;
status: GitStatus | null;
selectedFilePath: string;
onSelectFile: (file: GitFileStatus) => void;
onStage: (files: GitFileStatus[]) => void;
onUnstage: (files: GitFileStatus[]) => void;
onDiscard: (files: GitFileStatus[], staged: boolean) => void;
onDiscardMany: (files: GitFileStatus[]) => void;
onStash: (files: GitFileStatus[], label: string) => void;
onIgnore: (target: string, kind: GitIgnoreKind) => void;
onStopTracking: (target: string, kind: "file" | "folder") => void;
onPatch: (file: GitFileStatus, staged: boolean) => void;
onStageAll: () => void;
onUnstageAll: () => void;
}
type StatusViewMode = "list" | "tree";
type StatusLaneKind = "unstaged" | "staged";
interface StatusFolderNode {
kind: "folder";
name: string;
path: string;
depth: number;
fileCount: number;
children: StatusTreeNode[];
}
interface StatusFileNode {
kind: "file";
name: string;
path: string;
depth: number;
file: GitFileStatus;
}
type StatusTreeNode = StatusFolderNode | StatusFileNode;
interface StatusContextTarget {
lane: StatusLaneKind;
kind: "file" | "folder";
label: string;
files: GitFileStatus[];
}
let {
changedFiles = [],
stagedCount = 0,
unstagedCount = 0,
hasRepository = false,
isBusy = false,
operation = "",
status = null,
selectedFilePath = "",
onSelectFile = () => {},
onStage = () => {},
onUnstage = () => {},
onDiscard = () => {},
onDiscardMany = () => {},
onStash = () => {},
onIgnore = () => {},
onStopTracking = () => {},
onPatch = () => {},
onStageAll = () => {},
onUnstageAll = () => {},
}: Props = $props();
function statusLabel(kind: FileStatusKind | null): string {
return kind ?? "none";
}
function displayPath(file: GitFileStatus): 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 fileName(file: GitFileStatus): string {
return file.old_path ? `${baseName(file.old_path)} -> ${baseName(file.path)}` : baseName(file.path);
}
function canPatch(kind: FileStatusKind | null): boolean {
return kind === "modified";
}
function fileKey(file: GitFileStatus): string {
return `${file.old_path ?? ""}:${file.path}`;
}
function sortStatusTree(nodes: StatusTreeNode[]) {
nodes.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
return a.name.localeCompare(b.name);
});
for (const node of nodes) {
if (node.kind === "folder") sortStatusTree(node.children);
}
}
function buildStatusTree(files: GitFileStatus[]): StatusTreeNode[] {
const roots: StatusTreeNode[] = [];
const folders = new Map<string, StatusFolderNode>();
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
const parts = file.path.split(/[\\/]+/).filter(Boolean);
if (parts.length === 0) continue;
let currentPath = "";
let siblings = roots;
const ancestors: StatusFolderNode[] = [];
for (let index = 0; index < parts.length - 1; index++) {
currentPath = currentPath ? `${currentPath}/${parts[index]}` : parts[index];
let folder = folders.get(currentPath);
if (!folder) {
folder = {
kind: "folder",
name: parts[index],
path: currentPath,
depth: index,
fileCount: 0,
children: [],
};
folders.set(currentPath, folder);
siblings.push(folder);
}
ancestors.push(folder);
siblings = folder.children;
}
siblings.push({
kind: "file",
name: parts[parts.length - 1] ?? file.path,
path: file.path,
depth: Math.max(parts.length - 1, 0),
file,
});
for (const folder of ancestors) folder.fileCount += 1;
}
sortStatusTree(roots);
return roots;
}
function flattenStatusTree(nodes: StatusTreeNode[], lane: StatusLaneKind): StatusTreeNode[] {
const visible: StatusTreeNode[] = [];
for (const node of nodes) {
visible.push(node);
if (node.kind === "folder" && !collapsedStatusFolders.has(`${lane}:${node.path}`)) {
visible.push(...flattenStatusTree(node.children, lane));
}
}
return visible;
}
function listStatusRows(files: GitFileStatus[]): StatusFileNode[] {
return files.map((file) => ({
kind: "file",
name: fileName(file),
path: file.path,
depth: 0,
file,
}));
}
let selectedStatusPaths = $state<Set<string>>(new Set());
let selectionAnchorKey = $state("");
let statusView = $state<StatusViewMode>("list");
let collapsedStatusFolders = $state<Set<string>>(new Set());
let statusContextTarget = $state<StatusContextTarget | null>(null);
let statusContextMenuX = $state(0);
let statusContextMenuY = $state(0);
let statusContextMenuElement = $state<HTMLDivElement | null>(null);
function toggleStatusFolder(lane: StatusLaneKind, path: string) {
const next = new Set(collapsedStatusFolders);
const key = `${lane}:${path}`;
if (next.has(key)) next.delete(key);
else next.add(key);
collapsedStatusFolders = next;
}
function filesInStatusFolder(lane: StatusLaneKind, path: string): GitFileStatus[] {
const prefix = `${path.replace(/\\/g, "/").replace(/\/$/, "")}/`;
const files = lane === "unstaged" ? unstagedFiles : stagedFiles;
return files.filter((file) => {
const currentPath = file.path.replace(/\\/g, "/");
const oldPath = file.old_path?.replace(/\\/g, "/") ?? "";
return currentPath.startsWith(prefix) || oldPath.startsWith(prefix);
});
}
function openStatusContextMenu(
event: MouseEvent,
lane: StatusLaneKind,
kind: "file" | "folder",
label: string,
files: GitFileStatus[],
) {
event.preventDefault();
event.stopPropagation();
if (files.length === 0) return;
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - 288));
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - 174));
statusContextTarget = { lane, kind, label, files };
requestAnimationFrame(() => {
if (!statusContextMenuElement) return;
const bounds = statusContextMenuElement.getBoundingClientRect();
statusContextMenuX = Math.max(8, Math.min(event.clientX, window.innerWidth - bounds.width - 8));
statusContextMenuY = Math.max(8, Math.min(event.clientY, window.innerHeight - bounds.height - 8));
});
}
function statusContextName(label: string): string {
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
return normalized.split("/").pop() || label;
}
function statusContextParent(label: string): string {
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
const separator = normalized.lastIndexOf("/");
return separator > 0 ? normalized.slice(0, separator) : "Repository root";
}
function closeStatusContextMenu() {
statusContextTarget = null;
}
function runStatusContextStageAction() {
const target = statusContextTarget;
if (!target) return;
closeStatusContextMenu();
if (target.lane === "unstaged") onStage(target.files);
else onUnstage(target.files);
}
function runStatusContextStashAction() {
const target = statusContextTarget;
if (!target) return;
closeStatusContextMenu();
onStash(target.files, target.label);
}
function isIgnoreableNewFile(file: GitFileStatus): boolean {
const path = file.path.replace(/\\/g, "/").toLowerCase();
if (path === ".gitignore") return false;
return file.unstaged === "untracked" || (file.staged === "added" && file.old_path === null);
}
function isTrackedStatusFile(file: GitFileStatus): boolean {
return file.unstaged !== "untracked" && file.staged !== "deleted";
}
function statusContextExtension(label: string): string {
const name = statusContextName(label);
const separator = name.lastIndexOf(".");
return separator > 0 && separator < name.length - 1 ? name.slice(separator + 1) : "";
}
function statusContextFolder(target: StatusContextTarget): string {
if (target.kind === "folder") return target.label.replace(/\\/g, "/").replace(/\/+$/, "");
const normalized = target.label.replace(/\\/g, "/");
const separator = normalized.lastIndexOf("/");
return separator > 0 ? normalized.slice(0, separator) : "";
}
function runStatusContextIgnoreAction(kind: GitIgnoreKind) {
const target = statusContextTarget;
if (!target) return;
const ignoreTarget = kind === "folder" ? statusContextFolder(target) : target.label;
if (!ignoreTarget) return;
closeStatusContextMenu();
onIgnore(ignoreTarget, kind);
}
function runStatusContextStopTracking() {
const target = statusContextTarget;
if (!target) return;
closeStatusContextMenu();
onStopTracking(target.label, target.kind);
}
function handleStatusWindowKeydown(event: KeyboardEvent) {
if (event.key === "Escape") closeStatusContextMenu();
}
function isStatusSelected(file: GitFileStatus): boolean {
return selectedStatusPaths.has(fileKey(file));
}
function selectedFiles(): GitFileStatus[] {
return changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)));
}
function selectedStageTargets(file: GitFileStatus): GitFileStatus[] {
const files = isStatusSelected(file) ? selectedFiles() : [file];
return files.filter((item) => item.unstaged !== null);
}
function selectedUnstageTargets(file: GitFileStatus): GitFileStatus[] {
const files = isStatusSelected(file) ? selectedFiles() : [file];
return files.filter((item) => item.staged !== null);
}
function handleFileSelect(event: MouseEvent, file: GitFileStatus) {
const key = fileKey(file);
const allKeys = changedFiles.map(fileKey);
const next = new Set(selectedStatusPaths);
if (event.shiftKey && selectionAnchorKey) {
const anchorIndex = allKeys.indexOf(selectionAnchorKey);
const currentIndex = allKeys.indexOf(key);
if (anchorIndex >= 0 && currentIndex >= 0) {
const start = Math.min(anchorIndex, currentIndex);
const end = Math.max(anchorIndex, currentIndex);
for (const itemKey of allKeys.slice(start, end + 1)) next.add(itemKey);
} else {
next.add(key);
}
} else if (event.ctrlKey || event.metaKey) {
if (next.has(key)) next.delete(key);
else next.add(key);
selectionAnchorKey = key;
} else {
next.clear();
next.add(key);
selectionAnchorKey = key;
}
selectedStatusPaths = next;
onSelectFile(file);
}
function stageFromFile(file: GitFileStatus) {
const targets = selectedStageTargets(file);
if (targets.length === 0) return;
onStage(targets);
}
function unstageFromFile(file: GitFileStatus) {
const targets = selectedUnstageTargets(file);
if (targets.length === 0) return;
onUnstage(targets);
}
// Discard mirrors the stage/unstage target selection: if the clicked row is
// part of the current multi-selection, the whole selection (filtered to the
// relevant lane) is discarded; otherwise just that one file.
function discardStagedFromFile(file: GitFileStatus) {
const targets = selectedUnstageTargets(file);
if (targets.length === 0) return;
onDiscard(targets, true);
}
function discardUnstagedFromFile(file: GitFileStatus) {
const targets = selectedStageTargets(file);
if (targets.length === 0) return;
onDiscard(targets, false);
}
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
let unstagedFiles = $derived(changedFiles.filter((file) => file.unstaged !== null));
let stagedFiles = $derived(changedFiles.filter((file) => file.staged !== null));
let unstagedTree = $derived(buildStatusTree(unstagedFiles));
let stagedTree = $derived(buildStatusTree(stagedFiles));
let visibleUnstagedRows = $derived(statusView === "tree" ? flattenStatusTree(unstagedTree, "unstaged") : listStatusRows(unstagedFiles));
let visibleStagedRows = $derived(statusView === "tree" ? flattenStatusTree(stagedTree, "staged") : listStatusRows(stagedFiles));
let selectedUnstagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.unstaged !== null).length);
let selectedStagedCount = $derived(changedFiles.filter((f) => selectedStatusPaths.has(fileKey(f)) && f.staged !== null).length);
let statusContextCanIgnore = $derived(statusContextTarget?.files.some(isIgnoreableNewFile) ?? false);
let statusContextCanStopTracking = $derived(statusContextTarget?.files.some(isTrackedStatusFile) ?? false);
let statusContextIgnoreExtension = $derived(statusContextTarget?.kind === "file" ? statusContextExtension(statusContextTarget.label) : "");
let statusContextIgnoreFolder = $derived(statusContextTarget ? statusContextFolder(statusContextTarget) : "");
$effect(() => {
const validKeys = new Set(changedFiles.map(fileKey));
const next = new Set([...selectedStatusPaths].filter((key) => validKeys.has(key)));
if (next.size !== selectedStatusPaths.size) selectedStatusPaths = next;
if (selectionAnchorKey && !validKeys.has(selectionAnchorKey)) selectionAnchorKey = "";
});
</script>
<svelte:window onclick={closeStatusContextMenu} onkeydown={handleStatusWindowKeydown} />
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
<div class="section-head">
<div>
<span class="eyebrow">Workspace</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
</div>
<div class="status-view-switch" role="group" aria-label="Changes view">
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title="List view" aria-label="List view" aria-pressed={statusView === "list"}>
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>List</span>
</button>
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title="Tree view" aria-label="Tree view" aria-pressed={statusView === "tree"}>
<FolderTree size={13} aria-hidden="true" /><span>Tree</span>
</button>
</div>
<div class="status-head-actions">
<span class="pill pill-count">{stagedCount} staged</span>
<span class="pill pill-count">{unstagedCount} unstaged</span>
{#if hasRepository && changedFiles.length > 0}
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title="Discard all staged and unstaged changes">
<RotateCcw size={13} aria-hidden="true" /> Discard all
</button>
{/if}
</div>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else if status?.clean}
<div class="blank-state">Working tree is clean.</div>
{:else if changedFiles.length === 0}
<div class="blank-state">No file changes returned.</div>
{:else}
<div class="status-lanes">
<section class="status-lane unstaged-lane" aria-label="Unstaged changes">
<header class="status-lane-head">
<div class="status-lane-title">
<div class="status-lane-copy"><strong>Unstaged</strong><small>Working tree</small></div>
<span class="status-lane-count">{unstagedCount}</span>
</div>
<div class="status-lane-actions">
{#if selectedUnstagedCount > 1}
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
<ArrowRight size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
</button>
{/if}
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
<ArrowRight size={13} aria-hidden="true" /> Stage all
</button>
{#if selectedUnstagedCount > 1}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedUnstagedCount}
</button>
{/if}
</div>
</header>
<div class="status-file-list">
{#each visibleUnstagedRows as row (`unstaged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
{#if row.kind === "folder"}
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("unstaged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "folder", row.path, filesInStatusFolder("unstaged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)}>
<i class:expanded={!collapsedStatusFolders.has(`unstaged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
{#if collapsedStatusFolders.has(`unstaged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
<strong>{row.name}</strong>
<span class="status-folder-count">{row.fileCount}</span>
</button>
{:else}
{@const file = row.file}
{@const stageTargets = selectedStageTargets(file)}
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "file", file.path, [file])}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><ArrowRight size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/if}
{/each}
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
</div>
</section>
<div class="status-flow-divider" aria-hidden="true">
<span><ArrowRight size={12} /></span>
</div>
<section class="status-lane staged-lane" aria-label="Staged changes">
<header class="status-lane-head">
<div class="status-lane-title">
<div class="status-lane-copy"><strong>Staged</strong><small>Next commit</small></div>
<span class="status-lane-count">{stagedCount}</span>
</div>
<div class="status-lane-actions">
{#if selectedStagedCount > 1}
<span class="status-selection-count">{selectedStagedCount} selected</span>
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
<ArrowLeft size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
</button>
{/if}
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
<ArrowLeft size={13} aria-hidden="true" /> Unstage all
</button>
{#if selectedStagedCount > 1}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedStagedCount}
</button>
{/if}
</div>
</header>
<div class="status-file-list">
{#each visibleStagedRows as row (`staged:${row.kind}:${row.kind === "file" ? fileKey(row.file) : row.path}`)}
{#if row.kind === "folder"}
<button class="status-folder-row" type="button" style={`--tree-indent: ${row.depth * 15}px`} onclick={() => toggleStatusFolder("staged", row.path)} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "folder", row.path, filesInStatusFolder("staged", row.path))} title={row.path} aria-expanded={!collapsedStatusFolders.has(`staged:${row.path}`)}>
<i class:expanded={!collapsedStatusFolders.has(`staged:${row.path}`)} class="status-tree-chevron" aria-hidden="true"></i>
{#if collapsedStatusFolders.has(`staged:${row.path}`)}<Folder class="status-folder-lucide" size={15} aria-hidden="true" />{:else}<FolderOpen class="status-folder-lucide" size={15} aria-hidden="true" />{/if}
<strong>{row.name}</strong>
<span class="status-folder-count">{row.fileCount}</span>
</button>
{:else}
{@const file = row.file}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "file", file.path, [file])}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><ArrowLeft size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/if}
{/each}
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
</div>
</section>
</div>
{/if}
{#if isBusy && hasRepository}
<div class="status-panel-overlay" role="status" aria-live="polite">
<div class="status-panel-overlay-card">
<div class="status-panel-overlay-mark">
<span class="status-panel-overlay-halo halo-one"></span>
<span class="status-panel-overlay-halo halo-two"></span>
<svg class="status-panel-overlay-traces" viewBox="0 0 220 220" aria-hidden="true">
<path class="trace trace-main" d="M28 154 C72 114, 88 108, 110 110 S156 116, 192 68" />
<path class="trace trace-branch" d="M62 74 C100 88, 126 124, 158 166" />
<path class="trace trace-cut" d="M46 180 L174 180" />
</svg>
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
</div>
<span class="status-panel-overlay-label">{operation || "Working"}</span>
<div class="status-panel-overlay-bar"><span></span></div>
</div>
</div>
{/if}
</section>
{#if statusContextTarget}
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
<div class="status-context-label">
<span class="status-context-object-icon" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
</span>
<span class="status-context-object-copy">
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? "Unstaged" : "Staged"} {statusContextTarget.kind}</span>
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong>
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
</span>
<span class="status-context-count" title={`${statusContextTarget.files.length} ${statusContextTarget.files.length === 1 ? "file" : "files"}`}>
{statusContextTarget.files.length}
</span>
</div>
<button type="button" role="menuitem" onclick={runStatusContextStageAction} disabled={isBusy}>
<span class="status-context-action-icon" aria-hidden="true">
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
</span>
<span class="status-context-action-copy">
<strong>{statusContextTarget.lane === "unstaged" ? "Stage" : "Unstage"} {statusContextTarget.kind}</strong>
<span>{statusContextTarget.lane === "unstaged" ? "Add to the next commit" : "Move back to working changes"}</span>
</span>
</button>
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
<span class="status-context-action-icon" aria-hidden="true"><Archive size={15} /></span>
<span class="status-context-action-copy">
<strong>Stash {statusContextTarget.kind}</strong>
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
</span>
</button>
{#if statusContextCanIgnore || statusContextCanStopTracking}
<div class="menu-separator" role="separator"></div>
{/if}
{#if statusContextCanStopTracking}
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
<span class="status-context-action-icon untrack" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
</span>
<span class="status-context-action-copy">
<strong>Stop tracking {statusContextTarget.kind}</strong>
<span>Keep it on disk and remove it from Git</span>
</span>
</button>
{/if}
{#if statusContextCanIgnore}
{#if statusContextTarget.kind === "file"}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={`Add /${statusContextTarget.label.replace(/\\/g, "/")} to .gitignore`}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FileX size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore file</strong>
<span>Add only this file to .gitignore</span>
</span>
</button>
{/if}
{#if statusContextIgnoreExtension}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={`Add *.${statusContextIgnoreExtension} to .gitignore`}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FileType size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore all *.{statusContextIgnoreExtension} files</strong>
<span>Match this file type repository-wide</span>
</span>
</button>
{/if}
{#if statusContextTarget.kind === "folder" && statusContextIgnoreFolder}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={`Add /${statusContextIgnoreFolder}/ to .gitignore`}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FolderX size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore folder</strong>
<span>Add this folder and its contents to .gitignore</span>
</span>
</button>
{/if}
{/if}
</div>
{/if}
<style>
.status-panel-overlay {
position: absolute;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
background:
radial-gradient(
circle at 50% 38%,
color-mix(in srgb, var(--color-accent) 13%, transparent),
transparent 55%
),
var(--app-dialog-backdrop);
backdrop-filter: blur(4px);
animation: status-panel-overlay-in 120ms ease;
}
.status-panel-overlay-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: min(300px, calc(100% - 32px));
padding: 26px 28px 26px;
border: 1px solid var(--color-border-input);
border-radius: 16px;
background:
var(--app-panel-highlight),
var(--app-dialog-bg);
color: var(--color-ink);
box-shadow: var(--app-dialog-shadow);
}
.status-panel-overlay-mark {
position: relative;
width: 104px;
height: 104px;
display: grid;
place-items: center;
isolation: isolate;
}
.status-panel-overlay-halo {
position: absolute;
inset: 6px;
border: 1px solid color-mix(in srgb, var(--color-primary) 34%, transparent);
border-radius: 22px;
transform: rotate(45deg);
}
.status-panel-overlay-halo.halo-one {
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite;
}
.status-panel-overlay-halo.halo-two {
inset: 16px;
border-color: color-mix(in srgb, var(--color-accent) 38%, transparent);
animation: status-panel-overlay-halo-breathe 2.4s ease-in-out infinite reverse;
}
.status-panel-overlay-traces {
position: absolute;
inset: -12px;
width: 128px;
height: 128px;
overflow: visible;
z-index: 0;
}
.status-panel-overlay-traces .trace {
fill: none;
stroke-width: 3;
stroke-linecap: round;
stroke-dasharray: 165;
stroke-dashoffset: 165;
filter: drop-shadow(0 0 6px color-mix(in srgb, var(--color-accent) 38%, transparent));
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
}
.status-panel-overlay-traces .trace-main { stroke: var(--color-primary); }
.status-panel-overlay-traces .trace-branch {
stroke: var(--color-accent);
animation-delay: 0.28s;
}
.status-panel-overlay-traces .trace-cut {
stroke: color-mix(in srgb, var(--color-ink-muted) 52%, transparent);
stroke-dasharray: 128;
stroke-dashoffset: 128;
animation-delay: 0.55s;
}
.status-panel-overlay-icon {
position: relative;
z-index: 1;
width: 64px;
height: 64px;
object-fit: contain;
filter:
drop-shadow(0 8px 12px color-mix(in srgb, var(--color-ink) 24%, transparent))
drop-shadow(0 0 10px color-mix(in srgb, var(--color-accent) 24%, transparent));
animation: status-panel-overlay-icon-float 2.4s ease-in-out infinite;
}
.status-panel-overlay-label {
max-width: 100%;
overflow: hidden;
color: var(--color-ink);
font-size: 14px;
font-weight: 700;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-panel-overlay-bar {
position: relative;
width: min(190px, 100%);
height: 4px;
overflow: hidden;
border-radius: 999px;
border: 1px solid var(--color-border-subtle);
background: color-mix(in srgb, var(--color-primary) 12%, var(--color-surface-dim));
}
.status-panel-overlay-bar span {
position: absolute;
inset: 0;
width: 46%;
border-radius: inherit;
background: linear-gradient(90deg, transparent, var(--color-primary) 40%, var(--color-accent) 74%, transparent);
box-shadow: 0 0 12px color-mix(in srgb, var(--color-accent) 32%, transparent);
animation: status-panel-overlay-bar-slide 1.35s ease-in-out infinite;
}
@keyframes status-panel-overlay-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes status-panel-overlay-icon-float {
0%, 100% { transform: translateY(0) scale(1); }
50% { transform: translateY(-3px) scale(1.015); }
}
@keyframes status-panel-overlay-halo-breathe {
0%, 100% { opacity: 0.35; transform: rotate(45deg) scale(0.95); }
50% { opacity: 0.8; transform: rotate(45deg) scale(1.04); }
}
@keyframes status-panel-overlay-bar-slide {
0% { transform: translateX(-120%); }
100% { transform: translateX(320%); }
}
@keyframes status-panel-overlay-trace-draw {
0% { stroke-dashoffset: 165; opacity: 0; }
36% { opacity: 1; }
64%, 100% { stroke-dashoffset: 0; opacity: 0.72; }
}
@media (prefers-reduced-motion: reduce) {
.status-panel-overlay,
.status-panel-overlay-halo,
.status-panel-overlay-traces .trace,
.status-panel-overlay-icon,
.status-panel-overlay-bar span { animation: none; }
.status-panel-overlay-traces .trace { stroke-dashoffset: 0; opacity: 0.72; }
}
</style>