<!--
What the other machines hold under this node, and what to do about it.
The explorer says what is HERE. This says what is different somewhere else,
in the same place, for the same node — which is where somebody is already
looking when the question comes up.
It replaces a line that read "3 conflicts with laptop — open the machine page
to choose a side". That sentence had all three failings at once: it counted
only conflicts (an ordinary difference nobody had decided about was invisible),
it named no path, and it sent the reader somewhere else to act.
# Every difference, not only the ones that need arbitration
In manual mode nothing crosses until somebody says so, and every row here is
waiting for that. In auto mode the ordinary differences carry themselves and
what is left waiting is the conflicts. The list is the same either way; what
changes is how much of it resolves without being touched.
# One path at a time, deliberately
There is no "take everything". A sweep is precisely the thing manual mode
exists to avoid, and both directions of a decision overwrite the other
machine's version.
-->
<script lang="ts">
import { onDestroy } from "svelte";
import { peers } from "../../stores/peers.svelte.js";
import { currentWorktree } from "../../api/scope.svelte.js";
import AppIcon from "../layout/AppIcon.svelte";
import type { AppIconName } from "../layout/AppIcon.svelte";
import UnifiedDiff from "../content/UnifiedDiff.svelte";
import { describeBlocked, type Delta } from "../../api/types/peer.js";
import { isWaiting, rowsUnder, underNode, type DeltaRow } from "./node-peers.js";
interface Props {
/** The node this section belongs to; the worktree root is the empty string. */
path: string;
}
let { path }: Props = $props();
const worktree = $derived(currentWorktree());
/** How often the differences are re-read while this page is open. */
const REFRESH_MS = 5000;
let timer: ReturnType<typeof setInterval> | null = null;
$effect(() => {
const id = worktree;
if (!id) return;
void reload(id);
if (timer) clearInterval(timer);
timer = setInterval(() => void reload(id), REFRESH_MS);
return () => {
if (timer) clearInterval(timer);
timer = null;
};
});
onDestroy(() => {
if (timer) clearInterval(timer);
});
async function reload(id: string) {
if (peers.peers.length === 0) await peers.load();
for (const peer of peers.peers) {
await peers.loadDeltas(peer.id, id);
}
}
/** Every difference under this node, across every peer and repository. */
const rows = $derived(
worktree
? rowsUnder(path, peers.peers, peers.deltas, (peer) => peers.key(peer, worktree))
: [],
);
const waiting = $derived(rows.filter((row) => isWaiting(row.delta)).length);
/** Which peers have something to say about this node at all. */
const involved = $derived.by(() => {
if (!worktree) return [];
return peers.peers
.map((peer) => ({ peer, answer: peers.deltas[peers.key(peer.id, worktree)] }))
.filter((entry) => entry.answer !== undefined);
});
/** Anything a repository could not compare, said rather than left blank. */
const uncompared = $derived.by(() => {
const out: {
peerName: string;
peerId: string;
label: string;
repository: string;
why: string;
/**
* The commit that machine is on, when the ONLY thing between the two is
* which commit each is at. Present exactly when there is something to
* offer: a different branch, a detached HEAD or a missing repository is
* not fixed by moving to a commit.
*/
theirCommit: string | null;
}[] = [];
if (!worktree) return out;
for (const peer of peers.peers) {
const answer = peers.deltas[peers.key(peer.id, worktree)];
if (!answer || answer.continuity === "off") continue;
for (const repository of answer.repositories) {
if (repository.compared) continue;
if (!underNode(path, repository.repository) && !path.startsWith(repository.repository))
continue;
out.push({
peerName: peer.name,
peerId: peer.id,
label: repository.label,
repository: repository.repository,
why: repository.blocked
? describeBlocked(repository.blocked)
: "not compared yet",
theirCommit:
repository.blocked?.reason === "head_mismatch" ? repository.blocked.there : null,
});
}
}
return out;
});
/**
* The repository whose move is being confirmed, and what it costs.
*
* The first attempt carries no consent: the server computes the cost and
* refuses, and its refusal is the sentence a person is asked to approve.
* Nothing is guessed here about what would be lost — the machine that is
* about to do it is the only thing that can know.
*/
let confirming = $state<{
key: string;
peerName: string;
label: string;
peerId: string;
repository: string;
commit: string;
cost: string;
} | null>(null);
function uncomparedKey(entry: { peerId: string; repository: string }): string {
return `${entry.peerId}:${entry.repository}`;
}
async function align(entry: {
peerId: string;
peerName: string;
label: string;
repository: string;
theirCommit: string | null;
}) {
if (!worktree || !entry.theirCommit) return;
const refusal = await peers.alignToPeer(
entry.peerId,
worktree,
entry.repository,
entry.theirCommit,
// Nothing consented to yet. If anything would be lost the server says
// what, and that sentence is what gets confirmed.
{ discardChanges: false, discardCommits: false },
);
if (refusal === null) {
confirming = null;
return;
}
confirming = {
key: uncomparedKey(entry),
peerName: entry.peerName,
label: entry.label,
peerId: entry.peerId,
repository: entry.repository,
commit: entry.theirCommit,
cost: refusal,
};
}
/** Go ahead having read what it costs. */
async function alignAnyway() {
if (!worktree || !confirming) return;
const held = confirming;
const refusal = await peers.alignToPeer(
held.peerId,
worktree,
held.repository,
held.commit,
{ discardChanges: true, discardCommits: true },
);
// A second refusal is a different problem — the peer moved, the commit is
// not here — and it replaces the sentence rather than closing the box.
confirming = refusal === null ? null : { ...held, cost: refusal };
}
/**
* The sign for one difference's direction, as an icon.
*
* Icons rather than characters: these sit in a row beside a path, and a
* character inherits whatever font that row is in and lands wherever its
* metrics put it. A deletion is the direction plus an `x`, which is two
* icons rather than a glyph pair.
*/
function arrowIcon(delta: Delta): AppIconName {
switch (delta.direction) {
case "send":
case "send_deletion":
return "arrow-right";
case "receive":
case "receive_deletion":
return "arrow-left";
case "conflict":
return "triangle-alert";
}
}
/** Whether this difference is a deletion, which the sign says with an `x`. */
function isDeletion(delta: Delta): boolean {
return delta.direction === "send_deletion" || delta.direction === "receive_deletion";
}
function what(delta: Delta): string {
switch (delta.direction) {
case "send":
return "only here, or newer here";
case "receive":
return "only there, or newer there";
case "send_deletion":
return "deleted here";
case "receive_deletion":
return "deleted there";
case "conflict":
return "both machines changed it";
}
}
function said(delta: Delta): string {
if (delta.decision === "here") return "keeping this machine's version";
if (delta.decision === "there") return "taking the other machine's version";
if (delta.dismissed) return "left alone until either side changes it";
return "";
}
/** Which row's diff is open. One at a time: reading two is not a thing. */
let opened = $state<string | null>(null);
function rowKey(row: DeltaRow): string {
return `${row.peer}:${row.repository}/${row.inRepository}`;
}
async function compare(row: DeltaRow) {
const key = rowKey(row);
if (opened === key) {
opened = null;
return;
}
opened = key;
if (!worktree) return;
// Always re-read: the other machine's file may have changed since the
// comparison, and a stale version is the one thing nobody must decide from.
await peers.loadVersions(row.peer, worktree, row.repository, row.inRepository);
}
function versionsKey(row: DeltaRow): string {
return peers.conflictKey(row.peer, worktree ?? "", row.repository, row.inRepository);
}
async function act(row: DeltaRow, what: "here" | "there" | "dismiss" | "reconsider") {
if (!worktree) return;
await peers.decide(row.peer, worktree, row.repository, row.inRepository, what);
}
function busy(row: DeltaRow): boolean {
return peers.busy === `delta:${row.peer}:${row.repository}/${row.inRepository}`;
}
</script>
{#if involved.length > 0}
<section class="peers-section">
<div class="section-header">
<h2 class="section-heading">Peers</h2>
<span class="peers-facts">
{#each involved as entry (entry.peer.id)}
<span
class="peer-fact"
class:off={entry.answer?.continuity === "off"}
title="{entry.peer.name}: {entry.answer?.continuity === 'off'
? 'file sync is switched off'
: entry.answer?.continuity === 'auto'
? 'differences that are not conflicts cross by themselves'
: entry.answer?.continuity === 'paused'
? 'paused — nothing crosses'
: 'nothing crosses until you choose'}"
>
{entry.peer.name} · {entry.answer?.continuity}
</span>
{/each}
</span>
</div>
{#each uncompared as entry (uncomparedKey(entry))}
<p class="empty-hint">
{entry.peerName} · {entry.label}: nothing compared — {entry.why}
<!-- Offered only for a difference moving to a commit actually fixes.
A different branch, a detached HEAD or a missing repository are
not, and a button that did nothing would be worse than none. -->
{#if entry.theirCommit}
<button
class="align"
disabled={peers.busy === `align:${entry.peerId}:${entry.repository}`}
onclick={() => void align(entry)}
title="Put {entry.label} at the commit {entry.peerName} is on"
>go to {entry.peerName}'s commit</button>
{/if}
</p>
{#if confirming && confirming.key === uncomparedKey(entry)}
<!-- What it costs, in the words of the machine that computed it.
Shown before anything is touched, because both losses here are
ones a person only notices afterwards. -->
<div class="align-confirm">
<p class="align-cost">{confirming.cost}</p>
<div class="align-actions">
<button
class="align-go"
disabled={peers.busy === `align:${entry.peerId}:${entry.repository}`}
onclick={() => void alignAnyway()}
>Move anyway</button>
<button onclick={() => (confirming = null)}>Leave it</button>
</div>
</div>
{/if}
{/each}
{#if rows.length === 0}
<p class="empty-hint">
Nothing here differs from any paired machine.
</p>
{:else}
{#if waiting > 0}
<p class="waiting">
{waiting} difference{waiting === 1 ? "" : "s"} waiting for you.
</p>
{/if}
<ul class="delta-list">
{#each rows as row (rowKey(row))}
<li class="delta" class:settled={row.delta.decision !== null || row.delta.dismissed}>
<div class="delta-row">
<span class="delta-arrow" title={what(row.delta)}>
<AppIcon name={arrowIcon(row.delta)} size={11} />
{#if isDeletion(row.delta)}<AppIcon name="x" size={10} />{/if}
</span>
<button class="delta-path" onclick={() => void compare(row)} title="Compare the two versions">
{row.shown}
</button>
<span class="delta-peer">{row.peerName}</span>
{#if row.delta.size !== null}
<span class="delta-size">{row.delta.size} B</span>
{/if}
</div>
<div class="delta-actions">
{#if row.delta.decision !== null || row.delta.dismissed}
<span class="delta-said">{said(row.delta)}</span>
<button class="linky" disabled={busy(row)} onclick={() => void act(row, "reconsider")}>
Undo
</button>
{:else}
<span class="delta-what">{what(row.delta)}</span>
<button
class="chip"
disabled={busy(row)}
title="Carry the other machine's version here"
onclick={() => void act(row, "there")}
>
Take theirs
</button>
<button
class="chip"
disabled={busy(row)}
title="Carry this machine's version there"
onclick={() => void act(row, "here")}
>
Keep mine
</button>
<button
class="chip"
disabled={busy(row)}
title="Leave it alone — it comes back if either machine changes it"
onclick={() => void act(row, "dismiss")}
>
Leave
</button>
{/if}
</div>
{#if opened === rowKey(row)}
{@const versions = peers.versions[versionsKey(row)]}
{@const failure = peers.versionErrors[versionsKey(row)]}
<div class="delta-diff">
{#if failure}
<p class="empty-hint">Could not read the other machine's version: {failure}</p>
{:else if !versions}
<p class="empty-hint">Reading both versions…</p>
{:else if versions.diff}
<UnifiedDiff diff={versions.diff} />
{:else}
<p class="empty-hint">
No diff to show — {versions.reason ?? "the two versions cannot be compared"}.
Here {versions.here.present ? `${versions.here.size} B` : "absent"}, there
{versions.there.present ? ` ${versions.there.size} B` : " absent"}.
</p>
{/if}
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</section>
{/if}
<style>
.peers-section {
margin-top: var(--wb-spacing-20);
}
.peers-facts {
display: flex;
gap: var(--wb-spacing-8);
flex-wrap: wrap;
}
.peer-fact {
font-size: var(--wb-font-size-11);
color: var(--vscode-descriptionForeground);
}
.peer-fact.off {
opacity: 0.6;
}
.waiting {
margin: var(--wb-spacing-6) 0 var(--wb-spacing-4);
font-size: var(--wb-font-size-12);
color: var(--vscode-editor-foreground);
}
.delta-list {
list-style: none;
margin: var(--wb-spacing-4) 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--wb-spacing-2);
}
.delta {
padding: var(--wb-spacing-4) 0;
border-bottom: 1px solid var(--vscode-panel-border);
}
/* Decided or dismissed: still listed, because it is still a difference —
and quieter, because it is no longer asking. */
.delta.settled {
opacity: 0.72;
}
.delta-row {
display: flex;
align-items: baseline;
gap: var(--wb-spacing-8);
}
.delta-arrow {
width: var(--wb-control-size-24);
font-family: var(--wsb-font-mono);
font-size: var(--wb-font-size-11);
color: var(--vscode-descriptionForeground);
display: inline-flex;
align-items: center;
gap: 1px;
}
.delta-path {
flex: 1;
background: none;
border: none;
padding: 0;
text-align: left;
font-family: var(--wsb-font-mono);
font-size: var(--wb-font-size-11-5);
color: var(--vscode-textLink-foreground, #4daafc);
cursor: pointer;
overflow-wrap: anywhere;
}
.delta-path:hover {
text-decoration: underline;
}
.delta-peer,
.delta-size,
.delta-what,
.delta-said {
font-size: var(--wb-font-size-10-5);
color: var(--vscode-descriptionForeground);
}
.delta-actions {
display: flex;
align-items: center;
gap: var(--wb-spacing-6);
padding: var(--wb-spacing-2) 0 0 var(--wb-spacing-32);
flex-wrap: wrap;
}
.delta-what,
.delta-said {
flex: 1;
}
.delta-diff {
margin: var(--wb-spacing-6) 0 var(--wb-spacing-2) var(--wb-spacing-32);
max-height: 320px;
overflow: auto;
}
.align {
margin-left: 6px;
background: none;
border: 1px solid var(--vscode-panel-border, rgba(128, 128, 128, 0.3));
border-radius: 3px;
color: var(--vscode-textLink-foreground, #4daafc);
font-size: 11px;
padding: 0 6px;
cursor: pointer;
}
.align:disabled {
opacity: 0.5;
cursor: default;
}
.align-confirm {
margin: 4px 0 8px;
padding: 8px;
border: 1px solid var(--vscode-errorForeground, #f14c4c);
border-radius: 4px;
}
.align-cost {
margin: 0 0 6px;
font-size: 11px;
line-height: 1.5;
}
.align-actions {
display: flex;
gap: 6px;
}
.align-go {
background: var(--vscode-errorForeground, #f14c4c);
color: var(--vscode-editor-background, #fff);
border: none;
border-radius: 2px;
padding: 2px 8px;
font-size: 11px;
cursor: pointer;
}
</style>