<!--
One worktree, as it stands with one peer.
Three things a person actually needs, in this order: what state each
repository is in (and why, when it is blocked), what would move, and what
needs a decision. Nothing here resolves a conflict by itself — both versions
are kept until somebody chooses.
-->
<script lang="ts">
import { onDestroy } from "svelte";
import { peers } from "../../stores/peers.svelte.js";
import UnifiedDiff from "../content/UnifiedDiff.svelte";
import {
describeBlocked,
describeDirection,
describeUndiffable,
remedyFor,
type PeerSummary,
type PeerWorktree,
type RepositoryStatus,
} from "../../api/types/peer.js";
interface Props {
peer: PeerSummary;
worktree: PeerWorktree;
}
let { peer, worktree }: Props = $props();
const key = $derived(peers.key(peer.id, worktree.id));
const status = $derived(peers.status[key] ?? null);
const conflicts = $derived(peers.conflicts[key] ?? []);
const diff = $derived(peers.diffs[key] ?? null);
let timer: ReturnType<typeof setInterval> | null = null;
// Observed state changes without this page doing anything — the daemon is
// reconciling on its own — so it is polled rather than fetched once.
$effect(() => {
const id = worktree.id;
if (worktree.presence !== "shared") return;
peers.loadStatus(peer.id, id);
if (timer) clearInterval(timer);
timer = setInterval(() => peers.loadStatus(peer.id, id), 3000);
return () => {
if (timer) clearInterval(timer);
timer = null;
};
});
onDestroy(() => {
if (timer) clearInterval(timer);
});
function label(repository: RepositoryStatus): string {
return repository.repository === "" ? "superproject" : repository.repository;
}
function summary(repository: RepositoryStatus): string {
if (repository.blocked) return describeBlocked(repository.blocked);
if (repository.conflicts > 0)
return `${repository.conflicts} conflict(s) waiting for a decision`;
if (repository.pending > 0) return `${repository.pending} file(s) to carry`;
if (repository.branch) return `${repository.branch} · ${repository.head?.slice(0, 7) ?? ""}`;
return "";
}
const busyKey = $derived(`${peer.id}:${worktree.id}`);
/** Which conflict is open. One at a time: reading two is not a thing. */
let opened = $state<string | null>(null);
function keyOf(repository: string, path: string): string {
return peers.conflictKey(peer.id, worktree.id, repository, path);
}
async function toggleDiff(repository: string, path: string) {
const key = keyOf(repository, path);
if (opened === key) {
opened = null;
return;
}
opened = key;
// Always re-read: the peer's file may have changed since the conflict was
// recorded, and a stale version is the one thing a person must not decide
// from.
await peers.loadVersions(peer.id, worktree.id, repository, path);
}
</script>
<section class="relationship">
<header>
<span class="title">{worktree.display_name}</span>
<span class="presence">{worktree.presence.replace("_", " ")}</span>
<span class="spacer"></span>
{#if worktree.presence !== "shared"}
<span class="subtle">
Only one machine has this worktree, so there is nothing to converge.
</span>
{:else if worktree.desired === "disabled"}
<button
class="primary"
disabled={peers.busy === `sync:${busyKey}`}
onclick={() => peers.setDesired(peer.id, worktree.id, "sync")}
>
Carry this worktree
</button>
{:else if worktree.desired === "enabled"}
<button
disabled={peers.busy === `pause:${busyKey}`}
onclick={() => peers.setDesired(peer.id, worktree.id, "pause")}
>
Pause
</button>
{:else}
<button
class="primary"
disabled={peers.busy === `resume:${busyKey}`}
onclick={() => peers.setDesired(peer.id, worktree.id, "resume")}
>
Resume
</button>
{/if}
</header>
{#if worktree.desired !== "disabled" && worktree.presence === "shared"}
<!-- How it is carried, which is a separate question from whether it is.
Manual is the default and stays visible while it is in force: a mode
that only showed when it was the unusual one would make the usual one
invisible. -->
<div class="mode-row">
<div class="segmented" role="group" aria-label="How this worktree is carried">
<button
aria-pressed={worktree.mode === "manual"}
disabled={peers.busy === `mode:${busyKey}`}
title="Differences are listed; nothing crosses until you choose, path by path"
onclick={() => peers.setMode(peer.id, worktree.id, "manual")}
>
Manual
</button>
<button
aria-pressed={worktree.mode === "auto"}
disabled={peers.busy === `mode:${busyKey}`}
title="Everything that is not a conflict crosses by itself"
onclick={() => peers.setMode(peer.id, worktree.id, "auto")}
>
Auto-sync
</button>
</div>
<span class="subtle">
{#if worktree.continuity === "paused"}
Paused: still comparing, moving nothing — not even what has already
been chosen. What is listed below is what resuming would carry.
{:else if worktree.continuity === "manual"}
Nothing crosses until you choose. Each node's Peers section in the
explorer is where the choosing happens.
{:else}
Everything that is not a conflict crosses by itself. Conflicts wait
for you.
{/if}
</span>
</div>
{/if}
{#if status}
<table>
<tbody>
{#each status.repositories as repository (repository.repository)}
<tr class:blocked={repository.observed === "blocked"}>
<td class="repo">{label(repository)}</td>
<td class="observed {repository.observed}">{repository.observed}</td>
<td class="why">
{summary(repository)}
{#if repository.blocked}
{@const remedy = remedyFor(repository.blocked)}
{#if remedy}
<span class="remedy">{remedy}</span>
{/if}
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
{#if diff}
{#each diff.repositories as repository (repository.repository)}
{#if !repository.planned}
<p class="subtle">
{repository.label}: nothing was compared while it is
{repository.observed}.
</p>
{:else if repository.moves.length}
<div class="moves">
<h4>{repository.label}</h4>
<ul>
{#each repository.moves as move (move.path)}
<li>
<span class="direction {move.direction}">
{describeDirection(move.direction)}
</span>
<span class="path">{move.path}</span>
{#if move.size !== null}
<span class="size">{move.size} bytes</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
{/each}
{/if}
{#if conflicts.length}
<div class="conflicts">
<h4>Both machines changed these</h4>
<p class="subtle">
Nothing is chosen for you, and no version is lost until you choose. The
other side's version is overwritten by whichever you keep.
</p>
<ul>
{#each conflicts as conflict (conflict.repository + "/" + conflict.path)}
<li class="conflict">
<div class="conflict-row">
<span class="path">
{conflict.repository === "" ? "" : conflict.repository + "/"}{conflict.path}
</span>
<span class="sides">
here {conflict.here} · there {conflict.there}
</span>
<button
class="reveal"
aria-expanded={opened === keyOf(conflict.repository, conflict.path)}
disabled={peers.busy ===
`versions:${keyOf(conflict.repository, conflict.path)}`}
onclick={() => toggleDiff(conflict.repository, conflict.path)}
>
{opened === keyOf(conflict.repository, conflict.path)
? "Hide the diff"
: "View the diff"}
</button>
<button
disabled={peers.busy === `resolve:${conflict.path}`}
onclick={() =>
peers.resolve(
peer.id,
worktree.id,
conflict.repository,
conflict.path,
"here",
)}
>
Keep this machine's
</button>
<button
disabled={peers.busy === `resolve:${conflict.path}`}
onclick={() =>
peers.resolve(
peer.id,
worktree.id,
conflict.repository,
conflict.path,
"there",
)}
>
Keep {peer.name}'s
</button>
</div>
{#if opened === keyOf(conflict.repository, conflict.path)}
{@const key = keyOf(conflict.repository, conflict.path)}
{@const versions = peers.versions[key]}
{@const failure = peers.versionErrors[key]}
<div class="conflict-diff">
{#if failure}
<!-- The peer holds the other version, so its version cannot
be read while it is unreachable. Said plainly: an empty
diff here would read as agreement. -->
<p class="subtle">
{peer.name}'s version could not be read: {failure}
</p>
{:else if !versions}
<p class="subtle">Reading both versions…</p>
{:else if versions.diff}
<p class="subtle">
<strong>−</strong> this machine · <strong>+</strong>
{peer.name}
</p>
<div class="diff-frame">
<UnifiedDiff diff={versions.diff} />
</div>
{:else if versions.reason}
<p class="subtle">
{describeUndiffable(versions.reason, peer.name)}
</p>
{#if versions.here.present !== versions.there.present}
<p class="subtle">
{versions.here.present
? `This machine's copy is ${versions.here.size} bytes.`
: `${peer.name}'s copy is ${versions.there.size} bytes.`}
</p>
{/if}
{/if}
</div>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
</section>
<style>
.mode-row {
display: flex;
align-items: center;
gap: var(--wb-spacing-10);
margin: var(--wb-spacing-6) 0;
flex-wrap: wrap;
}
.relationship {
border-top: 1px solid var(--vscode-panel-border);
margin-top: var(--wb-spacing-8);
padding-top: var(--wb-spacing-8);
}
header {
display: flex;
align-items: center;
gap: var(--wb-spacing-10);
}
.title {
font-weight: 600;
}
.presence,
.subtle,
.size,
.sides {
color: var(--vscode-descriptionForeground);
font-size: var(--wb-font-size-11);
}
.spacer {
flex: 1;
}
table {
width: 100%;
border-collapse: collapse;
margin: var(--wb-spacing-8) 0;
font-size: var(--wb-font-size-12);
}
td {
padding: var(--wb-spacing-3) var(--wb-spacing-6) var(--wb-spacing-3) 0;
vertical-align: top;
}
.repo {
font-family: var(--vscode-editor-font-family, monospace);
white-space: nowrap;
}
.observed {
text-transform: uppercase;
font-size: var(--wb-font-size-10);
letter-spacing: 0.05em;
white-space: nowrap;
}
.observed.synced {
color: var(--vscode-testing-iconPassed, #2d7d46);
}
.observed.blocked,
.observed.error {
color: var(--vscode-errorForeground);
}
.observed.conflict {
color: var(--vscode-editorWarning-foreground, #cca700);
}
.remedy {
display: block;
color: var(--vscode-descriptionForeground);
}
.moves h4,
.conflicts h4 {
font-size: var(--wb-font-size-11);
margin: var(--wb-spacing-8) 0 var(--wb-spacing-4);
color: var(--vscode-descriptionForeground);
}
ul {
list-style: none;
margin: 0;
padding: 0;
font-size: var(--wb-font-size-12);
}
li {
display: flex;
align-items: baseline;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-2) 0;
}
li.conflict {
display: block;
}
.conflict-row {
display: flex;
align-items: baseline;
gap: var(--wb-spacing-8);
}
.reveal {
background: none;
border: 1px solid var(--vscode-panel-border);
color: var(--vscode-textLink-foreground);
}
.conflict-diff {
margin: var(--wb-spacing-4) 0 var(--wb-spacing-10);
}
.diff-frame {
max-height: 320px;
overflow: auto;
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wb-corner-radius-3);
}
.direction {
font-size: var(--wb-font-size-10);
text-transform: uppercase;
letter-spacing: 0.05em;
min-width: var(--wb-control-size-120);
color: var(--vscode-descriptionForeground);
}
.direction.conflict {
color: var(--vscode-editorWarning-foreground, #cca700);
}
.path {
font-family: var(--vscode-editor-font-family, monospace);
}
button {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
padding: var(--wb-spacing-2) var(--wb-spacing-8);
cursor: pointer;
border-radius: var(--wb-corner-radius-2);
font-size: var(--wb-font-size-11);
}
button.primary {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
</style>