This commit is contained in:
Christoph Brandau
2026-06-27 17:42:58 +02:00
parent ef1974f31f
commit 25700821d1
6 changed files with 976 additions and 143 deletions
+563 -122
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import {
AlertCircle,
ArrowRight,
@@ -37,6 +39,7 @@
push,
readConflict,
resolveConflict,
resolveConflictSide,
restoreFileFromCommit,
restoreFiles,
restoreToCommit,
@@ -74,6 +77,35 @@
| { kind: "text"; lines: string[] }
| { kind: "conflict"; index: number; oursLines: string[]; theirsLines: string[] };
type PreparedResolution =
| { kind: "content"; content: string }
| { kind: "side"; side: "ours" | "theirs" };
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;
let repoPath = "";
let activeRepoPath = "";
let status: GitStatus | null = null;
@@ -83,6 +115,7 @@
let selectedExplorerPath = "";
let selectedExplorerKind: ExplorerNodeKind = "file";
let expandedExplorerPaths = new Set<string>();
let expandedCommitHashes = new Set<string>();
let fileHistory: GitCommit[] = [];
let commitMessage = "";
let errorMessage = "";
@@ -99,6 +132,13 @@
let conflictParts: ConflictPart[] = [];
let conflictChoices: (ConflictChoice | null)[] = [];
let manualMode = false;
let binarySide: "ours" | "theirs" | null = null;
let preparedResolutions: Record<string, PreparedResolution> = {};
let autoRefreshEnabled = true;
let autoRefreshInFlight = false;
let lastStatusFingerprint = "";
const AUTO_REFRESH_INTERVAL = 4000;
let autoRefreshTimer: ReturnType<typeof setInterval> | undefined;
$: isBusy = operation.length > 0;
$: hasRepository = activeRepoPath.length > 0 && status !== null;
@@ -124,6 +164,15 @@
? resolveContent
: buildResolution(conflictParts, conflictChoices);
$: resolveHasMarkers = /^<{7}/m.test(resolvedContent) || /^>{7}/m.test(resolvedContent);
$: preparedCount = Object.keys(preparedResolutions).length;
$: currentPrepared = conflictTarget.length > 0 && preparedResolutions[conflictTarget] != null;
$: canMarkResolved =
!!conflict && !isBusy && (conflict.binary ? binarySide != null : !resolveHasMarkers);
$: localBranches = branches.filter((branch) => !branch.remote);
$: remoteBranches = branches.filter((branch) => branch.remote);
$: graph = computeGraph(commits);
$: graphRows = graph.rows;
$: graphWidth = Math.max(graph.columns, 1) * GRAPH_LANE;
$: diffByPath = comparison ? buildDiffByPath(comparison.patch) : new Map<string, string>();
$: selectedDiffFile = comparison?.files.find((file) => file.path === selectedDiffPath) ?? null;
$: selectedDiffPatch = selectedDiffFile ? diffByPath.get(selectedDiffFile.path) ?? "" : "";
@@ -137,8 +186,71 @@
status = nextStatus;
activeRepoPath = nextStatus.repo_path || activeRepoPath || repoPath.trim();
repoPath = activeRepoPath;
lastStatusFingerprint = statusFingerprint(nextStatus);
}
function statusFingerprint(value: GitStatus): string {
return JSON.stringify({
branch: value.current_branch,
upstream: value.upstream,
ahead: value.ahead,
behind: value.behind,
files: value.files,
});
}
async function autoRefreshTick() {
if (
!autoRefreshEnabled ||
!activeRepoPath ||
isBusy ||
autoRefreshInFlight ||
resolveDialogOpen ||
compareDialogOpen
) {
return;
}
autoRefreshInFlight = true;
try {
const nextStatus = await getStatus(activeRepoPath);
// Cheap guard: only do the heavier refresh when something actually changed.
if (statusFingerprint(nextStatus) === lastStatusFingerprint) {
return;
}
applyStatus(nextStatus);
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
} catch {
// Ignore transient errors during background refresh (e.g. mid-operation).
} finally {
autoRefreshInFlight = false;
}
}
function toggleAutoRefresh() {
autoRefreshEnabled = !autoRefreshEnabled;
if (autoRefreshEnabled) {
void autoRefreshTick();
}
}
onMount(() => {
autoRefreshTimer = setInterval(() => {
void autoRefreshTick();
}, AUTO_REFRESH_INTERVAL);
});
onDestroy(() => {
if (autoRefreshTimer) {
clearInterval(autoRefreshTimer);
}
});
function buildExplorerTree(files: GitRepositoryFile[]): ExplorerNode[] {
const roots: ExplorerNode[] = [];
const folders = new Map<string, ExplorerNode>();
@@ -375,6 +487,7 @@
selectedExplorerPath = "";
selectedExplorerKind = "file";
expandedExplorerPaths = new Set<string>();
expandedCommitHashes = new Set<string>();
fileHistory = [];
compareFrom = "";
compareTo = "";
@@ -388,6 +501,8 @@
conflictParts = [];
conflictChoices = [];
manualMode = false;
binarySide = null;
preparedResolutions = {};
await refreshBranchList(activeRepoPath);
await refreshCommitHistory(activeRepoPath);
await refreshExplorerFiles(activeRepoPath);
@@ -639,10 +754,29 @@
async function loadConflict(file: string) {
conflictTarget = file;
conflict = await readConflict(activeRepoPath, file);
const prepared = preparedResolutions[file];
if (conflict.binary) {
conflictParts = [];
conflictChoices = [];
manualMode = false;
resolveContent = "";
binarySide = prepared && prepared.kind === "side" ? prepared.side : null;
return;
}
binarySide = null;
conflictParts = parseConflicts(conflict.content);
conflictChoices = conflictParts.filter((part) => part.kind === "conflict").map(() => null);
manualMode = false;
resolveContent = conflict.content;
if (prepared && prepared.kind === "content") {
// Re-open an already prepared text resolution for review in manual mode.
resolveContent = prepared.content;
manualMode = true;
} else {
resolveContent = conflict.content;
manualMode = false;
}
}
function parseConflicts(content: string): ConflictPart[] {
@@ -765,6 +899,92 @@
manualMode = false;
}
function graphColX(column: number): number {
return column * GRAPH_LANE + GRAPH_LANE / 2;
}
function laneColor(column: number): string {
return GRAPH_COLORS[((column % GRAPH_COLORS.length) + GRAPH_COLORS.length) % GRAPH_COLORS.length];
}
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();
// Column for this commit: the first lane already waiting for it, else a
// free slot, else a brand new lane on the right.
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);
}
// Lanes that were waiting for this commit converge into it.
for (let k = 0; k < after.length; k += 1) {
if (after[k] === commit.hash) {
after[k] = null;
}
}
// The commit continues along its first parent in the same column.
after[col] = commit.parents.length > 0 ? commit.parents[0] : null;
// Slots that leave the commit dot (first parent + extra merge parents).
const fromCommit = new Set<number>([col]);
for (let p = 1; p < commit.parents.length; p += 1) {
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 += 1) {
const target = before[k];
if (target == null) {
continue;
}
const toCol = target === commit.hash ? col : k;
top.push({ fromCol: k, toCol, color: laneColor(k) });
}
const bottom: GraphSegment[] = [];
for (let k = 0; k < after.length; k += 1) {
if (after[k] == null) {
continue;
}
const fromCol = fromCommit.has(k) ? col : k;
bottom.push({ fromCol, toCol: k, color: laneColor(k) });
}
rows.push({ dotCol: col, dotColor: laneColor(col), top, bottom });
// Trim trailing empty lanes so the graph stays compact.
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 displayLine(line: string): string {
// Strip a trailing CR for display only; the stored line keeps it so the
// rebuilt file preserves its original line endings.
@@ -786,6 +1006,7 @@
const first = conflictedFiles[0].path;
await runOperation("Loading conflicts", async () => {
preparedResolutions = {};
resolveDialogOpen = true;
await loadConflict(first);
});
@@ -801,29 +1022,59 @@
});
}
async function saveResolution() {
if (!activeRepoPath || !conflictTarget || isBusy) {
function chooseBinarySide(side: "ours" | "theirs") {
binarySide = side;
}
async function markCurrentResolved() {
if (!conflict || !conflictTarget || !canMarkResolved) {
return;
}
if (
resolveHasMarkers &&
!window.confirm(
"Conflict markers (<<<<<<< / >>>>>>>) are still present. Mark this file as resolved anyway?",
)
) {
return;
}
const prepared: PreparedResolution = conflict.binary
? { kind: "side", side: binarySide as "ours" | "theirs" }
: { kind: "content", content: resolvedContent };
const resolvedPath = conflictTarget;
const content = resolvedContent;
await runOperation(`Resolving ${resolvedPath}`, async () => {
const nextStatus = await resolveConflict(activeRepoPath, resolvedPath, content);
applyStatus(nextStatus);
preparedResolutions = { ...preparedResolutions, [resolvedPath]: prepared };
// Jump to the next file that still needs a decision, if any.
const next = conflictedFiles.find(
(file) => file.path !== resolvedPath && preparedResolutions[file.path] == null,
);
if (next) {
await runOperation(`Loading ${next.path}`, async () => {
await loadConflict(next.path);
});
}
}
async function applyPreparedResolutions() {
if (!activeRepoPath || isBusy || preparedCount === 0) {
return;
}
const entries = Object.entries(preparedResolutions);
await runOperation(`Resolving ${entries.length} ${entries.length === 1 ? "file" : "files"}`, async () => {
let nextStatus: GitStatus | null = null;
for (const [file, prepared] of entries) {
nextStatus =
prepared.kind === "side"
? await resolveConflictSide(activeRepoPath, file, prepared.side)
: await resolveConflict(activeRepoPath, file, prepared.content);
}
preparedResolutions = {};
if (nextStatus) {
applyStatus(nextStatus);
}
await refreshExplorerFiles(activeRepoPath);
await refreshFileHistory(activeRepoPath);
const remaining = nextStatus.files.filter(
const remaining = (nextStatus?.files ?? status?.files ?? []).filter(
(file) => file.staged === "conflicted" || file.unstaged === "conflicted",
);
@@ -835,12 +1086,26 @@
conflictParts = [];
conflictChoices = [];
manualMode = false;
binarySide = null;
} else {
await loadConflict(remaining[0].path);
}
});
}
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 closeResolveDialog() {
resolveDialogOpen = false;
}
@@ -997,6 +1262,16 @@
return `${file.old_path} -> ${file.path}`;
}
function toggleCommitFiles(hash: string) {
const next = new Set(expandedCommitHashes);
if (next.has(hash)) {
next.delete(hash);
} else {
next.add(hash);
}
expandedCommitHashes = next;
}
function displayCommitFile(file: GitCommitFile): string {
if (!file.old_path) {
return file.path;
@@ -1062,6 +1337,19 @@
<RefreshCw class={operation === "Refreshing" ? "spin" : ""} size={16} aria-hidden="true" />
Refresh
</button>
<button
type="button"
class="auto-refresh-toggle"
class:active={autoRefreshEnabled}
onclick={toggleAutoRefresh}
aria-pressed={autoRefreshEnabled}
title={autoRefreshEnabled
? "Auto refresh is on — changes appear automatically"
: "Auto refresh is off"}
>
<RefreshCw class={autoRefreshInFlight ? "spin" : ""} size={16} aria-hidden="true" />
Auto {autoRefreshEnabled ? "on" : "off"}
</button>
</div>
</header>
@@ -1103,40 +1391,60 @@
<span class="counter">{branches.length}</span>
</div>
{#snippet branchRow(branch: GitBranchInfo)}
<article class:current={branch.current} class="branch-row">
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>{branch.remote ? "remote" : "local"}</span>
</div>
</div>
{#if branch.current}
<span class="active-pill">Current</span>
{:else}
<div class="branch-actions">
<button type="button" onclick={() => checkout(branch)} disabled={isBusy}>Checkout</button>
<button
type="button"
onclick={() => merge(branch)}
disabled={isBusy}
title="Merge branch into current branch"
>
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/snippet}
{#if !hasRepository}
<p class="empty-note">Open a repository to list branches.</p>
{:else if branches.length === 0}
<p class="empty-note">No branches returned.</p>
{:else}
<div class="branch-list">
{#each branches as branch (branch.name)}
<article class:current={branch.current} class="branch-row">
<div class="branch-info">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{branch.name}</strong>
<span>{branch.remote ? "remote" : "local"}</span>
</div>
</div>
{#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)}
{@render branchRow(branch)}
{/each}
{/if}
{#if branch.current}
<span class="active-pill">Current</span>
{:else}
<div class="branch-actions">
<button type="button" onclick={() => checkout(branch)} disabled={isBusy}>Checkout</button>
<button
type="button"
onclick={() => merge(branch)}
disabled={isBusy}
title="Merge branch into current branch"
>
<GitMerge size={15} aria-hidden="true" />
Merge
</button>
</div>
{/if}
</article>
{/each}
{#if remoteBranches.length > 0}
<div class="branch-group-label">
<span>Remote</span>
<span class="branch-group-count">{remoteBranches.length}</span>
</div>
{#each remoteBranches as branch (branch.name)}
{@render branchRow(branch)}
{/each}
{/if}
</div>
{/if}
</section>
@@ -1393,73 +1701,6 @@
{/if}
</section>
<section class="history-panel" aria-label="Commit history">
<div class="section-heading">
<div>
<span class="eyebrow">History</span>
<h2>Commits</h2>
</div>
<span class="counter">{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">
{#each commits as item (item.hash)}
<article class="commit-row">
<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>
{#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-file-list" aria-label="Changed files">
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
<button
type="button"
class="commit-file-button"
onclick={() => restoreCommitFile(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 class="commit-actions">
<time datetime={item.date}>{formatCommitDate(item.date)}</time>
<button
type="button"
onclick={() => restoreCommit(item)}
disabled={isBusy}
title="Reset current branch to this commit"
>
<RotateCcw size={15} aria-hidden="true" />
Restore
</button>
</div>
</article>
{/each}
</div>
{/if}
</section>
</aside>
</div>
@@ -1533,6 +1774,137 @@
{/if}
</section>
</section>
<aside class="history-aside" aria-label="Commit history">
<section class="history-panel" aria-label="Commit history">
<div class="section-heading">
<div>
<span class="eyebrow">History</span>
<h2>Commits</h2>
</div>
<span class="counter">{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">
{#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
type="button"
class="commit-files-toggle"
onclick={() => toggleCommitFiles(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
type="button"
class="commit-file-button"
onclick={() => restoreCommitFile(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
type="button"
onclick={() => restoreCommit(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>
</aside>
</section>
{#if compareDialogOpen && comparison}
@@ -1641,13 +2013,19 @@
<button
type="button"
class:active={conflictTarget === file.path}
class:prepared={preparedResolutions[file.path] != null}
class="dialog-file-row"
onclick={() => selectConflictFile(file.path)}
disabled={isBusy}
title={file.path}
>
<span class="status-badge conflicted">conflicted</span>
<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>
@@ -1656,7 +2034,48 @@
{#if !conflict}
<div class="blank-state">Select a file to resolve.</div>
{:else}
<div class="resolve-toolbar">
{#if conflict.binary}
<div class="resolve-binary">
<div class="resolve-binary-note">
<AlertCircle size={16} aria-hidden="true" />
<span>
Binary file — it cannot be merged line by line. Pick which version
to keep, then mark it resolved.
</span>
</div>
<div class="resolve-binary-options">
<button
type="button"
class="resolve-binary-card ours"
class:active={binarySide === "ours"}
onclick={() => chooseBinarySide("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
type="button"
class="resolve-binary-card theirs"
class:active={binarySide === "theirs"}
onclick={() => chooseBinarySide("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>
{:else}
<div class="resolve-toolbar">
{#if manualMode}
<button type="button" onclick={disableManualEdit} disabled={isBusy}>
Back to guided
@@ -1773,27 +2192,49 @@
{/if}
{/each}
</div>
{/if}
{/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
class="primary-button"
type="button"
onclick={saveResolution}
disabled={isBusy}
onclick={markCurrentResolved}
disabled={!canMarkResolved}
title="Prepare this file's resolution (applied with Apply resolved)"
>
{#if operation.startsWith("Resolving")}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Save &amp; mark resolved
<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="primary-button"
type="button"
onclick={applyPreparedResolutions}
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>
+254 -7
View File
@@ -127,6 +127,17 @@ textarea {
gap: 8px;
}
.auto-refresh-toggle.active {
border-color: #2f8f6a;
color: #1b6e4f;
background: #e6f6ee;
}
.auto-refresh-toggle.active:hover:not(:disabled) {
border-color: #25785a;
background: #d8f0e3;
}
.primary-button {
border-color: #256f8f;
color: #ffffff;
@@ -180,11 +191,16 @@ textarea {
.workspace {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
grid-template-columns: 320px minmax(0, 1fr) 440px;
min-height: 0;
gap: 10px;
}
.history-aside {
display: grid;
min-height: 0;
}
.left-sidebar {
display: grid;
grid-template-rows: minmax(220px, 0.9fr) minmax(260px, 1.1fr);
@@ -286,6 +302,34 @@ textarea {
padding: 8px;
}
.branch-group-label {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin: 2px 2px 6px;
color: #697681;
font-size: 11px;
font-weight: 800;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.branch-group-label:not(:first-child) {
margin-top: 12px;
}
.branch-group-count {
display: inline-flex;
align-items: center;
min-height: 18px;
padding: 0 7px;
border-radius: 999px;
color: #4f5d66;
background: #edf0f2;
font-size: 11px;
}
.branch-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
@@ -485,7 +529,7 @@ textarea {
.side-stack {
display: grid;
grid-template-rows: auto minmax(210px, 0.75fr) minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
min-height: 0;
gap: 10px;
}
@@ -717,6 +761,28 @@ textarea {
white-space: nowrap;
}
.commit-files {
display: grid;
gap: 6px;
}
.commit-files-toggle {
justify-content: flex-start;
gap: 5px;
min-height: 26px;
padding: 0 8px;
border-color: transparent;
background: transparent;
color: #4f5d66;
font-size: 12px;
font-weight: 700;
}
.commit-files-toggle:hover:not(:disabled) {
border-color: #c7ced4;
background: #f6f8f9;
}
.commit-file-list {
display: grid;
gap: 5px;
@@ -769,6 +835,75 @@ textarea {
gap: 6px;
}
.graph-list {
padding: 0;
}
.graph-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 0;
margin: 0;
padding: 0;
border: none;
border-radius: 0;
background: none;
}
.graph-row + .graph-row {
margin-top: 0;
}
.graph-gutter {
position: relative;
align-self: stretch;
min-height: 100%;
background: #fdfdfe;
}
.graph-svg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.graph-dot {
position: absolute;
top: 50%;
width: 11px;
height: 11px;
border-radius: 999px;
background: var(--dot-color, #2f6fb0);
border: 2px solid #ffffff;
box-shadow: 0 0 0 1px var(--dot-color, #2f6fb0);
transform: translate(-50%, -50%);
}
.graph-dot.merge {
width: 13px;
height: 13px;
background: #ffffff;
border-color: var(--dot-color, #2f6fb0);
box-shadow: 0 0 0 1px var(--dot-color, #2f6fb0);
}
.commit-body {
display: grid;
gap: 8px;
min-width: 0;
padding: 11px 12px;
}
.graph-row + .graph-row .commit-body {
border-top: 1px solid #e7ebee;
}
.graph-row:hover .commit-body {
background: #f6f9fb;
}
.compare-panel {
display: grid;
grid-template-rows: auto auto;
@@ -954,6 +1089,46 @@ textarea {
max-height: none;
}
.dialog-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 16px;
border-top: 1px solid #dce1e5;
background: #f4f6f8;
}
.dialog-footer-info {
color: #4f5d66;
font-size: 13px;
font-weight: 700;
}
.dialog-file-row.prepared {
border-color: #b9ddc6;
background: #f0f8f3;
}
.dialog-file-row.prepared.active {
border-color: #4aa777;
background: #e4f4ea;
}
.dialog-file-row svg {
color: #1f7a4d;
}
.prepared-tag {
display: inline-flex;
align-items: center;
gap: 4px;
margin-right: auto;
color: #1f7a4d;
font-size: 12px;
font-weight: 700;
}
.dialog-title {
margin: 2px 0 0;
color: #202326;
@@ -961,8 +1136,8 @@ textarea {
}
.resolve-editor {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
display: flex;
flex-direction: column;
min-height: 0;
padding: 10px;
gap: 8px;
@@ -999,8 +1174,8 @@ textarea {
.resolve-textarea {
width: 100%;
height: 100%;
min-height: 0;
flex: 1 1 auto;
min-height: 120px;
padding: 10px;
border: 1px solid #c4ccd3;
border-radius: 6px;
@@ -1018,6 +1193,7 @@ textarea {
display: grid;
align-content: start;
gap: 8px;
flex: 1 1 auto;
min-height: 0;
padding: 4px;
overflow: auto;
@@ -1148,6 +1324,67 @@ textarea {
color: #3a444c;
}
.resolve-binary {
display: grid;
align-content: start;
gap: 12px;
padding: 4px;
}
.resolve-binary-note {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
border: 1px solid #e3b778;
border-radius: 6px;
color: #8a4c0e;
background: #fff6e7;
font-size: 13px;
}
.resolve-binary-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.resolve-binary-card {
display: grid;
gap: 6px;
justify-items: start;
padding: 14px;
border: 1px solid #dce1e5;
border-left-width: 3px;
border-radius: 8px;
text-align: left;
}
.resolve-binary-card.ours {
border-left-color: #4aa777;
background: #f1faf4;
}
.resolve-binary-card.theirs {
border-left-color: #5a8bd0;
background: #f1f5fc;
}
.resolve-binary-card.active {
box-shadow: 0 0 0 2px #2f6fb0 inset;
border-color: #2f6fb0;
}
.resolve-binary-card strong {
color: #202326;
font-size: 18px;
}
.resolve-binary-hint {
color: #6c7882;
font-size: 12px;
}
.resolve-actions {
display: flex;
align-items: center;
@@ -1250,9 +1487,15 @@ textarea {
}
}
@media (max-width: 1320px) {
.workspace {
grid-template-columns: 300px minmax(0, 1fr) 380px;
}
}
@media (max-width: 1120px) {
.workspace {
grid-template-columns: 300px minmax(0, 1fr);
grid-template-columns: 260px minmax(0, 1fr) 330px;
}
.status-grid {
@@ -1309,6 +1552,10 @@ textarea {
justify-content: flex-start;
}
.history-aside {
min-height: 480px;
}
.branches {
min-height: 260px;
}
+8
View File
@@ -108,3 +108,11 @@ export function resolveConflict(
): Promise<GitStatus> {
return invoke<GitStatus>("resolve_conflict", { path, file, content });
}
export function resolveConflictSide(
path: string,
file: string,
side: "ours" | "theirs",
): Promise<GitStatus> {
return invoke<GitStatus>("resolve_conflict_side", { path, file, side });
}
+4
View File
@@ -38,6 +38,7 @@ export interface GitCommit {
author_email: string;
date: string;
refs: string[];
parents: string[];
files: GitCommitFile[];
}
@@ -76,4 +77,7 @@ export interface ConflictFile {
ours: string | null;
theirs: string | null;
base: string | null;
binary: boolean;
ours_size: number | null;
theirs_size: number | null;
}