The changes introduce server-side commands to manage gitignore rules and to untrack paths without deleting local files. A new GitIgnoreKind enum and helper functions normalize targets and build proper ignore patterns, and UI code was wired to use these commands. - add_to_gitignore command and related helpers - untrack_paths command to remove paths from the index - UI wiring to expose ignore and untrack actions in explorer
808 lines
35 KiB
Svelte
808 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%, rgba(111, 140, 255, 0.1), transparent 55%),
|
|
rgba(7, 10, 16, 0.62);
|
|
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 rgba(90, 111, 154, 0.28);
|
|
border-radius: 16px;
|
|
background:
|
|
linear-gradient(180deg, rgba(23, 29, 43, 0.92), rgba(12, 17, 27, 0.94)),
|
|
var(--color-surface-raised);
|
|
color: var(--color-ink);
|
|
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
|
}
|
|
|
|
.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 rgba(111, 140, 255, 0.22);
|
|
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: rgba(77, 182, 214, 0.24);
|
|
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 rgba(77, 182, 214, 0.32));
|
|
animation: status-panel-overlay-trace-draw 2.6s ease-in-out infinite;
|
|
}
|
|
.status-panel-overlay-traces .trace-main { stroke: #6f8cff; }
|
|
.status-panel-overlay-traces .trace-branch {
|
|
stroke: #4db6d6;
|
|
animation-delay: 0.28s;
|
|
}
|
|
.status-panel-overlay-traces .trace-cut {
|
|
stroke: rgba(177, 186, 208, 0.42);
|
|
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 rgba(0, 0, 0, 0.5))
|
|
drop-shadow(0 0 10px rgba(77, 182, 214, 0.2));
|
|
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;
|
|
background: rgba(111, 140, 255, 0.14);
|
|
}
|
|
.status-panel-overlay-bar span {
|
|
position: absolute;
|
|
inset: 0;
|
|
width: 46%;
|
|
border-radius: inherit;
|
|
background: linear-gradient(90deg, transparent, #6f8cff 40%, #4db6d6 74%, transparent);
|
|
box-shadow: 0 0 12px rgba(77, 182, 214, 0.26);
|
|
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>
|