<script lang="ts">
// A refactor's plan, read before it is carried out.
//
// The list is fetched a page at a time and appended as the reader scrolls:
// a rename across a worktree runs to thousands of rows, and asking for them
// all at once costs a request that never returns and a tab that never
// paints. Nothing here writes until Apply, and Apply is refused outright if
// the tree moved since the plan was read.
import { refactors as refactorsStore } from "../../stores/refactor-tabs.svelte.js";
import { describeChange, barrierGroups, BARRIERS } from "../../stores/refactor.svelte.js";
import { workbench } from "../../stores/workbench.svelte.js";
import AppIcon from "../layout/AppIcon.svelte";
import { scoped } from "../../stores/worktree-scope.svelte.js";
const refactors = scoped(refactorsStore);
let { tabKey, planId, root }: { tabKey: string; planId: string; root: string } = $props();
const refactor = $derived(refactors.get(planId));
const unreadable = $derived(refactor?.plan?.unreadable ?? []);
const groups = $derived(barrierGroups(unreadable));
const unreadableCount = $derived(unreadable.length);
const actionable = $derived(
unreadable.filter((one) => BARRIERS[one.barrier].actionable).length,
);
let barriersOpen = $state(false);
/** Which files the reader has opened. */
let expanded = $state<Set<string>>(new Set());
function toggle(path: string) {
const next = new Set(expanded);
if (next.has(path)) {
next.delete(path);
} else {
next.add(path);
// Fetched on first open, not with the plan: diffing four thousand files
// to show the two somebody reads would make the preview the slow part.
void refactor?.diffOf(path);
}
expanded = next;
}
/** Walk the tree again, after the reader has changed something. */
async function rePreview() {
await refactor?.preview();
}
/** How close to the bottom starts the next page, in pixels. */
const NEAR_BOTTOM = 400;
function onScroll(event: Event) {
const list = event.currentTarget as HTMLElement;
const remaining = list.scrollHeight - list.scrollTop - list.clientHeight;
if (remaining < NEAR_BOTTOM) void refactor?.more();
}
async function apply() {
if (!refactor) return;
await refactor.apply();
if (refactor.applied) refactors.forget(planId);
}
async function discard() {
if (!refactor) return;
await refactor.discard();
refactors.forget(planId);
// The tab is the review. With the plan gone there is nothing left in it,
// and leaving it open would offer an Apply that can only be refused.
workbench.close(tabKey);
}
</script>
<div class="refactor-review">
{#if !refactor}
<div class="gone">
<p>This refactor is no longer being held.</p>
<p class="dim">
A plan lives in the daemon only while it is being read — a restart, an
apply, or a discard ends it. Nothing was changed. Preview it again from
the tree.
</p>
</div>
{:else}
<header class="head">
<div class="what">
<!-- Every replacement, not just the first: a refactor can carry many,
and a header showing one of them describes a different change
from the one about to be applied. -->
<div class="pairs">
{#each refactor.request.pairs as pair, index (index)}
<div class="pair">
<span class="pattern">{pair.pattern}</span>
<span class="arrow"><AppIcon name="arrow-right" size={11} /></span>
<span class="pattern">{pair.substitute || "(nothing)"}</span>
</div>
{/each}
</div>
<code class="root" title={root}>{root}</code>
</div>
{#if refactor.plan}
<div class="tally">
<span>{refactor.plan.total} change{refactor.plan.total === 1 ? "" : "s"}</span>
{#if refactor.plan.files_edited > 0}
<span class="dim">{refactor.plan.files_edited} edited · {refactor.plan.replacements} replacements</span>
{/if}
{#if refactor.plan.files_renamed > 0}
<span class="dim">{refactor.plan.files_renamed} files renamed</span>
{/if}
{#if refactor.plan.directories_renamed > 0}
<span class="dim">{refactor.plan.directories_renamed} directories renamed</span>
{/if}
</div>
{/if}
</header>
{#if groups.length > 0}
<div class="barriers">
<button
class="barriers-head"
onclick={() => (barriersOpen = !barriersOpen)}
aria-expanded={barriersOpen}
>
<span class="twist"><AppIcon name={barriersOpen ? "chevron-down" : "chevron-right"} size={11} /></span>
{unreadableCount} path{unreadableCount === 1 ? "" : "s"} could not be examined
{#if actionable > 0}
<span class="actionable">{actionable} you can do something about</span>
{/if}
</button>
{#if barriersOpen}
{#each groups as group (group.barrier)}
<div class="barrier-group">
<div class="barrier-why">
<span class="barrier-label">{BARRIERS[group.barrier].label}</span>
<span class="dim">{group.paths.length}</span>
<span class="dim advice">{BARRIERS[group.barrier].advice}</span>
{#if BARRIERS[group.barrier].actionable}
<button class="btn" onclick={rePreview} disabled={refactor.loading}>
Preview again
</button>
{/if}
</div>
{#each group.paths.slice(0, 20) as one (one.path)}
<div class="barrier-path" title={one.reason}>{one.path}</div>
{/each}
{#if group.paths.length > 20}
<div class="barrier-path dim">… and {group.paths.length - 20} more</div>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
{#if refactor.applied}
<p class="done" role="status">
Applied — {refactor.applied.changes} change{refactor.applied.changes === 1 ? "" : "s"} written.
</p>
{:else if refactor.error}
<p class="error" role="alert">{refactor.error}</p>
{/if}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="list" onscroll={onScroll} role="list">
{#each refactor.changes as change, index (change.path + index)}
{@const expandable = change.kind === "content"}
{@const open = expanded.has(change.path)}
<div class="entry" role="listitem">
<svelte:element
this={expandable ? "button" : "div"}
class="row"
class:expandable
role={expandable ? undefined : "presentation"}
aria-expanded={expandable ? open : undefined}
onclick={expandable ? () => toggle(change.path) : undefined}
>
<span class="twist">
{#if expandable}
<AppIcon name={open ? "chevron-down" : "chevron-right"} size={11} />
{/if}
</span>
<span class="kind kind-{change.kind}">
{change.kind === "content" ? "edit" : change.kind === "rename_file" ? "file" : "dir"}
</span>
<span class="path" title={change.path}>{change.path}</span>
<span class="detail">{describeChange(change)}</span>
</svelte:element>
{#if expandable && open}
{#if refactor.diffErrors.get(change.path)}
<p class="diff-note error">{refactor.diffErrors.get(change.path)}</p>
{:else if refactor.diffLoading.has(change.path)}
<p class="diff-note dim">reading…</p>
{:else}
{@const hunks = refactor.diffs.get(change.path) ?? []}
{#each hunks as hunk, hunkIndex (hunkIndex)}
{#if hunkIndex > 0}
<div class="hunk-gap">⋯</div>
{/if}
<div class="hunk">
{#each hunk.lines as line, lineIndex (lineIndex)}
<div class="dline dline-{line.kind}">
<span class="ln">{line.kind === "added" ? "" : line.old}</span>
<span class="ln">{line.kind === "removed" ? "" : line.new}</span>
<span class="mark"
>{line.kind === "added" ? "+" : line.kind === "removed" ? "-" : " "}</span
>
<span class="text">{line.text}</span>
{#if line.no_newline}
<span class="no-newline" title="no newline at end of file">⏎̸</span>
{/if}
</div>
{/each}
</div>
{/each}
{#if hunks.length === 0}
<p class="diff-note dim">No line changes — the file's bytes differ only in encoding.</p>
{/if}
{/if}
{/if}
</div>
{/each}
{#if refactor.loading}
<div class="more dim">loading…</div>
{:else if !refactor.complete}
<!-- A reader who reaches here by keyboard, or a list shorter than its
own scroller, still needs a way to ask for the next page. -->
<button class="more" onclick={() => refactor.more()}>Load more</button>
{:else if refactor.changes.length === 0}
<div class="more dim">Nothing matches. Nothing to apply.</div>
{/if}
</div>
{#if !refactor.applied}
<footer class="foot">
<span class="foot-note dim">
{refactor.complete
? "Every change is listed."
: `${refactor.changes.length} of ${refactor.plan?.total ?? 0} loaded — scroll for the rest.`}
</span>
<button class="btn" onclick={discard} disabled={refactor.applying}>Discard</button>
<button
class="btn primary"
onclick={apply}
disabled={refactor.applying || (refactor.plan?.total ?? 0) === 0}
>
{refactor.applying ? "Applying…" : "Apply"}
</button>
</footer>
{/if}
{/if}
</div>
<style>
.refactor-review {
display: flex;
flex-direction: column;
min-height: 0;
flex: 1;
overflow: hidden;
}
.head {
padding: var(--wb-spacing-10) var(--wb-spacing-14);
border-bottom: 1px solid var(--vscode-panel-border);
}
.what {
display: flex;
align-items: baseline;
gap: var(--wb-spacing-8);
flex-wrap: wrap;
}
.pairs {
display: flex;
flex-direction: column;
gap: var(--wb-spacing-1);
}
.pair {
display: flex;
align-items: baseline;
gap: var(--wb-spacing-8);
}
.pattern {
font-family: var(--vscode-editor-font-family, monospace);
font-weight: 600;
}
.arrow {
opacity: 0.6;
display: inline-flex;
align-items: center;
gap: 2px;
}
.root {
opacity: 0.6;
font-size: 0.85em;
overflow-wrap: anywhere;
}
.tally {
display: flex;
gap: var(--wb-spacing-12);
flex-wrap: wrap;
margin-top: var(--wb-spacing-3);
font-size: 0.9em;
}
.barriers {
border-bottom: 1px solid var(--vscode-panel-border);
background: var(--vscode-inputValidation-warningBackground, rgba(204, 167, 0, 0.08));
}
.barriers-head {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
width: 100%;
padding: var(--wb-spacing-5) var(--wb-spacing-14);
background: none;
border: none;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.twist {
opacity: 0.6;
}
.actionable {
color: var(--vscode-testing-iconQueued, #cca700);
}
.barrier-group {
padding: var(--wb-spacing-2) var(--wb-spacing-14) var(--wb-spacing-8) var(--wb-spacing-30);
}
.barrier-why {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
flex-wrap: wrap;
padding: var(--wb-spacing-2) 0 var(--wb-spacing-4);
}
.barrier-label {
font-weight: 600;
}
.advice {
flex: 1;
min-width: 12ch;
}
.barrier-path {
font-family: var(--vscode-editor-font-family, monospace);
font-size: 0.88em;
opacity: 0.75;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
direction: rtl;
text-align: left;
}
.list {
flex: 1;
min-height: 0;
overflow: auto;
}
.row {
display: flex;
align-items: baseline;
gap: var(--wb-spacing-10);
width: 100%;
padding: var(--wb-spacing-2) var(--wb-spacing-14);
font-size: 0.92em;
background: none;
border: none;
color: inherit;
font-family: inherit;
text-align: left;
}
.row.expandable {
cursor: pointer;
}
.twist {
flex: 0 0 10px;
opacity: 0.6;
}
.hunk {
margin: var(--wb-spacing-2) 0 var(--wb-spacing-4) var(--wb-spacing-34);
border-left: 2px solid var(--vscode-panel-border);
font-family: var(--vscode-editor-font-family, monospace);
font-size: 0.88em;
/* A diff has long lines. They scroll here rather than widening the pane. */
overflow-x: auto;
}
.hunk-gap {
margin-left: var(--wb-spacing-34);
opacity: 0.4;
font-family: var(--vscode-editor-font-family, monospace);
}
.dline {
display: flex;
gap: var(--wb-spacing-6);
white-space: pre;
padding-left: var(--wb-spacing-6);
}
.dline-removed {
background: var(--vscode-diffEditor-removedTextBackground, rgba(244, 67, 54, 0.15));
}
.dline-added {
background: var(--vscode-diffEditor-insertedTextBackground, rgba(76, 175, 80, 0.15));
}
/* Line numbers in a fixed column so the text starts at the same place on
every row, and tabular figures so it stays there as the numbers grow. */
.ln {
flex: 0 0 42px;
text-align: right;
opacity: 0.45;
font-variant-numeric: tabular-nums;
}
.mark {
flex: 0 0 8px;
opacity: 0.7;
}
.text {
flex: 1;
}
.no-newline {
opacity: 0.6;
}
.diff-note {
margin: 0 0 var(--wb-spacing-4) var(--wb-spacing-34);
font-size: 0.9em;
}
.row:hover {
background: var(--vscode-list-hoverBackground);
}
.kind {
flex: 0 0 34px;
font-size: 0.82em;
text-transform: uppercase;
opacity: 0.75;
}
.kind-content {
color: var(--vscode-testing-iconQueued, #cca700);
}
.kind-rename_file,
.kind-rename_directory {
color: var(--vscode-testing-iconPassed, #4caf50);
}
.path {
flex: 1;
min-width: 0;
font-family: var(--vscode-editor-font-family, monospace);
/* Long paths truncate from the FRONT: the end of a path is the part that
identifies the file. */
direction: rtl;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail {
flex: 0 0 auto;
opacity: 0.7;
font-size: 0.9em;
}
.more {
display: block;
width: 100%;
padding: var(--wb-spacing-8) var(--wb-spacing-14);
background: none;
border: none;
color: inherit;
font: inherit;
text-align: center;
cursor: pointer;
}
.dim {
opacity: 0.6;
}
.gone {
padding: var(--wb-spacing-24);
max-width: 60ch;
}
.done,
.error {
margin: 0;
padding: var(--wb-spacing-6) var(--wb-spacing-14);
}
.done {
border-left: 2px solid var(--vscode-testing-iconPassed, #4caf50);
background: var(--vscode-inputValidation-infoBackground, rgba(76, 175, 80, 0.12));
}
.error {
border-left: 2px solid var(--vscode-testing-iconFailed, #f44336);
background: var(--vscode-inputValidation-errorBackground, rgba(244, 67, 54, 0.12));
}
.foot {
display: flex;
align-items: center;
gap: var(--wb-spacing-8);
padding: var(--wb-spacing-8) var(--wb-spacing-14);
border-top: 1px solid var(--vscode-panel-border);
}
.foot-note {
flex: 1;
font-size: 0.85em;
}
.btn {
padding: var(--wb-spacing-4) var(--wb-spacing-12);
border: 1px solid var(--vscode-panel-border);
border-radius: var(--wb-corner-radius-3);
background: none;
color: inherit;
font: inherit;
cursor: pointer;
}
.btn.primary {
background: var(--vscode-button-background, #0e639c);
color: var(--vscode-button-foreground, #fff);
border-color: transparent;
}
.btn:disabled {
opacity: 0.5;
cursor: default;
}
</style>