This commit is contained in:
2026-06-27 23:08:19 +02:00
parent 25700821d1
commit 5b44a65e51
22 changed files with 3388 additions and 3538 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}
+15 -1
View File
@@ -4,7 +4,21 @@
"Bash(cargo build *)", "Bash(cargo build *)",
"Bash(npm run *)", "Bash(npm run *)",
"Bash(kill %1)", "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 *)"
] ]
} }
} }
+1
View File
@@ -2,4 +2,5 @@
/dist /dist
/src-tauri/target /src-tauri/target
*.log *.log
.idea
.DS_Store .DS_Store
+589 -63
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -14,8 +14,10 @@
}, },
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1",
"@tauri-apps/api": "^2.5.0", "@tauri-apps/api": "^2.5.0",
"svelte": "^5.0.0" "svelte": "^5.0.0",
"tailwindcss": "^4.3.1"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^5.0.0", "@sveltejs/vite-plugin-svelte": "^5.0.0",
+9 -1
View File
@@ -3,5 +3,13 @@
"identifier": "default", "identifier": "default",
"description": "Default permissions for the desktop window", "description": "Default permissions for the desktop window",
"windows": ["main"], "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

+4 -2
View File
@@ -13,11 +13,13 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"title": "Tauri Git Lite", "title": "GitLite",
"width": 1200, "width": 1200,
"height": 800, "height": 800,
"minWidth": 900, "minWidth": 900,
"minHeight": 600 "minHeight": 600,
"decorations": false,
"shadow": true
} }
], ],
"security": { "security": {
+335 -1865
View File
File diff suppressed because it is too large Load Diff
+607 -1388
View File
File diff suppressed because it is too large Load Diff
+173
View File
@@ -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>
+107
View File
@@ -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>
+57
View File
@@ -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>
+143
View File
@@ -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>
+121
View File
@@ -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>
+170
View File
@@ -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>
+234
View File
@@ -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>
+356
View File
@@ -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>
+143
View File
@@ -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>
+22
View File
@@ -71,6 +71,28 @@ export interface GitCommitComparison {
patch: string; 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 { export interface ConflictFile {
path: string; path: string;
content: string; content: string;
+2 -1
View File
@@ -1,8 +1,9 @@
import { svelte } from "@sveltejs/vite-plugin-svelte"; import { svelte } from "@sveltejs/vite-plugin-svelte";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
export default defineConfig({ export default defineConfig({
plugins: [svelte()], plugins: [tailwindcss(), svelte()],
clearScreen: false, clearScreen: false,
server: { server: {
strictPort: true, strictPort: true,