edit
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"enabledPlugins": {
|
||||
"frontend-design@claude-plugins-official": true
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,21 @@
|
||||
"Bash(cargo build *)",
|
||||
"Bash(npm run *)",
|
||||
"Bash(kill %1)",
|
||||
"Bash(perl -0pi -e 's/\\\\{line \\\\|\\\\| \" \"\\\\}/{displayLine\\(line\\) || \" \"}/g' src/App.svelte)"
|
||||
"Bash(perl -0pi -e 's/\\\\{line \\\\|\\\\| \" \"\\\\}/{displayLine\\(line\\) || \" \"}/g' src/App.svelte)",
|
||||
"Bash(kill 26306)",
|
||||
"Bash(convert --version)",
|
||||
"Bash(magick /mnt/data/Development/GitLite/src-tauri/icons/icon.ico /mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
|
||||
"Bash(magick /mnt/data/Development/GitLite/src-tauri/icons/icon.ico -type TrueColorAlpha -alpha on PNG32:/mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
|
||||
"Bash(magick identify *)",
|
||||
"Bash(magick -size 512x512 xc:\"rgba\\(70,130,180,255\\)\" PNG32:/mnt/data/Development/GitLite/src-tauri/icons/icon.png)",
|
||||
"Bash(pkill -f \"vite --host\")",
|
||||
"Bash(pkill -f \"tauri dev\")",
|
||||
"Bash(pkill -f \"tauri_git_lite\")",
|
||||
"Bash(xargs kill -9)",
|
||||
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/b4ujhgmdk.output)",
|
||||
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/bt54rikhh.output)",
|
||||
"Bash(grep -qE \"\\(Running \\\\`target|error\\\\[|panicked|terminated\\)\" /tmp/claude-1000/-mnt-data-Development-GitLite/71a8b49f-ac61-4ad9-aaba-dc0219044038/tasks/b7nyub68s.output)",
|
||||
"Bash(npm install *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,4 +2,5 @@
|
||||
/dist
|
||||
/src-tauri/target
|
||||
*.log
|
||||
.idea
|
||||
.DS_Store
|
||||
|
||||
Generated
+589
-63
File diff suppressed because it is too large
Load Diff
+3
-1
@@ -14,8 +14,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/svelte": "^1.21.0",
|
||||
"@tailwindcss/vite": "^4.3.1",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"svelte": "^5.0.0"
|
||||
"svelte": "^5.0.0",
|
||||
"tailwindcss": "^4.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
|
||||
@@ -3,5 +3,13 @@
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for the desktop window",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-unminimize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.4 KiB |
@@ -13,11 +13,13 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "Tauri Git Lite",
|
||||
"title": "GitLite",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 900,
|
||||
"minHeight": 600
|
||||
"minHeight": 600,
|
||||
"decorations": false,
|
||||
"shadow": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
+389
-1919
File diff suppressed because it is too large
Load Diff
+769
-1550
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { Download, GitBranch, LoaderCircle, Minus, RefreshCw, Upload, X } from "@lucide/svelte";
|
||||
|
||||
export let branch: string = "";
|
||||
export let ahead: number = 0;
|
||||
export let behind: number = 0;
|
||||
export let repoName: string = "";
|
||||
export let hasRepository: boolean = false;
|
||||
export let isBusy: boolean = false;
|
||||
export let operation: string = "";
|
||||
export let autoRefreshEnabled: boolean = true;
|
||||
export let autoRefreshInFlight: boolean = false;
|
||||
export let onPull: () => void = () => {};
|
||||
export let onPush: () => void = () => {};
|
||||
export let onRefresh: () => void = () => {};
|
||||
export let onToggleAutoRefresh: () => void = () => {};
|
||||
|
||||
const win = getCurrentWindow();
|
||||
let isMaximized = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
isMaximized = await win.isMaximized();
|
||||
unlisten = await win.onResized(async () => {
|
||||
isMaximized = await win.isMaximized();
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unlisten?.();
|
||||
});
|
||||
</script>
|
||||
|
||||
<header class="titlebar" data-tauri-drag-region>
|
||||
<!-- Brand -->
|
||||
<div class="titlebar-brand" data-tauri-drag-region>
|
||||
<svg
|
||||
width="15"
|
||||
height="15"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<circle cx="12" cy="18" r="3" />
|
||||
<circle cx="6" cy="6" r="3" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<path d="M18 9v1a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V9" />
|
||||
<line x1="12" y1="12" x2="12" y2="15" />
|
||||
</svg>
|
||||
<span data-tauri-drag-region>GitLite</span>
|
||||
</div>
|
||||
|
||||
<!-- Center: repo + branch info -->
|
||||
<div class="titlebar-info" data-tauri-drag-region>
|
||||
{#if hasRepository}
|
||||
{#if repoName}
|
||||
<span class="tb-repo" data-tauri-drag-region>{repoName}</span>
|
||||
<span class="tb-sep" data-tauri-drag-region aria-hidden="true">/</span>
|
||||
{/if}
|
||||
<GitBranch size={12} aria-hidden="true" />
|
||||
<span class="tb-branch" data-tauri-drag-region>{branch}</span>
|
||||
{#if ahead > 0}
|
||||
<span class="tb-sync ahead" title="{ahead} commits ahead">↑{ahead}</span>
|
||||
{/if}
|
||||
{#if behind > 0}
|
||||
<span class="tb-sync behind" title="{behind} commits behind">↓{behind}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="tb-no-repo" data-tauri-drag-region>No repository open</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right: actions + window controls -->
|
||||
<div class="titlebar-right">
|
||||
<div class="titlebar-actions" role="toolbar" aria-label="Repository actions">
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onPull}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Pull"
|
||||
aria-label="Pull"
|
||||
>
|
||||
{#if operation === "Pulling"}
|
||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<Download size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="tb-action-label">Pull</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onPush}
|
||||
disabled={!hasRepository || isBusy}
|
||||
title="Push"
|
||||
aria-label="Push"
|
||||
>
|
||||
{#if operation === "Pushing"}
|
||||
<LoaderCircle class="spin" size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<Upload size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
<span class="tb-action-label">Push</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action"
|
||||
onclick={onRefresh}
|
||||
disabled={isBusy || !hasRepository}
|
||||
title="Refresh"
|
||||
aria-label="Refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
class={operation === "Refreshing" ? "spin" : ""}
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="tb-action-label">Refresh</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="tb-action auto-toggle"
|
||||
class:active={autoRefreshEnabled}
|
||||
onclick={onToggleAutoRefresh}
|
||||
aria-pressed={autoRefreshEnabled}
|
||||
title={autoRefreshEnabled ? "Auto-refresh on — click to disable" : "Auto-refresh off — click to enable"}
|
||||
aria-label="Toggle auto-refresh"
|
||||
>
|
||||
<RefreshCw
|
||||
class={autoRefreshInFlight ? "spin" : ""}
|
||||
size={14}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span class="tb-action-label">Auto</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="titlebar-divider" aria-hidden="true"></div>
|
||||
|
||||
<div class="titlebar-controls">
|
||||
<button class="tb-btn" onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
|
||||
<Minus size={12} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="tb-btn"
|
||||
onclick={() => win.toggleMaximize()}
|
||||
title={isMaximized ? "Restore" : "Maximize"}
|
||||
aria-label={isMaximized ? "Restore" : "Maximize"}
|
||||
>
|
||||
{#if isMaximized}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="3" y="1" width="8" height="8" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
<path d="M1 3v7a1 1 0 0 0 1 1h7" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||
</svg>
|
||||
{:else}
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
|
||||
<rect x="1" y="1" width="10" height="10" rx="1" stroke="currentColor" stroke-width="1.5" />
|
||||
</svg>
|
||||
{/if}
|
||||
</button>
|
||||
<button class="tb-btn close" onclick={() => win.close()} title="Close" aria-label="Close">
|
||||
<X size={12} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -0,0 +1,107 @@
|
||||
<script lang="ts">
|
||||
import { GitBranch, GitMerge } from "@lucide/svelte";
|
||||
import type { GitBranch as GitBranchInfo } from "../types";
|
||||
|
||||
interface Props {
|
||||
branches: GitBranchInfo[];
|
||||
localBranches: GitBranchInfo[];
|
||||
remoteBranches: GitBranchInfo[];
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
onCheckout: (branch: GitBranchInfo) => void;
|
||||
onMerge: (branch: GitBranchInfo) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
branches = [],
|
||||
localBranches = [],
|
||||
remoteBranches = [],
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
onCheckout = () => {},
|
||||
onMerge = () => {},
|
||||
}: Props = $props();
|
||||
</script>
|
||||
|
||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Branches</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
|
||||
</div>
|
||||
<span class="pill pill-count">{branches.length}</span>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<p class="blank-state">Open a repository to list branches.</p>
|
||||
{:else if branches.length === 0}
|
||||
<p class="blank-state">No branches returned.</p>
|
||||
{:else}
|
||||
<div class="overflow-auto p-2 flex flex-col gap-0">
|
||||
{#if localBranches.length > 0}
|
||||
<div class="branch-group-label">
|
||||
<span>Local</span>
|
||||
<span class="branch-group-count">{localBranches.length}</span>
|
||||
</div>
|
||||
{#each localBranches as branch (branch.name)}
|
||||
{#snippet branchCard()}
|
||||
<article class="branch-row" class:current={branch.current}>
|
||||
<div class="branch-info">
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{branch.name}</strong>
|
||||
<span>local</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{:else}
|
||||
<div class="branch-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
|
||||
Checkout
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
|
||||
<GitMerge size={15} aria-hidden="true" />
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/snippet}
|
||||
{@render branchCard()}
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
{#if remoteBranches.length > 0}
|
||||
<div class="branch-group-label" style="margin-top: {localBranches.length > 0 ? '12px' : '0'}">
|
||||
<span>Remote</span>
|
||||
<span class="branch-group-count">{remoteBranches.length}</span>
|
||||
</div>
|
||||
{#each remoteBranches as branch (branch.name)}
|
||||
<article class="branch-row" class:current={branch.current}>
|
||||
<div class="branch-info">
|
||||
<GitBranch size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{branch.name}</strong>
|
||||
<span>remote</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if branch.current}
|
||||
<span class="pill pill-active">Current</span>
|
||||
{:else}
|
||||
<div class="branch-actions">
|
||||
<button class="btn-sm" type="button" onclick={() => onCheckout(branch)} disabled={isBusy}>
|
||||
Checkout
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onMerge(branch)} disabled={isBusy} title="Merge into current">
|
||||
<GitMerge size={15} aria-hidden="true" />
|
||||
Merge
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script lang="ts">
|
||||
import { Check, LoaderCircle } from "@lucide/svelte";
|
||||
|
||||
interface Props {
|
||||
commitMessage: string;
|
||||
canCommit: boolean;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
operation: string;
|
||||
stagedCount: number;
|
||||
onCommit: () => void;
|
||||
onCommitMessageChange: (msg: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commitMessage = "",
|
||||
canCommit = false,
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
operation = "",
|
||||
stagedCount = 0,
|
||||
onCommit = () => {},
|
||||
onCommitMessageChange = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
onCommit();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel flex flex-col" aria-label="Commit">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Commit</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Message</h2>
|
||||
</div>
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
</div>
|
||||
|
||||
<form class="commit-form" onsubmit={handleSubmit}>
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
oninput={(e) => onCommitMessageChange((e.target as HTMLTextAreaElement).value)}
|
||||
placeholder="Commit message..."
|
||||
disabled={!hasRepository || isBusy}
|
||||
></textarea>
|
||||
<button class="btn-primary w-full flex-shrink-0" type="submit" disabled={!canCommit}>
|
||||
{#if operation === "Committing"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Commit
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, X } from "@lucide/svelte";
|
||||
import type { GitCommitComparison, GitDiffFile, FileStatusKind } from "../types";
|
||||
|
||||
type DiffLineKind = "meta" | "hunk" | "add" | "del" | "context";
|
||||
|
||||
interface Props {
|
||||
comparison: GitCommitComparison;
|
||||
selectedDiffPath: string;
|
||||
isBusy: boolean;
|
||||
onClose: () => void;
|
||||
onSelectFile: (file: GitDiffFile) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
comparison,
|
||||
selectedDiffPath = "",
|
||||
isBusy = false,
|
||||
onClose = () => {},
|
||||
onSelectFile = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function statusLabel(kind: FileStatusKind): string {
|
||||
return kind;
|
||||
}
|
||||
|
||||
function displayDiffFile(file: GitDiffFile): string {
|
||||
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
||||
}
|
||||
|
||||
function diffLineKind(line: string): DiffLineKind {
|
||||
if (line.startsWith("diff ") || line.startsWith("index ") || line.startsWith("--- ") ||
|
||||
line.startsWith("+++ ") || line.startsWith("new file") || line.startsWith("deleted file") ||
|
||||
line.startsWith("rename ") || line.startsWith("similarity ")) return "meta";
|
||||
if (line.startsWith("@@")) return "hunk";
|
||||
if (line.startsWith("+")) return "add";
|
||||
if (line.startsWith("-")) return "del";
|
||||
return "context";
|
||||
}
|
||||
|
||||
function diffLines(patch: string): { kind: DiffLineKind; text: string }[] {
|
||||
return patch.replace(/\n$/, "").split("\n").map((text) => ({ kind: diffLineKind(text), text }));
|
||||
}
|
||||
|
||||
function stripDiffPathPrefix(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === "/dev/null") return trimmed;
|
||||
if (trimmed.startsWith("a/") || trimmed.startsWith("b/")) return trimmed.slice(2);
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function patchFilePath(segment: string): string {
|
||||
let plusPath = "";
|
||||
let minusPath = "";
|
||||
for (const line of segment.split("\n")) {
|
||||
if (line.startsWith("+++ ")) plusPath = stripDiffPathPrefix(line.slice(4));
|
||||
else if (line.startsWith("--- ")) minusPath = stripDiffPathPrefix(line.slice(4));
|
||||
else if (line.startsWith("@@")) break;
|
||||
}
|
||||
return (plusPath && plusPath !== "/dev/null") ? plusPath : minusPath;
|
||||
}
|
||||
|
||||
function buildDiffByPath(patch: string): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
if (!patch.trim()) return map;
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const line of patch.split("\n")) {
|
||||
if (line.startsWith("diff --git ") && current.length > 0) {
|
||||
segments.push(current.join("\n"));
|
||||
current = [];
|
||||
}
|
||||
current.push(line);
|
||||
}
|
||||
if (current.length > 0) segments.push(current.join("\n"));
|
||||
for (const segment of segments) {
|
||||
const path = patchFilePath(segment);
|
||||
if (path) map.set(path, segment);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
let diffByPath = $derived(buildDiffByPath(comparison.patch));
|
||||
let selectedDiffFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
||||
let selectedDiffPatch = $derived(selectedDiffFile ? (diffByPath.get(selectedDiffFile.path) ?? "") : "");
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div class="dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="dialog-range">
|
||||
<span class="hash">{comparison.from_short}</span>
|
||||
<ArrowRight size={16} aria-hidden="true" />
|
||||
<span class="hash">{comparison.to_short}</span>
|
||||
</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if comparison.files.length === 0}
|
||||
<div class="blank-state">No differences to show — these versions are identical.</div>
|
||||
{:else}
|
||||
<div class="dialog-body">
|
||||
<aside class="dialog-files" aria-label="Changed files">
|
||||
{#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<button
|
||||
class="dialog-file-row"
|
||||
class:active={selectedDiffPath === file.path}
|
||||
type="button"
|
||||
onclick={() => onSelectFile(file)}
|
||||
title={displayDiffFile(file)}
|
||||
>
|
||||
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
|
||||
<strong>{displayDiffFile(file)}</strong>
|
||||
<span class="diff-counts">
|
||||
<span class="adds">+{file.additions}</span>
|
||||
<span class="dels">-{file.deletions}</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</aside>
|
||||
|
||||
<div class="dialog-diff" aria-label="File diff">
|
||||
{#if !selectedDiffFile}
|
||||
<div class="blank-state">Select a file to see its changes.</div>
|
||||
{:else if selectedDiffPatch.trim().length === 0}
|
||||
<div class="blank-state">No textual changes for this file.</div>
|
||||
{:else}
|
||||
<pre class="diff-view" aria-label="Unified diff">{#each diffLines(selectedDiffPatch) as line}<span class={`diff-line ${line.kind}`}>{line.text || " "}</span>{/each}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import { ArrowRight, GitCompare, LoaderCircle } from "@lucide/svelte";
|
||||
import type { GitCommit, GitCommitComparison } from "../types";
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
compareFrom: string;
|
||||
compareTo: string;
|
||||
canCompare: boolean;
|
||||
comparison: GitCommitComparison | null;
|
||||
operation: string;
|
||||
onCompareFromChange: (val: string) => void;
|
||||
onCompareToChange: (val: string) => void;
|
||||
onCompare: () => void;
|
||||
onOpenDialog: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
compareFrom = "",
|
||||
compareTo = "",
|
||||
canCompare = false,
|
||||
comparison = null,
|
||||
operation = "",
|
||||
onCompareFromChange = () => {},
|
||||
onCompareToChange = () => {},
|
||||
onCompare = () => {},
|
||||
onOpenDialog = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function commitOptionLabel(item: GitCommit): string {
|
||||
return `${item.short_hash} - ${item.summary}`;
|
||||
}
|
||||
|
||||
function handleSubmit(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
onCompare();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="compare-panel panel" aria-label="Compare commits">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Compare</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
|
||||
</div>
|
||||
{#if comparison}
|
||||
<span class="pill pill-count">{comparison.files.length} files</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
{:else if commits.length < 2}
|
||||
<div class="blank-state">At least two commits are needed to compare.</div>
|
||||
{:else}
|
||||
<form class="compare-form" onsubmit={handleSubmit}>
|
||||
<label class="compare-field">
|
||||
<span>From (older)</span>
|
||||
<select
|
||||
value={compareFrom}
|
||||
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
|
||||
|
||||
<label class="compare-field">
|
||||
<span>To (newer)</span>
|
||||
<select
|
||||
value={compareTo}
|
||||
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
|
||||
disabled={isBusy}
|
||||
>
|
||||
<option value="" disabled>Select a commit</option>
|
||||
{#each commits as item (item.hash)}
|
||||
<option value={item.hash}>{commitOptionLabel(item)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button class="btn-primary" type="submit" disabled={!canCompare}>
|
||||
{#if operation === "Comparing commits"}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<GitCompare size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Compare
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if compareFrom && compareTo && compareFrom === compareTo}
|
||||
<div class="blank-state">Select two different commits to compare.</div>
|
||||
{:else if comparison}
|
||||
<div class="compare-summary">
|
||||
<div class="compare-range">
|
||||
<span class="hash">{comparison.from_short}</span>
|
||||
<ArrowRight size={15} aria-hidden="true" />
|
||||
<span class="hash">{comparison.to_short}</span>
|
||||
<span class="compare-count">{comparison.files.length} changed files</span>
|
||||
</div>
|
||||
<button type="button" onclick={onOpenDialog} disabled={isBusy}>
|
||||
<GitCompare size={15} aria-hidden="true" />
|
||||
View comparison
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="blank-state">Pick two commits and run a comparison.</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,170 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight, FileText, Folder, FolderOpen } from "@lucide/svelte";
|
||||
import type { ExplorerNode, ExplorerNodeKind, FileStatusKind, GitRepositoryFile } from "../types";
|
||||
|
||||
interface Props {
|
||||
repoFiles: GitRepositoryFile[];
|
||||
expandedExplorerPaths: Set<string>;
|
||||
selectedExplorerPath: string;
|
||||
selectedExplorerKind: ExplorerNodeKind;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
onToggleFolder: (node: ExplorerNode) => void;
|
||||
onSelectNode: (node: ExplorerNode) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
repoFiles = [],
|
||||
expandedExplorerPaths = new Set(),
|
||||
selectedExplorerPath = "",
|
||||
selectedExplorerKind = "file",
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
onToggleFolder = () => {},
|
||||
onSelectNode = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function mergeExplorerStatus(current: FileStatusKind | null, next: FileStatusKind | null): FileStatusKind | null {
|
||||
if (!next) return current;
|
||||
if (!current) return next;
|
||||
const priority: FileStatusKind[] = ["conflicted", "modified", "renamed", "deleted", "added", "untracked", "unknown"];
|
||||
return priority.indexOf(next) < priority.indexOf(current) ? next : current;
|
||||
}
|
||||
|
||||
function sortExplorerNodes(nodes: ExplorerNode[]) {
|
||||
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) sortExplorerNodes(node.children);
|
||||
}
|
||||
|
||||
function buildExplorerTree(files: GitRepositoryFile[]): ExplorerNode[] {
|
||||
const roots: ExplorerNode[] = [];
|
||||
const folders = new Map<string, ExplorerNode>();
|
||||
|
||||
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
||||
const parts = file.path.split(/[\\/]+/).filter(Boolean);
|
||||
let currentPath = "";
|
||||
let siblings = roots;
|
||||
let ancestors: ExplorerNode[] = [];
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
|
||||
let folderNode = folders.get(currentPath);
|
||||
if (!folderNode) {
|
||||
folderNode = { name: parts[i], path: currentPath, kind: "folder", status: null, tracked: true, depth: i, children: [] };
|
||||
folders.set(currentPath, folderNode);
|
||||
siblings.push(folderNode);
|
||||
}
|
||||
ancestors = [...ancestors, folderNode];
|
||||
siblings = folderNode.children;
|
||||
}
|
||||
|
||||
const fileNode: ExplorerNode = {
|
||||
name: parts[parts.length - 1] ?? file.path,
|
||||
path: file.path,
|
||||
kind: "file",
|
||||
status: file.status,
|
||||
tracked: file.tracked,
|
||||
depth: Math.max(parts.length - 1, 0),
|
||||
children: [],
|
||||
};
|
||||
siblings.push(fileNode);
|
||||
|
||||
for (const folderNode of ancestors) {
|
||||
folderNode.status = mergeExplorerStatus(folderNode.status, file.status);
|
||||
folderNode.tracked = folderNode.tracked && file.tracked;
|
||||
}
|
||||
}
|
||||
|
||||
sortExplorerNodes(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function flattenExplorerTree(nodes: ExplorerNode[], expanded: Set<string>): ExplorerNode[] {
|
||||
const visible: ExplorerNode[] = [];
|
||||
for (const node of nodes) {
|
||||
visible.push(node);
|
||||
if (node.kind === "folder" && expanded.has(node.path)) {
|
||||
visible.push(...flattenExplorerTree(node.children, expanded));
|
||||
}
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
function statusLabel(kind: FileStatusKind | null): string {
|
||||
return kind ?? "none";
|
||||
}
|
||||
|
||||
let explorerTree = $derived(buildExplorerTree(repoFiles));
|
||||
let visibleNodes = $derived(flattenExplorerTree(explorerTree, expandedExplorerPaths));
|
||||
</script>
|
||||
|
||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="File explorer">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Explorer</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
|
||||
</div>
|
||||
<span class="pill pill-count">{repoFiles.length}</span>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<p class="blank-state">Open a repository to browse files.</p>
|
||||
{:else if repoFiles.length === 0}
|
||||
<p class="blank-state">No files returned.</p>
|
||||
{:else}
|
||||
<div class="explorer-list overflow-auto p-2">
|
||||
{#each visibleNodes as node (`${node.kind}:${node.path}`)}
|
||||
<div
|
||||
class="explorer-row"
|
||||
class:active={selectedExplorerPath === node.path && selectedExplorerKind === node.kind}
|
||||
class:folder={node.kind === "folder"}
|
||||
style={`--depth: ${node.depth}`}
|
||||
title={node.path}
|
||||
>
|
||||
{#if node.kind === "folder"}
|
||||
<button
|
||||
class="tree-toggle"
|
||||
type="button"
|
||||
onclick={() => onToggleFolder(node)}
|
||||
disabled={isBusy}
|
||||
title={expandedExplorerPaths.has(node.path) ? "Collapse folder" : "Expand folder"}
|
||||
>
|
||||
{#if expandedExplorerPaths.has(node.path)}
|
||||
<ChevronDown size={14} aria-hidden="true" />
|
||||
{:else}
|
||||
<ChevronRight size={14} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
{#if expandedExplorerPaths.has(node.path)}
|
||||
<FolderOpen size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<Folder size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="tree-spacer"></span>
|
||||
<FileText size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="explorer-select"
|
||||
type="button"
|
||||
onclick={() => onSelectNode(node)}
|
||||
disabled={isBusy}
|
||||
title={`Show history for ${node.path}`}
|
||||
>
|
||||
<span>{node.name}</span>
|
||||
</button>
|
||||
|
||||
{#if node.status}
|
||||
<small class={`status-badge ${node.status}`}>{statusLabel(node.status)}</small>
|
||||
{:else if !node.tracked}
|
||||
<small class="status-badge untracked">untracked</small>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,76 @@
|
||||
<script lang="ts">
|
||||
import { GitCompare, History, RotateCcw } from "@lucide/svelte";
|
||||
import type { GitCommit } from "../types";
|
||||
|
||||
interface Props {
|
||||
fileHistory: GitCommit[];
|
||||
selectedExplorerPath: string;
|
||||
selectedExplorerLabel: string;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
onDiff: (commit: GitCommit) => void;
|
||||
onRestore: (commit: GitCommit) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
fileHistory = [],
|
||||
selectedExplorerPath = "",
|
||||
selectedExplorerLabel = "File history",
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
onDiff = () => {},
|
||||
onRestore = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
function formatCommitDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Selected file history">
|
||||
<div class="section-head">
|
||||
<div class="min-w-0">
|
||||
<span class="eyebrow">{selectedExplorerLabel}</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight truncate">{selectedExplorerPath || "No file"}</h2>
|
||||
</div>
|
||||
<span class="pill pill-count flex-shrink-0">{fileHistory.length}</span>
|
||||
</div>
|
||||
|
||||
{#if !hasRepository}
|
||||
<div class="blank-state">No repository loaded.</div>
|
||||
{:else if !selectedExplorerPath}
|
||||
<div class="blank-state">Select a file in Explorer.</div>
|
||||
{:else if fileHistory.length === 0}
|
||||
<div class="blank-state">No history returned for this selection.</div>
|
||||
{:else}
|
||||
<div class="history-list overflow-auto p-2">
|
||||
{#each fileHistory as item (item.hash)}
|
||||
<article class="commit-row compact">
|
||||
<div class="commit-line">
|
||||
<History size={16} aria-hidden="true" />
|
||||
<div>
|
||||
<strong title={item.summary}>{item.summary}</strong>
|
||||
<span>{item.short_hash} - {item.author_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="commit-actions">
|
||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<div class="commit-action-buttons">
|
||||
<button class="btn-sm" type="button" onclick={() => onDiff(item)} disabled={isBusy} title="Show changes vs working tree">
|
||||
<GitCompare size={15} aria-hidden="true" />
|
||||
Diff
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onRestore(item)} disabled={isBusy} title="Restore selected file from this commit">
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,234 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, ChevronRight, RotateCcw } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||
|
||||
interface GraphSegment {
|
||||
fromCol: number;
|
||||
toCol: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface GraphRow {
|
||||
dotCol: number;
|
||||
dotColor: string;
|
||||
top: GraphSegment[];
|
||||
bottom: GraphSegment[];
|
||||
}
|
||||
|
||||
const GRAPH_COLORS = [
|
||||
"#2f6fb0", "#4aa777", "#c9851f", "#a05bd0",
|
||||
"#cc4b6e", "#1f9ab0", "#7a8a1f", "#b0631f",
|
||||
];
|
||||
const GRAPH_LANE = 16;
|
||||
|
||||
interface Props {
|
||||
commits: GitCommit[];
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
expandedCommitHashes: Set<string>;
|
||||
onRestoreCommit: (commit: GitCommit) => void;
|
||||
onRestoreCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||
onToggleCommitFiles: (hash: string) => void;
|
||||
}
|
||||
|
||||
let {
|
||||
commits = [],
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
expandedCommitHashes = new Set(),
|
||||
onRestoreCommit = () => {},
|
||||
onRestoreCommitFile = () => {},
|
||||
onToggleCommitFiles = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
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 computeGraph(items: GitCommit[]): { rows: GraphRow[]; columns: number } {
|
||||
const rows: GraphRow[] = [];
|
||||
let lanes: (string | null)[] = [];
|
||||
let maxColumns = 1;
|
||||
|
||||
for (const commit of items) {
|
||||
const before = lanes.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);
|
||||
|
||||
for (let k = 0; k < after.length; k++) {
|
||||
if (after[k] === commit.hash) after[k] = null;
|
||||
}
|
||||
|
||||
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
|
||||
|
||||
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); }
|
||||
after[slot] = commit.parents[p];
|
||||
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) });
|
||||
}
|
||||
|
||||
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) });
|
||||
}
|
||||
|
||||
rows.push({ dotCol: col, dotColor: laneColor(col), top, bottom });
|
||||
|
||||
lanes = after.slice();
|
||||
while (lanes.length > 0 && lanes[lanes.length - 1] == null) lanes.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 formatCommitDate(value: string): string {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return value;
|
||||
return new Intl.DateTimeFormat(undefined, { dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
|
||||
let graph = $derived(computeGraph(commits));
|
||||
let graphRows = $derived(graph.rows);
|
||||
let graphWidth = $derived(Math.max(graph.columns, 1) * GRAPH_LANE);
|
||||
</script>
|
||||
|
||||
<section class="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>
|
||||
<span class="pill pill-count">{commits.length}</span>
|
||||
</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">
|
||||
{#each commits as item, rowIndex (item.hash)}
|
||||
{@const row = graphRows[rowIndex]}
|
||||
<article class="commit-row graph-row">
|
||||
<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}
|
||||
<line
|
||||
x1={graphColX(seg.fromCol)} y1="0"
|
||||
x2={graphColX(seg.toCol)} y2="50"
|
||||
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/each}
|
||||
{#each row.bottom as seg}
|
||||
<line
|
||||
x1={graphColX(seg.fromCol)} y1="50"
|
||||
x2={graphColX(seg.toCol)} y2="100"
|
||||
stroke={seg.color} stroke-width="2" vector-effect="non-scaling-stroke"
|
||||
/>
|
||||
{/each}
|
||||
</svg>
|
||||
<span
|
||||
class="graph-dot"
|
||||
class:merge={item.parents.length > 1}
|
||||
style={`left:${graphColX(row.dotCol)}px; --dot-color:${row.dotColor}`}
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="commit-body">
|
||||
<div class="commit-line">
|
||||
<div>
|
||||
<strong title={item.summary}>{item.summary}</strong>
|
||||
<span>{item.short_hash} - {item.author_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if item.refs.length > 0}
|
||||
<div class="ref-list" aria-label="Commit refs">
|
||||
{#each item.refs as ref}
|
||||
<span>{ref}</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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={() => onRestoreCommitFile(item, file)}
|
||||
disabled={isBusy}
|
||||
title="Restore this file from this commit"
|
||||
>
|
||||
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
|
||||
<strong>{displayCommitFile(file)}</strong>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="commit-actions">
|
||||
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
|
||||
<div class="commit-action-buttons">
|
||||
<button class="btn-sm" type="button" onclick={() => onRestoreCommit(item)} disabled={isBusy} title="Reset current branch to this commit">
|
||||
<RotateCcw size={15} aria-hidden="true" />
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,356 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { AlertCircle, Check, LoaderCircle, X } from "@lucide/svelte";
|
||||
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
|
||||
|
||||
interface Props {
|
||||
conflictedFiles: GitFileStatus[];
|
||||
conflictTarget: string;
|
||||
conflict: ConflictFile | null;
|
||||
preparedResolutions: Record<string, PreparedResolution>;
|
||||
isBusy: boolean;
|
||||
operation: string;
|
||||
onClose: () => void;
|
||||
onSelectFile: (path: string) => void;
|
||||
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
|
||||
onApply: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
conflictedFiles = [],
|
||||
conflictTarget = "",
|
||||
conflict = null,
|
||||
preparedResolutions = {},
|
||||
isBusy = false,
|
||||
operation = "",
|
||||
onClose = () => {},
|
||||
onSelectFile = () => {},
|
||||
onMarkResolved = () => {},
|
||||
onApply = () => {},
|
||||
}: Props = $props();
|
||||
|
||||
let conflictParts = $derived<ConflictPart[]>(
|
||||
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
|
||||
);
|
||||
let conflictChoices = $state<(ConflictChoice | null)[]>([]);
|
||||
let resolveContent = $state("");
|
||||
let manualMode = $state(false);
|
||||
let binarySide = $state<"ours" | "theirs" | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
const c = conflict;
|
||||
const target = conflictTarget;
|
||||
const parts = conflictParts;
|
||||
|
||||
untrack(() => {
|
||||
if (!c) {
|
||||
conflictChoices = [];
|
||||
resolveContent = "";
|
||||
manualMode = false;
|
||||
binarySide = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const prepared = preparedResolutions[target];
|
||||
|
||||
if (c.binary) {
|
||||
conflictChoices = [];
|
||||
resolveContent = "";
|
||||
manualMode = false;
|
||||
binarySide = prepared?.kind === "side" ? prepared.side : null;
|
||||
return;
|
||||
}
|
||||
|
||||
binarySide = null;
|
||||
conflictChoices = parts.filter((p) => p.kind === "conflict").map(() => null);
|
||||
|
||||
if (prepared?.kind === "content") {
|
||||
resolveContent = prepared.content;
|
||||
manualMode = true;
|
||||
} else {
|
||||
resolveContent = c.content;
|
||||
manualMode = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let conflictRegionCount = $derived(conflictParts.filter((p) => p.kind === "conflict").length);
|
||||
let unresolvedCount = $derived(manualMode ? 0 : conflictChoices.filter((c) => c == null).length);
|
||||
let resolvedContent = $derived(manualMode ? resolveContent : buildResolution(conflictParts, conflictChoices));
|
||||
let resolveHasMarkers = $derived(/^<{7}/m.test(resolvedContent) || /^>{7}/m.test(resolvedContent));
|
||||
let preparedCount = $derived(Object.keys(preparedResolutions).length);
|
||||
let currentPrepared = $derived(conflictTarget.length > 0 && preparedResolutions[conflictTarget] != null);
|
||||
let canMarkResolved = $derived(!!conflict && !isBusy && (conflict.binary ? binarySide != null : !resolveHasMarkers));
|
||||
|
||||
function parseConflicts(content: string): ConflictPart[] {
|
||||
const lines = content.split("\n");
|
||||
const parts: ConflictPart[] = [];
|
||||
let textLines: string[] = [];
|
||||
let regionIndex = 0;
|
||||
let index = 0;
|
||||
|
||||
const flushText = () => {
|
||||
if (textLines.length > 0) { parts.push({ kind: "text", lines: textLines }); textLines = []; }
|
||||
};
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index];
|
||||
if (line.startsWith("<<<<<<<")) {
|
||||
flushText(); index++;
|
||||
const oursLines: string[] = [];
|
||||
while (index < lines.length && !lines[index].startsWith("=======") && !lines[index].startsWith("|||||||")) oursLines.push(lines[index++]);
|
||||
if (index < lines.length && lines[index].startsWith("|||||||")) { index++; while (index < lines.length && !lines[index].startsWith("=======")) index++; }
|
||||
if (index < lines.length && lines[index].startsWith("=======")) index++;
|
||||
const theirsLines: string[] = [];
|
||||
while (index < lines.length && !lines[index].startsWith(">>>>>>>")) theirsLines.push(lines[index++]);
|
||||
if (index < lines.length && lines[index].startsWith(">>>>>>>")) index++;
|
||||
parts.push({ kind: "conflict", index: regionIndex++, oursLines, theirsLines });
|
||||
} else { textLines.push(line); index++; }
|
||||
}
|
||||
flushText();
|
||||
return parts;
|
||||
}
|
||||
|
||||
function buildResolution(parts: ConflictPart[], choices: (ConflictChoice | null)[]): string {
|
||||
const out: string[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.kind === "text") { out.push(...part.lines); continue; }
|
||||
const choice = choices[part.index];
|
||||
if (choice === "ours") out.push(...part.oursLines);
|
||||
else if (choice === "theirs") out.push(...part.theirsLines);
|
||||
else if (choice === "both-ot") out.push(...part.oursLines, ...part.theirsLines);
|
||||
else if (choice === "both-to") out.push(...part.theirsLines, ...part.oursLines);
|
||||
else out.push("<<<<<<< current", ...part.oursLines, "=======", ...part.theirsLines, ">>>>>>> incoming");
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function setConflictChoice(index: number, choice: ConflictChoice) {
|
||||
const next = [...conflictChoices];
|
||||
next[index] = choice;
|
||||
conflictChoices = next;
|
||||
}
|
||||
|
||||
function setAllConflicts(choice: ConflictChoice) {
|
||||
conflictChoices = conflictParts.filter((p) => p.kind === "conflict").map(() => choice);
|
||||
}
|
||||
|
||||
function enableManualEdit() {
|
||||
resolveContent = buildResolution(conflictParts, conflictChoices);
|
||||
manualMode = true;
|
||||
}
|
||||
|
||||
function oursActive(choice: ConflictChoice | null): boolean {
|
||||
return choice === "ours" || choice === "both-ot" || choice === "both-to";
|
||||
}
|
||||
|
||||
function theirsActive(choice: ConflictChoice | null): boolean {
|
||||
return choice === "theirs" || choice === "both-ot" || choice === "both-to";
|
||||
}
|
||||
|
||||
function displayLine(line: string): string {
|
||||
return line.replace(/\r$/, "");
|
||||
}
|
||||
|
||||
function formatBytes(size: number | null): string {
|
||||
if (size == null) return "missing";
|
||||
if (size < 1024) return `${size} B`;
|
||||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||||
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
function handleMarkResolved() {
|
||||
if (!conflict || !conflictTarget || !canMarkResolved) return;
|
||||
const prepared: PreparedResolution = conflict.binary
|
||||
? { kind: "side", side: binarySide as "ours" | "theirs" }
|
||||
: { kind: "content", content: resolvedContent };
|
||||
onMarkResolved(conflictTarget, prepared);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="dialog-backdrop"
|
||||
role="presentation"
|
||||
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<div
|
||||
class="dialog"
|
||||
style="grid-template-rows: auto minmax(0,1fr) auto;"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Resolve merge conflicts"
|
||||
tabindex="-1"
|
||||
>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">Resolve</span>
|
||||
<h2 class="dialog-title">Merge conflicts</h2>
|
||||
</div>
|
||||
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#if conflictedFiles.length === 0}
|
||||
<div class="blank-state">All conflicts resolved. You can commit the merge now.</div>
|
||||
{:else}
|
||||
<div class="dialog-body">
|
||||
<aside class="dialog-files" aria-label="Conflicted files">
|
||||
{#each conflictedFiles as file (file.path)}
|
||||
<button
|
||||
class="dialog-file-row"
|
||||
class:active={conflictTarget === file.path}
|
||||
class:prepared={preparedResolutions[file.path] != null}
|
||||
type="button"
|
||||
onclick={() => onSelectFile(file.path)}
|
||||
disabled={isBusy}
|
||||
title={file.path}
|
||||
>
|
||||
<span class={`status-badge ${preparedResolutions[file.path] ? "added" : "conflicted"}`}>
|
||||
{preparedResolutions[file.path] ? "ready" : "conflicted"}
|
||||
</span>
|
||||
<strong>{file.path}</strong>
|
||||
{#if preparedResolutions[file.path]}
|
||||
<Check size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</aside>
|
||||
|
||||
<div class="resolve-editor" aria-label="Conflict editor">
|
||||
{#if !conflict}
|
||||
<div class="blank-state">Select a file to resolve.</div>
|
||||
{:else if conflict.binary}
|
||||
<div class="resolve-binary">
|
||||
<div class="resolve-binary-note">
|
||||
<AlertCircle size={16} aria-hidden="true" />
|
||||
<span>Binary file — pick which version to keep, then mark it resolved.</span>
|
||||
</div>
|
||||
<div class="resolve-binary-options">
|
||||
<button
|
||||
class="resolve-binary-card ours"
|
||||
class:active={binarySide === "ours"}
|
||||
type="button"
|
||||
onclick={() => { binarySide = "ours"; }}
|
||||
disabled={isBusy || conflict.ours_size == null}
|
||||
>
|
||||
<span class="resolve-side-label">Current (ours)</span>
|
||||
<strong>{formatBytes(conflict.ours_size)}</strong>
|
||||
<span class="resolve-binary-hint">{conflict.ours_size == null ? "Deleted on this side" : "Keep this version"}</span>
|
||||
</button>
|
||||
<button
|
||||
class="resolve-binary-card theirs"
|
||||
class:active={binarySide === "theirs"}
|
||||
type="button"
|
||||
onclick={() => { binarySide = "theirs"; }}
|
||||
disabled={isBusy || conflict.theirs_size == null}
|
||||
>
|
||||
<span class="resolve-side-label">Incoming (theirs)</span>
|
||||
<strong>{formatBytes(conflict.theirs_size)}</strong>
|
||||
<span class="resolve-binary-hint">{conflict.theirs_size == null ? "Deleted on this side" : "Keep this version"}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="resolve-actions">
|
||||
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
|
||||
{#if currentPrepared}
|
||||
<span class="prepared-tag"><Check size={14} aria-hidden="true" /> Prepared</span>
|
||||
{/if}
|
||||
<button type="button" onclick={handleMarkResolved} disabled={!canMarkResolved}>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{currentPrepared ? "Update decision" : "Mark as resolved"}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="resolve-toolbar">
|
||||
{#if manualMode}
|
||||
<button type="button" onclick={() => { manualMode = false; }} disabled={isBusy}>Back to guided</button>
|
||||
{:else}
|
||||
<span class="resolve-toolbar-label">Apply to all:</span>
|
||||
<button type="button" onclick={() => setAllConflicts("ours")} disabled={isBusy}>Current</button>
|
||||
<button type="button" onclick={() => setAllConflicts("theirs")} disabled={isBusy}>Incoming</button>
|
||||
<button type="button" onclick={() => setAllConflicts("both-ot")} disabled={isBusy}>Both</button>
|
||||
<button type="button" onclick={enableManualEdit} disabled={isBusy}>Edit manually</button>
|
||||
{/if}
|
||||
<span class="resolve-status">
|
||||
{#if unresolvedCount > 0}
|
||||
<AlertCircle size={14} aria-hidden="true" />
|
||||
{unresolvedCount} of {conflictRegionCount} unresolved
|
||||
{:else if resolveHasMarkers}
|
||||
<AlertCircle size={14} aria-hidden="true" />
|
||||
Conflict markers still present
|
||||
{:else}
|
||||
<Check size={14} aria-hidden="true" />
|
||||
{conflictRegionCount} {conflictRegionCount === 1 ? "conflict" : "conflicts"} resolved
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{#if manualMode}
|
||||
<textarea
|
||||
class="resolve-textarea"
|
||||
bind:value={resolveContent}
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
></textarea>
|
||||
{:else}
|
||||
<div class="resolve-structured">
|
||||
{#each conflictParts as part, partIndex (partIndex)}
|
||||
{#if part.kind === "text"}
|
||||
{#if part.lines.length > 0}
|
||||
<pre class="resolve-context">{#each part.lines as line}<span class="resolve-line context">{displayLine(line) || " "}</span>{/each}</pre>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="resolve-conflict" class:unresolved={conflictChoices[part.index] == null}>
|
||||
<div class="resolve-conflict-bar">
|
||||
<span class="resolve-conflict-label">Conflict {part.index + 1}</span>
|
||||
<div class="resolve-choice-buttons">
|
||||
<button type="button" class:active={conflictChoices[part.index] === "ours"} onclick={() => setConflictChoice(part.index, "ours")} disabled={isBusy}>Current</button>
|
||||
<button type="button" class:active={conflictChoices[part.index] === "theirs"} onclick={() => setConflictChoice(part.index, "theirs")} disabled={isBusy}>Incoming</button>
|
||||
<button type="button" class:active={conflictChoices[part.index] === "both-ot"} onclick={() => setConflictChoice(part.index, "both-ot")} disabled={isBusy} title="Both — current first">Both C+I</button>
|
||||
<button type="button" class:active={conflictChoices[part.index] === "both-to"} onclick={() => setConflictChoice(part.index, "both-to")} disabled={isBusy} title="Both — incoming first">Both I+C</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="resolve-side ours" class:dimmed={!oursActive(conflictChoices[part.index])}>
|
||||
<span class="resolve-side-label">Current (ours)</span>
|
||||
<pre class="resolve-lines">{#each part.oursLines as line}<span class="resolve-line ours">{displayLine(line) || " "}</span>{/each}</pre>
|
||||
</div>
|
||||
<div class="resolve-side theirs" class:dimmed={!theirsActive(conflictChoices[part.index])}>
|
||||
<span class="resolve-side-label">Incoming (theirs)</span>
|
||||
<pre class="resolve-lines">{#each part.theirsLines as line}<span class="resolve-line theirs">{displayLine(line) || " "}</span>{/each}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="resolve-actions">
|
||||
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
|
||||
{#if currentPrepared}
|
||||
<span class="prepared-tag"><Check size={14} aria-hidden="true" /> Prepared</span>
|
||||
{/if}
|
||||
<button type="button" onclick={handleMarkResolved} disabled={!canMarkResolved}>
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{currentPrepared ? "Update decision" : "Mark as resolved"}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="dialog-footer">
|
||||
<span class="dialog-footer-info">{preparedCount} of {conflictedFiles.length} prepared</span>
|
||||
<button class="btn-primary" type="button" onclick={onApply} disabled={isBusy || preparedCount === 0}>
|
||||
{#if operation.startsWith("Resolving")}
|
||||
<LoaderCircle class="spin" size={16} aria-hidden="true" />
|
||||
{:else}
|
||||
<Check size={16} aria-hidden="true" />
|
||||
{/if}
|
||||
Apply {preparedCount} resolved
|
||||
</button>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script lang="ts">
|
||||
import { Check, RotateCcw, Undo2 } from "@lucide/svelte";
|
||||
import type { FileStatusKind, GitFileStatus, GitStatus } from "../types";
|
||||
|
||||
interface Props {
|
||||
changedFiles: GitFileStatus[];
|
||||
stagedCount: number;
|
||||
unstagedCount: number;
|
||||
hasRepository: boolean;
|
||||
isBusy: boolean;
|
||||
status: GitStatus | null;
|
||||
onStage: (file: GitFileStatus) => void;
|
||||
onUnstage: (file: GitFileStatus) => void;
|
||||
onDiscard: (file: GitFileStatus, staged: boolean) => void;
|
||||
onStageAll: () => void;
|
||||
onUnstageAll: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
changedFiles = [],
|
||||
stagedCount = 0,
|
||||
unstagedCount = 0,
|
||||
hasRepository = false,
|
||||
isBusy = false,
|
||||
status = null,
|
||||
onStage = () => {},
|
||||
onUnstage = () => {},
|
||||
onDiscard = () => {},
|
||||
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;
|
||||
}
|
||||
|
||||
let hasUnstaged = $derived(changedFiles.some((f) => f.unstaged !== null));
|
||||
let hasStaged = $derived(changedFiles.some((f) => f.staged !== null));
|
||||
</script>
|
||||
|
||||
<section class="panel grid grid-rows-[auto_auto_1fr] overflow-hidden" aria-label="Working tree status">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<span class="eyebrow">Working tree</span>
|
||||
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Status</h2>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="pill pill-count">{stagedCount} staged</span>
|
||||
<span class="pill pill-count">{unstagedCount} unstaged</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if hasRepository && changedFiles.length > 0}
|
||||
<div class="status-toolbar">
|
||||
<button
|
||||
class="btn-sm"
|
||||
type="button"
|
||||
onclick={onStageAll}
|
||||
disabled={isBusy || !hasUnstaged}
|
||||
title="Stage all unstaged files"
|
||||
>
|
||||
<Check size={14} aria-hidden="true" />
|
||||
Stage all
|
||||
</button>
|
||||
<button
|
||||
class="btn-sm"
|
||||
type="button"
|
||||
onclick={onUnstageAll}
|
||||
disabled={isBusy || !hasStaged}
|
||||
title="Unstage all staged files"
|
||||
>
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
Unstage all
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#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="overflow-auto p-2">
|
||||
{#each changedFiles as file (`${file.old_path ?? ""}:${file.path}`)}
|
||||
<article class="file-row">
|
||||
<div class="file-title">
|
||||
<strong title={displayPath(file)}>{displayPath(file)}</strong>
|
||||
</div>
|
||||
|
||||
<div class="change-lanes">
|
||||
<div class="change-lane" class:inactive={!file.staged}>
|
||||
<div class="lane-header">
|
||||
<span class="lane-name">Staged</span>
|
||||
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
|
||||
</div>
|
||||
<div class="lane-actions">
|
||||
{#if file.staged}
|
||||
<button class="btn-sm" type="button" onclick={() => onUnstage(file)} disabled={isBusy} title="Unstage file">
|
||||
<Undo2 size={14} aria-hidden="true" />
|
||||
Unstage
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, true)} disabled={isBusy} title="Discard staged changes">
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No staged change</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="change-lane" class:inactive={!file.unstaged}>
|
||||
<div class="lane-header">
|
||||
<span class="lane-name">Unstaged</span>
|
||||
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
|
||||
</div>
|
||||
<div class="lane-actions">
|
||||
{#if file.unstaged}
|
||||
<button class="btn-sm" type="button" onclick={() => onStage(file)} disabled={isBusy} title="Stage file">
|
||||
<Check size={14} aria-hidden="true" />
|
||||
Stage
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => onDiscard(file, false)} disabled={isBusy} title="Discard unstaged changes">
|
||||
<RotateCcw size={14} aria-hidden="true" />
|
||||
Discard
|
||||
</button>
|
||||
{:else}
|
||||
<span class="quiet">No unstaged change</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -71,6 +71,28 @@ export interface GitCommitComparison {
|
||||
patch: string;
|
||||
}
|
||||
|
||||
export type ExplorerNodeKind = "folder" | "file";
|
||||
|
||||
export interface ExplorerNode {
|
||||
name: string;
|
||||
path: string;
|
||||
kind: ExplorerNodeKind;
|
||||
status: FileStatusKind | null;
|
||||
tracked: boolean;
|
||||
depth: number;
|
||||
children: ExplorerNode[];
|
||||
}
|
||||
|
||||
export type ConflictChoice = "ours" | "theirs" | "both-ot" | "both-to";
|
||||
|
||||
export type ConflictPart =
|
||||
| { kind: "text"; lines: string[] }
|
||||
| { kind: "conflict"; index: number; oursLines: string[]; theirsLines: string[] };
|
||||
|
||||
export type PreparedResolution =
|
||||
| { kind: "content"; content: string }
|
||||
| { kind: "side"; side: "ours" | "theirs" };
|
||||
|
||||
export interface ConflictFile {
|
||||
path: string;
|
||||
content: string;
|
||||
|
||||
+2
-1
@@ -1,8 +1,9 @@
|
||||
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [svelte()],
|
||||
plugins: [tailwindcss(), svelte()],
|
||||
clearScreen: false,
|
||||
server: {
|
||||
strictPort: true,
|
||||
|
||||
Reference in New Issue
Block a user