This commit introduces a comprehensive overhaul of the application's UI, focusing on modernizing the global workspace status bar and improving overall theming consistency. Several components were refactored to simplify prop handling and improve separation of concerns, particularly within the TitleBar and RepoToolbar. The CSS includes extensive new variables and styles for better visual fidelity across light and dark themes. - Overhauled the main application footer to display version, branch status, and sync metrics. - Added global CSS variables and component styling for a modern look. - Simplified repository state management by removing redundant props from TitleBar.
222 lines
7.5 KiB
Svelte
222 lines
7.5 KiB
Svelte
<script lang="ts">
|
|
import { LoaderCircle, X } from "@lucide/svelte";
|
|
import type { GitFileStatus, PatchApplyAction } from "../types";
|
|
|
|
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
|
|
|
interface PatchLine {
|
|
id: string;
|
|
text: string;
|
|
kind: PatchLineKind;
|
|
}
|
|
|
|
interface PatchHunk {
|
|
id: string;
|
|
header: string;
|
|
lines: PatchLine[];
|
|
}
|
|
|
|
interface ParsedPatch {
|
|
headerLines: string[];
|
|
hunks: PatchHunk[];
|
|
binary: boolean;
|
|
}
|
|
|
|
interface Props {
|
|
file: GitFileStatus;
|
|
staged: boolean;
|
|
patch: string;
|
|
isBusy: boolean;
|
|
isLoading: boolean;
|
|
error: string;
|
|
onClose: () => void;
|
|
onRefresh: () => void | Promise<void>;
|
|
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>;
|
|
}
|
|
|
|
let {
|
|
file,
|
|
staged = false,
|
|
patch = "",
|
|
isBusy = false,
|
|
isLoading = false,
|
|
error = "",
|
|
onClose = () => {},
|
|
onRefresh = () => {},
|
|
onApply = () => {},
|
|
}: Props = $props();
|
|
|
|
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
|
let patchScroll = $state<HTMLDivElement | null>(null);
|
|
|
|
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
|
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
|
|
|
$effect(() => {
|
|
parsed = parsePatch(patch);
|
|
});
|
|
|
|
function parsePatch(input: string): ParsedPatch {
|
|
const normalized = input.replace(/\r\n/g, "\n");
|
|
const lines = normalized.split("\n");
|
|
if (lines[lines.length - 1] === "") lines.pop();
|
|
|
|
const headerLines: string[] = [];
|
|
const hunks: PatchHunk[] = [];
|
|
let current: PatchHunk | null = null;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("@@ ")) {
|
|
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
|
|
hunks.push(current);
|
|
continue;
|
|
}
|
|
|
|
if (!current) {
|
|
headerLines.push(line);
|
|
continue;
|
|
}
|
|
|
|
const kind = patchLineKind(line);
|
|
current.lines.push({
|
|
id: `${current.id}-line-${current.lines.length}`,
|
|
text: line,
|
|
kind,
|
|
});
|
|
}
|
|
|
|
return {
|
|
headerLines,
|
|
hunks,
|
|
binary: /(^|\n)(Binary files|GIT binary patch|literal \d+)/.test(normalized),
|
|
};
|
|
}
|
|
|
|
function patchLineKind(line: string): PatchLineKind {
|
|
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
|
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
|
|
if (line.startsWith(" ")) return "context";
|
|
return "meta";
|
|
}
|
|
|
|
function linePrefix(line: PatchLine): string {
|
|
if (line.kind === "add") return "+";
|
|
if (line.kind === "delete") return "-";
|
|
if (line.kind === "meta") return "\\";
|
|
return " ";
|
|
}
|
|
|
|
function lineBody(line: PatchLine): string {
|
|
if (line.kind === "meta") return line.text;
|
|
return line.text.slice(1);
|
|
}
|
|
|
|
function buildHunkPatch(hunk: PatchHunk): string {
|
|
return `${[...parsed.headerLines, hunk.header, ...hunk.lines.map((line) => line.text)].join("\n")}\n`;
|
|
}
|
|
|
|
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
|
|
if (isBusy || isLoading) return;
|
|
await onApply(action, buildHunkPatch(hunk));
|
|
}
|
|
|
|
function hunkPosition(index: number): number {
|
|
const totalLines = parsed.hunks.reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
|
const precedingLines = parsed.hunks.slice(0, index).reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
|
return (precedingLines / Math.max(totalLines - 1, 1)) * 100;
|
|
}
|
|
|
|
function hunkKind(hunk: PatchHunk): "add" | "delete" | "mixed" {
|
|
const hasAdd = hunk.lines.some((line) => line.kind === "add");
|
|
const hasDelete = hunk.lines.some((line) => line.kind === "delete");
|
|
return hasAdd && hasDelete ? "mixed" : hasAdd ? "add" : "delete";
|
|
}
|
|
|
|
function scrollToHunk(hunkId: string) {
|
|
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
|
|
if (patchScroll && target) patchScroll.scrollTop = Math.max(target.offsetTop - 8, 0);
|
|
}
|
|
</script>
|
|
|
|
<div class="dialog-backdrop" role="presentation">
|
|
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label="Line patch">
|
|
<header class="dialog-header">
|
|
<div>
|
|
<span class="eyebrow">{scopeLabel}</span>
|
|
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
|
</div>
|
|
<div class="dialog-header-actions">
|
|
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
|
|
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
|
<X size={16} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div class="line-patch-body">
|
|
{#if isLoading}
|
|
<div class="blank-state">
|
|
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
|
Loading patch...
|
|
</div>
|
|
{:else if error}
|
|
<div class="blank-state">{error}</div>
|
|
{:else if !patch.trim()}
|
|
<div class="blank-state">No line patch available for this file.</div>
|
|
{:else if parsed.binary || parsed.hunks.length === 0}
|
|
<div class="blank-state">This change cannot be split into text lines.</div>
|
|
{:else}
|
|
<div class="line-patch-workspace">
|
|
<div class="line-patch-scroll" bind:this={patchScroll}>
|
|
{#each parsed.hunks as hunk (hunk.id)}
|
|
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
|
<div class="line-patch-hunk-head">
|
|
<code>{hunk.header}</code>
|
|
<div class="line-patch-hunk-actions">
|
|
{#if staged}
|
|
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
|
|
Discard Hunk
|
|
</button>
|
|
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
|
|
Unstage Hunk
|
|
</button>
|
|
{:else}
|
|
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
|
|
Discard Hunk
|
|
</button>
|
|
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
|
|
Stage Hunk
|
|
</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="line-patch-lines">
|
|
{#each hunk.lines as line (line.id)}
|
|
<div class={`line-patch-row ${line.kind}`}>
|
|
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
|
<code>{lineBody(line)}</code>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/each}
|
|
</div>
|
|
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
|
|
{#each parsed.hunks as hunk, index (hunk.id)}
|
|
<button
|
|
class="diff-overview-marker {hunkKind(hunk)}"
|
|
type="button"
|
|
style={`--marker-position: ${hunkPosition(index)}%`}
|
|
onclick={() => scrollToHunk(hunk.id)}
|
|
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
|
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
|
></button>
|
|
{/each}
|
|
</nav>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|